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:
+16
-10
@@ -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
@@ -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][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>
|
||||
'|(<https?:\\/\\/[^\\s&]+>)',
|
||||
// 粗体 **...** / __...__
|
||||
'|(\\*\\*[^*\\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(') {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user