# 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

URL path for this post (e.g. "my-post" → /my-post).

``` 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 |