/** * 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] / 自动链接 * - 图片 ![alt](url) / ![alt][ref] * - 转义字符 \X * - LaTeX 数学公式 $inline$ / $$block$$ * - 脚注 [^1] / [^1]: 定义 * - 定义列表 term\n: definition * - HTML 注释 * - 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

    '; fnIds.forEach((id, idx) => { html += `
  1. ${cachedRenderInline(footnotes[id], env)}
  2. `; }); html += '
'; } 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 `${cachedRenderInline(tok.text, env)}`; } case 'paragraph': return `

${cachedRenderInline(tok.text, env)}

`; case 'hr': return '
'; case 'quote': return `
${parseMarkdown(tok.content, env)}
`; case 'code': return renderCode(tok.content, tok.lang, env); case 'ul': { const body = tok.items.map((it) => renderListItem(it, env)).join(''); return ``; } 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 `${body}`; } case 'table': return renderTable(tok, env); case 'defList': { let html = '
'; html += `
${cachedRenderInline(tok.term, env)}
`; tok.defs.forEach((d) => { html += `
${cachedRenderInline(d, env)}
`; }); html += '
'; return html; } case 'mathBlock': return `
${escapeHTML(tok.content)}
`; default: return ''; } }; const renderListItem = (it, env, idx) => { if (it.task) { const checked = it.checked ? ' checked' : ''; let html = `
  • ${cachedRenderInline(it.text, env)}`; // 子 tokens(嵌套列表) if (it.subTokens) { html += '\n' + it.subTokens.map((st) => renderToken(st, env, null)).join('\n'); } html += '
  • '; return html; } let html = idx != null ? `
  • ` : '
  • '; html += cachedRenderInline(it.text, env); if (it.subTokens) { html += '\n' + it.subTokens.map((st) => renderToken(st, env, null)).join('\n'); } html += '
  • '; 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 `
    ${highlighted}
    `; } } catch (e) { console.error('MeEditor highlight error:', e); } } return `
    ${escapeHTML(code)}
    `; }; // ============ 表格渲染 ============ 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 = '
    '; html += headers.map((h, i) => `${cachedRenderInline(h, env)}`).join(''); html += ''; html += tok.rows.map((r) => { const cells = splitRow(r); return `${cells.map((c, i) => `${cachedRenderInline(c, env)}`).join('')}`; }).join(''); html += '
    '; 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(//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 `${escapeHTML(code.content)}`; }); // 7. Emoji 短码 :name: s = s.replace(/:([\w+-]+):/g, (m, name) => EMOJI_MAP[name] || m); // 8. 处理换行:硬换行(行尾2空格+换行 →
    )→ 软换行(普通换行 →
    ) s = s.replace(/ \n/g, '
    \n'); s = s.replace(/\n/g, '
    '); 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([ '(!\\[[^\\]]*\\]\\([^)]+\\))', '|(? { // 先保护代码占位符 \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 `${m[1]}`; } // 图片引用 ![...][ref] if (match.startsWith('![') && match.includes('][')) { const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/); if (!m) return match; return `${m[1]}`; } // 链接 [...](...) 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 `${m[1]}`; } // 链接引用 [...][ref] 或 [...][] if (match.startsWith('[') && match.includes('][')) { // 引用暂不支持,保留原文本 return match; } // 自动链接 if (match.startsWith('<http')) { const url = match.slice(4, -4); const u = safeUrl(url); return `${url}`; } // 粗体 **...** if (match.startsWith('**')) return `${match.slice(2, -2)}`; // 粗体 __...__ if (match.startsWith('__')) return `${match.slice(2, -2)}`; // 删除线 if (match.startsWith('~~')) return `${match.slice(2, -2)}`; // 高亮 if (match.startsWith('==')) return `${match.slice(2, -2)}`; // 斜体 *...*(在粗体/删除线后处理,避免 **_ 误匹配) if (match.startsWith('*') && !match.startsWith('**')) return `${match.slice(1, -1)}`; // 斜体 _..._ if (match.startsWith('_') && !match.startsWith('__')) return `${match.slice(1, -1)}`; // 上标 if (match.startsWith('^') && !match.startsWith('^^')) return `${match.slice(1, -1)}`; // 下标 if (match.startsWith('~') && !match.startsWith('~~')) return `${match.slice(1, -1)}`; // 行内公式 if (match.startsWith('$') && !match.startsWith('$$')) return `${escapeHTML(match.slice(1, -1))}`; // 脚注引用 [^n] if (/^\[\^/.test(match)) { const fnId = match.slice(2, -1); return `[${fnId}]`; } 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;