unresolved / unsafe target
//
// Two reasons this lives outside the viewer .vue:
//
// 1. The previous regex-based substitution was unsafe (interpolated raw
// target into HTML attributes, didn't skip code blocks, guessed slugs by
// lower-casing titles). Putting the new logic in a pure helper lets the
// 6 safety cases get covered by ordinary unit tests instead of mounting
// the whole Vue component.
// 2. The DOM walker has to skip code/pre/kbd/samp subtrees. That's the only
// "code block protection" needed once markdown has already produced
// proper wrappers — no string-level sentinel substitution.
/**
* Lightweight {slug, title, archived} entry. Shape mirrors the backend
* `PageRef` DTO and the store's `WikiPageRef`. Kept local to avoid creating
* a build dependency from this file onto the Pinia store.
*/
export interface WikilinkRef {
slug: string
title: string
archived?: boolean
}
/** Result of resolving a single `[[...]]` target string. */
export type WikilinkResolution =
| { kind: 'hit'; slug: string; display: string; archived: boolean }
| { kind: 'broken'; display: string; reason: 'empty' | 'dangerous' | 'too-long' | 'unknown' }
/**
* Tags whose contents must not be touched. `` and `` cover fenced
* and inline code blocks emitted by marked; `` and `` are listed
* for completeness so authors can show literal wikilink syntax in docs
* without it being silently rewritten.
*/
const SKIP_TAGS = new Set(['PRE', 'CODE', 'KBD', 'SAMP'])
/**
* Characters that turn a wikilink into an attribute-injection or HTML-context
* escape risk. The list is intentionally narrow — slug values can legitimately
* contain CJK and `-`, so we reject only what is unambiguously dangerous (HTML
* delimiters, quote characters, backtick, line breaks, C0 / DEL control bytes).
*
* 0x00–0x1F (excluding TAB which is rare in slugs anyway) and 0x7F catch the
* NUL / control-char family that broke an earlier draft of this RFC document
* when someone wrote them literally instead of as escape text. If a real slug
* needs a tab character, that is a backend bug worth surfacing.
*/
// eslint-disable-next-line no-control-regex
const DANGEROUS_CHAR_RE = /[<>"'`\n\r\x00-\x1F\x7F]/
/** Slug length cap. Backend `toSlug` produces slugs well under this. */
const MAX_SLUG_LEN = 256
/** `[[...]]` matcher used during text-node walking. Non-greedy. */
const WIKILINK_RE = /\[\[([^\]]+?)\]\]/g
/**
* Resolve a single raw target into a render directive.
*
* The function is pure: no DOM access, no store reads. Tests drive it with
* synthetic `refs` arrays to verify each of the six safety cases enumerated in
* the RFC (script tag, double quote, nested brackets, multi `|`, empty, oversize).
*
* Resolution order:
* 1. Empty / dangerous / oversize → broken span, raw never enters output
* attributes. The visible text falls back to the original `[[...]]`
* literal so users can spot the malformed content.
* 2. Exact slug match against active refs.
* 3. Title match (trim + case-insensitive) against active refs.
* 4. Same two passes against archived refs — hit renders as archived.
* 5. Otherwise broken.
*
* Title-fallback is kept because the RFC's migration plan allows older content
* that still writes `[[Page Title]]` to keep resolving for six months while
* the slug-first prompt rollout (Phase 3) replaces it. Once that window closes
* the title branch can be deleted without any other code change.
*/
export function resolveWikilink(
raw: string,
refs: WikilinkRef[],
archivedRefs: WikilinkRef[] = [],
): WikilinkResolution {
const rawTrimmed = (raw ?? '').trim()
const literal = `[[${raw ?? ''}]]`
if (!rawTrimmed) {
return { kind: 'broken', display: literal, reason: 'empty' }
}
if (DANGEROUS_CHAR_RE.test(rawTrimmed)) {
return { kind: 'broken', display: literal, reason: 'dangerous' }
}
// Split [[target|display]] form. Only the first `|` is honoured; any extras
// are kept verbatim in the display text and trigger the dangerous-char path
// only if they collide with the rejection set (they don't, `|` is allowed).
//
// `explicitDisplay` is the empty string when the source uses the bare
// `[[target]]` form. In that case the visible label falls back to the
// resolved page's title (more readable than the slug). When the source
// explicitly overrides via `|`, that override always wins.
const pipeIdx = rawTrimmed.indexOf('|')
const target = pipeIdx >= 0 ? rawTrimmed.slice(0, pipeIdx).trim() : rawTrimmed
const explicitDisplay = pipeIdx >= 0 ? rawTrimmed.slice(pipeIdx + 1).trim() : ''
if (!target) {
return { kind: 'broken', display: literal, reason: 'empty' }
}
if (target.length > MAX_SLUG_LEN) {
return { kind: 'broken', display: literal, reason: 'too-long' }
}
const lookupSlug = target.toLowerCase()
const lookupTitle = target.trim().toLowerCase()
for (const ref of refs) {
if (ref.slug.toLowerCase() === lookupSlug) {
return { kind: 'hit', slug: ref.slug, display: explicitDisplay || ref.title, archived: false }
}
}
for (const ref of refs) {
if (ref.title.trim().toLowerCase() === lookupTitle) {
return { kind: 'hit', slug: ref.slug, display: explicitDisplay || ref.title, archived: false }
}
}
for (const ref of archivedRefs) {
if (ref.slug.toLowerCase() === lookupSlug) {
return { kind: 'hit', slug: ref.slug, display: explicitDisplay || ref.title, archived: true }
}
}
for (const ref of archivedRefs) {
if (ref.title.trim().toLowerCase() === lookupTitle) {
return { kind: 'hit', slug: ref.slug, display: explicitDisplay || ref.title, archived: true }
}
}
return { kind: 'broken', display: literal, reason: 'unknown' }
}
/**
* Build the DOM element for a resolution result.
*
* Always uses `document.createElement` + `textContent` + `setAttribute`. No
* `innerHTML` writes anywhere — the previous regex-based substitution path
* concatenated raw target strings into HTML and was the original source of
* the bug class this RFC closes.
*/
function buildLinkElement(
doc: Document,
resolution: WikilinkResolution,
): HTMLElement {
if (resolution.kind === 'hit') {
const a = doc.createElement('a')
a.className = resolution.archived ? 'wiki-link wiki-link-archived' : 'wiki-link'
a.setAttribute('data-slug', resolution.slug)
// No href: the page viewer hooks click via the global `wiki-link` listener
// and routes through the Pinia store. Adding a real href would expose the
// app to middle-click "open in new tab" 404s since the route layer is SPA.
a.setAttribute('role', 'link')
a.setAttribute('tabindex', '0')
if (resolution.archived) {
a.setAttribute('title', 'Archived page')
}
a.textContent = resolution.display
return a
}
const span = doc.createElement('span')
span.className = 'wiki-link-broken'
span.setAttribute('title', `Target not found (${resolution.reason})`)
span.textContent = resolution.display
return span
}
/**
* Walk the rendered article DOM and replace every `[[...]]` token inside a
* text node with the appropriate `` or `` element.
*
* The walker uses {@link TreeWalker} with `NodeFilter.SHOW_TEXT` so we only
* ever look at text nodes — element nodes and their attributes are not even
* candidates for substitution. The filter additionally rejects any text node
* whose ancestor chain crosses a {@link SKIP_TAGS} element, so code blocks,
* inline code and the other docstring-style tags remain literal.
*
* The function is idempotent: text nodes that no longer match `[[...]]` are
* skipped, and previously-inserted ``/`` elements have no text-node
* children carrying the original syntax (it is consumed by the regex split).
*/
export function postprocessWikilinks(
root: HTMLElement,
resolver: (raw: string) => WikilinkResolution,
doc: Document = root.ownerDocument ?? document,
): void {
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
// Reject text nodes inside any of the skip tags. Walking ancestors is
// cheap because the markdown tree depth is bounded by Marked's grammar.
let parent: Node | null = node.parentNode
while (parent && parent !== root) {
if (parent.nodeType === 1 /* ELEMENT_NODE */) {
const tag = (parent as Element).tagName
if (SKIP_TAGS.has(tag)) return NodeFilter.FILTER_REJECT
}
parent = parent.parentNode
}
return WIKILINK_RE.test(node.nodeValue ?? '')
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT
},
})
// Collect first, mutate second — mutating the tree while walking it would
// skip siblings or revisit nodes.
const targets: Text[] = []
let cur = walker.nextNode()
while (cur) {
targets.push(cur as Text)
cur = walker.nextNode()
}
for (const textNode of targets) {
const original = textNode.nodeValue ?? ''
// Reset regex state — the regex is module-level with /g, so `lastIndex`
// carries over between text nodes if we don't.
WIKILINK_RE.lastIndex = 0
const fragment = doc.createDocumentFragment()
let lastIdx = 0
let match: RegExpExecArray | null
while ((match = WIKILINK_RE.exec(original)) !== null) {
if (match.index > lastIdx) {
fragment.appendChild(doc.createTextNode(original.slice(lastIdx, match.index)))
}
const resolution = resolver(match[1])
fragment.appendChild(buildLinkElement(doc, resolution))
lastIdx = match.index + match[0].length
}
if (lastIdx < original.length) {
fragment.appendChild(doc.createTextNode(original.slice(lastIdx)))
}
textNode.parentNode?.replaceChild(fragment, textNode)
}
}