@cellgit/markdown-render
v1.1.0
Published
Streaming-friendly markdown renderer with KaTeX math, code highlighting, and iOS-ready WebView bundle.
Downloads
396
Maintainers
Readme
@cellgit/markdown-render
English | 简体中文
基于 markdown-it 的流式友好 Markdown 渲染器。支持 KaTeX 数学公式、GitHub 风格代码高亮、任务列表、Emoji shortcode、表格,以及 WKWebView 内的动态高度渲染。专为 AI Chat 流式输出调优 —— v1.0 设计笔记见 docs/render-core-fixes-v1.md。
📖 相关资源
特性
- ✅ 基于 markdown-it — 使用业界标准的 markdown-it 解析器
- ✅ 语法高亮 — 内置 highlight.js(common 构建,约 40 种主流语言,含 Swift/Kotlin/TS 等;无语言标签时在 20 种高频语言内自动检测,超过 8KB 的代码块直接按纯文本渲染以保证流式性能)
- ✅ 数学公式 — KaTeX 集成,支持行内和块级数学公式
- ✅ 非标准 Markdown 容错 — 价格里的
$、方括号引用、公式尚未流完的右花括号,都不会变红;KaTeX 解析不了的内容退回作者原文,颜色与正文一致 - ✅ 流式渲染 — rAF 合并 + 增量安全边界缓存,整条流总成本 O(n)
- ✅ 推理折叠 —
<think>…</think>会从正文中分离到独立折叠面板:模型思考时展开并逐字流出,正文一开始就收起成一行;单独用字段下发推理的服务商可以直接喂同一个面板 - ✅ 任务列表 — 支持
- [x]和- [ ]语法 - ✅ 嵌套列表 — 支持多层嵌套列表(有序/无序)
- ✅ Emoji — 支持 emoji shortcode,如
:smile: - ✅ 表格 — 完整的 markdown 表格支持,窄屏自动横向滚动
- ✅ 代码块增强 — 带语言标签和复制按钮的代码块
- ✅ 主题支持 — 内置明暗主题自动切换 + token 化主题引擎
- ✅ 声明式扩展 — 用配置注册自定义行内语法(@提及、[[wiki]]、
||剧透||等) - ✅ 单文件输出 — 所有依赖打包在一个 JS 文件中(约 620KB)
- ✅ Pipeline 架构 — 可扩展的后处理机制
Roadmap:LLM 输出覆盖
真实 LLM 输出比规范 Markdown 更「脏」。下表列出的模式均已对当前渲染器实测验证,尚未一等公民支持,属于计划中的下一批能力。完整评估、验收标准与建议实施顺序见 docs/llm-output-roadmap.md。
| 优先级 | 模式 | 现状 | 计划 |
|---|---|---|---|
| P0 | GitHub Alerts > [!NOTE] | 普通引用块,[!NOTE] 字面可见 | 可主题化 callout 卡片(5 种类型) |
| P0 | 脚注 [^1] | 字面文本 | 上标角标 + 点击事件(RAG 引用场景) |
| P0 | 流尾未闭合 **加粗 / $公式 | 裸标记闪现,闭合瞬间跳变 | 尾部软收口,终帧恢复语义;可选打字光标 |
| P0 | YAML frontmatter | 误渲染为 <hr> + 标题 | 文档首部静默剥离 |
| ✅ 已完成 | <think> / <thinking> 推理内容 | 转义为可见文本 | 分离到可折叠面板,跨 chunk 边界也能正确识别 |
| P0 | <details> / <sub> / <sup> / <kbd> | 转义为可见文本 | 安全 HTML 白名单子集 |
| P1 | ```mermaid 围栏 | 按源码高亮 | 围栏闭合后渲染图表,失败回退代码块(懒加载) |
| P1 | 化学式 \ce{…}、公式无障碍与复制 | KaTeX 错误回退;无复制 | mhchem、点击复制原始 LaTeX、MathML 输出支持 VoiceOver |
| P1 | 代码块易用性 | 仅横滚 + 复制 | 换行开关、行号、超长块折叠 |
| P1 | 图片策略 | 失败显示裂图 | 失败占位、懒加载、远程图片开关 |
| P1 | 数字引用 [1]、【12†source】 | 字面文本 | 可点击引用 chip |
| P2 | 聊天列表规模化(SDK) | 每条消息一个 WKWebView | WebView 池 / 单 WebView 多消息 |
| P2 | 超长会话与生命周期 | 无基准;流只能 finish | 虚拟化、内存上限、性能基准套件、abortStreaming() |
生产就绪规格(商业 SDK)
语法覆盖只是商业 SDK 必须闭环的九个能力域之一。完整的审计规格 —— 每个域的 实测现状、优先级、验收标准以及 GA 阻塞检查单 —— 见 docs/production-readiness-spec.md:
| 能力域 | 现状 | GA 阻塞项(举例) | |---|---|---| | 1. 输入健壮性 | ⚠️ | 体量上限、嵌套炸弹、Unicode/bidi 安全、fuzz | | 2. 安全与隐私合规 | ⚠️ | 链接 scheme 风险信号(隐私清单与开源许可归集 ✅ 已交付) | | 3. 平台与生命周期 | ⚠️ | 主题运行时切换(Web 进程回收恢复 ✅ 已交付) | | 4. API 完整性与 DX | ⚠️ | 滚动控制、SwiftUI 自适应高度、运行时选项、SPM/Pods | | 5. 渲染保真度与规范符合性 | ⚠️ | CommonMark/GFM 语料基线 + CI 门禁 | | 6. 流式协议健壮性 | ⚠️ | 分块不变量属性测试、乱序调用语义 | | 7. 性能与体积 | ⚠️ | 预算 + 基准套件 + 内存基线(包体 −56%、字体 −73% ✅) | | 8. 质量工程与发布工程 | ❌ | CI 构建、签名 XCFramework、校验和、发版检查单 | | 9. 授权与商业运营 | ⚠️ | 公钥构建期注入、密钥轮换、状态 API |
安装
从 npm 安装(推荐)
npm install @cellgit/markdown-render
# 或
yarn add @cellgit/markdown-render
# 或
pnpm add @cellgit/markdown-render从源码构建
git clone <repository-url>
cd markdown-render
npm install
npm run build使用方法
在 npm 项目中使用
React 示例:
import { renderMarkdown } from '@cellgit/markdown-render';
import '@cellgit/markdown-render/styles';
function App() {
const html = renderMarkdown('# Hello **React**!');
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}Vue 示例:
<template>
<div v-html="html"></div>
</template>
<script setup>
import { renderMarkdown } from '@cellgit/markdown-render';
import '@cellgit/markdown-render/styles';
const html = renderMarkdown('# Hello **Vue**!');
</script>Vanilla JS:
import { renderMarkdown, setTheme } from '@cellgit/markdown-render';
import '@cellgit/markdown-render/styles';
setTheme('dark'); // 可选;传 null 恢复 prefers-color-scheme 行为
const html = renderMarkdown('# Hello **World**!', {
allowRawHTML: false, // 默认;仅对可信内容开启
math: true,
taskLists: true
});
document.getElementById('app').innerHTML = html;渲染选项
| 选项 | 默认值 | 说明 |
|---|---|---|
| allowRawHTML | false | 保留 Markdown 源中的原始 HTML。仅对可信内容开启。 |
| linkify | true | 自动识别裸 URL。 |
| typographer | true | 智能引号 / 破折号 / 省略号。 |
| breaks | false | 单个 \n 转换为 <br>。 |
| taskLists | true | - [ ] / - [x] 渲染。 |
| emoji | true | :smile: shortcode。 |
| math | true | 行内($…$、\(…\))与块级($$…$$、\[…\])KaTeX。 |
| highlight | hljs 默认 | 自定义高亮器 (code, lang) => string。 |
| pipeline | ['wrapTables', 'addCopyButton', 'handleLinks'] | 后处理器顺序。 |
| openLinksInNewTab | false | 为 true 时链接添加 target="_blank"。 |
📚 完整文档:
在 iOS 项目中使用
专为 iOS 打包:
npm run build:ios这会在 ios-bundle/ 目录生成适用于 iOS 的文件包。详细集成步骤请查看 iOS 集成指南。
快速开始:
- 运行
npm run build:ios - 将
ios-bundle/中的文件添加到 Xcode 项目 - 使用
MarkdownViewController渲染 Markdown
需要自定义 WebView 模板?请编辑
templates/markdown-render.html,然后重新运行npm run build或npm run build:ios,生成的dist/markdown-render.html会自动更新并被后续打包脚本使用。
let markdownVC = MarkdownViewController()
markdownVC.renderMarkdown("# Hello iOS\nThis is **bold** text!")完整示例代码请参考 ios-example/MarkdownViewController.swift。商业 SDK 封装(加密资源 + 授权)见 swift-markdown-kit 仓库。
在 HTML 中使用
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Markdown Render Example</title>
</head>
<body>
<div id="output"></div>
<script src="dist/markdown-render.js"></script>
<script>
const markdown = `
# Hello World
This is **bold** and this is *italic*.
\`\`\`javascript
console.log("Hello, World!");
\`\`\`
Math: $E = mc^2$
`;
const html = MarkdownRender.renderMarkdown(markdown);
document.getElementById('output').innerHTML = html;
</script>
</body>
</html>在 ES Module 中使用
import { renderMarkdown } from './dist/markdown-render.js';
const html = renderMarkdown('# Hello World');
console.log(html);复制按钮事件绑定(浏览器宿主)
iOS WebView 宿主里 scripts/copy.js 已自动处理复制;纯浏览器环境下自行委托点击即可:
document.addEventListener('click', (e) => {
const button = e.target.closest('.code-copy-button');
if (!button) return;
const code = button.closest('.code-block-wrapper')?.querySelector('code');
if (!code) return;
navigator.clipboard.writeText(code.textContent).then(() => {
const label = button.querySelector('.copy-text');
label.textContent = 'Copied!';
setTimeout(() => { label.textContent = 'Copy'; }, 2000);
});
});Chat runtime
templates/scripts/chat-renderer.js 是建立在同一个单文档渲染器之上的上层 runtime。它把整段会话保存在一个容器中,流式过程中只增量更新当前消息:
MarkdownChatRenderer.mount();
MarkdownChatRenderer.setMessages(messages);
MarkdownChatRenderer.appendMessage(message);
MarkdownChatRenderer.appendChunk(messageId, chunk, { isLast: false });
MarkdownChatRenderer.finishMessage(messageId);
MarkdownChatRenderer.scrollToBottom(true);通过 chat.messageActions 开启后,会提供可访问的整条消息复制、重试和编辑操作。浏览器或原生宿主可以监听 markdown-chat-action 与 markdown-chat-viewport DOM 事件;后者会返回 isNearBottom、滚动偏移、内容高度和视口高度。
推理折叠
推理模型下发思维链只有两种形态,runtime 都能处理,宿主不需要区分:
// 行内:`<think>` … `</think>` 混在正文流里
MarkdownChatRenderer.appendChunk(id, '<think>先把题目看清楚');
MarkdownChatRenderer.appendChunk(id, '</think>答案是 42。');
// 独立通道:服务商用单独的 SSE 字段下发推理
MarkdownChatRenderer.appendReasoningChunk(id, '先把题目看清楚');
MarkdownChatRenderer.appendChunk(id, '答案是 42。');两种形态都会落到消息上方的折叠面板里:模型思考时保持展开,正文第一个字出现的瞬间收起成一行。读者自己点开或收起之后,自动收起不再覆盖这个选择。finishReasoning(id) 可以提前收起,setReasoningVisible(id, expanded) 由宿主控制展开状态,折叠状态变化会派发 markdown-chat-reasoning DOM 事件。
复制消息复制的是正文,不含推理内容。
window.MarkdownWebViewConfig = {
chat: {
reasoning: {
enabled: true, // false 时标记保持为原文
inlineTags: true, // false 时只有 appendReasoningChunk 会写入面板
tags: ['think', 'thinking', 'thought', 'reasoning', 'reason'],
autoCollapse: true, // 正文开始时自动收起
defaultExpanded: false, // 历史消息重新加载时是否默认展开
labels: {
thinking: '思考中…',
done: '已深度思考',
duration: '已深度思考(用时 {seconds} 秒)'
}
}
}
};Chat runtime 之外,renderMarkdown() 会把完整的 <think> 块折叠成 <details class="md-reasoning">,配置结构与上面一致,写在 reasoning 渲染选项里,并且不需要打开 allowRawHTML。想自己分离两个通道的宿主可以直接用导出的 splitReasoning(text) 和 createReasoningSplitter();后者按流式设计,<thi + nk> 分两次到达也能正确识别。
流式渲染与桥接事件契约
WebView 宿主(templates/scripts/renderer.js)暴露:
window.renderMarkdown(text) // 整篇渲染(开启新的内容周期)
window.appendMarkdownChunk(chunk, { isLast }) // 流式追加(rAF 合并)
window.clearContent() // 清空(开启新的内容周期)
window.getContentHeight() // 同步测量高度事件契约(经 bridge.js 回传原生宿主):
| 事件 | 触发时机 |
|---|---|
| renderComplete | 每个内容周期恰好两次:首帧上屏 + isLast 最终 flush。 |
| contentHeightChanged | 流式期间的中间 flush、图片加载、字号变化等高度变化(宿主侧防抖)。 |
增量流式(默认)把已完成的安全块边界之前的 HTML 缓存起来,只重渲染不稳定尾部,整条流的总渲染成本保持 O(n);streaming.incremental = false 可退回整篇重渲染。安全边界包括空行、代码块的收尾围栏、以及显示公式的收尾 $$——因此一段长代码或长公式一写完就离开活跃尾部,不必等到下一个空行之前每帧重新解析一次。
renderMarkdownFragment(text, options) 直接渲染成 DocumentFragment 而非 HTML 字符串。Chat runtime 走这条路径,流式消息每帧只解析一次,省去序列化再解析的往返。
非标准 Markdown
模型输出只是「大致符合」Markdown 规范,渲染器把这当作常态而非错误:
| 输入 | 渲染结果 |
|---|---|
| 包 $100,三脚架 $200。 | 纯文本——单个 $ 不构成公式 |
| 公式:$x^{2$ | 原样显示 $x^{2$,颜色与正文一致 |
| $\foobar{x}$ | 原样显示——未知宏不会被涂成红色 |
| 详见 \[1\]。 | 保持为一句话——引用不会被提升为块级公式 |
| `echo $PATH` 之后的 $HOME | 代码片段保留,$HOME 不受影响 |
解析失败的表达式以 <span class="md-math-raw"> 输出,并保留原始定界符,因此内容不丢失、复制出去仍能还原。若失败发生在已定稿(非流式)的文本上,该 span 还会带上 data-md-math-error,记录 KaTeX 的原因——读者看不见,调试时有用。
主题系统(Token 化)
渲染核心内置一套 token 化主题引擎:命名预设 + 逐 token 覆盖,运行时即可切换(无需重新构建)。
API
import { applyTheme, setThemePreset, getThemePresets } from '@cellgit/markdown-render';
// 1) 切换命名预设
setThemePreset('github'); // 'system'(默认)| 'github'
// 2) 预设 + 强制深浅色 + 逐 token 覆盖
applyTheme({
preset: 'github',
mode: 'auto', // 'auto' | 'light' | 'dark'
tokens: {
light: { '--chat-link-color': '#0a7d55' },
dark: { '--chat-link-color': '#3fb950' },
metrics: { '--md-h1-size': '2.2em', '--md-code-radius': '8px' },
code: { light: { '--hljs-keyword': '#d73a49' } }
}
});
getThemePresets(); // ['system', 'github']applyTheme 生成/更新一个 <style id="md-theme-vars">,其选择器与基础样式同构,因此覆盖在同优先级下生效,且 mode:'auto' 时仍跟随系统深浅色。未覆盖的 token 回退到 styles.css 的基线默认值。
Token 三族
| 族 | 作用 | 代表 token | 按深浅色 |
|---|---|---|---|
| palette | 文本 / 链接 / 填充 / 表格 / 代码卡片配色、错误文本 | --chat-text-color、--chat-link-color、--code-surface、--md-blockquote-border、--md-error-color | 是(light/dark) |
| code | 代码高亮配色 | --hljs-keyword、--hljs-string、--hljs-comment | 是(light/dark) |
| metrics | 字号 / 行高 / 间距 / 圆角(相对字号) | --md-h1-size…--md-h6-size、--md-body-line-height、--md-paragraph-margin、--md-list-indent、--md-code-radius | 否(共享) |
完整 token 清单见 src/theme.js。布局类变量由宿主 API 设置:--markdown-padding / --markdown-background / --markdown-bottom-gap / --markdown-font-size(iOS 侧映射 Dynamic Type)。
Config 契约(供 WebView 宿主 / iOS SDK)
WebView 宿主通过注入的全局配置驱动主题:
window.MarkdownWebViewConfig = {
theme: {
mode: 'auto', // 'auto' | 'light' | 'dark'
preset: 'system', // 'system' | 'github'
tokens: { /* 同 applyTheme 的 tokens 形状,可选 */ }
}
};renderer.js 会把该 theme 透传给 applyTheme。swift-markdown-kit 的 Swift MarkdownTheme 会序列化成上面这个 theme 结构。
扩展机制(自定义语法)
渲染核心支持声明式行内扩展:用配置(而非任意 JS)注册自定义语法 —— 安全(不破坏 CSP、值全部转义)、可主题化(.md-ext-{name})、可回传动作。两种识别模式:
- prefix:触发串 + 受限字符体,如
@alice、$AAPL、#tag。 - delimiter:成对定界符捕获内部文本,如
[[Home]]、||剧透||。
const extensions = [
{ name: 'mention', type: 'prefix', trigger: '@', body: 'word', action: 'mention' },
{ name: 'ticker', type: 'prefix', trigger: '$', body: 'upper', action: 'ticker', className: 'ticker' },
{ name: 'wikilink', type: 'delimiter', open: '[[', close: ']]', action: 'wikilink', display: '{value}' }
];
renderMarkdown('Hi @alice, buy $AAPL, see [[Home]]', { extensions });
// → <span class="md-ext md-ext-mention" data-md-ext="mention" data-md-value="alice" data-md-action="mention">@alice</span> …| 字段 | 说明 |
|---|---|
| name | 扩展 id(决定 md-ext-{name} 类与 data-md-ext);清洗为 [A-Za-z0-9_-] |
| type | 'prefix'(默认)或 'delimiter' |
| trigger | prefix:触发串(如 @) |
| body | prefix:体字符类 word/alnum/letter/upper/upperdigit,或显式允许字符串 |
| open / close | delimiter:定界符 |
| action | 可选;点击该元素回传的动作名(经宿主 bridge) |
| display | 可选;展示模板,{value} 占位(默认 prefix 显示「触发串+体」,delimiter 显示内部文本) |
| className / dataset | 可选;附加 class / data-*(键清洗、值转义) |
安全:值与展示一律 HTML 转义,标识符清洗,扫描有长度上限(无 ReDoS、无任意 HTML/JS 注入)。
触发字符不受限:markdown-it 的 text 规则只在「终结符」处停下;当扩展的首字符不是终结符(如 ||…||、/cmd)时,核心会自动替换等价的 text 规则使其在正文中间也能命中,无需调用方做任何事。
每扩展样式钩子(可主题化)
buildExtensionCSS(extensions) 为每个扩展生成带回退值的 CSS 规则,renderer.js 会自动注入(<style id="md-ext-vars">)。宿主只需通过主题 token 覆盖变量即可改样式,无需注入任何 CSS:
/* 每个扩展 {name} 可用: */
--md-ext-{name}-color /* 默认 var(--chat-link-color) */
--md-ext-{name}-bg /* 默认 transparent */
--md-ext-{name}-radius / -padding / -weight / -decorationConfig 契约(WebView 宿主 / iOS SDK)
window.MarkdownWebViewConfig = { extensions: [ /* 同上数组 */ ] };renderer.js 会把 extensions 透传给渲染;swift-markdown-kit 的 MarkdownExtension 序列化成该数组,点击带 data-md-action 的元素经 bridge 回传 Swift。
Pipeline 机制
渲染后的 HTML 会经过一条可配置的后处理管线(解析 / 序列化各只做一次,添加处理器不会成倍增加开销)。内置处理器:wrapTables(表格横向滚动容器)、addCopyButton(代码块头部 + 复制按钮)、handleLinks(链接策略)。
自定义处理器:
import { registerPipeline } from '@cellgit/markdown-render';
// 处理器直接修改 Document(不做字符串往返)
registerPipeline('addAnchor', (doc) => {
doc.querySelectorAll('h2').forEach((h) => h.setAttribute('id', h.textContent));
});
renderMarkdown(text, { pipeline: ['wrapTables', 'addCopyButton', 'handleLinks', 'addAnchor'] });项目结构
markdown-render/
├── src/
│ ├── index.js # 主入口(公开 API)
│ ├── markdown.js # markdown-it 配置 + 数学规则
│ ├── highlight.js # 代码高亮(common 构建 + 自动检测策略)
│ ├── math.js # KaTeX 渲染与预处理
│ ├── extensions.js # 声明式行内扩展
│ ├── theme.js # Token 化主题引擎
│ ├── dom-utils.js # 流式 DOM diff
│ ├── styles.css # 基线样式
│ └── pipeline/ # HTML 后处理管线
├── templates/
│ ├── markdown-render.html # WebView 宿主页
│ └── scripts/ # bridge / renderer / height-sync / copy
├── dist/ # 构建产物
├── ios-bundle/ # build:ios 产物(swift-markdown-kit 输入)
└── tests/ # Node 测试套件版本依赖
- markdown-it: ^14.1.0
- highlight.js: ^11.9.0(common 构建)
- katex: ^0.16.9
- markdown-it-task-lists: ^2.1.1
- markdown-it-emoji: ^3.0.0
构建
npm run build # dist/(浏览器 IIFE + ESM + CSS + fonts + templates)
npm run build:ios # 上一步 + 生成 ios-bundle/(swift-markdown-kit 打包时读取)构建产物:dist/markdown-render.js(约 620KB,highlight.js common 构建)。
与 swift-markdown-kit 同步:修改本仓库任何
src/或templates/代码后,依次执行npm run build:ios→swift-markdown-kit/create_xcframework.sh(加密ios-bundle/为MarkdownRenderPackage.dat并产出 XCFramework),详见 swift-markdown-kit 的 README。
测试
npm testtests/ 下的 Node 测试套件:
| 文件 | 覆盖内容 |
|---|---|
| tests/render.test.mjs | 数学边界、表格包装、链接策略、选项开关、原始 HTML 默认关闭、emoji、任务列表。 |
| tests/streaming.test.mjs | rAF 合并的增量流式;稳定 / 不稳定边界正确性;renderComplete / height 事件契约。 |
| tests/extensions.test.mjs | 声明式扩展:prefix / delimiter 识别、非终结符触发字符、转义与清洗、buildExtensionCSS。 |
| tests/theme.test.mjs | Token 化主题引擎:preset、覆盖优先级、样式元素复用。 |
| tests/math-no-corruption.test.mjs | 数学边界不破坏正文(货币、代码 span 等)。 |
在浏览器中打开 test.html 可进行可视化调试。
浏览器兼容性
- Chrome/Edge: ✅
- Safari: ✅
- Firefox: ✅
- iOS Safari: ✅
- Android Chrome: ✅
License
MIT
致谢
本项目基于以下优秀开源项目:
