1
1
Эх сурвалжийг харах

Add Markdown blog server

Serves each content/*.md file as a post: index at /, posts at
/posts/<slug> with newer/older links, RSS at /feed.xml, and a 404 page.
Posts are re-read when the content directory changes, rendered with
goldmark (GFM, raw HTML omitted), and templates/CSS are embedded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude 2 өдөр өмнө
parent
commit
8ad17bd65c
15 өөрчлөгдсөн 975 нэмэгдсэн , 0 устгасан
  1. 2 0
      .gitignore
  2. 24 0
      content/2026-10-24-hello-world.md
  3. 68 0
      feed.go
  4. 5 0
      go.mod
  5. 2 0
      go.sum
  6. 140 0
      main.go
  7. 183 0
      main_test.go
  8. 237 0
      posts.go
  9. 104 0
      posts_test.go
  10. 78 0
      render.go
  11. 78 0
      static/style.css
  12. 6 0
      templates/error.html
  13. 15 0
      templates/index.html
  14. 19 0
      templates/layout.html
  15. 14 0
      templates/post.html

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+/posts
+*.env

+ 24 - 0
content/2026-10-24-hello-world.md

@@ -0,0 +1,24 @@
+---
+title: Hello, world
+summary: The first post, and a quick tour of what a post can contain.
+---
+
+This site is a folder of Markdown files. Each file in `content/` becomes a
+post, and the newest one is listed first.
+
+## What works
+
+- **Bold**, *italic*, ~~strikethrough~~ and `inline code`
+- Links like https://posts.monoaf.com are linked automatically
+- Tables, task lists and fenced code blocks
+
+| Feature | Supported |
+|---|---|
+| Tables | yes |
+| RSS | yes, at `/feed.xml` |
+
+```go
+fmt.Println("hello, world")
+```
+
+> Raw HTML in a post is left out, so posts can only contain Markdown.

+ 68 - 0
feed.go

@@ -0,0 +1,68 @@
+package main
+
+import (
+	"encoding/xml"
+	"net/http"
+	"time"
+)
+
+const feedItems = 20
+
+type rss struct {
+	XMLName xml.Name   `xml:"rss"`
+	Version string     `xml:"version,attr"`
+	Channel rssChannel `xml:"channel"`
+}
+
+type rssChannel struct {
+	Title         string    `xml:"title"`
+	Link          string    `xml:"link"`
+	Description   string    `xml:"description"`
+	LastBuildDate string    `xml:"lastBuildDate,omitempty"`
+	Items         []rssItem `xml:"item"`
+}
+
+type rssItem struct {
+	Title       string `xml:"title"`
+	Link        string `xml:"link"`
+	GUID        string `xml:"guid"`
+	PubDate     string `xml:"pubDate"`
+	Description string `xml:"description,omitempty"`
+}
+
+func (a *app) feed(w http.ResponseWriter, r *http.Request) {
+	idx, err := a.store.Index()
+	if err != nil {
+		a.serverError(w, err)
+		return
+	}
+	ch := rssChannel{
+		Title:       a.siteName,
+		Link:        a.siteURL + "/",
+		Description: "Posts from " + a.siteName,
+	}
+	for i, p := range idx.posts {
+		if i == feedItems {
+			break
+		}
+		if i == 0 {
+			ch.LastBuildDate = p.Date.Format(time.RFC1123Z)
+		}
+		link := a.siteURL + "/posts/" + p.Slug
+		ch.Items = append(ch.Items, rssItem{
+			Title:       p.Title,
+			Link:        link,
+			GUID:        link,
+			PubDate:     p.Date.Format(time.RFC1123Z),
+			Description: p.Summary,
+		})
+	}
+	out, err := xml.MarshalIndent(rss{Version: "2.0", Channel: ch}, "", "  ")
+	if err != nil {
+		a.serverError(w, err)
+		return
+	}
+	w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
+	w.Write([]byte(xml.Header))
+	w.Write(out)
+}

+ 5 - 0
go.mod

@@ -0,0 +1,5 @@
+module git.monoaf.com/bajdev/posts
+
+go 1.25.13
+
+require github.com/yuin/goldmark v1.8.6

+ 2 - 0
go.sum

@@ -0,0 +1,2 @@
+github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
+github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=

+ 140 - 0
main.go

@@ -0,0 +1,140 @@
+// posts serves a Markdown blog: each .md file in CONTENT_DIR is one post.
+package main
+
+import (
+	"html/template"
+	"io/fs"
+	"log"
+	"net/http"
+	"net/url"
+	"os"
+	"strings"
+	"time"
+)
+
+type app struct {
+	store    *Store
+	pages    map[string]*template.Template
+	siteURL  string // absolute base URL, no trailing slash
+	siteName string
+}
+
+func newApp(contentDir, siteURL string) (*app, error) {
+	pages, err := loadTemplates()
+	if err != nil {
+		return nil, err
+	}
+	siteURL = strings.TrimRight(siteURL, "/")
+	u, err := url.Parse(siteURL)
+	if err != nil {
+		return nil, err
+	}
+	return &app{
+		store:    NewStore(contentDir, newMarkdown()),
+		pages:    pages,
+		siteURL:  siteURL,
+		siteName: u.Host,
+	}, nil
+}
+
+func (a *app) routes() http.Handler {
+	static, err := fs.Sub(staticFS, "static")
+	if err != nil {
+		panic(err)
+	}
+	files := http.StripPrefix("/static/", http.FileServerFS(static))
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("GET /{$}", a.index)
+	mux.HandleFunc("GET /posts/{slug}", a.post)
+	mux.HandleFunc("GET /feed.xml", a.feed)
+	mux.HandleFunc("GET /static/", func(w http.ResponseWriter, r *http.Request) {
+		if strings.HasSuffix(r.URL.Path, "/") { // no directory listings
+			a.notFound(w, r)
+			return
+		}
+		w.Header().Set("Cache-Control", "public, max-age=3600")
+		files.ServeHTTP(w, r)
+	})
+	mux.HandleFunc("/", a.notFound)
+	return securityHeaders(mux)
+}
+
+func (a *app) index(w http.ResponseWriter, r *http.Request) {
+	idx, err := a.store.Index()
+	if err != nil {
+		a.serverError(w, err)
+		return
+	}
+	a.render(w, http.StatusOK, "index", pageData{Posts: idx.posts})
+}
+
+func (a *app) post(w http.ResponseWriter, r *http.Request) {
+	idx, err := a.store.Index()
+	if err != nil {
+		a.serverError(w, err)
+		return
+	}
+	i, ok := idx.bySlug[r.PathValue("slug")]
+	if !ok {
+		a.notFound(w, r)
+		return
+	}
+	data := pageData{Post: idx.posts[i]}
+	if i > 0 {
+		data.Newer = idx.posts[i-1]
+	}
+	if i+1 < len(idx.posts) {
+		data.Older = idx.posts[i+1]
+	}
+	a.render(w, http.StatusOK, "post", data)
+}
+
+func (a *app) notFound(w http.ResponseWriter, r *http.Request) {
+	a.render(w, http.StatusNotFound, "error", pageData{Status: 404, Message: "That page doesn't exist."})
+}
+
+func (a *app) serverError(w http.ResponseWriter, err error) {
+	log.Printf("error: %v", err)
+	a.render(w, http.StatusInternalServerError, "error", pageData{Status: 500, Message: "Something went wrong."})
+}
+
+// securityHeaders sets a strict policy: posts never contain scripts, and
+// images may come from anywhere over HTTPS.
+func securityHeaders(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		h := w.Header()
+		h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' https: data:; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
+		h.Set("X-Content-Type-Options", "nosniff")
+		h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
+		next.ServeHTTP(w, r)
+	})
+}
+
+func getenv(key, def string) string {
+	if v := os.Getenv(key); v != "" {
+		return v
+	}
+	return def
+}
+
+func main() {
+	port := os.Getenv("PORT")
+	if port == "" {
+		log.Fatal("PORT not set")
+	}
+	a, err := newApp(getenv("CONTENT_DIR", "content"), getenv("SITE_URL", "https://posts.monoaf.com"))
+	if err != nil {
+		log.Fatal(err)
+	}
+	srv := &http.Server{
+		Addr:              "127.0.0.1:" + port,
+		Handler:           a.routes(),
+		ReadHeaderTimeout: 5 * time.Second,
+		ReadTimeout:       10 * time.Second,
+		WriteTimeout:      30 * time.Second,
+		IdleTimeout:       2 * time.Minute,
+	}
+	log.Printf("listening on %s", srv.Addr)
+	log.Fatal(srv.ListenAndServe())
+}

+ 183 - 0
main_test.go

@@ -0,0 +1,183 @@
+package main
+
+import (
+	"encoding/xml"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func newTestServer(t *testing.T, files map[string]string) (*httptest.Server, string) {
+	t.Helper()
+	dir := t.TempDir()
+	for name, src := range files {
+		writeFile(t, dir, name, src)
+	}
+	a, err := newApp(dir, "https://posts.example.com/")
+	if err != nil {
+		t.Fatal(err)
+	}
+	srv := httptest.NewServer(a.routes())
+	t.Cleanup(srv.Close)
+	return srv, dir
+}
+
+func writeFile(t *testing.T, dir, name, src string) {
+	t.Helper()
+	if err := os.WriteFile(filepath.Join(dir, name), []byte(src), 0o644); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func get(t *testing.T, srv *httptest.Server, path string) (int, http.Header, string) {
+	t.Helper()
+	resp, err := http.Get(srv.URL + path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return resp.StatusCode, resp.Header, string(body)
+}
+
+var sample = map[string]string{
+	"2026-01-01-oldest.md": "# Oldest\n",
+	"2026-02-01-middle.md": "---\ntitle: Middle\nsummary: The middle one.\n---\nMiddle body\n",
+	"2026-03-01-newest.md": "# Newest\n",
+	"2026-04-01-draft.md":  "---\ndraft: true\n---\n# Secret draft\n",
+	"Bad Name.md":          "# Broken\n",
+	"notes.txt":            "not a post",
+}
+
+func TestIndex(t *testing.T) {
+	srv, _ := newTestServer(t, sample)
+	status, h, body := get(t, srv, "/")
+	if status != 200 {
+		t.Fatalf("status = %d", status)
+	}
+	if h.Get("Content-Security-Policy") == "" || h.Get("X-Content-Type-Options") != "nosniff" {
+		t.Errorf("missing security headers: %v", h)
+	}
+	n, m, o := strings.Index(body, "Newest"), strings.Index(body, "Middle"), strings.Index(body, "Oldest")
+	if n < 0 || m < 0 || o < 0 || !(n < m && m < o) {
+		t.Errorf("posts missing or out of order:\n%s", body)
+	}
+	if !strings.Contains(body, "The middle one.") {
+		t.Error("summary not shown")
+	}
+	for _, hidden := range []string{"Secret draft", "Broken"} {
+		if strings.Contains(body, hidden) {
+			t.Errorf("%q should not be listed", hidden)
+		}
+	}
+}
+
+func TestEmptyIndex(t *testing.T) {
+	srv, _ := newTestServer(t, nil)
+	if status, _, body := get(t, srv, "/"); status != 200 || !strings.Contains(body, "No posts yet") {
+		t.Errorf("status = %d, body:\n%s", status, body)
+	}
+}
+
+func TestPostPage(t *testing.T) {
+	srv, _ := newTestServer(t, sample)
+	status, _, body := get(t, srv, "/posts/middle")
+	if status != 200 {
+		t.Fatalf("status = %d", status)
+	}
+	for _, want := range []string{
+		"<title>Middle · posts.example.com</title>",
+		"<p>Middle body</p>",
+		`<a class="newer" href="/posts/newest">`,
+		`<a class="older" href="/posts/oldest">`,
+	} {
+		if !strings.Contains(body, want) {
+			t.Errorf("missing %q in:\n%s", want, body)
+		}
+	}
+
+	_, _, newest := get(t, srv, "/posts/newest")
+	if strings.Contains(newest, `class="newer"`) {
+		t.Error("newest post should have no newer link")
+	}
+	_, _, oldest := get(t, srv, "/posts/oldest")
+	if strings.Contains(oldest, `class="older"`) {
+		t.Error("oldest post should have no older link")
+	}
+}
+
+func TestNotFound(t *testing.T) {
+	srv, _ := newTestServer(t, sample)
+	for _, path := range []string{
+		"/posts/nope",
+		"/posts/draft",
+		"/posts/2026-01-01-oldest",
+		"/posts/..%2f..%2fetc%2fpasswd",
+		"/posts/",
+		"/static/",
+		"/static/missing.css",
+		"/whatever",
+	} {
+		status, _, body := get(t, srv, path)
+		if status != 404 {
+			t.Errorf("%s: status = %d, want 404", path, status)
+		}
+		if path != "/static/missing.css" && !strings.Contains(body, "<h1>404</h1>") {
+			t.Errorf("%s: expected styled 404 page", path)
+		}
+	}
+}
+
+func TestStatic(t *testing.T) {
+	srv, _ := newTestServer(t, nil)
+	status, h, body := get(t, srv, "/static/style.css")
+	if status != 200 || !strings.HasPrefix(h.Get("Content-Type"), "text/css") || !strings.Contains(body, ":root") {
+		t.Errorf("status = %d, type = %q", status, h.Get("Content-Type"))
+	}
+}
+
+func TestFeed(t *testing.T) {
+	srv, _ := newTestServer(t, sample)
+	status, h, body := get(t, srv, "/feed.xml")
+	if status != 200 || !strings.HasPrefix(h.Get("Content-Type"), "application/rss+xml") {
+		t.Fatalf("status = %d, type = %q", status, h.Get("Content-Type"))
+	}
+	var feed rss
+	if err := xml.Unmarshal([]byte(body), &feed); err != nil {
+		t.Fatalf("invalid XML: %v\n%s", err, body)
+	}
+	items := feed.Channel.Items
+	if len(items) != 3 {
+		t.Fatalf("got %d items, want 3", len(items))
+	}
+	if items[0].Link != "https://posts.example.com/posts/newest" {
+		t.Errorf("first link = %q", items[0].Link)
+	}
+	if items[1].Description != "The middle one." {
+		t.Errorf("description = %q", items[1].Description)
+	}
+}
+
+func TestReloadsWhenContentChanges(t *testing.T) {
+	srv, dir := newTestServer(t, map[string]string{"2026-01-01-one.md": "# One\n"})
+	if _, _, body := get(t, srv, "/"); strings.Contains(body, "Two") {
+		t.Fatal("unexpected post")
+	}
+	writeFile(t, dir, "2026-01-02-two.md", "# Two\n")
+	if status, _, _ := get(t, srv, "/posts/two"); status != 200 {
+		t.Errorf("new post not picked up: status = %d", status)
+	}
+	if err := os.Remove(filepath.Join(dir, "2026-01-01-one.md")); err != nil {
+		t.Fatal(err)
+	}
+	if status, _, _ := get(t, srv, "/posts/one"); status != 404 {
+		t.Errorf("removed post still served: status = %d", status)
+	}
+}

+ 237 - 0
posts.go

@@ -0,0 +1,237 @@
+package main
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"html/template"
+	"log"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/yuin/goldmark"
+)
+
+// Post is one rendered blog post.
+type Post struct {
+	Slug    string
+	Title   string
+	Date    time.Time
+	Summary string
+	Draft   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
+}
+
+// Store reads posts from a directory and caches them until the directory's
+// contents (file names, sizes or modification times) change.
+type Store struct {
+	dir string
+	md  goldmark.Markdown
+
+	mu  sync.Mutex
+	sig string
+	idx *index
+}
+
+func NewStore(dir string, md goldmark.Markdown) *Store {
+	return &Store{dir: dir, md: md}
+}
+
+// Index returns the current published posts, reloading them if any file in
+// the content directory has changed. Files that fail to parse are logged and
+// skipped so one bad post cannot take the site down.
+func (s *Store) Index() (*index, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	entries, err := os.ReadDir(s.dir)
+	if err != nil {
+		return nil, err
+	}
+	var sig strings.Builder
+	var files []os.DirEntry
+	for _, e := range entries {
+		if !e.Type().IsRegular() || filepath.Ext(e.Name()) != ".md" {
+			continue
+		}
+		info, err := e.Info()
+		if err != nil {
+			return nil, err
+		}
+		fmt.Fprintf(&sig, "%s\x00%d\x00%d\n", e.Name(), info.Size(), info.ModTime().UnixNano())
+		files = append(files, e)
+	}
+	if s.idx != nil && sig.String() == s.sig {
+		return s.idx, nil
+	}
+
+	idx := &index{bySlug: map[string]int{}}
+	for _, e := range files {
+		path := filepath.Join(s.dir, e.Name())
+		src, err := os.ReadFile(path)
+		if err != nil {
+			return nil, err
+		}
+		info, err := e.Info()
+		if err != nil {
+			return nil, err
+		}
+		p, err := parsePost(s.md, e.Name(), src, info.ModTime())
+		if err != nil {
+			log.Printf("skipping %s: %v", path, err)
+			continue
+		}
+		if p.Draft {
+			continue
+		}
+		if _, dup := idx.bySlug[p.Slug]; dup {
+			log.Printf("skipping %s: duplicate slug %q", path, p.Slug)
+			continue
+		}
+		idx.bySlug[p.Slug] = -1
+		idx.posts = append(idx.posts, p)
+	}
+	sort.SliceStable(idx.posts, func(i, j int) bool {
+		a, b := idx.posts[i], idx.posts[j]
+		if !a.Date.Equal(b.Date) {
+			return a.Date.After(b.Date)
+		}
+		return a.Slug < b.Slug
+	})
+	for i, p := range idx.posts {
+		idx.bySlug[p.Slug] = i
+	}
+
+	s.sig, s.idx = sig.String(), idx
+	return idx, nil
+}
+
+var (
+	datedName = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})-([a-z0-9][a-z0-9-]*)\.md$`)
+	plainName = regexp.MustCompile(`^([a-z0-9][a-z0-9-]*)\.md$`)
+)
+
+// slugFromName extracts the slug and, for YYYY-MM-DD-slug.md names, the date.
+func slugFromName(name string) (slug string, date time.Time, err error) {
+	if m := datedName.FindStringSubmatch(name); m != nil {
+		date, err = time.Parse(time.DateOnly, m[1])
+		if err != nil {
+			return "", time.Time{}, fmt.Errorf("bad date in filename: %w", err)
+		}
+		return m[2], date, nil
+	}
+	if m := plainName.FindStringSubmatch(name); m != nil {
+		return m[1], time.Time{}, nil
+	}
+	return "", time.Time{}, errors.New("filename must be YYYY-MM-DD-slug.md or slug.md using lowercase letters, digits and hyphens")
+}
+
+// parsePost builds a Post from a file's name and contents. modTime is the
+// last-resort date when neither front matter nor the filename gives one.
+func parsePost(md goldmark.Markdown, name string, src []byte, modTime time.Time) (*Post, error) {
+	slug, fileDate, err := slugFromName(name)
+	if err != nil {
+		return nil, err
+	}
+	meta, body, err := splitFrontMatter(src)
+	if err != nil {
+		return nil, err
+	}
+
+	p := &Post{Slug: slug, Title: meta["title"], Summary: meta["summary"]}
+
+	switch {
+	case meta["date"] != "":
+		if p.Date, err = parseDate(meta["date"]); err != nil {
+			return nil, err
+		}
+	case !fileDate.IsZero():
+		p.Date = fileDate
+	default:
+		p.Date = modTime.UTC().Truncate(24 * time.Hour)
+	}
+
+	if v := meta["draft"]; v != "" {
+		if p.Draft, err = strconv.ParseBool(v); err != nil {
+			return nil, fmt.Errorf("draft: %q is not true or false", v)
+		}
+	}
+
+	if p.Title == "" {
+		p.Title, body = takeHeading(body)
+	}
+	if p.Title == "" {
+		p.Title = slug
+	}
+
+	var buf bytes.Buffer
+	if err := md.Convert(body, &buf); err != nil {
+		return nil, fmt.Errorf("rendering markdown: %w", err)
+	}
+	p.HTML = template.HTML(buf.String())
+	return p, nil
+}
+
+func parseDate(s string) (time.Time, error) {
+	for _, layout := range []string{time.DateOnly, time.RFC3339} {
+		if t, err := time.Parse(layout, s); err == nil {
+			return t, nil
+		}
+	}
+	return time.Time{}, fmt.Errorf("date: %q is not YYYY-MM-DD", s)
+}
+
+// splitFrontMatter separates an optional leading block of "key: value" lines
+// fenced by "---" lines from the Markdown body.
+func splitFrontMatter(src []byte) (map[string]string, []byte, error) {
+	src = bytes.ReplaceAll(src, []byte("\r\n"), []byte("\n"))
+	meta := map[string]string{}
+	lines := strings.SplitAfter(string(src), "\n")
+	if strings.TrimSpace(lines[0]) != "---" {
+		return meta, src, nil
+	}
+	for i := 1; i < len(lines); i++ {
+		line := strings.TrimSpace(lines[i])
+		if line == "---" {
+			return meta, []byte(strings.Join(lines[i+1:], "")), nil
+		}
+		if line == "" || strings.HasPrefix(line, "#") {
+			continue
+		}
+		key, value, ok := strings.Cut(line, ":")
+		if !ok {
+			return nil, nil, fmt.Errorf("front matter line %d: expected key: value", i+1)
+		}
+		meta[strings.ToLower(strings.TrimSpace(key))] = unquote(strings.TrimSpace(value))
+	}
+	return nil, nil, errors.New("front matter is missing its closing ---")
+}
+
+func unquote(s string) string {
+	if len(s) >= 2 && (s[0] == '"' || s[0] == '\'') && s[len(s)-1] == s[0] {
+		return s[1 : len(s)-1]
+	}
+	return s
+}
+
+// takeHeading removes a leading "# Title" line from body and returns the title.
+func takeHeading(body []byte) (string, []byte) {
+	rest := bytes.TrimLeft(body, " \t\n")
+	line, after, _ := bytes.Cut(rest, []byte("\n"))
+	if title, ok := bytes.CutPrefix(line, []byte("# ")); ok {
+		return strings.TrimSpace(string(title)), after
+	}
+	return "", body
+}

+ 104 - 0
posts_test.go

@@ -0,0 +1,104 @@
+package main
+
+import (
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestParsePost(t *testing.T) {
+	mod := time.Date(2026, 3, 4, 15, 30, 0, 0, time.UTC)
+	tests := []struct {
+		name, file, src string
+		slug, title     string
+		date            string
+		summary         string
+		draft           bool
+		wantErr         string
+	}{
+		{
+			name: "full front matter",
+			file: "2026-01-01-first.md",
+			src:  "---\ntitle: \"Front title\"\ndate: 2026-02-02\nsummary: Short.\ndraft: false\n---\nBody\n",
+			slug: "first", title: "Front title", date: "2026-02-02", summary: "Short.",
+		},
+		{
+			name: "title from heading, date from filename",
+			file: "2026-01-01-first.md",
+			src:  "\n# Heading title\n\nBody\n",
+			slug: "first", title: "Heading title", date: "2026-01-01",
+		},
+		{
+			name: "plain filename falls back to slug and mtime",
+			file: "about.md",
+			src:  "Just text.\n",
+			slug: "about", title: "about", date: "2026-03-04",
+		},
+		{
+			name: "CRLF line endings",
+			file: "crlf.md",
+			src:  "---\r\ntitle: Windows\r\ndate: 2026-05-05\r\n---\r\nBody\r\n",
+			slug: "crlf", title: "Windows", date: "2026-05-05",
+		},
+		{
+			name: "draft",
+			file: "2026-01-01-wip.md",
+			src:  "---\ndraft: true\n---\n# WIP\n",
+			slug: "wip", title: "WIP", date: "2026-01-01", draft: true,
+		},
+		{name: "unterminated front matter", file: "a.md", src: "---\ntitle: x\n", wantErr: "closing ---"},
+		{name: "bad front matter line", file: "a.md", src: "---\nnot a pair\n---\n", wantErr: "expected key: value"},
+		{name: "bad date", file: "a.md", src: "---\ndate: yesterday\n---\n", wantErr: "not YYYY-MM-DD"},
+		{name: "bad draft", file: "a.md", src: "---\ndraft: maybe\n---\n", wantErr: "not true or false"},
+		{name: "bad filename", file: "Hello World.md", src: "x", wantErr: "filename must be"},
+		{name: "bad filename date", file: "2026-13-01-x.md", src: "x", wantErr: "bad date"},
+	}
+	md := newMarkdown()
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			p, err := parsePost(md, tt.file, []byte(tt.src), mod)
+			if tt.wantErr != "" {
+				if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
+					t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatal(err)
+			}
+			if p.Slug != tt.slug || p.Title != tt.title || p.Summary != tt.summary || p.Draft != tt.draft {
+				t.Errorf("got slug=%q title=%q summary=%q draft=%v", p.Slug, p.Title, p.Summary, p.Draft)
+			}
+			if got := p.Date.Format(time.DateOnly); got != tt.date {
+				t.Errorf("date = %s, want %s", got, tt.date)
+			}
+		})
+	}
+}
+
+func TestHeadingTitleRemovedFromBody(t *testing.T) {
+	p, err := parsePost(newMarkdown(), "x.md", []byte("# Title\n\nBody text\n"), time.Now())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if strings.Contains(string(p.HTML), "<h1") {
+		t.Errorf("heading used as title should not be repeated in body: %s", p.HTML)
+	}
+}
+
+func TestMarkdownRendering(t *testing.T) {
+	src := "| a | b |\n|---|---|\n| 1 | 2 |\n\n~~old~~ https://example.com\n\n<script>alert(1)</script>\n"
+	p, err := parsePost(newMarkdown(), "x.md", []byte(src), time.Now())
+	if err != nil {
+		t.Fatal(err)
+	}
+	html := string(p.HTML)
+	for _, want := range []string{"<table>", "<del>old</del>", `<a href="https://example.com">`} {
+		if !strings.Contains(html, want) {
+			t.Errorf("missing %q in %s", want, html)
+		}
+	}
+	if strings.Contains(html, "<script") {
+		t.Errorf("raw HTML was rendered: %s", html)
+	}
+}

+ 78 - 0
render.go

@@ -0,0 +1,78 @@
+package main
+
+import (
+	"bytes"
+	"embed"
+	"html/template"
+	"log"
+	"net/http"
+	"time"
+
+	"github.com/yuin/goldmark"
+	"github.com/yuin/goldmark/extension"
+	"github.com/yuin/goldmark/parser"
+)
+
+var (
+	//go:embed templates/*.html
+	templateFS embed.FS
+	//go:embed static
+	staticFS embed.FS
+)
+
+// newMarkdown returns a CommonMark renderer with GitHub-style extensions.
+// Raw HTML in posts is omitted (goldmark's default).
+func newMarkdown() goldmark.Markdown {
+	return goldmark.New(
+		goldmark.WithExtensions(extension.GFM),
+		goldmark.WithParserOptions(parser.WithAutoHeadingID()),
+	)
+}
+
+// pageData is passed to every template.
+type pageData struct {
+	Site         string
+	Posts        []*Post
+	Post         *Post
+	Newer, Older *Post
+	Status       int
+	Message      string
+}
+
+// loadTemplates parses each page template together with the shared layout.
+func loadTemplates() (map[string]*template.Template, error) {
+	funcs := template.FuncMap{
+		"date":    func(t time.Time) string { return t.Format("2 January 2006") },
+		"isodate": func(t time.Time) string { return t.Format(time.DateOnly) },
+	}
+	base, err := template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html")
+	if err != nil {
+		return nil, err
+	}
+	pages := map[string]*template.Template{}
+	for _, name := range []string{"index", "post", "error"} {
+		t, err := base.Clone()
+		if err != nil {
+			return nil, err
+		}
+		if pages[name], err = t.ParseFS(templateFS, "templates/"+name+".html"); err != nil {
+			return nil, err
+		}
+	}
+	return pages, nil
+}
+
+// render executes a page into a buffer first so a template error becomes a
+// clean 500 instead of a half-written page.
+func (a *app) render(w http.ResponseWriter, status int, page string, data pageData) {
+	data.Site = a.siteName
+	var buf bytes.Buffer
+	if err := a.pages[page].ExecuteTemplate(&buf, "layout.html", data); err != nil {
+		log.Printf("render %s: %v", page, err)
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	w.WriteHeader(status)
+	buf.WriteTo(w)
+}

+ 78 - 0
static/style.css

@@ -0,0 +1,78 @@
+:root {
+  --bg: #fdfdfc;
+  --fg: #1d1d1f;
+  --muted: #6b6b70;
+  --link: #1f5fbf;
+  --rule: #e4e4e2;
+  --code-bg: #f3f3f1;
+}
+@media (prefers-color-scheme: dark) {
+  :root {
+    --bg: #161618;
+    --fg: #e6e6e3;
+    --muted: #9a9a9f;
+    --link: #7fb0ff;
+    --rule: #2c2c30;
+    --code-bg: #222226;
+  }
+}
+
+* { box-sizing: border-box; }
+body {
+  margin: 0;
+  background: var(--bg);
+  color: var(--fg);
+  font: 17px/1.65 system-ui, -apple-system, "Segoe UI", sans-serif;
+}
+a { color: var(--link); }
+
+header.site, main {
+  max-width: 42rem;
+  margin: 0 auto;
+  padding: 0 1rem;
+}
+header.site {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  padding-top: 1.5rem;
+  padding-bottom: 1rem;
+  border-bottom: 1px solid var(--rule);
+  margin-bottom: 2rem;
+}
+header.site .home { color: var(--fg); font-weight: 600; text-decoration: none; }
+header.site nav a { color: var(--muted); font-size: 0.9rem; }
+
+main { padding-bottom: 4rem; }
+time { color: var(--muted); font-size: 0.9rem; }
+
+.post-list { list-style: none; padding: 0; margin: 0; }
+.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; }
+article .body { margin-top: 1.5rem; overflow-wrap: break-word; }
+article img { max-width: 100%; height: auto; }
+
+code, pre { font: 0.9em/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
+code { background: var(--code-bg); padding: 0.1em 0.3em; border-radius: 3px; }
+pre { background: var(--code-bg); padding: 1rem; overflow-x: auto; border-radius: 4px; }
+pre code { background: none; padding: 0; }
+
+blockquote { margin: 1rem 0; padding-left: 1rem; border-left: 3px solid var(--rule); color: var(--muted); }
+table { border-collapse: collapse; display: block; overflow-x: auto; }
+th, td { border: 1px solid var(--rule); padding: 0.4rem 0.7rem; text-align: left; }
+hr { border: 0; border-top: 1px solid var(--rule); }
+
+.pager {
+  display: flex;
+  justify-content: space-between;
+  gap: 1rem;
+  margin-top: 3rem;
+  padding-top: 1rem;
+  border-top: 1px solid var(--rule);
+}
+.pager .older { margin-left: auto; text-align: right; }

+ 6 - 0
templates/error.html

@@ -0,0 +1,6 @@
+{{define "title"}}{{.Status}} · {{.Site}}{{end}}
+{{define "content"}}
+<h1>{{.Status}}</h1>
+<p>{{.Message}}</p>
+<p><a href="/">Back to all posts</a></p>
+{{end}}

+ 15 - 0
templates/index.html

@@ -0,0 +1,15 @@
+{{define "content"}}
+{{if .Posts}}
+<ul class="post-list">
+{{range .Posts}}
+  <li>
+    <a href="/posts/{{.Slug}}">{{.Title}}</a>
+    <time datetime="{{isodate .Date}}">{{date .Date}}</time>
+    {{with .Summary}}<p>{{.}}</p>{{end}}
+  </li>
+{{end}}
+</ul>
+{{else}}
+<p>No posts yet.</p>
+{{end}}
+{{end}}

+ 19 - 0
templates/layout.html

@@ -0,0 +1,19 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{block "title" .}}{{.Site}}{{end}}</title>
+<link rel="stylesheet" href="/static/style.css">
+<link rel="alternate" type="application/rss+xml" title="{{.Site}}" href="/feed.xml">
+</head>
+<body>
+<header class="site">
+  <a class="home" href="/">{{.Site}}</a>
+  <nav><a href="/feed.xml">RSS</a></nav>
+</header>
+<main>
+{{template "content" .}}
+</main>
+</body>
+</html>

+ 14 - 0
templates/post.html

@@ -0,0 +1,14 @@
+{{define "title"}}{{.Post.Title}} · {{.Site}}{{end}}
+{{define "content"}}
+<article>
+  <h1>{{.Post.Title}}</h1>
+  <time datetime="{{isodate .Post.Date}}">{{date .Post.Date}}</time>
+  <div class="body">
+{{.Post.HTML}}
+  </div>
+</article>
+<nav class="pager">
+  {{with .Newer}}<a class="newer" href="/posts/{{.Slug}}">← {{.Title}}</a>{{end}}
+  {{with .Older}}<a class="older" href="/posts/{{.Slug}}">{{.Title}} →</a>{{end}}
+</nav>
+{{end}}