Переглянути джерело

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

bajdev 2 днів тому
батько
коміт
cd75a2b550

+ 6 - 1
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; 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
@@ -56,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

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

+ 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 | 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:
@@ -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.

+ 7 - 1
feed.go

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"cmp"
 	"encoding/xml"
 	"net/http"
 	"time"
@@ -11,6 +12,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 +30,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"`
 }
 
 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 + "/",
 		Description: "Posts from " + a.siteName,
 	}
+	defaultAuthor := a.config.Get().DefaultAuthor
 	for i, p := range idx.posts {
 		if i == feedItems {
 			break
@@ -55,9 +60,10 @@ func (a *app) feed(w http.ResponseWriter, r *http.Request) {
 			GUID:        link,
 			PubDate:     p.Date.Format(time.RFC1123Z),
 			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 {
 		a.serverError(w, err)
 		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) {
 	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"] != "":

+ 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 |
 | [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> · 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>
-  <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">
 {{.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