@mteditor/renderer-mini 0.0.0-stage → 0.1.0

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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +161 -2
  3. package/components/README.md +89 -0
  4. package/components/mt-node/index.js +123 -0
  5. package/components/mt-node/index.json +6 -0
  6. package/components/mt-node/index.wxml +95 -0
  7. package/components/mt-node/index.wxss +163 -0
  8. package/components/mt-renderer/index.js +51 -0
  9. package/components/mt-renderer/index.json +6 -0
  10. package/components/mt-renderer/index.wxml +24 -0
  11. package/components/mt-renderer/index.wxss +14 -0
  12. package/components/mt-theme.wxss +82 -0
  13. package/dist/alipay/index.cjs +4 -0
  14. package/dist/alipay/index.cjs.map +1 -0
  15. package/dist/alipay/index.d.cts +2 -0
  16. package/dist/alipay/index.d.ts +2 -0
  17. package/dist/alipay/index.js +3 -0
  18. package/dist/alipay/index.js.map +1 -0
  19. package/dist/arkts/index.cjs +4 -0
  20. package/dist/arkts/index.cjs.map +1 -0
  21. package/dist/arkts/index.d.cts +2 -0
  22. package/dist/arkts/index.d.ts +2 -0
  23. package/dist/arkts/index.js +3 -0
  24. package/dist/arkts/index.js.map +1 -0
  25. package/dist/bytedance/index.cjs +4 -0
  26. package/dist/bytedance/index.cjs.map +1 -0
  27. package/dist/bytedance/index.d.cts +2 -0
  28. package/dist/bytedance/index.d.ts +2 -0
  29. package/dist/bytedance/index.js +3 -0
  30. package/dist/bytedance/index.js.map +1 -0
  31. package/dist/index.cjs +259 -0
  32. package/dist/index.cjs.map +1 -0
  33. package/dist/index.d.cts +101 -0
  34. package/dist/index.d.ts +101 -0
  35. package/dist/index.js +247 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/taro/index.cjs +4 -0
  38. package/dist/taro/index.cjs.map +1 -0
  39. package/dist/taro/index.d.cts +2 -0
  40. package/dist/taro/index.d.ts +2 -0
  41. package/dist/taro/index.js +3 -0
  42. package/dist/taro/index.js.map +1 -0
  43. package/dist/theme-COcEGLOF.d.cts +214 -0
  44. package/dist/theme-COcEGLOF.d.ts +214 -0
  45. package/dist/uni-app/index.cjs +4 -0
  46. package/dist/uni-app/index.cjs.map +1 -0
  47. package/dist/uni-app/index.d.cts +2 -0
  48. package/dist/uni-app/index.d.ts +2 -0
  49. package/dist/uni-app/index.js +3 -0
  50. package/dist/uni-app/index.js.map +1 -0
  51. package/dist/wechat/index.cjs +259 -0
  52. package/dist/wechat/index.cjs.map +1 -0
  53. package/dist/wechat/index.d.cts +61 -0
  54. package/dist/wechat/index.d.ts +61 -0
  55. package/dist/wechat/index.js +250 -0
  56. package/dist/wechat/index.js.map +1 -0
  57. package/package.json +142 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MtEditor contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,162 @@
