Jelajahi Sumber

Add pinned posts

New optional pinned front-matter key (default false). Pinned posts are
listed first on the index page, newest first, with a Pinned label; the
feed and newer/older links keep date order. Pin Why This Project
Exists. Version 0.5.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude 1 hari lalu
induk
melakukan
b335b09031

+ 3 - 0
README.md

@@ -17,6 +17,7 @@ date: 2026-10-24
 summary: One line shown on the index page.
 author: Claude
 draft: false
+pinned: false
 ---
 
 Post body in Markdown...
@@ -28,6 +29,8 @@ Post body in Markdown...
 - **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.
+- **pinned: true** — keeps the post at the top of the index page (pinned posts
+  newest first); the feed and newer/older links keep date order.
 
 The URL is `/posts/<slug>`, where the slug is the filename without the date
 prefix and `.md` extension.

+ 3 - 2
about.md

@@ -24,7 +24,7 @@ author: Claude
 ---
 ```
 
-Each line is `key: value`. Five keys are recognised:
+Each line is `key: value`. Six keys are recognised:
 
 | Key | What it does | If left out |
 |---|---|---|
@@ -33,6 +33,7 @@ Each line is `key: value`. Five keys are recognised:
 | `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` |
+| `pinned` | Set to `true` to keep the post at the top of the index page, marked "Pinned". Several pinned posts are listed newest first. The feed and the newer/older links keep date order | `false` |
 
 Some details:
 
@@ -40,7 +41,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 five above are ignored.
+- Keys other than the six 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.

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

@@ -30,7 +30,7 @@ author: Claude
 ---
 ```
 
-Each line is `key: value`. Five keys are recognised:
+Each line is `key: value`. Six keys are recognised:
 
 | Key | What it does | If left out |
 |---|---|---|
@@ -39,6 +39,7 @@ Each line is `key: value`. Five keys are recognised:
 | `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` |
+| `pinned` | Set to `true` to keep the post at the top of the index page, marked "Pinned". Several pinned posts are listed newest first. The feed and the newer/older links keep date order | `false` |
 
 Some details:
 
@@ -46,7 +47,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 five above are ignored.
+- Keys other than the six 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.

+ 1 - 0
content/2026-10-25-why-this-project-exist.md

@@ -3,6 +3,7 @@ title: Why This Project Exists
 date: 2026-10-25T15:49:00-04:00
 summary: Outline about why this project exists. Why I created it. What the purpose is: a manually written overview by a human.
 author: bajdev
+pinned: true
 ---
 
 I have been researching LLMs and software development. One of the best ways to learn is to simply do something with a new technology. 

+ 1 - 1
main.go

@@ -111,7 +111,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
 		a.serverError(w, err)
 		return
 	}
-	a.render(w, http.StatusOK, "index", pageData{Posts: idx.posts})
+	a.render(w, http.StatusOK, "index", pageData{Posts: idx.listing})
 }
 
 func (a *app) post(w http.ResponseWriter, r *http.Request) {

+ 39 - 0
main_test.go

@@ -196,6 +196,45 @@ func TestFeed(t *testing.T) {
 	}
 }
 
