Răsfoiți Sursa

Add author to post header

New optional author front-matter key, shown as "· by <name>" after the
date on the index and post pages and as dc:creator in the RSS feed. The
hello-world post is credited to Claude, and its guide, about.md and the
README document the key. Version 0.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude 2 zile în urmă
părinte
comite
12ecfb307a

+ 3 - 0
README.md

@@ -15,6 +15,7 @@ Create `content/YYYY-MM-DD-slug.md`. Front matter is optional:
 title: My first post
 date: 2026-10-24
 summary: One line shown on the index page.
+author: Claude
 draft: false
 ---
 
@@ -24,6 +25,8 @@ Post body in Markdown...
 - **title** — falls back to the first `# heading`, then the slug.
 - **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`.
 - **draft: true** — hides the post everywhere.
 
 The URL is `/posts/<slug>`, where the slug is the filename without the date

+ 4 - 2
about.md

@@ -20,16 +20,18 @@ between two `---` lines. For example:
 ---
 title: Hello, world
 summary: The first post, and a guide to writing one.
+author: Claude
 ---
 ```
 
-Each line is `key: value`. Four keys are recognised:
+Each line is `key: value`. Five keys are recognised:
 
 | Key | What it does | If left out |
 |---|---|---|
 | `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 |
 | `draft` | Set to `true` to hide the post everywhere: index, post page and feed | `false` |
 
 Some details:
@@ -38,7 +40,7 @@ Some details:
   with a second `---` line.
 - Values can be wrapped in quotes, which are removed: `title: "Hello, world"`.
 - Lines starting with `#` inside the block are comments.
-- Keys other than the four above are ignored.
+- Keys other than the five above are ignored.
 - A post with a broken header, such as an unknown date format or a missing
   closing `---`, is skipped and the problem is logged. The rest of the site is
   unaffected.

+ 5 - 2
content/2026-10-24-hello-world.md

@@ -1,6 +1,7 @@
 ---
 title: Hello, world
 summary: The first post, and a guide to writing one — file names, the header block, and what Markdown works.
+author: Claude
 ---
 
 This site is a folder of Markdown files. Each file in `content/` becomes a
@@ -25,16 +26,18 @@ between two `---` lines. This post's header is:
 ---
 title: Hello, world
 summary: The first post, and a guide to writing one — file names, the header block, and what Markdown works.
+author: Claude
 ---
 ```
 
-Each line is `key: value`. Four keys are recognised:
+Each line is `key: value`. Five keys are recognised:
 
 | Key | What it does | If left out |
 |---|---|---|
 | `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 |
 | `draft` | Set to `true` to hide the post everywhere: index, post page and feed | `false` |
 
 Some details:
@@ -43,7 +46,7 @@ Some details:
   with a second `---` line.
 - Values can be wrapped in quotes, which are removed: `title: "Hello, world"`.
 - Lines starting with `#` inside the block are comments.
-- Keys other than the four above are ignored.
+- Keys other than the five above are ignored.
 - A post with a broken header, such as an unknown date format or a missing
   closing `---`, is skipped and the problem is logged. The rest of the site is
   unaffected.

+ 5 - 1
feed.go

@@ -11,6 +11,7 @@ const feedItems = 20
 type rss struct {
 	XMLName xml.Name   `xml:"rss"`
 	Version string     `xml:"version,attr"`
+	DC      string     `xml:"xmlns:dc,attr"` // Dublin Core, for dc:creator
 	Channel rssChannel `xml:"channel"`
 }
 
@@ -28,6 +29,8 @@ type rssItem struct {
 	GUID        string `xml:"guid"`
 	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"`
 }
 
 func (a *app) feed(w http.ResponseWriter, r *http.Request) {
@@ -55,9 +58,10 @@ func (a *app) feed(w http.ResponseWriter, r *http.Request) {
 			GUID:        link,
 			PubDate:     p.Date.Format(time.RFC1123Z),
 			Description: p.Summary,
+			Creator:     p.Author,
 		})
 	}
-	out, err := xml.MarshalIndent(rss{Version: "2.0", Channel: ch}, "", "  ")
+	out, err := xml.MarshalIndent(rss{Version: "2.0", DC: "http://purl.org/dc/elements/1.1/", Channel: ch}, "", "  ")
 	if err != nil {
 		a.serverError(w, err)
 		return

+ 33 - 0
main_test.go

@@ -196,6 +196,39 @@ func TestFeed(t *testing.T) {
 	}
 }
 
+func TestAuthor(t *testing.T) {
+	srv, _ := newTestServer(t, map[string]string{
+		"2026-01-01-anon.md":   "# Anonymous\n",
+		"2026-01-02-signed.md": "---\nauthor: Claude & 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)
+		}
+	}
+	if strings.Count(feed, "<dc:creator>") != 1 {
+		t.Error("feed: dc:creator should be omitted when there is no author")
+	}
+}
+
 func TestHeaderAndFooter(t *testing.T) {
 	srv, dir := newTestServer(t, sample)
 	for _, path := range []string{"/", "/posts/middle", "/about", "/nope"} {

+ 2 - 1
posts.go

@@ -24,6 +24,7 @@ type Post struct {
 	Title   string
 	Date    time.Time
 	Summary string
+	Author  string
 	Draft   bool
 	HTML    template.HTML
 }
@@ -150,7 +151,7 @@ func parsePost(md goldmark.Markdown, name string, src []byte, modTime time.Time)
 		return nil, err
 	}
 
-	p := &Post{Slug: slug, Title: meta["title"], Summary: meta["summary"]}
+	p := &Post{Slug: slug, Title: meta["title"], Summary: meta["summary"], Author: meta["author"]}
 
 	switch {
 	case meta["date"] != "":

+ 28 - 0
pull-releases/005-post-author/README.md

@@ -0,0 +1,28 @@
+# Add author to post header
+
+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.
+- **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`.
+- **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.
+
+## Screenshots
+
+From the branch's build running locally.
+
+| Desktop | Mobile |
+|---|---|
+| [![Desktop](desktop.png)](desktop.png) | [![Mobile](mobile.png)](mobile.png) |
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)

BIN
pull-releases/005-post-author/desktop.png


BIN
pull-releases/005-post-author/mobile.png


+ 1 - 0
pull-releases/README.md

@@ -31,3 +31,4 @@ screenshot, or its `README.md` doesn't load.
 | [002](002-header-about-config/) | About page, configurable Home link, RSS footer, MIT license |
 | [003](003-css-cache-busting/) | Content hash on the stylesheet URL; this folder |
 | [004](004-release-notes-page/) | Release notes pages on the site |
+| [005](005-post-author/) | Author in the post header |

+ 0 - 1
static/style.css

@@ -85,7 +85,6 @@ time { color: var(--muted); font-size: 0.9rem; }
 .post-list li { margin-bottom: 1.75rem; }
 .post-list li > a { font-size: 1.2rem; font-weight: 600; text-decoration: none; }
 .post-list li > a:hover { text-decoration: underline; }
-.post-list time { display: block; }
 .post-list p { margin: 0.25rem 0 0; }
 
 article h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 0.25rem; }

+ 1 - 1
templates/index.html

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

+ 1 - 1
templates/post.html

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

+ 1 - 1
version.go

@@ -4,7 +4,7 @@ import "runtime/debug"
 
 // version is the release number shown on the About page. Bump it in the PR
 // that makes a user-visible change.
-const version = "0.3.0"
+const version = "0.4.0"
 
 // buildCommit returns the short git revision that `go build` stamped into the
 // binary, with "-dirty" if the tree had uncommitted changes, or "" if the