feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,54 @@
# Vue3 ↔ React 逐组件对照表(markstream)
说明:
- Vue 源码在 `src/components/*`,React 源码在 `packages/markstream-react/src/components/*`。
- React 组件统一从 `packages/markstream-react/src/index.ts` 导出;样式来自 `markstream-react/index.css`(选择器前缀 `.markstream-react`)。
- 绝大多数“节点组件”在两端都是同名组件:输入 `node`(以及 renderer 透传的 `ctx` / `indexKey` / `customId` / `isDark` / `typewriter`),无额外 API 差异。
## 组件映射
| Vue 组件 | React 组件 | 备注(props / events / 样式) |
|---|---|---|
| `src/components/AdmonitionNode/AdmonitionNode.vue` | `packages/markstream-react/src/components/AdmonitionNode/AdmonitionNode.tsx` | 节点组件(`node` + renderer 透传字段) |
| `src/components/BlockquoteNode/BlockquoteNode.vue` | `packages/markstream-react/src/components/BlockquoteNode/BlockquoteNode.tsx` | 节点组件 |
| `src/components/CheckboxNode/CheckboxNode.vue` | `packages/markstream-react/src/components/CheckboxNode/CheckboxNode.tsx` | 节点组件 |
| `src/components/CodeBlockNode/CodeBlockNode.vue` | `packages/markstream-react/src/components/CodeBlockNode/CodeBlockNode.tsx` | `CodeBlockNodeProps` 两端一致(见 `src/types/component-props.ts` 与 `packages/markstream-react/src/types/component-props.ts`);Vue emits `copy`/`previewCode` ↔ React `onCopy`/`onPreviewCode` |
| `src/components/DefinitionListNode/DefinitionListNode.vue` | `packages/markstream-react/src/components/DefinitionListNode/DefinitionListNode.tsx` | 节点组件 |
| `src/components/EmojiNode/EmojiNode.vue` | `packages/markstream-react/src/components/EmojiNode/EmojiNode.tsx` | 节点组件 |
| `src/components/EmphasisNode/EmphasisNode.vue` | `packages/markstream-react/src/components/EmphasisNode/EmphasisNode.tsx` | 节点组件 |
| `src/components/FootnoteAnchorNode/FootnoteAnchorNode.vue` | `packages/markstream-react/src/components/FootnoteAnchorNode/FootnoteAnchorNode.tsx` | 节点组件 |
| `src/components/FootnoteNode/FootnoteNode.vue` | `packages/markstream-react/src/components/FootnoteNode/FootnoteNode.tsx` | 节点组件 |
| `src/components/FootnoteReferenceNode/FootnoteReferenceNode.vue` | `packages/markstream-react/src/components/FootnoteReferenceNode/FootnoteReferenceNode.tsx` | 节点组件 |
| `src/components/HardBreakNode/HardBreakNode.vue` | `packages/markstream-react/src/components/HardBreakNode/HardBreakNode.tsx` | 节点组件 |
| `src/components/HeadingNode/HeadingNode.vue` | `packages/markstream-react/src/components/HeadingNode/HeadingNode.tsx` | 节点组件 |
| `src/components/HighlightNode/HighlightNode.vue` | `packages/markstream-react/src/components/HighlightNode/HighlightNode.tsx` | 节点组件 |
| `src/components/HtmlBlockNode/HtmlBlockNode.vue` | `packages/markstream-react/src/components/HtmlBlockNode/HtmlBlockNode.tsx` | 节点组件 |
| `src/components/HtmlInlineNode/HtmlInlineNode.vue` | `packages/markstream-react/src/components/HtmlInlineNode/HtmlInlineNode.tsx` | 节点组件 |
| `src/components/ImageNode/ImageNode.vue` | `packages/markstream-react/src/components/ImageNode/ImageNode.tsx` | `ImageNodeProps` 两端一致;React 额外事件:`onLoad`/`onError`/`onClick`(不影响基础 API) |
| `src/components/InlineCodeNode/InlineCodeNode.vue` | `packages/markstream-react/src/components/InlineCodeNode/InlineCodeNode.tsx` | 节点组件 |
| `src/components/InsertNode/InsertNode.vue` | `packages/markstream-react/src/components/InsertNode/InsertNode.tsx` | 节点组件 |
| `src/components/LinkNode/LinkNode.vue` | `packages/markstream-react/src/components/LinkNode/LinkNode.tsx` | `LinkNodeProps` 两端一致;tooltip 使用单例实现(Vue `useSingletonTooltip` ↔ React `tooltip/singletonTooltip`) |
| `src/components/ListItemNode/ListItemNode.vue` | `packages/markstream-react/src/components/ListItemNode/ListItemNode.tsx` | 节点组件 |
| `src/components/ListNode/ListNode.vue` | `packages/markstream-react/src/components/ListNode/ListNode.tsx` | 节点组件 |
| `src/components/MarkdownCodeBlockNode/MarkdownCodeBlockNode.vue` | `packages/markstream-react/src/components/MarkdownCodeBlockNode/MarkdownCodeBlockNode.tsx` | 节点组件(markdown fenced code 的轻量渲染) |
| `src/components/MathBlockNode/MathBlockNode.vue` | `packages/markstream-react/src/components/MathBlockNode/MathBlockNode.tsx` | `MathBlockNodeProps` 两端一致 |
| `src/components/MathInlineNode/MathInlineNode.vue` | `packages/markstream-react/src/components/MathInlineNode/MathInlineNode.tsx` | `MathInlineNodeProps` 两端一致 |
| `src/components/MermaidBlockNode/MermaidBlockNode.vue` | `packages/markstream-react/src/components/MermaidBlockNode/MermaidBlockNode.tsx` | `MermaidBlockNodeProps` 两端一致;Vue emits `copy`/`export`/`open-modal`/`toggle-mode` ↔ React `onCopy`/`onExport`/`onOpenModal`/`onToggleMode`;全屏为独立 Portal 弹层(样式在 `packages/markstream-react/src/index.css` 的 `.mermaid-modal-*`) |
| `src/components/NodeRenderer/NodeRenderer.vue` | `packages/markstream-react/src/components/NodeRenderer.tsx` | 渲染器本体:增量渲染/虚拟列表/viewport priority;React 额外文件在 `packages/markstream-react/src/components/NodeRenderer/*` |
| `src/components/NodeRenderer/FallbackComponent.vue` | `packages/markstream-react/src/components/NodeRenderer/FallbackComponent.tsx` | 未识别节点兜底组件 |
| `src/components/ParagraphNode/ParagraphNode.vue` | `packages/markstream-react/src/components/ParagraphNode/ParagraphNode.tsx` | 节点组件 |
| `src/components/PreCodeNode/PreCodeNode.vue` | `packages/markstream-react/src/components/PreCodeNode/PreCodeNode.tsx` | React 实际实现位于 `packages/markstream-react/src/components/CodeBlockNode/PreCodeNode.tsx` 并在此处 re-export |
| `src/components/ReferenceNode/ReferenceNode.vue` | `packages/markstream-react/src/components/ReferenceNode/ReferenceNode.tsx` | 节点组件 |
| `src/components/StrikethroughNode/StrikethroughNode.vue` | `packages/markstream-react/src/components/StrikethroughNode/StrikethroughNode.tsx` | 节点组件 |
| `src/components/StrongNode/StrongNode.vue` | `packages/markstream-react/src/components/StrongNode/StrongNode.tsx` | 节点组件 |
| `src/components/SubscriptNode/SubscriptNode.vue` | `packages/markstream-react/src/components/SubscriptNode/SubscriptNode.tsx` | 节点组件 |
| `src/components/SuperscriptNode/SuperscriptNode.vue` | `packages/markstream-react/src/components/SuperscriptNode/SuperscriptNode.tsx` | 节点组件 |
| `src/components/TableNode/TableNode.vue` | `packages/markstream-react/src/components/TableNode/TableNode.tsx` | 节点组件(注意 table 内部 wrapper/`display: contents` 规则由 `markstream-react/index.css` 提供) |
| `src/components/TextNode/TextNode.vue` | `packages/markstream-react/src/components/TextNode/TextNode.tsx` | 节点组件 |
| `src/components/ThematicBreakNode/ThematicBreakNode.vue` | `packages/markstream-react/src/components/ThematicBreakNode/ThematicBreakNode.tsx` | 节点组件 |
| `src/components/Tooltip/Tooltip.vue` | `packages/markstream-react/src/components/Tooltip/Tooltip.tsx` | Tooltip 组件本体;常用调用方式是单例 tooltip API(两端一致思路) |
| `src/components/VmrContainerNode/VmrContainerNode.vue` | `packages/markstream-react/src/components/VmrContainerNode/VmrContainerNode.tsx` | 节点组件 |
## React 侧额外内部目录(无 Vue 对应)
- `packages/markstream-react/src/components/Math/*`:Math 渲染内部拆分实现(对外仍由 `MathInlineNode` / `MathBlockNode` 对齐 Vue API)。
@@ -0,0 +1,166 @@
# markstream-react
React renderer that consumes the structured AST output from `stream-markdown-parser` and renders it with lightweight semantic HTML components. This is the React counter-part to the Vue renderer that powers `markstream-vue`.
## Development
```bash
pnpm --filter markstream-react dev
```
## Build
```bash
pnpm --filter markstream-react build
pnpm --filter markstream-react build:analyze
pnpm --filter markstream-react size:check
```
## Usage
```tsx
import NodeRenderer from 'markstream-react'
import 'markstream-react/index.css'
export default function Article({ markdown }: { markdown: string }) {
return (
<NodeRenderer content={markdown} />
)
}
```
If your app scales root font size on mobile (`html` / `body`), use `markstream-react/index.px.css` to prevent `rem`-based global scaling side effects.
You can also pass a pre-parsed `nodes` array if you already have AST data.
## Streaming best practices
- For high-frequency SSE / token streaming, prefer parsing outside the component and pass `nodes` instead of reparsing the full `content` string every chunk.
- Keep `viewportPriority` enabled unless you explicitly want eager rendering. Mermaid / Monaco / D2 blocks now stay idle while offscreen and resume when they approach the viewport.
```tsx
import NodeRenderer from 'markstream-react'
export default function StreamView({ nodes, final }: { nodes: any[], final: boolean }) {
return (
<NodeRenderer
nodes={nodes}
final={final}
viewportPriority
deferNodesUntilVisible
/>
)
}
```
## Heavy-node prop forwarding
`NodeRenderer` can forward renderer-level props directly into Mermaid / D2 / Infographic blocks:
```tsx
<NodeRenderer
content={markdown}
mermaidProps={{
showHeader: false,
renderDebounceMs: 180,
previewPollDelayMs: 500,
}}
d2Props={{ progressiveIntervalMs: 500 }}
infographicProps={{ showHeader: false }}
/>
```
Notes:
- These props are forwarded to the built-in Mermaid / D2 / Infographic blocks and to custom `mermaid` / `d2` / `infographic` overrides registered with `setCustomComponents(...)`.
- `viewportPriority` applies to those heavy nodes too, so offscreen graphs will not keep doing background work while the text stream is still updating.
## Language-specific code block overrides
You can register a custom component under a fenced language key without wrapping the generic `code_block` renderer:
```tsx
import type { NodeComponentProps } from 'markstream-react'
import { setCustomComponents } from 'markstream-react'
function EChartsBlockNode(props: NodeComponentProps<any>) {
return <div data-language={String(props.node?.language)}>{String(props.node?.code || '')}</div>
}
setCustomComponents('docs', {
echarts: EChartsBlockNode,
})
```
Notes:
- `echarts` only catches fences whose language is `echarts`.
- Code block routing priority is exact language key -> built-in `mermaid` / `d2` / `infographic` routes -> `code_block`.
- Custom `mermaid` / `d2` / `infographic` overrides keep their specialized top-level props; other custom language keys use the normal custom component contract (`node`, `ctx`, `renderNode`, and friends).
## Mermaid tuning
Common `mermaidProps` keys for streaming scenarios:
- `renderDebounceMs`: delay progressive work during rapid token bursts.
- `contentStableDelayMs`: how long source mode waits before auto-switching back to preview when content stabilizes.
- `previewPollDelayMs`: initial delay before preview polling tries to upgrade a partial preview into a full render.
- `previewPollMaxDelayMs`: cap for preview polling backoff.
- `previewPollMaxAttempts`: maximum retry count while the Mermaid source is still incomplete.
## Bundle size notes
- Optional peers are not bundled; install only what you use (`stream-monaco`, `stream-markdown`, `mermaid`, `katex`, etc.).
- Infrequent language icons are split into an async chunk and loaded on demand.
- To avoid first-hit fallback icons, preload once when the app is idle:
```tsx
import { preloadExtendedLanguageIcons } from 'markstream-react'
if (typeof window !== 'undefined')
void preloadExtendedLanguageIcons()
```
## Tailwind
- Non-Tailwind projects: keep importing `markstream-react/index.css` (includes precompiled utilities for the renderer).
- Tailwind projects (avoid duplicate utilities): import `markstream-react/index.tailwind.css` and add `require('markstream-react/tailwind')` to your `tailwind.config.js` `content`.
## Custom components (e.g. `<thinking>`)
Custom tag-like blocks are exposed as nodes with `type: 'thinking'` (the tag name, no angle brackets) when you register the tag in `customHtmlTags` or register a custom component mapping for it.
```tsx
import type { NodeComponentProps } from 'markstream-react'
import NodeRenderer, { setCustomComponents } from 'markstream-react'
function ThinkingNode(props: NodeComponentProps<{ type: 'thinking', content: string }>) {
const { node, ctx } = props
return (
<div className="thinking-node">
<div className="thinking-title">Thinking</div>
<NodeRenderer
content={node.content}
customId={ctx?.customId}
isDark={ctx?.isDark}
typewriter={false}
batchRendering={false}
deferNodesUntilVisible={false}
viewportPriority={false}
maxLiveNodes={0}
/>
</div>
)
}
setCustomComponents('chat', { thinking: ThinkingNode })
```
## Type exports
`markstream-react` now exposes the core public types directly from the package root, including:
- `NodeRendererProps`
- `NodeComponentProps`
- `RenderContext`
- `RenderNodeFn`
- `CustomComponentMap`
- `CodeBlockMonacoOptions`
@@ -0,0 +1,144 @@
{
"name": "markstream-react",
"type": "module",
"version": "0.0.45",
"description": "React Markdown renderer optimized for large documents with progressive Mermaid rendering, streaming diff code blocks, and fast real-time preview. Built on stream-markdown AST for consistent rendering across frameworks. Perfect for documentation sites, AI chat interfaces, and content management systems.",
"author": "Simon He",
"license": "MIT",
"homepage": "https://github.com/Simon-He95/markstream-vue#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/Simon-He95/markstream-vue.git",
"directory": "packages/markstream-react"
},
"bugs": {
"url": "https://github.com/Simon-He95/markstream-vue/issues"
},
"keywords": [
"react",
"reactjs",
"markdown",
"markdown-to-html",
"markdown-renderer",
"react-markdown",
"react-markdown-renderer",
"react-markdown-component",
"markdown-preview",
"markdown-viewer",
"react-component",
"nextjs",
"next.js",
"remix",
"vite",
"webpack",
"sse",
"ai-chat",
"streaming-markdown",
"html",
"renderer",
"custom-component",
"mermaid",
"progressive-mermaid",
"streaming-diff",
"diff-codeblock",
"large-documents",
"performance",
"real-time-preview",
"markstream-react",
"markstream",
"documentation",
"docs",
"content-management",
"cms",
"markdown-parser",
"md-to-html",
"react-markdown-component"
],
"sideEffects": [
"**/*.css"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./next": {
"types": "./dist/next.d.ts",
"import": "./dist/next.js"
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.js"
},
"./index.css": "./dist/index.css",
"./index.px.css": "./dist/index.px.css",
"./index.tailwind.css": "./dist/index.tailwind.css",
"./tailwind": "./dist/tailwind.ts",
"./workers/katexRenderer.worker": "./dist/workers/katexRenderer.worker.js",
"./workers/mermaidParser.worker": "./dist/workers/mermaidParser.worker.js"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"dev": "vite dev",
"build": "vite build --mode npm && vite build -c vite.config.tailwind.ts --mode npm && node ./scripts/copy-tailwind-css.mjs && node ../../scripts/generate-px-css.mjs && pnpm run build:dts && node ./scripts/ensure-next-client-directive.mjs",
"build:analyze": "ANALYZE=true pnpm build",
"build:dts": "rollup -c ./scripts/rollup.dts.config.mjs && node ./scripts/clean-dts.cjs",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"size:check": "node ../../scripts/check-package-size.mjs",
"release": "bumpp --commit --no-tag --no-push && pnpm publish --access public && node ../../scripts/tag-package.mjs --package-json package.json --push"
},
"peerDependencies": {
"@antv/infographic": "^0.2.3",
"@terrastruct/d2": ">=0.1.33",
"katex": ">=0.16.22",
"mermaid": ">=11",
"react": ">=18",
"react-dom": ">=18",
"stream-markdown": ">=0.0.15",
"stream-monaco": ">=0.0.40"
},
"peerDependenciesMeta": {
"@antv/infographic": {
"optional": true
},
"@terrastruct/d2": {
"optional": true
},
"katex": {
"optional": true
},
"mermaid": {
"optional": true
},
"stream-markdown": {
"optional": true
},
"stream-monaco": {
"optional": true
}
},
"dependencies": {
"@floating-ui/dom": "^1.7.6",
"clsx": "^2.1.1",
"stream-markdown-parser": "workspace:*"
},
"devDependencies": {
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
"autoprefixer": "^10.4.27",
"rollup": "^3.30.0",
"rollup-plugin-dts": "^5.3.1",
"rollup-plugin-visualizer": "^6.0.11",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-dts": "^4.5.4"
}
}
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: { config: './tailwind.config.js' },
autoprefixer: {},
},
}
@@ -0,0 +1,26 @@
const fs = require('node:fs')
const path = require('node:path')
const typesDir = path.resolve(__dirname, '..', 'dist', 'types')
const distDir = path.resolve(__dirname, '..', 'dist')
function rewriteTypesEntry(content) {
return content
.replace(/from\s+(['"])\.\//g, 'from $1./types/')
.replace(/import\((['"])\.\//g, 'import($1./types/')
}
function copyEntry(entryName) {
const sourcePath = path.join(typesDir, `${entryName}.d.ts`)
const targetPath = path.join(distDir, `${entryName}.d.ts`)
if (!fs.existsSync(sourcePath))
return
const content = rewriteTypesEntry(fs.readFileSync(sourcePath, 'utf8'))
fs.writeFileSync(targetPath, content)
console.log('Rewrote', targetPath, 'from', sourcePath)
}
for (const entryName of ['index']) {
copyEntry(entryName)
}
@@ -0,0 +1,53 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
const fromDir = path.resolve('dist-tw')
const toDir = path.resolve('dist')
const fromCss = path.join(fromDir, 'index.tailwind.css')
const toCss = path.join(toDir, 'index.tailwind.css')
const primaryCss = path.join(toDir, 'index.css')
const fallbackPrimaryCss = path.join(toDir, 'markstream-react.css')
function stripTailwindDirectives(css) {
return css
.replace(/@tailwind\s+base\s*;?\s*/g, '')
.replace(/@tailwind\s+components\s*;?\s*/g, '')
.replace(/@tailwind\s+utilities\s*;?\s*/g, '')
.trimStart()
}
async function main() {
await fs.mkdir(toDir, { recursive: true })
try {
await fs.access(primaryCss)
}
catch {
try {
const css = await fs.readFile(fallbackPrimaryCss, 'utf8')
await fs.writeFile(primaryCss, css)
}
catch (err) {
const e = err
throw new Error(`[copy-tailwind-css] Missing ${primaryCss} and ${fallbackPrimaryCss}. Did the main npm build run? (${e?.message || e})`)
}
}
try {
const css = await fs.readFile(fromCss, 'utf8')
await fs.writeFile(toCss, stripTailwindDirectives(css))
}
catch (err) {
const e = err
throw new Error(`[copy-tailwind-css] Missing ${fromCss}. Did the tailwind build run? (${e?.message || e})`)
}
await fs.rm(fromDir, { recursive: true, force: true })
}
main().catch((err) => {
console.error(err)
process.exitCode = 1
})
@@ -0,0 +1,11 @@
import { readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const nextEntryPath = path.resolve(__dirname, '../dist/next.js')
const source = readFileSync(nextEntryPath, 'utf8')
if (!source.startsWith('\'use client\';')) {
writeFileSync(nextEntryPath, `'use client';\n${source}`)
}
@@ -0,0 +1,50 @@
import fs from 'node:fs'
import path from 'node:path'
import dts from 'rollup-plugin-dts'
const entryNames = ['index', 'next', 'server']
function resolveInputPath(entryName) {
const preferred = [
`./dist/types/${entryName}.d.ts`,
entryName === 'index' ? './dist/types/exports.d.ts' : null,
`./dist/${entryName}.d.ts`,
].filter(Boolean)
return preferred.find(p => fs.existsSync(path.resolve(p))) ?? null
}
const configs = entryNames.map((entryName) => {
const inputPath = resolveInputPath(entryName)
if (!inputPath) {
throw new Error(
`No declaration entry found for "${entryName}". Run \`pnpm run build\` before running \`build:dts\`.`,
)
}
return {
input: inputPath,
plugins: [
dts({
respectExternal: false,
}),
],
external: [
/^node:.*$/,
/^react(?:\/.*)?$/,
/^react-dom(?:\/.*)?$/,
/^(?:katex|mermaid|stream-monaco|stream-markdown)(?:\/.*)?$/,
/^stream-markdown-parser(?:\/.*)?$/,
/^@antv\/infographic(?:\/.*)?$/,
/^@floating-ui\/dom(?:\/.*)?$/,
],
output: [
{
file: `dist/${entryName}.d.ts`,
format: 'es',
},
],
}
})
export default configs
@@ -0,0 +1,62 @@
import type { ParsedNode } from 'stream-markdown-parser'
import type { NodeComponentProps } from '../../types/node-component'
import React, { useMemo, useState } from 'react'
import { renderNodeChildren } from '../../renderers/renderChildren'
const ICON_MAP: Record<string, string> = {
note: 'ℹ️',
info: 'ℹ️',
tip: '💡',
warning: '⚠️',
danger: '❗',
error: '⛔',
caution: '⚠️',
}
export function AdmonitionNode(props: NodeComponentProps<{
type: 'admonition'
kind?: string
title?: string
children?: ParsedNode[]
collapsible?: boolean
open?: boolean
}>) {
const { node, ctx, renderNode, indexKey, isDark } = props
const kind = String(node.kind || 'note')
const displayTitle = useMemo(() => {
if (node.title && String(node.title).trim().length)
return String(node.title)
return kind.charAt(0).toUpperCase() + kind.slice(1)
}, [kind, node.title])
const [collapsed, setCollapsed] = useState(() => node.collapsible ? !(node.open ?? true) : false)
const headerId = useMemo(() => `admonition-${Math.random().toString(36).slice(2, 9)}`, [])
return (
<div className={`admonition admonition-${kind}${isDark ? ' is-dark' : ''}`} data-index-key={indexKey}>
<div id={headerId} className="admonition-header">
{ICON_MAP[kind] && <span className="admonition-icon">{ICON_MAP[kind]}</span>}
<span className="admonition-title">{displayTitle}</span>
{node.collapsible && (
<button
type="button"
className="admonition-toggle"
aria-expanded={!collapsed}
aria-controls={`${headerId}-content`}
title={collapsed ? 'Expand' : 'Collapse'}
onClick={() => setCollapsed(v => !v)}
>
<span>{collapsed ? '▶' : '▼'}</span>
</button>
)}
</div>
{!collapsed && (
<div id={`${headerId}-content`} className="admonition-content" aria-labelledby={headerId}>
{ctx && renderNode ? renderNodeChildren(node.children, ctx, String(indexKey ?? headerId), renderNode) : null}
</div>
)}
</div>
)
}
export default AdmonitionNode
@@ -0,0 +1,38 @@
import type { ParsedNode } from 'stream-markdown-parser'
import type { NodeComponentProps } from '../../types/node-component'
import React from 'react'
import { NodeRenderer } from '../NodeRenderer'
export function BlockquoteNode(props: NodeComponentProps<{ type: 'blockquote', children?: ParsedNode[] }>) {
const { node, ctx, renderNode, indexKey, children } = props
return (
<blockquote className="blockquote-node" dir="auto" cite={(node as any).cite}>
{children ?? (ctx && renderNode
? (
<NodeRenderer
nodes={node.children || []}
indexKey={`blockquote-${String(indexKey ?? 'blockquote')}`}
customId={ctx.customId}
isDark={ctx.isDark}
typewriter={ctx.typewriter}
viewportPriority
codeBlockStream={ctx.codeBlockStream}
renderCodeBlocksAsPre={ctx.renderCodeBlocksAsPre}
codeBlockProps={ctx.codeBlockProps}
themes={ctx.codeBlockThemes?.themes}
codeBlockDarkTheme={ctx.codeBlockThemes?.darkTheme}
codeBlockLightTheme={ctx.codeBlockThemes?.lightTheme}
codeBlockMonacoOptions={ctx.codeBlockThemes?.monacoOptions}
codeBlockMinWidth={ctx.codeBlockThemes?.minWidth}
codeBlockMaxWidth={ctx.codeBlockThemes?.maxWidth}
showTooltips={ctx.showTooltips}
onCopy={ctx.events.onCopy}
onHandleArtifactClick={ctx.events.onHandleArtifactClick}
/>
)
: null)}
</blockquote>
)
}
export default BlockquoteNode
@@ -0,0 +1,19 @@
import type { NodeComponentProps } from '../../types/node-component'
import React from 'react'
export function CheckboxNode(props: NodeComponentProps<{ type: 'checkbox' | 'checkbox_input', checked?: boolean }>) {
const { node } = props
return (
<span className="checkbox-node">
<input
type="checkbox"
checked={Boolean(node.checked)}
disabled
className="checkbox-input"
readOnly
/>
</span>
)
}
export default CheckboxNode
@@ -0,0 +1,90 @@
import React, { useEffect, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { useSafeI18n } from '../../i18n/useSafeI18n'
export interface HtmlPreviewFrameProps {
code: string
isDark?: boolean
onClose?: () => void
title?: string
}
export function HtmlPreviewFrame(props: HtmlPreviewFrameProps) {
const { t } = useSafeI18n()
const srcdoc = useMemo(() => {
const base = props.code || ''
const lowered = base.trim().toLowerCase()
if (lowered.startsWith('<!doctype') || lowered.startsWith('<html') || lowered.startsWith('<body'))
return base
const bg = props.isDark ? '#020617' : '#ffffff'
const fg = props.isDark ? '#e5e7eb' : '#020617'
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
background-color: ${bg};
color: ${fg};
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'SF Pro Text', ui-sans-serif, sans-serif;
}
</style>
</head>
<body>
${base}
</body>
</html>`
}, [props.code, props.isDark])
useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' || event.key === 'Esc')
props.onClose?.()
}
if (typeof window !== 'undefined')
window.addEventListener('keydown', handleKeydown)
return () => {
if (typeof window !== 'undefined')
window.removeEventListener('keydown', handleKeydown)
}
}, [props])
if (typeof document === 'undefined')
return null
return createPortal(
<div className={`html-preview-frame__backdrop${props.isDark ? ' html-preview-frame__backdrop--dark' : ''}`} onClick={() => props.onClose?.()}>
<div className={`html-preview-frame${props.isDark ? ' html-preview-frame--dark' : ''}`} onClick={e => e.stopPropagation()}>
<div className="html-preview-frame__header">
<div className="html-preview-frame__title">
<span className="html-preview-frame__dot" />
<span className="html-preview-frame__label">{props.title || `HTML ${t('common.preview')}`}</span>
</div>
<button
type="button"
className={`html-preview-frame__close${props.isDark ? ' html-preview-frame__close--dark' : ''}`}
onClick={() => props.onClose?.()}
>
×
</button>
</div>
<iframe
className="html-preview-frame__iframe"
sandbox="allow-scripts allow-same-origin"
src="about:blank"
srcDoc={srcdoc}
title={props.title || 'Preview'}
/>
</div>
</div>,
document.body,
)
}
export default HtmlPreviewFrame
@@ -0,0 +1,26 @@
import type { PreCodeNodeProps } from '../../types/component-props'
import React, { useMemo } from 'react'
function normalizeLanguage(raw: unknown) {
const head = String(String(raw ?? '').split(/\s+/g)[0] ?? '').toLowerCase()
const safe = head.replace(/[^\w-]/g, '')
return safe || 'plaintext'
}
export function PreCodeNode({ node }: PreCodeNodeProps) {
const normalizedLanguage = useMemo(() => normalizeLanguage((node as any)?.language), [node])
const languageClass = `language-${normalizedLanguage}`
const ariaLabel = normalizedLanguage ? `Code block: ${normalizedLanguage}` : 'Code block'
return (
<pre
className={languageClass}
aria-busy={(node as any)?.loading === true}
aria-label={ariaLabel}
data-language={normalizedLanguage}
tabIndex={0}
>
<code translate="no">{String((node as any)?.code ?? '')}</code>
</pre>
)
}
@@ -0,0 +1,20 @@
import { preload } from '../NodeRenderer/preloadMonaco'
let mod: any = null
let importAttempted = false
export async function getUseMonaco() {
if (mod)
return mod
if (importAttempted)
return null
try {
mod = await import('stream-monaco')
await preload(mod)
return mod
}
catch {
importAttempted = true
return null
}
}
@@ -0,0 +1,94 @@
import { resetLastAppliedThemeKey, scheduleMonacoThemeUpdate } from './monacoThemeScheduler'
type MonacoTheme = any
type SetThemeFn = (theme: MonacoTheme, force?: boolean) => Promise<void> | void
let desiredTheme: MonacoTheme | null = null
let desiredKey: string | null = null
let setThemeImpl: SetThemeFn | null = null
const listeners = new Set<() => void>()
const themeKeyCache = new WeakMap<object, string>()
let themeKeySeq = 0
function themeKey(theme: MonacoTheme): string | null {
if (theme == null)
return null
if (typeof theme === 'string')
return theme
if (typeof theme === 'object' && theme && 'name' in theme)
return String((theme as any).name)
if (typeof theme === 'object') {
const cached = themeKeyCache.get(theme)
if (cached)
return cached
try {
const str = JSON.stringify(theme)
if (str) {
themeKeyCache.set(theme, str)
return str
}
}
catch {}
const id = `__theme_${++themeKeySeq}`
themeKeyCache.set(theme, id)
return id
}
return String(theme)
}
function notify() {
for (const listener of Array.from(listeners)) {
try {
listener()
}
catch {}
}
}
export function subscribeMonacoThemeApplied(listener: () => void) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
export function registerMonacoThemeSetter(setTheme: SetThemeFn | null | undefined) {
if (typeof setTheme !== 'function')
return
const isNewSetter = setThemeImpl !== setTheme
setThemeImpl = setTheme
if (isNewSetter) {
resetLastAppliedThemeKey()
}
console.log('[AQBot Theme Debug] registry: new setter registered, desiredKey:', desiredKey, 'isNew:', isNewSetter)
if (desiredTheme != null && desiredKey != null) {
void scheduleMonacoThemeUpdate(desiredTheme, setThemeImpl)
.then(() => notify())
.catch(() => {})
}
}
export function setDesiredMonacoTheme(theme: MonacoTheme | null | undefined) {
if (theme == null)
return
const key = themeKey(theme)
if (!key)
return
if (desiredKey === key) {
console.log('[AQBot Theme Debug] registry: desiredKey === key, skipping:', key)
return
}
desiredTheme = theme
desiredKey = key
console.log('[AQBot Theme Debug] registry: setDesiredMonacoTheme:', key, 'hasImpl:', !!setThemeImpl)
if (!setThemeImpl)
return
void scheduleMonacoThemeUpdate(desiredTheme, setThemeImpl)
.then(() => notify())
.catch(() => {})
}
export function getDesiredMonacoTheme(): MonacoTheme | null {
return desiredTheme
}
@@ -0,0 +1,97 @@
type MonacoTheme = any
type SetThemeFn = (theme: MonacoTheme, force?: boolean) => Promise<void> | void
let setThemeImpl: SetThemeFn | null = null
let applying = false
let inFlight: Promise<void> | null = null
let inFlightKey: string | null = null
let pendingTheme: MonacoTheme | null = null
let pendingKey: string | null = null
let lastAppliedKey: string | null = null
const themeKeyCache = new WeakMap<object, string>()
let themeKeySeq = 0
function themeKey(theme: MonacoTheme): string | null {
if (theme == null)
return null
if (typeof theme === 'string')
return theme
if (typeof theme === 'object' && theme && 'name' in theme)
return String((theme as any).name)
if (typeof theme === 'object') {
const cached = themeKeyCache.get(theme)
if (cached)
return cached
try {
const str = JSON.stringify(theme)
if (str) {
themeKeyCache.set(theme, str)
return str
}
}
catch {}
const id = `__theme_${++themeKeySeq}`
themeKeyCache.set(theme, id)
return id
}
return String(theme)
}
export function resetLastAppliedThemeKey() {
lastAppliedKey = null
}
export function scheduleMonacoThemeUpdate(theme: MonacoTheme, setTheme: SetThemeFn): Promise<void> {
const key = themeKey(theme)
if (!key)
return Promise.resolve()
if (setThemeImpl !== setTheme)
setThemeImpl = setTheme
if (!applying && lastAppliedKey === key) {
console.log('[AQBot Theme Debug] scheduler: skipping, lastAppliedKey === key:', key)
return Promise.resolve()
}
if (inFlight && (pendingKey === key || inFlightKey === key))
return inFlight
pendingTheme = theme
pendingKey = key
if (inFlight)
return inFlight
applying = true
inFlight = (async () => {
while (pendingTheme != null && pendingKey != null) {
const nextTheme = pendingTheme
const nextKey = pendingKey
pendingTheme = null
pendingKey = null
if (lastAppliedKey === nextKey)
continue
const impl = setThemeImpl
if (!impl)
break
try {
inFlightKey = nextKey
console.log('[AQBot Theme Debug] scheduler: applying theme:', nextKey)
await Promise.resolve(impl(nextTheme))
lastAppliedKey = nextKey
console.log('[AQBot Theme Debug] scheduler: applied successfully:', nextKey)
}
catch (err) {
console.error('[AQBot Theme Debug] scheduler: apply failed:', nextKey, err)
}
}
})().finally(() => {
applying = false
inFlight = null
inFlightKey = null
})
return inFlight
}
@@ -0,0 +1,573 @@
import type { VisibilityHandle } from '../../context/viewportPriority'
import type { D2BlockNodeProps } from '../../types/component-props'
import clsx from 'clsx'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useViewportPriority } from '../../context/viewportPriority'
import { useSafeI18n } from '../../i18n/useSafeI18n'
import { hideTooltip, showTooltipForAnchor } from '../../tooltip/singletonTooltip'
import { getD2 } from './d2'
const DEFAULTS = {
maxHeight: '500px',
loading: true,
progressiveRender: true,
progressiveIntervalMs: 700,
showHeader: true,
showModeToggle: true,
showCopyButton: true,
showExportButton: true,
showCollapseButton: true,
}
const DARK_THEME_OVERRIDES: Record<string, string> = {
N1: '#E5E7EB',
N2: '#CBD5E1',
N3: '#94A3B8',
N4: '#64748B',
N5: '#475569',
N6: '#334155',
N7: '#0B1220',
B1: '#60A5FA',
B2: '#3B82F6',
B3: '#2563EB',
B4: '#1D4ED8',
B5: '#1E40AF',
B6: '#111827',
AA2: '#22D3EE',
AA4: '#0EA5E9',
AA5: '#0284C7',
AB4: '#FBBF24',
AB5: '#F59E0B',
}
const DISALLOWED_STYLE_PATTERNS = [/javascript:/i, /expression\s*\(/i, /url\s*\(\s*javascript:/i, /@import/i]
const SAFE_URL_PROTOCOLS = /^(?:https?:|mailto:|tel:|#|\/|data:image\/(?:png|gif|jpe?g|webp);)/i
function neutralizeScriptProtocols(raw: string) {
return raw
.replace(/["']\s*javascript:/gi, '#')
.replace(/\bjavascript:/gi, '#')
.replace(/["']\s*vbscript:/gi, '#')
.replace(/\bvbscript:/gi, '#')
.replace(/\bdata:text\/html/gi, '#')
}
function sanitizeUrl(value: string | null | undefined) {
if (!value)
return ''
const trimmed = value.trim()
if (SAFE_URL_PROTOCOLS.test(trimmed))
return trimmed
return ''
}
function scrubSvgElement(svgEl: SVGElement) {
const forbiddenTags = new Set(['script'])
const nodes = [svgEl, ...Array.from(svgEl.querySelectorAll<SVGElement>('*'))]
for (const node of nodes) {
if (forbiddenTags.has(node.tagName.toLowerCase())) {
node.remove()
continue
}
const attrs = Array.from(node.attributes)
for (const attr of attrs) {
const name = attr.name
if (/^on/i.test(name)) {
node.removeAttribute(name)
continue
}
if (name === 'style' && attr.value) {
const val = attr.value
if (DISALLOWED_STYLE_PATTERNS.some(re => re.test(val))) {
node.removeAttribute(name)
continue
}
}
if ((name === 'href' || name === 'xlink:href') && attr.value) {
const safe = sanitizeUrl(attr.value)
if (!safe) {
node.removeAttribute(name)
continue
}
if (safe !== attr.value)
node.setAttribute(name, safe)
}
}
}
}
function toSafeSvgMarkup(svg: string | null | undefined) {
if (typeof window === 'undefined' || typeof DOMParser === 'undefined')
return ''
if (!svg)
return ''
const neutralized = neutralizeScriptProtocols(svg)
const parsed = new DOMParser().parseFromString(neutralized, 'image/svg+xml')
const svgEl = parsed.documentElement
if (!svgEl || svgEl.nodeName.toLowerCase() !== 'svg')
return ''
const svgElement = svgEl as unknown as SVGElement
scrubSvgElement(svgElement)
svgElement.classList.add('markstream-d2-root-svg')
return svgElement.outerHTML
}
function extractSvg(renderResult: any) {
if (!renderResult)
return ''
if (typeof renderResult === 'string')
return renderResult
if (typeof renderResult.svg === 'string')
return renderResult.svg
if (typeof renderResult.data === 'string')
return renderResult.data
return ''
}
function shouldSkipEventTarget(target: EventTarget | null) {
const btn = target as HTMLButtonElement | null
return !btn || btn.disabled
}
export function D2BlockNode(rawProps: D2BlockNodeProps) {
const props = { ...DEFAULTS, ...rawProps }
const { t } = useSafeI18n()
const [copying, setCopying] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(false)
const [showSource, setShowSource] = useState(false)
const [d2Available, setD2Available] = useState(false)
const [renderError, setRenderError] = useState<string | null>(null)
const [svgMarkup, setSvgMarkup] = useState('')
const [bodyMinHeight, setBodyMinHeight] = useState<number | null>(null)
const [isRendering, setIsRendering] = useState(false)
const [viewportReady, setViewportReady] = useState(() => typeof window === 'undefined')
const renderTokenRef = useRef(0)
const d2InstanceRef = useRef<any | null>(null)
const scheduledRef = useRef(false)
const unmountedRef = useRef(false)
const lastRenderAtRef = useRef(0)
const throttleTimerRef = useRef<number | null>(null)
const pendingRenderRef = useRef(false)
const viewportTargetRef = useRef<HTMLDivElement | null>(null)
const viewportHandleRef = useRef<VisibilityHandle | null>(null)
const bodyRef = useRef<HTMLDivElement | null>(null)
const bodyObserverRef = useRef<ResizeObserver | null>(null)
const isRenderingRef = useRef(false)
const renderDiagramRef = useRef<() => void>(() => {})
const registerViewport = useViewportPriority()
const baseCode = props.node?.code ?? ''
const showSourceFallback = showSource || !d2Available || !svgMarkup
const hasPreview = !!svgMarkup
const renderStyle = useMemo(() => {
if (!props.maxHeight)
return undefined
const max = typeof props.maxHeight === 'number' ? `${props.maxHeight}px` : String(props.maxHeight)
return { maxHeight: max }
}, [props.maxHeight])
const bodyStyle = useMemo(() => {
if (!showSourceFallback || !bodyMinHeight)
return undefined
return { minHeight: `${bodyMinHeight}px` }
}, [bodyMinHeight, showSourceFallback])
useEffect(() => {
isRenderingRef.current = isRendering
}, [isRendering])
useEffect(() => {
const el = viewportTargetRef.current
if (!el)
return
const handle = registerViewport(el, { rootMargin: '160px' })
viewportHandleRef.current = handle
if (handle.isVisible())
setViewportReady(true)
handle.whenVisible.then(() => setViewportReady(true))
return () => {
handle.destroy()
viewportHandleRef.current = null
}
}, [registerViewport])
const updateBodyMinHeight = useCallback(() => {
const el = bodyRef.current
if (!el)
return
const height = el.getBoundingClientRect().height
if (height > 0) {
setBodyMinHeight(prev => (prev !== height ? height : prev))
}
}, [])
const scheduleRender = useCallback((force = false) => {
if (scheduledRef.current || typeof window === 'undefined')
return
if (unmountedRef.current)
return
if (!viewportReady)
return
if (isRenderingRef.current) {
pendingRenderRef.current = true
return
}
const interval = Math.max(120, Number(props.progressiveIntervalMs) || 0)
const now = Date.now()
const elapsed = now - lastRenderAtRef.current
if (!force && elapsed < interval) {
pendingRenderRef.current = true
if (throttleTimerRef.current == null) {
throttleTimerRef.current = window.setTimeout(() => {
throttleTimerRef.current = null
if (pendingRenderRef.current) {
pendingRenderRef.current = false
scheduleRender(true)
}
}, Math.max(0, interval - elapsed))
}
return
}
scheduledRef.current = true
const runner = () => {
scheduledRef.current = false
lastRenderAtRef.current = Date.now()
renderDiagramRef.current()
}
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function')
window.requestAnimationFrame(runner)
else
setTimeout(runner, 0)
}, [props.progressiveIntervalMs, viewportReady])
const ensureD2Instance = useCallback(async () => {
if (d2InstanceRef.current)
return d2InstanceRef.current
const D2Ctor = await getD2()
if (!D2Ctor)
return null
if (typeof D2Ctor === 'function') {
const inst = new D2Ctor()
if (inst && typeof inst.compile === 'function')
d2InstanceRef.current = inst
else if (typeof (D2Ctor as any).compile === 'function')
d2InstanceRef.current = D2Ctor
return d2InstanceRef.current
}
if (D2Ctor?.D2 && typeof D2Ctor.D2 === 'function') {
d2InstanceRef.current = new D2Ctor.D2()
return d2InstanceRef.current
}
if (typeof D2Ctor.compile === 'function')
d2InstanceRef.current = D2Ctor
return d2InstanceRef.current
}, [])
const renderDiagram = useCallback(async () => {
if (typeof window === 'undefined' || unmountedRef.current)
return
if (!viewportReady)
return
if (props.loading && !props.progressiveRender)
return
if (!baseCode) {
setSvgMarkup('')
setRenderError(null)
return
}
const token = ++renderTokenRef.current
setIsRendering(true)
setRenderError(null)
try {
const instance = await ensureD2Instance()
if (!instance) {
setD2Available(false)
setShowSource(true)
setSvgMarkup('')
setRenderError('D2 is not available.')
return
}
if (typeof instance.compile !== 'function' || typeof instance.render !== 'function')
throw new Error('D2 instance is missing compile/render methods.')
setD2Available(true)
const compileResult = await instance.compile(baseCode)
if (token !== renderTokenRef.current)
return
const diagram = compileResult?.diagram ?? compileResult
const baseRenderOptions = compileResult?.renderOptions ?? compileResult?.options ?? {}
const resolvedThemeId = props.themeId ?? baseRenderOptions.themeID
const resolvedDarkThemeId = props.darkThemeId ?? baseRenderOptions.darkThemeID
const renderOptions: Record<string, any> = { ...baseRenderOptions }
renderOptions.themeID = props.isDark && resolvedDarkThemeId != null
? resolvedDarkThemeId
: resolvedThemeId
renderOptions.darkThemeID = null
renderOptions.darkThemeOverrides = null
if (props.isDark) {
const baseOverrides = baseRenderOptions.themeOverrides && typeof baseRenderOptions.themeOverrides === 'object'
? baseRenderOptions.themeOverrides
: null
renderOptions.themeOverrides = {
...DARK_THEME_OVERRIDES,
...(baseOverrides || {}),
}
}
const renderResult = await instance.render(diagram, renderOptions)
if (token !== renderTokenRef.current)
return
const svg = extractSvg(renderResult)
if (!svg)
throw new Error('D2 render returned empty output.')
const safe = toSafeSvgMarkup(svg)
setSvgMarkup(safe || '')
if (props.loading)
setShowSource(false)
setRenderError(null)
}
catch (err: any) {
if (token !== renderTokenRef.current)
return
const message = err?.message ? String(err.message) : 'D2 render failed.'
if (!props.loading)
setRenderError(message)
if (message.includes('@terrastruct/d2')) {
setD2Available(false)
setShowSource(true)
}
}
finally {
if (token === renderTokenRef.current) {
setIsRendering(false)
if (pendingRenderRef.current) {
pendingRenderRef.current = false
scheduleRender()
}
}
}
}, [baseCode, ensureD2Instance, props.darkThemeId, props.isDark, props.loading, props.progressiveRender, props.themeId, scheduleRender, viewportReady])
useEffect(() => {
renderDiagramRef.current = renderDiagram
}, [renderDiagram])
const handleCopy = useCallback(async () => {
try {
if (navigator?.clipboard?.writeText)
await navigator.clipboard.writeText(baseCode)
setCopying(true)
setTimeout(() => setCopying(false), 1000)
}
catch {}
}, [baseCode])
const handleExport = useCallback(() => {
if (!svgMarkup)
return
try {
const blob = new Blob([svgMarkup], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `d2-diagram-${Date.now()}.svg`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
catch (error) {
console.error('Failed to export SVG:', error)
}
}, [svgMarkup])
const onBtnHover = useCallback((e: React.MouseEvent, text: string) => {
if (shouldSkipEventTarget(e.currentTarget))
return
showTooltipForAnchor(e.currentTarget as HTMLElement, text, 'top', false, { x: e.clientX, y: e.clientY }, props.isDark)
}, [props.isDark])
const onBtnLeave = useCallback(() => {
hideTooltip()
}, [])
useEffect(() => {
scheduleRender()
}, [baseCode, props.loading, props.isDark, scheduleRender])
useEffect(() => {
if (props.loading)
return
scheduleRender(true)
}, [props.loading, scheduleRender])
useEffect(() => {
const raf = requestAnimationFrame(() => updateBodyMinHeight())
return () => cancelAnimationFrame(raf)
}, [showSourceFallback, svgMarkup, baseCode, updateBodyMinHeight])
useEffect(() => {
unmountedRef.current = false
scheduleRender()
const raf = requestAnimationFrame(() => updateBodyMinHeight())
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(() => {
updateBodyMinHeight()
})
bodyObserverRef.current = observer
if (bodyRef.current)
observer.observe(bodyRef.current)
}
return () => {
unmountedRef.current = true
if (throttleTimerRef.current != null) {
clearTimeout(throttleTimerRef.current)
throttleTimerRef.current = null
}
bodyObserverRef.current?.disconnect()
bodyObserverRef.current = null
cancelAnimationFrame(raf)
}
}, [scheduleRender, updateBodyMinHeight])
return (
<div
ref={viewportTargetRef}
className={clsx(
'd2-block my-4 rounded-lg border overflow-hidden shadow-sm',
props.isDark ? 'border-gray-700/30 bg-gray-900 text-gray-100' : 'border-gray-200 bg-white text-gray-900',
)}
>
{props.showHeader && (
<div
className="d2-block-header flex justify-between items-center px-4 py-2.5 border-b border-gray-400/5"
style={{ color: 'var(--vscode-editor-foreground)', backgroundColor: 'var(--vscode-editor-background)' }}
>
<div className="flex items-center gap-x-2">
<span className="text-sm font-medium font-mono">D2</span>
</div>
<div className="flex items-center gap-x-2">
{props.showModeToggle && (
<div className={clsx('flex items-center gap-x-1 rounded-md p-0.5', props.isDark ? 'bg-gray-700' : 'bg-gray-100')}>
<button
type="button"
className={clsx('mode-btn px-2 py-1 text-xs rounded', !showSource && 'is-active')}
onClick={() => setShowSource(false)}
onMouseEnter={e => onBtnHover(e, t('common.preview') || 'Preview')}
onFocus={e => onBtnHover(e as any, t('common.preview') || 'Preview')}
onMouseLeave={onBtnLeave}
onBlur={onBtnLeave}
>
{t('common.preview') || 'Preview'}
</button>
<button
type="button"
className={clsx('mode-btn px-2 py-1 text-xs rounded', showSource && 'is-active')}
onClick={() => setShowSource(true)}
onMouseEnter={e => onBtnHover(e, t('common.source') || 'Source')}
onFocus={e => onBtnHover(e as any, t('common.source') || 'Source')}
onMouseLeave={onBtnLeave}
onBlur={onBtnLeave}
>
{t('common.source') || 'Source'}
</button>
</div>
)}
{props.showCopyButton && (
<button
type="button"
className="d2-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
aria-label={copying ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy')}
onClick={handleCopy}
onMouseEnter={e => onBtnHover(e, copying ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy'))}
onFocus={e => onBtnHover(e as any, copying ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy'))}
onMouseLeave={onBtnLeave}
onBlur={onBtnLeave}
>
{!copying
? (
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3">
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</g>
</svg>
)
: (
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 6L9 17l-5-5" /></svg>
)}
</button>
)}
{props.showExportButton && svgMarkup && (
<button
type="button"
className="d2-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
aria-label={t('common.export') || 'Export'}
onClick={handleExport}
onMouseEnter={e => onBtnHover(e, t('common.export') || 'Export')}
onFocus={e => onBtnHover(e as any, t('common.export') || 'Export')}
onMouseLeave={onBtnLeave}
onBlur={onBtnLeave}
>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 3v12m0-12l-4 4m4-4l4 4M4 14v4a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4" /></svg>
</button>
)}
{props.showCollapseButton && (
<button
type="button"
className="d2-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
aria-pressed={isCollapsed}
onClick={() => setIsCollapsed(prev => !prev)}
onMouseEnter={e => onBtnHover(e, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))}
onFocus={e => onBtnHover(e as any, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))}
onMouseLeave={onBtnLeave}
onBlur={onBtnLeave}
>
<svg style={{ transform: `rotate(${isCollapsed ? 0 : 90}deg)` }} xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m9 18l6-6l-6-6" /></svg>
</button>
)}
</div>
</div>
)}
{!isCollapsed && (
<div ref={bodyRef} className="d2-block-body" style={bodyStyle}>
{props.loading && !hasPreview
? (
<div className="d2-source px-4 py-4">
<pre className="d2-code"><code>{baseCode}</code></pre>
{renderError && <p className="d2-error mt-2 text-xs">{renderError}</p>}
</div>
)
: (
<>
{showSourceFallback
? (
<div className="d2-source px-4 py-4">
<pre className="d2-code"><code>{baseCode}</code></pre>
{renderError && <p className="d2-error mt-2 text-xs">{renderError}</p>}
</div>
)
: (
<div className="d2-render" style={renderStyle}>
<div className="d2-svg" dangerouslySetInnerHTML={{ __html: svgMarkup }} />
{renderError && <p className="d2-error px-4 pb-3 text-xs">{renderError}</p>}
</div>
)}
</>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,67 @@
export type D2Loader = () => Promise<any> | any
const defaultD2Loader: D2Loader = () => import('@terrastruct/d2')
let cachedD2: any = null
let d2Loader: D2Loader | null = defaultD2Loader
function resetCachedD2() {
cachedD2 = null
}
function normalizeD2Module(mod: any) {
if (!mod)
return mod
if (mod.D2 && typeof mod.D2 === 'function')
return mod.D2
if (mod.default && mod.default.D2 && typeof mod.default.D2 === 'function')
return mod.default.D2
const candidate = mod.default ?? mod
if (typeof candidate === 'function')
return candidate
if (candidate?.D2 && typeof candidate.D2 === 'function')
return candidate.D2
return candidate
}
export function setD2Loader(loader: D2Loader | null) {
d2Loader = loader
resetCachedD2()
}
export function enableD2(loader?: D2Loader) {
setD2Loader(loader ?? defaultD2Loader)
}
export function disableD2() {
setD2Loader(null)
}
export function isD2Enabled() {
return typeof d2Loader === 'function'
}
export async function getD2() {
if (cachedD2)
return cachedD2
const loader = d2Loader
if (!loader)
return null
let mod: any
try {
mod = await loader()
}
catch (err) {
if (loader === defaultD2Loader) {
throw new Error('Optional dependency "@terrastruct/d2" is not installed. Please install it to enable D2 diagrams.')
}
throw err
}
if (!mod)
return null
cachedD2 = normalizeD2Module(mod)
return cachedD2
}
@@ -0,0 +1,2 @@
export { D2BlockNode } from './D2BlockNode'
export { D2BlockNode as default } from './D2BlockNode'
@@ -0,0 +1,24 @@
import type { NodeComponentProps } from '../../types/node-component'
import React from 'react'
import { renderInline } from '../../renderers/renderChildren'
export function DefinitionListNode(props: NodeComponentProps<{ type: 'definition_list', items?: any[] }>) {
const { node, ctx, renderNode, indexKey } = props
const items = Array.isArray(node.items) ? node.items : []
return (
<dl className="definition-list" data-index-key={indexKey}>
{items.map((item: any, idx: number) => (
<div key={`${String(indexKey ?? 'definition')}-${idx}`} className="mb-4">
<dt className="definition-term font-semibold">
{ctx && renderNode ? renderInline(item.term, ctx, `${String(indexKey ?? 'definition')}-term-${idx}`, renderNode) : null}
</dt>
<dd className="definition-desc ml-4">
{ctx && renderNode ? renderInline(item.definition, ctx, `${String(indexKey ?? 'definition')}-desc-${idx}`, renderNode) : null}
</dd>
</div>
))}
</dl>
)
}
export default DefinitionListNode
@@ -0,0 +1,13 @@
import type { NodeComponentProps } from '../../types/node-component'
import React from 'react'
export function EmojiNode(props: NodeComponentProps<{ type: 'emoji', name: string, markup?: string }>) {
const { node } = props
return (
<span className="emoji-node">
{node.name ?? node.markup}
</span>
)
}
export default EmojiNode
@@ -0,0 +1,8 @@
import type { NodeComponentProps } from '../../types/node-component'
import React from 'react'
export function EmphasisNode(props: NodeComponentProps<{ type: 'emphasis' }>) {
return <em className="emphasis-node">{props.children}</em>
}
export default EmphasisNode
@@ -0,0 +1,27 @@
import type { NodeComponentProps } from '../../types/node-component'
import React, { useCallback } from 'react'
export function FootnoteAnchorNode(props: NodeComponentProps<{ type: 'footnote_anchor', id: string }>) {
const { node } = props
const handleScroll = useCallback((event: React.MouseEvent) => {
event.preventDefault()
if (typeof document === 'undefined')
return
const target = document.getElementById(`fnref-${String(node.id ?? '')}`)
if (target)
target.scrollIntoView({ behavior: 'smooth' })
}, [node.id])
return (
<a
className="footnote-anchor text-sm text-[#0366d6] hover:underline cursor-pointer"
href={`#fnref-${node.id}`}
title={`Back to reference ${node.id}`}
onClick={handleScroll}
>
↩︎
</a>
)
}
export default FootnoteAnchorNode
@@ -0,0 +1,17 @@
import type { ParsedNode } from 'stream-markdown-parser'
import type { NodeComponentProps } from '../../types/node-component'
import React from 'react'
import { renderNodeChildren } from '../../renderers/renderChildren'
export function FootnoteNode(props: NodeComponentProps<{ type: 'footnote', id: string, children?: ParsedNode[] }>) {
const { node, ctx, renderNode, indexKey } = props
return (
<div id={`footnote-${node.id}`} className="footnote-node flex mt-2 mb-2 text-sm leading-relaxed border-t border-[#eaecef] pt-2">
<div className="flex-1">
{ctx && renderNode ? renderNodeChildren(node.children, ctx, String(indexKey ?? `footnote-${node.id}`), renderNode) : null}
</div>
</div>
)
}
export default FootnoteNode
@@ -0,0 +1,27 @@
import type { NodeComponentProps } from '../../types/node-component'
import React, { useCallback, useMemo } from 'react'
export function FootnoteReferenceNode(props: NodeComponentProps<{ type: 'footnote_reference', id: string }>) {
const { node } = props
const href = useMemo(() => `#footnote-${node.id}`, [node.id])
const handleScroll = useCallback((event: React.MouseEvent) => {
event.preventDefault()
if (typeof document === 'undefined')
return
const target = document.querySelector(href)
if (target)
target.scrollIntoView({ behavior: 'smooth' })
}, [href])
return (
<sup id={`fnref-${node.id}`} className="footnote-reference" onClick={handleScroll}>
<a href={href} title={`View footnote ${node.id}`} className="footnote-link cursor-pointer">
[
{node.id}
]
</a>
</sup>
)
}
export default FootnoteReferenceNode
@@ -0,0 +1,8 @@
import type { NodeComponentProps } from '../../types/node-component'
import React from 'react'
export function HardBreakNode(_props: NodeComponentProps<{ type: 'hardbreak' }>) {
return <br className="hard-break" />
}
export default HardBreakNode
@@ -0,0 +1,29 @@
import type { ParsedNode } from 'stream-markdown-parser'
import type { NodeComponentProps } from '../../types/node-component'
import clsx from 'clsx'
import React from 'react'
import { renderNodeChildren } from '../../renderers/renderChildren'
export function HeadingNode(props: NodeComponentProps<{ type: 'heading', level?: number, children?: ParsedNode[], attrs?: Record<string, string | boolean> }>) {
const { node, ctx, renderNode, indexKey, children } = props
const level = Math.min(6, Math.max(1, Number(node.level) || 1))
type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
const Tag = (`h${level}`) as HeadingTag
const attrs = (node as any)?.attrs as Record<string, unknown> | undefined
const { class: classAttr, className: classNameAttr, style: styleAttr, ...restAttrs } = attrs ?? {}
return (
<Tag
dir="auto"
className={clsx('heading-node font-semibold', `heading-${level}`, classAttr as any, classNameAttr as any)}
{...(typeof styleAttr === 'object' && styleAttr != null ? { style: styleAttr as any } : {})}
{...(restAttrs as React.HTMLAttributes<HTMLHeadingElement>)}
>
{children ?? (ctx && renderNode
? renderNodeChildren(node.children, ctx, String(indexKey ?? `heading-${level}`), renderNode)
: null)}
</Tag>
)
}
export default HeadingNode
@@ -0,0 +1,8 @@
import type { NodeComponentProps } from '../../types/node-component'
import React from 'react'
export function HighlightNode(props: NodeComponentProps<{ type: 'highlight' }>) {
return <mark className="highlight-node">{props.children}</mark>
}
export default HighlightNode
@@ -0,0 +1,162 @@
import type { NodeComponentProps } from '../../types/node-component'
import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { NON_STRUCTURING_HTML_TAGS, sanitizeHtmlContent, sanitizeHtmlTokenAttrs } from 'stream-markdown-parser'
import { useViewportPriority } from '../../context/viewportPriority'
import { getCustomComponentsRevision, getCustomNodeComponents, subscribeCustomComponents } from '../../customComponents'
import { renderNodeChildren, tokenAttrsToProps } from '../../renderers/renderChildren'
import { hasCustomHtmlComponents, normalizeDomAttrs, parseHtmlToReactNodes } from '../../utils/htmlToReact'
function mergeHtmlBlockClassName(attrs?: Record<string, any>) {
const next = { ...(attrs || {}) }
const existing = typeof next.className === 'string' ? next.className.trim() : ''
next.className = existing ? `html-block-node ${existing}` : 'html-block-node'
return next
}
export function HtmlBlockNode(props: NodeComponentProps<{
type: 'html_block'
content: string
raw?: string
tag?: string
attrs?: [string, string | null][] | null
children?: any[]
loading?: boolean
}> & {
customComponents?: Record<string, React.ComponentType<any>>
placeholder?: React.ReactNode
}) {
const { node, placeholder, customId } = props
const registerViewport = useViewportPriority()
const [hostEl, setHostEl] = useState<HTMLElement | null>(null)
const handleRef = useRef<ReturnType<typeof registerViewport> | null>(null)
const [shouldRender, setShouldRender] = useState(() => typeof window === 'undefined')
const [renderContent, setRenderContent] = useState(node.content)
const isDeferred = Boolean(node.loading)
const customComponentsRevision = useSyncExternalStore(
subscribeCustomComponents,
getCustomComponentsRevision,
getCustomComponentsRevision,
)
const effectiveCustomComponents = useMemo(() => {
// Allow explicit injection (primarily for tests), otherwise fall back to global store.
return props.customComponents ?? getCustomNodeComponents(customId)
}, [customId, props.customComponents, customComponentsRevision])
useEffect(() => {
if (typeof window === 'undefined') {
setShouldRender(true)
return
}
handleRef.current?.destroy()
handleRef.current = null
if (!isDeferred) {
setShouldRender(true)
setRenderContent(node.content)
return
}
if (!hostEl) {
setShouldRender(false)
return
}
const handle = registerViewport(hostEl, { rootMargin: '400px' })
handleRef.current = handle
if (handle.isVisible())
setShouldRender(true)
handle.whenVisible.then(() => setShouldRender(true)).catch(() => {})
return () => {
handle.destroy()
handleRef.current = null
}
}, [hostEl, isDeferred, node.content, registerViewport])
useEffect(() => () => {
handleRef.current?.destroy()
handleRef.current = null
}, [])
useEffect(() => {
if (!isDeferred || shouldRender)
setRenderContent(node.content)
}, [isDeferred, node.content, shouldRender])
const boundAttrs = useMemo(() => {
const rawAttrs = tokenAttrsToProps(sanitizeHtmlTokenAttrs(node.attrs ?? undefined))
return rawAttrs ? normalizeDomAttrs(rawAttrs as Record<string, string>) : undefined
}, [node.attrs])
const structuredTag = useMemo(() => String(node.tag ?? '').trim(), [node.tag])
const structuredChildren = useMemo(() => Array.isArray(node.children) ? node.children : [], [node.children])
const isStructured = structuredChildren.length > 0
&& !!structuredTag
&& !NON_STRUCTURING_HTML_TAGS.has(structuredTag.toLowerCase())
&& !!props.ctx
&& !!props.renderNode
const structuredWrapperProps = useMemo(
() => mergeHtmlBlockClassName(boundAttrs as Record<string, any> | undefined),
[boundAttrs],
)
// Check if we should use dynamic rendering
const useDynamic = useMemo(() => {
return hasCustomHtmlComponents(node.content ?? '', effectiveCustomComponents)
}, [effectiveCustomComponents, node.content])
const reactNodes = useMemo(() => {
if (!useDynamic || !node.content)
return null
return parseHtmlToReactNodes(node.content, effectiveCustomComponents)
}, [effectiveCustomComponents, node.content, useDynamic])
const safeHtmlContent = useMemo(() => sanitizeHtmlContent(renderContent ?? ''), [renderContent])
const structuredContent = useMemo(() => {
if (!isStructured || !props.ctx || !props.renderNode)
return null
return renderNodeChildren(
structuredChildren,
props.ctx,
`${String(props.indexKey ?? 'html-block')}-structured`,
props.renderNode,
)
}, [isStructured, props.ctx, props.indexKey, props.renderNode, structuredChildren])
const placeholderNode = (
<div className="html-block-node__placeholder">
{placeholder ?? (
<>
<span className="html-block-node__placeholder-bar" />
<span className="html-block-node__placeholder-bar w-4/5" />
<span className="html-block-node__placeholder-bar w-2/3" />
</>
)}
</div>
)
if (isStructured) {
return React.createElement(
structuredTag,
{
...(structuredWrapperProps as any),
ref: setHostEl,
},
shouldRender ? structuredContent : placeholderNode,
)
}
return (
<div ref={setHostEl} className="html-block-node" {...(boundAttrs as any)}>
{shouldRender
? (
useDynamic && reactNodes
? (
<>{reactNodes}</>
)
: (
<div dangerouslySetInnerHTML={{ __html: safeHtmlContent }} />
)
)
: placeholderNode}
</div>
)
}
export default HtmlBlockNode
@@ -0,0 +1,237 @@
import type { ComponentType } from 'react'
import type { NodeComponentProps } from '../../types/node-component'
import type { HtmlToken } from '../../utils/htmlToReact'
import React, { useEffect, useRef, useState } from 'react'
import { BLOCKED_HTML_TAGS as BLOCKED_TAGS, convertHtmlAttrsToProps, sanitizeHtmlContent } from 'stream-markdown-parser'
import { getCustomNodeComponents } from '../../customComponents'
import {
hasCustomHtmlComponents,
isCustomHtmlComponent,
normalizeDomAttrs,
sanitizeHtmlAttrs,
tokenizeHtml,
} from '../../utils/htmlToReact'
const SHOULD_LOG = (() => {
try {
return Boolean((import.meta as any).env?.DEV)
}
catch {}
return false
})()
function warn(message: string) {
if (SHOULD_LOG)
console.warn(message)
}
function logError(message: string, err: unknown) {
if (SHOULD_LOG)
console.error(message, err)
}
function convertAttrsToProps(attrs: Record<string, string>): Record<string, any> {
return convertHtmlAttrsToProps(attrs)
}
/**
* Build React element tree from tokens
*/
function buildReactElementTree(
tokens: HtmlToken[],
customComponents: Record<string, ComponentType<any>>,
): React.ReactNode[] {
let autoKeySeed = 0
const stack: Array<{ tagName: string, children: React.ReactNode[], attrs?: Record<string, string> }> = []
const rootNodes: React.ReactNode[] = []
for (const token of tokens) {
if (token.type === 'text') {
const target = stack.length > 0 ? stack[stack.length - 1].children : rootNodes
target.push(token.content!)
}
else if (token.type === 'self_closing') {
const element = createReactElement(token.tagName!, token.attrs || {}, [], customComponents, `ms-html-${autoKeySeed++}`)
const target = stack.length > 0 ? stack[stack.length - 1].children : rootNodes
element != null && target.push(element)
}
else if (token.type === 'tag_open') {
stack.push({ tagName: token.tagName!, children: [], attrs: token.attrs })
}
else if (token.type === 'tag_close') {
const closingTag = token.tagName!.toLowerCase()
// Find matching opening tag (handle nested same tags)
let matchedIndex = -1
for (let i = stack.length - 1; i >= 0; i--) {
if (stack[i].tagName.toLowerCase() === closingTag) {
matchedIndex = i
break
}
}
if (matchedIndex !== -1) {
// Pop all tags until the matched one (auto-closing intermediate tags)
while (stack.length > matchedIndex) {
const opening = stack.pop()!
const element = createReactElement(opening.tagName, opening.attrs || {}, opening.children, customComponents, `ms-html-${autoKeySeed++}`)
if (stack.length > 0)
element != null && stack[stack.length - 1].children.push(element)
else
element != null && rootNodes.push(element)
// Warn if auto-closing tags
if (opening.tagName.toLowerCase() !== closingTag && stack.length > matchedIndex) {
warn(`Auto-closing unclosed tag: <${opening.tagName}>`)
}
}
}
else {
// No matching opening tag, warn and ignore
warn(`Ignoring closing tag with no matching opening tag: </${token.tagName}>`)
}
}
}
// Handle any remaining unclosed tags
while (stack.length > 0) {
const unclosed = stack.pop()!
const element = createReactElement(unclosed.tagName, unclosed.attrs || {}, unclosed.children, customComponents, `ms-html-${autoKeySeed++}`)
element != null && rootNodes.push(element)
warn(`Auto-closing unclosed tag: <${unclosed.tagName}>`)
}
return rootNodes
}
/**
* Create React element for a tag
*/
function createReactElement(
tagName: string,
attrs: Record<string, string>,
children: React.ReactNode[],
customComponents: Record<string, ComponentType<any>>,
autoKey: string,
): React.ReactNode {
if (BLOCKED_TAGS.has(tagName.toLowerCase()))
return null
const sanitizedAttrs = sanitizeHtmlAttrs(attrs)
const explicitKey = (sanitizedAttrs as any).key
const elementKey = explicitKey != null && explicitKey !== '' ? explicitKey : autoKey
if (isCustomHtmlComponent(tagName, customComponents)) {
// It's a custom React component
const component = customComponents[tagName] || customComponents[tagName.toLowerCase()]
const convertedAttrs = convertAttrsToProps(sanitizedAttrs)
return React.createElement(component as ComponentType<any>, { ...convertedAttrs, key: elementKey }, ...children)
}
else {
// It's a standard HTML element
return React.createElement(tagName, { ...normalizeDomAttrs(sanitizedAttrs), key: elementKey }, ...children)
}
}
/**
* Parse HTML content to React elements
*/
function parseHtmlToReactNodes(
content: string,
customComponents: Record<string, ComponentType<any>>,
): React.ReactNode[] | null {
if (!content)
return []
try {
const tokens = tokenizeHtml(content)
const nodes = buildReactElementTree(tokens, customComponents)
return nodes
}
catch (error) {
logError('Failed to parse HTML to React nodes:', error)
return null
}
}
export function HtmlInlineNode(props: NodeComponentProps<{
type: 'html_inline'
content: string
loading?: boolean
autoClosed?: boolean
}>) {
const { node, customId } = props
const containerRef = useRef<HTMLSpanElement>(null)
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
// Get custom components from global registry
const customComponents = getCustomNodeComponents(customId)
const safeHtmlContent = React.useMemo(() => sanitizeHtmlContent(node.content ?? ''), [node.content])
// Computed property to determine render mode and content
const renderMode = React.useMemo(() => {
const content = node.content
if (!content)
return { mode: 'html', content: '' }
// Check if content contains custom components
if (!hasCustomHtmlComponents(content, customComponents))
return { mode: 'html', content }
// Parse and build React element tree
const nodes = parseHtmlToReactNodes(content, customComponents)
if (nodes === null)
return { mode: 'html', content } // Fallback to dangerouslySetInnerHTML if parsing fails
return { mode: 'dynamic', nodes }
}, [node.content, customComponents])
// Use DOM manipulation for pure HTML (mode: 'html')
useEffect(() => {
if (!isClient || !containerRef.current || renderMode.mode !== 'html')
return
const host = containerRef.current
host.innerHTML = ''
const template = document.createElement('template')
template.innerHTML = safeHtmlContent
host.appendChild(template.content.cloneNode(true))
}, [renderMode.mode, isClient, safeHtmlContent])
// Loading state handling
if (node.loading && !node.autoClosed) {
return (
<span className="html-inline-node html-inline-node--loading">
{node.content}
</span>
)
}
// Dynamic rendering for custom components
if (renderMode.mode === 'dynamic') {
return (
<span
className="html-inline-node"
style={{ display: 'inline' }}
>
{renderMode.nodes}
</span>
)
}
// Fallback to DOM rendering for standard HTML
return (
<span
ref={containerRef}
className="html-inline-node"
style={{ display: 'inline' }}
/>
)
}
export default HtmlInlineNode
@@ -0,0 +1,119 @@
import type { ImageNodeProps } from '../../types/component-props'
import React, { useEffect, useState } from 'react'
const DEFAULT_PROPS = {
fallbackSrc: '',
lazy: true,
usePlaceholder: true,
}
export interface ImageNodeReactEvents {
onLoad?: (src: string) => void
onError?: (src: string) => void
onClick?: (payload: [event: React.MouseEvent<HTMLImageElement>, src: string]) => void
}
export function ImageNode(rawProps: ImageNodeProps & ImageNodeReactEvents) {
const props = { ...DEFAULT_PROPS, ...rawProps }
const [imageLoaded, setImageLoaded] = useState(false)
const [hasError, setHasError] = useState(false)
const [fallbackTried, setFallbackTried] = useState(false)
const displaySrc = hasError && props.fallbackSrc
? props.fallbackSrc
: props.node.src
useEffect(() => {
setImageLoaded(false)
setHasError(false)
}, [displaySrc])
useEffect(() => {
setFallbackTried(false)
}, [props.node.src])
const handleImageError = () => {
if (props.fallbackSrc && !fallbackTried) {
setFallbackTried(true)
setHasError(true)
}
else {
setHasError(true)
props.onError?.(props.node.src)
}
}
const handleImageLoad = () => {
setImageLoaded(true)
setHasError(false)
props.onLoad?.(displaySrc)
}
const handleClick = (event: React.MouseEvent<HTMLImageElement>) => {
event.preventDefault()
if (!imageLoaded || hasError) {
return
}
props.onClick?.([event, displaySrc])
}
if (!props.node.loading && !hasError) {
return (
<img
key="image"
src={displaySrc}
alt={String(props.node.alt ?? props.node.title ?? '')}
title={String(props.node.title ?? props.node.alt ?? '')}
className={`image-node__img${imageLoaded ? ' is-loaded' : ''}`}
loading={props.lazy ? 'lazy' : 'eager'}
decoding="async"
tabIndex={imageLoaded ? 0 : -1}
aria-label={props.node.alt ?? 'Preview image'}
onError={handleImageError}
onLoad={handleImageLoad}
onClick={handleClick}
/>
)
}
if (!hasError) {
return (
<span key="placeholder" className="image-node__placeholder">
{props.usePlaceholder
? (
<>
<span className="image-node__spinner" aria-hidden="true" />
<span className="image-node__placeholder-text">Loading image...</span>
</>
)
: (
<span className="image-node__placeholder-text">{props.node.raw ?? 'Loading image…'}</span>
)}
</span>
)
}
if (!props.node.loading && !props.fallbackSrc) {
return (
<span className="image-node__error">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fill="currentColor"
d="M2 2h20v10h-2V4H4v9.586l5-5L14.414 14L13 15.414l-4-4l-5 5V20h8v2H2zm13.547 5a1 1 0 1 0 0 2a1 1 0 0 0 0-2m-3 1a3 3 0 1 1 6 0a3 3 0 0 1-6 0m3.625 6.757L19 17.586l2.828-2.829l1.415 1.415L20.414 19l2.829 2.828l-1.415 1.415L19 20.414l-2.828 2.829l-1.415-1.415L17.586 19l-2.829-2.828z"
/>
</svg>
<span className="image-node__placeholder-text">Image failed to load</span>
</span>
)
}
return null
}
export default ImageNode
@@ -0,0 +1,553 @@
import type { VisibilityHandle } from '../../context/viewportPriority'
import type { InfographicBlockNodeProps, InfographicBlockActionContext, MermaidBlockEvent } from '../../types/component-props'
import clsx from 'clsx'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useViewportPriority } from '../../context/viewportPriority'
import { useSafeI18n } from '../../i18n/useSafeI18n'
import { hideTooltip, showTooltipForAnchor } from '../../tooltip/singletonTooltip'
import { clampInfographicPreviewHeight, estimateInfographicPreviewHeight, parsePositiveNumber } from './height'
import { getInfographic } from './infographic'
// Inline Icon
const INFOGRAPHIC_ICON = (
<svg width="1em" height="1em" viewBox="0 0 291 300" fill="none" className="w-4 h-4 flex-shrink-0" xmlns="http://www.w3.org/2000/svg">
<g>
<path d="M140.904 239.376C128.83 239.683 119.675 239.299 115.448 243.843C110.902 248.07 111.288 257.227 110.979 269.302C111.118 274.675 111.118 279.478 111.472 283.52C111.662 285.638 111.95 287.547 112.406 289.224C112.411 289.243 112.416 289.259 112.422 289.28C112.462 289.419 112.496 289.558 112.539 289.691C113.168 291.787 114.088 293.491 115.446 294.758C116.662 296.064 118.283 296.963 120.264 297.69C120.36 297.614 120.464 297.646 120.555 297.675C120.56 297.68 120.56 297.68 120.566 297.68C120.848 297.768 121.142 297.846 121.443 297.923C121.454 297.923 121.464 297.928 121.478 297.934C122.875 298.272 124.424 298.507 126.11 298.678C126.326 298.696 126.542 298.718 126.763 298.739C130.79 299.086 135.558 299.088 140.904 299.222C152.974 298.912 162.128 299.302 166.36 294.758C170.904 290.526 170.515 281.371 170.824 269.302C170.515 257.227 170.907 248.07 166.36 243.843C162.131 239.299 152.974 239.683 140.904 239.376Z" fill="#FF6376"></path>
<path d="M21.2155 128.398C12.6555 128.616 6.16484 128.339 3.16751 131.56C-0.0538222 134.56 0.218178 141.054 -0.000488281 149.608C0.218178 158.168 -0.0538222 164.659 3.16751 167.656C6.16484 170.878 12.6555 170.606 21.2155 170.824C25.0262 170.726 28.4288 170.726 31.2955 170.475C32.7968 170.342 34.1488 170.136 35.3382 169.814C35.3542 169.811 35.3648 169.806 35.3782 169.803C35.4768 169.774 35.5755 169.747 35.6688 169.718C37.1568 169.272 38.3648 168.622 39.2635 167.656C40.1915 166.795 40.8262 165.646 41.2715 164.243C41.2875 164.174 41.3115 164.102 41.3328 164.035C41.3328 164.035 41.3355 164.032 41.3355 164.027C41.3968 163.827 41.4529 163.622 41.5062 163.406C41.5062 163.398 41.5115 163.392 41.5142 163.382C41.7542 162.392 41.9222 161.294 42.0422 160.096C42.0555 159.944 42.0715 159.792 42.0848 159.635C42.3328 156.779 42.3328 153.398 42.4262 149.608C42.2075 141.054 42.4848 134.56 39.2635 131.56C36.2635 128.339 29.7728 128.616 21.2155 128.398Z" fill="#FFCCCC"></path>
<path d="M81.0595 184.171C70.8568 184.433 63.1208 184.102 59.5475 187.942C55.7075 191.518 56.0328 199.254 55.7742 209.454C56.0328 219.657 55.7075 227.393 59.5475 230.963C63.1208 234.803 70.8568 234.478 81.0595 234.739C85.6008 234.622 89.6595 234.622 93.0728 234.323C94.8648 234.163 96.4755 233.921 97.8942 233.534C97.9102 233.529 97.9235 233.526 97.9422 233.521C98.0568 233.486 98.1742 233.457 98.2888 233.422C100.06 232.889 101.5 232.113 102.569 230.963C103.676 229.937 104.433 228.566 104.964 226.894C104.985 226.811 105.012 226.726 105.036 226.646C105.041 226.643 105.041 226.643 105.041 226.638C105.116 226.401 105.18 226.153 105.244 225.897C105.244 225.889 105.249 225.881 105.254 225.867C105.54 224.689 105.74 223.379 105.881 221.953C105.9 221.771 105.916 221.59 105.934 221.403C106.228 218.001 106.228 213.969 106.342 209.454C106.081 199.254 106.412 191.518 102.572 187.942C98.9955 184.102 91.2568 184.433 81.0595 184.171Z" fill="#FF939F"></path>
<path d="M260.591 151.87C215.652 151.87 203.02 164.523 203.02 209.462H198.476C198.476 164.523 185.836 151.881 140.895 151.881V147.337C185.836 147.337 198.487 134.705 198.487 89.7659H203.02C203.02 134.705 215.652 147.337 260.591 147.337V151.87ZM286.052 124.158C281.82 119.614 272.66 120.001 260.591 119.689C248.521 119.385 239.361 119.771 235.129 115.227C230.585 110.995 230.983 101.846 230.671 89.7659C230.513 83.7312 230.535 78.4272 230.023 74.1019C229.513 69.7659 228.481 66.4219 226.209 64.3046C221.967 59.7606 212.817 60.1472 200.748 59.8459C188.681 60.1472 179.519 59.7606 175.287 64.3046C170.753 68.5366 171.129 77.6966 170.828 89.7659C170.516 101.835 170.9 110.995 166.356 115.227C162.124 119.771 152.985 119.374 140.905 119.689C138.873 119.739 136.924 119.771 135.071 119.811C119.313 118.697 106.337 112.318 106.337 89.7659C106.212 84.6699 106.233 80.1792 105.807 76.5206C105.367 72.8726 104.492 70.0379 102.575 68.2566C99.0013 64.4112 91.2573 64.7446 81.0653 64.4832C70.86 64.7446 63.1186 64.4112 59.5533 68.2566C55.708 71.8299 56.0306 79.5632 55.7693 89.7659C56.0306 99.9686 55.708 107.702 59.5533 111.278C63.1186 115.113 70.86 114.79 81.0653 115.049C103.617 115.049 109.996 128.035 111.1 143.803C111.068 145.659 111.028 147.587 110.975 149.619C111.121 154.987 111.121 159.79 111.476 163.835C111.663 165.95 111.945 167.857 112.404 169.534C112.412 169.555 112.412 169.566 112.423 169.598C112.465 169.734 112.497 169.867 112.537 170.003C113.164 172.099 114.092 173.809 115.447 175.07C116.665 176.371 118.281 177.278 120.271 177.905C120.364 177.934 120.46 177.955 120.564 177.987C120.855 178.081 121.145 178.153 121.439 178.238C121.46 178.238 121.471 178.238 121.479 178.249C122.876 178.582 124.42 178.822 126.108 178.987C126.327 179.009 126.545 179.03 126.764 179.051C130.788 179.395 135.559 179.395 140.905 179.529C152.975 179.843 162.124 179.457 166.356 184.001C170.9 188.233 170.516 197.371 170.828 209.451C171.129 221.529 170.743 230.681 175.287 234.91C179.519 239.454 188.681 239.07 200.748 239.371C206.127 239.235 210.921 239.235 214.975 238.881C217.079 238.694 218.985 238.403 220.676 237.955C220.695 237.945 220.705 237.934 220.727 237.934C220.873 237.891 220.999 237.859 221.135 237.819C223.228 237.193 224.937 236.265 226.209 234.91C227.511 233.691 228.409 232.065 229.044 230.097C229.065 230.003 229.095 229.899 229.127 229.803V229.793C229.22 229.513 229.295 229.222 229.367 228.918C229.367 228.897 229.377 228.897 229.377 228.878C229.721 227.481 229.951 225.937 230.127 224.249C230.137 224.03 230.169 223.811 230.191 223.593C230.535 219.571 230.535 214.798 230.671 209.451C230.972 197.371 230.585 188.233 235.129 184.001C239.361 179.457 248.511 179.843 260.591 179.529C272.66 179.227 281.82 179.614 286.052 175.07C290.596 170.838 290.209 161.689 290.511 149.619C290.209 137.539 290.596 128.379 286.052 124.158Z" fill="#FF356A"></path>
<path d="M112.405 49.848C112.411 49.8694 112.416 49.8827 112.421 49.904C112.461 50.0427 112.499 50.1814 112.539 50.3147C113.171 52.4134 114.088 54.1147 115.448 55.384C116.661 56.6907 118.283 57.5894 120.264 58.2134C120.36 58.24 120.464 58.2694 120.555 58.3014C120.56 58.3067 120.56 58.3067 120.565 58.3067C120.848 58.3947 121.141 58.4694 121.443 58.5467C121.453 58.5467 121.464 58.552 121.48 58.5574C122.875 58.896 124.424 59.1334 126.112 59.3014C126.325 59.3227 126.541 59.3414 126.763 59.3627C130.789 59.712 135.56 59.712 140.904 59.8454C152.973 59.5387 162.128 59.928 166.36 55.384C170.907 51.152 170.515 41.9947 170.824 29.9254C170.517 17.8507 170.907 8.69602 166.363 4.46935C162.131 -0.0746511 152.973 0.309349 140.904 1.52588e-05C128.829 0.309349 119.675 -0.0746511 115.448 4.46935C110.904 8.69602 111.288 17.8507 110.979 29.9254C111.117 35.3014 111.117 40.1014 111.472 44.144C111.661 46.2614 111.949 48.1707 112.405 49.848Z" fill="#FF6376"></path>
</g>
</svg>
)
export interface InfographicBlockNodeReactEvents {
onCopy?: (code: string) => void
onExport?: (event: MermaidBlockEvent<{ type: 'export' }>) => void
onOpenModal?: (event: MermaidBlockEvent<{ type: 'open-modal' }>) => void
onToggleMode?: (
target: 'preview' | 'source',
event: MermaidBlockEvent<{ type: 'toggle-mode', target: 'preview' | 'source' }>,
) => void
}
const DEFAULTS = {
maxHeight: '500px',
loading: true,
showHeader: true,
showCopyButton: true,
showCollapseButton: true,
showModeToggle: true,
showExportButton: true,
showFullscreenButton: true,
showZoomControls: true,
}
export function InfographicBlockNode(rawProps: InfographicBlockNodeProps & InfographicBlockNodeReactEvents) {
const props = { ...DEFAULTS, ...rawProps }
const { t } = useSafeI18n()
const [copying, setCopying] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(false)
const [showSource, setShowSource] = useState(false)
const [modalOpen, setModalOpen] = useState(false)
const [viewportReady, setViewportReady] = useState(() => typeof window === 'undefined')
const baseCode = props.node.code
const maxPreviewHeight = useMemo(() => {
if (!props.maxHeight || props.maxHeight === 'none')
return null
return parsePositiveNumber(props.maxHeight)
}, [props.maxHeight])
const estimatedPreviewHeight = useMemo(() => {
return clampInfographicPreviewHeight(
parsePositiveNumber(props.estimatedPreviewHeightPx) ?? estimateInfographicPreviewHeight(baseCode),
undefined,
maxPreviewHeight,
)
}, [baseCode, maxPreviewHeight, props.estimatedPreviewHeightPx])
const [containerHeight, setContainerHeight] = useState(`${estimatedPreviewHeight}px`)
const [hasPreview, setHasPreview] = useState(false)
// Zoom
const [zoom, setZoom] = useState(1)
const [translate, setTranslate] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const viewportTargetRef = useRef<HTMLDivElement>(null)
const viewportHandleRef = useRef<VisibilityHandle | null>(null)
const modalContentRef = useRef<HTMLDivElement>(null)
const modalCloneWrapperRef = useRef<HTMLElement | null>(null)
const dragStartRef = useRef({ x: 0, y: 0 })
const instanceRef = useRef<any>(null)
const registerViewport = useViewportPriority()
const resolveContainerHeight = useCallback((actualHeight: number) => {
if (!props.maxHeight || props.maxHeight === 'none')
return `${Math.max(actualHeight, estimatedPreviewHeight)}px`
const maxHeight = Number.parseFloat(String(props.maxHeight))
if (!Number.isFinite(maxHeight))
return `${Math.max(actualHeight, estimatedPreviewHeight)}px`
return `${Math.max(Math.min(actualHeight, maxHeight), estimatedPreviewHeight)}px`
}, [estimatedPreviewHeight, props.maxHeight])
const updateContainerHeight = useCallback(() => {
const el = containerRef.current
if (!el)
return
const actualHeight = el.scrollHeight
if (actualHeight > 0)
setContainerHeight(resolveContainerHeight(actualHeight))
}, [resolveContainerHeight])
useEffect(() => {
if (showSource || isCollapsed)
return
setContainerHeight((current) => {
const currentHeight = parsePositiveNumber(current)
if (currentHeight != null && currentHeight >= estimatedPreviewHeight)
return current
return `${estimatedPreviewHeight}px`
})
}, [estimatedPreviewHeight, isCollapsed, showSource])
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard?.writeText(baseCode)
setCopying(true)
props.onCopy?.(baseCode)
setTimeout(() => setCopying(false), 1000)
}
catch {}
}, [baseCode, props])
const renderInfographic = useCallback(async () => {
if (!viewportReady)
return
if (!containerRef.current)
return
const el = containerRef.current
try {
const InfographicClass = await getInfographic()
if (!InfographicClass) {
console.warn('Infographic library failed to load.')
return
}
if (instanceRef.current) {
instanceRef.current.destroy?.()
instanceRef.current = null
}
el.innerHTML = ''
const instance = new InfographicClass({
container: el,
width: '100%',
height: '100%',
})
instanceRef.current = instance
instance.render(baseCode)
setHasPreview(true)
// Update height
setTimeout(() => {
updateContainerHeight()
}, 0)
}
catch (error) {
console.error('Failed to render infographic:', error)
setHasPreview(false)
el.innerHTML = `<div class="text-red-500 p-4">Failed to render infographic: ${error instanceof Error ? error.message : 'Unknown error'}</div>`
}
}, [baseCode, updateContainerHeight, viewportReady])
useEffect(() => {
const el = viewportTargetRef.current
if (!el)
return
const handle = registerViewport(el, { rootMargin: '160px' })
viewportHandleRef.current = handle
if (handle.isVisible())
setViewportReady(true)
handle.whenVisible.then(() => setViewportReady(true))
return () => {
handle.destroy()
viewportHandleRef.current = null
}
}, [registerViewport])
// Effects
useEffect(() => {
if (viewportReady && !showSource && !isCollapsed) {
renderInfographic()
}
}, [baseCode, isCollapsed, renderInfographic, showSource, viewportReady])
useEffect(() => {
return () => {
if (instanceRef.current) {
instanceRef.current.destroy?.()
instanceRef.current = null
}
}
}, [])
// Modal logic matching Vue implementation
const closeModal = useCallback(() => {
setModalOpen(false)
if (modalContentRef.current)
modalContentRef.current.innerHTML = ''
modalCloneWrapperRef.current = null
document.body.style.overflow = ''
}, [])
const handleOpenModal = useCallback(() => {
setModalOpen(true)
document.body.style.overflow = 'hidden'
}, [])
useEffect(() => {
if (!modalOpen)
return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape')
closeModal()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [modalOpen, closeModal])
useEffect(() => {
if (!modalOpen)
return
// Defer to allow portal to mount
setTimeout(() => {
if (containerRef.current && modalContentRef.current) {
modalContentRef.current.innerHTML = ''
const wrapper = document.createElement('div')
Object.assign(wrapper.style, {
transition: 'transform 0.1s ease',
transformOrigin: 'center center',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})
const clone = containerRef.current.cloneNode(true) as HTMLElement
clone.classList.add('fullscreen')
clone.style.height = 'auto'
wrapper.appendChild(clone)
modalContentRef.current.appendChild(wrapper)
modalCloneWrapperRef.current = wrapper
wrapper.style.transform = `translate(${translate.x}px, ${translate.y}px) scale(${zoom})`
}
}, 0)
}, [modalOpen]) // Intentionally not dependent on zoom/translate to avoid re-cloning
// Sync transform
useEffect(() => {
if (modalOpen && modalCloneWrapperRef.current) {
modalCloneWrapperRef.current.style.transform = `translate(${translate.x}px, ${translate.y}px) scale(${zoom})`
}
}, [modalOpen, translate, zoom])
const handleExport = useCallback(() => {
const svg = containerRef.current?.querySelector('svg')
if (!svg)
return
const data = new XMLSerializer().serializeToString(svg)
const blob = new Blob([data], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `infographic-${Date.now()}.svg`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}, [])
// Drag logic
const onMouseDown = (e: React.MouseEvent | React.TouchEvent) => {
setIsDragging(true)
const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX
const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY
dragStartRef.current = { x: clientX - translate.x, y: clientY - translate.y }
}
const onMouseMove = (e: React.MouseEvent | React.TouchEvent) => {
if (!isDragging)
return
const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX
const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY
setTranslate({
x: clientX - dragStartRef.current.x,
y: clientY - dragStartRef.current.y,
})
}
const stopDrag = () => setIsDragging(false)
const handleSwitchMode = useCallback((target: 'preview' | 'source') => {
setShowSource(target === 'source')
}, [])
const isFullscreenDisabled = showSource || isCollapsed
const actionContext: InfographicBlockActionContext = useMemo(() => ({
collapsed: isCollapsed,
copied: copying,
showSource,
modalOpen,
isDark: !!props.isDark,
code: baseCode,
isExportDisabled: isFullscreenDisabled,
zoom,
toggleCollapse: () => setIsCollapsed(v => !v),
copy: handleCopy,
exportSvg: handleExport,
toggleFullscreen: () => modalOpen ? closeModal() : handleOpenModal(),
switchMode: handleSwitchMode,
zoomIn: () => setZoom(v => Math.min(v + 0.1, 3)),
zoomOut: () => setZoom(v => Math.max(v - 0.1, 0.5)),
resetZoom: () => { setZoom(1); setTranslate({ x: 0, y: 0 }) },
}), [isCollapsed, copying, showSource, modalOpen, props.isDark, baseCode, isFullscreenDisabled, zoom, handleCopy, handleExport, closeModal, handleOpenModal, handleSwitchMode])
const computedButtonClass = 'infographic-action-btn p-2 text-xs rounded'
// JSX Structure mirroring Vue template
const defaultModeToggle = props.showModeToggle ? (
<div className="infographic-mode-toggle flex items-center gap-x-1 rounded-md p-0.5">
<button
className={clsx('infographic-mode-btn px-2.5 py-1 text-xs rounded transition-colors', { active: !showSource })}
onClick={() => setShowSource(false)}
onMouseEnter={e => showTooltipForAnchor(e.currentTarget, t('common.preview') || 'Preview', 'top', false, undefined, props.isDark)}
onMouseLeave={() => hideTooltip()}
>
<div className="flex items-center gap-x-1">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3">
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<path d="M2.062 12.348a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 19.876 0a1 1 0 0 1 0 .696a10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</g>
</svg>
<span>{t('common.preview') || 'Preview'}</span>
</div>
</button>
<button
className={clsx('infographic-mode-btn px-2.5 py-1 text-xs rounded transition-colors', { active: showSource })}
onClick={() => setShowSource(true)}
onMouseEnter={e => showTooltipForAnchor(e.currentTarget, t('common.source') || 'Source', 'top', false, undefined, props.isDark)}
onMouseLeave={() => hideTooltip()}
>
<div className="flex items-center gap-x-1">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m16 18l6-6l-6-6M8 6l-6 6l6 6" /></svg>
<span>{t('common.source') || 'Source'}</span>
</div>
</button>
</div>
) : null
const defaultActions = (
<div className="flex items-center gap-x-1">
{props.showCollapseButton && (
<button
className={computedButtonClass}
onClick={() => setIsCollapsed(!isCollapsed)}
onMouseEnter={e => showTooltipForAnchor(e.currentTarget, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'), 'top', false, undefined, props.isDark)}
onMouseLeave={() => hideTooltip()}
>
<svg style={{ rotate: isCollapsed ? '0deg' : '90deg' }} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m9 18l6-6l-6-6" /></svg>
</button>
)}
{props.showCopyButton && (
<button
className={computedButtonClass}
onClick={handleCopy}
onMouseEnter={e => showTooltipForAnchor(e.currentTarget, copying ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy'), 'top', false, undefined, props.isDark)}
onMouseLeave={() => hideTooltip()}
>
{!copying
? (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3">
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</g>
</svg>
)
: (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 6L9 17l-5-5" /></svg>
)}
</button>
)}
{props.showExportButton && (
<button
className={clsx(computedButtonClass, isFullscreenDisabled && 'opacity-50 cursor-not-allowed')}
disabled={isFullscreenDisabled}
onClick={handleExport}
onMouseEnter={e => showTooltipForAnchor(e.currentTarget, t('common.export') || 'Export', 'top', false, undefined, props.isDark)}
onMouseLeave={() => hideTooltip()}
>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3">
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<path d="M12 15V3m9 12v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="m7 10l5 5l5-5" />
</g>
</svg>
</button>
)}
{props.showFullscreenButton && (
<button
className={clsx(computedButtonClass, isFullscreenDisabled && 'opacity-50 cursor-not-allowed')}
disabled={isFullscreenDisabled}
onClick={handleOpenModal}
onMouseEnter={e => showTooltipForAnchor(e.currentTarget, modalOpen ? (t('common.minimize') || 'Minimize') : (t('common.open') || 'Open'), 'top', false, undefined, props.isDark)}
onMouseLeave={() => hideTooltip()}
>
{!modalOpen
? <svg xmlns="http://www.w3.org/2000/svg" width="0.75rem" height="0.75rem" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 3h6v6m0-6l-7 7M3 21l7-7m-1 7H3v-6" /></svg>
: <svg xmlns="http://www.w3.org/2000/svg" width="0.75rem" height="0.75rem" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m14 10l7-7m-1 7h-6V4M3 21l7-7m-6 0h6v6" /></svg>}
</button>
)}
</div>
)
const defaultZoomControls = (
<div className="absolute top-2 right-2 z-10 rounded-lg">
<div className="flex items-center gap-2 backdrop-blur rounded-lg">
<button
className="infographic-zoom-btn p-2 text-xs rounded transition-colors"
onClick={() => setZoom(Math.min(zoom + 0.1, 3))}
>
+
</button>
<button
className="infographic-zoom-btn p-2 text-xs rounded transition-colors"
onClick={() => setZoom(Math.max(zoom - 0.1, 0.5))}
>
-
</button>
<button
className="infographic-zoom-btn p-2 text-xs rounded transition-colors"
onClick={() => {
setZoom(1)
setTranslate({ x: 0, y: 0 })
}}
>
{Math.round(zoom * 100)}
%
</button>
</div>
</div>
)
const header = props.showHeader && (
props.renderHeader ? props.renderHeader(actionContext) : (
<div className="infographic-block-header flex justify-between items-center px-4 py-2.5 border-b">
<div className="flex items-center gap-x-2 overflow-hidden">
{INFOGRAPHIC_ICON}
<span className="infographic-block-title text-sm font-medium font-mono truncate">Infographic</span>
</div>
{props.renderModeToggle ? props.renderModeToggle(actionContext) : defaultModeToggle}
{props.renderHeaderActions ? props.renderHeaderActions(actionContext) : defaultActions}
</div>
)
)
const body = (
<div>
{showSource
? (
<div className="infographic-block-source p-4">
<pre className="text-sm font-mono whitespace-pre-wrap">{baseCode}</pre>
</div>
)
: (
<div className="relative">
{props.showZoomControls && (
props.renderZoomControls ? props.renderZoomControls(actionContext) : defaultZoomControls
)}
<div
className="infographic-block-body min-h-[360px] relative transition-all duration-100 overflow-hidden block"
style={{ height: containerHeight, maxHeight: props.maxHeight ?? undefined }}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={stopDrag}
onMouseLeave={stopDrag}
onTouchStart={onMouseDown}
onTouchMove={onMouseMove}
onTouchEnd={stopDrag}
>
<div className={clsx('absolute inset-0 cursor-grab', isDragging && 'cursor-grabbing')} style={{ transform: `translate(${translate.x}px, ${translate.y}px) scale(${zoom})` }}>
<div ref={containerRef} className="w-full text-center flex items-center justify-center min-h-full" />
</div>
</div>
</div>
)}
</div>
)
return (
<>
<div
ref={viewportTargetRef}
data-markstream-infographic="1"
data-markstream-mode={showSource ? 'source' : hasPreview ? 'preview' : 'pending'}
className={clsx(
'my-4 rounded-lg border overflow-hidden shadow-sm infographic-block',
{ 'is-dark': props.isDark, 'is-rendering': props.loading },
)}
>
{header}
{!isCollapsed && body}
</div>
{modalOpen && typeof document !== 'undefined' && createPortal(
<div className="markstream-react infographic-modal-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={closeModal} role="dialog" aria-modal="true">
<div className={clsx('infographic-modal-panel dialog-panel relative w-full h-full max-w-full max-h-full rounded shadow-lg overflow-hidden', { 'is-dark': props.isDark })} onClick={e => e.stopPropagation()}>
<div className="absolute top-6 right-6 z-50 flex items-center gap-2">
<button className="infographic-zoom-btn p-2 text-xs rounded transition-colors" onClick={() => setZoom(Math.min(zoom + 0.1, 3))}>+</button>
<button className="infographic-zoom-btn p-2 text-xs rounded transition-colors" onClick={() => setZoom(Math.max(zoom - 0.1, 0.5))}>-</button>
<button
className="infographic-zoom-btn p-2 text-xs rounded transition-colors"
onClick={() => {
setZoom(1)
setTranslate({ x: 0, y: 0 })
}}
>
{Math.round(zoom * 100)}
%
</button>
<button className="infographic-action-btn inline-flex items-center justify-center p-2 rounded transition-colors" onClick={closeModal}>X</button>
</div>
<div
ref={modalContentRef}
className={clsx('w-full h-full flex items-center justify-center p-4 overflow-hidden', !isDragging && 'cursor-grab', isDragging && 'cursor-grabbing')}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={stopDrag}
onMouseLeave={stopDrag}
onTouchStart={onMouseDown}
onTouchMove={onMouseMove}
onTouchEnd={stopDrag}
/>
</div>
</div>,
document.body,
)}
</>
)
}
@@ -0,0 +1,31 @@
export const INFOGRAPHIC_PREVIEW_MIN_HEIGHT = 360
export const INFOGRAPHIC_PREVIEW_MAX_HEIGHT = 500
export function parsePositiveNumber(value: unknown) {
const numeric = typeof value === 'number' ? value : Number.parseFloat(String(value ?? ''))
return Number.isFinite(numeric) && numeric > 0 ? numeric : null
}
export function estimateInfographicPreviewHeight(code: string) {
const itemCount = code
.split(/\r?\n/)
.filter(line => /^\s*-\s+/.test(line))
.length
if (itemCount >= 3)
return INFOGRAPHIC_PREVIEW_MAX_HEIGHT
if (itemCount > 0)
return 280 + itemCount * 60
return INFOGRAPHIC_PREVIEW_MIN_HEIGHT
}
export function clampInfographicPreviewHeight(
height: number,
minHeight = INFOGRAPHIC_PREVIEW_MIN_HEIGHT,
maxHeight: number | null = INFOGRAPHIC_PREVIEW_MAX_HEIGHT,
) {
return maxHeight == null
? Math.max(minHeight, height)
: Math.min(Math.max(minHeight, height), maxHeight)
}
@@ -0,0 +1,2 @@
export { InfographicBlockNode } from './InfographicBlockNode'
export { InfographicBlockNode as default } from './InfographicBlockNode'
@@ -0,0 +1,35 @@
let infographicPromise: Promise<any> | null = null
let infographicInstance: any = null
export async function getInfographic() {
if (infographicInstance)
return infographicInstance
if (infographicPromise)
return infographicPromise
infographicPromise = import('@antv/infographic')
.then((mod) => {
// Normalize module
const defaultExport = (mod && (mod as any).default) ? (mod as any).default : mod
let resolved = defaultExport
if (typeof defaultExport === 'function' && defaultExport.prototype && defaultExport.prototype.render) {
resolved = defaultExport
}
else if (mod.Infographic) {
resolved = mod.Infographic
}
else if (defaultExport && defaultExport.Infographic) {
resolved = defaultExport.Infographic
}
infographicInstance = resolved
return resolved
})
.catch((e) => {
console.warn('Failed to load @antv/infographic', e)
return null
})
return infographicPromise
}
@@ -0,0 +1,92 @@
import type { NodeComponentProps } from '../../types/node-component'
import clsx from 'clsx'
import React, { useEffect, useRef, useState } from 'react'
import { resolveStreamingTextUpdate } from '../../utils/streamingTextState'
export function InlineCodeNode(props: NodeComponentProps<{ type: 'inline_code', code: string }>) {
const { node, children, ctx, indexKey, typewriter } = props
const content = String(node.code ?? '')
const typewriterEnabled = typewriter ?? ctx?.typewriter ?? true
const streamStateKey = indexKey == null || indexKey === ''
? ''
: String(indexKey)
const [settledContent, setSettledContent] = useState(content)
const [streamedDelta, setStreamedDelta] = useState('')
const [streamFadeVersion, setStreamFadeVersion] = useState(0)
const renderedContentRef = useRef({
settledContent: content,
streamedDelta: '',
})
const setRenderedContent = (nextSettledContent: string, nextStreamedDelta: string) => {
renderedContentRef.current = {
settledContent: nextSettledContent,
streamedDelta: nextStreamedDelta,
}
setSettledContent(nextSettledContent)
setStreamedDelta(nextStreamedDelta)
}
const getRenderedContent = () => {
const { settledContent, streamedDelta } = renderedContentRef.current
return settledContent + streamedDelta
}
useEffect(() => {
if (children != null) {
setRenderedContent('', '')
return
}
const textStreamState = ctx?.textStreamState
const currentState = renderedContentRef.current
const persistedContent = streamStateKey
? textStreamState?.get(streamStateKey)
: undefined
const nextState = resolveStreamingTextUpdate({
nextContent: content,
persistedContent,
currentState,
typewriterEnabled,
})
setRenderedContent(nextState.settledContent, nextState.streamedDelta)
if (nextState.appended)
setStreamFadeVersion(version => version + 1)
if (streamStateKey)
textStreamState?.set(streamStateKey, content)
}, [children, content, ctx?.textStreamState, streamStateKey, typewriterEnabled])
const handleStreamedDeltaAnimationEnd = () => {
if (!renderedContentRef.current.streamedDelta)
return
setRenderedContent(getRenderedContent(), '')
}
return (
<code className="inline-code inline text-[85%] px-1 py-0.5 rounded font-mono bg-[hsl(var(--secondary))] whitespace-normal break-words max-w-full">
{children || (
<>
{settledContent ? <span>{settledContent}</span> : null}
{streamedDelta
? (
<span
className={clsx(
'text-node-stream-delta',
streamFadeVersion % 2 === 0
? 'text-node-stream-delta--a'
: 'text-node-stream-delta--b',
)}
onAnimationEnd={handleStreamedDeltaAnimationEnd}
>
{streamedDelta}
</span>
)
: null}
</>
)}
</code>
)
}
export default InlineCodeNode
@@ -0,0 +1,8 @@
import type { NodeComponentProps } from '../../types/node-component'
import React from 'react'
export function InsertNode(props: NodeComponentProps<{ type: 'insert' }>) {
return <ins className="insert-node">{props.children}</ins>
}
export default InsertNode
@@ -0,0 +1,125 @@
import type { ParsedNode } from 'stream-markdown-parser'
import type { NodeComponentProps } from '../../types/node-component'
import React, { useCallback, useMemo } from 'react'
import { renderNodeChildren } from '../../renderers/renderChildren'
import { hideTooltip, showTooltipForAnchor } from '../../tooltip/singletonTooltip'
import { TextNode } from '../TextNode/TextNode'
export interface LinkNodeStyleProps {
showTooltip?: boolean
color?: string
underlineHeight?: number
underlineBottom?: number | string
animationDuration?: number
animationOpacity?: number
animationTiming?: string
animationIteration?: string | number
}
export function LinkNode(props: NodeComponentProps<{
type: 'link'
href: string
title: string | null
text: string
children?: ParsedNode[]
loading?: boolean
}> & LinkNodeStyleProps) {
const { node, ctx, renderNode, indexKey } = props
const showTip = props.showTooltip !== false
const isDark = props.isDark ?? ctx?.isDark
const cssVars = useMemo(() => {
const bottom = props.underlineBottom !== undefined
? (typeof props.underlineBottom === 'number' ? `${props.underlineBottom}px` : String(props.underlineBottom))
: '-3px'
const activeOpacity = props.animationOpacity ?? 0.35
const restingOpacity = Math.max(0.12, Math.min(activeOpacity * 0.5, activeOpacity))
return {
['--link-color' as any]: props.color ?? '#0366d6',
['--underline-height' as any]: `${props.underlineHeight ?? 2}px`,
['--underline-bottom' as any]: bottom,
['--underline-opacity' as any]: String(activeOpacity),
['--underline-rest-opacity' as any]: String(restingOpacity),
['--underline-duration' as any]: `${props.animationDuration ?? 1.6}s`,
['--underline-timing' as any]: props.animationTiming ?? 'ease-in-out',
['--underline-iteration' as any]: typeof props.animationIteration === 'number'
? String(props.animationIteration)
: (props.animationIteration ?? 'infinite'),
} as React.CSSProperties
}, [
props.animationDuration,
props.animationIteration,
props.animationOpacity,
props.animationTiming,
props.color,
props.underlineBottom,
props.underlineHeight,
])
const title = typeof node.title === 'string' && node.title.trim().length > 0
? node.title
: String(node.href ?? '')
const onAnchorEnter = useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
if (!showTip)
return
const target = event.currentTarget as HTMLElement | null
const txt = String(node.title || node.href || node.text || '')
showTooltipForAnchor(target, txt, 'top', false, { x: event.clientX, y: event.clientY }, isDark)
}, [isDark, node.href, node.text, node.title, showTip])
const onAnchorLeave = useCallback(() => {
if (!showTip)
return
hideTooltip()
}, [showTip])
if (node.loading) {
return (
<span
className="link-loading inline-flex items-baseline gap-1.5"
aria-hidden="false"
style={cssVars}
>
<span className="link-text-wrapper relative inline-flex">
<span className="leading-[normal] link-text">
<TextNode
node={{ type: 'text', content: String(node.text ?? '') }}
ctx={ctx}
indexKey={`${String(indexKey ?? 'link')}-loading`}
typewriter={props.typewriter}
/>
</span>
<span className="link-loading-indicator" aria-hidden="true" />
</span>
</span>
)
}
return (
<a
className="link-node"
href={node.href}
title={showTip ? '' : title}
aria-label={`Link: ${title}`}
target="_blank"
rel="noopener noreferrer"
style={cssVars}
onMouseEnter={onAnchorEnter}
onMouseLeave={onAnchorLeave}
>
{ctx && renderNode
? renderNodeChildren(node.children, ctx, String(indexKey ?? 'link'), renderNode)
: (
<TextNode
node={{ type: 'text', content: String(node.text ?? '') }}
ctx={ctx}
indexKey={`${String(indexKey ?? 'link')}-fallback`}
typewriter={props.typewriter}
/>
)}
</a>
)
}
export default LinkNode
@@ -0,0 +1,28 @@
import type { ParsedNode } from 'stream-markdown-parser'
import type { NodeComponentProps } from '../../types/node-component'
import React from 'react'
import { renderNodeChildren } from '../../renderers/renderChildren'
export interface ListItemNodeData {
type: 'list_item'
children?: ParsedNode[]
raw?: string
}
export interface ListItemNodeProps extends NodeComponentProps<ListItemNodeData> {
value?: number
}
export function ListItemNode(props: ListItemNodeProps) {
const { node, ctx, renderNode, value, indexKey, children } = props
const valueAttr = value == null ? undefined : value
return (
<li className="list-item pl-1.5 my-2" dir="auto" value={valueAttr}>
{children ?? (ctx && renderNode
? renderNodeChildren(node.children, ctx, String(indexKey ?? 'list-item'), renderNode)
: null)}
</li>
)
}
export default ListItemNode
@@ -0,0 +1,37 @@
import type { NodeComponentProps } from '../../types/node-component'
import clsx from 'clsx'
import React from 'react'
import { getCustomNodeComponents } from '../../customComponents'
import { ListItemNode } from '../ListItemNode/ListItemNode'
export function ListNode(props: NodeComponentProps<{ type: 'list', ordered?: boolean, start?: number, items?: any[] }>) {
const { node, ctx, renderNode, indexKey } = props
const Tag = node.ordered ? 'ol' : 'ul'
const startAttr = node.ordered && node.start ? node.start : undefined
const ListItemComponent = ((ctx && getCustomNodeComponents(ctx.customId).list_item) || ListItemNode) as any
return (
<Tag
className={clsx(
'list-node my-5 pl-[calc(13/8*1em)]',
node.ordered ? 'list-decimal' : 'list-disc max-lg:my-[calc(4/3*1em)] max-lg:pl-[calc(14/9*1em)]',
)}
start={startAttr}
>
{node.items?.map((item: any, idx: number) => (
<ListItemComponent
key={`${String(indexKey ?? 'list')}-${idx}`}
node={item}
value={node.ordered ? (node.start ?? 1) + idx : undefined}
ctx={ctx}
renderNode={renderNode}
indexKey={`${String(indexKey ?? 'list')}-${idx}`}
customId={ctx?.customId}
isDark={ctx?.isDark}
typewriter={ctx?.typewriter}
/>
))}
</Tag>
)
}
export default ListNode
@@ -0,0 +1,620 @@
import type { VisibilityHandle } from '../../context/viewportPriority'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useViewportPriority } from '../../context/viewportPriority'
import { useSafeI18n } from '../../i18n/useSafeI18n'
import { hideTooltip, showTooltipForAnchor } from '../../tooltip/singletonTooltip'
import { getLanguageIcon, languageMap, normalizeLanguageIdentifier, subscribeLanguageIconsRevision } from '../../utils/languageIcon'
export interface MarkdownCodeBlockNodeProps {
node: {
type: 'code_block'
language: string
code: string
raw: string
diff?: boolean
originalCode?: string
updatedCode?: string
}
loading?: boolean
stream?: boolean
darkTheme?: string
lightTheme?: string
isDark?: boolean
isShowPreview?: boolean
enableFontSizeControl?: boolean
minWidth?: string | number
maxWidth?: string | number
themes?: string[]
showHeader?: boolean
showCopyButton?: boolean
showExpandButton?: boolean
showPreviewButton?: boolean
showCollapseButton?: boolean
showFontSizeButtons?: boolean
showTooltips?: boolean
onCopy?: (code: string) => void
onPreviewCode?: (payload: { type: string, content: string, title: string }) => void
}
interface ShikiRenderer {
updateCode: (code: string, lang?: string) => void | Promise<void>
setTheme: (theme?: string) => void | Promise<void>
dispose: () => void
}
function escapeHtml(str: string) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function normalizeRendererLanguage(rawLang?: string | null) {
const [baseToken] = String(rawLang ?? '').split(':')
const normalized = baseToken?.trim().toLowerCase() ?? ''
return normalized || 'plaintext'
}
export function MarkdownCodeBlockNode(rawProps: MarkdownCodeBlockNodeProps) {
const props: Required<Pick<
MarkdownCodeBlockNodeProps,
| 'loading'
| 'stream'
| 'darkTheme'
| 'lightTheme'
| 'isDark'
| 'isShowPreview'
| 'enableFontSizeControl'
| 'showHeader'
| 'showCopyButton'
| 'showExpandButton'
| 'showPreviewButton'
| 'showCollapseButton'
| 'showFontSizeButtons'
>> & MarkdownCodeBlockNodeProps = {
loading: true,
stream: true,
darkTheme: 'vitesse-dark',
lightTheme: 'vitesse-light',
isDark: false,
isShowPreview: true,
enableFontSizeControl: true,
showHeader: true,
showCopyButton: true,
showExpandButton: true,
showPreviewButton: true,
showCollapseButton: true,
showFontSizeButtons: true,
...rawProps,
}
const codeLanguage = useMemo(() => String(props.node.language ?? ''), [props.node.language])
const normalizedLanguage = useMemo(() => normalizeRendererLanguage(codeLanguage), [codeLanguage])
const canonicalLanguage = useMemo(() => normalizeLanguageIdentifier(codeLanguage), [codeLanguage])
const [languageIconsRevision, setLanguageIconsRevision] = useState(0)
useEffect(() => {
return subscribeLanguageIconsRevision(() => {
setLanguageIconsRevision(v => v + 1)
})
}, [])
const displayLanguage = useMemo(() => {
const label = languageMap[canonicalLanguage] || canonicalLanguage
if (!label)
return 'Text'
return label.charAt(0).toUpperCase() + label.slice(1)
}, [canonicalLanguage])
const languageIcon = useMemo(
() => getLanguageIcon(canonicalLanguage),
[canonicalLanguage, languageIconsRevision],
)
const { t } = useSafeI18n()
const isPreviewable = useMemo(() => {
if (!props.isShowPreview)
return false
return canonicalLanguage === 'html' || canonicalLanguage === 'svg'
}, [canonicalLanguage, props.isShowPreview])
const containerStyle = useMemo(() => {
const style: Record<string, string> = {}
const fmt = (v: string | number | undefined) => {
if (v == null)
return undefined
return typeof v === 'number' ? `${v}px` : String(v)
}
const min = fmt(props.minWidth)
const max = fmt(props.maxWidth)
if (min)
style.minWidth = min
if (max)
style.maxWidth = max
return style
}, [props.maxWidth, props.minWidth])
const [copied, setCopied] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(false)
const [fallbackHtml, setFallbackHtml] = useState('')
const [rendererReady, setRendererReady] = useState(false)
const [defaultFontSize, setDefaultFontSize] = useState<number>(14)
const [fontSize, setFontSize] = useState<number>(defaultFontSize)
const tooltipsEnabled = useMemo(() => props.showTooltips !== false, [props.showTooltips])
const viewportTargetRef = useRef<HTMLDivElement | null>(null)
const codeBlockContentRef = useRef<HTMLDivElement | null>(null)
const rendererTargetRef = useRef<HTMLDivElement | null>(null)
const rendererRef = useRef<ShikiRenderer | null>(null)
const createRendererRef = useRef<null | ((el: HTMLElement, opts: { theme?: string | undefined, themes?: string[] | undefined }) => ShikiRenderer)>(null)
const importAttemptedRef = useRef(false)
const viewportHandleRef = useRef<VisibilityHandle | null>(null)
const registerViewport = useViewportPriority()
const [viewportReady, setViewportReady] = useState(() => typeof window === 'undefined')
const getPreferredColorScheme = useCallback(() => {
return props.isDark ? props.darkTheme : props.lightTheme
}, [props.darkTheme, props.isDark, props.lightTheme])
const renderFallback = useCallback((code: string) => {
if (!code) {
setFallbackHtml('')
setRendererReady(false)
return
}
setFallbackHtml(`<pre class="shiki shiki-fallback"><code>${escapeHtml(code)}</code></pre>`)
setRendererReady(false)
}, [])
const clearFallback = useCallback(() => {
setFallbackHtml('')
setRendererReady(true)
}, [])
const ensureStreamMarkdownLoaded = useCallback(async () => {
if (createRendererRef.current || importAttemptedRef.current)
return
importAttemptedRef.current = true
try {
const mod: any = await import('stream-markdown')
createRendererRef.current = mod.createShikiStreamRenderer
}
catch {
// optional peer
}
}, [])
const initRenderer = useCallback(async () => {
if (!viewportReady) {
renderFallback(props.node.code)
return
}
await ensureStreamMarkdownLoaded()
if (!codeBlockContentRef.current || !rendererTargetRef.current) {
renderFallback(props.node.code)
return
}
if (!rendererRef.current && createRendererRef.current) {
rendererRef.current = createRendererRef.current(rendererTargetRef.current, {
theme: getPreferredColorScheme(),
themes: props.themes,
})
setRendererReady(true)
}
if (!rendererRef.current) {
renderFallback(props.node.code)
return
}
if (props.stream === false && props.loading) {
renderFallback(props.node.code)
return
}
renderFallback(props.node.code)
try {
await rendererRef.current.updateCode(props.node.code, normalizedLanguage)
clearFallback()
}
catch {
// keep fallback
}
}, [clearFallback, ensureStreamMarkdownLoaded, getPreferredColorScheme, normalizedLanguage, props.loading, props.node.code, props.stream, props.themes, renderFallback, viewportReady])
useEffect(() => {
const el = viewportTargetRef.current
if (!el)
return
const handle = registerViewport(el, { rootMargin: '400px' })
viewportHandleRef.current = handle
if (handle.isVisible())
setViewportReady(true)
handle.whenVisible.then(() => setViewportReady(true))
return () => {
handle.destroy()
viewportHandleRef.current = null
}
}, [registerViewport])
useEffect(() => {
void initRenderer()
return () => {
rendererRef.current?.dispose()
rendererRef.current = null
}
}, [initRenderer])
useEffect(() => {
if (!rendererRef.current)
return
if (!viewportReady)
return
void rendererRef.current.setTheme(getPreferredColorScheme())
}, [getPreferredColorScheme, viewportReady])
useEffect(() => {
if (!viewportReady) {
renderFallback(props.node.code)
return
}
if (!rendererRef.current)
return
if (props.stream === false && props.loading)
return
renderFallback(props.node.code)
Promise.resolve(rendererRef.current.updateCode(props.node.code, normalizedLanguage))
.then(() => clearFallback())
.catch(() => {})
}, [clearFallback, normalizedLanguage, props.loading, props.node.code, props.stream, renderFallback, viewportReady])
useEffect(() => {
if (!tooltipsEnabled)
hideTooltip(true)
}, [tooltipsEnabled])
const onBtnHover = useCallback((event: React.MouseEvent<HTMLElement> | React.FocusEvent<HTMLElement>, text: string) => {
if (!tooltipsEnabled)
return
const btn = event.currentTarget as unknown as HTMLElement
if (!btn || (btn as HTMLButtonElement).disabled)
return
const origin = 'clientX' in event
? { x: event.clientX, y: event.clientY }
: undefined
showTooltipForAnchor(btn, text, 'top', false, origin, props.isDark)
}, [props.isDark, tooltipsEnabled])
const onBtnLeave = useCallback(() => {
if (!tooltipsEnabled)
return
hideTooltip()
}, [tooltipsEnabled])
const copy = useCallback(async () => {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function')
await navigator.clipboard.writeText(props.node.code)
setCopied(true)
props.onCopy?.(props.node.code)
setTimeout(() => setCopied(false), 1000)
}
catch {}
}, [props])
const previewCode = useCallback(() => {
if (!isPreviewable)
return
const lowerLang = normalizedLanguage
const artifactType = lowerLang === 'html' ? 'text/html' : 'image/svg+xml'
const artifactTitle = lowerLang === 'html' ? 'HTML Preview' : 'SVG Preview'
props.onPreviewCode?.({
type: artifactType,
content: props.node.code,
title: artifactTitle,
})
}, [isPreviewable, normalizedLanguage, props])
const contentStyle = useMemo(() => ({ fontSize: `${fontSize}px` }), [fontSize])
const decreaseCodeFont = () => setFontSize(s => Math.max(10, s - 1))
const increaseCodeFont = () => setFontSize(s => Math.min(36, s + 1))
const resetCodeFont = () => setFontSize(defaultFontSize)
useEffect(() => {
const initial = 14
setDefaultFontSize(initial)
setFontSize(initial)
}, [])
return (
<div
ref={viewportTargetRef}
className={[
'code-block-container my-4 rounded-lg border overflow-hidden shadow-sm',
props.isDark ? 'border-gray-700/30 bg-gray-900 is-dark' : 'border-gray-200 bg-white',
].join(' ')}
style={containerStyle}
>
{props.showHeader && (
<div
className="code-block-header flex justify-between items-center px-4 py-2.5 border-b border-gray-400/5"
style={{ color: 'var(--vscode-editor-foreground)', backgroundColor: 'var(--vscode-editor-background)' }}
>
<div className="flex items-center space-x-2">
<span
className="icon-slot h-4 w-4 flex-shrink-0"
// language icons are trusted internal assets or user-supplied via resolver
dangerouslySetInnerHTML={{ __html: languageIcon }}
/>
<span className="text-sm font-medium font-mono">{displayLanguage}</span>
</div>
<div className="flex items-center space-x-2">
{props.showCollapseButton && (
<button
type="button"
className="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
aria-pressed={isCollapsed}
onClick={() => setIsCollapsed(v => !v)}
onMouseEnter={e => onBtnHover(e, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))}
onFocus={e => onBtnHover(e as any, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))}
onMouseLeave={onBtnLeave}
onBlur={onBtnLeave}
>
<svg
style={{ rotate: isCollapsed ? '0deg' : '90deg' }}
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
width="1em"
height="1em"
viewBox="0 0 24 24"
className="w-3 h-3"
>
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m9 18l6-6l-6-6" />
</svg>
</button>
)}
{props.showFontSizeButtons && props.enableFontSizeControl && (
<>
<button
type="button"
className="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
disabled={fontSize <= 10}
onClick={decreaseCodeFont}
onMouseEnter={e => onBtnHover(e, t('common.decrease') || 'Decrease')}
onFocus={e => onBtnHover(e as any, t('common.decrease') || 'Decrease')}
onMouseLeave={onBtnLeave}
onBlur={onBtnLeave}
>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
width="1em"
height="1em"
viewBox="0 0 24 24"
className="w-3 h-3"
>
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14" />
</svg>
</button>
<button
type="button"
className="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
disabled={fontSize === defaultFontSize}
onClick={resetCodeFont}
onMouseEnter={e => onBtnHover(e, t('common.reset') || 'Reset')}
onFocus={e => onBtnHover(e as any, t('common.reset') || 'Reset')}
onMouseLeave={onBtnLeave}
onBlur={onBtnLeave}
>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
width="1em"
height="1em"
viewBox="0 0 24 24"
className="w-3 h-3"
>
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<path d="M3 12a9 9 0 1 0 9-9a9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</g>
</svg>
</button>
<button
type="button"
className="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
disabled={fontSize >= 36}
onClick={increaseCodeFont}
onMouseEnter={e => onBtnHover(e, t('common.increase') || 'Increase')}
onFocus={e => onBtnHover(e as any, t('common.increase') || 'Increase')}
onMouseLeave={onBtnLeave}
onBlur={onBtnLeave}
>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
width="1em"
height="1em"
viewBox="0 0 24 24"
className="w-3 h-3"
>
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7-7v14" />
</svg>
</button>
</>
)}
{props.showCopyButton && (
<button
type="button"
className="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
aria-label={copied ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy')}
onClick={copy}
onMouseEnter={e => onBtnHover(e, copied ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy'))}
onFocus={e => onBtnHover(e as any, copied ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy'))}
onMouseLeave={onBtnLeave}
onBlur={onBtnLeave}
>
{!copied
? (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
width="1em"
height="1em"
viewBox="0 0 24 24"
className="w-3 h-3"
>
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</g>
</svg>
)
: (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
width="1em"
height="1em"
viewBox="0 0 24 24"
className="w-3 h-3"
>
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 6L9 17l-5-5" />
</svg>
)}
</button>
)}
{props.showExpandButton && (
<button
type="button"
className="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
aria-pressed={isExpanded}
onClick={(e) => {
setIsExpanded(v => !v)
onBtnHover(e, !isExpanded ? (t('common.collapse') || 'Collapse') : (t('common.expand') || 'Expand'))
}}
onMouseEnter={e => onBtnHover(e, isExpanded ? (t('common.collapse') || 'Collapse') : (t('common.expand') || 'Expand'))}
onFocus={e => onBtnHover(e as any, isExpanded ? (t('common.collapse') || 'Collapse') : (t('common.expand') || 'Expand'))}
onMouseLeave={onBtnLeave}
onBlur={onBtnLeave}
>
{isExpanded
? (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
width="1em"
height="1em"
viewBox="0 0 24 24"
className="w-3 h-3"
>
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m14 10l7-7m-1 7h-6V4M3 21l7-7m-6 0h6v6" />
</svg>
)
: (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
width="1em"
height="1em"
viewBox="0 0 24 24"
className="w-3 h-3"
>
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 3h6v6m0-6l-7 7M3 21l7-7m-1 7H3v-6" />
</svg>
)}
</button>
)}
{isPreviewable && props.showPreviewButton && (
<button
type="button"
className="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
aria-label={t('common.preview') || 'Preview'}
onClick={previewCode}
onMouseEnter={e => onBtnHover(e, t('common.preview') || 'Preview')}
onFocus={e => onBtnHover(e as any, t('common.preview') || 'Preview')}
onMouseLeave={onBtnLeave}
onBlur={onBtnLeave}
>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
width="1em"
height="1em"
viewBox="0 0 24 24"
className="w-3 h-3"
>
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<path d="M2.062 12.348a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 19.876 0a1 1 0 0 1 0 .696a10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</g>
</svg>
</button>
)}
</div>
</div>
)}
{!isCollapsed && (props.stream ? true : !props.loading) && (
<div
ref={codeBlockContentRef}
className="code-block-content"
style={{
...contentStyle,
maxHeight: isExpanded ? 'none' : '500px',
overflowY: isExpanded ? 'visible' : 'auto',
overflowX: 'auto',
}}
>
<div ref={rendererTargetRef} className="code-block-render" />
{!rendererReady && fallbackHtml && (
<div className="code-fallback-plain" dangerouslySetInnerHTML={{ __html: fallbackHtml }} />
)}
</div>
)}
{!props.stream && props.loading && (
<div className="code-loading-placeholder">
<div className="loading-skeleton">
<div className="skeleton-line" />
<div className="skeleton-line" />
<div className="skeleton-line short" />
</div>
</div>
)}
<span className="sr-only" aria-live="polite" role="status">{copied ? (t('common.copied') || 'Copied') : ''}</span>
</div>
)
}
export default MarkdownCodeBlockNode
@@ -0,0 +1,106 @@
import type { VisibilityHandle } from '../../context/viewportPriority'
import type { MathBlockNodeProps } from '../../types/component-props'
import { useEffect, useRef, useState } from 'react'
import { useViewportPriority } from '../../context/viewportPriority'
import { normalizeKaTeXRenderInput } from '../../utils/normalizeKaTeXRenderInput'
import { renderKaTeXWithBackpressure, setKaTeXCache, WORKER_BUSY_CODE } from '../../workers/katexWorkerClient'
import { getKatex } from './katex'
export function MathBlockNode({ node }: MathBlockNodeProps) {
const containerRef = useRef<HTMLDivElement | null>(null)
const mathRef = useRef<HTMLDivElement | null>(null)
const viewportHandleRef = useRef<VisibilityHandle | null>(null)
const registerViewport = useViewportPriority()
const [rendering, setRendering] = useState(true)
const [viewportReady, setViewportReady] = useState(() => typeof window === 'undefined')
const renderIdRef = useRef(0)
useEffect(() => {
const el = containerRef.current
if (!el)
return
const handle = registerViewport(el, { rootMargin: '400px' })
viewportHandleRef.current = handle
if (handle.isVisible())
setViewportReady(true)
handle.whenVisible.then(() => setViewportReady(true))
return () => {
handle.destroy()
viewportHandleRef.current = null
}
}, [registerViewport])
useEffect(() => {
const content = normalizeKaTeXRenderInput(node.content ?? '')
if (!content) {
if (mathRef.current)
mathRef.current.innerHTML = ''
setRendering(false)
return
}
if (!viewportReady) {
setRendering(true)
return
}
let aborted = false
const controller = new AbortController()
const renderId = ++renderIdRef.current
renderKaTeXWithBackpressure(content, true, {
timeout: 3000,
waitTimeout: 2000,
maxRetries: 1,
signal: controller.signal,
})
.then((html) => {
if (aborted || renderId !== renderIdRef.current)
return
if (mathRef.current)
mathRef.current.innerHTML = html
setRendering(false)
})
.catch(async (err: any) => {
if (aborted || renderId !== renderIdRef.current)
return
if (!mathRef.current)
return
const code = err?.code || err?.name
const isWorkerInitFailure = code === 'WORKER_INIT_ERROR' || err?.fallbackToRenderer
const isBusyOrTimeout = code === WORKER_BUSY_CODE || code === 'WORKER_TIMEOUT'
if (isWorkerInitFailure || isBusyOrTimeout) {
const katex = await getKatex()
if (katex) {
try {
const html = katex.renderToString(content, {
throwOnError: node.loading,
displayMode: true,
})
if (!aborted && renderId === renderIdRef.current && mathRef.current) {
mathRef.current.innerHTML = html
setRendering(false)
setKaTeXCache(content, true, html)
}
return
}
catch {}
}
}
if (!node.loading) {
mathRef.current.textContent = node.raw
setRendering(false)
}
})
return () => {
aborted = true
controller.abort()
}
}, [node.content, node.loading, node.raw, viewportReady])
return (
<div ref={containerRef} className="math-block text-center overflow-x-auto relative min-h-[40px]">
<div ref={mathRef} className={rendering ? 'math-rendering' : undefined} />
</div>
)
}
@@ -0,0 +1,113 @@
import type { VisibilityHandle } from '../../context/viewportPriority'
import type { MathInlineNodeProps } from '../../types/component-props'
import { useEffect, useRef, useState } from 'react'
import { useViewportPriority } from '../../context/viewportPriority'
import { normalizeKaTeXRenderInput } from '../../utils/normalizeKaTeXRenderInput'
import { renderKaTeXWithBackpressure, setKaTeXCache, WORKER_BUSY_CODE } from '../../workers/katexWorkerClient'
import { getKatex } from './katex'
export function MathInlineNode({ node }: MathInlineNodeProps) {
const containerRef = useRef<HTMLSpanElement | null>(null)
const mathRef = useRef<HTMLSpanElement | null>(null)
const viewportHandleRef = useRef<VisibilityHandle | null>(null)
const registerViewport = useViewportPriority()
const [loading, setLoading] = useState(true)
const [viewportReady, setViewportReady] = useState(() => typeof window === 'undefined')
const renderIdRef = useRef(0)
useEffect(() => {
const el = containerRef.current
if (!el)
return
const handle = registerViewport(el, { rootMargin: '400px' })
viewportHandleRef.current = handle
if (handle.isVisible())
setViewportReady(true)
handle.whenVisible.then(() => setViewportReady(true))
return () => {
handle.destroy()
viewportHandleRef.current = null
}
}, [registerViewport])
useEffect(() => {
const content = normalizeKaTeXRenderInput(node.content ?? '')
if (!content) {
if (mathRef.current)
mathRef.current.innerHTML = ''
setLoading(false)
return
}
if (!viewportReady) {
setLoading(true)
return
}
let aborted = false
const controller = new AbortController()
const renderId = ++renderIdRef.current
const displayMode = node.markup === '$$'
renderKaTeXWithBackpressure(content, displayMode, {
timeout: 1500,
waitTimeout: 0,
maxRetries: 0,
signal: controller.signal,
})
.then((html) => {
if (aborted || renderId !== renderIdRef.current)
return
if (mathRef.current) {
mathRef.current.innerHTML = html
setLoading(false)
}
})
.catch(async (err: any) => {
if (aborted || renderId !== renderIdRef.current)
return
if (!mathRef.current)
return
const code = err?.code || err?.name
const isWorkerInitFailure = code === 'WORKER_INIT_ERROR' || err?.fallbackToRenderer
const isBusyOrTimeout = code === WORKER_BUSY_CODE || code === 'WORKER_TIMEOUT'
if (isWorkerInitFailure || isBusyOrTimeout) {
const katex = await getKatex()
if (katex) {
try {
const html = katex.renderToString(content, {
throwOnError: node.loading,
displayMode,
})
if (!aborted && renderId === renderIdRef.current && mathRef.current) {
mathRef.current.innerHTML = html
setLoading(false)
setKaTeXCache(content, displayMode, html)
}
return
}
catch {}
}
}
if (!node.loading) {
mathRef.current.textContent = node.raw
setLoading(false)
}
})
return () => {
aborted = true
controller.abort()
}
}, [node.content, node.loading, node.raw, node.markup, viewportReady])
return (
<span ref={containerRef} className="math-inline-wrapper">
<span ref={mathRef} className={`math-inline${loading ? ' math-inline--hidden' : ''}`} />
{loading && (
<span className="math-inline__loading" role="status" aria-live="polite">
<span className="math-inline__spinner" aria-hidden="true" />
<span className="sr-only">Loading</span>
</span>
)}
</span>
)
}
@@ -0,0 +1,18 @@
let katex: any = null
let importAttempted = false
export async function getKatex() {
if (katex)
return katex
if (importAttempted)
return null
try {
katex = await import('katex')
await import('katex/contrib/mhchem')
return katex
}
catch {
importAttempted = true
return null
}
}
@@ -0,0 +1,2 @@
export { MathBlockNode } from '../Math/MathBlockNode'
export { MathBlockNode as default } from '../Math/MathBlockNode'
@@ -0,0 +1,2 @@
export { MathInlineNode } from '../Math/MathInlineNode'
export { MathInlineNode as default } from '../Math/MathInlineNode'
@@ -0,0 +1,47 @@
export const MERMAID_PREVIEW_MIN_HEIGHT = 360
export const MERMAID_PREVIEW_MAX_HEIGHT = 500
export function parsePositiveNumber(value: unknown) {
const numeric = typeof value === 'number' ? value : Number.parseFloat(String(value ?? ''))
return Number.isFinite(numeric) && numeric > 0 ? numeric : null
}
export function getMermaidDiagramKind(code: string) {
for (const rawLine of code.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('%%'))
continue
const match = line.match(/^([A-Z][\w-]*)\b/i)
return match?.[1]?.toLowerCase() || ''
}
return ''
}
export function estimateMermaidPreviewHeight(code: string) {
const meaningfulLines = code
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line && !line.startsWith('%%'))
const lineCount = Math.max(1, meaningfulLines.length)
const kind = getMermaidDiagramKind(code)
if (kind === 'gantt')
return 220 + lineCount * 28
if (kind === 'sequencediagram')
return 180 + lineCount * 26
if (kind === 'classdiagram' || kind === 'statediagram' || kind === 'erdiagram')
return 180 + lineCount * 24
if (kind === 'flowchart' || kind === 'graph')
return 170 + lineCount * 28
return 200 + lineCount * 22
}
export function clampMermaidPreviewHeight(
height: number,
minHeight = MERMAID_PREVIEW_MIN_HEIGHT,
maxHeight: number | null = MERMAID_PREVIEW_MAX_HEIGHT,
) {
return maxHeight == null
? Math.max(minHeight, height)
: Math.min(Math.max(minHeight, height), maxHeight)
}
@@ -0,0 +1,2 @@
export { MermaidBlockNode } from './MermaidBlockNode'
export { MermaidBlockNode as default } from './MermaidBlockNode'
@@ -0,0 +1,78 @@
let cachedMermaid: any = null
let importAttempted = false
let lastInitKey: string | null = null
let pendingImport: Promise<any | null> | null = null
function computeInitKey(config: Record<string, any>) {
const securityLevel = String(config?.securityLevel ?? 'loose')
const htmlLabels = config?.flowchart?.htmlLabels
return `${securityLevel}|htmlLabels:${htmlLabels === false ? '0' : '1'}`
}
function ensureInitialized(instance: any, config?: Record<string, any>) {
if (!instance || !config)
return
const key = computeInitKey(config)
if (lastInitKey === key)
return
try {
if (typeof instance.initialize === 'function')
instance.initialize(config)
else if (instance.mermaidAPI?.initialize)
instance.mermaidAPI.initialize(config)
lastInitKey = key
}
catch {
// ignore init failures; mermaid may already be initialized in some environments
}
}
export async function getMermaid(initConfig?: Record<string, any>) {
if (cachedMermaid) {
ensureInitialized(cachedMermaid, initConfig)
return cachedMermaid
}
if (pendingImport) {
const instance = await pendingImport
if (instance)
ensureInitialized(instance, initConfig)
return instance
}
if (importAttempted)
return null
try {
importAttempted = true
pendingImport = (async () => {
try {
const mod: any = await import('mermaid')
const candidate: any = mod?.default || mod
if (candidate?.default)
cachedMermaid = candidate.default
else if (candidate?.mermaidAPI)
cachedMermaid = candidate
else if (candidate?.mermaid)
cachedMermaid = candidate.mermaid
else
cachedMermaid = candidate
if (!cachedMermaid)
throw new Error('Mermaid module did not export expected API')
ensureInitialized(cachedMermaid, initConfig ?? { startOnLoad: false, securityLevel: 'loose' })
return cachedMermaid
}
catch (err) {
console.warn('[markstream-react] Failed to load mermaid:', err)
return null
}
finally {
pendingImport = null
}
})()
const instance = await pendingImport
if (instance)
ensureInitialized(instance, initConfig)
return instance
}
catch {
return null
}
}
@@ -0,0 +1,931 @@
import type { ParsedNode } from 'stream-markdown-parser'
import type { VisibilityHandle } from '../context/viewportPriority'
import type { NodeRendererProps, RenderContext } from '../types'
import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import {
getMarkdown,
mergeCustomHtmlTags,
parseMarkdownToStructure,
} from 'stream-markdown-parser'
import { useViewportPriority, ViewportPriorityProvider } from '../context/viewportPriority'
import { getCustomComponentsRevision, getCustomNodeComponents, subscribeCustomComponents } from '../customComponents'
import { renderNode } from '../renderers/renderNode'
import { normalizeLanguageIdentifier } from '../utils/languageIcon'
import { getUseMonaco } from './CodeBlockNode/monaco'
import { setDesiredMonacoTheme } from './CodeBlockNode/monacoThemeRegistry'
const DEFAULT_PROPS: Required<Pick<NodeRendererProps, 'codeBlockStream'
| 'typewriter'
| 'batchRendering'
| 'initialRenderBatchSize'
| 'renderBatchSize'
| 'renderBatchDelay'
| 'renderBatchBudgetMs'
| 'renderBatchIdleTimeoutMs'
| 'deferNodesUntilVisible'
| 'maxLiveNodes'
| 'liveNodeBuffer'>> = {
codeBlockStream: true,
typewriter: true,
batchRendering: true,
initialRenderBatchSize: 40,
renderBatchSize: 80,
renderBatchDelay: 16,
renderBatchBudgetMs: 6,
renderBatchIdleTimeoutMs: 120,
deferNodesUntilVisible: true,
maxLiveNodes: 320,
liveNodeBuffer: 60,
}
const fallbackMarkdown = getMarkdown()
type ResolvedProps = NodeRendererProps & typeof DEFAULT_PROPS
interface IdleDeadlineLike {
timeRemaining?: () => number
}
interface NodeRendererInnerProps {
props: ResolvedProps
parsedNodes: ParsedNode[]
renderCtx: RenderContext
indexPrefix: string
containerRef: React.RefObject<HTMLDivElement | null>
}
const DEFAULT_NODE_HEIGHT = 32
const MAX_DEFERRED_NODE_COUNT = 900
const NodeRendererInner: React.FC<NodeRendererInnerProps> = ({
props,
parsedNodes,
renderCtx,
indexPrefix,
containerRef,
}) => {
const registerNodeVisibility = useViewportPriority()
const isClient = typeof window !== 'undefined'
const hasIdleCallback = typeof window !== 'undefined' && typeof (window as any).requestIdleCallback === 'function'
const resolvedBatchSize = Math.max(0, Math.trunc(props.renderBatchSize ?? 80))
const resolvedInitialBatch = Math.max(
0,
Math.trunc(props.initialRenderBatchSize ?? (resolvedBatchSize || parsedNodes.length)),
)
const batchingEnabled = props.batchRendering !== false && resolvedBatchSize > 0 && isClient
const liveNodeBufferResolved = Math.max(0, props.liveNodeBuffer ?? 60)
const maxLiveNodesResolved = Math.max(1, props.maxLiveNodes ?? 320)
const virtualizationEnabled = (props.maxLiveNodes ?? 0) > 0 && parsedNodes.length > maxLiveNodesResolved
const viewportPriorityEnabled = props.viewportPriority !== false
const incrementalRenderingActive = batchingEnabled && (props.maxLiveNodes ?? 0) <= 0
const deferNodes = useMemo(() => {
if (props.deferNodesUntilVisible === false)
return false
if ((props.maxLiveNodes ?? 0) <= 0)
return false
if (virtualizationEnabled)
return false
if (parsedNodes.length > MAX_DEFERRED_NODE_COUNT)
return false
return viewportPriorityEnabled
}, [
parsedNodes.length,
props.deferNodesUntilVisible,
props.maxLiveNodes,
viewportPriorityEnabled,
virtualizationEnabled,
])
const [renderedCount, setRenderedCount] = useState(() => {
if (!incrementalRenderingActive)
return parsedNodes.length
return Math.min(parsedNodes.length, resolvedInitialBatch)
})
const renderedCountRef = useRef(renderedCount)
useEffect(() => {
renderedCountRef.current = renderedCount
}, [renderedCount])
const cleanupLimit = incrementalRenderingActive ? renderedCount : parsedNodes.length
const [focusIndex, setFocusIndex] = useState(0)
const [liveRange, setLiveRange] = useState({ start: 0, end: parsedNodes.length })
const nodeHeightsRef = useRef(new Map<number, number>())
const [heightsVersion, setHeightsVersion] = useState(0)
const nodeVisibilityStateRef = useRef<Record<number, boolean>>({})
const nodeVisibilityHandlesRef = useRef(new Map<number, VisibilityHandle>())
const nodeSlotElementsRef = useRef(new Map<number, HTMLElement | null>())
const nodeSlotRefCallbacksRef = useRef(new Map<number, (el: HTMLElement | null) => void>())
const nodeContentRefCallbacksRef = useRef(new Map<number, (el: HTMLDivElement | null) => void>())
const nodeSeenRef = useRef(new Set<number>())
const prevRenderedRef = useRef(renderedCount)
const batchRafRef = useRef<number | null>(null)
const batchTimeoutRef = useRef<number | null>(null)
const batchIdleRef = useRef<number | null>(null)
const batchPendingRef = useRef(false)
const pendingIncrementRef = useRef<number | null>(null)
const adaptiveBatchSizeRef = useRef(Math.max(1, resolvedBatchSize || 1))
const desiredRenderedCountRef = useRef(parsedNodes.length)
const previousDatasetRef = useRef<{ key: typeof props.indexKey, total: number }>({
key: props.indexKey,
total: parsedNodes.length,
})
const previousBatchConfigRef = useRef({
batchSize: resolvedBatchSize,
initial: resolvedInitialBatch,
delay: props.renderBatchDelay ?? 16,
enabled: incrementalRenderingActive,
})
const shouldObserveSlots = deferNodes || virtualizationEnabled
const averageNodeHeight = useMemo(() => {
const map = nodeHeightsRef.current
if (!map.size)
return DEFAULT_NODE_HEIGHT
let total = 0
for (const height of map.values())
total += height
return Math.max(16, total / map.size)
}, [heightsVersion])
const visibleNodes = useMemo(() => {
if (!virtualizationEnabled)
return parsedNodes.map((node, index) => ({ node, index }))
const total = parsedNodes.length
const start = Math.max(0, Math.min(liveRange.start, total))
const end = Math.max(start, Math.min(liveRange.end, total))
return parsedNodes.slice(start, end).map((node, idx) => ({
node,
index: start + idx,
}))
}, [parsedNodes, liveRange, virtualizationEnabled])
const desiredRenderedCount = useMemo(() => {
if (!virtualizationEnabled)
return parsedNodes.length
const overscanEnd = Math.max(liveRange.end + liveNodeBufferResolved, resolvedInitialBatch)
const target = Math.min(parsedNodes.length, overscanEnd)
return Math.max(renderedCount, target)
}, [
parsedNodes.length,
virtualizationEnabled,
liveRange.end,
liveNodeBufferResolved,
resolvedInitialBatch,
renderedCount,
])
desiredRenderedCountRef.current = desiredRenderedCount
const estimateHeightRange = useCallback((start: number, end: number) => {
if (start >= end)
return 0
const map = nodeHeightsRef.current
let total = 0
for (let i = start; i < end; i++)
total += map.get(i) ?? averageNodeHeight
return total
}, [averageNodeHeight])
const topSpacerHeight = virtualizationEnabled
? estimateHeightRange(0, Math.min(liveRange.start, parsedNodes.length))
: 0
const bottomSpacerHeight = virtualizationEnabled
? estimateHeightRange(Math.min(liveRange.end, parsedNodes.length), parsedNodes.length)
: 0
const cancelBatchTimers = useCallback(() => {
if (batchRafRef.current != null && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(batchRafRef.current)
batchRafRef.current = null
}
if (batchTimeoutRef.current != null) {
window.clearTimeout(batchTimeoutRef.current)
batchTimeoutRef.current = null
}
if (batchIdleRef.current != null && typeof (window as any).cancelIdleCallback === 'function') {
;(window as any).cancelIdleCallback(batchIdleRef.current)
batchIdleRef.current = null
}
batchPendingRef.current = false
pendingIncrementRef.current = null
}, [])
const adjustAdaptiveBatchSize = useCallback((elapsed: number) => {
if (!incrementalRenderingActive)
return
const budget = Math.max(2, props.renderBatchBudgetMs ?? 6)
const maxSize = Math.max(1, resolvedBatchSize || 1)
const minSize = Math.max(1, Math.floor(maxSize / 4))
if (elapsed > budget * 1.2)
adaptiveBatchSizeRef.current = Math.max(minSize, Math.floor(adaptiveBatchSizeRef.current * 0.7))
else if (elapsed < budget * 0.5 && adaptiveBatchSizeRef.current < maxSize)
adaptiveBatchSizeRef.current = Math.min(maxSize, Math.ceil(adaptiveBatchSizeRef.current * 1.2))
}, [incrementalRenderingActive, props.renderBatchBudgetMs, resolvedBatchSize])
const scheduleBatch = useCallback((increment: number, opts: { immediate?: boolean } = {}) => {
if (!incrementalRenderingActive)
return
const target = desiredRenderedCountRef.current
if (renderedCountRef.current >= target)
return
const amount = Math.max(1, increment)
const applyIncrement = (size: number) => {
const start = typeof performance !== 'undefined' ? performance.now() : Date.now()
setRenderedCount((prev) => {
const next = Math.min(desiredRenderedCountRef.current, prev + Math.max(1, size))
renderedCountRef.current = next
return next
})
const end = typeof performance !== 'undefined' ? performance.now() : Date.now()
adjustAdaptiveBatchSize(end - start)
}
const queueNextBatch = () => {
const dynamicSize = Math.max(1, Math.round(adaptiveBatchSizeRef.current))
scheduleBatch(dynamicSize)
}
const run = (deadline?: IdleDeadlineLike) => {
batchRafRef.current = null
batchTimeoutRef.current = null
batchIdleRef.current = null
batchPendingRef.current = false
const pending = pendingIncrementRef.current
pendingIncrementRef.current = null
const initialSize = pending != null ? pending : amount
applyIncrement(initialSize)
if (renderedCountRef.current >= desiredRenderedCountRef.current)
return
if (!deadline) {
queueNextBatch()
return
}
const budget = Math.max(2, props.renderBatchBudgetMs ?? 6)
while (renderedCountRef.current < desiredRenderedCountRef.current) {
const remaining = typeof deadline.timeRemaining === 'function' ? deadline.timeRemaining() : 0
if (remaining <= budget * 0.5)
break
applyIncrement(Math.max(1, Math.round(adaptiveBatchSizeRef.current)))
}
if (renderedCountRef.current < desiredRenderedCountRef.current)
queueNextBatch()
}
if (!isClient || opts.immediate) {
run()
return
}
const delay = Math.max(0, props.renderBatchDelay ?? 16)
pendingIncrementRef.current = pendingIncrementRef.current != null
? Math.max(pendingIncrementRef.current, amount)
: amount
if (batchPendingRef.current)
return
batchPendingRef.current = true
if (hasIdleCallback) {
const timeout = Math.max(0, props.renderBatchIdleTimeoutMs ?? 120)
batchIdleRef.current = (window as any).requestIdleCallback((deadline: IdleDeadlineLike) => run(deadline), { timeout })
return
}
if (typeof requestAnimationFrame !== 'function') {
batchTimeoutRef.current = window.setTimeout(() => run(), delay)
return
}
batchRafRef.current = requestAnimationFrame(() => {
if (delay === 0) {
run()
return
}
batchTimeoutRef.current = window.setTimeout(() => run(), delay)
})
}, [
adjustAdaptiveBatchSize,
hasIdleCallback,
incrementalRenderingActive,
isClient,
props.renderBatchDelay,
props.renderBatchBudgetMs,
props.renderBatchIdleTimeoutMs,
])
useEffect(() => {
const datasetKey = props.indexKey
const total = parsedNodes.length
const prevCtx = previousDatasetRef.current
const datasetChanged = datasetKey !== undefined
? datasetKey !== prevCtx.key
: total !== prevCtx.total
previousDatasetRef.current = { key: datasetKey, total }
const prevBatch = previousBatchConfigRef.current
const currentDelay = props.renderBatchDelay ?? 16
const batchConfigChanged
= prevBatch.batchSize !== resolvedBatchSize
|| prevBatch.initial !== resolvedInitialBatch
|| prevBatch.delay !== currentDelay
|| prevBatch.enabled !== incrementalRenderingActive
previousBatchConfigRef.current = {
batchSize: resolvedBatchSize,
initial: resolvedInitialBatch,
delay: currentDelay,
enabled: incrementalRenderingActive,
}
if (datasetChanged || batchConfigChanged || !incrementalRenderingActive)
cancelBatchTimers()
if (datasetChanged || batchConfigChanged) {
adaptiveBatchSizeRef.current = Math.max(1, resolvedBatchSize || 1)
nodeSeenRef.current.clear()
}
if (!total) {
renderedCountRef.current = 0
setRenderedCount(0)
return
}
const target = desiredRenderedCountRef.current
if (!incrementalRenderingActive) {
renderedCountRef.current = target
setRenderedCount(target)
return
}
if (datasetChanged || batchConfigChanged) {
const initial = Math.min(target, resolvedInitialBatch)
renderedCountRef.current = initial
setRenderedCount(initial)
if (initial < target)
scheduleBatch(Math.max(1, resolvedInitialBatch), { immediate: !isClient })
return
}
const capped = Math.min(renderedCountRef.current, target)
if (capped !== renderedCountRef.current) {
renderedCountRef.current = capped
setRenderedCount(capped)
}
if (renderedCountRef.current < target)
scheduleBatch(Math.max(1, resolvedBatchSize || 1))
}, [
cancelBatchTimers,
incrementalRenderingActive,
isClient,
parsedNodes.length,
props.indexKey,
props.renderBatchDelay,
resolvedBatchSize,
resolvedInitialBatch,
scheduleBatch,
])
useEffect(() => {
if (!virtualizationEnabled) {
setLiveRange({ start: 0, end: parsedNodes.length })
return
}
const total = parsedNodes.length
if (!total) {
setLiveRange({ start: 0, end: 0 })
return
}
const focus = Math.max(0, Math.min(focusIndex, total - 1))
let start = Math.max(0, focus - liveNodeBufferResolved)
let end = Math.min(total, focus + liveNodeBufferResolved + 1)
const size = end - start
if (size > maxLiveNodesResolved) {
const excess = size - maxLiveNodesResolved
start += Math.ceil(excess / 2)
end -= Math.floor(excess / 2)
}
else if (size < maxLiveNodesResolved) {
const missing = maxLiveNodesResolved - size
start = Math.max(0, start - Math.ceil(missing / 2))
end = Math.min(total, end + Math.floor(missing / 2))
}
setLiveRange({ start, end })
}, [focusIndex, liveNodeBufferResolved, maxLiveNodesResolved, parsedNodes.length, virtualizationEnabled])
useEffect(() => {
return () => {
cancelBatchTimers()
for (const handle of nodeVisibilityHandlesRef.current.values())
handle.destroy()
nodeVisibilityHandlesRef.current.clear()
}
}, [cancelBatchTimers])
const cleanupAfterTruncate = useCallback((limit: number) => {
for (const [index, handle] of nodeVisibilityHandlesRef.current.entries()) {
if (index >= limit) {
handle.destroy()
nodeVisibilityHandlesRef.current.delete(index)
delete nodeVisibilityStateRef.current[index]
nodeSlotElementsRef.current.delete(index)
nodeSlotRefCallbacksRef.current.delete(index)
nodeContentRefCallbacksRef.current.delete(index)
}
}
}, [])
useEffect(() => {
cleanupAfterTruncate(cleanupLimit)
}, [cleanupAfterTruncate, cleanupLimit])
useEffect(() => {
const total = parsedNodes.length
let changed = false
for (const key of Array.from(nodeHeightsRef.current.keys())) {
if (key >= total) {
nodeHeightsRef.current.delete(key)
changed = true
}
}
if (changed)
setHeightsVersion(v => v + 1)
for (const key of Object.keys(nodeVisibilityStateRef.current)) {
if (Number(key) >= total)
delete nodeVisibilityStateRef.current[key]
}
for (const index of Array.from(nodeSeenRef.current)) {
if (index >= total)
nodeSeenRef.current.delete(index)
}
}, [parsedNodes.length])
const markNodeVisible = useCallback((index: number, visible: boolean) => {
if (deferNodes && visible)
nodeVisibilityStateRef.current[index] = true
if (visible && virtualizationEnabled) {
setFocusIndex(prev => (index > prev ? index : prev))
}
}, [deferNodes, virtualizationEnabled])
const destroyNodeHandle = useCallback((index: number) => {
const handle = nodeVisibilityHandlesRef.current.get(index)
if (handle) {
handle.destroy()
nodeVisibilityHandlesRef.current.delete(index)
}
}, [])
const setNodeSlotElement = useCallback((index: number, el: HTMLElement | null) => {
const slots = nodeSlotElementsRef.current
if (el)
slots.set(index, el)
else
slots.delete(index)
if (!shouldObserveSlots || !el) {
destroyNodeHandle(index)
if (deferNodes && !el)
delete nodeVisibilityStateRef.current[index]
if (el)
markNodeVisible(index, true)
return
}
if (index < resolvedInitialBatch && !virtualizationEnabled) {
destroyNodeHandle(index)
markNodeVisible(index, true)
return
}
destroyNodeHandle(index)
const handle = registerNodeVisibility(el, { rootMargin: '400px' })
nodeVisibilityHandlesRef.current.set(index, handle)
if (handle.isVisible())
markNodeVisible(index, true)
handle.whenVisible.then(() => markNodeVisible(index, true)).catch(() => {})
}, [
deferNodes,
destroyNodeHandle,
markNodeVisible,
registerNodeVisibility,
resolvedInitialBatch,
shouldObserveSlots,
virtualizationEnabled,
])
const getNodeSlotRef = useCallback((index: number) => {
const existing = nodeSlotRefCallbacksRef.current.get(index)
if (existing)
return existing
const cb = (el: HTMLElement | null) => setNodeSlotElement(index, el)
nodeSlotRefCallbacksRef.current.set(index, cb)
return cb
}, [setNodeSlotElement])
const setNodeContentRef = useCallback((index: number, el: HTMLDivElement | null) => {
if (!el) {
if (nodeHeightsRef.current.has(index)) {
nodeHeightsRef.current.delete(index)
setHeightsVersion(v => v + 1)
}
return
}
const measure = () => {
const height = el.offsetHeight
if (!height)
return
const prev = nodeHeightsRef.current.get(index)
if (prev !== height) {
nodeHeightsRef.current.set(index, height)
setHeightsVersion(v => v + 1)
}
}
if (typeof queueMicrotask === 'function')
queueMicrotask(measure)
else
Promise.resolve().then(measure)
}, [])
const getNodeContentRef = useCallback((index: number) => {
const existing = nodeContentRefCallbacksRef.current.get(index)
if (existing)
return existing
const cb = (el: HTMLDivElement | null) => setNodeContentRef(index, el)
nodeContentRefCallbacksRef.current.set(index, cb)
return cb
}, [setNodeContentRef])
const shouldRenderNode = useCallback((index: number) => {
if (incrementalRenderingActive && index >= renderedCountRef.current)
return false
if (!deferNodes)
return true
if (index < resolvedInitialBatch)
return true
return nodeVisibilityStateRef.current[index] === true
}, [deferNodes, incrementalRenderingActive, resolvedInitialBatch])
const handleMouseEvent = useCallback((cb?: (event: React.MouseEvent<HTMLElement>) => void) => {
return (event: React.MouseEvent<HTMLElement>) => {
if (!cb)
return
const target = event.target as HTMLElement | null
if (!target?.closest('[data-node-index]'))
return
cb(event)
}
}, [])
useEffect(() => {
for (const [index, el] of nodeSlotElementsRef.current.entries()) {
if (el)
setNodeSlotElement(index, el)
}
}, [setNodeSlotElement])
useEffect(() => {
if (virtualizationEnabled && renderedCount > prevRenderedRef.current)
setFocusIndex(renderedCount - 1)
prevRenderedRef.current = renderedCount
}, [renderedCount, virtualizationEnabled])
const topSpacer = virtualizationEnabled
? <div className="node-spacer" style={{ height: `${topSpacerHeight}px` }} aria-hidden="true" />
: null
const bottomSpacer = virtualizationEnabled
? <div className="node-spacer" style={{ height: `${bottomSpacerHeight}px` }} aria-hidden="true" />
: null
return (
<div
ref={containerRef}
className={`markstream-react markdown-renderer${props.isDark ? ' dark' : ''}${virtualizationEnabled ? ' virtualized' : ''}`}
data-custom-id={props.customId}
onClick={props.onClick}
onMouseOver={handleMouseEvent(props.onMouseOver)}
onMouseOut={handleMouseEvent(props.onMouseOut)}
>
{topSpacer}
{visibleNodes.map(({ node, index }) => {
const canRender = shouldRenderNode(index)
const placeholderHeight = nodeHeightsRef.current.get(index) ?? averageNodeHeight
const shouldAnimate = props.typewriter !== false
&& node.type !== 'code_block'
&& !nodeSeenRef.current.has(index)
&& canRender
if (shouldAnimate)
nodeSeenRef.current.add(index)
return (
<div
key={`${indexPrefix}-${index}`}
ref={getNodeSlotRef(index)}
className="node-slot"
data-node-index={index}
data-node-type={node.type}
>
{canRender
? (
<div
ref={getNodeContentRef(index)}
className={`node-content${shouldAnimate ? ' typewriter-node' : ''}`}
>
{renderNode(node, `${indexPrefix}-${index}`, renderCtx)}
</div>
)
: (
<div className="node-placeholder" style={{ height: `${placeholderHeight}px` }} />
)}
</div>
)
})}
{bottomSpacer}
</div>
)
}
function areNodeRendererInnerPropsEqual(prev: NodeRendererInnerProps, next: NodeRendererInnerProps) {
if (prev.parsedNodes !== next.parsedNodes)
return false
if (prev.renderCtx !== next.renderCtx)
return false
if (prev.indexPrefix !== next.indexPrefix)
return false
if (prev.containerRef !== next.containerRef)
return false
const a = prev.props
const b = next.props
return a.isDark === b.isDark
&& a.typewriter === b.typewriter
&& a.batchRendering === b.batchRendering
&& a.initialRenderBatchSize === b.initialRenderBatchSize
&& a.renderBatchSize === b.renderBatchSize
&& a.renderBatchDelay === b.renderBatchDelay
&& a.renderBatchBudgetMs === b.renderBatchBudgetMs
&& a.renderBatchIdleTimeoutMs === b.renderBatchIdleTimeoutMs
&& a.deferNodesUntilVisible === b.deferNodesUntilVisible
&& a.maxLiveNodes === b.maxLiveNodes
&& a.liveNodeBuffer === b.liveNodeBuffer
&& a.viewportPriority === b.viewportPriority
&& a.indexKey === b.indexKey
&& a.onClick === b.onClick
&& a.onMouseOver === b.onMouseOver
&& a.onMouseOut === b.onMouseOut
}
const MemoNodeRendererInner = React.memo(NodeRendererInner, areNodeRendererInnerPropsEqual)
export const NodeRenderer: React.FC<NodeRendererProps> = (rawProps) => {
const props = { ...DEFAULT_PROPS, ...rawProps } as ResolvedProps
const containerRef = useRef<HTMLDivElement | null>(null)
const desiredThemeKeyRef = useRef<string | null>(null)
const textStreamStateRef = useRef(new Map<string, string>())
const previousStreamInputsRef = useRef<{
content?: NodeRendererProps['content']
nodes?: NodeRendererProps['nodes']
} | null>(null)
const streamRenderVersionRef = useRef(0)
if (
previousStreamInputsRef.current?.content !== props.content
|| previousStreamInputsRef.current?.nodes !== props.nodes
) {
streamRenderVersionRef.current += 1
previousStreamInputsRef.current = {
content: props.content,
nodes: props.nodes,
}
}
const customComponentsRevision = useSyncExternalStore(
subscribeCustomComponents,
getCustomComponentsRevision,
getCustomComponentsRevision,
)
const customComponents = useMemo(() => {
return getCustomNodeComponents(props.customId)
}, [props.customId, customComponentsRevision])
const instanceMsgId = useMemo(() => {
return props.customId
? `renderer-${props.customId}`
: `renderer-${Date.now()}-${Math.random().toString(36).slice(2)}`
}, [props.customId])
const effectiveCustomHtmlTags = useMemo(() => {
const base = props.parseOptions ?? {}
const optionTags = (base as any).customHtmlTags ?? []
return mergeCustomHtmlTags(
props.customHtmlTags,
Array.isArray(optionTags) ? optionTags : [],
)
}, [
props.customId,
props.customHtmlTags,
props.parseOptions,
customComponentsRevision,
])
const mdBase = useMemo(() => {
const tags = effectiveCustomHtmlTags
if (!tags || tags.length === 0)
return getMarkdown(instanceMsgId)
return getMarkdown(instanceMsgId, { customHtmlTags: tags })
}, [instanceMsgId, effectiveCustomHtmlTags])
const mdInstance = useMemo(() => {
const base = mdBase
return props.customMarkdownIt ? props.customMarkdownIt(base) : base
}, [mdBase, props.customMarkdownIt])
const mergedParseOptions = useMemo(() => {
const base = props.parseOptions ?? {}
const resolvedFinal = props.final ?? (base as any).final
const hasFinal = resolvedFinal != null
const hasCustom = effectiveCustomHtmlTags.length > 0
if (!hasFinal && !hasCustom)
return base
return {
...(base as any),
...(hasFinal ? { final: resolvedFinal } : {}),
...(hasCustom ? { customHtmlTags: effectiveCustomHtmlTags } : {}),
} as any
}, [effectiveCustomHtmlTags, props.final, props.parseOptions])
const parsedNodes = useMemo<ParsedNode[]>(() => {
const debugEnabled = Boolean(props.debugPerformance)
&& typeof console !== 'undefined'
&& typeof performance !== 'undefined'
const parseStart = debugEnabled ? performance.now() : 0
let result: ParsedNode[] = []
if (Array.isArray(props.nodes) && props.nodes.length) {
result = (props.nodes as ParsedNode[]).map(node => ({ ...node }))
}
else if (props.content) {
result = parseMarkdownToStructure(props.content, mdInstance ?? fallbackMarkdown, mergedParseOptions)
}
if (debugEnabled) {
console.info('[markstream-react][perf] parse(sync)', {
ms: Math.round(performance.now() - parseStart),
nodes: result.length,
contentLength: props.content?.length ?? 0,
})
}
return result
}, [
props.content,
props.debugPerformance,
props.nodes,
mergedParseOptions,
mdInstance,
props.customId,
customComponentsRevision,
effectiveCustomHtmlTags,
])
useEffect(() => {
if (typeof window === 'undefined')
return
if (props.renderCodeBlocksAsPre)
return
// React parity improvement: prefetch Monaco (and preload workers) in idle time
// so the first visible code block doesn't pay the full dynamic import cost.
const hasCodeBlock = parsedNodes.some((node) => {
if ((node as any)?.type !== 'code_block')
return false
const lang = normalizeLanguageIdentifier(String((node as any)?.language ?? ''))
return lang !== 'mermaid'
})
if (!hasCodeBlock)
return
const requestIdle = (window as any).requestIdleCallback
?? ((cb: (deadline: { didTimeout?: boolean, timeRemaining?: () => number }) => void, opts?: { timeout?: number }) => {
return window.setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), opts?.timeout ?? 600)
})
const cancelIdle = (window as any).cancelIdleCallback
?? ((id: number) => window.clearTimeout(id))
const id = requestIdle(() => {
void getUseMonaco()
}, { timeout: 900 })
return () => cancelIdle(id)
}, [parsedNodes, props.renderCodeBlocksAsPre])
useEffect(() => {
if (typeof window === 'undefined')
return
if (props.renderCodeBlocksAsPre)
return
const theme = props.isDark ? props.codeBlockDarkTheme : props.codeBlockLightTheme
const nextKey = typeof theme === 'string'
? theme
: (typeof theme === 'object' && theme && 'name' in (theme as any) ? String((theme as any).name) : null)
console.log('[AQBot Theme Debug] NodeRenderer useEffect:', { theme, nextKey, prevKey: desiredThemeKeyRef.current, isDark: props.isDark, darkTheme: props.codeBlockDarkTheme, lightTheme: props.codeBlockLightTheme })
if (nextKey && desiredThemeKeyRef.current !== nextKey) {
desiredThemeKeyRef.current = nextKey
setDesiredMonacoTheme(theme)
console.log('[AQBot Theme Debug] setDesiredMonacoTheme called with:', theme)
}
else {
console.log('[AQBot Theme Debug] skipped setDesiredMonacoTheme — same key or null')
}
}, [props.codeBlockDarkTheme, props.codeBlockLightTheme, props.isDark, props.renderCodeBlocksAsPre])
const indexPrefix = useMemo(() => {
return props.indexKey != null ? String(props.indexKey) : 'markdown-renderer'
}, [props.indexKey])
const mergedCodeBlockProps = useMemo(() => {
return {
...(typeof props.showTooltips === 'boolean' ? { showTooltips: props.showTooltips } : {}),
...(props.codeBlockProps || {}),
}
}, [props.codeBlockProps, props.showTooltips])
const mergedMermaidProps = useMemo(() => {
return {
...(props.mermaidProps || {}),
}
}, [props.mermaidProps])
const mergedD2Props = useMemo(() => {
return {
...(props.d2Props || {}),
}
}, [props.d2Props])
const mergedInfographicProps = useMemo(() => {
return {
...(props.infographicProps || {}),
}
}, [props.infographicProps])
const renderCtx = useMemo<RenderContext>(() => ({
customId: props.customId,
isDark: props.isDark,
indexKey: indexPrefix,
typewriter: props.typewriter,
textStreamState: textStreamStateRef.current,
streamRenderVersion: streamRenderVersionRef.current,
customComponents,
customHtmlTags: effectiveCustomHtmlTags,
showTooltips: props.showTooltips,
renderCodeBlocksAsPre: props.renderCodeBlocksAsPre,
codeBlockStream: props.codeBlockStream,
codeBlockProps: mergedCodeBlockProps,
mermaidProps: mergedMermaidProps,
d2Props: mergedD2Props,
infographicProps: mergedInfographicProps,
codeBlockThemes: {
themes: props.themes,
darkTheme: props.codeBlockDarkTheme,
lightTheme: props.codeBlockLightTheme,
monacoOptions: props.codeBlockMonacoOptions,
minWidth: props.codeBlockMinWidth,
maxWidth: props.codeBlockMaxWidth,
},
events: {
onCopy: props.onCopy,
onHandleArtifactClick: props.onHandleArtifactClick,
},
}), [
props.customId,
props.isDark,
indexPrefix,
props.typewriter,
props.showTooltips,
props.renderCodeBlocksAsPre,
props.codeBlockStream,
effectiveCustomHtmlTags,
mergedCodeBlockProps,
mergedMermaidProps,
mergedD2Props,
mergedInfographicProps,
props.themes,
props.codeBlockDarkTheme,
props.codeBlockLightTheme,
props.codeBlockMonacoOptions,
props.codeBlockMinWidth,
props.codeBlockMaxWidth,
streamRenderVersionRef.current,
props.onCopy,
props.onHandleArtifactClick,
customComponents,
])
return (
<ViewportPriorityProvider
getRoot={() => containerRef.current}
enabled={props.viewportPriority !== false}
>
<MemoNodeRendererInner
props={props}
parsedNodes={parsedNodes}
renderCtx={renderCtx}
indexPrefix={indexPrefix}
containerRef={containerRef}
/>
</ViewportPriorityProvider>
)
}
export default NodeRenderer
@@ -0,0 +1,15 @@
import type { NodeComponentProps } from '../../types/node-component'
import React from 'react'
export function FallbackComponent(props: NodeComponentProps<{ type: string }>) {
const { node } = props
return (
<div className="unknown-node text-sm text-gray-500 italic">
Unsupported node type:
{' '}
{String((node as any)?.type)}
</div>
)
}
export default FallbackComponent
@@ -0,0 +1,12 @@
let isPreloaded = false
export async function preload(mod: any) {
if (isPreloaded)
return
isPreloaded = true
const existingEnv = (globalThis as any)?.MonacoEnvironment
if (existingEnv && (typeof existingEnv.getWorker === 'function' || typeof existingEnv.getWorkerUrl === 'function'))
return
if (mod?.preloadMonacoWorkers)
await mod.preloadMonacoWorkers()
}
@@ -0,0 +1,117 @@
import type { ParsedNode } from 'stream-markdown-parser'
import type { NodeComponentProps } from '../../types/node-component'
import React from 'react'
import { getCustomNodeComponents } from '../../customComponents'
import { BLOCK_LEVEL_TYPES, renderNodeChildren } from '../../renderers/renderChildren'
import { isParagraphBreakingCustomHtmlNode } from '../../utils/customHtmlTag'
function isWhitespaceTextNode(node: ParsedNode | null | undefined) {
return node?.type === 'text' && String((node as any)?.content ?? '').trim() === ''
}
function getMeaningfulLinkChildren(node: ParsedNode | null | undefined) {
if (node?.type !== 'link' || !Array.isArray((node as any)?.children))
return []
return ((node as any).children as ParsedNode[]).filter(child => !isWhitespaceTextNode(child))
}
function isImageOnlyLinkNode(node: ParsedNode | null | undefined) {
const linkChildren = getMeaningfulLinkChildren(node)
return linkChildren.length === 1 && linkChildren[0]?.type === 'image'
}
function renderParagraphInlineNodes(
nodes: ParsedNode[],
renderNode: NonNullable<NodeComponentProps<{ type: 'paragraph', children?: ParsedNode[] }>['renderNode']>,
ctx: NonNullable<NodeComponentProps<{ type: 'paragraph', children?: ParsedNode[] }>['ctx']>,
prefix: string,
) {
const meaningfulChildren = nodes.filter(child => !isWhitespaceTextNode(child))
const mediaOnly = meaningfulChildren.length > 0
&& meaningfulChildren.every(child => child.type === 'image' || isImageOnlyLinkNode(child))
if (!mediaOnly || meaningfulChildren.length <= 1)
return renderNodeChildren(nodes, ctx, prefix, renderNode)
const normalizedNodes: ParsedNode[] = []
for (let index = 0; index < nodes.length; index++) {
const child = nodes[index]
if (!isWhitespaceTextNode(child)) {
normalizedNodes.push(child)
continue
}
const hasPrevious = normalizedNodes.length > 0
const hasNext = nodes.slice(index + 1).some(nextChild => !isWhitespaceTextNode(nextChild))
if (!hasPrevious || !hasNext)
continue
normalizedNodes.push({
...(child as any),
content: ' ',
raw: ' ',
})
}
return normalizedNodes.map((child, index) => (
mediaOnly && isWhitespaceTextNode(child)
? <React.Fragment key={`${prefix}-${index}`}>{String((child as any)?.content ?? '')}</React.Fragment>
: renderNode(child, `${prefix}-${index}`, ctx)
))
}
export function ParagraphNode(props: NodeComponentProps<{ type: 'paragraph', children?: ParsedNode[] }>) {
const { node, ctx, renderNode, indexKey, children } = props
if (!ctx || !renderNode) {
return (
<p dir="auto" className="paragraph-node">
{children}
</p>
)
}
const nodeChildren = node.children ?? []
const customComponents = ctx.customComponents ?? getCustomNodeComponents(ctx.customId)
const parts: React.ReactNode[] = []
const inlineBuffer: ParsedNode[] = []
const flushInline = () => {
if (!inlineBuffer.length)
return
const chunkIndex = parts.length
parts.push(
<p key={`${String(indexKey ?? 'paragraph')}-inline-${chunkIndex}`} dir="auto" className="paragraph-node">
{renderParagraphInlineNodes(inlineBuffer.slice(), renderNode, ctx, `${String(indexKey ?? 'paragraph')}-${chunkIndex}`)}
</p>,
)
inlineBuffer.length = 0
}
nodeChildren.forEach((child, childIndex) => {
if (BLOCK_LEVEL_TYPES.has(child.type) || isParagraphBreakingCustomHtmlNode(child, customComponents, ctx.customHtmlTags)) {
flushInline()
parts.push(
<React.Fragment key={`${String(indexKey ?? 'paragraph')}-block-${childIndex}`}>
{renderNode(child, `${String(indexKey ?? 'paragraph')}-block-${childIndex}`, ctx)}
</React.Fragment>,
)
}
else {
inlineBuffer.push(child)
}
})
flushInline()
if (!parts.length) {
return (
<p dir="auto" className="paragraph-node">
{renderParagraphInlineNodes(nodeChildren, renderNode, ctx, String(indexKey ?? 'paragraph'))}
</p>
)
}
return <>{parts}</>
}
export default ParagraphNode
@@ -0,0 +1,2 @@
export { PreCodeNode } from '../CodeBlockNode/PreCodeNode'
export { PreCodeNode as default } from '../CodeBlockNode/PreCodeNode'
@@ -0,0 +1,42 @@
import type { NodeComponentProps } from '../../types/node-component'
import React, { useCallback } from 'react'
export interface ReferenceNodeHandlers {
onClick?: (event: React.MouseEvent, id: string, messageId?: string, threadId?: string) => void
onMouseEnter?: (event: React.MouseEvent, id: string, messageId?: string, threadId?: string) => void
onMouseLeave?: (event: React.MouseEvent, id: string, messageId?: string, threadId?: string) => void
}
export function ReferenceNode(
props: NodeComponentProps<{ type: 'reference', id: string }>
& { messageId?: string, threadId?: string }
& ReferenceNodeHandlers,
) {
const { node } = props
const handleClick = useCallback((event: React.MouseEvent) => {
props.onClick?.(event, node.id, props.messageId, props.threadId)
}, [node.id, props])
return (
<span
className="reference-node cursor-pointer bg-[hsl(var(--muted))] text-xs rounded-md px-1.5 mx-0.5 hover:bg-[hsl(var(--secondary))]"
role="button"
tabIndex={0}
onClick={handleClick}
onMouseEnter={(event) => {
props.onMouseEnter?.(event, node.id, props.messageId, props.threadId)
}}
onMouseLeave={(event) => {
props.onMouseLeave?.(event, node.id, props.messageId, props.threadId)
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ')
handleClick(event as any)
}}
>
{node.id}
</span>
)
}
export default ReferenceNode
@@ -0,0 +1,8 @@
import type { NodeComponentProps } from '../../types/node-component'
import React from 'react'
export function StrikethroughNode(props: NodeComponentProps<{ type: 'strikethrough' }>) {
return <s className="strikethrough-node">{props.children}</s>
}
export default StrikethroughNode
@@ -0,0 +1,8 @@
import type { NodeComponentProps } from '../../types/node-component'
import React from 'react'
export function StrongNode(props: NodeComponentProps<{ type: 'strong' }>) {
return <strong className="strong-node">{props.children}</strong>
}
export default StrongNode
@@ -0,0 +1,8 @@
import type { NodeComponentProps } from '../../types/node-component'
import React from 'react'
export function SubscriptNode(props: NodeComponentProps<{ type: 'subscript' }>) {
return <sub className="subscript-node">{props.children}</sub>
}
export default SubscriptNode
@@ -0,0 +1,8 @@
import type { NodeComponentProps } from '../../types/node-component'
import React from 'react'
export function SuperscriptNode(props: NodeComponentProps<{ type: 'superscript' }>) {
return <sup className="superscript-node">{props.children}</sup>
}
export default SuperscriptNode
@@ -0,0 +1,178 @@
import type { NodeComponentProps } from '../../types/node-component'
import clsx from 'clsx'
import React from 'react'
import { renderInline } from '../../renderers/renderChildren'
export function TableNode(props: NodeComponentProps<{ type: 'table', header?: any, rows?: any[], loading?: boolean }>) {
const { node, ctx, renderNode, indexKey } = props
const headerCells = Array.isArray(node?.header?.cells) ? node.header.cells : []
const isLoading = Boolean(node?.loading)
const bodyRows = Array.isArray(node?.rows) ? node.rows : []
const tableRef = React.useRef<HTMLTableElement | null>(null)
const resizeStateRef = React.useRef<{
index: number
startX: number
startWidth: number
nextStartWidth: number
widths: number[]
} | null>(null)
const resizeListenersRef = React.useRef<{
move: (event: PointerEvent) => void
stop: () => void
} | null>(null)
const [columnWidths, setColumnWidths] = React.useState<number[]>([])
const minColumnWidth = 48
const getAlignClass = (align?: string) => {
if (align === 'right')
return 'text-right'
if (align === 'center')
return 'text-center'
return 'text-left'
}
const measureHeaderWidths = () => {
const cells = tableRef.current?.querySelectorAll('thead th')
return Array.from(cells ?? [], cell => Math.round(cell.getBoundingClientRect().width))
}
const stopColumnResize = () => {
const listeners = resizeListenersRef.current
if (listeners) {
window.removeEventListener('pointermove', listeners.move)
window.removeEventListener('pointerup', listeners.stop)
window.removeEventListener('pointercancel', listeners.stop)
resizeListenersRef.current = null
}
resizeStateRef.current = null
}
const startColumnResize = (index: number, event: React.PointerEvent<HTMLButtonElement>) => {
if (event.button !== 0)
return
const widths = measureHeaderWidths()
const startWidth = widths[index]
const nextStartWidth = widths[index + 1]
if (!startWidth || !nextStartWidth)
return
event.preventDefault()
stopColumnResize()
resizeStateRef.current = {
index,
startX: event.clientX,
startWidth,
nextStartWidth,
widths,
}
setColumnWidths(widths)
const move = (moveEvent: PointerEvent) => {
const resizeState = resizeStateRef.current
if (!resizeState)
return
moveEvent.preventDefault()
const pairWidth = resizeState.startWidth + resizeState.nextStartWidth
const minWidth = Math.min(minColumnWidth, Math.floor(pairWidth / 2))
const width = Math.max(
minWidth,
Math.min(pairWidth - minWidth, Math.round(resizeState.startWidth + moveEvent.clientX - resizeState.startX)),
)
const nextWidths = [...resizeState.widths]
nextWidths[resizeState.index] = width
nextWidths[resizeState.index + 1] = pairWidth - width
setColumnWidths(nextWidths)
}
const stop = () => {
stopColumnResize()
}
resizeListenersRef.current = { move, stop }
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', stop)
window.addEventListener('pointercancel', stop)
}
React.useEffect(() => {
stopColumnResize()
setColumnWidths([])
return stopColumnResize
}, [headerCells.length])
return (
<div className="table-node-wrapper" data-index-key={indexKey}>
<table
ref={tableRef}
className={clsx(
'my-8 text-sm table-node',
isLoading && 'table-node--loading',
)}
aria-busy={isLoading}
>
{columnWidths.length > 0 && (
<colgroup>
{headerCells.map((_: any, idx: number) => (
<col key={`col-${idx}`} style={columnWidths[idx] > 0 ? { width: `${columnWidths[idx]}px` } : undefined} />
))}
</colgroup>
)}
<thead className="border-[var(--table-border,#cbd5e1)]">
<tr className="border-b">
{headerCells.map((cell: any, idx: number) => (
<th
key={`header-${idx}`}
className={clsx('font-semibold p-[calc(4/7*1em)]', getAlignClass(cell.align))}
dir="auto"
>
{ctx && renderNode ? renderInline(cell.children, ctx, `${String(indexKey ?? 'table')}-th-${idx}`, renderNode) : null}
{idx < headerCells.length - 1 && (
<button
type="button"
className="table-node__resize-handle"
aria-label={`Resize columns ${idx + 1} and ${idx + 2}`}
onPointerDown={event => startColumnResize(idx, event)}
/>
)}
</th>
))}
</tr>
</thead>
<tbody>
{bodyRows.map((row: any, rowIdx: number) => (
<tr
key={`row-${rowIdx}`}
className={clsx(
'border-[var(--table-border,#cbd5e1)]',
rowIdx < bodyRows.length - 1 && 'border-b',
)}
>
{row.cells?.map((cell: any, cellIdx: number) => (
<td
key={`cell-${rowIdx}-${cellIdx}`}
className={clsx('p-[calc(4/7*1em)]', getAlignClass(cell.align))}
dir="auto"
>
{ctx && renderNode ? renderInline(cell.children, ctx, `${String(indexKey ?? 'table')}-row-${rowIdx}-${cellIdx}`, renderNode) : null}
</td>
))}
</tr>
))}
</tbody>
</table>
{isLoading && (
<div className="table-node__loading" role="status" aria-live="polite">
<span className="table-node__spinner" aria-hidden="true" />
<span className="sr-only">Loading</span>
</div>
)}
</div>
)
}
export default TableNode
@@ -0,0 +1,113 @@
import type { NodeComponentProps } from '../../types/node-component'
import clsx from 'clsx'
import React, { useEffect, useRef, useState } from 'react'
import { resolveStreamingTextUpdate } from '../../utils/streamingTextState'
export function TextNode(props: NodeComponentProps<{ type: 'text', content: string, center?: boolean }>) {
const { node, children, ctx, indexKey, typewriter } = props
const content = String(node.content ?? '')
const typewriterEnabled = typewriter ?? ctx?.typewriter ?? true
const streamStateKey = indexKey == null || indexKey === ''
? ''
: String(indexKey)
const [settledContent, setSettledContent] = useState(content)
const [streamedDelta, setStreamedDelta] = useState('')
const [streamFadeVersion, setStreamFadeVersion] = useState(0)
const lastStreamRenderVersionRef = useRef(ctx?.streamRenderVersion)
const renderedContentRef = useRef({
settledContent: content,
streamedDelta: '',
})
const setRenderedContent = (nextSettledContent: string, nextStreamedDelta: string) => {
renderedContentRef.current = {
settledContent: nextSettledContent,
streamedDelta: nextStreamedDelta,
}
setSettledContent(nextSettledContent)
setStreamedDelta(nextStreamedDelta)
}
const getRenderedContent = () => {
const { settledContent, streamedDelta } = renderedContentRef.current
return settledContent + streamedDelta
}
useEffect(() => {
const streamRenderVersion = ctx?.streamRenderVersion
const streamRenderVersionChanged = streamRenderVersion !== lastStreamRenderVersionRef.current
if (children != null) {
setRenderedContent('', '')
lastStreamRenderVersionRef.current = streamRenderVersion
return
}
const textStreamState = ctx?.textStreamState
const currentState = renderedContentRef.current
const persistedContent = streamStateKey
? textStreamState?.get(streamStateKey)
: undefined
const nextState = resolveStreamingTextUpdate({
nextContent: content,
persistedContent,
currentState,
typewriterEnabled,
streamRenderVersionChanged,
})
setRenderedContent(nextState.settledContent, nextState.streamedDelta)
if (nextState.appended)
setStreamFadeVersion(version => version + 1)
if (streamStateKey)
textStreamState?.set(streamStateKey, content)
lastStreamRenderVersionRef.current = streamRenderVersion
}, [children, content, ctx?.textStreamState, ctx?.streamRenderVersion, streamStateKey, typewriterEnabled])
const handleStreamedDeltaAnimationEnd = () => {
if (!renderedContentRef.current.streamedDelta)
return
setRenderedContent(getRenderedContent(), '')
}
if (children != null) {
return (
<span
className={clsx(
'text-node whitespace-pre-wrap break-words',
node.center && 'text-node-center',
)}
>
{children}
</span>
)
}
return (
<span
className={clsx(
'text-node whitespace-pre-wrap break-words',
node.center && 'text-node-center',
)}
>
{settledContent ? <span>{settledContent}</span> : null}
{streamedDelta
? (
<span
className={clsx(
'text-node-stream-delta',
streamFadeVersion % 2 === 0
? 'text-node-stream-delta--a'
: 'text-node-stream-delta--b',
)}
onAnimationEnd={handleStreamedDeltaAnimationEnd}
>
{streamedDelta}
</span>
)
: null}
</span>
)
}
export default TextNode
@@ -0,0 +1,7 @@
import React from 'react'
export function ThematicBreakNode() {
return <hr className="hr-node thematic-break" />
}
export default ThematicBreakNode
@@ -0,0 +1,110 @@
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'
export interface TooltipProps {
visible: boolean
anchorEl: HTMLElement | null
content: string
placement?: TooltipPlacement
offset?: number
originX?: number | null
originY?: number | null
id?: string | null
isDark?: boolean | null
}
function detectDarkModeHint(hint?: boolean | null) {
if (typeof hint === 'boolean')
return hint
if (typeof document !== 'undefined') {
try {
if (document.documentElement.classList.contains('dark'))
return true
}
catch {}
}
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
try {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
catch {}
}
return false
}
export function Tooltip(props: TooltipProps) {
const tooltipRef = useRef<HTMLDivElement | null>(null)
const [ready, setReady] = useState(false)
const [style, setStyle] = useState<React.CSSProperties>({ transform: 'translate3d(0px, 0px, 0px)', left: '0px', top: '0px' })
const isDarkEffective = useMemo(() => detectDarkModeHint(props.isDark), [props.isDark])
useEffect(() => {
if (!props.visible) {
setReady(false)
return
}
if (!props.anchorEl || !tooltipRef.current) {
setReady(true)
return
}
let cleanup: (() => void) | null = null
let cancelled = false
const update = async () => {
if (!props.anchorEl || !tooltipRef.current)
return
const { x, y } = await computePosition(props.anchorEl, tooltipRef.current, {
placement: props.placement ?? 'top',
middleware: [offset(props.offset ?? 8), flip(), shift({ padding: 8 })],
strategy: 'fixed',
})
if (cancelled)
return
setStyle({
position: 'fixed',
left: '0px',
top: '0px',
transform: `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`,
})
}
void update().then(() => {
if (!cancelled)
setReady(true)
})
cleanup = autoUpdate(props.anchorEl, tooltipRef.current, update)
return () => {
cancelled = true
cleanup?.()
}
}, [props.anchorEl, props.offset, props.placement, props.visible, ready])
if (typeof document === 'undefined')
return null
if (!props.visible || !ready)
return null
return createPortal(
<div
id={props.id ?? undefined}
ref={tooltipRef}
style={style}
className={[
'ms-tooltip z-[9999] inline-block text-base py-2 px-3 rounded-md shadow-md whitespace-nowrap pointer-events-none border tooltip-element',
isDarkEffective ? 'bg-gray-900 text-white border-gray-700 is-dark' : 'bg-white text-gray-900 border-gray-200',
].join(' ')}
role="tooltip"
>
{props.content}
</div>,
document.body,
)
}
export default Tooltip
@@ -0,0 +1,35 @@
import type { ParsedNode } from 'stream-markdown-parser'
import type { NodeComponentProps } from '../../types/node-component'
import React, { useMemo } from 'react'
import { tokenAttrsToProps } from '../../renderers/renderChildren'
export function VmrContainerNode(props: NodeComponentProps<{
type: 'vmr_container'
name: string
attrs?: Record<string, string> | [string, string | null][] | null
children?: ParsedNode[]
}>) {
const { node, ctx, renderNode, indexKey } = props
const containerClass = `vmr-container vmr-container-${node.name}`
const boundAttrs = useMemo(() => {
if (!node.attrs)
return undefined
if (Array.isArray(node.attrs))
return tokenAttrsToProps(node.attrs as any)
return node.attrs as any
}, [node.attrs])
return (
<div className={containerClass} {...(boundAttrs as any)}>
{(ctx && renderNode && Array.isArray(node.children))
? node.children.map((child, idx) => (
<React.Fragment key={`${String(indexKey ?? 'vmr-container')}-${idx}`}>
{renderNode(child, `${String(indexKey ?? 'vmr-container')}-${idx}`, ctx)}
</React.Fragment>
))
: null}
</div>
)
}
export default VmrContainerNode
@@ -0,0 +1,236 @@
import React, { createContext, useContext, useMemo, useRef } from 'react'
export interface VisibilityHandle {
isVisible: () => boolean
whenVisible: Promise<void>
destroy: () => void
}
export type RegisterViewportFn = (el: HTMLElement, opts?: { rootMargin?: string, threshold?: number }) => VisibilityHandle
type GetRootFn = () => HTMLElement | null
type EnabledFn = () => boolean
interface IdleDeadlineLike {
didTimeout: boolean
timeRemaining: () => number
}
const ViewportPriorityContext = createContext<RegisterViewportFn | null>(null)
function createViewportRegistrar(getRoot: GetRootFn, enabled: EnabledFn): RegisterViewportFn {
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'
const requestIdle = isBrowser
? ((window as any).requestIdleCallback as ((cb: (deadline: IdleDeadlineLike) => void, opts?: { timeout?: number }) => number) | undefined)
?? ((cb: (deadline: IdleDeadlineLike) => void) => window.setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), 16))
: null
const cancelIdle = isBrowser
? ((window as any).cancelIdleCallback as ((id: number) => void) | undefined)
?? ((id: number) => window.clearTimeout(id))
: null
interface ObserverConfig {
root: Element | null
rootMargin: string
threshold: number
}
let observer: IntersectionObserver | null = null
let currentConfig: ObserverConfig | null = null
const targets = new Map<Element, { resolve: () => void, state: { current: boolean } }>()
const idleQueue = new Set<Element>()
let idleJob: number | null = null
const normalizeConfig = (rootMargin: string, threshold: number): ObserverConfig => ({
root: getRoot() ?? null,
rootMargin,
threshold,
})
const sameConfig = (a: ObserverConfig | null, b: ObserverConfig) => {
return !!a
&& a.root === b.root
&& a.rootMargin === b.rootMargin
&& a.threshold === b.threshold
}
const clearIdleJob = () => {
if (idleJob == null)
return
try {
cancelIdle?.(idleJob)
}
catch {}
idleJob = null
}
const cleanupObserver = () => {
if (targets.size)
return
try {
observer?.disconnect()
}
catch {}
observer = null
currentConfig = null
if (!idleQueue.size)
clearIdleJob()
}
const settleTarget = (target: Element) => {
const data = targets.get(target)
if (!data)
return
data.state.current = true
try {
data.resolve()
}
catch {}
try {
observer?.unobserve(target)
}
catch {}
targets.delete(target)
idleQueue.delete(target)
cleanupObserver()
}
const scheduleIdleDrain = () => {
if (!requestIdle || idleJob != null || !idleQueue.size)
return
idleJob = requestIdle(() => {
idleJob = null
const next = idleQueue.values().next().value as Element | undefined
if (!next)
return
idleQueue.delete(next)
settleTarget(next)
if (idleQueue.size)
scheduleIdleDrain()
}, { timeout: 1200 })
}
const ensureObserver = (rootMargin: string, threshold: number) => {
if (!isBrowser)
return null
if (typeof IntersectionObserver === 'undefined')
return null
const nextConfig = normalizeConfig(rootMargin, threshold)
if (observer && sameConfig(currentConfig, nextConfig))
return observer
if (observer) {
try {
observer.disconnect()
}
catch {}
}
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const isVisible = entry.isIntersecting || entry.intersectionRatio > 0
if (isVisible)
settleTarget(entry.target)
}
}, {
root: nextConfig.root,
rootMargin: nextConfig.rootMargin,
threshold: nextConfig.threshold,
})
currentConfig = nextConfig
for (const element of targets.keys())
observer.observe(element)
return observer
}
const register: RegisterViewportFn = (el, opts) => {
const state = { current: false }
let settled = false
let resolve!: () => void
const whenVisible = new Promise<void>((res) => {
resolve = () => {
if (settled)
return
settled = true
res()
}
})
const destroy = () => {
if (targets.has(el)) {
targets.delete(el)
try {
observer?.unobserve(el)
}
catch {}
}
idleQueue.delete(el)
cleanupObserver()
}
if (!isBrowser || !enabled()) {
state.current = true
resolve()
return {
isVisible: () => true,
whenVisible,
destroy,
}
}
const rootMargin = opts?.rootMargin ?? '300px'
const threshold = opts?.threshold ?? 0
const nextObserver = ensureObserver(rootMargin, threshold)
if (!nextObserver) {
state.current = true
resolve()
return {
isVisible: () => true,
whenVisible,
destroy,
}
}
targets.set(el, { resolve, state })
nextObserver.observe(el)
idleQueue.add(el)
scheduleIdleDrain()
return {
isVisible: () => state.current,
whenVisible,
destroy,
}
}
return register
}
const fallbackRegister = createViewportRegistrar(() => null, () => true)
export interface ViewportPriorityProviderProps {
getRoot: () => HTMLElement | null
enabled?: boolean
children: React.ReactNode
}
export function ViewportPriorityProvider({ getRoot, enabled = true, children }: ViewportPriorityProviderProps) {
const enabledRef = useRef(enabled)
enabledRef.current = enabled
const getRootRef = useRef(getRoot)
getRootRef.current = getRoot
const registrar = useMemo(() => {
return createViewportRegistrar(() => getRootRef.current?.() ?? null, () => enabledRef.current)
}, [])
return (
<ViewportPriorityContext.Provider value={registrar}>
{children}
</ViewportPriorityContext.Provider>
)
}
export function useViewportPriority(): RegisterViewportFn {
return useContext(ViewportPriorityContext) ?? fallbackRegister
}
@@ -0,0 +1,102 @@
import type { ComponentType } from 'react'
export type CustomComponentDisplayMode = 'inline' | 'block'
export type MarkstreamCustomComponent<P = any> = ComponentType<P> & {
markstreamDisplay?: CustomComponentDisplayMode
}
export type CustomComponentMap = Record<string, MarkstreamCustomComponent<any>>
const GLOBAL_KEY = '__global__'
interface Store {
scopedComponents: Record<string, CustomComponentMap>
revision: number
listeners: Set<() => void>
}
const STORE_KEY = '__MARKSTREAM_REACT_CUSTOM_COMPONENTS_STORE__'
const store: Store = (() => {
const g = globalThis as any
if (g[STORE_KEY])
return g[STORE_KEY] as Store
const next: Store = {
scopedComponents: {},
revision: 0,
listeners: new Set(),
}
g[STORE_KEY] = next
return next
})()
function bumpRevision() {
store.revision++
for (const listener of Array.from(store.listeners)) {
try {
listener()
}
catch {
// ignore subscriber errors
}
}
}
export function subscribeCustomComponents(listener: () => void) {
store.listeners.add(listener)
return () => {
store.listeners.delete(listener)
}
}
export function getCustomComponentsRevision() {
return store.revision
}
export function setCustomComponents(id: string, mapping: CustomComponentMap): void
export function setCustomComponents(mapping: CustomComponentMap): void
export function setCustomComponents(idOrMapping: string | CustomComponentMap, maybeMapping?: CustomComponentMap) {
if (typeof idOrMapping === 'string')
store.scopedComponents[idOrMapping] = { ...(maybeMapping || {}) }
else
store.scopedComponents[GLOBAL_KEY] = { ...idOrMapping }
bumpRevision()
}
export function getCustomNodeComponents(customId?: string): CustomComponentMap {
const globalMapping = store.scopedComponents[GLOBAL_KEY] || {}
if (!customId)
return globalMapping
const scopedMapping = store.scopedComponents[customId] || {}
if (!globalMapping || Object.keys(globalMapping).length === 0)
return scopedMapping
if (!scopedMapping || Object.keys(scopedMapping).length === 0)
return globalMapping
return {
...globalMapping,
...scopedMapping,
}
}
export function removeCustomComponents(id: string) {
if (id === GLOBAL_KEY)
throw new Error('removeCustomComponents: cannot delete global mapping; call clearGlobalCustomComponents instead.')
delete store.scopedComponents[id]
bumpRevision()
}
export function clearGlobalCustomComponents() {
delete store.scopedComponents[GLOBAL_KEY]
bumpRevision()
}
export function getCustomComponentDisplay(component: ComponentType<any> | null | undefined): CustomComponentDisplayMode | undefined {
return (component as MarkstreamCustomComponent<any> | null | undefined)?.markstreamDisplay
}
export function withMarkstreamComponentDisplay<T extends ComponentType<any>>(
component: T,
display: CustomComponentDisplayMode,
) {
;(component as MarkstreamCustomComponent<any>).markstreamDisplay = display
return component as T & { markstreamDisplay: CustomComponentDisplayMode }
}
@@ -0,0 +1,42 @@
function humanizeKey(key: string) {
const s = key.split('.').pop() || key
return s
.replace(/[_-]/g, ' ')
.replace(/([A-Z])/g, ' $1')
.replace(/\s+/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase())
.trim()
}
const defaultMap: Record<string, string> = {
'common.copy': 'Copy',
'common.copied': 'Copied',
'common.decrease': 'Decrease',
'common.reset': 'Reset',
'common.increase': 'Increase',
'common.expand': 'Expand',
'common.collapse': 'Collapse',
'common.preview': 'Preview',
'common.source': 'Source',
'common.export': 'Export',
'common.open': 'Open',
'common.close': 'Close',
'common.zoomIn': 'Zoom in',
'common.zoomOut': 'Zoom out',
'common.resetZoom': 'Reset zoom',
'image.loadError': 'Image failed to load',
'image.loading': 'Loading image...',
}
/**
* Replace the entire default translation map.
* Consumers can call this to provide their own fallback translations (e.g. Chinese).
*/
export function setDefaultI18nMap(map: Record<keyof typeof defaultMap, string>) {
Object.assign(defaultMap, map)
}
export function useSafeI18n() {
const t = (key: string) => defaultMap[key] ?? humanizeKey(key)
return { t }
}
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<path fill="none" stroke="#8caaee" stroke-linecap="round" stroke-linejoin="round" d="m 2.5559121,12.951629 c 2.7459832,2.734744 7.1981158,2.734744 9.9441189,0 l -1.789955,-1.782586 c -1.7574201,1.750224 -4.606788,1.750224 -6.3642295,0 -1.7574416,-1.7502236 -1.7574416,-4.587893 0,-6.338097 1.7574415,-1.750224 4.6068094,-1.750224 6.3642295,0 l 0.894977,-0.8912929 0.894978,-0.891293 c -2.7460031,-2.73472867 -7.198136,-2.73472867 -9.9441191,0 -2.74598585,2.7347089 -2.74598585,7.1685599 2e-7,9.9032689 z" clip-rule="evenodd" />
<path fill="none" stroke="#8caaee" stroke-linecap="round" stroke-linejoin="round" d="M7.5 6v4M5.513524 7.9999996H9.51304M13.486476 5.9999996v4M11.5 7.9999992h3.999516" />
</svg>

After

Width:  |  Height:  |  Size: 795 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<g fill="none" stroke="#ca9ee6" stroke-linecap="round" stroke-linejoin="round">
<path d="m4 1.5h8c1.38 0 2.5 1.12 2.5 2.5v8c0 1.38-1.12 2.5-2.5 2.5h-8c-1.38 0-2.5-1.12-2.5-2.5v-8c0-1.38 1.12-2.5 2.5-2.5z" />
<path stroke-width=".814" d="m 10.240861,11.529149 c 0,0.58011 0.437448,1.039154 0.96002,1.035371 l 0.451635,-0.0032 c 0.522572,-0.0036 0.949379,-0.451477 0.949379,-1.032848 0,-0.581372 -0.426807,-1.065638 -0.949379,-1.065638 l -0.451635,3.4e-5 c -0.522572,3.9e-5 -0.949379,-0.4855273 -0.949379,-1.0656374 0,-0.5801104 0.426807,-1.0378931 0.949379,-1.0378931 l 0.451635,2.825e-4 c 0.522572,3.267e-4 0.951743,0.4577827 0.951743,1.0378931 M 6.8003972,11.529149 c 0,0.58011 0.4374474,1.039154 0.9600196,1.035371 l 0.46464,-0.0032 c 0.5225722,-0.0035 0.9363738,-0.451477 0.9363738,-1.031587 0,-0.580111 -0.4090724,-1.065638 -0.9316446,-1.065638 l -0.4693692,3.4e-5 c -0.5225722,3.8e-5 -0.949379,-0.4855272 -0.949379,-1.0656373 0,-0.5801104 0.4268068,-1.0378931 0.949379,-1.0378931 h 0.4516348 c 0.5225722,0 0.9635665,0.4577827 0.9635665,1.0378931 M 3.4072246,11.529149 c 0,0.58011 0.4374474,1.051765 0.9600196,1.051765 H 4.818879 c 0.5225722,0 0.949379,-0.456521 0.949379,-1.037893 m 0.01129,-2.1312747 c 0,-0.5801103 -0.4374474,-1.037893 -0.9600196,-1.037893 L 4.3678939,8.3741358 C 3.8453217,8.3744624 3.4078743,8.8420074 3.4078743,9.4233788 v 2.1186642" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="#ef9f76" d="M1.5 1.5h13L13 13l-5 2-5-2z" />
<path stroke="#c6d0f5" d="M11 4.5H5l.25 3h5.5l-.25 3-2.5 1-2.5-1-.08-1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 305 B

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<g fill="none" stroke="#99d1db" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 10.8c4.14 0 7.5-1.25 7.5-2.8S12.14 5.2 8 5.2.5 6.45.5 8s3.36 2.8 7.5 2.8" />
<path d="M5.52 9.4c2.07 3.5 4.86 5.72 6.23 4.95 1.37-.78.8-4.24-1.27-7.75C8.41 3.1 5.62.88 4.25 1.65c-1.37.78-.8 4.24 1.27 7.75" />
<path d="M5.52 6.6c-2.07 3.5-2.64 6.97-1.27 7.75 1.37.77 4.16-1.45 6.23-4.95s2.64-6.97 1.27-7.75C10.38.88 7.59 3.1 5.52 6.6" />
<path d="M8.5 8a.5.5 0 01-.5.5.5.5 0 01-.5-.5.5.5 0 01.5-.5.5.5 0 01.5.5" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 623 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<g fill="none" stroke="#e5c890" stroke-linecap="round" stroke-linejoin="round">
<path d="M4.5 11c0 .828427.6715729 1.5 1.5 1.5.8284271 0 1.5-.671573 1.5-1.5V7.5M12.5 8.75C12.5 8.05964406 11.9627417 7.5 11.3 7.5L10.7 7.5C10.0372583 7.5 9.5 8.05964406 9.5 8.75 9.5 9.44035594 10.0372583 10 10.7 10L11.3 10C11.9627417 10 12.5 10.5596441 12.5 11.25 12.5 11.9403559 11.9627417 12.5 11.3 12.5L10.7 12.5C10.0372583 12.5 9.5 11.9403559 9.5 11.25" />
<path d="m 4,1.5 h 8 c 1.385,0 2.5,1.115 2.5,2.5 v 8 c 0,1.385 -1.115,2.5 -2.5,2.5 H 4 C 2.615,14.5 1.5,13.385 1.5,12 V 4 C 1.5,2.615 2.615,1.5 4,1.5 Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 707 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<path fill="none" stroke="#e5c890" stroke-linecap="round" stroke-linejoin="round" d="M4.5 2.5H4c-.75 0-1.5.75-1.5 1.5v2c0 1.1-1 2-1.83 2 .83 0 1.83.9 1.83 2v2c0 .75.75 1.5 1.5 1.5h.5m7-11h.5c.75 0 1.5.75 1.5 1.5v2c0 1.1 1 2 1.83 2-.83 0-1.83.9-1.83 2v2c0 .74-.75 1.5-1.5 1.5h-.5m-6.5-3a.5.5 0 100-1 .5.5 0 000 1m3 0a.5.5 0 100-1 .5.5 0 000 1m3 0a.5.5 0 100-1 .5.5 0 000 1" />
</svg>

After

Width:  |  Height:  |  Size: 470 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<path fill="none" stroke="#85c1dc" stroke-linecap="round" stroke-linejoin="round" d="m9.25 8.25 2.25 2.25 2.25-2.25M3.5 11V5.5l2.04 3 1.96-3V11m4-.5V5M1.65 2.5h12.7c.59 0 1.15.49 1.15 1v9c0 .51-.56 1-1.15 1H1.65c-.59 0-1.15-.49-1.15-1V3.58c0-.5.56-1.08 1.15-1.08" />
</svg>

After

Width:  |  Height:  |  Size: 361 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<path fill="none" stroke="#ca9ee6" stroke-linecap="round" stroke-linejoin="round" d="M1.5 2.5c0 6 2.25 5.75 4 7 .83.67 1.17 2 1 4h3c-.17-2 .17-3.33 1-4 1.75-1.25 4-1 4-7C12 2.5 10 3 8 7 6 3 4 2.5 1.5 2.5" />
</svg>

After

Width:  |  Height:  |  Size: 302 B

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="#8caaee" d="M8.5 5.5h-3m6 0V3c0-.8-.7-1.5-1.5-1.5H7c-.8 0-1.5.7-1.5 1.5v2.5H3c-.8 0-1.5.7-1.5 1.5v2c0 .8.7 1.5 1.48 1.5" />
<path stroke="#e5c890" d="M10.5 10.5h-3m-3 0V13c0 .8.7 1.5 1.5 1.5h3c.8 0 1.5-.7 1.5-1.5v-2.5H13c.8 0 1.5-.7 1.5-1.5V7c0-.8-.7-1.5-1.48-1.5H11.5c0 1.5 0 2-1 2h-2" />
<path stroke="#8caaee" d="M2.98 10.5H4.5c0-1.5 0-2 1-2h2M7.5 3.5v0" />
<path stroke="#e5c890" d="m 8.5,12.5 v 0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 600 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<g fill="none" stroke="#8caaee" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 15.5c-.7 0-1.5-.8-1.5-1.5V5c0-.7.8-1.5 1.5-1.5h9c.7 0 1.5.8 1.5 1.5v9c0 .7-.8 1.5-1.5 1.5z" />
<path d="m1.2 3.8 3.04-2.5S5.17.5 5.7.5h8.4c.66 0 1.4.73 1.4 1.4v7.73a2.7 2.7 0 01-.7 1.75l-2.68 3.51M3 8.5l3 2-3 2m4 0h2" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 422 B

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16"><!-- Icon from Lucide by Lucide Contributors - https://github.com/lucide-icons/lucide/blob/main/LICENSE --><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m10 9l-3 3l3 3m4 0l3-3l-3-3"/><rect width="18" height="18" x="3" y="3" rx="2"/></g></svg>

After

Width:  |  Height:  |  Size: 391 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<path fill="none" stroke="#ef9f76" stroke-linecap="round" stroke-linejoin="round" d="m4.54 10 6.92-4m-6.92 4a1.5 1.5 0 10-2.6 1.5 1.5 1.5 0 002.6-1.5M8 4v8m0-8a1.5 1.5 0 100-3 1.5 1.5 0 000 3M4.54 6l6.92 4M4.54 6a1.5 1.5 0 10-2.6-1.5A1.5 1.5 0 004.54 6M8 12a1.5 1.5 0 100 3 1.5 1.5 0 000-3m3.46-2a1.5 1.5 0 102.6 1.5 1.5 1.5 0 00-2.6-1.5m0-4a1.5 1.5 0 102.6-1.5 1.5 1.5 0 00-2.6 1.5" />
</svg>

After

Width:  |  Height:  |  Size: 481 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<g fill="none" stroke="#c6d0f5" stroke-linecap="round" stroke-linejoin="round">
<path d="M13.5 6.5v6a2 2 0 01-2 2h-7a2 2 0 01-2-2v-9c0-1.1.9-2 2-2h4.01" />
<path d="m8.5 1.5 5 5h-4a1 1 0 01-1-1zm-3 10h5m-5-3h5m-5-3h1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 330 B

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<g fill="none" stroke="#8caaee" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 11.3c4.14 0 7.5-1.28 7.5-2.86S12.14 5.58 8 5.58.5 6.86.5 8.44s3.36 2.87 7.5 2.87Z" />
<path d="M5.52 9.87c2.07 3.6 4.86 5.86 6.23 5.07 1.37-.8.8-4.34-1.27-7.93S5.62 1.16 4.25 1.95s-.8 4.34 1.27 7.92" />
<path d="M5.52 7.01c-2.07 3.59-2.64 7.14-1.27 7.93s4.16-1.48 6.23-5.07c2.07-3.58 2.64-7.13 1.27-7.92-1.37-.8-4.16 1.47-6.23 5.06" />
<path d="M8.5 8.44a.5.5 0 01-.5.5.5.5 0 01-.5-.5.5.5 0 01.5-.5.5.5 0 01.5.5" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 625 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<g fill="none" stroke="#8caaee" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 1.5h8A2.5 2.5 0 0114.5 4v8a2.5 2.5 0 01-2.5 2.5H4A2.5 2.5 0 011.5 12V4A2.5 2.5 0 014 1.5" />
<path d="M12.5 8.75c0-.69-.54-1.25-1.2-1.25h-.6c-.66 0-1.2.56-1.2 1.25S10.04 10 10.7 10h.6c.66 0 1.2.56 1.2 1.25s-.54 1.25-1.2 1.25h-.6c-.66 0-1.2-.56-1.2-1.25m-3-3.75v5M5 7.5h3" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 476 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<g fill="none" stroke="#a6d189" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 1.5h5.44L8 4.56 9.56 1.5H15l-6.99 13z" />
<path d="M12.05 1.73 8 9.28 3.95 1.73" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 285 B

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
import type { CustomComponentMap as MarkstreamCustomComponentMap } from './customComponents'
import type { RenderContext as MarkstreamRenderContext, RenderNodeFn as MarkstreamRenderNodeFn } from './types'
import './index.css'
import './workers/katexRenderer.worker?worker'
import './workers/mermaidParser.worker?worker'
export { AdmonitionNode } from './components/AdmonitionNode/AdmonitionNode'
export { BlockquoteNode } from './components/BlockquoteNode/BlockquoteNode'
export { CheckboxNode } from './components/CheckboxNode/CheckboxNode'
export { CodeBlockNode } from './components/CodeBlockNode/CodeBlockNode'
export { CodeBlockNode as ReactCodeBlockNode } from './components/CodeBlockNode/CodeBlockNode'
export type { CodeBlockPreviewPayload, CodeBlockNodeReactEvents } from './components/CodeBlockNode/CodeBlockNode'
export { HtmlPreviewFrame } from './components/CodeBlockNode/HtmlPreviewFrame'
export type { HtmlPreviewFrameProps } from './components/CodeBlockNode/HtmlPreviewFrame'
export type { D2Loader } from './components/D2BlockNode/d2'
export { disableD2, enableD2, isD2Enabled, setD2Loader } from './components/D2BlockNode/d2'
export { D2BlockNode } from './components/D2BlockNode/D2BlockNode'
export { DefinitionListNode } from './components/DefinitionListNode/DefinitionListNode'
export { EmojiNode } from './components/EmojiNode/EmojiNode'
export { EmphasisNode } from './components/EmphasisNode/EmphasisNode'
export { FootnoteAnchorNode } from './components/FootnoteAnchorNode/FootnoteAnchorNode'
export { FootnoteNode } from './components/FootnoteNode/FootnoteNode'
export { FootnoteReferenceNode } from './components/FootnoteReferenceNode/FootnoteReferenceNode'
export { HardBreakNode } from './components/HardBreakNode/HardBreakNode'
export { HeadingNode } from './components/HeadingNode/HeadingNode'
export { HighlightNode } from './components/HighlightNode/HighlightNode'
export { HtmlBlockNode } from './components/HtmlBlockNode/HtmlBlockNode'
export { HtmlInlineNode } from './components/HtmlInlineNode/HtmlInlineNode'
export { ImageNode } from './components/ImageNode/ImageNode'
export { InfographicBlockNode } from './components/InfographicBlockNode/InfographicBlockNode'
export { InlineCodeNode } from './components/InlineCodeNode/InlineCodeNode'
export { InsertNode } from './components/InsertNode/InsertNode'
export { LinkNode } from './components/LinkNode/LinkNode'
export type { LinkNodeStyleProps } from './components/LinkNode/LinkNode'
export { ListItemNode } from './components/ListItemNode/ListItemNode'
export type { ListItemNodeProps } from './components/ListItemNode/ListItemNode'
export { ListNode } from './components/ListNode/ListNode'
export { MarkdownCodeBlockNode } from './components/MarkdownCodeBlockNode/MarkdownCodeBlockNode'
export type { MarkdownCodeBlockNodeProps } from './components/MarkdownCodeBlockNode/MarkdownCodeBlockNode'
export { MathBlockNode } from './components/MathBlockNode/MathBlockNode'
export { MathInlineNode } from './components/MathInlineNode/MathInlineNode'
export { MermaidBlockNode } from './components/MermaidBlockNode/MermaidBlockNode'
export { NodeRenderer } from './components/NodeRenderer'
export { default } from './components/NodeRenderer'
export { FallbackComponent } from './components/NodeRenderer/FallbackComponent'
export { ParagraphNode } from './components/ParagraphNode/ParagraphNode'
export { PreCodeNode } from './components/PreCodeNode/PreCodeNode'
export { ReferenceNode } from './components/ReferenceNode/ReferenceNode'
export { StrikethroughNode } from './components/StrikethroughNode/StrikethroughNode'
export { StrongNode } from './components/StrongNode/StrongNode'
export { SubscriptNode } from './components/SubscriptNode/SubscriptNode'
export { SuperscriptNode } from './components/SuperscriptNode/SuperscriptNode'
export { TableNode } from './components/TableNode/TableNode'
export { TextNode } from './components/TextNode/TextNode'
export { ThematicBreakNode } from './components/ThematicBreakNode/ThematicBreakNode'
export { Tooltip } from './components/Tooltip/Tooltip'
export type { TooltipPlacement, TooltipProps } from './components/Tooltip/Tooltip'
export { VmrContainerNode } from './components/VmrContainerNode/VmrContainerNode'
export {
clearGlobalCustomComponents,
getCustomComponentDisplay,
getCustomNodeComponents,
removeCustomComponents,
setCustomComponents,
withMarkstreamComponentDisplay,
} from './customComponents'
export type {
CustomComponentDisplayMode,
MarkstreamCustomComponent,
} from './customComponents'
export * from './i18n/useSafeI18n'
export * from './renderers/renderNode'
export type { NodeRendererCodeBlockProps, NodeRendererProps } from './types'
export type {
CodeBlockActionContext,
CodeBlockDiffAppearance,
CodeBlockDiffHideUnchangedRegions,
CodeBlockDiffHideUnchangedRegionsOptions,
CodeBlockDiffHunkActionContext,
CodeBlockDiffHunkActionKind,
CodeBlockDiffHunkSide,
CodeBlockDiffLineStyle,
CodeBlockDiffUnchangedRegionStyle,
CodeBlockMonacoLanguage,
CodeBlockMonacoOptions,
CodeBlockMonacoTheme,
CodeBlockMonacoThemeObject,
CodeBlockNodeProps,
D2BlockNodeProps,
ImageNodeProps,
InfographicBlockActionContext,
InfographicBlockNodeProps,
LinkNodeProps,
MathBlockNodeProps,
MathInlineNodeProps,
MermaidBlockActionContext,
MermaidBlockEvent,
MermaidBlockNodeProps,
PreCodeNodeProps,
} from './types/component-props'
export type { NodeComponentProps } from './types/node-component'
export * from './utils/languageIcon'
export * from './workers/katexWorkerClient'
export * from './workers/mermaidWorkerClient'
export type CustomComponentMap = MarkstreamCustomComponentMap
export type RenderContext = MarkstreamRenderContext
export type RenderNodeFn = MarkstreamRenderNodeFn
@@ -0,0 +1,161 @@
'use client'
import type { D2Loader } from './components/D2BlockNode/d2'
import React, { useEffect, useState } from 'react'
import { AdmonitionNode as ClientAdmonitionNode } from './components/AdmonitionNode/AdmonitionNode'
import { BlockquoteNode as ClientBlockquoteNode } from './components/BlockquoteNode/BlockquoteNode'
import { CheckboxNode as ClientCheckboxNode } from './components/CheckboxNode/CheckboxNode'
import { CodeBlockNode as ClientCodeBlockNode } from './components/CodeBlockNode/CodeBlockNode'
import { HtmlPreviewFrame as ClientHtmlPreviewFrame } from './components/CodeBlockNode/HtmlPreviewFrame'
import { disableD2, enableD2, isD2Enabled, setD2Loader } from './components/D2BlockNode/d2'
import { D2BlockNode as ClientD2BlockNode } from './components/D2BlockNode/D2BlockNode'
import { DefinitionListNode as ClientDefinitionListNode } from './components/DefinitionListNode/DefinitionListNode'
import { EmojiNode as ClientEmojiNode } from './components/EmojiNode/EmojiNode'
import { EmphasisNode as ClientEmphasisNode } from './components/EmphasisNode/EmphasisNode'
import { FootnoteAnchorNode as ClientFootnoteAnchorNode } from './components/FootnoteAnchorNode/FootnoteAnchorNode'
import { FootnoteNode as ClientFootnoteNode } from './components/FootnoteNode/FootnoteNode'
import { FootnoteReferenceNode as ClientFootnoteReferenceNode } from './components/FootnoteReferenceNode/FootnoteReferenceNode'
import { HardBreakNode as ClientHardBreakNode } from './components/HardBreakNode/HardBreakNode'
import { HeadingNode as ClientHeadingNode } from './components/HeadingNode/HeadingNode'
import { HighlightNode as ClientHighlightNode } from './components/HighlightNode/HighlightNode'
import { HtmlBlockNode as ClientHtmlBlockNode } from './components/HtmlBlockNode/HtmlBlockNode'
import { HtmlInlineNode as ClientHtmlInlineNode } from './components/HtmlInlineNode/HtmlInlineNode'
import { ImageNode as ClientImageNode } from './components/ImageNode/ImageNode'
import { InfographicBlockNode as ClientInfographicBlockNode } from './components/InfographicBlockNode/InfographicBlockNode'
import { InlineCodeNode as ClientInlineCodeNode } from './components/InlineCodeNode/InlineCodeNode'
import { InsertNode as ClientInsertNode } from './components/InsertNode/InsertNode'
import { LinkNode as ClientLinkNode } from './components/LinkNode/LinkNode'
import { ListItemNode as ClientListItemNode } from './components/ListItemNode/ListItemNode'
import { ListNode as ClientListNode } from './components/ListNode/ListNode'
import { MarkdownCodeBlockNode as ClientMarkdownCodeBlockNode } from './components/MarkdownCodeBlockNode/MarkdownCodeBlockNode'
import { MathBlockNode as ClientMathBlockNode } from './components/MathBlockNode/MathBlockNode'
import { MathInlineNode as ClientMathInlineNode } from './components/MathInlineNode/MathInlineNode'
import { MermaidBlockNode as ClientMermaidBlockNode } from './components/MermaidBlockNode/MermaidBlockNode'
import ClientNodeRenderer from './components/NodeRenderer'
import { FallbackComponent as ClientFallbackComponent } from './components/NodeRenderer/FallbackComponent'
import { ParagraphNode as ClientParagraphNode } from './components/ParagraphNode/ParagraphNode'
import { PreCodeNode as ClientPreCodeNode } from './components/PreCodeNode/PreCodeNode'
import { ReferenceNode as ClientReferenceNode } from './components/ReferenceNode/ReferenceNode'
import { StrikethroughNode as ClientStrikethroughNode } from './components/StrikethroughNode/StrikethroughNode'
import { StrongNode as ClientStrongNode } from './components/StrongNode/StrongNode'
import { SubscriptNode as ClientSubscriptNode } from './components/SubscriptNode/SubscriptNode'
import { SuperscriptNode as ClientSuperscriptNode } from './components/SuperscriptNode/SuperscriptNode'
import { TableNode as ClientTableNode } from './components/TableNode/TableNode'
import { TextNode as ClientTextNode } from './components/TextNode/TextNode'
import { ThematicBreakNode as ClientThematicBreakNode } from './components/ThematicBreakNode/ThematicBreakNode'
import { Tooltip as ClientTooltip } from './components/Tooltip/Tooltip'
import { VmrContainerNode as ClientVmrContainerNode } from './components/VmrContainerNode/VmrContainerNode'
import {
clearGlobalCustomComponents,
getCustomNodeComponents,
removeCustomComponents,
setCustomComponents,
} from './customComponents'
import { AdmonitionNode as ServerAdmonitionNode, BlockquoteNode as ServerBlockquoteNode, CheckboxNode as ServerCheckboxNode, CodeBlockNode as ServerCodeBlockNode, D2BlockNode as ServerD2BlockNode, DefinitionListNode as ServerDefinitionListNode, EmojiNode as ServerEmojiNode, EmphasisNode as ServerEmphasisNode, FallbackComponent as ServerFallbackComponent, FootnoteAnchorNode as ServerFootnoteAnchorNode, FootnoteNode as ServerFootnoteNode, FootnoteReferenceNode as ServerFootnoteReferenceNode, HardBreakNode as ServerHardBreakNode, HeadingNode as ServerHeadingNode, HighlightNode as ServerHighlightNode, HtmlBlockNode as ServerHtmlBlockNode, HtmlInlineNode as ServerHtmlInlineNode, HtmlPreviewFrame as ServerHtmlPreviewFrame, ImageNode as ServerImageNode, InfographicBlockNode as ServerInfographicBlockNode, InlineCodeNode as ServerInlineCodeNode, InsertNode as ServerInsertNode, LinkNode as ServerLinkNode, ListItemNode as ServerListItemNode, ListNode as ServerListNode, MarkdownCodeBlockNode as ServerMarkdownCodeBlockNode, MathBlockNode as ServerMathBlockNode, MathInlineNode as ServerMathInlineNode, MermaidBlockNode as ServerMermaidBlockNode, NodeRenderer as ServerNodeRenderer, ParagraphNode as ServerParagraphNode, PreCodeNode as ServerPreCodeNode, ReferenceNode as ServerReferenceNode, StrikethroughNode as ServerStrikethroughNode, StrongNode as ServerStrongNode, SubscriptNode as ServerSubscriptNode, SuperscriptNode as ServerSuperscriptNode, TableNode as ServerTableNode, TextNode as ServerTextNode, ThematicBreakNode as ServerThematicBreakNode, Tooltip as ServerTooltip, VmrContainerNode as ServerVmrContainerNode } from './server-renderer'
function createNextEntryComponent<P>(
ClientComponent: React.ComponentType<P>,
ServerComponent: React.ComponentType<P>,
) {
function renderServerComponentWithDisabledSyncKatex(props: P) {
const globalKey = '__MARKSTREAM_REACT_DISABLE_SYNC_KATEX__'
const globalState = globalThis as Record<string, unknown>
const previousValue = globalState[globalKey]
globalState[globalKey] = true
try {
return (ServerComponent as unknown as (props: P) => React.ReactElement | null)(props)
}
finally {
if (previousValue === undefined)
delete globalState[globalKey]
else
globalState[globalKey] = previousValue
}
}
function MarkstreamNextEntryComponent(props: P) {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted)
return renderServerComponentWithDisabledSyncKatex(props)
return React.createElement(ClientComponent, props)
}
MarkstreamNextEntryComponent.displayName = `MarkstreamNextEntry(${ClientComponent.displayName || ClientComponent.name || 'Component'})`
return MarkstreamNextEntryComponent
}
export const AdmonitionNode = createNextEntryComponent(ClientAdmonitionNode, ServerAdmonitionNode)
export const BlockquoteNode = createNextEntryComponent(ClientBlockquoteNode, ServerBlockquoteNode)
export const CheckboxNode = createNextEntryComponent(ClientCheckboxNode, ServerCheckboxNode)
export const CodeBlockNode = createNextEntryComponent(ClientCodeBlockNode, ServerCodeBlockNode)
export const ReactCodeBlockNode = CodeBlockNode
export const HtmlPreviewFrame = createNextEntryComponent(ClientHtmlPreviewFrame, ServerHtmlPreviewFrame)
export const D2BlockNode = createNextEntryComponent(ClientD2BlockNode, ServerD2BlockNode)
export const DefinitionListNode = createNextEntryComponent(ClientDefinitionListNode, ServerDefinitionListNode)
export const EmojiNode = createNextEntryComponent(ClientEmojiNode, ServerEmojiNode)
export const EmphasisNode = createNextEntryComponent(ClientEmphasisNode, ServerEmphasisNode)
export const FootnoteAnchorNode = createNextEntryComponent(ClientFootnoteAnchorNode, ServerFootnoteAnchorNode)
export const FootnoteNode = createNextEntryComponent(ClientFootnoteNode, ServerFootnoteNode)
export const FootnoteReferenceNode = createNextEntryComponent(ClientFootnoteReferenceNode, ServerFootnoteReferenceNode)
export const HardBreakNode = createNextEntryComponent(ClientHardBreakNode, ServerHardBreakNode)
export const HeadingNode = createNextEntryComponent(ClientHeadingNode, ServerHeadingNode)
export const HighlightNode = createNextEntryComponent(ClientHighlightNode, ServerHighlightNode)
export const HtmlBlockNode = createNextEntryComponent(ClientHtmlBlockNode, ServerHtmlBlockNode)
export const HtmlInlineNode = createNextEntryComponent(ClientHtmlInlineNode, ServerHtmlInlineNode)
export const ImageNode = createNextEntryComponent(ClientImageNode, ServerImageNode)
export const InfographicBlockNode = createNextEntryComponent(ClientInfographicBlockNode, ServerInfographicBlockNode)
export const InlineCodeNode = createNextEntryComponent(ClientInlineCodeNode, ServerInlineCodeNode)
export const InsertNode = createNextEntryComponent(ClientInsertNode, ServerInsertNode)
export const LinkNode = createNextEntryComponent(ClientLinkNode, ServerLinkNode)
export const ListItemNode = createNextEntryComponent(ClientListItemNode, ServerListItemNode)
export const ListNode = createNextEntryComponent(ClientListNode, ServerListNode)
export const MarkdownCodeBlockNode = createNextEntryComponent(ClientMarkdownCodeBlockNode, ServerMarkdownCodeBlockNode)
export const MathBlockNode = createNextEntryComponent(ClientMathBlockNode, ServerMathBlockNode)
export const MathInlineNode = createNextEntryComponent(ClientMathInlineNode, ServerMathInlineNode)
export const MermaidBlockNode = createNextEntryComponent(ClientMermaidBlockNode, ServerMermaidBlockNode)
export const NodeRenderer = createNextEntryComponent(ClientNodeRenderer, ServerNodeRenderer)
export const FallbackComponent = createNextEntryComponent(ClientFallbackComponent, ServerFallbackComponent)
export const ParagraphNode = createNextEntryComponent(ClientParagraphNode, ServerParagraphNode)
export const PreCodeNode = createNextEntryComponent(ClientPreCodeNode, ServerPreCodeNode)
export const ReferenceNode = createNextEntryComponent(ClientReferenceNode, ServerReferenceNode)
export const StrikethroughNode = createNextEntryComponent(ClientStrikethroughNode, ServerStrikethroughNode)
export const StrongNode = createNextEntryComponent(ClientStrongNode, ServerStrongNode)
export const SubscriptNode = createNextEntryComponent(ClientSubscriptNode, ServerSubscriptNode)
export const SuperscriptNode = createNextEntryComponent(ClientSuperscriptNode, ServerSuperscriptNode)
export const TableNode = createNextEntryComponent(ClientTableNode, ServerTableNode)
export const TextNode = createNextEntryComponent(ClientTextNode, ServerTextNode)
export const ThematicBreakNode = createNextEntryComponent(ClientThematicBreakNode, ServerThematicBreakNode)
export const Tooltip = createNextEntryComponent(ClientTooltip, ServerTooltip)
export const VmrContainerNode = createNextEntryComponent(ClientVmrContainerNode, ServerVmrContainerNode)
export default NodeRenderer
export type { D2Loader }
export { disableD2, enableD2, isD2Enabled, setD2Loader }
export {
clearGlobalCustomComponents,
getCustomNodeComponents,
removeCustomComponents,
setCustomComponents,
}
export type { HtmlPreviewFrameProps } from './components/CodeBlockNode/HtmlPreviewFrame'
export * from './components/D2BlockNode/d2'
export type { LinkNodeStyleProps } from './components/LinkNode/LinkNode'
export type { ListItemNodeProps } from './components/ListItemNode/ListItemNode'
export type { MarkdownCodeBlockNodeProps } from './components/MarkdownCodeBlockNode/MarkdownCodeBlockNode'
export type { TooltipPlacement, TooltipProps } from './components/Tooltip/Tooltip'
export * from './i18n/useSafeI18n'
export * from './renderers/renderNode'
export type { NodeRendererCodeBlockProps, NodeRendererProps } from './types'
export * from './types/component-props'
export type { NodeComponentProps } from './types/node-component'
export * from './utils/languageIcon'
export * from './workers/katexWorkerClient'
export * from './workers/mermaidWorkerClient'
@@ -0,0 +1,93 @@
import type { ReactNode } from 'react'
import type { ParsedNode } from 'stream-markdown-parser'
import type { RenderContext, RenderNodeFn } from '../types'
export const BLOCK_LEVEL_TYPES = new Set([
'table',
'code_block',
'html_block',
'blockquote',
'list',
'list_item',
'definition_list',
'footnote',
'footnote_reference',
'footnote_anchor',
'admonition',
'thematic_break',
'math_block',
'thinking',
'vmr_container',
])
export function tokenAttrsToProps(attrs?: [string, string | null][]) {
if (!Array.isArray(attrs) || attrs.length === 0)
return undefined
return attrs.reduce<Record<string, string | true>>((acc, [name, value]) => {
if (!name)
return acc
const attrName = name === 'for'
? 'htmlFor'
: name === 'class'
? 'className'
: name
acc[attrName] = value ?? true
return acc
}, {})
}
export function renderNodeChildren(
children: ParsedNode[] | undefined,
ctx: RenderContext,
prefix: string,
renderNode: RenderNodeFn,
) {
if (!Array.isArray(children) || children.length === 0)
return null
const result: ReactNode[] = []
for (let idx = 0; idx < children.length; idx++) {
const child = children[idx] as ParsedNode & { attrs?: [string, string | null][] }
if (!child)
continue
if (child.type === 'label_open') {
const labelChildren: ParsedNode[] = []
idx++
while (idx < children.length) {
const segment = children[idx]
if (segment?.type === 'label_close')
break
if (segment)
labelChildren.push(segment)
idx++
}
const key = `${prefix}-label-${idx}`
result.push(
<label key={key} {...tokenAttrsToProps(child.attrs)}>
{renderNodeChildren(labelChildren, ctx, `${key}-child`, renderNode)}
</label>,
)
continue
}
if (child.type === 'label_close')
continue
result.push(renderNode(child, `${prefix}-${idx}`, ctx))
}
return result
}
export function renderInline(
children: ParsedNode[] | undefined,
ctx: RenderContext,
prefix: string,
renderNode: RenderNodeFn,
) {
return (
<>
{renderNodeChildren(children, ctx, `${prefix}-inline`, renderNode)}
</>
)
}
@@ -0,0 +1,370 @@
import type { ParsedNode } from 'stream-markdown-parser'
import type { RenderContext } from '../types'
import React from 'react'
import { getHtmlTagFromContent, shouldRenderUnknownHtmlTagAsText, stripCustomHtmlWrapper } from 'stream-markdown-parser'
import { AdmonitionNode } from '../components/AdmonitionNode/AdmonitionNode'
import { BlockquoteNode } from '../components/BlockquoteNode/BlockquoteNode'
import { CheckboxNode } from '../components/CheckboxNode/CheckboxNode'
import { CodeBlockNode as MonacoCodeBlockNode } from '../components/CodeBlockNode/CodeBlockNode'
import { PreCodeNode } from '../components/CodeBlockNode/PreCodeNode'
import { D2BlockNode } from '../components/D2BlockNode/D2BlockNode'
import { DefinitionListNode } from '../components/DefinitionListNode/DefinitionListNode'
import { EmojiNode } from '../components/EmojiNode/EmojiNode'
import { EmphasisNode } from '../components/EmphasisNode/EmphasisNode'
import { FootnoteAnchorNode } from '../components/FootnoteAnchorNode/FootnoteAnchorNode'
import { FootnoteNode } from '../components/FootnoteNode/FootnoteNode'
import { FootnoteReferenceNode } from '../components/FootnoteReferenceNode/FootnoteReferenceNode'
import { HardBreakNode } from '../components/HardBreakNode/HardBreakNode'
import { HeadingNode } from '../components/HeadingNode/HeadingNode'
import { HighlightNode } from '../components/HighlightNode/HighlightNode'
import { HtmlBlockNode } from '../components/HtmlBlockNode/HtmlBlockNode'
import { HtmlInlineNode } from '../components/HtmlInlineNode/HtmlInlineNode'
import { ImageNode } from '../components/ImageNode/ImageNode'
import { clampInfographicPreviewHeight, estimateInfographicPreviewHeight, parsePositiveNumber as parsePositiveInfographicNumber } from '../components/InfographicBlockNode/height'
import { InfographicBlockNode } from '../components/InfographicBlockNode/InfographicBlockNode'
import { InlineCodeNode } from '../components/InlineCodeNode/InlineCodeNode'
import { InsertNode } from '../components/InsertNode/InsertNode'
import { LinkNode } from '../components/LinkNode/LinkNode'
import { ListItemNode } from '../components/ListItemNode/ListItemNode'
import { ListNode } from '../components/ListNode/ListNode'
import { MathBlockNode } from '../components/MathBlockNode/MathBlockNode'
import { MathInlineNode } from '../components/MathInlineNode/MathInlineNode'
import { clampMermaidPreviewHeight, estimateMermaidPreviewHeight, parsePositiveNumber as parsePositiveMermaidNumber } from '../components/MermaidBlockNode/height'
import { MermaidBlockNode } from '../components/MermaidBlockNode/MermaidBlockNode'
import { FallbackComponent } from '../components/NodeRenderer/FallbackComponent'
import { ParagraphNode } from '../components/ParagraphNode/ParagraphNode'
import { ReferenceNode } from '../components/ReferenceNode/ReferenceNode'
import { StrikethroughNode } from '../components/StrikethroughNode/StrikethroughNode'
import { StrongNode } from '../components/StrongNode/StrongNode'
import { SubscriptNode } from '../components/SubscriptNode/SubscriptNode'
import { SuperscriptNode } from '../components/SuperscriptNode/SuperscriptNode'
import { TableNode } from '../components/TableNode/TableNode'
import { TextNode } from '../components/TextNode/TextNode'
import { ThematicBreakNode } from '../components/ThematicBreakNode/ThematicBreakNode'
import { VmrContainerNode } from '../components/VmrContainerNode/VmrContainerNode'
import { getCustomNodeComponents } from '../customComponents'
import { resolveCustomHtmlTag } from '../utils/customHtmlTag'
import { normalizeLanguageIdentifier } from '../utils/languageIcon'
import { renderNodeChildren } from './renderChildren'
function getRawCodeBlockLanguage(node: any) {
const trimmed = String(node?.language || '').trim()
if (!trimmed)
return ''
const [firstToken] = trimmed.split(/\s+/)
const [base] = firstToken.split(':')
return base.toLowerCase()
}
function renderCustomCodeBlockComponent(
component: any,
node: any,
key: React.Key,
ctx: RenderContext,
) {
return React.createElement(component, {
key,
node,
customId: ctx.customId,
isDark: ctx.isDark,
ctx,
renderNode,
indexKey: key,
typewriter: ctx.typewriter,
})
}
function getMermaidRenderProps(node: any, ctx: RenderContext) {
const next = { ...(ctx.mermaidProps || {}) } as Record<string, any>
if (parsePositiveMermaidNumber(next.estimatedPreviewHeightPx) == null) {
next.estimatedPreviewHeightPx = clampMermaidPreviewHeight(
estimateMermaidPreviewHeight(String(node?.code ?? '')),
undefined,
next.maxHeight === 'none' ? null : (parsePositiveMermaidNumber(next.maxHeight) ?? undefined),
)
}
return next
}
function getInfographicRenderProps(node: any, ctx: RenderContext) {
const next = { ...(ctx.infographicProps || {}) } as Record<string, any>
if (parsePositiveInfographicNumber(next.estimatedPreviewHeightPx) == null) {
next.estimatedPreviewHeightPx = clampInfographicPreviewHeight(
estimateInfographicPreviewHeight(String(node?.code ?? '')),
undefined,
next.maxHeight === 'none' ? null : (parsePositiveInfographicNumber(next.maxHeight) ?? undefined),
)
}
return next
}
function renderCodeBlock(
node: any,
key: React.Key,
ctx: RenderContext,
customComponents: Record<string, any>,
) {
const rawLanguage = getRawCodeBlockLanguage(node)
const language = normalizeLanguageIdentifier(rawLanguage)
const customForLanguage = rawLanguage ? customComponents[rawLanguage] : null
if (language === 'mermaid') {
const mermaidProps = getMermaidRenderProps(node, ctx)
const customMermaid = customForLanguage || customComponents.mermaid
if (customMermaid)
return React.createElement(customMermaid as any, { key, node, isDark: ctx.isDark, ...mermaidProps })
if (!ctx.renderCodeBlocksAsPre) {
return (
<MermaidBlockNode
key={key}
node={node as any}
isDark={ctx.isDark}
loading={Boolean(node.loading)}
{...mermaidProps}
/>
)
}
}
if (language === 'infographic') {
const infographicProps = getInfographicRenderProps(node, ctx)
const customInfographic = customForLanguage || customComponents.infographic
if (customInfographic)
return React.createElement(customInfographic as any, { key, node, isDark: ctx.isDark, ...infographicProps })
return (
<InfographicBlockNode
key={key}
node={node as any}
isDark={ctx.isDark}
loading={Boolean(node.loading)}
{...infographicProps}
/>
)
}
if (language === 'd2' || language === 'd2lang') {
const customD2 = customForLanguage || customComponents.d2
if (customD2)
return React.createElement(customD2 as any, { key, node, isDark: ctx.isDark, ...(ctx.d2Props || {}) })
return (
<D2BlockNode
key={key}
node={node as any}
isDark={ctx.isDark}
loading={Boolean(node.loading)}
{...(ctx.d2Props || {})}
/>
)
}
if (customForLanguage)
return renderCustomCodeBlockComponent(customForLanguage, node, key, ctx)
const customCodeBlock = customComponents.code_block
if (customCodeBlock)
return renderCustomCodeBlockComponent(customCodeBlock, node, key, ctx)
if (ctx.renderCodeBlocksAsPre || language === 'mermaid') {
return <PreCodeNode key={key} node={node} />
}
return (
<MonacoCodeBlockNode
key={key}
node={node}
loading={Boolean(node.loading)}
stream={ctx.codeBlockStream}
monacoOptions={ctx.codeBlockThemes?.monacoOptions}
themes={ctx.codeBlockThemes?.themes}
darkTheme={ctx.codeBlockThemes?.darkTheme}
lightTheme={ctx.codeBlockThemes?.lightTheme}
minWidth={ctx.codeBlockThemes?.minWidth}
maxWidth={ctx.codeBlockThemes?.maxWidth}
isDark={ctx.isDark}
onCopy={ctx.events.onCopy}
{...(ctx.codeBlockProps || {})}
/>
)
}
export function renderNode(node: ParsedNode, key: React.Key, ctx: RenderContext) {
const customComponents = ctx.customComponents ?? getCustomNodeComponents(ctx.customId)
const custom = node.type === 'code_block'
? null
: (customComponents as Record<string, any>)[node.type]
if (custom) {
return React.createElement(custom, {
key,
node,
customId: ctx.customId,
isDark: ctx.isDark,
ctx,
renderNode,
indexKey: key,
typewriter: ctx.typewriter,
})
}
if (node.type === 'html_block' || node.type === 'html_inline') {
const resolvedCustomTag = resolveCustomHtmlTag(node as any, customComponents as any, ctx.customHtmlTags)
const fallbackTag = String((node as any).tag ?? '').trim().toLowerCase() || getHtmlTagFromContent((node as any).content)
const tag = resolvedCustomTag?.tag ?? fallbackTag
if (tag) {
const customForTag = resolvedCustomTag?.component ?? (customComponents as Record<string, any>)[tag]
const isWhitelisted = resolvedCustomTag?.isWhitelisted ?? (ctx.customHtmlTags ?? []).some((t: string) => t.toLowerCase() === tag)
if (isWhitelisted && customForTag) {
const coerced = {
...(node as any),
type: tag,
tag,
content: stripCustomHtmlWrapper((node as any).content, tag),
}
return React.createElement(customForTag as any, {
key,
node: coerced,
customId: ctx.customId,
isDark: ctx.isDark,
ctx,
renderNode,
indexKey: key,
typewriter: ctx.typewriter,
})
}
const rawContent = String((node as any).content ?? (node as any).raw ?? '')
if (!isWhitelisted && shouldRenderUnknownHtmlTagAsText(rawContent, tag)) {
if (node.type === 'html_inline') {
return <TextNode key={key} node={{ type: 'text', content: rawContent, raw: rawContent } as any} ctx={ctx} indexKey={key} typewriter={ctx.typewriter} />
}
else {
return <ParagraphNode key={key} node={{ type: 'paragraph', children: [{ type: 'text', content: rawContent, raw: rawContent }], raw: rawContent } as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
}
}
}
}
switch (node.type) {
case 'text':
return <TextNode key={key} node={node as any} ctx={ctx} indexKey={key} typewriter={ctx.typewriter} />
case 'text_special':
return <TextNode key={key} node={{ type: 'text', content: (node as any).content ?? '', center: (node as any).center } as any} ctx={ctx} indexKey={key} typewriter={ctx.typewriter} />
case 'paragraph':
return <ParagraphNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
case 'heading':
return <HeadingNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
case 'blockquote':
return <BlockquoteNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
case 'list':
return <ListNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
case 'list_item':
return <ListItemNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
case 'table':
return <TableNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
case 'definition_list':
return <DefinitionListNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
case 'footnote':
return <FootnoteNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
case 'footnote_reference':
return <FootnoteReferenceNode key={key} node={node as any} />
case 'footnote_anchor':
return <FootnoteAnchorNode key={key} node={node as any} />
case 'admonition':
return <AdmonitionNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} isDark={ctx.isDark} typewriter={ctx.typewriter} />
case 'hardbreak':
return <HardBreakNode key={key} node={node as any} typewriter={ctx.typewriter} />
case 'link':
return (
<LinkNode
key={key}
node={node as any}
ctx={ctx}
renderNode={renderNode}
indexKey={key}
isDark={ctx.isDark}
showTooltip={typeof ctx.showTooltips === 'boolean' ? ctx.showTooltips : undefined}
typewriter={ctx.typewriter}
/>
)
case 'image':
return (
<ImageNode
key={key}
node={node as any}
/>
)
case 'inline_code':
return <InlineCodeNode key={key} node={node as any} ctx={ctx} indexKey={key} typewriter={ctx.typewriter} />
case 'code_block':
return renderCodeBlock(node, key, ctx, customComponents)
case 'strong':
return (
<StrongNode
key={key}
node={node as any}
>
{renderNodeChildren((node as any).children, ctx, `${String(key)}-strong`, renderNode)}
</StrongNode>
)
case 'emphasis':
return (
<EmphasisNode key={key} node={node as any}>
{renderNodeChildren((node as any).children, ctx, `${String(key)}-em`, renderNode)}
</EmphasisNode>
)
case 'strikethrough':
return (
<StrikethroughNode key={key} node={node as any}>
{renderNodeChildren((node as any).children, ctx, `${String(key)}-strike`, renderNode)}
</StrikethroughNode>
)
case 'highlight':
return (
<HighlightNode key={key} node={node as any}>
{renderNodeChildren((node as any).children, ctx, `${String(key)}-highlight`, renderNode)}
</HighlightNode>
)
case 'insert':
return (
<InsertNode key={key} node={node as any}>
{renderNodeChildren((node as any).children, ctx, `${String(key)}-insert`, renderNode)}
</InsertNode>
)
case 'subscript':
return (
<SubscriptNode key={key} node={node as any}>
{renderNodeChildren((node as any).children, ctx, `${String(key)}-sub`, renderNode)}
</SubscriptNode>
)
case 'superscript':
return (
<SuperscriptNode key={key} node={node as any}>
{renderNodeChildren((node as any).children, ctx, `${String(key)}-sup`, renderNode)}
</SuperscriptNode>
)
case 'checkbox':
case 'checkbox_input':
return <CheckboxNode key={key} node={node as any} typewriter={ctx.typewriter} />
case 'emoji':
return <EmojiNode key={key} node={node as any} typewriter={ctx.typewriter} />
case 'thematic_break':
return <ThematicBreakNode key={key} />
case 'math_inline':
return <MathInlineNode key={key} node={node as any} />
case 'math_block':
return <MathBlockNode key={key} node={node as any} />
case 'reference':
return <ReferenceNode key={key} node={node as any} ctx={ctx} typewriter={ctx.typewriter} />
case 'html_block':
case 'html_inline':
return node.type === 'html_block'
? <HtmlBlockNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} customId={ctx.customId} />
: <HtmlInlineNode key={key} node={node as any} typewriter={ctx.typewriter} customId={ctx.customId} />
case 'vmr_container':
return <VmrContainerNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
case 'label_open':
case 'label_close':
return null
default:
return <FallbackComponent key={key} node={node as any} />
}
}
@@ -0,0 +1 @@
export { parseHtmlToReactNodes } from '../utils/htmlToReact'
@@ -0,0 +1,74 @@
import { normalizeKaTeXRenderInput } from '../utils/normalizeKaTeXRenderInput'
let katexModule: any | undefined
let katexLoadAttempted = false
function getRequire() {
try {
const processWithBuiltins = Reflect.get(globalThis, 'process') as {
getBuiltinModule?: (specifier: string) => any
} | undefined
const moduleApi = typeof processWithBuiltins?.getBuiltinModule === 'function'
? processWithBuiltins.getBuiltinModule('module')
: null
return typeof moduleApi?.createRequire === 'function'
? moduleApi.createRequire(import.meta.url)
: null
}
catch {
return null
}
}
function loadKatex() {
if (katexModule)
return katexModule
if (katexLoadAttempted)
return null
katexLoadAttempted = true
try {
const require = getRequire()
if (!require)
return null
const katex = require('katex')
try {
require('katex/contrib/mhchem')
}
catch {
// optional extension
}
katexModule = katex
return katexModule
}
catch {
return null
}
}
export function renderKatexToHtml(
content: string,
displayMode: boolean,
throwOnError: boolean,
) {
const normalizedContent = normalizeKaTeXRenderInput(content)
if (!normalizedContent)
return null
if ((globalThis as any).__MARKSTREAM_REACT_DISABLE_SYNC_KATEX__)
return null
const katex = loadKatex()
if (!katex)
return null
try {
return katex.renderToString(normalizedContent, {
displayMode,
throwOnError,
})
}
catch {
return null
}
}
@@ -0,0 +1,62 @@
export type { HtmlPreviewFrameProps } from './components/CodeBlockNode/HtmlPreviewFrame'
export type { LinkNodeStyleProps } from './components/LinkNode/LinkNode'
export type { ListItemNodeProps } from './components/ListItemNode/ListItemNode'
export type { MarkdownCodeBlockNodeProps } from './components/MarkdownCodeBlockNode/MarkdownCodeBlockNode'
export type { TooltipPlacement, TooltipProps } from './components/Tooltip/Tooltip'
export {
clearGlobalCustomComponents,
getCustomComponentDisplay,
getCustomNodeComponents,
removeCustomComponents,
setCustomComponents,
withMarkstreamComponentDisplay,
} from './customComponents'
export type { CustomComponentDisplayMode, MarkstreamCustomComponent } from './customComponents'
export { AdmonitionNode } from './server-renderer'
export { BlockquoteNode } from './server-renderer'
export { CheckboxNode } from './server-renderer'
export { CodeBlockNode } from './server-renderer'
export { CodeBlockNode as ReactCodeBlockNode } from './server-renderer'
export { HtmlPreviewFrame } from './server-renderer'
export { D2BlockNode } from './server-renderer'
export { DefinitionListNode } from './server-renderer'
export { EmojiNode } from './server-renderer'
export { EmphasisNode } from './server-renderer'
export { FootnoteAnchorNode } from './server-renderer'
export { FootnoteNode } from './server-renderer'
export { FootnoteReferenceNode } from './server-renderer'
export { HardBreakNode } from './server-renderer'
export { HeadingNode } from './server-renderer'
export { HighlightNode } from './server-renderer'
export { HtmlBlockNode } from './server-renderer'
export { HtmlInlineNode } from './server-renderer'
export { ImageNode } from './server-renderer'
export { InfographicBlockNode } from './server-renderer'
export { InlineCodeNode } from './server-renderer'
export { InsertNode } from './server-renderer'
export { LinkNode } from './server-renderer'
export { ListItemNode } from './server-renderer'
export { ListNode } from './server-renderer'
export { MarkdownCodeBlockNode } from './server-renderer'
export { MathBlockNode } from './server-renderer'
export { MathInlineNode } from './server-renderer'
export { MermaidBlockNode } from './server-renderer'
export { NodeRenderer } from './server-renderer'
export { default } from './server-renderer'
export { FallbackComponent } from './server-renderer'
export { ParagraphNode } from './server-renderer'
export { PreCodeNode } from './server-renderer'
export { ReferenceNode } from './server-renderer'
export { StrikethroughNode } from './server-renderer'
export { StrongNode } from './server-renderer'
export { SubscriptNode } from './server-renderer'
export { SuperscriptNode } from './server-renderer'
export { TableNode } from './server-renderer'
export { TextNode } from './server-renderer'
export { ThematicBreakNode } from './server-renderer'
export { Tooltip } from './server-renderer'
export { VmrContainerNode } from './server-renderer'
export { renderNode } from './server-renderer'
export type { NodeRendererCodeBlockProps, NodeRendererProps } from './types'
export * from './types/component-props'
export type { NodeComponentProps } from './types/node-component'
@@ -0,0 +1,6 @@
declare module 'katex/contrib/mhchem'
declare module '*.svg?raw' {
const content: string
export default content
}
@@ -0,0 +1,3 @@
import './index.css'
export {}
@@ -0,0 +1,141 @@
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'
let tooltipEl: HTMLDivElement | null = null
let currentAnchor: HTMLElement | null = null
let cleanupAutoUpdate: (() => void) | null = null
let showTimer: ReturnType<typeof setTimeout> | null = null
let hideTimer: ReturnType<typeof setTimeout> | null = null
let currentId: string | null = null
let visible = false
function clearTimers() {
if (showTimer) {
clearTimeout(showTimer)
showTimer = null
}
if (hideTimer) {
clearTimeout(hideTimer)
hideTimer = null
}
}
function ensureTooltipEl() {
if (tooltipEl || typeof document === 'undefined')
return tooltipEl
tooltipEl = document.createElement('div')
tooltipEl.className = 'ms-tooltip'
tooltipEl.setAttribute('role', 'tooltip')
tooltipEl.dataset.visible = 'false'
tooltipEl.style.position = 'fixed'
tooltipEl.style.left = '0px'
tooltipEl.style.top = '0px'
tooltipEl.style.transform = 'translate3d(0,0,0)'
document.body.appendChild(tooltipEl)
return tooltipEl
}
function detectDarkModeHint(hint?: boolean | null) {
if (typeof hint === 'boolean')
return hint
if (typeof document !== 'undefined') {
try {
if (document.documentElement.classList.contains('dark'))
return true
}
catch {}
}
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
try {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
catch {}
}
return false
}
async function updatePosition(placement: TooltipPlacement) {
if (!tooltipEl || !currentAnchor)
return
const { x, y } = await computePosition(currentAnchor, tooltipEl, {
placement,
middleware: [offset(8), flip(), shift({ padding: 8 })],
strategy: 'fixed',
})
tooltipEl.style.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`
}
export function showTooltipForAnchor(
anchor: HTMLElement | null,
content: string,
placement: TooltipPlacement = 'top',
immediate = false,
_origin?: { x: number, y: number } | undefined,
isDark?: boolean | null,
) {
if (!anchor || typeof document === 'undefined')
return
ensureTooltipEl()
if (!tooltipEl)
return
clearTimers()
const doShow = async () => {
if (!tooltipEl)
return
currentAnchor = anchor
tooltipEl.textContent = content
tooltipEl.dataset.placement = placement
tooltipEl.dataset.dark = detectDarkModeHint(isDark) ? 'true' : 'false'
tooltipEl.dataset.visible = 'false'
currentId = `tooltip-${Date.now()}-${Math.random().toString(36).slice(2)}`
tooltipEl.id = currentId
try {
anchor.setAttribute('aria-describedby', currentId)
}
catch {}
await updatePosition(placement)
tooltipEl.dataset.visible = 'true'
visible = true
cleanupAutoUpdate?.()
cleanupAutoUpdate = autoUpdate(anchor, tooltipEl!, () => {
updatePosition(placement)
})
}
if (immediate)
void doShow()
else
showTimer = setTimeout(doShow, 80)
}
export function hideTooltip(immediate = false) {
if (!tooltipEl)
return
clearTimers()
const doHide = () => {
if (!tooltipEl)
return
tooltipEl.dataset.visible = 'false'
visible = false
if (currentAnchor && currentId) {
try {
currentAnchor.removeAttribute('aria-describedby')
}
catch {}
}
currentAnchor = null
currentId = null
if (cleanupAutoUpdate) {
cleanupAutoUpdate()
cleanupAutoUpdate = null
}
}
if (immediate)
doHide()
else
hideTimer = setTimeout(doHide, 120)
}
export function isTooltipVisible() {
return visible
}
@@ -0,0 +1,95 @@
import type React from 'react'
import type { BaseNode, MarkdownIt, ParsedNode, ParseOptions } from 'stream-markdown-parser'
import type {
CodeBlockMonacoOptions,
CodeBlockMonacoTheme,
CodeBlockNodeProps,
D2BlockNodeProps,
InfographicBlockNodeProps,
MermaidBlockNodeProps,
} from './types/component-props'
export type NodeRendererCodeBlockProps = Partial<Omit<CodeBlockNodeProps, 'node'>> & Record<string, unknown>
export interface NodeRendererProps {
content?: string
nodes?: readonly BaseNode[] | null
/**
* Whether the input stream is complete (end-of-stream). When true, the parser
* can stop emitting streaming "loading" nodes for unfinished constructs.
*/
final?: boolean
parseOptions?: ParseOptions
customMarkdownIt?: (md: MarkdownIt) => MarkdownIt
/** Log parse/render timing stats (dev only). */
debugPerformance?: boolean
/**
* Custom HTML-like tags that should be emitted as custom nodes (e.g. ['thinking']).
* Forwarded to `getMarkdown()` and merged into parseOptions.
*/
customHtmlTags?: readonly string[]
viewportPriority?: boolean
codeBlockStream?: boolean
codeBlockDarkTheme?: CodeBlockMonacoTheme
codeBlockLightTheme?: CodeBlockMonacoTheme
codeBlockMonacoOptions?: CodeBlockMonacoOptions
renderCodeBlocksAsPre?: boolean
codeBlockMinWidth?: string | number
codeBlockMaxWidth?: string | number
codeBlockProps?: NodeRendererCodeBlockProps
mermaidProps?: Partial<Omit<MermaidBlockNodeProps, 'node' | 'loading' | 'isDark'>>
d2Props?: Partial<Omit<D2BlockNodeProps, 'node' | 'loading' | 'isDark'>>
infographicProps?: Partial<Omit<InfographicBlockNodeProps, 'node' | 'loading' | 'isDark'>>
showTooltips?: boolean
themes?: CodeBlockMonacoTheme[]
isDark?: boolean
customId?: string
indexKey?: number | string
typewriter?: boolean
batchRendering?: boolean
initialRenderBatchSize?: number
renderBatchSize?: number
renderBatchDelay?: number
renderBatchBudgetMs?: number
renderBatchIdleTimeoutMs?: number
deferNodesUntilVisible?: boolean
maxLiveNodes?: number
liveNodeBuffer?: number
onCopy?: (code: string) => void
onHandleArtifactClick?: (payload: any) => void
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void
onMouseOver?: (event: React.MouseEvent<HTMLElement>) => void
onMouseOut?: (event: React.MouseEvent<HTMLElement>) => void
}
export interface RenderContext {
customId?: string
isDark?: boolean
indexKey?: string
typewriter?: boolean
textStreamState?: Map<string, string>
streamRenderVersion?: number
customComponents?: Record<string, React.ComponentType<any>>
customHtmlTags?: readonly string[]
codeBlockProps?: NodeRendererCodeBlockProps
mermaidProps?: Partial<Omit<MermaidBlockNodeProps, 'node' | 'loading' | 'isDark'>>
d2Props?: Partial<Omit<D2BlockNodeProps, 'node' | 'loading' | 'isDark'>>
infographicProps?: Partial<Omit<InfographicBlockNodeProps, 'node' | 'loading' | 'isDark'>>
showTooltips?: boolean
codeBlockStream?: boolean
renderCodeBlocksAsPre?: boolean
codeBlockThemes?: {
themes?: CodeBlockMonacoTheme[]
darkTheme?: CodeBlockMonacoTheme
lightTheme?: CodeBlockMonacoTheme
monacoOptions?: CodeBlockMonacoOptions
minWidth?: string | number
maxWidth?: string | number
}
events: {
onCopy?: (code: string) => void
onHandleArtifactClick?: (payload: any) => void
}
}
export type RenderNodeFn = (node: ParsedNode, key: React.Key, ctx: RenderContext) => React.ReactNode
@@ -0,0 +1,302 @@
import type React from 'react'
import type { CodeBlockNode } from 'stream-markdown-parser'
export interface CodeBlockMonacoThemeObject {
name: string
base?: string
inherit?: boolean
colors?: Record<string, string>
rules?: Array<Record<string, unknown>>
[key: string]: unknown
}
export type CodeBlockMonacoTheme = string | CodeBlockMonacoThemeObject
export type CodeBlockMonacoLanguage = string | ((...args: any[]) => unknown)
export interface CodeBlockDiffHideUnchangedRegionsOptions {
enabled?: boolean
contextLineCount?: number
minimumLineCount?: number
revealLineCount?: number
}
export type CodeBlockDiffHideUnchangedRegions
= | boolean
| CodeBlockDiffHideUnchangedRegionsOptions
export type CodeBlockDiffLineStyle = 'background' | 'bar'
export type CodeBlockDiffAppearance = 'auto' | 'light' | 'dark'
export type CodeBlockDiffUnchangedRegionStyle = 'line-info' | 'line-info-basic' | 'metadata' | 'simple'
export type CodeBlockDiffHunkActionKind = 'revert' | 'stage'
export type CodeBlockDiffHunkSide = 'upper' | 'lower'
export interface CodeBlockDiffHunkActionContext {
action: CodeBlockDiffHunkActionKind
side: CodeBlockDiffHunkSide
lineChange: unknown
originalModel: unknown
modifiedModel: unknown
}
export interface CodeBlockMonacoOptions {
MAX_HEIGHT?: number | string
fontSize?: number
lineHeight?: number
fontFamily?: string
tabSize?: number
readOnly?: boolean
wordWrap?: 'off' | 'on' | 'wordWrapColumn' | 'bounded' | string
wrappingIndent?: 'none' | 'same' | 'indent' | 'deepIndent' | string
theme?: string
themes?: CodeBlockMonacoTheme[]
languages?: CodeBlockMonacoLanguage[]
renderSideBySide?: boolean
useInlineViewWhenSpaceIsLimited?: boolean
enableSplitViewResizing?: boolean
ignoreTrimWhitespace?: boolean
maxComputationTime?: number
diffAlgorithm?: string
renderIndicators?: boolean
originalEditable?: boolean
revealDebounceMs?: number
revealStrategy?: 'bottom' | 'centerIfOutside' | 'center'
revealBatchOnIdleMs?: number
updateThrottleMs?: number
diffUpdateThrottleMs?: number
diffAutoScroll?: boolean
diffHideUnchangedRegions?: CodeBlockDiffHideUnchangedRegions
diffLineStyle?: CodeBlockDiffLineStyle
diffAppearance?: CodeBlockDiffAppearance
diffUnchangedRegionStyle?: CodeBlockDiffUnchangedRegionStyle
diffHunkActionsOnHover?: boolean
diffHunkHoverHideDelayMs?: number
onDiffHunkAction?: (context: CodeBlockDiffHunkActionContext) => void | boolean | Promise<void | boolean>
scrollbar?: Record<string, any>
[key: string]: any
}
export interface CodeBlockActionContext {
collapsed: boolean
copied: boolean
expanded: boolean
fontSize: number
defaultFontSize: number
isDark: boolean
language: string
displayLanguage: string
languageIcon: string
isPreviewable: boolean
code: string
toggleCollapse: () => void
copy: () => Promise<void>
toggleExpand: () => void
setFontSize: (size: number) => void
resetFontSize: () => void
decreaseFontSize: () => void
increaseFontSize: () => void
previewCode: () => void
}
export interface CodeBlockNodeProps {
node: CodeBlockNode
isDark?: boolean
loading?: boolean
stream?: boolean
darkTheme?: CodeBlockMonacoTheme
lightTheme?: CodeBlockMonacoTheme
isShowPreview?: boolean
monacoOptions?: CodeBlockMonacoOptions
enableFontSizeControl?: boolean
minWidth?: string | number
maxWidth?: string | number
themes?: CodeBlockMonacoTheme[]
showHeader?: boolean
showCopyButton?: boolean
showExpandButton?: boolean
showPreviewButton?: boolean
showCollapseButton?: boolean
showFontSizeButtons?: boolean
showTooltips?: boolean
customId?: string
renderHeaderActions?: (context: CodeBlockActionContext) => React.ReactNode
renderHeader?: (context: CodeBlockActionContext) => React.ReactNode
}
export interface ImageNodeProps {
node: {
type: 'image'
src: string
alt: string
title: string | null
raw: string
loading?: boolean
}
fallbackSrc?: string
lazy?: boolean
usePlaceholder?: boolean
}
export interface LinkNodeProps {
node: {
type: 'link'
href: string
title: string | null
text: string
attrs?: [string, string][]
children: { type: string, raw: string }[]
raw: string
loading?: boolean
}
indexKey: number | string
customId?: string
showTooltip?: boolean
color?: string
underlineHeight?: number
underlineBottom?: number | string
animationDuration?: number
animationOpacity?: number
animationTiming?: string
animationIteration?: string | number
}
export interface PreCodeNodeProps {
node: CodeBlockNode
}
export interface MermaidBlockActionContext {
collapsed: boolean
copied: boolean
showSource: boolean
modalOpen: boolean
isDark: boolean
code: string
mermaidAvailable: boolean
isExportDisabled: boolean
zoom: number
toggleCollapse: () => void
copy: () => void
exportSvg: () => void
toggleFullscreen: () => void
switchMode: (mode: 'preview' | 'source') => void
zoomIn: () => void
zoomOut: () => void
resetZoom: () => void
}
export interface MermaidBlockNodeProps {
node: CodeBlockNode
maxHeight?: string | null
estimatedPreviewHeightPx?: number
loading?: boolean
isDark?: boolean
workerTimeoutMs?: number
parseTimeoutMs?: number
renderTimeoutMs?: number
fullRenderTimeoutMs?: number
renderDebounceMs?: number
contentStableDelayMs?: number
previewPollDelayMs?: number
previewPollMaxDelayMs?: number
previewPollMaxAttempts?: number
showHeader?: boolean
showModeToggle?: boolean
showCopyButton?: boolean
showExportButton?: boolean
showFullscreenButton?: boolean
showCollapseButton?: boolean
showZoomControls?: boolean
enableWheelZoom?: boolean
isStrict?: boolean
showTooltips?: boolean
onRenderError?: (error: unknown, code: string, container: HTMLElement) => boolean | void
renderHeader?: (ctx: MermaidBlockActionContext) => React.ReactNode
renderHeaderActions?: (ctx: MermaidBlockActionContext) => React.ReactNode
renderModeToggle?: (ctx: MermaidBlockActionContext) => React.ReactNode
renderZoomControls?: (ctx: MermaidBlockActionContext) => React.ReactNode
}
export interface MermaidBlockEvent<TPayload = any> {
payload?: TPayload
defaultPrevented: boolean
preventDefault: () => void
svgElement?: SVGElement | null
svgString?: string | null
}
export interface D2BlockNodeProps {
node: CodeBlockNode
maxHeight?: string | null
loading?: boolean
isDark?: boolean
progressiveRender?: boolean
progressiveIntervalMs?: number
themeId?: number | null
darkThemeId?: number | null
showHeader?: boolean
showModeToggle?: boolean
showCopyButton?: boolean
showExportButton?: boolean
showCollapseButton?: boolean
}
export interface MathBlockNodeProps {
node: {
type: 'math_block'
content: string
raw: string
loading?: boolean
}
}
export interface MathInlineNodeProps {
node: {
type: 'math_inline'
content: string
raw: string
loading?: boolean
markup?: string
}
}
export interface InfographicBlockActionContext {
collapsed: boolean
copied: boolean
showSource: boolean
modalOpen: boolean
isDark: boolean
code: string
isExportDisabled: boolean
zoom: number
toggleCollapse: () => void
copy: () => void
exportSvg: () => void
toggleFullscreen: () => void
switchMode: (mode: 'preview' | 'source') => void
zoomIn: () => void
zoomOut: () => void
resetZoom: () => void
}
export interface InfographicBlockNodeProps {
node: CodeBlockNode
maxHeight?: string | null
estimatedPreviewHeightPx?: number
loading?: boolean
isDark?: boolean
showHeader?: boolean
showModeToggle?: boolean
showCopyButton?: boolean
showCollapseButton?: boolean
showExportButton?: boolean
showFullscreenButton?: boolean
showZoomControls?: boolean
renderHeader?: (ctx: InfographicBlockActionContext) => React.ReactNode
renderHeaderActions?: (ctx: InfographicBlockActionContext) => React.ReactNode
renderModeToggle?: (ctx: InfographicBlockActionContext) => React.ReactNode
renderZoomControls?: (ctx: InfographicBlockActionContext) => React.ReactNode
}
@@ -0,0 +1,13 @@
import type React from 'react'
import type { RenderContext, RenderNodeFn } from '../types'
export interface NodeComponentProps<TNode = unknown> {
node: TNode
ctx?: RenderContext
renderNode?: RenderNodeFn
indexKey?: React.Key
customId?: string
isDark?: boolean
typewriter?: boolean
children?: React.ReactNode
}

Some files were not shown because too many files have changed in this diff Show More