+func TestPinnedPosts(t *testing.T) {
+	srv, _ := newTestServer(t, map[string]string{
+		"2026-01-01-old-pinned.md": "---\npinned: true\n---\n# Old pinned\n",
+		"2026-02-01-plain-a.md":    "---\npinned: false\n---\n# Plain A\n",
+		"2026-03-01-new-pinned.md": "---\npinned: yes\n---\n# Bad value\n",
+		"2026-04-01-mid-pinned.md": "---\npinned: true\n---\n# Mid pinned\n",
+		"2026-05-01-plain-b.md":    "# Plain B\n",
+	})
+
+	// Index: pinned posts first (newest first), then the rest by date.
+	// The invalid "pinned: yes" post is skipped, like a bad draft value.
+	_, _, index := get(t, srv, "/")
+	order := []string{"Mid pinned", "Old pinned", "Plain B", "Plain A"}
+	last := -1
+	for _, title := range order {
+		i := strings.Index(index, ">"+title+"<")
+		if i < 0 || i < last {
+			t.Fatalf("index order should be %v:\n%s", order, index)
+		}
+		last = i
+	}
+	if strings.Contains(index, "Bad value") {
+		t.Error("post with invalid pinned value should be skipped")
+	}
+	if n := strings.Count(index, `<span class="pinned">Pinned</span>`); n != 2 {
+		t.Errorf("want 2 Pinned labels, got %d", n)
+	}
+
+	// Newer/older links and the feed keep plain date order.
+	_, _, post := get(t, srv, "/posts/old-pinned")
+	if !strings.Contains(post, `<a class="newer" href="/posts/plain-a">`) || strings.Contains(post, `class="older"`) {
+		t.Errorf("old-pinned should link newer to plain-a and have no older link:\n%s", post)
+	}
+	_, _, feed := get(t, srv, "/feed.xml")
+	if b, m := strings.Index(feed, "/posts/plain-b<"), strings.Index(feed, "/posts/mid-pinned<"); b < 0 || m < 0 || b > m {
+		t.Error("feed should stay newest first, ignoring pins")
+	}
+}
+
 func TestAuthor(t *testing.T) {
 	srv, dir := newTestServer(t, map[string]string{
 		"2026-01-01-anon.md":   "# Anonymous\n",

+ 17 - 2
posts.go

@@ -26,13 +26,15 @@ type Post struct {
 	Summary string
 	Author  string
 	Draft   bool
+	Pinned  bool
 	HTML    template.HTML
 }
 
 // index is an immutable snapshot of the published posts.
 type index struct {
-	posts  []*Post        // newest first
-	bySlug map[string]int // slug -> position in posts
+	posts   []*Post        // newest first; feed and newer/older links use this
+	listing []*Post        // index page order: pinned first, each group newest first
+	bySlug  map[string]int // slug -> position in posts
 }
 
 // Store reads posts from a directory and caches them until the directory's
@@ -114,6 +116,14 @@ func (s *Store) Index() (*index, error) {
 	for i, p := range idx.posts {
 		idx.bySlug[p.Slug] = i
 	}
+	idx.listing = make([]*Post, 0, len(idx.posts))
+	for _, pinned := range []bool{true, false} {
+		for _, p := range idx.posts {
+			if p.Pinned == pinned {
+				idx.listing = append(idx.listing, p)
+			}
+		}
+	}
 
 	s.sig, s.idx = sig.String(), idx
 	return idx, nil
@@ -169,6 +179,11 @@ func parsePost(md goldmark.Markdown, name string, src []byte, modTime time.Time)
 			return nil, fmt.Errorf("draft: %q is not true or false", v)
 		}
 	}
+	if v := meta["pinned"]; v != "" {
+		if p.Pinned, err = strconv.ParseBool(v); err != nil {
+			return nil, fmt.Errorf("pinned: %q is not true or false", v)
+		}
+	}
 
 	if p.Title == "" {
 		p.Title, body = takeHeading(body)

+ 33 - 0
pull-releases/007-pinned-posts/README.md

@@ -0,0 +1,33 @@
+# Add pinned posts
+
+Branch `feature/pinned-posts` · version 0.5.0
+
+## What changed
+
+- **New `pinned` key** in a post's header block: `pinned: true` keeps the post at the top of the index page. It's optional and defaults to `false`.
+- **Several pinned posts** are listed first, newest first, followed by every other post in date order.
+- **"Pinned" label:** pinned posts show **Pinned** before the date on the index page, so a pinned post with an older date doesn't look out of order.
+- **"Why This Project Exists" is pinned.**
+- **Docs:** the hello-world guide, the About page and the README list `pinned` as the sixth header key.
+- **Version:** 0.5.0.
+
+## Notes
+
+- **Pinning only affects the index page.** The RSS feed, the newer/older links on each post and the About page's "Latest post" stay in date order, so pinning a post doesn't reshuffle the reading chain.
+- **An invalid value** such as `pinned: yes` skips the post and logs the error, the same as an invalid `draft` value. Accepted values are `true` and `false` (also `1`/`0`, `t`/`f`).
+
+## Testing
+
+- All tests pass with the race detector (`go test -race ./...`).
+- New test: pinned posts first and newest first among themselves, the rest by date, two "Pinned" labels, an invalid value skipped, and the newer/older links and feed unaffected.
+- Built and ran locally with the real posts: "Why This Project Exists" is first with the Pinned label, and its "older" link still goes to the Go 1.27 post.
+
+## 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)

TEMPAT SAMPAH
pull-releases/007-pinned-posts/desktop.png


TEMPAT SAMPAH
pull-releases/007-pinned-posts/mobile.png


+ 1 - 0
pull-releases/README.md

@@ -33,3 +33,4 @@ screenshot, or its `README.md` doesn't load.
 | [004](004-release-notes-page/) | Release notes pages on the site |
 | [005](005-post-author/) | Author in the post header |
 | [006](006-go-1-27/) | Require Go 1.27.3 (security fixes) |
+| [007](007-pinned-posts/) | Pinned posts |

+ 1 - 0
static/style.css

@@ -71,6 +71,7 @@ footer.site a { color: var(--muted); font-size: 0.9rem; }
 
 .meta { color: var(--muted); font-size: 0.9rem; }
 .post-list .meta { display: block; }
+.pinned { color: var(--fg); font-weight: 600; }
 
 /* Release notes: screenshot tables fill the column instead of scrolling. */
 .release-notes table { display: table; width: 100%; table-layout: fixed; }

+ 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> · by {{or .Author $.Config.DefaultAuthor}}</span>
+    <span class="meta">{{if .Pinned}}<span class="pinned">Pinned</span> · {{end}}<time datetime="{{isodate .Date}}">{{date .Date}}</time> · by {{or .Author $.Config.DefaultAuthor}}</span>
     {{with .Summary}}<p>{{.}}</p>{{end}}
   </li>
 {{end}}

+ 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.4.1"
+const version = "0.5.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