/** * Repair in-document links that lost their target during an import. * * HTML addresses its own sections by an `id` on the heading — Google Docs writes * `

` and links its table of contents to * `#h.2y1ok8y7pef0`. A converter that mints its own block ids and drops the * source ones leaves the links pointing at nothing, and the id itself is gone * for good: it existed only in the source file. * * What survives is the link's own text, because that is how a table of contents * is written — the link says the heading's name. This pass reads that text back * and hands the fragment to the heading it names, as `data.anchor`. * * It WRITES content, so it guesses far less than a render-time fallback may: * only headings with no anchor yet, only an exact text match, and only when * exactly one heading and one fragment claim each other. Anything less certain * is left alone and reported. * * Host-called on purpose — a heuristic that rewrites a document belongs in a * one-off upgrade the host decides to run, not in every load. Expects a * document already in Blok's hierarchical shape (migrate legacy data first). * * parse5-backed and DOM-free, so it runs in a Node script over stored records. */ import { parseFragment } from 'parse5'; import type { DefaultTreeAdapterMap } from 'parse5'; import { normalizeHeadingAnchor } from '../shared/heading-anchor'; import { htmlTextContent } from './html-text'; import type { OutputBlockData, OutputData } from '../../types'; type P5ChildNode = DefaultTreeAdapterMap['childNode']; /** Why a referenced fragment was left as it was. */ export type HeadingAnchorSkipReason = 'no-match' | 'ambiguous'; /** One fragment handed back to the heading that answers to it. */ export interface RestoredHeadingAnchor { /** The fragment, without the leading "#". */ anchor: string; /** Id of the header block it was written onto. */ blockId: string; } /** A fragment the pass refused to place, and why. */ export interface SkippedHeadingAnchor { /** The fragment, without the leading "#". */ anchor: string; /** `no-match` — no heading carries that text; `ambiguous` — more than one candidate. */ reason: HeadingAnchorSkipReason; } /** What one pass did, for a host that wants to log or gate on it. */ export interface HeadingAnchorReport { /** Fragments placed onto a heading, in the order they were referenced. */ restored: RestoredHeadingAnchor[]; /** Dead fragments left alone, in the order they were referenced. */ skipped: SkippedHeadingAnchor[]; } /** The repaired document plus the report for the pass that produced it. */ export interface HeadingAnchorResult { data: OutputData; report: HeadingAnchorReport; } /** * Compare heading text and link text on the only footing they share: their * words. Markup, entities and stray whitespace differ freely between the two * (`КЛН управляющего ` is the same label as the heading's styled span), * but punctuation stays significant — this pass writes data, so it should fail * to match rather than match something else. * @param text - plain text from a heading or a link * @returns a comparable label, '' when there is no text at all */ const normalizeLabel = (text: string): string => text .replace(/[\u200B-\u200D\u2060\uFEFF]/g, '') .replace(/\s+/g, ' ') .trim() .toLowerCase(); /** * Decode a fragment, tolerating a malformed percent-sequence. * @param raw - the fragment, without the leading "#" */ const decodeFragment = (raw: string): string => { try { return decodeURIComponent(raw); } catch { return raw; } }; /** One in-document link: the fragment it points at and the label it shows. */ interface FragmentLink { anchor: string; label: string; } /** * Collect every same-document link in an HTML fragment. * @param html - fragment markup * @param found - accumulator, in document order */ const linkFromNode = (node: P5ChildNode): FragmentLink | null => { if (node.nodeName !== 'a' || !('attrs' in node)) { return null; } const href = node.attrs.find((attr) => attr.name === 'href')?.value ?? ''; // A bare "#" addresses nothing. if (!href.startsWith('#') || href.length === 1) { return null; } return { anchor: decodeFragment(href.slice(1)), label: normalizeLabel(elementText(node)), }; }; const collectLinks = (nodes: P5ChildNode[], found: FragmentLink[]): void => { for (const node of nodes) { const link = linkFromNode(node); if (link !== null) { found.push(link); } if ('childNodes' in node) { collectLinks(node.childNodes, found); } } }; /** * Plain text of a parse5 element, reusing the view's own extraction so entities * and `
` read the same way they do everywhere else. * @param node - element whose text is wanted */ const elementText = (node: P5ChildNode): string => { if (!('childNodes' in node)) { return ''; } const text: string[] = []; const walk = (nodes: P5ChildNode[]): void => { for (const child of nodes) { if (child.nodeName === '#text') { text.push((child as DefaultTreeAdapterMap['textNode']).value); } else if ('childNodes' in child) { walk(child.childNodes); } } }; walk(node.childNodes); return text.join(''); }; /** * Walk every string a block's data holds, wherever it is nested. * * Links live in a paragraph's `text`, a list item's `text`, a table's nested * cell arrays — a per-type field registry would miss whichever shape is added * next, and walking strings costs the same. * @param value - any value from a block's data * @param found - accumulator, in document order */ const collectFromValue = (value: unknown, found: FragmentLink[]): void => { if (typeof value === 'string') { if (value.includes('#')) { collectLinks(parseFragment(value).childNodes, found); } return; } if (Array.isArray(value)) { value.forEach((item) => collectFromValue(item, found)); return; } if (value !== null && typeof value === 'object') { Object.values(value).forEach((item) => collectFromValue(item, found)); } }; /** Whether a block is a heading with usable text and no anchor of its own. */ const isRepairableHeading = (block: OutputBlockData): boolean => block.type === 'header' && block.id !== undefined && normalizeHeadingAnchor(block.data.anchor) === undefined; /** * Hand every dead in-document fragment back to the heading its link names. * * @param data - a saved document in Blok's hierarchical shape * @returns a new document with anchors filled in, and what the pass decided */ export const restoreHeadingAnchors = (data: OutputData): HeadingAnchorResult => { const blocks = data.blocks ?? []; /** * A fragment is live when something already answers to it — a heading's own * anchor, or a block id, which is the other namespace a Blok deep link uses. */ const live = new Set(); blocks.forEach((block) => { const anchor = block.type === 'header' ? normalizeHeadingAnchor(block.data.anchor) : undefined; if (anchor !== undefined) { live.add(anchor); } if (block.id !== undefined) { live.add(block.id); } }); const links: FragmentLink[] = []; blocks.forEach((block) => collectFromValue(block.data, links)); /** Candidate headings by label; a label claimed twice can never be resolved. */ const byLabel = new Map(); blocks.forEach((block) => { if (!isRepairableHeading(block)) { return; } const text = block.data.text; const key = typeof text === 'string' ? normalizeLabel(htmlTextContent(text)) : ''; if (key === '') { return; } byLabel.set(key, [...(byLabel.get(key) ?? []), String(block.id)]); }); const restored: RestoredHeadingAnchor[] = []; const skipped: SkippedHeadingAnchor[] = []; const seen = new Set(); /** Fragments that resolved to the same heading cancel each other out. */ const claimsByBlock = new Map(); links.forEach(({ anchor, label: linkLabel }) => { const normalized = normalizeHeadingAnchor(anchor); if (normalized === undefined || live.has(normalized) || seen.has(normalized)) { return; } seen.add(normalized); const candidates = byLabel.get(linkLabel) ?? []; if (candidates.length === 0) { skipped.push({ anchor: normalized, reason: 'no-match' }); return; } if (candidates.length > 1) { skipped.push({ anchor: normalized, reason: 'ambiguous' }); return; } const blockId = String(candidates[0]); claimsByBlock.set(blockId, [...(claimsByBlock.get(blockId) ?? []), normalized]); }); const placed = new Map(); claimsByBlock.forEach((anchors, blockId) => { if (anchors.length > 1) { anchors.forEach((anchor) => skipped.push({ anchor, reason: 'ambiguous' })); return; } const anchor = String(anchors[0]); placed.set(blockId, anchor); restored.push({ anchor, blockId }); }); return { data: { ...data, blocks: blocks.map((block) => { const anchor = block.id === undefined ? undefined : placed.get(block.id); return anchor === undefined ? block : { ...block, data: { ...block.data, anchor } }; }), }, report: { restored, skipped }, }; };