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