Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions docs/content/docs/components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,83 @@ Tabbed content panels using Apsara's `Tabs` component.
</Tabs>
````

## Badge

Small inline labels for status, counts, or categories. Renders as a `<span>`, so it works inline in prose, headings, table cells, and list items.

````mdx
<Badge>Default</Badge>
<Badge variant="success">Stable</Badge>
<Badge variant="warning">Beta</Badge>
<Badge variant="danger">Deprecated</Badge>
<Badge variant="neutral">Internal</Badge>
<Badge variant="gradient">New</Badge>

### Rate limits <Badge variant="warning" size="micro">Beta</Badge>

The `POST /users` endpoint <Badge variant="danger" size="micro">Deprecated</Badge> is removed in v3.
````

Badges work inside headings too. The table of contents lists such a heading by
its plain text, so `### Rate limits <Badge>Beta</Badge>` 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
<Badge icon="🔥" variant="danger">Hot path</Badge>
````

## Avatar

User or entity images with a text fallback.

````mdx
<Avatar src="/team/ada.png" alt="Ada Lovelace" fallback="AL" size={5} />
<Avatar fallback="RS" size={5} color="indigo" variant="soft" />
<Avatar fallback="RS" size={5} radius="full" />
````

**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 max={3}>
<Avatar fallback="AL" color="indigo" />
<Avatar fallback="GH" color="mint" />
<Avatar fallback="RS" color="gold" />
<Avatar fallback="KT" color="pink" />
</AvatarGroup>
````

**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:
Expand Down
45 changes: 45 additions & 0 deletions examples/basic/content/docs/components.mdx
Original file line number Diff line number Diff line change
@@ -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.

<Badge>Accent</Badge> <Badge variant="success">Success</Badge> <Badge variant="warning">Warning</Badge> <Badge variant="danger">Danger</Badge> <Badge variant="neutral">Neutral</Badge> <Badge variant="gradient">Gradient</Badge>

### Sizes

<Badge size="micro">Micro</Badge> <Badge size="small">Small</Badge> <Badge size="regular">Regular</Badge>

### With icons

<Badge icon="🔥" variant="danger">Hot path</Badge> <Badge icon="✨" variant="gradient">New</Badge>

### Rate limits <Badge variant="warning" size="micro">Beta</Badge>

Badges sit inline in prose too: the `POST /users` endpoint <Badge variant="danger" size="micro">Deprecated</Badge> is removed in v3.

| Endpoint | Status |
|----------|--------|
| `GET /users` | <Badge variant="success" size="micro">Stable</Badge> |
| `POST /users` | <Badge variant="danger" size="micro">Deprecated</Badge> |

## Avatar

<Avatar fallback="AL" size={5} color="indigo" /> <Avatar fallback="GH" size={5} color="mint" variant="solid" /> <Avatar fallback="RS" size={5} color="gold" radius="full" />

### Group

<AvatarGroup max={3}>
<Avatar fallback="AL" color="indigo" />
<Avatar fallback="GH" color="mint" />
<Avatar fallback="RS" color="gold" />
<Avatar fallback="KT" color="pink" />
</AvatarGroup>
5 changes: 4 additions & 1 deletion packages/chronicle/src/components/mdx/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })))
Expand Down Expand Up @@ -45,6 +45,9 @@ export const mdxComponents: MDXComponents = {
CalloutTitle,
CalloutDescription,
Tabs: MdxTabs,
Badge,
Avatar,
AvatarGroup,
Mermaid: (props: { chart: string }) => (
<Suspense fallback={<pre><code>{props.chart}</code></pre>}>
<LazyMermaid {...props} />
Expand Down
3 changes: 3 additions & 0 deletions packages/chronicle/src/lib/mdx-component-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export const MDX_COMPONENT_NAMES = [
'CalloutDescription',
'Tabs',
'Mermaid',
'Badge',
'Avatar',
'AvatarGroup',
] as const

export const KNOWN_TAGS = new Set<string>([...htmlTagNames, ...svgTagNames])
187 changes: 187 additions & 0 deletions packages/chronicle/src/lib/rehype-toc-text.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}
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([])
})
})
Loading
Loading