npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

incremark-renderer

v0.4.0

Published

Streaming Markdown renderer with incremental lexing, AST diffing, and partial DOM updates powered by marked.js.

Readme

incremark-renderer

面向聊天 UI、LLM 产品和流式内容场景的 Markdown 增量渲染器。它基于 marked.js,重点解决“Markdown 一边到达、一边渲染”时的性能、稳定性和可扩展性问题。

它是什么

incremark-renderer 是一个基于 marked.js 的流式 Markdown 渲染库。

和“每来一个 chunk 就重新解析整篇 Markdown、再把整个 DOM 重刷一遍”的方案不同,它会:

  • 保守地判断哪些块已经稳定
  • 只重新执行“新增稳定块 + 当前尾块”的 lexer
  • 对块级 token 树做比较并产出 patch
  • 在浏览器里按块做局部 DOM 更新

同时它还内置了 LLM 场景里常见的能力:

  • highlight.js 代码高亮
  • katex 数学公式渲染
  • ::: 自定义容器
  • 默认启用的 HTML 安全清洗
  • ChatGPT 风格打字机播放

它解决什么问题

如果你的 Markdown 是流式到达的,传统做法通常会遇到这些问题:

  • 文档越长,每次全量解析越贵
  • 段落、列表、代码块尚未闭合时会反复抖动
  • DOM 整体替换太频繁,容易闪烁
  • 业务自定义扩展点不够清晰,比如特殊容器、特殊代码块

incremark-renderer 就是针对这一类问题设计的。

它的优势

  • 兼容 marked.js:不脱离主流 Markdown 生态
  • 面向流式:核心流程就是围绕 append() 设计
  • 稳定块边界检测:未闭合内容会留在可变 tail 中
  • 块级局部更新:不动的块不会重复挂载
  • 可扩展:容器、代码块、sanitizer、block renderer、plugin 都可定制
  • 自带常用能力:高亮、公式、打字机、光标控制器
  • 默认更安全:渲染 HTML 会先经过 sanitizer

安装

npm install incremark-renderer

我该用哪个 API?

| 场景 | 推荐 API | | --- | --- | | 真实流式 Markdown,且不依赖 DOM | StreamMarkdownRenderer | | 真实流式 Markdown,直接渲染到浏览器 DOM | IncrementalDomRenderer | | 已经拿到完整 Markdown,只想要 HTML | renderMarkdownToString() | | 已经拿到完整 Markdown,还想拿 blocks 和 snapshot | renderMarkdown() | | 已经有完整字符串,只是想做打字机回放 | MarkdownTypewriter | | 上游内容是实时流式到达 | StreamingMarkdownTypewriter | | 需要一个跟随输出末尾的打字光标 | TypewriterCursorController |

快速开始

1. 流式渲染

import { StreamMarkdownRenderer } from 'incremark-renderer';

const renderer = new StreamMarkdownRenderer();

renderer.append('# 标题\n\n这是一段');
renderer.append('流式 Markdown。');
renderer.finalize();

console.log(renderer.renderToString());
console.log(renderer.getSnapshot());

2. 浏览器 DOM 渲染

import { IncrementalDomRenderer } from 'incremark-renderer';

const root = document.getElementById('app');
const renderer = new IncrementalDomRenderer(root);

renderer.append('## 标题\n\nPart');
renderer.append('ial 内容');
renderer.finalize();

3. 全量渲染

import { renderMarkdownToString } from 'incremark-renderer';

const html = renderMarkdownToString('# 历史消息\n\n完整内容');

如果你还需要 block 元数据:

import { renderMarkdown } from 'incremark-renderer';

const result = renderMarkdown('# 历史消息\n\n完整内容');

console.log(result.html);
console.log(result.blocks);
console.log(result.snapshot);

核心概念

Stable Blocks 和 Tail

渲染器会把当前输入拆成两部分:

  • stable blocks:已经稳定、不需要再重复 lexer 的块
  • tail:最后一个仍可能继续增长的可变片段

这也是整个增量渲染性能模型的基础。

Render Patch

每次更新会输出块级 patch:

  • insert:新增可见块
  • replace:已有块内容发生变化
  • remove:已有块从可见区移除

这既适合框架层消费,也适合直接驱动 DOM。

功能说明

全量渲染与流式渲染

