浏览代码

Default post author to Claude

Posts without an author line are credited to defaultAuthor from
content/site.json, which defaults to Claude, on the index, the post page
and in the feed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude 1 天之前
父节点
当前提交
0bf147d507
共有 10 个文件被更改,包括 72 次插入 和 40 次删除
  1. 4 2
      README.md
  2. 1 1
      about.md
  3. 5 1
      config.go
  4. 20 0
      config_test.go
  5. 1 1
      content/2026-10-24-hello-world.md
  6. 4 2
      feed.go
  7. 29 26
      main_test.go
  8. 6 5
      pull-releases/005-post-author/README.md
  9. 1 1
      templates/index.html
  10. 1 1
      templates/post.html

+ 4 - 2
README.md

@@ -26,7 +26,7 @@ Post body in Markdown...
 - **date** — falls back to the date in the filename.
 - **summary** — shown on the index page.
 - **author** — shown as "by …" next to the date and as `dc:creator` in the
-  feed; omitted if not set. Posts written by Claude use `author: Claude`.
+  feed; falls back to `defaultAuthor` in `site.json` (Claude).
 - **draft: true** — hides the post everywhere.
 
 The URL is `/posts/<slug>`, where the slug is the filename without the date
@@ -59,13 +59,15 @@ and take effect without a restart.
 
 ```json
 {
-  "homeText": "Home"
+  "homeText": "Home",
+  "defaultAuthor": "Claude"
 }
 ```
 
 | Setting | Default | Meaning |
 |---|---|---|
 | `homeText` | `Home` | Text of the header link to the index page |
+| `defaultAuthor` | `Claude` | Author shown for posts without an `author` line |
 
 A missing file, a missing setting or a blank value uses the default. If the
 file is invalid JSON or has an unknown setting name, the error is logged and

+ 1 - 1
about.md

@@ -31,7 +31,7 @@ Each line is `key: value`. Five keys are recognised:
 | `title` | The post's title, shown on the index page, at the top of the post and in the RSS feed | The first line of the post if it is a `# Heading` (that heading is then not repeated in the body); otherwise the slug |
 | `date` | The post's date, as `YYYY-MM-DD`. Posts are listed newest first | The date in the file name |
 | `summary` | One line shown under the title on the index page and used as the RSS description | Nothing is shown |
-| `author` | Who wrote the post, shown next to the date on the index page and the post, and in the RSS feed | Nothing is shown |
+| `author` | Who wrote the post, shown next to the date on the index page and the post, and in the RSS feed | The site's default author: Claude, unless `defaultAuthor` is set in `content/site.json` |
 | `draft` | Set to `true` to hide the post everywhere: index, post page and feed | `false` |
 
 Some details:

+ 5 - 1
config.go

@@ -13,7 +13,8 @@ import (
 
 // siteConfig holds the settings in site.json. Every field is optional.
 type siteConfig struct {
-	HomeText string `json:"homeText"` // text of the header link to the index
+	HomeText      string `json:"homeText"`      // text of the header link to the index
+	DefaultAuthor string `json:"defaultAuthor"` // author of posts that don't name one
 }
 
 // withDefaults fills in any setting that is missing or blank.
@@ -21,6 +22,9 @@ func (c siteConfig) withDefaults() siteConfig {
 	if strings.TrimSpace(c.HomeText) == "" {
 		c.HomeText = "Home"
 	}
+	if strings.TrimSpace(c.DefaultAuthor) == "" {
+		c.DefaultAuthor = "Claude"
+	}
 	return c
 }
 

+ 20 - 0
config_test.go

@@ -36,3 +36,23 @@ func TestConfigFile(t *testing.T) {
 		}
 	}
 }
+
+func TestDefaultAuthorSetting(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "site.json")
+	c := newConfigFile(path)
+	for _, s := range []struct{ json, want string }{
+		{"", "Claude"},
+		{`{"homeText": "Start"}`, "Claude"},
+		{`{"defaultAuthor": " "}`, "Claude"},
+		{`{"defaultAuthor": "Someone Else"}`, "Someone Else"},
+	} {
+		if s.json != "" {
+			if err := os.WriteFile(path, []byte(s.json), 0o644); err != nil {
+				t.Fatal(err)
+			}
+		}
+		if got := c.Get().DefaultAuthor; got != s.want {
+			t.Errorf("site.json %q: DefaultAuthor = %q, want %q", s.json, got, s.want)
+		}
+	}
+}

+ 1 - 1
content/2026-10-24-hello-world.md

