@cellgit/markdown-render 0.1.0 → 1.0.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.
Files changed (57) hide show
  1. package/README.md +436 -178
  2. package/README.zh-CN.md +530 -0
  3. package/THIRD-PARTY-NOTICES.md +332 -0
  4. package/dist/markdown-render.css +3 -3
  5. package/dist/markdown-render.esm.css +3 -3
  6. package/dist/markdown-render.esm.js +1 -91452
  7. package/dist/markdown-render.html +20 -75
  8. package/dist/markdown-render.js +1 -91457
  9. package/dist/scripts/bridge.js +119 -0
  10. package/dist/scripts/chat-renderer.js +2770 -0
  11. package/dist/scripts/copy.js +155 -0
  12. package/dist/scripts/height-sync.js +205 -0
  13. package/dist/scripts/renderer.js +398 -0
  14. package/ios-example/MarkdownViewController.swift +123 -33
  15. package/ios-example/README.md +2 -0
  16. package/ios-example/SwiftUIMarkdownExample.swift +111 -0
  17. package/package.json +17 -9
  18. package/dist/fonts/KaTeX_AMS-Regular.ttf +0 -0
  19. package/dist/fonts/KaTeX_AMS-Regular.woff +0 -0
  20. package/dist/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  21. package/dist/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  22. package/dist/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  23. package/dist/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  24. package/dist/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  25. package/dist/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  26. package/dist/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  27. package/dist/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  28. package/dist/fonts/KaTeX_Main-Bold.ttf +0 -0
  29. package/dist/fonts/KaTeX_Main-Bold.woff +0 -0
  30. package/dist/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  31. package/dist/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  32. package/dist/fonts/KaTeX_Main-Italic.ttf +0 -0
  33. package/dist/fonts/KaTeX_Main-Italic.woff +0 -0
  34. package/dist/fonts/KaTeX_Main-Regular.ttf +0 -0
  35. package/dist/fonts/KaTeX_Main-Regular.woff +0 -0
  36. package/dist/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  37. package/dist/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  38. package/dist/fonts/KaTeX_Math-Italic.ttf +0 -0
  39. package/dist/fonts/KaTeX_Math-Italic.woff +0 -0
  40. package/dist/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  41. package/dist/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  42. package/dist/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  43. package/dist/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  44. package/dist/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  45. package/dist/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  46. package/dist/fonts/KaTeX_Script-Regular.ttf +0 -0
  47. package/dist/fonts/KaTeX_Script-Regular.woff +0 -0
  48. package/dist/fonts/KaTeX_Size1-Regular.ttf +0 -0
  49. package/dist/fonts/KaTeX_Size1-Regular.woff +0 -0
  50. package/dist/fonts/KaTeX_Size2-Regular.ttf +0 -0
  51. package/dist/fonts/KaTeX_Size2-Regular.woff +0 -0
  52. package/dist/fonts/KaTeX_Size3-Regular.ttf +0 -0
  53. package/dist/fonts/KaTeX_Size3-Regular.woff +0 -0
  54. package/dist/fonts/KaTeX_Size4-Regular.ttf +0 -0
  55. package/dist/fonts/KaTeX_Size4-Regular.woff +0 -0
  56. package/dist/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  57. package/dist/fonts/KaTeX_Typewriter-Regular.woff +0 -0
