/** * Inline syntax the visual engine reads on top of CommonMark and GFM (spec * R-FMT-1, R-INL-1, R-INL-6, R-INL-7): * * - `==highlight==`, as the selection toolbar writes it; * - `underline`, the HTML pair that renders as underline everywhere; * - `[[Page]]` and `[[Page#heading-slug]]` wiki links; * - links to a paper in the project library, which edit as citation chips. * * CommonMark reads the first three as text or inline HTML, so they are found * in the parsed phrasing afterwards, and only where the source spells them * literally: an escaped `\==` or `\[\[` stays text. * * Clean implementation for Lattice; spec: docs/visual-editor-spec.md. */ import type { PhrasingContent } from "mdast"; type Position = { start: { offset?: number }; end: { offset?: number } }; /** Phrasing as parsed, plus this module's own node kinds. */ export type Phrasing = { type: string; value?: string; children?: Phrasing[]; position?: Position; [key: string]: unknown }; const at = (start: number, end: number): Position => ({ start: { offset: start }, end: { offset: end } }); const startOf = (node: Phrasing) => node.position?.start.offset; const endOf = (node: Phrasing) => node.position?.end.offset; /** A library paper's reading file, as a citation links it (R-FMT-17, §11.10). */ export const PAPER_HREF = /^(?:\.\.\/)*\.research\/papers\/[^/\s]+\/(?:paper|blog)\.md$/; /** * The phrasing of one parent, with highlight, underline and wiki links * recognized. Nested phrasing is extended when the reader reaches it. */ export function extendPhrasing(children: PhrasingContent[], source: string): PhrasingContent[] { let nodes = children as unknown as Phrasing[]; nodes = underlines(nodes); nodes = wikiLinks(nodes, source); nodes = highlights(nodes, source); return nodes as unknown as PhrasingContent[]; } /** A text node whose source is exactly its value: no escapes or references to misplace offsets. */ function literalText(node: Phrasing, source: string): boolean { const from = startOf(node); const to = endOf(node); return node.type === "text" && from != null && to != null && source.slice(from, to) === node.value; } const textPiece = (value: string, start: number): Phrasing => ({ type: "text", value, position: at(start, start + value.length) }); /** `` … `` among one parent's children becomes an underline around them. */ function underlines(nodes: Phrasing[]): Phrasing[] { const result: Phrasing[] = []; for (let index = 0; index < nodes.length; index += 1) { const node = nodes[index]!; if (node.type === "html" && /^$/i.test(node.value ?? "")) { const close = nodes.findIndex((candidate, position) => position > index && candidate.type === "html" && /^<\/u>$/i.test(candidate.value ?? "")); if (close > index + 1) { const from = startOf(node); const to = endOf(nodes[close]!); result.push({ type: "latticeUnderline", children: nodes.slice(index + 1, close), position: from != null && to != null ? at(from, to) : undefined }); index = close; continue; } } result.push(node); } return result; } const WIKI = /\[\[([^[\]\n|]+)\]\]/g; /** `[[Page]]` in literal text becomes a wiki link. */ function wikiLinks(nodes: Phrasing[], source: string): Phrasing[] { const result: Phrasing[] = []; for (const node of nodes) { if (!literalText(node, source) || !node.value!.includes("[[")) { result.push(node); continue; } const value = node.value!; const base = startOf(node)!; let cursor = 0; for (const match of value.matchAll(WIKI)) { const target = match[1]!.trim(); if (!target) continue; if (match.index > cursor) result.push(textPiece(value.slice(cursor, match.index), base + cursor)); result.push({ type: "latticeWiki", target, value: match[0], position: at(base + match.index, base + match.index + match[0].length) }); cursor = match.index + match[0].length; } if (cursor < value.length) result.push(cursor ? textPiece(value.slice(cursor), base + cursor) : node); } return result; } type Token = { node: Phrasing } | { delimiter: number; before: string; after: string }; /** * `==text==` becomes a highlight: an opening `==` followed by a non-space, a * closing one after a non-space, neither part of a longer run of `=`. */ function highlights(nodes: Phrasing[], source: string): Phrasing[] { if (!nodes.some((node) => literalText(node, source) && node.value!.includes("=="))) return nodes; const tokens: Token[] = []; nodes.forEach((node, index) => { if (!literalText(node, source) || !node.value!.includes("==")) { tokens.push({ node }); return; } const value = node.value!; const base = startOf(node)!; let cursor = 0; for (const match of value.matchAll(/=+/g)) { if (match[0].length !== 2) continue; if (match.index > cursor) tokens.push({ node: textPiece(value.slice(cursor, match.index), base + cursor) }); const before = match.index > 0 ? value[match.index - 1]! : nodes[index - 1] ? "x" : ""; const afterIndex = match.index + 2; const after = afterIndex < value.length ? value[afterIndex]! : nodes[index + 1] ? "x" : ""; tokens.push({ delimiter: base + match.index, before, after }); cursor = afterIndex; } if (cursor < value.length) tokens.push({ node: textPiece(value.slice(cursor), base + cursor) }); }); const result: Phrasing[] = []; let opener = -1; const flush = (upTo: number, from: number) => { for (let index = from; index < upTo; index += 1) { const token = tokens[index]!; result.push("node" in token ? token.node : textPiece("==", token.delimiter)); } }; let emitted = 0; tokens.forEach((token, index) => { if ("node" in token) return; const opens = token.after !== "" && !/\s/.test(token.after); const closes = token.before !== "" && !/\s/.test(token.before); if (opener >= 0 && closes && index > opener + 1) { flush(opener, emitted); const open = tokens[opener] as { delimiter: number }; const inner: Phrasing[] = []; for (let position = opener + 1; position < index; position += 1) { const item = tokens[position]!; inner.push("node" in item ? item.node : textPiece("==", item.delimiter)); } result.push({ type: "latticeHighlight", children: inner, position: at(open.delimiter, token.delimiter + 2) }); emitted = index + 1; opener = -1; } else if (opens) { opener = index; } }); flush(tokens.length, emitted); return mergeText(result); } /** Adjacent text pieces read as one text node, as the parser gives them. */ function mergeText(nodes: Phrasing[]): Phrasing[] { const result: Phrasing[] = []; for (const node of nodes) { const previous = result[result.length - 1]; if (previous?.type === "text" && node.type === "text" && endOf(previous) === startOf(node)) { result[result.length - 1] = { ...previous, value: `${previous.value}${node.value}`, position: at(startOf(previous)!, endOf(node)!) }; } else { result.push(node); } } return result; }