Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

377 lines
10 KiB
TypeScript

/**
* Utility functions for LaTeX processing
*
* remark-math only supports $...$ and $$...$$ delimiters by default.
* Many LLMs output LaTeX using \(...\) and \[...\] delimiters.
* This utility converts between formats.
*/
/**
* Convert LaTeX delimiters from \(...\) and \[...\] to $...$ and $$...$$
* This makes the content compatible with remark-math for ReactMarkdown rendering.
*
* @param content - The content containing LaTeX with \(...\) or \[...\] delimiters
* @returns Content with $...$ and $$...$$ delimiters
*/
export function convertLatexDelimiters(content: string): string {
if (!content) return content;
let result = content;
// editor.md examples sometimes wrap \( ... \) inside $$ ... $$.
// In that case the inner delimiters should be stripped rather than rewrapped.
result = result.replace(
/\$\$\s*\\\(([\s\S]*?)\\\)\s*\$\$/g,
(_match, expr) => {
return `\n$$\n${expr}\n$$\n`;
},
);
// Convert \[...\] to $$...$$ (block math).
// Use a regex that handles multiline content
// Note: In JSON strings, \[ becomes \\[ which in JS becomes \[
result = result.replace(/\\\[([\s\S]*?)\\\]/g, (_match, expr) => {
return `\n$$\n${expr}\n$$\n`;
});
// Convert \(...\) to $...$ (inline math).
// Be careful not to match escaped parentheses in other contexts
result = result.replace(/\\\(([\s\S]*?)\\\)/g, (_match, expr) => {
return ` $${expr}$ `;
});
// Also handle cases where LaTeX is directly in the text without proper delimiters
// e.g., standalone \lim, \frac, etc. that should be wrapped
// This is a common issue with LLM outputs
// Clean up multiple consecutive newlines
result = result.replace(/\n{3,}/g, "\n\n");
return result;
}
function normalizeEditorMdHeadings(content: string): string {
return content.replace(/^(#{1,6})([^#\s])/gm, "$1 $2");
}
const LIKELY_LATEX_BLOCK_RE = /\\[A-Za-z]+|\\\\|[_^&]/;
function looksLikeLatexBlock(lines: string[]): boolean {
const block = lines
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
return block.length > 0 && LIKELY_LATEX_BLOCK_RE.test(block);
}
function normalizeEditorMdInlineMath(content: string): string {
const lines = content.split("\n");
const result: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (trimmed === "$" && i + 1 < lines.length) {
let endIdx = -1;
for (let j = i + 1; j < lines.length; j++) {
if (lines[j].trim() === "$") {
endIdx = j;
break;
}
}
if (endIdx > i + 1 && looksLikeLatexBlock(lines.slice(i + 1, endIdx))) {
result.push("$$");
for (let j = i + 1; j < endIdx; j++) {
result.push(lines[j]);
}
result.push("$$");
i = endIdx;
continue;
}
}
if (
/^\$\$[\s\S]+\$\$$/.test(trimmed) &&
(trimmed.match(/\$\$/g)?.length ?? 0) === 2
) {
const inner = trimmed.slice(2, -2).trim();
result.push(`$$\n${inner}\n$$`);
continue;
}
// editor.md commonly uses $...$ for inline math.
result.push(
line.replace(/\$\$([^$\n]+?)\$\$/g, (_match, expr: string) => {
return `$${expr.trim()}$`;
}),
);
}
return result.join("\n");
}
type HeadingEntry = {
level: number;
text: string;
slug: string;
};
function slugifyHeading(text: string): string {
return text
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-");
}
function collectMarkdownHeadings(content: string): HeadingEntry[] {
const lines = content.split("\n");
const headings: HeadingEntry[] = [];
let inFence = false;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const trimmed = line.trim();
if (/^```/.test(trimmed)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const atxMatch = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
if (atxMatch) {
const text = atxMatch[2].replace(/\s+#+\s*$/, "").trim();
const slug = slugifyHeading(text);
if (slug) headings.push({ level: atxMatch[1].length, text, slug });
continue;
}
const next = lines[i + 1]?.trim();
if (!trimmed || !next) continue;
if (/^=+$/.test(next)) {
const slug = slugifyHeading(trimmed);
if (slug) headings.push({ level: 1, text: trimmed, slug });
i += 1;
continue;
}
if (/^-+$/.test(next)) {
const slug = slugifyHeading(trimmed);
if (slug) headings.push({ level: 2, text: trimmed, slug });
i += 1;
}
}
return headings;
}
function buildTableOfContents(headings: HeadingEntry[]): string {
if (headings.length === 0) return "";
return headings
.map(({ level, text, slug }) => {
const indent = " ".repeat(Math.max(level - 1, 0));
return `${indent}- [${text}](#${slug})`;
})
.join("\n");
}
function injectEditorMdTableOfContents(content: string): string {
const headings = collectMarkdownHeadings(content);
if (headings.length === 0) {
return content.replace(/^\[TOCM?\]\s*$/gim, "");
}
const toc = buildTableOfContents(headings);
return content.replace(/^\[TOCM?\]\s*$/gim, toc);
}
export function convertFlowFenceToMermaid(source: string): string | null {
const lines = source
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) return null;
const nodeDefs: string[] = [];
const edges: string[] = [];
const renderNode = (id: string, type: string, label: string): string => {
const safeLabel = label.replace(/\|/g, "\\|");
switch (type) {
case "start":
case "end":
return `${id}([${safeLabel}])`;
case "condition":
return `${id}{${safeLabel}}`;
case "inputoutput":
return `${id}[/${safeLabel}/]`;
case "subroutine":
return `${id}[[${safeLabel}]]`;
case "database":
return `${id}[(${safeLabel})]`;
default:
return `${id}[${safeLabel}]`;
}
};
for (const line of lines) {
const defMatch =
/^([A-Za-z][\w-]*)(?:=>|=)(start|end|operation|condition|inputoutput|subroutine|database):\s*(.+)$/.exec(
line,
);
if (defMatch) {
const [, id, type, label] = defMatch;
nodeDefs.push(` ${renderNode(id, type, label)}`);
continue;
}
if (!line.includes("->")) continue;
const parts = line
.split("->")
.map((part) => part.trim())
.filter(Boolean);
for (let i = 0; i < parts.length - 1; i += 1) {
const fromMatch = /^([A-Za-z][\w-]*)(?:\(([^)]+)\))?$/.exec(parts[i]);
const toMatch = /^([A-Za-z][\w-]*)(?:\(([^)]+)\))?$/.exec(parts[i + 1]);
if (!fromMatch || !toMatch) continue;
const [, fromId, fromAnnotation] = fromMatch;
const [, toId] = toMatch;
// flowchart.js puts branch labels on the source side (`cond(yes)->x`);
// pure layout hints (`op(right)->x`) are not labels.
const branch = fromAnnotation?.split(",")[0]?.trim();
const label =
branch && !/^(left|right|top|bottom)$/i.test(branch)
? `|${branch}|`
: "";
edges.push(` ${fromId} -->${label} ${toId}`);
}
}
if (nodeDefs.length === 0 || edges.length === 0) return null;
return ["flowchart TD", ...nodeDefs, ...edges].join("\n");
}
export function convertSequenceFenceToMermaid(source: string): string | null {
const lines = source
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) return null;
const isSequenceDirective = (line: string): boolean => {
return (
/^Note\s+(left|right)\s+of\s+/i.test(line) ||
/^participant\s+/i.test(line) ||
/^(title|autonumber|activate|deactivate|loop|rect|opt|alt|par|critical|break|box|create|destroy)\b/i.test(
line,
) ||
/^([A-Za-z][\w.-]*)(?:-{1,2}>>?|--?>)([A-Za-z][\w.-]*)\s*:\s*.+$/.test(
line,
)
);
};
const normalizedLines: string[] = [];
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (/^Note\s+(left|right)\s+of\s+/i.test(line)) {
let combined = line;
while (i + 1 < lines.length && !isSequenceDirective(lines[i + 1])) {
combined += `\\n${lines[i + 1]}`;
i += 1;
}
normalizedLines.push(combined);
continue;
}
normalizedLines.push(line);
}
const converted = normalizedLines.map((line) => {
if (/^Note\s+(left|right)\s+of\s+/i.test(line)) {
return ` ${line.replace(/\\n/g, "<br/>")}`;
}
const messageMatch =
/^([A-Za-z][\w.-]*)(-{1,2}>>?|--?>)([A-Za-z][\w.-]*)\s*:\s*(.+)$/.exec(
line,
);
if (messageMatch) {
const [, from, operator, to, message] = messageMatch;
const arrow =
operator === "--" || operator === "-->"
? "-->>"
: operator === "->>" || operator === "-->>"
? operator
: "->>";
return ` ${from}${arrow}${to}: ${message}`;
}
return ` ${line}`;
});
return ["sequenceDiagram", ...converted].join("\n");
}
function convertEditorMdFences(content: string): string {
return content.replace(
/```(flow|seq|sequence)\s*\n([\s\S]*?)```/g,
(_match, lang: string, body: string) => {
const converted =
lang === "flow"
? convertFlowFenceToMermaid(body)
: convertSequenceFenceToMermaid(body);
if (!converted) return `\`\`\`${lang}\n${body}\`\`\``;
return `\`\`\`mermaid\n${converted}\n\`\`\``;
},
);
}
/**
* Process content for ReactMarkdown rendering with proper LaTeX support
* This is a convenience wrapper that applies all necessary transformations.
*
* @param content - The raw content to process
* @returns Processed content ready for ReactMarkdown with remark-math
*/
export function processLatexContent(content: string): string {
if (!content) return "";
// Convert to string if not already
const str = String(content);
// Apply delimiter conversion
return convertLatexDelimiters(str);
}
export function processMarkdownContent(content: string): string {
if (!content) return "";
let result = String(content);
result = normalizeEditorMdHeadings(result);
result = normalizeEditorMdInlineMath(result);
result = convertEditorMdFences(result);
result = injectEditorMdTableOfContents(result);
result = convertLatexDelimiters(result);
result = result.replace(/\n{3,}/g, "\n\n");
return result;
}