summaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-04-04-draft-access-and-slug-editing-design.md
blob: eb8654cd449ed7c45b12da37af9713b8f8e7286f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
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 |