如果 Markdown 已经完整可用,请直接用 renderMarkdownToString()renderMarkdown()

如果 Markdown 是一段段到达的,请持续调用 append(),并在流结束时调用 finalize()

如果你想把一个已有 renderer 的内容一次性替换成新文档,请用 setMarkdown()

自定义容器

默认支持 ::: 容器语法。

import { renderMarkdownToString } from 'incremark-renderer';

const html = renderMarkdownToString(`:::note 快速开始
这里可以写 **容器内容**。
:::`);

默认输出结构包含:

  • .incremark-container
  • .incremark-container-{type}
  • [data-container-type="{type}"]
  • .incremark-container-title
  • .incremark-container-content

你也可以完全自定义外层结构:

import { StreamMarkdownRenderer } from 'incremark-renderer';

const renderer = new StreamMarkdownRenderer({
  container: {
    render: ({ type, title, innerHtml, closed }) =>
      `<aside class="callout callout-${type}" data-closed="${String(closed)}">${title ? `<h3>${title}</h3>` : ''}${innerHtml}</aside>`,
  },
});

这里的 closed 表示当前容器是否已经出现闭合 :::

如果你不希望启用容器解析,也可以直接关闭:

const renderer = new StreamMarkdownRenderer({
  container: false,
});

代码块与自定义代码块渲染

带语言标记的 fenced code block 默认启用语法高亮。

import { renderMarkdownToString } from 'incremark-renderer';

const html = renderMarkdownToString('```ts\nconst value = 1;\n```');

如果你想对没有语言标记的代码块启用自动识别:

import { StreamMarkdownRenderer } from 'incremark-renderer';

const renderer = new StreamMarkdownRenderer({
  highlight: {
    autoDetect: true,
    languages: ['javascript', 'typescript', 'json'],
  },
});

自定义代码块 header:

const renderer = new StreamMarkdownRenderer({
  highlight: {
    renderHeader: ({ code, defaultHeaderContent, closed }) => {
      const encoded = encodeURIComponent(code);
      return `${defaultHeaderContent}<button type="button" data-copy-code="${encoded}" data-closed="${String(closed)}">复制</button>`;
    },
  },
});

把特定语言渲染成业务组件:

const renderer = new StreamMarkdownRenderer({
  highlight: {
    languageRenderers: {
      markmap: ({ code, language, closed }) =>
        `<div class="markmap-view" data-language="${language}" data-closed="${String(closed)}" data-markmap="${encodeURIComponent(code)}"></div>`,
    },
  },
});

或者统一拦截所有代码块:

const renderer = new StreamMarkdownRenderer({
  highlight: {
    renderBlock: ({ declaredLanguage, defaultHtml }) => {
      if (declaredLanguage === 'markmap') {
        return '<div class="markmap-view"></div>';
      }
      return defaultHtml;
    },
  },
});

这里的 closed 表示当前 fenced code block 是否已经出现闭合 fence。

如果你希望完全关闭代码高亮,保留普通代码块输出:

const renderer = new StreamMarkdownRenderer({
  highlight: false,
});

数学公式

数学公式默认开启。

支持的分隔符:

  • 行内:$...$\(...\)
  • 块级:$$...$$\[...\]
import { StreamMarkdownRenderer } from 'incremark-renderer';

const renderer = new StreamMarkdownRenderer({
  math: {
    katex: {
      throwOnError: true,
      macros: {
        '\\RR': '\\mathbb{R}',
      },
    },
  },
});

如果你不希望启用数学公式:

const renderer = new StreamMarkdownRenderer({
  math: false,
});

HTML 安全清洗

默认会对最终 block HTML 做 sanitizer 处理。

主要拦截这类风险:

  • 内联事件属性,比如 onerror
  • 危险 URL scheme,比如 javascript:

只有在 Markdown 输入和所有自定义 HTML hook 都完全可信时,才建议关闭:

const renderer = new StreamMarkdownRenderer({
  sanitizeHtml: false,
});

如果你希望接入自己的 sanitizer:

const renderer = new StreamMarkdownRenderer({
  sanitizeHtml: {
    sanitizer: (html) => myTrustedSanitizer(html),
  },
});

注意:container.renderhighlight.renderHeaderhighlight.renderBlockrenderer.renderBlock() 返回的 HTML,在默认情况下也仍然会经过 sanitizer。

