182 lines
9.5 KiB
JavaScript
182 lines
9.5 KiB
JavaScript
/* =====================================================================
|
||||
|
|
* Markdown 轻量渲染器(markdown.js)
|
|||
|
|
* ---------------------------------------------------------------------
|
|||
|
|
* 【这个文件是干什么的?】
|
|||
|
|
* 博客文章正文是用 Markdown 语法写的(# 标题、**加粗**、```代码块```),
|
|||
|
|
* 但浏览器只认 HTML。这个文件负责把 Markdown 文本"翻译"成 HTML 字符串,
|
|||
|
|
* 再交给其他模块(article.js 渲染文章正文、manage.js 做编辑预览)插入页面。
|
|||
|
|
*
|
|||
|
|
* 【整体思路:分两层处理】
|
|||
|
|
* 第 1 层(安全):先把整篇文本用 escapeHtml 转义,
|
|||
|
|
* 把 < > & 等特殊字符变成 < > & 这样的"HTML 实体"。
|
|||
|
|
* 这样用户写的内容永远不会被当成 HTML 标签执行 —— 这是防 XSS 攻击的关键。
|
|||
|
|
* 第 2 层(排版):把转义后的文本按"块级元素"逐行解析
|
|||
|
|
* (标题 / 段落 / 列表 / 引用 / 分隔线 / 代码块),
|
|||
|
|
* 每一行内部再调用 inlineMarkdown 处理"行内元素"(加粗 / 斜体 / 链接等)。
|
|||
|
|
*
|
|||
|
|
* 【两个函数的分工】
|
|||
|
|
* - inlineMarkdown(text):只管"一行文本内部"的语法替换(行内元素)
|
|||
|
|
* - renderMarkdown(md):入口函数,负责整篇的分行、分块、组装(块级元素)
|
|||
|
|
* ===================================================================== */
|
|||
|
|
|
|||
|
|
import { escapeHtml, safeUrl } from "./utils.js";
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 行内 Markdown 渲染
|
|||
|
|
* 只处理"行内"语法(不跨行),比如:
|
|||
|
|
* **加粗** *斜体* `行内代码` [链接文字](URL) 
|
|||
|
|
* 原理:用一串正则表达式依次"查找 - 替换",把 Markdown 写法换成 HTML 标签。
|
|||
|
|
*
|
|||
|
|
* 注意:传入的 text 已经过 escapeHtml 转义(在 renderMarkdown 里完成),
|
|||
|
|
* 所以这里生成的内容不会携带危险标签;链接 / 图片地址还会再过一次
|
|||
|
|
* safeUrl 协议白名单(只允许 http/https 等安全协议),双保险防 XSS。
|
|||
|
|
*/
|
|||
|
|
function inlineMarkdown(text) {
|
|||
|
|
return text
|
|||
|
|
// ---- 图片: -> <img> ----
|
|||
|
|
// 正则逐段拆解:
|
|||
|
|
// !\[([^\]]*)\] 匹配 "![" + 任意个"不是 ] 的字符"(就是替代文字,存入分组1)
|
|||
|
|
// \(([^)\s]+)\) 匹配 "(" + 任意个"不是 ) 和空格 的字符"(就是 URL,存入分组2)
|
|||
|
|
// /g 标志 = 全局替换,把整行里所有图片语法都处理掉
|
|||
|
|
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt, url) => {
|
|||
|
|
// safeUrl(url, "image"):按"图片"规则校验地址是否安全
|
|||
|
|
const safe = safeUrl(url, "image");
|
|||
|
|
// 安全才输出 <img>;不安全直接返回空字符串(丢弃这张图)
|
|||
|
|
// loading="lazy":图片滚动到视野内才加载,省流量
|
|||
|
|
return safe ? `<img src="${safe}" alt="${alt}" loading="lazy">` : "";
|
|||
|
|
})
|
|||
|
|
// ---- 视频:@[说明文字](视频地址) -> <video> ----
|
|||
|
|
// 语法用 @[ 开头,避免与图片 ![] 混淆(发布时点"插入视频"自动生成)
|
|||
|
|
.replace(/@\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt, url) => {
|
|||
|
|
const safe = safeUrl(url, "image");
|
|||
|
|
// 安全才输出 <video controls>(带播放控制条);不安全返回空字符串
|
|||
|
|
return safe ? `<video controls preload="metadata" src="${safe}">${alt}</video>` : "";
|
|||
|
|
})
|
|||
|
|
// ---- 链接:[显示文字](地址) -> <a> ----
|
|||
|
|
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, label, url) => {
|
|||
|
|
const safe = safeUrl(url, "link");
|
|||
|
|
// target="_blank":新标签页打开
|
|||
|
|
// rel="noopener":禁止新页面通过 window.opener 操控本页(安全措施)
|
|||
|
|
return safe ? `<a href="${safe}" target="_blank" rel="noopener">${label}</a>` : label;
|
|||
|
|
})
|
|||
|
|
// ---- 行内代码:`代码` -> <code> ----
|
|||
|
|
// `([^`]+)` 匹配一对反引号包起来的非空内容,$1 就是里面的代码
|
|||
|
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|||
|
|
// ---- 粗体:**文字** -> <strong> ----
|
|||
|
|
// \*\* 转义匹配字面的两个星号;([^*]+) 捕获"不含星号"的文字
|
|||
|
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|||
|
|
// ---- 斜体:*文字* -> <em>(单个星号)----
|
|||
|
|
// 注意顺序:必须先处理 **(粗体)再处理 *(斜体),
|
|||
|
|
// 否则 "**粗体**" 会被单星号规则拦腰拆坏
|
|||
|
|
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
|||
|
|
// ---- 斜体(下划线写法):_文字_ -> <em> ----
|
|||
|
|
.replace(/_([^_]+)_/g, "<em>$1</em>");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Markdown 渲染入口(块级 + 行内)
|
|||
|
|
* 处理步骤:
|
|||
|
|
* 1. 空内容直接返回空字符串(避免后面处理报错)
|
|||
|
|
* 2. escapeHtml 转义全文(安全第一),并把 Windows 的 \r\n 统一成 \n
|
|||
|
|
* 3. 用"占位符"先把 ```代码块``` 整块抽出来存进数组,
|
|||
|
|
* 防止代码块里的 # 或 * 被后面的规则误当成标题 / 加粗
|
|||
|
|
* 4. 逐行扫描剩余文本:标题 / 分隔线 / 引用 / 列表 / 空行 / 段落
|
|||
|
|
* 5. 循环结束后把占位符换回真正的 <pre><code> 代码块
|
|||
|
|
*/
|
|||
|
|
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><code>(pre 会保留代码里的空格和换行)
|
|||
|
|
codeBlocks.push(`<pre><code>${code.replace(/\n$/, "")}</code></pre>`);
|
|||
|
|
// 返回占位符:\u0000 是"空字符",正常文章里几乎不会出现,避免误替换
|
|||
|
|
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let html = ""; // 最终输出的 HTML,逐行往这个字符串上追加
|
|||
|
|
let list = null; // 列表缓冲:{ type: "ul"|"ol", items: [] }
|
|||
|
|
|
|||
|
|
// 关闭列表:把攒在缓冲里的列表项一次性输出成 <ul>/<ol>,然后清空缓冲。
|
|||
|
|
// 为什么列表要"攒着"?因为列表项必须连续、外面要套同一个 <ul>,
|
|||
|
|
// 所以要等列表结束(遇到标题、空行等)时才知道在哪儿闭合标签。
|
|||
|
|
const closeList = () => {
|
|||
|
|
if (list) {
|
|||
|
|
const tag = list.type === "ol" ? "ol" : "ul";
|
|||
|
|
html += `<${tag}>${list.items.map((i) => `<li>${i}</li>`).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. 标题:# 一级 ## 二级 ... ###### 六级 -> <h1>~<h6>
|
|||
|
|
// ^(#{1,6}):行首 1~6 个 #(# 的个数 = 标题级别)
|
|||
|
|
// \s+:后面至少一个空格;(.*):剩下的文字就是标题内容
|
|||
|
|
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
|||
|
|
if (heading) {
|
|||
|
|
closeList(); // 标题会打断列表,先收尾
|
|||
|
|
const level = heading[1].length; // 捕获到的 # 字符串长度就是级别
|
|||
|
|
html += `<h${level}>${inlineMarkdown(heading[2])}</h${level}>`;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 分隔线:整行由短横线组成(--、--- 等) -> <hr>
|
|||
|
|
// ^\s*:行首可以有空格;-+:一个或多个短横线;$:一直到行尾
|
|||
|
|
if (/^\s*---+$/.test(line)) { closeList(); html += "<hr>"; continue; }
|
|||
|
|
|
|||
|
|
// 4. 引用:> 文字 -> <blockquote>
|
|||
|
|
// 为什么匹配 > 而不是 > ?
|
|||
|
|
// 因为整篇已经过 escapeHtml 转义,> 已经变成了 >
|
|||
|
|
const quote = line.match(/^>\s?(.*)$/);
|
|||
|
|
if (quote) { closeList(); html += `<blockquote>${inlineMarkdown(quote[1])}</blockquote>`; continue; }
|
|||
|
|
|
|||
|
|
// 5. 无序列表:- 项目 或 * 项目 或 + 项目 -> <ul><li>
|
|||
|
|
// ^\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. 项目 -> <ol><li>
|
|||
|
|
// ^\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. 普通段落:剩下的所有内容都包进 <p> 段落标签
|
|||
|
|
closeList();
|
|||
|
|
html += `<p>${inlineMarkdown(line)}</p>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 循环结束:如果还有没收尾的列表缓冲,补一次关闭
|
|||
|
|
closeList();
|
|||
|
|
return html;
|
|||
|
|
}
|