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

@@ -1,45 +1,97 @@
const wikiDb = require('./wiki.db')
const { cleanBody } = require('../../utils/sanitizeHtml')
const { extractTargets } = require('./wiki.links')
function slugifyTag(label) {
return String(label)
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
// Upsert each label into wiki_tags and set the page's tag set exactly.
async function syncTags(pageId, tags) {
const ids = []
const seen = new Set()
for (const raw of tags) {
const label = String(raw).trim()
if (!label) continue
const slug = slugifyTag(label)
if (!slug || seen.has(slug)) continue
seen.add(slug)
ids.push(await wikiDb.upsertTag(slug, label))
}
await wikiDb.setPageTags(pageId, ids)
await wikiDb.deleteOrphanTags()
}
// Rebuild the page's outgoing internal-link rows from its (sanitized) body.
async function rebuildLinks(pageId, html) {
await wikiDb.clearLinks(pageId)
for (const target of extractTargets(html)) {
await wikiDb.insertLink(pageId, target)
}
}
// ── Pages ──────────────────────────────────────────────────────────────
async function listPublished(categoryId = null) {
return wikiDb.listPublishedSummaries(categoryId)
async function listPublished(filters = {}) {
return wikiDb.listPublishedSummaries(filters)
}
async function listAll(filters = {}) {
return wikiDb.listAllSummaries(filters)
}
// Admin detail: page + its tags.
async function getBySlug(slug) {
return wikiDb.findBySlug(slug)
const page = await wikiDb.findBySlug(slug)
if (!page) return null
page.tags = await wikiDb.getTagsForPage(page.id)
return page
}
// Public detail: page + tags + backlinks + missing (red) link targets.
async function getPublishedBySlug(slug) {
return wikiDb.findPublishedBySlug(slug)
const page = await wikiDb.findPublishedBySlug(slug)
if (!page) return null
page.tags = await wikiDb.getTagsForPage(page.id)
page.backlinks = await wikiDb.getBacklinks(slug, { publishedOnly: true })
const targets = extractTargets(page.body)
const existing = await wikiDb.getExistingSlugs(targets)
page.missing_links = targets.filter((t) => !existing.has(t))
return page
}
async function create({ slug, title, body, excerpt, categoryId, published, updatedBy }) {
async function create({ slug, title, body, excerpt, categoryId, published, updatedBy, tags }) {
const clean = cleanBody(body)
await wikiDb.insert({
slug,
title,
body: cleanBody(body),
body: clean,
excerpt: excerpt || null,
categoryId: categoryId ?? null,
published: published !== false, // default published unless explicitly false
published: published !== false,
updatedBy,
})
return wikiDb.findBySlug(slug)
const page = await wikiDb.findBySlug(slug)
if (Array.isArray(tags)) await syncTags(page.id, tags)
await rebuildLinks(page.id, clean)
return getBySlug(slug)
}
// Partial update — only keys present in `input` are written. Body is sanitized;
// published_at is stamped the first time a page goes live.
// Partial update — only keys present in `input` are written.
async function update(slug, input) {
const current = await wikiDb.findBySlug(slug)
if (!current) return null
const fields = { updated_by: input.updatedBy ?? null }
let cleanForLinks = null
if ('title' in input) fields.title = input.title
if ('body' in input) fields.body = cleanBody(input.body)
if ('body' in input) {
fields.body = cleanBody(input.body)
cleanForLinks = fields.body
}
if ('excerpt' in input) fields.excerpt = input.excerpt || null
if ('categoryId' in input) fields.category_id = input.categoryId ?? null
if ('published' in input) {
@@ -48,7 +100,9 @@ async function update(slug, input) {
}
await wikiDb.updateBySlug(slug, fields)
return wikiDb.findBySlug(slug)
if (Array.isArray(input.tags)) await syncTags(current.id, input.tags)
if (cleanForLinks != null) await rebuildLinks(current.id, cleanForLinks)
return getBySlug(slug)
}
async function setPublished(slug, published) {
@@ -57,11 +111,22 @@ async function setPublished(slug, published) {
const fields = { published: published ? 1 : 0 }
if (published && !current.published_at) fields.published_at = new Date()
await wikiDb.updateBySlug(slug, fields)
return wikiDb.findBySlug(slug)
return getBySlug(slug)
}
async function remove(slug) {
return wikiDb.deleteBySlug(slug)
const res = await wikiDb.deleteBySlug(slug)
await wikiDb.deleteOrphanTags() // page_tags cascade on delete; drop now-empty tags
return res
}
// ── Tags ───────────────────────────────────────────────────────────────
async function listTags() {
return wikiDb.listTags()
}
async function getTagBySlug(slug) {
return wikiDb.findTagBySlug(slug)
}
// ── Categories ─────────────────────────────────────────────────────────
@@ -97,7 +162,6 @@ async function removeCategory(id) {
}
module.exports = {
list: listPublished, // back-compat alias (old callers expected published list)
listPublished,
listAll,
getBySlug,
@@ -106,6 +170,8 @@ module.exports = {
update,
setPublished,
remove,
listTags,
getTagBySlug,
listCategories,
getCategoryBySlug,
getCategoryById,