package/README.md CHANGED
@@ -1,34 +1,92 @@
1
- # markdown-render
2
-
3
- 一个功能完整的前端 Markdown 渲染库,基于 markdown-it,支持语法高亮、数学公式、任务列表、Emoji 等特性。完美兼容 WebView、Web、React Native 和 Electron。
4
-
5
- ## 特性
6
-
7
- **基于 markdown-it** - 使用业界标准的 markdown-it 解析器
8
- **语法高亮** - 内置 highlight.js,支持 180+ 编程语言
9
- ✅ **数学公式** - KaTeX 集成,支持行内和块级数学公式
10
- **任务列表** - 支持 `- [x]` 和 `- [ ]` 语法
11
- ✅ **嵌套列表** - 支持多层嵌套列表(有序/无序)
12
- ✅ **Emoji** - 支持 emoji shortcode,如 `:smile:` :smile:
13
- **表格** - 完整的 markdown 表格支持
14
- **代码块增强** - 带语言标签和复制按钮的代码块
15
- **主题支持** - 内置明暗主题自动切换
16
- ✅ **单文件输出** - 所有依赖打包在一个 JS 文件中
17
- **Pipeline 架构** - 可扩展的后处理机制
18
-
19
- ## 安装
20
-
21
- ### npm 安装(推荐)
1
+ # @cellgit/markdown-render
2
+
3
+ English | **[简体中文](README.zh-CN.md)**
4
+
5
+ Streaming-friendly Markdown renderer built on `markdown-it`. Supports KaTeX
6
+ math, GitHub-style code highlighting, task lists, emoji shortcodes, tables,
7
+ and dynamic-height rendering inside `WKWebView`. Tuned for AI chat
8
+ streams see `docs/render-core-fixes-v1.md` for the v1.0 design notes.
9
+
10
+ ## 📖 Resources
11
+
12
+ - [npm package page](https://www.npmjs.com/package/@cellgit/markdown-render)
13
+ - [markdown-render repository](https://github.com/cellgit/markdown-render)
14
+ - [iOS integration example](https://github.com/cellgit/MarkdownRenderDemo)
15
+ - [npm package test project](https://github.com/cellgit/markdown-render-npm-test)
16
+
17
+ ## Features
18
+
19
+ - ✅ **Built on markdown-it** — the industry-standard Markdown parser
20
+ - ✅ **Syntax highlighting** — bundled highlight.js (common build, ~40 mainstream languages incl. Swift/Kotlin/TS; unlabeled blocks auto-detect among 20 high-frequency languages, and blocks over 8KB render as plain text to keep streaming fast)
21
+ - **Math** — KaTeX integration, inline and block formulas
22
+ - ✅ **Tolerant of non-standard markdown** — a `$` next to a price, a bracketed citation, a formula whose closing brace has not streamed in yet: none of these turn red. Anything KaTeX cannot parse falls back to the author's own characters, in the surrounding text colour
23
+ - ✅ **Streaming** — rAF-coalesced flushes + incremental safe-boundary caching; a whole stream renders in O(n) total
24
+ - ✅ **Reasoning folds** — `<think>` … `</think>` is routed out of the answer into a collapsible panel that streams while the model thinks and folds to one line when the answer starts; providers that stream reasoning on a separate field feed the same panel directly
25
+ - ✅ **Task lists** — `- [x]` / `- [ ]` syntax
26
+ - ✅ **Nested lists** — multi-level ordered/unordered lists
27
+ - ✅ **Emoji** — shortcodes such as `:smile:`
28
+ - ✅ **Tables** — full Markdown table support with horizontal scroll on narrow viewports
29
+ - ✅ **Enhanced code blocks** — language label + copy button
30
+ - ✅ **Theming** — automatic light/dark switching plus a token-based theme engine
31
+ - ✅ **Declarative extensions** — register custom inline syntax by configuration (@mentions, [[wikilinks]], `||spoilers||`, …)
32
+ - ✅ **Single-file output** — all dependencies in one JS file (~620KB)
33
+ - ✅ **Pipeline architecture** — extensible HTML post-processing
34
+
35
+ ## Roadmap: LLM output coverage
36
+
37
+ Real LLM output is messier than well-formed Markdown. The patterns below were
38
+ **verified against the current renderer** and are not yet first-class; they are
39
+ the planned next tranche. Full evaluation, acceptance criteria, and suggested
40
+ order live in [docs/llm-output-roadmap.md](docs/llm-output-roadmap.md).
41
+
42
+ | priority | pattern | today | planned |
43
+ |---|---|---|---|
44
+ | P0 | GitHub alerts `> [!NOTE]` | plain blockquote, literal `[!NOTE]` | themable callout cards (5 types) |
45
+ | P0 | Footnotes `[^1]` | literal text | superscript chips + tap events (RAG citations) |
46
+ | P0 | Unclosed `**bold` / `$math` at the stream tail | raw markers flash, then jump | soft-close at the tail, restored on final flush; optional typing caret |
47
+ | P0 | YAML frontmatter | misrendered as `<hr>` + heading | stripped at document start |
48
+ | ✅ done | `<think>` / `<thinking>` reasoning | escaped to visible text | routed into a collapsible fold, streaming-safe across chunk boundaries |
49
+ | P0 | `<details>` / `<sub>` / `<sup>` / `<kbd>` | escaped to visible text | sanitized safe-HTML subset |
50
+ | P1 | ```` ```mermaid ```` fences | highlighted source code | diagram rendered on fence close, code fallback on error (lazy-loaded) |
51
+ | P1 | Chemistry `\ce{…}`, math a11y & copy | KaTeX error fallback; no copy | mhchem, tap-to-copy raw LaTeX, MathML output for VoiceOver |
52
+ | P1 | Code block ergonomics | scroll + copy only | wrap toggle, line numbers, collapse long blocks |
53
+ | P1 | Image policy | broken icon on failure | failure placeholder, lazy loading, remote-image switch |
54
+ | P1 | Numeric citations `[1]`, `【12†source】` | literal text | clickable citation chips |
55
+ | P2 | Chat-list scale (SDK) | one `WKWebView` per message | WebView pooling / single-WebView message list |
56
+ | P2 | Long sessions & lifecycle | no benchmarks; finish-only streams | virtualization, memory ceiling, benchmark suite, `abortStreaming()` |
57
+
58
+ ### Production-readiness spec (commercial SDK)
59
+
60
+ Syntax coverage is only one of nine capability domains a commercial SDK must
61
+ close. The full audited specification — every domain with verified current
62
+ status, priorities, and acceptance criteria, plus the GA blocking checklist —
63
+ lives in [docs/production-readiness-spec.md](docs/production-readiness-spec.md):
64
+
65
+ | domain | status | GA blockers (examples) |
66
+ |---|---|---|
67
+ | 1. Input robustness | ⚠️ | size limits, nesting bombs, Unicode/bidi safety, fuzzing |
68
+ | 2. Security & privacy compliance | ⚠️ | link-scheme risk signals (privacy manifest & OSS notices ✅ shipped) |
69
+ | 3. Platform & lifecycle | ⚠️ | runtime theme switching (web-process crash recovery ✅ shipped) |
70
+ | 4. API completeness & DX | ⚠️ | scroll control, SwiftUI self-sizing, runtime options, SPM/Pods |
71
+ | 5. Rendering fidelity & conformance | ⚠️ | CommonMark/GFM corpus baseline + CI gate |
72
+ | 6. Streaming protocol robustness | ⚠️ | chunking-invariant property tests, out-of-order call semantics |
73
+ | 7. Performance & footprint | ⚠️ | budgets + benchmark suite + memory baseline (bundle −56%, fonts −73% ✅) |
74
+ | 8. Quality & release engineering | ❌ | CI builds, signed XCFramework, checksums, release checklist |
75
+ | 9. Licensing & commercial ops | ⚠️ | build-time public-key injection, key rotation, status API |
76
+
77
+ ## Installation
78
+
79
+ ### From npm (recommended)
22
80
 
23
81
  ```bash
24
- npm install markdown-render
25
- #
26
- yarn add markdown-render
27
- #
28
- pnpm add markdown-render
82
+ npm install @cellgit/markdown-render
83
+ # or
84
+ yarn add @cellgit/markdown-render
85
+ # or
86
+ pnpm add @cellgit/markdown-render
29
87
  ```
30
88
 
31
- ### 从源码构建
89
+ ### From source
32
90
 
33
91
  ```bash
34
92
  git clone <repository-url>
@@ -37,14 +95,14 @@ npm install
37
95
  npm run build
38
96
  ```
39
97
 
40
- ## 使用方法
98
+ ## Usage
41
99
 
42
- ### npm 项目中使用
100
+ ### In an npm project
43
101
 
44
- **React 示例:**
102
+ **React:**
45
103
  ```jsx
46
- import { renderMarkdown } from 'markdown-render';
47
- import 'markdown-render/styles';
104
+ import { renderMarkdown } from '@cellgit/markdown-render';
105
+ import '@cellgit/markdown-render/styles';
48
106
 
49
107
  function App() {
50
108
  const html = renderMarkdown('# Hello **React**!');
@@ -52,58 +110,84 @@ function App() {
52
110
  }
53
111
  ```
54
112
 
55
- **Vue 示例:**
113
+ **Vue:**
56
114
  ```vue
57
115
  <template>
58
116
  <div v-html="html"></div>
59
117
  </template>
60
118
 
61
119
  <script setup>
62
- import { renderMarkdown } from 'markdown-render';
63
- import 'markdown-render/styles';
120
+ import { renderMarkdown } from '@cellgit/markdown-render';
121
+ import '@cellgit/markdown-render/styles';
64
122
 
65
123
  const html = renderMarkdown('# Hello **Vue**!');
66
124
  </script>
67
125
  ```
68
126
 
69
- **Vanilla JS:**
127
+ **Vanilla JS:**
70
128
  ```javascript
71
- import { renderMarkdown } from 'markdown-render';
72
- import 'markdown-render/styles';
73
-
74
- const html = renderMarkdown('# Hello **World**!');
129
+ import { renderMarkdown, setTheme } from '@cellgit/markdown-render';
130
+ import '@cellgit/markdown-render/styles';
131
+
132
+ setTheme('dark'); // optional; null restores prefers-color-scheme behaviour
133
+ const html = renderMarkdown('# Hello **World**!', {
134
+ allowRawHTML: false, // default; opt in only for trusted input
135
+ math: true,
136
+ taskLists: true
137
+ });
75
138
  document.getElementById('app').innerHTML = html;
76
139
  ```
77
140
 
78
- 📚 **完整文档:**
79
- - [NPM 使用指南](NPM_USAGE.md) - 详细的 npm 使用说明
80
- - [示例代码](examples/) - React、Vue、Vanilla JS 完整示例
141
+ ### Render options
142
+
143
+ | key | default | description |
144
+ |---|---|---|
145
+ | `allowRawHTML` | `false` | Preserve raw HTML in markdown source. **Enable only for trusted content.** |
146
+ | `linkify` | `true` | Auto-detect bare URLs. |
147
+ | `typographer` | `true` | Smart quotes / dashes / ellipses. |
148
+ | `breaks` | `false` | Convert single `\n` into `<br>`. |
149
+ | `taskLists` | `true` | `- [ ]` and `- [x]` rendering. |
150
+ | `emoji` | `true` | `:smile:` shortcodes. |
151
+ | `math` | `true` | Inline (`$…$`, `\(…\)`) and block (`$$…$$`, `\[…\]`) KaTeX. |
152
+ | `highlight` | hljs default | Custom highlighter `(code, lang) => string`. |
153
+ | `pipeline` | `['wrapTables', 'addCopyButton', 'handleLinks']` | Ordered processor names. |
154
+ | `openLinksInNewTab` | `false` | When `true`, links get `target="_blank"`. |
81
155
 
82
- ### iOS 项目中使用
156
+ 📚 **Full docs:**
157
+ - [NPM usage guide](NPM_USAGE.md)
158
+ - [Examples](examples/) — complete React, Vue, and Vanilla JS samples
83
159
 
84
- 专为 iOS 打包:
160
+ ### In an iOS project
161
+
162
+ Build the iOS package:
85
163
 
86
164
  ```bash
87
165
  npm run build:ios
88
166
  ```
89
167
 
90
- 这会在 `ios-bundle/` 目录生成适用于 iOS 的文件包。详细集成步骤请查看 [iOS 集成指南](ios-example/README.md)。
168
+ This produces an iOS-ready bundle under `ios-bundle/`. See the
169
+ [iOS integration guide](ios-example/README.md) for detailed steps.
91
170
 
92
- 快速开始:
93
- 1. 运行 `npm run build:ios`
94
- 2. `ios-bundle/` 中的文件添加到 Xcode 项目
95
- 3. 使用 `MarkdownViewController` 渲染 Markdown
171
+ Quick start:
172
+ 1. Run `npm run build:ios`
173
+ 2. Add the files from `ios-bundle/` to your Xcode project
174
+ 3. Render Markdown with `MarkdownViewController`
96
175
 
97
- > 需要自定义 WebView 模板?请编辑 `templates/markdown-render.html`,然后重新运行 `npm run build` 或 `npm run build:ios`,生成的 `dist/markdown-render.html` 会自动更新并被后续打包脚本使用。
176
+ > Need a custom WebView template? Edit `templates/markdown-render.html`, then
177
+ > re-run `npm run build` or `npm run build:ios` — the generated
178
+ > `dist/markdown-render.html` updates automatically and is picked up by the
179
+ > packaging scripts.
98
180
 
99
181
  ```swift
100
182
  let markdownVC = MarkdownViewController()
101
183
  markdownVC.renderMarkdown("# Hello iOS\nThis is **bold** text!")
102
184
  ```
103
185
 
104
- 完整示例代码请参考 `ios-example/MarkdownViewController.swift`。
186
+ See `ios-example/MarkdownViewController.swift` for the complete sample. For
187
+ the commercial SDK wrapper (encrypted resources + licensing), see the
188
+ `swift-markdown-kit` repository.
105
189
 
106
- ### HTML 中使用
190
+ ### In plain HTML
107
191
 
108
192
  ```html
109
193
  <!DOCTYPE html>
@@ -136,7 +220,7 @@ Math: $E = mc^2$
136
220
  </html>
137
221
  ```
138
222
 
139
- ### ES Module 中使用
223
+ ### As an ES module
140
224
 
141
225
  ```javascript
142
226
  import { renderMarkdown } from './dist/markdown-render.js';
@@ -145,182 +229,356 @@ const html = renderMarkdown('# Hello World');
145
229
  console.log(html);
146
230
  ```
147
231
 
148
- ### 添加复制代码功能
232
+ ### Wiring the copy button (browser hosts)
233
+
234
+ Inside the iOS WebView host, `scripts/copy.js` already handles copying. In a
235
+ plain browser, delegate the click yourself:
149
236
 
150
237
  ```javascript
151
- document.addEventListener('click', function(e) {
152
- if (e.target.classList.contains('code-copy-button') ||
153
- e.target.closest('.code-copy-button')) {
154
- const button = e.target.classList.contains('code-copy-button')
155
- ? e.target
156
- : e.target.closest('.code-copy-button');
157
-
158
- const wrapper = button.closest('.code-block-wrapper');
159
- if (!wrapper) return;
160
-
161
- const codeNode = wrapper.querySelector('code');
162
- const codeText = codeNode ? codeNode.textContent : '';
163
-
164
- navigator.clipboard.writeText(codeText).then(() => {
165
- const copyTextSpan = button.querySelector('.copy-text');
166
- copyTextSpan.textContent = 'Copied!';
167
- setTimeout(() => {
168
- copyTextSpan.textContent = 'Copy code';
169
- }, 2000);
170
- });
171
- }
238
+ document.addEventListener('click', (e) => {
239
+ const button = e.target.closest('.code-copy-button');
240
+ if (!button) return;
241
+ const code = button.closest('.code-block-wrapper')?.querySelector('code');
242
+ if (!code) return;
243
+ navigator.clipboard.writeText(code.textContent).then(() => {
244
+ const label = button.querySelector('.copy-text');
245
+ label.textContent = 'Copied!';
246
+ setTimeout(() => { label.textContent = 'Copy'; }, 2000);
247
+ });
172
248
  });
173
249
  ```
174
250
 
175
- ## 功能示例
251
+ ## Chat runtime
176
252
 
177
- ### 代码块
253
+ `templates/scripts/chat-renderer.js` is an upper runtime built on the same
254
+ single-document renderer. It keeps a whole conversation in one container and
255
+ incrementally updates only the active message:
178
256
 
179
- \`\`\`javascript
180
- function hello() {
181
- console.log("Hello, World!");
182
- }
183
- \`\`\`
257
+ ```js
258
+ MarkdownChatRenderer.mount();
259
+ MarkdownChatRenderer.setMessages(messages);
260
+ MarkdownChatRenderer.appendMessage(message);
261
+ MarkdownChatRenderer.appendChunk(messageId, chunk, { isLast: false });
262
+ MarkdownChatRenderer.finishMessage(messageId);
263
+ MarkdownChatRenderer.scrollToBottom(true);
264
+ ```
184
265
 
185
- ### 数学公式
266
+ When enabled through `chat.messageActions`, it renders accessible whole-message
267
+ copy/retry/edit controls. Browser and native hosts can listen for
268
+ `markdown-chat-action` and `markdown-chat-viewport` DOM events. The latter
269
+ reports `isNearBottom`, scroll offset, content height, and viewport height.
186
270
 
187
- 行内数学:$E = mc^2$
271
+ ### Reasoning
188
272
 
189
- 块级数学:
273
+ A reasoning model publishes its chain of thought in one of two shapes, and the
274
+ runtime handles both without the host having to know which:
190
275
 
191
- $$
192
- \\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}
193
- $$
276
+ ```js
277
+ // Inline: `<think>` `</think>` arrives inside the content stream.
278
+ MarkdownChatRenderer.appendChunk(id, '<think>weighing the options');
279
+ MarkdownChatRenderer.appendChunk(id, '</think>The answer is 42.');
194
280
 
195
- ### 任务列表
281
+ // Channel: the provider streams reasoning in its own SSE field.
282
+ MarkdownChatRenderer.appendReasoningChunk(id, 'weighing the options');
283
+ MarkdownChatRenderer.appendChunk(id, 'The answer is 42.');
284
+ ```
196
285
 
197
- - [x] 已完成的任务
198
- - [ ] 未完成的任务
286
+ Either way the reasoning lands in a fold above the message: open while the model
287
+ is thinking, collapsed to a single tappable line the moment the first answer
288
+ text arrives. A reader who opens or closes the fold themselves is never
289
+ overruled afterwards. `finishReasoning(id)` closes it early;
290
+ `setReasoningVisible(id, expanded)` drives it from the host; toggles are
291
+ published as a `markdown-chat-reasoning` DOM event.
292
+
293
+ Copying a message copies the answer, not the reasoning.
294
+
295
+ ```js
296
+ window.MarkdownWebViewConfig = {
297
+ chat: {
298
+ reasoning: {
299
+ enabled: true, // false leaves markers as literal text
300
+ inlineTags: true, // false: only appendReasoningChunk feeds the fold
301
+ tags: ['think', 'thinking', 'thought', 'reasoning', 'reason'],
302
+ autoCollapse: true, // fold away when the answer begins
303
+ defaultExpanded: false, // for finished messages loaded from history
304
+ labels: {
305
+ thinking: 'Thinking…',
306
+ done: 'Thought process',
307
+ duration: 'Thought for {seconds}s'
308
+ }
309
+ }
310
+ }
311
+ };
312
+ ```
199
313
 
200
- ### 嵌套列表
314
+ Outside the chat runtime, `renderMarkdown()` folds a complete `<think>` block
315
+ into a `<details class="md-reasoning">` element — configured through the same
316
+ shape under the `reasoning` render option, and available without turning on
317
+ `allowRawHTML`. `splitReasoning(text)` and `createReasoningSplitter()` are
318
+ exported for hosts that want to separate the two channels themselves; the
319
+ splitter is streaming-safe, so a marker arriving as `<thi` + `nk>` is still
320
+ recognised.
201
321
 
202
- - 第一层
203
- - 第二层
204
- - 第三层
322
+ ## Streaming & bridge event contract
205
323
 
206
- ### 表格
324
+ The WebView host (`templates/scripts/renderer.js`) exposes:
207
325
 
208
- | 列1 | 列2 | 列3 |
209
- |-----|-----|-----|
210
- | A | B | C |
211
- | 1 | 2 | 3 |
326
+ ```js
327
+ window.renderMarkdown(text) // full render (starts a new content cycle)
328
+ window.appendMarkdownChunk(chunk, { isLast }) // streaming append (rAF-coalesced)
329
+ window.clearContent() // clear (starts a new content cycle)
330
+ window.getContentHeight() // synchronous height measurement
331
+ ```
212
332
 
213
- ### Emoji
333
+ Event contract (delivered to the native host via `bridge.js`):
214
334
 
215
- 支持 emoji shortcode::smile: :heart: :rocket:
335
+ | event | when it fires |
336
+ |---|---|
337
+ | `renderComplete` | **Exactly twice** per content cycle: first paint + the final (`isLast`) flush. |
338
+ | `contentHeightChanged` | Height changes in between — streaming flushes, image loads, font-size changes (debounced host-side). |
216
339
 
217
- ## 项目结构
340
+ Incremental streaming (the default) caches HTML up to the last completed safe
341
+ block boundary and re-renders only the unstable tail, keeping total render
342
+ cost O(n) across the whole stream; set `streaming.incremental = false` to fall
343
+ back to full re-renders. A safe boundary is a blank line, the closing fence of
344
+ a code block, or the closing `$$` of a display formula — so a long code block
345
+ or formula leaves the live tail as soon as it is complete instead of being
346
+ reparsed on every frame until the next blank line.
218
347
 
348
+ `renderMarkdownFragment(text, options)` renders straight to a `DocumentFragment`
349
+ instead of an HTML string. The chat runtime uses it so that a streamed message
350
+ is parsed once per frame rather than being serialized and parsed back again.
351
+
352
+ ## Malformed markdown
353
+
354
+ Model output is only approximately standard markdown, and the renderer treats
355
+ that as normal rather than as an error:
356
+
357
+ | input | rendered as |
358
+ |---|---|
359
+ | `The bag is $100 and the tripod is $200.` | plain text — a lone `$` is not a formula |
360
+ | `Formula: $x^{2$` | the literal `$x^{2$`, in the surrounding text colour |
361
+ | `$\foobar{x}$` | the literal source — an unknown macro is not painted red |
362
+ | `See \[1\] for details.` | one sentence — a citation is not promoted to display maths |
363
+ | `` `echo $PATH` `` then `$HOME` | code span preserved, `$HOME` left alone |
364
+
365
+ An expression that fails to parse is emitted as `<span class="md-math-raw">`
366
+ carrying the original delimiters, so nothing is lost and copying the text back
367
+ out still round-trips. When the parse failed on settled (non-streaming) text
368
+ the span also carries `data-md-math-error` with KaTeX's reason, which is
369
+ invisible to the reader and useful in a debugger.
370
+
371
+ ## Theme system (token-based)
372
+
373
+ The render core ships a **token-based theme engine**: a named preset plus
374
+ per-token overrides, switchable at runtime (no rebuild required).
375
+
376
+ ### API
377
+
378
+ ```javascript
379
+ import { applyTheme, setThemePreset, getThemePresets } from '@cellgit/markdown-render';
380
+
381
+ // 1) Switch to a named preset
382
+ setThemePreset('github'); // 'system' (default) | 'github'
383
+
384
+ // 2) Preset + forced color scheme + per-token overrides
385
+ applyTheme({
386
+ preset: 'github',
387
+ mode: 'auto', // 'auto' | 'light' | 'dark'
388
+ tokens: {
389
+ light: { '--chat-link-color': '#0a7d55' },
390
+ dark: { '--chat-link-color': '#3fb950' },
391
+ metrics: { '--md-h1-size': '2.2em', '--md-code-radius': '8px' },
392
+ code: { light: { '--hljs-keyword': '#d73a49' } }
393
+ }
394
+ });
395
+
396
+ getThemePresets(); // ['system', 'github']
219
397
  ```
220
- markdown-render/
221
- ├── src/
222
- │ ├── index.js # 主入口
223
- │ ├── markdown.js # markdown-it 配置
224
- │ ├── highlight.js # 代码高亮
225
- │ ├── math.js # 数学公式渲染
226
- │ ├── utils.js # 工具函数
227
- │ ├── styles.css # 样式表
228
- │ └── pipeline/ # Pipeline 后处理
229
- │ ├── index.js # Pipeline 编排
230
- │ ├── copy-button.js # 复制按钮
231
- │ └── link-handler.js # 链接处理
232
- ├── dist/
233
- │ └── markdown-render.js # 构建产物
234
- ├── package.json
235
- ├── rollup.config.js
236
- └── test.html # 测试页面
398
+
399
+ `applyTheme` creates/updates a single `<style id="md-theme-vars">` whose
400
+ selectors mirror the base stylesheet, so overrides win on equal specificity
401
+ and `mode:'auto'` still follows the system color scheme. Tokens you don't
402
+ override fall back to the baseline defaults in `styles.css`.
403
+
404
+ ### The three token families
405
+
406
+ | family | purpose | representative tokens | per-scheme |
407
+ |---|---|---|---|
408
+ | palette | text / links / fills / tables / code card colors, error text | `--chat-text-color`, `--chat-link-color`, `--code-surface`, `--md-blockquote-border`, `--md-error-color` | yes (light/dark) |
409
+ | code | syntax-highlight colors | `--hljs-keyword`, `--hljs-string`, `--hljs-comment` | yes (light/dark) |
410
+ | metrics | sizes / line-heights / spacing / radii (relative to base font) | `--md-h1-size`…`--md-h6-size`, `--md-body-line-height`, `--md-paragraph-margin`, `--md-list-indent`, `--md-code-radius` | no (shared) |
411
+
412
+ See `src/theme.js` for the complete token list. Layout variables are set via
413
+ host APIs: `--markdown-padding` / `--markdown-background` /
414
+ `--markdown-bottom-gap` / `--markdown-font-size` (mapped from iOS Dynamic
415
+ Type by the SDK).
416
+
417
+ ### Config contract (WebView hosts / iOS SDK)
418
+
419
+ WebView hosts drive the theme through the injected global config:
420
+
421
+ ```js
422
+ window.MarkdownWebViewConfig = {
423
+ theme: {
424
+ mode: 'auto', // 'auto' | 'light' | 'dark'
425
+ preset: 'system', // 'system' | 'github'
426
+ tokens: { /* same shape as applyTheme's tokens, optional */ }
427
+ }
428
+ };
237
429
  ```
238
430
 
239
- ## Pipeline 机制
431
+ `renderer.js` passes that `theme` straight to `applyTheme`. The Swift
432
+ `MarkdownTheme` in `swift-markdown-kit` serializes into exactly this shape.
433
+
434
+ ## Extensions (custom inline syntax)
240
435
 
241
- 本库采用 Pipeline 架构,可以轻松扩展后处理功能:
436
+ The render core supports **declarative inline extensions**: custom syntax
437
+ registered by configuration, never arbitrary JS — safe (CSP-friendly, all
438
+ values escaped), themable (`.md-ext-{name}`), and tappable. Two recognition
439
+ modes:
440
+
441
+ - **prefix**: a trigger string plus a bounded body, e.g. `@alice`, `$AAPL`, `#tag`.
442
+ - **delimiter**: paired delimiters capturing inner text, e.g. `[[Home]]`, `||spoiler||`.
242
443
 
243
444
  ```javascript
244
- // src/pipeline/index.js
245
- const pipelines = [
246
- addCopyButton, // 添加复制按钮
247
- handleLinks // 处理链接
445
+ const extensions = [
446
+ { name: 'mention', type: 'prefix', trigger: '@', body: 'word', action: 'mention' },
447
+ { name: 'ticker', type: 'prefix', trigger: '$', body: 'upper', action: 'ticker', className: 'ticker' },
448
+ { name: 'wikilink', type: 'delimiter', open: '[[', close: ']]', action: 'wikilink', display: '{value}' }
248
449
  ];
249
450
 
250
- export function applyPipeline(html) {
251
- return pipelines.reduce((currentHtml, processor) => {
252
- return processor(currentHtml);
253
- }, html);
254
- }
451
+ renderMarkdown('Hi @alice, buy $AAPL, see [[Home]]', { extensions });
452
+ // <span class="md-ext md-ext-mention" data-md-ext="mention" data-md-value="alice" data-md-action="mention">@alice</span> …
255
453
  ```
256
454
 
257
- ## 自定义 Pipeline
455
+ | field | description |
456
+ |---|---|
457
+ | `name` | Extension id (drives the `md-ext-{name}` class and `data-md-ext`); sanitized to `[A-Za-z0-9_-]` |
458
+ | `type` | `'prefix'` (default) or `'delimiter'` |
459
+ | `trigger` | prefix: the trigger string (e.g. `@`) |
460
+ | `body` | prefix: body character class `word`/`alnum`/`letter`/`upper`/`upperdigit`, or an explicit allowed-character string |
461
+ | `open` / `close` | delimiter: the delimiters |
462
+ | `action` | optional; action name reported on tap (via the host bridge) |
463
+ | `display` | optional; display template with a `{value}` placeholder (default: prefix shows trigger+body, delimiter shows inner text) |
464
+ | `className` / `dataset` | optional; extra class / `data-*` attributes (keys sanitized, values escaped) |
465
+
466
+ **Safety**: values and display text are always HTML-escaped, identifiers are
467
+ sanitized, and scanning is length-capped (no ReDoS, no HTML/JS injection).
468
+
469
+ **Any trigger character works**: markdown-it's `text` rule only stops at
470
+ "terminator" characters. When an extension's first character is not one of
471
+ them (e.g. `||…||`, `/cmd`), the core automatically swaps in an equivalent
472
+ text rule so the extension also matches mid-text — no caller action needed.
473
+
474
+ ### Per-extension style hooks (themable)
475
+
476
+ `buildExtensionCSS(extensions)` generates CSS rules with fallback values for
477
+ each extension, injected automatically by `renderer.js`
478
+ (`<style id="md-ext-vars">`). Hosts restyle extensions purely through theme
479
+ tokens — no CSS crosses the bridge:
480
+
481
+ ```css
482
+ /* available for each extension {name}: */
483
+ --md-ext-{name}-color /* default var(--chat-link-color) */
484
+ --md-ext-{name}-bg /* default transparent */
485
+ --md-ext-{name}-radius / -padding / -weight / -decoration
486
+ ```
487
+
488
+ ### Config contract (WebView hosts / iOS SDK)
489
+
490
+ ```js
491
+ window.MarkdownWebViewConfig = { extensions: [ /* same array as above */ ] };
492
+ ```
493
+
494
+ `renderer.js` passes `extensions` through to the renderer; `MarkdownExtension`
495
+ in `swift-markdown-kit` serializes into this array, and taps on elements with
496
+ `data-md-action` are reported back to Swift over the bridge.
497
+
498
+ ## Pipeline
258
499
 
259
- 你可以添加自己的 pipeline 处理器:
500
+ Rendered HTML flows through a configurable post-processing pipeline (one
501
+ parse / one serialize regardless of processor count). Built-ins:
502
+ `wrapTables` (horizontal-scroll containers), `addCopyButton` (code block
503
+ header + copy button), `handleLinks` (link policy).
504
+
505
+ Custom processors:
260
506
 
261
507
  ```javascript
262
- // src/pipeline/my-processor.js
263
- export function myProcessor(html) {
264
- // 处理 html
265
- return html;
266
- }
508
+ import { registerPipeline } from '@cellgit/markdown-render';
267
509
 
268
- // src/pipeline/index.js 中添加
269
- import { myProcessor } from './my-processor.js';
510
+ // Processors mutate the Document in place (no string round-trips)
511
+ registerPipeline('addAnchor', (doc) => {
512
+ doc.querySelectorAll('h2').forEach((h) => h.setAttribute('id', h.textContent));
513
+ });
270
514
 
271
- const pipelines = [
272
- addCopyButton,
273
- handleLinks,
274
- myProcessor // 添加你的处理器
275
- ];
515
+ renderMarkdown(text, { pipeline: ['wrapTables', 'addCopyButton', 'handleLinks', 'addAnchor'] });
516
+ ```
517
+
518
+ ## Project structure
519
+
520
+ ```
521
+ markdown-render/
522
+ ├── src/
523
+ │ ├── index.js # main entry (public API)
524
+ │ ├── markdown.js # markdown-it setup + math rules
525
+ │ ├── highlight.js # code highlighting (common build + auto-detect policy)
526
+ │ ├── math.js # KaTeX rendering & preprocessing
527
+ │ ├── extensions.js # declarative inline extensions
528
+ │ ├── theme.js # token-based theme engine
529
+ │ ├── dom-utils.js # streaming DOM diff
530
+ │ ├── styles.css # baseline styles
531
+ │ └── pipeline/ # HTML post-processing pipeline
532
+ ├── templates/
533
+ │ ├── markdown-render.html # WebView host page
534
+ │ └── scripts/ # bridge / renderer / height-sync / copy
535
+ ├── dist/ # build output
536
+ ├── ios-bundle/ # build:ios output (input to swift-markdown-kit)
537
+ └── tests/ # Node test suites
276
538
  ```
277
539
 
278
- ## 版本依赖
540
+ ## Dependencies
279
541
 
280
542
  - **markdown-it**: ^14.1.0
281
- - **highlight.js**: ^11.9.0 (匹配 GPTDemo)
282
- - **katex**: ^0.16.9 (匹配 GPTDemo)
543
+ - **highlight.js**: ^11.9.0 (common build)
544
+ - **katex**: ^0.16.9
283
545
  - **markdown-it-task-lists**: ^2.1.1
284
546
  - **markdown-it-emoji**: ^3.0.0
285
547
 
286
- ## 构建
548
+ ## Build
287
549
 
288
550
  ```bash
289
- npm run build
551
+ npm run build # dist/ (browser IIFE + ESM + CSS + fonts + templates)
552
+ npm run build:ios # the above + ios-bundle/ (consumed by swift-markdown-kit)
290
553
  ```
291
554
 
292
- 构建产物:`dist/markdown-render.js` ( 1.3MB)
555
+ Output: `dist/markdown-render.js` (~620KB, highlight.js common build).
293
556
 
294
- ## 测试
557
+ > **Syncing with swift-markdown-kit**: after changing anything under `src/` or
558
+ > `templates/`, run `npm run build:ios` →
559
+ > `swift-markdown-kit/create_xcframework.sh` (encrypts `ios-bundle/` into
560
+ > `MarkdownRenderPackage.dat` and produces the XCFramework). See the
561
+ > swift-markdown-kit README for details.
295
562
 
296
- 在浏览器中打开 `test.html` 查看所有功能演示。
563
+ ## Tests
297
564
 
298
- ## CSS 变量
565
+ ```bash
566
+ npm test
567
+ ```
299
568
 
300
- 可通过 CSS 变量自定义主题:
569
+ Node-side test suites under `tests/`:
301
570
 
302
- ```css
303
- :root {
304
- --chat-text-color: #1C1C1E;
305
- --chat-link-color: #0A5EFF;
306
- --chat-background: #FFFFFF;
307
- --chat-code-bg: var(--chat-background);
308
- --chat-inline-code-bg: rgba(175, 184, 193, 0.2);
309
- --chat-table-border: #D1D5DB;
310
- --chat-copy-button-bg: rgba(0, 0, 0, 0.65);
311
- }
571
+ | file | what it covers |
572
+ |---|---|
573
+ | `tests/render.test.mjs` | Math boundary checks, table wrapping, link policy, options gating, raw HTML default, emoji, task lists. |
574
+ | `tests/streaming.test.mjs` | rAF-coalesced incremental streaming; stable / unstable boundary correctness; the renderComplete / height event contract. |
575
+ | `tests/extensions.test.mjs` | Declarative extensions: prefix / delimiter recognition, non-terminator trigger chars, escaping & sanitizing, `buildExtensionCSS`. |
576
+ | `tests/theme.test.mjs` | Token-based theme engine: presets, override precedence, style element reuse. |
577
+ | `tests/math-no-corruption.test.mjs` | Math boundaries never corrupt prose (currency, code spans, …). |
312
578
 
313
- @media (prefers-color-scheme: dark) {
314
- :root {
315
- --chat-text-color: #ECECEC;
316
- --chat-link-color: #0A84FF;
317
- --chat-background: #1C1C1E;
318
- /* ... */
319
- }
320
- }
321
- ```
579
+ Open `test.html` in a browser for the visual sandbox.
322
580
 
323
- ## 浏览器兼容性
581
+ ## Browser compatibility
324
582
 
325
583
  - Chrome/Edge: ✅
326
584
  - Safari: ✅
@@ -332,9 +590,9 @@ npm run build
332
590
 
333
591
  MIT
334
592
 
335
- ## 致谢
593
+ ## Acknowledgements
336
594
 
337
- 本项目基于以下优秀开源项目:
595
+ Built on these excellent open-source projects:
338
596
 
339
597
  - [markdown-it](https://github.com/markdown-it/markdown-it)
340
598
  - [highlight.js](https://highlightjs.org/)