264 lines
9.7 KiB
JavaScript
264 lines
9.7 KiB
JavaScript
/*
|
||
* marked.js —— 轻量 Markdown 解析器(本地自带,不依赖任何 CDN)
|
||
* 用法:marked.parse(markdownString) -> htmlString
|
||
* 支持:标题、粗体/斜体/删除线、行内代码、代码块、引用、有序/无序列表(含嵌套)、
|
||
* 链接、图片、分隔线、GFM 表格、段落与软换行。
|
||
* 安全:先转义原文 HTML,链接 URL 做白名单过滤,避免 XSS。
|
||
*/
|
||
(function (global) {
|
||
'use strict';
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
}
|
||
|
||
// 仅转义属性里的引号(文本已在 escapeHtml 中转义过 < > &)
|
||
function attrSafe(s) {
|
||
return String(s).replace(/"/g, '"');
|
||
}
|
||
|
||
// URL 白名单:仅允许 http/https/mailto/tel/锚点/相对路径;拦截 javascript:/vbscript:/危险 data:
|
||
function safeUrl(url) {
|
||
var u = String(url || '').trim();
|
||
if (/^\s*(javascript|vbscript):/i.test(u)) return '#';
|
||
if (/^\s*data:/i.test(u) && !/^data:image\//i.test(u)) return '#';
|
||
return u;
|
||
}
|
||
|
||
// 行内解析:输入为「未转义」的 markdown 文本
|
||
function parseInline(text) {
|
||
var codes = [];
|
||
// 1. 先抽出行内代码,避免内部内容被后续规则破坏
|
||
text = String(text).replace(/`([^`]+)`/g, function (_m, c) {
|
||
codes.push(c);
|
||
return 'CODE' + (codes.length - 1) + '';
|
||
});
|
||
// 2. 转义 HTML(代码占位符只含字母数字与空字符,不受影响)
|
||
text = escapeHtml(text);
|
||
// 3. 图片 
|
||
text = text.replace(/!\[([^\]]*)\]\(((?:[^()\s]|\([^()]*\))+)(?:\s+"([^)]*?)")?\)/g, function (_m, alt, url, title) {
|
||
var t = title ? ' title="' + attrSafe(title) + '"' : '';
|
||
return '<img src="' + attrSafe(safeUrl(url)) + '" alt="' + attrSafe(alt) + '"' + t + '>';
|
||
});
|
||
// 4. 链接 [text](url "title")
|
||
text = text.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()]*\))+)(?:\s+"([^)]*?)")?\)/g, function (_m, txt, url, title) {
|
||
var t = title ? ' title="' + attrSafe(title) + '"' : '';
|
||
var ext = /^(https?:)?\/\//i.test(url);
|
||
var extAttr = ext ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||
return '<a href="' + attrSafe(safeUrl(url)) + '"' + t + extAttr + '>' + txt + '</a>';
|
||
});
|
||
// 5. 粗体 **x** / __x__
|
||
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||
text = text.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
||
// 6. 斜体 *x* / _x_
|
||
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||
text = text.replace(/(^|[^\w])_([^_]+)_(?=[^\w]|$)/g, '$1<em>$2</em>');
|
||
// 7. 删除线 ~~x~~
|
||
text = text.replace(/~~([^~]+)~~/g, '<del>$1</del>');
|
||
// 8. 还原行内代码
|
||
text = text.replace(/CODE(\d+)/g, function (_m, n) {
|
||
return '<code>' + escapeHtml(codes[+n]) + '</code>';
|
||
});
|
||
return text;
|
||
}
|
||
|
||
// 列表解析:返回 { html, next }
|
||
function parseList(lines, start) {
|
||
var first = lines[start];
|
||
var m = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(first);
|
||
var baseIndent = m[1].length;
|
||
var ordered = /\d+\./.test(m[2]);
|
||
var items = [];
|
||
var i = start;
|
||
|
||
while (i < lines.length) {
|
||
var line = lines[i];
|
||
if (/^\s*$/.test(line)) break; // 空行结束列表
|
||
var mm = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(line);
|
||
if (!mm) {
|
||
// 同级的后续非标记行视为当前条目的延续(懒继续)
|
||
if (items.length && /^\s*\S/.test(line) && !/^\s*[-*+]\s/.test(line) && !/^\s*\d+\.\s/.test(line)) {
|
||
items[items.length - 1].text.push(line.trim());
|
||
i++;
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
var ind = mm[1].length;
|
||
if (ind < baseIndent) break; // 减缩进,结束
|
||
if (ind > baseIndent) { // 嵌套列表
|
||
var sub = parseList(lines, i);
|
||
items[items.length - 1].nested += sub.html;
|
||
i = sub.next;
|
||
continue;
|
||
}
|
||
// 同级新条目
|
||
items.push({ text: [mm[3]], nested: '' });
|
||
i++;
|
||
}
|
||
|
||
var tag = ordered ? 'ol' : 'ul';
|
||
var out = '<' + tag + '>';
|
||
for (var k = 0; k < items.length; k++) {
|
||
out += '<li>' + parseInline(items[k].text.join(' '));
|
||
if (items[k].nested) out += items[k].nested;
|
||
out += '</li>';
|
||
}
|
||
out += '</' + tag + '>';
|
||
return { html: out, next: i };
|
||
}
|
||
|
||
function splitRow(line) {
|
||
var t = line.trim();
|
||
if (t.charAt(0) === '|') t = t.slice(1);
|
||
if (t.charAt(t.length - 1) === '|') t = t.slice(0, -1);
|
||
return t.split('|').map(function (c) { return c.trim(); });
|
||
}
|
||
|
||
// 表格解析:返回 { html, next }
|
||
function parseTable(lines, start) {
|
||
var header = splitRow(lines[start]);
|
||
var sep = lines[start + 1];
|
||
var aligns = splitRow(sep).map(function (c) {
|
||
var left = c.charAt(0) === ':';
|
||
var right = c.charAt(c.length - 1) === ':';
|
||
if (left && right) return 'center';
|
||
if (right) return 'right';
|
||
if (left) return 'left';
|
||
return '';
|
||
});
|
||
var rows = [];
|
||
var j = start + 2;
|
||
while (j < lines.length && /\|/.test(lines[j]) && lines[j].trim() !== '') {
|
||
rows.push(splitRow(lines[j]));
|
||
j++;
|
||
}
|
||
function cell(tag, c, idx) {
|
||
var a = aligns[idx] ? ' style="text-align:' + aligns[idx] + '"' : '';
|
||
return '<' + tag + a + '>' + parseInline(c) + '</' + tag + '>';
|
||
}
|
||
var html = '<table><thead><tr>';
|
||
for (var h = 0; h < header.length; h++) html += cell('th', header[h], h);
|
||
html += '</tr></thead><tbody>';
|
||
for (var r = 0; r < rows.length; r++) {
|
||
html += '<tr>';
|
||
for (var c = 0; c < rows[r].length; c++) html += cell('td', rows[r][c], c);
|
||
html += '</tr>';
|
||
}
|
||
html += '</tbody></table>';
|
||
return { html: html, next: j };
|
||
}
|
||
|
||
// 块级解析
|
||
function parse(src) {
|
||
src = (src || '').replace(/\r\n?/g, '\n');
|
||
var lines = src.split('\n');
|
||
var html = [];
|
||
var para = [];
|
||
var i = 0;
|
||
|
||
function flushPara() {
|
||
if (para.length) {
|
||
var text = para.join('\n');
|
||
// 段内软换行 -> <br>
|
||
var inline = parseInline(text).replace(/\n/g, '<br>\n');
|
||
html.push('<p>' + inline + '</p>');
|
||
para = [];
|
||
}
|
||
}
|
||
|
||
while (i < lines.length) {
|
||
var line = lines[i];
|
||
|
||
// 代码围栏 ```
|
||
if (/^\s*```/.test(line)) {
|
||
flushPara();
|
||
var lang = line.replace(/^\s*```/, '').trim();
|
||
var buf = [];
|
||
i++;
|
||
while (i < lines.length && !/^\s*```/.test(lines[i])) {
|
||
buf.push(lines[i]);
|
||
i++;
|
||
}
|
||
i++; // 跳过结束围栏
|
||
html.push('<pre><code' +
|
||
(lang ? ' class="language-' + attrSafe(lang) + '"' : '') +
|
||
'>' + escapeHtml(buf.join('\n')) + '</code></pre>');
|
||
continue;
|
||
}
|
||
|
||
// 分隔线
|
||
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
|
||
flushPara();
|
||
html.push('<hr>');
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
// 标题 # ~ ######
|
||
var hm = /^\s*(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
|
||
if (hm) {
|
||
flushPara();
|
||
var lvl = hm[1].length;
|
||
html.push('<h' + lvl + '>' + parseInline(hm[2].trim()) + '</h' + lvl + '>');
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
// 引用 >
|
||
if (/^\s*>\s?/.test(line)) {
|
||
flushPara();
|
||
var qbuf = [];
|
||
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
|
||
qbuf.push(lines[i].replace(/^\s*>\s?/, ''));
|
||
i++;
|
||
}
|
||
html.push('<blockquote>' + parse(qbuf.join('\n')) + '</blockquote>');
|
||
continue;
|
||
}
|
||
|
||
// 表格(当前行含 |,且下一行是分隔行)
|
||
if (/\|/.test(line) && i + 1 < lines.length &&
|
||
/^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && /-/.test(lines[i + 1])) {
|
||
flushPara();
|
||
var tbl = parseTable(lines, i);
|
||
html.push(tbl.html);
|
||
i = tbl.next;
|
||
continue;
|
||
}
|
||
|
||
// 列表
|
||
if (/^\s*([-*+]|\d+\.)\s+/.test(line)) {
|
||
flushPara();
|
||
var lst = parseList(lines, i);
|
||
html.push(lst.html);
|
||
i = lst.next;
|
||
continue;
|
||
}
|
||
|
||
// 空行
|
||
if (/^\s*$/.test(line)) {
|
||
flushPara();
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
// 段落文本
|
||
para.push(line);
|
||
i++;
|
||
}
|
||
flushPara();
|
||
return html.join('\n');
|
||
}
|
||
|
||
var api = { parse: parse, escapeHtml: escapeHtml };
|
||
|
||
if (typeof module !== 'undefined' && module.exports) {
|
||
module.exports = api;
|
||
}
|
||
global.marked = api;
|
||
})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this));
|