@cellgit/markdown-render 0.1.0 → 1.0.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.
- package/README.md +436 -178
- package/README.zh-CN.md +530 -0
- package/THIRD-PARTY-NOTICES.md +332 -0
- package/dist/markdown-render.css +3 -3
- package/dist/markdown-render.esm.css +3 -3
- package/dist/markdown-render.esm.js +1 -91452
- package/dist/markdown-render.html +20 -75
- package/dist/markdown-render.js +1 -91457
- package/dist/scripts/bridge.js +119 -0
- package/dist/scripts/chat-renderer.js +2770 -0
- package/dist/scripts/copy.js +155 -0
- package/dist/scripts/height-sync.js +205 -0
- package/dist/scripts/renderer.js +398 -0
- package/ios-example/MarkdownViewController.swift +123 -33
- package/ios-example/README.md +2 -0
- package/ios-example/SwiftUIMarkdownExample.swift +111 -0
- package/package.json +17 -9
- package/dist/fonts/KaTeX_AMS-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_AMS-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/dist/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- package/dist/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- package/dist/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- package/dist/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Main-Bold.ttf +0 -0
- package/dist/fonts/KaTeX_Main-Bold.woff +0 -0
- package/dist/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- package/dist/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- package/dist/fonts/KaTeX_Main-Italic.ttf +0 -0
- package/dist/fonts/KaTeX_Main-Italic.woff +0 -0
- package/dist/fonts/KaTeX_Main-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Main-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- package/dist/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- package/dist/fonts/KaTeX_Math-Italic.ttf +0 -0
- package/dist/fonts/KaTeX_Math-Italic.woff +0 -0
- package/dist/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- package/dist/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- package/dist/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- package/dist/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- package/dist/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Script-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Script-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Size1-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Size1-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Size2-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Size2-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Size3-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Size3-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Size4-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Size4-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Typewriter-Regular.woff +0 -0
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
# @cellgit/markdown-render
|
|
2
|
+
|
|
3
|
+
**[English](README.md)** | 简体中文
|
|
4
|
+
|
|
5
|
+
基于 `markdown-it` 的流式友好 Markdown 渲染器。支持 KaTeX 数学公式、GitHub 风格代码高亮、任务列表、Emoji shortcode、表格,以及 `WKWebView` 内的动态高度渲染。专为 AI Chat 流式输出调优 —— v1.0 设计笔记见 `docs/render-core-fixes-v1.md`。
|
|
6
|
+
|
|
7
|
+
## 📖 相关资源
|
|
8
|
+
|
|
9
|
+
- [markdown-render npm 包页面](https://www.npmjs.com/package/@cellgit/markdown-render)
|
|
10
|
+
- [markdown-render 仓库](https://github.com/cellgit/markdown-render)
|
|
11
|
+
- [markdown-render iOS 集成示例仓库](https://github.com/cellgit/MarkdownRenderDemo)
|
|
12
|
+
- [markdown-render npm 包测试工程仓库](https://github.com/cellgit/markdown-render-npm-test)
|
|
13
|
+
|
|
14
|
+
## 特性
|
|
15
|
+
|
|
16
|
+
- ✅ **基于 markdown-it** — 使用业界标准的 markdown-it 解析器
|
|
17
|
+
- ✅ **语法高亮** — 内置 highlight.js(common 构建,约 40 种主流语言,含 Swift/Kotlin/TS 等;无语言标签时在 20 种高频语言内自动检测,超过 8KB 的代码块直接按纯文本渲染以保证流式性能)
|
|
18
|
+
- ✅ **数学公式** — KaTeX 集成,支持行内和块级数学公式
|
|
19
|
+
- ✅ **非标准 Markdown 容错** — 价格里的 `$`、方括号引用、公式尚未流完的右花括号,都不会变红;KaTeX 解析不了的内容退回作者原文,颜色与正文一致
|
|
20
|
+
- ✅ **流式渲染** — rAF 合并 + 增量安全边界缓存,整条流总成本 O(n)
|
|
21
|
+
- ✅ **推理折叠** — `<think>` … `</think>` 会从正文中分离到独立折叠面板:模型思考时展开并逐字流出,正文一开始就收起成一行;单独用字段下发推理的服务商可以直接喂同一个面板
|
|
22
|
+
- ✅ **任务列表** — 支持 `- [x]` 和 `- [ ]` 语法
|
|
23
|
+
- ✅ **嵌套列表** — 支持多层嵌套列表(有序/无序)
|
|
24
|
+
- ✅ **Emoji** — 支持 emoji shortcode,如 `:smile:`
|
|
25
|
+
- ✅ **表格** — 完整的 markdown 表格支持,窄屏自动横向滚动
|
|
26
|
+
- ✅ **代码块增强** — 带语言标签和复制按钮的代码块
|
|
27
|
+
- ✅ **主题支持** — 内置明暗主题自动切换 + token 化主题引擎
|
|
28
|
+
- ✅ **声明式扩展** — 用配置注册自定义行内语法(@提及、[[wiki]]、`||剧透||` 等)
|
|
29
|
+
- ✅ **单文件输出** — 所有依赖打包在一个 JS 文件中(约 620KB)
|
|
30
|
+
- ✅ **Pipeline 架构** — 可扩展的后处理机制
|
|
31
|
+
|
|
32
|
+
## Roadmap:LLM 输出覆盖
|
|
33
|
+
|
|
34
|
+
真实 LLM 输出比规范 Markdown 更「脏」。下表列出的模式均已**对当前渲染器实测验证**,尚未一等公民支持,属于计划中的下一批能力。完整评估、验收标准与建议实施顺序见 [docs/llm-output-roadmap.md](docs/llm-output-roadmap.md)。
|
|
35
|
+
|
|
36
|
+
| 优先级 | 模式 | 现状 | 计划 |
|
|
37
|
+
|---|---|---|---|
|
|
38
|
+
| P0 | GitHub Alerts `> [!NOTE]` | 普通引用块,`[!NOTE]` 字面可见 | 可主题化 callout 卡片(5 种类型) |
|
|
39
|
+
| P0 | 脚注 `[^1]` | 字面文本 | 上标角标 + 点击事件(RAG 引用场景) |
|
|
40
|
+
| P0 | 流尾未闭合 `**加粗` / `$公式` | 裸标记闪现,闭合瞬间跳变 | 尾部软收口,终帧恢复语义;可选打字光标 |
|
|
41
|
+
| P0 | YAML frontmatter | 误渲染为 `<hr>` + 标题 | 文档首部静默剥离 |
|
|
42
|
+
| ✅ 已完成 | `<think>` / `<thinking>` 推理内容 | 转义为可见文本 | 分离到可折叠面板,跨 chunk 边界也能正确识别 |
|
|
43
|
+
| P0 | `<details>` / `<sub>` / `<sup>` / `<kbd>` | 转义为可见文本 | 安全 HTML 白名单子集 |
|
|
44
|
+
| P1 | ```` ```mermaid ```` 围栏 | 按源码高亮 | 围栏闭合后渲染图表,失败回退代码块(懒加载) |
|
|
45
|
+
| P1 | 化学式 `\ce{…}`、公式无障碍与复制 | KaTeX 错误回退;无复制 | mhchem、点击复制原始 LaTeX、MathML 输出支持 VoiceOver |
|
|
46
|
+
| P1 | 代码块易用性 | 仅横滚 + 复制 | 换行开关、行号、超长块折叠 |
|
|
47
|
+
| P1 | 图片策略 | 失败显示裂图 | 失败占位、懒加载、远程图片开关 |
|
|
48
|
+
| P1 | 数字引用 `[1]`、`【12†source】` | 字面文本 | 可点击引用 chip |
|
|
49
|
+
| P2 | 聊天列表规模化(SDK) | 每条消息一个 `WKWebView` | WebView 池 / 单 WebView 多消息 |
|
|
50
|
+
| P2 | 超长会话与生命周期 | 无基准;流只能 finish | 虚拟化、内存上限、性能基准套件、`abortStreaming()` |
|
|
51
|
+
|
|
52
|
+
### 生产就绪规格(商业 SDK)
|
|
53
|
+
|
|
54
|
+
语法覆盖只是商业 SDK 必须闭环的九个能力域之一。完整的审计规格 —— 每个域的
|
|
55
|
+
实测现状、优先级、验收标准以及 GA 阻塞检查单 —— 见
|
|
56
|
+
[docs/production-readiness-spec.md](docs/production-readiness-spec.md):
|
|
57
|
+
|
|
58
|
+
| 能力域 | 现状 | GA 阻塞项(举例) |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| 1. 输入健壮性 | ⚠️ | 体量上限、嵌套炸弹、Unicode/bidi 安全、fuzz |
|
|
61
|
+
| 2. 安全与隐私合规 | ⚠️ | 链接 scheme 风险信号(隐私清单与开源许可归集 ✅ 已交付) |
|
|
62
|
+
| 3. 平台与生命周期 | ⚠️ | 主题运行时切换(Web 进程回收恢复 ✅ 已交付) |
|
|
63
|
+
| 4. API 完整性与 DX | ⚠️ | 滚动控制、SwiftUI 自适应高度、运行时选项、SPM/Pods |
|
|
64
|
+
| 5. 渲染保真度与规范符合性 | ⚠️ | CommonMark/GFM 语料基线 + CI 门禁 |
|
|
65
|
+
| 6. 流式协议健壮性 | ⚠️ | 分块不变量属性测试、乱序调用语义 |
|
|
66
|
+
| 7. 性能与体积 | ⚠️ | 预算 + 基准套件 + 内存基线(包体 −56%、字体 −73% ✅) |
|
|
67
|
+
| 8. 质量工程与发布工程 | ❌ | CI 构建、签名 XCFramework、校验和、发版检查单 |
|
|
68
|
+
| 9. 授权与商业运营 | ⚠️ | 公钥构建期注入、密钥轮换、状态 API |
|
|
69
|
+
|
|
70
|
+
## 安装
|
|
71
|
+
|
|
72
|
+
### 从 npm 安装(推荐)
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm install @cellgit/markdown-render
|
|
76
|
+
# 或
|
|
77
|
+
yarn add @cellgit/markdown-render
|
|
78
|
+
# 或
|
|
79
|
+
pnpm add @cellgit/markdown-render
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 从源码构建
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
git clone <repository-url>
|
|
86
|
+
cd markdown-render
|
|
87
|
+
npm install
|
|
88
|
+
npm run build
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## 使用方法
|
|
92
|
+
|
|
93
|
+
### 在 npm 项目中使用
|
|
94
|
+
|
|
95
|
+
**React 示例:**
|
|
96
|
+
```jsx
|
|
97
|
+
import { renderMarkdown } from '@cellgit/markdown-render';
|
|
98
|
+
import '@cellgit/markdown-render/styles';
|
|
99
|
+
|
|
100
|
+
function App() {
|
|
101
|
+
const html = renderMarkdown('# Hello **React**!');
|
|
102
|
+
return <div dangerouslySetInnerHTML={{ __html: html }} />;
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Vue 示例:**
|
|
107
|
+
```vue
|
|
108
|
+
<template>
|
|
109
|
+
<div v-html="html"></div>
|
|
110
|
+
</template>
|
|
111
|
+
|
|
112
|
+
<script setup>
|
|
113
|
+
import { renderMarkdown } from '@cellgit/markdown-render';
|
|
114
|
+
import '@cellgit/markdown-render/styles';
|
|
115
|
+
|
|
116
|
+
const html = renderMarkdown('# Hello **Vue**!');
|
|
117
|
+
</script>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Vanilla JS:**
|
|
121
|
+
```javascript
|
|
122
|
+
import { renderMarkdown, setTheme } from '@cellgit/markdown-render';
|
|
123
|
+
import '@cellgit/markdown-render/styles';
|
|
124
|
+
|
|
125
|
+
setTheme('dark'); // 可选;传 null 恢复 prefers-color-scheme 行为
|
|
126
|
+
const html = renderMarkdown('# Hello **World**!', {
|
|
127
|
+
allowRawHTML: false, // 默认;仅对可信内容开启
|
|
128
|
+
math: true,
|
|
129
|
+
taskLists: true
|
|
130
|
+
});
|
|
131
|
+
document.getElementById('app').innerHTML = html;
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### 渲染选项
|
|
135
|
+
|
|
136
|
+
| 选项 | 默认值 | 说明 |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `allowRawHTML` | `false` | 保留 Markdown 源中的原始 HTML。**仅对可信内容开启。** |
|
|
139
|
+
| `linkify` | `true` | 自动识别裸 URL。 |
|
|
140
|
+
| `typographer` | `true` | 智能引号 / 破折号 / 省略号。 |
|
|
141
|
+
| `breaks` | `false` | 单个 `\n` 转换为 `<br>`。 |
|
|
142
|
+
| `taskLists` | `true` | `- [ ]` / `- [x]` 渲染。 |
|
|
143
|
+
| `emoji` | `true` | `:smile:` shortcode。 |
|
|
144
|
+
| `math` | `true` | 行内(`$…$`、`\(…\)`)与块级(`$$…$$`、`\[…\]`)KaTeX。 |
|
|
145
|
+
| `highlight` | hljs 默认 | 自定义高亮器 `(code, lang) => string`。 |
|
|
146
|
+
| `pipeline` | `['wrapTables', 'addCopyButton', 'handleLinks']` | 后处理器顺序。 |
|
|
147
|
+
| `openLinksInNewTab` | `false` | 为 `true` 时链接添加 `target="_blank"`。 |
|
|
148
|
+
|
|
149
|
+
📚 **完整文档:**
|
|
150
|
+
- [NPM 使用指南](NPM_USAGE.md) — 详细的 npm 使用说明
|
|
151
|
+
- [示例代码](examples/) — React、Vue、Vanilla JS 完整示例
|
|
152
|
+
|
|
153
|
+
### 在 iOS 项目中使用
|
|
154
|
+
|
|
155
|
+
专为 iOS 打包:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
npm run build:ios
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
这会在 `ios-bundle/` 目录生成适用于 iOS 的文件包。详细集成步骤请查看 [iOS 集成指南](ios-example/README.md)。
|
|
162
|
+
|
|
163
|
+
快速开始:
|
|
164
|
+
1. 运行 `npm run build:ios`
|
|
165
|
+
2. 将 `ios-bundle/` 中的文件添加到 Xcode 项目
|
|
166
|
+
3. 使用 `MarkdownViewController` 渲染 Markdown
|
|
167
|
+
|
|
168
|
+
> 需要自定义 WebView 模板?请编辑 `templates/markdown-render.html`,然后重新运行 `npm run build` 或 `npm run build:ios`,生成的 `dist/markdown-render.html` 会自动更新并被后续打包脚本使用。
|
|
169
|
+
|
|
170
|
+
```swift
|
|
171
|
+
let markdownVC = MarkdownViewController()
|
|
172
|
+
markdownVC.renderMarkdown("# Hello iOS\nThis is **bold** text!")
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
完整示例代码请参考 `ios-example/MarkdownViewController.swift`。商业 SDK 封装(加密资源 + 授权)见 `swift-markdown-kit` 仓库。
|
|
176
|
+
|
|
177
|
+
### 在 HTML 中使用
|
|
178
|
+
|
|
179
|
+
```html
|
|
180
|
+
<!DOCTYPE html>
|
|
181
|
+
<html>
|
|
182
|
+
<head>
|
|
183
|
+
<meta charset="UTF-8">
|
|
184
|
+
<title>Markdown Render Example</title>
|
|
185
|
+
</head>
|
|
186
|
+
<body>
|
|
187
|
+
<div id="output"></div>
|
|
188
|
+
|
|
189
|
+
<script src="dist/markdown-render.js"></script>
|
|
190
|
+
<script>
|
|
191
|
+
const markdown = `
|
|
192
|
+
# Hello World
|
|
193
|
+
|
|
194
|
+
This is **bold** and this is *italic*.
|
|
195
|
+
|
|
196
|
+
\`\`\`javascript
|
|
197
|
+
console.log("Hello, World!");
|
|
198
|
+
\`\`\`
|
|
199
|
+
|
|
200
|
+
Math: $E = mc^2$
|
|
201
|
+
`;
|
|
202
|
+
|
|
203
|
+
const html = MarkdownRender.renderMarkdown(markdown);
|
|
204
|
+
document.getElementById('output').innerHTML = html;
|
|
205
|
+
</script>
|
|
206
|
+
</body>
|
|
207
|
+
</html>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### 在 ES Module 中使用
|
|
211
|
+
|
|
212
|
+
```javascript
|
|
213
|
+
import { renderMarkdown } from './dist/markdown-render.js';
|
|
214
|
+
|
|
215
|
+
const html = renderMarkdown('# Hello World');
|
|
216
|
+
console.log(html);
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### 复制按钮事件绑定(浏览器宿主)
|
|
220
|
+
|
|
221
|
+
iOS WebView 宿主里 `scripts/copy.js` 已自动处理复制;纯浏览器环境下自行委托点击即可:
|
|
222
|
+
|
|
223
|
+
```javascript
|
|
224
|
+
document.addEventListener('click', (e) => {
|
|
225
|
+
const button = e.target.closest('.code-copy-button');
|
|
226
|
+
if (!button) return;
|
|
227
|
+
const code = button.closest('.code-block-wrapper')?.querySelector('code');
|
|
228
|
+
if (!code) return;
|
|
229
|
+
navigator.clipboard.writeText(code.textContent).then(() => {
|
|
230
|
+
const label = button.querySelector('.copy-text');
|
|
231
|
+
label.textContent = 'Copied!';
|
|
232
|
+
setTimeout(() => { label.textContent = 'Copy'; }, 2000);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Chat runtime
|
|
238
|
+
|
|
239
|
+
`templates/scripts/chat-renderer.js` 是建立在同一个单文档渲染器之上的上层 runtime。它把整段会话保存在一个容器中,流式过程中只增量更新当前消息:
|
|
240
|
+
|
|
241
|
+
```js
|
|
242
|
+
MarkdownChatRenderer.mount();
|
|
243
|
+
MarkdownChatRenderer.setMessages(messages);
|
|
244
|
+
MarkdownChatRenderer.appendMessage(message);
|
|
245
|
+
MarkdownChatRenderer.appendChunk(messageId, chunk, { isLast: false });
|
|
246
|
+
MarkdownChatRenderer.finishMessage(messageId);
|
|
247
|
+
MarkdownChatRenderer.scrollToBottom(true);
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
通过 `chat.messageActions` 开启后,会提供可访问的整条消息复制、重试和编辑操作。浏览器或原生宿主可以监听 `markdown-chat-action` 与 `markdown-chat-viewport` DOM 事件;后者会返回 `isNearBottom`、滚动偏移、内容高度和视口高度。
|
|
251
|
+
|
|
252
|
+
### 推理折叠
|
|
253
|
+
|
|
254
|
+
推理模型下发思维链只有两种形态,runtime 都能处理,宿主不需要区分:
|
|
255
|
+
|
|
256
|
+
```js
|
|
257
|
+
// 行内:`<think>` … `</think>` 混在正文流里
|
|
258
|
+
MarkdownChatRenderer.appendChunk(id, '<think>先把题目看清楚');
|
|
259
|
+
MarkdownChatRenderer.appendChunk(id, '</think>答案是 42。');
|
|
260
|
+
|
|
261
|
+
// 独立通道:服务商用单独的 SSE 字段下发推理
|
|
262
|
+
MarkdownChatRenderer.appendReasoningChunk(id, '先把题目看清楚');
|
|
263
|
+
MarkdownChatRenderer.appendChunk(id, '答案是 42。');
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
两种形态都会落到消息上方的折叠面板里:模型思考时保持展开,正文第一个字出现的瞬间收起成一行。读者自己点开或收起之后,自动收起不再覆盖这个选择。`finishReasoning(id)` 可以提前收起,`setReasoningVisible(id, expanded)` 由宿主控制展开状态,折叠状态变化会派发 `markdown-chat-reasoning` DOM 事件。
|
|
267
|
+
|
|
268
|
+
复制消息复制的是正文,不含推理内容。
|
|
269
|
+
|
|
270
|
+
```js
|
|
271
|
+
window.MarkdownWebViewConfig = {
|
|
272
|
+
chat: {
|
|
273
|
+
reasoning: {
|
|
274
|
+
enabled: true, // false 时标记保持为原文
|
|
275
|
+
inlineTags: true, // false 时只有 appendReasoningChunk 会写入面板
|
|
276
|
+
tags: ['think', 'thinking', 'thought', 'reasoning', 'reason'],
|
|
277
|
+
autoCollapse: true, // 正文开始时自动收起
|
|
278
|
+
defaultExpanded: false, // 历史消息重新加载时是否默认展开
|
|
279
|
+
labels: {
|
|
280
|
+
thinking: '思考中…',
|
|
281
|
+
done: '已深度思考',
|
|
282
|
+
duration: '已深度思考(用时 {seconds} 秒)'
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Chat runtime 之外,`renderMarkdown()` 会把完整的 `<think>` 块折叠成 `<details class="md-reasoning">`,配置结构与上面一致,写在 `reasoning` 渲染选项里,并且不需要打开 `allowRawHTML`。想自己分离两个通道的宿主可以直接用导出的 `splitReasoning(text)` 和 `createReasoningSplitter()`;后者按流式设计,`<thi` + `nk>` 分两次到达也能正确识别。
|
|
290
|
+
|
|
291
|
+
## 流式渲染与桥接事件契约
|
|
292
|
+
|
|
293
|
+
WebView 宿主(`templates/scripts/renderer.js`)暴露:
|
|
294
|
+
|
|
295
|
+
```js
|
|
296
|
+
window.renderMarkdown(text) // 整篇渲染(开启新的内容周期)
|
|
297
|
+
window.appendMarkdownChunk(chunk, { isLast }) // 流式追加(rAF 合并)
|
|
298
|
+
window.clearContent() // 清空(开启新的内容周期)
|
|
299
|
+
window.getContentHeight() // 同步测量高度
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
事件契约(经 `bridge.js` 回传原生宿主):
|
|
303
|
+
|
|
304
|
+
| 事件 | 触发时机 |
|
|
305
|
+
|---|---|
|
|
306
|
+
| `renderComplete` | 每个内容周期**恰好两次**:首帧上屏 + `isLast` 最终 flush。 |
|
|
307
|
+
| `contentHeightChanged` | 流式期间的中间 flush、图片加载、字号变化等高度变化(宿主侧防抖)。 |
|
|
308
|
+
|
|
309
|
+
增量流式(默认)把已完成的安全块边界之前的 HTML 缓存起来,只重渲染不稳定尾部,整条流的总渲染成本保持 O(n);`streaming.incremental = false` 可退回整篇重渲染。安全边界包括空行、代码块的收尾围栏、以及显示公式的收尾 `$$`——因此一段长代码或长公式一写完就离开活跃尾部,不必等到下一个空行之前每帧重新解析一次。
|
|
310
|
+
|
|
311
|
+
`renderMarkdownFragment(text, options)` 直接渲染成 `DocumentFragment` 而非 HTML 字符串。Chat runtime 走这条路径,流式消息每帧只解析一次,省去序列化再解析的往返。
|
|
312
|
+
|
|
313
|
+
## 非标准 Markdown
|
|
314
|
+
|
|
315
|
+
模型输出只是「大致符合」Markdown 规范,渲染器把这当作常态而非错误:
|
|
316
|
+
|
|
317
|
+
| 输入 | 渲染结果 |
|
|
318
|
+
|---|---|
|
|
319
|
+
| `包 $100,三脚架 $200。` | 纯文本——单个 `$` 不构成公式 |
|
|
320
|
+
| `公式:$x^{2$` | 原样显示 `$x^{2$`,颜色与正文一致 |
|
|
321
|
+
| `$\foobar{x}$` | 原样显示——未知宏不会被涂成红色 |
|
|
322
|
+
| `详见 \[1\]。` | 保持为一句话——引用不会被提升为块级公式 |
|
|
323
|
+
| `` `echo $PATH` `` 之后的 `$HOME` | 代码片段保留,`$HOME` 不受影响 |
|
|
324
|
+
|
|
325
|
+
解析失败的表达式以 `<span class="md-math-raw">` 输出,并保留原始定界符,因此内容不丢失、复制出去仍能还原。若失败发生在已定稿(非流式)的文本上,该 span 还会带上 `data-md-math-error`,记录 KaTeX 的原因——读者看不见,调试时有用。
|
|
326
|
+
|
|
327
|
+
## 主题系统(Token 化)
|
|
328
|
+
|
|
329
|
+
渲染核心内置一套 **token 化主题引擎**:命名预设 + 逐 token 覆盖,运行时即可切换(无需重新构建)。
|
|
330
|
+
|
|
331
|
+
### API
|
|
332
|
+
|
|
333
|
+
```javascript
|
|
334
|
+
import { applyTheme, setThemePreset, getThemePresets } from '@cellgit/markdown-render';
|
|
335
|
+
|
|
336
|
+
// 1) 切换命名预设
|
|
337
|
+
setThemePreset('github'); // 'system'(默认)| 'github'
|
|
338
|
+
|
|
339
|
+
// 2) 预设 + 强制深浅色 + 逐 token 覆盖
|
|
340
|
+
applyTheme({
|
|
341
|
+
preset: 'github',
|
|
342
|
+
mode: 'auto', // 'auto' | 'light' | 'dark'
|
|
343
|
+
tokens: {
|
|
344
|
+
light: { '--chat-link-color': '#0a7d55' },
|
|
345
|
+
dark: { '--chat-link-color': '#3fb950' },
|
|
346
|
+
metrics: { '--md-h1-size': '2.2em', '--md-code-radius': '8px' },
|
|
347
|
+
code: { light: { '--hljs-keyword': '#d73a49' } }
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
getThemePresets(); // ['system', 'github']
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
`applyTheme` 生成/更新一个 `<style id="md-theme-vars">`,其选择器与基础样式同构,因此覆盖在同优先级下生效,且 `mode:'auto'` 时仍跟随系统深浅色。未覆盖的 token 回退到 `styles.css` 的基线默认值。
|
|
355
|
+
|
|
356
|
+
### Token 三族
|
|
357
|
+
|
|
358
|
+
| 族 | 作用 | 代表 token | 按深浅色 |
|
|
359
|
+
|---|---|---|---|
|
|
360
|
+
| palette | 文本 / 链接 / 填充 / 表格 / 代码卡片配色、错误文本 | `--chat-text-color`、`--chat-link-color`、`--code-surface`、`--md-blockquote-border`、`--md-error-color` | 是(light/dark) |
|
|
361
|
+
| code | 代码高亮配色 | `--hljs-keyword`、`--hljs-string`、`--hljs-comment` | 是(light/dark) |
|
|
362
|
+
| metrics | 字号 / 行高 / 间距 / 圆角(相对字号) | `--md-h1-size`…`--md-h6-size`、`--md-body-line-height`、`--md-paragraph-margin`、`--md-list-indent`、`--md-code-radius` | 否(共享) |
|
|
363
|
+
|
|
364
|
+
完整 token 清单见 `src/theme.js`。布局类变量由宿主 API 设置:`--markdown-padding` / `--markdown-background` / `--markdown-bottom-gap` / `--markdown-font-size`(iOS 侧映射 Dynamic Type)。
|
|
365
|
+
|
|
366
|
+
### Config 契约(供 WebView 宿主 / iOS SDK)
|
|
367
|
+
|
|
368
|
+
WebView 宿主通过注入的全局配置驱动主题:
|
|
369
|
+
|
|
370
|
+
```js
|
|
371
|
+
window.MarkdownWebViewConfig = {
|
|
372
|
+
theme: {
|
|
373
|
+
mode: 'auto', // 'auto' | 'light' | 'dark'
|
|
374
|
+
preset: 'system', // 'system' | 'github'
|
|
375
|
+
tokens: { /* 同 applyTheme 的 tokens 形状,可选 */ }
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
`renderer.js` 会把该 `theme` 透传给 `applyTheme`。`swift-markdown-kit` 的 Swift `MarkdownTheme` 会序列化成上面这个 `theme` 结构。
|
|
381
|
+
|
|
382
|
+
## 扩展机制(自定义语法)
|
|
383
|
+
|
|
384
|
+
渲染核心支持**声明式行内扩展**:用配置(而非任意 JS)注册自定义语法 —— 安全(不破坏 CSP、值全部转义)、可主题化(`.md-ext-{name}`)、可回传动作。两种识别模式:
|
|
385
|
+
|
|
386
|
+
- **prefix**:触发串 + 受限字符体,如 `@alice`、`$AAPL`、`#tag`。
|
|
387
|
+
- **delimiter**:成对定界符捕获内部文本,如 `[[Home]]`、`||剧透||`。
|
|
388
|
+
|
|
389
|
+
```javascript
|
|
390
|
+
const extensions = [
|
|
391
|
+
{ name: 'mention', type: 'prefix', trigger: '@', body: 'word', action: 'mention' },
|
|
392
|
+
{ name: 'ticker', type: 'prefix', trigger: '$', body: 'upper', action: 'ticker', className: 'ticker' },
|
|
393
|
+
{ name: 'wikilink', type: 'delimiter', open: '[[', close: ']]', action: 'wikilink', display: '{value}' }
|
|
394
|
+
];
|
|
395
|
+
|
|
396
|
+
renderMarkdown('Hi @alice, buy $AAPL, see [[Home]]', { extensions });
|
|
397
|
+
// → <span class="md-ext md-ext-mention" data-md-ext="mention" data-md-value="alice" data-md-action="mention">@alice</span> …
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
| 字段 | 说明 |
|
|
401
|
+
|---|---|
|
|
402
|
+
| `name` | 扩展 id(决定 `md-ext-{name}` 类与 `data-md-ext`);清洗为 `[A-Za-z0-9_-]` |
|
|
403
|
+
| `type` | `'prefix'`(默认)或 `'delimiter'` |
|
|
404
|
+
| `trigger` | prefix:触发串(如 `@`) |
|
|
405
|
+
| `body` | prefix:体字符类 `word`/`alnum`/`letter`/`upper`/`upperdigit`,或显式允许字符串 |
|
|
406
|
+
| `open` / `close` | delimiter:定界符 |
|
|
407
|
+
| `action` | 可选;点击该元素回传的动作名(经宿主 bridge) |
|
|
408
|
+
| `display` | 可选;展示模板,`{value}` 占位(默认 prefix 显示「触发串+体」,delimiter 显示内部文本) |
|
|
409
|
+
| `className` / `dataset` | 可选;附加 class / `data-*`(键清洗、值转义) |
|
|
410
|
+
|
|
411
|
+
**安全**:值与展示一律 HTML 转义,标识符清洗,扫描有长度上限(无 ReDoS、无任意 HTML/JS 注入)。
|
|
412
|
+
|
|
413
|
+
**触发字符不受限**:markdown-it 的 `text` 规则只在「终结符」处停下;当扩展的首字符不是终结符(如 `||…||`、`/cmd`)时,核心会自动替换等价的 text 规则使其在正文中间也能命中,无需调用方做任何事。
|
|
414
|
+
|
|
415
|
+
### 每扩展样式钩子(可主题化)
|
|
416
|
+
|
|
417
|
+
`buildExtensionCSS(extensions)` 为每个扩展生成带回退值的 CSS 规则,`renderer.js` 会自动注入(`<style id="md-ext-vars">`)。宿主只需通过主题 token 覆盖变量即可改样式,无需注入任何 CSS:
|
|
418
|
+
|
|
419
|
+
```css
|
|
420
|
+
/* 每个扩展 {name} 可用: */
|
|
421
|
+
--md-ext-{name}-color /* 默认 var(--chat-link-color) */
|
|
422
|
+
--md-ext-{name}-bg /* 默认 transparent */
|
|
423
|
+
--md-ext-{name}-radius / -padding / -weight / -decoration
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### Config 契约(WebView 宿主 / iOS SDK)
|
|
427
|
+
|
|
428
|
+
```js
|
|
429
|
+
window.MarkdownWebViewConfig = { extensions: [ /* 同上数组 */ ] };
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
`renderer.js` 会把 `extensions` 透传给渲染;`swift-markdown-kit` 的 `MarkdownExtension` 序列化成该数组,点击带 `data-md-action` 的元素经 bridge 回传 Swift。
|
|
433
|
+
|
|
434
|
+
## Pipeline 机制
|
|
435
|
+
|
|
436
|
+
渲染后的 HTML 会经过一条可配置的后处理管线(解析 / 序列化各只做一次,添加处理器不会成倍增加开销)。内置处理器:`wrapTables`(表格横向滚动容器)、`addCopyButton`(代码块头部 + 复制按钮)、`handleLinks`(链接策略)。
|
|
437
|
+
|
|
438
|
+
自定义处理器:
|
|
439
|
+
|
|
440
|
+
```javascript
|
|
441
|
+
import { registerPipeline } from '@cellgit/markdown-render';
|
|
442
|
+
|
|
443
|
+
// 处理器直接修改 Document(不做字符串往返)
|
|
444
|
+
registerPipeline('addAnchor', (doc) => {
|
|
445
|
+
doc.querySelectorAll('h2').forEach((h) => h.setAttribute('id', h.textContent));
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
renderMarkdown(text, { pipeline: ['wrapTables', 'addCopyButton', 'handleLinks', 'addAnchor'] });
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
## 项目结构
|
|
452
|
+
|
|
453
|
+
```
|
|
454
|
+
markdown-render/
|
|
455
|
+
├── src/
|
|
456
|
+
│ ├── index.js # 主入口(公开 API)
|
|
457
|
+
│ ├── markdown.js # markdown-it 配置 + 数学规则
|
|
458
|
+
│ ├── highlight.js # 代码高亮(common 构建 + 自动检测策略)
|
|
459
|
+
│ ├── math.js # KaTeX 渲染与预处理
|
|
460
|
+
│ ├── extensions.js # 声明式行内扩展
|
|
461
|
+
│ ├── theme.js # Token 化主题引擎
|
|
462
|
+
│ ├── dom-utils.js # 流式 DOM diff
|
|
463
|
+
│ ├── styles.css # 基线样式
|
|
464
|
+
│ └── pipeline/ # HTML 后处理管线
|
|
465
|
+
├── templates/
|
|
466
|
+
│ ├── markdown-render.html # WebView 宿主页
|
|
467
|
+
│ └── scripts/ # bridge / renderer / height-sync / copy
|
|
468
|
+
├── dist/ # 构建产物
|
|
469
|
+
├── ios-bundle/ # build:ios 产物(swift-markdown-kit 输入)
|
|
470
|
+
└── tests/ # Node 测试套件
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
## 版本依赖
|
|
474
|
+
|
|
475
|
+
- **markdown-it**: ^14.1.0
|
|
476
|
+
- **highlight.js**: ^11.9.0(common 构建)
|
|
477
|
+
- **katex**: ^0.16.9
|
|
478
|
+
- **markdown-it-task-lists**: ^2.1.1
|
|
479
|
+
- **markdown-it-emoji**: ^3.0.0
|
|
480
|
+
|
|
481
|
+
## 构建
|
|
482
|
+
|
|
483
|
+
```bash
|
|
484
|
+
npm run build # dist/(浏览器 IIFE + ESM + CSS + fonts + templates)
|
|
485
|
+
npm run build:ios # 上一步 + 生成 ios-bundle/(swift-markdown-kit 打包时读取)
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
构建产物:`dist/markdown-render.js`(约 620KB,highlight.js common 构建)。
|
|
489
|
+
|
|
490
|
+
> **与 swift-markdown-kit 同步**:修改本仓库任何 `src/` 或 `templates/` 代码后,依次执行 `npm run build:ios` → `swift-markdown-kit/create_xcframework.sh`(加密 `ios-bundle/` 为 `MarkdownRenderPackage.dat` 并产出 XCFramework),详见 swift-markdown-kit 的 README。
|
|
491
|
+
|
|
492
|
+
## 测试
|
|
493
|
+
|
|
494
|
+
```bash
|
|
495
|
+
npm test
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
`tests/` 下的 Node 测试套件:
|
|
499
|
+
|
|
500
|
+
| 文件 | 覆盖内容 |
|
|
501
|
+
|---|---|
|
|
502
|
+
| `tests/render.test.mjs` | 数学边界、表格包装、链接策略、选项开关、原始 HTML 默认关闭、emoji、任务列表。 |
|
|
503
|
+
| `tests/streaming.test.mjs` | rAF 合并的增量流式;稳定 / 不稳定边界正确性;renderComplete / height 事件契约。 |
|
|
504
|
+
| `tests/extensions.test.mjs` | 声明式扩展:prefix / delimiter 识别、非终结符触发字符、转义与清洗、`buildExtensionCSS`。 |
|
|
505
|
+
| `tests/theme.test.mjs` | Token 化主题引擎:preset、覆盖优先级、样式元素复用。 |
|
|
506
|
+
| `tests/math-no-corruption.test.mjs` | 数学边界不破坏正文(货币、代码 span 等)。 |
|
|
507
|
+
|
|
508
|
+
在浏览器中打开 `test.html` 可进行可视化调试。
|
|
509
|
+
|
|
510
|
+
## 浏览器兼容性
|
|
511
|
+
|
|
512
|
+
- Chrome/Edge: ✅
|
|
513
|
+
- Safari: ✅
|
|
514
|
+
- Firefox: ✅
|
|
515
|
+
- iOS Safari: ✅
|
|
516
|
+
- Android Chrome: ✅
|
|
517
|
+
|
|
518
|
+
## License
|
|
519
|
+
|
|
520
|
+
MIT
|
|
521
|
+
|
|
522
|
+
## 致谢
|
|
523
|
+
|
|
524
|
+
本项目基于以下优秀开源项目:
|
|
525
|
+
|
|
526
|
+
- [markdown-it](https://github.com/markdown-it/markdown-it)
|
|
527
|
+
- [highlight.js](https://highlightjs.org/)
|
|
528
|
+
- [KaTeX](https://katex.org/)
|
|
529
|
+
- [markdown-it-task-lists](https://github.com/revin/markdown-it-task-lists)
|
|
530
|
+
- [markdown-it-emoji](https://github.com/markdown-it/markdown-it-emoji)
|