summaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-04-04-draft-access-and-slug-editing-design.md
diff options
context:
space:
mode:
authorivar <i@oiee.no>2026-04-04 16:01:46 +0200
committerivar <i@oiee.no>2026-04-04 16:01:46 +0200
commit59429d9d696b4c6c8d59b48769b286f65e7b1163 (patch)
treeeb7c68d20b1ed3123f8118eba8de29934dbc8417 /docs/superpowers/specs/2026-04-04-draft-access-and-slug-editing-design.md
parent49b5af2bded29e559b3710dc6e172f979321fbd0 (diff)
downloadnebbet.no-59429d9d696b4c6c8d59b48769b286f65e7b1163.tar.xz
nebbet.no-59429d9d696b4c6c8d59b48769b286f65e7b1163.zip
docs: add spec and implementation plan for draft access and slug editing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/specs/2026-04-04-draft-access-and-slug-editing-design.md')
-rw-r--r--docs/superpowers/specs/2026-04-04-draft-access-and-slug-editing-design.md112
1 files changed, 112 insertions, 0 deletions
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 |