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
903 lines
30 KiB
JavaScript
903 lines
30 KiB
JavaScript
/**
|
||
* 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][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([
|
||
'(!\\[[^\\]]*\\]\\([^)]+\\))',
|
||
'|(?<!!)(\\[[^\\]]+\\]\\([^)]+\\))',
|
||
'|(!\\[[^\\]]*\\]\\[[^\\]]*\\])',
|
||
'|(?<!!)(\\[[^\\]]+\\]\\[[^\\]]*\\])',
|
||
'|(<https?:\\/\\/[^\\s&]+>)',
|
||
'|(\\*\\*[^*\\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(') {
|
||
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('<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;
|