You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
221 lines
6.6 KiB
221 lines
6.6 KiB
// mermaidSanitizer.js
|
|
export function validateAndFixMermaid(raw) {
|
|
const warnings = [];
|
|
|
|
if (!raw || !String(raw).trim()) {
|
|
return { ok: false, fixedSource: "", warnings };
|
|
}
|
|
|
|
let text = String(raw)
|
|
// HTML 实体
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
// 零宽字符
|
|
.replace(/[\u200b-\u200f\u2028\u202f\u205f\u2060\ufeff]/g, "")
|
|
// 全角符号
|
|
.replace(/[“”]/g, '"')
|
|
.replace(/[‘’]/g, "'")
|
|
.replace(/\u00a0/g, " ")
|
|
// HTML 换行
|
|
.replace(/<br\s*\/?>/gi, "\n")
|
|
// CRLF
|
|
.replace(/\r\n?/g, "\n")
|
|
.trim();
|
|
|
|
// 清理残留的 HTML 标签,但保留 mermaid 语法可能用到的字符
|
|
// 仅移除常见的块级和内联标签,避免误伤 mermaid 节点文本(如 A[<Label>])
|
|
text = text
|
|
.replace(
|
|
/<\/?(div|p|span|br|pre|code|table|tr|td|th|thead|tbody|ul|ol|li|h[1-6]|img)\b[^>]*>/gi,
|
|
""
|
|
)
|
|
.trim();
|
|
|
|
// 移除可能混入的 Markdown 图片语法
|
|
text = text
|
|
.split("\n")
|
|
.filter((line) => !/^\s*!\[[^\]]*]\([^)]+\)\s*$/.test(line))
|
|
.join("\n")
|
|
.trim();
|
|
|
|
// 修复被压成一行的语句,避免多个语句连在一起导致语法错误
|
|
const injectLineBreaks = (value) => {
|
|
let out = String(value || "");
|
|
out = out.replace(/\s+(?=section\b)/gi, "\n");
|
|
out = out.replace(
|
|
/([^\n])\s+(?=[A-Za-z0-9_]+\s*(-->|--|==>|==|<--|<-->)) /g,
|
|
"$1\n"
|
|
);
|
|
out = out.replace(
|
|
/([^\n])\s+(?=[A-Za-z0-9_]+\s*(-->|--|==>|==|<--|<-->))/g,
|
|
"$1\n"
|
|
);
|
|
return out;
|
|
};
|
|
text = injectLineBreaks(text);
|
|
|
|
// 去掉代码围栏
|
|
text = text
|
|
.replace(/^\s*```mermaid\s*/i, "")
|
|
.replace(/^\s*```/gm, "")
|
|
.replace(/^\s*~~~mermaid\s*/i, "")
|
|
.replace(/^\s*~~~/gm, "")
|
|
.trim();
|
|
|
|
const lines = text.split("\n").filter(Boolean);
|
|
if (!lines.length) {
|
|
return { ok: false, fixedSource: "", warnings };
|
|
}
|
|
|
|
// 自动为包含特殊字符(如 |)的节点文本添加引号
|
|
// 处理 [] [[ ]]
|
|
text = text.replace(
|
|
/([A-Za-z0-9_\u4e00-\u9fa5]+)\s*(\[+)([^"\]\n]*\|[^"\]\n]*)(\]+)/g,
|
|
(match, id, open, content, close) => {
|
|
warnings.push(`自动为包含特殊字符的节点添加引号: ${id}`);
|
|
return `${id}${open}"${content}"${close}`;
|
|
}
|
|
);
|
|
// 处理 () (( ))
|
|
text = text.replace(
|
|
/([A-Za-z0-9_\u4e00-\u9fa5]+)\s*(\(+)([^"\)\n]*\|[^"\)\n]*)(\)+)/g,
|
|
(match, id, open, content, close) => {
|
|
warnings.push(`自动为包含特殊字符的节点添加引号: ${id}`);
|
|
return `${id}${open}"${content}"${close}`;
|
|
}
|
|
);
|
|
// 处理 {} {{ }}
|
|
text = text.replace(
|
|
/([A-Za-z0-9_\u4e00-\u9fa5]+)\s*(\{+)([^"\}\n]*\|[^"\}\n]*)(\}+)/g,
|
|
(match, id, open, content, close) => {
|
|
warnings.push(`自动为包含特殊字符的节点添加引号: ${id}`);
|
|
return `${id}${open}"${content}"${close}`;
|
|
}
|
|
);
|
|
|
|
// 修复非法边标签
|
|
text = text.replace(
|
|
/(\S+)\s*--\s*([^|\n-]+?)\s*-->\s*(\S+)/g,
|
|
(_, from, label, to) => {
|
|
warnings.push(`修复非法边标签: "${label.trim()}"`);
|
|
return `${from} --|${label.trim()}|--> ${to}`;
|
|
}
|
|
);
|
|
|
|
// subgraph 中文名加引号
|
|
text = text.replace(/^\s*subgraph\s+(.+)$/gim, (_, name) => {
|
|
const n = name.trim();
|
|
if (/^["'].*["']$/.test(n)) return `subgraph ${n}`;
|
|
if (/[\u4e00-\u9fa5\s]/.test(n)) {
|
|
warnings.push(`subgraph 名称加引号: ${n}`);
|
|
return `subgraph "${n}"`;
|
|
}
|
|
return `subgraph ${n}`;
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
fixedSource: text.trim(),
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
const normalizeEscapedNewlines = (raw) => {
|
|
const source = String(raw || "");
|
|
let result = "";
|
|
let bracketDepth = 0;
|
|
let parenthesisDepth = 0;
|
|
let braceDepth = 0;
|
|
let pipeLabel = false;
|
|
|
|
for (let index = 0; index < source.length; index += 1) {
|
|
const current = source[index];
|
|
const next = source[index + 1];
|
|
const inLabel =
|
|
bracketDepth > 0 ||
|
|
parenthesisDepth > 0 ||
|
|
braceDepth > 0 ||
|
|
pipeLabel;
|
|
|
|
if (current === "\\" && next === "n") {
|
|
result += inLabel ? "<br/>" : "\n";
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (current === "\n") {
|
|
result += inLabel ? "<br/>" : "\n";
|
|
continue;
|
|
}
|
|
|
|
if (current === "[") bracketDepth += 1;
|
|
if (current === "]" && bracketDepth > 0) bracketDepth -= 1;
|
|
if (current === "(") parenthesisDepth += 1;
|
|
if (current === ")" && parenthesisDepth > 0) parenthesisDepth -= 1;
|
|
if (current === "{") braceDepth += 1;
|
|
if (current === "}" && braceDepth > 0) braceDepth -= 1;
|
|
if (
|
|
current === "|" &&
|
|
bracketDepth === 0 &&
|
|
parenthesisDepth === 0 &&
|
|
braceDepth === 0
|
|
) {
|
|
pipeLabel = !pipeLabel;
|
|
}
|
|
|
|
result += current;
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const normalizeMermaidRenderSource = (raw) => {
|
|
let text = String(raw || "")
|
|
.replace(/\r\n?/g, "\n")
|
|
.replace(/<br\s*\/?>/gi, "\n")
|
|
.replace(/[“”]/g, '"')
|
|
.replace(/[‘’]/g, "'")
|
|
.replace(/[:]/g, ":")
|
|
.replace(/\u00a0/g, " ");
|
|
|
|
text = normalizeEscapedNewlines(text)
|
|
.replace(/^\s*>\s?/gm, "")
|
|
.replace(/^\s*code\b[:\s]*/gim, "")
|
|
.replace(/^\s*```(?:mermaid)?\s*/gim, "")
|
|
.replace(/^\s*~~~(?:mermaid)?\s*/gim, "")
|
|
.replace(/^\s*subgraph\s+(.+)$/gim, (_, name) => {
|
|
const trimmedName = String(name || "").trim();
|
|
if (!trimmedName || /^["'].*["']$/.test(trimmedName)) {
|
|
return `subgraph ${trimmedName}`.trim();
|
|
}
|
|
if (/[\s\u4e00-\u9fa5]/.test(trimmedName)) {
|
|
return `subgraph "${trimmedName}"`;
|
|
}
|
|
return `subgraph ${trimmedName}`;
|
|
})
|
|
.trim();
|
|
|
|
return text;
|
|
};
|
|
|
|
/**
|
|
* 功能:生成 Mermaid 节点视图可依次尝试的渲染源码。
|
|
* 使用场景:兼容接口返回的转义换行、中文 subgraph 和旧 Mermaid 源码。
|
|
*
|
|
* 入参:原始 Mermaid 源码。
|
|
*
|
|
* 返回:去重后的候选源码数组,首项为常规清洗结果。
|
|
*
|
|
* 注意:调用方应依次尝试候选源码,不能在首个候选失败后直接清空预览。
|
|
*/
|
|
export function getMermaidRenderCandidates(raw) {
|
|
const { fixedSource } = validateAndFixMermaid(raw);
|
|
const candidates = [fixedSource, normalizeMermaidRenderSource(fixedSource)];
|
|
|
|
return [...new Set(candidates.map((item) => String(item || "").trim()))].filter(
|
|
Boolean
|
|
);
|
|
}
|
|
|