@cellgit/markdown-render

Streaming-friendly markdown renderer with KaTeX math, code highlighting, and iOS-ready WebView bundle.
1,268
Install
npm install @cellgit/markdown-render
Latest Version:1.3.3
License:MIT
Last Updated:Sep 27, 2026
Links: npm
Maintainer: cellgit

@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 见 Pipeline 机制 后处理器顺序。
openLinksInNewTab false 为 true 时链接添加 target="_blank"。
inlineMathFlow true 行内公式随句子排、落在文字基线上,可在段与段之间折行。false 让每个行内公式都待在自己的可横滑盒子里(1.1 的排法)。见行内公式排版。
inlineMathStyle 'text' 'display' 让行内公式按展示样式排(分数不缩小),但仍留在行内。给单独渲染的片段用,比如选择题选项。
mathPunctuation 中文 + ASCII 标点 { leading, trailing }:keepPunctuationWithMath 要和公式绑在一起的字符集合,传空串关掉那一侧。
choices true 识别 :::choices 块,见选择题选项。
choiceMathStyle 'display' 选项里行内公式的样式('text' 保持小号)。
imageSources — Markdown 里写的图片路径 → { url, width, height }(或只给地址)。换成真实地址并写上尺寸,图片到货时页面不跳。
imageUnitsPerEm — 图片声明尺寸里多少个单位等于它所在正文的 1em。给了之后,句子里的图片按 em 定宽,和文字保持原来的比例。见句子里的图片。

📚 完整文档:

在 iOS 项目中使用

专为 iOS 打包:

npm run build:ios

这会在 ios-bundle/ 目录生成适用于 iOS 的文件包。详细集成步骤请查看 iOS 集成指南。

快速开始:

  1. 运行 npm run build:ios
  2. 将 ios-bundle/ 中的文件添加到 Xcode 项目
  3. 使用 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-math-font-size、--md-text-wrap、--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 / -decoration

Config 契约(WebView 宿主 / iOS SDK)

window.MarkdownWebViewConfig = { extensions: [ /* 同上数组 */ ] };

renderer.js 会把 extensions 透传给渲染;swift-markdown-kit 的 MarkdownExtension 序列化成该数组,点击带 data-md-action 的元素经 bridge 回传 Swift。

Pipeline 机制

渲染后的 HTML 会经过一条可配置的后处理管线(解析 / 序列化各只做一次,添加处理器不会成倍增加开销)。内置处理器按默认顺序:resolveImages(宿主提供的图片映射)、keepPunctuationWithMath(公式和紧挨的标点不被拆到两行)、wrapTables(表格横向滚动容器)、addCopyButton(代码块头部 + 复制按钮)、handleLinks(链接策略)、prepareImages(按已知尺寸预留占位)。

传 pipeline 会替换那一次渲染的整个列表,所以任何内建处理器都可以按次去掉或调整顺序;找不到的名字直接跳过。

自定义处理器:

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'] });

行内公式排版

KaTeX 在顶层的关系符和二元运算符处把公式切成若干段(x=5 是 x= + 5),浏览器可以在段与段之间换行,和 TeX 一样。公式一律渲染成 .math-inline--flow,随句子折行,各段落在文字的基线上;只有一段的公式只是没有可断的地方。

