diff --git a/internal/web/frontend/src/components/Markdown.tsx b/internal/web/frontend/src/components/Markdown.tsx index 14c6f83..e7df6d8 100644 --- a/internal/web/frontend/src/components/Markdown.tsx +++ b/internal/web/frontend/src/components/Markdown.tsx @@ -39,6 +39,11 @@ export function Markdown({ text }: { text: string }) { if (href?.startsWith('ref:')) { return } + // An in-app route (the hash router's `#/…`) is navigation, not an + // outbound link: it stays in this tab. + if (href?.startsWith('#')) { + return {children} + } return ( {children} diff --git a/internal/web/frontend/src/components/Rail.tsx b/internal/web/frontend/src/components/Rail.tsx index 167d45d..121979f 100644 --- a/internal/web/frontend/src/components/Rail.tsx +++ b/internal/web/frontend/src/components/Rail.tsx @@ -146,18 +146,18 @@ function PlansRail({ version, current }: { version: number; current: string | nu function WikiRail({ version, current }: { version: number; current: string | null }) { const [pages, setPages] = useState([]) const [categories, setCategories] = useState([]) + const [hasIndex, setHasIndex] = useState(false) useEffect(() => { api .wiki() .then((w) => { setPages(w?.pages ?? []) setCategories(w?.categories ?? []) + setHasIndex(!!w?.has_index) }) .catch(() => setPages([])) }, [version]) - if (pages.length === 0) return
no pages yet
- // Grouped by the index.md catalog, in the order the catalog lists them; pages // absent from it fall into a trailing group (the read model sorts them last). const groups = [...categories, '\0'].map((c) => ({ @@ -167,6 +167,24 @@ function WikiRail({ version, current }: { version: number; current: string | nul return ( <> + {/* The index is the wiki's home page, so it is a row of its own above the + catalog it produced — `#/wiki` with no slug lands there. */} + {hasIndex && ( +
+
+ p.slug === current) ? '' : 'active'}`} + href={href('wiki')} + title="docs/wiki/index.md" + > +
+ Index +
+
+
+
+ )} + {pages.length === 0 &&
no pages yet
} {groups .filter((g) => g.pages.length > 0) .map((g) => ( diff --git a/internal/web/frontend/src/components/WikiView.tsx b/internal/web/frontend/src/components/WikiView.tsx index 7b55d9d..6da4d14 100644 --- a/internal/web/frontend/src/components/WikiView.tsx +++ b/internal/web/frontend/src/components/WikiView.tsx @@ -25,17 +25,112 @@ export function WikiView({ slug, version }: { slug: string | null; version: numb if (!ov.present) return if (ov.pages.length === 0) return - const current = (slug && ov.pages.find((p) => p.slug === slug)) || ov.pages[0] + // A route with no slug — or a slug that no longer exists — lands on the wiki's + // own front door: docs/wiki/index.md. The catalog is authored, so it is the + // page that says what this knowledge base is; opening whichever page happened + // to sort first never did. + const current = slug ? ov.pages.find((p) => p.slug === slug) : undefined return (
- + {current ? ( + + ) : ov.has_index ? ( + + ) : ( + + )}
) } +const WIKI_INDEX_PATH = 'docs/wiki/index.md' + +// WikiIndexPane renders docs/wiki/index.md as the wiki's home page: the author's +// own prose and categories, not a landing page synthesised from the read model. +// Its entries point at files on disk, so they are rewritten into app routes +// before rendering — the catalog has to navigate. +function WikiIndexPane({ all, version }: { all: WikiOverview; version: number }) { + const [text, setText] = useState(null) + const [err, setErr] = useState(null) + + useEffect(() => { + setText(null) + setErr(null) + api + .file(WIKI_INDEX_PATH) + .then((f) => setText(f.text ?? '')) + .catch((e) => setErr(String(e))) + }, [version]) + + const known = useMemo(() => new Set(all.pages.map((p) => p.slug)), [all]) + const { title, body } = useMemo(() => splitLeadingHeading(text ?? ''), [text]) + const rendered = useMemo(() => rewriteIndexEntries(rewriteRefTokens(body), known), [body, known]) + const unlisted = all.pages.filter((p) => !p.in_index) + + return ( +
+
+

{title || 'Wiki'}

+
+ + {all.pages.length} page{all.pages.length === 1 ? '' : 's'} + + {unlisted.length > 0 && {unlisted.length} not in index.md} +
+
+ +
+ {err ? ( +
{err}
+ ) : text == null ? ( +
Loading…
+ ) : ( + + )} +
+ +
+ {unlisted.length > 0 && ( +
+
Not in the index
+ {unlisted.map((p) => ( + + {p.title} + + ))} +
+ )} +
+ {WIKI_INDEX_PATH} +
+
+
+ ) +} + +// The pane header carries the title, so a leading `# Heading` is lifted out of +// the body rather than rendered a second time under it. +function splitLeadingHeading(text: string): { title: string; body: string } { + const m = /^\s*#\s+(.+?)[ \t]*(?:\n|$)/.exec(text) + if (!m) return { title: '', body: text } + return { title: m[1], body: text.slice(m[0].length) } +} + +// index.md links a page as `[Title](pages/.md)` because it is a real file +// beside them. In the dashboard those have to be routes: a slug that exists +// becomes an in-app link; one that does not becomes a citation token, so a stale +// entry renders as the broken reference it is instead of a dead file link. +const INDEX_ENTRY_RE = /\]\(\s*(?:\.\/)?(?:[^)\s]*\/)?pages\/([^)\s/]+)\.md\s*\)/g + +function rewriteIndexEntries(markdown: string, known: Set): string { + return markdown.replace(INDEX_ENTRY_RE, (_m, slug: string) => + known.has(slug) ? `](${href('wiki', slug)})` : `](ref:[[${slug}]])`, + ) +} + function WikiPagePane({ page, all, version }: { page: WikiPage; all: WikiOverview; version: number }) { const [text, setText] = useState(null) const [err, setErr] = useState(null)