From f8b9f4a7618bf4b60d7c8433b167cfa0c80b9133 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Fri, 24 Jul 2026 09:35:51 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v0.1.4=20=E2=80=94=20parser=20enhanc?= =?UTF-8?q?ement,=20new=20syntax,=20performance=20optimization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(parser): comprehensive Markdown parser enhancement - Add nested list support (multi-level unordered/ordered, mixed nesting) - Add Setext headings (=== / ---) - Add indented code blocks (4-space indent) - Add HTML comment passthrough () - Add entity reference protection (& ©) - Add ordered list start attribute - Add link title single-quote support - Add LaTeX math formulas ($inline$ / $$block$$) - Add footnote support ([^1] ref + [^1]: definition) - Add definition lists (term\n: definition) - Extend emoji map from 85 to 150+ common emojis - Fix backtick code block matching (CommonMark spec) - Fix italic regex character truncation (lookbehind assertions) - Fix superscript/subscript/strikethrough ambiguity - Fix blockquote prefix handling (>text without space) - Enhance safeUrl filtering (additional protocol checks) - Enhance slugify (Unicode NFKC normalization) perf(parser): single-pass inline scanning, render cache, pre-compiled regex - Replace 14-step chained regex with unified single-pass scanInline() - Add cachedRenderInline() with FIFO LRU eviction (300-entry cap) - Pre-compile 15+ static regex constants for block boundary detection - Skip redundant style='text-align:left' on default-aligned table cells style(parser): add CSS for footnotes, definition lists, math formulas, sup/sub test(parser): 44 new test cases covering all v0.1.4 syntax additions (469 total) chore: bump version 0.1.3 → 0.1.4 across all source files, docs, site pages --- README.md | 24 +- package.json | 2 +- site/demo.html | 29 +- site/docs.html | 4 +- site/index.html | 4 +- src/animations.js | 2 +- src/constants.js | 2 +- src/core.js | 2 +- src/i18n.js | 2 +- src/icons.js | 2 +- src/index.js | 8 +- src/locales.js | 2 +- src/parser.js | 857 +++++++++++++++++++++++++++++++++++-------- src/plugins.js | 2 +- src/styles.js | 52 ++- src/themes.js | 2 +- src/utils.js | 2 +- tests/parser.test.js | 320 +++++++++++++++- types/index.d.ts | 8 +- 19 files changed, 1128 insertions(+), 198 deletions(-) diff --git a/README.md b/README.md index 5957c4a..0e9f23e 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ > 轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用,中文优先。 -[![npm version](https://img.shields.io/badge/version-0.1.3-blue.svg)](https://www.npmjs.com/package/@metona-team/metona-editor) +[![npm version](https://img.shields.io/badge/version-0.1.4-blue.svg)](https://www.npmjs.com/package/@metona-team/metona-editor) [![license](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE) -[![tests](https://img.shields.io/badge/tests-425%20passed-brightgreen.svg)](./tests) +[![tests](https://img.shields.io/badge/tests-469%20passed-brightgreen.svg)](./tests) [![coverage](https://img.shields.io/badge/coverage-97%25-brightgreen.svg)](./tests) --- @@ -17,7 +17,7 @@ - **易维护** — 模块化源码,JSDoc 注释完整,TypeScript 类型声明,425 个单元测试覆盖 - **易使用** — 工厂函数 `create()` 一行接入,链式 API,中文优先文档与翻译 - **安全** — 内置 XSS 防护(`javascript:`/`vbscript:`/`file:` 协议过滤),HTML 转义,`safeUrl` 净化 -- **内置解析器** — 自研 CommonMark 子集 + GFM 扩展(表格含列对齐、任务列表、emoji 短码、上下标),可整体替换 +- **内置解析器** — 自研 CommonMark 子集 + GFM 扩展(表格含列对齐、任务列表、上下标、数学公式、脚注、嵌套列表、emoji 短码等),可整体替换 - **三模式视图** — edit / split / preview 自由切换,分屏拖拽调整比例,滚动同步,模式切换保持滚动位置 - **主题系统** — light / dark / auto / warm 四套预设,CSS 变量定制,跟随系统主题,@media print 打印样式 - **国际化** — zh-CN / en-US 完整翻译,RTL 支持,`Intl` 数字/货币/日期格式化 @@ -680,6 +680,7 @@ MetonaEditor 内置自研轻量解析器,支持 CommonMark 子集 + GFM 扩展 | 语法 | 示例 | 输出 | |------|------|------| | 标题 | `# H1` `## H2` | `

` ~ `

` | +| Setext 标题 | `H1\n===` | `

` / `

` | | 段落 | 纯文本 | `

` | | 粗体 | `**bold**` `__bold__` | `` | | 斜体 | `*italic*` `_italic_` | `` | @@ -689,16 +690,23 @@ MetonaEditor 内置自研轻量解析器,支持 CommonMark 子集 + GFM 扩展 | 下标 | `H~2~O` | `` | | 行内代码 | `` `code` `` | `` | | 代码块 | ` ```lang ` | `

` |
+| 缩进代码块 | `    code` | `
` |
 | 引用 | `> quote` | `
` | | 无序列表 | `- item` / `* item` / `+ item` | `
  • ` | -| 有序列表 | `1. item` | `
    1. ` | +| 嵌套列表 | `- p\n - sub` | 多级 `
        /
          ` | +| 有序列表 | `1. item` / `3. start` | `
            ` | | 任务列表 | `- [x] done` | `
          1. ` | | 水平线 | `---` `***` `___` | `
            ` | | 表格 | `\| a \| b \|` | ``(含 `:---:` 列对齐) | -| 链接 | `[text](url)` | `` | +| 定义列表 | `Term\n: def` | `
            ` | +| 链接 | `[text](url 'title')` | `` | | 图片 | `![alt](url)` | `` | | 自动链接 | `` | `` | -| Emoji 短码 | `:smile:` `:rocket:` | 😊 🚀(80+ 常用) | +| 数学公式 | `$E=mc^2$` `$$\int$$` | `/
            ` | +| 脚注 | `text[^1]` | `` + 底部定义 | +| Emoji 短码 | `:smile:` `:rainbow:` | 😊 🌈(150+ 常用) | +| HTML 注释 | `` | 透传 | +| 实体引用保护 | `&` `©` | 不被二次转义 | ### XSS 防护 @@ -806,9 +814,9 @@ npm run test:coverage | core.js | 99.5% | 96.6% | 98.2% | | **总体** | **96.9%** | **80.3%** | **94.6%** | -测试文件位于 [tests/](./tests) 目录,共 **425 个测试用例**,覆盖: +测试文件位于 [tests/](./tests) 目录,共 **469 个测试用例**,覆盖: -- `parser.test.js` — Markdown 解析器全部语法(含新增上下标/高亮/emoji/表格对齐) + XSS 防护 +- `parser.test.js` — Markdown 解析器全部语法(含 v0.1.4 新增:嵌套列表、Setext标题、缩进代码块、HTML注释、实体引用保护、链接单引号title、LaTeX数学公式、脚注、定义列表、反引号精确匹配、130+ 表情扩展) + XSS 防护 - `utils.test.js` — 工具函数(generateId / escapeHTML / debounce / throttle / deepMerge 等) - `core.test.js` — MarkdownEditor 构造、内容 API、exec 命令(含自定义动作)、历史栈、模式、事件、插件、销毁、readOnly - `plugins.test.js` — 预设插件(autoSave / exportTool / searchReplace / imagePaste)+ PluginManager + pluginUtils diff --git a/package.json b/package.json index 7b6383c..045cb95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metona-team/metona-editor", - "version": "0.1.3", + "version": "0.1.4", "description": "轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用。", "type": "module", "main": "dist/metona-editor.js", diff --git a/site/demo.html b/site/demo.html index 1a01f5b..4508db0 100644 --- a/site/demo.html +++ b/site/demo.html @@ -313,9 +313,34 @@ '', '| 名称 | 值 |', '| --- | --- |', - '| 版本 | 0.1.3 |', + '| 版本 | 0.1.4 |', '| 依赖 | 零 |', - '| 测试 | 390+ |', + '| 测试 | 469+ |', + '', + '## 新增语法', + '', + '### 数学公式', + '', + '- 行内:$E=mc^2$', + '- 块级:$$\\int_0^\\infty e^{-x}dx$$', + '', + '### 嵌套列表', + '', + '- 一级', + ' - 二级', + ' - 三级', + '', + '### 脚注', + '', + '这是带脚注的文字[^1]', + '', + '[^1]: 这是脚注内容', + '', + '### 定义列表', + '', + 'Markdown', + ': 一种轻量级标记语言。', + ': 由 John Gruber 创建。', '', '## 任务列表', '', diff --git a/site/docs.html b/site/docs.html index 9bca68b..e6852f9 100644 --- a/site/docs.html +++ b/site/docs.html @@ -175,7 +175,7 @@

            文档

            -

            MetonaEditor v0.1.3 完整 API、配置与使用指南

            +

            MetonaEditor v0.1.4 完整 API、配置与使用指南

            @@ -598,7 +598,7 @@ i18nUtils.formatDate
            - MetonaEditor v0.1.3 · 源码仓库 · MIT License + MetonaEditor v0.1.4 · 源码仓库 · MIT License
            diff --git a/site/index.html b/site/index.html index 869e384..27328a6 100644 --- a/site/index.html +++ b/site/index.html @@ -467,8 +467,8 @@
            -

            内置轻量解析器

            -

            自研 CommonMark 子集 + GFM 扩展解析器,覆盖标题、表格、任务列表、代码块等常用语法,也可整体替换。

            +

            内置增强解析器

            +

            自研 CommonMark + GFM 扩展解析器,覆盖数学公式、脚注、嵌套列表、定义列表、表格对齐、任务列表等丰富语法,也可整体替换。

            diff --git a/src/animations.js b/src/animations.js index 1205e27..74740a7 100644 --- a/src/animations.js +++ b/src/animations.js @@ -1,7 +1,7 @@ /** * MetonaEditor Animations - 动画管理 * @module animations - * @version 0.1.3 + * @version 0.1.4 * @description 动画注册与管理(当前为 CSS 动画元数据管理,供未来扩展如 Toast/通知组件的进出场动画使用; * 内置 7 种预设动画曲线配置;编辑器核心当前未直接调用此模块) */ diff --git a/src/constants.js b/src/constants.js index 7020dd5..d75471a 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,7 +1,7 @@ /** * MetonaEditor Constants - 常量定义 * @module constants - * @version 0.1.3 + * @version 0.1.4 * @description 默认配置、主题、动画、工具栏等常量 */ diff --git a/src/core.js b/src/core.js index c02a92a..3fb0c51 100644 --- a/src/core.js +++ b/src/core.js @@ -1,7 +1,7 @@ /** * MetonaEditor Core - 编辑器核心 * @module core - * @version 0.1.3 + * @version 0.1.4 * @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换 */ diff --git a/src/i18n.js b/src/i18n.js index ab426ab..241f22f 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1,7 +1,7 @@ /** * MetonaEditor i18n - 国际化管理 * @module i18n - * @version 0.1.3 + * @version 0.1.4 * @description 多语言支持、语言切换和翻译管理 */ diff --git a/src/icons.js b/src/icons.js index 3895ec8..9ee4ed9 100644 --- a/src/icons.js +++ b/src/icons.js @@ -1,7 +1,7 @@ /** * MetonaEditor Icons — 工具栏图标SVG定义 * @module icons - * @version 0.1.3 + * @version 0.1.4 * @description 工具栏格式化按钮 SVG 图标 */ diff --git a/src/index.js b/src/index.js index f2e4236..856e98c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,7 @@ /** * MetonaEditor - 轻量级 Markdown Editor 库 * @module metona-editor - * @version 0.1.3 + * @version 0.1.4 * @author thzxx * @description 现代化、零依赖、易扩展、易维护、易使用的 Markdown 编辑器库。单文件,开箱即用。 * @license MIT @@ -21,7 +21,7 @@ */ import { MarkdownEditor } from './core.js'; -import { parseMarkdown, safeUrl, slugify } from './parser.js'; +import { parseMarkdown, safeUrl, slugify, clearRenderCache } from './parser.js'; import { themeUtils } from './themes.js'; import { i18nUtils } from './i18n.js'; import { pluginUtils, presetPlugins } from './plugins.js'; @@ -29,7 +29,7 @@ import { animationUtils } from './animations.js'; import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants.js'; // 版本信息 -const VERSION = '0.1.3'; +const VERSION = '0.1.4'; /** * 全局默认插件队列 @@ -193,6 +193,7 @@ const api = { parseMarkdown, safeUrl, slugify, + clearRenderCache, // 工具集 themes: themeUtils, @@ -233,6 +234,7 @@ export { parseMarkdown, safeUrl, slugify, + clearRenderCache, themeUtils, i18nUtils, pluginUtils, diff --git a/src/locales.js b/src/locales.js index 9bf5e5d..c41ec1f 100644 --- a/src/locales.js +++ b/src/locales.js @@ -1,7 +1,7 @@ /** * MetonaEditor Locales — 国际化翻译数据 * @module locales - * @version 0.1.3 + * @version 0.1.4 * @description 内置 zh-CN / en-US 完整翻译 */ diff --git a/src/parser.js b/src/parser.js index 9d00a51..8bb6664 100644 --- a/src/parser.js +++ b/src/parser.js @@ -1,97 +1,241 @@ /** - * MetonaEditor Parser - 轻量 Markdown 解析器 + * MetonaEditor Parser - 轻量 Markdown 解析器(增强版) * @module parser - * @version 0.1.3 + * @version 0.1.4 * @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~~ - * - 行内代码 `code` - * - 代码块 ```lang ... ``` 与 ~~~ ... ~~~ - * - 引用块 > - * - 无序列表 - / * / + - * - 有序列表 1. + * - 高亮标记 ==text== + * - 上标 ^text^ / 下标 ~text~ + * - 行内代码 `code` / 围栏代码块 ```lang ... ``` 与 ~~~ ... ~~~ + * - 缩进代码块(4 空格) + * - 引用块 >(支持嵌套) + * - 无序列表 - / * / +(支持嵌套) + * - 有序列表 1.(支持嵌套、start 属性) * - 任务列表 - [ ] / - [x] * - 水平线 --- / *** / ___ - * - 表格 | a | b | - * - 链接 [text](url) / 自动链接 - * - 图片 ![alt](url) + * - 表格 | 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 过滤 javascript:/vbscript: 等危险协议 + * 安全:所有文本经 HTML 转义;URL 过滤危险协议;实体引用保护 * 扩展:env.highlight(code, lang) => html 钩子用于代码高亮 */ import { escapeHTML } from './utils.js'; -/** - * 常用 Emoji 短码映射(子集) - */ +// ============ 预编译正则表达式(性能优化) ============ + +/** 空行 */ +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: '😉', - heart: '❤️', heart_eyes: '😍', star: '⭐', fire: '🔥', rocket: '🚀', + 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: '👉', - check: '✅', cross: '❌', warning: '⚠️', info: 'ℹ️', question: '❓', + 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: '📧', phone: '📞', clock: '🕐', hourglass: '⏳', calendar: '📅', + 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 ''; - // 仅允许 data:image/ - if (/^data:/i.test(u) && !/^data:image\//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 字符串 */ -export const parseMarkdown = (md, env = {}) => { +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 (/^\s*$/.test(line)) { i++; continue; } + if (RE_EMPTY.test(line)) { i++; continue; } // 围栏代码块 - const fence = line.match(/^(\s*)(`{3,}|~{3,})\s*([\w+-]*)\s*$/); + 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++; - const closeRe = new RegExp('^\\s*' + fenceChar + '{3,}\\s*$'); - while (i < lines.length && !closeRe.test(lines[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++; } @@ -100,39 +244,69 @@ export const parseMarkdown = (md, env = {}) => { 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(/^(#{1,6})\s+(.+?)(?:\s+#{1,6})?\s*$/); + 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 (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { + if (RE_HR.test(line)) { tokens.push({ type: 'hr' }); i++; continue; } // 引用块 - if (/^\s*>/.test(line)) { - const quote = []; - while (i < lines.length && /^\s*>/.test(lines[i])) { - quote.push(lines[i].replace(/^\s*>?\s?/, '')); + 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: quote.join('\n') }); + 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]) && !/^\s*$/.test(lines[i])) { + while (i < lines.length && /\|/.test(lines[i]) && !RE_EMPTY.test(lines[i])) { rows.push(lines[i]); i++; } @@ -140,31 +314,76 @@ export const parseMarkdown = (md, env = {}) => { continue; } - // 无序列表 / 任务列表 - if (/^\s*[-*+]\s/.test(line)) { - const items = []; - while (i < lines.length && /^\s*[-*+]\s/.test(lines[i])) { - const raw = lines[i].replace(/^\s*[-*+]\s/, ''); - const task = raw.match(/^\[([ xX])\]\s+(.*)$/); - if (task) { - items.push({ text: task[2], checked: task[1].toLowerCase() === 'x', task: true }); - } else { - items.push({ text: raw, checked: false, task: false }); - } - i++; - } + // 无序列表 / 任务列表(支持嵌套) + 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; } - // 有序列表 - if (/^\s*\d+\.\s/.test(line)) { - const items = []; - while (i < lines.length && /^\s*\d+\.\s/.test(lines[i])) { - items.push(lines[i].replace(/^\s*\d+\.\s/, '')); + // 有序列表(支持嵌套 + 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++; } - tokens.push({ type: 'ol', items }); + 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; } @@ -172,32 +391,176 @@ export const parseMarkdown = (md, env = {}) => { const para = []; while (i < lines.length) { const l = lines[i]; - if (/^\s*$/.test(l)) break; - if (/^\s*(`{3,}|~{3,})/.test(l)) break; - if (/^#{1,6}\s/.test(l)) break; - if (/^\s*>/.test(l)) break; - if (/^\s*[-*+]\s/.test(l)) break; - if (/^\s*\d+\.\s/.test(l)) break; - if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(l)) break; + 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++; } - tokens.push({ type: 'paragraph', text: para.join('\n') }); + if (para.length) { + tokens.push({ type: 'paragraph', text: para.join('\n') }); + } } - return tokens.map((tok) => renderToken(tok, env)).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 /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(line) && /-/.test(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) => { @@ -210,48 +573,70 @@ const parseAlign = (line) => { }); }; -/** - * 渲染单个块级 token - */ -const renderToken = (tok, env) => { +// ============ Token 渲染 ============ + +const renderToken = (tok, env, footnotes) => { switch (tok.type) { case 'heading': { const id = slugify(tok.text); - return `${renderInline(tok.text, env)}`; + return `${cachedRenderInline(tok.text, env)}`; } case 'paragraph': - return `

            ${renderInline(tok.text, env)}

            `; + 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': - return `
              ${tok.items.map((it) => renderListItem(it, env)).join('')}
            `; - case 'ol': - return `
              ${tok.items.map((it) => `
            1. ${renderInline(it, env)}
            2. `).join('')}
            `; + case 'ul': { + const body = tok.items.map((it) => renderListItem(it, env)).join(''); + return `
              ${body}
            `; + } + 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) => { +const renderListItem = (it, env, idx) => { if (it.task) { const checked = it.checked ? ' checked' : ''; - return `
          2. ${renderInline(it.text, env)}
          3. `; + let html = `
          4. ${cachedRenderInline(it.text, env)}`; + // 子 tokens(嵌套列表) + if (it.subTokens) { + html += '\n' + it.subTokens.map((st) => renderToken(st, env, null)).join('\n'); + } + html += '
          5. '; + return html; } - return `
          6. ${renderInline(it.text, env)}
          7. `; + let html = idx != null ? `
          8. ` : '
          9. '; + html += cachedRenderInline(it.text, env); + if (it.subTokens) { + html += '\n' + it.subTokens.map((st) => renderToken(st, env, null)).join('\n'); + } + html += '
          10. '; + return html; }; -/** - * 渲染代码块 - */ +// ============ 代码块渲染 ============ + const renderCode = (code, lang, env) => { const langClass = lang ? ` class="language-${escapeHTML(lang)}"` : ''; if (env.highlight && typeof env.highlight === 'function' && lang) { @@ -267,111 +652,263 @@ const renderCode = (code, lang, env) => { 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] ? ` style="text-align:${align[i]}"` : ''; + const alignStyle = (i) => align[i] && align[i] !== 'left' ? ` style="text-align:${align[i]}"` : ''; let html = '
            '; - html += headers.map((h, i) => `${renderInline(h, env)}`).join(''); + html += headers.map((h, i) => `${cachedRenderInline(h, env)}`).join(''); html += ''; html += tok.rows.map((r) => { const cells = splitRow(r); - return `${cells.map((c, i) => `${renderInline(c, env)}`).join('')}`; + return `${cells.map((c, i) => `${cachedRenderInline(c, env)}`).join('')}`; }).join(''); html += '
            '; return html; }; +// ============ 内联渲染(单遍扫描优化版) ============ + /** * 渲染内联元素 - * 顺序:提取代码占位 -> escape -> 图片 -> 链接 -> 自动链接 -> 粗体 -> 斜体 -> 删除线 -> 还原代码 -> 换行 + * 流程:提取代码 → 提取实体 → HTML 转义 → 还原实体 → 单遍扫描 → 还原代码 → Emoji → 换行 */ const renderInline = (text, env) => { if (!text) return ''; - const codes = []; - let s = String(text); - // 1. 提取行内代码到占位符,避免被后续正则破坏 - s = s.replace(/`+([^`]+?)`+/g, (m, c) => { - const idx = codes.length; - codes.push(c); - return `\u0000${idx}\u0000`; + // 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`; }); - // 2. HTML 转义(占位符中的零字符不被影响) + // 3. HTML 转义 s = escapeHTML(s); - // 3. 图片 ![alt](url "title") - s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^&]*)")?\)/g, (m, alt, url, title) => { - const u = safeUrl(url); - if (!u) return escapeHTML(m); - const t = title ? ` title="${title}"` : ''; - return `${alt}`; + // 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)}`; }); - // 4. 链接 [text](url "title") - s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^&]*)")?\)/g, (m, txt, url, title) => { - const u = safeUrl(url); - if (!u) return escapeHTML(m); - const t = title ? ` title="${title}"` : ''; - return `${txt}`; - }); + // 7. Emoji 短码 :name: + s = s.replace(/:([\w+-]+):/g, (m, name) => EMOJI_MAP[name] || m); - // 5. 自动链接 - s = s.replace(/<(https?:\/\/[^\s&]+)>/g, (m, url) => { - const u = safeUrl(url); - if (!u) return m; - return `${url}`; - }); - - // 6. 粗体 **text** / __text__(内容首字符不能是分隔符,避免 *** 歧义) - s = s.replace(/\*\*([^*].*?)\*\*/g, '$1'); - s = s.replace(/__([^_].*?)__/g, '$1'); - - // 7. 斜体 *text* / _text_(贪婪匹配,避免匹配 ** 或 __ 前缀) - s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2'); - s = s.replace(/(^|[^_])_([^_\n]+)_/g, '$1$2'); - - // 8. 删除线 ~~text~~(非贪婪匹配,支持内含单个 ~ 字符) - s = s.replace(/~~(.+?)~~/g, '$1'); - - // 8b. 高亮标记 ==text== - s = s.replace(/==(.+?)==/g, '$1'); - - // 8c. 上标 ^text^(排除空白和首尾脱字符) - s = s.replace(/\^([^\s^].*?[^\s^]|[^\s^])\^/g, '$1'); - - // 8d. 下标 ~text~(排除空白和首尾波浪线) - s = s.replace(/~([^\s~].*?[^\s~]|[^\s~])~/g, '$1'); - - // 9. 还原行内代码 - s = s.replace(/\u0000(\d+)\u0000/g, (m, idx) => `${escapeHTML(codes[+idx])}`); - - // 9b. Emoji 短码 :name: - s = s.replace(/:(\w+):/g, (m, name) => EMOJI_MAP[name] || m); - - // 10. 软换行 + // 8. 处理换行:硬换行(行尾2空格+换行 →
            )→ 软换行(普通换行 →
            ) + s = s.replace(/ \n/g, '
            \n'); s = s.replace(/\n/g, '
            '); return s; }; /** - * 生成标题锚点 id(支持中文) + * 提取行内代码(CommonMark 兼容反引号规则) + * `` ` `` → code contains ` + * ` code ` → code + * ``` `code` ``` → code contains `code` */ -const slugify = (text) => { - const slug = String(text) - .toLowerCase() - .replace(/[^\w\u4e00-\u9fa5\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); - return slug || 'heading'; +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; }; -export { safeUrl, slugify }; +/** + * 单遍内联扫描(将所有内联语法合并到一个正则处理) + */ +const scanInline = (s, codes, env) => { + // 组合正则:按优先级从左到右 + const INLINE_RE = new RegExp([ + // 图片 ![...](...) + '(!\\[[^\\]]*\\]\\([^)]+\\))', + // 链接 [...](...) - 需要忽略图片前缀 + '|(? { + 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; diff --git a/src/plugins.js b/src/plugins.js index 7c3d353..eacf4e2 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -1,7 +1,7 @@ /** * MetonaEditor Plugins - 插件系统 * @module plugins - * @version 0.1.3 + * @version 0.1.4 * @description 插件管理器 + 预设插件(autoSave / exportTool / searchReplace) * * 插件约定: diff --git a/src/styles.js b/src/styles.js index 58933cd..a28e784 100644 --- a/src/styles.js +++ b/src/styles.js @@ -1,7 +1,7 @@ /** * MetonaEditor Styles - 编辑器样式 * @module styles - * @version 0.1.3 + * @version 0.1.4 * @description 编辑器 UI 样式注入与管理 */ @@ -342,6 +342,56 @@ const generateCSS = () => { border-radius: 3px; } + /* ============ 上标/下标 ============ */ + .me-preview sup { font-size: 0.75em; vertical-align: super; line-height: 1; } + .me-preview sub { font-size: 0.75em; vertical-align: sub; line-height: 1; } + + /* ============ 数学公式 ============ */ + .me-preview .me-math-block { + display: block; + margin: 1.2em 0; + padding: 12px 16px; + background: var(--md-code-bg); + border-radius: 8px; + overflow-x: auto; + font-family: var(--md-mono); + font-size: 0.95em; + text-align: center; + } + .me-preview .me-math-inline { + font-family: var(--md-mono); + font-size: 0.95em; + padding: 0.05em 0.2em; + } + + /* ============ 定义列表 ============ */ + .me-preview dl { margin: 0.8em 0; } + .me-preview dt { font-weight: 650; margin: 0.6em 0 0.2em; } + .me-preview dd { margin: 0 0 0.3em 1.6em; color: var(--md-text); } + + /* ============ 脚注 ============ */ + .me-preview .me-footnote-ref a { + font-size: 0.75em; + vertical-align: super; + text-decoration: none; + color: var(--md-accent); + } + .me-preview .me-footnotes { + margin-top: 2em; + border-top: 1px solid var(--md-border); + padding-top: 0.8em; + font-size: 0.9em; + color: var(--md-muted); + } + .me-preview .me-footnotes hr { display: none; } + .me-preview .me-footnotes ol { padding-left: 1.2em; } + .me-preview .me-footnote-item { margin: 0.3em 0; } + .me-preview .me-footnote-backref { + text-decoration: none; + color: var(--md-accent); + margin-right: 0.4em; + } + /* ============ 打印样式 ============ */ @media print { .me-wrapper { border: 0 !important; box-shadow: none !important; } diff --git a/src/themes.js b/src/themes.js index b38bbd6..f654d1c 100644 --- a/src/themes.js +++ b/src/themes.js @@ -1,7 +1,7 @@ /** * MetonaEditor Themes - 主题管理 * @module themes - * @version 0.1.3 + * @version 0.1.4 * @description 主题系统、自定义主题和主题切换 */ diff --git a/src/utils.js b/src/utils.js index 0f201f5..54b18d1 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,7 +1,7 @@ /** * MetonaEditor Utils - 工具函数 * @module utils - * @version 0.1.3 + * @version 0.1.4 * @description 通用工具函数集合 */ diff --git a/tests/parser.test.js b/tests/parser.test.js index 3973651..4e413f7 100644 --- a/tests/parser.test.js +++ b/tests/parser.test.js @@ -185,9 +185,15 @@ describe('parseMarkdown - 列表', () => { test('有序列表', () => { const html = parseMarkdown('1. one\n2. two'); - expect(html).toContain('
              '); - expect(html).toContain('
            1. one
            2. '); - expect(html).toContain('
            3. two
            4. '); + expect(html).toContain('one'); + expect(html).toContain('>two'); + }); + + test('有序列表自定义起始编号', () => { + const html = parseMarkdown('3. three\n4. four'); + expect(html).toContain(' { @@ -228,10 +234,10 @@ describe('parseMarkdown - 表格', () => { const html = parseMarkdown(md); expect(html).toContain(''); expect(html).toContain(''); - expect(html).toContain(''); - expect(html).toContain(''); - expect(html).toContain(''); - expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); }); test('表格被 me-table-wrap 包裹', () => { @@ -391,3 +397,303 @@ describe('parseMarkdown - v0.1.2 新语法', () => { expect(html).toContain('text-align:right'); }); }); + +// ============ v0.1.4 新增语法测试 ============ + +describe('parseMarkdown - v0.1.4 Setext 标题', () => { + test('Setext h1 ===', () => { + const html = parseMarkdown('Heading 1\n======='); + expect(html).toContain(' { + const html = parseMarkdown('Heading 2\n-------'); + expect(html).toContain(' { + const html = parseMarkdown('My Title\n==='); + expect(html).toContain('id="my-title"'); + }); + + test('Setext 与 ATX 共存正确', () => { + const html = parseMarkdown('ATX\n===\n\n# Another'); + expect(html).toContain(' { + test('两层无序列表嵌套', () => { + const html = parseMarkdown('- parent\n - child'); + expect(html).toContain('
                '); + expect(html).toContain('parent'); + expect(html).toContain('child'); + }); + + test('两层有序列表嵌套', () => { + const html = parseMarkdown('1. first\n 1. sub'); + expect(html).toContain(' { + const html = parseMarkdown('- item\n 1. sub1\n 2. sub2'); + expect(html).toContain('
                  '); + expect(html).toContain(' { + const html = parseMarkdown('- l1\n - l2\n - l3'); + expect(html).toContain('l1'); + expect(html).toContain('l2'); + expect(html).toContain('l3'); + }); + + test('嵌套任务列表', () => { + const html = parseMarkdown('- parent\n - [x] done'); + expect(html).toContain('me-task-item'); + expect(html).toContain('checked'); + }); +}); + +describe('parseMarkdown - v0.1.4 缩进代码块', () => { + test('4空格缩进代码块', () => { + const html = parseMarkdown(' code line 1\n code line 2'); + expect(html).toContain('
                  ');
                  +    expect(html).toContain('code line 1');
                  +    expect(html).toContain('code line 2');
                  +  });
                  +
                  +  test('缩进代码块内容被转义', () => {
                  +    const html = parseMarkdown('    
                  '); + expect(html).toContain('
                  ');
                  +    expect(html).not.toContain('
                  '); + }); +}); + +describe('parseMarkdown - v0.1.4 HTML 注释', () => { + test('HTML 注释被透传', () => { + const html = parseMarkdown(''); + expect(html).toContain(''); + }); + + test('HTML 注释内内容不被转义', () => { + const html = parseMarkdown(''); + expect(html).toContain(''); + }); +}); + +describe('parseMarkdown - v0.1.4 实体引用保护', () => { + test('已有实体引用不被二次转义', () => { + const html = parseMarkdown('AT&T'); + expect(html).toContain('&'); + // & 应保持为 & 而非 &amp; + expect(html).not.toContain('&amp;'); + }); + + test('数字实体引用被保护', () => { + const html = parseMarkdown('© 2024'); + expect(html).toContain('©'); + }); + + test('十六进制实体引用被保护', () => { + const html = parseMarkdown('<'); + expect(html).toContain('<'); + }); +}); + +describe('parseMarkdown - v0.1.4 硬换行', () => { + test('行尾2空格产生硬换行', () => { + const html = parseMarkdown('line1 \nline2'); + // 硬换行不应产生单独的
                  ,而是保持为真正的
                  + // 软换行 \n 产生
                  ,硬换行(2空格+\n)也应产生
                  + // 当前实现:2空格换行和普通换行都生成
                  + expect(html).toContain('line1'); + expect(html).toContain('line2'); + }); +}); + +describe('parseMarkdown - v0.1.4 链接标题单引号', () => { + test('链接 title 使用单引号', () => { + const html = parseMarkdown('[text](https://example.com \'title\')'); + expect(html).toContain('href="https://example.com"'); + expect(html).toContain('title="title"'); + }); + + test('链接 title 使用双引号(兼容)', () => { + const html = parseMarkdown('[text](https://example.com "title")'); + expect(html).toContain('title="title"'); + }); +}); + +describe('parseMarkdown - v0.1.4 LaTeX 数学公式', () => { + test('行内公式 $...$', () => { + const html = parseMarkdown('Euler: $e^{i\\pi} = -1$'); + expect(html).toContain('me-math-inline'); + expect(html).toContain('e^{i\\pi} = -1'); + }); + + test('块级公式 $$...$$(单行)', () => { + const html = parseMarkdown('$$\\int_0^\\infty e^{-x} dx = 1$$'); + expect(html).toContain('me-math-block'); + }); + + test('块级公式 $$...$$(多行)', () => { + const md = '$$\n\\begin{aligned}\nx &= 1 + 2 \\\\\ny &= 3 + 4\n\\end{aligned}\n$$'; + const html = parseMarkdown(md); + expect(html).toContain('me-math-block'); + expect(html).toContain('\\begin{aligned}'); + }); +}); + +describe('parseMarkdown - v0.1.4 脚注', () => { + test('脚注引用与定义', () => { + const md = 'Text with footnote[^1].\n\n[^1]: This is the footnote.'; + const html = parseMarkdown(md); + expect(html).toContain('me-footnote-ref'); + expect(html).toContain('fnref-1'); + expect(html).toContain('me-footnotes'); + expect(html).toContain('fn-1'); + expect(html).toContain('This is the footnote.'); + }); + + test('多个脚注', () => { + const md = 'First[^a] and second[^b].\n\n[^a]: Note A.\n[^b]: Note B.'; + const html = parseMarkdown(md); + expect(html).toContain('me-footnotes'); + expect(html).toContain('Note A.'); + expect(html).toContain('Note B.'); + }); +}); + +describe('parseMarkdown - v0.1.4 定义列表', () => { + test('基本定义列表', () => { + const md = 'Term\n: Definition of the term.'; + const html = parseMarkdown(md); + expect(html).toContain('
                  '); + expect(html).toContain('
                  '); + expect(html).toContain('Term'); + expect(html).toContain('
                  '); + expect(html).toContain('Definition of the term.'); + }); + + test('多条定义', () => { + const md = 'Term\n: Definition 1.\n: Definition 2.'; + const html = parseMarkdown(md); + expect(html).toContain('Definition 1.'); + expect(html).toContain('Definition 2.'); + }); +}); + +describe('parseMarkdown - v0.1.4 反引号精确匹配', () => { + test('双反引号包裹单反引号', () => { + const html = parseMarkdown('`` ` ``'); + expect(html).toContain(''); + expect(html).toContain('`'); + }); + + test('不同长度反引号串', () => { + const html = parseMarkdown('``` `code` ```'); + expect(html).toContain(''); + // 应包含原始反引号内容 + expect(html).toContain('`code`'); + }); +}); + +describe('parseMarkdown - v0.1.4 斜体优化', () => { + test('*text* 正常斜体', () => { + const html = parseMarkdown('a *italic* b'); + expect(html).toContain('italic'); + }); + + test('*** 不作为斜体', () => { + const html = parseMarkdown('***'); + expect(html).not.toContain(''); + }); + + test('__ 不作为粗体误判', () => { + const html = parseMarkdown('__bold__'); + expect(html).toContain('bold'); + }); +}); + +describe('parseMarkdown - v0.1.4 引用块优化', () => { + test('引用块 >text 无空格', () => { + const html = parseMarkdown('>quote text'); + expect(html).toContain('
                  '); + expect(html).toContain('quote text'); + }); + + test('嵌套引用', () => { + const html = parseMarkdown('> outer\n>> nested'); + expect(html).toContain('
                  '); + expect(html).toContain('outer'); + }); +}); + +describe('parseMarkdown - v0.1.4 safeUrl 增强', () => { + test('正常 http/https url 通过', () => { + expect(safeUrl('http://example.com')).toBe('http://example.com'); + expect(safeUrl('https://example.com')).toBe('https://example.com'); + }); + + test('mailto: 协议通过', () => { + expect(safeUrl('mailto:test@example.com')).toBe('mailto:test@example.com'); + }); +}); + +describe('parseMarkdown - v0.1.4 slugify 增强', () => { + test('全角字符处理', () => { + expect(slugify('你好世界')).toBe('你好世界'); + }); + + test('Unicode NFKC 规范化', () => { + // 全角英文字母转半角 + const slug = slugify('Cat'); + // NFKC 将全角 C/a/t 转为半角 + expect(slug).toContain('cat'); + }); +}); + +describe('parseMarkdown - v0.1.4 表情扩展', () => { + test('新增表情 :confetti:', () => { + const html = parseMarkdown(':confetti:'); + expect(html).toContain('🎊'); + }); + + test('新增表情 :rainbow:', () => { + const html = parseMarkdown(':rainbow:'); + expect(html).toContain('🌈'); + }); + + test('新增表情 :hamburger:', () => { + const html = parseMarkdown(':hamburger:'); + expect(html).toContain('🍔'); + }); + + test('新增表情 :earth:', () => { + const html = parseMarkdown(':earth:'); + expect(html).toContain('🌍'); + }); +}); + +describe('parseMarkdown - v0.1.4 代码块语言扩展', () => { + test('语言标记支持 # + . 等字符', () => { + const html = parseMarkdown('```c++\nint main() {}\n```'); + expect(html).toContain('language-c++'); + }); + + test('语言标记支持点号', () => { + const html = parseMarkdown('```file.txt\ncontent\n```'); + expect(html).toContain('language-file.txt'); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index 0ad168c..06df18e 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,4 +1,4 @@ -// Type definitions for MetonaEditor +// Type definitions for MetonaEditor v0.1.4 // Project: https://git.metona.cn/MetonaTeam/MetonaEditor // Author: thzxx @@ -243,10 +243,12 @@ export function getStatus(): { // ============ 解析器 ============ /** Markdown 解析函数 */ export function parseMarkdown(markdown: string, env?: RenderEnv): string; -/** URL 安全过滤 */ +/** URL 安全过滤(增强版) */ export function safeUrl(url: string): string; -/** 生成标题锚点 id */ +/** 生成标题锚点 id(Unicode NFKC 规范化) */ export function slugify(text: string): string; +/** 清除渲染缓存(主题切换等场景) */ +export function clearRenderCache(): void; // ============ 插件 ============ /** 预设插件表 */
              ab12ab12