summaryrefslogtreecommitdiffstats
path: root/docs/superpowers
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/plans/2026-04-04-draft-access-and-slug-editing.md746
-rw-r--r--docs/superpowers/specs/2026-04-04-draft-access-and-slug-editing-design.md112
2 files changed, 858 insertions, 0 deletions
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"}}
+ <h1>{{.Title}}</h1>
+ <form method="POST" action="{{.Action}}" id="postForm">
+ <label for="title">Title</label>
+ <input type="text" id="title" name="title" value="{{.Post.Title}}" required autofocus>
+
+ <label for="slug">Slug</label>
+ <input type="text" id="slug" name="slug" value="{{.Post.Slug}}" placeholder="auto-generated from title">
+ <p class="hint">URL path for this post (e.g. "my-post" → /my-post). Lowercase letters, numbers, and hyphens only.</p>
+
+ <label for="date">Date</label>
+ <input type="date" id="date" name="date" value="{{.Post.Date}}">
+
+ <label for="tags">Tags</label>
+ <input type="text" id="tags" name="tags" value="{{range $i, $t := .Post.Tags}}{{if $i}}, {{end}}{{$t}}{{end}}" placeholder="tag1, tag2, tag3">
+ <p class="hint">Comma-separated list of tags.</p>
+
+ <label for="draft">
+ <input type="checkbox" id="draft" name="draft"{{if .Post.Draft}} checked{{end}}>
+ Draft (not published)
+ </label>
+
+ <label for="blocks">Content</label>
+ <div id="editor" style="border: 1px solid #ccc; border-radius: 4px; min-height: 340px;"></div>
+ <input type="hidden" id="blocks" name="blocks" value="">
+
+ <div class="form-actions">
+ <button type="submit" class="btn btn-primary">
+ {{if .IsNew}}Create Post{{else}}Save Changes{{end}}
+ </button>
+ <a href="/admin/" class="btn btn-secondary">Cancel</a>
+ </div>
+ </form>
+
+ <script type="module">
+ import { EditorJS, Header, Paragraph, List, Code, Quote, ImageTool } from 'editorjs-bundle';
+ import ComponentTool from '/assets/admin/lib/component-tool.js';
+
+ const blocksField = document.getElementById('blocks');
+ const form = document.getElementById('postForm');
+ const titleInput = document.getElementById('title');
+ const slugInput = document.getElementById('slug');
+
+ // Auto-populate slug from title for new posts only, until user edits slug manually.
+ {{if .IsNew}}
+ let slugManuallyEdited = slugInput.value !== '';
+ slugInput.addEventListener('input', () => { slugManuallyEdited = true; });
+ titleInput.addEventListener('input', () => {
+ if (slugManuallyEdited) return;
+ slugInput.value = titleInput.value
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+ });
+ {{end}}
+
+ let initialData = [];
+ try {
+ initialData = JSON.parse(blocksField.value || '[]');
+ } catch (e) {
+ console.error('Failed to parse blocks JSON:', e);
+ }
+
+ const editor = new EditorJS({
+ holder: 'editor',
+ tools: {
+ header: Header,
+ paragraph: Paragraph,
+ list: List,
+ code: Code,
+ quote: Quote,
+ image: ImageTool,
+ component: ComponentTool,
+ },
+ data: { blocks: initialData },
+ });
+
+ // Sync editor content back to hidden field before form submission
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ try {
+ const editorData = await editor.save();
+ blocksField.value = JSON.stringify(editorData);
+ } catch (err) {
+ console.error('Failed to save editor data:', err);
+ return;
+ }
+
+ form.submit();
+ });
+ </script>
+{{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"
+```
diff --git a/docs/superpowers/specs/2026-04-04-draft-access-and-slug-editing-design.md b/docs/superpowers/specs/2026-04-04-draft-access-and-slug-editing-design.md
new file mode 100644
index 0000000..eb8654c
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-04-draft-access-and-slug-editing-design.md
@@ -0,0 +1,112 @@
+# Draft Access & Slug Editing Design
+
+**Date:** 2026-04-04
+
+## Overview
+
+Two related improvements to the nebbet admin and serving layer:
+
+1. **Draft access by direct link** — drafts are currently 404 to all visitors. After this change, drafts are accessible at their direct URL but remain excluded from the frontpage and search results.
+2. **Editable slugs with redirects** — slugs are currently immutable after creation. After this change, slugs can be renamed in the admin UI; the old URL automatically redirects (301) to the new one.
+
+---
+
+## 1. Draft Serving
+
+### Current behaviour
+`posthandler.go` returns 404 for any post where `Draft == true`, for both HTML (`ServeHTML`) and JSON (`ServeJSON`) endpoints.
+
+### New behaviour
+Remove the draft guards. Drafts are served like published posts. The frontpage and search already exclude drafts (`ListPosts(false)` in `frontpage.go:44`, `searchResultsToPosts` in `frontpage.go:80`) — no change needed there.
+
+Drafts are **not** given any special visual indicator (not requested).
+
+Caching behaviour is unchanged: draft posts are cached on first render and invalidated on edit, same as published posts.
+
+---
+
+## 2. Slug Redirects
+
+### Schema addition
+Add a `redirects` table to `meta.db` (managed by `internal/db/meta.go`):
+
+```sql
+CREATE TABLE IF NOT EXISTS redirects (
+ from_slug TEXT PRIMARY KEY,
+ to_slug TEXT NOT NULL
+);
+```
+
+### DB methods (internal/db/posts.go)
+Three new methods on `MetaDB`:
+
+- `AddRedirect(fromSlug, toSlug string) error` — upserts a redirect row.
+- `GetRedirect(fromSlug string) (string, error)` — returns `to_slug` or `sql.ErrNoRows`.
+- `CollapseRedirects(oldSlug, newSlug string) error` — updates all rows where `to_slug = oldSlug` to point to `newSlug` instead, preventing redirect chains.
+
+### RenamePost (internal/db/posts.go)
+New method `RenamePost(oldSlug, newSlug string) error`:
+
+1. Fetch existing record by `oldSlug` (return error if not found).
+2. Insert record with `newSlug` (same title, date, tags, draft, blocks, updated_at).
+3. Delete record with `oldSlug`.
+4. Call `CollapseRedirects(oldSlug, newSlug)` to fix any existing redirect chains.
+5. Call `AddRedirect(oldSlug, newSlug)`.
+
+All steps run in a single SQLite transaction.
+
+### PostHandler redirect lookup (internal/server/posthandler.go)
+In `ServeHTML` and `ServeJSON`, after a failed `GetPost`:
+
+```go
+if toSlug, err := h.DB.GetRedirect(slug); err == nil {
+ c.Redirect(http.StatusMovedPermanently, "/"+toSlug)
+ return
+}
+c.AbortWithStatus(http.StatusNotFound)
+```
+
+---
+
+## 3. Slug Field in Admin UI
+
+### Form template (internal/admin/templates/form.html)
+Add a slug input between the title and date fields:
+
+```html
+<label for="slug">Slug</label>
+<input type="text" id="slug" name="slug" value="{{.Post.Slug}}" placeholder="auto-generated from title">
+<p class="hint">URL path for this post (e.g. "my-post" → /my-post).</p>
+```
+
+For new posts, populate the slug field automatically from the title via JS (same `slugify` logic: lowercase, replace non-alphanumeric runs with `-`, trim leading/trailing `-`). Allow manual override. Stop auto-populating once the user edits the slug field directly.
+
+### postFromForm (internal/admin/server.go)
+Read the `slug` field: `Slug: strings.TrimSpace(c.PostForm("slug"))`.
+
+### handleNewPost (internal/admin/server.go)
+No change needed — already falls back to `slugify(p.Title)` when `p.Slug == ""`.
+
+### handleEdit POST (internal/admin/server.go)
+If `updated.Slug != slug` (slug changed):
+
+1. Validate `updated.Slug` is non-empty and matches `[a-z0-9-]+` pattern.
+2. Check no post already exists at the new slug.
+3. Call `s.MetaDB.RenamePost(slug, updated.Slug)`.
+4. Invalidate cache for old slug (`invalidatePostCache(slug)`).
+5. Remove old search index entry (`s.SearchDB.DeletePage("/" + slug)`).
+6. Re-index with new path (`s.SearchDB.IndexPage(...)` using `updated.Slug`).
+
+If slug is unchanged, continue with existing update logic.
+
+---
+
+## Files Changed
+
+| File | Change |
+|------|--------|
+| `internal/db/meta.go` | Add `redirects` table to schema |
+| `internal/db/posts.go` | Add `AddRedirect`, `GetRedirect`, `CollapseRedirects`, `RenamePost` |
+| `internal/server/posthandler.go` | Remove draft guards; add redirect lookup after 404 |
+| `internal/admin/server.go` | Read slug from form; handle slug rename in `handleEdit`; update `postFromForm` |
+| `internal/admin/templates/form.html` | Add slug input with JS auto-population |