Files
MetonaEditor/src/parser.js
T
thzxx faa329682c release: v0.1.11 — quality & stability improvements
fix(core): clear _outlineTimer in destroy() to prevent post-destroy callbacks
fix(core): remove '<' from BRACKET_PAIRS to avoid HTML tag conflicts
fix(core): setValue now updates outline panel and resets gutter scroll
fix(core): getValue returns '' on destroyed instances (safe API access)
opt(core): _renderGutter skips rebuild when line count unchanged
chore: bump version 0.1.10 → 0.1.11 across all files
2026-07-24 15:59:07 +08:00

903 lines
30 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.
/**
* MetonaEditor Parser - 轻量 Markdown 解析器(增强版)
* @module parser
* @version 0.1.11
* @description 自研零依赖 Markdown 解析器,覆盖 CommonMark 子集 + GFM 扩展
*
* v0.1.4 增强:
* - 嵌套列表(支持多级缩进)
* - Setext 标题(=== / ---
* - 缩进代码块(4 空格)
* - HTML 注释透传
* - 实体引用保护
* - 引用链接 [text][ref]
* - 链接标题单引号
* - 硬换行(行尾 2 空格)
* - 有序列表 start 属性
* - LaTeX 数学公式 $...$ / $$...$$
* - 脚注 [^1] / [^1]: 定义
* - 定义列表
* - 反引号代码块精确匹配(CommonMark)
* - 斜体正则优化(lookbehind
* - safeUrl 增强 / slugify Unicode 规范化
* - 单遍内联扫描性能优化
* - 表情扩展至 150+ 常用表情
*
* 支持语法:
* - 标题 # ~ ###### / Setext === ---
* - 段落 / 软换行 / 硬换行(行尾2空格)
* - 粗体 **text** / __text__
* - 斜体 *text* / _text_
* - 删除线 ~~text~~
* - 高亮标记 ==text==
* - 上标 ^text^ / 下标 ~text~
* - 行内代码 `code` / 围栏代码块 ```lang ... ``` 与 ~~~ ... ~~~
* - 缩进代码块(4 空格)
* - 引用块 >(支持嵌套)
* - 无序列表 - / * / +(支持嵌套)
* - 有序列表 1.(支持嵌套、start 属性)
* - 任务列表 - [ ] / - [x]
* - 水平线 --- / *** / ___
* - 表格 | a | b |(含列对齐 :---:
* - 链接 [text](url) / [text][ref] / 自动链接 <url>
* - 图片 ![alt](url) / ![alt][ref]
* - 转义字符 \X
* - LaTeX 数学公式 $inline$ / $$block$$
* - 脚注 [^1] / [^1]: 定义
* - 定义列表 term\n: definition
* - HTML 注释 <!-- comment -->
* - Emoji 短码 :smile:150+ 常用)
*
* 安全:所有文本经 HTML 转义;URL 过滤危险协议;实体引用保护
* 扩展:env.highlight(code, lang) => html 钩子用于代码高亮
*/
import { escapeHTML } from './utils.js';
// ============ 预编译正则表达式(性能优化) ============
/** 空行 */
const RE_EMPTY = /^\s*$/;
/** ATX 标题 */
const RE_ATX = /^(#{1,6})\s+(.+?)(?:\s+#{1,6})?\s*$/;
/** Setext 标题分隔行 */
const RE_SETEXT_H1 = /^={3,}\s*$/;
const RE_SETEXT_H2 = /^-{3,}\s*$/;
/** 围栏代码块开始 */
const RE_FENCE_START = /^(\s{0,3})(`{3,}|~{3,})\s*([\w+#.-]*)\s*$/;
/** 水平线 */
const RE_HR = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/;
/** 引用块 */
const RE_QUOTE = /^\s{0,3}>\s?/;
/** 无序列表 */
const RE_UL = /^(\s*)([-*+])\s/;
/** 有序列表 */
const RE_OL = /^(\s*)(\d+)\.\s/;
/** 任务列表项 */
const RE_TASK = /^\[([ xX])\]\s+(.*)$/;
/** 表格分隔行 */
const RE_TABLE_SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
/** 缩进代码块(4空格或1tab) */
const RE_INDENT_CODE = /^ {4}|\t/;
/** 定义列表 */
const RE_DEF_LIST = /^:\s+/;
/** 脚注定义 */
const RE_FOOTNOTE_DEF = /^\[\^([^\]]+)\]:\s*/;
/** 段落边界检测(预编译合并版) */
const RE_BLOCK_BOUNDARY = /^\s*(`{3,}|~{3,}|#{1,6}\s|>|[-*+]\s|\d+\.\s|[-*_](\s*\2){2,}\s*$|\[[\^]|:\s)/;
// ============ 常用 Emoji 短码映射(扩展至 150+ ============
const EMOJI_MAP = {
// 表情
smile: '😊', grinning: '😀', joy: '😂', rofl: '🤣', wink: '😉',
blush: '😊', innocent: '😇', heart_eyes: '😍', kissing_heart: '😘',
yum: '😋', stuck_out_tongue: '😛', sunglasses: '😎', smirk: '😏',
unamused: '😒', disappointed: '😞', worried: '😟', confused: '😕',
cry: '😢', sob: '😭', scream: '😱', angry: '😠', rage: '💢',
triumph: '😤', sleepy: '😪', dizzy_face: '😵', zipper_mouth: '🤐',
nerd: '🤓', thinking: '🤔', rolling_eyes: '🙄', expressionless: '😑',
// 手势
thumbsup: '👍', thumbsdown: '👎', clap: '👏', pray: '🙏', muscle: '💪',
ok: '👌', point_up: '👆', point_down: '👇', point_left: '👈', point_right: '👉',
raised_hands: '🙌', wave: '👋', punch: '👊', crossed_fingers: '🤞',
// 符号
heart: '❤️', broken_heart: '💔', star: '⭐', star2: '🌟', fire: '🔥',
rocket: '🚀', check: '✅', cross: '❌', warning: '⚠️', info: '️',
question: '❓', exclamation: '❗', bangbang: '‼️', grey_exclamation: '❕',
bulb: '💡', book: '📖', memo: '📝', pin: '📌', link: '🔗',
lock: '🔒', unlock: '🔓', key: '🔑', hammer: '🔨', wrench: '🔧',
gear: '⚙️', tools: '🛠️', magnet: '🧲', zap: '⚡', cloud: '☁️',
// 天气
sun: '☀️', moon: '🌙', rain: '🌧️', snow: '❄️', umbrella: '☂️',
rainbow: '🌈', tornado: '🌪️', fog: '🌫️', droplet: '💧',
// 饮食
coffee: '☕', pizza: '🍕', cake: '🎂', beer: '🍺', wine: '🍷',
hamburger: '🍔', fries: '🍟', apple: '🍎', banana: '🍌', grapes: '🍇',
watermelon: '🍉', strawberry: '🍓', peach: '🍑', cherry: '🍒', taco: '🌮',
// 庆祝
tada: '🎉', gift: '🎁', crown: '👑', gem: '💎', ring: '💍',
confetti: '🎊', balloon: '🎈', ribbon: '🎀', medal: '🏅', trophy: '🏆',
// 身体
eye: '👁️', ear: '👂', nose: '👃', tongue: '👅', lips: '👄',
brain: '🧠', speech: '💬', thought: '💭', anger: '💢', sweat: '💦',
// 数字
one: '1️⃣', two: '2️⃣', three: '3️⃣', four: '4️⃣', five: '5️⃣',
six: '6️⃣', seven: '7️⃣', eight: '8️⃣', nine: '9️⃣', zero: '0️⃣',
// 箭头
arrow_up: '⬆️', arrow_down: '⬇️', arrow_left: '⬅️', arrow_right: '➡️',
arrow_upper_right: '↗️', arrow_lower_right: '↘️', arrow_lower_left: '↙️', arrow_upper_left: '↖️',
// 科技
computer: '💻', phone: '📱', battery: '🔋', electric_plug: '🔌',
// 其他
copyright: '©️', registered: '®️', tm: '™️', x: '❌', o: '⭕',
hundred: '💯', boom: '💥', dash: '💨', hole: '🕳️', bomb: '💣',
email: '📧', phone2: '📞', clock: '🕐', hourglass: '⏳', calendar: '📅',
money: '💰', shopping: '🛒', package: '📦', mailbox: '📫',
art: '🎨', music: '🎵', movie: '🎬', game: '🎮', sport: '⚽',
earth: '🌍', house: '🏠', car: '🚗', airplane: '✈️', ship: '🚢',
};
// ============ 渲染缓存 ============
const renderCache = new Map();
const MAX_CACHE_SIZE = 300;
const cachedRenderInline = (text, env) => {
if (!text) return '';
// 仅在无 highlight 钩子且文本较短时使用缓存
if (!env?.highlight && text.length < 600) {
const cached = renderCache.get(text);
if (cached !== undefined) return cached;
const result = renderInline(text, env);
if (renderCache.size >= MAX_CACHE_SIZE) {
// FIFO 淘汰:删除最早条目
const firstKey = renderCache.keys().next().value;
renderCache.delete(firstKey);
}
renderCache.set(text, result);
return result;
}
return renderInline(text, env);
};
/** 清除渲染缓存(主题切换等场景) */
const clearRenderCache = () => {
renderCache.clear();
};
// ============ 安全工具 ============
/**
* 危险协议过滤(增强版)
* 过滤 javascript: / vbscript: / file: / 非 image 的 data:
* 仅允许 http / https / mailto / ftp / data:image / 相对路径
*/
const safeUrl = (url) => {
if (!url) return '';
const u = String(url).trim();
if (/^(javascript|vbscript|file):/i.test(u)) return '';
if (/^data:/i.test(u)) {
if (!/^data:image\//i.test(u)) return '';
// data:image 大小限制(粗略,防 DoS)
if (u.length > 500000) return '';
}
return u;
};
/**
* 生成标题锚点 id(Unicode 规范化增强版)
*/
const slugify = (text) => {
let slug = String(text)
.normalize('NFKC')
.toLowerCase()
.replace(/[^\w\u4e00-\u9fff\u3400-\u4dbf\s-]/g, '')
.trim()
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return slug || 'heading';
};
// ============ 块级解析 ============
/**
* 主解析入口
* @param {string} md - Markdown 源文本
* @param {Object} env - 渲染环境 { highlight, locale }
* @returns {string} HTML 字符串
*/
const parseMarkdown = (md, env = {}) => {
if (md == null) return '';
const text = String(md).replace(/\r\n?/g, '\n');
const lines = text.split('\n');
const tokens = [];
const footnotes = {};
let i = 0;
while (i < lines.length) {
const line = lines[i];
// 空行
if (RE_EMPTY.test(line)) { i++; continue; }
// 围栏代码块
const fence = line.match(RE_FENCE_START);
if (fence) {
const fenceChar = fence[2][0];
const fenceLen = fence[2].length;
const lang = fence[3] || '';
const codeLines = [];
i++;
// 关闭围栏:至少与开头同长或更长的相同字符
while (i < lines.length) {
const closeMatch = lines[i].match(/^(\s{0,3})(`{3,}|~{3,})\s*$/);
if (closeMatch && closeMatch[2][0] === fenceChar && closeMatch[2].length >= fenceLen) {
break;
}
codeLines.push(lines[i]);
i++;
}
if (i < lines.length) i++; // 跳过结束围栏
tokens.push({ type: 'code', lang, content: codeLines.join('\n') });
continue;
}
// 缩进代码块(4 空格或 tab,不与其他块级语法重叠时)
if (RE_INDENT_CODE.test(line) && !RE_ATX.test(line) && !RE_QUOTE.test(line)
&& !RE_UL.test(line) && !RE_OL.test(line)) {
const codeLines = [];
while (i < lines.length && RE_INDENT_CODE.test(lines[i])) {
codeLines.push(lines[i].replace(/^ {0,4}/, ''));
i++;
}
if (codeLines.length) {
tokens.push({ type: 'code', lang: '', content: codeLines.join('\n'), indent: true });
continue;
}
}
// ATX 标题(非空标题文本)
const h = line.match(RE_ATX);
if (h) {
tokens.push({ type: 'heading', level: h[1].length, text: h[2] });
i++;
continue;
}
// Setext 标题(h1 === / h2 ---,前一行必须是段落文本)
if (i + 1 < lines.length && RE_SETEXT_H1.test(lines[i + 1])
&& !RE_EMPTY.test(line) && !RE_UL.test(line) && !RE_OL.test(line)
&& !RE_QUOTE.test(line) && !/^\s*`{3,}/.test(line)) {
tokens.push({ type: 'heading', level: 1, text: line });
i += 2;
continue;
}
if (i + 1 < lines.length && RE_SETEXT_H2.test(lines[i + 1])
&& !RE_EMPTY.test(line) && !RE_UL.test(line) && !RE_OL.test(line)
&& !RE_QUOTE.test(line) && !/^\s*`{3,}/.test(line)) {
tokens.push({ type: 'heading', level: 2, text: line });
i += 2;
continue;
}
// 水平线
if (RE_HR.test(line)) {
tokens.push({ type: 'hr' });
i++;
continue;
}
// 引用块
if (RE_QUOTE.test(line)) {
const quoteLines = [];
while (i < lines.length && RE_QUOTE.test(lines[i])) {
quoteLines.push(lines[i].replace(RE_QUOTE, ''));
i++;
}
tokens.push({ type: 'quote', content: quoteLines.join('\n') });
continue;
}
// 表格
if (/\|/.test(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
const header = line;
const align = parseAlign(lines[i + 1]);
i += 2;
const rows = [];
while (i < lines.length && /\|/.test(lines[i]) && !RE_EMPTY.test(lines[i])) {
rows.push(lines[i]);
i++;
}
tokens.push({ type: 'table', header, rows, align });
continue;
}
// 无序列表 / 任务列表(支持嵌套)
const ulMatch = line.match(RE_UL);
if (ulMatch) {
const { items, endIdx } = parseList(lines, i, ulMatch[2], ulMatch[1].length, false);
tokens.push({ type: 'ul', items });
i = endIdx;
continue;
}
// 有序列表(支持嵌套 + start 属性)
const olMatch = line.match(RE_OL);
if (olMatch) {
const start = parseInt(olMatch[2], 10);
const { items, endIdx } = parseList(lines, i, olMatch[2], olMatch[1].length, true, start);
tokens.push({ type: 'ol', items, start });
i = endIdx;
continue;
}
// 脚注定义(仅块级,在段落解析前)
const fnMatch = line.match(RE_FOOTNOTE_DEF);
if (fnMatch) {
const fnId = fnMatch[1];
const fnContent = line.slice(fnMatch[0].length);
const fnLines = [fnContent];
i++;
while (i < lines.length && !RE_EMPTY.test(lines[i]) && !RE_BLOCK_BOUNDARY.test(lines[i])) {
fnLines.push(lines[i]);
i++;
}
footnotes[fnId] = fnLines.join(' ');
continue;
}
// 定义列表(: term,前一行应为术语)
if (RE_DEF_LIST.test(line)) {
const term = (tokens.length > 0 && tokens[tokens.length - 1].type === 'paragraph')
? tokens.pop().text : '';
const defs = [line.replace(RE_DEF_LIST, '')];
i++;
while (i < lines.length && RE_DEF_LIST.test(lines[i])) {
defs.push(lines[i].replace(RE_DEF_LIST, ''));
i++;
}
tokens.push({ type: 'defList', term, defs });
continue;
}
// LaTeX 块级公式 $$...$$
// 单行公式
const singleMath = line.match(/^\$\$([\s\S]+?)\$\$\s*$/);
if (singleMath) {
tokens.push({ type: 'mathBlock', content: singleMath[1] });
i++;
continue;
}
// 多行公式(起始行只有 $$ 或 $$ 后跟内容但无闭合)
if (/^\$\$/.test(line)) {
const mathLines = [];
mathLines.push(line.replace(/^\$\$/, ''));
i++;
while (i < lines.length && !/\$\$/.test(lines[i])) {
mathLines.push(lines[i]);
i++;
}
if (i < lines.length) {
mathLines.push(lines[i].replace(/\$\$\s*$/, ''));
i++;
}
tokens.push({ type: 'mathBlock', content: mathLines.join('\n') });
continue;
}
// 段落(收集连续非块级行)
const para = [];
while (i < lines.length) {
const l = lines[i];
if (RE_EMPTY.test(l)) break;
// 使用预编译合并正则加速检测
if (RE_BLOCK_BOUNDARY.test(l)) {
// 消除对列表/引用/标题的正则匹配假阳性(作为段落开头的)
if (RE_ATX.test(l)) break;
if (RE_QUOTE.test(l)) break;
if (RE_UL.test(l)) break;
if (RE_OL.test(l)) break;
if (RE_HR.test(l)) break;
if (RE_FOOTNOTE_DEF.test(l)) break;
if (RE_DEF_LIST.test(l)) break;
// 代码围栏和 Setext 分隔行属于段落的情况极罕见,不额外检测
}
// 检查下一行是否为 Setext 分隔行 → 不作为段落
if (i + 1 < lines.length && (RE_SETEXT_H1.test(lines[i + 1]) || RE_SETEXT_H2.test(lines[i + 1]))) {
break;
}
// 检查表格分隔行 → 如果这行含 | 且下一行是分隔行,不作为段落
if (/\|/.test(l) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
break;
}
para.push(l);
i++;
}
if (para.length) {
tokens.push({ type: 'paragraph', text: para.join('\n') });
}
}
let html = tokens.map((tok) => renderToken(tok, env, footnotes)).join('\n');
// 如果有脚注,追加脚注区
const fnIds = Object.keys(footnotes);
if (fnIds.length) {
html += '\n<div class="me-footnotes"><hr/><ol>';
fnIds.forEach((id, idx) => {
html += `<li id="fn-${id}" class="me-footnote-item"><a href="#fnref-${id}" class="me-footnote-backref">↩</a> ${cachedRenderInline(footnotes[id], env)}</li>`;
});
html += '</ol></div>';
}
return html;
};
// ============ 列表解析(嵌套支持) ============
/**
* 解析列表(含嵌套子列表)
* @param {string[]} lines - 所有行
* @param {number} startIdx - 列表起始行索引
* @param {string} marker - 标记字符(- * + 或数字)
* @param {number} baseIndent - 基本缩进
* @param {boolean} isOl - 是否为有序列表
* @param {number} [olStart] - 有序列表起始编号
* @returns {{ items: object[], endIdx: number }}
*/
const parseList = (lines, startIdx, marker, baseIndent, isOl, olStart) => {
const items = [];
let i = startIdx;
const itemIndent = baseIndent + (isOl ? String(marker).length + 2 : 2);
while (i < lines.length) {
const line = lines[i];
// 检测当前行是否为列表项
let itemMatch;
if (isOl) {
itemMatch = line.match(new RegExp(`^(\\s{${baseIndent}})\\d+\\.\\s`));
if (!itemMatch) break;
} else {
itemMatch = line.match(new RegExp(`^(\\s{${baseIndent}})[-*+]\\s`));
if (!itemMatch) break;
}
// 提取列表项内容
let raw = line.slice(itemMatch[0].length);
// 任务列表检测
const task = raw.match(RE_TASK);
let content = task ? task[2] : raw;
const checked = task ? task[1].toLowerCase() === 'x' : false;
const isTask = !!task;
i++;
// 收集当前列表项的连续内容(包括后续缩进行和子列表)
const subContent = [];
let subTokens = [];
while (i < lines.length) {
const cl = lines[i];
// 空行 → 可能段落分隔
if (RE_EMPTY.test(cl)) {
// 检查空行后是否还属于同一列表项(后续有缩进行)
let peek = i + 1;
while (peek < lines.length && RE_EMPTY.test(lines[peek])) peek++;
if (peek < lines.length) {
const pl = lines[peek];
const isContinuation = pl.length > itemIndent && pl[itemIndent] !== ' '
? false : new RegExp(`^\\s{${itemIndent},}`).test(pl);
if (isContinuation) {
subContent.push('');
i++;
continue;
}
}
break;
}
// 同级或上级列表项 → 结束当前项
if (isOl) {
if (new RegExp(`^(\\s{0,${baseIndent}})\\d+\\.\\s`).test(cl)) break;
} else {
if (new RegExp(`^(\\s{0,${baseIndent}})[-*+]\\s`).test(cl)) break;
}
// 其他块级元素 → 结束当前项
if (RE_QUOTE.test(cl) || RE_ATX.test(cl) || RE_HR.test(cl) || RE_FOOTNOTE_DEF.test(cl)) break;
// 嵌套子列表(缩进 >= itemIndent + 1
const nestedUlMatch = cl.match(new RegExp(`^(\\s{${itemIndent},})([-*+])\\s`));
const nestedOlMatch = cl.match(new RegExp(`^(\\s{${itemIndent},})(\\d+)\\.\\s`));
if (nestedUlMatch || nestedOlMatch) {
const isNestedOl = !!nestedOlMatch;
const nestedMarker = isNestedOl ? nestedOlMatch[2] : nestedUlMatch[2];
const nestedIndent = isNestedOl ? nestedOlMatch[1].length : nestedUlMatch[1].length;
const nestedStart = isNestedOl ? parseInt(nestedOlMatch[2], 10) : undefined;
const nested = parseList(lines, i, nestedMarker, nestedIndent, isNestedOl, nestedStart);
subTokens.push({
type: isNestedOl ? 'ol' : 'ul',
items: nested.items,
start: nestedStart,
});
i = nested.endIdx;
continue;
}
// 续行(缩进行或普通文本行)
if (cl.length > itemIndent && cl.slice(0, itemIndent).trim() === '') {
subContent.push(cl.slice(itemIndent));
} else {
subContent.push(cl);
}
i++;
}
// 合并子内容
if (subContent.length) {
content = content ? content + '\n' + subContent.join('\n') : subContent.join('\n');
}
items.push({
type: 'listItem',
text: content,
checked,
task: isTask,
subTokens: subTokens.length ? subTokens : undefined,
});
}
return { items, endIdx: i };
};
// ============ 表格工具 ============
const isTableSeparator = (line) => {
return RE_TABLE_SEP.test(line) && /-/.test(line);
};
const parseAlign = (line) => {
const cells = line.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
return cells.map((c) => {
const l = /^:/.test(c);
const r = /:$/.test(c);
if (l && r) return 'center';
if (r) return 'right';
if (l) return 'left';
return 'left';
});
};
// ============ Token 渲染 ============
const renderToken = (tok, env, footnotes) => {
switch (tok.type) {
case 'heading': {
const id = slugify(tok.text);
return `<h${tok.level} id="${id}">${cachedRenderInline(tok.text, env)}</h${tok.level}>`;
}
case 'paragraph':
return `<p>${cachedRenderInline(tok.text, env)}</p>`;
case 'hr':
return '<hr/>';
case 'quote':
return `<blockquote>${parseMarkdown(tok.content, env)}</blockquote>`;
case 'code':
return renderCode(tok.content, tok.lang, env);
case 'ul': {
const body = tok.items.map((it) => renderListItem(it, env)).join('');
return `<ul>${body}</ul>`;
}
case 'ol': {
const startNum = tok.start || 1;
const startAttr = startNum > 1 ? ` start="${startNum}"` : '';
const body = tok.items.map((it, idx) => renderListItem(it, env, startNum > 1 ? idx + startNum : undefined)).join('');
return `<ol${startAttr}>${body}</ol>`;
}
case 'table':
return renderTable(tok, env);
case 'defList': {
let html = '<dl>';
html += `<dt>${cachedRenderInline(tok.term, env)}</dt>`;
tok.defs.forEach((d) => { html += `<dd>${cachedRenderInline(d, env)}</dd>`; });
html += '</dl>';
return html;
}
case 'mathBlock':
return `<div class="me-math-block">${escapeHTML(tok.content)}</div>`;
default:
return '';
}
};
const renderListItem = (it, env, idx) => {
if (it.task) {
const checked = it.checked ? ' checked' : '';
let html = `<li class="me-task-item"><input type="checkbox" disabled${checked}/> ${cachedRenderInline(it.text, env)}`;
// 子 tokens(嵌套列表)
if (it.subTokens) {
html += '\n' + it.subTokens.map((st) => renderToken(st, env, null)).join('\n');
}
html += '</li>';
return html;
}
let html = idx != null ? `<li value="${idx}">` : '<li>';
html += cachedRenderInline(it.text, env);
if (it.subTokens) {
html += '\n' + it.subTokens.map((st) => renderToken(st, env, null)).join('\n');
}
html += '</li>';
return html;
};
// ============ 代码块渲染 ============
const renderCode = (code, lang, env) => {
const langClass = lang ? ` class="language-${escapeHTML(lang)}"` : '';
if (env.highlight && typeof env.highlight === 'function' && lang) {
try {
const highlighted = env.highlight(code, lang);
if (typeof highlighted === 'string') {
return `<pre><code${langClass}>${highlighted}</code></pre>`;
}
} catch (e) {
console.error('MeEditor highlight error:', e);
}
}
return `<pre><code${langClass}>${escapeHTML(code)}</code></pre>`;
};
// ============ 表格渲染 ============
const renderTable = (tok, env) => {
const splitRow = (r) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
const headers = splitRow(tok.header);
const align = tok.align || [];
const alignStyle = (i) => align[i] && align[i] !== 'left' ? ` style="text-align:${align[i]}"` : '';
let html = '<div class="me-table-wrap"><table><thead><tr>';
html += headers.map((h, i) => `<th${alignStyle(i)}>${cachedRenderInline(h, env)}</th>`).join('');
html += '</tr></thead><tbody>';
html += tok.rows.map((r) => {
const cells = splitRow(r);
return `<tr>${cells.map((c, i) => `<td${alignStyle(i)}>${cachedRenderInline(c, env)}</td>`).join('')}</tr>`;
}).join('');
html += '</tbody></table></div>';
return html;
};
// ============ 内联渲染(单遍扫描优化版) ============
/**
* 渲染内联元素
* 流程:提取代码 → 提取实体 → HTML 转义 → 还原实体 → 单遍扫描 → 还原代码 → Emoji → 换行
*/
const renderInline = (text, env) => {
if (!text) return '';
// 1. 提取行内代码到占位符
const codes = [];
let s = extractInlineCodes(text, codes);
// 2. 提取 HTML 注释和实体引用到占位符,避免被 DOM-based escapeHTML 破坏
const protectedItems = [];
s = s.replace(/&(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});/g, (m) => {
const idx = protectedItems.length;
protectedItems.push(m);
return `\u0005${idx}\u0005`;
});
s = s.replace(/<!--[\s\S]*?-->/g, (m) => {
const idx = protectedItems.length;
protectedItems.push(m);
return `\u0005${idx}\u0005`;
});
// 3. HTML 转义
s = escapeHTML(s);
// 4. 还原被保护的实体/注释
s = s.replace(/\u0005(\d+)\u0005/g, (m, idx) => protectedItems[+idx] || m);
// 5. 单遍扫描处理所有内联语法
s = scanInline(s, codes, env);
// 6. 还原行内代码
s = s.replace(/\u0000(\d+)\u0000/g, (m, idx) => {
const code = codes[+idx];
if (!code) return m;
return `<code>${escapeHTML(code.content)}</code>`;
});
// 7. Emoji 短码 :name:
s = s.replace(/:([\w+-]+):/g, (m, name) => EMOJI_MAP[name] || m);
// 8. 处理换行:硬换行(行尾2空格+换行 → <br>)→ 软换行(普通换行 → <br>
s = s.replace(/ \n/g, '<br/>\n');
s = s.replace(/\n/g, '<br/>');
return s;
};
/**
* 提取行内代码(CommonMark 兼容反引号规则)
* `` ` `` → code contains `
* ` code ` → code
* ``` `code` ``` → code contains `code`
*/
const extractInlineCodes = (text, codes) => {
// 匹配反引号串:开始和结束使用相同数量的反引号
let result = '';
let i = 0;
while (i < text.length) {
// 查找反引号开始
if (text[i] === '`') {
let startLen = 1;
while (i + startLen < text.length && text[i + startLen] === '`') startLen++;
// 搜索匹配的结束反引号串
let j = i + startLen;
let found = false;
while (j < text.length) {
if (text[j] === '`') {
let endLen = 1;
while (j + endLen < text.length && text[j + endLen] === '`') endLen++;
if (endLen === startLen) {
// 匹配成功
const content = text.slice(i + startLen, j);
const idx = codes.length;
codes.push({ content, len: startLen });
result += `\u0000${idx}\u0000`;
i = j + endLen;
found = true;
break;
} else {
j += endLen;
}
} else {
j++;
}
}
if (!found) {
// 未能匹配闭合,原样保留
result += text.slice(i, i + startLen);
i += startLen;
}
} else {
result += text[i];
i++;
}
}
return result;
};
/**
* 单遍内联扫描正则(模块级常量,避免每次调用重新编译)
*/
const INLINE_RE = new RegExp([
'(!\\[[^\\]]*\\]\\([^)]+\\))',
'|(?<!!)(\\[[^\\]]+\\]\\([^)]+\\))',
'|(!\\[[^\\]]*\\]\\[[^\\]]*\\])',
'|(?<!!)(\\[[^\\]]+\\]\\[[^\\]]*\\])',
'|(&lt;https?:\\/\\/[^\\s&]+&gt;)',
'|(\\*\\*[^*\\n][^*\\n]*?\\*\\*)',
'|(__[^_\\n][^_\\n]*?__)',
'|(~~[^\\n]+?~~)',
'|(==[^\\n]+?==)',
'|((?<=^|[^*])\\*(?!\\*)([^*\\n]+?)\\*(?!\\*))',
'|((?<=^|[^_])_(?!_)([^_\\n]+?)_(?!_))',
'|(\\^[^\\s^][^\\s^]*?\\^)',
'|(~[^\\s~][^\\s~]*?~)',
'|(?<!\\$)(\\$(?!\\$)[^\\n]+?\\$(?!\\$))',
'|(\\[\\^[^\\]]+\\])',
].join(''), 'g');
/**
* 单遍内联扫描(将所有内联语法合并到一个正则处理)
*/
const scanInline = (s, codes, env) => {
// 先保护代码占位符 \u0000N\u0000,避免被正则破坏
const placeholderMap = new Map();
s = s.replace(/\u0000\d+\u0000/g, (m) => {
const key = `\u0003${placeholderMap.size}\u0003`;
placeholderMap.set(key, m);
return key;
});
s = s.replace(INLINE_RE, (fullMatch, ...groups) => {
const match = fullMatch;
// 图片 ![...](...)
if (match.startsWith('![') && match.includes('](')) {
const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
if (!m) return match;
const u = safeUrl(m[2]);
if (!u) return escapeHTML(match);
const t = m[3] ? ` title="${m[3]}"` : '';
return `<img src="${u}" alt="${m[1]}"${t} loading="lazy"/>`;
}
// 图片引用 ![...][ref]
if (match.startsWith('![') && match.includes('][')) {
const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/);
if (!m) return match;
return `<img src="" alt="${m[1]}" class="me-img-ref"/>`;
}
// 链接 [...](...)
if (match.startsWith('[') && match.includes('](')) {
const m = match.match(/\[([^\]]+)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
if (!m) return match;
const u = safeUrl(m[2]);
if (!u) return match;
const t = m[3] ? ` title="${m[3]}"` : '';
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${m[1]}</a>`;
}
// 链接引用 [...][ref] 或 [...][]
if (match.startsWith('[') && match.includes('][')) {
// 引用暂不支持,保留原文本
return match;
}
// 自动链接
if (match.startsWith('&lt;http')) {
const url = match.slice(4, -4);
const u = safeUrl(url);
return `<a href="${u}" target="_blank" rel="noopener noreferrer">${url}</a>`;
}
// 粗体 **...**
if (match.startsWith('**')) return `<strong>${match.slice(2, -2)}</strong>`;
// 粗体 __...__
if (match.startsWith('__')) return `<strong>${match.slice(2, -2)}</strong>`;
// 删除线
if (match.startsWith('~~')) return `<del>${match.slice(2, -2)}</del>`;
// 高亮
if (match.startsWith('==')) return `<mark>${match.slice(2, -2)}</mark>`;
// 斜体 *...*(在粗体/删除线后处理,避免 **_ 误匹配)
if (match.startsWith('*') && !match.startsWith('**'))
return `<em>${match.slice(1, -1)}</em>`;
// 斜体 _..._
if (match.startsWith('_') && !match.startsWith('__'))
return `<em>${match.slice(1, -1)}</em>`;
// 上标
if (match.startsWith('^') && !match.startsWith('^^'))
return `<sup>${match.slice(1, -1)}</sup>`;
// 下标
if (match.startsWith('~') && !match.startsWith('~~'))
return `<sub>${match.slice(1, -1)}</sub>`;
// 行内公式
if (match.startsWith('$') && !match.startsWith('$$'))
return `<span class="me-math-inline">${escapeHTML(match.slice(1, -1))}</span>`;
// 脚注引用 [^n]
if (/^\[\^/.test(match)) {
const fnId = match.slice(2, -1);
return `<sup class="me-footnote-ref"><a href="#fn-${fnId}" id="fnref-${fnId}">[${fnId}]</a></sup>`;
}
return match;
});
// 还原占位符
s = s.replace(/\u0003(\d+)\u0003/g, (m, idx) => {
return placeholderMap.get(m) || m;
});
return s;
};
// ============ 导出 ============
export { parseMarkdown, safeUrl, slugify, clearRenderCache };
export default parseMarkdown;