diff options
| author | ivar <i@oiee.no> | 2026-04-04 16:01:46 +0200 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2026-04-04 16:01:46 +0200 |
| commit | 59429d9d696b4c6c8d59b48769b286f65e7b1163 (patch) | |
| tree | eb7c68d20b1ed3123f8118eba8de29934dbc8417 /docs/superpowers/specs | |
| parent | 49b5af2bded29e559b3710dc6e172f979321fbd0 (diff) | |
| download | nebbet.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')
| -rw-r--r-- | docs/superpowers/specs/2026-04-04-draft-access-and-slug-editing-design.md | 112 |
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 | |
