Wiki Phase 3: internal links, backlinks, and tags

Connectivity phase of the wiki upgrade (see WIKI_UPGRADE.md).

Schema (additive new tables): wiki_tags, wiki_page_tags, wiki_links.

Internal links & backlinks:
- new wiki.links.js parses a saved body for /wiki/<slug> (and data-wiki-slug)
  targets; wiki_links is rebuilt on every save
- article shows a "Linked from" section (published backlinks) and renders
  links to non-existent pages as red links (server returns missing_links)
- editor gains an internal-link picker listing existing pages

Tags:
- pages accept a tags[] array; tags upsert on save, page tag-set is replaced,
  and orphaned tags are auto-pruned (on save and delete)
- public/admin list filter by ?tag=; /wiki/tags lists tags with published counts
- article shows tag chips; the index has a flat tag-filtered view; editor has a
  comma-separated tags field

Verified end-to-end: A->B backlink appears, red link detected, link index
rebuilds on edit, tag filtering + chips + pruning all work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 11:25:15 -05:00
parent 4a7dbf0085
commit 7c081ae749
15 changed files with 516 additions and 70 deletions

View File

@@ -15,19 +15,25 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
excerpt: '',
category_id: '',
published: true,
tags: '',
})
const [categories, setCategories] = useState([])
const [pages, setPages] = useState([])
const [loading, setLoading] = useState(isEdit)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
// Categories for the dropdown (both new and edit).
// Categories (dropdown) + pages (internal-link picker), for both new and edit.
useEffect(() => {
let active = true
api.admin
.listWikiCategories()
.then((cats) => active && setCategories(cats))
.catch(() => {})
api.admin
.listWiki()
.then((list) => active && setPages(list.map((p) => ({ slug: p.slug, title: p.title }))))
.catch(() => {})
return () => {
active = false
}
@@ -48,6 +54,7 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
excerpt: p.excerpt || '',
category_id: p.category_id != null ? String(p.category_id) : '',
published: Boolean(p.published),
tags: (p.tags || []).map((t) => t.label).join(', '),
}),
)
.catch(() => active && setError('Could not load this page.'))
@@ -66,6 +73,10 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
excerpt: form.excerpt.trim(),
category_id: form.category_id ? Number(form.category_id) : null,
published: form.published,
tags: form.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
}
}
@@ -174,10 +185,24 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
placeholder="One-line summary shown on the wiki home."
/>
</label>
<label>
<span className="field-label">Tags (comma-separated)</span>
<input
type="text"
value={form.tags}
onChange={set('tags')}
className="input"
placeholder="beginner, pvp, towns"
/>
</label>
<div>
<span className="field-label">Body (use Heading 2 for table-of-contents sections)</span>
<Suspense fallback={<span className="spin" />}>
<RichTextEditor value={form.body} onChange={(html) => setForm((f) => ({ ...f, body: html }))} />
<RichTextEditor
value={form.body}
onChange={(html) => setForm((f) => ({ ...f, body: html }))}
pages={pages.filter((p) => p.slug !== form.slug)}
/>
</Suspense>
</div>
</div>