From 59429d9d696b4c6c8d59b48769b286f65e7b1163 Mon Sep 17 00:00:00 2001 From: ivar Date: Sat, 4 Apr 2026 16:01:46 +0200 Subject: docs: add spec and implementation plan for draft access and slug editing Co-Authored-By: Claude Sonnet 4.6 --- .../2026-04-04-draft-access-and-slug-editing.md | 746 +++++++++++++++++++++ 1 file changed, 746 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-04-draft-access-and-slug-editing.md (limited to 'docs/superpowers/plans/2026-04-04-draft-access-and-slug-editing.md') diff --git a/docs/superpowers/plans/2026-04-04-draft-access-and-slug-editing.md b/docs/superpowers/plans/2026-04-04-draft-access-and-slug-editing.md new file mode 100644 index 0000000..0ca6135 --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-draft-access-and-slug-editing.md @@ -0,0 +1,746 @@ +# 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"}} +

{{.Title}}

+
+ + + + + +

URL path for this post (e.g. "my-post" → /my-post). Lowercase letters, numbers, and hyphens only.

+ + + + + + +

Comma-separated list of tags.

+ + + + +
+ + +
+ + Cancel +
+
+ + +{{end}} +``` + +- [ ] **Step 2: Build to verify** + +```bash +go build ./... +``` +Expected: no output. + +- [ ] **Step 3: Commit** + +```bash +git add internal/admin/templates/form.html +git commit -m "feat: add editable slug field to admin form with JS auto-population" +``` + +--- + +## Task 6: Update admin handlers for slug reading and renaming + +**Files:** +- Modify: `internal/admin/server.go` + +- [ ] **Step 1: Add slug validation regexp** + +In `internal/admin/server.go`, near the existing `nonAlnum` regexp, add: + +```go +var validSlug = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) +``` + +- [ ] **Step 2: Update postFromForm to read slug** + +Replace the existing `postFromForm` function: + +```go +func postFromForm(c *gin.Context) Post { + tagsStr := strings.TrimSpace(c.PostForm("tags")) + var tags []string + if tagsStr != "" { + for _, t := range strings.Split(tagsStr, ",") { + t = strings.TrimSpace(t) + if t != "" { + tags = append(tags, t) + } + } + } + + draft := c.PostForm("draft") != "" + + return Post{ + Slug: strings.TrimSpace(c.PostForm("slug")), + Title: strings.TrimSpace(c.PostForm("title")), + Date: strings.TrimSpace(c.PostForm("date")), + Tags: tags, + Blocks: c.PostForm("blocks"), + Draft: draft, + } +} +``` + +- [ ] **Step 3: Update handleNewPost to validate custom slug** + +In `handleNewPost`, after the `if p.Slug == "" { p.Slug = slugify(p.Title) }` block, add slug validation: + +```go +if p.Slug == "" { + p.Slug = slugify(p.Title) +} +if !validSlug.MatchString(p.Slug) { + s.renderError(c, fmt.Sprintf("Invalid slug %q: use lowercase letters, numbers, and hyphens only", p.Slug)) + return +} +``` + +- [ ] **Step 4: Update handleEdit POST to support slug rename** + +Replace the `if c.Request.Method == http.MethodPost` block inside `handleEdit`: + +```go +if c.Request.Method == http.MethodPost { + updated := postFromForm(c) + if updated.Title == "" { + s.renderError(c, "Title is required") + return + } + + targetSlug := slug // default: slug unchanged + + if updated.Slug != "" && updated.Slug != slug { + // Slug rename requested + if !validSlug.MatchString(updated.Slug) { + s.renderError(c, fmt.Sprintf("Invalid slug %q: use lowercase letters, numbers, and hyphens only", updated.Slug)) + return + } + // Check target slug is not already taken + if existing, err := s.MetaDB.GetPost(updated.Slug); err == nil && existing != nil { + s.renderError(c, fmt.Sprintf("Post %q already exists", updated.Slug)) + return + } + // Perform rename (creates redirect, collapses chains) + if err := s.MetaDB.RenamePost(slug, updated.Slug); err != nil { + s.renderError(c, "Failed to rename post: "+err.Error()) + return + } + // Invalidate old cache + s.invalidatePostCache(slug) + // Remove old search index entry + _ = s.SearchDB.DeletePage("/" + slug) + targetSlug = updated.Slug + } + + // Update content under the (possibly new) slug + record := db.PostRecord{ + Slug: targetSlug, + Title: updated.Title, + Date: updated.Date, + Tags: updated.Tags, + Draft: updated.Draft, + Blocks: updated.Blocks, + UpdatedAt: time.Now().UnixMicro(), + } + if err := s.MetaDB.UpsertPost(record); err != nil { + c.HTML(http.StatusInternalServerError, "base", gin.H{ + "Title": "Error", + "ContentTemplate": "error-content", + "Message": err.Error(), + }) + return + } + + // Invalidate cache for the target slug + s.invalidatePostCache(targetSlug) + + // Re-index with target slug + plainText := extractPlainTextFromEditorJS(updated.Blocks) + _ = s.SearchDB.IndexPage(db.SearchPage{ + Path: "/" + targetSlug, + Title: updated.Title, + Content: plainText, + }) + + c.Redirect(http.StatusSeeOther, "/admin/") + return +} +``` + +- [ ] **Step 5: Build to verify** + +```bash +go build ./... +``` +Expected: no output. + +- [ ] **Step 6: Run all tests** + +```bash +go test ./... +``` +Expected: all tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/admin/server.go +git commit -m "feat: support slug editing and rename in admin UI with redirect and cache invalidation" +``` -- cgit v1.3