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..e5e06e46 --- /dev/null +++ b/packages/chronicle/src/lib/rehype-toc-text.test.ts @@ -0,0 +1,187 @@ +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 } +} + +/** 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')])]) + 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('replaces a toc already exported 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 { 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' } }, + ], + }, + ]) + 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']) + }) + + 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', () => { + 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..594ebf52 --- /dev/null +++ b/packages/chronicle/src/lib/rehype-toc-text.ts @@ -0,0 +1,193 @@ +import type { Element, Root, RootContent } from 'hast' +import type { Plugin } from 'unified' +import { SKIP, 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 : '' +} + +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. */ +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) + // 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). + 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)) + } +} + +export default rehypeTocText diff --git a/packages/chronicle/src/server/vite-config.ts b/packages/chronicle/src/server/vite-config.ts index 06196c31..472da04b 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) { @@ -167,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 { @@ -198,6 +211,14 @@ 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 => !isRehypeToc(plugin)), + rehypeTocText, + ], remarkPlugins: [ remarkDirective, [remarkDirectiveAdmonition, {