紧挨公式的标点(公式后的「,」、公式前的「(」)跟着公式一起换行,不会单独落到行首或留在行尾:只有一段的公式和标点一起放进 .math-punct inline-block;随句子折行的公式只把首段或末段和标点合进 .math-punct-edge,其余部分照样能折行。连这一段加标点都放不进一行时,inlineMath 布局步骤会把它松开(.math-punct-edge--loose),标点照常断开。公式在加粗、斜体、删除线的开头或结尾时(**$x=2$**,),先把公式从这层强调里挪出来再绑定——KaTeX 自带字体,挪出来看不出任何区别;链接不挪。(给行内的包装加 white-space: nowrap 不管用:Chrome 和 WebKit 仍会在公式那一段的 inline-block 后面断行。)

行盒是紧贴着往下排的,两行里如果都有比文字高的公式(分式、根号、大括号、矩阵、上下限、伸长的箭头和括号),它们会碰到一起。段落和列表项里的这些部分带一点上下外边距,相当于 TeX 的 \lineskip:只会推开本来就被它撑高的那一行(普通分式约多 2px),没有这类公式的行仍是 --md-body-line-height。选项和表格单元格不受影响。

随句子折行唯一处理不了的,是某一段本身比整行还宽(比如写成一整段的长集合、长括号)——它会被容器截掉,在表格里则会压到下一列上。这取决于屏幕宽度,只能在布局之后判断:

import { renderMarkdownToElement, settleLayout, observeLayout } from '@cellgit/markdown-render';

renderMarkdownToElement(text, el, options);
settleLayout(el);               // 每次渲染后调(流式 diff 会重置各步骤设过的 class 和样式)
const stop = observeLayout(el); // 调一次:宽度变化、字体或图片加载完都会重新排

settleLayout 依次执行所有布局步骤——需要排好版的 DOM 才能做的判断。内置两步:inlineMath(有超宽段的公式挪到 .math-inline--boxed 可横滑盒子,放得下了再挪回来)和 choices(见下)。用 registerLayoutPass(name, fn) 加自己的步骤,传 null 删除。宿主不调它也能正确折行,只是超宽段不能横滑(会溢出)、选项不会并排。内置的 WebView 运行时(templates/scripts/renderer.js、chat-renderer.js)已经都接好了。

相关的 metrics token:--md-math-font-size(默认 1.1em;KaTeX 自带的 1.21em 会让数字和大写字母比系统字体高 12–16%)、--md-text-wrap(默认 wrap)。pretty 故意不作默认:WebKit(Safari 和所有 iOS WKWebView)会拿它平衡整段的行长,中文段落右侧会留出一条空白。

选择题选项

:::choices
A. $-\frac{1}{21}$
B. $-\frac{1}{63}$
C. $0$
D. $\frac17$
:::

以标号(A. B. C、 1. ①)开头的一行开始一个选项,之后的行都属于它,直到下一个标号。choices 布局步骤量出选项渲染后的宽度,像试卷那样一行排 4 个、2 个或 1 个,同一行按正文基线对齐。选项里的公式默认按展示样式排,choiceMathStyle: 'text' 可关掉。

宿主可以给选项加 data-md-choice-state="correct" 或 "incorrect",样式走 --md-choice-correct-bg、--md-choice-correct-border、--md-choice-correct-label、--md-choice-incorrect-bg、--md-choice-incorrect-border。列数的纯函数判断导出为 chooseChoiceColumns。

句子里的图片

图片可以是句子的一部分:两个词之间的结构式(离子液体[<结构式>]HSO₄⁻),答案里圈号后面的结构简式。印刷的试卷把它竖直居中放在文字行上;交给浏览器则落在基线上,整句话都挤在图的底边。

prepareImages 给和文字或公式同在一行的图片加上 data-md-inline="text",样式表让它居中对齐文字行,去掉独立插图才有的外边距和圆角。单独一段的图、并排的几张图、表格单元格里和选项里的图都不是句子,保持原样。

它和文字的大小比例由宿主给,因为只有宿主知道图片从哪里来。imageUnitsPerEm 说明图片声明尺寸(imageSources 里的 width/height)里多少个单位等于原文正文的 1em,图片随之得到以 em 计的宽度。从 144 dpi 渲染的试卷上裁下、正文 10.5 磅的图,这个值是 21。不给这个选项,图片保持声明尺寸。

项目结构

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 test

tests/ 下的 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

致谢

本项目基于以下优秀开源项目:

Related Packages

@symbiote-native/vue

SymbioteNative's Vue adapter — a @vue/runtime-core createRenderer driving real n...

5,455 72
brookmd

Zero-dep streaming markdown for the browser. Rust→WASM core, Web Worker per stre...

3,299 10
katex-vue3

katex use vue3

2,037
@qkix/better-blocks-vue-renderer

Vue 3 renderer for Strapi v5 Blocks content with full Better Blocks plugin suppo...

0 13
nativescript-vue

Vue 3 renderer for NativeScript

164,550 6,532

Version History

Version Released License
1.3.3 MIT
1.3.2 MIT
1.3.1 MIT
1.1.0 MIT
1.0.1 MIT