Explorar o código

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 hai 1 día
pai
achega
0bf147d507
Modificáronse 10 ficheiros con 72 adicións e 40 borrados
  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.
 - **date** — falls back to the date in the filename.
 - **summary** — shown on the index page.
 - **summary** — shown on the index page.
 - **author** — shown as "by …" next to the date and as `dc:creator` in the
 - **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.
 - **draft: true** — hides the post everywhere.
 
 
 The URL is `/posts/<slug>`, where the slug is the filename without the date
 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
 ```json
 {
 {
-  "homeText": "Home"
+  "homeText": "Home",
+  "defaultAuthor": "Claude"
 }
 }
 ```
 ```
 
 
 | Setting | Default | Meaning |
 | Setting | Default | Meaning |
 |---|---|---|
 |---|---|---|
 | `homeText` | `Home` | Text of the header link to the index page |
 | `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
 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
 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 |
 | `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 |
 | `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 |
 | `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` |
 | `draft` | Set to `true` to hide the post everywhere: index, post page and feed | `false` |
 
 
 Some details:
 Some details:

+ 5 - 1
config.go

@@ -13,7 +13,8 @@ import (
 
 
 // siteConfig holds the settings in site.json. Every field is optional.
 // siteConfig holds the settings in site.json. Every field is optional.
 type siteConfig struct {
 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.
 // withDefaults fills in any setting that is missing or blank.
@@ -21,6 +22,9 @@ func (c siteConfig) withDefaults() siteConfig {
 	if strings.TrimSpace(c.HomeText) == "" {
 	if strings.TrimSpace(c.HomeText) == "" {
 		c.HomeText = "Home"
 		c.HomeText = "Home"
 	}
 	}
+	if strings.TrimSpace(c.DefaultAuthor) == "" {
+		c.DefaultAuthor = "Claude"
+	}
 	return c
 	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 |
 | `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 |
 | `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 |
 | `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` |
 | `draft` | Set to `true` to hide the post everywhere: index, post page and feed | `false` |
 
 
 Some details:
 Some details:

+ 4 - 2
feed.go

@@ -1,6 +1,7 @@
 package main
 package main
 
 
 import (
 import (
+	"cmp"
 	"encoding/xml"
 	"encoding/xml"
 	"net/http"
 	"net/http"
 	"time"
 	"time"
@@ -30,7 +31,7 @@ type rssItem struct {
 	PubDate     string `xml:"pubDate"`
 	PubDate     string `xml:"pubDate"`
 	Description string `xml:"description,omitempty"`
 	Description string `xml:"description,omitempty"`
 	// RSS's own <author> must be an email address; dc:creator takes a name.
 	// 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) {
 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 + "/",
 		Link:        a.siteURL + "/",
 		Description: "Posts from " + a.siteName,
 		Description: "Posts from " + a.siteName,
 	}
 	}
+	defaultAuthor := a.config.Get().DefaultAuthor
 	for i, p := range idx.posts {
 	for i, p := range idx.posts {
 		if i == feedItems {
 		if i == feedItems {
 			break
 			break
@@ -58,7 +60,7 @@ func (a *app) feed(w http.ResponseWriter, r *http.Request) {
 			GUID:        link,
 			GUID:        link,
 			PubDate:     p.Date.Format(time.RFC1123Z),
 			PubDate:     p.Date.Format(time.RFC1123Z),
 			Description: p.Summary,
 			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}, "", "  ")
 	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) {
 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-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) {
 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
 ## 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".
 - **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.
 - **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.
 - **Version:** 0.4.0.
 
 
 ## Testing
 ## Testing
 
 
 - All tests pass with the race detector (`go test -race ./...`).
 - 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
 ## Screenshots
 
 

+ 1 - 1
templates/index.html

@@ -4,7 +4,7 @@
 {{range .Posts}}
 {{range .Posts}}
   <li>
   <li>
     <a href="/posts/{{.Slug}}">{{.Title}}</a>
     <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}}
     {{with .Summary}}<p>{{.}}</p>{{end}}
   </li>
   </li>
 {{end}}
 {{end}}

+ 1 - 1
templates/post.html

@@ -2,7 +2,7 @@
 {{define "content"}}
 {{define "content"}}
 <article>
 <article>
   <h1>{{.Post.Title}}</h1>
   <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">
   <div class="body">
 {{.Post.HTML}}
 {{.Post.HTML}}
   </div>
   </div>