diff options
| -rw-r--r-- | docs/superpowers/plans/2026-04-04-draft-access-and-slug-editing.md | 746 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-04-04-draft-access-and-slug-editing-design.md | 112 | ||||
| -rw-r--r-- | nebbet.service | 27 | ||||
| -rw-r--r-- | nginx.conf | 71 |
4 files changed, 858 insertions, 98 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 | diff --git a/nebbet.service b/nebbet.service deleted file mode 100644 index 9da673e..0000000 --- a/nebbet.service +++ /dev/null @@ -1,27 +0,0 @@ -# systemd service for nebbet watch daemon -# -# Install: -# sudo cp nebbet.service /etc/systemd/system/ -# sudo systemctl daemon-reload -# sudo systemctl enable --now nebbet -# -# Adjust WorkingDirectory and User as needed. - -[Unit] -Description=nebbet static site builder (watch mode) -After=network.target - -[Service] -Type=simple -User=www-data -WorkingDirectory=SITE_ROOT -ExecStart=SITE_ROOT/nebbet build --watch -Restart=on-failure -RestartSec=5 - -# Limit what the process can do -PrivateTmp=true -NoNewPrivileges=true - -[Install] -WantedBy=multi-user.target diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index 715ac9a..0000000 --- a/nginx.conf +++ /dev/null @@ -1,71 +0,0 @@ -# nebbet.no nginx configuration -# Adjust SITE_ROOT to the absolute path of your project directory. -# Reload after changes: sudo nginx -s reload - -server { - listen 80; - listen [::]:80; - server_name nebbet.no www.nebbet.no; - - # Redirect www → apex (optional, remove if not needed) - if ($host = www.nebbet.no) { - return 301 $scheme://nebbet.no$request_uri; - } - - # ── static assets ──────────────────────────────────────────────────────── - # Served directly from source so you don't have to copy them on every build. - - location /styles/ { - alias SITE_ROOT/styles/; - expires 1d; - add_header Cache-Control "public"; - } - - location /components/ { - alias SITE_ROOT/components/; - expires 1d; - add_header Cache-Control "public"; - } - - location /lib/ { - alias SITE_ROOT/lib/; - expires 1d; - add_header Cache-Control "public"; - } - - # ── admin (password-protected) ─────────────────────────────────────────── - location /admin/ { - auth_basic "Admin"; - # The .passwords file is htpasswd-compatible (bcrypt). - # Manage with: nebbet user add <name> - auth_basic_user_file SITE_ROOT/.passwords; - - root SITE_ROOT/public; - index index.html; - try_files $uri $uri.html $uri/index.html =404; - } - - # ── public pages ───────────────────────────────────────────────────────── - location / { - root SITE_ROOT/public; - index index.html; - - # Clean URLs: /about → /about.html - try_files $uri $uri.html $uri/index.html =404; - - expires 1h; - add_header Cache-Control "public"; - } - - # ── 404 ────────────────────────────────────────────────────────────────── - error_page 404 /404.html; - location = /404.html { - root SITE_ROOT/public; - internal; - } - - # ── gzip ───────────────────────────────────────────────────────────────── - gzip on; - gzip_types text/html text/css application/javascript application/json; - gzip_min_length 1024; -} |
