返回 DeepSeek-Reasonix
markdownPipeline.ts
根目录 / desktop / frontend / src / lib / markdownPipeline.ts
1 // markdownPipeline — the isomorphic Markdown→HAST parse pipeline (Phase E of
2 // the history refactor). It runs the EXACT processor chain react-markdown uses
3 // in MarkdownRenderer, factored into a side-effect-free function that runs both
4 // on the main thread (jsdom/tests, Worker-less fallback) and inside
5 // markdown.worker.ts:
6 //
7 // normalizeMath pre-pass
8 // → remark-parse + remarkGfm + remarkMath + remarkMathPolicy + remarkLocalPathLinks
9 // → remark-rehype (allowDangerousHtml, same as react-markdown's default)
10 // → rehypeReasonixKatex
11 // → react-markdown's post-transform (raw → text, urlTransform on URL attrs)
12 //
13 // The document is parsed WHOLE so definitions, footnotes, and reference links
14 // resolve globally; sliceHastBlocks then cuts the resulting HAST top-level
15 // children into renderable blocks (the remark-rehype footnote section stays its
16 // own trailing block). Concatenating the rendered blocks is byte-identical to
17 // rendering the unsliced root.
18 //
19 // This module must stay free of DOM/React imports so the inline worker bundle
20 // contains no component code.
21
22 import { unified } from "unified";
23 import { VFile } from "vfile";
24 import remarkParse from "remark-parse";
25 import remarkRehype from "remark-rehype";
26 import { urlAttributes } from "html-url-attributes";
27 import { visit } from "unist-util-visit";
28 import type { Element as HastElement, Root as HastRoot, RootContent as HastRootContent } from "hast";
29 import { normalizeMath } from "../components/mathNormalize";
30 import { reasonixRemarkPlugins } from "../components/markdownRemarkPlugins";
31 import { reasonixRehypePlugins } from "../components/rehypeReasonixKatex";
32 import {
33 extractLargePlainMarkdownTables,
34 type VirtualMarkdownTableData,
35 } from "./largeMarkdownTable";
36 import { isLocalFileHref } from "./localFileUrl";
37 import { contentRevision } from "./contentRevision";
38 import { markdownSelectionTextFromBlocks } from "./markdownSelectionProjection";
39 export { estimateHastBytes } from "./markdownByteEstimate";
40
41 export type { HastRoot, HastRootContent };
42
43 export interface MarkdownBlock {
44 /** Stable within one parse: the block's top-level index. */
45 key: string;
46 children: HastRootContent[];
47 /** Lightweight representation for a large table with plain-text cells. */
48 virtualTable?: VirtualMarkdownTableData;
49 /**
50 * Content identity of this block, computed by the parse that produced it.
51 * Two blocks from two parses are the same block when their keys and
52 * fingerprints match, which lets the render path keep the previous AST
53 * object (and therefore native selection and disclosure state) without
54 * serializing either tree. The value is opaque: never interpret it.
55 */
56 fingerprint: number;
57 /** Parsed HAST element count used to bound progressive DOM publication. */
58 elementCount?: number;
59 }
60
61 export interface MarkdownParseResult {
62 blocks: MarkdownBlock[];
63 selectionText: string;
64 selectionRevision: number;
65 }
66
67 const SAFE_PROTOCOL_RE = /^(https?|ircs?|mailto|xmpp)$/i;
68
69 // Mirror of react-markdown's exported defaultUrlTransform (which itself mirrors
70 // micromark-util-sanitize-uri without the encode pass). Copied so the worker
71 // bundle does not pull React in through react-markdown; the pipeline parity
72 // test pins this copy against the original over a URL corpus.
73 export function defaultMarkdownUrlTransform(value: string): string {
74 const colon = value.indexOf(":");
75 const questionMark = value.indexOf("?");
76 const numberSign = value.indexOf("#");
77 const slash = value.indexOf("/");
78
79 if (
80 // If there is no protocol, it’s relative.
81 colon === -1
82 // If the first colon is after a `?`, `#`, or `/`, it’s not a protocol.
83 || (slash !== -1 && colon > slash)
84 || (questionMark !== -1 && colon > questionMark)
85 || (numberSign !== -1 && colon > numberSign)
86 // It is a protocol, it should be allowed.
87 || SAFE_PROTOCOL_RE.test(value.slice(0, colon))
88 ) {
89 return value;
90 }
91
92 return "";
93 }
94
95 // Local file hrefs come from local-path linkification (remarkLocalPathLinks)
96 // or explicit Markdown links and must survive URL sanitisation, which would
97 // otherwise blank them along with javascript: and friends.
98 export function markdownUrlTransform(value: string): string {
99 return isLocalFileHref(value) ? value : defaultMarkdownUrlTransform(value);
100 }
101
102 // Images use a separate protocol policy because their bytes are resolved and
103 // validated by ResolveMarkdownImageForTab before reaching the WebView. Ordinary
104 // links intentionally continue to reject data: URLs.
105 export function markdownImageUrlTransform(value: string): string {
106 if (/^data:image\/(?:png|jpeg|gif|webp)(?:;base64)?,/i.test(value)) return value;
107 return isLocalFileHref(value) ? value : defaultMarkdownUrlTransform(value);
108 }
109
110 // The same transform react-markdown applies between the rehype plugins and
111 // hast-util-to-jsx-runtime (its internal `post()` visitor): raw HTML nodes
112 // become text (skipHtml is false), and every URL attribute passes through
113 // urlTransform. applied here so worker output arrives render-ready.
114 function applyReactMarkdownTransforms(tree: HastRoot): void {
115 visit(tree, (node, index, parent) => {
116 if ((node.type as string) === "raw" && parent && typeof index === "number") {
117 parent.children[index] = { type: "text", value: (node as unknown as { value: string }).value };
118 return index;
119 }
120 if (node.type === "element") {
121 const element = node as HastElement;
122 for (const key in urlAttributes) {
123 const hasAttr = Object.prototype.hasOwnProperty.call(urlAttributes, key)
124 && Object.prototype.hasOwnProperty.call(element.properties, key);
125 if (!hasAttr) continue;
126 const value = element.properties[key];
127 const test = urlAttributes[key as keyof typeof urlAttributes];
128 if (test === null || (test as readonly string[]).includes(element.tagName)) {
129 element.properties[key] = element.tagName === "img" && key === "src"
130 ? markdownImageUrlTransform(String(value || ""))
131 : markdownUrlTransform(String(value || ""));
132 }
133 }
134 }
135 return undefined;
136 });
137 }
138
139 /**
140 * Parse markdown text into a render-ready HAST root using the exact chain the
141 * main-thread renderer uses. Synchronous (unified runSync), DOM-free, safe to
142 * run inside a Web Worker.
143 */
144 export function parseMarkdownToHast(text: string): HastRoot {
145 return parseNormalizedMarkdownToHast(normalizeMath(text));
146 }
147
148 function parseNormalizedMarkdownToHast(normalized: string): HastRoot {
149 const processor = unified()
150 .use(remarkParse)
151 .use(reasonixRemarkPlugins)
152 .use(remarkRehype, { allowDangerousHtml: true })
153 .use(reasonixRehypePlugins);
154 // The same VFile must flow through parse and runSync: remarkMathPolicy
155 // slices original math sources out of file.value by node position.
156 const file = new VFile({ value: normalized });
157 const tree = processor.runSync(processor.parse(file), file) as unknown as HastRoot;
158 applyReactMarkdownTransforms(tree);
159 return tree;
160 }
161
162 const VIRTUAL_TABLE_TAG = "reasonix-virtual-table";
163
164 function injectVirtualTablePlaceholders(
165 root: HastRoot,
166 markerPrefix: string,
167 tableCount: number,
168 ): void {
169 const walk = (node: HastRoot | HastElement): void => {
170 for (let index = 0; index < node.children.length; index += 1) {
171 const child = node.children[index];
172 if (child.type === "element") {
173 const paragraph = child as HastElement;
174 const only = paragraph.tagName === "p" && paragraph.children.length === 1
175 ? paragraph.children[0]
176 : undefined;
177 const value = only?.type === "text" ? only.value : "";
178 if (value.startsWith(markerPrefix)) {
179 const tableIndex = Number(value.slice(markerPrefix.length));
180 if (Number.isInteger(tableIndex) && tableIndex >= 0 && tableIndex < tableCount) {
181 node.children[index] = {
182 type: "element",
183 tagName: VIRTUAL_TABLE_TAG,
184 properties: { dataReasonixTableIndex: tableIndex },
185 children: [],
186 };
187 continue;
188 }
189 }
190 walk(paragraph);
191 }
192 }
193 };
194 walk(root);
195 }
196
197 /**
198 * Slice a parsed HAST root into top-level blocks. Each element child starts a
199 * new block; interstitial whitespace text attaches to the FOLLOWING block so
200 * concatenating the rendered blocks reproduces the unsliced render byte for
201 * byte. Whole-document parsing has already resolved footnote/reference
202 * definitions, and remark-rehype's footnote section arrives as a trailing
203 * top-level element, so it becomes its own trailing block.
204 */
205 /** A block before its parse has stamped a fingerprint. */
206 export type UnfingerprintedBlock = Omit<MarkdownBlock, "fingerprint">;
207
208 export function sliceHastBlocks(root: HastRoot): UnfingerprintedBlock[] {
209 const blocks: UnfingerprintedBlock[] = [];
210 let pending: HastRootContent[] = [];
211 for (const child of root.children) {
212 if (child.type === "element") {
213 blocks.push({ key: `b${blocks.length}`, children: [...pending, child] });
214 pending = [];
215 } else if (blocks.length === 0) {
216 pending.push(child);
217 } else {
218 blocks[blocks.length - 1].children.push(child);
219 }
220 }
221 if (pending.length > 0) {
222 if (blocks.length === 0) blocks.push({ key: "b0", children: pending });
223 else blocks[blocks.length - 1].children.push(...pending);
224 }
225 return blocks;
226 }
227
228 /** Parse + slice in one call (the worker entry point). */
229 export function parseMarkdownToBlocks(text: string): MarkdownBlock[] {
230 const normalized = normalizeMath(text);
231 const extracted = extractLargePlainMarkdownTables(normalized);
232 if (extracted.tables.length === 0) return fingerprintBlocks(sliceHastBlocks(parseNormalizedMarkdownToHast(normalized)));
233
234 const processor = unified()
235 .use(remarkParse)
236 .use(reasonixRemarkPlugins)
237 .use(remarkRehype, { allowDangerousHtml: true })
238 .use(reasonixRehypePlugins);
239 const file = new VFile({ value: extracted.text });
240 const tree = processor.runSync(processor.parse(file), file) as unknown as HastRoot;
241 applyReactMarkdownTransforms(tree);
242 injectVirtualTablePlaceholders(tree, extracted.markerPrefix, extracted.tables.length);
243
244 return fingerprintBlocks(sliceHastBlocks(tree).map((block) => {
245 const placeholder = block.children.find(
246 (child): child is HastElement => child.type === "element" && child.tagName === VIRTUAL_TABLE_TAG,
247 );
248 if (!placeholder) return block;
249 const index = Number(placeholder.properties.dataReasonixTableIndex);
250 return {
251 ...block,
252 children: block.children.filter((child) => child !== placeholder),
253 virtualTable: extracted.tables[index],
254 };
255 }));
256 }
257
258 // 32-bit FNV-1a over a value's JSON-visible shape. Property order is sorted
259 // rather than preserved: both sides of every comparison come from this
260 // function, so what matters is that equal shapes hash equal and different
261 // shapes almost never collide — not that the walk matches JSON.stringify
262 // byte for byte. Values JSON.stringify would drop (`undefined`, functions)
263 // are skipped here for the same reason.
264 function hashValue(hash: number, value: unknown, depth: number): number {
265 if (depth > 64) return hash;
266 if (value === null) return hashText(hash, "null");
267 switch (typeof value) {
268 case "string": return hashText(hash, value);
269 case "number": return hashText(hash, String(value));
270 case "boolean": return hashText(hash, value ? "t" : "f");
271 case "undefined":
272 case "function":
273 return hash;
274 case "object": {
275 if (Array.isArray(value)) {
276 let next = hashText(hash, "[");
277 for (const item of value) next = hashValue(next, item, depth + 1);
278 return hashText(next, "]");
279 }
280 let next = hashText(hash, "{");
281 const record = value as Record<string, unknown>;
282 for (const key of Object.keys(record).sort()) {
283 next = hashValue(hashText(next, key), record[key], depth + 1);
284 }
285 return hashText(next, "}");
286 }
287 default:
288 return hash;
289 }
290 }
291
292 function hashText(hash: number, text: string): number {
293 let next = hash;
294 for (let index = 0; index < text.length; index += 1) {
295 next ^= text.charCodeAt(index);
296 next = Math.imul(next, 0x01000193) >>> 0;
297 }
298 // A separator so `["ab"]` and `["a","b"]` cannot hash alike.
299 next ^= 0x1f;
300 return Math.imul(next, 0x01000193) >>> 0;
301 }
302
303 /** Stamps every block with its content fingerprint. Call once, on final blocks. */
304 export function fingerprintBlocks(blocks: UnfingerprintedBlock[]): MarkdownBlock[] {
305 for (const block of blocks) {
306 let hash = hashText(0x811c9dc5, block.key);
307 for (const child of block.children) hash = hashValue(hash, child, 0);
308 if (block.virtualTable) hash = hashValue(hash, block.virtualTable, 0);
309 (block as MarkdownBlock).fingerprint = hash;
310 (block as MarkdownBlock).elementCount = countHastElements(block.children);
311 }
312 return blocks as MarkdownBlock[];
313 }
314
315 function countHastElements(children: HastRootContent[]): number {
316 let count = 0;
317 const visitChildren = (nodes: HastRootContent[]): void => {
318 for (const node of nodes) {
319 if (node.type !== "element") continue;
320 count += 1;
321 visitChildren(node.children as HastRootContent[]);
322 }
323 };
324 visitChildren(children);
325 return count;
326 }
327
328 /** Parse once and derive both the render tree and copy projection. */
329 export function parseMarkdown(text: string): MarkdownParseResult {
330 const blocks = parseMarkdownToBlocks(text);
331 const selectionText = markdownSelectionTextFromBlocks(blocks);
332 return {
333 blocks,
334 selectionText,
335 selectionRevision: contentRevision(selectionText),
336 };
337 }
338
339 /**
340 * Content-derived cache revision for the transcript markdown cache: an FNV-1a
341 * fingerprint of the source text. Cache entries also store the source itself,
342 * so a (practically impossible) hash collision is caught by comparison.
343 */
344 export function markdownContentRevision(text: string): number {
345 return contentRevision(text);
346 }
347
347 lines TYPESCRIPT