@mteditor/renderer-web 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -41
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +12 -12
- package/dist/index.d.ts +12 -12
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.global.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/options.ts","../../document/src/utils/id.ts","../../document/src/types/base.ts","../../document/src/types/marks.ts","../../document/src/schema/nodes.ts","../../document/src/schema/marks.ts","../../document/src/schema/content-expression.ts","../../document/src/schema/index.ts","../../document/src/report/codes.ts","../../document/src/report/index.ts","../../document/src/utils/guards.ts","../../document/src/utils/url.ts","../../document/src/normalize/marks.ts","../../document/src/utils/stable-json.ts","../../document/src/normalize/text.ts","../../document/src/utils/escape.ts","../../document/src/sanitize/tokenizer.ts","../../document/src/utils/style-units.ts","../../document/src/utils/attr-values.ts","../../document/src/sanitize/policy.ts","../../document/src/sanitize/index.ts","../../document/src/sanitize/svg.ts","../../document/src/parse/context.ts","../../document/src/parse/html-atoms.ts","../../document/src/parse/html-marks.ts","../../document/src/parse/html-nodes.ts","../../document/src/parse/html.ts","../../document/src/parse/markdown-inline.ts","../../document/src/parse/markdown.ts","../../document/src/parse/index.ts","../../document/src/utils/hash.ts","../../document/src/utils/tree.ts","../../document/src/serialize/html-attrs.ts","../../document/src/serialize/html-marks.ts","../../document/src/serialize/html.ts","../../document/src/serialize/markdown.ts","../../document/src/serialize/mini-context.ts","../../document/src/serialize/mini-inline.ts","../../document/src/serialize/mini.ts","../../document/src/serialize/text.ts","../../document/src/serialize/index.ts","../../document/src/validate/table.ts","../../document/src/validate/index.ts","../../document/src/migrate/index.ts","../../document/src/index.ts","../src/container.ts","../src/render-content.ts","../src/render-to-string.ts","../src/render-to-dom.ts"],"sourcesContent":["/**\n * `@mteditor/renderer-web` —— Web 端只读渲染器(公开 API 入口)。\n *\n * 把 `MtDocument` JSON 渲染成 HTML 字符串或真实 DOM,**不加载编辑器实例**,\n * 因此可以在「详情页 / SEO 静态页 / 邮件预览」等场景独立使用。\n *\n * ## 与 `document` 的关系(ADR-0021)\n *\n * 本包**不实现自己的 HTML 序列化器**,HTML 一律由 `@mteditor/document` 的\n * `serializeDocumentToHtml` 产出,本包只负责三件事:**容器包装、主题接线、DOM 挂载**。\n * 这保证「详情页看到的」与「存库 / 导出 / 小程序端渲染的」来自同一套真相。\n *\n * ## 与 `core` 的关系\n *\n * 依赖方向为 `renderer-web` → `document`(AGENTS.md §5),**不得依赖 `core`**。\n *\n * ## 硬约束\n *\n * 1. **不改写序列化产物**:不注入 `loading=\"lazy\"`、不追加 `data-mt-*`、不做降级高亮;\n * 2. 降级报告必须能被调用方取到,不允许静默丢弃(§12.2);\n * 3. 主题默认跟随宿主(`data-mt-theme` 写在宿主上),只在显式传 `theme` 时局部覆盖。\n */\n\nexport { MT_CONTENT_CLASS, MT_THEME_ATTR } from './options'\nexport type { MtDomRenderResult, MtRenderOptions, MtRenderResult, MtTheme } from './options'\nexport { renderToString } from './render-to-string'\nexport { renderToDOM } from './render-to-dom'\nexport {\n applyContainerAttrs,\n containerAttrsToString,\n resolveContainerAttrs,\n wrapContainer,\n} from './container'\n","/**\n * `@mteditor/renderer-web` 的公开选项与结果类型(`docs/decisions/ADR-0021`)。\n */\n\nimport type { MtDegradationEntry, MtPreRenderedIndex, MtSerializeStats } from '@mteditor/document'\n\n/** 内容容器类名 —— 与 `@mteditor/theme-default` 的 `.mt-content` 约定一致(AGENTS.md §7.4) */\nexport const MT_CONTENT_CLASS = 'mt-content'\n\n/** 主题属性名 —— 主题层只认这一个属性(ADR-0007) */\nexport const MT_THEME_ATTR = 'data-mt-theme'\n\n/** 主题取值;不开放任意字符串,避免写出主题层无法识别的属性值 */\nexport type MtTheme = 'light' | 'dark'\n\n/** 渲染选项;全部字段可选 */\nexport interface MtRenderOptions {\n /**\n * 容器的**完整** `class` 值,缺省 `'mt-content'`。\n *\n * 需要追加自己的类名时请一并写出,例如 `'mt-content article-body'`;\n * 传空字符串则完全不输出 `class` 属性(此时将失去主题排版样式)。\n */\n className?: string\n /**\n * 主题。**缺省不写 `data-mt-theme`**,跟随宿主(AGENTS.md §7.4 的主题模型)。\n *\n * 只在需要**局部覆盖**时显式传入,例如邮件预览固定亮色。\n */\n theme?: MtTheme\n /** 预渲染产物索引(公式 SVG / 代码高亮,ADR-0012、ADR-0013) */\n preRendered?: MtPreRenderedIndex\n /** 缩进层级,缺省 `0`;仅影响 HTML 可读性,不参与语义 */\n indent?: number\n /** 是否返回降级报告,缺省 `true`。**置 `false` 等于主动放弃降级信息**(§12.2) */\n report?: boolean\n}\n\n/** 渲染结果(`renderToString` 的返回值) */\nexport interface MtRenderResult {\n /** 完整 HTML,**含外层容器** */\n html: string\n /** 降级报告;`report: false` 时为空数组 */\n report: MtDegradationEntry[]\n /** 序列化统计(**不含**外层容器,即 `document` 序列化器的原始统计) */\n stats: MtSerializeStats\n}\n\n/** DOM 渲染结果(`renderToDOM` 的返回值) */\nexport interface MtDomRenderResult extends MtRenderResult {\n /** 实际挂载进容器的元素 */\n element: HTMLElement\n}\n","/**\n * 块级节点 ID 的生成与校验(`docs/document-model.md` §3.1)。\n *\n * 格式:`blk_<base36 时间戳>_<4 位 base36 随机>`,例 `blk_m1x2y3_a4b5`。\n *\n * 设计要点:\n * - **可注入** `now` 与 `random`,测试可固定取值以获得可复现的 ID;\n * - 同一生成器内保证不重复(批量为一个文档补发 ID 时的硬要求)。\n */\n\n/** ID 前缀 */\nexport const MT_BLOCK_ID_PREFIX = 'blk_'\n\n/** base36 字符表 */\nconst BASE36 = '0123456789abcdefghijklmnopqrstuvwxyz'\n\n/** ID 生成器选项 */\nexport interface MtIdGeneratorOptions {\n /** 时间戳基准(毫秒),缺省取当前时间 */\n now?: number\n /** 随机数源,返回 `[0, 1)`,缺省 `Math.random` */\n random?: () => number\n /** 随机后缀长度,缺省 4 */\n suffixLength?: number\n}\n\n/**\n * 创建一个块级节点 ID 生成器。\n *\n * @param options 生成器选项\n * @returns 每次调用返回一个全新 ID 的函数\n *\n * @example\n * ```ts\n * let seed = 0\n * const next = createIdGenerator({ now: 1_700_000_000_000, random: () => (seed++ % 7) / 7 })\n * next() // 'blk_...'(同一 now 与 random 序列下完全可复现)\n * ```\n */\nexport const createIdGenerator = (options: MtIdGeneratorOptions = {}): (() => string) => {\n const now = options.now ?? Date.now()\n const random = options.random ?? Math.random\n const suffixLength = Math.max(2, options.suffixLength ?? 4)\n const timestamp = Math.max(0, Math.floor(now)).toString(36)\n const used = new Set<string>()\n\n return () => {\n for (let attempt = 0; attempt < 128; attempt += 1) {\n let suffix = ''\n for (let i = 0; i < suffixLength; i += 1) {\n suffix += BASE36.charAt(Math.floor(random() * 36))\n }\n const id = `${MT_BLOCK_ID_PREFIX}${timestamp}_${suffix}`\n if (!used.has(id)) {\n used.add(id)\n return id\n }\n }\n throw new Error('节点 ID 生成失败:随机后缀连续冲突,请检查注入的 random 实现')\n }\n}\n\n/**\n * 校验字符串是否形如块级节点 ID。\n *\n * 判定从宽(仅要求 `blk_` 前缀且不含空白),原因见 `docs/document-model.md` §2:\n * 最小合法文档的示例 ID 即为 `blk_`,不允许把它判为非法。\n *\n * @param value 待校验值\n * @returns 是否形如块级 ID\n *\n * @example\n * ```ts\n * isValidBlockId('blk_m1x2y3_a4b5') // true\n * isValidBlockId('node-1') // false\n * ```\n */\nexport const isValidBlockId = (value: unknown): value is string => {\n if (typeof value !== 'string') return false\n if (!value.startsWith(MT_BLOCK_ID_PREFIX)) return false\n for (let i = 0; i < value.length; i += 1) {\n const code = value.charCodeAt(i)\n // 空白与控制字符均视为非法\n if (code <= 32 || code === 127) return false\n }\n return true\n}\n\n/** 全局默认生成器:`core` 创建节点、`parse` 补发 ID 时共用,避免频繁构造 */\nconst defaultGenerator = createIdGenerator()\n\n/**\n * 用默认生成器产出一个块级节点 ID。\n *\n * @returns 全新 ID\n *\n * @example\n * ```ts\n * const id = createBlockId() // 'blk_...'\n * ```\n */\nexport const createBlockId = (): string => defaultGenerator()\n","/**\n * 基础字面量联合与模型版本号(`docs/document-model.md` §2、§3.4、§4.2–§4.5、§12)。\n *\n * 本文件只放**跨模块复用的原子类型**,不承载任何逻辑。\n */\n\n/**\n * 当前模型版本号。\n *\n * 变更规则见 §13.1:新增节点/标记或新增可选属性**不**升版本;\n * 属性重命名、内容表达式变更、必需属性新增**必须**升版本。\n */\nexport const MT_SCHEMA_VERSION = 1\n\n/** `schemaVersion` 的取值类型(当前仅 `1`) */\nexport type MtSchemaVersion = typeof MT_SCHEMA_VERSION\n\n/** 块级文本容器的水平对齐方式(§3.4) */\nexport type MtTextAlign = 'left' | 'center' | 'right' | 'justify'\n\n/** 媒体节点的水平对齐方式(§4.3,无 `justify`) */\nexport type MtMediaAlign = 'left' | 'center' | 'right'\n\n/** `orderedList` 的编号样式(§4.2) */\nexport type MtOrderedListType = '1' | 'a' | 'A' | 'i' | 'I'\n\n/** `heading` 的层级(§4.2,1–6) */\nexport type MtHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6\n\n/** `mention` 的引用类型(§4.5) */\nexport type MtMentionKind = 'user' | 'doc' | 'custom'\n\n/** 降级报告采取的补偿策略(§12) */\nexport type MtDegradationStrategy = 'dropped' | 'replaced' | 'flattened' | 'literalized'\n\n/** 校验问题的严重级别(§11):`error` 阻断序列化,`warning` 仅记录 */\nexport type MtIssueSeverity = 'error' | 'warning'\n\n/** 序列化目标(§10) */\nexport type MtSerializeTarget = 'html' | 'markdown' | 'text' | 'mini'\n\n/** 小程序平台标识(§10) */\nexport type MtMiniPlatform = 'wechat' | 'alipay' | 'bytedance'\n\n/** 小程序渲染通道(§10 / ADR-0005) */\nexport type MtMiniMode = 'component' | 'richText'\n\n/** 全部节点类型名(含未在 `MtBlockNode` 联合中出现的内部结构节点) */\nexport const MT_NODE_TYPES = [\n 'doc',\n 'paragraph',\n 'heading',\n 'blockquote',\n 'bulletList',\n 'orderedList',\n 'listItem',\n 'taskList',\n 'taskItem',\n 'codeBlock',\n 'horizontalRule',\n 'image',\n 'video',\n 'audio',\n 'attachment',\n 'mathBlock',\n 'table',\n 'tableRow',\n 'tableCell',\n 'tableHeader',\n 'text',\n 'hardBreak',\n 'mention',\n 'inlineMath',\n 'emoji',\n] as const\n\n/** 节点类型名的联合 */\nexport type MtNodeType = (typeof MT_NODE_TYPES)[number]\n\n/** 全部标记类型名(§5) */\nexport const MT_MARK_TYPES = [\n 'link',\n 'code',\n 'textStyle',\n 'bold',\n 'italic',\n 'underline',\n 'strike',\n 'highlight',\n 'subscript',\n 'superscript',\n] as const\n\n/** 标记类型名的联合 */\nexport type MtMarkType = (typeof MT_MARK_TYPES)[number]\n","/**\n * 标记(mark)类型定义(`docs/document-model.md` §5)。\n *\n * 约定:\n * - 标记只作用于 `text` 节点;\n * - 除 `codeBlock` 外,所有块级文本容器均允许标记;\n * - 序列化时按 §5 的**优先级降序由外向内**包裹,保证跨端嵌套顺序一致。\n */\n\n/** `link` 标记属性(§5) */\nexport interface MtLinkAttrs {\n /** 目标地址,必填 */\n href: string\n /** 打开方式,缺省 `'_blank'` */\n target?: string\n /** 安全属性,缺省 `'noopener noreferrer'` */\n rel?: string\n /** 链接标题 */\n title?: string\n}\n\n/** `textStyle` 标记属性(§5);全部可空,但至少一个非空 */\nexport interface MtTextStyleAttrs {\n fontFamily?: string | null\n fontSize?: string | null\n color?: string | null\n backgroundColor?: string | null\n lineHeight?: string | null\n letterSpacing?: string | null\n}\n\n/** `highlight` 标记属性(§5):`color` 必填 */\nexport interface MtHighlightAttrs {\n color: string\n}\n\n/** `link` 标记:不可嵌套自身 */\nexport interface MtLinkMark {\n type: 'link'\n attrs: MtLinkAttrs\n}\n\n/** `code` 标记:与 `textStyle` / `highlight` 互斥 */\nexport interface MtCodeMark {\n type: 'code'\n}\n\n/** `textStyle` 标记:单例,多次施加时属性合并 */\nexport interface MtTextStyleMark {\n type: 'textStyle'\n attrs: MtTextStyleAttrs\n}\n\n/** `bold` 标记 */\nexport interface MtBoldMark {\n type: 'bold'\n}\n\n/** `italic` 标记 */\nexport interface MtItalicMark {\n type: 'italic'\n}\n\n/** `underline` 标记 */\nexport interface MtUnderlineMark {\n type: 'underline'\n}\n\n/** `strike` 标记 */\nexport interface MtStrikeMark {\n type: 'strike'\n}\n\n/** `highlight` 标记 */\nexport interface MtHighlightMark {\n type: 'highlight'\n attrs: MtHighlightAttrs\n}\n\n/** `subscript` 标记:与 `superscript` 互斥 */\nexport interface MtSubscriptMark {\n type: 'subscript'\n}\n\n/** `superscript` 标记:与 `subscript` 互斥 */\nexport interface MtSuperscriptMark {\n type: 'superscript'\n}\n\n/** 全部标记的判别联合 */\nexport type MtMark =\n | MtLinkMark\n | MtCodeMark\n | MtTextStyleMark\n | MtBoldMark\n | MtItalicMark\n | MtUnderlineMark\n | MtStrikeMark\n | MtHighlightMark\n | MtSubscriptMark\n | MtSuperscriptMark\n\n/** 按类型取标记的具体形态,便于 `switch` 处收窄 */\nexport type MtMarkOf<T extends MtMark['type']> = Extract<MtMark, { type: T }>\n\n/** 无属性标记的类型名集合(序列化时可省略 `attrs`) */\nexport const MT_ATTRLESS_MARKS = [\n 'code',\n 'bold',\n 'italic',\n 'underline',\n 'strike',\n 'subscript',\n 'superscript',\n] as const\n","/**\n * 节点规格表 —— 全量节点元信息的**唯一来源**(`docs/document-model.md` §3.3、§4、§6)。\n *\n * 本表驱动三件事:\n * 1. `validate` 的必需属性与 `content` 表达式校验;\n * 2. `parse` 的标签映射与默认值填充;\n * 3. `serialize` 的分支选择与 ID 分配策略。\n *\n * **改动本表 = 改动文档模型**,必须同步 `docs/document-model.md` 与小程序映射表(§17.1.1)。\n */\n\nimport type { MtNodeType } from '../types/base'\n\n/** 节点分组名(§3.3) */\nexport type MtNodeGroup = 'block' | 'inline' | 'listItem' | 'tableCell'\n\n/** 单个节点的元信息 */\nexport interface MtNodeSpec {\n /** 节点类型名 */\n name: MtNodeType\n /** 所属组;`doc` 无组 */\n group: readonly MtNodeGroup[]\n /** `content` 表达式原文;atom 节点为 `null` */\n content: string | null\n /** 是否为 atom(无 content、内部不可编辑) */\n isAtom: boolean\n /**\n * 是否必须分配 `attrs.id`(§3.1:所有块级节点必须有 ID,行内节点不分配)。\n * 注意 `tableRow` / `tableCell` / `tableHeader` 虽属表格结构,但同样不分配 ID。\n */\n needsId: boolean\n /** 必需属性名 */\n requiredAttrs: readonly string[]\n /** 允许出现的属性名(含可选) */\n allowedAttrs: readonly string[]\n /** 其文本子节点是否允许携带 marks(§5:除 `codeBlock` 外均允许) */\n allowsMarks: boolean\n}\n\n/** 文本容器共有的对齐/行高属性名(§3.4) */\nconst ALIGN_ATTRS = ['textAlign', 'lineHeight'] as const\n\n/** 表格单元格共有的属性名(§4.4) */\nconst CELL_ATTRS = [\n 'colspan',\n 'rowspan',\n 'colwidth',\n 'align',\n 'background',\n ...ALIGN_ATTRS,\n] as const\n\n/**\n * 全量节点规格。\n *\n * 顺序与 §6「全量节点总表」一致,便于逐行比对。\n */\nexport const MT_NODE_SPECS: readonly MtNodeSpec[] = [\n {\n name: 'doc',\n group: [],\n content: 'block+',\n isAtom: false,\n needsId: false,\n requiredAttrs: [],\n allowedAttrs: [],\n allowsMarks: false,\n },\n {\n name: 'paragraph',\n group: ['block'],\n content: 'inline*',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id', 'indent', ...ALIGN_ATTRS],\n allowsMarks: true,\n },\n {\n name: 'heading',\n group: ['block'],\n content: 'inline*',\n isAtom: false,\n needsId: true,\n requiredAttrs: ['level'],\n allowedAttrs: ['id', 'level', ...ALIGN_ATTRS],\n allowsMarks: true,\n },\n {\n name: 'blockquote',\n group: ['block'],\n content: 'block+',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id'],\n allowsMarks: false,\n },\n {\n name: 'bulletList',\n group: ['block'],\n content: 'listItem+',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id'],\n allowsMarks: false,\n },\n {\n name: 'orderedList',\n group: ['block'],\n content: 'listItem+',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id', 'start', 'type'],\n allowsMarks: false,\n },\n {\n name: 'listItem',\n group: ['listItem'],\n content: 'paragraph block*',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id'],\n allowsMarks: false,\n },\n {\n name: 'taskList',\n group: ['block'],\n content: 'taskItem+',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id'],\n allowsMarks: false,\n },\n {\n name: 'taskItem',\n group: ['listItem'],\n content: 'paragraph block*',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id', 'checked'],\n allowsMarks: false,\n },\n {\n name: 'codeBlock',\n group: ['block'],\n content: 'text*',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id', 'language', 'showLineNumbers'],\n allowsMarks: false,\n },\n {\n name: 'horizontalRule',\n group: ['block'],\n content: null,\n isAtom: true,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id'],\n allowsMarks: false,\n },\n {\n name: 'image',\n group: ['block'],\n content: null,\n isAtom: true,\n needsId: true,\n requiredAttrs: ['src'],\n allowedAttrs: ['id', 'src', 'alt', 'title', 'width', 'height', 'align'],\n allowsMarks: false,\n },\n {\n name: 'video',\n group: ['block'],\n content: null,\n isAtom: true,\n needsId: true,\n requiredAttrs: ['src'],\n allowedAttrs: ['id', 'src', 'poster', 'width', 'height', 'align'],\n allowsMarks: false,\n },\n {\n name: 'audio',\n group: ['block'],\n content: null,\n isAtom: true,\n needsId: true,\n requiredAttrs: ['src'],\n allowedAttrs: ['id', 'src', 'title'],\n allowsMarks: false,\n },\n {\n name: 'attachment',\n group: ['block'],\n content: null,\n isAtom: true,\n needsId: true,\n requiredAttrs: ['src', 'name'],\n allowedAttrs: ['id', 'src', 'name', 'size', 'mime'],\n allowsMarks: false,\n },\n {\n name: 'mathBlock',\n group: ['block'],\n content: null,\n isAtom: true,\n needsId: true,\n requiredAttrs: ['tex'],\n allowedAttrs: ['id', 'tex', 'svg', 'numbering'],\n allowsMarks: false,\n },\n {\n name: 'table',\n group: ['block'],\n content: 'tableRow+',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id', 'colWidths'],\n allowsMarks: false,\n },\n {\n name: 'tableRow',\n group: [],\n content: '(tableCell | tableHeader)+',\n isAtom: false,\n needsId: false,\n requiredAttrs: [],\n allowedAttrs: [],\n allowsMarks: false,\n },\n {\n name: 'tableCell',\n group: ['tableCell'],\n content: 'block+',\n isAtom: false,\n needsId: false,\n requiredAttrs: [],\n allowedAttrs: [...CELL_ATTRS],\n allowsMarks: true,\n },\n {\n name: 'tableHeader',\n group: ['tableCell'],\n content: 'block+',\n isAtom: false,\n needsId: false,\n requiredAttrs: [],\n allowedAttrs: [...CELL_ATTRS],\n allowsMarks: true,\n },\n {\n name: 'text',\n group: ['inline'],\n content: null,\n isAtom: true,\n needsId: false,\n requiredAttrs: ['text'],\n allowedAttrs: [],\n allowsMarks: true,\n },\n {\n name: 'hardBreak',\n group: ['inline'],\n content: null,\n isAtom: true,\n needsId: false,\n requiredAttrs: [],\n allowedAttrs: [],\n allowsMarks: false,\n },\n {\n name: 'mention',\n group: ['inline'],\n content: null,\n isAtom: true,\n needsId: false,\n requiredAttrs: ['id', 'label'],\n allowedAttrs: ['id', 'label', 'kind'],\n allowsMarks: false,\n },\n {\n name: 'inlineMath',\n group: ['inline'],\n content: null,\n isAtom: true,\n needsId: false,\n requiredAttrs: ['tex'],\n allowedAttrs: ['tex', 'svg'],\n allowsMarks: false,\n },\n {\n name: 'emoji',\n group: ['inline'],\n content: null,\n isAtom: true,\n needsId: false,\n requiredAttrs: ['name', 'unicode'],\n allowedAttrs: ['name', 'unicode'],\n allowsMarks: false,\n },\n]\n\n/** 节点名 → 规格 的索引 */\nexport const MT_NODE_SPEC_MAP: ReadonlyMap<string, MtNodeSpec> = new Map(\n MT_NODE_SPECS.map((spec) => [spec.name, spec]),\n)\n\n/**\n * 按组名取出成员节点名。\n *\n * @param group 组名(§3.3)\n * @returns 属于该组的节点类型名数组\n */\nexport const nodesInGroup = (group: MtNodeGroup): string[] =>\n MT_NODE_SPECS.filter((spec) => spec.group.includes(group)).map((spec) => spec.name)\n\n/** 组的成员在模块加载时算一次,避免校验热路径反复过滤 */\nexport const MT_GROUP_MEMBERS: Readonly<Record<MtNodeGroup, readonly string[]>> = {\n block: nodesInGroup('block'),\n inline: nodesInGroup('inline'),\n listItem: nodesInGroup('listItem'),\n tableCell: nodesInGroup('tableCell'),\n}\n\n/**\n * 判断某个节点类型是否满足 content 表达式中的**单个名称**。\n *\n * 名称既可能是**组名**(`block` / `inline` / `listItem` / `tableCell`),\n * 也可能是**具体节点名**(`paragraph` / `tableCell` …),与 ProseMirror 语义一致。\n *\n * @param nodeType 待判断的节点类型名\n * @param name 表达式中的名称\n * @returns 是否匹配\n */\nexport const nodeMatchesName = (nodeType: string, name: string): boolean => {\n if (name in MT_GROUP_MEMBERS) {\n return MT_GROUP_MEMBERS[name as MtNodeGroup].includes(nodeType)\n }\n return nodeType === name\n}\n\n/**\n * 取节点规格。\n *\n * @param nodeType 节点类型名\n * @returns 规格对象;未知类型返回 `undefined`\n */\nexport const getNodeSpec = (nodeType: string): MtNodeSpec | undefined =>\n MT_NODE_SPEC_MAP.get(nodeType)\n","/**\n * 标记规格表(`docs/document-model.md` §5)。\n *\n * `priority` 决定序列化时的包裹顺序:数值**越大越靠外**。\n * 同一份 JSON 在任何端产出的嵌套顺序都必须一致,依据即本表。\n */\n\nimport type { MtMarkType } from '../types/base'\n\n/** 单个标记的元信息 */\nexport interface MtMarkSpec {\n /** 标记类型名 */\n name: MtMarkType\n /** 序列化优先级;越大越靠外 */\n priority: number\n /** 必需属性名 */\n requiredAttrs: readonly string[]\n /** 允许出现的属性名(含必需) */\n allowedAttrs: readonly string[]\n /** 互斥标记(同时出现时保留优先级更高者) */\n exclusive: readonly MtMarkType[]\n /** 是否为单例标记(重复施加时合并属性而非嵌套) */\n singleton: boolean\n}\n\n/** `textStyle` 允许的样式属性(§5) */\nconst TEXT_STYLE_ATTRS = [\n 'fontFamily',\n 'fontSize',\n 'color',\n 'backgroundColor',\n 'lineHeight',\n 'letterSpacing',\n] as const\n\n/** 全量标记规格,按 `priority` 降序排列 */\nexport const MT_MARK_SPECS: readonly MtMarkSpec[] = [\n {\n name: 'link',\n priority: 1000,\n requiredAttrs: ['href'],\n allowedAttrs: ['href', 'target', 'rel', 'title'],\n // 「不可嵌套自身」:同一段文本只能有一个 link\n exclusive: ['link'],\n singleton: true,\n },\n {\n name: 'code',\n priority: 900,\n requiredAttrs: [],\n allowedAttrs: [],\n exclusive: ['textStyle', 'highlight'],\n singleton: true,\n },\n {\n name: 'textStyle',\n priority: 800,\n requiredAttrs: [],\n allowedAttrs: [...TEXT_STYLE_ATTRS],\n exclusive: [],\n singleton: true,\n },\n {\n name: 'bold',\n priority: 700,\n requiredAttrs: [],\n allowedAttrs: [],\n exclusive: [],\n singleton: true,\n },\n {\n name: 'italic',\n priority: 650,\n requiredAttrs: [],\n allowedAttrs: [],\n exclusive: [],\n singleton: true,\n },\n {\n name: 'underline',\n priority: 600,\n requiredAttrs: [],\n allowedAttrs: [],\n exclusive: [],\n singleton: true,\n },\n {\n name: 'strike',\n priority: 550,\n requiredAttrs: [],\n allowedAttrs: [],\n exclusive: [],\n singleton: true,\n },\n {\n name: 'highlight',\n priority: 500,\n requiredAttrs: ['color'],\n allowedAttrs: ['color'],\n exclusive: ['code'],\n singleton: true,\n },\n {\n name: 'subscript',\n priority: 400,\n requiredAttrs: [],\n allowedAttrs: [],\n exclusive: ['superscript'],\n singleton: true,\n },\n {\n name: 'superscript',\n priority: 350,\n requiredAttrs: [],\n allowedAttrs: [],\n exclusive: ['subscript'],\n singleton: true,\n },\n]\n\n/** 标记名 → 规格 的索引 */\nexport const MT_MARK_SPEC_MAP: ReadonlyMap<string, MtMarkSpec> = new Map(\n MT_MARK_SPECS.map((spec) => [spec.name, spec]),\n)\n\n/**\n * `link.target` 的默认值(§5)。\n *\n * §7.2 要求序列化时**强制补** `target` / `rel`,以消除\n * `target=\"_blank\"` 的 tabnabbing 风险;与之对称,规范化阶段会把\n * **恰好等于默认值**的取值剥除,从而既保留安全属性、又不破坏\n * `JSON → HTML → JSON` 逐字符等价。\n */\nexport const MT_LINK_DEFAULT_TARGET = '_blank'\n\n/** `link.rel` 的默认值(§5),语义同 {@link MT_LINK_DEFAULT_TARGET} */\nexport const MT_LINK_DEFAULT_REL = 'noopener noreferrer'\n\n/**\n * 取标记规格。\n *\n * @param markType 标记类型名\n * @returns 规格对象;未知类型返回 `undefined`\n */\nexport const getMarkSpec = (markType: string): MtMarkSpec | undefined =>\n MT_MARK_SPEC_MAP.get(markType)\n\n/**\n * 标记排序比较器:按 `priority` **降序**(越靠前越靠外)。\n *\n * @param a 标记类型名\n * @param b 标记类型名\n * @returns 比较值\n */\nexport const compareMarkPriority = (a: string, b: string): number => {\n const pa = MT_MARK_SPEC_MAP.get(a)?.priority ?? 0\n const pb = MT_MARK_SPEC_MAP.get(b)?.priority ?? 0\n return pb - pa\n}\n","/**\n * `content` 表达式引擎(`docs/document-model.md` §3.2)。\n *\n * 支持 ProseMirror 记法的子集:\n *\n * | 记法 | 含义 |\n * |---|---|\n * | `a b` | 先 `a` 后 `b` |\n * | `a+` | 一个或多个 |\n * | `a*` | 零个或多个 |\n * | `a?` | 零个或一个 |\n * | `a \\| b` | `a` 或 `b` |\n * | `(a \\| b)+` | 括号内任一项,一个或多个 |\n *\n * 实现采用**手写扫描 + 回溯匹配**:不引入正则与第三方解析器,\n * 保证零 DOM、零依赖,可在小程序运行时执行。\n */\n\n/** 量词 */\nexport type MtQuantifier = '' | '?' | '*' | '+'\n\n/** 表达式中的一段:一组备选名称 + 量词 */\nexport interface MtContentPiece {\n /** 备选类型/组名 */\n alternatives: readonly string[]\n /** 量词 */\n quantifier: MtQuantifier\n}\n\n/** 解析后的 content 表达式 */\nexport interface MtContentExpr {\n /** 原文,用于报错定位 */\n raw: string\n /** 片段序列 */\n pieces: readonly MtContentPiece[]\n}\n\n/** content 表达式语法错误 */\nexport class MtContentExprError extends Error {\n /** 原始表达式 */\n readonly raw: string\n\n /**\n * @param raw 原始表达式\n * @param detail 错误细节(中文)\n */\n constructor(raw: string, detail: string) {\n super(`content 表达式 \"${raw}\" 语法错误:${detail}`)\n this.name = 'MtContentExprError'\n this.raw = raw\n }\n}\n\n/** 取字符;越界返回空串,避免 `string | undefined` 污染比较逻辑 */\nconst chAt = (source: string, index: number): string => source.charAt(index)\n\n/** 是否为合法名称字符 */\nconst isNameChar = (ch: string): boolean => {\n if (ch === '') return false\n const code = ch.charCodeAt(0)\n const isDigit = code >= 48 && code <= 57\n const isUpper = code >= 65 && code <= 90\n const isLower = code >= 97 && code <= 122\n return isDigit || isUpper || isLower || ch === '_'\n}\n\n/** 跳过空白 */\nconst skipSpaces = (source: string, index: number): number => {\n let i = index\n while (chAt(source, i) === ' ') i += 1\n return i\n}\n\n/**\n * 解析一个「来源」:单个名称,或 `(a | b)` 形式的备选组。\n *\n * @param source 表达式原文\n * @param start 起始下标\n * @returns 备选名称与下一个待读下标\n * @throws {MtContentExprError} 语法非法时抛出\n */\nconst parseSource = (source: string, start: number): { alternatives: string[]; next: number } => {\n let i = skipSpaces(source, start)\n\n if (chAt(source, i) === '(') {\n const alternatives: string[] = []\n i += 1\n for (;;) {\n i = skipSpaces(source, i)\n const nameStart = i\n while (isNameChar(chAt(source, i))) i += 1\n if (i === nameStart) {\n throw new MtContentExprError(source, `第 ${i} 个字符处缺少节点名`)\n }\n alternatives.push(source.slice(nameStart, i))\n i = skipSpaces(source, i)\n if (chAt(source, i) === '|') {\n i += 1\n continue\n }\n if (chAt(source, i) === ')') {\n i += 1\n break\n }\n throw new MtContentExprError(source, `第 ${i} 个字符处应为 '|' 或 ')'`)\n }\n return { alternatives, next: i }\n }\n\n const nameStart = i\n while (isNameChar(chAt(source, i))) i += 1\n if (i === nameStart) {\n throw new MtContentExprError(source, `第 ${i} 个字符处缺少节点名`)\n }\n return { alternatives: [source.slice(nameStart, i)], next: i }\n}\n\n/** 已解析表达式的缓存:表达式数量极少且反复用于校验热路径 */\nconst CONTENT_EXPR_CACHE = new Map<string, MtContentExpr>()\n\n/**\n * 解析一个「片段来源」:`a | b` 形式的备选集合。\n *\n * 与 {@link parseSource} 的区别在于:本函数把顶层用 `|` 串联的多个来源\n * 合并为**同一片段**的备选,从而同时支持 `a | b` 与 `(a | b)+` 两种写法。\n *\n * @param source 表达式原文\n * @param start 起始下标\n * @returns 备选名称与下一个待读下标\n * @throws {MtContentExprError} 语法非法时抛出\n */\nconst parseAlternatives = (\n source: string,\n start: number,\n): { alternatives: string[]; next: number } => {\n const alternatives: string[] = []\n let i = start\n\n for (;;) {\n const one = parseSource(source, i)\n alternatives.push(...one.alternatives)\n i = skipSpaces(source, one.next)\n if (chAt(source, i) !== '|') {\n return { alternatives, next: i }\n }\n i += 1\n }\n}\n\n/**\n * 解析 content 表达式(带缓存)。\n *\n * @param raw 表达式原文,如 `'paragraph block*'`\n * @returns 解析结果\n * @throws {MtContentExprError} 语法非法时抛出\n *\n * @example\n * ```ts\n * const expr = parseContentExpr('(tableCell | tableHeader)+')\n * expr.pieces[0]?.alternatives // ['tableCell', 'tableHeader']\n * expr.pieces[0]?.quantifier // '+'\n * ```\n */\nexport const parseContentExpr = (raw: string): MtContentExpr => {\n const cached = CONTENT_EXPR_CACHE.get(raw)\n if (cached) return cached\n\n const pieces: MtContentPiece[] = []\n let i = 0\n let guard = 0\n\n while (i < raw.length) {\n i = skipSpaces(raw, i)\n if (i >= raw.length) break\n const { alternatives, next } = parseAlternatives(raw, i)\n i = next\n const quantifierChar = chAt(raw, i)\n let quantifier: MtQuantifier = ''\n if (quantifierChar === '?' || quantifierChar === '*' || quantifierChar === '+') {\n quantifier = quantifierChar\n i += 1\n }\n pieces.push({ alternatives, quantifier })\n guard += 1\n if (guard > 64) {\n throw new MtContentExprError(raw, '片段数量异常,疑似表达式构造有误')\n }\n }\n\n if (pieces.length === 0) {\n throw new MtContentExprError(raw, '表达式为空')\n }\n\n const expr: MtContentExpr = { raw, pieces }\n CONTENT_EXPR_CACHE.set(raw, expr)\n return expr\n}\n\n/**\n * 判断一段子节点类型序列是否满足 content 表达式。\n *\n * 采用回溯匹配:对 `*` / `+` 片段先尽量多吃,再逐个回退,\n * 语义与正则全量匹配一致(要求**恰好**消费完整个序列)。\n *\n * @param childTypes 子节点类型名序列\n * @param expr 已解析的表达式\n * @param matcher 名称匹配器,见 {@link MtNameMatcher}\n * @returns 是否匹配\n *\n * @example\n * ```ts\n * const expr = parseContentExpr('paragraph block*')\n * const ok = matchesContentExpr(['paragraph', 'bulletList'], expr, nodeMatchesName)\n * // ok === true\n * ```\n */\nexport const matchesContentExpr = (\n childTypes: readonly string[],\n expr: MtContentExpr,\n matcher: MtNameMatcher,\n): boolean => {\n const { pieces } = expr\n const total = childTypes.length\n\n const matchesAt = (piece: MtContentPiece, index: number): boolean => {\n const type = childTypes[index]\n if (type === undefined) return false\n return piece.alternatives.some((name) => matcher(type, name))\n }\n\n const walk = (pieceIndex: number, typeIndex: number): boolean => {\n const piece = pieces[pieceIndex]\n if (piece === undefined) return typeIndex === total\n\n if (piece.quantifier === '') {\n // 恰好一次:必须先消费,且只消费一个\n if (!matchesAt(piece, typeIndex)) return false\n return walk(pieceIndex + 1, typeIndex + 1)\n }\n\n if (piece.quantifier === '?') {\n if (matchesAt(piece, typeIndex) && walk(pieceIndex + 1, typeIndex + 1)) return true\n return walk(pieceIndex + 1, typeIndex)\n }\n\n let max = 0\n while (matchesAt(piece, typeIndex + max)) max += 1\n const min = piece.quantifier === '+' ? 1 : 0\n for (let count = max; count >= min; count -= 1) {\n if (walk(pieceIndex + 1, typeIndex + count)) return true\n }\n return false\n }\n\n return walk(0, 0)\n}\n\n/**\n * 名称匹配器:判断某个节点类型是否满足表达式中的一个名称。\n *\n * 名称既可能是组名也可能是具体节点名,由调用方注入解析规则,\n * 从而让本模块保持与 `schema/nodes.ts` 解耦。\n */\nexport type MtNameMatcher = (nodeType: string, name: string) => boolean\n","/**\n * schema 层出口:节点/标记元信息与 `content` 表达式引擎。\n */\n\nexport {\n getNodeSpec,\n MT_GROUP_MEMBERS,\n MT_NODE_SPEC_MAP,\n MT_NODE_SPECS,\n nodeMatchesName,\n nodesInGroup,\n} from './nodes'\nexport type { MtNodeGroup, MtNodeSpec } from './nodes'\n\nexport {\n compareMarkPriority,\n getMarkSpec,\n MT_LINK_DEFAULT_REL,\n MT_LINK_DEFAULT_TARGET,\n MT_MARK_SPEC_MAP,\n MT_MARK_SPECS,\n} from './marks'\nexport type { MtMarkSpec } from './marks'\n\nexport { matchesContentExpr, MtContentExprError, parseContentExpr } from './content-expression'\nexport type {\n MtContentExpr,\n MtContentPiece,\n MtNameMatcher,\n MtQuantifier,\n} from './content-expression'\n\nimport { matchesContentExpr, parseContentExpr } from './content-expression'\nimport { getNodeSpec, nodeMatchesName } from './nodes'\n\n/**\n * 判断某节点的子节点类型序列是否满足其规格中声明的 content 表达式。\n *\n * atom 节点(`content === null`)仅在其**无子节点**时通过。\n *\n * @param nodeType 父节点类型名\n * @param childTypes 子节点类型名序列\n * @returns 是否匹配;父节点类型未知时返回 `false`\n *\n * @example\n * ```ts\n * contentMatches('heading', ['text']) // true\n * contentMatches('heading', ['paragraph']) // false(inline* 不接受 block)\n * contentMatches('image', []) // true(atom)\n * ```\n */\nexport const contentMatches = (nodeType: string, childTypes: readonly string[]): boolean => {\n const spec = getNodeSpec(nodeType)\n if (!spec) return false\n if (spec.content === null) return childTypes.length === 0\n return matchesContentExpr(childTypes, parseContentExpr(spec.content), nodeMatchesName)\n}\n","/**\n * 降级码与校验码常量表(`docs/document-model.md` §11、§12)。\n *\n * 码值一经发布**不可更改语义**,只允许追加。\n */\n\n/** 降级码(§12) */\nexport const MT_DEG = {\n /** 标签不在小程序白名单,被解包或丢弃 */\n TAG_NOT_ALLOWED: 'MT_DEG_001',\n /** 节点在目标端无法表达(如 `video` 在 `rich-text`) */\n UNSUPPORTED_NODE: 'MT_DEG_002',\n /** 属性被丢弃(`id`、`class`、`colspan` 等) */\n ATTR_DROPPED: 'MT_DEG_003',\n /** 格式转换有损(Markdown 目标) */\n LOSSY_CONVERSION: 'MT_DEG_004',\n /** 缺少预渲染产物(`math.svg` / 代码高亮) */\n MISSING_PRERENDER: 'MT_DEG_005',\n /** 冲突标记被归一化 */\n MARK_CONFLICT: 'MT_DEG_006',\n /** 危险 URL 协议被置空 */\n DANGEROUS_URL: 'MT_DEG_007',\n /** `emoji` 的 unicode 在当前运行时无法正确拼合 */\n EMOJI_UNSUPPORTED: 'MT_DEG_008',\n /** 未知标记类型被丢弃(M1 追加) */\n UNKNOWN_MARK: 'MT_DEG_009',\n /** 不允许标记的位置上出现的标记被清除(M1 追加) */\n MARK_NOT_ALLOWED: 'MT_DEG_010',\n /** 空容器节点无内容,已补占位或丢弃(M1 追加) */\n EMPTY_CONTAINER: 'MT_DEG_011',\n} as const\n\n/** 校验码(§11) */\nexport const MT_DOC = {\n /** 未知节点类型 */\n UNKNOWN_NODE: 'MT_DOC_001',\n /** 缺少必填属性 */\n MISSING_ATTR: 'MT_DOC_002',\n /** `content` 不匹配内容表达式 */\n CONTENT_MISMATCH: 'MT_DOC_003',\n /** 未知 mark 类型 */\n UNKNOWN_MARK: 'MT_DOC_004',\n /** mark 互斥冲突 */\n MARK_CONFLICT: 'MT_DOC_005',\n /** 非法 URL 协议 */\n BAD_URL: 'MT_DOC_006',\n /** 表格结构非法 */\n BAD_TABLE: 'MT_DOC_007',\n /** 不支持的 `schemaVersion` */\n UNSUPPORTED_VERSION: 'MT_DOC_008',\n /** 节点 ID 重复 */\n DUPLICATE_ID: 'MT_DOC_009',\n /** `codeBlock` 内出现 mark */\n MARKS_IN_CODE_BLOCK: 'MT_DOC_010',\n} as const\n\n/** 降级码的中文释义 */\nexport const MT_DEG_LABEL: Readonly<Record<string, string>> = {\n [MT_DEG.TAG_NOT_ALLOWED]: '标签不在白名单,被解包或丢弃',\n [MT_DEG.UNSUPPORTED_NODE]: '节点在目标端无法表达',\n [MT_DEG.ATTR_DROPPED]: '属性被丢弃',\n [MT_DEG.LOSSY_CONVERSION]: '格式转换有损',\n [MT_DEG.MISSING_PRERENDER]: '缺少预渲染产物',\n [MT_DEG.MARK_CONFLICT]: '冲突标记被归一化',\n [MT_DEG.DANGEROUS_URL]: '危险 URL 协议被置空',\n [MT_DEG.EMOJI_UNSUPPORTED]: 'emoji 在当前运行时无法正确拼合',\n [MT_DEG.UNKNOWN_MARK]: '未知标记类型被丢弃',\n [MT_DEG.MARK_NOT_ALLOWED]: '该位置不允许标记,标记被清除',\n [MT_DEG.EMPTY_CONTAINER]: '空容器节点无内容,已补占位或丢弃',\n}\n\n/** 校验码的中文释义 */\nexport const MT_DOC_LABEL: Readonly<Record<string, string>> = {\n [MT_DOC.UNKNOWN_NODE]: '未知节点类型',\n [MT_DOC.MISSING_ATTR]: '缺少必填属性',\n [MT_DOC.CONTENT_MISMATCH]: 'content 不匹配内容表达式',\n [MT_DOC.UNKNOWN_MARK]: '未知 mark 类型',\n [MT_DOC.MARK_CONFLICT]: 'mark 互斥冲突',\n [MT_DOC.BAD_URL]: '非法 URL 协议',\n [MT_DOC.BAD_TABLE]: '表格结构非法',\n [MT_DOC.UNSUPPORTED_VERSION]: '不支持的 schemaVersion',\n [MT_DOC.DUPLICATE_ID]: '节点 ID 重复',\n [MT_DOC.MARKS_IN_CODE_BLOCK]: 'codeBlock 内出现 mark',\n}\n","/**\n * 降级报告与校验问题的构造工具(`docs/document-model.md` §11、§12)。\n *\n * 统一走构造函数而非到处手写字面量,保证 `code` 与 `message` 永远配套,\n * 也便于后续按码做国际化。\n */\n\nimport type { MtDegradationStrategy, MtIssueSeverity } from '../types/base'\nimport type { MtDegradationEntry, MtValidationIssue } from '../types/report'\nimport { MT_DEG_LABEL, MT_DOC_LABEL } from './codes'\n\n/**\n * 构造一条降级报告。\n *\n * @param code 降级码,如 `MT_DEG_003`\n * @param type 受影响的节点/标记类型\n * @param path 文档内定位路径\n * @param message 具体说明(中文)\n * @param strategy 采取的策略\n * @returns 降级条目\n *\n * @example\n * ```ts\n * degrade(MT_DEG.DANGEROUS_URL, 'link', 'content[0].content[1]', 'javascript: 被置空', 'dropped')\n * ```\n */\nexport const degrade = (\n code: string,\n type: string,\n path: string,\n message: string,\n strategy: MtDegradationStrategy,\n): MtDegradationEntry => ({\n code,\n type,\n path,\n message: `${MT_DEG_LABEL[code] ?? code}:${message}`,\n strategy,\n})\n\n/**\n * 构造一条校验问题。\n *\n * @param code 校验码,如 `MT_DOC_003`\n * @param level 严重级别\n * @param path 文档内定位路径\n * @param message 具体说明(中文)\n * @param handled 已自动采取的处理\n * @returns 校验问题\n *\n * @example\n * ```ts\n * issue(MT_DOC.CONTENT_MISMATCH, 'error', 'content[1]', 'heading 只能包含行内节点')\n * ```\n */\nexport const issue = (\n code: string,\n level: MtIssueSeverity,\n path: string,\n message: string,\n handled?: string,\n): MtValidationIssue => ({\n code,\n level,\n path,\n message: `${MT_DOC_LABEL[code] ?? code}:${message}`,\n ...(handled === undefined ? {} : { handled }),\n})\n","/**\n * 基础类型守卫。\n *\n * 本项目禁用 `any`(`AGENTS.md` §13.1),因此所有外部输入一律以 `unknown` 接收,\n * 再由本文件的守卫逐步收窄。守卫必须**永不抛异常**、**永不误判**。\n */\n\n/** 是否为非 null 的非数组对象(即「记录」) */\nexport const isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value)\n\n/** 是否为字符串 */\nexport const isString = (value: unknown): value is string => typeof value === 'string'\n\n/** 是否为非空字符串 */\nexport const isNonEmptyString = (value: unknown): value is string =>\n typeof value === 'string' && value.length > 0\n\n/** 是否为有限数字 */\nexport const isFiniteNumber = (value: unknown): value is number =>\n typeof value === 'number' && Number.isFinite(value)\n\n/** 是否为布尔值 */\nexport const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean'\n\n/** 是否为数组 */\nexport const isArray = (value: unknown): value is unknown[] => Array.isArray(value)\n\n/**\n * 是否为「节点数组」(每项都是记录)。\n *\n * @param value 待判断值\n * @returns 是否满足\n */\nexport const isNodeArray = (value: unknown): value is Array<Record<string, unknown>> =>\n Array.isArray(value) && value.every(isRecord)\n\n/**\n * 把未知值读成字符串,非字符串一律返回 `undefined`。\n *\n * @param value 待读值\n * @returns 字符串或 `undefined`\n */\nexport const readString = (value: unknown): string | undefined =>\n typeof value === 'string' ? value : undefined\n\n/**\n * 把未知值读成有限数字,其余返回 `undefined`。\n *\n * @param value 待读值\n * @returns 数字或 `undefined`\n */\nexport const readNumber = (value: unknown): number | undefined =>\n isFiniteNumber(value) ? value : undefined\n\n/**\n * 把未知值读成布尔值,其余返回 `undefined`。\n *\n * @param value 待读值\n * @returns 布尔值或 `undefined`\n */\nexport const readBoolean = (value: unknown): boolean | undefined =>\n typeof value === 'boolean' ? value : undefined\n\n/**\n * 在一个有限取值集合内读取字符串枚举值。\n *\n * @param value 待读值\n * @param allowed 允许的取值集合\n * @returns 命中的值,或 `undefined`\n *\n * @example\n * ```ts\n * readEnum('center', ['left', 'center', 'right']) // 'center'\n * readEnum('middle', ['left', 'center', 'right']) // undefined\n * ```\n */\nexport const readEnum = <T extends string>(\n value: unknown,\n allowed: readonly T[],\n): T | undefined => {\n if (typeof value !== 'string') return undefined\n return allowed.includes(value as T) ? (value as T) : undefined\n}\n","/**\n * URL 协议白名单校验(`docs/document-model.md` §7.3)。\n *\n * 规则:\n * - 允许 `http:` `https:` `mailto:` `tel:`,以及无协议的相对地址与 `#锚点`;\n * - 仅当显式开启 `allowDataImage` 时放行 `data:image/<白名单 MIME>`;\n * - 其余协议(`javascript:` `vbscript:` `file:` 等)一律判为不安全。\n *\n * 攻击面注意:浏览器会忽略协议名中的控制字符与空白(`java\\tscript:`),\n * 因此必须在判定前把这些字符剥离,否则白名单会被轻易绕过。\n */\n\n/** 允许的协议 */\nconst ALLOWED_SCHEMES: readonly string[] = ['http', 'https', 'mailto', 'tel']\n\n/** 允许的 `data:` 图片 MIME(刻意排除 `image/svg+xml`:SVG 可内嵌脚本) */\nconst ALLOWED_DATA_IMAGE_MIMES: readonly string[] = [\n 'image/png',\n 'image/jpeg',\n 'image/jpg',\n 'image/gif',\n 'image/webp',\n 'image/bmp',\n 'image/avif',\n 'image/x-icon',\n 'image/vnd.microsoft.icon',\n]\n\n/** URL 校验结果 */\nexport interface MtUrlCheckResult {\n /** 是否安全 */\n ok: boolean\n /** 不安全时给出中文原因 */\n reason?: string\n}\n\n/** URL 校验选项 */\nexport interface MtUrlCheckOptions {\n /** 是否放行 `data:image/*`(仅图片节点应为 `true`) */\n allowDataImage?: boolean\n}\n\n/**\n * 剥离控制字符与首尾空白。\n *\n * @param raw 原始地址\n * @returns 归一化后的地址\n */\nconst normalizeForScheme = (raw: string): string => {\n let out = ''\n for (let i = 0; i < raw.length; i += 1) {\n const code = raw.charCodeAt(i)\n if (code <= 0x20 || code === 0x7f) continue\n out += raw.charAt(i)\n }\n return out\n}\n\n/**\n * 查找 `:` 与「路径起始字符」中更早出现的位置。\n *\n * @param source 归一化后的地址\n * @returns `{ colon, pathStart }`,未出现则为 `-1`\n */\nconst locateSchemeBoundary = (source: string): { colon: number; pathStart: number } => {\n let colon = -1\n let pathStart = -1\n for (let i = 0; i < source.length; i += 1) {\n const ch = source.charAt(i)\n if (ch === ':' && colon < 0) colon = i\n if ((ch === '/' || ch === '?' || ch === '#') && pathStart < 0) pathStart = i\n }\n return { colon, pathStart }\n}\n\n/**\n * 判断 `data:` 地址是否为白名单内的图片。\n *\n * @param source 归一化后的地址(已确认以 `data:` 开头)\n * @returns 是否放行\n */\nconst isAllowedDataImage = (source: string): boolean => {\n const payload = source.slice('data:'.length)\n if (!payload.startsWith('image/')) return false\n let end = payload.length\n for (let i = 0; i < payload.length; i += 1) {\n const ch = payload.charAt(i)\n if (ch === ';' || ch === ',') {\n end = i\n break\n }\n }\n const mime = payload.slice(0, end).toLowerCase()\n return ALLOWED_DATA_IMAGE_MIMES.includes(mime)\n}\n\n/**\n * 校验地址是否落在协议白名单内。\n *\n * @param raw 原始地址(可以是 `unknown`,非字符串一律判为不安全)\n * @param options 校验选项\n * @returns 校验结果\n *\n * @example\n * ```ts\n * checkUrl('https://example.com').ok // true\n * checkUrl('javascript:alert(1)').ok // false\n * checkUrl('java\\tscript:alert(1)').ok // false(控制字符被剥离后仍被拦下)\n * checkUrl('data:image/png;base64,AAA', { allowDataImage: true }).ok // true\n * ```\n */\nexport const checkUrl = (raw: unknown, options: MtUrlCheckOptions = {}): MtUrlCheckResult => {\n if (typeof raw !== 'string') {\n return { ok: false, reason: '地址不是字符串' }\n }\n if (raw.length === 0) return { ok: true }\n\n const source = normalizeForScheme(raw)\n if (source.length === 0) return { ok: true }\n\n const { colon, pathStart } = locateSchemeBoundary(source)\n\n // 无协议(相对地址、锚点):放行\n if (colon < 0 || (pathStart >= 0 && pathStart < colon)) return { ok: true }\n\n const scheme = source.slice(0, colon).toLowerCase()\n\n if (scheme === 'data') {\n if (options.allowDataImage === true && isAllowedDataImage(source)) return { ok: true }\n return { ok: false, reason: 'data: 协议仅允许白名单内的图片 MIME' }\n }\n\n if (ALLOWED_SCHEMES.includes(scheme)) return { ok: true }\n return { ok: false, reason: `协议 ${scheme}: 不在白名单内` }\n}\n\n/**\n * 清洗地址:不安全时返回空串。\n *\n * @param raw 原始地址\n * @param options 校验选项\n * @returns 安全地址,或空串\n *\n * @example\n * ```ts\n * sanitizeUrl('javascript:alert(1)') // ''\n * sanitizeUrl('https://a.com') // 'https://a.com'\n * ```\n */\nexport const sanitizeUrl = (raw: unknown, options: MtUrlCheckOptions = {}): string =>\n checkUrl(raw, options).ok && typeof raw === 'string' ? raw : ''\n","/**\n * 标记规范化(`docs/document-model.md` §5.1)。\n *\n * 解析外部输入(HTML / Markdown / 历史 JSON)后必须做一次规范化:\n *\n * 1. 丢弃未知标记类型(`MT_DEG_009` / `MT_DOC_004`);\n * 2. 冲突标记按「保留优先级更高者」处理(`MT_DEG_006` / `MT_DOC_005`);\n * 3. 单例标记重复施加时**合并属性**而非嵌套;\n * 4. 按 §5 优先级**降序排序**,保证跨端嵌套顺序一致;\n * 5. 危险 URL 协议置空(`MT_DEG_007` / `MT_DOC_006`)。\n *\n * 本模块**不注入可选属性的默认值**(如 `link.target`),\n * 默认值一律在序列化时补,否则会破坏 `JSON → HTML → JSON` 的逐字符等价。\n */\n\nimport { MT_DEG, MT_DOC } from '../report/codes'\nimport { degrade, issue } from '../report'\nimport {\n compareMarkPriority,\n getMarkSpec,\n MT_LINK_DEFAULT_REL,\n MT_LINK_DEFAULT_TARGET,\n} from '../schema/marks'\nimport type { MtMarkType } from '../types/base'\nimport type { MtLinkAttrs, MtMark, MtTextStyleAttrs, MtTextStyleMark } from '../types/marks'\nimport type { MtDegradationEntry, MtValidationIssue } from '../types/report'\nimport { isRecord, readString } from '../utils/guards'\nimport { checkUrl } from '../utils/url'\n\n/** 规范化过程中的收集器与上下文 */\nexport interface MtNormalizeContext {\n /** 当前文本节点的文档路径 */\n path: string\n /** 该位置是否允许携带标记(`codeBlock` 内为 `false`) */\n marksAllowed: boolean\n /** 降级报告收集器 */\n degradations: MtDegradationEntry[]\n /** 校验问题收集器 */\n issues: MtValidationIssue[]\n}\n\n/** `textStyle` 允许的样式属性名 */\nconst TEXT_STYLE_ATTR_NAMES: readonly string[] = [\n 'fontFamily',\n 'fontSize',\n 'color',\n 'backgroundColor',\n 'lineHeight',\n 'letterSpacing',\n]\n\n/** 归一化后的标记集合 */\nexport interface MtNormalizeMarksResult {\n /** 已排序、已去重、已消解冲突的标记 */\n marks: MtMark[]\n}\n\n/**\n * 合并两个 `textStyle` 的属性:以先到者为先,空缺由后者补齐。\n *\n * @param base 先到的属性\n * @param extra 后到的属性\n * @returns 合并结果\n */\nconst mergeTextStyleAttrs = (base: MtTextStyleAttrs, extra: MtTextStyleAttrs): MtTextStyleAttrs => {\n const merged: MtTextStyleAttrs = { ...base }\n for (const name of TEXT_STYLE_ATTR_NAMES) {\n const key = name as keyof MtTextStyleAttrs\n const current = merged[key]\n if (current === undefined || current === null) {\n const incoming = extra[key]\n if (incoming !== undefined && incoming !== null) merged[key] = incoming\n }\n }\n return merged\n}\n\n/**\n * 把未知值收窄为带属性的标记。\n *\n * @param type 标记类型\n * @param attrs 已收窄的属性对象\n * @returns 标记对象\n */\nconst buildMark = (type: MtMarkType, attrs: Record<string, string>): MtMark => {\n switch (type) {\n case 'link': {\n // `MT_MARK_SPECS.link.allowedAttrs` 声明了四个属性,此处必须一致地保留它们,\n // 否则 `target` / `rel` / `title` 会在规范化阶段被静默丢弃\n const linkAttrs: MtLinkAttrs = { href: attrs.href ?? '' }\n if (attrs.target !== undefined) linkAttrs.target = attrs.target\n if (attrs.rel !== undefined) linkAttrs.rel = attrs.rel\n if (attrs.title !== undefined) linkAttrs.title = attrs.title\n return { type: 'link', attrs: linkAttrs }\n }\n case 'highlight':\n return { type: 'highlight', attrs: { color: attrs.color ?? '' } }\n case 'textStyle': {\n const styleAttrs: MtTextStyleAttrs = {}\n for (const name of TEXT_STYLE_ATTR_NAMES) {\n const value = attrs[name]\n if (value !== undefined) {\n ;(styleAttrs as Record<string, unknown>)[name] = value\n }\n }\n return { type: 'textStyle', attrs: styleAttrs }\n }\n case 'code':\n return { type: 'code' }\n case 'bold':\n return { type: 'bold' }\n case 'italic':\n return { type: 'italic' }\n case 'underline':\n return { type: 'underline' }\n case 'strike':\n return { type: 'strike' }\n case 'subscript':\n return { type: 'subscript' }\n case 'superscript':\n return { type: 'superscript' }\n default:\n return { type: 'bold' }\n }\n}\n\n/**\n * 收窄单个标记的属性,并返回是否可接受。\n *\n * @param type 标记类型\n * @param rawAttrs 原始属性对象\n * @param ctx 规范化上下文\n * @returns `{ attrs, ok }`;`ok === false` 表示应丢弃该标记\n */\nconst coerceMarkAttrs = (\n type: MtMarkType,\n rawAttrs: Record<string, unknown>,\n ctx: MtNormalizeContext,\n): { attrs: Record<string, string>; ok: boolean } => {\n const attrs: Record<string, string> = {}\n\n if (type === 'link') {\n const href = readString(rawAttrs.href)\n // 空串与缺失等价:无地址的链接没有意义,且会留下 validate 无法接受的 `href: ''`\n if (href === undefined || href.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'link',\n ctx.path,\n '缺少必填属性 href,已丢弃该链接标记',\n 'dropped',\n ),\n )\n ctx.issues.push(\n issue(MT_DOC.MISSING_ATTR, 'error', ctx.path, 'link 缺少必填属性 href', '已丢弃该 mark'),\n )\n return { attrs, ok: false }\n }\n const verdict = checkUrl(href)\n if (!verdict.ok) {\n // 危险协议必须让整条链接标记退化为纯文本:置空 href 会留下一个\n // `href: ''` 的非法 link(validate 层会判为缺少必填属性)。\n ctx.degradations.push(\n degrade(\n MT_DEG.DANGEROUS_URL,\n 'link',\n ctx.path,\n `href=\"${href}\"(${verdict.reason ?? ''}),已丢弃该链接标记`,\n 'dropped',\n ),\n )\n ctx.issues.push(\n issue(\n MT_DOC.BAD_URL,\n 'error',\n ctx.path,\n `link.href 协议非法:${verdict.reason ?? ''}`,\n '已丢弃该 mark',\n ),\n )\n return { attrs, ok: false }\n }\n attrs.href = href\n // 与序列化侧「强制补默认值」对称:等于默认值的取值归一为省略\n const target = readString(rawAttrs.target)\n if (target !== undefined && target !== MT_LINK_DEFAULT_TARGET) attrs.target = target\n const rel = readString(rawAttrs.rel)\n if (rel !== undefined && rel !== MT_LINK_DEFAULT_REL) attrs.rel = rel\n const title = readString(rawAttrs.title)\n if (title !== undefined) attrs.title = title\n return { attrs, ok: true }\n }\n\n if (type === 'highlight') {\n const color = readString(rawAttrs.color)\n if (color === undefined) {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'highlight',\n ctx.path,\n '缺少必填属性 color,已丢弃该高亮标记',\n 'dropped',\n ),\n )\n ctx.issues.push(\n issue(\n MT_DOC.MISSING_ATTR,\n 'error',\n ctx.path,\n 'highlight 缺少必填属性 color',\n '已丢弃该 mark',\n ),\n )\n return { attrs, ok: false }\n }\n attrs.color = color\n return { attrs, ok: true }\n }\n\n if (type === 'textStyle') {\n for (const name of TEXT_STYLE_ATTR_NAMES) {\n const value = readString(rawAttrs[name])\n if (value !== undefined) attrs[name] = value\n }\n if (Object.keys(attrs).length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'textStyle',\n ctx.path,\n 'textStyle 全部属性为空,已丢弃',\n 'dropped',\n ),\n )\n return { attrs, ok: false }\n }\n return { attrs, ok: true }\n }\n\n return { attrs, ok: true }\n}\n\n/**\n * 把单个原始值收窄为一个标记。\n *\n * @param entry 原始值\n * @param ctx 规范化上下文\n * @returns 标记对象;不可接受时返回 `undefined`\n */\nconst coerceMark = (entry: unknown, ctx: MtNormalizeContext): MtMark | undefined => {\n if (!isRecord(entry)) return undefined\n const type = entry.type\n if (typeof type !== 'string') return undefined\n\n const spec = getMarkSpec(type)\n if (!spec) {\n ctx.degradations.push(\n degrade(MT_DEG.UNKNOWN_MARK, type, ctx.path, `未知标记类型 '${type}'`, 'dropped'),\n )\n ctx.issues.push(\n issue(MT_DOC.UNKNOWN_MARK, 'error', ctx.path, `未知 mark 类型 '${type}'`, '已丢弃该 mark'),\n )\n return undefined\n }\n\n const rawAttrs = isRecord(entry.attrs) ? entry.attrs : {}\n const { attrs, ok } = coerceMarkAttrs(type as MtMarkType, rawAttrs, ctx)\n if (!ok) return undefined\n return buildMark(type as MtMarkType, attrs)\n}\n\n/**\n * 消解互斥冲突并去重(输入须已按优先级降序排好)。\n *\n * @param marks 已排序的标记\n * @param ctx 规范化上下文\n * @returns 消解后的标记\n */\nconst resolveConflicts = (marks: readonly MtMark[], ctx: MtNormalizeContext): MtMark[] => {\n const kept: MtMark[] = []\n\n for (const mark of marks) {\n const spec = getMarkSpec(mark.type)\n if (!spec) continue\n\n const sameType = kept.find((item) => item.type === mark.type)\n if (sameType) {\n // 单例重复:合并 textStyle 属性,其余直接跳过\n if (sameType.type === 'textStyle' && mark.type === 'textStyle') {\n const base = sameType.attrs as Record<string, string | undefined>\n const extra = mark.attrs as Record<string, string | undefined>\n // 同名属性取值不同时「以先到者为先」,被舍弃的值必须留痕(§12.2)\n const overridden = Object.keys(extra).filter(\n (key) => base[key] !== undefined && base[key] !== extra[key],\n )\n if (overridden.length > 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MARK_CONFLICT,\n 'textStyle',\n ctx.path,\n `多次施加 textStyle,属性 ${overridden.join('、')} 冲突,已保留先到值`,\n 'replaced',\n ),\n )\n }\n const merged = mergeTextStyleAttrs(sameType.attrs, mark.attrs)\n if (JSON.stringify(merged) !== JSON.stringify(sameType.attrs)) {\n ;(sameType as MtTextStyleMark).attrs = merged\n ctx.degradations.push(\n degrade(\n MT_DEG.MARK_CONFLICT,\n 'textStyle',\n ctx.path,\n '多次施加 textStyle,已合并属性',\n 'replaced',\n ),\n )\n }\n }\n continue\n }\n\n const conflicting = kept.find((item) => {\n const itemSpec = getMarkSpec(item.type)\n if (!itemSpec) return false\n return itemSpec.exclusive.includes(mark.type) || spec.exclusive.includes(item.type)\n })\n\n if (conflicting) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MARK_CONFLICT,\n mark.type,\n ctx.path,\n `与 ${conflicting.type} 互斥,保留优先级更高的 ${conflicting.type}`,\n 'dropped',\n ),\n )\n ctx.issues.push(\n issue(\n MT_DOC.MARK_CONFLICT,\n 'error',\n ctx.path,\n `${mark.type} 与 ${conflicting.type} 互斥`,\n `已保留 ${conflicting.type}`,\n ),\n )\n continue\n }\n\n kept.push(mark)\n }\n\n return kept\n}\n\n/**\n * 规范化一段文本节点的 `marks`。\n *\n * @param raw 原始 `marks` 值(可为 `unknown`)\n * @param ctx 规范化上下文(收集器会被就地写入)\n * @returns 规范化结果\n *\n * @example\n * ```ts\n * const ctx = { path: 'content[0].content[0]', marksAllowed: true, degradations: [], issues: [] }\n * normalizeMarks([\n * { type: 'textStyle', attrs: { color: 'red' } },\n * { type: 'bold' },\n * ], ctx).marks.map((m) => m.type) // ['textStyle', 'bold'](按优先级降序)\n * ```\n */\nexport const normalizeMarks = (raw: unknown, ctx: MtNormalizeContext): MtNormalizeMarksResult => {\n if (!Array.isArray(raw) || raw.length === 0) return { marks: [] }\n\n if (!ctx.marksAllowed) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MARK_NOT_ALLOWED,\n 'text',\n ctx.path,\n '该容器不允许携带标记(如 codeBlock),已清除全部标记',\n 'dropped',\n ),\n )\n ctx.issues.push(\n issue(\n MT_DOC.MARKS_IN_CODE_BLOCK,\n 'warning',\n ctx.path,\n 'codeBlock 内出现 mark',\n '已清除 marks',\n ),\n )\n return { marks: [] }\n }\n\n const coerced: MtMark[] = []\n for (const entry of raw) {\n const mark = coerceMark(entry, ctx)\n if (mark) coerced.push(mark)\n }\n\n coerced.sort((a, b) => compareMarkPriority(a.type, b.type))\n return { marks: resolveConflicts(coerced, ctx) }\n}\n","/**\n * 稳定的 JSON 序列化(键名递归排序)。\n *\n * 用途:比较两个 `attrs` / `marks` 是否**语义相等**。\n * 直接 `JSON.stringify` 的结果受键插入顺序影响,会把\n * `{ a: 1, b: 2 }` 与 `{ b: 2, a: 1 }` 判为不等,导致标记合并与\n * 往返校验出现假阳性。\n */\n\nimport { isRecord } from './guards'\n\n/**\n * 序列化为键名有序的 JSON 字符串。\n *\n * @param value 待序列化值\n * @returns 稳定字符串\n *\n * @example\n * ```ts\n * stableStringify({ b: 1, a: 2 }) === stableStringify({ a: 2, b: 1 }) // true\n * ```\n */\nexport const stableStringify = (value: unknown): string => {\n if (value === null || value === undefined) return 'null'\n if (Array.isArray(value)) {\n return `[${value.map((item) => stableStringify(item)).join(',')}]`\n }\n if (isRecord(value)) {\n const keys = Object.keys(value).sort()\n const body = keys\n .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)\n .join(',')\n return `{${body}}`\n }\n return JSON.stringify(value)\n}\n","/**\n * 行内节点收尾规范化(`docs/document-model.md` §3.5、§5.1)。\n *\n * 职责:\n * 1. 丢弃 `text === ''` 的空文本节点(§5.1 规则 3);\n * 2. `marks: []` 归一为**省略该字段**(§3.5、§5.1 规则 4);\n * 3. 相邻且标记完全相同的 `text` **合并**为一个(§5.1 规则 1)。\n *\n * 规范化在 `parse` 产出节点后调用,是 `JSON → HTML → JSON`\n * 逐字符等价的关键一环:合并与省略规则必须与序列化器对称。\n */\n\nimport type { MtInlineNode, MtTextNode } from '../types/nodes'\nimport { stableStringify } from '../utils/stable-json'\n\n/** 取文本节点的标记「指纹」,用于判断相邻节点能否合并 */\nconst marksKey = (node: MtTextNode): string => {\n const marks = node.marks\n if (!marks || marks.length === 0) return '[]'\n return stableStringify(marks)\n}\n\n/** 原地清理单个文本节点:空 `marks` 数组归一为省略 */\nconst tidyMarks = (node: MtTextNode): MtTextNode => {\n if (node.marks && node.marks.length === 0) {\n const { marks: _marks, ...rest } = node\n return rest\n }\n return node\n}\n\n/**\n * 收尾规范化一段行内节点序列。\n *\n * @param nodes 原始行内节点序列\n * @returns 规范化后的序列(新数组,不修改入参)\n *\n * @example\n * ```ts\n * finalizeInlineNodes([\n * { type: 'text', text: 'a', marks: [{ type: 'bold' }] },\n * { type: 'text', text: 'b', marks: [{ type: 'bold' }] },\n * ])\n * // [{ type: 'text', text: 'ab', marks: [{ type: 'bold' }] }]\n * ```\n */\nexport const finalizeInlineNodes = (nodes: readonly MtInlineNode[]): MtInlineNode[] => {\n /** @type {MtInlineNode[]} */\n const out: MtInlineNode[] = []\n\n for (const node of nodes) {\n if (node.type !== 'text') {\n out.push(node)\n continue\n }\n\n if (node.text.length === 0) continue\n\n const tidied = tidyMarks(node)\n const previous = out[out.length - 1]\n\n if (\n previous !== undefined &&\n previous.type === 'text' &&\n marksKey(previous) === marksKey(tidied)\n ) {\n // 标记完全一致:并入前一个节点,保持 JSON 体积最小\n out[out.length - 1] = { ...previous, text: previous.text + tidied.text }\n continue\n }\n\n out.push(tidied)\n }\n\n return out\n}\n\n/**\n * 判断两个文本节点的标记是否完全相同。\n *\n * @param a 文本节点 A\n * @param b 文本节点 B\n * @returns 是否相同\n */\nexport const hasSameMarks = (a: MtTextNode, b: MtTextNode): boolean => marksKey(a) === marksKey(b)\n","/**\n * HTML 文本/属性的转义与实体解码(零 DOM、零正则)。\n *\n * 为什么不用正则:ADR-0006 明确「禁止正则清洗 HTML」,本文件服务于\n * `parse` / `serialize` 的 HTML 通道,因此全部采用字符扫描实现。\n *\n * 往返约定:\n * - 序列化时只转义**会破坏解析**的字符(`&` `<` `>`,属性额外转义 `\"`);\n * - `\\u00a0`(不换行空格)等字符原样输出为 UTF-8,不再还原为实体,\n * 以保证 `JSON → HTML → JSON` 逐字符等价。\n */\n\n/** 文本节点需要转义的字符 */\nconst TEXT_ESCAPES: ReadonlyMap<string, string> = new Map([\n ['&', '&'],\n ['<', '<'],\n ['>', '>'],\n])\n\n/** 属性值需要额外转义的字符 */\nconst ATTR_ESCAPES: ReadonlyMap<string, string> = new Map([\n ['&', '&'],\n ['<', '<'],\n ['>', '>'],\n ['\"', '"'],\n])\n\n/**\n * 按给定映射表逐字符替换。\n *\n * @param source 源字符串\n * @param table 替换表\n * @returns 替换结果\n */\nconst escapeWith = (source: string, table: ReadonlyMap<string, string>): string => {\n let out = ''\n let changed = false\n for (let i = 0; i < source.length; i += 1) {\n const ch = source.charAt(i)\n const replacement = table.get(ch)\n if (replacement === undefined) {\n out += ch\n } else {\n out += replacement\n changed = true\n }\n }\n return changed ? out : source\n}\n\n/**\n * 转义文本内容。\n *\n * @param text 原始文本\n * @returns 可安全嵌入 HTML 文本位置的字符串\n *\n * @example\n * ```ts\n * escapeHtml('<b>1 & 2</b>') // '<b>1 & 2</b>'\n * ```\n */\nexport const escapeHtml = (text: string): string => escapeWith(text, TEXT_ESCAPES)\n\n/**\n * 转义属性值(额外处理双引号,序列化时属性统一用双引号包裹)。\n *\n * @param value 原始属性值\n * @returns 可安全嵌入 `attr=\"...\"` 的字符串\n *\n * @example\n * ```ts\n * escapeAttr('a\"b') // 'a"b'\n * ```\n */\nexport const escapeAttr = (value: string): string => escapeWith(value, ATTR_ESCAPES)\n\n/** 具名实体表(覆盖排版常用集合;未收录者按原样保留) */\nconst NAMED_ENTITIES: ReadonlyMap<string, string> = new Map([\n ['amp', '&'],\n ['lt', '<'],\n ['gt', '>'],\n ['quot', '\"'],\n ['apos', \"'\"],\n ['nbsp', '\\u00a0'],\n ['copy', '\\u00a9'],\n ['reg', '\\u00ae'],\n ['trade', '\\u2122'],\n ['hellip', '\\u2026'],\n ['mdash', '\\u2014'],\n ['ndash', '\\u2013'],\n ['laquo', '\\u00ab'],\n ['raquo', '\\u00bb'],\n ['ldquo', '\\u201c'],\n ['rdquo', '\\u201d'],\n ['lsquo', '\\u2018'],\n ['rsquo', '\\u2019'],\n ['middot', '\\u00b7'],\n ['bull', '\\u2022'],\n ['sect', '\\u00a7'],\n ['para', '\\u00b6'],\n ['deg', '\\u00b0'],\n ['plusmn', '\\u00b1'],\n ['times', '\\u00d7'],\n ['divide', '\\u00f7'],\n ['frac12', '\\u00bd'],\n ['sup2', '\\u00b2'],\n ['sup3', '\\u00b3'],\n ['euro', '\\u20ac'],\n ['pound', '\\u00a3'],\n ['yen', '\\u00a5'],\n ['cent', '\\u00a2'],\n ['rarr', '\\u2192'],\n ['larr', '\\u2190'],\n ['harr', '\\u2194'],\n ['ne', '\\u2260'],\n ['le', '\\u2264'],\n ['ge', '\\u2265'],\n])\n\n/** 实体名最大长度,超出即判定为非法实体并原样保留 */\nconst MAX_ENTITY_NAME_LENGTH = 32\n\n/**\n * 解析一个数字型实体(`{` 或 `😀`)。\n *\n * @param body 实体体(不含 `&` 与 `;`)\n * @returns 解码后的字符;非法则返回 `undefined`\n */\nconst decodeNumericEntity = (body: string): string | undefined => {\n const isHex = body.charAt(1) === 'x' || body.charAt(1) === 'X'\n const digits = isHex ? body.slice(2) : body.slice(1)\n if (digits.length === 0) return undefined\n\n let code = 0\n for (let i = 0; i < digits.length; i += 1) {\n const ch = digits.charAt(i)\n const codePoint = ch.charCodeAt(0)\n let digit = -1\n if (codePoint >= 48 && codePoint <= 57) digit = codePoint - 48\n else if (isHex && codePoint >= 97 && codePoint <= 102) digit = codePoint - 87\n else if (isHex && codePoint >= 65 && codePoint <= 70) digit = codePoint - 55\n if (digit < 0) return undefined\n code = code * (isHex ? 16 : 10) + digit\n if (code > 0x10ffff) return undefined\n }\n\n // 代理区码点与 0 视为非法,直接保留原文,避免产出破损字符\n if (code === 0 || (code >= 0xd800 && code <= 0xdfff)) return undefined\n return String.fromCodePoint(code)\n}\n\n/**\n * 解码 HTML 实体。\n *\n * @param text 含实体的文本\n * @returns 解码后的文本;无法识别的实体原样保留\n *\n * @example\n * ```ts\n * decodeEntities('a & b © 😀') // 'a & b © 😀'\n * ```\n */\nexport const decodeEntities = (text: string): string => {\n if (text.indexOf('&') < 0) return text\n\n let out = ''\n let i = 0\n while (i < text.length) {\n const ch = text.charAt(i)\n if (ch !== '&') {\n out += ch\n i += 1\n continue\n }\n\n const semi = text.indexOf(';', i + 1)\n const body = semi > i ? text.slice(i + 1, semi) : ''\n\n if (semi < 0 || body.length === 0 || body.length > MAX_ENTITY_NAME_LENGTH) {\n out += ch\n i += 1\n continue\n }\n\n if (body.charAt(0) === '#') {\n const decoded = decodeNumericEntity(body)\n if (decoded === undefined) {\n out += ch\n i += 1\n continue\n }\n out += decoded\n i = semi + 1\n continue\n }\n\n const named = NAMED_ENTITIES.get(body.toLowerCase())\n if (named === undefined) {\n out += ch\n i += 1\n continue\n }\n out += named\n i = semi + 1\n }\n\n return out\n}\n","/**\n * HTML 分词器 —— **零 DOM、零正则**的手写字符扫描实现。\n *\n * 为什么必须自己写:\n * - ADR-0006 禁止正则清洗 HTML(正则在嵌套与实体上必然出漏洞);\n * - `document` 包需在 Node 与小程序运行时执行,不能用 `DOMParser`;\n * - `tsconfig` 的 `lib` 已剔除 DOM,类型层面也拿不到任何解析器。\n *\n * 本模块**只负责语法**,不做任何安全判断(白名单在 `policy.ts` / `index.ts`)。\n * 为保证与真实浏览器尽可能一致,实现了三件事:\n *\n * 1. **原生文本元素**(`script` / `style` / `textarea` / `title`)的内容按原始\n * 文本吞掉,否则 `<script>if (a<b) {}</script>` 会被误判为标签;\n * 2. **隐式闭合**(`<p>` 遇 `<div>`、`<li>` 遇 `<li>` 等),这是外部 HTML\n * 最常见的「未闭合标签」形态;\n * 3. **注释 / doctype / 处理指令**整段跳过。\n */\n\nimport { decodeEntities } from '../utils/escape'\n\n/** 一个属性 */\nexport interface MtHtmlAttribute {\n /** 属性名,已小写 */\n name: string\n /** 属性值,已解码实体 */\n value: string\n}\n\n/** 元素节点 */\nexport interface MtHtmlElement {\n kind: 'element'\n /** 标签名,已小写 */\n tag: string\n /** 属性列表,保持出现顺序 */\n attrs: MtHtmlAttribute[]\n /** 子节点 */\n children: MtHtmlNode[]\n /**\n * 内部原始源码(仅当元素命中 {@link RAW_INNER_ATTRS} 时捕获)。\n *\n * 用途:`mathBlock` / `inlineMath` 的预渲染 SVG 需要**逐字节保留**,\n * 但它不能进 HTML 白名单(SVG 可内嵌脚本)。因此这里只保留原始字符串,\n * 由 `sanitize/svg.ts` 用独立的 SVG 白名单复验后才允许进入文档。\n */\n rawInner?: string\n}\n\n/** 文本节点 */\nexport interface MtHtmlText {\n kind: 'text'\n /** 文本内容,已解码实体 */\n value: string\n}\n\n/** 分词结果节点 */\nexport type MtHtmlNode = MtHtmlElement | MtHtmlText\n\n/** 无子节点的空元素(HTML 规范 `void elements`) */\nconst VOID_TAGS: ReadonlySet<string> = new Set([\n 'area',\n 'base',\n 'br',\n 'col',\n 'embed',\n 'hr',\n 'img',\n 'input',\n 'link',\n 'meta',\n 'param',\n 'source',\n 'track',\n 'wbr',\n])\n\n/** 内容按原始文本处理的元素(其内部不解析标签) */\nconst RAW_TEXT_TAGS: ReadonlySet<string> = new Set(['script', 'style', 'textarea', 'title'])\n\n/**\n * 命中这些属性的元素,其内部源码会被**原样捕获**到 `rawInner`。\n *\n * 目前仅用于公式容器:预渲染 SVG 必须逐字节保留,才能保证\n * `JSON → HTML → JSON` 等价(§1 原则 2)。\n */\nconst RAW_INNER_ATTRS: ReadonlySet<string> = new Set(['data-mt-math', 'data-mt-math-inline'])\n\n/**\n * 使未闭合的 `p` 隐式闭合的块级标签。\n *\n * HTML 规范中 `<p>` 的结束标签可省略:其后一旦出现块级元素,\n * 当前 `p` 即被关闭,二者成为兄弟而非嵌套。\n */\nconst P_CLOSERS: readonly string[] = [\n 'address',\n 'article',\n 'aside',\n 'blockquote',\n 'details',\n 'div',\n 'dl',\n 'fieldset',\n 'figcaption',\n 'figure',\n 'footer',\n 'form',\n 'h1',\n 'h2',\n 'h3',\n 'h4',\n 'h5',\n 'h6',\n 'header',\n 'hgroup',\n 'hr',\n 'main',\n 'menu',\n 'nav',\n 'ol',\n 'p',\n 'pre',\n 'section',\n 'table',\n 'ul',\n]\n\n/** 表格结构标签:出现时需闭合的栈顶标签集合 */\nconst TABLE_PART_CLOSERS: ReadonlySet<string> = new Set([\n 'caption',\n 'colgroup',\n 'thead',\n 'tbody',\n 'tfoot',\n 'tr',\n 'td',\n 'th',\n])\n\n/**\n * 隐式闭合表:key 为**新开的标签**,value 为出现该标签时应当先行闭合的**栈顶标签**集合。\n *\n * 例:解析到 `<li>` 时,若栈顶就是 `li`,先闭合它,形成兄弟关系而非嵌套;\n * 解析到块级元素时,若栈顶是 `p`,先闭合 `p`(`<p>` 的结束标签可省略)。\n */\nconst IMPLICIT_CLOSE: ReadonlyMap<string, ReadonlySet<string>> = new Map<\n string,\n ReadonlySet<string>\n>([\n ...P_CLOSERS.map((tag): [string, ReadonlySet<string>] => [tag, new Set(['p'])]),\n ['li', new Set(['li'])],\n ['dt', new Set(['dt', 'dd'])],\n ['dd', new Set(['dt', 'dd'])],\n ['option', new Set(['option', 'optgroup'])],\n ['optgroup', new Set(['optgroup'])],\n ['caption', TABLE_PART_CLOSERS],\n ['colgroup', TABLE_PART_CLOSERS],\n ['thead', TABLE_PART_CLOSERS],\n ['tbody', TABLE_PART_CLOSERS],\n ['tfoot', TABLE_PART_CLOSERS],\n ['tr', new Set(['tr', 'td', 'th'])],\n ['td', new Set(['td', 'th'])],\n ['th', new Set(['td', 'th'])],\n])\n\n/** 嵌套深度上限:超出后不再入栈,防止病态输入造成深递归 */\nconst MAX_NESTING_DEPTH = 256\n\n/** 单次分词产出的节点数上限 */\nconst MAX_NODE_COUNT = 200_000\n\n/** 取字符;越界返回空串 */\nconst chAt = (source: string, index: number): string => source.charAt(index)\n\n/** 是否为标签名/属性名允许的字符 */\nconst isNameChar = (ch: string): boolean => {\n if (ch === '') return false\n const code = ch.charCodeAt(0)\n const isDigit = code >= 48 && code <= 57\n const isUpper = code >= 65 && code <= 90\n const isLower = code >= 97 && code <= 122\n return isDigit || isUpper || isLower || ch === '-' || ch === '_' || ch === ':'\n}\n\n/** 是否为空白字符(HTML 空白集合) */\nconst isWhitespace = (ch: string): boolean =>\n ch === ' ' || ch === '\\t' || ch === '\\n' || ch === '\\r' || ch === '\\f'\n\n/** 是否为 ASCII 字母 */\nconst isAsciiLetter = (ch: string): boolean => {\n const code = ch.charCodeAt(0)\n return (code >= 65 && code <= 90) || (code >= 97 && code <= 122)\n}\n\n/**\n * 解析起始标签。\n *\n * @param source 源文本\n * @param start `<` 所在下标\n * @returns 标签名、属性、是否自闭合,以及下一个待读下标;语法非法时返回 `undefined`\n */\nconst parseStartTag = (\n source: string,\n start: number,\n): { tag: string; attrs: MtHtmlAttribute[]; selfClosing: boolean; next: number } | undefined => {\n let i = start + 1\n const nameStart = i\n while (isNameChar(chAt(source, i))) i += 1\n const tag = source.slice(nameStart, i).toLowerCase()\n if (tag.length === 0) return undefined\n\n const attrs: MtHtmlAttribute[] = []\n const seen = new Set<string>()\n\n for (;;) {\n while (isWhitespace(chAt(source, i))) i += 1\n const ch = chAt(source, i)\n\n if (ch === '') return { tag, attrs, selfClosing: false, next: i }\n if (ch === '>') return { tag, attrs, selfClosing: false, next: i + 1 }\n if (ch === '/' && chAt(source, i + 1) === '>') {\n return { tag, attrs, selfClosing: true, next: i + 2 }\n }\n if (ch === '/') {\n i += 1\n continue\n }\n\n const attrStart = i\n while (isNameChar(chAt(source, i)) || chAt(source, i) === '.') i += 1\n if (i === attrStart) {\n // 非法字符:跳过,避免死循环\n i += 1\n continue\n }\n const name = source.slice(attrStart, i).toLowerCase()\n\n while (isWhitespace(chAt(source, i))) i += 1\n let value = ''\n if (chAt(source, i) === '=') {\n i += 1\n while (isWhitespace(chAt(source, i))) i += 1\n const quote = chAt(source, i)\n if (quote === '\"' || quote === \"'\") {\n i += 1\n const valueStart = i\n while (chAt(source, i) !== quote && chAt(source, i) !== '') i += 1\n value = source.slice(valueStart, i)\n if (chAt(source, i) === quote) i += 1\n } else {\n const valueStart = i\n while (!isWhitespace(chAt(source, i)) && chAt(source, i) !== '>' && chAt(source, i) !== '')\n i += 1\n value = source.slice(valueStart, i)\n }\n }\n\n // 重复属性以**首次**出现为准(与浏览器一致)\n if (!seen.has(name)) {\n seen.add(name)\n attrs.push({ name, value: decodeEntities(value) })\n }\n }\n}\n\n/**\n * 跳过原生文本元素的内容,定位到其结束标签之后。\n *\n * @param source 源文本\n * @param start 内容起点\n * @param tag 原生文本元素标签名\n * @returns 结束标签之后的下标;未找到结束标签时返回源文本长度\n */\nconst skipRawText = (source: string, start: number, tag: string): number => {\n const closer = `</${tag}`\n let i = start\n for (;;) {\n const found = source.toLowerCase().indexOf(closer, i)\n if (found < 0) return source.length\n const after = chAt(source, found + closer.length)\n if (after === '>' || isWhitespace(after) || after === '/') {\n const gt = source.indexOf('>', found)\n return gt < 0 ? source.length : gt + 1\n }\n i = found + closer.length\n }\n}\n\n/** 构造元素节点 */\nconst createElement = (tag: string, attrs: MtHtmlAttribute[]): MtHtmlElement => ({\n kind: 'element',\n tag,\n attrs,\n children: [],\n})\n\n/**\n * 把 HTML 源码分词为一棵未做安全过滤的节点树。\n *\n * @param source HTML 源码\n * @returns 根层节点数组\n *\n * @example\n * ```ts\n * const nodes = tokenizeHtml('<p>a<br>b</p>')\n * nodes[0] // { kind: 'element', tag: 'p', children: [text 'a', element br, text 'b'] }\n * ```\n */\nexport const tokenizeHtml = (source: string): MtHtmlNode[] => {\n const root: MtHtmlElement = createElement('#root', [])\n const stack: MtHtmlElement[] = [root]\n let nodeCount = 1\n let i = 0\n\n /** 当前应当追加子节点的父元素 */\n const parent = (): MtHtmlElement => stack[stack.length - 1] ?? root\n\n while (i < source.length && nodeCount < MAX_NODE_COUNT) {\n const ch = chAt(source, i)\n\n if (ch !== '<') {\n const next = source.indexOf('<', i)\n const end = next < 0 ? source.length : next\n const raw = source.slice(i, end)\n if (raw.length > 0) {\n parent().children.push({ kind: 'text', value: decodeEntities(raw) })\n nodeCount += 1\n }\n i = end\n continue\n }\n\n // 注释:整段跳过\n if (source.startsWith('<!--', i)) {\n const end = source.indexOf('-->', i + 4)\n i = end < 0 ? source.length : end + 3\n continue\n }\n\n // doctype / 处理指令:跳到下一个 `>`\n if (chAt(source, i + 1) === '!' || chAt(source, i + 1) === '?') {\n const end = source.indexOf('>', i)\n i = end < 0 ? source.length : end + 1\n continue\n }\n\n // 结束标签\n if (chAt(source, i + 1) === '/') {\n let j = i + 2\n const nameEnd = j\n while (isNameChar(chAt(source, j))) j += 1\n const tag = source.slice(nameEnd, j).toLowerCase()\n const end = source.indexOf('>', j)\n i = end < 0 ? source.length : end + 1\n\n // 从栈顶向下找最近的同名元素;找不到则忽略该结束标签\n for (let depth = stack.length - 1; depth >= 1; depth -= 1) {\n const element = stack[depth]\n if (element !== undefined && element.tag === tag) {\n stack.length = depth\n break\n }\n }\n continue\n }\n\n // 起始标签\n if (isAsciiLetter(chAt(source, i + 1))) {\n const parsed = parseStartTag(source, i)\n if (parsed === undefined) {\n parent().children.push({ kind: 'text', value: '<' })\n nodeCount += 1\n i += 1\n continue\n }\n\n const { tag, attrs, selfClosing, next } = parsed\n i = next\n\n // 隐式闭合:栈顶若与新标签冲突,先弹出\n const closers = IMPLICIT_CLOSE.get(tag)\n if (closers) {\n while (stack.length > 1) {\n const top = stack[stack.length - 1]\n if (top === undefined || !closers.has(top.tag)) break\n stack.length -= 1\n }\n }\n\n const element = createElement(tag, attrs)\n parent().children.push(element)\n nodeCount += 1\n\n if (selfClosing || VOID_TAGS.has(tag)) continue\n\n if (RAW_TEXT_TAGS.has(tag)) {\n // 原生文本元素:内容整体作为文本(后续会被安全策略整段丢弃)\n const contentStart = i\n i = skipRawText(source, i, tag)\n const closeStart = source.indexOf('</', contentStart)\n const innerEnd = closeStart >= 0 && closeStart < i ? closeStart : i\n element.children.push({ kind: 'text', value: source.slice(contentStart, innerEnd) })\n nodeCount += 1\n continue\n }\n\n // 公式容器:原样捕获内部源码,后续交由 SVG 白名单复验\n if (attrs.some((attr) => RAW_INNER_ATTRS.has(attr.name))) {\n const closeTag = `</${tag}`\n const lower = source.toLowerCase()\n let cursor = i\n let closeAt = -1\n for (;;) {\n const found = lower.indexOf(closeTag, cursor)\n if (found < 0) break\n const after = chAt(source, found + closeTag.length)\n if (after === '>' || isWhitespace(after) || after === '/') {\n closeAt = found\n break\n }\n cursor = found + closeTag.length\n }\n element.rawInner = closeAt < 0 ? source.slice(i) : source.slice(i, closeAt)\n if (closeAt < 0) {\n i = source.length\n } else {\n const gt = source.indexOf('>', closeAt)\n i = gt < 0 ? source.length : gt + 1\n }\n continue\n }\n\n if (stack.length < MAX_NESTING_DEPTH) stack.push(element)\n continue\n }\n\n // 孤立的 `<`:按字面文本处理\n parent().children.push({ kind: 'text', value: '<' })\n nodeCount += 1\n i += 1\n }\n\n return root.children\n}\n\n/**\n * 从元素取属性值。\n *\n * @param element 元素节点\n * @param name 属性名(小写)\n * @returns 属性值;不存在返回 `undefined`\n */\nexport const getAttribute = (element: MtHtmlElement, name: string): string | undefined => {\n for (const attr of element.attrs) {\n if (attr.name === name) return attr.value\n }\n return undefined\n}\n\n/**\n * 判断节点是否为元素。\n *\n * @param node 待判断节点\n * @returns 是否为元素\n */\nexport const isElement = (node: MtHtmlNode): node is MtHtmlElement => node.kind === 'element'\n","/**\n * 内联样式的单位与取值约定。\n *\n * 序列化器与解析器必须共用同一套约定,否则 `JSON → HTML → JSON`\n * 会在 `indent` / `colwidth` 这类数值属性上出现漂移。\n */\n\nimport type { MtMediaAlign, MtOrderedListType, MtTextAlign } from '../types/base'\n\n/** `indent` 的 CSS 单位:`indent` 为 2 时序列化为 `padding-left: 2em` */\nexport const MT_INDENT_UNIT = 'em'\n\n/** `indent` 的取值上限(§4.2:0–4) */\nexport const MT_INDENT_MAX = 4\n\n/** `colwidth` 的 CSS 单位 */\nexport const MT_COLWIDTH_UNIT = 'px'\n\n/** 水平对齐的合法取值(§3.4) */\nexport const MT_TEXT_ALIGN_VALUES: readonly MtTextAlign[] = ['left', 'center', 'right', 'justify']\n\n/** 媒体对齐的合法取值(§4.3) */\nexport const MT_MEDIA_ALIGN_VALUES: readonly MtMediaAlign[] = ['left', 'center', 'right']\n\n/** 有序列表编号样式的合法取值(§4.2) */\nexport const MT_ORDERED_TYPE_VALUES: readonly MtOrderedListType[] = ['1', 'a', 'A', 'i', 'I']\n\n/**\n * 解析「数值 + 单位」形式的 CSS 长度。\n *\n * @param value CSS 值,如 `'2em'`\n * @param unit 期望单位,如 `'em'`\n * @returns 数值;不匹配或非有限数时返回 `undefined`\n *\n * @example\n * ```ts\n * parseCssLength('2em', 'em') // 2\n * parseCssLength('32px', 'em') // undefined(单位不符)\n * ```\n */\nexport const parseCssLength = (value: string | undefined, unit: string): number | undefined => {\n if (value === undefined) return undefined\n const trimmed = value.trim().toLowerCase()\n if (!trimmed.endsWith(unit)) return undefined\n const numeric = trimmed.slice(0, trimmed.length - unit.length).trim()\n if (numeric.length === 0) return undefined\n const parsed = Number(numeric)\n return Number.isFinite(parsed) ? parsed : undefined\n}\n\n/**\n * 解析无单位数值(如 `line-height: 1.5`)。\n *\n * @param value CSS 值\n * @returns 数值;非法时返回 `undefined`\n *\n * @example\n * ```ts\n * parseUnitless('1.5') // 1.5\n * parseUnitless('normal') // undefined\n * ```\n */\nexport const parseUnitless = (value: string | undefined): number | undefined => {\n if (value === undefined) return undefined\n const trimmed = value.trim()\n if (trimmed.length === 0) return undefined\n const parsed = Number(trimmed)\n return Number.isFinite(parsed) ? parsed : undefined\n}\n\n/**\n * 把数值格式化为「数值 + 单位」的 CSS 长度。\n *\n * @param value 数值\n * @param unit 单位\n * @returns CSS 值\n *\n * @example\n * ```ts\n * formatCssLength(2, 'em') // '2em'\n * ```\n */\nexport const formatCssLength = (value: number, unit: string): string => `${value}${unit}`\n\n/**\n * 解析 `padding-left` 得到缩进层级。\n *\n * 兼容 `em`(本项目的规范写法)与 `px`(外部 HTML 常见写法,按 16px = 1 级折算)。\n *\n * @param value `padding-left` 的 CSS 值\n * @returns 缩进层级(已按 0–4 钳制);无法解析时返回 `undefined`\n */\nexport const parseIndent = (value: string | undefined): number | undefined => {\n if (value === undefined) return undefined\n const em = parseCssLength(value, 'em')\n const level =\n em !== undefined\n ? em\n : (() => {\n const px = parseCssLength(value, 'px')\n return px === undefined ? undefined : Math.round(px / 16)\n })()\n if (level === undefined) return undefined\n return Math.min(MT_INDENT_MAX, Math.max(0, Math.round(level)))\n}\n","/**\n * HTML 属性取值的解析原语。\n *\n * 这些函数把「从 HTML 上读到的字符串」翻译成 `MtDocument` 的取值域,\n * 与序列化器写出的形式严格对称——`JSON → HTML → JSON` 逐字符等价的前提,\n * 就是「写出去什么、就读回来什么,且只读回真实出现的信息」。\n *\n * 之所以独立成模块:`@mteditor/core` 的 Tiptap 属性定义必须复用**完全相同**的\n * 取值约定,否则编辑器写入的 HTML 与只读渲染器读出的 JSON 会漂移。\n * 这些函数只依赖 {@link ./style-units} 与类型定义,不触碰 DOM。\n */\n\nimport type { MtMediaAlign, MtOrderedListType, MtTextAlign } from '../types/base'\nimport {\n MT_MEDIA_ALIGN_VALUES,\n MT_ORDERED_TYPE_VALUES,\n MT_TEXT_ALIGN_VALUES,\n parseUnitless,\n} from './style-units'\n\n/**\n * 读取文本对齐(§3.4)。\n *\n * @param value CSS `text-align` 取值\n * @returns 对齐方式;非法或缺失时返回 `undefined`\n *\n * @example\n * ```ts\n * parseTextAlign('Center') // 'center'\n * parseTextAlign('middle') // undefined\n * ```\n */\nexport const parseTextAlign = (value: string | undefined): MtTextAlign | undefined => {\n if (value === undefined) return undefined\n const lowered = value.trim().toLowerCase()\n return MT_TEXT_ALIGN_VALUES.find((item) => item === lowered)\n}\n\n/**\n * 读取媒体对齐(§4.3,取自 `data-mt-align`)。\n *\n * @param value 属性值\n * @returns 对齐方式;非法或缺失时返回 `undefined`\n *\n * @example\n * ```ts\n * parseMediaAlign('right') // 'right'\n * parseMediaAlign('justify') // undefined(媒体不支持两端对齐)\n * ```\n */\nexport const parseMediaAlign = (value: string | undefined): MtMediaAlign | undefined => {\n if (value === undefined) return undefined\n const lowered = value.trim().toLowerCase()\n return MT_MEDIA_ALIGN_VALUES.find((item) => item === lowered)\n}\n\n/**\n * 读取有序列表编号样式(§4.2)。\n *\n * 与 {@link parseTextAlign} 不同,这里**不转小写**——`a` 与 `A` 是两种合法取值,\n * 分别表示小写与大写字母编号。\n *\n * @param value `type` 属性值\n * @returns 编号样式;非法或缺失时返回 `undefined`\n *\n * @example\n * ```ts\n * parseOrderedType('A') // 'A'\n * parseOrderedType('x') // undefined\n * ```\n */\nexport const parseOrderedType = (value: string | undefined): MtOrderedListType | undefined => {\n if (value === undefined) return undefined\n return MT_ORDERED_TYPE_VALUES.find((item) => item === value)\n}\n\n/**\n * 读取整型属性(如 `colspan` / `start`)。\n *\n * @param value 属性值\n * @returns 截断后的整数;非法或缺失时返回 `undefined`\n *\n * @example\n * ```ts\n * parseInteger('3') // 3\n * parseInteger('2.9') // 2\n * parseInteger('abc') // undefined\n * ```\n */\nexport const parseInteger = (value: string | undefined): number | undefined => {\n if (value === undefined) return undefined\n const trimmed = value.trim()\n if (trimmed.length === 0) return undefined\n const parsed = Number(trimmed)\n if (!Number.isFinite(parsed)) return undefined\n return Math.trunc(parsed)\n}\n\n/**\n * 读取 `line-height`(无单位倍数,§3.4)。\n *\n * @param value CSS `line-height` 取值\n * @returns 倍数;非法或缺失时返回 `undefined`\n *\n * @example\n * ```ts\n * parseLineHeight('1.6') // 1.6\n * parseLineHeight('normal') // undefined\n * ```\n */\nexport const parseLineHeight = (value: string | undefined): number | undefined =>\n parseUnitless(value)\n\n/**\n * 读取布尔属性(`data-mt-*` 形式的 `\"true\"` / `\"false\"`,§4.2)。\n *\n * 只有显式的 `\"true\"` / `\"false\"` 才被接受——空串、`\"1\"` 一律视为非法,\n * 避免「属性存在即真」的隐式约定破坏往返等价。\n *\n * @param value 属性值\n * @returns 布尔值;非法或缺失时返回 `undefined`\n *\n * @example\n * ```ts\n * parseBooleanAttr('true') // true\n * parseBooleanAttr('FALSE') // false\n * parseBooleanAttr('') // undefined\n * ```\n */\nexport const parseBooleanAttr = (value: string | undefined): boolean | undefined => {\n if (value === undefined) return undefined\n const lowered = value.trim().toLowerCase()\n if (lowered === 'true') return true\n if (lowered === 'false') return false\n return undefined\n}\n\n/** 3 / 4 / 6 / 8 位十六进制颜色(`#abc` / `#abcd` / `#aabbcc` / `#aabbccdd`) */\nconst HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/\n\n/**\n * `rgb(r, g, b)` / `rgba(r, g, b, 1)`。\n *\n * 三个分量只接受整数或百分比;alpha 只接受 `1`(或缺省)。带真实透明度的\n * 颜色无法无损压成 `#rrggbb`,交由上层白名单判定。\n */\nconst RGB_COLOR_PATTERN =\n /^rgba?\\(\\s*(\\d{1,3}%?)\\s*,\\s*(\\d{1,3}%?)\\s*,\\s*(\\d{1,3}%?)\\s*(?:,\\s*(?:1(?:\\.0+)?)\\s*)?\\)$/\n\n/** 把 `rgb()` 的单个分量转成两位十六进制;越界或非法时返回空串 */\nconst toByteHex = (component: string): string => {\n const byte = component.endsWith('%')\n ? Math.round((Number.parseFloat(component) / 100) * 255)\n : Number.parseInt(component, 10)\n if (!Number.isFinite(byte) || byte < 0 || byte > 255) return ''\n return byte.toString(16).padStart(2, '0')\n}\n\n/** `#rgb` / `#rgba` 展开成 `#rrggbb` / `#rrggbbaa` */\nconst expandHexColor = (hex: string): string =>\n hex.length === 4 || hex.length === 5\n ? `#${hex\n .slice(1)\n .split('')\n .map((char) => char + char)\n .join('')}`\n : hex\n\n/**\n * 把 CSS 颜色归一化为规范形式:**小写、完整位数的十六进制**。\n *\n * 存在意义是**对抗 DOM 的强制归一化**:任何真实 DOM 的\n * `CSSStyleDeclaration.cssText` 都会把 `background-color: #ffec3d` 改写成\n * `background-color: rgb(255, 236, 61)`。而 `@mteditor/core` 读样式只能拿到\n * 改写后的串,且改写后含 `(`——会被 `parseInlineStyle` 的安全白名单判为\n * 不可信而**静默丢弃颜色**。把它还原成 `#rrggbb` 后:\n *\n * 1. 编辑器(经 DOM)与只读渲染器(零 DOM)对同一份 HTML 得到同一个值;\n * 2. `JSON → DOM → JSON` 在颜色这一维度上重新变得无损且幂等。\n *\n * 只识别**字面量**颜色(hex / `rgb()` / `rgba(…,1)`),不做颜色名补全,\n * 也不接受任何其它函数式写法——归一化结果一定是纯十六进制串,\n * 不引入新的注入面。\n *\n * @param value CSS 颜色取值\n * @returns 归一化后的 `#rrggbb…`;不是可识别的字面量颜色时返回 `undefined`\n *\n * @example\n * ```ts\n * normalizeStyleColor('#FAFAFA') // '#fafafa'\n * normalizeStyleColor('#fff') // '#ffffff'\n * normalizeStyleColor('rgb(255, 236, 61)') // '#ffec3d'\n * normalizeStyleColor('rgba(0, 0, 0, 1)') // '#000000'\n * normalizeStyleColor('transparent') // undefined(交给白名单原样保留)\n * normalizeStyleColor('expression(alert(1))') // undefined\n * ```\n */\nexport const normalizeStyleColor = (value: string | undefined): string | undefined => {\n if (value === undefined) return undefined\n const trimmed = value.trim().toLowerCase()\n if (HEX_COLOR_PATTERN.test(trimmed)) return expandHexColor(trimmed)\n\n const rgb = RGB_COLOR_PATTERN.exec(trimmed)\n if (rgb === null) return undefined\n // `noUncheckedIndexedAccess` 下捕获组可能是 undefined,逐个收窄\n const [red, green, blue] = [rgb[1], rgb[2], rgb[3]]\n if (red === undefined || green === undefined || blue === undefined) return undefined\n const channels = [toByteHex(red), toByteHex(green), toByteHex(blue)]\n return channels.includes('') ? undefined : `#${channels.join('')}`\n}\n","/**\n * HTML 安全白名单策略(`docs/document-model.md` §7.1–§7.3)。\n *\n * 三条白名单:**标签**、**属性**、**URL 协议**。白名单之外的处置分两类:\n *\n * | 情形 | 处置 | 理由 |\n * |---|---|---|\n * | 危险标签(`script` / `iframe` / `form` …) | **整段丢弃** | 其内容本身即攻击载荷 |\n * | 普通容器标签(`div` / `section` / `article` …) | **解包保留文本** | 丢弃会损失正文 |\n * | `class` / `id` | **静默丢弃** | 属样式与宿主契约,无内容语义(§7.1) |\n * | 其余属性 | 丢弃并写降级报告 | 可能是语义或安全的载体 |\n */\n\nimport { normalizeStyleColor } from '../utils/attr-values'\n\n/** 标签规则 */\nexport interface MtTagRule {\n /** 允许的普通属性名(小写) */\n attrs: readonly string[]\n /** 是否允许任意 `data-mt-*` 属性 */\n allowDataMt: boolean\n /** 原子元素:忽略其全部子节点(`img` / `video` / `audio` / `hr` / `br` / `col`) */\n atom: boolean\n}\n\n/** 文本块容器允许的属性(§7.1:`style` 承载对齐/行高/缩进) */\nconst TEXT_BLOCK_ATTRS: readonly string[] = ['style']\n\n/** 表格单元格允许的属性(§7.1) */\nconst CELL_ATTRS: readonly string[] = ['colspan', 'rowspan', 'style']\n\n/**\n * 标签白名单。\n *\n * 除 §7.1 / §7.2 列出的标签外,额外接纳三类**外部 HTML 常见同义标签**,\n * 以便粘贴场景不丢语义(序列化时仍统一输出 §7.1 规定的规范标签):\n * - `b` / `i` → `bold` / `italic`;\n * - `del` / `strike` → `strike`。\n */\nexport const MT_TAG_RULES: ReadonlyMap<string, MtTagRule> = new Map<string, MtTagRule>([\n ['p', { attrs: TEXT_BLOCK_ATTRS, allowDataMt: true, atom: false }],\n ['h1', { attrs: TEXT_BLOCK_ATTRS, allowDataMt: true, atom: false }],\n ['h2', { attrs: TEXT_BLOCK_ATTRS, allowDataMt: true, atom: false }],\n ['h3', { attrs: TEXT_BLOCK_ATTRS, allowDataMt: true, atom: false }],\n ['h4', { attrs: TEXT_BLOCK_ATTRS, allowDataMt: true, atom: false }],\n ['h5', { attrs: TEXT_BLOCK_ATTRS, allowDataMt: true, atom: false }],\n ['h6', { attrs: TEXT_BLOCK_ATTRS, allowDataMt: true, atom: false }],\n ['blockquote', { attrs: [], allowDataMt: true, atom: false }],\n ['ul', { attrs: [], allowDataMt: true, atom: false }],\n ['ol', { attrs: ['start', 'type'], allowDataMt: true, atom: false }],\n ['li', { attrs: [], allowDataMt: true, atom: false }],\n ['pre', { attrs: [], allowDataMt: true, atom: false }],\n ['code', { attrs: ['class'], allowDataMt: false, atom: false }],\n ['hr', { attrs: [], allowDataMt: true, atom: true }],\n ['br', { attrs: [], allowDataMt: false, atom: true }],\n [\n 'img',\n { attrs: ['src', 'alt', 'title', 'width', 'height', 'style'], allowDataMt: true, atom: true },\n ],\n [\n 'video',\n {\n attrs: ['src', 'poster', 'width', 'height', 'controls', 'style'],\n allowDataMt: true,\n atom: true,\n },\n ],\n ['audio', { attrs: ['src', 'title', 'controls'], allowDataMt: true, atom: true }],\n [\n 'a',\n {\n attrs: ['href', 'target', 'rel', 'title', 'download', 'style'],\n allowDataMt: true,\n atom: false,\n },\n ],\n ['table', { attrs: [], allowDataMt: true, atom: false }],\n ['colgroup', { attrs: [], allowDataMt: false, atom: false }],\n ['col', { attrs: ['span', 'width'], allowDataMt: false, atom: true }],\n ['thead', { attrs: [], allowDataMt: false, atom: false }],\n ['tbody', { attrs: [], allowDataMt: false, atom: false }],\n ['tfoot', { attrs: [], allowDataMt: false, atom: false }],\n ['tr', { attrs: [], allowDataMt: false, atom: false }],\n ['td', { attrs: CELL_ATTRS, allowDataMt: true, atom: false }],\n ['th', { attrs: CELL_ATTRS, allowDataMt: true, atom: false }],\n ['span', { attrs: ['style', 'data-tex'], allowDataMt: true, atom: false }],\n ['div', { attrs: ['style', 'data-tex'], allowDataMt: true, atom: false }],\n ['strong', { attrs: [], allowDataMt: false, atom: false }],\n ['em', { attrs: [], allowDataMt: false, atom: false }],\n ['u', { attrs: [], allowDataMt: false, atom: false }],\n ['s', { attrs: [], allowDataMt: false, atom: false }],\n ['del', { attrs: [], allowDataMt: false, atom: false }],\n ['strike', { attrs: [], allowDataMt: false, atom: false }],\n ['b', { attrs: [], allowDataMt: false, atom: false }],\n ['i', { attrs: [], allowDataMt: false, atom: false }],\n ['sub', { attrs: [], allowDataMt: false, atom: false }],\n ['sup', { attrs: [], allowDataMt: false, atom: false }],\n ['mark', { attrs: ['style'], allowDataMt: false, atom: false }],\n])\n\n/**\n * 整段丢弃的标签(含其全部内容)。\n *\n * 覆盖四类:脚本/样式载体、可执行嵌入、表单控件、元信息容器。\n */\nexport const MT_DROP_TAGS: ReadonlySet<string> = new Set([\n 'script',\n 'style',\n 'iframe',\n 'frame',\n 'frameset',\n 'noframes',\n 'object',\n 'embed',\n 'applet',\n 'param',\n 'form',\n 'input',\n 'select',\n 'option',\n 'optgroup',\n 'textarea',\n 'button',\n 'label',\n 'svg',\n 'math',\n 'canvas',\n 'template',\n 'noscript',\n 'plaintext',\n 'xmp',\n 'listing',\n 'link',\n 'meta',\n 'base',\n 'source',\n 'track',\n 'area',\n 'map',\n 'head',\n 'title',\n])\n\n/** URL 属性规则:`tag -> (attr -> 是否放行 data:image/*)` */\nconst URL_ATTR_RULES: ReadonlyMap<string, ReadonlyMap<string, boolean>> = new Map([\n ['a', new Map([['href', false]])],\n ['img', new Map([['src', true]])],\n [\n 'video',\n new Map([\n ['src', false],\n ['poster', false],\n ]),\n ],\n ['audio', new Map([['src', false]])],\n])\n\n/** 静默丢弃、不写报告的属性名(样式与宿主契约,无内容语义) */\nconst SILENT_DROP_ATTRS: ReadonlySet<string> = new Set(['class', 'id'])\n\n/** 允许保留的 CSS 属性白名单 */\nexport const MT_ALLOWED_STYLE_PROPS: readonly string[] = [\n // 块级容器(§3.4)\n 'text-align',\n 'line-height',\n 'padding-left',\n // textStyle(§5)\n 'font-family',\n 'font-size',\n 'color',\n 'background-color',\n 'letter-spacing',\n // 表格单元格列宽(§4.4 colwidth)\n 'width',\n // 外部 HTML 常见的内联强调写法,解析时会转成对应 mark\n 'font-weight',\n 'font-style',\n 'text-decoration',\n]\n\n/**\n * 判断某属性是否为 URL 属性,以及是否放行 `data:image/*`。\n *\n * @param tag 标签名\n * @param attr 属性名\n * @returns 判定结果;非 URL 属性时 `isUrl` 为 `false`\n */\nexport const getUrlAttrRule = (\n tag: string,\n attr: string,\n): { isUrl: boolean; allowDataImage: boolean } => {\n const rule = URL_ATTR_RULES.get(tag)?.get(attr)\n if (rule === undefined) return { isUrl: false, allowDataImage: false }\n return { isUrl: true, allowDataImage: rule }\n}\n\n/**\n * 判断属性是否应静默丢弃(不写降级报告)。\n *\n * @param attr 属性名\n * @returns 是否静默丢弃\n */\nexport const isSilentlyDroppedAttr = (attr: string): boolean => SILENT_DROP_ATTRS.has(attr)\n\n/**\n * 判断 CSS 值是否安全。\n *\n * 白名单属性本身已收窄了攻击面,此处再拒绝一切含括号、引号、尖括号或\n * 反斜杠的值——本项目的目标属性全部是纯字面量,无需任何函数式写法。\n *\n * @param value CSS 值\n * @returns 是否安全\n */\nconst isSafeStyleValue = (value: string): boolean => {\n if (value.length === 0 || value.length > 256) return false\n for (let i = 0; i < value.length; i += 1) {\n const ch = value.charAt(i)\n if (ch === '(' || ch === ')' || ch === '\"' || ch === \"'\") return false\n if (ch === '<' || ch === '>' || ch === '\\\\' || ch === ';') return false\n }\n return true\n}\n\n/**\n * 允许出现**函数式颜色**的 CSS 属性。\n *\n * 只有这两个属性会被 DOM 改写成 `rgb(...)`(`CSSStyleDeclaration.cssText`\n * 把一切颜色统一成函数式写法),因此也只有它们需要走\n * {@link normalizeStyleColor} 的还原通道。\n */\nconst COLOR_STYLE_PROPS: readonly string[] = ['color', 'background-color']\n\n/**\n * 决定一条 CSS 声明最终以什么取值进入文档。\n *\n * 颜色属性先过 {@link normalizeStyleColor}:命中说明它是可识别的**字面量**\n * 颜色,归一化成 `#rrggbb` 后返回;未命中(含 `transparent`、颜色名、\n * 真透明度的 `rgba()`、以及 `expression(…)` 这类载荷)落回\n * {@link isSafeStyleValue} 的通用判定,行为与从前完全一致。\n *\n * @param name 小写 CSS 属性名\n * @param rawValue 已 trim 的原始取值\n * @returns 归一化后的取值;不可接受时返回 `undefined`\n */\nconst resolveStyleValue = (name: string, rawValue: string): string | undefined => {\n if (COLOR_STYLE_PROPS.includes(name)) {\n const normalized = normalizeStyleColor(rawValue)\n if (normalized !== undefined) return normalized\n }\n return isSafeStyleValue(rawValue) ? rawValue : undefined\n}\n\n/**\n * 解析内联 `style` 属性,仅保留白名单内的 CSS 属性。\n *\n * 被丢弃的声明必须**留痕**(AGENTS.md §12.2:不允许静默丢弃)——\n * 尤其是「属性名合法但值不可信」的情形(如\n * `width: expression(alert(1))`),它恰恰是攻击载体,\n * 静默丢弃会让安全回归失去观测点。\n *\n * @param value 原始 `style` 值\n * @param onDrop 丢弃声明的回调(收到的是规范化的 `name: value` 文本)\n * @returns 属性名 → 值 的映射(键已小写)\n *\n * @example\n * ```ts\n * parseInlineStyle('text-align: center; background: url(x)')\n * // { 'text-align': 'center' }(background 不在白名单,url(x) 亦不可信)\n *\n * // 颜色属性走归一化通道:DOM 把 hex 改写成 rgb() 后仍能还原回同一个值\n * parseInlineStyle('background-color: rgb(255, 236, 61)')\n * // { 'background-color': '#ffec3d' }\n * ```\n */\nexport const parseInlineStyle = (\n value: string,\n onDrop?: (declaration: string, reason: 'prop' | 'value') => void,\n): Record<string, string> => {\n const result: Record<string, string> = {}\n if (value.length === 0) return result\n\n const parts = value.split(';')\n for (const part of parts) {\n const colon = part.indexOf(':')\n // 无冒号的片段属语法噪声,不作为「被丢弃的声明」上报\n if (colon <= 0) continue\n const name = part.slice(0, colon).trim().toLowerCase()\n const rawValue = part.slice(colon + 1).trim()\n if (!MT_ALLOWED_STYLE_PROPS.includes(name)) {\n onDrop?.(`${name}: ${rawValue}`, 'prop')\n continue\n }\n const resolved = resolveStyleValue(name, rawValue)\n if (resolved === undefined) {\n onDrop?.(`${name}: ${rawValue}`, 'value')\n continue\n }\n result[name] = resolved\n }\n return result\n}\n\n/**\n * 把属性映射重新拼成 `style` 字符串(键按插入顺序)。\n *\n * @param props 属性映射\n * @returns `style` 值;无属性时返回空串\n *\n * @example\n * ```ts\n * buildStyleString({ 'text-align': 'center' }) // 'text-align: center'\n * ```\n */\nexport const buildStyleString = (props: Record<string, string>): string => {\n const parts: string[] = []\n for (const name of Object.keys(props)) {\n const value = props[name]\n if (value === undefined) continue\n parts.push(`${name}: ${value}`)\n }\n return parts.join('; ')\n}\n","/**\n * HTML 安全清洗(`docs/document-model.md` §7.3、ADR-0006)。\n *\n * 入口唯一:`parseHTML()` 必须先经本模块,**禁止把原始字符串直接透传**。\n * 实现路线:分词(`tokenizer.ts`)→ 白名单过滤(`policy.ts`)→ 输出**清洗后的节点树**,\n * 全程 AST 级处理,不使用正则、不触碰 DOM。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport type { MtDegradationEntry } from '../types/report'\nimport { checkUrl } from '../utils/url'\nimport { isElement, tokenizeHtml } from './tokenizer'\nimport type { MtHtmlElement, MtHtmlNode } from './tokenizer'\nimport {\n buildStyleString,\n getUrlAttrRule,\n isSilentlyDroppedAttr,\n MT_DROP_TAGS,\n MT_TAG_RULES,\n parseInlineStyle,\n} from './policy'\nimport type { MtTagRule } from './policy'\n\n/** 清洗后的元素节点 */\nexport interface MtCleanElement {\n kind: 'element'\n /** 标签名(已小写) */\n tag: string\n /** 白名单内的属性 */\n attrs: Record<string, string>\n /** 子节点 */\n children: MtCleanNode[]\n /**\n * 内部原始源码(仅公式容器)。\n * 由 `sanitize/svg.ts` 的 SVG 白名单复验后才允许进入文档。\n */\n rawInner?: string\n}\n\n/** 清洗后的文本节点 */\nexport interface MtCleanText {\n kind: 'text'\n value: string\n}\n\n/** 清洗后的节点 */\nexport type MtCleanNode = MtCleanElement | MtCleanText\n\n/** 清洗结果 */\nexport interface MtSanitizeResult {\n /** 清洗后的节点树 */\n nodes: MtCleanNode[]\n /** 降级报告 */\n degradations: MtDegradationEntry[]\n}\n\n/** 清洗过程的上下文 */\ninterface SanitizeContext {\n degradations: MtDegradationEntry[]\n}\n\n/** 允许携带 `rawInner` 的标签(公式容器) */\nconst RAW_INNER_TAGS: ReadonlySet<string> = new Set(['div', 'span'])\n\n/**\n * 过滤单个元素的属性。\n *\n * 处置顺序(先安全、后白名单):\n * 1. `on*` 事件属性 → 丢弃并报告;\n * 2. `class` / `id` → 静默丢弃(无内容语义);\n * 3. 未声明的属性 → 丢弃并报告;\n * 4. `style` → 白名单 CSS 属性过滤后重新拼接;\n * 5. URL 属性 → 协议白名单校验,不安全则置空并报告。\n *\n * @param element 原始元素\n * @param rule 该标签的规则\n * @param path 定位路径\n * @param ctx 清洗上下文\n * @returns 过滤后的属性\n */\nconst cleanAttrs = (\n element: MtHtmlElement,\n rule: MtTagRule,\n path: string,\n ctx: SanitizeContext,\n): Record<string, string> => {\n const out: Record<string, string> = {}\n const tag = element.tag\n\n for (const attr of element.attrs) {\n const name = attr.name\n\n if (name.startsWith('on')) {\n ctx.degradations.push(\n degrade(MT_DEG.ATTR_DROPPED, tag, path, `事件属性 ${name} 已丢弃`, 'dropped'),\n )\n continue\n }\n\n // 白名单判定优先于静默丢弃:`class` 仅对 `code` 有意义(`language-x`)\n const isDataMt = rule.allowDataMt && name.startsWith('data-mt-')\n const declared = rule.attrs.includes(name)\n if (!isDataMt && !declared) {\n if (isSilentlyDroppedAttr(name)) continue\n ctx.degradations.push(\n degrade(MT_DEG.ATTR_DROPPED, tag, path, `属性 ${name} 不在白名单,已丢弃`, 'dropped'),\n )\n continue\n }\n\n if (name === 'style') {\n const styleValue = buildStyleString(\n parseInlineStyle(attr.value, (declaration, reason) => {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n tag,\n path,\n reason === 'value'\n ? `style 声明 \"${declaration}\" 的值不可信(含括号/引号/分号等),已丢弃`\n : `style 声明 \"${declaration}\" 的属性不在白名单,已丢弃`,\n 'dropped',\n ),\n )\n }),\n )\n // 全部声明都被丢弃时不保留空的 `style` 属性\n if (styleValue.length > 0) out.style = styleValue\n continue\n }\n\n const urlRule = getUrlAttrRule(tag, name)\n if (urlRule.isUrl) {\n const verdict = checkUrl(attr.value, { allowDataImage: urlRule.allowDataImage })\n if (!verdict.ok) {\n ctx.degradations.push(\n degrade(\n MT_DEG.DANGEROUS_URL,\n tag,\n path,\n `${name}=\"${attr.value}\"(${verdict.reason ?? ''}),已置空`,\n 'dropped',\n ),\n )\n out[name] = ''\n continue\n }\n }\n\n out[name] = attr.value\n }\n\n return out\n}\n\n/**\n * 递归清洗节点序列。\n *\n * @param nodes 原始节点序列\n * @param basePath 当前路径前缀\n * @param ctx 清洗上下文\n * @returns 清洗后的节点序列\n */\nconst cleanNodes = (\n nodes: readonly MtHtmlNode[],\n basePath: string,\n ctx: SanitizeContext,\n): MtCleanNode[] => {\n const out: MtCleanNode[] = []\n\n for (let index = 0; index < nodes.length; index += 1) {\n const node = nodes[index]\n if (node === undefined) continue\n const path = `${basePath}[${index}]`\n\n if (!isElement(node)) {\n out.push({ kind: 'text', value: node.value })\n continue\n }\n\n const tag = node.tag\n\n if (MT_DROP_TAGS.has(tag)) {\n ctx.degradations.push(\n degrade(MT_DEG.TAG_NOT_ALLOWED, tag, path, `标签 <${tag}> 及其内容已被整段丢弃`, 'dropped'),\n )\n continue\n }\n\n const rule = MT_TAG_RULES.get(tag)\n if (rule === undefined) {\n // 普通容器:解包保留子节点,避免丢失正文\n ctx.degradations.push(\n degrade(\n MT_DEG.TAG_NOT_ALLOWED,\n tag,\n path,\n `标签 <${tag}> 不在白名单,已解包保留内容`,\n 'flattened',\n ),\n )\n out.push(...cleanNodes(node.children, path, ctx))\n continue\n }\n\n out.push({\n kind: 'element',\n tag,\n attrs: cleanAttrs(node, rule, path, ctx),\n children: rule.atom ? [] : cleanNodes(node.children, path, ctx),\n ...(RAW_INNER_TAGS.has(tag) && node.rawInner !== undefined\n ? { rawInner: node.rawInner }\n : {}),\n })\n }\n\n return out\n}\n\n/**\n * 清洗 HTML 源码,产出白名单内的节点树。\n *\n * @param source HTML 源码\n * @returns 清洗结果(含降级报告)\n *\n * @example\n * ```ts\n * const { nodes, degradations } = sanitizeHtml('<p onclick=\"x()\">hi</p><script>alert(1)</script>')\n * // nodes 只剩 <p>hi</p>;degradations 记录了 onclick 与 script\n * ```\n */\nexport const sanitizeHtml = (source: string): MtSanitizeResult => {\n const ctx: SanitizeContext = { degradations: [] }\n if (typeof source !== 'string' || source.length === 0) {\n return { nodes: [], degradations: [] }\n }\n const nodes = cleanNodes(tokenizeHtml(source), 'html', ctx)\n return { nodes, degradations: ctx.degradations }\n}\n","/**\n * 预渲染 SVG 的独立白名单复验(`docs/document-model.md` §7.1、ADR-0013)。\n *\n * 背景:`mathBlock.svg` / `inlineMath.svg` 需**逐字节保留**原始 SVG 才能满足\n * `JSON → HTML → JSON` 等价。把 `<svg>` 加进 HTML 白名单是不可接受的——\n * SVG 可内嵌 `<script>`、`<foreignObject>`、事件属性与外部引用。\n *\n * 因此采用**独立命名空间的白名单**:只放行数学排版必需的 SVG 元素与\n * 安全的引用形式(`#id` 锚点),其余一律判定为不安全并丢弃整段产物。\n */\n\nimport { tokenizeHtml } from './tokenizer'\nimport type { MtHtmlNode } from './tokenizer'\n\n/** 数学排版必需的 SVG 元素白名单 */\nconst ALLOWED_SVG_TAGS: ReadonlySet<string> = new Set([\n 'svg',\n 'g',\n 'path',\n 'use',\n 'defs',\n 'symbol',\n 'rect',\n 'line',\n 'polyline',\n 'polygon',\n 'circle',\n 'ellipse',\n 'text',\n 'tspan',\n 'title',\n 'desc',\n 'clippath',\n 'mask',\n 'marker',\n 'lineargradient',\n 'radialgradient',\n 'stop',\n 'pattern',\n 'filter',\n 'feblend',\n 'fecolormatrix',\n 'fecomponenttransfer',\n 'fecomposite',\n 'fegaussianblur',\n 'femerge',\n 'femergenode',\n 'feoffset',\n 'feflood',\n 'feturbulence',\n 'fedisplacementmap',\n 'fedropshadow',\n 'fefunca',\n 'fefuncb',\n 'fefuncg',\n 'fefuncr',\n])\n\n/** 必须为同文档锚点(`#id`)的引用属性 */\nconst ANCHOR_ONLY_ATTRS: ReadonlySet<string> = new Set(['href', 'xlink:href'])\n\n/** 属性值中一旦出现即判定为危险的片段(已小写) */\nconst DANGEROUS_VALUE_SNIPPETS: readonly string[] = ['javascript:', 'vbscript:', 'data:', '&#']\n\n/**\n * 判断 SVG 标记是否安全。\n *\n * 判定为安全需同时满足:\n * 1. 去空白后以 `<svg` 开头;\n * 2. 全部元素都在白名单内;\n * 3. 不存在 `on*` 事件属性;\n * 4. 引用属性只允许 `#锚点`;\n * 5. 属性值不包含 `javascript:` / `data:` 等危险片段。\n *\n * @param raw 待复验的标记(非字符串一律判定为不安全)\n * @returns 是否安全\n *\n * @example\n * ```ts\n * isSafeSvgMarkup('<svg><path d=\"M0 0\"/></svg>') // true\n * isSafeSvgMarkup('<svg><script>alert(1)</script></svg>') // false\n * isSafeSvgMarkup('<svg><use xlink:href=\"javascript:x\"/></svg>') // false\n * ```\n */\nexport const isSafeSvgMarkup = (raw: unknown): boolean => {\n if (typeof raw !== 'string') return false\n const source = raw.trim()\n if (source.length === 0) return false\n if (!source.toLowerCase().startsWith('<svg')) return false\n\n let ok = true\n\n const walk = (nodes: readonly MtHtmlNode[], depth: number): void => {\n if (!ok || depth > 256) return\n for (const node of nodes) {\n if (!ok) return\n if (node.kind === 'text') continue\n\n if (!ALLOWED_SVG_TAGS.has(node.tag)) {\n ok = false\n return\n }\n for (const attr of node.attrs) {\n const name = attr.name\n if (name.startsWith('on')) {\n ok = false\n return\n }\n if (ANCHOR_ONLY_ATTRS.has(name) && !attr.value.startsWith('#')) {\n ok = false\n return\n }\n const lowered = attr.value.toLowerCase()\n for (const snippet of DANGEROUS_VALUE_SNIPPETS) {\n if (lowered.includes(snippet)) {\n ok = false\n return\n }\n }\n }\n walk(node.children, depth + 1)\n }\n }\n\n const roots = tokenizeHtml(source)\n const first = roots[0]\n if (!first || first.kind !== 'element' || first.tag !== 'svg') return false\n // 只允许单一 `<svg>` 根,出现兄弟节点即视为异常输入\n if (roots.length > 1) return false\n\n walk(roots, 0)\n return ok\n}\n","/**\n * HTML 解析的共享上下文与属性读取工具。\n *\n * 铁律(保证 `JSON → HTML → JSON` 逐字符等价):\n * **只还原 HTML 中真实出现的信息,绝不补默认值。**\n * 例如 `orderedList.start` 缺省为 1,但若 JSON 里显式写了 `start: 1`,\n * 序列化就会输出 `start=\"1\"`,解析也必须读回来,否则往返不一致。\n */\n\nimport { MT_DOC } from '../report/codes'\nimport { issue } from '../report'\nimport type { MtDegradationEntry, MtValidationIssue } from '../types/report'\nimport { isValidBlockId } from '../utils/id'\nimport { parseInlineStyle } from '../sanitize/policy'\nimport type { MtCleanElement, MtCleanNode } from '../sanitize'\n\n/**\n * 取值解析原语已抽到 `utils/attr-values`——`@mteditor/core` 的 Tiptap 属性定义\n * 必须复用同一套约定。此处按原名义再导出,解析层既有调用点无需改动。\n */\nexport {\n parseBooleanAttr as readBooleanAttr,\n parseInteger as readInteger,\n parseLineHeight as readLineHeight,\n parseMediaAlign as readMediaAlign,\n parseOrderedType as readOrderedType,\n parseTextAlign as readTextAlign,\n} from '../utils/attr-values'\n\n/**\n * 判断清洗节点是否为元素。\n *\n * 放在本模块是因为 HTML 解析的三个子模块(骨架 / 原子构造 / 标记推导)都要用它,\n * 而它只依赖清洗层类型,不依赖任何解析逻辑。\n *\n * @param node 清洗后的节点\n * @returns 是否为元素节点\n */\nexport const isCleanElement = (node: MtCleanNode): node is MtCleanElement => node.kind === 'element'\n\n/** 解析上下文 */\nexport interface MtParseContext {\n /** 缺失 ID 时是否自动补发 */\n assignIds: boolean\n /** ID 生成器 */\n nextId: () => string\n /** 降级报告收集器 */\n degradations: MtDegradationEntry[]\n /** 校验问题收集器 */\n issues: MtValidationIssue[]\n}\n\n/**\n * 读取块级节点 ID。\n *\n * @param element 元素\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns 节点 ID;`assignIds: false` 且 HTML 未提供时返回空串并记录问题\n */\nexport const readId = (element: MtCleanElement, ctx: MtParseContext, path: string): string => {\n const raw = element.attrs['data-mt-id']\n if (raw !== undefined && isValidBlockId(raw)) return raw\n\n if (!ctx.assignIds) {\n ctx.issues.push(\n issue(\n MT_DOC.MISSING_ATTR,\n 'error',\n path,\n '块级节点缺少合法的 data-mt-id',\n '已留空,待上层补发',\n ),\n )\n return ''\n }\n return ctx.nextId()\n}\n\n/**\n * 取元素经过清洗的内联样式(已按白名单过滤)。\n *\n * @param element 元素\n * @returns CSS 属性映射\n */\nexport const styleOf = (element: MtCleanElement): Record<string, string> =>\n parseInlineStyle(element.attrs.style ?? '')\n","/**\n * HTML 原子节点构造(`docs/document-model.md` §4.3–§4.6、§7.1)。\n *\n * 本模块负责「一个元素 → 一个节点」的**叶子**映射:媒体(`img` / `video` /\n * `audio`)、附件、代码块、块级与行内公式、提及、表情。\n *\n * 与 `parse/html.ts` 分离的原因:这些构造器都不参与节点树递归,\n * 可以直接读元素属性产出节点;把它们与递归骨架混在一起会让「谁是递归入口」\n * 变得难以看清。反之,本模块**不得**依赖 `parse/html.ts`。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport { isSafeSvgMarkup } from '../sanitize/svg'\nimport type { MtHeadingLevel, MtMentionKind, MtTextAlign } from '../types/base'\nimport type {\n MtAttachmentAttrs,\n MtAudioAttrs,\n MtCodeBlockAttrs,\n MtImageAttrs,\n MtInlineMathAttrs,\n MtMathBlockAttrs,\n MtMentionAttrs,\n MtVideoAttrs,\n} from '../types/attrs'\nimport type { MtBlockNode, MtInlineNode } from '../types/nodes'\nimport type { MtCleanElement, MtCleanNode } from '../sanitize'\nimport {\n isCleanElement,\n readBooleanAttr,\n readId,\n readInteger,\n readLineHeight,\n readMediaAlign,\n readTextAlign,\n styleOf,\n} from './context'\nimport type { MtParseContext } from './context'\n\n/**\n * 从标签名取标题层级。\n *\n * @param tag 标签名,如 `h3`\n * @returns 层级 1–6;无法识别时按 1 处理\n */\nexport const headingLevel = (tag: string): MtHeadingLevel => {\n const digit = tag.charCodeAt(1) - 48\n if (digit >= 1 && digit <= 6) return digit as MtHeadingLevel\n return 1\n}\n\n/**\n * 抽取元素内的纯文本(`codeBlock` / `mention` / `emoji` 使用)。\n *\n * @param nodes 清洗后的节点\n * @returns 纯文本;`<br>` 记为换行\n */\nexport const extractText = (nodes: readonly MtCleanNode[]): string => {\n let out = ''\n for (const node of nodes) {\n if (!isCleanElement(node)) {\n out += node.value\n continue\n }\n if (node.tag === 'br') {\n out += '\\n'\n continue\n }\n out += extractText(node.children)\n }\n return out\n}\n\n/**\n * 读取文本容器的对齐/行高属性(§3.4)。\n *\n * @param element 清洗后的元素\n * @returns 对齐与行高;未声明的属性不出现在结果中\n */\nexport const readAlignAttrs = (\n element: MtCleanElement,\n): { textAlign?: MtTextAlign; lineHeight?: number } => {\n const style = styleOf(element)\n const textAlign = readTextAlign(style['text-align'])\n const lineHeight = readLineHeight(style['line-height'])\n return {\n ...(textAlign === undefined ? {} : { textAlign }),\n ...(lineHeight === undefined ? {} : { lineHeight }),\n }\n}\n\n/** 从 `<code class=\"language-x\">` 提取语言(`data-mt-language` 缺省时的兜底) */\nconst languageFromCodeChild = (element: MtCleanElement): string | undefined => {\n for (const node of element.children) {\n if (!isCleanElement(node) || node.tag !== 'code') continue\n const className = node.attrs.class\n if (className === undefined) continue\n if (!className.startsWith('language-')) continue\n return className.slice('language-'.length)\n }\n return undefined\n}\n\n/**\n * 解析 `pre` → `codeBlock`。\n *\n * @param element 清洗后的 `pre` 元素\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns 代码块节点\n */\nexport const parseCodeBlock = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtBlockNode => {\n const attrs: MtCodeBlockAttrs = { id: readId(element, ctx, path) }\n const language = element.attrs['data-mt-language'] ?? languageFromCodeChild(element)\n if (language !== undefined) attrs.language = language\n const showLineNumbers = readBooleanAttr(element.attrs['data-mt-line-numbers'])\n if (showLineNumbers !== undefined) attrs.showLineNumbers = showLineNumbers\n\n const text = extractText(element.children)\n return {\n type: 'codeBlock',\n attrs,\n ...(text.length > 0 ? { content: [{ type: 'text', text }] } : {}),\n }\n}\n\n/**\n * 递归收集表格行(穿透 `thead` / `tbody` / `tfoot`)。\n *\n * @param element 表格元素\n * @returns `tr` 元素数组\n */\nexport const collectRows = (element: MtCleanElement): MtCleanElement[] => {\n const rows: MtCleanElement[] = []\n for (const node of element.children) {\n if (!isCleanElement(node)) continue\n if (node.tag === 'tr') {\n rows.push(node)\n continue\n }\n if (node.tag === 'thead' || node.tag === 'tbody' || node.tag === 'tfoot') {\n rows.push(...collectRows(node))\n }\n }\n return rows\n}\n\n/**\n * 解析 `<colgroup><col width>` 得到列宽。\n *\n * @param element 表格元素\n * @returns 列宽数组(单位 px)\n */\nexport const parseColWidths = (element: MtCleanElement): number[] => {\n const widths: number[] = []\n for (const node of element.children) {\n if (!isCleanElement(node) || node.tag !== 'colgroup') continue\n for (const col of node.children) {\n if (!isCleanElement(col) || col.tag !== 'col') continue\n const width = readInteger(col.attrs.width)\n if (width !== undefined) widths.push(width)\n }\n }\n return widths\n}\n\n/**\n * 解析媒体节点(`img` / `video` / `audio`)。\n *\n * @param element 清洗后的媒体元素\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns 媒体节点;缺少可用 `src` 时返回 `undefined` 并写降级报告\n */\nexport const parseMedia = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtBlockNode | undefined => {\n const src = element.attrs.src ?? ''\n if (src.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n element.tag,\n path,\n `<${element.tag}> 缺少可用 src,已丢弃该节点`,\n 'dropped',\n ),\n )\n return undefined\n }\n\n const width = readInteger(element.attrs.width)\n const height = readInteger(element.attrs.height)\n const align = readMediaAlign(element.attrs['data-mt-align'])\n const poster = element.attrs.poster\n const title = element.attrs.title\n const alt = element.attrs.alt\n\n if (element.tag === 'img') {\n return {\n type: 'image',\n attrs: {\n id: readId(element, ctx, path),\n src,\n ...(alt === undefined ? {} : { alt }),\n ...(title === undefined ? {} : { title }),\n ...(width === undefined ? {} : { width }),\n ...(height === undefined ? {} : { height }),\n ...(align === undefined ? {} : { align }),\n } satisfies MtImageAttrs,\n }\n }\n\n if (element.tag === 'audio') {\n return {\n type: 'audio',\n attrs: {\n id: readId(element, ctx, path),\n src,\n ...(title === undefined ? {} : { title }),\n } satisfies MtAudioAttrs,\n }\n }\n\n return {\n type: 'video',\n attrs: {\n id: readId(element, ctx, path),\n src,\n ...(poster === undefined ? {} : { poster }),\n ...(width === undefined ? {} : { width }),\n ...(height === undefined ? {} : { height }),\n ...(align === undefined ? {} : { align }),\n } satisfies MtVideoAttrs,\n }\n}\n\n/**\n * 解析附件(`a[data-mt-attachment]`)。\n *\n * @param element 清洗后的 `a` 元素\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns 附件节点;缺少 `href` 或 `data-mt-name` 时返回 `undefined`\n */\nexport const parseAttachment = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtBlockNode | undefined => {\n const src = element.attrs.href ?? ''\n const name = element.attrs['data-mt-name'] ?? ''\n if (src.length === 0 || name.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'attachment',\n path,\n '附件缺少 href 或 data-mt-name,已丢弃',\n 'dropped',\n ),\n )\n return undefined\n }\n const size = readInteger(element.attrs['data-mt-size'])\n const mime = element.attrs['data-mt-mime']\n return {\n type: 'attachment',\n attrs: {\n id: readId(element, ctx, path),\n src,\n name,\n ...(size === undefined ? {} : { size }),\n ...(mime === undefined ? {} : { mime }),\n } satisfies MtAttachmentAttrs,\n }\n}\n\n/** 收集并复验预渲染 SVG */\nconst readSvg = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): string | undefined => {\n const raw = element.rawInner\n if (raw === undefined || raw.trim().length === 0) return undefined\n if (isSafeSvgMarkup(raw)) return raw\n ctx.degradations.push(\n degrade(\n MT_DEG.TAG_NOT_ALLOWED,\n element.tag,\n path,\n '预渲染 SVG 未通过 SVG 白名单复验,已丢弃该产物',\n 'dropped',\n ),\n )\n return undefined\n}\n\n/**\n * 解析 `div[data-mt-math]` → `mathBlock`。\n *\n * @param element 清洗后的 `div` 元素\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns 块级公式节点\n */\nexport const parseMathBlock = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtBlockNode => {\n const tex = element.attrs['data-tex'] ?? ''\n const svg = readSvg(element, ctx, path)\n const numbering = element.attrs['data-mt-numbering']\n const attrs: MtMathBlockAttrs = {\n id: readId(element, ctx, path),\n tex,\n ...(svg === undefined ? {} : { svg }),\n ...(numbering === undefined ? {} : { numbering }),\n }\n return { type: 'mathBlock', attrs }\n}\n\n/**\n * 解析 `span[data-mt-math-inline]` → `inlineMath`。\n *\n * @param element 清洗后的 `span` 元素\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns 行内公式节点;缺少 `data-tex` 时返回 `undefined`\n */\nexport const parseInlineMath = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtInlineNode | undefined => {\n const tex = element.attrs['data-tex'] ?? ''\n if (tex.length === 0) {\n ctx.degradations.push(\n degrade(MT_DEG.ATTR_DROPPED, 'inlineMath', path, '行内公式缺少 data-tex,已丢弃', 'dropped'),\n )\n return undefined\n }\n const svg = readSvg(element, ctx, path)\n const attrs: MtInlineMathAttrs = { tex, ...(svg === undefined ? {} : { svg }) }\n return { type: 'inlineMath', attrs }\n}\n\n/**\n * 解析 `span[data-mt-mention]` → `mention`。\n *\n * @param element 清洗后的 `span` 元素\n * @returns 提及节点;缺少 id 或标签文本时返回 `undefined`\n */\nexport const parseMention = (element: MtCleanElement): MtInlineNode | undefined => {\n const id = element.attrs['data-mt-id'] ?? ''\n const label = extractText(element.children)\n if (id.length === 0 || label.length === 0) return undefined\n const kind = element.attrs['data-mt-kind']\n const attrs: MtMentionAttrs = {\n id,\n label,\n ...(kind === 'user' || kind === 'doc' || kind === 'custom'\n ? { kind: kind as MtMentionKind }\n : {}),\n }\n return { type: 'mention', attrs }\n}\n\n/**\n * 解析 `span[data-mt-emoji]` → `emoji`。\n *\n * 缺少 `data-mt-name` 时退化为纯文本(字符本身仍有价值,不应丢弃)。\n *\n * @param element 清洗后的 `span` 元素\n * @returns 表情节点或退化的文本节点\n */\nexport const parseEmoji = (element: MtCleanElement): MtInlineNode | undefined => {\n const name = element.attrs['data-mt-name'] ?? ''\n const unicode = extractText(element.children)\n if (name.length === 0 || unicode.length === 0) {\n return unicode.length > 0 ? { type: 'text', text: unicode } : undefined\n }\n return { type: 'emoji', attrs: { name, unicode } }\n}\n","/**\n * HTML 解析的标记推导(`docs/document-model.md` §5、§7.2)。\n *\n * 把外部 HTML 的三种「标记写法」统一还原为 §5 定义的 `MtMark`:\n * 1. 无属性标签(`<strong>` / `<u>` / `<code>` …);\n * 2. `span` 的内联样式(`font-weight:bold`、`text-decoration:underline` …);\n * 3. 带属性的标签(`<a href>` → `link`、`<mark style=\"background-color\">` → `highlight`)。\n *\n * 与 `parse/html.ts` 分离的原因:这里是**叶子逻辑**,不参与节点树递归;\n * 反过来,它也绝不允许反向依赖递归骨架,否则会形成循环。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport type { MtLinkAttrs, MtMark, MtTextStyleAttrs } from '../types/marks'\nimport type { MtCleanElement } from '../sanitize'\nimport { styleOf } from './context'\nimport type { MtParseContext } from './context'\n\n/** 无属性标记的标签 → 标记映射 */\nexport const SIMPLE_MARK_TAGS: ReadonlyMap<string, MtMark> = new Map<string, MtMark>([\n ['strong', { type: 'bold' }],\n ['b', { type: 'bold' }],\n ['em', { type: 'italic' }],\n ['i', { type: 'italic' }],\n ['u', { type: 'underline' }],\n ['s', { type: 'strike' }],\n ['del', { type: 'strike' }],\n ['strike', { type: 'strike' }],\n ['sub', { type: 'subscript' }],\n ['sup', { type: 'superscript' }],\n ['code', { type: 'code' }],\n])\n\n/** `span` 样式属性 → `textStyle` 属性名 的映射 */\nconst SPAN_STYLE_MAP: ReadonlyMap<string, keyof MtTextStyleAttrs> = new Map([\n ['font-family', 'fontFamily'],\n ['font-size', 'fontSize'],\n ['color', 'color'],\n ['background-color', 'backgroundColor'],\n ['line-height', 'lineHeight'],\n ['letter-spacing', 'letterSpacing'],\n])\n\n/**\n * 由 `span` 的内联样式推导附加标记(外部 HTML 的 `font-weight:bold` 等写法)。\n *\n * @param element 清洗后的 `span` 元素\n * @returns 推导出的标记数组(可能为空)\n */\nexport const spanStyleMarks = (element: MtCleanElement): MtMark[] => {\n const style = styleOf(element)\n const marks: MtMark[] = []\n\n const weight = style['font-weight']\n if (weight !== undefined) {\n const numeric = Number(weight)\n if (weight === 'bold' || weight === 'bolder' || (Number.isFinite(numeric) && numeric >= 600)) {\n marks.push({ type: 'bold' })\n }\n }\n const fontStyle = style['font-style']\n if (fontStyle === 'italic' || fontStyle === 'oblique') marks.push({ type: 'italic' })\n\n const decoration = style['text-decoration'] ?? ''\n if (decoration.includes('underline')) marks.push({ type: 'underline' })\n if (decoration.includes('line-through')) marks.push({ type: 'strike' })\n\n const styleAttrs: MtTextStyleAttrs = {}\n for (const [cssName, key] of SPAN_STYLE_MAP) {\n const value = style[cssName]\n if (value !== undefined) styleAttrs[key] = value\n }\n if (Object.keys(styleAttrs).length > 0) marks.push({ type: 'textStyle', attrs: styleAttrs })\n\n return marks\n}\n\n/**\n * 由 `a` 构造 `link` 标记。\n *\n * @param element 清洗后的 `a` 元素\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns `link` 标记;`href` 缺失或已被告知置空时返回 `undefined`\n */\nexport const linkMark = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtMark | undefined => {\n const href = element.attrs.href\n if (href === undefined || href.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.DANGEROUS_URL,\n 'link',\n path,\n 'href 缺失或已被安全清洗置空,链接标记已丢弃',\n 'dropped',\n ),\n )\n return undefined\n }\n const attrs: MtLinkAttrs = { href }\n const target = element.attrs.target\n if (target !== undefined) attrs.target = target\n const rel = element.attrs.rel\n if (rel !== undefined) attrs.rel = rel\n const title = element.attrs.title\n if (title !== undefined) attrs.title = title\n return { type: 'link', attrs }\n}\n\n/**\n * 由 `mark` 构造 `highlight` 标记。\n *\n * @param element 清洗后的 `mark` 元素\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns `highlight` 标记;缺少背景色时返回 `undefined`\n */\nexport const highlightMark = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtMark | undefined => {\n const color = styleOf(element)['background-color']\n if (color === undefined || color.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'highlight',\n path,\n 'mark 缺少 background-color,高亮标记已丢弃',\n 'dropped',\n ),\n )\n return undefined\n }\n return { type: 'highlight', attrs: { color } }\n}\n","/**\n * HTML 解析的节点构造原语与采集条目类型(`docs/document-model.md` §2、§7)。\n *\n * 三个构造器都遵守同一条铁律:**只还原真实出现的信息,绝不补默认值**\n * (`parse/context.ts` 的注释有完整说明)——所以空 `content` 一律不写该字段。\n *\n * 单独成模块是因为它们被「递归骨架」(`parse/html.ts`)与文本容器装配共同使用,\n * 放在骨架里会让骨架超出单文件行数上限,且不易看出「构造」与「遍历」的边界。\n */\n\nimport { finalizeInlineNodes, normalizeMarks } from '../normalize'\nimport type { MtHeadingAttrs, MtParagraphAttrs } from '../types/attrs'\nimport type { MtMark } from '../types/marks'\nimport type { MtBlockNode, MtInlineNode, MtParagraphNode, MtTextNode } from '../types/nodes'\nimport type { MtParseContext } from './context'\n\n/** 采集到的条目:行内节点或块级节点 */\nexport type MtCollected =\n { kind: 'inline'; node: MtInlineNode } | { kind: 'block'; node: MtBlockNode }\n\n/**\n * 包装为行内条目。\n *\n * @param node 行内节点\n * @returns 采集条目\n */\nexport const asInline = (node: MtInlineNode): MtCollected => ({ kind: 'inline', node })\n\n/**\n * 包装为块级条目。\n *\n * @param node 块级节点\n * @returns 采集条目\n */\nexport const asBlock = (node: MtBlockNode): MtCollected => ({ kind: 'block', node })\n\n/**\n * 构造段落。\n *\n * @param attrs 段落属性\n * @param content 行内子节点\n * @returns 段落节点\n */\nexport const makeParagraph = (\n attrs: MtParagraphAttrs,\n content: readonly MtInlineNode[],\n): MtParagraphNode => ({\n type: 'paragraph',\n attrs,\n ...(content.length > 0 ? { content: [...content] } : {}),\n})\n\n/**\n * 构造标题。\n *\n * @param attrs 标题属性\n * @param content 行内子节点\n * @returns 标题节点\n */\nexport const makeHeading = (\n attrs: MtHeadingAttrs,\n content: readonly MtInlineNode[],\n): MtBlockNode => ({\n type: 'heading',\n attrs,\n ...(content.length > 0 ? { content: [...content] } : {}),\n})\n\n/**\n * 构造文本节点(标记在此完成规范化:排序、去重、冲突消解)。\n *\n * @param value 文本\n * @param marks 累积的标记\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns 文本节点;空文本返回 `undefined`\n */\nexport const makeText = (\n value: string,\n marks: readonly MtMark[],\n ctx: MtParseContext,\n path: string,\n): MtTextNode | undefined => {\n if (value.length === 0) return undefined\n const { marks: normalized } = normalizeMarks(marks, {\n path,\n marksAllowed: true,\n degradations: ctx.degradations,\n issues: ctx.issues,\n })\n return normalized.length > 0\n ? { type: 'text', text: value, marks: normalized }\n : { type: 'text', text: value }\n}\n\n/**\n * 把「行内 / 块级」混合序列按段落分组。\n *\n * 连续的行内节点合并为一个段落,块级节点原样保留,从而保证\n * `content: block+` 的结构约束始终成立。\n *\n * @param items 采集序列\n * @param ctx 解析上下文\n * @returns 块级节点序列\n */\nexport const groupItems = (items: readonly MtCollected[], ctx: MtParseContext): MtBlockNode[] => {\n const out: MtBlockNode[] = []\n let buffer: MtInlineNode[] = []\n\n const flush = (): void => {\n if (buffer.length === 0) return\n out.push(makeParagraph({ id: ctx.nextId() }, finalizeInlineNodes(buffer)))\n buffer = []\n }\n\n for (const item of items) {\n if (item.kind === 'inline') {\n buffer.push(item.node)\n continue\n }\n flush()\n out.push(item.node)\n }\n flush()\n\n return out\n}\n","/**\n * HTML → `MtDocument`(`docs/document-model.md` §7.1–§7.3)。\n *\n * 流程:`sanitizeHtml()` 清洗 → 递归映射为节点树 → 规范化(合并相邻文本、排序标记)\n * → 批量补发块级 ID → 输出文档与降级报告。\n *\n * 两条通用原则:\n * 1. **只还原 HTML 中真实出现的信息,绝不补默认值**——否则 `JSON → HTML → JSON`\n * 在 `start` / `checked` 这类「默认值属性」上必然漂移;\n * 2. **节点身份一律由 `data-mt-*` 承载**(§7.1 注),不依赖 `class`。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport { MT_SCHEMA_VERSION } from '../types/base'\nimport type {\n MtHeadingAttrs,\n MtOrderedListAttrs,\n MtParagraphAttrs,\n MtTableCellAttrs,\n MtTaskItemAttrs,\n MtTableAttrs,\n} from '../types/attrs'\nimport type { MtMark } from '../types/marks'\nimport type { MtDocument } from '../types/document'\nimport type {\n MtBlockNode,\n MtInlineNode,\n MtTableNode,\n MtTableCellNodeUnion,\n MtTableRowNode,\n} from '../types/nodes'\nimport type { MtParseOptions, MtParseResult } from '../types/io'\nimport { createIdGenerator } from '../utils/id'\nimport { MT_COLWIDTH_UNIT, parseCssLength, parseIndent } from '../utils/style-units'\nimport type { MtCleanElement, MtCleanNode } from '../sanitize'\nimport { sanitizeHtml } from '../sanitize'\nimport {\n collectRows,\n headingLevel,\n parseAttachment,\n parseCodeBlock,\n parseColWidths,\n parseEmoji,\n parseInlineMath,\n parseMathBlock,\n parseMedia,\n parseMention,\n readAlignAttrs,\n} from './html-atoms'\nimport { SIMPLE_MARK_TAGS, highlightMark, linkMark, spanStyleMarks } from './html-marks'\nimport { asBlock, asInline, groupItems, makeHeading, makeParagraph, makeText } from './html-nodes'\nimport type { MtCollected } from './html-nodes'\nimport {\n isCleanElement,\n readBooleanAttr,\n readId,\n readInteger,\n readLineHeight,\n readMediaAlign,\n readOrderedType,\n readTextAlign,\n styleOf,\n} from './context'\nimport type { MtParseContext } from './context'\n\n/**\n * 递归采集节点序列。\n *\n * @param nodes 清洗后的节点\n * @param marks 当前累积的标记\n * @param ctx 解析上下文\n * @param basePath 路径前缀\n * @param inlineContext 是否处于行内上下文(决定纯空白文本是否保留)\n * @returns 采集序列\n */\nconst collect = (\n nodes: readonly MtCleanNode[],\n marks: readonly MtMark[],\n ctx: MtParseContext,\n basePath: string,\n inlineContext: boolean,\n): MtCollected[] => {\n const out: MtCollected[] = []\n\n for (let index = 0; index < nodes.length; index += 1) {\n const node = nodes[index]\n if (node === undefined) continue\n const path = `${basePath}[${index}]`\n\n if (!isCleanElement(node)) {\n // 块级上下文中的纯空白文本属于标签间的排版空白,不产生内容\n if (!inlineContext && node.value.trim().length === 0) continue\n const text = makeText(node.value, marks, ctx, path)\n if (text) out.push(asInline(text))\n continue\n }\n\n out.push(...collectElement(node, marks, ctx, path, inlineContext))\n }\n\n return out\n}\n\n/** 解析块级内容(不含行内包裹) */\nconst parseBlocks = (\n nodes: readonly MtCleanNode[],\n ctx: MtParseContext,\n basePath: string,\n): MtBlockNode[] => groupItems(collect(nodes, [], ctx, basePath, false), ctx)\n\n/**\n * 文本容器(`p` / `h1`–`h6`)的统一装配。\n *\n * 容器内若混入块级内容(畸形 HTML),后续块会**提升**为同级块,\n * 保证父级 `content` 表达式不被破坏。\n */\nconst buildTextContainer = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n make: (content: readonly MtInlineNode[]) => MtBlockNode,\n): MtCollected[] => {\n const blocks = groupItems(collect(element.children, [], ctx, `${path}.content`, true), ctx)\n const head = blocks[0]\n\n if (head !== undefined && head.type === 'paragraph') {\n const out: MtBlockNode[] = [make(head.content ?? [])]\n for (let i = 1; i < blocks.length; i += 1) {\n const extra = blocks[i]\n if (extra !== undefined) out.push(extra)\n }\n return out.map(asBlock)\n }\n\n return [make([]), ...blocks].map(asBlock)\n}\n\n/** 解析 `p` */\nconst parseParagraph = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtCollected[] => {\n const style = styleOf(element)\n const indent = parseIndent(style['padding-left'])\n const align = readAlignAttrs(element)\n const attrs: MtParagraphAttrs = {\n id: readId(element, ctx, path),\n ...align,\n ...(indent === undefined ? {} : { indent }),\n }\n return buildTextContainer(element, ctx, path, (content) => makeParagraph(attrs, content))\n}\n\n/** 解析 `h1`–`h6` */\nconst parseHeading = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtCollected[] => {\n const attrs: MtHeadingAttrs = {\n id: readId(element, ctx, path),\n level: headingLevel(element.tag),\n ...readAlignAttrs(element),\n }\n return buildTextContainer(element, ctx, path, (content) => makeHeading(attrs, content))\n}\n\n/** 解析列表(`ul` / `ol`,含任务列表判定) */\nconst parseList = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n ordered: boolean,\n): MtBlockNode => {\n const items = element.children.filter(\n (node): node is MtCleanElement => isCleanElement(node) && node.tag === 'li',\n )\n const isTask =\n element.attrs['data-mt-task-list'] !== undefined ||\n items.some((li) => li.attrs['data-mt-checked'] !== undefined)\n\n const id = readId(element, ctx, path)\n const parsed = items.map((li, index) => {\n const liPath = `${path}.content[${index}]`\n const liId = readId(li, ctx, liPath)\n const content = parseBlocks(li.children, ctx, `${liPath}.content`)\n const body =\n content[0]?.type === 'paragraph'\n ? content\n : [makeParagraph({ id: ctx.nextId() }, []), ...content]\n return { liId, body, checked: readBooleanAttr(li.attrs['data-mt-checked']) }\n })\n\n // 空列表(`<ul></ul>`)会让 `listItem+` 不成立,补一个空列表项保住该元素\n if (parsed.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.EMPTY_CONTAINER,\n ordered ? 'orderedList' : 'bulletList',\n path,\n '空列表已补一个空列表项,以保证 content 表达式 listItem+ 成立',\n 'replaced',\n ),\n )\n parsed.push({\n liId: ctx.nextId(),\n body: [makeParagraph({ id: ctx.nextId() }, [])],\n checked: undefined,\n })\n }\n\n if (isTask) {\n return {\n type: 'taskList',\n attrs: { id },\n content: parsed.map((item) => {\n const attrs: MtTaskItemAttrs = { id: item.liId }\n if (item.checked !== undefined) attrs.checked = item.checked\n return { type: 'taskItem', attrs, content: item.body }\n }),\n }\n }\n\n if (ordered) {\n const attrs: MtOrderedListAttrs = { id }\n const start = readInteger(element.attrs.start)\n if (start !== undefined) attrs.start = start\n const type = readOrderedType(element.attrs.type)\n if (type !== undefined) attrs.type = type\n return {\n type: 'orderedList',\n attrs,\n content: parsed.map((item) => ({\n type: 'listItem',\n attrs: { id: item.liId },\n content: item.body,\n })),\n }\n }\n\n return {\n type: 'bulletList',\n attrs: { id },\n content: parsed.map((item) => ({\n type: 'listItem',\n attrs: { id: item.liId },\n content: item.body,\n })),\n }\n}\n\n/** 解析表格单元格 */\nconst parseCell = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtTableCellNodeUnion => {\n const attrs: MtTableCellAttrs = {}\n const colspan = readInteger(element.attrs.colspan)\n if (colspan !== undefined) attrs.colspan = colspan\n const rowspan = readInteger(element.attrs.rowspan)\n if (rowspan !== undefined) attrs.rowspan = rowspan\n\n const style = styleOf(element)\n const textAlign = readTextAlign(style['text-align'])\n if (textAlign !== undefined) attrs.textAlign = textAlign\n const lineHeight = readLineHeight(style['line-height'])\n if (lineHeight !== undefined) attrs.lineHeight = lineHeight\n if (style['background-color'] !== undefined) attrs.background = style['background-color']\n const colwidth = parseCssLength(style.width, MT_COLWIDTH_UNIT)\n if (colwidth !== undefined) attrs.colwidth = colwidth\n const align = readMediaAlign(element.attrs['data-mt-align'])\n if (align !== undefined) attrs.align = align\n\n const content = parseBlocks(element.children, ctx, `${path}.content`)\n const body = content.length > 0 ? content : [makeParagraph({ id: ctx.nextId() }, [])]\n const hasAttrs = Object.keys(attrs).length > 0\n\n if (element.tag === 'th') {\n return hasAttrs\n ? { type: 'tableHeader', attrs, content: body }\n : { type: 'tableHeader', content: body }\n }\n return hasAttrs\n ? { type: 'tableCell', attrs, content: body }\n : { type: 'tableCell', content: body }\n}\n\n/** 解析 `table`;无任何单元格的空表格返回 `undefined`(无内容可还原) */\nconst parseTable = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtTableNode | undefined => {\n const rows: MtTableRowNode[] = []\n collectRows(element).forEach((rowElement, rowIndex) => {\n const cells = rowElement.children.filter(\n (node): node is MtCleanElement =>\n isCleanElement(node) && (node.tag === 'td' || node.tag === 'th'),\n )\n if (cells.length === 0) return\n rows.push({\n type: 'tableRow',\n content: cells.map((cell, cellIndex) =>\n parseCell(cell, ctx, `${path}.content[${rowIndex}].content[${cellIndex}]`),\n ),\n })\n })\n\n // 空表格既无法构造合法的 `tableRow+`,也没有内容可还原,只能丢弃\n if (rows.length === 0) {\n ctx.degradations.push(\n degrade(MT_DEG.EMPTY_CONTAINER, 'table', path, '表格不含任何单元格,已丢弃该节点', 'dropped'),\n )\n return undefined\n }\n\n const attrs: MtTableAttrs = { id: readId(element, ctx, path) }\n const colWidths = parseColWidths(element)\n if (colWidths.length > 0) attrs.colWidths = colWidths\n\n return { type: 'table', attrs, content: rows }\n}\n\n/**\n * 映射单个元素。\n *\n * @param element 清洗后的元素\n * @param marks 当前累积的标记\n * @param ctx 解析上下文\n * @param path 定位路径\n * @param inlineContext 是否处于行内上下文\n * @returns 采集序列\n */\nconst collectElement = (\n element: MtCleanElement,\n marks: readonly MtMark[],\n ctx: MtParseContext,\n path: string,\n inlineContext: boolean,\n): MtCollected[] => {\n const tag = element.tag\n const childPath = `${path}.content`\n const deeper = (next: readonly MtMark[]): MtCollected[] =>\n collect(element.children, next, ctx, childPath, true)\n\n switch (tag) {\n case 'p':\n return parseParagraph(element, ctx, path)\n case 'h1':\n case 'h2':\n case 'h3':\n case 'h4':\n case 'h5':\n case 'h6':\n return parseHeading(element, ctx, path)\n case 'blockquote': {\n const blocks = parseBlocks(element.children, ctx, childPath)\n // 空引用块直接产出会让 `block+` 不成立(validate 判 MT_DOC_003 并阻断序列化),\n // 故与 §2「不允许出现 content: [] 的 doc」同一处理:补一个空段落。\n if (blocks.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.EMPTY_CONTAINER,\n 'blockquote',\n path,\n '空引用块已补一个空段落,以保证 content 表达式 block+ 成立',\n 'replaced',\n ),\n )\n }\n return [\n asBlock({\n type: 'blockquote',\n attrs: { id: readId(element, ctx, path) },\n content: blocks.length > 0 ? blocks : [makeParagraph({ id: ctx.nextId() }, [])],\n }),\n ]\n }\n case 'ul':\n case 'ol':\n return [asBlock(parseList(element, ctx, path, tag === 'ol'))]\n case 'pre':\n return [asBlock(parseCodeBlock(element, ctx, path))]\n case 'hr':\n return [asBlock({ type: 'horizontalRule', attrs: { id: readId(element, ctx, path) } })]\n case 'table': {\n const table = parseTable(element, ctx, path)\n return table === undefined ? [] : [asBlock(table)]\n }\n case 'img':\n case 'video':\n case 'audio': {\n const media = parseMedia(element, ctx, path)\n return media ? [asBlock(media)] : []\n }\n case 'li':\n case 'thead':\n case 'tbody':\n case 'tfoot':\n case 'tr':\n case 'td':\n case 'th':\n // 游离的表格/列表结构元素:解包保留内容\n return collect(element.children, marks, ctx, childPath, false)\n case 'div': {\n if (element.attrs['data-mt-math'] !== undefined) {\n return [asBlock(parseMathBlock(element, ctx, path))]\n }\n return collect(element.children, marks, ctx, childPath, inlineContext)\n }\n case 'a': {\n if (element.attrs['data-mt-attachment'] !== undefined) {\n const attachment = parseAttachment(element, ctx, path)\n return attachment ? [asBlock(attachment)] : []\n }\n const mark = linkMark(element, ctx, path)\n return deeper(mark === undefined ? marks : [...marks, mark])\n }\n case 'span': {\n if (element.attrs['data-mt-mention'] !== undefined) {\n const mention = parseMention(element)\n return mention ? [asInline(mention)] : []\n }\n if (element.attrs['data-mt-math-inline'] !== undefined) {\n const math = parseInlineMath(element, ctx, path)\n return math ? [asInline(math)] : []\n }\n if (element.attrs['data-mt-emoji'] !== undefined) {\n const emoji = parseEmoji(element)\n return emoji ? [asInline(emoji)] : []\n }\n return deeper([...marks, ...spanStyleMarks(element)])\n }\n case 'mark': {\n const mark = highlightMark(element, ctx, path)\n return deeper(mark === undefined ? marks : [...marks, mark])\n }\n case 'br':\n return [asInline({ type: 'hardBreak' })]\n default: {\n const mark = SIMPLE_MARK_TAGS.get(tag)\n if (mark !== undefined) return deeper([...marks, mark])\n return collect(element.children, marks, ctx, childPath, inlineContext)\n }\n }\n}\n\n/**\n * 把 HTML 源码解析为 `MtDocument`。\n *\n * @param html HTML 源码\n * @param options 解析选项\n * @returns 文档、降级报告与校验问题\n *\n * @example\n * ```ts\n * const { doc, report } = parseHtmlToDocument('<h1>标题</h1><p>正文<strong>加粗</strong></p>')\n * doc.content[0]?.type // 'heading'\n * report.length // 0(无降级)\n * ```\n */\nexport const parseHtmlToDocument = (html: string, options: MtParseOptions = {}): MtParseResult => {\n const sanitized = sanitizeHtml(html)\n const ctx: MtParseContext = {\n assignIds: options.assignIds !== false,\n nextId: createIdGenerator(options.now === undefined ? {} : { now: options.now }),\n degradations: [...sanitized.degradations],\n issues: [],\n }\n\n const blocks = parseBlocks(sanitized.nodes, ctx, 'content')\n // §2:不允许出现 content: [] 的 doc,空输入补一个空段落\n const content = blocks.length > 0 ? blocks : [makeParagraph({ id: ctx.nextId() }, [])]\n\n const doc: MtDocument = { schemaVersion: MT_SCHEMA_VERSION, type: 'doc', content }\n\n return {\n doc,\n report: options.report === false ? [] : ctx.degradations,\n issues: ctx.issues,\n }\n}\n","/**\n * Markdown 行内解析(`docs/document-model.md` §8.1–§8.2)。\n *\n * 与块级解析(`parse/markdown.ts`)分离的原因:行内解析是**递归**的\n * (`**粗 `码`**` 这类嵌套),而块级解析只按行扫描;两者的状态模型完全不同,\n * 混在一个文件里既超行数上限,也让「谁是递归出口」变得难以看清。\n *\n * 行内只识别 §8.2 列出的子集,未列出的语法一律按普通文本处理,不做猜测。\n */\n\nimport { normalizeMarks } from '../normalize'\nimport type { MtMark } from '../types/marks'\nimport type { MtBlockNode, MtInlineNode, MtParagraphNode } from '../types/nodes'\nimport type { MtParseContext } from './context'\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\n\n/** 采集条目:行内或块级(行内图片需要提升为块级) */\nexport type MtCollected =\n { kind: 'inline'; node: MtInlineNode } | { kind: 'block'; node: MtBlockNode }\n\n/** 取字符;越界返回空串 */\nexport const chAt = (source: string, index: number): string => source.charAt(index)\n\n/**\n * 构造段落。\n *\n * @param attrs 段落属性\n * @param content 行内子节点\n * @returns 段落节点(无内容时不写 `content`)\n */\nexport const makeParagraph = (\n attrs: { id: string },\n content: readonly MtInlineNode[],\n): MtParagraphNode => ({\n type: 'paragraph',\n attrs,\n ...(content.length > 0 ? { content: [...content] } : {}),\n})\n\n/**\n * 构造文本节点(含标记规范化)。\n *\n * @param value 文本\n * @param marks 累积的标记\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns 文本节点;空文本返回 `undefined`\n */\nconst makeText = (\n value: string,\n marks: readonly MtMark[],\n ctx: MtParseContext,\n path: string,\n): MtInlineNode | undefined => {\n if (value.length === 0) return undefined\n const { marks: normalized } = normalizeMarks(marks, {\n path,\n marksAllowed: true,\n degradations: ctx.degradations,\n issues: ctx.issues,\n })\n return normalized.length > 0\n ? { type: 'text', text: value, marks: normalized }\n : { type: 'text', text: value }\n}\n\n/**\n * 查找从 `from` 开始、由 `marker` 组成且长度不小于 `length` 的连续片段。\n *\n * @param text 文本\n * @param from 起始下标\n * @param marker 标记字符\n * @param length 期望的连续长度\n * @returns 起始下标;未找到返回 `-1`\n */\nconst findRun = (text: string, from: number, marker: string, length: number): number => {\n let i = from\n while (i < text.length) {\n if (chAt(text, i) === marker) {\n let run = 0\n while (chAt(text, i + run) === marker) run += 1\n if (run >= length) return i\n i += run\n continue\n }\n i += 1\n }\n return -1\n}\n\n/**\n * 统计从 `from` 开始的连续同字符长度。\n *\n * @param text 文本\n * @param from 起始下标\n * @param marker 标记字符\n * @returns 连续长度\n */\nconst countRun = (text: string, from: number, marker: string): number => {\n let run = 0\n while (chAt(text, from + run) === marker) run += 1\n return run\n}\n\n/**\n * 查找闭合标记;反斜杠转义会被跳过。\n *\n * @param text 文本\n * @param from 起始下标\n * @param marker 闭合标记\n * @returns 闭合位置;未找到返回 `-1`\n */\nconst findClosing = (text: string, from: number, marker: string): number => {\n let i = from\n while (i < text.length) {\n const ch = chAt(text, i)\n if (ch === '\\\\') {\n i += 2\n continue\n }\n if (text.startsWith(marker, i)) return i\n i += 1\n }\n return -1\n}\n\n/**\n * 解析 `[label](href)` 或 ``。\n *\n * @param text 文本\n * @param bracketStart `[` 的下标\n * @returns 标签、地址与结束下标;形态不符时返回 `undefined`\n */\nconst parseLinkAt = (\n text: string,\n bracketStart: number,\n): { label: string; href: string; end: number } | undefined => {\n const closeBracket = findClosing(text, bracketStart + 1, ']')\n if (closeBracket < 0) return undefined\n if (chAt(text, closeBracket + 1) !== '(') return undefined\n const closeParen = findClosing(text, closeBracket + 2, ')')\n if (closeParen < 0) return undefined\n const label = text.slice(bracketStart + 1, closeBracket)\n let href = text.slice(closeBracket + 2, closeParen).trim()\n // 去掉可选的 title:`href \"title\"`\n const spaceAt = href.search(/\\s/)\n if (spaceAt > 0) href = href.slice(0, spaceAt)\n return { label, href, end: closeParen + 1 }\n}\n\n/** 需要识别为标记的内联 HTML 标签 */\nconst INLINE_TAG_MARKS: ReadonlyMap<string, string> = new Map([\n ['u', 'underline'],\n ['mark', 'highlight'],\n ['sub', 'subscript'],\n ['sup', 'superscript'],\n])\n\n/** 内联 HTML 起始标签的形态 */\nconst RE_INLINE_TAG = /^<(\\/?)([a-zA-Z][a-zA-Z0-9]*)(?:\\s[^>]*)?>/\n\n/**\n * 解析行内序列(可产出块级节点,如行内图片需要提升)。\n *\n * @param text 行内文本\n * @param marks 当前累积的标记\n * @param ctx 解析上下文\n * @param basePath 路径前缀\n * @returns 采集序列\n */\nexport const parseInlineSequence = (\n text: string,\n marks: readonly MtMark[],\n ctx: MtParseContext,\n basePath: string,\n): MtCollected[] => {\n const out: MtCollected[] = []\n let buffer = ''\n let i = 0\n\n const flush = (): void => {\n if (buffer.length === 0) return\n const node = makeText(buffer, marks, ctx, basePath)\n if (node) out.push({ kind: 'inline', node })\n buffer = ''\n }\n\n while (i < text.length) {\n const ch = chAt(text, i)\n\n if (ch === '\\\\' && i + 1 < text.length) {\n buffer += chAt(text, i + 1)\n i += 2\n continue\n }\n\n if (ch === '\\n') {\n // 行尾两空格 = 硬换行(§8.1)\n if (buffer.endsWith(' ')) {\n buffer = buffer.slice(0, -2)\n flush()\n out.push({ kind: 'inline', node: { type: 'hardBreak' } })\n } else {\n buffer += '\\n'\n }\n i += 1\n continue\n }\n\n if (ch === '`') {\n const run = countRun(text, i, '`')\n const close = findRun(text, i + run, '`', run)\n if (close > 0) {\n flush()\n const node = makeText(\n text.slice(i + run, close),\n [...marks, { type: 'code' }],\n ctx,\n basePath,\n )\n if (node) out.push({ kind: 'inline', node })\n i = close + run\n continue\n }\n }\n\n if (ch === '!' && chAt(text, i + 1) === '[') {\n const parsed = parseLinkAt(text, i + 1)\n if (parsed) {\n flush()\n out.push({\n kind: 'block',\n node: {\n type: 'image',\n attrs: { id: ctx.nextId(), src: parsed.href, alt: parsed.label },\n },\n })\n i = parsed.end\n continue\n }\n }\n\n if (ch === '[') {\n const parsed = parseLinkAt(text, i)\n if (parsed) {\n flush()\n const node = makeText(\n parsed.label,\n [...marks, { type: 'link', attrs: { href: parsed.href } }],\n ctx,\n basePath,\n )\n if (node) out.push({ kind: 'inline', node })\n i = parsed.end\n continue\n }\n }\n\n if (ch === '$' && countRun(text, i, '$') === 1) {\n const close = findClosing(text, i + 1, '$')\n if (close > 0) {\n flush()\n out.push({\n kind: 'inline',\n node: { type: 'inlineMath', attrs: { tex: text.slice(i + 1, close) } },\n })\n i = close + 1\n continue\n }\n }\n\n if (ch === '*' || ch === '_' || ch === '~') {\n const run = ch === '~' ? countRun(text, i, '~') : countRun(text, i, ch)\n const width = ch === '~' ? 2 : run >= 2 ? 2 : 1\n if (ch !== '~' || run >= 2) {\n const marker = ch.repeat(width)\n const close = findClosing(text, i + width, marker)\n if (close > i + width) {\n flush()\n const inner = parseInlineSequence(\n text.slice(i + width, close),\n [\n ...marks,\n ch === '~' ? { type: 'strike' } : width === 2 ? { type: 'bold' } : { type: 'italic' },\n ],\n ctx,\n basePath,\n )\n out.push(...inner)\n i = close + width\n continue\n }\n }\n }\n\n if (ch === '<') {\n const tagMatch = RE_INLINE_TAG.exec(text.slice(i))\n if (tagMatch) {\n const closing = tagMatch[1] === '/'\n const name = (tagMatch[2] ?? '').toLowerCase()\n const markName = INLINE_TAG_MARKS.get(name)\n const full = tagMatch[0]\n\n if (markName !== undefined) {\n const innerStart = i + full.length\n const endTag = `</${name}>`\n if (closing) {\n // 孤立的闭合标签:忽略\n i = innerStart\n continue\n }\n const endAt = findClosing(text, innerStart, endTag)\n if (endAt > 0) {\n flush()\n const attrMatch = /style=\"([^\"]*)\"/.exec(full)\n const extra: MtMark[] =\n markName === 'highlight'\n ? [\n {\n type: 'highlight',\n attrs: { color: attrMatch?.[1]?.replace(/^background-color:\\s*/, '') ?? '' },\n },\n ]\n : [{ type: markName as 'underline' | 'subscript' | 'superscript' }]\n out.push(\n ...parseInlineSequence(\n text.slice(innerStart, endAt),\n [...marks, ...extra],\n ctx,\n basePath,\n ),\n )\n i = endAt + endTag.length\n continue\n }\n }\n\n if (name === 'span') {\n const attrMatch = /style=\"([^\"]*)\"/.exec(full)\n if (!closing && attrMatch) {\n const endAt = findClosing(text, i + full.length, '</span>')\n if (endAt > 0) {\n flush()\n ctx.degradations.push(\n degrade(\n MT_DEG.LOSSY_CONVERSION,\n 'textStyle',\n basePath,\n 'Markdown 内联 <span> 的样式已被还原为 textStyle(部分属性可能丢失)',\n 'literalized',\n ),\n )\n out.push(\n ...parseInlineSequence(text.slice(i + full.length, endAt), marks, ctx, basePath),\n )\n i = endAt + '</span>'.length\n continue\n }\n }\n }\n }\n }\n\n buffer += ch\n i += 1\n }\n\n flush()\n return out\n}\n","/**\n * Markdown → `MtDocument`(`docs/document-model.md` §8)。\n *\n * **Markdown 是有损格式**,本模块只覆盖 §8.1/§8.2 定义的往返子集:\n * 标题、段落、引用、有序/无序/任务列表(含嵌套)、围栏代码块、分隔线、\n * GFM 表格、图片、链接、强调、行内代码、行内/块级公式、以及本包自己产出的\n * 内联 HTML 标记(`<u>` `<mark>` `<sub>` `<sup>` `<span>`)。\n *\n * 覆盖不到的语法**不猜测**:按普通文本处理,并在降级报告中说明。\n *\n * 实现说明:块级识别使用正则(Markdown 语法识别与 HTML 清洗无关,\n * ADR-0006 的「禁止正则」仅约束 XSS 清洗路径)。\n */\n\nimport { finalizeInlineNodes } from '../normalize'\nimport { MT_SCHEMA_VERSION } from '../types/base'\nimport type { MtHeadingLevel } from '../types/base'\nimport type { MtDocument } from '../types/document'\nimport type { MtBlockNode, MtInlineNode, MtTableNode, MtTableRowNode } from '../types/nodes'\nimport type { MtParseOptions, MtParseResult } from '../types/io'\nimport { createIdGenerator } from '../utils/id'\nimport type { MtParseContext } from './context'\nimport { chAt, makeParagraph, parseInlineSequence } from './markdown-inline'\nimport type { MtCollected } from './markdown-inline'\n\n/** 五级标题的正则 */\nconst RE_ATX = /^ {0,3}(#{1,6})(?:\\s+(.*?))?\\s*#*\\s*$/\n/** 围栏起始 */\nconst RE_FENCE = /^ {0,3}(`{3,}|~{3,})\\s*([^\\s`]*)/\n/** 分隔线 */\nconst RE_HR = /^ {0,3}(?:(?:\\*\\s*){3,}|(?:-\\s*){3,}|(?:_\\s*){3,})$/\n/** 块级公式围栏:独占一行的 `$$`(与 `serialize/markdown.ts` 的输出严格对称) */\nconst RE_MATH_FENCE = /^ {0,3}\\$\\$\\s*$/\n/** 列表项:无序 `- * +`、有序 `N.` / `N)`、任务框 */\nconst RE_LIST = /^( *)([-*+]|\\d{1,9}[.)])(?: +|$)/\n/** 任务框 */\nconst RE_TASK = /^\\[( |x|X)\\]\\s*/\n/** GFM 表格分隔行 */\nconst RE_TABLE_DELIM = /^ {0,3}\\|? *:?-{1,}:? *(\\| *:?-{1,}:? *)*\\|? *$/\n\n/** 计算行首缩进宽度(Tab 记 4 空格) */\nconst indentWidth = (line: string): number => {\n let width = 0\n for (let i = 0; i < line.length; i += 1) {\n const ch = line.charAt(i)\n if (ch === ' ') width += 1\n else if (ch === '\\t') width += 4\n else break\n }\n return width\n}\n\n/** 去掉至多 `count` 个前导空格 */\nconst stripIndent = (line: string, count: number): string => {\n let i = 0\n while (i < count && chAt(line, i) === ' ') i += 1\n return line.slice(i)\n}\n\n/** 把采集序列按段落分组为块级节点 */\nconst groupItems = (items: readonly MtCollected[], ctx: MtParseContext): MtBlockNode[] => {\n const out: MtBlockNode[] = []\n let buffer: MtInlineNode[] = []\n const flush = (): void => {\n if (buffer.length === 0) return\n out.push(makeParagraph({ id: ctx.nextId() }, finalizeInlineNodes(buffer)))\n buffer = []\n }\n for (const item of items) {\n if (item.kind === 'inline') {\n buffer.push(item.node)\n continue\n }\n flush()\n out.push(item.node)\n }\n flush()\n return out\n}\n\n/** 按行解析块级结构 */\nconst parseLines = (\n lines: readonly string[],\n ctx: MtParseContext,\n basePath: string,\n): MtBlockNode[] => {\n const out: MtBlockNode[] = []\n let i = 0\n\n /** 段落收集器 */\n const paragraphLines: string[] = []\n const flushParagraph = (): void => {\n if (paragraphLines.length === 0) return\n const text = paragraphLines.join('\\n')\n paragraphLines.length = 0\n out.push(...groupItems(parseInlineSequence(text, [], ctx, basePath), ctx))\n }\n\n while (i < lines.length) {\n const line = lines[i] ?? ''\n\n if (line.trim() === '') {\n flushParagraph()\n i += 1\n continue\n }\n\n const fence = RE_FENCE.exec(line)\n if (fence) {\n flushParagraph()\n const marker = fence[1] ?? '```'\n const language = fence[2] ?? ''\n const body: string[] = []\n i += 1\n while (i < lines.length && !(lines[i] ?? '').trimStart().startsWith(marker)) {\n body.push(lines[i] ?? '')\n i += 1\n }\n i += 1\n out.push({\n type: 'codeBlock',\n attrs: { id: ctx.nextId(), ...(language.length > 0 ? { language } : {}) },\n ...(body.length > 0 ? { content: [{ type: 'text', text: body.join('\\n') }] } : {}),\n })\n continue\n }\n\n const mathFence = RE_MATH_FENCE.test(line)\n if (mathFence) {\n flushParagraph()\n const body: string[] = []\n i += 1\n while (i < lines.length && !RE_MATH_FENCE.test(lines[i] ?? '')) {\n body.push(lines[i] ?? '')\n i += 1\n }\n i += 1\n // `svg` 为可选属性(ADR-0013 的预渲染产物),Markdown 中不存在该信息,\n // 由业务侧在渲染前用 preRendered 索引补齐\n out.push({ type: 'mathBlock', attrs: { id: ctx.nextId(), tex: body.join('\\n') } })\n continue\n }\n\n const atx = RE_ATX.exec(line)\n if (atx) {\n flushParagraph()\n const level = Math.min(6, (atx[1] ?? '#').length) as MtHeadingLevel\n const text = atx[2] ?? ''\n out.push({\n type: 'heading',\n attrs: { id: ctx.nextId(), level },\n ...(text.length > 0\n ? {\n content: finalizeInlineNodes(\n parseInlineSequence(text, [], ctx, basePath).flatMap((item) =>\n item.kind === 'inline' ? [item.node] : [],\n ),\n ),\n }\n : {}),\n })\n i += 1\n continue\n }\n\n if (RE_HR.test(line)) {\n flushParagraph()\n out.push({ type: 'horizontalRule', attrs: { id: ctx.nextId() } })\n i += 1\n continue\n }\n\n if (/^ {0,3}>/.test(line)) {\n flushParagraph()\n const inner: string[] = []\n while (i < lines.length && /^ {0,3}>/.test(lines[i] ?? '')) {\n inner.push((lines[i] ?? '').replace(/^ {0,3}> ?/, ''))\n i += 1\n }\n out.push({\n type: 'blockquote',\n attrs: { id: ctx.nextId() },\n content: parseLines(inner, ctx, basePath),\n })\n continue\n }\n\n if (RE_LIST.test(line)) {\n flushParagraph()\n const list = parseListBlock(lines, i, ctx, basePath)\n out.push(list.node)\n i = list.next\n continue\n }\n\n const next = lines[i + 1]\n if (line.includes('|') && next !== undefined && RE_TABLE_DELIM.test(next)) {\n flushParagraph()\n const table = parseTableBlock(lines, i, ctx, basePath)\n out.push(table.node)\n i = table.next\n continue\n }\n\n paragraphLines.push(line)\n i += 1\n }\n\n flushParagraph()\n return out\n}\n\n/** 列表解析结果 */\ninterface MtListParse {\n node: MtBlockNode\n next: number\n}\n\n/**\n * 解析列表块(含嵌套与任务框)。\n *\n * @param lines 全部行\n * @param start 起始行号\n * @param ctx 解析上下文\n * @param basePath 路径前缀\n * @returns 列表节点与下一行号\n */\nconst parseListBlock = (\n lines: readonly string[],\n start: number,\n ctx: MtParseContext,\n basePath: string,\n): MtListParse => {\n const firstMarker = RE_LIST.exec(lines[start] ?? '')\n const baseIndent = firstMarker?.[1]?.length ?? 0\n const ordered = firstMarker !== null && /\\d/.test(firstMarker[2] ?? '')\n const items: Array<{ id: string; body: MtBlockNode[]; checked?: boolean }> = []\n let i = start\n\n while (i < lines.length) {\n const line = lines[i] ?? ''\n const marker = RE_LIST.exec(line)\n if (!marker || (marker[1]?.length ?? 0) !== baseIndent) break\n if (/\\d/.test(marker[2] ?? '') !== ordered) break\n\n let rest = line.slice(marker[0].length)\n let checked: boolean | undefined\n const task = RE_TASK.exec(rest)\n if (task) {\n checked = (task[1] ?? '').toLowerCase() === 'x'\n rest = rest.slice(task[0].length)\n }\n\n const bodyLines = [rest]\n i += 1\n while (i < lines.length) {\n const cont = lines[i] ?? ''\n if (cont.trim() === '') {\n const after = lines[i + 1]\n if (after !== undefined && indentWidth(after) > baseIndent) {\n bodyLines.push('')\n i += 1\n continue\n }\n break\n }\n const width = indentWidth(cont)\n if (width > baseIndent) {\n bodyLines.push(stripIndent(cont, Math.min(width, baseIndent + 2)))\n i += 1\n continue\n }\n break\n }\n\n items.push({ id: ctx.nextId(), body: parseLines(bodyLines, ctx, basePath), checked })\n\n // 项间空行\n while (i < lines.length && (lines[i] ?? '').trim() === '') {\n const after = lines[i + 1]\n const afterMarker = after === undefined ? null : RE_LIST.exec(after)\n if (afterMarker && (afterMarker[1]?.length ?? 0) === baseIndent) {\n i += 1\n break\n }\n break\n }\n }\n\n const isTask = items.some((item) => item.checked !== undefined)\n const withLeading = (body: MtBlockNode[]): MtBlockNode[] =>\n body[0]?.type === 'paragraph' ? body : [makeParagraph({ id: ctx.nextId() }, []), ...body]\n\n if (isTask) {\n return {\n node: {\n type: 'taskList',\n attrs: { id: ctx.nextId() },\n content: items.map((item) => ({\n type: 'taskItem',\n attrs: { id: item.id, ...(item.checked === undefined ? {} : { checked: item.checked }) },\n content: withLeading(item.body),\n })),\n },\n next: i,\n }\n }\n\n const content = items.map((item) => ({\n type: 'listItem' as const,\n attrs: { id: item.id },\n content: withLeading(item.body),\n }))\n\n if (ordered) {\n const startText = firstMarker?.[2] ?? '1.'\n const startNumber = Number.parseInt(startText, 10)\n return {\n node: {\n type: 'orderedList',\n attrs: {\n id: ctx.nextId(),\n ...(Number.isFinite(startNumber) && startNumber !== 1 ? { start: startNumber } : {}),\n },\n content,\n },\n next: i,\n }\n }\n\n return { node: { type: 'bulletList', attrs: { id: ctx.nextId() }, content }, next: i }\n}\n\n/** 拆分表格行的单元格 */\nconst splitCells = (line: string): string[] => {\n let trimmed = line.trim()\n if (trimmed.startsWith('|')) trimmed = trimmed.slice(1)\n if (trimmed.endsWith('|')) trimmed = trimmed.slice(0, -1)\n const cells: string[] = []\n let buffer = ''\n for (let i = 0; i < trimmed.length; i += 1) {\n const ch = trimmed.charAt(i)\n if (ch === '\\\\' && chAt(trimmed, i + 1) === '|') {\n buffer += '|'\n i += 1\n continue\n }\n if (ch === '|') {\n cells.push(buffer.trim())\n buffer = ''\n continue\n }\n buffer += ch\n }\n cells.push(buffer.trim())\n return cells\n}\n\n/**\n * 解析 GFM 表格。\n *\n * @param lines 全部行\n * @param start 起始行号\n * @param ctx 解析上下文\n * @param basePath 路径前缀\n * @returns 表格节点与下一行号\n */\nconst parseTableBlock = (\n lines: readonly string[],\n start: number,\n ctx: MtParseContext,\n basePath: string,\n): { node: MtTableNode; next: number } => {\n const header = splitCells(lines[start] ?? '')\n const rows: MtTableRowNode[] = []\n\n const cellBlocks = (text: string, index: number): MtBlockNode[] => {\n const blocks = groupItems(parseInlineSequence(text, [], ctx, `${basePath}[${index}]`), ctx)\n return blocks.length > 0 ? blocks : [makeParagraph({ id: ctx.nextId() }, [])]\n }\n\n const headerCells = header.map((text, index) => ({\n type: 'tableHeader' as const,\n content: cellBlocks(text, index),\n }))\n rows.push({ type: 'tableRow', content: headerCells })\n\n let i = start + 2\n while (i < lines.length && (lines[i] ?? '').trim() !== '' && (lines[i] ?? '').includes('|')) {\n const cells = splitCells(lines[i] ?? '')\n rows.push({\n type: 'tableRow',\n content: cells.map((text, index) => ({\n type: 'tableCell' as const,\n content: cellBlocks(text, index),\n })),\n })\n i += 1\n }\n\n return { node: { type: 'table', attrs: { id: ctx.nextId() }, content: rows }, next: i }\n}\n\n/**\n * 把 Markdown 源码解析为 `MtDocument`。\n *\n * @param markdown Markdown 源码\n * @param options 解析选项\n * @returns 文档、降级报告与校验问题\n *\n * @example\n * ```ts\n * const { doc, report } = parseMarkdownToDocument('# 标题\\n\\n正文 **加粗**')\n * doc.content.map((n) => n.type) // ['heading', 'paragraph']\n * ```\n */\nexport const parseMarkdownToDocument = (\n markdown: string,\n options: MtParseOptions = {},\n): MtParseResult => {\n const ctx: MtParseContext = {\n assignIds: options.assignIds !== false,\n nextId: createIdGenerator(options.now === undefined ? {} : { now: options.now }),\n degradations: [],\n issues: [],\n }\n\n if (typeof markdown !== 'string' || markdown.trim().length === 0) {\n const doc: MtDocument = {\n schemaVersion: MT_SCHEMA_VERSION,\n type: 'doc',\n content: [makeParagraph({ id: ctx.nextId() }, [])],\n }\n return { doc, report: [], issues: [] }\n }\n\n const normalized = markdown.split('\\r\\n').join('\\n').split('\\r').join('\\n')\n const blocks = parseLines(normalized.split('\\n'), ctx, 'content')\n\n const doc: MtDocument = {\n schemaVersion: MT_SCHEMA_VERSION,\n type: 'doc',\n content: blocks.length > 0 ? blocks : [makeParagraph({ id: ctx.nextId() }, [])],\n }\n\n return {\n doc,\n report: options.report === false ? [] : ctx.degradations,\n issues: ctx.issues,\n }\n}\n","/**\n * parse 层出口:HTML / Markdown → `MtDocument`。\n */\n\nimport type { MtParseOptions, MtParseResult } from '../types/io'\nimport { parseHtmlToDocument } from './html'\nimport { parseMarkdownToDocument } from './markdown'\n\nexport { parseHtmlToDocument } from './html'\nexport { parseMarkdownToDocument } from './markdown'\nexport type { MtParseContext } from './context'\n\n/** 支持的输入格式 */\nexport type MtParseFormat = 'html' | 'markdown'\n\n/**\n * 按格式解析源码。\n *\n * @param source 源码\n * @param format 格式\n * @param options 解析选项\n * @returns 解析结果\n *\n * @example\n * ```ts\n * parseDocument('<p>a</p>', 'html')\n * parseDocument('# 标题', 'markdown')\n * ```\n */\nexport const parseDocument = (\n source: string,\n format: MtParseFormat,\n options: MtParseOptions = {},\n): MtParseResult =>\n format === 'markdown'\n ? parseMarkdownToDocument(source, options)\n : parseHtmlToDocument(source, options)\n","/**\n * 轻量哈希与预渲染产物索引键(`docs/document-model.md` §10)。\n *\n * 用途:`preRendered.code` 以**代码内容哈希**为键,避免在文档里内联整段源码\n * 作为索引键(体积与可读性都不可接受)。\n */\n\n/** FNV-1a 32 位哈希的 offset basis */\nconst FNV_OFFSET = 0x811c9dc5\n\n/** FNV-1a 32 位哈希的质数 */\nconst FNV_PRIME = 0x01000193\n\n/**\n * 计算字符串的 FNV-1a 32 位哈希(返回 8 位十六进制)。\n *\n * 选择 FNV-1a 的理由:实现只有几行、无依赖、结果稳定,\n * 且**非密码学**用途下分布足够用于索引键。\n *\n * @param text 输入文本\n * @returns 8 位十六进制哈希\n *\n * @example\n * ```ts\n * fnv1a('const a = 1') // '2f3a…'(同输入恒等)\n * ```\n */\nexport const fnv1a = (text: string): string => {\n let hash = FNV_OFFSET\n for (let i = 0; i < text.length; i += 1) {\n hash ^= text.charCodeAt(i)\n hash = Math.imul(hash, FNV_PRIME) >>> 0\n }\n return hash.toString(16).padStart(8, '0')\n}\n","/**\n * 通用树遍历与体积统计工具(零依赖、零 DOM)。\n */\n\nimport { isRecord } from './guards'\n\n/** 文档根的路径前缀,与 `docs/document-model.md` §12 的路径写法一致 */\nexport const MT_TREE_ROOT_PATH = 'content'\n\n/** 可参与遍历的最小节点结构 */\nexport interface MtTreeNodeLike {\n type?: unknown\n content?: unknown\n}\n\n/** 一次访问的上下文 */\nexport interface MtTreeVisit<T extends MtTreeNodeLike> {\n /** 当前节点 */\n node: T\n /** 文档内定位路径,如 `content[2].content[0]` */\n path: string\n /** 嵌套深度,根层为 0 */\n depth: number\n /** 父节点;根层为 `undefined` */\n parent: T | undefined\n /** 在父节点 `content` 中的下标 */\n index: number\n}\n\n/**\n * 拼接子节点路径。\n *\n * @param base 父路径\n * @param index 下标\n * @returns 子节点路径\n */\nexport const childPath = (base: string, index: number): string => `${base}[${index}]`\n\n/**\n * 深度优先遍历节点树。\n *\n * 采用显式下标循环而非 `forEach`,避免闭包捕获带来的额外开销;\n * 递归深度等于文档嵌套深度,长文档亦不会触及栈上限。\n *\n * @param roots 根层节点数组\n * @param visit 访问回调\n * @param rootPath 根路径前缀,缺省 `'content'`\n *\n * @example\n * ```ts\n * walkTree(doc.content, ({ path, depth }) => {\n * console.log(path, depth)\n * })\n * ```\n */\nexport const walkTree = <T extends MtTreeNodeLike>(\n roots: readonly T[],\n visit: (info: MtTreeVisit<T>) => void,\n rootPath: string = MT_TREE_ROOT_PATH,\n): void => {\n const step = (nodes: readonly T[], parent: T | undefined, depth: number, base: string): void => {\n for (let index = 0; index < nodes.length; index += 1) {\n const node = nodes[index]\n if (node === undefined) continue\n const path = childPath(base, index)\n visit({ node, path, depth, parent, index })\n const children = node.content\n if (Array.isArray(children)) {\n step(children as T[], node, depth + 1, `${path}.content`)\n }\n }\n }\n step(roots, undefined, 0, rootPath)\n}\n\n/** 树的规模统计 */\nexport interface MtTreeStats {\n /** 节点总数(不含根容器) */\n nodeCount: number\n /** 最大嵌套深度(根层节点深度为 1) */\n maxDepth: number\n}\n\n/**\n * 统计节点数与最大嵌套深度。\n *\n * @param roots 根层节点数组\n * @returns 规模统计\n *\n * @example\n * ```ts\n * computeTreeStats([{ type: 'paragraph' }]) // { nodeCount: 1, maxDepth: 1 }\n * ```\n */\nexport const computeTreeStats = (roots: readonly MtTreeNodeLike[]): MtTreeStats => {\n let nodeCount = 0\n let maxDepth = 0\n walkTree(roots, ({ depth }) => {\n nodeCount += 1\n if (depth + 1 > maxDepth) maxDepth = depth + 1\n })\n return { nodeCount, maxDepth }\n}\n\n/**\n * 计算字符串的 UTF-8 字节长度。\n *\n * 不依赖 `TextEncoder`:`document` 包需在小程序运行时执行,\n * 且其 `tsconfig` 的 `lib` 已剔除 DOM,故手工按码点宽度累加。\n *\n * @param text 待计算文本\n * @returns 字节数\n *\n * @example\n * ```ts\n * utf8ByteLength('中文') // 6\n * utf8ByteLength('😀') // 4\n * ```\n */\nexport const utf8ByteLength = (text: string): number => {\n let bytes = 0\n for (let i = 0; i < text.length; i += 1) {\n const code = text.charCodeAt(i)\n if (code < 0x80) {\n bytes += 1\n } else if (code < 0x800) {\n bytes += 2\n } else if (code >= 0xd800 && code <= 0xdbff) {\n // 高位代理:与低位代理共同构成一个码点,占 4 字节,跳过低位\n bytes += 4\n i += 1\n } else {\n bytes += 3\n }\n }\n return bytes\n}\n\n/**\n * 深拷贝纯 JSON 结构。\n *\n * 节点树全部由 JSON 可序列化的值构成,因此不使用 `structuredClone`\n * (小程序运行时不一定提供),也不使用 `JSON.parse(JSON.stringify())`\n * (会丢弃 `undefined` 之外还会改变 `-0`、`NaN` 等边界值)。\n *\n * @param value 待拷贝值\n * @returns 拷贝结果\n *\n * @example\n * ```ts\n * const copy = cloneJson({ a: [1, 2] })\n * ```\n */\nexport const cloneJson = <T>(value: T): T => {\n if (Array.isArray(value)) {\n return value.map((item: unknown) => cloneJson(item)) as unknown as T\n }\n if (isRecord(value)) {\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(value)) {\n const item = value[key]\n if (item === undefined) continue\n out[key] = cloneJson(item)\n }\n return out as unknown as T\n }\n return value\n}\n","/**\n * HTML 序列化的属性与样式原语(`docs/document-model.md` §7.1)。\n *\n * 被 `serialize/html.ts`(节点)与 `serialize/html-marks.ts`(标记)共用,\n * 因此单独成模块——两处必须用**同一套**转义与跳过 `undefined` / `null` 的规则,\n * 否则「节点属性」与「标记属性」的转义口径会漂移。\n */\n\nimport { escapeAttr } from '../utils/escape'\n\n/**\n * 属性对:`undefined` / `null` 表示「该属性缺省,不输出」。\n *\n * 之所以连 `null` 一起接受:`MtDocument` 的类型把可选属性声明为 `?:`,\n * 但**运行时的 JSON 可能带 `null`**(ProseMirror 侧可选属性的 `default`\n * 就是 `null`,旧数据的迁移也可能留下它)。让它在这里被统一跳过,\n * 比在每个调用点各写一遍 `=== undefined || === null` 可靠(ADR-0023)。\n */\nexport type MtAttrPair = readonly [name: string, value: string | undefined | null]\n\n/**\n * 拼接属性串(跳过 `undefined` / `null`)。\n *\n * @param pairs 属性对列表\n * @returns 形如 ` a=\"1\" b=\"2\"` 的字符串\n *\n * @example\n * ```ts\n * attrsToString([['href', 'https://a.test'], ['title', undefined]]) // ' href=\"https://a.test\"'\n * attrsToString([['start', null]]) // '' —— null 与 undefined 同等对待\n * ```\n */\nexport const attrsToString = (pairs: readonly MtAttrPair[]): string => {\n let out = ''\n for (const [name, value] of pairs) {\n if (value === undefined || value === null) continue\n out += ` ${name}=\"${escapeAttr(value)}\"`\n }\n return out\n}\n\n/**\n * 拼接内联 `style` 值(跳过空对象)。\n *\n * @param pairs 属性对列表\n * @returns `style` 值;无内容时返回 `undefined`\n *\n * @example\n * ```ts\n * styleToString([['text-align', 'center']]) // 'text-align: center'\n * ```\n */\nexport const styleToString = (pairs: readonly MtAttrPair[]): string | undefined => {\n const parts: string[] = []\n for (const [name, value] of pairs) {\n if (value === undefined || value === null) continue\n parts.push(`${name}: ${value}`)\n }\n return parts.length > 0 ? parts.join('; ') : undefined\n}\n","/**\n * HTML 序列化的标记包裹(`docs/document-model.md` §5、§7.2)。\n *\n * 与 `parse/html-marks.ts` 严格对称:那边是「标签/样式 → 标记」,这边是\n * 「标记 → 标签/样式」。对称性由 `JSON → HTML → JSON` 往返用例保证。\n *\n * 唯一的「补默认值」例外是 `link` 的 `target` / `rel`:§7.2 要求强制补出,\n * 以消除 `target=\"_blank\"` 的 tabnabbing 风险;解析侧把恰好等于默认值的取值\n * 视为缺省,因此不破坏往返等价。\n */\n\nimport { MT_LINK_DEFAULT_REL, MT_LINK_DEFAULT_TARGET } from '../schema/marks'\nimport type { MtMark } from '../types/marks'\nimport { attrsToString, styleToString } from './html-attrs'\n\n/** 标记 → HTML 包装器的映射(无属性标记) */\nconst MARK_TAGS: ReadonlyMap<string, string> = new Map([\n ['bold', 'strong'],\n ['italic', 'em'],\n ['underline', 'u'],\n ['strike', 's'],\n ['code', 'code'],\n ['subscript', 'sub'],\n ['superscript', 'sup'],\n])\n\n/**\n * 把标记转成 HTML 包装器。\n *\n * @param mark 标记\n * @param open 是否为开标签\n * @returns 标签字符串\n */\nconst markWrapper = (mark: MtMark, open: boolean): string => {\n switch (mark.type) {\n case 'link': {\n const attrs = attrsToString([\n ['href', mark.attrs.href],\n ['target', mark.attrs.target ?? MT_LINK_DEFAULT_TARGET],\n ['rel', mark.attrs.rel ?? MT_LINK_DEFAULT_REL],\n ['title', mark.attrs.title],\n ])\n return open ? `<a${attrs}>` : '</a>'\n }\n case 'textStyle': {\n const style = styleToString([\n ['font-family', mark.attrs.fontFamily ?? undefined],\n ['font-size', mark.attrs.fontSize ?? undefined],\n ['color', mark.attrs.color ?? undefined],\n ['background-color', mark.attrs.backgroundColor ?? undefined],\n ['line-height', mark.attrs.lineHeight ?? undefined],\n ['letter-spacing', mark.attrs.letterSpacing ?? undefined],\n ])\n return open ? `<span${attrsToString([['style', style]])}>` : '</span>'\n }\n case 'highlight':\n return open\n ? `<mark${attrsToString([['style', styleToString([['background-color', mark.attrs.color]])]])}>`\n : '</mark>'\n default: {\n const tag = MARK_TAGS.get(mark.type) ?? 'span'\n return open ? `<${tag}>` : `</${tag}>`\n }\n }\n}\n\n/**\n * 按优先级降序把标记包裹到内容外层。\n *\n * @param content 已转义的文本\n * @param marks 标记数组(须已按优先级降序排好)\n * @returns 包裹结果\n *\n * @example\n * ```ts\n * wrapMarks('文本', [{ type: 'bold' }]) // '<strong>文本</strong>'\n * ```\n */\nexport const wrapMarks = (content: string, marks: readonly MtMark[] | undefined): string => {\n if (!marks || marks.length === 0) return content\n let out = content\n // 数组已降序,逆序遍历即「由内向外」闭合\n for (let i = marks.length - 1; i >= 0; i -= 1) {\n const mark = marks[i]\n if (mark === undefined) continue\n out = `${markWrapper(mark, true)}${out}${markWrapper(mark, false)}`\n }\n return out\n}\n","/**\n * `MtDocument` → HTML(`docs/document-model.md` §7.1–§7.3)。\n *\n * 三条铁律:\n * 1. **只输出文档里真实存在的信息**,不补可选属性——`start` / `checked` 这类\n * 「默认值属性」一旦被擅自补出,`JSON → HTML → JSON` 立刻漂移;\n * 2. **标记按 §5 优先级降序由外向内包裹**,保证同一份 JSON 在任何端产出的嵌套顺序一致;\n * 3. **节点身份一律用 `data-mt-*` 承载**,不依赖 `class`(class 属主题层契约)。\n *\n * 唯一的例外是 `link` 的 `target` / `rel`:§7.2 要求「强制补」,以消除\n * `target=\"_blank\"` 的 tabnabbing 风险。与之对称,解析侧会把**恰好等于默认值**\n * 的 `target` / `rel` 视为缺省,从而既不丢安全属性、也不破坏往返等价。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport type { MtBlockNode, MtDocumentNode, MtInlineNode, MtTableRowNode } from '../types/nodes'\nimport type { MtDocument } from '../types/document'\nimport type { MtPreRenderedIndex, MtSerializeResult } from '../types/io'\nimport type { MtDegradationEntry } from '../types/report'\nimport { escapeHtml } from '../utils/escape'\nimport { fnv1a } from '../utils/hash'\nimport { MT_COLWIDTH_UNIT, MT_INDENT_UNIT, formatCssLength } from '../utils/style-units'\nimport { computeTreeStats, utf8ByteLength } from '../utils/tree'\nimport { attrsToString, styleToString } from './html-attrs'\nimport { wrapMarks } from './html-marks'\n\n/** 序列化上下文 */\nexport interface MtHtmlSerializeContext {\n /** 降级收集器 */\n degradations: MtDegradationEntry[]\n /** 预渲染产物索引 */\n preRendered: MtPreRenderedIndex\n /** 是否启用换行美化 */\n pretty: boolean\n}\n\n/** 序列化行内节点 */\nconst serializeInline = (node: MtInlineNode, ctx: MtHtmlSerializeContext): string => {\n switch (node.type) {\n case 'text':\n return wrapMarks(escapeHtml(node.text), node.marks)\n case 'hardBreak':\n return '<br>'\n case 'mention': {\n const kind = node.attrs.kind\n return `<span${attrsToString([\n ['data-mt-mention', ''],\n ['data-mt-id', node.attrs.id],\n ['data-mt-kind', kind === undefined ? undefined : kind],\n ])}>${escapeHtml(node.attrs.label)}</span>`\n }\n case 'inlineMath': {\n const svg = node.attrs.svg ?? ctx.preRendered.math?.[node.attrs.tex]\n if (svg === undefined || svg === null || svg.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MISSING_PRERENDER,\n 'inlineMath',\n 'inline',\n `行内公式 \"${node.attrs.tex}\" 缺少预渲染 SVG`,\n 'replaced',\n ),\n )\n }\n return `<span${attrsToString([\n ['data-mt-math-inline', ''],\n ['data-tex', node.attrs.tex],\n ])}>${svg ?? ''}</span>`\n }\n case 'emoji':\n return `<span${attrsToString([\n ['data-mt-emoji', ''],\n ['data-mt-name', node.attrs.name],\n ])}>${escapeHtml(node.attrs.unicode)}</span>`\n default:\n return ''\n }\n}\n\n/** 序列化行内节点序列 */\nconst serializeInlineNodes = (\n nodes: readonly MtInlineNode[] | undefined,\n ctx: MtHtmlSerializeContext,\n): string => {\n if (!nodes) return ''\n let out = ''\n for (const node of nodes) out += serializeInline(node, ctx)\n return out\n}\n\n/** 由 `child[0]` 是否为 `text` 判断是否单文本子节点 */\nconst singleText = (nodes: readonly MtDocumentNode[]): string => {\n const first = nodes[0]\n if (first !== undefined && first.type === 'text') return first.text\n return ''\n}\n\n/** 序列化表格单元格 */\nconst serializeRow = (row: MtTableRowNode, ctx: MtHtmlSerializeContext): string => {\n let cells = ''\n for (const cell of row.content ?? []) {\n const tag = cell.type === 'tableHeader' ? 'th' : 'td'\n const attrs = cell.attrs\n const style = styleToString([\n ['text-align', attrs?.textAlign ?? undefined],\n [\n 'line-height',\n attrs?.lineHeight === undefined || attrs.lineHeight === null\n ? undefined\n : String(attrs.lineHeight),\n ],\n ['background-color', attrs?.background ?? undefined],\n [\n 'width',\n attrs?.colwidth === undefined || attrs.colwidth === null\n ? undefined\n : formatCssLength(attrs.colwidth, MT_COLWIDTH_UNIT),\n ],\n ])\n const head = attrsToString([\n // `null` 与 `undefined` 都表示「没写」。少了 `null` 这一支时 `String(null)`\n // 会把字面量 `colspan=\"null\"` 写进产物,而编辑器侧未声明的属性恰好是 `null`。\n [\n 'colspan',\n attrs?.colspan === undefined || attrs.colspan === null ? undefined : String(attrs.colspan),\n ],\n [\n 'rowspan',\n attrs?.rowspan === undefined || attrs.rowspan === null ? undefined : String(attrs.rowspan),\n ],\n ['data-mt-align', attrs?.align ?? undefined],\n ['style', style],\n ])\n cells += `<${tag}${head}>${serializeBlocks(cell.content ?? [], ctx)}</${tag}>`\n }\n return `<tr>${cells}</tr>`\n}\n\n/** 序列化媒体/特殊块 */\nconst serializeAtomBlock = (node: MtBlockNode, ctx: MtHtmlSerializeContext): string => {\n switch (node.type) {\n case 'horizontalRule':\n return `<hr${attrsToString([['data-mt-id', node.attrs.id]])}>`\n case 'image':\n return `<img${attrsToString([\n ['data-mt-id', node.attrs.id],\n ['src', node.attrs.src],\n ['alt', node.attrs.alt ?? undefined],\n ['title', node.attrs.title ?? undefined],\n [\n 'width',\n node.attrs.width === undefined || node.attrs.width === null\n ? undefined\n : String(node.attrs.width),\n ],\n [\n 'height',\n node.attrs.height === undefined || node.attrs.height === null\n ? undefined\n : String(node.attrs.height),\n ],\n ['data-mt-align', node.attrs.align ?? undefined],\n ])}>`\n case 'video':\n return `<video${attrsToString([\n ['data-mt-id', node.attrs.id],\n ['src', node.attrs.src],\n ['poster', node.attrs.poster ?? undefined],\n [\n 'width',\n node.attrs.width === undefined || node.attrs.width === null\n ? undefined\n : String(node.attrs.width),\n ],\n [\n 'height',\n node.attrs.height === undefined || node.attrs.height === null\n ? undefined\n : String(node.attrs.height),\n ],\n ['data-mt-align', node.attrs.align ?? undefined],\n ['controls', ''],\n ])}></video>`\n case 'audio':\n return `<audio${attrsToString([\n ['data-mt-id', node.attrs.id],\n ['src', node.attrs.src],\n ['title', node.attrs.title ?? undefined],\n ['controls', ''],\n ])}></audio>`\n case 'attachment':\n return `<a${attrsToString([\n ['data-mt-attachment', ''],\n ['data-mt-id', node.attrs.id],\n ['href', node.attrs.src],\n ['data-mt-name', node.attrs.name],\n [\n 'data-mt-size',\n node.attrs.size === undefined || node.attrs.size === null\n ? undefined\n : String(node.attrs.size),\n ],\n ['data-mt-mime', node.attrs.mime ?? undefined],\n ])}>${escapeHtml(node.attrs.name)}</a>`\n case 'mathBlock': {\n const svg = node.attrs.svg ?? ctx.preRendered.math?.[node.attrs.tex]\n if (svg === undefined || svg === null || svg.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MISSING_PRERENDER,\n 'mathBlock',\n 'block',\n `公式 \"${node.attrs.tex}\" 缺少预渲染 SVG`,\n 'replaced',\n ),\n )\n }\n const head = attrsToString([\n ['data-mt-math', ''],\n ['data-mt-id', node.attrs.id],\n ['data-tex', node.attrs.tex],\n ['data-mt-numbering', node.attrs.numbering ?? undefined],\n ])\n return `<div${head}>${svg ?? ''}</div>`\n }\n default:\n return ''\n }\n}\n\n/** 序列化代码块 */\nconst serializeCodeBlock = (\n node: Extract<MtBlockNode, { type: 'codeBlock' }>,\n ctx: MtHtmlSerializeContext,\n): string => {\n const text = singleText(node.content ?? [])\n const language = node.attrs.language\n const highlight = ctx.preRendered.code?.[fnv1a(text)] ?? ctx.preRendered.code?.[text]\n if (\n highlight === undefined &&\n text.length > 0 &&\n language !== undefined &&\n language !== null &&\n language.length > 0\n ) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MISSING_PRERENDER,\n 'codeBlock',\n 'block',\n `代码块 \"${language ?? ''}\" 缺少预渲染高亮产物`,\n 'literalized',\n ),\n )\n }\n const preAttrs = attrsToString([\n ['data-mt-id', node.attrs.id],\n // `language` 要判 `null`:ProseMirror 侧可选属性的 `default` 就是 `null`,\n // 只判 `undefined` 会让它穿透到 `language.length` 上(TypeError,ADR-0023)。\n [\n 'data-mt-language',\n language === undefined || language === null || language.length === 0 ? undefined : language,\n ],\n [\n 'data-mt-line-numbers',\n node.attrs.showLineNumbers === undefined || node.attrs.showLineNumbers === null\n ? undefined\n : String(node.attrs.showLineNumbers),\n ],\n ])\n const codeAttrs = attrsToString([\n [\n 'class',\n language === undefined || language === null || language.length === 0\n ? undefined\n : `language-${language}`,\n ],\n ])\n const body = highlight ?? escapeHtml(text)\n return `<pre${preAttrs}><code${codeAttrs}>${body}</code></pre>`\n}\n\n/** 序列化块级节点 */\nconst serializeBlock = (node: MtBlockNode, ctx: MtHtmlSerializeContext): string => {\n switch (node.type) {\n case 'paragraph': {\n const attrs = node.attrs\n const style = styleToString([\n ['text-align', attrs.textAlign ?? undefined],\n [\n 'line-height',\n attrs.lineHeight === undefined || attrs.lineHeight === null\n ? undefined\n : String(attrs.lineHeight),\n ],\n [\n 'padding-left',\n attrs.indent === undefined || attrs.indent === null\n ? undefined\n : formatCssLength(attrs.indent, MT_INDENT_UNIT),\n ],\n ])\n const head = attrsToString([\n ['data-mt-id', attrs.id],\n ['style', style],\n ])\n return `<p${head}>${serializeInlineNodes(node.content, ctx)}</p>`\n }\n case 'heading': {\n const attrs = node.attrs\n const style = styleToString([\n ['text-align', attrs.textAlign ?? undefined],\n [\n 'line-height',\n attrs.lineHeight === undefined || attrs.lineHeight === null\n ? undefined\n : String(attrs.lineHeight),\n ],\n ])\n const head = attrsToString([\n ['data-mt-id', attrs.id],\n ['style', style],\n ])\n return `<h${attrs.level}${head}>${serializeInlineNodes(node.content, ctx)}</h${attrs.level}>`\n }\n case 'blockquote':\n return `<blockquote${attrsToString([['data-mt-id', node.attrs.id]])}>${serializeBlocks(node.content ?? [], ctx)}</blockquote>`\n case 'bulletList': {\n let items = ''\n for (const item of node.content ?? []) {\n items += `<li${attrsToString([['data-mt-id', item.attrs.id]])}>${serializeBlocks(item.content ?? [], ctx)}</li>`\n }\n return `<ul${attrsToString([['data-mt-id', node.attrs.id]])}>${items}</ul>`\n }\n case 'orderedList': {\n let items = ''\n for (const item of node.content ?? []) {\n items += `<li${attrsToString([['data-mt-id', item.attrs.id]])}>${serializeBlocks(item.content ?? [], ctx)}</li>`\n }\n const head = attrsToString([\n ['data-mt-id', node.attrs.id],\n [\n 'start',\n node.attrs.start === undefined || node.attrs.start === null\n ? undefined\n : String(node.attrs.start),\n ],\n ['type', node.attrs.type ?? undefined],\n ])\n return `<ol${head}>${items}</ol>`\n }\n case 'taskList': {\n let items = ''\n for (const item of node.content ?? []) {\n const checked =\n item.attrs.checked === undefined || item.attrs.checked === null\n ? undefined\n : String(item.attrs.checked)\n items += `<li${attrsToString([\n ['data-mt-id', item.attrs.id],\n ['data-mt-checked', checked],\n ])}>${serializeBlocks(item.content ?? [], ctx)}</li>`\n }\n return `<ul${attrsToString([\n ['data-mt-task-list', ''],\n ['data-mt-id', node.attrs.id],\n ])}>${items}</ul>`\n }\n case 'codeBlock':\n return serializeCodeBlock(node, ctx)\n case 'table': {\n const widths = node.attrs.colWidths\n const colgroup =\n widths && widths.length > 0\n ? `<colgroup>${widths.map((width) => `<col${attrsToString([['width', String(width)]])}>`).join('')}</colgroup>`\n : ''\n const rows = (node.content ?? []).map((row) => serializeRow(row, ctx)).join('')\n return `<table${attrsToString([['data-mt-id', node.attrs.id]])}>${colgroup}<tbody>${rows}</tbody></table>`\n }\n default:\n return serializeAtomBlock(node, ctx)\n }\n}\n\n/**\n * 序列化块级节点序列。\n *\n * @param nodes 块级节点\n * @param ctx 序列化上下文\n * @returns HTML 片段\n */\nexport function serializeBlocks(\n nodes: readonly MtBlockNode[],\n ctx: MtHtmlSerializeContext,\n): string {\n const separator = ctx.pretty ? '\\n' : ''\n const parts: string[] = []\n for (const node of nodes) parts.push(serializeBlock(node, ctx))\n return parts.join(separator)\n}\n\n/**\n * 把文档序列化为 HTML。\n *\n * @param doc 文档\n * @param options 序列化选项(`indent > 0` 时启用换行美化)\n * @returns 序列化结果\n *\n * @example\n * ```ts\n * const { output } = serializeDocumentToHtml(doc, { target: 'html' })\n * ```\n */\nexport const serializeDocumentToHtml = (\n doc: MtDocument,\n options: { preRendered?: MtPreRenderedIndex; indent?: number } = {},\n): MtSerializeResult => {\n const ctx: MtHtmlSerializeContext = {\n degradations: [],\n preRendered: options.preRendered ?? {},\n pretty: (options.indent ?? 0) > 0,\n }\n\n const output = serializeBlocks(doc.content, ctx)\n const { nodeCount, maxDepth } = computeTreeStats(doc.content)\n\n return {\n output,\n report: ctx.degradations,\n stats: { nodeCount, maxDepth, bytes: utf8ByteLength(output) },\n }\n}\n","/**\n * `MtDocument` → Markdown(`docs/document-model.md` §8.1、§8.2)。\n *\n * **Markdown 是有损格式**:本模块每遇到一处信息损失,都必须写入降级报告\n *(码 `MT_DEG_004`),业务侧若依赖无损往返请改用 JSON 或 HTML。\n *\n * 文本转义只覆盖**会影响再解析**的字符,避免过度转义损伤可读性。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport type { MtBlockNode, MtInlineNode, MtTableRowNode } from '../types/nodes'\nimport type { MtMark } from '../types/marks'\nimport type { MtDocument } from '../types/document'\nimport type { MtSerializeResult } from '../types/io'\nimport type { MtDegradationEntry } from '../types/report'\nimport { isRecord } from '../utils/guards'\nimport { computeTreeStats, utf8ByteLength, walkTree } from '../utils/tree'\n\n/** 序列化上下文 */\ninterface MdContext {\n degradations: MtDegradationEntry[]\n /** 缩进宽度 */\n indent: string\n}\n\n/**\n * 记录一处有损转换。\n *\n * @param ctx 上下文\n * @param type 涉及的节点/标记类型\n * @param path 定位路径\n * @param message 说明\n */\nconst lossy = (ctx: MdContext, type: string, path: string, message: string): void => {\n ctx.degradations.push(degrade(MT_DEG.LOSSY_CONVERSION, type, path, message, 'flattened'))\n}\n\n/** 需要反斜杠转义的行内字符 */\nconst SPECIAL_CHARS = new Set(['\\\\', '`', '*', '_', '[', ']', '~', '|'])\n\n/** 行首需要转义的字符(否则会被解析为块级语法) */\nconst LINE_START_CHARS = new Set(['#', '>', '-', '+', '=', '|', '~'])\n\n/**\n * 转义行内文本。\n *\n * @param text 原始文本\n * @returns 转义后的文本\n */\nconst escapeText = (text: string): string => {\n let out = ''\n let lineStart = true\n for (let i = 0; i < text.length; i += 1) {\n const ch = text.charAt(i)\n if (ch === '\\n') {\n out += ch\n lineStart = true\n continue\n }\n if (lineStart && LINE_START_CHARS.has(ch)) out += '\\\\'\n lineStart = false\n if (SPECIAL_CHARS.has(ch)) out += '\\\\'\n out += ch\n }\n return out\n}\n\n/** 表格单元格内需要额外转义 `|` */\n/** 标记 → Markdown 包装器 */\nconst wrapMark = (inner: string, mark: MtMark, ctx: MdContext, path: string): string => {\n switch (mark.type) {\n case 'bold':\n return `**${inner}**`\n case 'italic':\n return `*${inner}*`\n case 'strike':\n return `~~${inner}~~`\n case 'code':\n return `\\`${inner}\\``\n case 'link':\n if (mark.attrs.title !== undefined && mark.attrs.title !== null)\n lossy(ctx, 'link', path, '链接的 title 在 Markdown 中丢失')\n return `[${inner}](${mark.attrs.href})`\n case 'underline':\n lossy(ctx, 'underline', path, 'GFM 无下划线语法,已降级为内联 HTML <u>')\n return `<u>${inner}</u>`\n case 'highlight':\n return `<mark>${inner}</mark>`\n case 'textStyle':\n lossy(ctx, 'textStyle', path, '内联样式多数 Markdown 渲染器会剥除')\n return `<span>${inner}</span>`\n case 'subscript':\n return `<sub>${inner}</sub>`\n case 'superscript':\n return `<sup>${inner}</sup>`\n default:\n return inner\n }\n}\n\n/** 序列化行内节点 */\nconst inlineMd = (\n nodes: readonly MtInlineNode[] | undefined,\n ctx: MdContext,\n path: string,\n): string => {\n if (!nodes) return ''\n let out = ''\n nodes.forEach((node, index) => {\n const childPath = `${path}[${index}]`\n switch (node.type) {\n case 'text': {\n let text = escapeText(node.text)\n const marks = node.marks ?? []\n for (let i = marks.length - 1; i >= 0; i -= 1) {\n const mark = marks[i]\n if (mark !== undefined) text = wrapMark(text, mark, ctx, childPath)\n }\n out += text\n break\n }\n case 'hardBreak':\n out += ' \\n'\n break\n case 'mention':\n lossy(ctx, 'mention', childPath, 'mention 的 id / kind 在 Markdown 中丢失')\n out += `@${escapeText(node.attrs.label)}`\n break\n case 'inlineMath':\n out += `$${node.attrs.tex}$`\n break\n case 'emoji':\n lossy(ctx, 'emoji', childPath, 'emoji 的 name 在 Markdown 中丢失,仅保留 unicode')\n out += node.attrs.unicode\n break\n default:\n break\n }\n })\n return out\n}\n\n/** 序列化表格 */\nconst tableMd = (rows: readonly MtTableRowNode[], ctx: MdContext, path: string): string => {\n const cells: string[][] = rows.map((row, rowIndex) =>\n (row.content ?? []).map((cell, cellIndex) => {\n const attrs = cell.attrs\n if (attrs !== undefined) {\n if ((attrs.colspan ?? 1) !== 1 || (attrs.rowspan ?? 1) !== 1) {\n lossy(\n ctx,\n 'table',\n `${path}[${rowIndex}].content[${cellIndex}]`,\n 'colspan / rowspan 在 GFM 表格中丢失',\n )\n }\n if (attrs.background !== undefined && attrs.background !== null) {\n lossy(\n ctx,\n 'table',\n `${path}[${rowIndex}].content[${cellIndex}]`,\n '单元格背景色在 GFM 表格中丢失',\n )\n }\n }\n return (cell.content ?? [])\n .map((block) => blockMd(block, ctx, `${path}[${rowIndex}]`, 0))\n .join(' ')\n }),\n )\n\n if (cells.length === 0) return ''\n const width = cells.reduce((max, row) => Math.max(max, row.length), 0)\n const pad = (row: string[]): string[] => {\n const copy = [...row]\n while (copy.length < width) copy.push('')\n return copy\n }\n const header = pad(cells[0] ?? [])\n const separator = header.map(() => '---')\n const body = cells.slice(1).map((row) => pad(row))\n /**\n * 单元格内容在生成时已由 `inlineMd` → `escapeText` 完成转义\n *(`|` 属 `SPECIAL_CHARS`),此处**不得**二次转义,否则会产出 `\\\\|`。\n * 分隔行是表格语法本身而非用户内容,同样必须原样输出。\n */\n const toLine = (row: string[]): string => `| ${row.join(' | ')} |`\n return [toLine(header), toLine(separator), ...body.map(toLine)].join('\\n')\n}\n\n/** 序列化单个块级节点 */\nconst blockMd = (node: MtBlockNode, ctx: MdContext, path: string, depth: number): string => {\n const pad = ctx.indent.repeat(depth)\n\n switch (node.type) {\n case 'paragraph':\n return pad + inlineMd(node.content, ctx, `${path}.content`)\n case 'heading':\n return `${pad}${'#'.repeat(node.attrs.level)} ${inlineMd(node.content, ctx, `${path}.content`)}`\n case 'blockquote': {\n const inner = (node.content ?? [])\n .map((child, index) => blockMd(child, ctx, `${path}.content[${index}]`, 0))\n .join('\\n\\n')\n return inner\n .split('\\n')\n .map((line) => `${pad}> ${line}`)\n .join('\\n')\n }\n case 'bulletList':\n case 'orderedList':\n case 'taskList': {\n const lines: string[] = []\n const items = node.content ?? []\n if (\n node.type === 'orderedList' &&\n node.attrs.type !== undefined &&\n node.attrs.type !== null\n ) {\n lossy(ctx, 'orderedList', path, `编号样式 type=\"${node.attrs.type}\" 在 Markdown 中丢失`)\n }\n const start = node.type === 'orderedList' ? (node.attrs.start ?? 1) : 1\n for (let index = 0; index < items.length; index += 1) {\n const item = items[index]\n if (item === undefined) continue\n const itemPath = `${path}.content[${index}]`\n let marker = '- '\n if (node.type === 'taskList') {\n marker = item.type === 'taskItem' && item.attrs.checked === true ? '- [x] ' : '- [ ] '\n } else if (node.type === 'orderedList') {\n marker = `${start + index}. `\n }\n const body = item.content ?? []\n const head = body[0]\n const headText =\n head !== undefined && head.type === 'paragraph'\n ? inlineMd(head.content, ctx, `${itemPath}.content[0].content`)\n : ''\n lines.push(pad + marker + headText)\n for (let i = 1; i < body.length; i += 1) {\n const child = body[i]\n if (child !== undefined)\n lines.push(blockMd(child, ctx, `${itemPath}.content[${i}]`, depth + 1))\n }\n }\n return lines.join('\\n')\n }\n case 'codeBlock': {\n if (node.attrs.showLineNumbers === true)\n lossy(ctx, 'codeBlock', path, 'showLineNumbers 在 Markdown 中丢失')\n const language = node.attrs.language ?? ''\n const text = (node.content ?? []).map((item) => item.text).join('')\n return `${pad}\\`\\`\\`${language}\\n${text}\\n\\`\\`\\``\n }\n case 'horizontalRule':\n return `${pad}---`\n case 'image':\n if (node.attrs.width != null || node.attrs.height != null || node.attrs.align != null) {\n lossy(ctx, 'image', path, 'width / height / align 在 Markdown 中丢失')\n }\n return `${pad}`\n case 'video':\n lossy(ctx, 'video', path, 'video 在 Markdown 中降级为普通链接,语义丢失')\n return `${pad}[视频](${node.attrs.src})`\n case 'audio':\n lossy(ctx, 'audio', path, 'audio 在 Markdown 中降级为普通链接,语义丢失')\n return `${pad}[音频](${node.attrs.src})`\n case 'attachment':\n if (node.attrs.size != null || node.attrs.mime != null) {\n lossy(ctx, 'attachment', path, 'size / mime 在 Markdown 中丢失')\n }\n return `${pad}[${escapeText(node.attrs.name)}](${node.attrs.src})`\n case 'mathBlock':\n return `${pad}$$\\n${node.attrs.tex}\\n$$`\n case 'table':\n return (node.content ?? []).length > 0 ? tableMd(node.content ?? [], ctx, path) : ''\n default:\n return ''\n }\n}\n\n/**\n * 把文档序列化为 Markdown。\n *\n * @param doc 文档\n * @param options 序列化选项(`indent` 为缩进宽度,缺省 2)\n * @returns 序列化结果\n *\n * @example\n * ```ts\n * const { output, report } = serializeDocumentToMarkdown(doc, { indent: 2 })\n * ```\n */\nexport const serializeDocumentToMarkdown = (\n doc: MtDocument,\n options: { indent?: number } = {},\n): MtSerializeResult => {\n const ctx: MdContext = {\n degradations: [],\n indent: ' '.repeat(Math.max(0, options.indent ?? 2)),\n }\n\n let idCount = 0\n walkTree(doc.content, ({ node }) => {\n const attrs = node.attrs\n if (isRecord(attrs) && typeof attrs.id === 'string') idCount += 1\n })\n if (idCount > 0) {\n lossy(ctx, 'doc', 'content', `Markdown 不保留块级节点 ID(共 ${idCount} 个)`)\n }\n\n const output = doc.content\n .map((node, index) => blockMd(node, ctx, `content[${index}]`, 0))\n .filter((part) => part.length > 0)\n .join('\\n\\n')\n\n const { nodeCount, maxDepth } = computeTreeStats(doc.content)\n return {\n output,\n report: ctx.degradations,\n stats: { nodeCount, maxDepth, bytes: utf8ByteLength(output) },\n }\n}\n","/**\n * 小程序序列化的共享上下文与节点原语(`docs/document-model.md` §9)。\n *\n * 单独成模块的原因有两个:\n * 1. 双通道(ADR-0005)的差异只在「标签名 + 属性写法」上,两处必须成对演进而非各写一份;\n * 2. 主题值必须在此处解析为字面量(§9.3:小程序 `style` 不参与宿主 CSS 变量继承)。\n */\n\nimport type { MtMiniMode, MtMiniPlatform } from '../types/base'\nimport type { MtMark } from '../types/marks'\nimport type { MtDegradationEntry } from '../types/report'\nimport type { MtMiniElementNode, MtMiniNode, MtMiniTextNode, MtPreRenderedIndex } from '../types/io'\n\n/** 序列化上下文 */\nexport interface MiniContext {\n platform: MtMiniPlatform\n mode: MtMiniMode\n themeTokens: Record<string, string>\n preRendered: MtPreRenderedIndex\n degradations: MtDegradationEntry[]\n}\n\n/** 构造小程序元素节点(空 attrs 与空 children 一律省略,减小体积) */\nexport const el = (\n name: string,\n attrs: Record<string, string> | undefined,\n children: MtMiniNode[] | undefined,\n): MtMiniElementNode => ({\n type: 'node',\n name,\n ...(attrs !== undefined && Object.keys(attrs).length > 0 ? { attrs } : {}),\n ...(children !== undefined && children.length > 0 ? { children } : {}),\n})\n\n/** 构造小程序文本节点 */\nexport const txt = (value: string): MtMiniTextNode => ({ type: 'text', text: value })\n\n/** 单标签的三种身份 */\nexport interface MiniTagSpec {\n /** `rich-text` 通道标签名 */\n rich: string\n /** 组件通道标签名 */\n component: string\n /** 组件通道 `class` */\n className: string\n}\n\n/**\n * 解析主题字面量(§9.3:小程序不可用 CSS 变量)。\n *\n * @param ctx 上下文\n * @param token CSS 变量名\n * @param fallback 兜底字面量\n * @returns 字面量值\n */\nexport const themeValue = (ctx: MiniContext, token: string, fallback: string): string =>\n ctx.themeTokens[token] ?? fallback\n\n/**\n * 按通道构造元素。\n *\n * @param ctx 上下文\n * @param spec 标签身份\n * @param style 内联样式\n * @param extra 额外属性(白名单内)\n * @param children 子节点\n * @returns 元素节点\n */\nexport const makeElement = (\n ctx: MiniContext,\n spec: MiniTagSpec,\n style: string,\n extra: Record<string, string>,\n children: MtMiniNode[],\n): MtMiniElementNode => {\n const attrs: Record<string, string> = {}\n // 节点自身的属性在**两个通道都要输出**:组件通道下它们由原生组件消费\n // (`<image src>` / `<video src poster>` / `<view data-src>`),\n // rich-text 通道下则由白名单放行。\n //\n // 这里曾只在 richText 通道输出 —— 后果是**默认的组件通道下图片没有 `src`**、\n // 视频没有 `poster`、有序列表没有 `start`。由于组件通道才是默认通道,\n // 这等于「小程序端图片根本不显示」,且既有断言只检查了 name 与 class,全部漏过。\n for (const key of Object.keys(extra)) attrs[key] = extra[key] ?? ''\n // 组件通道用 `class` 作为组件映射键;rich-text 通道不使用 class(§9.3)\n if (ctx.mode !== 'richText') attrs.class = spec.className\n if (style.length > 0) attrs.style = style\n return el(ctx.mode === 'richText' ? spec.rich : spec.component, attrs, children)\n}\n\n/** 标记 → 内联样式声明数组 */\nexport const markDeclarations = (mark: MtMark, ctx: MiniContext): string[] => {\n switch (mark.type) {\n case 'bold':\n return ['font-weight: bold']\n case 'italic':\n return ['font-style: italic']\n case 'underline':\n return ['text-decoration: underline']\n case 'strike':\n return ['text-decoration: line-through']\n case 'code':\n return [\n `font-family: ${themeValue(ctx, '--mt-font-family-mono', 'ui-monospace, monospace')}`,\n `background-color: ${themeValue(ctx, '--mt-color-code-bg', '#f5f5f5')}`,\n 'padding: 0 2px',\n 'border-radius: 2px',\n ]\n case 'link':\n return [\n `color: ${themeValue(ctx, '--mt-color-link', '#1a6dff')}`,\n 'text-decoration: underline',\n ]\n case 'highlight':\n return [`background-color: ${mark.attrs.color}`]\n case 'textStyle': {\n const out: string[] = []\n if (mark.attrs.fontFamily != null) out.push(`font-family: ${mark.attrs.fontFamily}`)\n if (mark.attrs.fontSize != null) out.push(`font-size: ${mark.attrs.fontSize}`)\n if (mark.attrs.color != null) out.push(`color: ${mark.attrs.color}`)\n if (mark.attrs.backgroundColor != null)\n out.push(`background-color: ${mark.attrs.backgroundColor}`)\n if (mark.attrs.lineHeight != null) out.push(`line-height: ${mark.attrs.lineHeight}`)\n if (mark.attrs.letterSpacing != null) out.push(`letter-spacing: ${mark.attrs.letterSpacing}`)\n return out\n }\n case 'subscript':\n return ['font-size: 0.75em', 'vertical-align: sub']\n case 'superscript':\n return ['font-size: 0.75em', 'vertical-align: super']\n default:\n return []\n }\n}\n\n/**\n * 把标记数组摊平为一行内联样式。\n *\n * @param marks 标记数组\n * @param ctx 上下文\n * @returns 内联样式;无标记时为空串\n */\nexport const marksToStyle = (marks: readonly MtMark[] | undefined, ctx: MiniContext): string => {\n if (!marks || marks.length === 0) return ''\n const declarations: string[] = []\n for (const mark of marks) declarations.push(...markDeclarations(mark, ctx))\n return declarations.join('; ')\n}\n\n/**\n * 输出一段带样式的文本。\n *\n * @param value 文本\n * @param style 内联样式\n * @param ctx 上下文\n * @returns 文本节点(带样式时套一层元素)\n */\nexport const emitText = (value: string, style: string, ctx: MiniContext): MtMiniNode => {\n if (ctx.mode === 'richText') {\n return style.length === 0 ? txt(value) : el('span', { style }, [txt(value)])\n }\n return el('text', style.length === 0 ? undefined : { style }, [txt(value)])\n}\n","/**\n * 小程序序列化的行内节点通道(`docs/document-model.md` §9.2)。\n *\n * 行内是双通道差异最集中的地方:`richText` 只能靠 `span style` 承载标记,\n * 组件通道则把文本挂在 `<text>` 上、由组件自己读 `style`。\n * 这类差异全部收在 `mini-context.ts` 的 `emitText` / `marksToStyle` 里,本模块只负责\n * 「按节点类型分发 + 写降级报告」。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport type { MtInlineNode } from '../types/nodes'\nimport type { MtMiniNode } from '../types/io'\nimport { el, emitText, marksToStyle, themeValue, txt } from './mini-context'\nimport type { MiniContext } from './mini-context'\n\n/**\n * 序列化行内节点序列。\n *\n * @param nodes 行内节点\n * @param ctx 序列化上下文\n * @param path 定位路径\n * @returns 小程序 nodes 数组\n */\nexport const inlineNodes = (\n nodes: readonly MtInlineNode[] | undefined,\n ctx: MiniContext,\n path: string,\n): MtMiniNode[] => {\n const out: MtMiniNode[] = []\n if (!nodes) return out\n\n nodes.forEach((node, index) => {\n const childPath = `${path}[${index}]`\n switch (node.type) {\n case 'text':\n out.push(emitText(node.text, marksToStyle(node.marks, ctx), ctx))\n break\n case 'hardBreak':\n out.push(ctx.mode === 'richText' ? el('br', undefined, undefined) : emitText('\\n', '', ctx))\n break\n case 'mention':\n out.push(\n emitText(\n `@${node.attrs.label}`,\n `color: ${themeValue(ctx, '--mt-color-link', '#1a6dff')}`,\n ctx,\n ),\n )\n break\n case 'inlineMath': {\n const svg = node.attrs.svg ?? ctx.preRendered.math?.[node.attrs.tex]\n if (svg === undefined || svg === null || svg.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MISSING_PRERENDER,\n 'inlineMath',\n childPath,\n '行内公式缺少预渲染 SVG,已降级为 TeX 文本',\n 'literalized',\n ),\n )\n out.push(emitText(`$${node.attrs.tex}$`, '', ctx))\n break\n }\n if (ctx.mode === 'richText') {\n ctx.degradations.push(\n degrade(\n MT_DEG.UNSUPPORTED_NODE,\n 'inlineMath',\n childPath,\n 'rich-text 通道无法内联 SVG,已降级为 TeX 文本',\n 'replaced',\n ),\n )\n out.push(emitText(`$${node.attrs.tex}$`, '', ctx))\n break\n }\n // 组件通道:SVG 以文本载荷挂在 mt-math-inline 下,由 renderer-mini 渲染\n out.push(el('view', { class: 'mt-math-inline' }, [txt(svg)]))\n break\n }\n case 'emoji':\n out.push(emitText(node.attrs.unicode, '', ctx))\n break\n default:\n break\n }\n })\n\n return out\n}\n","/**\n * `MtDocument` → 小程序 `nodes`(`docs/document-model.md` §9)。\n *\n * 目标端**只读渲染**(ADR-0004),双通道(ADR-0005):\n * - `mode: 'component'`(默认):递归自定义组件通道,输出 `view` / `text`,\n * 以 `class`(`mt-p` / `mt-h1` …)作为组件映射键,缩进与序号由组件自绘;\n * - `mode: 'richText'`:降级兜底通道,输出微信 `rich-text` 白名单标签,\n * 缩进**必须用 `style` 模拟**(白名单无 `ul` 的嵌套缩进能力)。\n *\n * 两条硬约束:\n * 1. 小程序端 `style` 不参与宿主 CSS 变量继承(§9.3),主题值必须在序列化时\n * 解析为**字面量**,故 `themeTokens` 为 `mini` 目标的必填项;\n * 2. 小程序**不支持 `id`**(§9.1),本通道不输出节点 ID 并写降级报告。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport type { MtBlockNode, MtTableRowNode } from '../types/nodes'\nimport type { MtDocument } from '../types/document'\nimport type { MtMiniMode, MtMiniPlatform } from '../types/base'\nimport type {\n MtMiniElementNode,\n MtMiniNode,\n MtPreRenderedIndex,\n MtSerializeResult,\n} from '../types/io'\nimport { escapeHtml } from '../utils/escape'\nimport { fnv1a } from '../utils/hash'\nimport { MT_COLWIDTH_UNIT, MT_INDENT_UNIT, formatCssLength } from '../utils/style-units'\nimport { computeTreeStats, utf8ByteLength, walkTree } from '../utils/tree'\nimport { isRecord } from '../utils/guards'\nimport { el, emitText, makeElement, themeValue, txt } from './mini-context'\nimport type { MiniContext, MiniTagSpec } from './mini-context'\nimport { inlineNodes } from './mini-inline'\n\n/** 序列化表格行 */\nconst miniRow = (row: MtTableRowNode, ctx: MiniContext, path: string): MtMiniElementNode => {\n const cells: MtMiniNode[] = []\n ;(row.content ?? []).forEach((cell, index) => {\n const extra: Record<string, string> = {}\n // span 的值可能是 `null`(ProseMirror 侧可选属性的 `default`),\n // 只判 `undefined` 会把它写成字面量 `\"null\"`(ADR-0023)\n if (cell.attrs?.colspan !== undefined && cell.attrs.colspan !== null) {\n extra.colspan = String(cell.attrs.colspan)\n }\n if (cell.attrs?.rowspan !== undefined && cell.attrs.rowspan !== null) {\n extra.rowspan = String(cell.attrs.rowspan)\n }\n if (cell.attrs?.background != null) extra.style = `background-color: ${cell.attrs.background}`\n const spec: MiniTagSpec =\n cell.type === 'tableHeader'\n ? { rich: 'th', component: 'view', className: 'mt-th' }\n : { rich: 'td', component: 'view', className: 'mt-td' }\n cells.push(\n makeElement(\n ctx,\n spec,\n extra.style ?? '',\n extra,\n miniBlocks(cell.content ?? [], ctx, `${path}[${index}]`),\n ),\n )\n })\n // 必须走 `makeElement` 而不是 `el`:组件通道的合法标签只有 view / text / image /\n // video / audio,`<tr>` 在小程序里不是组件标签 —— 递归组件会拿到未知标签。\n return makeElement(ctx, { rich: 'tr', component: 'view', className: 'mt-tr' }, '', {}, cells)\n}\n\n/** 序列化块级节点序列 */\nconst miniBlocks = (\n nodes: readonly MtBlockNode[],\n ctx: MiniContext,\n path: string,\n): MtMiniNode[] => {\n const out: MtMiniNode[] = []\n nodes.forEach((node, index) => {\n out.push(...miniBlock(node, ctx, `${path}[${index}]`))\n })\n return out\n}\n\n/** 列表项内容(首段作为行内文本,其余块级内容下沉) */\nconst listItemChildren = (\n item: { content?: MtBlockNode[] },\n ctx: MiniContext,\n path: string,\n): MtMiniNode[] => {\n const body = item.content ?? []\n const head = body[0]\n const headNodes =\n head !== undefined && head.type === 'paragraph'\n ? inlineNodes(head.content, ctx, `${path}.content[0].content`)\n : []\n const rest: MtMiniNode[] = []\n for (let i = 1; i < body.length; i += 1) {\n const child = body[i]\n if (child !== undefined) rest.push(...miniBlock(child, ctx, `${path}.content[${i}]`))\n }\n return [...headNodes, ...rest]\n}\n\n/** 序列化单个块级节点(可能产出多个节点) */\nconst miniBlock = (node: MtBlockNode, ctx: MiniContext, path: string): MtMiniNode[] => {\n switch (node.type) {\n case 'paragraph': {\n const style: string[] = []\n if (node.attrs.textAlign != null) style.push(`text-align: ${node.attrs.textAlign}`)\n if (node.attrs.lineHeight != null) style.push(`line-height: ${node.attrs.lineHeight}`)\n if (node.attrs.indent != null)\n style.push(`padding-left: ${formatCssLength(node.attrs.indent, MT_INDENT_UNIT)}`)\n return [\n makeElement(\n ctx,\n { rich: 'p', component: 'view', className: 'mt-p' },\n style.join('; '),\n {},\n inlineNodes(node.content, ctx, `${path}.content`),\n ),\n ]\n }\n case 'heading': {\n const style: string[] = []\n if (node.attrs.textAlign != null) style.push(`text-align: ${node.attrs.textAlign}`)\n if (node.attrs.lineHeight != null) style.push(`line-height: ${node.attrs.lineHeight}`)\n return [\n makeElement(\n ctx,\n { rich: `h${node.attrs.level}`, component: 'view', className: `mt-h${node.attrs.level}` },\n style.join('; '),\n {},\n inlineNodes(node.content, ctx, `${path}.content`),\n ),\n ]\n }\n case 'blockquote':\n return [\n makeElement(\n ctx,\n { rich: 'blockquote', component: 'view', className: 'mt-blockquote' },\n `border-left: 3px solid ${themeValue(ctx, '--mt-color-border', '#e0e0e0')}; padding-left: 12px`,\n {},\n miniBlocks(node.content ?? [], ctx, `${path}.content`),\n ),\n ]\n case 'bulletList':\n case 'orderedList': {\n const ordered = node.type === 'orderedList'\n const extra: Record<string, string> = {}\n if (ordered && node.attrs.start != null) extra.start = String(node.attrs.start)\n if (ordered && node.attrs.type != null) extra.type = node.attrs.type\n if (ordered && node.attrs.type != null && ctx.mode === 'component') {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'orderedList',\n path,\n `编号样式 type=\"${node.attrs.type}\" 在组件通道由组件自绘,需渲染器实现`,\n 'dropped',\n ),\n )\n }\n const items = (node.content ?? []).map((item, index) =>\n makeElement(\n ctx,\n { rich: 'li', component: 'view', className: 'mt-li' },\n '',\n {},\n listItemChildren(item, ctx, `${path}.content[${index}]`),\n ),\n )\n return [\n makeElement(\n ctx,\n ordered\n ? { rich: 'ol', component: 'view', className: 'mt-ol' }\n : { rich: 'ul', component: 'view', className: 'mt-ul' },\n '',\n extra,\n items,\n ),\n ]\n }\n case 'taskList': {\n const items = (node.content ?? []).map((item, index) => {\n const mark = item.attrs.checked === true ? '☑' : '☐'\n return makeElement(\n ctx,\n { rich: 'div', component: 'view', className: 'mt-task-item' },\n '',\n {},\n [\n emitText(`${mark} `, '', ctx),\n ...listItemChildren(item, ctx, `${path}.content[${index}]`),\n ],\n )\n })\n return [\n makeElement(\n ctx,\n { rich: 'div', component: 'view', className: 'mt-task-list' },\n '',\n {},\n items,\n ),\n ]\n }\n case 'codeBlock': {\n const text = (node.content ?? []).map((item) => item.text).join('')\n const highlight = ctx.preRendered.code?.[fnv1a(text)] ?? ctx.preRendered.code?.[text]\n if (highlight === undefined && text.length > 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MISSING_PRERENDER,\n 'codeBlock',\n path,\n '代码块缺少预渲染高亮产物,已按纯文本输出',\n 'literalized',\n ),\n )\n }\n const body = highlight ?? escapeHtml(text)\n const style = `background-color: ${themeValue(ctx, '--mt-color-code-bg', '#f5f5f5')}; padding: 8px; overflow-x: auto`\n if (ctx.mode === 'richText') {\n return [el('pre', { style }, [el('code', undefined, [txt(body)])])]\n }\n return [\n makeElement(ctx, { rich: 'pre', component: 'view', className: 'mt-pre' }, style, {}, [\n emitText(\n text,\n `font-family: ${themeValue(ctx, '--mt-font-family-mono', 'ui-monospace, monospace')}`,\n ctx,\n ),\n ]),\n ]\n }\n case 'horizontalRule':\n return [\n makeElement(\n ctx,\n { rich: 'hr', component: 'view', className: 'mt-hr' },\n `border-top: 1px solid ${themeValue(ctx, '--mt-color-border', '#e0e0e0')}`,\n {},\n [],\n ),\n ]\n case 'image': {\n const extra: Record<string, string> = { src: node.attrs.src }\n if (node.attrs.alt != null) extra.alt = node.attrs.alt\n if (node.attrs.width != null) extra.width = String(node.attrs.width)\n if (node.attrs.height != null) extra.height = String(node.attrs.height)\n if (node.attrs.align != null) {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'image',\n path,\n `align=\"${node.attrs.align}\" 在小程序端由宿主样式决定,未输出`,\n 'dropped',\n ),\n )\n }\n return [\n makeElement(\n ctx,\n { rich: 'img', component: 'image', className: 'mt-img' },\n 'max-width: 100%',\n extra,\n [],\n ),\n ]\n }\n case 'video':\n case 'audio': {\n if (ctx.mode === 'richText') {\n ctx.degradations.push(\n degrade(\n MT_DEG.UNSUPPORTED_NODE,\n node.type,\n path,\n `${node.type} 在 rich-text 通道不可用,已降级为文本占位`,\n 'replaced',\n ),\n )\n return [emitText(`[${node.type === 'video' ? '视频' : '音频'}]`, '', ctx)]\n }\n const extra: Record<string, string> = { src: node.attrs.src }\n if (node.type === 'video' && node.attrs.poster != null) extra.poster = node.attrs.poster\n return [\n makeElement(\n ctx,\n { rich: node.type, component: node.type, className: `mt-${node.type}` },\n '',\n extra,\n [],\n ),\n ]\n }\n case 'attachment':\n return [\n makeElement(\n ctx,\n { rich: 'div', component: 'view', className: 'mt-attachment' },\n `color: ${themeValue(ctx, '--mt-color-link', '#1a6dff')}`,\n { 'data-src': node.attrs.src },\n [emitText(node.attrs.name, '', ctx)],\n ),\n ]\n case 'mathBlock': {\n const svg = node.attrs.svg ?? ctx.preRendered.math?.[node.attrs.tex]\n if (svg === undefined || svg === null || svg.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MISSING_PRERENDER,\n 'mathBlock',\n path,\n '公式缺少预渲染 SVG,已降级为 TeX 文本',\n 'literalized',\n ),\n )\n return [emitText(`$$${node.attrs.tex}$$`, '', ctx)]\n }\n if (ctx.mode === 'richText') {\n ctx.degradations.push(\n degrade(\n MT_DEG.UNSUPPORTED_NODE,\n 'mathBlock',\n path,\n 'rich-text 通道无法内联 SVG,已降级为 TeX 文本',\n 'replaced',\n ),\n )\n return [emitText(`$$${node.attrs.tex}$$`, '', ctx)]\n }\n // 组件通道:SVG 以文本载荷挂在 mt-math 下,由 renderer-mini 渲染\n return [el('view', { class: 'mt-math' }, [txt(svg)])]\n }\n case 'table': {\n const widths = node.attrs.colWidths\n const colgroup: MtMiniNode[] =\n ctx.mode === 'richText' && widths && widths.length > 0\n ? [\n el(\n 'colgroup',\n undefined,\n widths.map((width) =>\n el('col', { width: formatCssLength(width, MT_COLWIDTH_UNIT) }, undefined),\n ),\n ),\n ]\n : []\n const rows = (node.content ?? []).map((row, index) =>\n miniRow(row, ctx, `${path}.content[${index}]`),\n )\n return [\n makeElement(\n ctx,\n { rich: 'table', component: 'view', className: 'mt-table' },\n 'width: 100%',\n {},\n [...colgroup, ...rows],\n ),\n ]\n }\n default:\n return []\n }\n}\n\n/**\n * 把文档序列化为小程序 `nodes`。\n *\n * @param doc 文档\n * @param options 序列化选项(`themeTokens` 必填)\n * @returns 序列化结果,`output` 为 `MtMiniNode[]`\n *\n * @example\n * ```ts\n * const { output } = serializeDocumentToMini(doc, {\n * mode: 'richText',\n * platform: 'wechat',\n * themeTokens: { '--mt-color-link': '#1a6dff' },\n * })\n * ```\n */\nexport const serializeDocumentToMini = (\n doc: MtDocument,\n options: {\n mode?: MtMiniMode\n platform?: MtMiniPlatform\n themeTokens?: Record<string, string>\n preRendered?: MtPreRenderedIndex\n } = {},\n): MtSerializeResult<MtMiniNode[]> => {\n const ctx: MiniContext = {\n platform: options.platform ?? 'wechat',\n mode: options.mode ?? 'component',\n themeTokens: options.themeTokens ?? {},\n preRendered: options.preRendered ?? {},\n degradations: [],\n }\n\n let idCount = 0\n walkTree(doc.content, ({ node }) => {\n const attrs = node.attrs\n if (isRecord(attrs) && typeof attrs.id === 'string') idCount += 1\n })\n if (idCount > 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'doc',\n 'content',\n `小程序不支持 id,已丢弃 ${idCount} 个块级节点 ID`,\n 'dropped',\n ),\n )\n }\n\n const output = miniBlocks(doc.content, ctx, 'content')\n const { nodeCount, maxDepth } = computeTreeStats(doc.content)\n const bytes = utf8ByteLength(JSON.stringify(output))\n\n return {\n output,\n report: ctx.degradations,\n stats: { nodeCount, maxDepth, bytes },\n }\n}\n","/**\n * `MtDocument` → 纯文本。\n *\n * 目标端没有任何富文本能力,因此**全部语义损失**;本序列化器只保证\n * 文本内容与阅读顺序不丢失,用于搜索索引、摘要与剪贴板降级。\n */\n\nimport type { MtBlockNode, MtInlineNode, MtTableRowNode } from '../types/nodes'\nimport type { MtDocument } from '../types/document'\nimport type { MtSerializeResult } from '../types/io'\nimport { computeTreeStats, utf8ByteLength } from '../utils/tree'\n\n/** 列表缩进单位 */\nconst INDENT = ' '\n\n/**\n * 抽取行内纯文本。\n *\n * @param nodes 行内节点\n * @returns 文本\n */\nconst inlineText = (nodes: readonly MtInlineNode[] | undefined): string => {\n if (!nodes) return ''\n let out = ''\n for (const node of nodes) {\n switch (node.type) {\n case 'text':\n out += node.text\n break\n case 'hardBreak':\n out += '\\n'\n break\n case 'mention':\n out += `@${node.attrs.label}`\n break\n case 'inlineMath':\n out += node.attrs.tex\n break\n case 'emoji':\n out += node.attrs.unicode\n break\n default:\n break\n }\n }\n return out\n}\n\n/** 把表格行渲染为制表符分隔的一行 */\nconst rowText = (row: MtTableRowNode): string =>\n (row.content ?? [])\n .map((cell) =>\n (cell.content ?? [])\n .map((block) => blockText(block, 0))\n .join(' ')\n .trim(),\n )\n .join('\\t')\n\n/** 渲染单个块为文本(可能多行) */\nconst blockText = (node: MtBlockNode, depth: number): string => {\n const pad = INDENT.repeat(depth)\n switch (node.type) {\n case 'paragraph':\n case 'heading':\n return pad + inlineText(node.content)\n case 'blockquote':\n return (node.content ?? [])\n .map((child) => blockText(child, depth))\n .join('\\n')\n .split('\\n')\n .map((line) => `${pad}> ${line}`)\n .join('\\n')\n case 'bulletList':\n case 'orderedList':\n case 'taskList': {\n const lines: string[] = []\n const items = node.content ?? []\n for (let index = 0; index < items.length; index += 1) {\n const item = items[index]\n if (item === undefined) continue\n let marker = '- '\n if (node.type === 'taskList') {\n marker = item.type === 'taskItem' && item.attrs.checked === true ? '- [x] ' : '- [ ] '\n } else if (node.type === 'orderedList') {\n marker = `${index + 1}. `\n }\n const body = item.content ?? []\n const head = body[0]\n const headText =\n head !== undefined && head.type === 'paragraph' ? inlineText(head.content) : ''\n lines.push(pad + marker + headText)\n for (let i = 1; i < body.length; i += 1) {\n const child = body[i]\n if (child !== undefined) lines.push(blockText(child, depth + 1))\n }\n }\n return lines.join('\\n')\n }\n case 'codeBlock':\n return (node.content ?? []).map((text) => text.text).join('')\n case 'horizontalRule':\n return `${pad}---`\n case 'image':\n return pad + (node.attrs.alt ?? node.attrs.src)\n case 'video':\n return pad + (node.attrs.poster ?? node.attrs.src)\n case 'audio':\n return pad + (node.attrs.title ?? node.attrs.src)\n case 'attachment':\n return pad + node.attrs.name\n case 'mathBlock':\n return pad + node.attrs.tex\n case 'table':\n return (node.content ?? []).map(rowText).join('\\n')\n default:\n return ''\n }\n}\n\n/**\n * 把文档序列化为纯文本。\n *\n * @param doc 文档\n * @returns 序列化结果\n *\n * @example\n * ```ts\n * const { output } = serializeDocumentToText(doc)\n * ```\n */\nexport const serializeDocumentToText = (doc: MtDocument): MtSerializeResult => {\n const output = doc.content.map((node) => blockText(node, 0)).join('\\n\\n')\n const { nodeCount, maxDepth } = computeTreeStats(doc.content)\n return {\n output,\n report: [],\n stats: { nodeCount, maxDepth, bytes: utf8ByteLength(output) },\n }\n}\n","/**\n * serialize 层出口与按 `target` 的统一分发(`docs/document-model.md` §10)。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport type { MtDocument } from '../types/document'\nimport type { MtSerializeOptions, MtSerializeOutput, MtSerializeResult } from '../types/io'\nimport { serializeDocumentToHtml } from './html'\nimport { serializeDocumentToMarkdown } from './markdown'\nimport { serializeDocumentToMini } from './mini'\nimport { serializeDocumentToText } from './text'\n\nexport { serializeBlocks, serializeDocumentToHtml } from './html'\nexport type { MtHtmlSerializeContext } from './html'\nexport { serializeDocumentToMarkdown } from './markdown'\nexport { serializeDocumentToMini } from './mini'\nexport { serializeDocumentToText } from './text'\n\n/**\n * 按 `options.target` 分发到具体序列化器。\n *\n * @param doc 文档\n * @param options 序列化选项\n * @returns 序列化结果\n *\n * @example\n * ```ts\n * const { output, report } = serializeDocument(doc, { target: 'html' })\n * const md = serializeDocument(doc, { target: 'markdown' })\n * const nodes = serializeDocument(doc, { target: 'mini', themeTokens: {} }).output\n * ```\n */\nexport const serializeDocument = (\n doc: MtDocument,\n options: MtSerializeOptions,\n): MtSerializeResult<MtSerializeOutput> => {\n const result = ((): MtSerializeResult<MtSerializeOutput> => {\n switch (options.target) {\n case 'markdown':\n return serializeDocumentToMarkdown(doc, { indent: options.indent })\n case 'text':\n return serializeDocumentToText(doc)\n case 'mini': {\n const mini = serializeDocumentToMini(doc, {\n mode: options.mode,\n platform: options.platform,\n themeTokens: options.themeTokens,\n preRendered: options.preRendered,\n })\n if (options.themeTokens === undefined) {\n mini.report.unshift(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'doc',\n 'content',\n '未提供 themeTokens,主题值已退回内置兜底字面量,小程序端视觉可能不一致',\n 'literalized',\n ),\n )\n }\n return mini\n }\n default:\n return serializeDocumentToHtml(doc, {\n preRendered: options.preRendered,\n indent: options.indent,\n })\n }\n })()\n\n return options.report === false ? { ...result, report: [] } : result\n}\n","/**\n * 表格结构校验(`docs/document-model.md` §4.4)。\n *\n * 三条规则(码 `MT_DOC_007`):\n * 1. 同一行内所有 `colspan` 之和必须相等(矩形表);\n * 2. `rowspan` 不得越出表格底部边界;\n * 3. 不存在被 `rowspan` 覆盖的位置上仍出现单元格的重叠冲突。\n */\n\nimport { MT_DOC } from '../report/codes'\nimport { issue } from '../report'\nimport type { MtValidationIssue } from '../types/report'\nimport { isRecord } from '../utils/guards'\n\n/** 表格校验上下文 */\nexport interface MtTableContext {\n issues: MtValidationIssue[]\n fix: boolean\n}\n\n/** 读取单元格的跨度 */\nconst readSpan = (attrs: Record<string, unknown>, name: string): number => {\n const value = attrs[name]\n if (typeof value !== 'number' || !Number.isFinite(value)) return 1\n return Math.max(1, Math.trunc(value))\n}\n\n/**\n * 从 `start` 起找到该行第一个未被上方 `rowspan` 占用的列。\n *\n * 规则 1 以 `colspan` 总和判定矩形,因此后续行可省略被跨行单元格覆盖的位置\n * (如首列被 `rowspan=2` 占用时,次行只写一个 `td`),此处负责把这些位置跳过。\n *\n * @param grid 占用栅格\n * @param row 当前行号\n * @param start 起始列号\n * @returns 首个空闲列号(该行未创建时即 `start`)\n */\nconst firstFreeColumn = (grid: readonly boolean[][], row: number, start: number): number => {\n const line = grid[row]\n if (line === undefined) return start\n let column = start\n while (line[column] === true) column += 1\n return column\n}\n\n/**\n * 校验单个表格节点。\n *\n * @param raw 表格节点(宽松输入)\n * @param path 文档定位路径\n * @param ctx 校验上下文(`fix: true` 时会就地修正 `rowspan`)\n *\n * @example\n * ```ts\n * validateTable(tableNode, 'content[0]', { issues: [], fix: true })\n * ```\n */\nexport const validateTable = (\n raw: Record<string, unknown>,\n path: string,\n ctx: MtTableContext,\n): void => {\n const rows = Array.isArray(raw.content) ? raw.content : []\n if (rows.length === 0) return\n\n /** 占用栅格:`grid[row][col] === true` 表示该位置已被覆盖 */\n const grid: boolean[][] = []\n const sums: number[] = []\n\n rows.forEach((row, rowIndex) => {\n const rowPath = `${path}.content[${rowIndex}]`\n const cells = isRecord(row) && Array.isArray(row.content) ? row.content : []\n let column = 0\n\n cells.forEach((cell, cellIndex) => {\n const cellPath = `${rowPath}.content[${cellIndex}]`\n const attrs = isRecord(cell) && isRecord(cell.attrs) ? cell.attrs : {}\n const colspan = readSpan(attrs, 'colspan')\n const rowspan = readSpan(attrs, 'rowspan')\n\n // 规则 2:越界\n if (rowIndex + rowspan > rows.length) {\n const clamped = Math.max(1, rows.length - rowIndex)\n ctx.issues.push(\n issue(\n MT_DOC.BAD_TABLE,\n 'error',\n cellPath,\n `rowspan=${rowspan} 越出表格底部(共 ${rows.length} 行)`,\n ctx.fix ? `已修正为 ${clamped}` : undefined,\n ),\n )\n if (ctx.fix) attrs.rowspan = clamped\n }\n\n const effectiveRowspan = ctx.fix ? readSpan(attrs, 'rowspan') : rowspan\n\n // 跳过被上方 rowspan 占用的列,使跨行表可省略占位单元格\n column = firstFreeColumn(grid, rowIndex, column)\n\n // 规则 3:重叠(单元格自身的跨度范围内包含已占用位置)\n for (let r = rowIndex; r < Math.min(rows.length, rowIndex + effectiveRowspan); r += 1) {\n const line = grid[r] ?? []\n for (let c = column; c < column + colspan; c += 1) {\n if (line[c] === true) {\n ctx.issues.push(\n issue(\n MT_DOC.BAD_TABLE,\n 'error',\n cellPath,\n `位置 (行${r}, 列${c}) 已被 rowspan 覆盖,出现单元格重叠`,\n ),\n )\n }\n line[c] = true\n }\n grid[r] = line\n }\n\n column += colspan\n })\n\n sums.push(column)\n })\n\n // 规则 1:各行 colspan 总和一致\n const first = sums[0]\n if (first !== undefined) {\n sums.forEach((sum, rowIndex) => {\n if (sum !== first) {\n ctx.issues.push(\n issue(\n MT_DOC.BAD_TABLE,\n 'error',\n `${path}.content[${rowIndex}]`,\n `该行 colspan 总和为 ${sum},与首行的 ${first} 不一致(非矩形表)`,\n ),\n )\n }\n })\n }\n}\n","/**\n * 结构校验(`docs/document-model.md` §11)。\n *\n * `validateDocument()` 返回 `MtValidationIssue[]`:`error` 级问题会**阻断**序列化,\n * `warning` 仅记录。默认开启 `fix`,对可自动修复的问题就地修正(如重复 ID 重发、\n * 互斥标记归一、`rowspan` 越界钳制),并把处理方式写进 `handled` 字段。\n *\n * 两类**静默规范化**(不写报告,因为不丢信息):\n * 1. 可选属性的 `null` 归一为**省略**——`null` 与「缺省」在本模型中语义等价,\n * 统一后才能保证跨端往返稳定;\n * 2. 空数组形态的 `content` 归一为省略。\n */\n\nimport { normalizeMarks } from '../normalize'\nimport { MT_DOC } from '../report/codes'\nimport { issue } from '../report'\nimport { contentMatches, getNodeSpec } from '../schema'\nimport { MT_SCHEMA_VERSION } from '../types/base'\nimport type { MtDocument } from '../types/document'\nimport type { MtValidateOptions, MtValidationResult } from '../types/io'\nimport type { MtValidationIssue } from '../types/report'\nimport { createIdGenerator } from '../utils/id'\nimport { isRecord } from '../utils/guards'\nimport { stableStringify } from '../utils/stable-json'\nimport { cloneJson } from '../utils/tree'\nimport { validateTable } from './table'\n\n/** 校验上下文 */\ninterface MtValidateContext {\n issues: MtValidationIssue[]\n fix: boolean\n /** 已出现的块级 ID,用于 `MT_DOC_009` 判重 */\n seenIds: Set<string>\n /** 重发 ID 用 */\n nextId: () => string\n}\n\n/**\n * 删除属性对象中的 `null` 值(就地)。\n *\n * @param attrs 属性对象\n * @returns 是否发生了变更\n */\nconst dropNullAttrs = (attrs: Record<string, unknown>): boolean => {\n let changed = false\n for (const key of Object.keys(attrs)) {\n if (attrs[key] === null) {\n delete attrs[key]\n changed = true\n }\n }\n return changed\n}\n\n/**\n * 校验并(可选)修复文本节点的标记。\n *\n * @param node 文本节点(宽松输入)\n * @param path 定位路径\n * @param ctx 校验上下文\n */\nconst validateMarks = (\n node: Record<string, unknown>,\n path: string,\n ctx: MtValidateContext,\n): void => {\n const raw = node.marks\n if (raw === undefined) return\n\n const before = stableStringify(raw)\n const { marks } = normalizeMarks(raw, {\n path,\n marksAllowed: true,\n degradations: [],\n issues: ctx.issues,\n })\n\n if (before === stableStringify(marks)) return\n if (!ctx.fix) return\n\n if (marks.length > 0) node.marks = marks\n else delete node.marks\n}\n\n/**\n * 校验单个节点(递归)。\n *\n * @param raw 节点(宽松输入)\n * @param path 文档定位路径\n * @param ctx 校验上下文\n * @param parentType 父节点类型(用于 `codeBlock` 内的标记判定)\n */\nconst validateNode = (\n raw: unknown,\n path: string,\n ctx: MtValidateContext,\n parentType?: string,\n): void => {\n if (!isRecord(raw)) {\n ctx.issues.push(issue(MT_DOC.UNKNOWN_NODE, 'error', path, '节点不是对象'))\n return\n }\n\n const type = raw.type\n if (typeof type !== 'string') {\n ctx.issues.push(issue(MT_DOC.UNKNOWN_NODE, 'error', path, '节点缺少 type 字段'))\n return\n }\n\n const spec = getNodeSpec(type)\n if (!spec) {\n ctx.issues.push(\n issue(\n MT_DOC.UNKNOWN_NODE,\n 'error',\n path,\n `未知节点类型 '${type}',请检查 schemaVersion 是否需要迁移`,\n ),\n )\n return\n }\n\n const attrs = isRecord(raw.attrs) ? raw.attrs : {}\n if (isRecord(raw.attrs) && ctx.fix) dropNullAttrs(attrs)\n\n // 必需属性\n for (const name of spec.requiredAttrs) {\n const value = name === 'text' ? raw.text : attrs[name]\n if (value === undefined || (typeof value === 'string' && value.length === 0)) {\n ctx.issues.push(issue(MT_DOC.MISSING_ATTR, 'error', path, `${type} 缺少必填属性 ${name}`))\n }\n }\n\n // 块级 ID:必填 + 唯一\n if (spec.needsId) {\n const id = attrs.id\n if (typeof id !== 'string' || id.length === 0) {\n ctx.issues.push(\n issue(\n MT_DOC.MISSING_ATTR,\n 'error',\n path,\n `${type} 缺少必填属性 id`,\n ctx.fix ? '已补发新 ID' : undefined,\n ),\n )\n if (ctx.fix) attrs.id = ctx.nextId()\n } else if (ctx.seenIds.has(id)) {\n ctx.issues.push(\n issue(\n MT_DOC.DUPLICATE_ID,\n 'error',\n path,\n `节点 ID '${id}' 重复`,\n ctx.fix ? '已重新生成后者' : undefined,\n ),\n )\n if (ctx.fix) attrs.id = ctx.nextId()\n } else {\n ctx.seenIds.add(id)\n }\n }\n\n // 标记\n if (type === 'text') {\n if (parentType === 'codeBlock') {\n if (raw.marks !== undefined) {\n ctx.issues.push(\n issue(\n MT_DOC.MARKS_IN_CODE_BLOCK,\n 'warning',\n path,\n 'codeBlock 内出现 mark',\n ctx.fix ? '已清除 marks' : undefined,\n ),\n )\n if (ctx.fix) delete raw.marks\n }\n } else {\n validateMarks(raw, path, ctx)\n }\n }\n\n // 子节点\n const children = Array.isArray(raw.content) ? raw.content : []\n // `content: []` 归一为省略(空数组与缺省在本模型中语义等价)\n if (children.length === 0 && ctx.fix && Array.isArray(raw.content)) {\n delete raw.content\n }\n\n const childTypes = children.map((child) =>\n isRecord(child) && typeof child.type === 'string' ? child.type : '\\u0000unknown',\n )\n if (!contentMatches(type, childTypes)) {\n ctx.issues.push(\n issue(\n MT_DOC.CONTENT_MISMATCH,\n 'error',\n path,\n `${type} 的 content 为 [${childTypes.join(', ')}],不满足表达式 '${spec.content ?? 'atom(无 content)'}'`,\n ),\n )\n }\n\n children.forEach((child, index) => validateNode(child, `${path}.content[${index}]`, ctx, type))\n\n if (type === 'table') validateTable(raw, path, ctx)\n}\n\n/**\n * 校验文档结构。\n *\n * @param doc 待校验文档\n * @param options 校验选项(默认 `fix: true`)\n * @returns 校验结果;`ok` 为 `false` 时表示存在 `error` 级问题\n *\n * @example\n * ```ts\n * const { ok, issues, doc: fixed } = validateDocument(rawDoc)\n * ```\n */\nexport const validateDocument = (\n doc: MtDocument,\n options: MtValidateOptions = {},\n): MtValidationResult => {\n const fix = options.fix !== false\n const issues: MtValidationIssue[] = []\n const working = fix ? (cloneJson(doc) as MtDocument) : doc\n\n const version = (working as { schemaVersion?: unknown }).schemaVersion\n if (version !== MT_SCHEMA_VERSION) {\n issues.push(\n issue(\n MT_DOC.UNSUPPORTED_VERSION,\n 'error',\n 'schemaVersion',\n `schemaVersion 为 ${String(version)},当前仅支持 ${MT_SCHEMA_VERSION},请先执行 migrateDocument()`,\n ),\n )\n }\n\n const ctx: MtValidateContext = {\n issues,\n fix,\n seenIds: new Set<string>(),\n nextId: createIdGenerator(),\n }\n\n const top = (working as { content?: unknown }).content\n if (!Array.isArray(top) || top.length === 0) {\n issues.push(\n issue(\n MT_DOC.CONTENT_MISMATCH,\n 'error',\n 'content',\n 'doc.content 必须至少包含一个块级节点(§2)',\n fix ? '已补一个空段落' : undefined,\n ),\n )\n if (fix) {\n ;(working as { content: unknown }).content = [\n { type: 'paragraph', attrs: { id: ctx.nextId() } },\n ]\n }\n } else {\n top.forEach((node, index) => validateNode(node, `content[${index}]`, ctx))\n }\n\n return {\n ok: !issues.some((item) => item.level === 'error'),\n issues,\n doc: working,\n }\n}\n","/**\n * `schemaVersion` 迁移链(`docs/document-model.md` §13)。\n *\n * 约定(§13.2):\n * - 迁移**只增不改**,历史迁移函数永不删除;\n * - 迁移必须**幂等**:对已是目标版本的输入直接返回原值;\n * - 每次迁移必须附一个 `tests/fixtures/migrations/v{n}-to-v{n+1}.json` 对照用例。\n *\n * 当前为 `schemaVersion = 1` 的初始版本,尚无历史迁移。\n */\n\nimport { MT_DOC } from '../report/codes'\nimport { issue } from '../report'\nimport { MT_SCHEMA_VERSION } from '../types/base'\nimport type { MtDocument } from '../types/document'\nimport type { MtDegradationEntry, MtValidationIssue } from '../types/report'\nimport { isRecord } from '../utils/guards'\nimport { cloneJson } from '../utils/tree'\n\n/** 单步迁移函数:把 v{n} 的宽松对象迁到 v{n+1} */\nexport type MtMigration = (doc: Record<string, unknown>) => Record<string, unknown>\n\n/**\n * 迁移链表:`MIGRATIONS[n]` 负责把 `n` 迁到 `n + 1`。\n *\n * 新增版本时在此追加,**禁止修改已有项**。\n */\nexport const MT_MIGRATIONS: Readonly<Record<number, MtMigration>> = {}\n\n/** 迁移结果 */\nexport interface MtMigrateResult {\n /** 迁移后的文档(当前版本) */\n doc: MtDocument\n /** 是否发生了迁移 */\n migrated: boolean\n /** 校验问题(如目标版本高于本包支持上限) */\n issues: MtValidationIssue[]\n /** 降级报告(迁移过程中的信息损失) */\n report: MtDegradationEntry[]\n}\n\n/**\n * 把任意历史版本的文档迁移到当前版本。\n *\n * @param input 宽松输入(来自网络、旧版本数据或文件)\n * @returns 迁移结果;无法迁移时 `issues` 中会含 `MT_DOC_008`\n *\n * @example\n * ```ts\n * const { doc, migrated } = migrateDocument(JSON.parse(raw))\n * ```\n */\nexport const migrateDocument = (input: unknown): MtMigrateResult => {\n const issues: MtValidationIssue[] = []\n const report: MtDegradationEntry[] = []\n\n if (!isRecord(input)) {\n issues.push(issue(MT_DOC.UNKNOWN_NODE, 'error', 'doc', '文档不是一个对象'))\n return {\n doc: { schemaVersion: MT_SCHEMA_VERSION, type: 'doc', content: [] },\n migrated: false,\n issues,\n report,\n }\n }\n\n let working: Record<string, unknown> = cloneJson(input)\n const rawVersion = working.schemaVersion\n\n if (typeof rawVersion === 'number' && rawVersion > MT_SCHEMA_VERSION) {\n issues.push(\n issue(\n MT_DOC.UNSUPPORTED_VERSION,\n 'error',\n 'schemaVersion',\n `文档版本 ${rawVersion} 高于本包支持的 ${MT_SCHEMA_VERSION},请升级 @mteditor/document`,\n ),\n )\n return { doc: working as unknown as MtDocument, migrated: false, issues, report }\n }\n\n // 版本缺失或非数字:按当前版本处理(§13.3:序列化输出永远使用当前版本编号)\n let version =\n typeof rawVersion === 'number' && Number.isFinite(rawVersion)\n ? Math.trunc(rawVersion)\n : MT_SCHEMA_VERSION\n let migrated = false\n\n while (version < MT_SCHEMA_VERSION) {\n const step = MT_MIGRATIONS[version]\n if (step === undefined) {\n issues.push(\n issue(\n MT_DOC.UNSUPPORTED_VERSION,\n 'error',\n 'schemaVersion',\n `缺少 v${version} → v${version + 1} 的迁移函数`,\n ),\n )\n break\n }\n working = step(working)\n version += 1\n migrated = true\n }\n\n working.schemaVersion = MT_SCHEMA_VERSION\n if (working.type !== 'doc') working.type = 'doc'\n if (!Array.isArray(working.content)) working.content = []\n\n return { doc: working as unknown as MtDocument, migrated, issues, report }\n}\n","/**\n * `@mteditor/document` —— MtDocument 文档模型层(公开 API 入口)。\n *\n * **本包是全部上层包的地基**(`AGENTS.md` §5):不得依赖任何 `@mteditor/*` 包,\n * 也不得触碰 `window` / `document` / `navigator` / `DOMParser` / `innerHTML`\n *(§9.4,由 `pnpm check:boundaries` 卡口强制执行)。因此同一份产物可在\n * Node、浏览器与小程序运行时三处直接执行。\n *\n * 模块划分(权威规范见 `docs/document-model.md`):\n *\n * | 目录 | 职责 | 规范章节 |\n * |---|---|---|\n * | `types/` | `MtDocument` / 节点 / 标记 / IO 契约 类型定义 | §2–§5、§10–§12 |\n * | `schema/` | 节点与标记元信息、`content` 表达式引擎 | §3.2–§3.3、§4–§6 |\n * | `sanitize/` | 手写 HTML 分词器 + AST 白名单 + SVG 复验 | §7.3 |\n * | `normalize/` | 标记规范化、行内节点收尾 | §3.5、§5.1 |\n * | `parse/` | `parseHTML` / `parseMarkdown` → `MtDocument` | §7–§8 |\n * | `serialize/` | HTML / Markdown / text / mini 序列化 | §7–§10 |\n * | `validate/` | 结构校验与 `MT_DOC_*` 错误码 | §11 |\n * | `migrate/` | `schemaVersion` 迁移链 | §13 |\n * | `report/` | 降级码 `MT_DEG_*` 与报告构造 | §12 |\n * | `utils/` | ID、转义、URL 白名单、树遍历、单位换算 | — |\n */\n\nimport { createIdGenerator } from './utils/id'\nimport { MT_SCHEMA_VERSION } from './types/base'\nimport type { MtDocument } from './types/document'\n\nexport * from './types'\nexport * from './schema'\nexport * from './report/codes'\nexport { degrade, issue } from './report'\nexport { normalizeMarks, finalizeInlineNodes, hasSameMarks } from './normalize'\nexport { sanitizeHtml } from './sanitize'\nexport { parseInlineStyle } from './sanitize/policy'\nexport type { MtCleanElement, MtCleanNode, MtSanitizeResult, MtCleanText } from './sanitize'\nexport { isSafeSvgMarkup } from './sanitize/svg'\nexport { tokenizeHtml, isElement, getAttribute } from './sanitize/tokenizer'\nexport type { MtHtmlAttribute, MtHtmlElement, MtHtmlNode, MtHtmlText } from './sanitize/tokenizer'\nexport { parseDocument, parseHtmlToDocument, parseMarkdownToDocument } from './parse'\nexport type { MtParseFormat } from './parse'\nexport {\n serializeBlocks,\n serializeDocument,\n serializeDocumentToHtml,\n serializeDocumentToMarkdown,\n serializeDocumentToMini,\n serializeDocumentToText,\n} from './serialize'\nexport { validateDocument } from './validate'\nexport { migrateDocument, MT_MIGRATIONS } from './migrate'\nexport type { MtMigration, MtMigrateResult } from './migrate'\nexport { createBlockId, createIdGenerator, isValidBlockId, MT_BLOCK_ID_PREFIX } from './utils/id'\nexport { checkUrl, sanitizeUrl } from './utils/url'\nexport { escapeAttr, escapeHtml, decodeEntities } from './utils/escape'\nexport { computeTreeStats, walkTree, utf8ByteLength, cloneJson } from './utils/tree'\nexport type { MtTreeStats, MtTreeNodeLike, MtTreeVisit } from './utils/tree'\nexport { stableStringify } from './utils/stable-json'\nexport { fnv1a } from './utils/hash'\nexport {\n formatCssLength,\n MT_COLWIDTH_UNIT,\n MT_INDENT_MAX,\n MT_INDENT_UNIT,\n parseCssLength,\n parseIndent,\n} from './utils/style-units'\nexport {\n normalizeStyleColor,\n parseBooleanAttr,\n parseInteger,\n parseLineHeight,\n parseMediaAlign,\n parseOrderedType,\n parseTextAlign,\n} from './utils/attr-values'\n\n/**\n * 创建一个空文档。\n *\n * §2 规定:**不允许出现 `content: []` 的 doc**,空文档必须包含一个空 `paragraph`。\n *\n * @param now 可选的 ID 时间戳基准(测试用,保证可复现)\n * @returns 最小合法文档\n *\n * @example\n * ```ts\n * createDocument().content[0]?.type // 'paragraph'\n * ```\n */\nexport const createDocument = (now?: number): MtDocument => {\n const nextId = createIdGenerator(now === undefined ? {} : { now })\n return {\n schemaVersion: MT_SCHEMA_VERSION,\n type: 'doc',\n content: [{ type: 'paragraph', attrs: { id: nextId() } }],\n }\n}\n","/**\n * 渲染容器的属性装配(`docs/decisions/ADR-0021`)。\n *\n * 容器属性有**两条出口**:字符串出口(`renderToString`)与真实元素出口(`renderToDOM`)。\n * 两者必须产出同一组属性,因此都从本模块的 `resolveContainerAttrs` 取值——\n * 这是「SSR 字符串与客户端 DOM 一致」的实现基础。\n */\n\nimport { escapeAttr } from '@mteditor/document'\n\nimport { MT_CONTENT_CLASS, MT_THEME_ATTR, type MtRenderOptions } from './options'\n\n/** 属性对:`[属性名, 属性值]`,值必为字符串(无「缺省」概念,缺省即不产出该对) */\ntype MtContainerAttr = readonly [name: string, value: string]\n\n/**\n * 解析容器应当产出的属性对(顺序固定:`class` 在前)。\n *\n * @param options 渲染选项\n * @returns 属性对列表;可能为空数组\n *\n * @example\n * ```ts\n * resolveContainerAttrs({ className: '' }) // []\n * resolveContainerAttrs({ theme: 'dark' }) // [['class', 'mt-content'], ['data-mt-theme', 'dark']]\n * ```\n */\nexport const resolveContainerAttrs = (options: MtRenderOptions): MtContainerAttr[] => {\n const attrs: MtContainerAttr[] = []\n const className = options.className ?? MT_CONTENT_CLASS\n if (className.length > 0) attrs.push(['class', className])\n if (options.theme !== undefined) attrs.push([MT_THEME_ATTR, options.theme])\n return attrs\n}\n\n/**\n * 把容器属性序列化为 HTML 属性串。\n *\n * @param options 渲染选项\n * @returns 形如 ` class=\"mt-content\"` 的字符串;无属性时为空串\n *\n * @example\n * ```ts\n * containerAttrsToString({}) // ' class=\"mt-content\"'\n * ```\n */\nexport const containerAttrsToString = (options: MtRenderOptions): string =>\n resolveContainerAttrs(options)\n .map(([name, value]) => ` ${name}=\"${escapeAttr(value)}\"`)\n .join('')\n\n/**\n * 把容器属性写到真实元素上。\n *\n * @param element 目标元素\n * @param options 渲染选项\n */\nexport const applyContainerAttrs = (element: Element, options: MtRenderOptions): void => {\n for (const [name, value] of resolveContainerAttrs(options)) {\n element.setAttribute(name, value)\n }\n}\n\n/**\n * 用容器包裹内容 HTML。\n *\n * `renderToString`(字符串出口)与 `renderToDOM`(DOM 出口)共用本函数,\n * 因此两者返回的 `html` **必然逐字符相同**——这是「SSR 产物与客户端产物一致」的保证。\n *\n * @param output 容器内部的 HTML\n * @param options 渲染选项\n * @returns 含容器的完整 HTML\n *\n * @example\n * ```ts\n * wrapContainer('<p>hi</p>', {}) // '<div class=\"mt-content\"><p>hi</p></div>'\n * ```\n */\nexport const wrapContainer = (output: string, options: MtRenderOptions): string =>\n `<div${containerAttrsToString(options)}>${output}</div>`\n","/**\n * 内容序列化的**唯一入口**(`docs/decisions/ADR-0021`)。\n *\n * 本模块是 `renderToString` 与 `renderToDOM` 的共同上游:两个公开函数\n * 都从这里拿内容,保证「字符串出口」与「DOM 出口」不可能产出不同的节点结构。\n *\n * ⚠️ 本模块**不得做任何改写**:不加 `loading=\"lazy\"`、不注入额外的 `data-mt-*`、\n * 不做降级高亮。任何改写都会让 Web 只读渲染与小程序端 / 编辑器的节点结构\n * 不再逐字符一致(ADR-0021 决策 4)。\n */\n\nimport { serializeDocumentToHtml } from '@mteditor/document'\nimport type { MtDegradationEntry, MtDocument, MtSerializeStats } from '@mteditor/document'\n\nimport type { MtRenderOptions } from './options'\n\n/** 内容序列化结果(不含外层容器) */\nexport interface MtRenderedContent {\n /** 容器内部的 HTML(即 `document` 序列化器的原样产物) */\n output: string\n /** 降级报告;`report: false` 时为空数组 */\n report: MtDegradationEntry[]\n /** 序列化统计 */\n stats: MtSerializeStats\n}\n\n/**\n * 把 `MtDocument` 序列化为容器内部的 HTML。\n *\n * @param doc 文档\n * @param options 渲染选项\n * @returns 内容与报告、统计\n */\nexport const renderContent = (doc: MtDocument, options: MtRenderOptions): MtRenderedContent => {\n const serialized = serializeDocumentToHtml(doc, {\n preRendered: options.preRendered,\n indent: options.indent,\n })\n\n return {\n output: serialized.output,\n // §12.2:只有调用方**显式**要求关闭时才丢弃报告\n report: options.report === false ? [] : serialized.report,\n stats: serialized.stats,\n }\n}\n","/**\n * `renderToString` —— 输出 HTML 字符串,**全程不触碰 DOM**(SSR / 邮件 / 静态页)。\n */\n\nimport type { MtDocument } from '@mteditor/document'\n\nimport { wrapContainer } from './container'\nimport type { MtRenderOptions, MtRenderResult } from './options'\nimport { renderContent } from './render-content'\n\n/**\n * 把 `MtDocument` 渲染为 HTML 字符串。\n *\n * 产物 = `<div class=\"mt-content\">{document 序列化产物}</div>`,\n * 内容部分与 `@mteditor/document` 的 `serializeDocumentToHtml` **逐字符一致**\n * (ADR-0021),因此不存在「详情页与编辑器渲染不一致」的可能。\n *\n * 本函数不使用 `window` / `document` / `innerHTML`,可在 Node、边缘函数、\n * 小程序服务端等任何无 DOM 环境执行。\n *\n * @param doc 文档\n * @param options 渲染选项\n * @returns 渲染结果(`html` / `report` / `stats`)\n *\n * @example\n * ```ts\n * const { html, report } = renderToString(doc)\n * // 需要局部固定暗色时:\n * renderToString(doc, { theme: 'dark', className: 'mt-content article-body' }).html\n * // 只要字符串:\n * const str = renderToString(doc).html\n * ```\n */\nexport const renderToString = (doc: MtDocument, options: MtRenderOptions = {}): MtRenderResult => {\n const content = renderContent(doc, options)\n return {\n html: wrapContainer(content.output, options),\n report: content.report,\n stats: content.stats,\n }\n}\n","/**\n * `renderToDOM` —— 把 `MtDocument` 渲染为真实 DOM 节点并挂载进容器。\n *\n * 与 `renderToString` 的关系:**同一份内容、同一套容器属性**,\n * 因此 `renderToDOM(doc, el).html === renderToString(doc).html` 恒成立(ADR-0021)。\n */\n\nimport type { MtDocument } from '@mteditor/document'\n\nimport { applyContainerAttrs, wrapContainer } from './container'\nimport type { MtDomRenderResult, MtRenderOptions } from './options'\nimport { renderContent } from './render-content'\n\n/**\n * 把 `MtDocument` 渲染为 DOM 并挂载到 `container`。\n *\n * **挂载语义**:调用后 `container` 的子节点被**整体替换**为渲染出的容器元素\n * (即重复调用是幂等的,不会累积)。`container` 自身的属性不被修改——\n * 主题由宿主控制(AGENTS.md §7.4),需要局部覆盖时请用 `options.theme`。\n *\n * @param doc 文档\n * @param container 挂载容器(普通文档流中的元素,不得位于 `position: fixed` 容器内,§10.3)\n * @param options 渲染选项\n * @returns 渲染结果 + 实际挂载的元素\n *\n * @example\n * ```ts\n * const { element, report } = renderToDOM(doc, document.getElementById('view')!)\n * element.dataset.rendered // 可通过返回的元素继续做懒加载等增强\n * ```\n */\nexport const renderToDOM = (\n doc: MtDocument,\n container: HTMLElement,\n options: MtRenderOptions = {},\n): MtDomRenderResult => {\n const content = renderContent(doc, options)\n const element = container.ownerDocument.createElement('div')\n\n applyContainerAttrs(element, options)\n // 安全性:content.output 由 @mteditor/document 的序列化器产出——文本与属性值\n // 均已转义,仅白名单内的标签/属性可存活(docs/document-model.md §7.3,\n // 10 份 XSS 载荷基线为证)。此处不接受任何未经清洗的外部输入。\n element.innerHTML = content.output\n\n container.replaceChildren(element)\n\n return {\n html: wrapContainer(content.output, options),\n report: content.report,\n stats: content.stats,\n element,\n }\n}\n"],"mappings":"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","names":["src_exports","__export","MT_CONTENT_CLASS","MT_THEME_ATTR","applyContainerAttrs","containerAttrsToString","renderToDOM","renderToString","resolveContainerAttrs","wrapContainer","MT_CONTENT_CLASS","MT_THEME_ATTR","MT_BLOCK_ID_PREFIX","BASE36","createIdGenerator","options","now","random","suffixLength","timestamp","used","attempt","suffix","i","id","defaultGenerator","createIdGenerator","ALIGN_ATTRS","CELL_ATTRS","MT_NODE_SPECS","MT_NODE_SPEC_MAP","spec","nodesInGroup","group","MT_GROUP_MEMBERS","TEXT_STYLE_ATTRS","MT_MARK_SPECS","MT_MARK_SPEC_MAP","spec","MT_LINK_DEFAULT_TARGET","MT_LINK_DEFAULT_REL","MT_DEG","MT_DOC","MT_DEG_LABEL","MT_DOC_LABEL","degrade","code","type","path","message","strategy","TEXT_ESCAPES","ATTR_ESCAPES","escapeWith","source","table","out","changed","i","ch","replacement","escapeHtml","text","escapeAttr","value","P_CLOSERS","TABLE_PART_CLOSERS","IMPLICIT_CLOSE","tag","MT_INDENT_UNIT","MT_COLWIDTH_UNIT","formatCssLength","value","unit","FNV_OFFSET","FNV_PRIME","fnv1a","text","hash","i","MT_TREE_ROOT_PATH","childPath","base","index","walkTree","roots","visit","rootPath","step","nodes","parent","depth","node","path","children","computeTreeStats","nodeCount","maxDepth","utf8ByteLength","bytes","code","attrsToString","pairs","out","name","value","escapeAttr","styleToString","parts","MARK_TAGS","markWrapper","mark","open","attrs","MT_LINK_DEFAULT_TARGET","MT_LINK_DEFAULT_REL","style","tag","wrapMarks","content","marks","i","serializeInline","node","ctx","escapeHtml","kind","svg","degrade","MT_DEG","serializeInlineNodes","nodes","singleText","first","serializeRow","row","cells","cell","formatCssLength","MT_COLWIDTH_UNIT","head","serializeBlocks","serializeAtomBlock","serializeCodeBlock","text","language","highlight","fnv1a","preAttrs","codeAttrs","body","serializeBlock","MT_INDENT_UNIT","items","item","checked","widths","colgroup","width","rows","separator","serializeDocumentToHtml","doc","options","output","nodeCount","maxDepth","computeTreeStats","utf8ByteLength","resolveContainerAttrs","options","attrs","className","MT_CONTENT_CLASS","MT_THEME_ATTR","containerAttrsToString","name","value","escapeAttr","applyContainerAttrs","element","wrapContainer","output","renderContent","doc","options","serialized","serializeDocumentToHtml","renderToString","doc","options","content","renderContent","wrapContainer","renderToDOM","doc","container","options","content","renderContent","element","applyContainerAttrs","wrapContainer"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/options.ts","../../document/src/utils/id.ts","../../document/src/types/base.ts","../../document/src/types/marks.ts","../../document/src/schema/nodes.ts","../../document/src/schema/marks.ts","../../document/src/schema/content-expression.ts","../../document/src/schema/index.ts","../../document/src/report/codes.ts","../../document/src/report/index.ts","../../document/src/utils/guards.ts","../../document/src/utils/url.ts","../../document/src/normalize/marks.ts","../../document/src/utils/stable-json.ts","../../document/src/normalize/text.ts","../../document/src/utils/escape.ts","../../document/src/sanitize/tokenizer.ts","../../document/src/utils/style-units.ts","../../document/src/utils/attr-values.ts","../../document/src/sanitize/policy.ts","../../document/src/sanitize/index.ts","../../document/src/sanitize/svg.ts","../../document/src/parse/context.ts","../../document/src/parse/html-atoms.ts","../../document/src/parse/html-marks.ts","../../document/src/parse/html-nodes.ts","../../document/src/parse/html.ts","../../document/src/parse/markdown-inline.ts","../../document/src/parse/markdown.ts","../../document/src/parse/index.ts","../../document/src/utils/hash.ts","../../document/src/utils/tree.ts","../../document/src/serialize/html-attrs.ts","../../document/src/serialize/html-marks.ts","../../document/src/serialize/html.ts","../../document/src/serialize/markdown.ts","../../document/src/serialize/mini-context.ts","../../document/src/serialize/mini-inline.ts","../../document/src/serialize/mini.ts","../../document/src/serialize/text.ts","../../document/src/serialize/index.ts","../../document/src/validate/table.ts","../../document/src/validate/index.ts","../../document/src/migrate/index.ts","../../document/src/index.ts","../src/container.ts","../src/render-content.ts","../src/render-to-string.ts","../src/render-to-dom.ts"],"sourcesContent":["/**\n * `@mteditor/renderer-web` —— Web 端只读渲染器(公开 API 入口)。\n *\n * 把 `MtDocument` JSON 渲染成 HTML 字符串或真实 DOM,**不加载编辑器实例**,\n * 因此可以在「详情页 / SEO 静态页 / 邮件预览」等场景独立使用。\n *\n * ## 与 `document` 的关系\n *\n * 本包**不实现自己的 HTML 序列化器**,HTML 一律由 `@mteditor/document` 的\n * `serializeDocumentToHtml` 产出,本包只负责三件事:**容器包装、主题接线、DOM 挂载**。\n * 这保证「详情页看到的」与「存库 / 导出 / 小程序端渲染的」来自同一套真相。\n *\n * ## 与 `core` 的关系\n *\n * 依赖方向为 `renderer-web` → `document`,**不得依赖 `core`**。\n *\n * ## 硬约束\n *\n * 1. **不改写序列化产物**:不注入 `loading=\"lazy\"`、不追加 `data-mt-*`、不做降级高亮;\n * 2. 降级报告必须能被调用方取到,不允许静默丢弃;\n * 3. 主题默认跟随宿主(`data-mt-theme` 写在宿主上),只在显式传 `theme` 时局部覆盖。\n */\n\nexport { MT_CONTENT_CLASS, MT_THEME_ATTR } from './options'\nexport type { MtDomRenderResult, MtRenderOptions, MtRenderResult, MtTheme } from './options'\nexport { renderToString } from './render-to-string'\nexport { renderToDOM } from './render-to-dom'\nexport {\n applyContainerAttrs,\n containerAttrsToString,\n resolveContainerAttrs,\n wrapContainer,\n} from './container'\n","/**\n * `@mteditor/renderer-web` 的公开选项与结果类型。\n */\n\nimport type { MtDegradationEntry, MtPreRenderedIndex, MtSerializeStats } from '@mteditor/document'\n\n/** 内容容器类名 —— 与 `@mteditor/theme-default` 的 `.mt-content` 约定一致 */\nexport const MT_CONTENT_CLASS = 'mt-content'\n\n/** 主题属性名 —— 主题层只认这一个属性 */\nexport const MT_THEME_ATTR = 'data-mt-theme'\n\n/** 主题取值;不开放任意字符串,避免写出主题层无法识别的属性值 */\nexport type MtTheme = 'light' | 'dark'\n\n/** 渲染选项;全部字段可选 */\nexport interface MtRenderOptions {\n /**\n * 容器的**完整** `class` 值,缺省 `'mt-content'`。\n *\n * 需要追加自己的类名时请一并写出,例如 `'mt-content article-body'`;\n * 传空字符串则完全不输出 `class` 属性(此时将失去主题排版样式)。\n */\n className?: string\n /**\n * 主题。**缺省不写 `data-mt-theme`**,跟随宿主(主题模型)。\n *\n * 只在需要**局部覆盖**时显式传入,例如邮件预览固定亮色。\n */\n theme?: MtTheme\n /** 预渲染产物索引(公式 SVG / 代码高亮) */\n preRendered?: MtPreRenderedIndex\n /** 缩进层级,缺省 `0`;仅影响 HTML 可读性,不参与语义 */\n indent?: number\n /** 是否返回降级报告,缺省 `true`。**置 `false` 等于主动放弃降级信息** */\n report?: boolean\n}\n\n/** 渲染结果(`renderToString` 的返回值) */\nexport interface MtRenderResult {\n /** 完整 HTML,**含外层容器** */\n html: string\n /** 降级报告;`report: false` 时为空数组 */\n report: MtDegradationEntry[]\n /** 序列化统计(**不含**外层容器,即 `document` 序列化器的原始统计) */\n stats: MtSerializeStats\n}\n\n/** DOM 渲染结果(`renderToDOM` 的返回值) */\nexport interface MtDomRenderResult extends MtRenderResult {\n /** 实际挂载进容器的元素 */\n element: HTMLElement\n}\n","/**\n * 块级节点 ID 的生成与校验。\n *\n * 格式:`blk_<base36 时间戳>_<4 位 base36 随机>`,例 `blk_m1x2y3_a4b5`。\n *\n * 设计要点:\n * - **可注入** `now` 与 `random`,测试可固定取值以获得可复现的 ID;\n * - 同一生成器内保证不重复(批量为一个文档补发 ID 时的硬要求)。\n */\n\n/** ID 前缀 */\nexport const MT_BLOCK_ID_PREFIX = 'blk_'\n\n/** base36 字符表 */\nconst BASE36 = '0123456789abcdefghijklmnopqrstuvwxyz'\n\n/** ID 生成器选项 */\nexport interface MtIdGeneratorOptions {\n /** 时间戳基准(毫秒),缺省取当前时间 */\n now?: number\n /** 随机数源,返回 `[0, 1)`,缺省 `Math.random` */\n random?: () => number\n /** 随机后缀长度,缺省 4 */\n suffixLength?: number\n}\n\n/**\n * 创建一个块级节点 ID 生成器。\n *\n * @param options 生成器选项\n * @returns 每次调用返回一个全新 ID 的函数\n *\n * @example\n * ```ts\n * let seed = 0\n * const next = createIdGenerator({ now: 1_700_000_000_000, random: () => (seed++ % 7) / 7 })\n * next() // 'blk_...'(同一 now 与 random 序列下完全可复现)\n * ```\n */\nexport const createIdGenerator = (options: MtIdGeneratorOptions = {}): (() => string) => {\n const now = options.now ?? Date.now()\n const random = options.random ?? Math.random\n const suffixLength = Math.max(2, options.suffixLength ?? 4)\n const timestamp = Math.max(0, Math.floor(now)).toString(36)\n const used = new Set<string>()\n\n return () => {\n for (let attempt = 0; attempt < 128; attempt += 1) {\n let suffix = ''\n for (let i = 0; i < suffixLength; i += 1) {\n suffix += BASE36.charAt(Math.floor(random() * 36))\n }\n const id = `${MT_BLOCK_ID_PREFIX}${timestamp}_${suffix}`\n if (!used.has(id)) {\n used.add(id)\n return id\n }\n }\n throw new Error('节点 ID 生成失败:随机后缀连续冲突,请检查注入的 random 实现')\n }\n}\n\n/**\n * 校验字符串是否形如块级节点 ID。\n *\n * 判定从宽(仅要求 `blk_` 前缀且不含空白),原因\n * 最小合法文档的示例 ID 即为 `blk_`,不允许把它判为非法。\n *\n * @param value 待校验值\n * @returns 是否形如块级 ID\n *\n * @example\n * ```ts\n * isValidBlockId('blk_m1x2y3_a4b5') // true\n * isValidBlockId('node-1') // false\n * ```\n */\nexport const isValidBlockId = (value: unknown): value is string => {\n if (typeof value !== 'string') return false\n if (!value.startsWith(MT_BLOCK_ID_PREFIX)) return false\n for (let i = 0; i < value.length; i += 1) {\n const code = value.charCodeAt(i)\n // 空白与控制字符均视为非法\n if (code <= 32 || code === 127) return false\n }\n return true\n}\n\n/** 全局默认生成器:`core` 创建节点、`parse` 补发 ID 时共用,避免频繁构造 */\nconst defaultGenerator = createIdGenerator()\n\n/**\n * 用默认生成器产出一个块级节点 ID。\n *\n * @returns 全新 ID\n *\n * @example\n * ```ts\n * const id = createBlockId() // 'blk_...'\n * ```\n */\nexport const createBlockId = (): string => defaultGenerator()\n","/**\n * 基础字面量联合与模型版本号。\n *\n * 本文件只放**跨模块复用的原子类型**,不承载任何逻辑。\n */\n\n/**\n * 当前模型版本号。\n *\n * 变更规则见:新增节点/标记或新增可选属性**不**升版本\n * 属性重命名、内容表达式变更、必需属性新增**必须**升版本。\n */\nexport const MT_SCHEMA_VERSION = 1\n\n/** `schemaVersion` 的取值类型(当前仅 `1`) */\nexport type MtSchemaVersion = typeof MT_SCHEMA_VERSION\n\n/** 块级文本容器的水平对齐方式 */\nexport type MtTextAlign = 'left' | 'center' | 'right' | 'justify'\n\n/** 媒体节点的水平对齐方式(无 `justify`) */\nexport type MtMediaAlign = 'left' | 'center' | 'right'\n\n/** `orderedList` 的编号样式 */\nexport type MtOrderedListType = '1' | 'a' | 'A' | 'i' | 'I'\n\n/** `heading` 的层级(1–6) */\nexport type MtHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6\n\n/** `mention` 的引用类型 */\nexport type MtMentionKind = 'user' | 'doc' | 'custom'\n\n/** 降级报告采取的补偿策略 */\nexport type MtDegradationStrategy = 'dropped' | 'replaced' | 'flattened' | 'literalized'\n\n/** 校验问题的严重级别:`error` 阻断序列化,`warning` 仅记录 */\nexport type MtIssueSeverity = 'error' | 'warning'\n\n/** 序列化目标 */\nexport type MtSerializeTarget = 'html' | 'markdown' | 'text' | 'mini'\n\n/** 小程序平台标识 */\nexport type MtMiniPlatform = 'wechat' | 'alipay' | 'bytedance'\n\n/** 小程序渲染通道 */\nexport type MtMiniMode = 'component' | 'richText'\n\n/** 全部节点类型名(含未在 `MtBlockNode` 联合中出现的内部结构节点) */\nexport const MT_NODE_TYPES = [\n 'doc',\n 'paragraph',\n 'heading',\n 'blockquote',\n 'bulletList',\n 'orderedList',\n 'listItem',\n 'taskList',\n 'taskItem',\n 'codeBlock',\n 'horizontalRule',\n 'image',\n 'video',\n 'audio',\n 'attachment',\n 'mathBlock',\n 'table',\n 'tableRow',\n 'tableCell',\n 'tableHeader',\n 'text',\n 'hardBreak',\n 'mention',\n 'inlineMath',\n 'emoji',\n] as const\n\n/** 节点类型名的联合 */\nexport type MtNodeType = (typeof MT_NODE_TYPES)[number]\n\n/** 全部标记类型名 */\nexport const MT_MARK_TYPES = [\n 'link',\n 'code',\n 'textStyle',\n 'bold',\n 'italic',\n 'underline',\n 'strike',\n 'highlight',\n 'subscript',\n 'superscript',\n] as const\n\n/** 标记类型名的联合 */\nexport type MtMarkType = (typeof MT_MARK_TYPES)[number]\n","/**\n * 标记(mark)类型定义。\n *\n * 约定:\n * - 标记只作用于 `text` 节点;\n * - 除 `codeBlock` 外,所有块级文本容器均允许标记;\n * 序列化时按的**优先级降序由外向内**包裹,保证跨端嵌套顺序一致。\n */\n\n/** `link` 标记属性 */\nexport interface MtLinkAttrs {\n /** 目标地址,必填 */\n href: string\n /** 打开方式,缺省 `'_blank'` */\n target?: string\n /** 安全属性,缺省 `'noopener noreferrer'` */\n rel?: string\n /** 链接标题 */\n title?: string\n}\n\n/** `textStyle` 标记属性;全部可空,但至少一个非空 */\nexport interface MtTextStyleAttrs {\n fontFamily?: string | null\n fontSize?: string | null\n color?: string | null\n backgroundColor?: string | null\n lineHeight?: string | null\n letterSpacing?: string | null\n}\n\n/** `highlight` 标记属性:`color` 必填 */\nexport interface MtHighlightAttrs {\n color: string\n}\n\n/** `link` 标记:不可嵌套自身 */\nexport interface MtLinkMark {\n type: 'link'\n attrs: MtLinkAttrs\n}\n\n/** `code` 标记:与 `textStyle` / `highlight` 互斥 */\nexport interface MtCodeMark {\n type: 'code'\n}\n\n/** `textStyle` 标记:单例,多次施加时属性合并 */\nexport interface MtTextStyleMark {\n type: 'textStyle'\n attrs: MtTextStyleAttrs\n}\n\n/** `bold` 标记 */\nexport interface MtBoldMark {\n type: 'bold'\n}\n\n/** `italic` 标记 */\nexport interface MtItalicMark {\n type: 'italic'\n}\n\n/** `underline` 标记 */\nexport interface MtUnderlineMark {\n type: 'underline'\n}\n\n/** `strike` 标记 */\nexport interface MtStrikeMark {\n type: 'strike'\n}\n\n/** `highlight` 标记 */\nexport interface MtHighlightMark {\n type: 'highlight'\n attrs: MtHighlightAttrs\n}\n\n/** `subscript` 标记:与 `superscript` 互斥 */\nexport interface MtSubscriptMark {\n type: 'subscript'\n}\n\n/** `superscript` 标记:与 `subscript` 互斥 */\nexport interface MtSuperscriptMark {\n type: 'superscript'\n}\n\n/** 全部标记的判别联合 */\nexport type MtMark =\n | MtLinkMark\n | MtCodeMark\n | MtTextStyleMark\n | MtBoldMark\n | MtItalicMark\n | MtUnderlineMark\n | MtStrikeMark\n | MtHighlightMark\n | MtSubscriptMark\n | MtSuperscriptMark\n\n/** 按类型取标记的具体形态,便于 `switch` 处收窄 */\nexport type MtMarkOf<T extends MtMark['type']> = Extract<MtMark, { type: T }>\n\n/** 无属性标记的类型名集合(序列化时可省略 `attrs`) */\nexport const MT_ATTRLESS_MARKS = [\n 'code',\n 'bold',\n 'italic',\n 'underline',\n 'strike',\n 'subscript',\n 'superscript',\n] as const\n","/**\n * 节点规格表 —— 全量节点元信息的**唯一来源**。\n *\n * 本表驱动三件事:\n * 1. `validate` 的必需属性与 `content` 表达式校验;\n * 2. `parse` 的标签映射与默认值填充;\n * 3. `serialize` 的分支选择与 ID 分配策略。\n *\n * **改动本表 = 改动文档模型**,必须同步与小程序映射表。\n */\n\nimport type { MtNodeType } from '../types/base'\n\n/** 节点分组名 */\nexport type MtNodeGroup = 'block' | 'inline' | 'listItem' | 'tableCell'\n\n/** 单个节点的元信息 */\nexport interface MtNodeSpec {\n /** 节点类型名 */\n name: MtNodeType\n /** 所属组;`doc` 无组 */\n group: readonly MtNodeGroup[]\n /** `content` 表达式原文;atom 节点为 `null` */\n content: string | null\n /** 是否为 atom(无 content、内部不可编辑) */\n isAtom: boolean\n /**\n * 是否必须分配 `attrs.id`(所有块级节点必须有 ID,行内节点不分配)。\n * 注意 `tableRow` / `tableCell` / `tableHeader` 虽属表格结构,但同样不分配 ID。\n */\n needsId: boolean\n /** 必需属性名 */\n requiredAttrs: readonly string[]\n /** 允许出现的属性名(含可选) */\n allowedAttrs: readonly string[]\n /** 其文本子节点是否允许携带 marks(除 `codeBlock` 外均允许) */\n allowsMarks: boolean\n}\n\n/** 文本容器共有的对齐/行高属性名 */\nconst ALIGN_ATTRS = ['textAlign', 'lineHeight'] as const\n\n/** 表格单元格共有的属性名 */\nconst CELL_ATTRS = [\n 'colspan',\n 'rowspan',\n 'colwidth',\n 'align',\n 'background',\n ...ALIGN_ATTRS,\n] as const\n\n/**\n * 全量节点规格。\n *\n * 顺序与 「全量节点总表」一致,便于逐行比对。\n */\nexport const MT_NODE_SPECS: readonly MtNodeSpec[] = [\n {\n name: 'doc',\n group: [],\n content: 'block+',\n isAtom: false,\n needsId: false,\n requiredAttrs: [],\n allowedAttrs: [],\n allowsMarks: false,\n },\n {\n name: 'paragraph',\n group: ['block'],\n content: 'inline*',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id', 'indent', ...ALIGN_ATTRS],\n allowsMarks: true,\n },\n {\n name: 'heading',\n group: ['block'],\n content: 'inline*',\n isAtom: false,\n needsId: true,\n requiredAttrs: ['level'],\n allowedAttrs: ['id', 'level', ...ALIGN_ATTRS],\n allowsMarks: true,\n },\n {\n name: 'blockquote',\n group: ['block'],\n content: 'block+',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id'],\n allowsMarks: false,\n },\n {\n name: 'bulletList',\n group: ['block'],\n content: 'listItem+',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id'],\n allowsMarks: false,\n },\n {\n name: 'orderedList',\n group: ['block'],\n content: 'listItem+',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id', 'start', 'type'],\n allowsMarks: false,\n },\n {\n name: 'listItem',\n group: ['listItem'],\n content: 'paragraph block*',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id'],\n allowsMarks: false,\n },\n {\n name: 'taskList',\n group: ['block'],\n content: 'taskItem+',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id'],\n allowsMarks: false,\n },\n {\n name: 'taskItem',\n group: ['listItem'],\n content: 'paragraph block*',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id', 'checked'],\n allowsMarks: false,\n },\n {\n name: 'codeBlock',\n group: ['block'],\n content: 'text*',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id', 'language', 'showLineNumbers'],\n allowsMarks: false,\n },\n {\n name: 'horizontalRule',\n group: ['block'],\n content: null,\n isAtom: true,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id'],\n allowsMarks: false,\n },\n {\n name: 'image',\n group: ['block'],\n content: null,\n isAtom: true,\n needsId: true,\n requiredAttrs: ['src'],\n allowedAttrs: ['id', 'src', 'alt', 'title', 'width', 'height', 'align'],\n allowsMarks: false,\n },\n {\n name: 'video',\n group: ['block'],\n content: null,\n isAtom: true,\n needsId: true,\n requiredAttrs: ['src'],\n allowedAttrs: ['id', 'src', 'poster', 'width', 'height', 'align'],\n allowsMarks: false,\n },\n {\n name: 'audio',\n group: ['block'],\n content: null,\n isAtom: true,\n needsId: true,\n requiredAttrs: ['src'],\n allowedAttrs: ['id', 'src', 'title'],\n allowsMarks: false,\n },\n {\n name: 'attachment',\n group: ['block'],\n content: null,\n isAtom: true,\n needsId: true,\n requiredAttrs: ['src', 'name'],\n allowedAttrs: ['id', 'src', 'name', 'size', 'mime'],\n allowsMarks: false,\n },\n {\n name: 'mathBlock',\n group: ['block'],\n content: null,\n isAtom: true,\n needsId: true,\n requiredAttrs: ['tex'],\n allowedAttrs: ['id', 'tex', 'svg', 'numbering'],\n allowsMarks: false,\n },\n {\n name: 'table',\n group: ['block'],\n content: 'tableRow+',\n isAtom: false,\n needsId: true,\n requiredAttrs: [],\n allowedAttrs: ['id', 'colWidths'],\n allowsMarks: false,\n },\n {\n name: 'tableRow',\n group: [],\n content: '(tableCell | tableHeader)+',\n isAtom: false,\n needsId: false,\n requiredAttrs: [],\n allowedAttrs: [],\n allowsMarks: false,\n },\n {\n name: 'tableCell',\n group: ['tableCell'],\n content: 'block+',\n isAtom: false,\n needsId: false,\n requiredAttrs: [],\n allowedAttrs: [...CELL_ATTRS],\n allowsMarks: true,\n },\n {\n name: 'tableHeader',\n group: ['tableCell'],\n content: 'block+',\n isAtom: false,\n needsId: false,\n requiredAttrs: [],\n allowedAttrs: [...CELL_ATTRS],\n allowsMarks: true,\n },\n {\n name: 'text',\n group: ['inline'],\n content: null,\n isAtom: true,\n needsId: false,\n requiredAttrs: ['text'],\n allowedAttrs: [],\n allowsMarks: true,\n },\n {\n name: 'hardBreak',\n group: ['inline'],\n content: null,\n isAtom: true,\n needsId: false,\n requiredAttrs: [],\n allowedAttrs: [],\n allowsMarks: false,\n },\n {\n name: 'mention',\n group: ['inline'],\n content: null,\n isAtom: true,\n needsId: false,\n requiredAttrs: ['id', 'label'],\n allowedAttrs: ['id', 'label', 'kind'],\n allowsMarks: false,\n },\n {\n name: 'inlineMath',\n group: ['inline'],\n content: null,\n isAtom: true,\n needsId: false,\n requiredAttrs: ['tex'],\n allowedAttrs: ['tex', 'svg'],\n allowsMarks: false,\n },\n {\n name: 'emoji',\n group: ['inline'],\n content: null,\n isAtom: true,\n needsId: false,\n requiredAttrs: ['name', 'unicode'],\n allowedAttrs: ['name', 'unicode'],\n allowsMarks: false,\n },\n]\n\n/** 节点名 → 规格 的索引 */\nexport const MT_NODE_SPEC_MAP: ReadonlyMap<string, MtNodeSpec> = new Map(\n MT_NODE_SPECS.map((spec) => [spec.name, spec]),\n)\n\n/**\n * 按组名取出成员节点名。\n *\n * @param group 组名\n * @returns 属于该组的节点类型名数组\n */\nexport const nodesInGroup = (group: MtNodeGroup): string[] =>\n MT_NODE_SPECS.filter((spec) => spec.group.includes(group)).map((spec) => spec.name)\n\n/** 组的成员在模块加载时算一次,避免校验热路径反复过滤 */\nexport const MT_GROUP_MEMBERS: Readonly<Record<MtNodeGroup, readonly string[]>> = {\n block: nodesInGroup('block'),\n inline: nodesInGroup('inline'),\n listItem: nodesInGroup('listItem'),\n tableCell: nodesInGroup('tableCell'),\n}\n\n/**\n * 判断某个节点类型是否满足 content 表达式中的**单个名称**。\n *\n * 名称既可能是**组名**(`block` / `inline` / `listItem` / `tableCell`),\n * 也可能是**具体节点名**(`paragraph` / `tableCell` …),与 ProseMirror 语义一致。\n *\n * @param nodeType 待判断的节点类型名\n * @param name 表达式中的名称\n * @returns 是否匹配\n */\nexport const nodeMatchesName = (nodeType: string, name: string): boolean => {\n if (name in MT_GROUP_MEMBERS) {\n return MT_GROUP_MEMBERS[name as MtNodeGroup].includes(nodeType)\n }\n return nodeType === name\n}\n\n/**\n * 取节点规格。\n *\n * @param nodeType 节点类型名\n * @returns 规格对象;未知类型返回 `undefined`\n */\nexport const getNodeSpec = (nodeType: string): MtNodeSpec | undefined =>\n MT_NODE_SPEC_MAP.get(nodeType)\n","/**\n * 标记规格表。\n *\n * `priority` 决定序列化时的包裹顺序:数值**越大越靠外**。\n * 同一份 JSON 在任何端产出的嵌套顺序都必须一致,依据即本表。\n */\n\nimport type { MtMarkType } from '../types/base'\n\n/** 单个标记的元信息 */\nexport interface MtMarkSpec {\n /** 标记类型名 */\n name: MtMarkType\n /** 序列化优先级;越大越靠外 */\n priority: number\n /** 必需属性名 */\n requiredAttrs: readonly string[]\n /** 允许出现的属性名(含必需) */\n allowedAttrs: readonly string[]\n /** 互斥标记(同时出现时保留优先级更高者) */\n exclusive: readonly MtMarkType[]\n /** 是否为单例标记(重复施加时合并属性而非嵌套) */\n singleton: boolean\n}\n\n/** `textStyle` 允许的样式属性 */\nconst TEXT_STYLE_ATTRS = [\n 'fontFamily',\n 'fontSize',\n 'color',\n 'backgroundColor',\n 'lineHeight',\n 'letterSpacing',\n] as const\n\n/** 全量标记规格,按 `priority` 降序排列 */\nexport const MT_MARK_SPECS: readonly MtMarkSpec[] = [\n {\n name: 'link',\n priority: 1000,\n requiredAttrs: ['href'],\n allowedAttrs: ['href', 'target', 'rel', 'title'],\n // 「不可嵌套自身」:同一段文本只能有一个 link\n exclusive: ['link'],\n singleton: true,\n },\n {\n name: 'code',\n priority: 900,\n requiredAttrs: [],\n allowedAttrs: [],\n exclusive: ['textStyle', 'highlight'],\n singleton: true,\n },\n {\n name: 'textStyle',\n priority: 800,\n requiredAttrs: [],\n allowedAttrs: [...TEXT_STYLE_ATTRS],\n exclusive: [],\n singleton: true,\n },\n {\n name: 'bold',\n priority: 700,\n requiredAttrs: [],\n allowedAttrs: [],\n exclusive: [],\n singleton: true,\n },\n {\n name: 'italic',\n priority: 650,\n requiredAttrs: [],\n allowedAttrs: [],\n exclusive: [],\n singleton: true,\n },\n {\n name: 'underline',\n priority: 600,\n requiredAttrs: [],\n allowedAttrs: [],\n exclusive: [],\n singleton: true,\n },\n {\n name: 'strike',\n priority: 550,\n requiredAttrs: [],\n allowedAttrs: [],\n exclusive: [],\n singleton: true,\n },\n {\n name: 'highlight',\n priority: 500,\n requiredAttrs: ['color'],\n allowedAttrs: ['color'],\n exclusive: ['code'],\n singleton: true,\n },\n {\n name: 'subscript',\n priority: 400,\n requiredAttrs: [],\n allowedAttrs: [],\n exclusive: ['superscript'],\n singleton: true,\n },\n {\n name: 'superscript',\n priority: 350,\n requiredAttrs: [],\n allowedAttrs: [],\n exclusive: ['subscript'],\n singleton: true,\n },\n]\n\n/** 标记名 → 规格 的索引 */\nexport const MT_MARK_SPEC_MAP: ReadonlyMap<string, MtMarkSpec> = new Map(\n MT_MARK_SPECS.map((spec) => [spec.name, spec]),\n)\n\n/**\n * `link.target` 的默认值。\n *\n * 要求序列化时**强制补** `target` / `rel`,以消除\n * `target=\"_blank\"` 的 tabnabbing 风险;与之对称,规范化阶段会把\n * **恰好等于默认值**的取值剥除,从而既保留安全属性、又不破坏\n * `JSON → HTML → JSON` 逐字符等价。\n */\nexport const MT_LINK_DEFAULT_TARGET = '_blank'\n\n/** `link.rel` 的默认值,语义同 {@link MT_LINK_DEFAULT_TARGET} */\nexport const MT_LINK_DEFAULT_REL = 'noopener noreferrer'\n\n/**\n * 取标记规格。\n *\n * @param markType 标记类型名\n * @returns 规格对象;未知类型返回 `undefined`\n */\nexport const getMarkSpec = (markType: string): MtMarkSpec | undefined =>\n MT_MARK_SPEC_MAP.get(markType)\n\n/**\n * 标记排序比较器:按 `priority` **降序**(越靠前越靠外)。\n *\n * @param a 标记类型名\n * @param b 标记类型名\n * @returns 比较值\n */\nexport const compareMarkPriority = (a: string, b: string): number => {\n const pa = MT_MARK_SPEC_MAP.get(a)?.priority ?? 0\n const pb = MT_MARK_SPEC_MAP.get(b)?.priority ?? 0\n return pb - pa\n}\n","/**\n * `content` 表达式引擎。\n *\n * 支持 ProseMirror 记法的子集:\n *\n * | 记法 | 含义 |\n * |---|---|\n * | `a b` | 先 `a` 后 `b` |\n * | `a+` | 一个或多个 |\n * | `a*` | 零个或多个 |\n * | `a?` | 零个或一个 |\n * | `a \\| b` | `a` 或 `b` |\n * | `(a \\| b)+` | 括号内任一项,一个或多个 |\n *\n * 实现采用**手写扫描 + 回溯匹配**:不引入正则与第三方解析器,\n * 保证零 DOM、零依赖,可在小程序运行时执行。\n */\n\n/** 量词 */\nexport type MtQuantifier = '' | '?' | '*' | '+'\n\n/** 表达式中的一段:一组备选名称 + 量词 */\nexport interface MtContentPiece {\n /** 备选类型/组名 */\n alternatives: readonly string[]\n /** 量词 */\n quantifier: MtQuantifier\n}\n\n/** 解析后的 content 表达式 */\nexport interface MtContentExpr {\n /** 原文,用于报错定位 */\n raw: string\n /** 片段序列 */\n pieces: readonly MtContentPiece[]\n}\n\n/** content 表达式语法错误 */\nexport class MtContentExprError extends Error {\n /** 原始表达式 */\n readonly raw: string\n\n /**\n * @param raw 原始表达式\n * @param detail 错误细节(中文)\n */\n constructor(raw: string, detail: string) {\n super(`content 表达式 \"${raw}\" 语法错误:${detail}`)\n this.name = 'MtContentExprError'\n this.raw = raw\n }\n}\n\n/** 取字符;越界返回空串,避免 `string | undefined` 污染比较逻辑 */\nconst chAt = (source: string, index: number): string => source.charAt(index)\n\n/** 是否为合法名称字符 */\nconst isNameChar = (ch: string): boolean => {\n if (ch === '') return false\n const code = ch.charCodeAt(0)\n const isDigit = code >= 48 && code <= 57\n const isUpper = code >= 65 && code <= 90\n const isLower = code >= 97 && code <= 122\n return isDigit || isUpper || isLower || ch === '_'\n}\n\n/** 跳过空白 */\nconst skipSpaces = (source: string, index: number): number => {\n let i = index\n while (chAt(source, i) === ' ') i += 1\n return i\n}\n\n/**\n * 解析一个「来源」:单个名称,或 `(a | b)` 形式的备选组。\n *\n * @param source 表达式原文\n * @param start 起始下标\n * @returns 备选名称与下一个待读下标\n * @throws {MtContentExprError} 语法非法时抛出\n */\nconst parseSource = (source: string, start: number): { alternatives: string[]; next: number } => {\n let i = skipSpaces(source, start)\n\n if (chAt(source, i) === '(') {\n const alternatives: string[] = []\n i += 1\n for (;;) {\n i = skipSpaces(source, i)\n const nameStart = i\n while (isNameChar(chAt(source, i))) i += 1\n if (i === nameStart) {\n throw new MtContentExprError(source, `第 ${i} 个字符处缺少节点名`)\n }\n alternatives.push(source.slice(nameStart, i))\n i = skipSpaces(source, i)\n if (chAt(source, i) === '|') {\n i += 1\n continue\n }\n if (chAt(source, i) === ')') {\n i += 1\n break\n }\n throw new MtContentExprError(source, `第 ${i} 个字符处应为 '|' 或 ')'`)\n }\n return { alternatives, next: i }\n }\n\n const nameStart = i\n while (isNameChar(chAt(source, i))) i += 1\n if (i === nameStart) {\n throw new MtContentExprError(source, `第 ${i} 个字符处缺少节点名`)\n }\n return { alternatives: [source.slice(nameStart, i)], next: i }\n}\n\n/** 已解析表达式的缓存:表达式数量极少且反复用于校验热路径 */\nconst CONTENT_EXPR_CACHE = new Map<string, MtContentExpr>()\n\n/**\n * 解析一个「片段来源」:`a | b` 形式的备选集合。\n *\n * 与 {@link parseSource} 的区别在于:本函数把顶层用 `|` 串联的多个来源\n * 合并为**同一片段**的备选,从而同时支持 `a | b` 与 `(a | b)+` 两种写法。\n *\n * @param source 表达式原文\n * @param start 起始下标\n * @returns 备选名称与下一个待读下标\n * @throws {MtContentExprError} 语法非法时抛出\n */\nconst parseAlternatives = (\n source: string,\n start: number,\n): { alternatives: string[]; next: number } => {\n const alternatives: string[] = []\n let i = start\n\n for (;;) {\n const one = parseSource(source, i)\n alternatives.push(...one.alternatives)\n i = skipSpaces(source, one.next)\n if (chAt(source, i) !== '|') {\n return { alternatives, next: i }\n }\n i += 1\n }\n}\n\n/**\n * 解析 content 表达式(带缓存)。\n *\n * @param raw 表达式原文,如 `'paragraph block*'`\n * @returns 解析结果\n * @throws {MtContentExprError} 语法非法时抛出\n *\n * @example\n * ```ts\n * const expr = parseContentExpr('(tableCell | tableHeader)+')\n * expr.pieces[0]?.alternatives // ['tableCell', 'tableHeader']\n * expr.pieces[0]?.quantifier // '+'\n * ```\n */\nexport const parseContentExpr = (raw: string): MtContentExpr => {\n const cached = CONTENT_EXPR_CACHE.get(raw)\n if (cached) return cached\n\n const pieces: MtContentPiece[] = []\n let i = 0\n let guard = 0\n\n while (i < raw.length) {\n i = skipSpaces(raw, i)\n if (i >= raw.length) break\n const { alternatives, next } = parseAlternatives(raw, i)\n i = next\n const quantifierChar = chAt(raw, i)\n let quantifier: MtQuantifier = ''\n if (quantifierChar === '?' || quantifierChar === '*' || quantifierChar === '+') {\n quantifier = quantifierChar\n i += 1\n }\n pieces.push({ alternatives, quantifier })\n guard += 1\n if (guard > 64) {\n throw new MtContentExprError(raw, '片段数量异常,疑似表达式构造有误')\n }\n }\n\n if (pieces.length === 0) {\n throw new MtContentExprError(raw, '表达式为空')\n }\n\n const expr: MtContentExpr = { raw, pieces }\n CONTENT_EXPR_CACHE.set(raw, expr)\n return expr\n}\n\n/**\n * 判断一段子节点类型序列是否满足 content 表达式。\n *\n * 采用回溯匹配:对 `*` / `+` 片段先尽量多吃,再逐个回退,\n * 语义与正则全量匹配一致(要求**恰好**消费完整个序列)。\n *\n * @param childTypes 子节点类型名序列\n * @param expr 已解析的表达式\n * @param matcher 名称匹配器,见 {@link MtNameMatcher}\n * @returns 是否匹配\n *\n * @example\n * ```ts\n * const expr = parseContentExpr('paragraph block*')\n * const ok = matchesContentExpr(['paragraph', 'bulletList'], expr, nodeMatchesName)\n * // ok === true\n * ```\n */\nexport const matchesContentExpr = (\n childTypes: readonly string[],\n expr: MtContentExpr,\n matcher: MtNameMatcher,\n): boolean => {\n const { pieces } = expr\n const total = childTypes.length\n\n const matchesAt = (piece: MtContentPiece, index: number): boolean => {\n const type = childTypes[index]\n if (type === undefined) return false\n return piece.alternatives.some((name) => matcher(type, name))\n }\n\n const walk = (pieceIndex: number, typeIndex: number): boolean => {\n const piece = pieces[pieceIndex]\n if (piece === undefined) return typeIndex === total\n\n if (piece.quantifier === '') {\n // 恰好一次:必须先消费,且只消费一个\n if (!matchesAt(piece, typeIndex)) return false\n return walk(pieceIndex + 1, typeIndex + 1)\n }\n\n if (piece.quantifier === '?') {\n if (matchesAt(piece, typeIndex) && walk(pieceIndex + 1, typeIndex + 1)) return true\n return walk(pieceIndex + 1, typeIndex)\n }\n\n let max = 0\n while (matchesAt(piece, typeIndex + max)) max += 1\n const min = piece.quantifier === '+' ? 1 : 0\n for (let count = max; count >= min; count -= 1) {\n if (walk(pieceIndex + 1, typeIndex + count)) return true\n }\n return false\n }\n\n return walk(0, 0)\n}\n\n/**\n * 名称匹配器:判断某个节点类型是否满足表达式中的一个名称。\n *\n * 名称既可能是组名也可能是具体节点名,由调用方注入解析规则,\n * 从而让本模块保持与 `schema/nodes.ts` 解耦。\n */\nexport type MtNameMatcher = (nodeType: string, name: string) => boolean\n","/**\n * schema 层出口:节点/标记元信息与 `content` 表达式引擎。\n */\n\nexport {\n getNodeSpec,\n MT_GROUP_MEMBERS,\n MT_NODE_SPEC_MAP,\n MT_NODE_SPECS,\n nodeMatchesName,\n nodesInGroup,\n} from './nodes'\nexport type { MtNodeGroup, MtNodeSpec } from './nodes'\n\nexport {\n compareMarkPriority,\n getMarkSpec,\n MT_LINK_DEFAULT_REL,\n MT_LINK_DEFAULT_TARGET,\n MT_MARK_SPEC_MAP,\n MT_MARK_SPECS,\n} from './marks'\nexport type { MtMarkSpec } from './marks'\n\nexport { matchesContentExpr, MtContentExprError, parseContentExpr } from './content-expression'\nexport type {\n MtContentExpr,\n MtContentPiece,\n MtNameMatcher,\n MtQuantifier,\n} from './content-expression'\n\nimport { matchesContentExpr, parseContentExpr } from './content-expression'\nimport { getNodeSpec, nodeMatchesName } from './nodes'\n\n/**\n * 判断某节点的子节点类型序列是否满足其规格中声明的 content 表达式。\n *\n * atom 节点(`content === null`)仅在其**无子节点**时通过。\n *\n * @param nodeType 父节点类型名\n * @param childTypes 子节点类型名序列\n * @returns 是否匹配;父节点类型未知时返回 `false`\n *\n * @example\n * ```ts\n * contentMatches('heading', ['text']) // true\n * contentMatches('heading', ['paragraph']) // false(inline* 不接受 block)\n * contentMatches('image', []) // true(atom)\n * ```\n */\nexport const contentMatches = (nodeType: string, childTypes: readonly string[]): boolean => {\n const spec = getNodeSpec(nodeType)\n if (!spec) return false\n if (spec.content === null) return childTypes.length === 0\n return matchesContentExpr(childTypes, parseContentExpr(spec.content), nodeMatchesName)\n}\n","/**\n * 降级码与校验码常量表。\n *\n * 码值一经发布**不可更改语义**,只允许追加。\n */\n\n/** 降级码 */\nexport const MT_DEG = {\n /** 标签不在小程序白名单,被解包或丢弃 */\n TAG_NOT_ALLOWED: 'MT_DEG_001',\n /** 节点在目标端无法表达(如 `video` 在 `rich-text`) */\n UNSUPPORTED_NODE: 'MT_DEG_002',\n /** 属性被丢弃(`id`、`class`、`colspan` 等) */\n ATTR_DROPPED: 'MT_DEG_003',\n /** 格式转换有损(Markdown 目标) */\n LOSSY_CONVERSION: 'MT_DEG_004',\n /** 缺少预渲染产物(`math.svg` / 代码高亮) */\n MISSING_PRERENDER: 'MT_DEG_005',\n /** 冲突标记被归一化 */\n MARK_CONFLICT: 'MT_DEG_006',\n /** 危险 URL 协议被置空 */\n DANGEROUS_URL: 'MT_DEG_007',\n /** `emoji` 的 unicode 在当前运行时无法正确拼合 */\n EMOJI_UNSUPPORTED: 'MT_DEG_008',\n /** 未知标记类型被丢弃 */\n UNKNOWN_MARK: 'MT_DEG_009',\n /** 不允许标记的位置上出现的标记被清除 */\n MARK_NOT_ALLOWED: 'MT_DEG_010',\n /** 空容器节点无内容,已补占位或丢弃 */\n EMPTY_CONTAINER: 'MT_DEG_011',\n} as const\n\n/** 校验码 */\nexport const MT_DOC = {\n /** 未知节点类型 */\n UNKNOWN_NODE: 'MT_DOC_001',\n /** 缺少必填属性 */\n MISSING_ATTR: 'MT_DOC_002',\n /** `content` 不匹配内容表达式 */\n CONTENT_MISMATCH: 'MT_DOC_003',\n /** 未知 mark 类型 */\n UNKNOWN_MARK: 'MT_DOC_004',\n /** mark 互斥冲突 */\n MARK_CONFLICT: 'MT_DOC_005',\n /** 非法 URL 协议 */\n BAD_URL: 'MT_DOC_006',\n /** 表格结构非法 */\n BAD_TABLE: 'MT_DOC_007',\n /** 不支持的 `schemaVersion` */\n UNSUPPORTED_VERSION: 'MT_DOC_008',\n /** 节点 ID 重复 */\n DUPLICATE_ID: 'MT_DOC_009',\n /** `codeBlock` 内出现 mark */\n MARKS_IN_CODE_BLOCK: 'MT_DOC_010',\n} as const\n\n/** 降级码的中文释义 */\nexport const MT_DEG_LABEL: Readonly<Record<string, string>> = {\n [MT_DEG.TAG_NOT_ALLOWED]: '标签不在白名单,被解包或丢弃',\n [MT_DEG.UNSUPPORTED_NODE]: '节点在目标端无法表达',\n [MT_DEG.ATTR_DROPPED]: '属性被丢弃',\n [MT_DEG.LOSSY_CONVERSION]: '格式转换有损',\n [MT_DEG.MISSING_PRERENDER]: '缺少预渲染产物',\n [MT_DEG.MARK_CONFLICT]: '冲突标记被归一化',\n [MT_DEG.DANGEROUS_URL]: '危险 URL 协议被置空',\n [MT_DEG.EMOJI_UNSUPPORTED]: 'emoji 在当前运行时无法正确拼合',\n [MT_DEG.UNKNOWN_MARK]: '未知标记类型被丢弃',\n [MT_DEG.MARK_NOT_ALLOWED]: '该位置不允许标记,标记被清除',\n [MT_DEG.EMPTY_CONTAINER]: '空容器节点无内容,已补占位或丢弃',\n}\n\n/** 校验码的中文释义 */\nexport const MT_DOC_LABEL: Readonly<Record<string, string>> = {\n [MT_DOC.UNKNOWN_NODE]: '未知节点类型',\n [MT_DOC.MISSING_ATTR]: '缺少必填属性',\n [MT_DOC.CONTENT_MISMATCH]: 'content 不匹配内容表达式',\n [MT_DOC.UNKNOWN_MARK]: '未知 mark 类型',\n [MT_DOC.MARK_CONFLICT]: 'mark 互斥冲突',\n [MT_DOC.BAD_URL]: '非法 URL 协议',\n [MT_DOC.BAD_TABLE]: '表格结构非法',\n [MT_DOC.UNSUPPORTED_VERSION]: '不支持的 schemaVersion',\n [MT_DOC.DUPLICATE_ID]: '节点 ID 重复',\n [MT_DOC.MARKS_IN_CODE_BLOCK]: 'codeBlock 内出现 mark',\n}\n","/**\n * 降级报告与校验问题的构造工具。\n *\n * 统一走构造函数而非到处手写字面量,保证 `code` 与 `message` 永远配套,\n * 也便于后续按码做国际化。\n */\n\nimport type { MtDegradationStrategy, MtIssueSeverity } from '../types/base'\nimport type { MtDegradationEntry, MtValidationIssue } from '../types/report'\nimport { MT_DEG_LABEL, MT_DOC_LABEL } from './codes'\n\n/**\n * 构造一条降级报告。\n *\n * @param code 降级码,如 `MT_DEG_003`\n * @param type 受影响的节点/标记类型\n * @param path 文档内定位路径\n * @param message 具体说明(中文)\n * @param strategy 采取的策略\n * @returns 降级条目\n *\n * @example\n * ```ts\n * degrade(MT_DEG.DANGEROUS_URL, 'link', 'content[0].content[1]', 'javascript: 被置空', 'dropped')\n * ```\n */\nexport const degrade = (\n code: string,\n type: string,\n path: string,\n message: string,\n strategy: MtDegradationStrategy,\n): MtDegradationEntry => ({\n code,\n type,\n path,\n message: `${MT_DEG_LABEL[code] ?? code}:${message}`,\n strategy,\n})\n\n/**\n * 构造一条校验问题。\n *\n * @param code 校验码,如 `MT_DOC_003`\n * @param level 严重级别\n * @param path 文档内定位路径\n * @param message 具体说明(中文)\n * @param handled 已自动采取的处理\n * @returns 校验问题\n *\n * @example\n * ```ts\n * issue(MT_DOC.CONTENT_MISMATCH, 'error', 'content[1]', 'heading 只能包含行内节点')\n * ```\n */\nexport const issue = (\n code: string,\n level: MtIssueSeverity,\n path: string,\n message: string,\n handled?: string,\n): MtValidationIssue => ({\n code,\n level,\n path,\n message: `${MT_DOC_LABEL[code] ?? code}:${message}`,\n ...(handled === undefined ? {} : { handled }),\n})\n","/**\n * 基础类型守卫。\n *\n * 本项目禁用 `any`,因此所有外部输入一律以 `unknown` 接收,\n * 再由本文件的守卫逐步收窄。守卫必须**永不抛异常**、**永不误判**。\n */\n\n/** 是否为非 null 的非数组对象(即「记录」) */\nexport const isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value)\n\n/** 是否为字符串 */\nexport const isString = (value: unknown): value is string => typeof value === 'string'\n\n/** 是否为非空字符串 */\nexport const isNonEmptyString = (value: unknown): value is string =>\n typeof value === 'string' && value.length > 0\n\n/** 是否为有限数字 */\nexport const isFiniteNumber = (value: unknown): value is number =>\n typeof value === 'number' && Number.isFinite(value)\n\n/** 是否为布尔值 */\nexport const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean'\n\n/** 是否为数组 */\nexport const isArray = (value: unknown): value is unknown[] => Array.isArray(value)\n\n/**\n * 是否为「节点数组」(每项都是记录)。\n *\n * @param value 待判断值\n * @returns 是否满足\n */\nexport const isNodeArray = (value: unknown): value is Array<Record<string, unknown>> =>\n Array.isArray(value) && value.every(isRecord)\n\n/**\n * 把未知值读成字符串,非字符串一律返回 `undefined`。\n *\n * @param value 待读值\n * @returns 字符串或 `undefined`\n */\nexport const readString = (value: unknown): string | undefined =>\n typeof value === 'string' ? value : undefined\n\n/**\n * 把未知值读成有限数字,其余返回 `undefined`。\n *\n * @param value 待读值\n * @returns 数字或 `undefined`\n */\nexport const readNumber = (value: unknown): number | undefined =>\n isFiniteNumber(value) ? value : undefined\n\n/**\n * 把未知值读成布尔值,其余返回 `undefined`。\n *\n * @param value 待读值\n * @returns 布尔值或 `undefined`\n */\nexport const readBoolean = (value: unknown): boolean | undefined =>\n typeof value === 'boolean' ? value : undefined\n\n/**\n * 在一个有限取值集合内读取字符串枚举值。\n *\n * @param value 待读值\n * @param allowed 允许的取值集合\n * @returns 命中的值,或 `undefined`\n *\n * @example\n * ```ts\n * readEnum('center', ['left', 'center', 'right']) // 'center'\n * readEnum('middle', ['left', 'center', 'right']) // undefined\n * ```\n */\nexport const readEnum = <T extends string>(\n value: unknown,\n allowed: readonly T[],\n): T | undefined => {\n if (typeof value !== 'string') return undefined\n return allowed.includes(value as T) ? (value as T) : undefined\n}\n","/**\n * URL 协议白名单校验。\n *\n * 规则:\n * - 允许 `http:` `https:` `mailto:` `tel:`,以及无协议的相对地址与 `#锚点`;\n * - 仅当显式开启 `allowDataImage` 时放行 `data:image/<白名单 MIME>`;\n * - 其余协议(`javascript:` `vbscript:` `file:` 等)一律判为不安全。\n *\n * 攻击面注意:浏览器会忽略协议名中的控制字符与空白(`java\\tscript:`),\n * 因此必须在判定前把这些字符剥离,否则白名单会被轻易绕过。\n */\n\n/** 允许的协议 */\nconst ALLOWED_SCHEMES: readonly string[] = ['http', 'https', 'mailto', 'tel']\n\n/** 允许的 `data:` 图片 MIME(刻意排除 `image/svg+xml`:SVG 可内嵌脚本) */\nconst ALLOWED_DATA_IMAGE_MIMES: readonly string[] = [\n 'image/png',\n 'image/jpeg',\n 'image/jpg',\n 'image/gif',\n 'image/webp',\n 'image/bmp',\n 'image/avif',\n 'image/x-icon',\n 'image/vnd.microsoft.icon',\n]\n\n/** URL 校验结果 */\nexport interface MtUrlCheckResult {\n /** 是否安全 */\n ok: boolean\n /** 不安全时给出中文原因 */\n reason?: string\n}\n\n/** URL 校验选项 */\nexport interface MtUrlCheckOptions {\n /** 是否放行 `data:image/*`(仅图片节点应为 `true`) */\n allowDataImage?: boolean\n}\n\n/**\n * 剥离控制字符与首尾空白。\n *\n * @param raw 原始地址\n * @returns 归一化后的地址\n */\nconst normalizeForScheme = (raw: string): string => {\n let out = ''\n for (let i = 0; i < raw.length; i += 1) {\n const code = raw.charCodeAt(i)\n if (code <= 0x20 || code === 0x7f) continue\n out += raw.charAt(i)\n }\n return out\n}\n\n/**\n * 查找 `:` 与「路径起始字符」中更早出现的位置。\n *\n * @param source 归一化后的地址\n * @returns `{ colon, pathStart }`,未出现则为 `-1`\n */\nconst locateSchemeBoundary = (source: string): { colon: number; pathStart: number } => {\n let colon = -1\n let pathStart = -1\n for (let i = 0; i < source.length; i += 1) {\n const ch = source.charAt(i)\n if (ch === ':' && colon < 0) colon = i\n if ((ch === '/' || ch === '?' || ch === '#') && pathStart < 0) pathStart = i\n }\n return { colon, pathStart }\n}\n\n/**\n * 判断 `data:` 地址是否为白名单内的图片。\n *\n * @param source 归一化后的地址(已确认以 `data:` 开头)\n * @returns 是否放行\n */\nconst isAllowedDataImage = (source: string): boolean => {\n const payload = source.slice('data:'.length)\n if (!payload.startsWith('image/')) return false\n let end = payload.length\n for (let i = 0; i < payload.length; i += 1) {\n const ch = payload.charAt(i)\n if (ch === ';' || ch === ',') {\n end = i\n break\n }\n }\n const mime = payload.slice(0, end).toLowerCase()\n return ALLOWED_DATA_IMAGE_MIMES.includes(mime)\n}\n\n/**\n * 校验地址是否落在协议白名单内。\n *\n * @param raw 原始地址(可以是 `unknown`,非字符串一律判为不安全)\n * @param options 校验选项\n * @returns 校验结果\n *\n * @example\n * ```ts\n * checkUrl('https://example.com').ok // true\n * checkUrl('javascript:alert(1)').ok // false\n * checkUrl('java\\tscript:alert(1)').ok // false(控制字符被剥离后仍被拦下)\n * checkUrl('data:image/png;base64,AAA', { allowDataImage: true }).ok // true\n * ```\n */\nexport const checkUrl = (raw: unknown, options: MtUrlCheckOptions = {}): MtUrlCheckResult => {\n if (typeof raw !== 'string') {\n return { ok: false, reason: '地址不是字符串' }\n }\n if (raw.length === 0) return { ok: true }\n\n const source = normalizeForScheme(raw)\n if (source.length === 0) return { ok: true }\n\n const { colon, pathStart } = locateSchemeBoundary(source)\n\n // 无协议(相对地址、锚点):放行\n if (colon < 0 || (pathStart >= 0 && pathStart < colon)) return { ok: true }\n\n const scheme = source.slice(0, colon).toLowerCase()\n\n if (scheme === 'data') {\n if (options.allowDataImage === true && isAllowedDataImage(source)) return { ok: true }\n return { ok: false, reason: 'data: 协议仅允许白名单内的图片 MIME' }\n }\n\n if (ALLOWED_SCHEMES.includes(scheme)) return { ok: true }\n return { ok: false, reason: `协议 ${scheme}: 不在白名单内` }\n}\n\n/**\n * 清洗地址:不安全时返回空串。\n *\n * @param raw 原始地址\n * @param options 校验选项\n * @returns 安全地址,或空串\n *\n * @example\n * ```ts\n * sanitizeUrl('javascript:alert(1)') // ''\n * sanitizeUrl('https://a.com') // 'https://a.com'\n * ```\n */\nexport const sanitizeUrl = (raw: unknown, options: MtUrlCheckOptions = {}): string =>\n checkUrl(raw, options).ok && typeof raw === 'string' ? raw : ''\n","/**\n * 标记规范化。\n *\n * 解析外部输入(HTML / Markdown / 历史 JSON)后必须做一次规范化:\n *\n * 1. 丢弃未知标记类型(`MT_DEG_009` / `MT_DOC_004`);\n * 2. 冲突标记按「保留优先级更高者」处理(`MT_DEG_006` / `MT_DOC_005`);\n * 3. 单例标记重复施加时**合并属性**而非嵌套;\n * 4. 按优先级**降序排序**,保证跨端嵌套顺序一致\n * 5. 危险 URL 协议置空(`MT_DEG_007` / `MT_DOC_006`)。\n *\n * 本模块**不注入可选属性的默认值**(如 `link.target`),\n * 默认值一律在序列化时补,否则会破坏 `JSON → HTML → JSON` 的逐字符等价。\n */\n\nimport { MT_DEG, MT_DOC } from '../report/codes'\nimport { degrade, issue } from '../report'\nimport {\n compareMarkPriority,\n getMarkSpec,\n MT_LINK_DEFAULT_REL,\n MT_LINK_DEFAULT_TARGET,\n} from '../schema/marks'\nimport type { MtMarkType } from '../types/base'\nimport type { MtLinkAttrs, MtMark, MtTextStyleAttrs, MtTextStyleMark } from '../types/marks'\nimport type { MtDegradationEntry, MtValidationIssue } from '../types/report'\nimport { isRecord, readString } from '../utils/guards'\nimport { checkUrl } from '../utils/url'\n\n/** 规范化过程中的收集器与上下文 */\nexport interface MtNormalizeContext {\n /** 当前文本节点的文档路径 */\n path: string\n /** 该位置是否允许携带标记(`codeBlock` 内为 `false`) */\n marksAllowed: boolean\n /** 降级报告收集器 */\n degradations: MtDegradationEntry[]\n /** 校验问题收集器 */\n issues: MtValidationIssue[]\n}\n\n/** `textStyle` 允许的样式属性名 */\nconst TEXT_STYLE_ATTR_NAMES: readonly string[] = [\n 'fontFamily',\n 'fontSize',\n 'color',\n 'backgroundColor',\n 'lineHeight',\n 'letterSpacing',\n]\n\n/** 归一化后的标记集合 */\nexport interface MtNormalizeMarksResult {\n /** 已排序、已去重、已消解冲突的标记 */\n marks: MtMark[]\n}\n\n/**\n * 合并两个 `textStyle` 的属性:以先到者为先,空缺由后者补齐。\n *\n * @param base 先到的属性\n * @param extra 后到的属性\n * @returns 合并结果\n */\nconst mergeTextStyleAttrs = (base: MtTextStyleAttrs, extra: MtTextStyleAttrs): MtTextStyleAttrs => {\n const merged: MtTextStyleAttrs = { ...base }\n for (const name of TEXT_STYLE_ATTR_NAMES) {\n const key = name as keyof MtTextStyleAttrs\n const current = merged[key]\n if (current === undefined || current === null) {\n const incoming = extra[key]\n if (incoming !== undefined && incoming !== null) merged[key] = incoming\n }\n }\n return merged\n}\n\n/**\n * 把未知值收窄为带属性的标记。\n *\n * @param type 标记类型\n * @param attrs 已收窄的属性对象\n * @returns 标记对象\n */\nconst buildMark = (type: MtMarkType, attrs: Record<string, string>): MtMark => {\n switch (type) {\n case 'link': {\n // `MT_MARK_SPECS.link.allowedAttrs` 声明了四个属性,此处必须一致地保留它们,\n // 否则 `target` / `rel` / `title` 会在规范化阶段被静默丢弃\n const linkAttrs: MtLinkAttrs = { href: attrs.href ?? '' }\n if (attrs.target !== undefined) linkAttrs.target = attrs.target\n if (attrs.rel !== undefined) linkAttrs.rel = attrs.rel\n if (attrs.title !== undefined) linkAttrs.title = attrs.title\n return { type: 'link', attrs: linkAttrs }\n }\n case 'highlight':\n return { type: 'highlight', attrs: { color: attrs.color ?? '' } }\n case 'textStyle': {\n const styleAttrs: MtTextStyleAttrs = {}\n for (const name of TEXT_STYLE_ATTR_NAMES) {\n const value = attrs[name]\n if (value !== undefined) {\n ;(styleAttrs as Record<string, unknown>)[name] = value\n }\n }\n return { type: 'textStyle', attrs: styleAttrs }\n }\n case 'code':\n return { type: 'code' }\n case 'bold':\n return { type: 'bold' }\n case 'italic':\n return { type: 'italic' }\n case 'underline':\n return { type: 'underline' }\n case 'strike':\n return { type: 'strike' }\n case 'subscript':\n return { type: 'subscript' }\n case 'superscript':\n return { type: 'superscript' }\n default:\n return { type: 'bold' }\n }\n}\n\n/**\n * 收窄单个标记的属性,并返回是否可接受。\n *\n * @param type 标记类型\n * @param rawAttrs 原始属性对象\n * @param ctx 规范化上下文\n * @returns `{ attrs, ok }`;`ok === false` 表示应丢弃该标记\n */\nconst coerceMarkAttrs = (\n type: MtMarkType,\n rawAttrs: Record<string, unknown>,\n ctx: MtNormalizeContext,\n): { attrs: Record<string, string>; ok: boolean } => {\n const attrs: Record<string, string> = {}\n\n if (type === 'link') {\n const href = readString(rawAttrs.href)\n // 空串与缺失等价:无地址的链接没有意义,且会留下 validate 无法接受的 `href: ''`\n if (href === undefined || href.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'link',\n ctx.path,\n '缺少必填属性 href,已丢弃该链接标记',\n 'dropped',\n ),\n )\n ctx.issues.push(\n issue(MT_DOC.MISSING_ATTR, 'error', ctx.path, 'link 缺少必填属性 href', '已丢弃该 mark'),\n )\n return { attrs, ok: false }\n }\n const verdict = checkUrl(href)\n if (!verdict.ok) {\n // 危险协议必须让整条链接标记退化为纯文本:置空 href 会留下一个\n // `href: ''` 的非法 link(validate 层会判为缺少必填属性)。\n ctx.degradations.push(\n degrade(\n MT_DEG.DANGEROUS_URL,\n 'link',\n ctx.path,\n `href=\"${href}\"(${verdict.reason ?? ''}),已丢弃该链接标记`,\n 'dropped',\n ),\n )\n ctx.issues.push(\n issue(\n MT_DOC.BAD_URL,\n 'error',\n ctx.path,\n `link.href 协议非法:${verdict.reason ?? ''}`,\n '已丢弃该 mark',\n ),\n )\n return { attrs, ok: false }\n }\n attrs.href = href\n // 与序列化侧「强制补默认值」对称:等于默认值的取值归一为省略\n const target = readString(rawAttrs.target)\n if (target !== undefined && target !== MT_LINK_DEFAULT_TARGET) attrs.target = target\n const rel = readString(rawAttrs.rel)\n if (rel !== undefined && rel !== MT_LINK_DEFAULT_REL) attrs.rel = rel\n const title = readString(rawAttrs.title)\n if (title !== undefined) attrs.title = title\n return { attrs, ok: true }\n }\n\n if (type === 'highlight') {\n const color = readString(rawAttrs.color)\n if (color === undefined) {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'highlight',\n ctx.path,\n '缺少必填属性 color,已丢弃该高亮标记',\n 'dropped',\n ),\n )\n ctx.issues.push(\n issue(\n MT_DOC.MISSING_ATTR,\n 'error',\n ctx.path,\n 'highlight 缺少必填属性 color',\n '已丢弃该 mark',\n ),\n )\n return { attrs, ok: false }\n }\n attrs.color = color\n return { attrs, ok: true }\n }\n\n if (type === 'textStyle') {\n for (const name of TEXT_STYLE_ATTR_NAMES) {\n const value = readString(rawAttrs[name])\n if (value !== undefined) attrs[name] = value\n }\n if (Object.keys(attrs).length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'textStyle',\n ctx.path,\n 'textStyle 全部属性为空,已丢弃',\n 'dropped',\n ),\n )\n return { attrs, ok: false }\n }\n return { attrs, ok: true }\n }\n\n return { attrs, ok: true }\n}\n\n/**\n * 把单个原始值收窄为一个标记。\n *\n * @param entry 原始值\n * @param ctx 规范化上下文\n * @returns 标记对象;不可接受时返回 `undefined`\n */\nconst coerceMark = (entry: unknown, ctx: MtNormalizeContext): MtMark | undefined => {\n if (!isRecord(entry)) return undefined\n const type = entry.type\n if (typeof type !== 'string') return undefined\n\n const spec = getMarkSpec(type)\n if (!spec) {\n ctx.degradations.push(\n degrade(MT_DEG.UNKNOWN_MARK, type, ctx.path, `未知标记类型 '${type}'`, 'dropped'),\n )\n ctx.issues.push(\n issue(MT_DOC.UNKNOWN_MARK, 'error', ctx.path, `未知 mark 类型 '${type}'`, '已丢弃该 mark'),\n )\n return undefined\n }\n\n const rawAttrs = isRecord(entry.attrs) ? entry.attrs : {}\n const { attrs, ok } = coerceMarkAttrs(type as MtMarkType, rawAttrs, ctx)\n if (!ok) return undefined\n return buildMark(type as MtMarkType, attrs)\n}\n\n/**\n * 消解互斥冲突并去重(输入须已按优先级降序排好)。\n *\n * @param marks 已排序的标记\n * @param ctx 规范化上下文\n * @returns 消解后的标记\n */\nconst resolveConflicts = (marks: readonly MtMark[], ctx: MtNormalizeContext): MtMark[] => {\n const kept: MtMark[] = []\n\n for (const mark of marks) {\n const spec = getMarkSpec(mark.type)\n if (!spec) continue\n\n const sameType = kept.find((item) => item.type === mark.type)\n if (sameType) {\n // 单例重复:合并 textStyle 属性,其余直接跳过\n if (sameType.type === 'textStyle' && mark.type === 'textStyle') {\n const base = sameType.attrs as Record<string, string | undefined>\n const extra = mark.attrs as Record<string, string | undefined>\n // 同名属性取值不同时「以先到者为先」,被舍弃的值必须留痕\n const overridden = Object.keys(extra).filter(\n (key) => base[key] !== undefined && base[key] !== extra[key],\n )\n if (overridden.length > 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MARK_CONFLICT,\n 'textStyle',\n ctx.path,\n `多次施加 textStyle,属性 ${overridden.join('、')} 冲突,已保留先到值`,\n 'replaced',\n ),\n )\n }\n const merged = mergeTextStyleAttrs(sameType.attrs, mark.attrs)\n if (JSON.stringify(merged) !== JSON.stringify(sameType.attrs)) {\n ;(sameType as MtTextStyleMark).attrs = merged\n ctx.degradations.push(\n degrade(\n MT_DEG.MARK_CONFLICT,\n 'textStyle',\n ctx.path,\n '多次施加 textStyle,已合并属性',\n 'replaced',\n ),\n )\n }\n }\n continue\n }\n\n const conflicting = kept.find((item) => {\n const itemSpec = getMarkSpec(item.type)\n if (!itemSpec) return false\n return itemSpec.exclusive.includes(mark.type) || spec.exclusive.includes(item.type)\n })\n\n if (conflicting) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MARK_CONFLICT,\n mark.type,\n ctx.path,\n `与 ${conflicting.type} 互斥,保留优先级更高的 ${conflicting.type}`,\n 'dropped',\n ),\n )\n ctx.issues.push(\n issue(\n MT_DOC.MARK_CONFLICT,\n 'error',\n ctx.path,\n `${mark.type} 与 ${conflicting.type} 互斥`,\n `已保留 ${conflicting.type}`,\n ),\n )\n continue\n }\n\n kept.push(mark)\n }\n\n return kept\n}\n\n/**\n * 规范化一段文本节点的 `marks`。\n *\n * @param raw 原始 `marks` 值(可为 `unknown`)\n * @param ctx 规范化上下文(收集器会被就地写入)\n * @returns 规范化结果\n *\n * @example\n * ```ts\n * const ctx = { path: 'content[0].content[0]', marksAllowed: true, degradations: [], issues: [] }\n * normalizeMarks([\n * { type: 'textStyle', attrs: { color: 'red' } },\n * { type: 'bold' },\n * ], ctx).marks.map((m) => m.type) // ['textStyle', 'bold'](按优先级降序)\n * ```\n */\nexport const normalizeMarks = (raw: unknown, ctx: MtNormalizeContext): MtNormalizeMarksResult => {\n if (!Array.isArray(raw) || raw.length === 0) return { marks: [] }\n\n if (!ctx.marksAllowed) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MARK_NOT_ALLOWED,\n 'text',\n ctx.path,\n '该容器不允许携带标记(如 codeBlock),已清除全部标记',\n 'dropped',\n ),\n )\n ctx.issues.push(\n issue(\n MT_DOC.MARKS_IN_CODE_BLOCK,\n 'warning',\n ctx.path,\n 'codeBlock 内出现 mark',\n '已清除 marks',\n ),\n )\n return { marks: [] }\n }\n\n const coerced: MtMark[] = []\n for (const entry of raw) {\n const mark = coerceMark(entry, ctx)\n if (mark) coerced.push(mark)\n }\n\n coerced.sort((a, b) => compareMarkPriority(a.type, b.type))\n return { marks: resolveConflicts(coerced, ctx) }\n}\n","/**\n * 稳定的 JSON 序列化(键名递归排序)。\n *\n * 用途:比较两个 `attrs` / `marks` 是否**语义相等**。\n * 直接 `JSON.stringify` 的结果受键插入顺序影响,会把\n * `{ a: 1, b: 2 }` 与 `{ b: 2, a: 1 }` 判为不等,导致标记合并与\n * 往返校验出现假阳性。\n */\n\nimport { isRecord } from './guards'\n\n/**\n * 序列化为键名有序的 JSON 字符串。\n *\n * @param value 待序列化值\n * @returns 稳定字符串\n *\n * @example\n * ```ts\n * stableStringify({ b: 1, a: 2 }) === stableStringify({ a: 2, b: 1 }) // true\n * ```\n */\nexport const stableStringify = (value: unknown): string => {\n if (value === null || value === undefined) return 'null'\n if (Array.isArray(value)) {\n return `[${value.map((item) => stableStringify(item)).join(',')}]`\n }\n if (isRecord(value)) {\n const keys = Object.keys(value).sort()\n const body = keys\n .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)\n .join(',')\n return `{${body}}`\n }\n return JSON.stringify(value)\n}\n","/**\n * 行内节点收尾规范化。\n *\n * 职责:\n * 1. 丢弃 `text === ''` 的空文本节点;\n * 2. `marks: []` 归一为**省略该字段**;\n * 3. 相邻且标记完全相同的 `text` **合并**为一个。\n *\n * 规范化在 `parse` 产出节点后调用,是 `JSON → HTML → JSON`\n * 逐字符等价的关键一环:合并与省略规则必须与序列化器对称。\n */\n\nimport type { MtInlineNode, MtTextNode } from '../types/nodes'\nimport { stableStringify } from '../utils/stable-json'\n\n/** 取文本节点的标记「指纹」,用于判断相邻节点能否合并 */\nconst marksKey = (node: MtTextNode): string => {\n const marks = node.marks\n if (!marks || marks.length === 0) return '[]'\n return stableStringify(marks)\n}\n\n/** 原地清理单个文本节点:空 `marks` 数组归一为省略 */\nconst tidyMarks = (node: MtTextNode): MtTextNode => {\n if (node.marks && node.marks.length === 0) {\n const { marks: _marks, ...rest } = node\n return rest\n }\n return node\n}\n\n/**\n * 收尾规范化一段行内节点序列。\n *\n * @param nodes 原始行内节点序列\n * @returns 规范化后的序列(新数组,不修改入参)\n *\n * @example\n * ```ts\n * finalizeInlineNodes([\n * { type: 'text', text: 'a', marks: [{ type: 'bold' }] },\n * { type: 'text', text: 'b', marks: [{ type: 'bold' }] },\n * ])\n * // [{ type: 'text', text: 'ab', marks: [{ type: 'bold' }] }]\n * ```\n */\nexport const finalizeInlineNodes = (nodes: readonly MtInlineNode[]): MtInlineNode[] => {\n /** @type {MtInlineNode[]} */\n const out: MtInlineNode[] = []\n\n for (const node of nodes) {\n if (node.type !== 'text') {\n out.push(node)\n continue\n }\n\n if (node.text.length === 0) continue\n\n const tidied = tidyMarks(node)\n const previous = out[out.length - 1]\n\n if (\n previous !== undefined &&\n previous.type === 'text' &&\n marksKey(previous) === marksKey(tidied)\n ) {\n // 标记完全一致:并入前一个节点,保持 JSON 体积最小\n out[out.length - 1] = { ...previous, text: previous.text + tidied.text }\n continue\n }\n\n out.push(tidied)\n }\n\n return out\n}\n\n/**\n * 判断两个文本节点的标记是否完全相同。\n *\n * @param a 文本节点 A\n * @param b 文本节点 B\n * @returns 是否相同\n */\nexport const hasSameMarks = (a: MtTextNode, b: MtTextNode): boolean => marksKey(a) === marksKey(b)\n","/**\n * HTML 文本/属性的转义与实体解码(零 DOM、零正则)。\n *\n * 为什么不用正则:明确「禁止正则清洗 HTML」,本文件服务于\n * `parse` / `serialize` 的 HTML 通道,因此全部采用字符扫描实现。\n *\n * 往返约定:\n * - 序列化时只转义**会破坏解析**的字符(`&` `<` `>`,属性额外转义 `\"`);\n * - `\\u00a0`(不换行空格)等字符原样输出为 UTF-8,不再还原为实体,\n * 以保证 `JSON → HTML → JSON` 逐字符等价。\n */\n\n/** 文本节点需要转义的字符 */\nconst TEXT_ESCAPES: ReadonlyMap<string, string> = new Map([\n ['&', '&'],\n ['<', '<'],\n ['>', '>'],\n])\n\n/** 属性值需要额外转义的字符 */\nconst ATTR_ESCAPES: ReadonlyMap<string, string> = new Map([\n ['&', '&'],\n ['<', '<'],\n ['>', '>'],\n ['\"', '"'],\n])\n\n/**\n * 按给定映射表逐字符替换。\n *\n * @param source 源字符串\n * @param table 替换表\n * @returns 替换结果\n */\nconst escapeWith = (source: string, table: ReadonlyMap<string, string>): string => {\n let out = ''\n let changed = false\n for (let i = 0; i < source.length; i += 1) {\n const ch = source.charAt(i)\n const replacement = table.get(ch)\n if (replacement === undefined) {\n out += ch\n } else {\n out += replacement\n changed = true\n }\n }\n return changed ? out : source\n}\n\n/**\n * 转义文本内容。\n *\n * @param text 原始文本\n * @returns 可安全嵌入 HTML 文本位置的字符串\n *\n * @example\n * ```ts\n * escapeHtml('<b>1 & 2</b>') // '<b>1 & 2</b>'\n * ```\n */\nexport const escapeHtml = (text: string): string => escapeWith(text, TEXT_ESCAPES)\n\n/**\n * 转义属性值(额外处理双引号,序列化时属性统一用双引号包裹)。\n *\n * @param value 原始属性值\n * @returns 可安全嵌入 `attr=\"...\"` 的字符串\n *\n * @example\n * ```ts\n * escapeAttr('a\"b') // 'a"b'\n * ```\n */\nexport const escapeAttr = (value: string): string => escapeWith(value, ATTR_ESCAPES)\n\n/** 具名实体表(覆盖排版常用集合;未收录者按原样保留) */\nconst NAMED_ENTITIES: ReadonlyMap<string, string> = new Map([\n ['amp', '&'],\n ['lt', '<'],\n ['gt', '>'],\n ['quot', '\"'],\n ['apos', \"'\"],\n ['nbsp', '\\u00a0'],\n ['copy', '\\u00a9'],\n ['reg', '\\u00ae'],\n ['trade', '\\u2122'],\n ['hellip', '\\u2026'],\n ['mdash', '\\u2014'],\n ['ndash', '\\u2013'],\n ['laquo', '\\u00ab'],\n ['raquo', '\\u00bb'],\n ['ldquo', '\\u201c'],\n ['rdquo', '\\u201d'],\n ['lsquo', '\\u2018'],\n ['rsquo', '\\u2019'],\n ['middot', '\\u00b7'],\n ['bull', '\\u2022'],\n ['sect', '\\u00a7'],\n ['para', '\\u00b6'],\n ['deg', '\\u00b0'],\n ['plusmn', '\\u00b1'],\n ['times', '\\u00d7'],\n ['divide', '\\u00f7'],\n ['frac12', '\\u00bd'],\n ['sup2', '\\u00b2'],\n ['sup3', '\\u00b3'],\n ['euro', '\\u20ac'],\n ['pound', '\\u00a3'],\n ['yen', '\\u00a5'],\n ['cent', '\\u00a2'],\n ['rarr', '\\u2192'],\n ['larr', '\\u2190'],\n ['harr', '\\u2194'],\n ['ne', '\\u2260'],\n ['le', '\\u2264'],\n ['ge', '\\u2265'],\n])\n\n/** 实体名最大长度,超出即判定为非法实体并原样保留 */\nconst MAX_ENTITY_NAME_LENGTH = 32\n\n/**\n * 解析一个数字型实体(`{` 或 `😀`)。\n *\n * @param body 实体体(不含 `&` 与 `;`)\n * @returns 解码后的字符;非法则返回 `undefined`\n */\nconst decodeNumericEntity = (body: string): string | undefined => {\n const isHex = body.charAt(1) === 'x' || body.charAt(1) === 'X'\n const digits = isHex ? body.slice(2) : body.slice(1)\n if (digits.length === 0) return undefined\n\n let code = 0\n for (let i = 0; i < digits.length; i += 1) {\n const ch = digits.charAt(i)\n const codePoint = ch.charCodeAt(0)\n let digit = -1\n if (codePoint >= 48 && codePoint <= 57) digit = codePoint - 48\n else if (isHex && codePoint >= 97 && codePoint <= 102) digit = codePoint - 87\n else if (isHex && codePoint >= 65 && codePoint <= 70) digit = codePoint - 55\n if (digit < 0) return undefined\n code = code * (isHex ? 16 : 10) + digit\n if (code > 0x10ffff) return undefined\n }\n\n // 代理区码点与 0 视为非法,直接保留原文,避免产出破损字符\n if (code === 0 || (code >= 0xd800 && code <= 0xdfff)) return undefined\n return String.fromCodePoint(code)\n}\n\n/**\n * 解码 HTML 实体。\n *\n * @param text 含实体的文本\n * @returns 解码后的文本;无法识别的实体原样保留\n *\n * @example\n * ```ts\n * decodeEntities('a & b © 😀') // 'a & b © 😀'\n * ```\n */\nexport const decodeEntities = (text: string): string => {\n if (text.indexOf('&') < 0) return text\n\n let out = ''\n let i = 0\n while (i < text.length) {\n const ch = text.charAt(i)\n if (ch !== '&') {\n out += ch\n i += 1\n continue\n }\n\n const semi = text.indexOf(';', i + 1)\n const body = semi > i ? text.slice(i + 1, semi) : ''\n\n if (semi < 0 || body.length === 0 || body.length > MAX_ENTITY_NAME_LENGTH) {\n out += ch\n i += 1\n continue\n }\n\n if (body.charAt(0) === '#') {\n const decoded = decodeNumericEntity(body)\n if (decoded === undefined) {\n out += ch\n i += 1\n continue\n }\n out += decoded\n i = semi + 1\n continue\n }\n\n const named = NAMED_ENTITIES.get(body.toLowerCase())\n if (named === undefined) {\n out += ch\n i += 1\n continue\n }\n out += named\n i = semi + 1\n }\n\n return out\n}\n","/**\n * HTML 分词器 —— **零 DOM、零正则**的手写字符扫描实现。\n *\n * 为什么必须自己写:\n * 禁止正则清洗 HTML(正则在嵌套与实体上必然出漏洞)\n * - `document` 包需在 Node 与小程序运行时执行,不能用 `DOMParser`;\n * - `tsconfig` 的 `lib` 已剔除 DOM,类型层面也拿不到任何解析器。\n *\n * 本模块**只负责语法**,不做任何安全判断(白名单在 `policy.ts` / `index.ts`)。\n * 为保证与真实浏览器尽可能一致,实现了三件事:\n *\n * 1. **原生文本元素**(`script` / `style` / `textarea` / `title`)的内容按原始\n * 文本吞掉,否则 `<script>if (a<b) {}</script>` 会被误判为标签;\n * 2. **隐式闭合**(`<p>` 遇 `<div>`、`<li>` 遇 `<li>` 等),这是外部 HTML\n * 最常见的「未闭合标签」形态;\n * 3. **注释 / doctype / 处理指令**整段跳过。\n */\n\nimport { decodeEntities } from '../utils/escape'\n\n/** 一个属性 */\nexport interface MtHtmlAttribute {\n /** 属性名,已小写 */\n name: string\n /** 属性值,已解码实体 */\n value: string\n}\n\n/** 元素节点 */\nexport interface MtHtmlElement {\n kind: 'element'\n /** 标签名,已小写 */\n tag: string\n /** 属性列表,保持出现顺序 */\n attrs: MtHtmlAttribute[]\n /** 子节点 */\n children: MtHtmlNode[]\n /**\n * 内部原始源码(仅当元素命中 {@link RAW_INNER_ATTRS} 时捕获)。\n *\n * 用途:`mathBlock` / `inlineMath` 的预渲染 SVG 需要**逐字节保留**,\n * 但它不能进 HTML 白名单(SVG 可内嵌脚本)。因此这里只保留原始字符串,\n * 由 `sanitize/svg.ts` 用独立的 SVG 白名单复验后才允许进入文档。\n */\n rawInner?: string\n}\n\n/** 文本节点 */\nexport interface MtHtmlText {\n kind: 'text'\n /** 文本内容,已解码实体 */\n value: string\n}\n\n/** 分词结果节点 */\nexport type MtHtmlNode = MtHtmlElement | MtHtmlText\n\n/** 无子节点的空元素(HTML 规范 `void elements`) */\nconst VOID_TAGS: ReadonlySet<string> = new Set([\n 'area',\n 'base',\n 'br',\n 'col',\n 'embed',\n 'hr',\n 'img',\n 'input',\n 'link',\n 'meta',\n 'param',\n 'source',\n 'track',\n 'wbr',\n])\n\n/** 内容按原始文本处理的元素(其内部不解析标签) */\nconst RAW_TEXT_TAGS: ReadonlySet<string> = new Set(['script', 'style', 'textarea', 'title'])\n\n/**\n * 命中这些属性的元素,其内部源码会被**原样捕获**到 `rawInner`。\n *\n * 目前仅用于公式容器:预渲染 SVG 必须逐字节保留,才能保证\n * `JSON → HTML → JSON` 等价。\n */\nconst RAW_INNER_ATTRS: ReadonlySet<string> = new Set(['data-mt-math', 'data-mt-math-inline'])\n\n/**\n * 使未闭合的 `p` 隐式闭合的块级标签。\n *\n * HTML 规范中 `<p>` 的结束标签可省略:其后一旦出现块级元素,\n * 当前 `p` 即被关闭,二者成为兄弟而非嵌套。\n */\nconst P_CLOSERS: readonly string[] = [\n 'address',\n 'article',\n 'aside',\n 'blockquote',\n 'details',\n 'div',\n 'dl',\n 'fieldset',\n 'figcaption',\n 'figure',\n 'footer',\n 'form',\n 'h1',\n 'h2',\n 'h3',\n 'h4',\n 'h5',\n 'h6',\n 'header',\n 'hgroup',\n 'hr',\n 'main',\n 'menu',\n 'nav',\n 'ol',\n 'p',\n 'pre',\n 'section',\n 'table',\n 'ul',\n]\n\n/** 表格结构标签:出现时需闭合的栈顶标签集合 */\nconst TABLE_PART_CLOSERS: ReadonlySet<string> = new Set([\n 'caption',\n 'colgroup',\n 'thead',\n 'tbody',\n 'tfoot',\n 'tr',\n 'td',\n 'th',\n])\n\n/**\n * 隐式闭合表:key 为**新开的标签**,value 为出现该标签时应当先行闭合的**栈顶标签**集合。\n *\n * 例:解析到 `<li>` 时,若栈顶就是 `li`,先闭合它,形成兄弟关系而非嵌套;\n * 解析到块级元素时,若栈顶是 `p`,先闭合 `p`(`<p>` 的结束标签可省略)。\n */\nconst IMPLICIT_CLOSE: ReadonlyMap<string, ReadonlySet<string>> = new Map<\n string,\n ReadonlySet<string>\n>([\n ...P_CLOSERS.map((tag): [string, ReadonlySet<string>] => [tag, new Set(['p'])]),\n ['li', new Set(['li'])],\n ['dt', new Set(['dt', 'dd'])],\n ['dd', new Set(['dt', 'dd'])],\n ['option', new Set(['option', 'optgroup'])],\n ['optgroup', new Set(['optgroup'])],\n ['caption', TABLE_PART_CLOSERS],\n ['colgroup', TABLE_PART_CLOSERS],\n ['thead', TABLE_PART_CLOSERS],\n ['tbody', TABLE_PART_CLOSERS],\n ['tfoot', TABLE_PART_CLOSERS],\n ['tr', new Set(['tr', 'td', 'th'])],\n ['td', new Set(['td', 'th'])],\n ['th', new Set(['td', 'th'])],\n])\n\n/** 嵌套深度上限:超出后不再入栈,防止病态输入造成深递归 */\nconst MAX_NESTING_DEPTH = 256\n\n/** 单次分词产出的节点数上限 */\nconst MAX_NODE_COUNT = 200_000\n\n/** 取字符;越界返回空串 */\nconst chAt = (source: string, index: number): string => source.charAt(index)\n\n/** 是否为标签名/属性名允许的字符 */\nconst isNameChar = (ch: string): boolean => {\n if (ch === '') return false\n const code = ch.charCodeAt(0)\n const isDigit = code >= 48 && code <= 57\n const isUpper = code >= 65 && code <= 90\n const isLower = code >= 97 && code <= 122\n return isDigit || isUpper || isLower || ch === '-' || ch === '_' || ch === ':'\n}\n\n/** 是否为空白字符(HTML 空白集合) */\nconst isWhitespace = (ch: string): boolean =>\n ch === ' ' || ch === '\\t' || ch === '\\n' || ch === '\\r' || ch === '\\f'\n\n/** 是否为 ASCII 字母 */\nconst isAsciiLetter = (ch: string): boolean => {\n const code = ch.charCodeAt(0)\n return (code >= 65 && code <= 90) || (code >= 97 && code <= 122)\n}\n\n/**\n * 解析起始标签。\n *\n * @param source 源文本\n * @param start `<` 所在下标\n * @returns 标签名、属性、是否自闭合,以及下一个待读下标;语法非法时返回 `undefined`\n */\nconst parseStartTag = (\n source: string,\n start: number,\n): { tag: string; attrs: MtHtmlAttribute[]; selfClosing: boolean; next: number } | undefined => {\n let i = start + 1\n const nameStart = i\n while (isNameChar(chAt(source, i))) i += 1\n const tag = source.slice(nameStart, i).toLowerCase()\n if (tag.length === 0) return undefined\n\n const attrs: MtHtmlAttribute[] = []\n const seen = new Set<string>()\n\n for (;;) {\n while (isWhitespace(chAt(source, i))) i += 1\n const ch = chAt(source, i)\n\n if (ch === '') return { tag, attrs, selfClosing: false, next: i }\n if (ch === '>') return { tag, attrs, selfClosing: false, next: i + 1 }\n if (ch === '/' && chAt(source, i + 1) === '>') {\n return { tag, attrs, selfClosing: true, next: i + 2 }\n }\n if (ch === '/') {\n i += 1\n continue\n }\n\n const attrStart = i\n while (isNameChar(chAt(source, i)) || chAt(source, i) === '.') i += 1\n if (i === attrStart) {\n // 非法字符:跳过,避免死循环\n i += 1\n continue\n }\n const name = source.slice(attrStart, i).toLowerCase()\n\n while (isWhitespace(chAt(source, i))) i += 1\n let value = ''\n if (chAt(source, i) === '=') {\n i += 1\n while (isWhitespace(chAt(source, i))) i += 1\n const quote = chAt(source, i)\n if (quote === '\"' || quote === \"'\") {\n i += 1\n const valueStart = i\n while (chAt(source, i) !== quote && chAt(source, i) !== '') i += 1\n value = source.slice(valueStart, i)\n if (chAt(source, i) === quote) i += 1\n } else {\n const valueStart = i\n while (!isWhitespace(chAt(source, i)) && chAt(source, i) !== '>' && chAt(source, i) !== '')\n i += 1\n value = source.slice(valueStart, i)\n }\n }\n\n // 重复属性以**首次**出现为准(与浏览器一致)\n if (!seen.has(name)) {\n seen.add(name)\n attrs.push({ name, value: decodeEntities(value) })\n }\n }\n}\n\n/**\n * 跳过原生文本元素的内容,定位到其结束标签之后。\n *\n * @param source 源文本\n * @param start 内容起点\n * @param tag 原生文本元素标签名\n * @returns 结束标签之后的下标;未找到结束标签时返回源文本长度\n */\nconst skipRawText = (source: string, start: number, tag: string): number => {\n const closer = `</${tag}`\n let i = start\n for (;;) {\n const found = source.toLowerCase().indexOf(closer, i)\n if (found < 0) return source.length\n const after = chAt(source, found + closer.length)\n if (after === '>' || isWhitespace(after) || after === '/') {\n const gt = source.indexOf('>', found)\n return gt < 0 ? source.length : gt + 1\n }\n i = found + closer.length\n }\n}\n\n/** 构造元素节点 */\nconst createElement = (tag: string, attrs: MtHtmlAttribute[]): MtHtmlElement => ({\n kind: 'element',\n tag,\n attrs,\n children: [],\n})\n\n/**\n * 把 HTML 源码分词为一棵未做安全过滤的节点树。\n *\n * @param source HTML 源码\n * @returns 根层节点数组\n *\n * @example\n * ```ts\n * const nodes = tokenizeHtml('<p>a<br>b</p>')\n * nodes[0] // { kind: 'element', tag: 'p', children: [text 'a', element br, text 'b'] }\n * ```\n */\nexport const tokenizeHtml = (source: string): MtHtmlNode[] => {\n const root: MtHtmlElement = createElement('#root', [])\n const stack: MtHtmlElement[] = [root]\n let nodeCount = 1\n let i = 0\n\n /** 当前应当追加子节点的父元素 */\n const parent = (): MtHtmlElement => stack[stack.length - 1] ?? root\n\n while (i < source.length && nodeCount < MAX_NODE_COUNT) {\n const ch = chAt(source, i)\n\n if (ch !== '<') {\n const next = source.indexOf('<', i)\n const end = next < 0 ? source.length : next\n const raw = source.slice(i, end)\n if (raw.length > 0) {\n parent().children.push({ kind: 'text', value: decodeEntities(raw) })\n nodeCount += 1\n }\n i = end\n continue\n }\n\n // 注释:整段跳过\n if (source.startsWith('<!--', i)) {\n const end = source.indexOf('-->', i + 4)\n i = end < 0 ? source.length : end + 3\n continue\n }\n\n // doctype / 处理指令:跳到下一个 `>`\n if (chAt(source, i + 1) === '!' || chAt(source, i + 1) === '?') {\n const end = source.indexOf('>', i)\n i = end < 0 ? source.length : end + 1\n continue\n }\n\n // 结束标签\n if (chAt(source, i + 1) === '/') {\n let j = i + 2\n const nameEnd = j\n while (isNameChar(chAt(source, j))) j += 1\n const tag = source.slice(nameEnd, j).toLowerCase()\n const end = source.indexOf('>', j)\n i = end < 0 ? source.length : end + 1\n\n // 从栈顶向下找最近的同名元素;找不到则忽略该结束标签\n for (let depth = stack.length - 1; depth >= 1; depth -= 1) {\n const element = stack[depth]\n if (element !== undefined && element.tag === tag) {\n stack.length = depth\n break\n }\n }\n continue\n }\n\n // 起始标签\n if (isAsciiLetter(chAt(source, i + 1))) {\n const parsed = parseStartTag(source, i)\n if (parsed === undefined) {\n parent().children.push({ kind: 'text', value: '<' })\n nodeCount += 1\n i += 1\n continue\n }\n\n const { tag, attrs, selfClosing, next } = parsed\n i = next\n\n // 隐式闭合:栈顶若与新标签冲突,先弹出\n const closers = IMPLICIT_CLOSE.get(tag)\n if (closers) {\n while (stack.length > 1) {\n const top = stack[stack.length - 1]\n if (top === undefined || !closers.has(top.tag)) break\n stack.length -= 1\n }\n }\n\n const element = createElement(tag, attrs)\n parent().children.push(element)\n nodeCount += 1\n\n if (selfClosing || VOID_TAGS.has(tag)) continue\n\n if (RAW_TEXT_TAGS.has(tag)) {\n // 原生文本元素:内容整体作为文本(后续会被安全策略整段丢弃)\n const contentStart = i\n i = skipRawText(source, i, tag)\n const closeStart = source.indexOf('</', contentStart)\n const innerEnd = closeStart >= 0 && closeStart < i ? closeStart : i\n element.children.push({ kind: 'text', value: source.slice(contentStart, innerEnd) })\n nodeCount += 1\n continue\n }\n\n // 公式容器:原样捕获内部源码,后续交由 SVG 白名单复验\n if (attrs.some((attr) => RAW_INNER_ATTRS.has(attr.name))) {\n const closeTag = `</${tag}`\n const lower = source.toLowerCase()\n let cursor = i\n let closeAt = -1\n for (;;) {\n const found = lower.indexOf(closeTag, cursor)\n if (found < 0) break\n const after = chAt(source, found + closeTag.length)\n if (after === '>' || isWhitespace(after) || after === '/') {\n closeAt = found\n break\n }\n cursor = found + closeTag.length\n }\n element.rawInner = closeAt < 0 ? source.slice(i) : source.slice(i, closeAt)\n if (closeAt < 0) {\n i = source.length\n } else {\n const gt = source.indexOf('>', closeAt)\n i = gt < 0 ? source.length : gt + 1\n }\n continue\n }\n\n if (stack.length < MAX_NESTING_DEPTH) stack.push(element)\n continue\n }\n\n // 孤立的 `<`:按字面文本处理\n parent().children.push({ kind: 'text', value: '<' })\n nodeCount += 1\n i += 1\n }\n\n return root.children\n}\n\n/**\n * 从元素取属性值。\n *\n * @param element 元素节点\n * @param name 属性名(小写)\n * @returns 属性值;不存在返回 `undefined`\n */\nexport const getAttribute = (element: MtHtmlElement, name: string): string | undefined => {\n for (const attr of element.attrs) {\n if (attr.name === name) return attr.value\n }\n return undefined\n}\n\n/**\n * 判断节点是否为元素。\n *\n * @param node 待判断节点\n * @returns 是否为元素\n */\nexport const isElement = (node: MtHtmlNode): node is MtHtmlElement => node.kind === 'element'\n","/**\n * 内联样式的单位与取值约定。\n *\n * 序列化器与解析器必须共用同一套约定,否则 `JSON → HTML → JSON`\n * 会在 `indent` / `colwidth` 这类数值属性上出现漂移。\n */\n\nimport type { MtMediaAlign, MtOrderedListType, MtTextAlign } from '../types/base'\n\n/** `indent` 的 CSS 单位:`indent` 为 2 时序列化为 `padding-left: 2em` */\nexport const MT_INDENT_UNIT = 'em'\n\n/** `indent` 的取值上限(0–4) */\nexport const MT_INDENT_MAX = 4\n\n/** `colwidth` 的 CSS 单位 */\nexport const MT_COLWIDTH_UNIT = 'px'\n\n/** 水平对齐的合法取值 */\nexport const MT_TEXT_ALIGN_VALUES: readonly MtTextAlign[] = ['left', 'center', 'right', 'justify']\n\n/** 媒体对齐的合法取值 */\nexport const MT_MEDIA_ALIGN_VALUES: readonly MtMediaAlign[] = ['left', 'center', 'right']\n\n/** 有序列表编号样式的合法取值 */\nexport const MT_ORDERED_TYPE_VALUES: readonly MtOrderedListType[] = ['1', 'a', 'A', 'i', 'I']\n\n/**\n * 解析「数值 + 单位」形式的 CSS 长度。\n *\n * @param value CSS 值,如 `'2em'`\n * @param unit 期望单位,如 `'em'`\n * @returns 数值;不匹配或非有限数时返回 `undefined`\n *\n * @example\n * ```ts\n * parseCssLength('2em', 'em') // 2\n * parseCssLength('32px', 'em') // undefined(单位不符)\n * ```\n */\nexport const parseCssLength = (value: string | undefined, unit: string): number | undefined => {\n if (value === undefined) return undefined\n const trimmed = value.trim().toLowerCase()\n if (!trimmed.endsWith(unit)) return undefined\n const numeric = trimmed.slice(0, trimmed.length - unit.length).trim()\n if (numeric.length === 0) return undefined\n const parsed = Number(numeric)\n return Number.isFinite(parsed) ? parsed : undefined\n}\n\n/**\n * 解析无单位数值(如 `line-height: 1.5`)。\n *\n * @param value CSS 值\n * @returns 数值;非法时返回 `undefined`\n *\n * @example\n * ```ts\n * parseUnitless('1.5') // 1.5\n * parseUnitless('normal') // undefined\n * ```\n */\nexport const parseUnitless = (value: string | undefined): number | undefined => {\n if (value === undefined) return undefined\n const trimmed = value.trim()\n if (trimmed.length === 0) return undefined\n const parsed = Number(trimmed)\n return Number.isFinite(parsed) ? parsed : undefined\n}\n\n/**\n * 把数值格式化为「数值 + 单位」的 CSS 长度。\n *\n * @param value 数值\n * @param unit 单位\n * @returns CSS 值\n *\n * @example\n * ```ts\n * formatCssLength(2, 'em') // '2em'\n * ```\n */\nexport const formatCssLength = (value: number, unit: string): string => `${value}${unit}`\n\n/**\n * 解析 `padding-left` 得到缩进层级。\n *\n * 兼容 `em`(本项目的规范写法)与 `px`(外部 HTML 常见写法,按 16px = 1 级折算)。\n *\n * @param value `padding-left` 的 CSS 值\n * @returns 缩进层级(已按 0–4 钳制);无法解析时返回 `undefined`\n */\nexport const parseIndent = (value: string | undefined): number | undefined => {\n if (value === undefined) return undefined\n const em = parseCssLength(value, 'em')\n const level =\n em !== undefined\n ? em\n : (() => {\n const px = parseCssLength(value, 'px')\n return px === undefined ? undefined : Math.round(px / 16)\n })()\n if (level === undefined) return undefined\n return Math.min(MT_INDENT_MAX, Math.max(0, Math.round(level)))\n}\n","/**\n * HTML 属性取值的解析原语。\n *\n * 这些函数把「从 HTML 上读到的字符串」翻译成 `MtDocument` 的取值域,\n * 与序列化器写出的形式严格对称——`JSON → HTML → JSON` 逐字符等价的前提,\n * 就是「写出去什么、就读回来什么,且只读回真实出现的信息」。\n *\n * 之所以独立成模块:`@mteditor/core` 的 Tiptap 属性定义必须复用**完全相同**的\n * 取值约定,否则编辑器写入的 HTML 与只读渲染器读出的 JSON 会漂移。\n * 这些函数只依赖 {@link ./style-units} 与类型定义,不触碰 DOM。\n */\n\nimport type { MtMediaAlign, MtOrderedListType, MtTextAlign } from '../types/base'\nimport {\n MT_MEDIA_ALIGN_VALUES,\n MT_ORDERED_TYPE_VALUES,\n MT_TEXT_ALIGN_VALUES,\n parseUnitless,\n} from './style-units'\n\n/**\n * 读取文本对齐。\n *\n * @param value CSS `text-align` 取值\n * @returns 对齐方式;非法或缺失时返回 `undefined`\n *\n * @example\n * ```ts\n * parseTextAlign('Center') // 'center'\n * parseTextAlign('middle') // undefined\n * ```\n */\nexport const parseTextAlign = (value: string | undefined): MtTextAlign | undefined => {\n if (value === undefined) return undefined\n const lowered = value.trim().toLowerCase()\n return MT_TEXT_ALIGN_VALUES.find((item) => item === lowered)\n}\n\n/**\n * 读取媒体对齐(取自 `data-mt-align`)。\n *\n * @param value 属性值\n * @returns 对齐方式;非法或缺失时返回 `undefined`\n *\n * @example\n * ```ts\n * parseMediaAlign('right') // 'right'\n * parseMediaAlign('justify') // undefined(媒体不支持两端对齐)\n * ```\n */\nexport const parseMediaAlign = (value: string | undefined): MtMediaAlign | undefined => {\n if (value === undefined) return undefined\n const lowered = value.trim().toLowerCase()\n return MT_MEDIA_ALIGN_VALUES.find((item) => item === lowered)\n}\n\n/**\n * 读取有序列表编号样式。\n *\n * 与 {@link parseTextAlign} 不同,这里**不转小写**——`a` 与 `A` 是两种合法取值,\n * 分别表示小写与大写字母编号。\n *\n * @param value `type` 属性值\n * @returns 编号样式;非法或缺失时返回 `undefined`\n *\n * @example\n * ```ts\n * parseOrderedType('A') // 'A'\n * parseOrderedType('x') // undefined\n * ```\n */\nexport const parseOrderedType = (value: string | undefined): MtOrderedListType | undefined => {\n if (value === undefined) return undefined\n return MT_ORDERED_TYPE_VALUES.find((item) => item === value)\n}\n\n/**\n * 读取整型属性(如 `colspan` / `start`)。\n *\n * @param value 属性值\n * @returns 截断后的整数;非法或缺失时返回 `undefined`\n *\n * @example\n * ```ts\n * parseInteger('3') // 3\n * parseInteger('2.9') // 2\n * parseInteger('abc') // undefined\n * ```\n */\nexport const parseInteger = (value: string | undefined): number | undefined => {\n if (value === undefined) return undefined\n const trimmed = value.trim()\n if (trimmed.length === 0) return undefined\n const parsed = Number(trimmed)\n if (!Number.isFinite(parsed)) return undefined\n return Math.trunc(parsed)\n}\n\n/**\n * 读取 `line-height`(无单位倍数)。\n *\n * @param value CSS `line-height` 取值\n * @returns 倍数;非法或缺失时返回 `undefined`\n *\n * @example\n * ```ts\n * parseLineHeight('1.6') // 1.6\n * parseLineHeight('normal') // undefined\n * ```\n */\nexport const parseLineHeight = (value: string | undefined): number | undefined =>\n parseUnitless(value)\n\n/**\n * 读取布尔属性(`data-mt-*` 形式的 `\"true\"` / `\"false\"`)。\n *\n * 只有显式的 `\"true\"` / `\"false\"` 才被接受——空串、`\"1\"` 一律视为非法,\n * 避免「属性存在即真」的隐式约定破坏往返等价。\n *\n * @param value 属性值\n * @returns 布尔值;非法或缺失时返回 `undefined`\n *\n * @example\n * ```ts\n * parseBooleanAttr('true') // true\n * parseBooleanAttr('FALSE') // false\n * parseBooleanAttr('') // undefined\n * ```\n */\nexport const parseBooleanAttr = (value: string | undefined): boolean | undefined => {\n if (value === undefined) return undefined\n const lowered = value.trim().toLowerCase()\n if (lowered === 'true') return true\n if (lowered === 'false') return false\n return undefined\n}\n\n/** 3 / 4 / 6 / 8 位十六进制颜色(`#abc` / `#abcd` / `#aabbcc` / `#aabbccdd`) */\nconst HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/\n\n/**\n * `rgb(r, g, b)` / `rgba(r, g, b, 1)`。\n *\n * 三个分量只接受整数或百分比;alpha 只接受 `1`(或缺省)。带真实透明度的\n * 颜色无法无损压成 `#rrggbb`,交由上层白名单判定。\n */\nconst RGB_COLOR_PATTERN =\n /^rgba?\\(\\s*(\\d{1,3}%?)\\s*,\\s*(\\d{1,3}%?)\\s*,\\s*(\\d{1,3}%?)\\s*(?:,\\s*(?:1(?:\\.0+)?)\\s*)?\\)$/\n\n/** 把 `rgb()` 的单个分量转成两位十六进制;越界或非法时返回空串 */\nconst toByteHex = (component: string): string => {\n const byte = component.endsWith('%')\n ? Math.round((Number.parseFloat(component) / 100) * 255)\n : Number.parseInt(component, 10)\n if (!Number.isFinite(byte) || byte < 0 || byte > 255) return ''\n return byte.toString(16).padStart(2, '0')\n}\n\n/** `#rgb` / `#rgba` 展开成 `#rrggbb` / `#rrggbbaa` */\nconst expandHexColor = (hex: string): string =>\n hex.length === 4 || hex.length === 5\n ? `#${hex\n .slice(1)\n .split('')\n .map((char) => char + char)\n .join('')}`\n : hex\n\n/**\n * 把 CSS 颜色归一化为规范形式:**小写、完整位数的十六进制**。\n *\n * 存在意义是**对抗 DOM 的强制归一化**:任何真实 DOM 的\n * `CSSStyleDeclaration.cssText` 都会把 `background-color: #ffec3d` 改写成\n * `background-color: rgb(255, 236, 61)`。而 `@mteditor/core` 读样式只能拿到\n * 改写后的串,且改写后含 `(`——会被 `parseInlineStyle` 的安全白名单判为\n * 不可信而**静默丢弃颜色**。把它还原成 `#rrggbb` 后:\n *\n * 1. 编辑器(经 DOM)与只读渲染器(零 DOM)对同一份 HTML 得到同一个值;\n * 2. `JSON → DOM → JSON` 在颜色这一维度上重新变得无损且幂等。\n *\n * 只识别**字面量**颜色(hex / `rgb()` / `rgba(…,1)`),不做颜色名补全,\n * 也不接受任何其它函数式写法——归一化结果一定是纯十六进制串,\n * 不引入新的注入面。\n *\n * @param value CSS 颜色取值\n * @returns 归一化后的 `#rrggbb…`;不是可识别的字面量颜色时返回 `undefined`\n *\n * @example\n * ```ts\n * normalizeStyleColor('#FAFAFA') // '#fafafa'\n * normalizeStyleColor('#fff') // '#ffffff'\n * normalizeStyleColor('rgb(255, 236, 61)') // '#ffec3d'\n * normalizeStyleColor('rgba(0, 0, 0, 1)') // '#000000'\n * normalizeStyleColor('transparent') // undefined(交给白名单原样保留)\n * normalizeStyleColor('expression(alert(1))') // undefined\n * ```\n */\nexport const normalizeStyleColor = (value: string | undefined): string | undefined => {\n if (value === undefined) return undefined\n const trimmed = value.trim().toLowerCase()\n if (HEX_COLOR_PATTERN.test(trimmed)) return expandHexColor(trimmed)\n\n const rgb = RGB_COLOR_PATTERN.exec(trimmed)\n if (rgb === null) return undefined\n // `noUncheckedIndexedAccess` 下捕获组可能是 undefined,逐个收窄\n const [red, green, blue] = [rgb[1], rgb[2], rgb[3]]\n if (red === undefined || green === undefined || blue === undefined) return undefined\n const channels = [toByteHex(red), toByteHex(green), toByteHex(blue)]\n return channels.includes('') ? undefined : `#${channels.join('')}`\n}\n","/**\n * HTML 安全白名单策略。\n *\n * 三条白名单:**标签**、**属性**、**URL 协议**。白名单之外的处置分两类:\n *\n * | 情形 | 处置 | 理由 |\n * |---|---|---|\n * | 危险标签(`script` / `iframe` / `form` …) | **整段丢弃** | 其内容本身即攻击载荷 |\n * | 普通容器标签(`div` / `section` / `article` …) | **解包保留文本** | 丢弃会损失正文 |\n * | `class` / `id` | **静默丢弃** | 属样式与宿主契约,无内容语义 |\n * | 其余属性 | 丢弃并写降级报告 | 可能是语义或安全的载体 |\n */\n\nimport { normalizeStyleColor } from '../utils/attr-values'\n\n/** 标签规则 */\nexport interface MtTagRule {\n /** 允许的普通属性名(小写) */\n attrs: readonly string[]\n /** 是否允许任意 `data-mt-*` 属性 */\n allowDataMt: boolean\n /** 原子元素:忽略其全部子节点(`img` / `video` / `audio` / `hr` / `br` / `col`) */\n atom: boolean\n}\n\n/** 文本块容器允许的属性(`style` 承载对齐/行高/缩进) */\nconst TEXT_BLOCK_ATTRS: readonly string[] = ['style']\n\n/** 表格单元格允许的属性 */\nconst CELL_ATTRS: readonly string[] = ['colspan', 'rowspan', 'style']\n\n/**\n * 标签白名单。\n *\n * 除 / 列出的标签外,额外接纳三类**外部 HTML 常见同义标签**\n * 以便粘贴场景不丢语义(序列化时仍统一输出规定的规范标签):\n * - `b` / `i` → `bold` / `italic`;\n * - `del` / `strike` → `strike`。\n */\nexport const MT_TAG_RULES: ReadonlyMap<string, MtTagRule> = new Map<string, MtTagRule>([\n ['p', { attrs: TEXT_BLOCK_ATTRS, allowDataMt: true, atom: false }],\n ['h1', { attrs: TEXT_BLOCK_ATTRS, allowDataMt: true, atom: false }],\n ['h2', { attrs: TEXT_BLOCK_ATTRS, allowDataMt: true, atom: false }],\n ['h3', { attrs: TEXT_BLOCK_ATTRS, allowDataMt: true, atom: false }],\n ['h4', { attrs: TEXT_BLOCK_ATTRS, allowDataMt: true, atom: false }],\n ['h5', { attrs: TEXT_BLOCK_ATTRS, allowDataMt: true, atom: false }],\n ['h6', { attrs: TEXT_BLOCK_ATTRS, allowDataMt: true, atom: false }],\n ['blockquote', { attrs: [], allowDataMt: true, atom: false }],\n ['ul', { attrs: [], allowDataMt: true, atom: false }],\n ['ol', { attrs: ['start', 'type'], allowDataMt: true, atom: false }],\n ['li', { attrs: [], allowDataMt: true, atom: false }],\n ['pre', { attrs: [], allowDataMt: true, atom: false }],\n ['code', { attrs: ['class'], allowDataMt: false, atom: false }],\n ['hr', { attrs: [], allowDataMt: true, atom: true }],\n ['br', { attrs: [], allowDataMt: false, atom: true }],\n [\n 'img',\n { attrs: ['src', 'alt', 'title', 'width', 'height', 'style'], allowDataMt: true, atom: true },\n ],\n [\n 'video',\n {\n attrs: ['src', 'poster', 'width', 'height', 'controls', 'style'],\n allowDataMt: true,\n atom: true,\n },\n ],\n ['audio', { attrs: ['src', 'title', 'controls'], allowDataMt: true, atom: true }],\n [\n 'a',\n {\n attrs: ['href', 'target', 'rel', 'title', 'download', 'style'],\n allowDataMt: true,\n atom: false,\n },\n ],\n ['table', { attrs: [], allowDataMt: true, atom: false }],\n ['colgroup', { attrs: [], allowDataMt: false, atom: false }],\n ['col', { attrs: ['span', 'width'], allowDataMt: false, atom: true }],\n ['thead', { attrs: [], allowDataMt: false, atom: false }],\n ['tbody', { attrs: [], allowDataMt: false, atom: false }],\n ['tfoot', { attrs: [], allowDataMt: false, atom: false }],\n ['tr', { attrs: [], allowDataMt: false, atom: false }],\n ['td', { attrs: CELL_ATTRS, allowDataMt: true, atom: false }],\n ['th', { attrs: CELL_ATTRS, allowDataMt: true, atom: false }],\n ['span', { attrs: ['style', 'data-tex'], allowDataMt: true, atom: false }],\n ['div', { attrs: ['style', 'data-tex'], allowDataMt: true, atom: false }],\n ['strong', { attrs: [], allowDataMt: false, atom: false }],\n ['em', { attrs: [], allowDataMt: false, atom: false }],\n ['u', { attrs: [], allowDataMt: false, atom: false }],\n ['s', { attrs: [], allowDataMt: false, atom: false }],\n ['del', { attrs: [], allowDataMt: false, atom: false }],\n ['strike', { attrs: [], allowDataMt: false, atom: false }],\n ['b', { attrs: [], allowDataMt: false, atom: false }],\n ['i', { attrs: [], allowDataMt: false, atom: false }],\n ['sub', { attrs: [], allowDataMt: false, atom: false }],\n ['sup', { attrs: [], allowDataMt: false, atom: false }],\n ['mark', { attrs: ['style'], allowDataMt: false, atom: false }],\n])\n\n/**\n * 整段丢弃的标签(含其全部内容)。\n *\n * 覆盖四类:脚本/样式载体、可执行嵌入、表单控件、元信息容器。\n */\nexport const MT_DROP_TAGS: ReadonlySet<string> = new Set([\n 'script',\n 'style',\n 'iframe',\n 'frame',\n 'frameset',\n 'noframes',\n 'object',\n 'embed',\n 'applet',\n 'param',\n 'form',\n 'input',\n 'select',\n 'option',\n 'optgroup',\n 'textarea',\n 'button',\n 'label',\n 'svg',\n 'math',\n 'canvas',\n 'template',\n 'noscript',\n 'plaintext',\n 'xmp',\n 'listing',\n 'link',\n 'meta',\n 'base',\n 'source',\n 'track',\n 'area',\n 'map',\n 'head',\n 'title',\n])\n\n/** URL 属性规则:`tag -> (attr -> 是否放行 data:image/*)` */\nconst URL_ATTR_RULES: ReadonlyMap<string, ReadonlyMap<string, boolean>> = new Map([\n ['a', new Map([['href', false]])],\n ['img', new Map([['src', true]])],\n [\n 'video',\n new Map([\n ['src', false],\n ['poster', false],\n ]),\n ],\n ['audio', new Map([['src', false]])],\n])\n\n/** 静默丢弃、不写报告的属性名(样式与宿主契约,无内容语义) */\nconst SILENT_DROP_ATTRS: ReadonlySet<string> = new Set(['class', 'id'])\n\n/** 允许保留的 CSS 属性白名单 */\nexport const MT_ALLOWED_STYLE_PROPS: readonly string[] = [\n // 块级容器\n 'text-align',\n 'line-height',\n 'padding-left',\n // textStyle\n 'font-family',\n 'font-size',\n 'color',\n 'background-color',\n 'letter-spacing',\n // 表格单元格列宽(colwidth)\n 'width',\n // 外部 HTML 常见的内联强调写法,解析时会转成对应 mark\n 'font-weight',\n 'font-style',\n 'text-decoration',\n]\n\n/**\n * 判断某属性是否为 URL 属性,以及是否放行 `data:image/*`。\n *\n * @param tag 标签名\n * @param attr 属性名\n * @returns 判定结果;非 URL 属性时 `isUrl` 为 `false`\n */\nexport const getUrlAttrRule = (\n tag: string,\n attr: string,\n): { isUrl: boolean; allowDataImage: boolean } => {\n const rule = URL_ATTR_RULES.get(tag)?.get(attr)\n if (rule === undefined) return { isUrl: false, allowDataImage: false }\n return { isUrl: true, allowDataImage: rule }\n}\n\n/**\n * 判断属性是否应静默丢弃(不写降级报告)。\n *\n * @param attr 属性名\n * @returns 是否静默丢弃\n */\nexport const isSilentlyDroppedAttr = (attr: string): boolean => SILENT_DROP_ATTRS.has(attr)\n\n/**\n * 判断 CSS 值是否安全。\n *\n * 白名单属性本身已收窄了攻击面,此处再拒绝一切含括号、引号、尖括号或\n * 反斜杠的值——本项目的目标属性全部是纯字面量,无需任何函数式写法。\n *\n * @param value CSS 值\n * @returns 是否安全\n */\nconst isSafeStyleValue = (value: string): boolean => {\n if (value.length === 0 || value.length > 256) return false\n for (let i = 0; i < value.length; i += 1) {\n const ch = value.charAt(i)\n if (ch === '(' || ch === ')' || ch === '\"' || ch === \"'\") return false\n if (ch === '<' || ch === '>' || ch === '\\\\' || ch === ';') return false\n }\n return true\n}\n\n/**\n * 允许出现**函数式颜色**的 CSS 属性。\n *\n * 只有这两个属性会被 DOM 改写成 `rgb(...)`(`CSSStyleDeclaration.cssText`\n * 把一切颜色统一成函数式写法),因此也只有它们需要走\n * {@link normalizeStyleColor} 的还原通道。\n */\nconst COLOR_STYLE_PROPS: readonly string[] = ['color', 'background-color']\n\n/**\n * 决定一条 CSS 声明最终以什么取值进入文档。\n *\n * 颜色属性先过 {@link normalizeStyleColor}:命中说明它是可识别的**字面量**\n * 颜色,归一化成 `#rrggbb` 后返回;未命中(含 `transparent`、颜色名、\n * 真透明度的 `rgba()`、以及 `expression(…)` 这类载荷)落回\n * {@link isSafeStyleValue} 的通用判定,行为与从前完全一致。\n *\n * @param name 小写 CSS 属性名\n * @param rawValue 已 trim 的原始取值\n * @returns 归一化后的取值;不可接受时返回 `undefined`\n */\nconst resolveStyleValue = (name: string, rawValue: string): string | undefined => {\n if (COLOR_STYLE_PROPS.includes(name)) {\n const normalized = normalizeStyleColor(rawValue)\n if (normalized !== undefined) return normalized\n }\n return isSafeStyleValue(rawValue) ? rawValue : undefined\n}\n\n/**\n * 解析内联 `style` 属性,仅保留白名单内的 CSS 属性。\n *\n * 被丢弃的声明必须**留痕**(不允许静默丢弃)——\n * 尤其是「属性名合法但值不可信」的情形(如\n * `width: expression(alert(1))`),它恰恰是攻击载体,\n * 静默丢弃会让安全回归失去观测点。\n *\n * @param value 原始 `style` 值\n * @param onDrop 丢弃声明的回调(收到的是规范化的 `name: value` 文本)\n * @returns 属性名 → 值 的映射(键已小写)\n *\n * @example\n * ```ts\n * parseInlineStyle('text-align: center; background: url(x)')\n * // { 'text-align': 'center' }(background 不在白名单,url(x) 亦不可信)\n *\n * // 颜色属性走归一化通道:DOM 把 hex 改写成 rgb() 后仍能还原回同一个值\n * parseInlineStyle('background-color: rgb(255, 236, 61)')\n * // { 'background-color': '#ffec3d' }\n * ```\n */\nexport const parseInlineStyle = (\n value: string,\n onDrop?: (declaration: string, reason: 'prop' | 'value') => void,\n): Record<string, string> => {\n const result: Record<string, string> = {}\n if (value.length === 0) return result\n\n const parts = value.split(';')\n for (const part of parts) {\n const colon = part.indexOf(':')\n // 无冒号的片段属语法噪声,不作为「被丢弃的声明」上报\n if (colon <= 0) continue\n const name = part.slice(0, colon).trim().toLowerCase()\n const rawValue = part.slice(colon + 1).trim()\n if (!MT_ALLOWED_STYLE_PROPS.includes(name)) {\n onDrop?.(`${name}: ${rawValue}`, 'prop')\n continue\n }\n const resolved = resolveStyleValue(name, rawValue)\n if (resolved === undefined) {\n onDrop?.(`${name}: ${rawValue}`, 'value')\n continue\n }\n result[name] = resolved\n }\n return result\n}\n\n/**\n * 把属性映射重新拼成 `style` 字符串(键按插入顺序)。\n *\n * @param props 属性映射\n * @returns `style` 值;无属性时返回空串\n *\n * @example\n * ```ts\n * buildStyleString({ 'text-align': 'center' }) // 'text-align: center'\n * ```\n */\nexport const buildStyleString = (props: Record<string, string>): string => {\n const parts: string[] = []\n for (const name of Object.keys(props)) {\n const value = props[name]\n if (value === undefined) continue\n parts.push(`${name}: ${value}`)\n }\n return parts.join('; ')\n}\n","/**\n * HTML 安全清洗。\n *\n * 入口唯一:`parseHTML()` 必须先经本模块,**禁止把原始字符串直接透传**。\n * 实现路线:分词(`tokenizer.ts`)→ 白名单过滤(`policy.ts`)→ 输出**清洗后的节点树**,\n * 全程 AST 级处理,不使用正则、不触碰 DOM。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport type { MtDegradationEntry } from '../types/report'\nimport { checkUrl } from '../utils/url'\nimport { isElement, tokenizeHtml } from './tokenizer'\nimport type { MtHtmlElement, MtHtmlNode } from './tokenizer'\nimport {\n buildStyleString,\n getUrlAttrRule,\n isSilentlyDroppedAttr,\n MT_DROP_TAGS,\n MT_TAG_RULES,\n parseInlineStyle,\n} from './policy'\nimport type { MtTagRule } from './policy'\n\n/** 清洗后的元素节点 */\nexport interface MtCleanElement {\n kind: 'element'\n /** 标签名(已小写) */\n tag: string\n /** 白名单内的属性 */\n attrs: Record<string, string>\n /** 子节点 */\n children: MtCleanNode[]\n /**\n * 内部原始源码(仅公式容器)。\n * 由 `sanitize/svg.ts` 的 SVG 白名单复验后才允许进入文档。\n */\n rawInner?: string\n}\n\n/** 清洗后的文本节点 */\nexport interface MtCleanText {\n kind: 'text'\n value: string\n}\n\n/** 清洗后的节点 */\nexport type MtCleanNode = MtCleanElement | MtCleanText\n\n/** 清洗结果 */\nexport interface MtSanitizeResult {\n /** 清洗后的节点树 */\n nodes: MtCleanNode[]\n /** 降级报告 */\n degradations: MtDegradationEntry[]\n}\n\n/** 清洗过程的上下文 */\ninterface SanitizeContext {\n degradations: MtDegradationEntry[]\n}\n\n/** 允许携带 `rawInner` 的标签(公式容器) */\nconst RAW_INNER_TAGS: ReadonlySet<string> = new Set(['div', 'span'])\n\n/**\n * 过滤单个元素的属性。\n *\n * 处置顺序(先安全、后白名单):\n * 1. `on*` 事件属性 → 丢弃并报告;\n * 2. `class` / `id` → 静默丢弃(无内容语义);\n * 3. 未声明的属性 → 丢弃并报告;\n * 4. `style` → 白名单 CSS 属性过滤后重新拼接;\n * 5. URL 属性 → 协议白名单校验,不安全则置空并报告。\n *\n * @param element 原始元素\n * @param rule 该标签的规则\n * @param path 定位路径\n * @param ctx 清洗上下文\n * @returns 过滤后的属性\n */\nconst cleanAttrs = (\n element: MtHtmlElement,\n rule: MtTagRule,\n path: string,\n ctx: SanitizeContext,\n): Record<string, string> => {\n const out: Record<string, string> = {}\n const tag = element.tag\n\n for (const attr of element.attrs) {\n const name = attr.name\n\n if (name.startsWith('on')) {\n ctx.degradations.push(\n degrade(MT_DEG.ATTR_DROPPED, tag, path, `事件属性 ${name} 已丢弃`, 'dropped'),\n )\n continue\n }\n\n // 白名单判定优先于静默丢弃:`class` 仅对 `code` 有意义(`language-x`)\n const isDataMt = rule.allowDataMt && name.startsWith('data-mt-')\n const declared = rule.attrs.includes(name)\n if (!isDataMt && !declared) {\n if (isSilentlyDroppedAttr(name)) continue\n ctx.degradations.push(\n degrade(MT_DEG.ATTR_DROPPED, tag, path, `属性 ${name} 不在白名单,已丢弃`, 'dropped'),\n )\n continue\n }\n\n if (name === 'style') {\n const styleValue = buildStyleString(\n parseInlineStyle(attr.value, (declaration, reason) => {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n tag,\n path,\n reason === 'value'\n ? `style 声明 \"${declaration}\" 的值不可信(含括号/引号/分号等),已丢弃`\n : `style 声明 \"${declaration}\" 的属性不在白名单,已丢弃`,\n 'dropped',\n ),\n )\n }),\n )\n // 全部声明都被丢弃时不保留空的 `style` 属性\n if (styleValue.length > 0) out.style = styleValue\n continue\n }\n\n const urlRule = getUrlAttrRule(tag, name)\n if (urlRule.isUrl) {\n const verdict = checkUrl(attr.value, { allowDataImage: urlRule.allowDataImage })\n if (!verdict.ok) {\n ctx.degradations.push(\n degrade(\n MT_DEG.DANGEROUS_URL,\n tag,\n path,\n `${name}=\"${attr.value}\"(${verdict.reason ?? ''}),已置空`,\n 'dropped',\n ),\n )\n out[name] = ''\n continue\n }\n }\n\n out[name] = attr.value\n }\n\n return out\n}\n\n/**\n * 递归清洗节点序列。\n *\n * @param nodes 原始节点序列\n * @param basePath 当前路径前缀\n * @param ctx 清洗上下文\n * @returns 清洗后的节点序列\n */\nconst cleanNodes = (\n nodes: readonly MtHtmlNode[],\n basePath: string,\n ctx: SanitizeContext,\n): MtCleanNode[] => {\n const out: MtCleanNode[] = []\n\n for (let index = 0; index < nodes.length; index += 1) {\n const node = nodes[index]\n if (node === undefined) continue\n const path = `${basePath}[${index}]`\n\n if (!isElement(node)) {\n out.push({ kind: 'text', value: node.value })\n continue\n }\n\n const tag = node.tag\n\n if (MT_DROP_TAGS.has(tag)) {\n ctx.degradations.push(\n degrade(MT_DEG.TAG_NOT_ALLOWED, tag, path, `标签 <${tag}> 及其内容已被整段丢弃`, 'dropped'),\n )\n continue\n }\n\n const rule = MT_TAG_RULES.get(tag)\n if (rule === undefined) {\n // 普通容器:解包保留子节点,避免丢失正文\n ctx.degradations.push(\n degrade(\n MT_DEG.TAG_NOT_ALLOWED,\n tag,\n path,\n `标签 <${tag}> 不在白名单,已解包保留内容`,\n 'flattened',\n ),\n )\n out.push(...cleanNodes(node.children, path, ctx))\n continue\n }\n\n out.push({\n kind: 'element',\n tag,\n attrs: cleanAttrs(node, rule, path, ctx),\n children: rule.atom ? [] : cleanNodes(node.children, path, ctx),\n ...(RAW_INNER_TAGS.has(tag) && node.rawInner !== undefined\n ? { rawInner: node.rawInner }\n : {}),\n })\n }\n\n return out\n}\n\n/**\n * 清洗 HTML 源码,产出白名单内的节点树。\n *\n * @param source HTML 源码\n * @returns 清洗结果(含降级报告)\n *\n * @example\n * ```ts\n * const { nodes, degradations } = sanitizeHtml('<p onclick=\"x()\">hi</p><script>alert(1)</script>')\n * // nodes 只剩 <p>hi</p>;degradations 记录了 onclick 与 script\n * ```\n */\nexport const sanitizeHtml = (source: string): MtSanitizeResult => {\n const ctx: SanitizeContext = { degradations: [] }\n if (typeof source !== 'string' || source.length === 0) {\n return { nodes: [], degradations: [] }\n }\n const nodes = cleanNodes(tokenizeHtml(source), 'html', ctx)\n return { nodes, degradations: ctx.degradations }\n}\n","/**\n * 预渲染 SVG 的独立白名单复验。\n *\n * 背景:`mathBlock.svg` / `inlineMath.svg` 需**逐字节保留**原始 SVG 才能满足\n * `JSON → HTML → JSON` 等价。把 `<svg>` 加进 HTML 白名单是不可接受的——\n * SVG 可内嵌 `<script>`、`<foreignObject>`、事件属性与外部引用。\n *\n * 因此采用**独立命名空间的白名单**:只放行数学排版必需的 SVG 元素与\n * 安全的引用形式(`#id` 锚点),其余一律判定为不安全并丢弃整段产物。\n */\n\nimport { tokenizeHtml } from './tokenizer'\nimport type { MtHtmlNode } from './tokenizer'\n\n/** 数学排版必需的 SVG 元素白名单 */\nconst ALLOWED_SVG_TAGS: ReadonlySet<string> = new Set([\n 'svg',\n 'g',\n 'path',\n 'use',\n 'defs',\n 'symbol',\n 'rect',\n 'line',\n 'polyline',\n 'polygon',\n 'circle',\n 'ellipse',\n 'text',\n 'tspan',\n 'title',\n 'desc',\n 'clippath',\n 'mask',\n 'marker',\n 'lineargradient',\n 'radialgradient',\n 'stop',\n 'pattern',\n 'filter',\n 'feblend',\n 'fecolormatrix',\n 'fecomponenttransfer',\n 'fecomposite',\n 'fegaussianblur',\n 'femerge',\n 'femergenode',\n 'feoffset',\n 'feflood',\n 'feturbulence',\n 'fedisplacementmap',\n 'fedropshadow',\n 'fefunca',\n 'fefuncb',\n 'fefuncg',\n 'fefuncr',\n])\n\n/** 必须为同文档锚点(`#id`)的引用属性 */\nconst ANCHOR_ONLY_ATTRS: ReadonlySet<string> = new Set(['href', 'xlink:href'])\n\n/** 属性值中一旦出现即判定为危险的片段(已小写) */\nconst DANGEROUS_VALUE_SNIPPETS: readonly string[] = ['javascript:', 'vbscript:', 'data:', '&#']\n\n/**\n * 判断 SVG 标记是否安全。\n *\n * 判定为安全需同时满足:\n * 1. 去空白后以 `<svg` 开头;\n * 2. 全部元素都在白名单内;\n * 3. 不存在 `on*` 事件属性;\n * 4. 引用属性只允许 `#锚点`;\n * 5. 属性值不包含 `javascript:` / `data:` 等危险片段。\n *\n * @param raw 待复验的标记(非字符串一律判定为不安全)\n * @returns 是否安全\n *\n * @example\n * ```ts\n * isSafeSvgMarkup('<svg><path d=\"M0 0\"/></svg>') // true\n * isSafeSvgMarkup('<svg><script>alert(1)</script></svg>') // false\n * isSafeSvgMarkup('<svg><use xlink:href=\"javascript:x\"/></svg>') // false\n * ```\n */\nexport const isSafeSvgMarkup = (raw: unknown): boolean => {\n if (typeof raw !== 'string') return false\n const source = raw.trim()\n if (source.length === 0) return false\n if (!source.toLowerCase().startsWith('<svg')) return false\n\n let ok = true\n\n const walk = (nodes: readonly MtHtmlNode[], depth: number): void => {\n if (!ok || depth > 256) return\n for (const node of nodes) {\n if (!ok) return\n if (node.kind === 'text') continue\n\n if (!ALLOWED_SVG_TAGS.has(node.tag)) {\n ok = false\n return\n }\n for (const attr of node.attrs) {\n const name = attr.name\n if (name.startsWith('on')) {\n ok = false\n return\n }\n if (ANCHOR_ONLY_ATTRS.has(name) && !attr.value.startsWith('#')) {\n ok = false\n return\n }\n const lowered = attr.value.toLowerCase()\n for (const snippet of DANGEROUS_VALUE_SNIPPETS) {\n if (lowered.includes(snippet)) {\n ok = false\n return\n }\n }\n }\n walk(node.children, depth + 1)\n }\n }\n\n const roots = tokenizeHtml(source)\n const first = roots[0]\n if (!first || first.kind !== 'element' || first.tag !== 'svg') return false\n // 只允许单一 `<svg>` 根,出现兄弟节点即视为异常输入\n if (roots.length > 1) return false\n\n walk(roots, 0)\n return ok\n}\n","/**\n * HTML 解析的共享上下文与属性读取工具。\n *\n * 铁律(保证 `JSON → HTML → JSON` 逐字符等价):\n * **只还原 HTML 中真实出现的信息,绝不补默认值。**\n * 例如 `orderedList.start` 缺省为 1,但若 JSON 里显式写了 `start: 1`,\n * 序列化就会输出 `start=\"1\"`,解析也必须读回来,否则往返不一致。\n */\n\nimport { MT_DOC } from '../report/codes'\nimport { issue } from '../report'\nimport type { MtDegradationEntry, MtValidationIssue } from '../types/report'\nimport { isValidBlockId } from '../utils/id'\nimport { parseInlineStyle } from '../sanitize/policy'\nimport type { MtCleanElement, MtCleanNode } from '../sanitize'\n\n/**\n * 取值解析原语已抽到 `utils/attr-values`——`@mteditor/core` 的 Tiptap 属性定义\n * 必须复用同一套约定。此处按原名义再导出,解析层既有调用点无需改动。\n */\nexport {\n parseBooleanAttr as readBooleanAttr,\n parseInteger as readInteger,\n parseLineHeight as readLineHeight,\n parseMediaAlign as readMediaAlign,\n parseOrderedType as readOrderedType,\n parseTextAlign as readTextAlign,\n} from '../utils/attr-values'\n\n/**\n * 判断清洗节点是否为元素。\n *\n * 放在本模块是因为 HTML 解析的三个子模块(骨架 / 原子构造 / 标记推导)都要用它,\n * 而它只依赖清洗层类型,不依赖任何解析逻辑。\n *\n * @param node 清洗后的节点\n * @returns 是否为元素节点\n */\nexport const isCleanElement = (node: MtCleanNode): node is MtCleanElement => node.kind === 'element'\n\n/** 解析上下文 */\nexport interface MtParseContext {\n /** 缺失 ID 时是否自动补发 */\n assignIds: boolean\n /** ID 生成器 */\n nextId: () => string\n /** 降级报告收集器 */\n degradations: MtDegradationEntry[]\n /** 校验问题收集器 */\n issues: MtValidationIssue[]\n}\n\n/**\n * 读取块级节点 ID。\n *\n * @param element 元素\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns 节点 ID;`assignIds: false` 且 HTML 未提供时返回空串并记录问题\n */\nexport const readId = (element: MtCleanElement, ctx: MtParseContext, path: string): string => {\n const raw = element.attrs['data-mt-id']\n if (raw !== undefined && isValidBlockId(raw)) return raw\n\n if (!ctx.assignIds) {\n ctx.issues.push(\n issue(\n MT_DOC.MISSING_ATTR,\n 'error',\n path,\n '块级节点缺少合法的 data-mt-id',\n '已留空,待上层补发',\n ),\n )\n return ''\n }\n return ctx.nextId()\n}\n\n/**\n * 取元素经过清洗的内联样式(已按白名单过滤)。\n *\n * @param element 元素\n * @returns CSS 属性映射\n */\nexport const styleOf = (element: MtCleanElement): Record<string, string> =>\n parseInlineStyle(element.attrs.style ?? '')\n","/**\n * HTML 原子节点构造。\n *\n * 本模块负责「一个元素 → 一个节点」的**叶子**映射:媒体(`img` / `video` /\n * `audio`)、附件、代码块、块级与行内公式、提及、表情。\n *\n * 与 `parse/html.ts` 分离的原因:这些构造器都不参与节点树递归,\n * 可以直接读元素属性产出节点;把它们与递归骨架混在一起会让「谁是递归入口」\n * 变得难以看清。反之,本模块**不得**依赖 `parse/html.ts`。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport { isSafeSvgMarkup } from '../sanitize/svg'\nimport type { MtHeadingLevel, MtMentionKind, MtTextAlign } from '../types/base'\nimport type {\n MtAttachmentAttrs,\n MtAudioAttrs,\n MtCodeBlockAttrs,\n MtImageAttrs,\n MtInlineMathAttrs,\n MtMathBlockAttrs,\n MtMentionAttrs,\n MtVideoAttrs,\n} from '../types/attrs'\nimport type { MtBlockNode, MtInlineNode } from '../types/nodes'\nimport type { MtCleanElement, MtCleanNode } from '../sanitize'\nimport {\n isCleanElement,\n readBooleanAttr,\n readId,\n readInteger,\n readLineHeight,\n readMediaAlign,\n readTextAlign,\n styleOf,\n} from './context'\nimport type { MtParseContext } from './context'\n\n/**\n * 从标签名取标题层级。\n *\n * @param tag 标签名,如 `h3`\n * @returns 层级 1–6;无法识别时按 1 处理\n */\nexport const headingLevel = (tag: string): MtHeadingLevel => {\n const digit = tag.charCodeAt(1) - 48\n if (digit >= 1 && digit <= 6) return digit as MtHeadingLevel\n return 1\n}\n\n/**\n * 抽取元素内的纯文本(`codeBlock` / `mention` / `emoji` 使用)。\n *\n * @param nodes 清洗后的节点\n * @returns 纯文本;`<br>` 记为换行\n */\nexport const extractText = (nodes: readonly MtCleanNode[]): string => {\n let out = ''\n for (const node of nodes) {\n if (!isCleanElement(node)) {\n out += node.value\n continue\n }\n if (node.tag === 'br') {\n out += '\\n'\n continue\n }\n out += extractText(node.children)\n }\n return out\n}\n\n/**\n * 读取文本容器的对齐/行高属性。\n *\n * @param element 清洗后的元素\n * @returns 对齐与行高;未声明的属性不出现在结果中\n */\nexport const readAlignAttrs = (\n element: MtCleanElement,\n): { textAlign?: MtTextAlign; lineHeight?: number } => {\n const style = styleOf(element)\n const textAlign = readTextAlign(style['text-align'])\n const lineHeight = readLineHeight(style['line-height'])\n return {\n ...(textAlign === undefined ? {} : { textAlign }),\n ...(lineHeight === undefined ? {} : { lineHeight }),\n }\n}\n\n/** 从 `<code class=\"language-x\">` 提取语言(`data-mt-language` 缺省时的兜底) */\nconst languageFromCodeChild = (element: MtCleanElement): string | undefined => {\n for (const node of element.children) {\n if (!isCleanElement(node) || node.tag !== 'code') continue\n const className = node.attrs.class\n if (className === undefined) continue\n if (!className.startsWith('language-')) continue\n return className.slice('language-'.length)\n }\n return undefined\n}\n\n/**\n * 解析 `pre` → `codeBlock`。\n *\n * @param element 清洗后的 `pre` 元素\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns 代码块节点\n */\nexport const parseCodeBlock = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtBlockNode => {\n const attrs: MtCodeBlockAttrs = { id: readId(element, ctx, path) }\n const language = element.attrs['data-mt-language'] ?? languageFromCodeChild(element)\n if (language !== undefined) attrs.language = language\n const showLineNumbers = readBooleanAttr(element.attrs['data-mt-line-numbers'])\n if (showLineNumbers !== undefined) attrs.showLineNumbers = showLineNumbers\n\n const text = extractText(element.children)\n return {\n type: 'codeBlock',\n attrs,\n ...(text.length > 0 ? { content: [{ type: 'text', text }] } : {}),\n }\n}\n\n/**\n * 递归收集表格行(穿透 `thead` / `tbody` / `tfoot`)。\n *\n * @param element 表格元素\n * @returns `tr` 元素数组\n */\nexport const collectRows = (element: MtCleanElement): MtCleanElement[] => {\n const rows: MtCleanElement[] = []\n for (const node of element.children) {\n if (!isCleanElement(node)) continue\n if (node.tag === 'tr') {\n rows.push(node)\n continue\n }\n if (node.tag === 'thead' || node.tag === 'tbody' || node.tag === 'tfoot') {\n rows.push(...collectRows(node))\n }\n }\n return rows\n}\n\n/**\n * 解析 `<colgroup><col width>` 得到列宽。\n *\n * @param element 表格元素\n * @returns 列宽数组(单位 px)\n */\nexport const parseColWidths = (element: MtCleanElement): number[] => {\n const widths: number[] = []\n for (const node of element.children) {\n if (!isCleanElement(node) || node.tag !== 'colgroup') continue\n for (const col of node.children) {\n if (!isCleanElement(col) || col.tag !== 'col') continue\n const width = readInteger(col.attrs.width)\n if (width !== undefined) widths.push(width)\n }\n }\n return widths\n}\n\n/**\n * 解析媒体节点(`img` / `video` / `audio`)。\n *\n * @param element 清洗后的媒体元素\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns 媒体节点;缺少可用 `src` 时返回 `undefined` 并写降级报告\n */\nexport const parseMedia = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtBlockNode | undefined => {\n const src = element.attrs.src ?? ''\n if (src.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n element.tag,\n path,\n `<${element.tag}> 缺少可用 src,已丢弃该节点`,\n 'dropped',\n ),\n )\n return undefined\n }\n\n const width = readInteger(element.attrs.width)\n const height = readInteger(element.attrs.height)\n const align = readMediaAlign(element.attrs['data-mt-align'])\n const poster = element.attrs.poster\n const title = element.attrs.title\n const alt = element.attrs.alt\n\n if (element.tag === 'img') {\n return {\n type: 'image',\n attrs: {\n id: readId(element, ctx, path),\n src,\n ...(alt === undefined ? {} : { alt }),\n ...(title === undefined ? {} : { title }),\n ...(width === undefined ? {} : { width }),\n ...(height === undefined ? {} : { height }),\n ...(align === undefined ? {} : { align }),\n } satisfies MtImageAttrs,\n }\n }\n\n if (element.tag === 'audio') {\n return {\n type: 'audio',\n attrs: {\n id: readId(element, ctx, path),\n src,\n ...(title === undefined ? {} : { title }),\n } satisfies MtAudioAttrs,\n }\n }\n\n return {\n type: 'video',\n attrs: {\n id: readId(element, ctx, path),\n src,\n ...(poster === undefined ? {} : { poster }),\n ...(width === undefined ? {} : { width }),\n ...(height === undefined ? {} : { height }),\n ...(align === undefined ? {} : { align }),\n } satisfies MtVideoAttrs,\n }\n}\n\n/**\n * 解析附件(`a[data-mt-attachment]`)。\n *\n * @param element 清洗后的 `a` 元素\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns 附件节点;缺少 `href` 或 `data-mt-name` 时返回 `undefined`\n */\nexport const parseAttachment = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtBlockNode | undefined => {\n const src = element.attrs.href ?? ''\n const name = element.attrs['data-mt-name'] ?? ''\n if (src.length === 0 || name.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'attachment',\n path,\n '附件缺少 href 或 data-mt-name,已丢弃',\n 'dropped',\n ),\n )\n return undefined\n }\n const size = readInteger(element.attrs['data-mt-size'])\n const mime = element.attrs['data-mt-mime']\n return {\n type: 'attachment',\n attrs: {\n id: readId(element, ctx, path),\n src,\n name,\n ...(size === undefined ? {} : { size }),\n ...(mime === undefined ? {} : { mime }),\n } satisfies MtAttachmentAttrs,\n }\n}\n\n/** 收集并复验预渲染 SVG */\nconst readSvg = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): string | undefined => {\n const raw = element.rawInner\n if (raw === undefined || raw.trim().length === 0) return undefined\n if (isSafeSvgMarkup(raw)) return raw\n ctx.degradations.push(\n degrade(\n MT_DEG.TAG_NOT_ALLOWED,\n element.tag,\n path,\n '预渲染 SVG 未通过 SVG 白名单复验,已丢弃该产物',\n 'dropped',\n ),\n )\n return undefined\n}\n\n/**\n * 解析 `div[data-mt-math]` → `mathBlock`。\n *\n * @param element 清洗后的 `div` 元素\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns 块级公式节点\n */\nexport const parseMathBlock = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtBlockNode => {\n const tex = element.attrs['data-tex'] ?? ''\n const svg = readSvg(element, ctx, path)\n const numbering = element.attrs['data-mt-numbering']\n const attrs: MtMathBlockAttrs = {\n id: readId(element, ctx, path),\n tex,\n ...(svg === undefined ? {} : { svg }),\n ...(numbering === undefined ? {} : { numbering }),\n }\n return { type: 'mathBlock', attrs }\n}\n\n/**\n * 解析 `span[data-mt-math-inline]` → `inlineMath`。\n *\n * @param element 清洗后的 `span` 元素\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns 行内公式节点;缺少 `data-tex` 时返回 `undefined`\n */\nexport const parseInlineMath = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtInlineNode | undefined => {\n const tex = element.attrs['data-tex'] ?? ''\n if (tex.length === 0) {\n ctx.degradations.push(\n degrade(MT_DEG.ATTR_DROPPED, 'inlineMath', path, '行内公式缺少 data-tex,已丢弃', 'dropped'),\n )\n return undefined\n }\n const svg = readSvg(element, ctx, path)\n const attrs: MtInlineMathAttrs = { tex, ...(svg === undefined ? {} : { svg }) }\n return { type: 'inlineMath', attrs }\n}\n\n/**\n * 解析 `span[data-mt-mention]` → `mention`。\n *\n * @param element 清洗后的 `span` 元素\n * @returns 提及节点;缺少 id 或标签文本时返回 `undefined`\n */\nexport const parseMention = (element: MtCleanElement): MtInlineNode | undefined => {\n const id = element.attrs['data-mt-id'] ?? ''\n const label = extractText(element.children)\n if (id.length === 0 || label.length === 0) return undefined\n const kind = element.attrs['data-mt-kind']\n const attrs: MtMentionAttrs = {\n id,\n label,\n ...(kind === 'user' || kind === 'doc' || kind === 'custom'\n ? { kind: kind as MtMentionKind }\n : {}),\n }\n return { type: 'mention', attrs }\n}\n\n/**\n * 解析 `span[data-mt-emoji]` → `emoji`。\n *\n * 缺少 `data-mt-name` 时退化为纯文本(字符本身仍有价值,不应丢弃)。\n *\n * @param element 清洗后的 `span` 元素\n * @returns 表情节点或退化的文本节点\n */\nexport const parseEmoji = (element: MtCleanElement): MtInlineNode | undefined => {\n const name = element.attrs['data-mt-name'] ?? ''\n const unicode = extractText(element.children)\n if (name.length === 0 || unicode.length === 0) {\n return unicode.length > 0 ? { type: 'text', text: unicode } : undefined\n }\n return { type: 'emoji', attrs: { name, unicode } }\n}\n","/**\n * HTML 解析的标记推导。\n *\n * 把外部 HTML 的三种「标记写法」统一还原为定义的 `MtMark`\n * 1. 无属性标签(`<strong>` / `<u>` / `<code>` …);\n * 2. `span` 的内联样式(`font-weight:bold`、`text-decoration:underline` …);\n * 3. 带属性的标签(`<a href>` → `link`、`<mark style=\"background-color\">` → `highlight`)。\n *\n * 与 `parse/html.ts` 分离的原因:这里是**叶子逻辑**,不参与节点树递归;\n * 反过来,它也绝不允许反向依赖递归骨架,否则会形成循环。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport type { MtLinkAttrs, MtMark, MtTextStyleAttrs } from '../types/marks'\nimport type { MtCleanElement } from '../sanitize'\nimport { styleOf } from './context'\nimport type { MtParseContext } from './context'\n\n/** 无属性标记的标签 → 标记映射 */\nexport const SIMPLE_MARK_TAGS: ReadonlyMap<string, MtMark> = new Map<string, MtMark>([\n ['strong', { type: 'bold' }],\n ['b', { type: 'bold' }],\n ['em', { type: 'italic' }],\n ['i', { type: 'italic' }],\n ['u', { type: 'underline' }],\n ['s', { type: 'strike' }],\n ['del', { type: 'strike' }],\n ['strike', { type: 'strike' }],\n ['sub', { type: 'subscript' }],\n ['sup', { type: 'superscript' }],\n ['code', { type: 'code' }],\n])\n\n/** `span` 样式属性 → `textStyle` 属性名 的映射 */\nconst SPAN_STYLE_MAP: ReadonlyMap<string, keyof MtTextStyleAttrs> = new Map([\n ['font-family', 'fontFamily'],\n ['font-size', 'fontSize'],\n ['color', 'color'],\n ['background-color', 'backgroundColor'],\n ['line-height', 'lineHeight'],\n ['letter-spacing', 'letterSpacing'],\n])\n\n/**\n * 由 `span` 的内联样式推导附加标记(外部 HTML 的 `font-weight:bold` 等写法)。\n *\n * @param element 清洗后的 `span` 元素\n * @returns 推导出的标记数组(可能为空)\n */\nexport const spanStyleMarks = (element: MtCleanElement): MtMark[] => {\n const style = styleOf(element)\n const marks: MtMark[] = []\n\n const weight = style['font-weight']\n if (weight !== undefined) {\n const numeric = Number(weight)\n if (weight === 'bold' || weight === 'bolder' || (Number.isFinite(numeric) && numeric >= 600)) {\n marks.push({ type: 'bold' })\n }\n }\n const fontStyle = style['font-style']\n if (fontStyle === 'italic' || fontStyle === 'oblique') marks.push({ type: 'italic' })\n\n const decoration = style['text-decoration'] ?? ''\n if (decoration.includes('underline')) marks.push({ type: 'underline' })\n if (decoration.includes('line-through')) marks.push({ type: 'strike' })\n\n const styleAttrs: MtTextStyleAttrs = {}\n for (const [cssName, key] of SPAN_STYLE_MAP) {\n const value = style[cssName]\n if (value !== undefined) styleAttrs[key] = value\n }\n if (Object.keys(styleAttrs).length > 0) marks.push({ type: 'textStyle', attrs: styleAttrs })\n\n return marks\n}\n\n/**\n * 由 `a` 构造 `link` 标记。\n *\n * @param element 清洗后的 `a` 元素\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns `link` 标记;`href` 缺失或已被告知置空时返回 `undefined`\n */\nexport const linkMark = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtMark | undefined => {\n const href = element.attrs.href\n if (href === undefined || href.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.DANGEROUS_URL,\n 'link',\n path,\n 'href 缺失或已被安全清洗置空,链接标记已丢弃',\n 'dropped',\n ),\n )\n return undefined\n }\n const attrs: MtLinkAttrs = { href }\n const target = element.attrs.target\n if (target !== undefined) attrs.target = target\n const rel = element.attrs.rel\n if (rel !== undefined) attrs.rel = rel\n const title = element.attrs.title\n if (title !== undefined) attrs.title = title\n return { type: 'link', attrs }\n}\n\n/**\n * 由 `mark` 构造 `highlight` 标记。\n *\n * @param element 清洗后的 `mark` 元素\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns `highlight` 标记;缺少背景色时返回 `undefined`\n */\nexport const highlightMark = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtMark | undefined => {\n const color = styleOf(element)['background-color']\n if (color === undefined || color.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'highlight',\n path,\n 'mark 缺少 background-color,高亮标记已丢弃',\n 'dropped',\n ),\n )\n return undefined\n }\n return { type: 'highlight', attrs: { color } }\n}\n","/**\n * HTML 解析的节点构造原语与采集条目类型。\n *\n * 三个构造器都遵守同一条铁律:**只还原真实出现的信息,绝不补默认值**\n * (`parse/context.ts` 的注释有完整说明)——所以空 `content` 一律不写该字段。\n *\n * 单独成模块是因为它们被「递归骨架」(`parse/html.ts`)与文本容器装配共同使用,\n * 放在骨架里会让骨架超出单文件行数上限,且不易看出「构造」与「遍历」的边界。\n */\n\nimport { finalizeInlineNodes, normalizeMarks } from '../normalize'\nimport type { MtHeadingAttrs, MtParagraphAttrs } from '../types/attrs'\nimport type { MtMark } from '../types/marks'\nimport type { MtBlockNode, MtInlineNode, MtParagraphNode, MtTextNode } from '../types/nodes'\nimport type { MtParseContext } from './context'\n\n/** 采集到的条目:行内节点或块级节点 */\nexport type MtCollected =\n { kind: 'inline'; node: MtInlineNode } | { kind: 'block'; node: MtBlockNode }\n\n/**\n * 包装为行内条目。\n *\n * @param node 行内节点\n * @returns 采集条目\n */\nexport const asInline = (node: MtInlineNode): MtCollected => ({ kind: 'inline', node })\n\n/**\n * 包装为块级条目。\n *\n * @param node 块级节点\n * @returns 采集条目\n */\nexport const asBlock = (node: MtBlockNode): MtCollected => ({ kind: 'block', node })\n\n/**\n * 构造段落。\n *\n * @param attrs 段落属性\n * @param content 行内子节点\n * @returns 段落节点\n */\nexport const makeParagraph = (\n attrs: MtParagraphAttrs,\n content: readonly MtInlineNode[],\n): MtParagraphNode => ({\n type: 'paragraph',\n attrs,\n ...(content.length > 0 ? { content: [...content] } : {}),\n})\n\n/**\n * 构造标题。\n *\n * @param attrs 标题属性\n * @param content 行内子节点\n * @returns 标题节点\n */\nexport const makeHeading = (\n attrs: MtHeadingAttrs,\n content: readonly MtInlineNode[],\n): MtBlockNode => ({\n type: 'heading',\n attrs,\n ...(content.length > 0 ? { content: [...content] } : {}),\n})\n\n/**\n * 构造文本节点(标记在此完成规范化:排序、去重、冲突消解)。\n *\n * @param value 文本\n * @param marks 累积的标记\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns 文本节点;空文本返回 `undefined`\n */\nexport const makeText = (\n value: string,\n marks: readonly MtMark[],\n ctx: MtParseContext,\n path: string,\n): MtTextNode | undefined => {\n if (value.length === 0) return undefined\n const { marks: normalized } = normalizeMarks(marks, {\n path,\n marksAllowed: true,\n degradations: ctx.degradations,\n issues: ctx.issues,\n })\n return normalized.length > 0\n ? { type: 'text', text: value, marks: normalized }\n : { type: 'text', text: value }\n}\n\n/**\n * 把「行内 / 块级」混合序列按段落分组。\n *\n * 连续的行内节点合并为一个段落,块级节点原样保留,从而保证\n * `content: block+` 的结构约束始终成立。\n *\n * @param items 采集序列\n * @param ctx 解析上下文\n * @returns 块级节点序列\n */\nexport const groupItems = (items: readonly MtCollected[], ctx: MtParseContext): MtBlockNode[] => {\n const out: MtBlockNode[] = []\n let buffer: MtInlineNode[] = []\n\n const flush = (): void => {\n if (buffer.length === 0) return\n out.push(makeParagraph({ id: ctx.nextId() }, finalizeInlineNodes(buffer)))\n buffer = []\n }\n\n for (const item of items) {\n if (item.kind === 'inline') {\n buffer.push(item.node)\n continue\n }\n flush()\n out.push(item.node)\n }\n flush()\n\n return out\n}\n","/**\n * HTML → `MtDocument`。\n *\n * 流程:`sanitizeHtml()` 清洗 → 递归映射为节点树 → 规范化(合并相邻文本、排序标记)\n * → 批量补发块级 ID → 输出文档与降级报告。\n *\n * 两条通用原则:\n * 1. **只还原 HTML 中真实出现的信息,绝不补默认值**——否则 `JSON → HTML → JSON`\n * 在 `start` / `checked` 这类「默认值属性」上必然漂移;\n * 2. **节点身份一律由 `data-mt-*` 承载**,不依赖 `class`。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport { MT_SCHEMA_VERSION } from '../types/base'\nimport type {\n MtHeadingAttrs,\n MtOrderedListAttrs,\n MtParagraphAttrs,\n MtTableCellAttrs,\n MtTaskItemAttrs,\n MtTableAttrs,\n} from '../types/attrs'\nimport type { MtMark } from '../types/marks'\nimport type { MtDocument } from '../types/document'\nimport type {\n MtBlockNode,\n MtInlineNode,\n MtTableNode,\n MtTableCellNodeUnion,\n MtTableRowNode,\n} from '../types/nodes'\nimport type { MtParseOptions, MtParseResult } from '../types/io'\nimport { createIdGenerator } from '../utils/id'\nimport { MT_COLWIDTH_UNIT, parseCssLength, parseIndent } from '../utils/style-units'\nimport type { MtCleanElement, MtCleanNode } from '../sanitize'\nimport { sanitizeHtml } from '../sanitize'\nimport {\n collectRows,\n headingLevel,\n parseAttachment,\n parseCodeBlock,\n parseColWidths,\n parseEmoji,\n parseInlineMath,\n parseMathBlock,\n parseMedia,\n parseMention,\n readAlignAttrs,\n} from './html-atoms'\nimport { SIMPLE_MARK_TAGS, highlightMark, linkMark, spanStyleMarks } from './html-marks'\nimport { asBlock, asInline, groupItems, makeHeading, makeParagraph, makeText } from './html-nodes'\nimport type { MtCollected } from './html-nodes'\nimport {\n isCleanElement,\n readBooleanAttr,\n readId,\n readInteger,\n readLineHeight,\n readMediaAlign,\n readOrderedType,\n readTextAlign,\n styleOf,\n} from './context'\nimport type { MtParseContext } from './context'\n\n/**\n * 递归采集节点序列。\n *\n * @param nodes 清洗后的节点\n * @param marks 当前累积的标记\n * @param ctx 解析上下文\n * @param basePath 路径前缀\n * @param inlineContext 是否处于行内上下文(决定纯空白文本是否保留)\n * @returns 采集序列\n */\nconst collect = (\n nodes: readonly MtCleanNode[],\n marks: readonly MtMark[],\n ctx: MtParseContext,\n basePath: string,\n inlineContext: boolean,\n): MtCollected[] => {\n const out: MtCollected[] = []\n\n for (let index = 0; index < nodes.length; index += 1) {\n const node = nodes[index]\n if (node === undefined) continue\n const path = `${basePath}[${index}]`\n\n if (!isCleanElement(node)) {\n // 块级上下文中的纯空白文本属于标签间的排版空白,不产生内容\n if (!inlineContext && node.value.trim().length === 0) continue\n const text = makeText(node.value, marks, ctx, path)\n if (text) out.push(asInline(text))\n continue\n }\n\n out.push(...collectElement(node, marks, ctx, path, inlineContext))\n }\n\n return out\n}\n\n/** 解析块级内容(不含行内包裹) */\nconst parseBlocks = (\n nodes: readonly MtCleanNode[],\n ctx: MtParseContext,\n basePath: string,\n): MtBlockNode[] => groupItems(collect(nodes, [], ctx, basePath, false), ctx)\n\n/**\n * 文本容器(`p` / `h1`–`h6`)的统一装配。\n *\n * 容器内若混入块级内容(畸形 HTML),后续块会**提升**为同级块,\n * 保证父级 `content` 表达式不被破坏。\n */\nconst buildTextContainer = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n make: (content: readonly MtInlineNode[]) => MtBlockNode,\n): MtCollected[] => {\n const blocks = groupItems(collect(element.children, [], ctx, `${path}.content`, true), ctx)\n const head = blocks[0]\n\n if (head !== undefined && head.type === 'paragraph') {\n const out: MtBlockNode[] = [make(head.content ?? [])]\n for (let i = 1; i < blocks.length; i += 1) {\n const extra = blocks[i]\n if (extra !== undefined) out.push(extra)\n }\n return out.map(asBlock)\n }\n\n return [make([]), ...blocks].map(asBlock)\n}\n\n/** 解析 `p` */\nconst parseParagraph = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtCollected[] => {\n const style = styleOf(element)\n const indent = parseIndent(style['padding-left'])\n const align = readAlignAttrs(element)\n const attrs: MtParagraphAttrs = {\n id: readId(element, ctx, path),\n ...align,\n ...(indent === undefined ? {} : { indent }),\n }\n return buildTextContainer(element, ctx, path, (content) => makeParagraph(attrs, content))\n}\n\n/** 解析 `h1`–`h6` */\nconst parseHeading = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtCollected[] => {\n const attrs: MtHeadingAttrs = {\n id: readId(element, ctx, path),\n level: headingLevel(element.tag),\n ...readAlignAttrs(element),\n }\n return buildTextContainer(element, ctx, path, (content) => makeHeading(attrs, content))\n}\n\n/** 解析列表(`ul` / `ol`,含任务列表判定) */\nconst parseList = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n ordered: boolean,\n): MtBlockNode => {\n const items = element.children.filter(\n (node): node is MtCleanElement => isCleanElement(node) && node.tag === 'li',\n )\n const isTask =\n element.attrs['data-mt-task-list'] !== undefined ||\n items.some((li) => li.attrs['data-mt-checked'] !== undefined)\n\n const id = readId(element, ctx, path)\n const parsed = items.map((li, index) => {\n const liPath = `${path}.content[${index}]`\n const liId = readId(li, ctx, liPath)\n const content = parseBlocks(li.children, ctx, `${liPath}.content`)\n const body =\n content[0]?.type === 'paragraph'\n ? content\n : [makeParagraph({ id: ctx.nextId() }, []), ...content]\n return { liId, body, checked: readBooleanAttr(li.attrs['data-mt-checked']) }\n })\n\n // 空列表(`<ul></ul>`)会让 `listItem+` 不成立,补一个空列表项保住该元素\n if (parsed.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.EMPTY_CONTAINER,\n ordered ? 'orderedList' : 'bulletList',\n path,\n '空列表已补一个空列表项,以保证 content 表达式 listItem+ 成立',\n 'replaced',\n ),\n )\n parsed.push({\n liId: ctx.nextId(),\n body: [makeParagraph({ id: ctx.nextId() }, [])],\n checked: undefined,\n })\n }\n\n if (isTask) {\n return {\n type: 'taskList',\n attrs: { id },\n content: parsed.map((item) => {\n const attrs: MtTaskItemAttrs = { id: item.liId }\n if (item.checked !== undefined) attrs.checked = item.checked\n return { type: 'taskItem', attrs, content: item.body }\n }),\n }\n }\n\n if (ordered) {\n const attrs: MtOrderedListAttrs = { id }\n const start = readInteger(element.attrs.start)\n if (start !== undefined) attrs.start = start\n const type = readOrderedType(element.attrs.type)\n if (type !== undefined) attrs.type = type\n return {\n type: 'orderedList',\n attrs,\n content: parsed.map((item) => ({\n type: 'listItem',\n attrs: { id: item.liId },\n content: item.body,\n })),\n }\n }\n\n return {\n type: 'bulletList',\n attrs: { id },\n content: parsed.map((item) => ({\n type: 'listItem',\n attrs: { id: item.liId },\n content: item.body,\n })),\n }\n}\n\n/** 解析表格单元格 */\nconst parseCell = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtTableCellNodeUnion => {\n const attrs: MtTableCellAttrs = {}\n const colspan = readInteger(element.attrs.colspan)\n if (colspan !== undefined) attrs.colspan = colspan\n const rowspan = readInteger(element.attrs.rowspan)\n if (rowspan !== undefined) attrs.rowspan = rowspan\n\n const style = styleOf(element)\n const textAlign = readTextAlign(style['text-align'])\n if (textAlign !== undefined) attrs.textAlign = textAlign\n const lineHeight = readLineHeight(style['line-height'])\n if (lineHeight !== undefined) attrs.lineHeight = lineHeight\n if (style['background-color'] !== undefined) attrs.background = style['background-color']\n const colwidth = parseCssLength(style.width, MT_COLWIDTH_UNIT)\n if (colwidth !== undefined) attrs.colwidth = colwidth\n const align = readMediaAlign(element.attrs['data-mt-align'])\n if (align !== undefined) attrs.align = align\n\n const content = parseBlocks(element.children, ctx, `${path}.content`)\n const body = content.length > 0 ? content : [makeParagraph({ id: ctx.nextId() }, [])]\n const hasAttrs = Object.keys(attrs).length > 0\n\n if (element.tag === 'th') {\n return hasAttrs\n ? { type: 'tableHeader', attrs, content: body }\n : { type: 'tableHeader', content: body }\n }\n return hasAttrs\n ? { type: 'tableCell', attrs, content: body }\n : { type: 'tableCell', content: body }\n}\n\n/** 解析 `table`;无任何单元格的空表格返回 `undefined`(无内容可还原) */\nconst parseTable = (\n element: MtCleanElement,\n ctx: MtParseContext,\n path: string,\n): MtTableNode | undefined => {\n const rows: MtTableRowNode[] = []\n collectRows(element).forEach((rowElement, rowIndex) => {\n const cells = rowElement.children.filter(\n (node): node is MtCleanElement =>\n isCleanElement(node) && (node.tag === 'td' || node.tag === 'th'),\n )\n if (cells.length === 0) return\n rows.push({\n type: 'tableRow',\n content: cells.map((cell, cellIndex) =>\n parseCell(cell, ctx, `${path}.content[${rowIndex}].content[${cellIndex}]`),\n ),\n })\n })\n\n // 空表格既无法构造合法的 `tableRow+`,也没有内容可还原,只能丢弃\n if (rows.length === 0) {\n ctx.degradations.push(\n degrade(MT_DEG.EMPTY_CONTAINER, 'table', path, '表格不含任何单元格,已丢弃该节点', 'dropped'),\n )\n return undefined\n }\n\n const attrs: MtTableAttrs = { id: readId(element, ctx, path) }\n const colWidths = parseColWidths(element)\n if (colWidths.length > 0) attrs.colWidths = colWidths\n\n return { type: 'table', attrs, content: rows }\n}\n\n/**\n * 映射单个元素。\n *\n * @param element 清洗后的元素\n * @param marks 当前累积的标记\n * @param ctx 解析上下文\n * @param path 定位路径\n * @param inlineContext 是否处于行内上下文\n * @returns 采集序列\n */\nconst collectElement = (\n element: MtCleanElement,\n marks: readonly MtMark[],\n ctx: MtParseContext,\n path: string,\n inlineContext: boolean,\n): MtCollected[] => {\n const tag = element.tag\n const childPath = `${path}.content`\n const deeper = (next: readonly MtMark[]): MtCollected[] =>\n collect(element.children, next, ctx, childPath, true)\n\n switch (tag) {\n case 'p':\n return parseParagraph(element, ctx, path)\n case 'h1':\n case 'h2':\n case 'h3':\n case 'h4':\n case 'h5':\n case 'h6':\n return parseHeading(element, ctx, path)\n case 'blockquote': {\n const blocks = parseBlocks(element.children, ctx, childPath)\n // 空引用块直接产出会让 `block+` 不成立(validate 判 MT_DOC_003 并阻断序列化),\n // 故与 「不允许出现 content: [] 的 doc」同一处理:补一个空段落。\n if (blocks.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.EMPTY_CONTAINER,\n 'blockquote',\n path,\n '空引用块已补一个空段落,以保证 content 表达式 block+ 成立',\n 'replaced',\n ),\n )\n }\n return [\n asBlock({\n type: 'blockquote',\n attrs: { id: readId(element, ctx, path) },\n content: blocks.length > 0 ? blocks : [makeParagraph({ id: ctx.nextId() }, [])],\n }),\n ]\n }\n case 'ul':\n case 'ol':\n return [asBlock(parseList(element, ctx, path, tag === 'ol'))]\n case 'pre':\n return [asBlock(parseCodeBlock(element, ctx, path))]\n case 'hr':\n return [asBlock({ type: 'horizontalRule', attrs: { id: readId(element, ctx, path) } })]\n case 'table': {\n const table = parseTable(element, ctx, path)\n return table === undefined ? [] : [asBlock(table)]\n }\n case 'img':\n case 'video':\n case 'audio': {\n const media = parseMedia(element, ctx, path)\n return media ? [asBlock(media)] : []\n }\n case 'li':\n case 'thead':\n case 'tbody':\n case 'tfoot':\n case 'tr':\n case 'td':\n case 'th':\n // 游离的表格/列表结构元素:解包保留内容\n return collect(element.children, marks, ctx, childPath, false)\n case 'div': {\n if (element.attrs['data-mt-math'] !== undefined) {\n return [asBlock(parseMathBlock(element, ctx, path))]\n }\n return collect(element.children, marks, ctx, childPath, inlineContext)\n }\n case 'a': {\n if (element.attrs['data-mt-attachment'] !== undefined) {\n const attachment = parseAttachment(element, ctx, path)\n return attachment ? [asBlock(attachment)] : []\n }\n const mark = linkMark(element, ctx, path)\n return deeper(mark === undefined ? marks : [...marks, mark])\n }\n case 'span': {\n if (element.attrs['data-mt-mention'] !== undefined) {\n const mention = parseMention(element)\n return mention ? [asInline(mention)] : []\n }\n if (element.attrs['data-mt-math-inline'] !== undefined) {\n const math = parseInlineMath(element, ctx, path)\n return math ? [asInline(math)] : []\n }\n if (element.attrs['data-mt-emoji'] !== undefined) {\n const emoji = parseEmoji(element)\n return emoji ? [asInline(emoji)] : []\n }\n return deeper([...marks, ...spanStyleMarks(element)])\n }\n case 'mark': {\n const mark = highlightMark(element, ctx, path)\n return deeper(mark === undefined ? marks : [...marks, mark])\n }\n case 'br':\n return [asInline({ type: 'hardBreak' })]\n default: {\n const mark = SIMPLE_MARK_TAGS.get(tag)\n if (mark !== undefined) return deeper([...marks, mark])\n return collect(element.children, marks, ctx, childPath, inlineContext)\n }\n }\n}\n\n/**\n * 把 HTML 源码解析为 `MtDocument`。\n *\n * @param html HTML 源码\n * @param options 解析选项\n * @returns 文档、降级报告与校验问题\n *\n * @example\n * ```ts\n * const { doc, report } = parseHtmlToDocument('<h1>标题</h1><p>正文<strong>加粗</strong></p>')\n * doc.content[0]?.type // 'heading'\n * report.length // 0(无降级)\n * ```\n */\nexport const parseHtmlToDocument = (html: string, options: MtParseOptions = {}): MtParseResult => {\n const sanitized = sanitizeHtml(html)\n const ctx: MtParseContext = {\n assignIds: options.assignIds !== false,\n nextId: createIdGenerator(options.now === undefined ? {} : { now: options.now }),\n degradations: [...sanitized.degradations],\n issues: [],\n }\n\n const blocks = parseBlocks(sanitized.nodes, ctx, 'content')\n // 不允许出现 content: [] 的 doc,空输入补一个空段落\n const content = blocks.length > 0 ? blocks : [makeParagraph({ id: ctx.nextId() }, [])]\n\n const doc: MtDocument = { schemaVersion: MT_SCHEMA_VERSION, type: 'doc', content }\n\n return {\n doc,\n report: options.report === false ? [] : ctx.degradations,\n issues: ctx.issues,\n }\n}\n","/**\n * Markdown 行内解析。\n *\n * 与块级解析(`parse/markdown.ts`)分离的原因:行内解析是**递归**的\n * (`**粗 `码`**` 这类嵌套),而块级解析只按行扫描;两者的状态模型完全不同,\n * 混在一个文件里既超行数上限,也让「谁是递归出口」变得难以看清。\n *\n * 行内只识别列出的子集,未列出的语法一律按普通文本处理,不做猜测。\n */\n\nimport { normalizeMarks } from '../normalize'\nimport type { MtMark } from '../types/marks'\nimport type { MtBlockNode, MtInlineNode, MtParagraphNode } from '../types/nodes'\nimport type { MtParseContext } from './context'\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\n\n/** 采集条目:行内或块级(行内图片需要提升为块级) */\nexport type MtCollected =\n { kind: 'inline'; node: MtInlineNode } | { kind: 'block'; node: MtBlockNode }\n\n/** 取字符;越界返回空串 */\nexport const chAt = (source: string, index: number): string => source.charAt(index)\n\n/**\n * 构造段落。\n *\n * @param attrs 段落属性\n * @param content 行内子节点\n * @returns 段落节点(无内容时不写 `content`)\n */\nexport const makeParagraph = (\n attrs: { id: string },\n content: readonly MtInlineNode[],\n): MtParagraphNode => ({\n type: 'paragraph',\n attrs,\n ...(content.length > 0 ? { content: [...content] } : {}),\n})\n\n/**\n * 构造文本节点(含标记规范化)。\n *\n * @param value 文本\n * @param marks 累积的标记\n * @param ctx 解析上下文\n * @param path 定位路径\n * @returns 文本节点;空文本返回 `undefined`\n */\nconst makeText = (\n value: string,\n marks: readonly MtMark[],\n ctx: MtParseContext,\n path: string,\n): MtInlineNode | undefined => {\n if (value.length === 0) return undefined\n const { marks: normalized } = normalizeMarks(marks, {\n path,\n marksAllowed: true,\n degradations: ctx.degradations,\n issues: ctx.issues,\n })\n return normalized.length > 0\n ? { type: 'text', text: value, marks: normalized }\n : { type: 'text', text: value }\n}\n\n/**\n * 查找从 `from` 开始、由 `marker` 组成且长度不小于 `length` 的连续片段。\n *\n * @param text 文本\n * @param from 起始下标\n * @param marker 标记字符\n * @param length 期望的连续长度\n * @returns 起始下标;未找到返回 `-1`\n */\nconst findRun = (text: string, from: number, marker: string, length: number): number => {\n let i = from\n while (i < text.length) {\n if (chAt(text, i) === marker) {\n let run = 0\n while (chAt(text, i + run) === marker) run += 1\n if (run >= length) return i\n i += run\n continue\n }\n i += 1\n }\n return -1\n}\n\n/**\n * 统计从 `from` 开始的连续同字符长度。\n *\n * @param text 文本\n * @param from 起始下标\n * @param marker 标记字符\n * @returns 连续长度\n */\nconst countRun = (text: string, from: number, marker: string): number => {\n let run = 0\n while (chAt(text, from + run) === marker) run += 1\n return run\n}\n\n/**\n * 查找闭合标记;反斜杠转义会被跳过。\n *\n * @param text 文本\n * @param from 起始下标\n * @param marker 闭合标记\n * @returns 闭合位置;未找到返回 `-1`\n */\nconst findClosing = (text: string, from: number, marker: string): number => {\n let i = from\n while (i < text.length) {\n const ch = chAt(text, i)\n if (ch === '\\\\') {\n i += 2\n continue\n }\n if (text.startsWith(marker, i)) return i\n i += 1\n }\n return -1\n}\n\n/**\n * 解析 `[label](href)` 或 ``。\n *\n * @param text 文本\n * @param bracketStart `[` 的下标\n * @returns 标签、地址与结束下标;形态不符时返回 `undefined`\n */\nconst parseLinkAt = (\n text: string,\n bracketStart: number,\n): { label: string; href: string; end: number } | undefined => {\n const closeBracket = findClosing(text, bracketStart + 1, ']')\n if (closeBracket < 0) return undefined\n if (chAt(text, closeBracket + 1) !== '(') return undefined\n const closeParen = findClosing(text, closeBracket + 2, ')')\n if (closeParen < 0) return undefined\n const label = text.slice(bracketStart + 1, closeBracket)\n let href = text.slice(closeBracket + 2, closeParen).trim()\n // 去掉可选的 title:`href \"title\"`\n const spaceAt = href.search(/\\s/)\n if (spaceAt > 0) href = href.slice(0, spaceAt)\n return { label, href, end: closeParen + 1 }\n}\n\n/** 需要识别为标记的内联 HTML 标签 */\nconst INLINE_TAG_MARKS: ReadonlyMap<string, string> = new Map([\n ['u', 'underline'],\n ['mark', 'highlight'],\n ['sub', 'subscript'],\n ['sup', 'superscript'],\n])\n\n/** 内联 HTML 起始标签的形态 */\nconst RE_INLINE_TAG = /^<(\\/?)([a-zA-Z][a-zA-Z0-9]*)(?:\\s[^>]*)?>/\n\n/**\n * 解析行内序列(可产出块级节点,如行内图片需要提升)。\n *\n * @param text 行内文本\n * @param marks 当前累积的标记\n * @param ctx 解析上下文\n * @param basePath 路径前缀\n * @returns 采集序列\n */\nexport const parseInlineSequence = (\n text: string,\n marks: readonly MtMark[],\n ctx: MtParseContext,\n basePath: string,\n): MtCollected[] => {\n const out: MtCollected[] = []\n let buffer = ''\n let i = 0\n\n const flush = (): void => {\n if (buffer.length === 0) return\n const node = makeText(buffer, marks, ctx, basePath)\n if (node) out.push({ kind: 'inline', node })\n buffer = ''\n }\n\n while (i < text.length) {\n const ch = chAt(text, i)\n\n if (ch === '\\\\' && i + 1 < text.length) {\n buffer += chAt(text, i + 1)\n i += 2\n continue\n }\n\n if (ch === '\\n') {\n // 行尾两空格 = 硬换行\n if (buffer.endsWith(' ')) {\n buffer = buffer.slice(0, -2)\n flush()\n out.push({ kind: 'inline', node: { type: 'hardBreak' } })\n } else {\n buffer += '\\n'\n }\n i += 1\n continue\n }\n\n if (ch === '`') {\n const run = countRun(text, i, '`')\n const close = findRun(text, i + run, '`', run)\n if (close > 0) {\n flush()\n const node = makeText(\n text.slice(i + run, close),\n [...marks, { type: 'code' }],\n ctx,\n basePath,\n )\n if (node) out.push({ kind: 'inline', node })\n i = close + run\n continue\n }\n }\n\n if (ch === '!' && chAt(text, i + 1) === '[') {\n const parsed = parseLinkAt(text, i + 1)\n if (parsed) {\n flush()\n out.push({\n kind: 'block',\n node: {\n type: 'image',\n attrs: { id: ctx.nextId(), src: parsed.href, alt: parsed.label },\n },\n })\n i = parsed.end\n continue\n }\n }\n\n if (ch === '[') {\n const parsed = parseLinkAt(text, i)\n if (parsed) {\n flush()\n const node = makeText(\n parsed.label,\n [...marks, { type: 'link', attrs: { href: parsed.href } }],\n ctx,\n basePath,\n )\n if (node) out.push({ kind: 'inline', node })\n i = parsed.end\n continue\n }\n }\n\n if (ch === '$' && countRun(text, i, '$') === 1) {\n const close = findClosing(text, i + 1, '$')\n if (close > 0) {\n flush()\n out.push({\n kind: 'inline',\n node: { type: 'inlineMath', attrs: { tex: text.slice(i + 1, close) } },\n })\n i = close + 1\n continue\n }\n }\n\n if (ch === '*' || ch === '_' || ch === '~') {\n const run = ch === '~' ? countRun(text, i, '~') : countRun(text, i, ch)\n const width = ch === '~' ? 2 : run >= 2 ? 2 : 1\n if (ch !== '~' || run >= 2) {\n const marker = ch.repeat(width)\n const close = findClosing(text, i + width, marker)\n if (close > i + width) {\n flush()\n const inner = parseInlineSequence(\n text.slice(i + width, close),\n [\n ...marks,\n ch === '~' ? { type: 'strike' } : width === 2 ? { type: 'bold' } : { type: 'italic' },\n ],\n ctx,\n basePath,\n )\n out.push(...inner)\n i = close + width\n continue\n }\n }\n }\n\n if (ch === '<') {\n const tagMatch = RE_INLINE_TAG.exec(text.slice(i))\n if (tagMatch) {\n const closing = tagMatch[1] === '/'\n const name = (tagMatch[2] ?? '').toLowerCase()\n const markName = INLINE_TAG_MARKS.get(name)\n const full = tagMatch[0]\n\n if (markName !== undefined) {\n const innerStart = i + full.length\n const endTag = `</${name}>`\n if (closing) {\n // 孤立的闭合标签:忽略\n i = innerStart\n continue\n }\n const endAt = findClosing(text, innerStart, endTag)\n if (endAt > 0) {\n flush()\n const attrMatch = /style=\"([^\"]*)\"/.exec(full)\n const extra: MtMark[] =\n markName === 'highlight'\n ? [\n {\n type: 'highlight',\n attrs: { color: attrMatch?.[1]?.replace(/^background-color:\\s*/, '') ?? '' },\n },\n ]\n : [{ type: markName as 'underline' | 'subscript' | 'superscript' }]\n out.push(\n ...parseInlineSequence(\n text.slice(innerStart, endAt),\n [...marks, ...extra],\n ctx,\n basePath,\n ),\n )\n i = endAt + endTag.length\n continue\n }\n }\n\n if (name === 'span') {\n const attrMatch = /style=\"([^\"]*)\"/.exec(full)\n if (!closing && attrMatch) {\n const endAt = findClosing(text, i + full.length, '</span>')\n if (endAt > 0) {\n flush()\n ctx.degradations.push(\n degrade(\n MT_DEG.LOSSY_CONVERSION,\n 'textStyle',\n basePath,\n 'Markdown 内联 <span> 的样式已被还原为 textStyle(部分属性可能丢失)',\n 'literalized',\n ),\n )\n out.push(\n ...parseInlineSequence(text.slice(i + full.length, endAt), marks, ctx, basePath),\n )\n i = endAt + '</span>'.length\n continue\n }\n }\n }\n }\n }\n\n buffer += ch\n i += 1\n }\n\n flush()\n return out\n}\n","/**\n * Markdown → `MtDocument`。\n *\n * **Markdown 是有损格式**,本模块只覆盖 / 定义的往返子集\n * 标题、段落、引用、有序/无序/任务列表(含嵌套)、围栏代码块、分隔线、\n * GFM 表格、图片、链接、强调、行内代码、行内/块级公式、以及本包自己产出的\n * 内联 HTML 标记(`<u>` `<mark>` `<sub>` `<sup>` `<span>`)。\n *\n * 覆盖不到的语法**不猜测**:按普通文本处理,并在降级报告中说明。\n *\n * 实现说明:块级识别使用正则(Markdown 语法识别与 HTML 清洗无关,\n * 「禁止正则」仅约束 XSS 清洗路径)。\n */\n\nimport { finalizeInlineNodes } from '../normalize'\nimport { MT_SCHEMA_VERSION } from '../types/base'\nimport type { MtHeadingLevel } from '../types/base'\nimport type { MtDocument } from '../types/document'\nimport type { MtBlockNode, MtInlineNode, MtTableNode, MtTableRowNode } from '../types/nodes'\nimport type { MtParseOptions, MtParseResult } from '../types/io'\nimport { createIdGenerator } from '../utils/id'\nimport type { MtParseContext } from './context'\nimport { chAt, makeParagraph, parseInlineSequence } from './markdown-inline'\nimport type { MtCollected } from './markdown-inline'\n\n/** 五级标题的正则 */\nconst RE_ATX = /^ {0,3}(#{1,6})(?:\\s+(.*?))?\\s*#*\\s*$/\n/** 围栏起始 */\nconst RE_FENCE = /^ {0,3}(`{3,}|~{3,})\\s*([^\\s`]*)/\n/** 分隔线 */\nconst RE_HR = /^ {0,3}(?:(?:\\*\\s*){3,}|(?:-\\s*){3,}|(?:_\\s*){3,})$/\n/** 块级公式围栏:独占一行的 `$$`(与 `serialize/markdown.ts` 的输出严格对称) */\nconst RE_MATH_FENCE = /^ {0,3}\\$\\$\\s*$/\n/** 列表项:无序 `- * +`、有序 `N.` / `N)`、任务框 */\nconst RE_LIST = /^( *)([-*+]|\\d{1,9}[.)])(?: +|$)/\n/** 任务框 */\nconst RE_TASK = /^\\[( |x|X)\\]\\s*/\n/** GFM 表格分隔行 */\nconst RE_TABLE_DELIM = /^ {0,3}\\|? *:?-{1,}:? *(\\| *:?-{1,}:? *)*\\|? *$/\n\n/** 计算行首缩进宽度(Tab 记 4 空格) */\nconst indentWidth = (line: string): number => {\n let width = 0\n for (let i = 0; i < line.length; i += 1) {\n const ch = line.charAt(i)\n if (ch === ' ') width += 1\n else if (ch === '\\t') width += 4\n else break\n }\n return width\n}\n\n/** 去掉至多 `count` 个前导空格 */\nconst stripIndent = (line: string, count: number): string => {\n let i = 0\n while (i < count && chAt(line, i) === ' ') i += 1\n return line.slice(i)\n}\n\n/** 把采集序列按段落分组为块级节点 */\nconst groupItems = (items: readonly MtCollected[], ctx: MtParseContext): MtBlockNode[] => {\n const out: MtBlockNode[] = []\n let buffer: MtInlineNode[] = []\n const flush = (): void => {\n if (buffer.length === 0) return\n out.push(makeParagraph({ id: ctx.nextId() }, finalizeInlineNodes(buffer)))\n buffer = []\n }\n for (const item of items) {\n if (item.kind === 'inline') {\n buffer.push(item.node)\n continue\n }\n flush()\n out.push(item.node)\n }\n flush()\n return out\n}\n\n/** 按行解析块级结构 */\nconst parseLines = (\n lines: readonly string[],\n ctx: MtParseContext,\n basePath: string,\n): MtBlockNode[] => {\n const out: MtBlockNode[] = []\n let i = 0\n\n /** 段落收集器 */\n const paragraphLines: string[] = []\n const flushParagraph = (): void => {\n if (paragraphLines.length === 0) return\n const text = paragraphLines.join('\\n')\n paragraphLines.length = 0\n out.push(...groupItems(parseInlineSequence(text, [], ctx, basePath), ctx))\n }\n\n while (i < lines.length) {\n const line = lines[i] ?? ''\n\n if (line.trim() === '') {\n flushParagraph()\n i += 1\n continue\n }\n\n const fence = RE_FENCE.exec(line)\n if (fence) {\n flushParagraph()\n const marker = fence[1] ?? '```'\n const language = fence[2] ?? ''\n const body: string[] = []\n i += 1\n while (i < lines.length && !(lines[i] ?? '').trimStart().startsWith(marker)) {\n body.push(lines[i] ?? '')\n i += 1\n }\n i += 1\n out.push({\n type: 'codeBlock',\n attrs: { id: ctx.nextId(), ...(language.length > 0 ? { language } : {}) },\n ...(body.length > 0 ? { content: [{ type: 'text', text: body.join('\\n') }] } : {}),\n })\n continue\n }\n\n const mathFence = RE_MATH_FENCE.test(line)\n if (mathFence) {\n flushParagraph()\n const body: string[] = []\n i += 1\n while (i < lines.length && !RE_MATH_FENCE.test(lines[i] ?? '')) {\n body.push(lines[i] ?? '')\n i += 1\n }\n i += 1\n // `svg` 为可选属性(预渲染产物),Markdown 中不存在该信息,\n // 由业务侧在渲染前用 preRendered 索引补齐\n out.push({ type: 'mathBlock', attrs: { id: ctx.nextId(), tex: body.join('\\n') } })\n continue\n }\n\n const atx = RE_ATX.exec(line)\n if (atx) {\n flushParagraph()\n const level = Math.min(6, (atx[1] ?? '#').length) as MtHeadingLevel\n const text = atx[2] ?? ''\n out.push({\n type: 'heading',\n attrs: { id: ctx.nextId(), level },\n ...(text.length > 0\n ? {\n content: finalizeInlineNodes(\n parseInlineSequence(text, [], ctx, basePath).flatMap((item) =>\n item.kind === 'inline' ? [item.node] : [],\n ),\n ),\n }\n : {}),\n })\n i += 1\n continue\n }\n\n if (RE_HR.test(line)) {\n flushParagraph()\n out.push({ type: 'horizontalRule', attrs: { id: ctx.nextId() } })\n i += 1\n continue\n }\n\n if (/^ {0,3}>/.test(line)) {\n flushParagraph()\n const inner: string[] = []\n while (i < lines.length && /^ {0,3}>/.test(lines[i] ?? '')) {\n inner.push((lines[i] ?? '').replace(/^ {0,3}> ?/, ''))\n i += 1\n }\n out.push({\n type: 'blockquote',\n attrs: { id: ctx.nextId() },\n content: parseLines(inner, ctx, basePath),\n })\n continue\n }\n\n if (RE_LIST.test(line)) {\n flushParagraph()\n const list = parseListBlock(lines, i, ctx, basePath)\n out.push(list.node)\n i = list.next\n continue\n }\n\n const next = lines[i + 1]\n if (line.includes('|') && next !== undefined && RE_TABLE_DELIM.test(next)) {\n flushParagraph()\n const table = parseTableBlock(lines, i, ctx, basePath)\n out.push(table.node)\n i = table.next\n continue\n }\n\n paragraphLines.push(line)\n i += 1\n }\n\n flushParagraph()\n return out\n}\n\n/** 列表解析结果 */\ninterface MtListParse {\n node: MtBlockNode\n next: number\n}\n\n/**\n * 解析列表块(含嵌套与任务框)。\n *\n * @param lines 全部行\n * @param start 起始行号\n * @param ctx 解析上下文\n * @param basePath 路径前缀\n * @returns 列表节点与下一行号\n */\nconst parseListBlock = (\n lines: readonly string[],\n start: number,\n ctx: MtParseContext,\n basePath: string,\n): MtListParse => {\n const firstMarker = RE_LIST.exec(lines[start] ?? '')\n const baseIndent = firstMarker?.[1]?.length ?? 0\n const ordered = firstMarker !== null && /\\d/.test(firstMarker[2] ?? '')\n const items: Array<{ id: string; body: MtBlockNode[]; checked?: boolean }> = []\n let i = start\n\n while (i < lines.length) {\n const line = lines[i] ?? ''\n const marker = RE_LIST.exec(line)\n if (!marker || (marker[1]?.length ?? 0) !== baseIndent) break\n if (/\\d/.test(marker[2] ?? '') !== ordered) break\n\n let rest = line.slice(marker[0].length)\n let checked: boolean | undefined\n const task = RE_TASK.exec(rest)\n if (task) {\n checked = (task[1] ?? '').toLowerCase() === 'x'\n rest = rest.slice(task[0].length)\n }\n\n const bodyLines = [rest]\n i += 1\n while (i < lines.length) {\n const cont = lines[i] ?? ''\n if (cont.trim() === '') {\n const after = lines[i + 1]\n if (after !== undefined && indentWidth(after) > baseIndent) {\n bodyLines.push('')\n i += 1\n continue\n }\n break\n }\n const width = indentWidth(cont)\n if (width > baseIndent) {\n bodyLines.push(stripIndent(cont, Math.min(width, baseIndent + 2)))\n i += 1\n continue\n }\n break\n }\n\n items.push({ id: ctx.nextId(), body: parseLines(bodyLines, ctx, basePath), checked })\n\n // 项间空行\n while (i < lines.length && (lines[i] ?? '').trim() === '') {\n const after = lines[i + 1]\n const afterMarker = after === undefined ? null : RE_LIST.exec(after)\n if (afterMarker && (afterMarker[1]?.length ?? 0) === baseIndent) {\n i += 1\n break\n }\n break\n }\n }\n\n const isTask = items.some((item) => item.checked !== undefined)\n const withLeading = (body: MtBlockNode[]): MtBlockNode[] =>\n body[0]?.type === 'paragraph' ? body : [makeParagraph({ id: ctx.nextId() }, []), ...body]\n\n if (isTask) {\n return {\n node: {\n type: 'taskList',\n attrs: { id: ctx.nextId() },\n content: items.map((item) => ({\n type: 'taskItem',\n attrs: { id: item.id, ...(item.checked === undefined ? {} : { checked: item.checked }) },\n content: withLeading(item.body),\n })),\n },\n next: i,\n }\n }\n\n const content = items.map((item) => ({\n type: 'listItem' as const,\n attrs: { id: item.id },\n content: withLeading(item.body),\n }))\n\n if (ordered) {\n const startText = firstMarker?.[2] ?? '1.'\n const startNumber = Number.parseInt(startText, 10)\n return {\n node: {\n type: 'orderedList',\n attrs: {\n id: ctx.nextId(),\n ...(Number.isFinite(startNumber) && startNumber !== 1 ? { start: startNumber } : {}),\n },\n content,\n },\n next: i,\n }\n }\n\n return { node: { type: 'bulletList', attrs: { id: ctx.nextId() }, content }, next: i }\n}\n\n/** 拆分表格行的单元格 */\nconst splitCells = (line: string): string[] => {\n let trimmed = line.trim()\n if (trimmed.startsWith('|')) trimmed = trimmed.slice(1)\n if (trimmed.endsWith('|')) trimmed = trimmed.slice(0, -1)\n const cells: string[] = []\n let buffer = ''\n for (let i = 0; i < trimmed.length; i += 1) {\n const ch = trimmed.charAt(i)\n if (ch === '\\\\' && chAt(trimmed, i + 1) === '|') {\n buffer += '|'\n i += 1\n continue\n }\n if (ch === '|') {\n cells.push(buffer.trim())\n buffer = ''\n continue\n }\n buffer += ch\n }\n cells.push(buffer.trim())\n return cells\n}\n\n/**\n * 解析 GFM 表格。\n *\n * @param lines 全部行\n * @param start 起始行号\n * @param ctx 解析上下文\n * @param basePath 路径前缀\n * @returns 表格节点与下一行号\n */\nconst parseTableBlock = (\n lines: readonly string[],\n start: number,\n ctx: MtParseContext,\n basePath: string,\n): { node: MtTableNode; next: number } => {\n const header = splitCells(lines[start] ?? '')\n const rows: MtTableRowNode[] = []\n\n const cellBlocks = (text: string, index: number): MtBlockNode[] => {\n const blocks = groupItems(parseInlineSequence(text, [], ctx, `${basePath}[${index}]`), ctx)\n return blocks.length > 0 ? blocks : [makeParagraph({ id: ctx.nextId() }, [])]\n }\n\n const headerCells = header.map((text, index) => ({\n type: 'tableHeader' as const,\n content: cellBlocks(text, index),\n }))\n rows.push({ type: 'tableRow', content: headerCells })\n\n let i = start + 2\n while (i < lines.length && (lines[i] ?? '').trim() !== '' && (lines[i] ?? '').includes('|')) {\n const cells = splitCells(lines[i] ?? '')\n rows.push({\n type: 'tableRow',\n content: cells.map((text, index) => ({\n type: 'tableCell' as const,\n content: cellBlocks(text, index),\n })),\n })\n i += 1\n }\n\n return { node: { type: 'table', attrs: { id: ctx.nextId() }, content: rows }, next: i }\n}\n\n/**\n * 把 Markdown 源码解析为 `MtDocument`。\n *\n * @param markdown Markdown 源码\n * @param options 解析选项\n * @returns 文档、降级报告与校验问题\n *\n * @example\n * ```ts\n * const { doc, report } = parseMarkdownToDocument('# 标题\\n\\n正文 **加粗**')\n * doc.content.map((n) => n.type) // ['heading', 'paragraph']\n * ```\n */\nexport const parseMarkdownToDocument = (\n markdown: string,\n options: MtParseOptions = {},\n): MtParseResult => {\n const ctx: MtParseContext = {\n assignIds: options.assignIds !== false,\n nextId: createIdGenerator(options.now === undefined ? {} : { now: options.now }),\n degradations: [],\n issues: [],\n }\n\n if (typeof markdown !== 'string' || markdown.trim().length === 0) {\n const doc: MtDocument = {\n schemaVersion: MT_SCHEMA_VERSION,\n type: 'doc',\n content: [makeParagraph({ id: ctx.nextId() }, [])],\n }\n return { doc, report: [], issues: [] }\n }\n\n const normalized = markdown.split('\\r\\n').join('\\n').split('\\r').join('\\n')\n const blocks = parseLines(normalized.split('\\n'), ctx, 'content')\n\n const doc: MtDocument = {\n schemaVersion: MT_SCHEMA_VERSION,\n type: 'doc',\n content: blocks.length > 0 ? blocks : [makeParagraph({ id: ctx.nextId() }, [])],\n }\n\n return {\n doc,\n report: options.report === false ? [] : ctx.degradations,\n issues: ctx.issues,\n }\n}\n","/**\n * parse 层出口:HTML / Markdown → `MtDocument`。\n */\n\nimport type { MtParseOptions, MtParseResult } from '../types/io'\nimport { parseHtmlToDocument } from './html'\nimport { parseMarkdownToDocument } from './markdown'\n\nexport { parseHtmlToDocument } from './html'\nexport { parseMarkdownToDocument } from './markdown'\nexport type { MtParseContext } from './context'\n\n/** 支持的输入格式 */\nexport type MtParseFormat = 'html' | 'markdown'\n\n/**\n * 按格式解析源码。\n *\n * @param source 源码\n * @param format 格式\n * @param options 解析选项\n * @returns 解析结果\n *\n * @example\n * ```ts\n * parseDocument('<p>a</p>', 'html')\n * parseDocument('# 标题', 'markdown')\n * ```\n */\nexport const parseDocument = (\n source: string,\n format: MtParseFormat,\n options: MtParseOptions = {},\n): MtParseResult =>\n format === 'markdown'\n ? parseMarkdownToDocument(source, options)\n : parseHtmlToDocument(source, options)\n","/**\n * 轻量哈希与预渲染产物索引键。\n *\n * 用途:`preRendered.code` 以**代码内容哈希**为键,避免在文档里内联整段源码\n * 作为索引键(体积与可读性都不可接受)。\n */\n\n/** FNV-1a 32 位哈希的 offset basis */\nconst FNV_OFFSET = 0x811c9dc5\n\n/** FNV-1a 32 位哈希的质数 */\nconst FNV_PRIME = 0x01000193\n\n/**\n * 计算字符串的 FNV-1a 32 位哈希(返回 8 位十六进制)。\n *\n * 选择 FNV-1a 的理由:实现只有几行、无依赖、结果稳定,\n * 且**非密码学**用途下分布足够用于索引键。\n *\n * @param text 输入文本\n * @returns 8 位十六进制哈希\n *\n * @example\n * ```ts\n * fnv1a('const a = 1') // '2f3a…'(同输入恒等)\n * ```\n */\nexport const fnv1a = (text: string): string => {\n let hash = FNV_OFFSET\n for (let i = 0; i < text.length; i += 1) {\n hash ^= text.charCodeAt(i)\n hash = Math.imul(hash, FNV_PRIME) >>> 0\n }\n return hash.toString(16).padStart(8, '0')\n}\n","/**\n * 通用树遍历与体积统计工具(零依赖、零 DOM)。\n */\n\nimport { isRecord } from './guards'\n\n/** 文档根的路径前缀,路径写法一致 */\nexport const MT_TREE_ROOT_PATH = 'content'\n\n/** 可参与遍历的最小节点结构 */\nexport interface MtTreeNodeLike {\n type?: unknown\n content?: unknown\n}\n\n/** 一次访问的上下文 */\nexport interface MtTreeVisit<T extends MtTreeNodeLike> {\n /** 当前节点 */\n node: T\n /** 文档内定位路径,如 `content[2].content[0]` */\n path: string\n /** 嵌套深度,根层为 0 */\n depth: number\n /** 父节点;根层为 `undefined` */\n parent: T | undefined\n /** 在父节点 `content` 中的下标 */\n index: number\n}\n\n/**\n * 拼接子节点路径。\n *\n * @param base 父路径\n * @param index 下标\n * @returns 子节点路径\n */\nexport const childPath = (base: string, index: number): string => `${base}[${index}]`\n\n/**\n * 深度优先遍历节点树。\n *\n * 采用显式下标循环而非 `forEach`,避免闭包捕获带来的额外开销;\n * 递归深度等于文档嵌套深度,长文档亦不会触及栈上限。\n *\n * @param roots 根层节点数组\n * @param visit 访问回调\n * @param rootPath 根路径前缀,缺省 `'content'`\n *\n * @example\n * ```ts\n * walkTree(doc.content, ({ path, depth }) => {\n * console.log(path, depth)\n * })\n * ```\n */\nexport const walkTree = <T extends MtTreeNodeLike>(\n roots: readonly T[],\n visit: (info: MtTreeVisit<T>) => void,\n rootPath: string = MT_TREE_ROOT_PATH,\n): void => {\n const step = (nodes: readonly T[], parent: T | undefined, depth: number, base: string): void => {\n for (let index = 0; index < nodes.length; index += 1) {\n const node = nodes[index]\n if (node === undefined) continue\n const path = childPath(base, index)\n visit({ node, path, depth, parent, index })\n const children = node.content\n if (Array.isArray(children)) {\n step(children as T[], node, depth + 1, `${path}.content`)\n }\n }\n }\n step(roots, undefined, 0, rootPath)\n}\n\n/** 树的规模统计 */\nexport interface MtTreeStats {\n /** 节点总数(不含根容器) */\n nodeCount: number\n /** 最大嵌套深度(根层节点深度为 1) */\n maxDepth: number\n}\n\n/**\n * 统计节点数与最大嵌套深度。\n *\n * @param roots 根层节点数组\n * @returns 规模统计\n *\n * @example\n * ```ts\n * computeTreeStats([{ type: 'paragraph' }]) // { nodeCount: 1, maxDepth: 1 }\n * ```\n */\nexport const computeTreeStats = (roots: readonly MtTreeNodeLike[]): MtTreeStats => {\n let nodeCount = 0\n let maxDepth = 0\n walkTree(roots, ({ depth }) => {\n nodeCount += 1\n if (depth + 1 > maxDepth) maxDepth = depth + 1\n })\n return { nodeCount, maxDepth }\n}\n\n/**\n * 计算字符串的 UTF-8 字节长度。\n *\n * 不依赖 `TextEncoder`:`document` 包需在小程序运行时执行,\n * 且其 `tsconfig` 的 `lib` 已剔除 DOM,故手工按码点宽度累加。\n *\n * @param text 待计算文本\n * @returns 字节数\n *\n * @example\n * ```ts\n * utf8ByteLength('中文') // 6\n * utf8ByteLength('😀') // 4\n * ```\n */\nexport const utf8ByteLength = (text: string): number => {\n let bytes = 0\n for (let i = 0; i < text.length; i += 1) {\n const code = text.charCodeAt(i)\n if (code < 0x80) {\n bytes += 1\n } else if (code < 0x800) {\n bytes += 2\n } else if (code >= 0xd800 && code <= 0xdbff) {\n // 高位代理:与低位代理共同构成一个码点,占 4 字节,跳过低位\n bytes += 4\n i += 1\n } else {\n bytes += 3\n }\n }\n return bytes\n}\n\n/**\n * 深拷贝纯 JSON 结构。\n *\n * 节点树全部由 JSON 可序列化的值构成,因此不使用 `structuredClone`\n * (小程序运行时不一定提供),也不使用 `JSON.parse(JSON.stringify())`\n * (会丢弃 `undefined` 之外还会改变 `-0`、`NaN` 等边界值)。\n *\n * @param value 待拷贝值\n * @returns 拷贝结果\n *\n * @example\n * ```ts\n * const copy = cloneJson({ a: [1, 2] })\n * ```\n */\nexport const cloneJson = <T>(value: T): T => {\n if (Array.isArray(value)) {\n return value.map((item: unknown) => cloneJson(item)) as unknown as T\n }\n if (isRecord(value)) {\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(value)) {\n const item = value[key]\n if (item === undefined) continue\n out[key] = cloneJson(item)\n }\n return out as unknown as T\n }\n return value\n}\n","/**\n * HTML 序列化的属性与样式原语。\n *\n * 被 `serialize/html.ts`(节点)与 `serialize/html-marks.ts`(标记)共用,\n * 因此单独成模块——两处必须用**同一套**转义与跳过 `undefined` / `null` 的规则,\n * 否则「节点属性」与「标记属性」的转义口径会漂移。\n */\n\nimport { escapeAttr } from '../utils/escape'\n\n/**\n * 属性对:`undefined` / `null` 表示「该属性缺省,不输出」。\n *\n * 之所以连 `null` 一起接受:`MtDocument` 的类型把可选属性声明为 `?:`,\n * 但**运行时的 JSON 可能带 `null`**(ProseMirror 侧可选属性的 `default`\n * 就是 `null`,旧数据的迁移也可能留下它)。让它在这里被统一跳过,\n * 比在每个调用点各写一遍 `=== undefined || === null` 可靠。\n */\nexport type MtAttrPair = readonly [name: string, value: string | undefined | null]\n\n/**\n * 拼接属性串(跳过 `undefined` / `null`)。\n *\n * @param pairs 属性对列表\n * @returns 形如 ` a=\"1\" b=\"2\"` 的字符串\n *\n * @example\n * ```ts\n * attrsToString([['href', 'https://a.test'], ['title', undefined]]) // ' href=\"https://a.test\"'\n * attrsToString([['start', null]]) // '' —— null 与 undefined 同等对待\n * ```\n */\nexport const attrsToString = (pairs: readonly MtAttrPair[]): string => {\n let out = ''\n for (const [name, value] of pairs) {\n if (value === undefined || value === null) continue\n out += ` ${name}=\"${escapeAttr(value)}\"`\n }\n return out\n}\n\n/**\n * 拼接内联 `style` 值(跳过空对象)。\n *\n * @param pairs 属性对列表\n * @returns `style` 值;无内容时返回 `undefined`\n *\n * @example\n * ```ts\n * styleToString([['text-align', 'center']]) // 'text-align: center'\n * ```\n */\nexport const styleToString = (pairs: readonly MtAttrPair[]): string | undefined => {\n const parts: string[] = []\n for (const [name, value] of pairs) {\n if (value === undefined || value === null) continue\n parts.push(`${name}: ${value}`)\n }\n return parts.length > 0 ? parts.join('; ') : undefined\n}\n","/**\n * HTML 序列化的标记包裹。\n *\n * 与 `parse/html-marks.ts` 严格对称:那边是「标签/样式 → 标记」,这边是\n * 「标记 → 标签/样式」。对称性由 `JSON → HTML → JSON` 往返用例保证。\n *\n * 唯一的「补默认值」例外是 `link` 的 `target` / `rel`:要求强制补出\n * 以消除 `target=\"_blank\"` 的 tabnabbing 风险;解析侧把恰好等于默认值的取值\n * 视为缺省,因此不破坏往返等价。\n */\n\nimport { MT_LINK_DEFAULT_REL, MT_LINK_DEFAULT_TARGET } from '../schema/marks'\nimport type { MtMark } from '../types/marks'\nimport { attrsToString, styleToString } from './html-attrs'\n\n/** 标记 → HTML 包装器的映射(无属性标记) */\nconst MARK_TAGS: ReadonlyMap<string, string> = new Map([\n ['bold', 'strong'],\n ['italic', 'em'],\n ['underline', 'u'],\n ['strike', 's'],\n ['code', 'code'],\n ['subscript', 'sub'],\n ['superscript', 'sup'],\n])\n\n/**\n * 把标记转成 HTML 包装器。\n *\n * @param mark 标记\n * @param open 是否为开标签\n * @returns 标签字符串\n */\nconst markWrapper = (mark: MtMark, open: boolean): string => {\n switch (mark.type) {\n case 'link': {\n const attrs = attrsToString([\n ['href', mark.attrs.href],\n ['target', mark.attrs.target ?? MT_LINK_DEFAULT_TARGET],\n ['rel', mark.attrs.rel ?? MT_LINK_DEFAULT_REL],\n ['title', mark.attrs.title],\n ])\n return open ? `<a${attrs}>` : '</a>'\n }\n case 'textStyle': {\n const style = styleToString([\n ['font-family', mark.attrs.fontFamily ?? undefined],\n ['font-size', mark.attrs.fontSize ?? undefined],\n ['color', mark.attrs.color ?? undefined],\n ['background-color', mark.attrs.backgroundColor ?? undefined],\n ['line-height', mark.attrs.lineHeight ?? undefined],\n ['letter-spacing', mark.attrs.letterSpacing ?? undefined],\n ])\n return open ? `<span${attrsToString([['style', style]])}>` : '</span>'\n }\n case 'highlight':\n return open\n ? `<mark${attrsToString([['style', styleToString([['background-color', mark.attrs.color]])]])}>`\n : '</mark>'\n default: {\n const tag = MARK_TAGS.get(mark.type) ?? 'span'\n return open ? `<${tag}>` : `</${tag}>`\n }\n }\n}\n\n/**\n * 按优先级降序把标记包裹到内容外层。\n *\n * @param content 已转义的文本\n * @param marks 标记数组(须已按优先级降序排好)\n * @returns 包裹结果\n *\n * @example\n * ```ts\n * wrapMarks('文本', [{ type: 'bold' }]) // '<strong>文本</strong>'\n * ```\n */\nexport const wrapMarks = (content: string, marks: readonly MtMark[] | undefined): string => {\n if (!marks || marks.length === 0) return content\n let out = content\n // 数组已降序,逆序遍历即「由内向外」闭合\n for (let i = marks.length - 1; i >= 0; i -= 1) {\n const mark = marks[i]\n if (mark === undefined) continue\n out = `${markWrapper(mark, true)}${out}${markWrapper(mark, false)}`\n }\n return out\n}\n","/**\n * `MtDocument` → HTML。\n *\n * 三条铁律:\n * 1. **只输出文档里真实存在的信息**,不补可选属性——`start` / `checked` 这类\n * 「默认值属性」一旦被擅自补出,`JSON → HTML → JSON` 立刻漂移;\n * 2. **标记按优先级降序由外向内包裹**,保证同一份 JSON 在任何端产出的嵌套顺序一致\n * 3. **节点身份一律用 `data-mt-*` 承载**,不依赖 `class`(class 属主题层契约)。\n *\n * 唯一的例外是 `link` 的 `target` / `rel`:要求「强制补」,以消除\n * `target=\"_blank\"` 的 tabnabbing 风险。与之对称,解析侧会把**恰好等于默认值**\n * 的 `target` / `rel` 视为缺省,从而既不丢安全属性、也不破坏往返等价。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport type { MtBlockNode, MtDocumentNode, MtInlineNode, MtTableRowNode } from '../types/nodes'\nimport type { MtDocument } from '../types/document'\nimport type { MtPreRenderedIndex, MtSerializeResult } from '../types/io'\nimport type { MtDegradationEntry } from '../types/report'\nimport { escapeHtml } from '../utils/escape'\nimport { fnv1a } from '../utils/hash'\nimport { MT_COLWIDTH_UNIT, MT_INDENT_UNIT, formatCssLength } from '../utils/style-units'\nimport { computeTreeStats, utf8ByteLength } from '../utils/tree'\nimport { attrsToString, styleToString } from './html-attrs'\nimport { wrapMarks } from './html-marks'\n\n/** 序列化上下文 */\nexport interface MtHtmlSerializeContext {\n /** 降级收集器 */\n degradations: MtDegradationEntry[]\n /** 预渲染产物索引 */\n preRendered: MtPreRenderedIndex\n /** 是否启用换行美化 */\n pretty: boolean\n}\n\n/** 序列化行内节点 */\nconst serializeInline = (node: MtInlineNode, ctx: MtHtmlSerializeContext): string => {\n switch (node.type) {\n case 'text':\n return wrapMarks(escapeHtml(node.text), node.marks)\n case 'hardBreak':\n return '<br>'\n case 'mention': {\n const kind = node.attrs.kind\n return `<span${attrsToString([\n ['data-mt-mention', ''],\n ['data-mt-id', node.attrs.id],\n ['data-mt-kind', kind === undefined ? undefined : kind],\n ])}>${escapeHtml(node.attrs.label)}</span>`\n }\n case 'inlineMath': {\n const svg = node.attrs.svg ?? ctx.preRendered.math?.[node.attrs.tex]\n if (svg === undefined || svg === null || svg.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MISSING_PRERENDER,\n 'inlineMath',\n 'inline',\n `行内公式 \"${node.attrs.tex}\" 缺少预渲染 SVG`,\n 'replaced',\n ),\n )\n }\n return `<span${attrsToString([\n ['data-mt-math-inline', ''],\n ['data-tex', node.attrs.tex],\n ])}>${svg ?? ''}</span>`\n }\n case 'emoji':\n return `<span${attrsToString([\n ['data-mt-emoji', ''],\n ['data-mt-name', node.attrs.name],\n ])}>${escapeHtml(node.attrs.unicode)}</span>`\n default:\n return ''\n }\n}\n\n/** 序列化行内节点序列 */\nconst serializeInlineNodes = (\n nodes: readonly MtInlineNode[] | undefined,\n ctx: MtHtmlSerializeContext,\n): string => {\n if (!nodes) return ''\n let out = ''\n for (const node of nodes) out += serializeInline(node, ctx)\n return out\n}\n\n/** 由 `child[0]` 是否为 `text` 判断是否单文本子节点 */\nconst singleText = (nodes: readonly MtDocumentNode[]): string => {\n const first = nodes[0]\n if (first !== undefined && first.type === 'text') return first.text\n return ''\n}\n\n/** 序列化表格单元格 */\nconst serializeRow = (row: MtTableRowNode, ctx: MtHtmlSerializeContext): string => {\n let cells = ''\n for (const cell of row.content ?? []) {\n const tag = cell.type === 'tableHeader' ? 'th' : 'td'\n const attrs = cell.attrs\n const style = styleToString([\n ['text-align', attrs?.textAlign ?? undefined],\n [\n 'line-height',\n attrs?.lineHeight === undefined || attrs.lineHeight === null\n ? undefined\n : String(attrs.lineHeight),\n ],\n ['background-color', attrs?.background ?? undefined],\n [\n 'width',\n attrs?.colwidth === undefined || attrs.colwidth === null\n ? undefined\n : formatCssLength(attrs.colwidth, MT_COLWIDTH_UNIT),\n ],\n ])\n const head = attrsToString([\n // `null` 与 `undefined` 都表示「没写」。少了 `null` 这一支时 `String(null)`\n // 会把字面量 `colspan=\"null\"` 写进产物,而编辑器侧未声明的属性恰好是 `null`。\n [\n 'colspan',\n attrs?.colspan === undefined || attrs.colspan === null ? undefined : String(attrs.colspan),\n ],\n [\n 'rowspan',\n attrs?.rowspan === undefined || attrs.rowspan === null ? undefined : String(attrs.rowspan),\n ],\n ['data-mt-align', attrs?.align ?? undefined],\n ['style', style],\n ])\n cells += `<${tag}${head}>${serializeBlocks(cell.content ?? [], ctx)}</${tag}>`\n }\n return `<tr>${cells}</tr>`\n}\n\n/** 序列化媒体/特殊块 */\nconst serializeAtomBlock = (node: MtBlockNode, ctx: MtHtmlSerializeContext): string => {\n switch (node.type) {\n case 'horizontalRule':\n return `<hr${attrsToString([['data-mt-id', node.attrs.id]])}>`\n case 'image':\n return `<img${attrsToString([\n ['data-mt-id', node.attrs.id],\n ['src', node.attrs.src],\n ['alt', node.attrs.alt ?? undefined],\n ['title', node.attrs.title ?? undefined],\n [\n 'width',\n node.attrs.width === undefined || node.attrs.width === null\n ? undefined\n : String(node.attrs.width),\n ],\n [\n 'height',\n node.attrs.height === undefined || node.attrs.height === null\n ? undefined\n : String(node.attrs.height),\n ],\n ['data-mt-align', node.attrs.align ?? undefined],\n ])}>`\n case 'video':\n return `<video${attrsToString([\n ['data-mt-id', node.attrs.id],\n ['src', node.attrs.src],\n ['poster', node.attrs.poster ?? undefined],\n [\n 'width',\n node.attrs.width === undefined || node.attrs.width === null\n ? undefined\n : String(node.attrs.width),\n ],\n [\n 'height',\n node.attrs.height === undefined || node.attrs.height === null\n ? undefined\n : String(node.attrs.height),\n ],\n ['data-mt-align', node.attrs.align ?? undefined],\n ['controls', ''],\n ])}></video>`\n case 'audio':\n return `<audio${attrsToString([\n ['data-mt-id', node.attrs.id],\n ['src', node.attrs.src],\n ['title', node.attrs.title ?? undefined],\n ['controls', ''],\n ])}></audio>`\n case 'attachment':\n return `<a${attrsToString([\n ['data-mt-attachment', ''],\n ['data-mt-id', node.attrs.id],\n ['href', node.attrs.src],\n ['data-mt-name', node.attrs.name],\n [\n 'data-mt-size',\n node.attrs.size === undefined || node.attrs.size === null\n ? undefined\n : String(node.attrs.size),\n ],\n ['data-mt-mime', node.attrs.mime ?? undefined],\n ])}>${escapeHtml(node.attrs.name)}</a>`\n case 'mathBlock': {\n const svg = node.attrs.svg ?? ctx.preRendered.math?.[node.attrs.tex]\n if (svg === undefined || svg === null || svg.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MISSING_PRERENDER,\n 'mathBlock',\n 'block',\n `公式 \"${node.attrs.tex}\" 缺少预渲染 SVG`,\n 'replaced',\n ),\n )\n }\n const head = attrsToString([\n ['data-mt-math', ''],\n ['data-mt-id', node.attrs.id],\n ['data-tex', node.attrs.tex],\n ['data-mt-numbering', node.attrs.numbering ?? undefined],\n ])\n return `<div${head}>${svg ?? ''}</div>`\n }\n default:\n return ''\n }\n}\n\n/** 序列化代码块 */\nconst serializeCodeBlock = (\n node: Extract<MtBlockNode, { type: 'codeBlock' }>,\n ctx: MtHtmlSerializeContext,\n): string => {\n const text = singleText(node.content ?? [])\n const language = node.attrs.language\n const highlight = ctx.preRendered.code?.[fnv1a(text)] ?? ctx.preRendered.code?.[text]\n if (\n highlight === undefined &&\n text.length > 0 &&\n language !== undefined &&\n language !== null &&\n language.length > 0\n ) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MISSING_PRERENDER,\n 'codeBlock',\n 'block',\n `代码块 \"${language ?? ''}\" 缺少预渲染高亮产物`,\n 'literalized',\n ),\n )\n }\n const preAttrs = attrsToString([\n ['data-mt-id', node.attrs.id],\n // `language` 要判 `null`:ProseMirror 侧可选属性的 `default` 就是 `null`,\n // 只判 `undefined` 会让它穿透到 `language.length` 上(TypeError)。\n [\n 'data-mt-language',\n language === undefined || language === null || language.length === 0 ? undefined : language,\n ],\n [\n 'data-mt-line-numbers',\n node.attrs.showLineNumbers === undefined || node.attrs.showLineNumbers === null\n ? undefined\n : String(node.attrs.showLineNumbers),\n ],\n ])\n const codeAttrs = attrsToString([\n [\n 'class',\n language === undefined || language === null || language.length === 0\n ? undefined\n : `language-${language}`,\n ],\n ])\n const body = highlight ?? escapeHtml(text)\n return `<pre${preAttrs}><code${codeAttrs}>${body}</code></pre>`\n}\n\n/** 序列化块级节点 */\nconst serializeBlock = (node: MtBlockNode, ctx: MtHtmlSerializeContext): string => {\n switch (node.type) {\n case 'paragraph': {\n const attrs = node.attrs\n const style = styleToString([\n ['text-align', attrs.textAlign ?? undefined],\n [\n 'line-height',\n attrs.lineHeight === undefined || attrs.lineHeight === null\n ? undefined\n : String(attrs.lineHeight),\n ],\n [\n 'padding-left',\n attrs.indent === undefined || attrs.indent === null\n ? undefined\n : formatCssLength(attrs.indent, MT_INDENT_UNIT),\n ],\n ])\n const head = attrsToString([\n ['data-mt-id', attrs.id],\n ['style', style],\n ])\n return `<p${head}>${serializeInlineNodes(node.content, ctx)}</p>`\n }\n case 'heading': {\n const attrs = node.attrs\n const style = styleToString([\n ['text-align', attrs.textAlign ?? undefined],\n [\n 'line-height',\n attrs.lineHeight === undefined || attrs.lineHeight === null\n ? undefined\n : String(attrs.lineHeight),\n ],\n ])\n const head = attrsToString([\n ['data-mt-id', attrs.id],\n ['style', style],\n ])\n return `<h${attrs.level}${head}>${serializeInlineNodes(node.content, ctx)}</h${attrs.level}>`\n }\n case 'blockquote':\n return `<blockquote${attrsToString([['data-mt-id', node.attrs.id]])}>${serializeBlocks(node.content ?? [], ctx)}</blockquote>`\n case 'bulletList': {\n let items = ''\n for (const item of node.content ?? []) {\n items += `<li${attrsToString([['data-mt-id', item.attrs.id]])}>${serializeBlocks(item.content ?? [], ctx)}</li>`\n }\n return `<ul${attrsToString([['data-mt-id', node.attrs.id]])}>${items}</ul>`\n }\n case 'orderedList': {\n let items = ''\n for (const item of node.content ?? []) {\n items += `<li${attrsToString([['data-mt-id', item.attrs.id]])}>${serializeBlocks(item.content ?? [], ctx)}</li>`\n }\n const head = attrsToString([\n ['data-mt-id', node.attrs.id],\n [\n 'start',\n node.attrs.start === undefined || node.attrs.start === null\n ? undefined\n : String(node.attrs.start),\n ],\n ['type', node.attrs.type ?? undefined],\n ])\n return `<ol${head}>${items}</ol>`\n }\n case 'taskList': {\n let items = ''\n for (const item of node.content ?? []) {\n const checked =\n item.attrs.checked === undefined || item.attrs.checked === null\n ? undefined\n : String(item.attrs.checked)\n items += `<li${attrsToString([\n ['data-mt-id', item.attrs.id],\n ['data-mt-checked', checked],\n ])}>${serializeBlocks(item.content ?? [], ctx)}</li>`\n }\n return `<ul${attrsToString([\n ['data-mt-task-list', ''],\n ['data-mt-id', node.attrs.id],\n ])}>${items}</ul>`\n }\n case 'codeBlock':\n return serializeCodeBlock(node, ctx)\n case 'table': {\n const widths = node.attrs.colWidths\n const colgroup =\n widths && widths.length > 0\n ? `<colgroup>${widths.map((width) => `<col${attrsToString([['width', String(width)]])}>`).join('')}</colgroup>`\n : ''\n const rows = (node.content ?? []).map((row) => serializeRow(row, ctx)).join('')\n return `<table${attrsToString([['data-mt-id', node.attrs.id]])}>${colgroup}<tbody>${rows}</tbody></table>`\n }\n default:\n return serializeAtomBlock(node, ctx)\n }\n}\n\n/**\n * 序列化块级节点序列。\n *\n * @param nodes 块级节点\n * @param ctx 序列化上下文\n * @returns HTML 片段\n */\nexport function serializeBlocks(\n nodes: readonly MtBlockNode[],\n ctx: MtHtmlSerializeContext,\n): string {\n const separator = ctx.pretty ? '\\n' : ''\n const parts: string[] = []\n for (const node of nodes) parts.push(serializeBlock(node, ctx))\n return parts.join(separator)\n}\n\n/**\n * 把文档序列化为 HTML。\n *\n * @param doc 文档\n * @param options 序列化选项(`indent > 0` 时启用换行美化)\n * @returns 序列化结果\n *\n * @example\n * ```ts\n * const { output } = serializeDocumentToHtml(doc, { target: 'html' })\n * ```\n */\nexport const serializeDocumentToHtml = (\n doc: MtDocument,\n options: { preRendered?: MtPreRenderedIndex; indent?: number } = {},\n): MtSerializeResult => {\n const ctx: MtHtmlSerializeContext = {\n degradations: [],\n preRendered: options.preRendered ?? {},\n pretty: (options.indent ?? 0) > 0,\n }\n\n const output = serializeBlocks(doc.content, ctx)\n const { nodeCount, maxDepth } = computeTreeStats(doc.content)\n\n return {\n output,\n report: ctx.degradations,\n stats: { nodeCount, maxDepth, bytes: utf8ByteLength(output) },\n }\n}\n","/**\n * `MtDocument` → Markdown。\n *\n * **Markdown 是有损格式**:本模块每遇到一处信息损失,都必须写入降级报告\n *(码 `MT_DEG_004`),业务侧若依赖无损往返请改用 JSON 或 HTML。\n *\n * 文本转义只覆盖**会影响再解析**的字符,避免过度转义损伤可读性。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport type { MtBlockNode, MtInlineNode, MtTableRowNode } from '../types/nodes'\nimport type { MtMark } from '../types/marks'\nimport type { MtDocument } from '../types/document'\nimport type { MtSerializeResult } from '../types/io'\nimport type { MtDegradationEntry } from '../types/report'\nimport { isRecord } from '../utils/guards'\nimport { computeTreeStats, utf8ByteLength, walkTree } from '../utils/tree'\n\n/** 序列化上下文 */\ninterface MdContext {\n degradations: MtDegradationEntry[]\n /** 缩进宽度 */\n indent: string\n}\n\n/**\n * 记录一处有损转换。\n *\n * @param ctx 上下文\n * @param type 涉及的节点/标记类型\n * @param path 定位路径\n * @param message 说明\n */\nconst lossy = (ctx: MdContext, type: string, path: string, message: string): void => {\n ctx.degradations.push(degrade(MT_DEG.LOSSY_CONVERSION, type, path, message, 'flattened'))\n}\n\n/** 需要反斜杠转义的行内字符 */\nconst SPECIAL_CHARS = new Set(['\\\\', '`', '*', '_', '[', ']', '~', '|'])\n\n/** 行首需要转义的字符(否则会被解析为块级语法) */\nconst LINE_START_CHARS = new Set(['#', '>', '-', '+', '=', '|', '~'])\n\n/**\n * 转义行内文本。\n *\n * @param text 原始文本\n * @returns 转义后的文本\n */\nconst escapeText = (text: string): string => {\n let out = ''\n let lineStart = true\n for (let i = 0; i < text.length; i += 1) {\n const ch = text.charAt(i)\n if (ch === '\\n') {\n out += ch\n lineStart = true\n continue\n }\n if (lineStart && LINE_START_CHARS.has(ch)) out += '\\\\'\n lineStart = false\n if (SPECIAL_CHARS.has(ch)) out += '\\\\'\n out += ch\n }\n return out\n}\n\n/** 表格单元格内需要额外转义 `|` */\n/** 标记 → Markdown 包装器 */\nconst wrapMark = (inner: string, mark: MtMark, ctx: MdContext, path: string): string => {\n switch (mark.type) {\n case 'bold':\n return `**${inner}**`\n case 'italic':\n return `*${inner}*`\n case 'strike':\n return `~~${inner}~~`\n case 'code':\n return `\\`${inner}\\``\n case 'link':\n if (mark.attrs.title !== undefined && mark.attrs.title !== null)\n lossy(ctx, 'link', path, '链接的 title 在 Markdown 中丢失')\n return `[${inner}](${mark.attrs.href})`\n case 'underline':\n lossy(ctx, 'underline', path, 'GFM 无下划线语法,已降级为内联 HTML <u>')\n return `<u>${inner}</u>`\n case 'highlight':\n return `<mark>${inner}</mark>`\n case 'textStyle':\n lossy(ctx, 'textStyle', path, '内联样式多数 Markdown 渲染器会剥除')\n return `<span>${inner}</span>`\n case 'subscript':\n return `<sub>${inner}</sub>`\n case 'superscript':\n return `<sup>${inner}</sup>`\n default:\n return inner\n }\n}\n\n/** 序列化行内节点 */\nconst inlineMd = (\n nodes: readonly MtInlineNode[] | undefined,\n ctx: MdContext,\n path: string,\n): string => {\n if (!nodes) return ''\n let out = ''\n nodes.forEach((node, index) => {\n const childPath = `${path}[${index}]`\n switch (node.type) {\n case 'text': {\n let text = escapeText(node.text)\n const marks = node.marks ?? []\n for (let i = marks.length - 1; i >= 0; i -= 1) {\n const mark = marks[i]\n if (mark !== undefined) text = wrapMark(text, mark, ctx, childPath)\n }\n out += text\n break\n }\n case 'hardBreak':\n out += ' \\n'\n break\n case 'mention':\n lossy(ctx, 'mention', childPath, 'mention 的 id / kind 在 Markdown 中丢失')\n out += `@${escapeText(node.attrs.label)}`\n break\n case 'inlineMath':\n out += `$${node.attrs.tex}$`\n break\n case 'emoji':\n lossy(ctx, 'emoji', childPath, 'emoji 的 name 在 Markdown 中丢失,仅保留 unicode')\n out += node.attrs.unicode\n break\n default:\n break\n }\n })\n return out\n}\n\n/** 序列化表格 */\nconst tableMd = (rows: readonly MtTableRowNode[], ctx: MdContext, path: string): string => {\n const cells: string[][] = rows.map((row, rowIndex) =>\n (row.content ?? []).map((cell, cellIndex) => {\n const attrs = cell.attrs\n if (attrs !== undefined) {\n if ((attrs.colspan ?? 1) !== 1 || (attrs.rowspan ?? 1) !== 1) {\n lossy(\n ctx,\n 'table',\n `${path}[${rowIndex}].content[${cellIndex}]`,\n 'colspan / rowspan 在 GFM 表格中丢失',\n )\n }\n if (attrs.background !== undefined && attrs.background !== null) {\n lossy(\n ctx,\n 'table',\n `${path}[${rowIndex}].content[${cellIndex}]`,\n '单元格背景色在 GFM 表格中丢失',\n )\n }\n }\n return (cell.content ?? [])\n .map((block) => blockMd(block, ctx, `${path}[${rowIndex}]`, 0))\n .join(' ')\n }),\n )\n\n if (cells.length === 0) return ''\n const width = cells.reduce((max, row) => Math.max(max, row.length), 0)\n const pad = (row: string[]): string[] => {\n const copy = [...row]\n while (copy.length < width) copy.push('')\n return copy\n }\n const header = pad(cells[0] ?? [])\n const separator = header.map(() => '---')\n const body = cells.slice(1).map((row) => pad(row))\n /**\n * 单元格内容在生成时已由 `inlineMd` → `escapeText` 完成转义\n *(`|` 属 `SPECIAL_CHARS`),此处**不得**二次转义,否则会产出 `\\\\|`。\n * 分隔行是表格语法本身而非用户内容,同样必须原样输出。\n */\n const toLine = (row: string[]): string => `| ${row.join(' | ')} |`\n return [toLine(header), toLine(separator), ...body.map(toLine)].join('\\n')\n}\n\n/** 序列化单个块级节点 */\nconst blockMd = (node: MtBlockNode, ctx: MdContext, path: string, depth: number): string => {\n const pad = ctx.indent.repeat(depth)\n\n switch (node.type) {\n case 'paragraph':\n return pad + inlineMd(node.content, ctx, `${path}.content`)\n case 'heading':\n return `${pad}${'#'.repeat(node.attrs.level)} ${inlineMd(node.content, ctx, `${path}.content`)}`\n case 'blockquote': {\n const inner = (node.content ?? [])\n .map((child, index) => blockMd(child, ctx, `${path}.content[${index}]`, 0))\n .join('\\n\\n')\n return inner\n .split('\\n')\n .map((line) => `${pad}> ${line}`)\n .join('\\n')\n }\n case 'bulletList':\n case 'orderedList':\n case 'taskList': {\n const lines: string[] = []\n const items = node.content ?? []\n if (\n node.type === 'orderedList' &&\n node.attrs.type !== undefined &&\n node.attrs.type !== null\n ) {\n lossy(ctx, 'orderedList', path, `编号样式 type=\"${node.attrs.type}\" 在 Markdown 中丢失`)\n }\n const start = node.type === 'orderedList' ? (node.attrs.start ?? 1) : 1\n for (let index = 0; index < items.length; index += 1) {\n const item = items[index]\n if (item === undefined) continue\n const itemPath = `${path}.content[${index}]`\n let marker = '- '\n if (node.type === 'taskList') {\n marker = item.type === 'taskItem' && item.attrs.checked === true ? '- [x] ' : '- [ ] '\n } else if (node.type === 'orderedList') {\n marker = `${start + index}. `\n }\n const body = item.content ?? []\n const head = body[0]\n const headText =\n head !== undefined && head.type === 'paragraph'\n ? inlineMd(head.content, ctx, `${itemPath}.content[0].content`)\n : ''\n lines.push(pad + marker + headText)\n for (let i = 1; i < body.length; i += 1) {\n const child = body[i]\n if (child !== undefined)\n lines.push(blockMd(child, ctx, `${itemPath}.content[${i}]`, depth + 1))\n }\n }\n return lines.join('\\n')\n }\n case 'codeBlock': {\n if (node.attrs.showLineNumbers === true)\n lossy(ctx, 'codeBlock', path, 'showLineNumbers 在 Markdown 中丢失')\n const language = node.attrs.language ?? ''\n const text = (node.content ?? []).map((item) => item.text).join('')\n return `${pad}\\`\\`\\`${language}\\n${text}\\n\\`\\`\\``\n }\n case 'horizontalRule':\n return `${pad}---`\n case 'image':\n if (node.attrs.width != null || node.attrs.height != null || node.attrs.align != null) {\n lossy(ctx, 'image', path, 'width / height / align 在 Markdown 中丢失')\n }\n return `${pad}`\n case 'video':\n lossy(ctx, 'video', path, 'video 在 Markdown 中降级为普通链接,语义丢失')\n return `${pad}[视频](${node.attrs.src})`\n case 'audio':\n lossy(ctx, 'audio', path, 'audio 在 Markdown 中降级为普通链接,语义丢失')\n return `${pad}[音频](${node.attrs.src})`\n case 'attachment':\n if (node.attrs.size != null || node.attrs.mime != null) {\n lossy(ctx, 'attachment', path, 'size / mime 在 Markdown 中丢失')\n }\n return `${pad}[${escapeText(node.attrs.name)}](${node.attrs.src})`\n case 'mathBlock':\n return `${pad}$$\\n${node.attrs.tex}\\n$$`\n case 'table':\n return (node.content ?? []).length > 0 ? tableMd(node.content ?? [], ctx, path) : ''\n default:\n return ''\n }\n}\n\n/**\n * 把文档序列化为 Markdown。\n *\n * @param doc 文档\n * @param options 序列化选项(`indent` 为缩进宽度,缺省 2)\n * @returns 序列化结果\n *\n * @example\n * ```ts\n * const { output, report } = serializeDocumentToMarkdown(doc, { indent: 2 })\n * ```\n */\nexport const serializeDocumentToMarkdown = (\n doc: MtDocument,\n options: { indent?: number } = {},\n): MtSerializeResult => {\n const ctx: MdContext = {\n degradations: [],\n indent: ' '.repeat(Math.max(0, options.indent ?? 2)),\n }\n\n let idCount = 0\n walkTree(doc.content, ({ node }) => {\n const attrs = node.attrs\n if (isRecord(attrs) && typeof attrs.id === 'string') idCount += 1\n })\n if (idCount > 0) {\n lossy(ctx, 'doc', 'content', `Markdown 不保留块级节点 ID(共 ${idCount} 个)`)\n }\n\n const output = doc.content\n .map((node, index) => blockMd(node, ctx, `content[${index}]`, 0))\n .filter((part) => part.length > 0)\n .join('\\n\\n')\n\n const { nodeCount, maxDepth } = computeTreeStats(doc.content)\n return {\n output,\n report: ctx.degradations,\n stats: { nodeCount, maxDepth, bytes: utf8ByteLength(output) },\n }\n}\n","/**\n * 小程序序列化的共享上下文与节点原语。\n *\n * 单独成模块的原因有两个:\n * 1. 双通道的差异只在「标签名 + 属性写法」上,两处必须成对演进而非各写一份;\n * 2. 主题值必须在此处解析为字面量(小程序 `style` 不参与宿主 CSS 变量继承)。\n */\n\nimport type { MtMiniMode, MtMiniPlatform } from '../types/base'\nimport type { MtMark } from '../types/marks'\nimport type { MtDegradationEntry } from '../types/report'\nimport type { MtMiniElementNode, MtMiniNode, MtMiniTextNode, MtPreRenderedIndex } from '../types/io'\n\n/** 序列化上下文 */\nexport interface MiniContext {\n platform: MtMiniPlatform\n mode: MtMiniMode\n themeTokens: Record<string, string>\n preRendered: MtPreRenderedIndex\n degradations: MtDegradationEntry[]\n}\n\n/** 构造小程序元素节点(空 attrs 与空 children 一律省略,减小体积) */\nexport const el = (\n name: string,\n attrs: Record<string, string> | undefined,\n children: MtMiniNode[] | undefined,\n): MtMiniElementNode => ({\n type: 'node',\n name,\n ...(attrs !== undefined && Object.keys(attrs).length > 0 ? { attrs } : {}),\n ...(children !== undefined && children.length > 0 ? { children } : {}),\n})\n\n/** 构造小程序文本节点 */\nexport const txt = (value: string): MtMiniTextNode => ({ type: 'text', text: value })\n\n/** 单标签的三种身份 */\nexport interface MiniTagSpec {\n /** `rich-text` 通道标签名 */\n rich: string\n /** 组件通道标签名 */\n component: string\n /** 组件通道 `class` */\n className: string\n}\n\n/**\n * 解析主题字面量(小程序不可用 CSS 变量)。\n *\n * @param ctx 上下文\n * @param token CSS 变量名\n * @param fallback 兜底字面量\n * @returns 字面量值\n */\nexport const themeValue = (ctx: MiniContext, token: string, fallback: string): string =>\n ctx.themeTokens[token] ?? fallback\n\n/**\n * 按通道构造元素。\n *\n * @param ctx 上下文\n * @param spec 标签身份\n * @param style 内联样式\n * @param extra 额外属性(白名单内)\n * @param children 子节点\n * @returns 元素节点\n */\nexport const makeElement = (\n ctx: MiniContext,\n spec: MiniTagSpec,\n style: string,\n extra: Record<string, string>,\n children: MtMiniNode[],\n): MtMiniElementNode => {\n const attrs: Record<string, string> = {}\n // 节点自身的属性在**两个通道都要输出**:组件通道下它们由原生组件消费\n // (`<image src>` / `<video src poster>` / `<view data-src>`),\n // rich-text 通道下则由白名单放行。\n //\n // 这里曾只在 richText 通道输出 —— 后果是**默认的组件通道下图片没有 `src`**、\n // 视频没有 `poster`、有序列表没有 `start`。由于组件通道才是默认通道,\n // 这等于「小程序端图片根本不显示」,且既有断言只检查了 name 与 class,全部漏过。\n for (const key of Object.keys(extra)) attrs[key] = extra[key] ?? ''\n // 组件通道用 `class` 作为组件映射键;rich-text 通道不使用 class\n if (ctx.mode !== 'richText') attrs.class = spec.className\n if (style.length > 0) attrs.style = style\n return el(ctx.mode === 'richText' ? spec.rich : spec.component, attrs, children)\n}\n\n/** 标记 → 内联样式声明数组 */\nexport const markDeclarations = (mark: MtMark, ctx: MiniContext): string[] => {\n switch (mark.type) {\n case 'bold':\n return ['font-weight: bold']\n case 'italic':\n return ['font-style: italic']\n case 'underline':\n return ['text-decoration: underline']\n case 'strike':\n return ['text-decoration: line-through']\n case 'code':\n return [\n `font-family: ${themeValue(ctx, '--mt-font-family-mono', 'ui-monospace, monospace')}`,\n `background-color: ${themeValue(ctx, '--mt-color-code-bg', '#f5f5f5')}`,\n 'padding: 0 2px',\n 'border-radius: 2px',\n ]\n case 'link':\n return [\n `color: ${themeValue(ctx, '--mt-color-link', '#1a6dff')}`,\n 'text-decoration: underline',\n ]\n case 'highlight':\n return [`background-color: ${mark.attrs.color}`]\n case 'textStyle': {\n const out: string[] = []\n if (mark.attrs.fontFamily != null) out.push(`font-family: ${mark.attrs.fontFamily}`)\n if (mark.attrs.fontSize != null) out.push(`font-size: ${mark.attrs.fontSize}`)\n if (mark.attrs.color != null) out.push(`color: ${mark.attrs.color}`)\n if (mark.attrs.backgroundColor != null)\n out.push(`background-color: ${mark.attrs.backgroundColor}`)\n if (mark.attrs.lineHeight != null) out.push(`line-height: ${mark.attrs.lineHeight}`)\n if (mark.attrs.letterSpacing != null) out.push(`letter-spacing: ${mark.attrs.letterSpacing}`)\n return out\n }\n case 'subscript':\n return ['font-size: 0.75em', 'vertical-align: sub']\n case 'superscript':\n return ['font-size: 0.75em', 'vertical-align: super']\n default:\n return []\n }\n}\n\n/**\n * 把标记数组摊平为一行内联样式。\n *\n * @param marks 标记数组\n * @param ctx 上下文\n * @returns 内联样式;无标记时为空串\n */\nexport const marksToStyle = (marks: readonly MtMark[] | undefined, ctx: MiniContext): string => {\n if (!marks || marks.length === 0) return ''\n const declarations: string[] = []\n for (const mark of marks) declarations.push(...markDeclarations(mark, ctx))\n return declarations.join('; ')\n}\n\n/**\n * 输出一段带样式的文本。\n *\n * @param value 文本\n * @param style 内联样式\n * @param ctx 上下文\n * @returns 文本节点(带样式时套一层元素)\n */\nexport const emitText = (value: string, style: string, ctx: MiniContext): MtMiniNode => {\n if (ctx.mode === 'richText') {\n return style.length === 0 ? txt(value) : el('span', { style }, [txt(value)])\n }\n return el('text', style.length === 0 ? undefined : { style }, [txt(value)])\n}\n","/**\n * 小程序序列化的行内节点通道。\n *\n * 行内是双通道差异最集中的地方:`richText` 只能靠 `span style` 承载标记,\n * 组件通道则把文本挂在 `<text>` 上、由组件自己读 `style`。\n * 这类差异全部收在 `mini-context.ts` 的 `emitText` / `marksToStyle` 里,本模块只负责\n * 「按节点类型分发 + 写降级报告」。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport type { MtInlineNode } from '../types/nodes'\nimport type { MtMiniNode } from '../types/io'\nimport { el, emitText, marksToStyle, themeValue, txt } from './mini-context'\nimport type { MiniContext } from './mini-context'\n\n/**\n * 序列化行内节点序列。\n *\n * @param nodes 行内节点\n * @param ctx 序列化上下文\n * @param path 定位路径\n * @returns 小程序 nodes 数组\n */\nexport const inlineNodes = (\n nodes: readonly MtInlineNode[] | undefined,\n ctx: MiniContext,\n path: string,\n): MtMiniNode[] => {\n const out: MtMiniNode[] = []\n if (!nodes) return out\n\n nodes.forEach((node, index) => {\n const childPath = `${path}[${index}]`\n switch (node.type) {\n case 'text':\n out.push(emitText(node.text, marksToStyle(node.marks, ctx), ctx))\n break\n case 'hardBreak':\n out.push(ctx.mode === 'richText' ? el('br', undefined, undefined) : emitText('\\n', '', ctx))\n break\n case 'mention':\n out.push(\n emitText(\n `@${node.attrs.label}`,\n `color: ${themeValue(ctx, '--mt-color-link', '#1a6dff')}`,\n ctx,\n ),\n )\n break\n case 'inlineMath': {\n const svg = node.attrs.svg ?? ctx.preRendered.math?.[node.attrs.tex]\n if (svg === undefined || svg === null || svg.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MISSING_PRERENDER,\n 'inlineMath',\n childPath,\n '行内公式缺少预渲染 SVG,已降级为 TeX 文本',\n 'literalized',\n ),\n )\n out.push(emitText(`$${node.attrs.tex}$`, '', ctx))\n break\n }\n if (ctx.mode === 'richText') {\n ctx.degradations.push(\n degrade(\n MT_DEG.UNSUPPORTED_NODE,\n 'inlineMath',\n childPath,\n 'rich-text 通道无法内联 SVG,已降级为 TeX 文本',\n 'replaced',\n ),\n )\n out.push(emitText(`$${node.attrs.tex}$`, '', ctx))\n break\n }\n // 组件通道:SVG 以文本载荷挂在 mt-math-inline 下,由 renderer-mini 渲染\n out.push(el('view', { class: 'mt-math-inline' }, [txt(svg)]))\n break\n }\n case 'emoji':\n out.push(emitText(node.attrs.unicode, '', ctx))\n break\n default:\n break\n }\n })\n\n return out\n}\n","/**\n * `MtDocument` → 小程序 `nodes`。\n *\n * 目标端**只读渲染**,双通道:\n * - `mode: 'component'`(默认):递归自定义组件通道,输出 `view` / `text`,\n * 以 `class`(`mt-p` / `mt-h1` …)作为组件映射键,缩进与序号由组件自绘;\n * - `mode: 'richText'`:降级兜底通道,输出微信 `rich-text` 白名单标签,\n * 缩进**必须用 `style` 模拟**(白名单无 `ul` 的嵌套缩进能力)。\n *\n * 两条硬约束:\n * 1. 小程序端 `style` 不参与宿主 CSS 变量继承,主题值必须在序列化时\n * 解析为**字面量**,故 `themeTokens` 为 `mini` 目标的必填项;\n * 2. 小程序**不支持 `id`**,本通道不输出节点 ID 并写降级报告。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport type { MtBlockNode, MtTableRowNode } from '../types/nodes'\nimport type { MtDocument } from '../types/document'\nimport type { MtMiniMode, MtMiniPlatform } from '../types/base'\nimport type {\n MtMiniElementNode,\n MtMiniNode,\n MtPreRenderedIndex,\n MtSerializeResult,\n} from '../types/io'\nimport { escapeHtml } from '../utils/escape'\nimport { fnv1a } from '../utils/hash'\nimport { MT_COLWIDTH_UNIT, MT_INDENT_UNIT, formatCssLength } from '../utils/style-units'\nimport { computeTreeStats, utf8ByteLength, walkTree } from '../utils/tree'\nimport { isRecord } from '../utils/guards'\nimport { el, emitText, makeElement, themeValue, txt } from './mini-context'\nimport type { MiniContext, MiniTagSpec } from './mini-context'\nimport { inlineNodes } from './mini-inline'\n\n/** 序列化表格行 */\nconst miniRow = (row: MtTableRowNode, ctx: MiniContext, path: string): MtMiniElementNode => {\n const cells: MtMiniNode[] = []\n ;(row.content ?? []).forEach((cell, index) => {\n const extra: Record<string, string> = {}\n // span 的值可能是 `null`(ProseMirror 侧可选属性的 `default`),\n // 只判 `undefined` 会把它写成字面量 `\"null\"`\n if (cell.attrs?.colspan !== undefined && cell.attrs.colspan !== null) {\n extra.colspan = String(cell.attrs.colspan)\n }\n if (cell.attrs?.rowspan !== undefined && cell.attrs.rowspan !== null) {\n extra.rowspan = String(cell.attrs.rowspan)\n }\n if (cell.attrs?.background != null) extra.style = `background-color: ${cell.attrs.background}`\n const spec: MiniTagSpec =\n cell.type === 'tableHeader'\n ? { rich: 'th', component: 'view', className: 'mt-th' }\n : { rich: 'td', component: 'view', className: 'mt-td' }\n cells.push(\n makeElement(\n ctx,\n spec,\n extra.style ?? '',\n extra,\n miniBlocks(cell.content ?? [], ctx, `${path}[${index}]`),\n ),\n )\n })\n // 必须走 `makeElement` 而不是 `el`:组件通道的合法标签只有 view / text / image /\n // video / audio,`<tr>` 在小程序里不是组件标签 —— 递归组件会拿到未知标签。\n return makeElement(ctx, { rich: 'tr', component: 'view', className: 'mt-tr' }, '', {}, cells)\n}\n\n/** 序列化块级节点序列 */\nconst miniBlocks = (\n nodes: readonly MtBlockNode[],\n ctx: MiniContext,\n path: string,\n): MtMiniNode[] => {\n const out: MtMiniNode[] = []\n nodes.forEach((node, index) => {\n out.push(...miniBlock(node, ctx, `${path}[${index}]`))\n })\n return out\n}\n\n/** 列表项内容(首段作为行内文本,其余块级内容下沉) */\nconst listItemChildren = (\n item: { content?: MtBlockNode[] },\n ctx: MiniContext,\n path: string,\n): MtMiniNode[] => {\n const body = item.content ?? []\n const head = body[0]\n const headNodes =\n head !== undefined && head.type === 'paragraph'\n ? inlineNodes(head.content, ctx, `${path}.content[0].content`)\n : []\n const rest: MtMiniNode[] = []\n for (let i = 1; i < body.length; i += 1) {\n const child = body[i]\n if (child !== undefined) rest.push(...miniBlock(child, ctx, `${path}.content[${i}]`))\n }\n return [...headNodes, ...rest]\n}\n\n/** 序列化单个块级节点(可能产出多个节点) */\nconst miniBlock = (node: MtBlockNode, ctx: MiniContext, path: string): MtMiniNode[] => {\n switch (node.type) {\n case 'paragraph': {\n const style: string[] = []\n if (node.attrs.textAlign != null) style.push(`text-align: ${node.attrs.textAlign}`)\n if (node.attrs.lineHeight != null) style.push(`line-height: ${node.attrs.lineHeight}`)\n if (node.attrs.indent != null)\n style.push(`padding-left: ${formatCssLength(node.attrs.indent, MT_INDENT_UNIT)}`)\n return [\n makeElement(\n ctx,\n { rich: 'p', component: 'view', className: 'mt-p' },\n style.join('; '),\n {},\n inlineNodes(node.content, ctx, `${path}.content`),\n ),\n ]\n }\n case 'heading': {\n const style: string[] = []\n if (node.attrs.textAlign != null) style.push(`text-align: ${node.attrs.textAlign}`)\n if (node.attrs.lineHeight != null) style.push(`line-height: ${node.attrs.lineHeight}`)\n return [\n makeElement(\n ctx,\n { rich: `h${node.attrs.level}`, component: 'view', className: `mt-h${node.attrs.level}` },\n style.join('; '),\n {},\n inlineNodes(node.content, ctx, `${path}.content`),\n ),\n ]\n }\n case 'blockquote':\n return [\n makeElement(\n ctx,\n { rich: 'blockquote', component: 'view', className: 'mt-blockquote' },\n `border-left: 3px solid ${themeValue(ctx, '--mt-color-border', '#e0e0e0')}; padding-left: 12px`,\n {},\n miniBlocks(node.content ?? [], ctx, `${path}.content`),\n ),\n ]\n case 'bulletList':\n case 'orderedList': {\n const ordered = node.type === 'orderedList'\n const extra: Record<string, string> = {}\n if (ordered && node.attrs.start != null) extra.start = String(node.attrs.start)\n if (ordered && node.attrs.type != null) extra.type = node.attrs.type\n if (ordered && node.attrs.type != null && ctx.mode === 'component') {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'orderedList',\n path,\n `编号样式 type=\"${node.attrs.type}\" 在组件通道由组件自绘,需渲染器实现`,\n 'dropped',\n ),\n )\n }\n const items = (node.content ?? []).map((item, index) =>\n makeElement(\n ctx,\n { rich: 'li', component: 'view', className: 'mt-li' },\n '',\n {},\n listItemChildren(item, ctx, `${path}.content[${index}]`),\n ),\n )\n return [\n makeElement(\n ctx,\n ordered\n ? { rich: 'ol', component: 'view', className: 'mt-ol' }\n : { rich: 'ul', component: 'view', className: 'mt-ul' },\n '',\n extra,\n items,\n ),\n ]\n }\n case 'taskList': {\n const items = (node.content ?? []).map((item, index) => {\n const mark = item.attrs.checked === true ? '☑' : '☐'\n return makeElement(\n ctx,\n { rich: 'div', component: 'view', className: 'mt-task-item' },\n '',\n {},\n [\n emitText(`${mark} `, '', ctx),\n ...listItemChildren(item, ctx, `${path}.content[${index}]`),\n ],\n )\n })\n return [\n makeElement(\n ctx,\n { rich: 'div', component: 'view', className: 'mt-task-list' },\n '',\n {},\n items,\n ),\n ]\n }\n case 'codeBlock': {\n const text = (node.content ?? []).map((item) => item.text).join('')\n const highlight = ctx.preRendered.code?.[fnv1a(text)] ?? ctx.preRendered.code?.[text]\n if (highlight === undefined && text.length > 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MISSING_PRERENDER,\n 'codeBlock',\n path,\n '代码块缺少预渲染高亮产物,已按纯文本输出',\n 'literalized',\n ),\n )\n }\n const body = highlight ?? escapeHtml(text)\n const style = `background-color: ${themeValue(ctx, '--mt-color-code-bg', '#f5f5f5')}; padding: 8px; overflow-x: auto`\n if (ctx.mode === 'richText') {\n return [el('pre', { style }, [el('code', undefined, [txt(body)])])]\n }\n return [\n makeElement(ctx, { rich: 'pre', component: 'view', className: 'mt-pre' }, style, {}, [\n emitText(\n text,\n `font-family: ${themeValue(ctx, '--mt-font-family-mono', 'ui-monospace, monospace')}`,\n ctx,\n ),\n ]),\n ]\n }\n case 'horizontalRule':\n return [\n makeElement(\n ctx,\n { rich: 'hr', component: 'view', className: 'mt-hr' },\n `border-top: 1px solid ${themeValue(ctx, '--mt-color-border', '#e0e0e0')}`,\n {},\n [],\n ),\n ]\n case 'image': {\n const extra: Record<string, string> = { src: node.attrs.src }\n if (node.attrs.alt != null) extra.alt = node.attrs.alt\n if (node.attrs.width != null) extra.width = String(node.attrs.width)\n if (node.attrs.height != null) extra.height = String(node.attrs.height)\n if (node.attrs.align != null) {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'image',\n path,\n `align=\"${node.attrs.align}\" 在小程序端由宿主样式决定,未输出`,\n 'dropped',\n ),\n )\n }\n return [\n makeElement(\n ctx,\n { rich: 'img', component: 'image', className: 'mt-img' },\n 'max-width: 100%',\n extra,\n [],\n ),\n ]\n }\n case 'video':\n case 'audio': {\n if (ctx.mode === 'richText') {\n ctx.degradations.push(\n degrade(\n MT_DEG.UNSUPPORTED_NODE,\n node.type,\n path,\n `${node.type} 在 rich-text 通道不可用,已降级为文本占位`,\n 'replaced',\n ),\n )\n return [emitText(`[${node.type === 'video' ? '视频' : '音频'}]`, '', ctx)]\n }\n const extra: Record<string, string> = { src: node.attrs.src }\n if (node.type === 'video' && node.attrs.poster != null) extra.poster = node.attrs.poster\n return [\n makeElement(\n ctx,\n { rich: node.type, component: node.type, className: `mt-${node.type}` },\n '',\n extra,\n [],\n ),\n ]\n }\n case 'attachment':\n return [\n makeElement(\n ctx,\n { rich: 'div', component: 'view', className: 'mt-attachment' },\n `color: ${themeValue(ctx, '--mt-color-link', '#1a6dff')}`,\n { 'data-src': node.attrs.src },\n [emitText(node.attrs.name, '', ctx)],\n ),\n ]\n case 'mathBlock': {\n const svg = node.attrs.svg ?? ctx.preRendered.math?.[node.attrs.tex]\n if (svg === undefined || svg === null || svg.length === 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.MISSING_PRERENDER,\n 'mathBlock',\n path,\n '公式缺少预渲染 SVG,已降级为 TeX 文本',\n 'literalized',\n ),\n )\n return [emitText(`$$${node.attrs.tex}$$`, '', ctx)]\n }\n if (ctx.mode === 'richText') {\n ctx.degradations.push(\n degrade(\n MT_DEG.UNSUPPORTED_NODE,\n 'mathBlock',\n path,\n 'rich-text 通道无法内联 SVG,已降级为 TeX 文本',\n 'replaced',\n ),\n )\n return [emitText(`$$${node.attrs.tex}$$`, '', ctx)]\n }\n // 组件通道:SVG 以文本载荷挂在 mt-math 下,由 renderer-mini 渲染\n return [el('view', { class: 'mt-math' }, [txt(svg)])]\n }\n case 'table': {\n const widths = node.attrs.colWidths\n const colgroup: MtMiniNode[] =\n ctx.mode === 'richText' && widths && widths.length > 0\n ? [\n el(\n 'colgroup',\n undefined,\n widths.map((width) =>\n el('col', { width: formatCssLength(width, MT_COLWIDTH_UNIT) }, undefined),\n ),\n ),\n ]\n : []\n const rows = (node.content ?? []).map((row, index) =>\n miniRow(row, ctx, `${path}.content[${index}]`),\n )\n return [\n makeElement(\n ctx,\n { rich: 'table', component: 'view', className: 'mt-table' },\n 'width: 100%',\n {},\n [...colgroup, ...rows],\n ),\n ]\n }\n default:\n return []\n }\n}\n\n/**\n * 把文档序列化为小程序 `nodes`。\n *\n * @param doc 文档\n * @param options 序列化选项(`themeTokens` 必填)\n * @returns 序列化结果,`output` 为 `MtMiniNode[]`\n *\n * @example\n * ```ts\n * const { output } = serializeDocumentToMini(doc, {\n * mode: 'richText',\n * platform: 'wechat',\n * themeTokens: { '--mt-color-link': '#1a6dff' },\n * })\n * ```\n */\nexport const serializeDocumentToMini = (\n doc: MtDocument,\n options: {\n mode?: MtMiniMode\n platform?: MtMiniPlatform\n themeTokens?: Record<string, string>\n preRendered?: MtPreRenderedIndex\n } = {},\n): MtSerializeResult<MtMiniNode[]> => {\n const ctx: MiniContext = {\n platform: options.platform ?? 'wechat',\n mode: options.mode ?? 'component',\n themeTokens: options.themeTokens ?? {},\n preRendered: options.preRendered ?? {},\n degradations: [],\n }\n\n let idCount = 0\n walkTree(doc.content, ({ node }) => {\n const attrs = node.attrs\n if (isRecord(attrs) && typeof attrs.id === 'string') idCount += 1\n })\n if (idCount > 0) {\n ctx.degradations.push(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'doc',\n 'content',\n `小程序不支持 id,已丢弃 ${idCount} 个块级节点 ID`,\n 'dropped',\n ),\n )\n }\n\n const output = miniBlocks(doc.content, ctx, 'content')\n const { nodeCount, maxDepth } = computeTreeStats(doc.content)\n const bytes = utf8ByteLength(JSON.stringify(output))\n\n return {\n output,\n report: ctx.degradations,\n stats: { nodeCount, maxDepth, bytes },\n }\n}\n","/**\n * `MtDocument` → 纯文本。\n *\n * 目标端没有任何富文本能力,因此**全部语义损失**;本序列化器只保证\n * 文本内容与阅读顺序不丢失,用于搜索索引、摘要与剪贴板降级。\n */\n\nimport type { MtBlockNode, MtInlineNode, MtTableRowNode } from '../types/nodes'\nimport type { MtDocument } from '../types/document'\nimport type { MtSerializeResult } from '../types/io'\nimport { computeTreeStats, utf8ByteLength } from '../utils/tree'\n\n/** 列表缩进单位 */\nconst INDENT = ' '\n\n/**\n * 抽取行内纯文本。\n *\n * @param nodes 行内节点\n * @returns 文本\n */\nconst inlineText = (nodes: readonly MtInlineNode[] | undefined): string => {\n if (!nodes) return ''\n let out = ''\n for (const node of nodes) {\n switch (node.type) {\n case 'text':\n out += node.text\n break\n case 'hardBreak':\n out += '\\n'\n break\n case 'mention':\n out += `@${node.attrs.label}`\n break\n case 'inlineMath':\n out += node.attrs.tex\n break\n case 'emoji':\n out += node.attrs.unicode\n break\n default:\n break\n }\n }\n return out\n}\n\n/** 把表格行渲染为制表符分隔的一行 */\nconst rowText = (row: MtTableRowNode): string =>\n (row.content ?? [])\n .map((cell) =>\n (cell.content ?? [])\n .map((block) => blockText(block, 0))\n .join(' ')\n .trim(),\n )\n .join('\\t')\n\n/** 渲染单个块为文本(可能多行) */\nconst blockText = (node: MtBlockNode, depth: number): string => {\n const pad = INDENT.repeat(depth)\n switch (node.type) {\n case 'paragraph':\n case 'heading':\n return pad + inlineText(node.content)\n case 'blockquote':\n return (node.content ?? [])\n .map((child) => blockText(child, depth))\n .join('\\n')\n .split('\\n')\n .map((line) => `${pad}> ${line}`)\n .join('\\n')\n case 'bulletList':\n case 'orderedList':\n case 'taskList': {\n const lines: string[] = []\n const items = node.content ?? []\n for (let index = 0; index < items.length; index += 1) {\n const item = items[index]\n if (item === undefined) continue\n let marker = '- '\n if (node.type === 'taskList') {\n marker = item.type === 'taskItem' && item.attrs.checked === true ? '- [x] ' : '- [ ] '\n } else if (node.type === 'orderedList') {\n marker = `${index + 1}. `\n }\n const body = item.content ?? []\n const head = body[0]\n const headText =\n head !== undefined && head.type === 'paragraph' ? inlineText(head.content) : ''\n lines.push(pad + marker + headText)\n for (let i = 1; i < body.length; i += 1) {\n const child = body[i]\n if (child !== undefined) lines.push(blockText(child, depth + 1))\n }\n }\n return lines.join('\\n')\n }\n case 'codeBlock':\n return (node.content ?? []).map((text) => text.text).join('')\n case 'horizontalRule':\n return `${pad}---`\n case 'image':\n return pad + (node.attrs.alt ?? node.attrs.src)\n case 'video':\n return pad + (node.attrs.poster ?? node.attrs.src)\n case 'audio':\n return pad + (node.attrs.title ?? node.attrs.src)\n case 'attachment':\n return pad + node.attrs.name\n case 'mathBlock':\n return pad + node.attrs.tex\n case 'table':\n return (node.content ?? []).map(rowText).join('\\n')\n default:\n return ''\n }\n}\n\n/**\n * 把文档序列化为纯文本。\n *\n * @param doc 文档\n * @returns 序列化结果\n *\n * @example\n * ```ts\n * const { output } = serializeDocumentToText(doc)\n * ```\n */\nexport const serializeDocumentToText = (doc: MtDocument): MtSerializeResult => {\n const output = doc.content.map((node) => blockText(node, 0)).join('\\n\\n')\n const { nodeCount, maxDepth } = computeTreeStats(doc.content)\n return {\n output,\n report: [],\n stats: { nodeCount, maxDepth, bytes: utf8ByteLength(output) },\n }\n}\n","/**\n * serialize 层出口与按 `target` 的统一分发。\n */\n\nimport { MT_DEG } from '../report/codes'\nimport { degrade } from '../report'\nimport type { MtDocument } from '../types/document'\nimport type { MtSerializeOptions, MtSerializeOutput, MtSerializeResult } from '../types/io'\nimport { serializeDocumentToHtml } from './html'\nimport { serializeDocumentToMarkdown } from './markdown'\nimport { serializeDocumentToMini } from './mini'\nimport { serializeDocumentToText } from './text'\n\nexport { serializeBlocks, serializeDocumentToHtml } from './html'\nexport type { MtHtmlSerializeContext } from './html'\nexport { serializeDocumentToMarkdown } from './markdown'\nexport { serializeDocumentToMini } from './mini'\nexport { serializeDocumentToText } from './text'\n\n/**\n * 按 `options.target` 分发到具体序列化器。\n *\n * @param doc 文档\n * @param options 序列化选项\n * @returns 序列化结果\n *\n * @example\n * ```ts\n * const { output, report } = serializeDocument(doc, { target: 'html' })\n * const md = serializeDocument(doc, { target: 'markdown' })\n * const nodes = serializeDocument(doc, { target: 'mini', themeTokens: {} }).output\n * ```\n */\nexport const serializeDocument = (\n doc: MtDocument,\n options: MtSerializeOptions,\n): MtSerializeResult<MtSerializeOutput> => {\n const result = ((): MtSerializeResult<MtSerializeOutput> => {\n switch (options.target) {\n case 'markdown':\n return serializeDocumentToMarkdown(doc, { indent: options.indent })\n case 'text':\n return serializeDocumentToText(doc)\n case 'mini': {\n const mini = serializeDocumentToMini(doc, {\n mode: options.mode,\n platform: options.platform,\n themeTokens: options.themeTokens,\n preRendered: options.preRendered,\n })\n if (options.themeTokens === undefined) {\n mini.report.unshift(\n degrade(\n MT_DEG.ATTR_DROPPED,\n 'doc',\n 'content',\n '未提供 themeTokens,主题值已退回内置兜底字面量,小程序端视觉可能不一致',\n 'literalized',\n ),\n )\n }\n return mini\n }\n default:\n return serializeDocumentToHtml(doc, {\n preRendered: options.preRendered,\n indent: options.indent,\n })\n }\n })()\n\n return options.report === false ? { ...result, report: [] } : result\n}\n","/**\n * 表格结构校验。\n *\n * 三条规则(码 `MT_DOC_007`):\n * 1. 同一行内所有 `colspan` 之和必须相等(矩形表);\n * 2. `rowspan` 不得越出表格底部边界;\n * 3. 不存在被 `rowspan` 覆盖的位置上仍出现单元格的重叠冲突。\n */\n\nimport { MT_DOC } from '../report/codes'\nimport { issue } from '../report'\nimport type { MtValidationIssue } from '../types/report'\nimport { isRecord } from '../utils/guards'\n\n/** 表格校验上下文 */\nexport interface MtTableContext {\n issues: MtValidationIssue[]\n fix: boolean\n}\n\n/** 读取单元格的跨度 */\nconst readSpan = (attrs: Record<string, unknown>, name: string): number => {\n const value = attrs[name]\n if (typeof value !== 'number' || !Number.isFinite(value)) return 1\n return Math.max(1, Math.trunc(value))\n}\n\n/**\n * 从 `start` 起找到该行第一个未被上方 `rowspan` 占用的列。\n *\n * 规则 1 以 `colspan` 总和判定矩形,因此后续行可省略被跨行单元格覆盖的位置\n * (如首列被 `rowspan=2` 占用时,次行只写一个 `td`),此处负责把这些位置跳过。\n *\n * @param grid 占用栅格\n * @param row 当前行号\n * @param start 起始列号\n * @returns 首个空闲列号(该行未创建时即 `start`)\n */\nconst firstFreeColumn = (grid: readonly boolean[][], row: number, start: number): number => {\n const line = grid[row]\n if (line === undefined) return start\n let column = start\n while (line[column] === true) column += 1\n return column\n}\n\n/**\n * 校验单个表格节点。\n *\n * @param raw 表格节点(宽松输入)\n * @param path 文档定位路径\n * @param ctx 校验上下文(`fix: true` 时会就地修正 `rowspan`)\n *\n * @example\n * ```ts\n * validateTable(tableNode, 'content[0]', { issues: [], fix: true })\n * ```\n */\nexport const validateTable = (\n raw: Record<string, unknown>,\n path: string,\n ctx: MtTableContext,\n): void => {\n const rows = Array.isArray(raw.content) ? raw.content : []\n if (rows.length === 0) return\n\n /** 占用栅格:`grid[row][col] === true` 表示该位置已被覆盖 */\n const grid: boolean[][] = []\n const sums: number[] = []\n\n rows.forEach((row, rowIndex) => {\n const rowPath = `${path}.content[${rowIndex}]`\n const cells = isRecord(row) && Array.isArray(row.content) ? row.content : []\n let column = 0\n\n cells.forEach((cell, cellIndex) => {\n const cellPath = `${rowPath}.content[${cellIndex}]`\n const attrs = isRecord(cell) && isRecord(cell.attrs) ? cell.attrs : {}\n const colspan = readSpan(attrs, 'colspan')\n const rowspan = readSpan(attrs, 'rowspan')\n\n // 规则 2:越界\n if (rowIndex + rowspan > rows.length) {\n const clamped = Math.max(1, rows.length - rowIndex)\n ctx.issues.push(\n issue(\n MT_DOC.BAD_TABLE,\n 'error',\n cellPath,\n `rowspan=${rowspan} 越出表格底部(共 ${rows.length} 行)`,\n ctx.fix ? `已修正为 ${clamped}` : undefined,\n ),\n )\n if (ctx.fix) attrs.rowspan = clamped\n }\n\n const effectiveRowspan = ctx.fix ? readSpan(attrs, 'rowspan') : rowspan\n\n // 跳过被上方 rowspan 占用的列,使跨行表可省略占位单元格\n column = firstFreeColumn(grid, rowIndex, column)\n\n // 规则 3:重叠(单元格自身的跨度范围内包含已占用位置)\n for (let r = rowIndex; r < Math.min(rows.length, rowIndex + effectiveRowspan); r += 1) {\n const line = grid[r] ?? []\n for (let c = column; c < column + colspan; c += 1) {\n if (line[c] === true) {\n ctx.issues.push(\n issue(\n MT_DOC.BAD_TABLE,\n 'error',\n cellPath,\n `位置 (行${r}, 列${c}) 已被 rowspan 覆盖,出现单元格重叠`,\n ),\n )\n }\n line[c] = true\n }\n grid[r] = line\n }\n\n column += colspan\n })\n\n sums.push(column)\n })\n\n // 规则 1:各行 colspan 总和一致\n const first = sums[0]\n if (first !== undefined) {\n sums.forEach((sum, rowIndex) => {\n if (sum !== first) {\n ctx.issues.push(\n issue(\n MT_DOC.BAD_TABLE,\n 'error',\n `${path}.content[${rowIndex}]`,\n `该行 colspan 总和为 ${sum},与首行的 ${first} 不一致(非矩形表)`,\n ),\n )\n }\n })\n }\n}\n","/**\n * 结构校验。\n *\n * `validateDocument()` 返回 `MtValidationIssue[]`:`error` 级问题会**阻断**序列化,\n * `warning` 仅记录。默认开启 `fix`,对可自动修复的问题就地修正(如重复 ID 重发、\n * 互斥标记归一、`rowspan` 越界钳制),并把处理方式写进 `handled` 字段。\n *\n * 两类**静默规范化**(不写报告,因为不丢信息):\n * 1. 可选属性的 `null` 归一为**省略**——`null` 与「缺省」在本模型中语义等价,\n * 统一后才能保证跨端往返稳定;\n * 2. 空数组形态的 `content` 归一为省略。\n */\n\nimport { normalizeMarks } from '../normalize'\nimport { MT_DOC } from '../report/codes'\nimport { issue } from '../report'\nimport { contentMatches, getNodeSpec } from '../schema'\nimport { MT_SCHEMA_VERSION } from '../types/base'\nimport type { MtDocument } from '../types/document'\nimport type { MtValidateOptions, MtValidationResult } from '../types/io'\nimport type { MtValidationIssue } from '../types/report'\nimport { createIdGenerator } from '../utils/id'\nimport { isRecord } from '../utils/guards'\nimport { stableStringify } from '../utils/stable-json'\nimport { cloneJson } from '../utils/tree'\nimport { validateTable } from './table'\n\n/** 校验上下文 */\ninterface MtValidateContext {\n issues: MtValidationIssue[]\n fix: boolean\n /** 已出现的块级 ID,用于 `MT_DOC_009` 判重 */\n seenIds: Set<string>\n /** 重发 ID 用 */\n nextId: () => string\n}\n\n/**\n * 删除属性对象中的 `null` 值(就地)。\n *\n * @param attrs 属性对象\n * @returns 是否发生了变更\n */\nconst dropNullAttrs = (attrs: Record<string, unknown>): boolean => {\n let changed = false\n for (const key of Object.keys(attrs)) {\n if (attrs[key] === null) {\n delete attrs[key]\n changed = true\n }\n }\n return changed\n}\n\n/**\n * 校验并(可选)修复文本节点的标记。\n *\n * @param node 文本节点(宽松输入)\n * @param path 定位路径\n * @param ctx 校验上下文\n */\nconst validateMarks = (\n node: Record<string, unknown>,\n path: string,\n ctx: MtValidateContext,\n): void => {\n const raw = node.marks\n if (raw === undefined) return\n\n const before = stableStringify(raw)\n const { marks } = normalizeMarks(raw, {\n path,\n marksAllowed: true,\n degradations: [],\n issues: ctx.issues,\n })\n\n if (before === stableStringify(marks)) return\n if (!ctx.fix) return\n\n if (marks.length > 0) node.marks = marks\n else delete node.marks\n}\n\n/**\n * 校验单个节点(递归)。\n *\n * @param raw 节点(宽松输入)\n * @param path 文档定位路径\n * @param ctx 校验上下文\n * @param parentType 父节点类型(用于 `codeBlock` 内的标记判定)\n */\nconst validateNode = (\n raw: unknown,\n path: string,\n ctx: MtValidateContext,\n parentType?: string,\n): void => {\n if (!isRecord(raw)) {\n ctx.issues.push(issue(MT_DOC.UNKNOWN_NODE, 'error', path, '节点不是对象'))\n return\n }\n\n const type = raw.type\n if (typeof type !== 'string') {\n ctx.issues.push(issue(MT_DOC.UNKNOWN_NODE, 'error', path, '节点缺少 type 字段'))\n return\n }\n\n const spec = getNodeSpec(type)\n if (!spec) {\n ctx.issues.push(\n issue(\n MT_DOC.UNKNOWN_NODE,\n 'error',\n path,\n `未知节点类型 '${type}',请检查 schemaVersion 是否需要迁移`,\n ),\n )\n return\n }\n\n const attrs = isRecord(raw.attrs) ? raw.attrs : {}\n if (isRecord(raw.attrs) && ctx.fix) dropNullAttrs(attrs)\n\n // 必需属性\n for (const name of spec.requiredAttrs) {\n const value = name === 'text' ? raw.text : attrs[name]\n if (value === undefined || (typeof value === 'string' && value.length === 0)) {\n ctx.issues.push(issue(MT_DOC.MISSING_ATTR, 'error', path, `${type} 缺少必填属性 ${name}`))\n }\n }\n\n // 块级 ID:必填 + 唯一\n if (spec.needsId) {\n const id = attrs.id\n if (typeof id !== 'string' || id.length === 0) {\n ctx.issues.push(\n issue(\n MT_DOC.MISSING_ATTR,\n 'error',\n path,\n `${type} 缺少必填属性 id`,\n ctx.fix ? '已补发新 ID' : undefined,\n ),\n )\n if (ctx.fix) attrs.id = ctx.nextId()\n } else if (ctx.seenIds.has(id)) {\n ctx.issues.push(\n issue(\n MT_DOC.DUPLICATE_ID,\n 'error',\n path,\n `节点 ID '${id}' 重复`,\n ctx.fix ? '已重新生成后者' : undefined,\n ),\n )\n if (ctx.fix) attrs.id = ctx.nextId()\n } else {\n ctx.seenIds.add(id)\n }\n }\n\n // 标记\n if (type === 'text') {\n if (parentType === 'codeBlock') {\n if (raw.marks !== undefined) {\n ctx.issues.push(\n issue(\n MT_DOC.MARKS_IN_CODE_BLOCK,\n 'warning',\n path,\n 'codeBlock 内出现 mark',\n ctx.fix ? '已清除 marks' : undefined,\n ),\n )\n if (ctx.fix) delete raw.marks\n }\n } else {\n validateMarks(raw, path, ctx)\n }\n }\n\n // 子节点\n const children = Array.isArray(raw.content) ? raw.content : []\n // `content: []` 归一为省略(空数组与缺省在本模型中语义等价)\n if (children.length === 0 && ctx.fix && Array.isArray(raw.content)) {\n delete raw.content\n }\n\n const childTypes = children.map((child) =>\n isRecord(child) && typeof child.type === 'string' ? child.type : '\\u0000unknown',\n )\n if (!contentMatches(type, childTypes)) {\n ctx.issues.push(\n issue(\n MT_DOC.CONTENT_MISMATCH,\n 'error',\n path,\n `${type} 的 content 为 [${childTypes.join(', ')}],不满足表达式 '${spec.content ?? 'atom(无 content)'}'`,\n ),\n )\n }\n\n children.forEach((child, index) => validateNode(child, `${path}.content[${index}]`, ctx, type))\n\n if (type === 'table') validateTable(raw, path, ctx)\n}\n\n/**\n * 校验文档结构。\n *\n * @param doc 待校验文档\n * @param options 校验选项(默认 `fix: true`)\n * @returns 校验结果;`ok` 为 `false` 时表示存在 `error` 级问题\n *\n * @example\n * ```ts\n * const { ok, issues, doc: fixed } = validateDocument(rawDoc)\n * ```\n */\nexport const validateDocument = (\n doc: MtDocument,\n options: MtValidateOptions = {},\n): MtValidationResult => {\n const fix = options.fix !== false\n const issues: MtValidationIssue[] = []\n const working = fix ? (cloneJson(doc) as MtDocument) : doc\n\n const version = (working as { schemaVersion?: unknown }).schemaVersion\n if (version !== MT_SCHEMA_VERSION) {\n issues.push(\n issue(\n MT_DOC.UNSUPPORTED_VERSION,\n 'error',\n 'schemaVersion',\n `schemaVersion 为 ${String(version)},当前仅支持 ${MT_SCHEMA_VERSION},请先执行 migrateDocument()`,\n ),\n )\n }\n\n const ctx: MtValidateContext = {\n issues,\n fix,\n seenIds: new Set<string>(),\n nextId: createIdGenerator(),\n }\n\n const top = (working as { content?: unknown }).content\n if (!Array.isArray(top) || top.length === 0) {\n issues.push(\n issue(\n MT_DOC.CONTENT_MISMATCH,\n 'error',\n 'content',\n 'doc.content 必须至少包含一个块级节点',\n fix ? '已补一个空段落' : undefined,\n ),\n )\n if (fix) {\n ;(working as { content: unknown }).content = [\n { type: 'paragraph', attrs: { id: ctx.nextId() } },\n ]\n }\n } else {\n top.forEach((node, index) => validateNode(node, `content[${index}]`, ctx))\n }\n\n return {\n ok: !issues.some((item) => item.level === 'error'),\n issues,\n doc: working,\n }\n}\n","/**\n * `schemaVersion` 迁移链。\n *\n * 约定:\n * - 迁移**只增不改**,历史迁移函数永不删除;\n * - 迁移必须**幂等**:对已是目标版本的输入直接返回原值;\n * - 每次迁移必须附一个 `tests/fixtures/migrations/v{n}-to-v{n+1}.json` 对照用例。\n *\n * 当前为 `schemaVersion = 1` 的初始版本,尚无历史迁移。\n */\n\nimport { MT_DOC } from '../report/codes'\nimport { issue } from '../report'\nimport { MT_SCHEMA_VERSION } from '../types/base'\nimport type { MtDocument } from '../types/document'\nimport type { MtDegradationEntry, MtValidationIssue } from '../types/report'\nimport { isRecord } from '../utils/guards'\nimport { cloneJson } from '../utils/tree'\n\n/** 单步迁移函数:把 v{n} 的宽松对象迁到 v{n+1} */\nexport type MtMigration = (doc: Record<string, unknown>) => Record<string, unknown>\n\n/**\n * 迁移链表:`MIGRATIONS[n]` 负责把 `n` 迁到 `n + 1`。\n *\n * 新增版本时在此追加,**禁止修改已有项**。\n */\nexport const MT_MIGRATIONS: Readonly<Record<number, MtMigration>> = {}\n\n/** 迁移结果 */\nexport interface MtMigrateResult {\n /** 迁移后的文档(当前版本) */\n doc: MtDocument\n /** 是否发生了迁移 */\n migrated: boolean\n /** 校验问题(如目标版本高于本包支持上限) */\n issues: MtValidationIssue[]\n /** 降级报告(迁移过程中的信息损失) */\n report: MtDegradationEntry[]\n}\n\n/**\n * 把任意历史版本的文档迁移到当前版本。\n *\n * @param input 宽松输入(来自网络、旧版本数据或文件)\n * @returns 迁移结果;无法迁移时 `issues` 中会含 `MT_DOC_008`\n *\n * @example\n * ```ts\n * const { doc, migrated } = migrateDocument(JSON.parse(raw))\n * ```\n */\nexport const migrateDocument = (input: unknown): MtMigrateResult => {\n const issues: MtValidationIssue[] = []\n const report: MtDegradationEntry[] = []\n\n if (!isRecord(input)) {\n issues.push(issue(MT_DOC.UNKNOWN_NODE, 'error', 'doc', '文档不是一个对象'))\n return {\n doc: { schemaVersion: MT_SCHEMA_VERSION, type: 'doc', content: [] },\n migrated: false,\n issues,\n report,\n }\n }\n\n let working: Record<string, unknown> = cloneJson(input)\n const rawVersion = working.schemaVersion\n\n if (typeof rawVersion === 'number' && rawVersion > MT_SCHEMA_VERSION) {\n issues.push(\n issue(\n MT_DOC.UNSUPPORTED_VERSION,\n 'error',\n 'schemaVersion',\n `文档版本 ${rawVersion} 高于本包支持的 ${MT_SCHEMA_VERSION},请升级 @mteditor/document`,\n ),\n )\n return { doc: working as unknown as MtDocument, migrated: false, issues, report }\n }\n\n // 版本缺失或非数字:按当前版本处理(序列化输出永远使用当前版本编号)\n let version =\n typeof rawVersion === 'number' && Number.isFinite(rawVersion)\n ? Math.trunc(rawVersion)\n : MT_SCHEMA_VERSION\n let migrated = false\n\n while (version < MT_SCHEMA_VERSION) {\n const step = MT_MIGRATIONS[version]\n if (step === undefined) {\n issues.push(\n issue(\n MT_DOC.UNSUPPORTED_VERSION,\n 'error',\n 'schemaVersion',\n `缺少 v${version} → v${version + 1} 的迁移函数`,\n ),\n )\n break\n }\n working = step(working)\n version += 1\n migrated = true\n }\n\n working.schemaVersion = MT_SCHEMA_VERSION\n if (working.type !== 'doc') working.type = 'doc'\n if (!Array.isArray(working.content)) working.content = []\n\n return { doc: working as unknown as MtDocument, migrated, issues, report }\n}\n","/**\n * `@mteditor/document` —— MtDocument 文档模型层(公开 API 入口)。\n *\n * **本包是全部上层包的地基**:不得依赖任何 `@mteditor/*` 包,\n * 也不得触碰 `window` / `document` / `navigator` / `DOMParser` / `innerHTML`\n *(由 `pnpm check:boundaries` 卡口强制执行)。因此同一份产物可在\n * Node、浏览器与小程序运行时三处直接执行。\n *\n * 模块划分:\n *\n * | 目录 | 职责 |\n * |---|---|\n * | `types/` | `MtDocument` / 节点 / 标记 / IO 契约 类型定义 |\n * | `schema/` | 节点与标记元信息、`content` 表达式引擎 |\n * | `sanitize/` | 手写 HTML 分词器 + AST 白名单 + SVG 复验 |\n * | `normalize/` | 标记规范化、行内节点收尾 |\n * | `parse/` | `parseHTML` / `parseMarkdown` → `MtDocument` |\n * | `serialize/` | HTML / Markdown / text / mini 序列化 |\n * | `validate/` | 结构校验与 `MT_DOC_*` 错误码 |\n * | `migrate/` | `schemaVersion` 迁移链 |\n * | `report/` | 降级码 `MT_DEG_*` 与报告构造 |\n * | `utils/` | ID、转义、URL 白名单、树遍历、单位换算 |\n */\n\nimport { createIdGenerator } from './utils/id'\nimport { MT_SCHEMA_VERSION } from './types/base'\nimport type { MtDocument } from './types/document'\n\nexport * from './types'\nexport * from './schema'\nexport * from './report/codes'\nexport { degrade, issue } from './report'\nexport { normalizeMarks, finalizeInlineNodes, hasSameMarks } from './normalize'\nexport { sanitizeHtml } from './sanitize'\nexport { parseInlineStyle } from './sanitize/policy'\nexport type { MtCleanElement, MtCleanNode, MtSanitizeResult, MtCleanText } from './sanitize'\nexport { isSafeSvgMarkup } from './sanitize/svg'\nexport { tokenizeHtml, isElement, getAttribute } from './sanitize/tokenizer'\nexport type { MtHtmlAttribute, MtHtmlElement, MtHtmlNode, MtHtmlText } from './sanitize/tokenizer'\nexport { parseDocument, parseHtmlToDocument, parseMarkdownToDocument } from './parse'\nexport type { MtParseFormat } from './parse'\nexport {\n serializeBlocks,\n serializeDocument,\n serializeDocumentToHtml,\n serializeDocumentToMarkdown,\n serializeDocumentToMini,\n serializeDocumentToText,\n} from './serialize'\nexport { validateDocument } from './validate'\nexport { migrateDocument, MT_MIGRATIONS } from './migrate'\nexport type { MtMigration, MtMigrateResult } from './migrate'\nexport { createBlockId, createIdGenerator, isValidBlockId, MT_BLOCK_ID_PREFIX } from './utils/id'\nexport { checkUrl, sanitizeUrl } from './utils/url'\nexport { escapeAttr, escapeHtml, decodeEntities } from './utils/escape'\nexport { computeTreeStats, walkTree, utf8ByteLength, cloneJson } from './utils/tree'\nexport type { MtTreeStats, MtTreeNodeLike, MtTreeVisit } from './utils/tree'\nexport { stableStringify } from './utils/stable-json'\nexport { fnv1a } from './utils/hash'\nexport {\n formatCssLength,\n MT_COLWIDTH_UNIT,\n MT_INDENT_MAX,\n MT_INDENT_UNIT,\n parseCssLength,\n parseIndent,\n} from './utils/style-units'\nexport {\n normalizeStyleColor,\n parseBooleanAttr,\n parseInteger,\n parseLineHeight,\n parseMediaAlign,\n parseOrderedType,\n parseTextAlign,\n} from './utils/attr-values'\n\n/**\n * 创建一个空文档。\n *\n * 规定:**不允许出现 `content: []` 的 doc**,空文档必须包含一个空 `paragraph`。\n *\n * @param now 可选的 ID 时间戳基准(测试用,保证可复现)\n * @returns 最小合法文档\n *\n * @example\n * ```ts\n * createDocument().content[0]?.type // 'paragraph'\n * ```\n */\nexport const createDocument = (now?: number): MtDocument => {\n const nextId = createIdGenerator(now === undefined ? {} : { now })\n return {\n schemaVersion: MT_SCHEMA_VERSION,\n type: 'doc',\n content: [{ type: 'paragraph', attrs: { id: nextId() } }],\n }\n}\n","/**\n * 渲染容器的属性装配。\n *\n * 容器属性有**两条出口**:字符串出口(`renderToString`)与真实元素出口(`renderToDOM`)。\n * 两者必须产出同一组属性,因此都从本模块的 `resolveContainerAttrs` 取值——\n * 这是「SSR 字符串与客户端 DOM 一致」的实现基础。\n */\n\nimport { escapeAttr } from '@mteditor/document'\n\nimport { MT_CONTENT_CLASS, MT_THEME_ATTR, type MtRenderOptions } from './options'\n\n/** 属性对:`[属性名, 属性值]`,值必为字符串(无「缺省」概念,缺省即不产出该对) */\ntype MtContainerAttr = readonly [name: string, value: string]\n\n/**\n * 解析容器应当产出的属性对(顺序固定:`class` 在前)。\n *\n * @param options 渲染选项\n * @returns 属性对列表;可能为空数组\n *\n * @example\n * ```ts\n * resolveContainerAttrs({ className: '' }) // []\n * resolveContainerAttrs({ theme: 'dark' }) // [['class', 'mt-content'], ['data-mt-theme', 'dark']]\n * ```\n */\nexport const resolveContainerAttrs = (options: MtRenderOptions): MtContainerAttr[] => {\n const attrs: MtContainerAttr[] = []\n const className = options.className ?? MT_CONTENT_CLASS\n if (className.length > 0) attrs.push(['class', className])\n if (options.theme !== undefined) attrs.push([MT_THEME_ATTR, options.theme])\n return attrs\n}\n\n/**\n * 把容器属性序列化为 HTML 属性串。\n *\n * @param options 渲染选项\n * @returns 形如 ` class=\"mt-content\"` 的字符串;无属性时为空串\n *\n * @example\n * ```ts\n * containerAttrsToString({}) // ' class=\"mt-content\"'\n * ```\n */\nexport const containerAttrsToString = (options: MtRenderOptions): string =>\n resolveContainerAttrs(options)\n .map(([name, value]) => ` ${name}=\"${escapeAttr(value)}\"`)\n .join('')\n\n/**\n * 把容器属性写到真实元素上。\n *\n * @param element 目标元素\n * @param options 渲染选项\n */\nexport const applyContainerAttrs = (element: Element, options: MtRenderOptions): void => {\n for (const [name, value] of resolveContainerAttrs(options)) {\n element.setAttribute(name, value)\n }\n}\n\n/**\n * 用容器包裹内容 HTML。\n *\n * `renderToString`(字符串出口)与 `renderToDOM`(DOM 出口)共用本函数,\n * 因此两者返回的 `html` **必然逐字符相同**——这是「SSR 产物与客户端产物一致」的保证。\n *\n * @param output 容器内部的 HTML\n * @param options 渲染选项\n * @returns 含容器的完整 HTML\n *\n * @example\n * ```ts\n * wrapContainer('<p>hi</p>', {}) // '<div class=\"mt-content\"><p>hi</p></div>'\n * ```\n */\nexport const wrapContainer = (output: string, options: MtRenderOptions): string =>\n `<div${containerAttrsToString(options)}>${output}</div>`\n","/**\n * 内容序列化的**唯一入口**。\n *\n * 本模块是 `renderToString` 与 `renderToDOM` 的共同上游:两个公开函数\n * 都从这里拿内容,保证「字符串出口」与「DOM 出口」不可能产出不同的节点结构。\n *\n * ⚠️ 本模块**不得做任何改写**:不加 `loading=\"lazy\"`、不注入额外的 `data-mt-*`、\n * 不做降级高亮。任何改写都会让 Web 只读渲染与小程序端 / 编辑器的节点结构\n * 不再逐字符一致。\n */\n\nimport { serializeDocumentToHtml } from '@mteditor/document'\nimport type { MtDegradationEntry, MtDocument, MtSerializeStats } from '@mteditor/document'\n\nimport type { MtRenderOptions } from './options'\n\n/** 内容序列化结果(不含外层容器) */\nexport interface MtRenderedContent {\n /** 容器内部的 HTML(即 `document` 序列化器的原样产物) */\n output: string\n /** 降级报告;`report: false` 时为空数组 */\n report: MtDegradationEntry[]\n /** 序列化统计 */\n stats: MtSerializeStats\n}\n\n/**\n * 把 `MtDocument` 序列化为容器内部的 HTML。\n *\n * @param doc 文档\n * @param options 渲染选项\n * @returns 内容与报告、统计\n */\nexport const renderContent = (doc: MtDocument, options: MtRenderOptions): MtRenderedContent => {\n const serialized = serializeDocumentToHtml(doc, {\n preRendered: options.preRendered,\n indent: options.indent,\n })\n\n return {\n output: serialized.output,\n // 只有调用方**显式**要求关闭时才丢弃报告\n report: options.report === false ? [] : serialized.report,\n stats: serialized.stats,\n }\n}\n","/**\n * `renderToString` —— 输出 HTML 字符串,**全程不触碰 DOM**(SSR / 邮件 / 静态页)。\n */\n\nimport type { MtDocument } from '@mteditor/document'\n\nimport { wrapContainer } from './container'\nimport type { MtRenderOptions, MtRenderResult } from './options'\nimport { renderContent } from './render-content'\n\n/**\n * 把 `MtDocument` 渲染为 HTML 字符串。\n *\n * 产物 = `<div class=\"mt-content\">{document 序列化产物}</div>`,\n * 内容部分与 `@mteditor/document` 的 `serializeDocumentToHtml` **逐字符一致**,\n * 因此不存在「详情页与编辑器渲染不一致」的可能。\n *\n * 本函数不使用 `window` / `document` / `innerHTML`,可在 Node、边缘函数、\n * 小程序服务端等任何无 DOM 环境执行。\n *\n * @param doc 文档\n * @param options 渲染选项\n * @returns 渲染结果(`html` / `report` / `stats`)\n *\n * @example\n * ```ts\n * const { html, report } = renderToString(doc)\n * // 需要局部固定暗色时:\n * renderToString(doc, { theme: 'dark', className: 'mt-content article-body' }).html\n * // 只要字符串:\n * const str = renderToString(doc).html\n * ```\n */\nexport const renderToString = (doc: MtDocument, options: MtRenderOptions = {}): MtRenderResult => {\n const content = renderContent(doc, options)\n return {\n html: wrapContainer(content.output, options),\n report: content.report,\n stats: content.stats,\n }\n}\n","/**\n * `renderToDOM` —— 把 `MtDocument` 渲染为真实 DOM 节点并挂载进容器。\n *\n * 与 `renderToString` 的关系:**同一份内容、同一套容器属性**,\n * 因此 `renderToDOM(doc, el).html === renderToString(doc).html` 恒成立。\n */\n\nimport type { MtDocument } from '@mteditor/document'\n\nimport { applyContainerAttrs, wrapContainer } from './container'\nimport type { MtDomRenderResult, MtRenderOptions } from './options'\nimport { renderContent } from './render-content'\n\n/**\n * 把 `MtDocument` 渲染为 DOM 并挂载到 `container`。\n *\n * **挂载语义**:调用后 `container` 的子节点被**整体替换**为渲染出的容器元素\n * (即重复调用是幂等的,不会累积)。`container` 自身的属性不被修改——\n * 主题由宿主控制,需要局部覆盖时请用 `options.theme`。\n *\n * @param doc 文档\n * @param container 挂载容器(普通文档流中的元素,不得位于 `position: fixed` 容器内)\n * @param options 渲染选项\n * @returns 渲染结果 + 实际挂载的元素\n *\n * @example\n * ```ts\n * const { element, report } = renderToDOM(doc, document.getElementById('view')!)\n * element.dataset.rendered // 可通过返回的元素继续做懒加载等增强\n * ```\n */\nexport const renderToDOM = (\n doc: MtDocument,\n container: HTMLElement,\n options: MtRenderOptions = {},\n): MtDomRenderResult => {\n const content = renderContent(doc, options)\n const element = container.ownerDocument.createElement('div')\n\n applyContainerAttrs(element, options)\n // 安全性:content.output 由 @mteditor/document 的序列化器产出——文本与属性值\n // 均已转义,仅白名单内的标签/属性可存活(10 份 XSS 载荷基线为证)。\n // 此处不接受任何未经清洗的外部输入。\n element.innerHTML = content.output\n\n container.replaceChildren(element)\n\n return {\n html: wrapContainer(content.output, options),\n report: content.report,\n stats: content.stats,\n element,\n }\n}\n"],"mappings":"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","names":["src_exports","__export","MT_CONTENT_CLASS","MT_THEME_ATTR","applyContainerAttrs","containerAttrsToString","renderToDOM","renderToString","resolveContainerAttrs","wrapContainer","MT_CONTENT_CLASS","MT_THEME_ATTR","MT_BLOCK_ID_PREFIX","BASE36","createIdGenerator","options","now","random","suffixLength","timestamp","used","attempt","suffix","i","id","defaultGenerator","createIdGenerator","ALIGN_ATTRS","CELL_ATTRS","MT_NODE_SPECS","MT_NODE_SPEC_MAP","spec","nodesInGroup","group","MT_GROUP_MEMBERS","TEXT_STYLE_ATTRS","MT_MARK_SPECS","MT_MARK_SPEC_MAP","spec","MT_LINK_DEFAULT_TARGET","MT_LINK_DEFAULT_REL","MT_DEG","MT_DOC","MT_DEG_LABEL","MT_DOC_LABEL","degrade","code","type","path","message","strategy","TEXT_ESCAPES","ATTR_ESCAPES","escapeWith","source","table","out","changed","i","ch","replacement","escapeHtml","text","escapeAttr","value","P_CLOSERS","TABLE_PART_CLOSERS","IMPLICIT_CLOSE","tag","MT_INDENT_UNIT","MT_COLWIDTH_UNIT","formatCssLength","value","unit","FNV_OFFSET","FNV_PRIME","fnv1a","text","hash","i","MT_TREE_ROOT_PATH","childPath","base","index","walkTree","roots","visit","rootPath","step","nodes","parent","depth","node","path","children","computeTreeStats","nodeCount","maxDepth","utf8ByteLength","bytes","code","attrsToString","pairs","out","name","value","escapeAttr","styleToString","parts","MARK_TAGS","markWrapper","mark","open","attrs","MT_LINK_DEFAULT_TARGET","MT_LINK_DEFAULT_REL","style","tag","wrapMarks","content","marks","i","serializeInline","node","ctx","escapeHtml","kind","svg","degrade","MT_DEG","serializeInlineNodes","nodes","singleText","first","serializeRow","row","cells","cell","formatCssLength","MT_COLWIDTH_UNIT","head","serializeBlocks","serializeAtomBlock","serializeCodeBlock","text","language","highlight","fnv1a","preAttrs","codeAttrs","body","serializeBlock","MT_INDENT_UNIT","items","item","checked","widths","colgroup","width","rows","separator","serializeDocumentToHtml","doc","options","output","nodeCount","maxDepth","computeTreeStats","utf8ByteLength","resolveContainerAttrs","options","attrs","className","MT_CONTENT_CLASS","MT_THEME_ATTR","containerAttrsToString","name","value","escapeAttr","applyContainerAttrs","element","wrapContainer","output","renderContent","doc","options","serialized","serializeDocumentToHtml","renderToString","doc","options","content","renderContent","wrapContainer","renderToDOM","doc","container","options","content","renderContent","element","applyContainerAttrs","wrapContainer"]}
|