打字机播放

如果你已经拿到完整 Markdown,可以使用 MarkdownTypewriter

import {
  IncrementalDomRenderer,
  MarkdownTypewriter,
  TypewriterCursorController,
} from 'incremark-renderer';

const root = document.getElementById('app');
const renderer = new IncrementalDomRenderer(root);
const cursor = new TypewriterCursorController(root);

const typewriter = new MarkdownTypewriter('# Hello\n\nStreaming markdown.', {
  baseDelayMs: 26,
  minChunkSize: 1,
  maxChunkSize: 12,
  onChunk: (chunk, meta) => {
    renderer.append(chunk);

    if (meta.inCodeFence) {
      cursor.hide();
    } else {
      cursor.show();
      cursor.update();
    }
  },
  onComplete: () => {
    cursor.hide();
    renderer.finalize();
  },
});

typewriter.start();

如果是对接真实流式上游,请使用 StreamingMarkdownTypewriter

import {
  IncrementalDomRenderer,
  StreamingMarkdownTypewriter,
} from 'incremark-renderer';

const root = document.getElementById('app');
const renderer = new IncrementalDomRenderer(root);
const typewriter = new StreamingMarkdownTypewriter({
  onChunk: (chunk) => {
    renderer.append(chunk);
  },
  onComplete: () => {
    renderer.finalize();
  },
});

typewriter.start();

upstream.on('data', (chunk) => {
  typewriter.push(chunk);
});

upstream.on('end', () => {
  typewriter.close();
});

API 参考

主要导出

| 导出项 | 类型 | 作用 | | --- | --- | --- | | renderMarkdownToString | function | 全量渲染并返回 HTML | | renderMarkdown | function | 全量渲染并返回 { html, blocks, snapshot } | | StreamMarkdownRenderer | class | 与框架无关的增量渲染器 | | IncrementalDomRenderer | class | 浏览器 DOM 增量渲染器 | | MarkdownTypewriter | class | 已知完整字符串的打字机回放 | | StreamingMarkdownTypewriter | class | 实时流式输入的打字机 | | TypewriterCursorController | class | 浏览器打字光标控制器 | | extractStableBlocks | function | 底层稳定块检测工具 | | diffAst | function | 底层 token diff 工具 | | digestTokens | function | 底层 token 摘要工具 | | createContainerExtension | function | 高级 marked 扩展导出 | | createHighlightExtension | function | 高级 marked 扩展导出 | | createMathExtension | function | 高级 marked 扩展导出 | | createDefaultHtmlSanitizer | function | 内置 sanitizer 工厂 | | createHtmlSanitizer | function | 带自定义入口的 sanitizer 工厂 | | DefaultBlockRenderer | class | 默认 block -> HTML 渲染器 | | wrapBlockHtml | function | 给 block HTML 包装元数据外层 |

StreamMarkdownRenderer

构造函数

new StreamMarkdownRenderer(options?: StreamMarkdownOptions)

方法

| 方法 | 说明 | | --- | --- | | append(chunk: string) | 追加流式 Markdown,并返回 patch | | setMarkdown(markdown: string) | 用完整文档替换当前状态 | | finalize() | 在流结束时把剩余 tail 刷出 | | reset() | 清空内部状态 | | getSnapshot() | 返回 { blocks, stableCount, sourceLength } | | getBlocks() | 返回当前可见 blocks | | renderToString() | 返回当前 HTML 字符串 |

IncrementalDomRenderer

构造函数

new IncrementalDomRenderer(root: HTMLElement, options?: StreamMarkdownOptions)

方法

| 方法 | 说明 | | --- | --- | | append(chunk: string) | 直接把流式 patch 应用到 DOM | | setMarkdown(markdown: string) | 直接用完整文档替换当前 DOM 状态 | | finalize() | 把剩余 tail 应用到 DOM | | reset() | 清空状态并清空 root | | getBlocks() | 返回当前可见 blocks | | renderToString() | 返回当前 HTML 字符串 |

MarkdownTypewriter

构造函数

new MarkdownTypewriter(text: string, options: TypewriterOptions)

方法

