返回 DeepSeek-Reasonix
remarkMathPolicy.ts
根目录 / desktop / frontend / src / components / remarkMathPolicy.ts
1 import type { Root, Text } from "mdast";
2 import type { InlineMath } from "mdast-util-math";
3 import { visit } from "unist-util-visit";
4 import { classifyInlineMath } from "./mathClassify";
5 import { latexNormalizeForKatex } from "./latexNormalize";
6 import {
7 resolveProtectedInlineMathSource,
8 restoreProtectedInlineMathSource,
9 } from "./mathNormalize";
10
11 type HastLikeNode = {
12 type: string;
13 value?: string;
14 children?: HastLikeNode[];
15 };
16
17 type MathNodeData = {
18 hChildren?: HastLikeNode[];
19 hProperties?: Record<string, unknown>;
20 };
21
22 type ParentWithChildren = {
23 children: Array<{ type: string; value?: unknown; position?: unknown }>;
24 };
25
26 type VFileLike = {
27 value: unknown;
28 };
29
30 function siblingText(parent: ParentWithChildren, index: number, offset: -1 | 1): string {
31 const sibling = parent.children[index + offset];
32 if (sibling?.type !== "text" || typeof sibling.value !== "string") return "";
33 return offset < 0 ? sibling.value.slice(-120) : sibling.value.slice(0, 120);
34 }
35
36 function stripAdjacentMarkdown(text: string, side: -1 | 1): string {
37 let current = text;
38 while (true) {
39 const next = side < 0
40 ? current.replace(/(?:[*_~]{1,3}|\[)\s*$/, "")
41 : current.replace(/^\s*(?:[*_~]{1,3}|\])/, "");
42 if (next === current) return current;
43 current = next;
44 }
45 }
46
47 function inlineMathContext(
48 node: InlineMath,
49 file: VFileLike,
50 parent: ParentWithChildren,
51 index: number,
52 ): { before: string; after: string } {
53 const source = String(file.value ?? "");
54 const start = node.position?.start.offset;
55 const end = node.position?.end.offset;
56 if (typeof start === "number" && typeof end === "number") {
57 return {
58 before: stripAdjacentMarkdown(source.slice(Math.max(0, start - 120), start), -1),
59 after: stripAdjacentMarkdown(source.slice(end, end + 120), 1),
60 };
61 }
62 return {
63 before: siblingText(parent, index, -1),
64 after: siblingText(parent, index, 1),
65 };
66 }
67
68 function originalSource(node: InlineMath, file: VFileLike): string {
69 const source = String(file.value ?? "");
70 const start = node.position?.start.offset;
71 const end = node.position?.end.offset;
72 if (typeof start === "number" && typeof end === "number") {
73 const span = source.slice(start, end);
74 if (span.startsWith("$") && span.endsWith("$")) {
75 return `$${restoreProtectedInlineMathSource(span.slice(1, -1))}$`;
76 }
77 return restoreProtectedInlineMathSource(span);
78 }
79 return `$${restoreProtectedInlineMathSource(node.value)}$`;
80 }
81
82 function inlineMathSources(node: InlineMath, file: VFileLike): {
83 source: string;
84 rendered: string;
85 } {
86 const parsed = resolveProtectedInlineMathSource(node.value);
87 const fileSource = String(file.value ?? "");
88 const start = node.position?.start.offset;
89 const end = node.position?.end.offset;
90 if (typeof start !== "number" || typeof end !== "number") return parsed;
91
92 const span = fileSource.slice(start, end);
93 if (!span.startsWith("$") || !span.endsWith("$")) return parsed;
94 return {
95 source: resolveProtectedInlineMathSource(span.slice(1, -1)).source,
96 rendered: parsed.rendered,
97 };
98 }
99
100 function setMathValue(
101 node: { value: string; data?: unknown },
102 source: string,
103 value: string,
104 ): void {
105 node.value = value;
106
107 // mdast-util-math caches hast children while parsing. Keep that rendering
108 // payload in sync with node.value when a later remark plugin normalises it,
109 // while carrying the unnormalised TeX through to the rehype stage.
110 const data = node.data as MathNodeData | undefined;
111 if (data) {
112 data.hProperties ??= {};
113 data.hProperties.dataLatexSource = source;
114 }
115 const hChildren = data?.hChildren;
116 const updateFirstText = (children: HastLikeNode[] | undefined): boolean => {
117 if (!children) return false;
118 for (const child of children) {
119 if (child.type === "text") {
120 child.value = value;
121 return true;
122 }
123 if (updateFirstText(child.children)) return true;
124 }
125 return false;
126 };
127 updateFirstText(hChildren);
128 }
129
130 /**
131 * Semantic policy layered after remark-math has parsed Markdown boundaries.
132 * Code spans/fences never become math nodes, so this plugin only decides how
133 * already-parsed inline math should render.
134 */
135 export function remarkMathPolicy() {
136 return (tree: Root, file: VFileLike) => {
137 visit(tree, "inlineMath", (node, index, parent) => {
138 if (typeof index !== "number" || !parent) return;
139
140 const typedNode = node as InlineMath;
141 const typedParent = parent as ParentWithChildren;
142 const { source, rendered } = inlineMathSources(typedNode, file);
143 const classificationSource = source.trim();
144 const decision = classifyInlineMath(
145 classificationSource,
146 inlineMathContext(typedNode, file, typedParent, index),
147 );
148
149 if (decision === "math") {
150 setMathValue(typedNode, source, latexNormalizeForKatex(rendered));
151 return;
152 }
153
154 const value = decision === "currency"
155 ? `$${classificationSource}`
156 : originalSource(typedNode, file);
157 typedParent.children[index] = { type: "text", value } satisfies Text;
158 });
159
160 visit(tree, "math", (node) => {
161 const { source, rendered } = resolveProtectedInlineMathSource(node.value);
162 setMathValue(node, source, latexNormalizeForKatex(rendered));
163 });
164 };
165 }
166
166 lines TYPESCRIPT