# Draft Access & Slug Editing Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Allow drafts to be served at their direct URL (not frontpage), and allow slugs to be renamed in the admin UI with automatic 301 redirects from the old URL. **Architecture:** Add a `redirects` table to meta.db; add DB methods for redirect management and atomic slug renaming; remove draft 404 guards in the post handler; add redirect lookup on 404; add a slug field to the admin form with JS auto-population; update admin handlers to handle slug renames. **Tech Stack:** Go, SQLite (`modernc.org/sqlite`), Gin, html/template --- ## File Map | File | Change | |------|--------| | `internal/db/meta.go` | Add `redirects` table to schema in `OpenMeta` | | `internal/db/posts.go` | Add `AddRedirect`, `GetRedirect`, `CollapseRedirects`, `RenamePost` | | `internal/db/posts_test.go` | New — tests for redirect methods and `RenamePost` | | `internal/server/posthandler.go` | Remove draft 404 guards; add redirect lookup after post not found | | `internal/admin/templates/form.html` | Add slug input with JS auto-population from title | | `internal/admin/server.go` | `postFromForm` reads slug; `handleEdit` handles rename; `handleNewPost` validates custom slug | --- ## Task 1: Add redirects table to schema **Files:** - Modify: `internal/db/meta.go` - [ ] **Step 1: Add redirects table to OpenMeta** In `internal/db/meta.go`, extend the schema string inside `OpenMeta` to add the redirects table after the posts index lines. Replace the closing backtick of the `db.Exec` call with the new table: ```go _, err = db.Exec(` CREATE TABLE IF NOT EXISTS pages ( id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT NOT NULL UNIQUE, html_path TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', date TEXT DEFAULT '', tags TEXT DEFAULT '[]', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_pages_path ON pages(path); CREATE INDEX IF NOT EXISTS idx_pages_date ON pages(date); CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, title TEXT NOT NULL DEFAULT '', date TEXT DEFAULT '', tags TEXT DEFAULT '[]', draft INTEGER NOT NULL DEFAULT 0, blocks TEXT NOT NULL DEFAULT '[]', updated_at INTEGER NOT NULL DEFAULT (cast(strftime('%s','now') * 1000000 as integer)) ); CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug); CREATE INDEX IF NOT EXISTS idx_posts_date ON posts(date); CREATE TABLE IF NOT EXISTS redirects ( from_slug TEXT PRIMARY KEY, to_slug TEXT NOT NULL ); `) ``` - [ ] **Step 2: Build to verify schema compiles** ```bash go build ./... ``` Expected: no output (success). - [ ] **Step 3: Commit** ```bash git add internal/db/meta.go git commit -m "feat: add redirects table to meta.db schema" ``` --- ## Task 2: Add redirect DB methods **Files:** - Modify: `internal/db/posts.go` - Create: `internal/db/posts_test.go` - [ ] **Step 1: Write failing tests** Create `internal/db/posts_test.go`: ```go package db import ( "database/sql" "testing" ) func openTestDB(t *testing.T) *MetaDB { t.Helper() m, err := OpenMeta(":memory:") if err != nil { t.Fatalf("open test db: %v", err) } t.Cleanup(func() { m.Close() }) return m } func TestAddAndGetRedirect(t *testing.T) { m := openTestDB(t) if err := m.AddRedirect("old-slug", "new-slug"); err != nil { t.Fatalf("AddRedirect: %v", err) } got, err := m.GetRedirect("old-slug") if err != nil { t.Fatalf("GetRedirect: %v", err) } if got != "new-slug" { t.Errorf("got %q, want %q", got, "new-slug") } } func TestGetRedirect_NotFound(t *testing.T) { m := openTestDB(t) _, err := m.GetRedirect("missing") if err != sql.ErrNoRows { t.Errorf("expected sql.ErrNoRows, got %v", err) } } func TestCollapseRedirects(t *testing.T) { m := openTestDB(t) // a -> b, then b -> c: collapse should make a -> c if err := m.AddRedirect("a", "b"); err != nil { t.Fatalf("AddRedirect: %v", err) } if err := m.CollapseRedirects("b", "c"); err != nil { t.Fatalf("CollapseRedirects: %v", err) } got, err := m.GetRedirect("a") if err != nil { t.Fatalf("GetRedirect: %v", err) } if got != "c" { t.Errorf("got %q, want %q", got, "c") } } func TestAddRedirect_Upsert(t *testing.T) { m := openTestDB(t) if err := m.AddRedirect("old", "first"); err != nil { t.Fatalf("AddRedirect first: %v", err) } if err := m.AddRedirect("old", "second"); err != nil { t.Fatalf("AddRedirect second: %v", err) } got, err := m.GetRedirect("old") if err != nil { t.Fatalf("GetRedirect: %v", err) } if got != "second" { t.Errorf("got %q, want %q", got, "second") } } ``` - [ ] **Step 2: Run tests to verify they fail** ```bash go test ./internal/db/... -run "TestAddAndGetRedirect|TestGetRedirect_NotFound|TestCollapseRedirects|TestAddRedirect_Upsert" -v ``` Expected: compile error — `AddRedirect`, `GetRedirect`, `CollapseRedirects` undefined. - [ ] **Step 3: Implement redirect methods** Add to the bottom of `internal/db/posts.go`: ```go // AddRedirect inserts or replaces a redirect from fromSlug to toSlug. func (m *MetaDB) AddRedirect(fromSlug, toSlug string) error { _, err := m.db.Exec( `INSERT INTO redirects (from_slug, to_slug) VALUES (?, ?) ON CONFLICT(from_slug) DO UPDATE SET to_slug = excluded.to_slug`, fromSlug, toSlug, ) return err } // GetRedirect returns the slug that fromSlug redirects to, or sql.ErrNoRows if none. func (m *MetaDB) GetRedirect(fromSlug string) (string, error) { var toSlug string err := m.db.QueryRow( `SELECT to_slug FROM redirects WHERE from_slug = ?`, fromSlug, ).Scan(&toSlug) return toSlug, err } // CollapseRedirects updates all redirects pointing to oldSlug so they point to newSlug instead, // preventing redirect chains when a slug is renamed again. func (m *MetaDB) CollapseRedirects(oldSlug, newSlug string) error { _, err := m.db.Exec( `UPDATE redirects SET to_slug = ? WHERE to_slug = ?`, newSlug, oldSlug, ) return err } ``` - [ ] **Step 4: Run tests to verify they pass** ```bash go test ./internal/db/... -run "TestAddAndGetRedirect|TestGetRedirect_NotFound|TestCollapseRedirects|TestAddRedirect_Upsert" -v ``` Expected: all 4 tests PASS. - [ ] **Step 5: Commit** ```bash git add internal/db/posts.go internal/db/posts_test.go git commit -m "feat: add redirect DB methods (AddRedirect, GetRedirect, CollapseRedirects)" ``` --- ## Task 3: Add RenamePost method **Files:** - Modify: `internal/db/posts.go` - Modify: `internal/db/posts_test.go` - [ ] **Step 1: Write failing test** Append to `internal/db/posts_test.go`: ```go func TestRenamePost(t *testing.T) { m := openTestDB(t) original := PostRecord{ Slug: "original-slug", Title: "My Post", Date: "2026-04-04", Tags: []string{"go"}, Draft: false, Blocks: `[{"type":"paragraph","data":{"text":"hello"}}]`, UpdatedAt: 1000000, } if err := m.UpsertPost(original); err != nil { t.Fatalf("UpsertPost: %v", err) } if err := m.RenamePost("original-slug", "new-slug"); err != nil { t.Fatalf("RenamePost: %v", err) } // New slug exists with same content got, err := m.GetPost("new-slug") if err != nil { t.Fatalf("GetPost new-slug: %v", err) } if got.Title != "My Post" { t.Errorf("title: got %q, want %q", got.Title, "My Post") } // Old slug is gone _, err = m.GetPost("original-slug") if err == nil { t.Error("expected old slug to be deleted, but GetPost returned no error") } // Redirect exists toSlug, err := m.GetRedirect("original-slug") if err != nil { t.Fatalf("GetRedirect: %v", err) } if toSlug != "new-slug" { t.Errorf("redirect: got %q, want %q", toSlug, "new-slug") } } func TestRenamePost_CollapsesChain(t *testing.T) { m := openTestDB(t) // Create post at "b" if err := m.UpsertPost(PostRecord{Slug: "b", Title: "B", Blocks: "[]", UpdatedAt: 1}); err != nil { t.Fatalf("UpsertPost: %v", err) } // Existing redirect a -> b if err := m.AddRedirect("a", "b"); err != nil { t.Fatalf("AddRedirect: %v", err) } // Rename b -> c: should collapse a -> c if err := m.RenamePost("b", "c"); err != nil { t.Fatalf("RenamePost: %v", err) } got, err := m.GetRedirect("a") if err != nil { t.Fatalf("GetRedirect a: %v", err) } if got != "c" { t.Errorf("chain collapse: got %q, want %q", got, "c") } } ``` - [ ] **Step 2: Run tests to verify they fail** ```bash go test ./internal/db/... -run "TestRenamePost" -v ``` Expected: compile error — `RenamePost` undefined. - [ ] **Step 3: Implement RenamePost** Add to the bottom of `internal/db/posts.go`: ```go // RenamePost atomically renames a post from oldSlug to newSlug, // updates any existing redirect chains, and adds a redirect from oldSlug to newSlug. func (m *MetaDB) RenamePost(oldSlug, newSlug string) error { tx, err := m.db.Begin() if err != nil { return err } defer tx.Rollback() // Fetch existing record var p PostRecord var tagsJSON string var draft int row := tx.QueryRow( `SELECT slug, title, date, tags, draft, blocks, updated_at FROM posts WHERE slug = ?`, oldSlug) if err := row.Scan(&p.Slug, &p.Title, &p.Date, &tagsJSON, &draft, &p.Blocks, &p.UpdatedAt); err != nil { return err } p.Draft = draft != 0 _ = json.Unmarshal([]byte(tagsJSON), &p.Tags) // Insert with new slug tags, _ := json.Marshal(p.Tags) draftInt := 0 if p.Draft { draftInt = 1 } _, err = tx.Exec(` INSERT INTO posts (slug, title, date, tags, draft, blocks, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, newSlug, p.Title, p.Date, string(tags), draftInt, p.Blocks, p.UpdatedAt, ) if err != nil { return err } // Delete old record if _, err = tx.Exec(`DELETE FROM posts WHERE slug = ?`, oldSlug); err != nil { return err } // Collapse existing redirect chains: anything pointing to oldSlug now points to newSlug if _, err = tx.Exec( `UPDATE redirects SET to_slug = ? WHERE to_slug = ?`, newSlug, oldSlug, ); err != nil { return err } // Add redirect from oldSlug to newSlug if _, err = tx.Exec(` INSERT INTO redirects (from_slug, to_slug) VALUES (?, ?) ON CONFLICT(from_slug) DO UPDATE SET to_slug = excluded.to_slug`, oldSlug, newSlug, ); err != nil { return err } return tx.Commit() } ``` - [ ] **Step 4: Run all DB tests** ```bash go test ./internal/db/... -v ``` Expected: all tests PASS. - [ ] **Step 5: Commit** ```bash git add internal/db/posts.go internal/db/posts_test.go git commit -m "feat: add RenamePost with transactional rename and redirect creation" ``` --- ## Task 4: Remove draft guards and add redirect lookup in PostHandler **Files:** - Modify: `internal/server/posthandler.go` - [ ] **Step 1: Remove draft guards from ServeHTML** In `internal/server/posthandler.go`, delete lines 52–55 from `ServeHTML`: ```go // DELETE these lines: if post.Draft { c.AbortWithStatus(http.StatusNotFound) return } ``` After the deletion, the post-not-found error path becomes the place to add redirect lookup. Replace the current `c.AbortWithStatus(http.StatusNotFound)` in `ServeHTML` (the one after `GetPost` fails) with: ```go post, err := h.DB.GetPost(slug) if err != nil { if toSlug, rerr := h.DB.GetRedirect(slug); rerr == nil { c.Redirect(http.StatusMovedPermanently, "/"+toSlug) return } c.AbortWithStatus(http.StatusNotFound) return } ``` - [ ] **Step 2: Remove draft guard from ServeJSON and add redirect lookup** In `ServeJSON`, delete lines 111–114: ```go // DELETE these lines: if post.Draft { c.AbortWithStatus(http.StatusNotFound) return } ``` Replace the `GetPost` error path in `ServeJSON` with: ```go post, err := h.DB.GetPost(slug) if err != nil { if toSlug, rerr := h.DB.GetRedirect(slug); rerr == nil { c.Redirect(http.StatusMovedPermanently, "/"+toSlug+".json") return } c.AbortWithStatus(http.StatusNotFound) return } ``` - [ ] **Step 3: Build to verify** ```bash go build ./... ``` Expected: no output. - [ ] **Step 4: Commit** ```bash git add internal/server/posthandler.go git commit -m "feat: serve drafts by direct link; add 301 redirect on renamed slugs" ``` --- ## Task 5: Add slug field to admin form with JS auto-population **Files:** - Modify: `internal/admin/templates/form.html` - [ ] **Step 1: Add slug input field** In `internal/admin/templates/form.html`, add the slug field between the title input and the date label. The full updated template: ```html {{define "form-content"}}