Skip to content

Commit c09f44e

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(docs): mermaid diagrams + long-doc table of contents (ADR-0046) (#1730)
Render ```mermaid fenced blocks as diagrams in @object-ui/plugin-markdown (lazy-loaded, securityLevel:'strict', drawn post-sanitize by a trusted component, soft-degrades to source). Add extractToc() with rehype-slug- compatible slugs and a sticky h2–h3 TOC rail in DocPage for long docs. help.onThisPage i18n key (en/zh). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fa528f5 commit c09f44e

12 files changed

Lines changed: 349 additions & 12 deletions

File tree

.changeset/docs-mermaid-toc.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@object-ui/plugin-markdown": minor
3+
"@object-ui/console": minor
4+
"@object-ui/i18n": patch
5+
---
6+
7+
Docs: mermaid diagrams + long-doc table of contents (ADR-0046).
8+
9+
- **plugin-markdown** renders ```mermaid fenced blocks as diagrams (`<Mermaid>`: lazy-loaded mermaid, `securityLevel: 'strict'`, rendered post-`rehype-sanitize` by a trusted component, degrades to the raw source on error). Mermaid is text → SVG, so it stays within the v1 image/binary ban. Adds `extractToc(markdown)` — a TOC builder whose slugs are generated with the same `github-slugger` `rehype-slug` uses, so `#id` links resolve to the rendered heading anchors.
10+
- **console** `DocPage` shows a sticky right-rail table of contents (h2–h3) for docs with ≥3 headings, plus an app-independent `/apps/:packageId/docs` index already added earlier.
11+
- **i18n** adds `help.onThisPage` (en/zh; other locales fall back).

apps/console/src/pages/DocPage.tsx

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9-
import { useCallback, useEffect, useState } from 'react';
9+
import { useCallback, useEffect, useMemo, useState } from 'react';
1010
import { useNavigate, useParams } from 'react-router-dom';
1111
import { AlertCircle, FileQuestion, Loader2 } from 'lucide-react';
1212
import { useAdapter } from '@object-ui/app-shell';
13-
import { MarkdownRenderer } from '@object-ui/plugin-markdown';
13+
import { MarkdownRenderer, extractToc } from '@object-ui/plugin-markdown';
14+
import { useObjectTranslation } from '@object-ui/i18n';
1415
import { rewriteDocLinks } from './doc-links';
1516
import { DocShell } from './DocShell';
1617

@@ -39,6 +40,7 @@ export default function DocPage() {
3940
const { name, appName } = useParams<{ name: string; appName?: string }>();
4041
const navigate = useNavigate();
4142
const adapter = useAdapter();
43+
const { t } = useObjectTranslation();
4244
const [doc, setDoc] = useState<DocItem | null>(null);
4345
const [state, setState] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading');
4446
const [errorMessage, setErrorMessage] = useState<string>('');
@@ -96,6 +98,12 @@ export default function DocPage() {
9698
[navigate],
9799
);
98100

101+
// Long-doc table of contents (h2–h3). Slugs match rehype-slug so a #id
102+
// link resolves to the rendered heading's anchor. Shown only past a few
103+
// headings so short docs stay clean.
104+
const toc = useMemo(() => extractToc(doc?.content ?? ''), [doc?.content]);
105+
const tocLabel = t('help.onThisPage', { defaultValue: 'On this page' });
106+
99107
if (state === 'loading') {
100108
return (
101109
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
@@ -133,10 +141,36 @@ export default function DocPage() {
133141

134142
return (
135143
<DocShell breadcrumb={doc?.label ?? name}>
136-
<div className="mx-auto max-w-3xl p-4 sm:p-6" onClick={onContentClick}>
137-
<MarkdownRenderer
138-
schema={{ type: 'markdown', content: rewriteDocLinks(doc?.content ?? '') }}
139-
/>
144+
<div className="mx-auto flex max-w-5xl gap-8 p-4 sm:p-6">
145+
<article
146+
className="min-w-0 max-w-3xl flex-1 [&_h1]:scroll-mt-24 [&_h2]:scroll-mt-24 [&_h3]:scroll-mt-24"
147+
onClick={onContentClick}
148+
>
149+
<MarkdownRenderer
150+
schema={{ type: 'markdown', content: rewriteDocLinks(doc?.content ?? '') }}
151+
/>
152+
</article>
153+
{toc.length >= 3 ? (
154+
<aside className="hidden xl:block w-56 shrink-0">
155+
<nav aria-label={tocLabel} className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-auto">
156+
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
157+
{tocLabel}
158+
</div>
159+
<ul className="border-l border-border text-sm">
160+
{toc.map((item) => (
161+
<li key={item.id} style={{ paddingLeft: (item.depth - 2) * 12 }}>
162+
<a
163+
href={`#${item.id}`}
164+
className="-ml-px block border-l border-transparent py-0.5 pl-3 text-muted-foreground hover:border-primary hover:text-foreground"
165+
>
166+
{item.text}
167+
</a>
168+
</li>
169+
))}
170+
</ul>
171+
</nav>
172+
</aside>
173+
) : null}
140174
</div>
141175
</DocShell>
142176
);

packages/i18n/src/locales/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1471,6 +1471,7 @@ const en = {
14711471
settings: 'Workspace settings',
14721472
},
14731473
help: {
1474+
onThisPage: 'On this page',
14741475
appDocs: "This app's docs",
14751476
allDocs: 'All documentation',
14761477
onlineDocs: 'Online documentation',

packages/i18n/src/locales/zh.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,6 +1484,7 @@ const zh = {
14841484
settings: '工作区设置',
14851485
},
14861486
help: {
1487+
onThisPage: '本页目录',
14871488
appDocs: '本应用文档',
14881489
allDocs: '全部文档',
14891490
onlineDocs: '在线文档',

packages/plugin-markdown/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
"@object-ui/core": "workspace:*",
3636
"@object-ui/react": "workspace:*",
3737
"@object-ui/types": "workspace:*",
38+
"github-slugger": "^2.0.0",
39+
"mermaid": "^11.4.0",
3840
"react-markdown": "^10.1.0",
3941
"rehype-autolink-headings": "^7.1.0",
4042
"rehype-highlight": "^7.0.0",

packages/plugin-markdown/src/MarkdownImpl.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77
*/
88

99
import * as React from "react"
10-
import ReactMarkdown from "react-markdown"
10+
import ReactMarkdown, { type Components } from "react-markdown"
1111
import remarkGfm from "remark-gfm"
1212
import { remarkAlert } from "remark-github-blockquote-alert"
1313
import rehypeSanitize, { defaultSchema } from "rehype-sanitize"
1414
import rehypeSlug from "rehype-slug"
1515
import rehypeHighlight from "rehype-highlight"
1616
import rehypeAutolinkHeadings from "rehype-autolink-headings"
1717
import { ensureMarkdownStyles } from "./markdown-theme"
18+
import { Mermaid } from "./Mermaid"
1819

1920
/**
2021
* Props for the Markdown component implementation.
@@ -99,11 +100,39 @@ const rehypePlugins = [
99100
rehypeNormalizeClassName,
100101
// Skip code blocks tagged with an unknown language instead of throwing;
101102
// highlight only what it recognises.
102-
[rehypeHighlight, { detect: true, ignoreMissing: true }],
103+
[rehypeHighlight, { detect: true, ignoreMissing: true, plainText: ["mermaid"] }],
103104
[rehypeAutolinkHeadings, { behavior: "append", properties: { className: ["md-anchor"], ariaHidden: true, tabIndex: -1 }, content: { type: "text", value: "#" } }],
104105
[rehypeSanitize, sanitizeSchema],
105106
] as React.ComponentProps<typeof ReactMarkdown>["rehypePlugins"]
106107

108+
// Flatten a react-markdown code child down to its raw text. mermaid blocks are
109+
// emitted as a plain text node (rehype-highlight is told to skip "mermaid"), so
110+
// this yields the diagram source verbatim.
111+
function nodeText(node: React.ReactNode): string {
112+
if (typeof node === "string") return node
113+
if (typeof node === "number") return String(node)
114+
if (Array.isArray(node)) return node.map(nodeText).join("")
115+
if (React.isValidElement(node)) return nodeText((node.props as { children?: React.ReactNode }).children)
116+
return ""
117+
}
118+
119+
// Intercept fenced ```mermaid blocks at the <pre> level (so no <pre> wrapper is
120+
// emitted) and render them as diagrams; every other block renders normally.
121+
const mdComponents: Components = {
122+
pre({ node: _node, children, ...rest }) {
123+
const child = React.Children.toArray(children)[0]
124+
const className =
125+
React.isValidElement(child) && typeof (child.props as { className?: unknown }).className === "string"
126+
? ((child.props as { className?: string }).className as string)
127+
: ""
128+
if (/\blanguage-mermaid\b/.test(className)) {
129+
const source = nodeText((child as React.ReactElement<{ children?: React.ReactNode }>).props.children)
130+
return <Mermaid chart={source} />
131+
}
132+
return <pre {...rest}>{children}</pre>
133+
},
134+
}
135+
107136
/**
108137
* Internal Markdown implementation component.
109138
* This contains the actual react-markdown import (heavy ~100-200 KB).
@@ -139,6 +168,7 @@ export default function MarkdownImpl({ content, className }: MarkdownImplProps)
139168
<ReactMarkdown
140169
remarkPlugins={remarkPlugins}
141170
rehypePlugins={rehypePlugins}
171+
components={mdComponents}
142172
// Defense-in-depth beyond rehype-sanitize: these never reach the DOM.
143173
disallowedElements={['script', 'style', 'iframe', 'object', 'embed']}
144174
unwrapDisallowed={true}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import * as React from "react"
10+
11+
/**
12+
* Mermaid diagram renderer for ```mermaid fenced code blocks (ADR-0046).
13+
*
14+
* Mermaid is text → SVG, so it sidesteps the v1 image/binary ban while still
15+
* giving docs flow / state-machine / sequence diagrams. The library is heavy,
16+
* so it is dynamically imported the first time a diagram actually mounts —
17+
* docs with no diagrams pay nothing.
18+
*
19+
* Security: rendered with `securityLevel: 'strict'` (labels sanitized, no raw
20+
* HTML, no click handlers). The SVG is injected by this trusted component
21+
* AFTER react-markdown's `rehype-sanitize` gate (which only sees the source
22+
* text, never this output), so the two sanitizers do not fight. A render
23+
* failure degrades to the raw source in a <pre>, never a thrown error.
24+
*/
25+
26+
let mermaidPromise: Promise<typeof import("mermaid").default> | null = null
27+
function loadMermaid() {
28+
if (!mermaidPromise) mermaidPromise = import("mermaid").then((m) => m.default)
29+
return mermaidPromise
30+
}
31+
32+
// Module-scoped monotonic id — mermaid.render needs a unique DOM id per call.
33+
let renderSeq = 0
34+
35+
function isDark(): boolean {
36+
return typeof document !== "undefined" && document.documentElement.classList.contains("dark")
37+
}
38+
39+
export function Mermaid({ chart }: { chart: string }) {
40+
const [svg, setSvg] = React.useState<string>("")
41+
const [error, setError] = React.useState<string | null>(null)
42+
// Re-render when the app theme toggles so the diagram matches light/dark.
43+
const [dark, setDark] = React.useState<boolean>(isDark)
44+
45+
React.useEffect(() => {
46+
if (typeof document === "undefined") return
47+
const target = document.documentElement
48+
const obs = new MutationObserver(() => setDark(isDark()))
49+
obs.observe(target, { attributes: true, attributeFilter: ["class"] })
50+
return () => obs.disconnect()
51+
}, [])
52+
53+
React.useEffect(() => {
54+
let cancelled = false
55+
const source = chart.trim()
56+
if (!source) {
57+
setSvg("")
58+
setError(null)
59+
return
60+
}
61+
loadMermaid()
62+
.then(async (mermaid) => {
63+
mermaid.initialize({
64+
startOnLoad: false,
65+
securityLevel: "strict",
66+
theme: dark ? "dark" : "default",
67+
})
68+
const id = `os-mermaid-${(renderSeq += 1)}`
69+
const { svg } = await mermaid.render(id, source)
70+
if (!cancelled) {
71+
setSvg(svg)
72+
setError(null)
73+
}
74+
})
75+
.catch((e) => {
76+
if (!cancelled) setError(e instanceof Error ? e.message : String(e))
77+
})
78+
return () => {
79+
cancelled = true
80+
}
81+
}, [chart, dark])
82+
83+
if (error) {
84+
return (
85+
<pre data-mermaid data-mermaid-error className="my-4 overflow-auto rounded border border-destructive/40 bg-muted p-3 text-xs">
86+
<code>{chart}</code>
87+
</pre>
88+
)
89+
}
90+
91+
if (!svg) {
92+
// Pre-render placeholder: keep the source reachable to assistive tech
93+
// and to tests while the (async) diagram is still being drawn.
94+
return (
95+
<div data-mermaid data-mermaid-pending className="my-4 text-sm text-muted-foreground">
96+
<pre className="sr-only">{chart}</pre>
97+
</div>
98+
)
99+
}
100+
101+
return (
102+
<div
103+
data-mermaid
104+
role="img"
105+
className="my-4 flex justify-center overflow-auto [&_svg]:h-auto [&_svg]:max-w-full"
106+
// eslint-disable-next-line react/no-danger -- mermaid strict-mode SVG, rendered post-sanitize by our own trusted component
107+
dangerouslySetInnerHTML={{ __html: svg }}
108+
/>
109+
)
110+
}

packages/plugin-markdown/src/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { Skeleton } from '@object-ui/components';
1212

1313
// Export types for external use
1414
export type { MarkdownSchema } from './types';
15+
export { extractToc, type TocItem } from './toc';
16+
export { Mermaid } from './Mermaid';
1517

1618
// 🚀 Lazy load the implementation file
1719
// This ensures react-markdown is only loaded when the component is actually rendered

packages/plugin-markdown/src/markdown-render.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,20 @@ describe('markdown enrichments (ADR-0046)', () => {
7979
expect(html).not.toContain('<svg');
8080
});
8181
});
82+
83+
describe('mermaid diagrams (ADR-0046)', () => {
84+
it('routes ```mermaid blocks to a diagram container, not a code block', () => {
85+
const html = render('```mermaid\ngraph TD; A-->B;\n```\n');
86+
// rendered by the trusted <Mermaid> component, not the highlight pipeline
87+
expect(html).toContain('data-mermaid');
88+
expect(html).not.toMatch(/hljs[^"]*language-mermaid|language-mermaid[^"]*hljs/);
89+
// source preserved (a11y / fallback) while the async render runs
90+
expect(html).toContain('graph TD');
91+
});
92+
93+
it('still renders ordinary fenced code as a highlighted block', () => {
94+
const html = render('```js\nconst x = 1;\n```\n');
95+
expect(html).not.toContain('data-mermaid');
96+
expect(html).toContain('hljs');
97+
});
98+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { extractToc } from './toc';
11+
12+
describe('extractToc (ADR-0046 long-doc TOC)', () => {
13+
it('extracts h2/h3 with rehype-slug-compatible ids', () => {
14+
const toc = extractToc('# Title\n\n## First Section\n\n### Sub A\n\n## Second\n');
15+
expect(toc).toEqual([
16+
{ depth: 2, text: 'First Section', id: 'first-section' },
17+
{ depth: 3, text: 'Sub A', id: 'sub-a' },
18+
{ depth: 2, text: 'Second', id: 'second' },
19+
]);
20+
});
21+
22+
it('excludes h1 and h4+ by default', () => {
23+
const toc = extractToc('# H1\n\n## H2\n\n#### H4\n');
24+
expect(toc.map((t) => t.depth)).toEqual([2]);
25+
});
26+
27+
it('skips headings inside fenced code blocks', () => {
28+
const toc = extractToc('## Real\n\n```\n## Not A Heading\n```\n\n~~~\n### Also Not\n~~~\n');
29+
expect(toc.map((t) => t.text)).toEqual(['Real']);
30+
});
31+
32+
it('strips inline markdown from heading text', () => {
33+
const toc = extractToc('## The **overlay** `rule` and a [link](/x)\n');
34+
expect(toc[0]).toEqual({ depth: 2, text: 'The overlay rule and a link', id: 'the-overlay-rule-and-a-link' });
35+
});
36+
37+
it('matches duplicate-heading suffixes like rehype-slug (shared slugger over all headings)', () => {
38+
// the h1 also advances the slugger, exactly as rehype-slug does
39+
const toc = extractToc('# Setup\n\n## Setup\n\n## Setup\n');
40+
expect(toc.map((t) => t.id)).toEqual(['setup-1', 'setup-2']);
41+
});
42+
43+
it('returns [] for empty / heading-free content', () => {
44+
expect(extractToc('')).toEqual([]);
45+
expect(extractToc('just a paragraph\n')).toEqual([]);
46+
});
47+
});

0 commit comments

Comments
 (0)