| 方法 | 说明 | | --- | --- | | start() | 开始播放 | | pause() | 暂停播放 | | resume() | 恢复播放 | | stop() | 停止播放并重置内部游标 | | isRunning() | 返回当前是否正在播放 |

StreamingMarkdownTypewriter

构造函数

new StreamingMarkdownTypewriter(options: TypewriterOptions)

方法

| 方法 | 说明 | | --- | --- | | push(chunk: string) | 向缓冲区追加上游文本 | | close() | 标记上游输入已结束 | | isClosed() | 返回上游是否已结束 | | start() | 开始播放 | | pause() | 暂停播放 | | resume() | 恢复播放 | | stop() | 停止播放并重置内部游标 | | isRunning() | 返回当前是否正在播放 |

TypewriterCursorController

构造函数

new TypewriterCursorController(root: HTMLElement, options?: TypewriterCursorOptions)

方法

| 方法 | 说明 | | --- | --- | | show() | 显示光标 | | hide() | 隐藏光标 | | update() | 重新计算光标位置 | | destroy() | 销毁监听和光标 DOM |

Options 参考

StreamMarkdownOptions

| 选项 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | marked | MarkedOptions | undefined | 透传给 marked 的配置 | | sanitizeHtml | HtmlSanitizeOptions \| false | 开启 | 控制渲染后的 HTML 安全清洗 | | container | ContainerOptions \| false | 开启 | 配置 ::: 容器,或显式关闭 | | math | MathRenderOptions \| false | 开启 | 配置数学公式,或显式关闭 | | highlight | CodeHighlightOptions \| false | 开启 | 配置代码高亮和代码块自定义渲染 | | renderer | BlockRenderer | DefaultBlockRenderer | 覆盖 block -> HTML 渲染逻辑 | | plugins | StreamMarkdownPlugin[] | [] | 监听 block 或 patch 的插件机制 |

HtmlSanitizeOptions

| 选项 | 类型 | 说明 | | --- | --- | --- | | sanitizer | (html: string) => string | 自定义 sanitizer 函数 |

ContainerOptions

| 选项 | 类型 | 说明 | | --- | --- | --- | | render | (context: ContainerRenderContext) => string \| null \| undefined | 自定义容器外层 HTML |

ContainerRenderContext

| 字段 | 类型 | 说明 | | --- | --- | --- | | type | string | 容器类型 | | info | string | ::: 后的原始 info 字符串 | | title | string \| undefined | 解析出的可选标题 | | closed | boolean | 当前容器是否已经出现闭合 ::: | | raw | string | 整个容器块的原始源码 | | text | string | 容器内部的 Markdown 源码 | | innerHtml | string | 默认内层渲染结果 | | defaultClassName | string | 默认 class,例如 incremark-container incremark-container-note |

CodeHighlightOptions

| 选项 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | autoDetect | boolean | false | 是否对未标记语言的 fenced block 做自动识别 | | defaultLanguage | string | undefined | 未标记语言时使用的默认语言 | | languages | string[] | undefined | 自动识别时的候选语言范围 | | renderHeader | CodeBlockHeaderRenderer | undefined | 自定义代码块 header | | renderBlock | CodeBlockRenderer | undefined | 自定义整个代码块 HTML | | languageRenderers | Record<string, CodeBlockRenderer> | undefined | 按语言分发的代码块 renderer |

CodeBlockHeaderRenderContext

| 字段 | 类型 | 说明 | | --- | --- | --- | | code | string | 原始代码内容 | | language | string \| undefined | 最终用于渲染的语言 | | declaredLanguage | string \| undefined | fence info string 中声明的语言 | | highlighted | boolean | 是否成功命中高亮 | | closed | boolean | 当前代码块是否已经出现闭合 fence | | defaultHeaderContent | string | 默认语言 badge HTML |

CodeBlockRenderContext

| 字段 | 类型 | 说明 | | --- | --- | --- | | code | string | 原始代码内容 | | language | string \| undefined | 最终用于渲染的语言 | | declaredLanguage | string \| undefined | fence info string 中声明的语言 | | highlighted | boolean | 是否成功命中高亮 | | closed | boolean | 当前代码块是否已经出现闭合 fence | | defaultHeaderContent | string | 默认语言 badge HTML | | headerHtml | string | 完整默认 header HTML | | bodyHtml | string | 代码内容 HTML | | codeClassName | string \| undefined | 应用于 <code> 的 className | | defaultHtml | string | 完整默认代码块 HTML |