@@ -37,7 +37,7 @@ Each line is `key: value`. Five keys are recognised:
 | `title` | The post's title, shown on the index page, at the top of the post and in the RSS feed | The first line of the post if it is a `# Heading` (that heading is then not repeated in the body); otherwise the slug |
 | `date` | The post's date, as `YYYY-MM-DD`. Posts are listed newest first | The date in the file name |
 | `summary` | One line shown under the title on the index page and used as the RSS description | Nothing is shown |
-| `author` | Who wrote the post, shown next to the date on the index page and the post, and in the RSS feed | Nothing is shown |
+| `author` | Who wrote the post, shown next to the date on the index page and the post, and in the RSS feed | The site's default author: Claude, unless `defaultAuthor` is set in `content/site.json` |
 | `draft` | Set to `true` to hide the post everywhere: index, post page and feed | `false` |
 
 Some details:

+ 4 - 2
feed.go

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"cmp"
 	"encoding/xml"
 	"net/http"
 	"time"
@@ -30,7 +31,7 @@ type rssItem struct {
 	PubDate     string `xml:"pubDate"`
 	Description string `xml:"description,omitempty"`
 	// RSS's own <author> must be an email address; dc:creator takes a name.
-	Creator string `xml:"dc:creator,omitempty"`
+	Creator string `xml:"dc:creator"`
 }
 
 func (a *app) feed(w http.ResponseWriter, r *http.Request) {
@@ -44,6 +45,7 @@ func (a *app) feed(w http.ResponseWriter, r *http.Request) {
 		Link:        a.siteURL + "/",
 		Description: "Posts from " + a.siteName,
 	}
+	defaultAuthor := a.config.Get().DefaultAuthor
 	for i, p := range idx.posts {
 		if i == feedItems {
 			break
@@ -58,7 +60,7 @@ func (a *app) feed(w http.ResponseWriter, r *http.Request) {
 			GUID:        link,
 			PubDate:     p.Date.Format(time.RFC1123Z),
 			Description: p.Summary,
-			Creator:     p.Author,
+			Creator:     cmp.Or(p.Author, defaultAuthor),
 		})
 	}
 	out, err := xml.MarshalIndent(rss{Version: "2.0", DC: "http://purl.org/dc/elements/1.1/", Channel: ch}, "", "  ")

+ 29 - 26
main_test.go

@@ -197,36 +197,39 @@ func TestFeed(t *testing.T) {
 }
 
 func TestAuthor(t *testing.T) {
-	srv, _ := newTestServer(t, map[string]string{
+	srv, dir := newTestServer(t, map[string]string{
 		"2026-01-01-anon.md":   "# Anonymous\n",
-		"2026-01-02-signed.md": "---\nauthor: Claude & co\n---\n# Signed\n",
+		"2026-01-02-signed.md": "---\nauthor: Ann & co\n---\n# Signed\n",
 	})
-	_, _, index := get(t, srv, "/")
-	if !strings.Contains(index, "</time> · by Claude &amp; co</span>") {
-		t.Errorf("index: author missing or unescaped:\n%s", index)
-	}
-	if strings.Count(index, " · by ") != 1 {
-		t.Error("index: a post without an author should not show a byline")
-	}
-	_, _, post := get(t, srv, "/posts/signed")
-	if !strings.Contains(post, "</time> · by Claude &amp; co</span>") {
-		t.Errorf("post page: author missing:\n%s", post)
-	}
-	if _, _, anon := get(t, srv, "/posts/anon"); strings.Contains(anon, " · by ") {
-		t.Error("post page: byline shown without an author")
-	}
-	_, _, feed := get(t, srv, "/feed.xml")
-	for _, want := range []string{
-		`xmlns:dc="http://purl.org/dc/elements/1.1/"`,
-		"<dc:creator>Claude &amp; co</dc:creator>",
-	} {
-		if !strings.Contains(feed, want) {
-			t.Errorf("feed: missing %q in:\n%s", want, feed)
+	check := func(defaultAuthor string) {
+		t.Helper()
+		_, _, index := get(t, srv, "/")
+		for _, want := range []string{"</time> · by Ann &amp; co</span>", "</time> · by " + defaultAuthor + "</span>"} {
+			if !strings.Contains(index, want) {
+				t.Errorf("index: missing %q:\n%s", want, index)
+			}
+		}
+		if _, _, post := get(t, srv, "/posts/signed"); !strings.Contains(post, "</time> · by Ann &amp; co</span>") {
+			t.Errorf("signed post: author missing:\n%s", post)
+		}
+		if _, _, anon := get(t, srv, "/posts/anon"); !strings.Contains(anon, "</time> · by "+defaultAuthor+"</span>") {
+			t.Errorf("anonymous post: want default author %q:\n%s", defaultAuthor, anon)
+		}
+		_, _, feed := get(t, srv, "/feed.xml")
+		for _, want := range []string{
+			`xmlns:dc="http://purl.org/dc/elements/1.1/"`,
+			"<dc:creator>Ann &amp; co</dc:creator>",
+			"<dc:creator>" + defaultAuthor + "</dc:creator>",
+		} {
+			if !strings.Contains(feed, want) {
+				t.Errorf("feed: missing %q in:\n%s", want, feed)
+			}
 		}
 	}
-	if strings.Count(feed, "<dc:creator>") != 1 {
-		t.Error("feed: dc:creator should be omitted when there is no author")
-	}
+
+	check("Claude")
+	writeFile(t, dir, "site.json", `{"defaultAuthor": "Site Owner"}`)
+	check("Site Owner")
 }
 
 func TestHeaderAndFooter(t *testing.T) {

+ 6 - 5
pull-releases/005-post-author/README.md

@@ -4,18 +4,19 @@ Branch `feature/post-author` · version 0.4.0
 
 ## What changed
 
-- **New `author` key** in a post's header block, e.g. `author: Claude`. It's optional; if it's missing, nothing is shown.
+- **New `author` key** in a post's header block, e.g. `author: Claude`.
+- **Default author:** a post without an `author` line is credited to **Claude**. Change this with `"defaultAuthor"` in `content/site.json`; a missing or blank setting means Claude.
 - **Byline:** the author appears as "by …" after the date, on the index page and at the top of the post: "24 October 2026 · by Claude".
 - **RSS:** each item carries the author as `<dc:creator>`. RSS's own `<author>` field must be an email address, so feeds normally use the Dublin Core element for a name.
-- **Hello-world post:** now has `author: Claude`. Its guide and the About page both list `author` as the fifth header key, and the post's "This post's header is" example matches its real header.
-- **README:** documents the key; posts written by Claude use `author: Claude`.
+- **Hello-world post:** now has `author: Claude`. Its guide and the About page both list `author` as the fifth header key with the default, and the post's "This post's header is" example matches its real header.
+- **README:** documents the key and the `defaultAuthor` setting.
 - **Version:** 0.4.0.
 
 ## Testing
 
 - All tests pass with the race detector (`go test -race ./...`).
-- New test: the byline on the index and post page, HTML escaping (`Claude & co`), no byline when there's no author, and `dc:creator` with its namespace in the feed (omitted for posts without an author).
-- Built and ran locally, and checked the index, the hello-world post at mobile width, and the feed.
+- New tests: the byline on the index and post page, HTML escaping (`Ann & co`), the default author for posts without one (Claude, then a `defaultAuthor` from `site.json`), `dc:creator` with its namespace in the feed, and the `defaultAuthor` setting's fallbacks.
+- Built and ran locally, and checked the index, the hello-world post at mobile width, and the feed. Also ran a copy of the hello-world post with its `author` line removed: it showed "by Claude" and `<dc:creator>Claude</dc:creator>`.
 
 ## Screenshots
 

+ 1 - 1
templates/index.html

@@ -4,7 +4,7 @@
 {{range .Posts}}
   <li>
     <a href="/posts/{{.Slug}}">{{.Title}}</a>
-    <span class="meta"><time datetime="{{isodate .Date}}">{{date .Date}}</time>{{with .Author}} · by {{.}}{{end}}</span>
+    <span class="meta"><time datetime="{{isodate .Date}}">{{date .Date}}</time> · by {{or .Author $.Config.DefaultAuthor}}</span>
     {{with .Summary}}<p>{{.}}</p>{{end}}
   </li>
 {{end}}

+ 1 - 1
templates/post.html

@@ -2,7 +2,7 @@
 {{define "content"}}
 <article>
   <h1>{{.Post.Title}}</h1>
-  <span class="meta"><time datetime="{{isodate .Post.Date}}">{{date .Post.Date}}</time>{{with .Post.Author}} · by {{.}}{{end}}</span>
+  <span class="meta"><time datetime="{{isodate .Post.Date}}">{{date .Post.Date}}</time> · by {{or .Post.Author .Config.DefaultAuthor}}</span>
   <div class="body">
 {{.Post.HTML}}
   </div>