----
.replace(/_([^_]+)_/g, "$1");
}
/**
* Markdown 渲染入口(块级 + 行内)
* 处理步骤:
* 1. 空内容直接返回空字符串(避免后面处理报错)
* 2. escapeHtml 转义全文(安全第一),并把 Windows 的 \r\n 统一成 \n
* 3. 用"占位符"先把 ```代码块``` 整块抽出来存进数组,
* 防止代码块里的 # 或 * 被后面的规则误当成标题 / 加粗
* 4. 逐行扫描剩余文本:标题 / 分隔线 / 引用 / 列表 / 空行 / 段落
* 5. 循环结束后把占位符换回真正的 代码块
*/
export function renderMarkdown(md) {
if (!md) return "";
// 统一转义 + 统一换行符(\r\n 是 Windows 换行,\n 是 Linux/Mac 换行)
const escaped = escapeHtml(md).replace(/\r\n/g, "\n");
// 代码块缓存数组:抽出来的代码块按顺序存这里
const codeBlocks = [];
// 围栏代码块正则:```语言名\n 任意内容 ```
// ```([^\n]*) 第一个 ``` 后到换行为止 = 语言名(如 js)
// \n 换行
// ([\s\S]*?) 任意字符(\s\S 合起来表示"包括换行的任意字符")
// 后面的 ? 是非贪婪模式:遇到第一个 ``` 就停,不吞后面内容
// 回调函数里 codeBlocks 存的是拼好的 HTML,返回的是占位符
const text = escaped.replace(/```([^\n]*)\n([\s\S]*?)```/g, (match, lang, code) => {
// 去掉代码末尾多余换行,包进 (pre 会保留代码里的空格和换行)
codeBlocks.push(`${code.replace(/\n$/, "")}
`);
// 返回占位符:\u0000 是"空字符",正常文章里几乎不会出现,避免误替换
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
});
let html = ""; // 最终输出的 HTML,逐行往这个字符串上追加
let list = null; // 列表缓冲:{ type: "ul"|"ol", items: [] }
// 关闭列表:把攒在缓冲里的列表项一次性输出成 /,然后清空缓冲。
// 为什么列表要"攒着"?因为列表项必须连续、外面要套同一个 ,
// 所以要等列表结束(遇到标题、空行等)时才知道在哪儿闭合标签。
const closeList = () => {
if (list) {
const tag = list.type === "ol" ? "ol" : "ul";
html += `<${tag}>${list.items.map((i) => `- ${i}
`).join("")}${tag}>`;
list = null;
}
};
// 逐行处理:把文本按 \n 切成一行一行的数组,依次判断每一行属于哪种块级语法
for (const rawLine of text.split("\n")) {
const line = rawLine;
// 1. 代码块占位符(形如 \u0000CODE0\u0000)
// 先关闭可能开着的列表,再把占位符换成真正的代码块 HTML
const codeMatch = line.match(/^\u0000CODE(\d+)\u0000$/);
if (codeMatch) { closeList(); html += codeBlocks[Number(codeMatch[1])]; continue; }
// 2. 标题:# 一级 ## 二级 ... ###### 六级 -> ~
// ^(#{1,6}):行首 1~6 个 #(# 的个数 = 标题级别)
// \s+:后面至少一个空格;(.*):剩下的文字就是标题内容
const heading = line.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
closeList(); // 标题会打断列表,先收尾
const level = heading[1].length; // 捕获到的 # 字符串长度就是级别
html += `${inlineMarkdown(heading[2])}`;
continue;
}
// 3. 分隔线:整行由短横线组成(--、--- 等) ->
// ^\s*:行首可以有空格;-+:一个或多个短横线;$:一直到行尾
if (/^\s*---+$/.test(line)) { closeList(); html += "
"; continue; }
// 4. 引用:> 文字 ->
// 为什么匹配 > 而不是 > ?
// 因为整篇已经过 escapeHtml 转义,> 已经变成了 >
const quote = line.match(/^>\s?(.*)$/);
if (quote) { closeList(); html += `${inlineMarkdown(quote[1])}
`; continue; }
// 5. 无序列表:- 项目 或 * 项目 或 + 项目 -> -
// ^\s*[-*+]\s+:行首空格 + 符号(- * + 三选一)+ 空格;(.*) 是列表内容
const ulItem = line.match(/^\s*[-*+]\s+(.*)$/);
if (ulItem) {
// 当前没有列表、或是"有序"列表时,先关闭旧的,再开一个新的无序列表
if (!list || list.type !== "ul") { closeList(); list = { type: "ul", items: [] }; }
list.items.push(inlineMarkdown(ulItem[1])); // 先攒进缓冲,等列表结束统一输出
continue;
}
// 6. 有序列表:1. 项目 2. 项目 ->
-
// ^\s*\d+\.\s+:行首空格 + 数字(\d+)+ 点 + 空格
const olItem = line.match(/^\s*\d+\.\s+(.*)$/);
if (olItem) {
if (!list || list.type !== "ol") { closeList(); list = { type: "ol", items: [] }; }
list.items.push(inlineMarkdown(olItem[1]));
continue;
}
// 7. 空行:段落之间的分隔,同时也会打断列表
if (line.trim() === "") { closeList(); continue; }
// 8. 普通段落:剩下的所有内容都包进
段落标签
closeList();
html += `
${inlineMarkdown(line)}
`;
}
// 循环结束:如果还有没收尾的列表缓冲,补一次关闭
closeList();
return html;
}