Files
guzhujushiBlog/frontend/markdown.js
T

182 lines
9.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
* Markdown 轻量渲染器(markdown.js
* ---------------------------------------------------------------------
* 【这个文件是干什么的?】
* 博客文章正文是用 Markdown 语法写的(# 标题、**加粗**、```代码块```),
* 但浏览器只认 HTML。这个文件负责把 Markdown 文本"翻译"成 HTML 字符串,
* 再交给其他模块(article.js 渲染文章正文、manage.js 做编辑预览)插入页面。
*
* 【整体思路:分两层处理】
* 第 1 层(安全):先把整篇文本用 escapeHtml 转义,
* 把 < > & 等特殊字符变成 &lt; &gt; &amp; 这样的"HTML 实体"。
* 这样用户写的内容永远不会被当成 HTML 标签执行 —— 这是防 XSS 攻击的关键。
* 第 2 层(排版):把转义后的文本按"块级元素"逐行解析
* (标题 / 段落 / 列表 / 引用 / 分隔线 / 代码块),
* 每一行内部再调用 inlineMarkdown 处理"行内元素"(加粗 / 斜体 / 链接等)。
*
* 【两个函数的分工】
* - inlineMarkdown(text):只管"一行文本内部"的语法替换(行内元素)
* - renderMarkdown(md):入口函数,负责整篇的分行、分块、组装(块级元素)
* ===================================================================== */
import { escapeHtml, safeUrl } from "./utils.js";
/**
* 行内 Markdown 渲染
* 只处理"行内"语法(不跨行),比如:
* **加粗** *斜体* `行内代码` [链接文字](URL) ![图片描述](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>
// 为什么匹配 &gt; 而不是 >
// 因为整篇已经过 escapeHtml 转义,> 已经变成了 &gt;
const quote = line.match(/^&gt;\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;
}