feat(parser): v0.1.15 — parser short+medium term improvements

Short-term fixes:
- Fix ***bold italic*** / ___bold italic___ triple emphasis
- Fix link text inline formatting (recursive renderInline on [**bold**](url))
- Fix backtick inline code nesting (CommonMark-compliant extractInlineCodes)
- Fix tab indentation stripping in indented code blocks

Medium-term improvements:
- parseTokens() / renderTokens() separation API for token-level transform
- registerBlockHandler() table-driven block handler registry
- Backslash escape support (\* \_ \)

Also:
- Add 48 new test cases (583 total, 7 suites, all passing)
- Parser coverage: 95.43% stmts / 91.38% branches / 100% funcs / 98.25% lines
- Update README.md, site/, package.json to v0.1.15
This commit is contained in:
2026-07-24 21:39:11 +08:00
parent c6db68af59
commit d7cae48073
8 changed files with 968 additions and 253 deletions
+16 -10
View File
@@ -21,7 +21,7 @@
*/
import { MarkdownEditor } from './core.js';
import { parseMarkdown, safeUrl, slugify, clearRenderCache } from './parser.js';
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler } from './parser.js';
import { themeUtils, exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme } from './themes.js';
import { i18nUtils, createInstanceI18n, loadRemote } from './i18n.js';
import { pluginUtils, presetPlugins, topologicalSort, validateConfig } from './plugins.js';
@@ -189,11 +189,14 @@ const api = {
destroy,
getStatus,
// 内置解析器(可单独使用或替换)
parseMarkdown,
safeUrl,
slugify,
clearRenderCache,
// 内置解析器(可单独使用或替换)
parseMarkdown,
parseTokens,
renderTokens,
safeUrl,
slugify,
clearRenderCache,
registerBlockHandler,
// 工具集
themes: themeUtils,
@@ -246,10 +249,13 @@ export {
setLocale,
destroy,
getStatus,
parseMarkdown,
safeUrl,
slugify,
clearRenderCache,
parseMarkdown,
parseTokens,
renderTokens,
safeUrl,
slugify,
clearRenderCache,
registerBlockHandler,
themeUtils,
i18nUtils,
pluginUtils,
+416 -226
View File
@@ -1,13 +1,22 @@
/**
* MetonaEditor Parser - 轻量 Markdown 解析器(增强版)
* MetonaEditor Parser - 轻量 Markdown 解析器(v0.1.15 增强版)
* @module parser
* @version 0.1.14
* @version 0.1.15
* @description 自研零依赖 Markdown 解析器,覆盖 CommonMark 子集 + GFM 扩展
*
* v0.1.15 增强:
* - 修复 ***bold italic*** / ___bold italic___ 三重强调语法
* - 修复链接/图片文本内支持行内格式(递归渲染)
* - 修复反引号代码块精确嵌套匹配(`` ` `` / ``` `` ``` 等)
* - 修复缩进代码块 Tab 字符剥离
* - parseTokens() / renderTokens() 分离:中间 Token 数组可供用户变换
* - 块级处理器表驱动架构:优先级显式、易扩展新语法
* - 转义反斜杠支持 \* \_ \[ 等
*
* v0.1.4 增强:
* - 嵌套列表(支持多级缩进)
* - Setext 标题(=== / ---
* - 缩进代码块(4 空格)
* - 缩进代码块(4 空格或 1 Tab
* - HTML 注释透传
* - 实体引用保护
* - 引用链接 [text][ref]
@@ -27,12 +36,13 @@
* - 标题 # ~ ###### / Setext === ---
* - 段落 / 软换行 / 硬换行(行尾2空格)
* - 粗体 **text** / __text__
* - 粗斜体 ***text*** / ___text___ ← v0.1.15 新增
* - 斜体 *text* / _text_
* - 删除线 ~~text~~
* - 高亮标记 ==text==
* - 上标 ^text^ / 下标 ~text~
* - 行内代码 `code` / 围栏代码块 ```lang ... ``` 与 ~~~ ... ~~~
* - 缩进代码块(4 空格)
* - 缩进代码块(4 空格或 Tab
* - 引用块 >(支持嵌套)
* - 无序列表 - / * / +(支持嵌套)
* - 有序列表 1.(支持嵌套、start 属性)
@@ -41,7 +51,7 @@
* - 表格 | a | b |(含列对齐 :---:
* - 链接 [text](url) / [text][ref] / 自动链接 <url>
* - 图片 ![alt](url) / ![alt][ref]
* - 转义字符 \X
* - 转义字符 \X ← v0.1.15 增强
* - LaTeX 数学公式 $inline$ / $$block$$
* - 脚注 [^1] / [^1]: 定义
* - 定义列表 term\n: definition
@@ -50,6 +60,10 @@
*
* 安全:所有文本经 HTML 转义;URL 过滤危险协议;实体引用保护
* 扩展:env.highlight(code, lang) => html 钩子用于代码高亮
* API
* parseMarkdown(md, env) → HTML(便捷全流程)
* parseTokens(md) → { tokens, footnotes }(中间 Token 数组,可变换后传入 renderTokens
* renderTokens(tokens, env, footnotes) → HTML(从 Token 渲染)
*/
import { escapeHTML } from './utils.js';
@@ -78,13 +92,11 @@ 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_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+ ============
@@ -200,16 +212,287 @@ const slugify = (text) => {
return slug || 'heading';
};
// ============ 块级解析 ============
// ============ 块级处理器注册表(v0.1.15 表驱动架构) ============
/**
* 主解析入口
* @param {string} md - Markdown 源文本
* @param {Object} env - 渲染环境 { highlight, locale }
* @returns {string} HTML 字符串
* 每个处理器形如:
* {
* name: 'heading',
* priority: 10, // 数字越小越先尝试
* test: (line, lines, i) => match | null, // 检测当前行是否匹配
* parse: (lines, i, match, tokens) => { token, newIndex } // 解析并返回 token 和新索引
* }
*/
const parseMarkdown = (md, env = {}) => {
if (md == null) return '';
const blockHandlers = [];
const registerBlockHandler = (handler) => {
blockHandlers.push(handler);
// 按 priority 升序排列
blockHandlers.sort((a, b) => a.priority - b.priority);
};
// ---------- 注册各块级处理器 ----------
// 1. 空行(优先级最高,跳过)
registerBlockHandler({
name: 'blank',
priority: 0,
test: (line) => RE_EMPTY.test(line) ? true : null,
parse: (lines, i) => ({ token: null, newIndex: i + 1 }),
});
// 2. 围栏代码块
registerBlockHandler({
name: 'fencedCode',
priority: 1,
test: (line) => line.match(RE_FENCE_START),
parse: (lines, i, match) => {
const fenceChar = match[2][0];
const fenceLen = match[2].length;
const lang = match[3] || '';
const codeLines = [];
let j = i + 1;
while (j < lines.length) {
const closeMatch = lines[j].match(/^(\s{0,3})(`{3,}|~{3,})\s*$/);
if (closeMatch && closeMatch[2][0] === fenceChar && closeMatch[2].length >= fenceLen) {
break;
}
codeLines.push(lines[j]);
j++;
}
if (j < lines.length) j++; // 跳过结束围栏
return { token: { type: 'code', lang, content: codeLines.join('\n') }, newIndex: j };
},
});
// 3. 缩进代码块
registerBlockHandler({
name: 'indentedCode',
priority: 2,
test: (line) => {
if (!RE_INDENT_CODE.test(line)) return null;
if (RE_ATX.test(line) || RE_QUOTE.test(line) || RE_UL.test(line) || RE_OL.test(line)) return null;
return true;
},
parse: (lines, i) => {
const codeLines = [];
while (i < lines.length && RE_INDENT_CODE.test(lines[i])) {
// v0.1.15 修复:兼容空格和 Tab 缩进剥离(4空格或1Tab)
codeLines.push(lines[i].replace(/^( {4}|\t)/, ''));
i++;
}
if (codeLines.length) {
return { token: { type: 'code', lang: '', content: codeLines.join('\n'), indent: true }, newIndex: i };
}
return { token: null, newIndex: i };
},
});
// 4. ATX 标题
registerBlockHandler({
name: 'atxHeading',
priority: 3,
test: (line) => line.match(RE_ATX),
parse: (lines, i, match) => ({
token: { type: 'heading', level: match[1].length, text: match[2] },
newIndex: i + 1,
}),
});
// 5. Setext 标题 h1
registerBlockHandler({
name: 'setextH1',
priority: 4,
test: (line, lines, i) => {
if (i + 1 >= lines.length) return null;
if (!RE_SETEXT_H1.test(lines[i + 1])) return null;
if (RE_EMPTY.test(line) || RE_UL.test(line) || RE_OL.test(line)
|| RE_QUOTE.test(line) || /^\s*`{3,}/.test(line)) return null;
return true;
},
parse: (lines, i) => ({
token: { type: 'heading', level: 1, text: lines[i] },
newIndex: i + 2,
}),
});
// 6. Setext 标题 h2
registerBlockHandler({
name: 'setextH2',
priority: 5,
test: (line, lines, i) => {
if (i + 1 >= lines.length) return null;
if (!RE_SETEXT_H2.test(lines[i + 1])) return null;
if (RE_EMPTY.test(line) || RE_UL.test(line) || RE_OL.test(line)
|| RE_QUOTE.test(line) || /^\s*`{3,}/.test(line)) return null;
return true;
},
parse: (lines, i) => ({
token: { type: 'heading', level: 2, text: lines[i] },
newIndex: i + 2,
}),
});
// 7. 水平线
registerBlockHandler({
name: 'hr',
priority: 6,
test: (line) => RE_HR.test(line) ? true : null,
parse: (lines, i) => ({ token: { type: 'hr' }, newIndex: i + 1 }),
});
// 8. 引用块
registerBlockHandler({
name: 'blockquote',
priority: 7,
test: (line) => RE_QUOTE.test(line) ? true : null,
parse: (lines, i) => {
const quoteLines = [];
while (i < lines.length && RE_QUOTE.test(lines[i])) {
quoteLines.push(lines[i].replace(RE_QUOTE, ''));
i++;
}
return { token: { type: 'quote', content: quoteLines.join('\n') }, newIndex: i };
},
});
// 9. 表格
registerBlockHandler({
name: 'table',
priority: 8,
test: (line, lines, i) => {
if (!/\|/.test(line)) return null;
if (i + 1 >= lines.length) return null;
if (!isTableSeparator(lines[i + 1])) return null;
return { header: line, align: parseAlign(lines[i + 1]) };
},
parse: (lines, i, match) => {
let j = i + 2;
const rows = [];
while (j < lines.length && /\|/.test(lines[j]) && !RE_EMPTY.test(lines[j])) {
rows.push(lines[j]);
j++;
}
return { token: { type: 'table', header: match.header, rows, align: match.align }, newIndex: j };
},
});
// 10. 无序列表
registerBlockHandler({
name: 'ul',
priority: 9,
test: (line) => line.match(RE_UL),
parse: (lines, i, match) => {
const { items, endIdx } = parseList(lines, i, match[2], match[1].length, false);
return { token: { type: 'ul', items }, newIndex: endIdx };
},
});
// 11. 有序列表
registerBlockHandler({
name: 'ol',
priority: 10,
test: (line) => line.match(RE_OL),
parse: (lines, i, match) => {
const start = parseInt(match[2], 10);
const { items, endIdx } = parseList(lines, i, match[2], match[1].length, true, start);
return { token: { type: 'ol', items, start }, newIndex: endIdx };
},
});
// 12. 脚注定义
registerBlockHandler({
name: 'footnoteDef',
priority: 11,
test: (line) => line.match(RE_FOOTNOTE_DEF),
parse: (lines, i, match, tokens, footnotes) => {
const fnId = match[1];
const fnContent = lines[i].slice(match[0].length);
const fnLines = [fnContent];
let j = i + 1;
while (j < lines.length && !RE_EMPTY.test(lines[j]) && !isBlockStart(lines[j])) {
fnLines.push(lines[j]);
j++;
}
footnotes[fnId] = fnLines.join(' ');
return { token: null, newIndex: j };
},
});
// 13. 定义列表
registerBlockHandler({
name: 'defList',
priority: 12,
test: (line) => RE_DEF_LIST.test(line) ? true : null,
parse: (lines, i, match, tokens) => {
const term = (tokens.length > 0 && tokens[tokens.length - 1].type === 'paragraph')
? tokens.pop().text : '';
const defs = [lines[i].replace(RE_DEF_LIST, '')];
let j = i + 1;
while (j < lines.length && RE_DEF_LIST.test(lines[j])) {
defs.push(lines[j].replace(RE_DEF_LIST, ''));
j++;
}
return { token: { type: 'defList', term, defs }, newIndex: j };
},
});
// 14. LaTeX 块级公式
registerBlockHandler({
name: 'mathBlock',
priority: 13,
test: (line) => {
if (/^\$\$/.test(line)) return true;
return null;
},
parse: (lines, i) => {
// 单行公式
const singleMath = lines[i].match(/^\$\$([\s\S]+?)\$\$\s*$/);
if (singleMath) {
return { token: { type: 'mathBlock', content: singleMath[1] }, newIndex: i + 1 };
}
// 多行公式
const mathLines = [];
mathLines.push(lines[i].replace(/^\$\$/, ''));
let j = i + 1;
while (j < lines.length && !/\$\$/.test(lines[j])) {
mathLines.push(lines[j]);
j++;
}
if (j < lines.length) {
mathLines.push(lines[j].replace(/\$\$\s*$/, ''));
j++;
}
return { token: { type: 'mathBlock', content: mathLines.join('\n') }, newIndex: j };
},
});
// ============ 段落边界检测(用于 isBlockStart ============
const isBlockStart = (line) => {
// 检查所有块级处理器是否可以处理该行
if (RE_EMPTY.test(line)) return true;
if (RE_FENCE_START.test(line)) return true;
if (RE_ATX.test(line)) return true;
if (RE_HR.test(line)) return true;
if (RE_QUOTE.test(line)) return true;
if (RE_UL.test(line)) return true;
if (RE_OL.test(line)) return true;
if (RE_FOOTNOTE_DEF.test(line)) return true;
if (RE_DEF_LIST.test(line)) return true;
if (/^\$\$/.test(line)) return true;
return false;
};
// ============ 块级解析(v0.1.15 表驱动版) ============
/**
* 将 Markdown 解析为 Token 数组(新增分离 API)
* @param {string} md - Markdown 源文本
* @returns {{ tokens: Object[], footnotes: Object }}
*/
const parseTokens = (md) => {
if (md == null) return { tokens: [], footnotes: {} };
const text = String(md).replace(/\r\n?/g, '\n');
const lines = text.split('\n');
const tokens = [];
@@ -218,197 +501,33 @@ const parseMarkdown = (md, env = {}) => {
while (i < lines.length) {
const line = lines[i];
let handled = false;
// 空行
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;
// 遍历处理器表(按 priority 排序)
for (const handler of blockHandlers) {
const match = handler.test(line, lines, i);
if (match !== null && match !== false) {
const result = handler.parse(lines, i, match, tokens, footnotes);
if (result.token) tokens.push(result.token);
i = result.newIndex;
handled = true;
break;
}
}
// ATX 标题(非空标题文本)
const h = line.match(RE_ATX);
if (h) {
tokens.push({ type: 'heading', level: h[1].length, text: h[2] });
i++;
continue;
}
if (handled) 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 分隔行属于段落的情况极罕见,不额外检测
}
if (isBlockStart(l)) break;
// 检查下一行是否为 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;
}
@@ -420,13 +539,24 @@ const parseMarkdown = (md, env = {}) => {
}
}
return { tokens, footnotes };
};
/**
* 从 Token 数组渲染 HTML(新增分离 API)
* @param {Object[]} tokens - parseTokens 返回的 token 数组
* @param {Object} env - 渲染环境 { highlight, locale }
* @param {Object} footnotes - 脚注映射表
* @returns {string} HTML
*/
const renderTokens = (tokens, env = {}, footnotes = {}) => {
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) => {
fnIds.forEach((id) => {
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>';
@@ -435,6 +565,17 @@ const parseMarkdown = (md, env = {}) => {
return html;
};
/**
* 主解析入口(便捷版:解析+渲染一气呵成)
* @param {string} md - Markdown 源文本
* @param {Object} env - 渲染环境 { highlight, locale }
* @returns {string} HTML 字符串
*/
const parseMarkdown = (md, env = {}) => {
const { tokens, footnotes } = parseTokens(md);
return renderTokens(tokens, env, footnotes);
};
// ============ 列表解析(嵌套支持) ============
/**
@@ -477,14 +618,13 @@ const parseList = (lines, startIdx, marker, baseIndent, isOl, olStart) => {
// 收集当前列表项的连续内容(包括后续缩进行和子列表)
const subContent = [];
let subTokens = [];
const 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) {
@@ -619,7 +759,6 @@ 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');
}
@@ -716,7 +855,7 @@ const renderInline = (text, env) => {
return `<code>${escapeHTML(code.content)}</code>`;
});
// 7. Emoji 短码 :name:
// 7. Emoji 短码 :name:(仅在非 HTML 标签内替换)
s = s.replace(/:([\w+-]+):/g, (m, name) => EMOJI_MAP[name] || m);
// 8. 处理换行:硬换行(行尾2空格+换行 → <br>)→ 软换行(普通换行 → <br>
@@ -727,45 +866,50 @@ const renderInline = (text, env) => {
};
/**
* 提取行内代码(CommonMark 兼容反引号规则)
* 提取行内代码(v0.1.15 重写: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++;
// 查找反引号
const nextTick = text.indexOf('`', j);
if (nextTick === -1) break; // 无闭合
// 计算该位置的反引号串长度
let endLen = 1;
while (nextTick + endLen < text.length && text[nextTick + endLen] === '`') endLen++;
if (endLen === startLen) {
// 精确匹配:提取内容
const content = text.slice(i + startLen, nextTick);
const idx = codes.length;
codes.push({ content, len: startLen });
result += `\u0000${idx}\u0000`;
i = nextTick + endLen;
found = true;
break;
}
// 长度不匹配:继续搜索(跳过长反引号串)
j = nextTick + endLen;
}
if (!found) {
// 未能匹配闭合,原样保留
result += text.slice(i, i + startLen);
@@ -780,24 +924,40 @@ const extractInlineCodes = (text, codes) => {
};
/**
* 单遍内联扫描正则(模块级常量,避免每次调用重新编译
* 单遍内联扫描正则(v0.1.15 增强:新增 ***...*** / ___...___ 三重强调 + 反斜杠转义
*/
const INLINE_RE = new RegExp([
'(!\\[[^\\]]*\\]\\([^)]+\\))',
// 粗斜体 ***...*** / ___...___(在粗体前匹配,避免被 ** / __ 抢先)
'(\\*\\*\\*[^*\\n][^*\\n]*?\\*\\*\\*)',
'|(___[^_\\n][^_\\n]*?___)',
// 图片 ![...](...)
'|(!\\[[^\\]]*\\]\\([^)]+\\))',
// 链接 [...](...)(排除图片前缀)
'|(?<!!)(\\[[^\\]]+\\]\\([^)]+\\))',
// 图片引用 ![...][ref]
'|(!\\[[^\\]]*\\]\\[[^\\]]*\\])',
// 链接引用 [...][ref]
'|(?<!!)(\\[[^\\]]+\\]\\[[^\\]]*\\])',
// 自动链接 <url>
'|(&lt;https?:\\/\\/[^\\s&]+&gt;)',
// 粗体 **...** / __...__
'|(\\*\\*[^*\\n][^*\\n]*?\\*\\*)',
'|(__[^_\\n][^_\\n]*?__)',
// 删除线 / 高亮
'|(~~[^\\n]+?~~)',
'|(==[^\\n]+?==)',
// 斜体 *...* / _..._ (使用 lookbehind/lookahead 避免匹配 ** / __
'|((?<=^|[^*])\\*(?!\\*)([^*\\n]+?)\\*(?!\\*))',
'|((?<=^|[^_])_(?!_)([^_\\n]+?)_(?!_))',
// 上标 / 下标
'|(\\^[^\\s^][^\\s^]*?\\^)',
'|(~[^\\s~][^\\s~]*?~)',
// 行内公式
'|(?<!\\$)(\\$(?!\\$)[^\\n]+?\\$(?!\\$))',
// 脚注引用
'|(\\[\\^[^\\]]+\\])',
// 反斜杠转义(v0.1.15 新增)
'|(\\\\.)',
].join(''), 'g');
/**
@@ -816,6 +976,15 @@ const scanInline = (s, codes, env) => {
s = s.replace(INLINE_RE, (fullMatch, ...groups) => {
const match = fullMatch;
// 粗斜体 ***...*** (v0.1.15 新增)
if (match.startsWith('***')) {
return `<em><strong>${match.slice(3, -3)}</strong></em>`;
}
// 粗斜体 ___...___ (v0.1.15 新增)
if (match.startsWith('___')) {
return `<em><strong>${match.slice(3, -3)}</strong></em>`;
}
// 图片 ![...](...)
if (match.startsWith('![') && match.includes('](')) {
const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
@@ -833,14 +1002,16 @@ const scanInline = (s, codes, env) => {
return `<img src="" alt="${m[1]}" class="me-img-ref"/>`;
}
// 链接 [...](...)
// 链接 [...](...) — v0.1.15 修复:链接文本递归渲染内联格式
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>`;
// v0.1.15: 递归渲染链接文本中的行内格式(**bold** / *italic* 等)
const linkText = renderInline(m[1], env);
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${linkText}</a>`;
}
// 链接引用 [...][ref] 或 [...][]
@@ -866,10 +1037,10 @@ const scanInline = (s, codes, env) => {
if (match.startsWith('==')) return `<mark>${match.slice(2, -2)}</mark>`;
// 斜体 *...*(在粗体/删除线后处理,避免 **_ 误匹配)
if (match.startsWith('*') && !match.startsWith('**'))
if (match.startsWith('*') && !match.startsWith('**') && !match.startsWith('***'))
return `<em>${match.slice(1, -1)}</em>`;
// 斜体 _..._
if (match.startsWith('_') && !match.startsWith('__'))
if (match.startsWith('_') && !match.startsWith('__') && !match.startsWith('___'))
return `<em>${match.slice(1, -1)}</em>`;
// 上标
@@ -889,6 +1060,17 @@ const scanInline = (s, codes, env) => {
return `<sup class="me-footnote-ref"><a href="#fn-${fnId}" id="fnref-${fnId}">[${fnId}]</a></sup>`;
}
// 反斜杠转义(v0.1.15 新增)
if (match.startsWith('\\') && match.length === 2) {
const escaped = match[1];
// 可转义的 ASCII 标点字符(CommonMark 规范)
if (/[!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~]/.test(escaped)) {
return escaped;
}
// 非转义字符保留反斜杠
return match;
}
return match;
});
@@ -902,5 +1084,13 @@ const scanInline = (s, codes, env) => {
// ============ 导出 ============
export { parseMarkdown, safeUrl, slugify, clearRenderCache };
export {
parseMarkdown,
parseTokens,
renderTokens,
safeUrl,
slugify,
clearRenderCache,
registerBlockHandler, // v0.1.15: 允许外部注册自定义块级处理器
};
export default parseMarkdown;