From f58a5ea4e8e84872bc92178e2f08132bfc2efc84 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 19 Aug 2026 11:41:40 +0530 Subject: [PATCH 1/3] feat: add Badge and Avatar MDX components Register Apsara's Badge, Avatar, and AvatarGroup as MDX components so content can label status inline and show user images. Using any component inside a heading previously failed to compile: fumadocs' rehypeToc exports each heading as JSX evaluated at module scope, where MDX components are not in scope, so `### Limits Beta` threw `Badge is not defined`. Replace it with a toc that exports plain-text titles, which is what both themes already reduce them to. The `[toc]` and `[!toc]` heading tags are preserved. Co-Authored-By: Claude Opus 5 (1M context) --- docs/content/docs/components.mdx | 77 +++++++++++ examples/basic/content/docs/components.mdx | 45 +++++++ .../chronicle/src/components/mdx/index.tsx | 5 +- .../chronicle/src/lib/mdx-component-names.ts | 3 + .../chronicle/src/lib/rehype-toc-text.test.ts | 100 ++++++++++++++ packages/chronicle/src/lib/rehype-toc-text.ts | 124 ++++++++++++++++++ packages/chronicle/src/server/vite-config.ts | 14 +- 7 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 examples/basic/content/docs/components.mdx create mode 100644 packages/chronicle/src/lib/rehype-toc-text.test.ts create mode 100644 packages/chronicle/src/lib/rehype-toc-text.ts diff --git a/docs/content/docs/components.mdx b/docs/content/docs/components.mdx index 4f9937db..84f3633a 100644 --- a/docs/content/docs/components.mdx +++ b/docs/content/docs/components.mdx @@ -98,6 +98,83 @@ Tabbed content panels using Apsara's `Tabs` component. ```` +## Badge + +Small inline labels for status, counts, or categories. Renders as a ``, so it works inline in prose, headings, table cells, and list items. + +````mdx +Default +Stable +Beta +Deprecated +Internal +New + +### Rate limits Beta + +The `POST /users` endpoint Deprecated is removed in v3. +```` + +Badges work inside headings too. The table of contents lists such a heading by +its plain text, so `### Rate limits Beta` shows as "Rate limits Beta". + +**Badge Props** + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `variant` | `'accent' \| 'warning' \| 'danger' \| 'success' \| 'neutral' \| 'gradient'` | `'accent'` | Color variant | +| `size` | `'micro' \| 'small' \| 'regular'` | `'small'` | Badge size | +| `icon` | `ReactNode` | — | Icon or emoji rendered before the label | +| `screenReaderText` | `string` | — | Extra context announced by screen readers | +| `className` | `string` | — | Additional CSS class | + +Emoji work as icons without an import: + +````mdx +Hot path +```` + +## Avatar + +User or entity images with a text fallback. + +````mdx + + + +```` + +**Avatar Props** + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `src` | `string` | — | Image URL | +| `alt` | `string` | — | Alternative text for the image | +| `fallback` | `ReactNode` | — | Shown while loading or when `src` is missing | +| `size` | `1`–`13` | `3` | Avatar size step | +| `variant` | `'solid' \| 'soft'` | `'soft'` | Fallback fill style | +| `color` | `'indigo' \| 'neutral' \| 'cyan' \| 'crimson' \| 'gold' \| 'lime' \| 'orange' \| 'pink' \| 'purple' \| 'mint' \| 'sky' \| 'grass' \| 'iris'` | `'indigo'` | Fallback color | +| `radius` | `'small' \| 'full'` | `'small'` | Corner radius | +| `className` | `string` | — | Additional CSS class | + +Group avatars with `AvatarGroup`, which overlaps children and collapses the overflow into a `+N` counter: + +````mdx + + + + + + +```` + +**AvatarGroup Props** + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `max` | `number` | — | Maximum avatars shown before collapsing into `+N` | +| `className` | `string` | — | Additional CSS class | + ## Mermaid Diagrams Render diagrams using Mermaid syntax in fenced code blocks: diff --git a/examples/basic/content/docs/components.mdx b/examples/basic/content/docs/components.mdx new file mode 100644 index 00000000..cfa63945 --- /dev/null +++ b/examples/basic/content/docs/components.mdx @@ -0,0 +1,45 @@ +--- +title: Components +description: Live demo of Chronicle's built-in MDX components +order: 3 +--- + +# Components + +Live rendering of the MDX components Chronicle registers for content files. + +## Badge + +Inline labels for status, counts, and categories. + +Accent Success Warning Danger Neutral Gradient + +### Sizes + +Micro Small Regular + +### With icons + +Hot path New + +### Rate limits Beta + +Badges sit inline in prose too: the `POST /users` endpoint Deprecated is removed in v3. + +| Endpoint | Status | +|----------|--------| +| `GET /users` | Stable | +| `POST /users` | Deprecated | + +## Avatar + + + +### Group + + + + + + + diff --git a/packages/chronicle/src/components/mdx/index.tsx b/packages/chronicle/src/components/mdx/index.tsx index 731fc5b3..5d86e027 100644 --- a/packages/chronicle/src/components/mdx/index.tsx +++ b/packages/chronicle/src/components/mdx/index.tsx @@ -6,7 +6,7 @@ import { MdxPre, MdxCode } from './code' import { MdxDetails, MdxSummary } from './details' import { MdxParagraph } from './paragraph' import { CalloutContainer, CalloutTitle, CalloutDescription, MdxBlockquote } from '@/components/common/callout' -import { Tabs } from '@raystack/apsara' +import { Avatar, AvatarGroup, Badge, Tabs } from '@raystack/apsara' import { type ComponentProps, lazy, useEffect, useState, Suspense } from 'react' const LazyMermaid = lazy(() => import('./mermaid').then(m => ({ default: m.Mermaid }))) @@ -45,6 +45,9 @@ export const mdxComponents: MDXComponents = { CalloutTitle, CalloutDescription, Tabs: MdxTabs, + Badge, + Avatar, + AvatarGroup, Mermaid: (props: { chart: string }) => ( {props.chart}}> diff --git a/packages/chronicle/src/lib/mdx-component-names.ts b/packages/chronicle/src/lib/mdx-component-names.ts index 50ca4575..2bed06cb 100644 --- a/packages/chronicle/src/lib/mdx-component-names.ts +++ b/packages/chronicle/src/lib/mdx-component-names.ts @@ -10,6 +10,9 @@ export const MDX_COMPONENT_NAMES = [ 'CalloutDescription', 'Tabs', 'Mermaid', + 'Badge', + 'Avatar', + 'AvatarGroup', ] as const export const KNOWN_TAGS = new Set([...htmlTagNames, ...svgTagNames]) diff --git a/packages/chronicle/src/lib/rehype-toc-text.test.ts b/packages/chronicle/src/lib/rehype-toc-text.test.ts new file mode 100644 index 00000000..21af765d --- /dev/null +++ b/packages/chronicle/src/lib/rehype-toc-text.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'bun:test'; +import type { Root, RootContent } from 'hast'; +import rehypeTocText from './rehype-toc-text'; + +type EstreeNode = { + type: string + name?: string + value?: EstreeNode | string | number + key?: EstreeNode + properties?: EstreeNode[] + elements?: EstreeNode[] + declarations?: Array<{ init?: EstreeNode }> + declaration?: EstreeNode + body?: EstreeNode[] +} + +interface TocItem { + depth: number + url: string + title: string +} + +function heading(tagName: string, id: string, children: RootContent[]): RootContent { + return { type: 'element', tagName, properties: { id }, children } as RootContent +} + +const text = (value: string): RootContent => ({ type: 'text', value }) + +/** An MDX component in a heading, as rehype sees it after the MDX parser. */ +const jsx = (name: string, children: RootContent[]): RootContent => + ({ type: 'mdxJsxTextElement', name, attributes: [], children }) as unknown as RootContent + +/** Runs the plugin and reads the exported `toc` back out of the estree it appends. */ +function runPlugin(children: RootContent[]): { toc: TocItem[]; tree: Root } { + const tree: Root = { type: 'root', children } + const transform = rehypeTocText.call({ use: () => undefined } as never) as (t: Root) => void + transform(tree) + + const exported = tree.children[tree.children.length - 1] as { data?: { estree?: EstreeNode } } + const program = exported.data?.estree + const array = program?.body?.[0]?.declaration?.declarations?.[0]?.init + const toc = (array?.elements ?? []).map(element => { + const item: Record = {} + for (const property of element.properties ?? []) { + const literal = property.value as EstreeNode | undefined + item[property.key?.name as string] = literal?.value + } + return item as unknown as TocItem + }) + return { toc, tree } +} + +describe('rehypeTocText', () => { + test('exports headings as plain-text titles', () => { + const { toc } = runPlugin([heading('h2', 'hello-world', [text('Hello world')])]) + expect(toc).toEqual([{ depth: 2, url: '#hello-world', title: 'Hello world' }]) + }) + + test('flattens components in a heading to their text', () => { + const { toc } = runPlugin([ + heading('h3', 'rate-limits', [text('Rate limits '), jsx('Badge', [text('Beta')])]), + ]) + expect(toc).toEqual([{ depth: 3, url: '#rate-limits', title: 'Rate limits Beta' }]) + }) + + test('flattens inline markup in a heading', () => { + const { toc } = runPlugin([ + heading('h2', 'the-id-field', [ + text('The '), + { type: 'element', tagName: 'code', properties: {}, children: [text('id')] } as RootContent, + text(' field'), + ]), + ]) + expect(toc).toEqual([{ depth: 2, url: '#the-id-field', title: 'The id field' }]) + }) + + test('omits headings tagged [!toc] and strips the tag from the page', () => { + const { toc, tree } = runPlugin([heading('h2', 'hidden', [text('Hidden [!toc]')])]) + expect(toc).toEqual([]) + const rendered = tree.children[0] as { children: Array<{ value: string }> } + expect(rendered.children[0].value).toBe('Hidden') + }) + + test('keeps [toc]-only headings in the toc but drops them from the page', () => { + const { toc, tree } = runPlugin([heading('h2', 'toc-only', [text('Toc only [toc]')])]) + expect(toc).toEqual([{ depth: 2, url: '#toc-only', title: 'Toc only' }]) + expect(tree.children).toHaveLength(1) // only the toc export is left + }) + + test('skips headings without an id', () => { + const { toc } = runPlugin([ + { type: 'element', tagName: 'h2', properties: {}, children: [text('No id')] } as RootContent, + ]) + expect(toc).toEqual([]) + }) + + test('exports an empty toc when there are no headings', () => { + expect(runPlugin([{ type: 'element', tagName: 'p', properties: {}, children: [text('Body')] } as RootContent]).toc).toEqual([]) + }) +}) diff --git a/packages/chronicle/src/lib/rehype-toc-text.ts b/packages/chronicle/src/lib/rehype-toc-text.ts new file mode 100644 index 00000000..80e78d23 --- /dev/null +++ b/packages/chronicle/src/lib/rehype-toc-text.ts @@ -0,0 +1,124 @@ +import type { Element, Root, RootContent } from 'hast' +import type { Plugin } from 'unified' +import { visit } from 'unist-util-visit' + +const TOC_ONLY_TAG = '[toc]' +const NO_TOC_TAG = '[!toc]' +const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) + +interface TocItem { + title: string + url: string + depth: number +} + +/** Strips `tag` from `value`, or returns false when it isn't present. */ +function handleTag(value: string, tag: string): string | false { + const idx = value.indexOf(tag) + if (idx === -1) return false + return value.slice(0, idx).trimEnd() + value.slice(idx + tag.length) +} + +/** Concatenates every text descendant, including those inside MDX JSX elements. */ +function toText(node: unknown): string { + if (!node || typeof node !== 'object') return '' + const n = node as { children?: unknown[]; value?: unknown } + if (Array.isArray(n.children)) return n.children.map(toText).join('') + return typeof n.value === 'string' ? n.value : '' +} + +/** `export const toc = ` as an MDX ESM node. */ +function tocExportNode(items: TocItem[]): RootContent { + return { + type: 'mdxjsEsm', + value: '', + data: { + estree: { + type: 'Program', + sourceType: 'module', + body: [ + { + type: 'ExportNamedDeclaration', + attributes: [], + specifiers: [], + declaration: { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { type: 'Identifier', name: 'toc' }, + init: { + type: 'ArrayExpression', + elements: items.map(item => ({ + type: 'ObjectExpression', + properties: (['depth', 'url', 'title'] as const).map(key => ({ + type: 'Property', + method: false, + shorthand: false, + computed: false, + kind: 'init', + key: { type: 'Identifier', name: key }, + value: { type: 'Literal', value: item[key] }, + })), + })), + }, + }, + ], + }, + }, + ], + }, + }, + } as unknown as RootContent +} + +/** + * Replaces fumadocs' `rehypeToc`, which exports each heading as JSX evaluated at + * module scope — a component in a heading (`### Limits Beta`) then + * throws `Badge is not defined` at build time, because MDX components are only in + * scope while rendering. Titles are exported as plain strings instead; both themes + * already flatten them to text (`themes/default/Toc.tsx`, `themes/paper/ReadingProgress.tsx`). + * + * Keeps fumadocs' heading tags: `[!toc]` omits a heading from the toc, `[toc]` + * lists it in the toc only and drops it from the page. + */ +const rehypeTocText: Plugin<[], Root> = () => { + return tree => { + const items: TocItem[] = [] + + visit(tree, 'element', (element: Element, idx, parent) => { + if (!HEADING_TAGS.has(element.tagName) || element.children.length === 0) return + const id = element.properties.id + if (typeof id !== 'string') return 'skip' + + let isTocOnly = false + const last = element.children[element.children.length - 1] + if (last?.type === 'text') { + const noToc = handleTag(last.value, NO_TOC_TAG) + if (noToc !== false) { + last.value = noToc + return 'skip' + } + const tocOnly = handleTag(last.value, TOC_ONLY_TAG) + if (tocOnly !== false) { + isTocOnly = true + last.value = tocOnly + } + } + + items.push({ + title: toText(element).trim(), + url: `#${id}`, + depth: Number(element.tagName[1]), + }) + + if (isTocOnly && parent && typeof idx === 'number') parent.children.splice(idx, 1) + return 'skip' + }) + + tree.children.push(tocExportNode(items)) + } +} + +export default rehypeTocText diff --git a/packages/chronicle/src/server/vite-config.ts b/packages/chronicle/src/server/vite-config.ts index 06196c31..b98a698f 100644 --- a/packages/chronicle/src/server/vite-config.ts +++ b/packages/chronicle/src/server/vite-config.ts @@ -1,5 +1,5 @@ import react from '@vitejs/plugin-react'; -import { rehypeCodeDefaultOptions, remarkDirectiveAdmonition, remarkMdxMermaid } from 'fumadocs-core/mdx-plugins'; +import { rehypeCodeDefaultOptions, rehypeToc, remarkDirectiveAdmonition, remarkMdxMermaid } from 'fumadocs-core/mdx-plugins'; import { defineConfig as defineFumadocsConfig } from 'fumadocs-mdx/config'; import mdx from 'fumadocs-mdx/vite'; import { nitro } from 'nitro/vite'; @@ -12,7 +12,9 @@ import remarkResolveImages from '../lib/remark-resolve-images'; import remarkResolveLinks from '../lib/remark-resolve-links'; import remarkReadingTime from 'remark-reading-time'; import remarkUnusedDirectives from '../lib/remark-unused-directives'; +import type { Pluggable } from 'unified'; import remarkValidateMdx from '../lib/remark-validate-mdx'; +import rehypeTocText from '../lib/rehype-toc-text'; function getDatabaseConnector(preset?: string): { connector: string; options?: Record } { switch (preset) { @@ -198,6 +200,16 @@ export async function createViteConfig( ...rehypeCodeDefaultOptions, fallbackLanguage: 'text', }, + // Swap fumadocs' rehypeToc for a text-only toc: it exports heading + // content as JSX evaluated at module scope, so any component in a + // heading fails to compile. Function form is required — an array + // would be inserted before rehypeToc rather than replacing it. + rehypePlugins: (plugins: Pluggable[]) => [ + ...plugins.filter( + plugin => plugin !== rehypeToc && !(Array.isArray(plugin) && plugin[0] === rehypeToc) + ), + rehypeTocText, + ], remarkPlugins: [ remarkDirective, [remarkDirectiveAdmonition, { From 1588b8a37c7e63ba80e27cc9126a9ea9b589af57 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 19 Aug 2026 12:45:16 +0530 Subject: [PATCH 2/3] fix: match fumadocs rehypeToc by name when swapping it out The CLI bundle and fumadocs-mdx can resolve fumadocs-core/mdx-plugins to separate module instances, so the imported rehypeToc is not the same object as the one in the plugin list and the identity filter missed it. Both toc plugins then ran and the build failed with "Duplicated export 'toc'". Match on function name as well, and drop any toc already in the tree before exporting ours so a duplicate can never reach the parser. Co-Authored-By: Claude Opus 5 (1M context) --- .../chronicle/src/lib/rehype-toc-text.test.ts | 53 +++++++++++++++++++ packages/chronicle/src/lib/rehype-toc-text.ts | 21 ++++++++ packages/chronicle/src/server/vite-config.ts | 15 ++++-- 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/packages/chronicle/src/lib/rehype-toc-text.test.ts b/packages/chronicle/src/lib/rehype-toc-text.test.ts index 21af765d..69a87d03 100644 --- a/packages/chronicle/src/lib/rehype-toc-text.test.ts +++ b/packages/chronicle/src/lib/rehype-toc-text.test.ts @@ -94,6 +94,59 @@ describe('rehypeTocText', () => { expect(toc).toEqual([]) }) + test('replaces a toc already exported upstream', () => { + const upstream = { + type: 'mdxjsEsm', + value: '', + data: { + estree: { + type: 'Program', + sourceType: 'module', + body: [ + { + type: 'ExportNamedDeclaration', + declaration: { + type: 'VariableDeclaration', + kind: 'let', + declarations: [{ type: 'VariableDeclarator', id: { type: 'Identifier', name: 'toc' } }], + }, + }, + ], + }, + }, + } as unknown as RootContent + + const { toc, tree } = runPlugin([heading('h2', 'hello', [text('Hello')]), upstream]) + expect(toc).toEqual([{ depth: 2, url: '#hello', title: 'Hello' }]) + expect(tree.children.filter(child => (child as { type: string }).type === 'mdxjsEsm')).toHaveLength(1) + }) + + test('leaves other ESM exports alone', () => { + const other = { + type: 'mdxjsEsm', + value: '', + data: { + estree: { + type: 'Program', + sourceType: 'module', + body: [ + { + type: 'ExportNamedDeclaration', + declaration: { + type: 'VariableDeclaration', + kind: 'const', + declarations: [{ type: 'VariableDeclarator', id: { type: 'Identifier', name: 'readingTime' } }], + }, + }, + ], + }, + }, + } as unknown as RootContent + + const { tree } = runPlugin([heading('h2', 'hello', [text('Hello')]), other]) + expect(tree.children.filter(child => (child as { type: string }).type === 'mdxjsEsm')).toHaveLength(2) + }) + test('exports an empty toc when there are no headings', () => { expect(runPlugin([{ type: 'element', tagName: 'p', properties: {}, children: [text('Body')] } as RootContent]).toc).toEqual([]) }) diff --git a/packages/chronicle/src/lib/rehype-toc-text.ts b/packages/chronicle/src/lib/rehype-toc-text.ts index 80e78d23..2f4c820d 100644 --- a/packages/chronicle/src/lib/rehype-toc-text.ts +++ b/packages/chronicle/src/lib/rehype-toc-text.ts @@ -27,6 +27,20 @@ function toText(node: unknown): string { return typeof n.value === 'string' ? n.value : '' } +/** True for an ESM node that already exports a binding named `toc`. */ +function isTocExport(node: RootContent): boolean { + // `mdxjsEsm` isn't part of hast's own content union — MDX adds it. + const esm = node as unknown as { type: string; data?: { estree?: { body?: unknown[] } } } + if (esm.type !== 'mdxjsEsm') return false + const body = esm.data?.estree?.body ?? [] + return body.some(statement => { + const declarations = + (statement as { declaration?: { declarations?: Array<{ id?: { name?: string } }> } }).declaration + ?.declarations ?? [] + return declarations.some(declaration => declaration.id?.name === 'toc') + }) +} + /** `export const toc = ` as an MDX ESM node. */ function tocExportNode(items: TocItem[]): RootContent { return { @@ -117,6 +131,13 @@ const rehypeTocText: Plugin<[], Root> = () => { return 'skip' }) + // Drop any toc already exported upstream — two `export const toc` bindings + // fail the MDX parser, and fumadocs' rehypeToc slips back into the pipeline + // whenever its module resolves to a second instance (see isRehypeToc in + // server/vite-config.ts). + const existing = tree.children.findIndex(isTocExport) + if (existing !== -1) tree.children.splice(existing, 1) + tree.children.push(tocExportNode(items)) } } diff --git a/packages/chronicle/src/server/vite-config.ts b/packages/chronicle/src/server/vite-config.ts index b98a698f..472da04b 100644 --- a/packages/chronicle/src/server/vite-config.ts +++ b/packages/chronicle/src/server/vite-config.ts @@ -169,6 +169,17 @@ async function readChronicleConfig(projectRoot: string, configPath?: string): Pr } } +/** + * fumadocs' `rehypeToc`, in either bare or `[plugin, options]` form. Matched by + * name as well as identity: the CLI and fumadocs-mdx can resolve + * `fumadocs-core/mdx-plugins` to separate module instances, in which case the + * imported function is not the same object as the one in the plugin list. + */ +function isRehypeToc(plugin: Pluggable): boolean { + const fn = Array.isArray(plugin) ? plugin[0] : plugin; + return fn === rehypeToc || (typeof fn === 'function' && fn.name === 'rehypeToc'); +} + export async function createViteConfig( options: ViteConfigOptions ): Promise { @@ -205,9 +216,7 @@ export async function createViteConfig( // heading fails to compile. Function form is required — an array // would be inserted before rehypeToc rather than replacing it. rehypePlugins: (plugins: Pluggable[]) => [ - ...plugins.filter( - plugin => plugin !== rehypeToc && !(Array.isArray(plugin) && plugin[0] === rehypeToc) - ), + ...plugins.filter(plugin => !isRehypeToc(plugin)), rehypeTocText, ], remarkPlugins: [ From d6aa2e35d2c22761d2262b79851c8fdc8f9bc1fd Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 19 Aug 2026 13:51:19 +0530 Subject: [PATCH 3/3] fix: keep the sibling after a [toc] heading and unrelated toc-node exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splicing a `[toc]`-only heading out of its parent shifted the next sibling into the visited index, so `SKIP` stepped over it and that heading never reached the toc. Return the adjusted index instead. Removing an upstream toc dropped the whole ESM node, taking any binding declared alongside it. Strip just the `toc` binding — including an aliased `export { upstreamToc as toc }` — and remove the node only once it exports nothing. Co-Authored-By: Claude Opus 5 (1M context) --- .../chronicle/src/lib/rehype-toc-text.test.ts | 120 +++++++++++------- packages/chronicle/src/lib/rehype-toc-text.ts | 86 ++++++++++--- 2 files changed, 144 insertions(+), 62 deletions(-) diff --git a/packages/chronicle/src/lib/rehype-toc-text.test.ts b/packages/chronicle/src/lib/rehype-toc-text.test.ts index 69a87d03..e5e06e46 100644 --- a/packages/chronicle/src/lib/rehype-toc-text.test.ts +++ b/packages/chronicle/src/lib/rehype-toc-text.test.ts @@ -50,6 +50,42 @@ function runPlugin(children: RootContent[]): { toc: TocItem[]; tree: Root } { return { toc, tree } } +/** An ESM export node shaped like the ones MDX plugins append. */ +function esmExport(statements: unknown[]): RootContent { + return { + type: 'mdxjsEsm', + value: '', + data: { estree: { type: 'Program', sourceType: 'module', body: statements } }, + } as unknown as RootContent +} + +function namedExport(...names: string[]): unknown { + return { + type: 'ExportNamedDeclaration', + specifiers: [], + declaration: { + type: 'VariableDeclaration', + kind: 'const', + declarations: names.map(name => ({ type: 'VariableDeclarator', id: { type: 'Identifier', name } })), + }, + } +} + +/** Reads the binding names an ESM node still exports. */ +function exportedNames(node: RootContent): string[] { + const body = (node as unknown as { data?: { estree?: { body?: unknown[] } } }).data?.estree?.body ?? [] + return body.flatMap(statement => { + const s = statement as { + specifiers?: Array<{ exported?: { name?: string } }> + declaration?: { declarations?: Array<{ id?: { name?: string } }> } | null + } + return [ + ...(s.declaration?.declarations ?? []).map(d => d.id?.name ?? ''), + ...(s.specifiers ?? []).map(spec => spec.exported?.name ?? ''), + ] + }) +} + describe('rehypeTocText', () => { test('exports headings as plain-text titles', () => { const { toc } = runPlugin([heading('h2', 'hello-world', [text('Hello world')])]) @@ -95,56 +131,54 @@ describe('rehypeTocText', () => { }) test('replaces a toc already exported upstream', () => { - const upstream = { - type: 'mdxjsEsm', - value: '', - data: { - estree: { - type: 'Program', - sourceType: 'module', - body: [ - { - type: 'ExportNamedDeclaration', - declaration: { - type: 'VariableDeclaration', - kind: 'let', - declarations: [{ type: 'VariableDeclarator', id: { type: 'Identifier', name: 'toc' } }], - }, - }, - ], - }, - }, - } as unknown as RootContent - - const { toc, tree } = runPlugin([heading('h2', 'hello', [text('Hello')]), upstream]) + const { toc, tree } = runPlugin([heading('h2', 'hello', [text('Hello')]), esmExport([namedExport('toc')])]) expect(toc).toEqual([{ depth: 2, url: '#hello', title: 'Hello' }]) expect(tree.children.filter(child => (child as { type: string }).type === 'mdxjsEsm')).toHaveLength(1) }) test('leaves other ESM exports alone', () => { - const other = { - type: 'mdxjsEsm', - value: '', - data: { - estree: { - type: 'Program', - sourceType: 'module', - body: [ - { - type: 'ExportNamedDeclaration', - declaration: { - type: 'VariableDeclaration', - kind: 'const', - declarations: [{ type: 'VariableDeclarator', id: { type: 'Identifier', name: 'readingTime' } }], - }, - }, - ], - }, + const { tree } = runPlugin([heading('h2', 'hello', [text('Hello')]), esmExport([namedExport('readingTime')])]) + expect(tree.children.filter(child => (child as { type: string }).type === 'mdxjsEsm')).toHaveLength(2) + }) + + test('keeps bindings declared alongside an upstream toc', () => { + const { tree } = runPlugin([ + heading('h2', 'hello', [text('Hello')]), + esmExport([namedExport('toc', 'structuredData')]), + ]) + const esm = tree.children.filter(child => (child as { type: string }).type === 'mdxjsEsm') + expect(esm).toHaveLength(2) + expect(exportedNames(esm[0])).toEqual(['structuredData']) + expect(exportedNames(esm[1])).toEqual(['toc']) + }) + + test('removes a toc exported under an alias', () => { + const aliased = esmExport([ + { + type: 'ExportNamedDeclaration', + declaration: null, + specifiers: [ + { type: 'ExportSpecifier', local: { name: 'upstreamToc' }, exported: { name: 'toc' } }, + { type: 'ExportSpecifier', local: { name: 'images' }, exported: { name: 'images' } }, + ], }, - } as unknown as RootContent + ]) + const { tree } = runPlugin([heading('h2', 'hello', [text('Hello')]), aliased]) + const esm = tree.children.filter(child => (child as { type: string }).type === 'mdxjsEsm') + expect(esm).toHaveLength(2) + expect(exportedNames(esm[0])).toEqual(['images']) + }) - const { tree } = runPlugin([heading('h2', 'hello', [text('Hello')]), other]) - expect(tree.children.filter(child => (child as { type: string }).type === 'mdxjsEsm')).toHaveLength(2) + test('still collects the heading after a [toc]-only heading', () => { + const { toc, tree } = runPlugin([ + heading('h2', 'toc-only', [text('Toc only [toc]')]), + heading('h2', 'next', [text('Next')]), + ]) + expect(toc).toEqual([ + { depth: 2, url: '#toc-only', title: 'Toc only' }, + { depth: 2, url: '#next', title: 'Next' }, + ]) + expect(tree.children.filter(child => (child as { type: string }).type === 'element')).toHaveLength(1) }) test('exports an empty toc when there are no headings', () => { diff --git a/packages/chronicle/src/lib/rehype-toc-text.ts b/packages/chronicle/src/lib/rehype-toc-text.ts index 2f4c820d..594ebf52 100644 --- a/packages/chronicle/src/lib/rehype-toc-text.ts +++ b/packages/chronicle/src/lib/rehype-toc-text.ts @@ -1,6 +1,6 @@ import type { Element, Root, RootContent } from 'hast' import type { Plugin } from 'unified' -import { visit } from 'unist-util-visit' +import { SKIP, visit } from 'unist-util-visit' const TOC_ONLY_TAG = '[toc]' const NO_TOC_TAG = '[!toc]' @@ -27,18 +27,53 @@ function toText(node: unknown): string { return typeof n.value === 'string' ? n.value : '' } -/** True for an ESM node that already exports a binding named `toc`. */ -function isTocExport(node: RootContent): boolean { - // `mdxjsEsm` isn't part of hast's own content union — MDX adds it. - const esm = node as unknown as { type: string; data?: { estree?: { body?: unknown[] } } } - if (esm.type !== 'mdxjsEsm') return false - const body = esm.data?.estree?.body ?? [] - return body.some(statement => { - const declarations = - (statement as { declaration?: { declarations?: Array<{ id?: { name?: string } }> } }).declaration - ?.declarations ?? [] - return declarations.some(declaration => declaration.id?.name === 'toc') - }) +interface EsmStatement { + type: string + specifiers?: Array<{ exported?: { name?: string } }> + declaration?: { declarations?: Array<{ id?: { name?: string } }> } | null +} + +/** An MDX ESM node — `mdxjsEsm` isn't part of hast's own content union. */ +function asEsmNode(node: RootContent): { data?: { estree?: { body?: EsmStatement[] } } } | undefined { + const esm = node as unknown as { type: string; data?: { estree?: { body?: EsmStatement[] } } } + return esm.type === 'mdxjsEsm' ? esm : undefined +} + +/** + * Removes a `toc` export from one ESM node, leaving any binding it was declared + * alongside (`export const toc = [], other = 1`) in place. Returns whether the + * node still exports anything. + */ +function stripTocExport(statements: EsmStatement[]): { removed: boolean; remaining: EsmStatement[] } { + let removed = false + const remaining: EsmStatement[] = [] + + for (const statement of statements) { + if (statement.type !== 'ExportNamedDeclaration') { + remaining.push(statement) + continue + } + + const declarations = statement.declaration?.declarations + const specifiers = statement.specifiers + const keptDeclarations = declarations?.filter(declaration => declaration.id?.name !== 'toc') + // `export { upstreamToc as toc }` exports the name it is aliased to. + const keptSpecifiers = specifiers?.filter(specifier => specifier.exported?.name !== 'toc') + + if (keptDeclarations?.length === declarations?.length && keptSpecifiers?.length === specifiers?.length) { + remaining.push(statement) + continue + } + + removed = true + if (keptDeclarations?.length) { + remaining.push({ ...statement, declaration: { ...statement.declaration, declarations: keptDeclarations } }) + } else if (keptSpecifiers?.length) { + remaining.push({ ...statement, declaration: null, specifiers: keptSpecifiers }) + } + } + + return { removed, remaining } } /** `export const toc = ` as an MDX ESM node. */ @@ -104,7 +139,7 @@ const rehypeTocText: Plugin<[], Root> = () => { visit(tree, 'element', (element: Element, idx, parent) => { if (!HEADING_TAGS.has(element.tagName) || element.children.length === 0) return const id = element.properties.id - if (typeof id !== 'string') return 'skip' + if (typeof id !== 'string') return SKIP let isTocOnly = false const last = element.children[element.children.length - 1] @@ -112,7 +147,7 @@ const rehypeTocText: Plugin<[], Root> = () => { const noToc = handleTag(last.value, NO_TOC_TAG) if (noToc !== false) { last.value = noToc - return 'skip' + return SKIP } const tocOnly = handleTag(last.value, TOC_ONLY_TAG) if (tocOnly !== false) { @@ -127,16 +162,29 @@ const rehypeTocText: Plugin<[], Root> = () => { depth: Number(element.tagName[1]), }) - if (isTocOnly && parent && typeof idx === 'number') parent.children.splice(idx, 1) - return 'skip' + if (isTocOnly && parent && typeof idx === 'number') { + parent.children.splice(idx, 1) + // Revisit this index: the next sibling has shifted into it, and moving on + // to idx + 1 would step over it. + return [SKIP, idx] + } + return SKIP }) // Drop any toc already exported upstream — two `export const toc` bindings // fail the MDX parser, and fumadocs' rehypeToc slips back into the pipeline // whenever its module resolves to a second instance (see isRehypeToc in // server/vite-config.ts). - const existing = tree.children.findIndex(isTocExport) - if (existing !== -1) tree.children.splice(existing, 1) + for (let i = tree.children.length - 1; i >= 0; i--) { + const esm = asEsmNode(tree.children[i]) + const estree = esm?.data?.estree + if (!estree?.body) continue + + const { removed, remaining } = stripTocExport(estree.body) + if (!removed) continue + if (remaining.length === 0) tree.children.splice(i, 1) + else estree.body = remaining + } tree.children.push(tocExportNode(items)) }