MathRenderOptions

| 选项 | 类型 | 说明 | | --- | --- | --- | | katex | Omit<KatexOptions, 'displayMode'> | 透传给 KaTeX 的配置 |

StreamMarkdownPlugin

| 字段 | 类型 | 说明 | | --- | --- | --- | | name | string | 插件名称 | | onBlockParsed | (block: StableBlock) => StableBlock \| void | block 解析和渲染完成后的 hook | | onPatchesComputed | (patches: RenderPatch[], snapshot: StreamRendererSnapshot) => void | patch 计算完成后的 hook |

TypewriterOptions

| 选项 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | baseDelayMs | number | 26 | 自适应打字节奏的基础延迟 | | minChunkSize | number | 2 | 每次输出的最小 chunk 大小 | | maxChunkSize | number | 14 | 每次输出的最大 chunk 大小 | | onChunk | (chunk, meta) => void | 必填 | 每次输出 chunk 时触发 | | onComplete | (meta) => void | 空函数 | 播放完成时触发 | | onPause | (meta) => void | 空函数 | 暂停时触发 | | onResume | (meta) => void | 空函数 | 恢复时触发 | | onStart | (meta) => void | 空函数 | 开始时触发 | | onStateChange | (meta) => void | 空函数 | 状态变化时触发 | | onStop | (meta) => void | 空函数 | 停止时触发 |

TypewriterChunkMeta

| 字段 | 类型 | 说明 | | --- | --- | --- | | chunk | string | 当前输出片段 | | chunkSize | number | 当前片段字符数 | | delayMs | number | 下一次输出前的延迟 | | done | boolean | 当前是否已播放完成 | | closed | boolean | 上游输入是否已结束 | | inCodeFence | boolean | 当前可见输出是否处于 fenced code block 内 | | cursor | number | 当前绝对游标位置 | | total | number | 当前总长度,或流式模式下当前已缓冲长度 |

TypewriterEventMeta

| 字段 | 类型 | 说明 | | --- | --- | --- | | state | TypewriterState | idlerunningpausedcompletedstopped | | cursor | number | 当前绝对游标位置 | | total | number | 当前总长度,或流式模式下当前已缓冲长度 | | closed | boolean | 上游输入是否已结束 | | inCodeFence | boolean | 当前可见输出是否处于 fenced code block 内 | | lastChunk | string \| undefined | 如果状态变化由一次真实输出触发,这里会带上最后一次 chunk |

TypewriterCursorOptions

| 选项 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | className | string | 'incremark-typewriter-cursor' | 自定义光标 class | | autoScroll | boolean | true | 是否自动滚动以保持光标可见 |

核心数据结构

StableBlock

| 字段 | 说明 | | --- | --- | | key | block 唯一标识 | | text | block 原始源码 | | html | block 渲染后的 HTML | | tokens | block 对应的 marked token 列表 | | digest | 用于比较结构变化的摘要 | | stable | 当前 block 是否已经稳定 |

RenderPatch

| 字段 | 说明 | | --- | --- | | type | insertreplaceremove | | key | block key | | index | 当前可见 block 的索引 | | block | 新 block,按场景可选 | | previousBlock | 旧 block,按场景可选 | | astPatches | 可选的 token-tree diff 信息 |

StreamRendererSnapshot

| 字段 | 说明 | | --- | --- | | blocks | 当前可见 blocks | | stableCount | 当前稳定块数量 | | sourceLength | 当前累计输入长度 |

Demo

执行:

npm run demo

然后打开 http://127.0.0.1:4177/demo/

demo 页面可以直接验证:

  • chunk 级流式输入
  • 增量 patch 输出
  • block 快照和稳定块数量
  • 打字机播放与光标跟随
  • 代码高亮、自定义容器、数学公式
  • IncrementalDomRenderer 驱动的浏览器局部更新

安全说明

  • 默认启用 sanitizer。
  • 如果关闭 sanitizer,请把 Markdown 输入和所有自定义 HTML hook 都视为“仅可信输入可用”。
  • 业务方返回的自定义 HTML,本身也是安全边界的一部分。

致谢

本项目在稳定块边界检测思路上参考了开源项目 kingshuaishuai/incremark