Просмотр исходного кода

Merge branch 'feature/post-author' of bajdev/posts into main

bajdev 1 день назад
Родитель
Сommit
cd75a2b550

+ 6 - 1
README.md

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

+ 4 - 2
about.md

@@ -20,16 +20,18 @@ between two `---` lines. For example:
 ---
 ---
 title: Hello, world
 title: Hello, world
 summary: The first post, and a guide to writing one.
 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 |
 | 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 |
 | `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 | 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:
@@ -38,7 +40,7 @@ Some details:
   with a second `---` line.
   with a second `---` line.
 - Values can be wrapped in quotes, which are removed: `title: "Hello, world"`.
 - Values can be wrapped in quotes, which are removed: `title: "Hello, world"`.
 - Lines starting with `#` inside the block are comments.
 - 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
 - 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
   closing `---`, is skipped and the problem is logged. The rest of the site is
   unaffected.
   unaffected.

+ 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)
+		}
+	}
+}

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

@@ -1,6 +1,7 @@
 ---
 ---
 title: Hello, world
 title: Hello, world
 summary: The first post, and a guide to writing one — file names, the header block, and what Markdown works.
 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
 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
 title: Hello, world
 summary: The first post, and a guide to writing one — file names, the header block, and what Markdown works.
 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 |
 | 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 |
 | `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 | 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:
@@ -43,7 +46,7 @@ Some details:
   with a second `---` line.
   with a second `---` line.
 - Values can be wrapped in quotes, which are removed: `title: "Hello, world"`.
 - Values can be wrapped in quotes, which are removed: `title: "Hello, world"`.
 - Lines starting with `#` inside the block are comments.
 - 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
 - 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
   closing `---`, is skipped and the problem is logged. The rest of the site is
   unaffected.
   unaffected.

+ 7 - 1
feed.go

@@ -1,6 +1,7 @@
 package main
 package main
 
 
 import (
 import (
+	"cmp"
 	"encoding/xml"
 	"encoding/xml"
 	"net/http"
 	"net/http"
 	"time"
 	"time"
@@ -11,6 +12,7 @@ const feedItems = 20
 type rss struct {
 type rss struct {
 	XMLName xml.Name   `xml:"rss"`
 	XMLName xml.Name   `xml:"rss"`
 	Version string     `xml:"version,attr"`
 	Version string     `xml:"version,attr"`
+	DC      string     `xml:"xmlns:dc,attr"` // Dublin Core, for dc:creator
 	Channel rssChannel `xml:"channel"`
 	Channel rssChannel `xml:"channel"`
 }
 }
 
 
@@ -28,6 +30,8 @@ type rssItem struct {
 	GUID        string `xml:"guid"`
 	GUID        string `xml:"guid"`
 	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.
+	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) {
@@ -41,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
@@ -55,9 +60,10 @@ 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:     cmp.Or(p.Author, defaultAuthor),
 		})
 		})
 	}
 	}
-	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 {
 	if err != nil {
 		a.serverError(w, err)
 		a.serverError(w, err)
 		return
 		return

+ 36 - 0
main_test.go

@@ -196,6 +196,42 @@ func TestFeed(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestAuthor(t *testing.T) {
+	srv, dir := newTestServer(t, map[string]string{
+		"2026-01-01-anon.md":   "# Anonymous\n",
+		"2026-01-02-signed.md": "---\nauthor: Ann & co\n---\n# Signed\n",
+	})
+	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)
+			}
+		}
+	}
+
+	check("Claude")
+	writeFile(t, dir, "site.json", `{"defaultAuthor": "Site Owner"}`)
+	check("Site Owner")
+}
+
 func TestHeaderAndFooter(t *testing.T) {
 func TestHeaderAndFooter(t *testing.T) {
 	srv, dir := newTestServer(t, sample)
 	srv, dir := newTestServer(t, sample)
 	for _, path := range []string{"/", "/posts/middle", "/about", "/nope"} {
 	for _, path := range []string{"/", "/posts/middle", "/about", "/nope"} {

+ 2 - 1
posts.go

@@ -24,6 +24,7 @@ type Post struct {
 	Title   string
 	Title   string
 	Date    time.Time
 	Date    time.Time
 	Summary string
 	Summary string
+	Author  string
 	Draft   bool
 	Draft   bool
 	HTML    template.HTML
 	HTML    template.HTML
 }
 }
@@ -150,7 +151,7 @@ func parsePost(md goldmark.Markdown, name string, src []byte, modTime time.Time)
 		return nil, err
 		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 {
 	switch {
 	case meta["date"] != "":
 	case meta["date"] != "":

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

@@ -0,0 +1,29 @@
+# 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`.
+- **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 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 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
+
+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 |
 | [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 |
 | [003](003-css-cache-busting/) | Content hash on the stylesheet URL; this folder |
 | [004](004-release-notes-page/) | Release notes pages on the site |
 | [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 { margin-bottom: 1.75rem; }
 .post-list li > a { font-size: 1.2rem; font-weight: 600; text-decoration: none; }
 .post-list li > a { font-size: 1.2rem; font-weight: 600; text-decoration: none; }
 .post-list li > a:hover { text-decoration: underline; }
 .post-list li > a:hover { text-decoration: underline; }
-.post-list time { display: block; }
 .post-list p { margin: 0.25rem 0 0; }
 .post-list p { margin: 0.25rem 0 0; }
 
 
 article h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 0.25rem; }
 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}}
 {{range .Posts}}
   <li>
   <li>
     <a href="/posts/{{.Slug}}">{{.Title}}</a>
     <a href="/posts/{{.Slug}}">{{.Title}}</a>
-    <time datetime="{{isodate .Date}}">{{date .Date}}</time>
+    <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>
-  <time datetime="{{isodate .Post.Date}}">{{date .Post.Date}}</time>
+  <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>

+ 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
 // version is the release number shown on the About page. Bump it in the PR
 // that makes a user-visible change.
 // 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
 // buildCommit returns the short git revision that `go build` stamped into the
 // binary, with "-dirty" if the tree had uncommitted changes, or "" if the
 // binary, with "-dirty" if the tree had uncommitted changes, or "" if the