1
- # Temporary Holding Version
1
+ # @mteditor/renderer-mini
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > 小程序 / uni-app / Taro / 鸿蒙 ArkTS 只读渲染器 —— 子路径导出多端。
4
+
5
+ ## 定位(ADR-0004,必须写进对外文档)
6
+
7
+ > **小程序端不提供完整富文本编辑。** 小程序无稳定 `contenteditable`,业界无可靠原生富文本方案。
8
+ > MtEditor 在小程序端提供 **100% 保真的只读渲染**;如需编辑,引导用户跳转 H5 / App 编辑页,
9
+ > 编辑后回传 `MtDocument`。
10
+
11
+ ## 状态
12
+
13
+ **M4-a 已落地**(ADR-0030)。`/wechat` 可用;`/alipay` `/bytedance` `/uni-app` `/taro` `/arkts`
14
+ 仍为占位入口 —— 前两者的 `rich-text` 白名单需各自开发者工具**实测**,排期 M4-b,
15
+ 按 AGENTS.md §4.4 禁止凭猜测提供(传给 `renderMiniDocument` 会被 `RangeError` 拒绝)。
16
+
17
+ ## 本包做什么:只做三件事,不自己序列化 nodes
18
+
19
+ | 职责 | API |
20
+ |---|---|
21
+ | ① 通道解析(`auto` → 阈值判定) | `resolveChannel` |
22
+ | ② 主题字面量注入(小程序无 CSS 变量) | `resolveThemeTokens` / `MT_MINI_TOKENS_*` |
23
+ | ③ 平台能力表(准入) | `resolveCapabilities` / `MT_MINI_CAPABILITIES` |
24
+
25
+ `nodes` 一律由 `@mteditor/document` 的 `serializeDocumentToMini` 产出 ——
26
+ 与 `renderer-web` 不自己序列化 HTML(ADR-0021)是同一条原则,
27
+ 保证「编辑、详情页、小程序、导出」四处看到的是同一份真相。
28
+
29
+ ## API
30
+
31
+ | 子路径 | 入口 | 说明 |
32
+ |---|---|---|
33
+ | 通用 | `renderMiniDocument(doc, options)` | `platform` 显式传入;返回 `MtMiniRenderResult` |
34
+ | `/wechat` | `render(doc, options)` | `platform` 已绑定 `'wechat'`;另再导出微信白名单常量 |
35
+
36
+ ```ts
37
+ import { render } from '@mteditor/renderer-mini/wechat'
38
+
39
+ const { mode, nodes, report, stats, autoSwitched } = render(doc, { theme: 'dark' })
40
+
41
+ // mode 是「实际使用的通道」,不是入参 —— 宿主必须按它分支
42
+ // nodes 直接喂给 <rich-text nodes="{{nodes}}"> 或递归组件
43
+ // report 为空数组表示内容无损;autoSwitched 非 null 表示触发了阈值降级
44
+ ```
45
+
46
+ ### 返回值(`MtMiniRenderResult`)
47
+
48
+ | 字段 | 说明 |
49
+ |---|---|
50
+ | `platform` | 目标平台 |
51
+ | `mode` | **实际使用**的通道;入参 `'auto'` 时这里是解析结果 |
52
+ | `nodes` | 小程序 nodes(**Array 形式**,§9.1) |
53
+ | `report` | 降级报告(`MT_DEG_*`),无降级时为空数组。**始终返回、不可关闭** |
54
+ | `stats` | 序列化统计(节点数 / 最大深度 / 字节数) |
55
+ | `autoSwitched` | 自动通道切换记录(`from` / `to` / `nodeCount` / `maxNodes`);未触发为 `null` |
56
+
57
+ > `report` 与 `autoSwitched` 刻意分开:前者是**内容表达损失**,后者是**渲染策略**。
58
+ > 混在一起会让「报告为空」读不出「内容无损」。
59
+ >
60
+ > 本包**不提供关闭降级报告的开关**(与 `renderer-web` 不同):`rich-text` 通道会把
61
+ > `video` / `audio` / 公式降级成文本,这类损失用户直接看得见,留开关等于给静默留后门(§12.2)。
62
+
63
+ ### 选项(`MtMiniRenderOptions`)
64
+
65
+ | 选项 | 缺省 | 说明 |
66
+ |---|---|---|
67
+ | `platform` | `'wechat'` | 目标平台;未实测核实的平台会被拒绝(抛 `RangeError`) |
68
+ | `mode` | `'auto'` | `'auto' \| 'richText' \| 'component'` |
69
+ | `maxNodes` | `3000` | `auto` 的降级阈值,单位是**文档节点数**;判定在序列化**之前** |
70
+ | `theme` | `'light'` | 内置主题名,或自定义字面量表(键含 `--` 前缀) |
71
+ | `themeTokens` | — | 主题字面量补充/覆盖,与 `theme` 合并后**后者优先** |
72
+ | `preRendered` | — | 预渲染产物索引(公式 SVG / 代码高亮,ADR-0012 / ADR-0013) |
73
+
74
+ ## 双通道渲染(ADR-0005)
75
+
76
+ | 通道 | 实现 | 适用 | 样式来源 |
77
+ |---|---|---|---|
78
+ | **B. 递归自定义组件**(默认) | JSON → 递归 `view`/`text` 树 | 复杂内容、需要交互(图片预览、表格横滑) | 组件 wxss + CSS 变量 |
79
+ | **A. `rich-text` 原生组件** | JSON → `nodes` 数组 | 内容简单、性能优先 | 序列化时**内联的字面量** |
80
+
81
+ `mode: 'auto'` 的判据是**文档节点数**(`computeTreeStats(doc.content).nodeCount` 与 `maxNodes` 比较),
82
+ **判定发生在序列化之前** —— 判定的意义正是避免一次昂贵序列化。
83
+ 不采用「输出节点数」:那要先序列化一次才能判定。
84
+
85
+ `auto` 触发降级时,`autoSwitched` 会带上 `from` / `to` / `nodeCount` / `maxNodes`。
86
+ 本包**不持有事件总线**,所以「降级警告」的载体是这个显式字段,而非 `console.warn`
87
+ (后者在宿主项目里会被淹没,而「功能没生效但也没报错」是本项目出现最多的失败模式)。
88
+
89
+ ## 关键约束
90
+
91
+ 1. **零 DOM**(§9.4):禁止 `window` / `document` / `navigator` / `DOMParser` / `innerHTML`。
92
+ `tsconfig.json` 的 `lib` 已剔除 `DOM`,由 `pnpm check:boundaries` 强制。
93
+ 测试配置(`vitest.config.ts` / `tsconfig.test.json`)**刻意不用 jsdom**,
94
+ 否则会掩盖「不小心用了浏览器全局」。
95
+ 2. **各端映射表独立**(§4.4):微信 / 支付宝 / 抖音的 `rich-text` 白名单**不完全一致**,
96
+ 禁止共用一份映射表。未实测的平台**拒绝执行**,不猜。
97
+ 3. **CSS 变量不可用**(§9.3):`rich-text` 内的 `style` 不参与宿主变量继承,
98
+ 序列化时必须把主题变量**解析为字面量**。本包内置 light / dark 两套,键名与
99
+ `theme-default/src/tokens.css` 逐键对应,由 `pnpm check:theme-tokens` 卡口守着。
100
+ 4. **无客户端运行时**:代码高亮与公式必须在 `document` 层预渲染(内联 `style` 的 `span` / 内联 SVG)。
101
+ 5. **降级必须可见**(§12.2):`video` / `audio` / `mention` 等在 `rich-text` 下不可表达的
102
+ 节点必须写入降级报告,禁止静默丢弃。
103
+ 6. 体积预算:`dist/wechat/index.js` gzip ≤ 30 KB(ADR-0020:esbuild minify + zlib level 9)。
104
+
105
+ ## 组件资产(通道 B 的实现,随包分发)
106
+
107
+ `components/` 是**递归自定义组件通道的实现**,作为**唯一权威副本(canonical)**随包发布:
108
+
109
+ | 文件 | 作用 |
110
+ |---|---|
111
+ | `mt-renderer/` | 入口组件:按 `render()` 返回的 `mode` 在 `<rich-text>` 与递归组件之间分支 |
112
+ | `mt-node/` | 递归节点组件:按 `node.name` 分派到 `<view>` / `<text>` / `<image>` / `<video>` / `<audio>` |
113
+ | `mt-theme.wxss` | 主题变量表(宿主 `@import` 一次),亮色默认 + `mt-theme-dark` 覆盖 |
114
+
115
+ 接入三步、两条通道的样式差异、三处有意的额外包裹(表格 `scroll-view scroll-x` / 公式 data URI /
116
+ 附件独立分支)、组件选项(`virtualHost` + `addGlobalClass`,最低基础库 2.21.0)
117
+ 见 [`components/README.md`](./components/README.md)。
118
+
119
+ > **产物形态的例外**(ADR-0030 决策 6):`components/` 不经 tsup 编译、**不进 bundle**、
120
+ > 因此**不计入 30 KB 体积预算**;它是 `files` 的一部分,由 `check-artifacts.mjs` 断言四件套齐备。
121
+ > 依据:ADR-0005 通道 B 的名字就是「递归自定义组件」—— 组件是通道实现的一部分,不是示例的私事;
122
+ > 放示例里则 canonical 副本与后续更新没有任何同步机制。
123
+
124
+ ## 产物
125
+
126
+ | 文件 | 用途 |
127
+ |---|---|
128
+ | `dist/index.js` / `dist/index.cjs` | 通用入口(ESM / CJS),`@mteditor/document` 保持 external |
129
+ | `dist/wechat/index.js` / `.cjs` | `/wechat` 子路径 |
130
+ | `dist/{alipay,bytedance,uni-app,taro,arkts}/index.*` | 占位入口(M4-b / M4-c / M5 排期) |
131
+ | `dist/*/index.d.ts` | 类型定义 |
132
+ | `components/**` | 通道 B 的小程序组件(canonical,见上) |
133
+
134
+ ## 命令
135
+
136
+ ```bash
137
+ pnpm --filter @mteditor/renderer-mini build
138
+ pnpm --filter @mteditor/renderer-mini typecheck # src + 测试配置各一次
139
+ pnpm --filter @mteditor/renderer-mini test # 默认开启覆盖率(ADR-0019 阈值)
140
+ ```
141
+
142
+ ## 测试
143
+
144
+ | 层面 | 手段 | 覆盖 |
145
+ |---|---|---|
146
+ | 单元 / 契约 | Vitest(`environment: 'node'`) | 能力表 / 通道解析 / 主题 / 渲染编排 / `/wechat` / 公开面;**35 篇基线 × 两条通道**逐字节比对 |
147
+ | 端到端 | `miniprogram-automator` | 渲染器全部节点在**真实小程序运行时**下的渲染 |
148
+
149
+ **必须真机或微信开发者工具运行**,禁止只跑 jsdom —— jsdom 无法反映真实白名单行为。
150
+ 冒烟脚本 `scripts/miniprogram-smoke.mjs`;缺工具时自动跳过,`--require` 强制。
151
+ > ⚠️ 自动化需用 `MINIPROGRAM_APPID` 注入**测试号 AppID**:游客模式 `touristappid`
152
+ > 只在 GUI 手动导入时可用,CLI 会校验 AppID 并报 `APPID_ERROR`(详见
153
+ > `examples/miniprogram/README.md`)。
154
+
155
+ ## 分层
156
+
157
+ `renderer-mini` → `document`(AGENTS.md §5)。**本包不得依赖 `core`**,
158
+ 也不得持有任何 DOM 全局。
159
+
160
+ ## 许可
161
+
162
+ MIT
@@ -0,0 +1,89 @@
1
+ # renderer-mini 的小程序组件(canonical 副本)
2
+
3
+ > 这一目录是**递归自定义组件通道(ADR-0005 通道 B)的实现**,随包分发。
4
+ > 它的存在理由:通道 B 的名字就叫「递归自定义组件」—— 组件是这条通道的一部分,
5
+ > 不是某个示例的私事(ADR-0030 决策 6)。
6
+
7
+ ## 目录
8
+
9
+ | 文件 | 作用 |
10
+ |---|---|
11
+ | `mt-renderer/` | 入口组件:按 `render()` 返回的 `mode` 在 `<rich-text>` 与递归组件之间分支 |
12
+ | `mt-node/` | 递归节点组件:按 `node.name` 分派到 `<view>` / `<text>` / `<image>` / `<video>` / `<audio>` |
13
+ | `mt-theme.wxss` | 主题变量表(宿主 `@import` 一次),亮色默认 + `mt-theme-dark` 覆盖 |
14
+
15
+ ## 接入(三步)
16
+
17
+ **1. 复制或引用组件**
18
+
19
+ 小程序无法从 `node_modules` 直接读组件,把这两个目录复制进项目(如 `components/mt/`)即可。
20
+ 组件的 `usingComponents` 用的是相对路径,复制后无需改动。
21
+
22
+ **2. 页面声明组件**
23
+
24
+ ```json
25
+ {
26
+ "usingComponents": {
27
+ "mt-renderer": "/components/mt/mt-renderer/index"
28
+ }
29
+ }
30
+ ```
31
+
32
+ **3. 渲染**
33
+
34
+ ```js
35
+ import { render } from '@mteditor/renderer-mini/wechat'
36
+
37
+ Page({
38
+ data: { mode: 'component', nodes: [] },
39
+
40
+ onLoad() {
41
+ const { mode, nodes, report, autoSwitched } = render(doc, { theme: 'dark' })
42
+ this.setData({ mode, nodes })
43
+ if (report.length > 0) console.warn('内容有降级', report)
44
+ if (autoSwitched) console.warn('已自动切换通道', autoSwitched)
45
+ },
46
+
47
+ /** 图片点击 → 预览;附件点击 → 下载 */
48
+ onItemTap(event) {
49
+ const { kind, value } = event.detail
50
+ if (kind === 'image') wx.previewImage({ urls: [value] })
51
+ if (kind === 'attachment') wx.downloadFile({ url: value })
52
+ },
53
+ })
54
+ ```
55
+
56
+ ```xml
57
+ <mt-renderer mode="{{mode}}" nodes="{{nodes}}" bind:mttap="onItemTap" />
58
+ ```
59
+
60
+ `mode` **必须是 `render()` 返回的值**,不要传你传进去的 `'auto'` ——
61
+ `'auto'` 的解析结果只有渲染器知道。
62
+
63
+ ## 两条通道的样式来源不同(重要)
64
+
65
+ | 通道 | 样式来源 | 主题切换 |
66
+ |---|---|---|
67
+ | `component`(默认) | 组件 wxss + CSS 变量 | 覆盖 `page { --mt-*: … }`,或加 `mt-theme-dark` class |
68
+ | `richText` | 序列化时**内联的字面量** | `render(doc, { theme, themeTokens })` |
69
+
70
+ 原因见 `docs/document-model.md` §9.3:`rich-text` 内的 `style` 不参与宿主 CSS 变量继承,
71
+ 所以那条通道的主题必须在序列化阶段就解析成字面量。
72
+
73
+ ## 三处有意的额外包裹
74
+
75
+ 跨端一致性要求「无多余包裹」(§12.2),所以下面三处是**明确记录在案**的例外,
76
+ 都是小程序能力约束所致:
77
+
78
+ 1. **表格**:外包一层 `<scroll-view scroll-x>` —— 宽表格必须能横滑(§9.2);
79
+ 2. **公式**:`<view class="mt-math">` 里的 SVG 文本由组件转成 `data:image/svg+xml,` URI 后交给 `<image>`;
80
+ 3. **附件**:独立分支以便绑定点击。
81
+
82
+ ## 组件选项
83
+
84
+ 两个组件都开了:
85
+
86
+ - `virtualHost: true` —— 组件自身不产生包裹节点(基础库 ≥ 2.19.2,本包基线 2.21.0);
87
+ - `addGlobalClass: true` —— 允许宿主的全局样式作用进组件,宿主才有办法微调外观。
88
+
89
+ 最低基础库:**2.21.0**(AGENTS.md §4.4)。
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `mt-node` —— 递归节点组件(ADR-0005 的「通道 B:递归自定义组件」)。
3
+ *
4
+ * ## 输入契约
5
+ *
6
+ * `node` 就是一个 `MtMiniNode`(由 `renderMiniDocument` / `render` 产出):
7
+ *
8
+ * ```ts
9
+ * { type: 'node', name: 'view', attrs: { class: 'mt-p' }, children: [...] }
10
+ * { type: 'text', text: '段落内容' }
11
+ * ```
12
+ *
13
+ * 本组件**不做任何数据解释**(不解析 JSON、不拼字符串),只按 `name` 分派
14
+ * 到对应的小程序标签 —— 所有结构决策都在 `@mteditor/document` 里做完了。
15
+ *
16
+ * ## 两个必须显式打开的能力
17
+ *
18
+ * - `virtualHost: true`:组件自身不产生包裹节点。§12.2 要求跨端「节点数与层级结构
19
+ * 一一对应,无多余包裹」,默认的包裹行为会让 35 篇基线全部多出一层。
20
+ * 基础库要求 ≥ 2.19.2,本包基线 2.21.0(§4.4)满足。
21
+ * - `addGlobalClass: true`:允许宿主的全局样式(`app.wxss`)作用到本组件内,
22
+ * 宿主才有办法微调外观而不必改组件源码。
23
+ *
24
+ * ## 事件
25
+ *
26
+ * 只冒泡一种事件 `mttap`,`detail` 形如:
27
+ *
28
+ * ```ts
29
+ * { kind: 'image' | 'attachment', value: string }
30
+ * ```
31
+ *
32
+ * 递归链上每一层都原样转发,最终由 `mt-renderer` 抛给页面 ——
33
+ * 节点树有多深,事件都能一路冒上来。
34
+ */
35
+
36
+ Component({
37
+ options: {
38
+ virtualHost: true,
39
+ addGlobalClass: true,
40
+ },
41
+
42
+ properties: {
43
+ /** 待渲染的单个节点(`MtMiniNode`) */
44
+ node: { type: null, value: null },
45
+ /** 是否允许长按选中文本,缺省允许(只读页里用户常需要复制) */
46
+ selectable: { type: Boolean, value: true },
47
+ },
48
+
49
+ data: {
50
+ /** `mt-math` 节点转换出的 data URI;空串表示未转换 */
51
+ mathSrc: '',
52
+ },
53
+
54
+ observers: {
55
+ node(value) {
56
+ this.refreshMath(value)
57
+ },
58
+ },
59
+
60
+ methods: {
61
+ /**
62
+ * 公式节点预处理。
63
+ *
64
+ * `document` 把预渲染好的 SVG 以**文本载荷**挂在 `<view class="mt-math">` 下
65
+ * (它不假设宿主如何渲染 SVG)。小程序不能内联 SVG 标签,但 `<image>` 可以吃
66
+ * `data:image/svg+xml,` 开头的 data URI —— 转换放在这里,因为它属于「渲染」。
67
+ *
68
+ * @param {object} node 当前节点
69
+ */
70
+ refreshMath(node) {
71
+ const isMath = node && node.attrs && node.attrs.class === 'mt-math'
72
+ if (!isMath) {
73
+ if (this.data.mathSrc !== '') this.setData({ mathSrc: '' })
74
+ return
75
+ }
76
+ const first = node.children && node.children[0]
77
+ const svg = first && first.type === 'text' ? first.text : ''
78
+ const src = svg ? `data:image/svg+xml,${encodeURIComponent(svg)}` : ''
79
+ if (src !== this.data.mathSrc) this.setData({ mathSrc: src })
80
+ },
81
+
82
+ /**
83
+ * 向上转发子节点的事件。
84
+ *
85
+ * @param {object} event 子组件抛出的 `mttap`
86
+ */
87
+ onBubble(event) {
88
+ this.triggerEvent('mttap', event.detail)
89
+ },
90
+
91
+ /** 图片点击:把 `src` 交给页面去 `previewImage` */
92
+ onImageTap() {
93
+ this.emit('image', this.attr('src'))
94
+ },
95
+
96
+ /** 附件点击:把下载地址交给页面 */
97
+ onAttachmentTap() {
98
+ this.emit('attachment', this.attr('data-src'))
99
+ },
100
+
101
+ /**
102
+ * 读取当前节点的某个属性。
103
+ *
104
+ * @param {string} key 属性名
105
+ * @returns {string} 属性值;缺失时为空串
106
+ */
107
+ attr(key) {
108
+ const node = this.data.node
109
+ return (node && node.attrs && node.attrs[key]) || ''
110
+ },
111
+
112
+ /**
113
+ * 抛出 `mttap`。
114
+ *
115
+ * @param {'image' | 'attachment'} kind 事件种类
116
+ * @param {string} value 载荷(通常是 URL)
117
+ */
118
+ emit(kind, value) {
119
+ if (!value) return
120
+ this.triggerEvent('mttap', { kind, value })
121
+ },
122
+ },
123
+ })
@@ -0,0 +1,6 @@
1
+ {
2
+ "component": true,
3
+ "usingComponents": {
4
+ "mt-node": "./index"
5
+ }
6
+ }
@@ -0,0 +1,95 @@
1
+ <!--
2
+ mt-node —— 递归渲染单个节点(ADR-0005 通道 B)
3
+
4
+ 分派规则与 `docs/document-model.md` §9.2 的「递归组件通道」一列逐行对应:
5
+ 文本 → <text>;图片/视频/音频 → 原生组件;其余 → <view> 并递归 children。
6
+
7
+ 三处**有意的额外包裹**,都是小程序的能力约束所致,不是设计疏漏:
8
+ 1. 表格:包一层 <scroll-view scroll-x> —— 宽表格必须能横滑(§9.2);
9
+ 2. 公式:<view class="mt-math"> 里的 SVG 文本在 JS 里转成 data URI 后由 <image> 承载;
10
+ 3. 附件:需要绑定点击,故独立分支而非走通用 <view>。
11
+ -->
12
+
13
+ <!-- 文本节点 -->
14
+ <text
15
+ wx:if="{{node.type === 'text'}}"
16
+ class="{{node.attrs.class}}"
17
+ style="{{node.attrs.style}}"
18
+ user-select="{{selectable}}"
19
+ >{{node.text}}</text>
20
+
21
+ <!-- 图片:mode=widthFix 是「宽度自适应、高度按比例」的唯一可靠方式(§9.2) -->
22
+ <image
23
+ wx:elif="{{node.name === 'image'}}"
24
+ class="{{node.attrs.class}}"
25
+ style="{{node.attrs.style}}"
26
+ src="{{node.attrs.src}}"
27
+ mode="widthFix"
28
+ bindtap="onImageTap"
29
+ />
30
+
31
+ <!-- 视频(rich-text 通道不可表达,必须由原生组件承载) -->
32
+ <video
33
+ wx:elif="{{node.name === 'video'}}"
34
+ class="{{node.attrs.class}}"
35
+ style="{{node.attrs.style}}"
36
+ src="{{node.attrs.src}}"
37
+ poster="{{node.attrs.poster}}"
38
+ />
39
+
40
+ <!-- 音频 -->
41
+ <audio
42
+ wx:elif="{{node.name === 'audio'}}"
43
+ class="{{node.attrs.class}}"
44
+ style="{{node.attrs.style}}"
45
+ src="{{node.attrs.src}}"
46
+ />
47
+
48
+ <!-- 公式:SVG 已在 JS 中转为 data URI -->
49
+ <image
50
+ wx:elif="{{node.attrs.class === 'mt-math' && mathSrc}}"
51
+ class="mt-math"
52
+ src="{{mathSrc}}"
53
+ mode="widthFix"
54
+ />
55
+
56
+ <!-- 表格:外层 <scroll-view scroll-x> 提供横滑 -->
57
+ <scroll-view wx:elif="{{node.attrs.class === 'mt-table'}}" class="mt-table-scroll" scroll-x>
58
+ <view class="{{node.attrs.class}}" style="{{node.attrs.style}}">
59
+ <mt-node
60
+ wx:for="{{node.children}}"
61
+ wx:key="index"
62
+ node="{{item}}"
63
+ selectable="{{selectable}}"
64
+ bind:mttap="onBubble"
65
+ />
66
+ </view>
67
+ </scroll-view>
68
+
69
+ <!-- 附件卡片:可点击 -->
70
+ <view
71
+ wx:elif="{{node.attrs.class === 'mt-attachment'}}"
72
+ class="mt-attachment"
73
+ style="{{node.attrs.style}}"
74
+ data-src="{{node.attrs['data-src']}}"
75
+ bindtap="onAttachmentTap"
76
+ >
77
+ <mt-node
78
+ wx:for="{{node.children}}"
79
+ wx:key="index"
80
+ node="{{item}}"
81
+ selectable="{{selectable}}"
82
+ bind:mttap="onBubble"
83
+ />
84
+ </view>
85
+
86
+ <!-- 通用元素节点:<view> + 递归 -->
87
+ <view wx:else class="{{node.attrs.class}}" style="{{node.attrs.style}}">
88
+ <mt-node
89
+ wx:for="{{node.children}}"
90
+ wx:key="index"
91
+ node="{{item}}"
92
+ selectable="{{selectable}}"
93
+ bind:mttap="onBubble"
94
+ />
95
+ </view>
@@ -0,0 +1,163 @@
1
+ /**
2
+ * mt-node 的节点样式。
3
+ *
4
+ * ## 与 rich-text 通道的**本质差异**(必须写进对外文档)
5
+ *
6
+ * | 通道 | 样式来源 | 主题切换方式 |
7
+ * |---|---|---|
8
+ * | 组件通道(本文件) | WXSS + CSS 变量 | 宿主覆盖 `page { --mt-*: … }`,或用 `mt-theme.wxss` |
9
+ * | `rich-text` 通道 | 序列化时**内联的字面量** | `render(doc, { theme, themeTokens })` |
10
+ *
11
+ * 原因是硬约束:`rich-text` 内的 `style` 不参与宿主 CSS 变量继承(§9.3),
12
+ * 而组件通道里的元素是宿主的真实节点,CSS 变量照常生效。
13
+ *
14
+ * ## 每个取值都带 fallback
15
+ *
16
+ * 宿主不引 `mt-theme.wxss` 也要能渲染出可用外观 —— 一个「必须再引一个文件才能看」
17
+ * 的渲染器,第一次用就会被认为是坏的。
18
+ */
19
+
20
+ /* ---- 段落与标题 ---- */
21
+
22
+ .mt-p {
23
+ margin: 0 0 0.75em;
24
+ font-size: var(--mt-font-size, 15px);
25
+ line-height: var(--mt-line-height, 1.7);
26
+ color: var(--mt-color-text, #1f2329);
27
+ overflow-wrap: break-word;
28
+ }
29
+
30
+ .mt-h1,
31
+ .mt-h2,
32
+ .mt-h3,
33
+ .mt-h4,
34
+ .mt-h5,
35
+ .mt-h6 {
36
+ margin: 1em 0 0.5em;
37
+ font-weight: 600;
38
+ line-height: 1.35;
39
+ color: var(--mt-color-text, #1f2329);
40
+ }
41
+
42
+ .mt-h1 {
43
+ font-size: var(--mt-h1-font-size, 28px);
44
+ }
45
+
46
+ .mt-h2 {
47
+ font-size: var(--mt-h2-font-size, 24px);
48
+ }
49
+
50
+ .mt-h3 {
51
+ font-size: var(--mt-h3-font-size, 20px);
52
+ }
53
+
54
+ .mt-h4 {
55
+ font-size: var(--mt-h4-font-size, 18px);
56
+ }
57
+
58
+ .mt-h5 {
59
+ font-size: var(--mt-h5-font-size, 16px);
60
+ }
61
+
62
+ .mt-h6 {
63
+ font-size: var(--mt-h6-font-size, 15px);
64
+ }
65
+
66
+ /* ---- 引用(左侧竖线由序列化时内联的 style 提供) ---- */
67
+
68
+ .mt-blockquote {
69
+ margin: 0 0 0.75em;
70
+ color: var(--mt-color-text-secondary, #646a73);
71
+ }
72
+
73
+ /* ---- 列表:缩进由 WXSS 提供,序号由 <ol> 自绘 ---- */
74
+
75
+ .mt-ul,
76
+ .mt-ol {
77
+ margin: 0 0 0.75em;
78
+ padding-left: 1.6em;
79
+ }
80
+
81
+ .mt-li {
82
+ margin: 0.25em 0;
83
+ }
84
+
85
+ /* 嵌套列表再加一层缩进 */
86
+ .mt-li .mt-ul,
87
+ .mt-li .mt-ol {
88
+ margin-bottom: 0;
89
+ }
90
+
91
+ /* ---- 任务列表(小程序无 input,序列化时已用 ☐ / ☑ 字符模拟) ---- */
92
+
93
+ .mt-task-list {
94
+ margin: 0 0 0.75em;
95
+ }
96
+
97
+ .mt-task-item {
98
+ margin: 0.25em 0;
99
+ }
100
+
101
+ /* ---- 代码块(背景色与横向滚动由内联 style 提供) ---- */
102
+
103
+ .mt-pre {
104
+ margin: 0 0 0.75em;
105
+ border-radius: var(--mt-radius-md, 6px);
106
+ font-size: var(--mt-font-size-sm, 13px);
107
+ white-space: pre-wrap;
108
+ overflow-wrap: break-word;
109
+ }
110
+
111
+ /* ---- 分割线(border-top 由内联 style 提供) ---- */
112
+
113
+ .mt-hr {
114
+ height: 0;
115
+ margin: 1.25em 0;
116
+ }
117
+
118
+ /* ---- 表格:外层 scroll-view 提供横滑 ---- */
119
+
120
+ .mt-table-scroll {
121
+ width: 100%;
122
+ margin: 0 0 0.75em;
123
+ }
124
+
125
+ .mt-table {
126
+ border-collapse: collapse;
127
+ font-size: var(--mt-font-size-sm, 13px);
128
+ }
129
+
130
+ .mt-th,
131
+ .mt-td {
132
+ padding: 6px 8px;
133
+ border: 1px solid var(--mt-color-border, #dee0e3);
134
+ vertical-align: top;
135
+ color: var(--mt-color-text, #1f2329);
136
+ }
137
+
138
+ .mt-th {
139
+ background-color: var(--mt-color-bg-muted, #f5f7fa);
140
+ font-weight: 600;
141
+ }
142
+
143
+ /* ---- 媒体与其它 ---- */
144
+
145
+ .mt-img {
146
+ display: block;
147
+ max-width: 100%;
148
+ }
149
+
150
+ .mt-math {
151
+ display: block;
152
+ max-width: 100%;
153
+ margin: 0.5em 0;
154
+ }
155
+
156
+ .mt-attachment {
157
+ display: block;
158
+ margin: 0 0 0.75em;
159
+ padding: 8px 12px;
160
+ border: 1px solid var(--mt-color-border, #dee0e3);
161
+ border-radius: var(--mt-radius-md, 6px);
162
+ background-color: var(--mt-color-bg-muted, #f5f7fa);
163
+ }