@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.md
CHANGED
|
@@ -1,34 +1,92 @@
|
|
|
1
|
-
# markdown-render
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
156
|
+
📚 **Full docs:**
|
|
157
|
+
- [NPM usage guide](NPM_USAGE.md)
|
|
158
|
+
- [Examples](examples/) — complete React, Vue, and Vanilla JS samples
|
|
83
159
|
|
|
84
|
-
|
|
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
|
-
|
|
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.
|
|
94
|
-
2.
|
|
95
|
-
3.
|
|
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
|
-
>
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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',
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
333
|
+
Event contract (delivered to the native host via `bridge.js`):
|
|
214
334
|
|
|
215
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
export function myProcessor(html) {
|
|
264
|
-
// 处理 html
|
|
265
|
-
return html;
|
|
266
|
-
}
|
|
508
|
+
import { registerPipeline } from '@cellgit/markdown-render';
|
|
267
509
|
|
|
268
|
-
//
|
|
269
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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 (
|
|
282
|
-
- **katex**: ^0.16.9
|
|
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
|
-
|
|
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
|
-
|
|
563
|
+
## Tests
|
|
297
564
|
|
|
298
|
-
|
|
565
|
+
```bash
|
|
566
|
+
npm test
|
|
567
|
+
```
|
|
299
568
|
|
|
300
|
-
|
|
569
|
+
Node-side test suites under `tests/`:
|
|
301
570
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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/)
|