From 5d1053f33a381e1051e18c60e8ffb3a232f204cc Mon Sep 17 00:00:00 2001 From: Thomas Vu Date: Sun, 11 Oct 2026 11:40:54 -0400 Subject: [PATCH 1/7] feat: add collapsible :::details callouts --- .../developer-guides/callouts-and-code.json | 7 ++++- .../developer-guides/callouts-in-mdx.json | 2 +- public/llms-full.txt | 21 ++++++++++++++ scripts/lib/mdxAuthoring.mjs | 29 +++++++++++++++++-- scripts/lib/mdxCompile.mjs | 11 +++++-- src/components/MarkdownRenderer.tsx | 2 +- .../developer-guides/callouts-and-code.md | 15 ++++++++++ .../developer-guides/callouts-in-mdx.mdx | 6 ++++ src/globals.css | 19 ++++++++++++ src/utils/MarkdownProcessor.tsx | 3 ++ src/utils/markdownCore.ts | 11 +++++-- test/authoring-features.test.ts | 18 ++++++++++++ test/browser/docs.spec.mjs | 12 ++++++++ 13 files changed, 146 insertions(+), 10 deletions(-) diff --git a/public/docs-content/developer-guides/callouts-and-code.json b/public/docs-content/developer-guides/callouts-and-code.json index 5ca65e9..9e0a1b5 100644 --- a/public/docs-content/developer-guides/callouts-and-code.json +++ b/public/docs-content/developer-guides/callouts-and-code.json @@ -5,7 +5,7 @@ "frontmatter": { "description": "Callouts (admonitions), GitHub-style alerts, code block titles, line highlighting, and diffs." }, - "content": "# Callouts And Code Blocks\n\nPapers Markdown supports callouts for asides and warnings, plus code fence\nmetadata for filenames, highlighted lines, and diffs. These features work in\n`.md` pages.\n\n## Callouts\n\nWrap content in `:::type` and close it with `:::`. The five types are `note`,\n`tip`, `info`, `warning`, and `danger`. Text after the type becomes the title.\n\n```md\n:::note\nCallouts can contain **Markdown**, lists, and code.\n:::\n\n:::warning Breaking change\n`generate:docs` now validates frontmatter.\n:::\n```\n\n:::note\nCallouts can contain **Markdown**, lists, and code.\n:::\n\n:::tip Faster previews\nRun `npm run dev` to regenerate artifacts and start Vite in one step.\n:::\n\n:::info\nSearch results come from the Pagefind index built with `npm run build`.\n:::\n\n:::warning Breaking change\n`generate:docs` now validates frontmatter.\n:::\n\n:::danger\nNever commit deployment credentials to `wrangler.toml`.\n:::\n\n### Nesting\n\nGive the outer fence more colons than the inner one:\n\n```md\n::::info Outer\nOuter text.\n\n:::tip Inner\nInner text.\n:::\n::::\n```\n\n::::info Outer\nOuter text.\n\n:::tip Inner\nInner text.\n:::\n::::\n\n## GitHub-Style Alerts\n\nThe GitHub alert syntax renders the same way, so READMEs and docs can share\ncontent. `IMPORTANT` uses the info style and `CAUTION` uses the danger style.\n\n```md\n> [!TIP]\n> Alerts written for GitHub render as callouts here too.\n```\n\n> [!TIP]\n> Alerts written for GitHub render as callouts here too.\n\n> [!CAUTION]\n> Deleting `dist/` removes the last build output.\n\n## Code Block Titles\n\nAdd `title=\"...\"` after the language to show a filename in the code block header.\n\n````md\n```ts title=\"src/greet.ts\"\nexport const greet = (name: string) => `Hello, ${name}`;\n```\n````\n\n```ts title=\"src/greet.ts\"\nexport const greet = (name: string) => `Hello, ${name}`;\n```\n\n## Line Highlighting\n\nList line numbers or ranges in braces. You can combine them with a title.\n\n````md\n```ts title=\"vite.config.ts\" {2,4-5}\nimport { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n plugins: [react()],\n});\n```\n````\n\n```ts title=\"vite.config.ts\" {2,4-5}\nimport { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n plugins: [react()],\n});\n```\n\n## Diffs\n\nA `diff` fence marks lines that start with `+` as added and lines that start\nwith `-` as removed. The `+` and `-` markers stay visible, so the change does\nnot depend on color alone.\n\n````md\n```diff title=\"package.json\"\n \"scripts\": {\n- \"build\": \"vite build\"\n+ \"build\": \"npm run generate:docs && vite build\"\n }\n```\n````\n\n```diff title=\"package.json\"\n \"scripts\": {\n- \"build\": \"vite build\"\n+ \"build\": \"npm run generate:docs && vite build\"\n }\n```\n\nEvery code block keeps its copy button. The copy button copies the code\nexactly as written, including diff markers.\n\n## Content Tabs\n\nWrap tabs in `:::tabs` and start each tab with a `::tab Label` line. Tabs can\nhold any Markdown, including code blocks.\n\nAdd a group key after `tabs` to keep tab sets in sync. Choosing a tab in one\nset switches every set with the same key on the page. The choice is saved in\nthe browser, so other pages open on the same tab.\n\n````md\n:::tabs pkg\n::tab npm\n\n```bash\nnpm install papers\n```\n\n::tab pnpm\n\n```bash\npnpm add papers\n```\n\n:::\n````\n\n:::tabs pkg\n::tab npm\n\n```bash\nnpm install papers\n```\n\n::tab pnpm\n\n```bash\npnpm add papers\n```\n\n:::\n\nThis second set uses the same `pkg` key, so it follows the first one:\n\n:::tabs pkg\n::tab npm\nRun `npm run dev` to start the dev server.\n::tab pnpm\nRun `pnpm dev` to start the dev server.\n:::\n\nTabs without a group key switch on their own. If a panel contains a callout,\ngive the tabs fence more colons than the callout, for example `::::tabs`.\nTabs cannot be nested.\n", + "content": "# Callouts And Code Blocks\n\nPapers Markdown supports callouts for asides and warnings, plus code fence\nmetadata for filenames, highlighted lines, and diffs. These features work in\n`.md` pages.\n\n## Callouts\n\nWrap content in `:::type` and close it with `:::`. The five types are `note`,\n`tip`, `info`, `warning`, and `danger`. Text after the type becomes the title.\n\n```md\n:::note\nCallouts can contain **Markdown**, lists, and code.\n:::\n\n:::warning Breaking change\n`generate:docs` now validates frontmatter.\n:::\n```\n\n:::note\nCallouts can contain **Markdown**, lists, and code.\n:::\n\n:::tip Faster previews\nRun `npm run dev` to regenerate artifacts and start Vite in one step.\n:::\n\n:::info\nSearch results come from the Pagefind index built with `npm run build`.\n:::\n\n:::warning Breaking change\n`generate:docs` now validates frontmatter.\n:::\n\n:::danger\nNever commit deployment credentials to `wrangler.toml`.\n:::\n\n### Nesting\n\nGive the outer fence more colons than the inner one:\n\n```md\n::::info Outer\nOuter text.\n\n:::tip Inner\nInner text.\n:::\n::::\n```\n\n::::info Outer\nOuter text.\n\n:::tip Inner\nInner text.\n:::\n::::\n\n### Collapsible Callouts\n\n`:::details Title` renders a callout that starts closed. Readers open it by\nclicking the title or pressing Enter or Space on it.\n\n```md\n:::details Show the full config\nThe full configuration lives in `shared/documentation-config.js`.\n:::\n```\n\n:::details Show the full config\nThe full configuration lives in `shared/documentation-config.js`.\n:::\n\n## GitHub-Style Alerts\n\nThe GitHub alert syntax renders the same way, so READMEs and docs can share\ncontent. `IMPORTANT` uses the info style and `CAUTION` uses the danger style.\n\n```md\n> [!TIP]\n> Alerts written for GitHub render as callouts here too.\n```\n\n> [!TIP]\n> Alerts written for GitHub render as callouts here too.\n\n> [!CAUTION]\n> Deleting `dist/` removes the last build output.\n\n## Code Block Titles\n\nAdd `title=\"...\"` after the language to show a filename in the code block header.\n\n````md\n```ts title=\"src/greet.ts\"\nexport const greet = (name: string) => `Hello, ${name}`;\n```\n````\n\n```ts title=\"src/greet.ts\"\nexport const greet = (name: string) => `Hello, ${name}`;\n```\n\n## Line Highlighting\n\nList line numbers or ranges in braces. You can combine them with a title.\n\n````md\n```ts title=\"vite.config.ts\" {2,4-5}\nimport { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n plugins: [react()],\n});\n```\n````\n\n```ts title=\"vite.config.ts\" {2,4-5}\nimport { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n plugins: [react()],\n});\n```\n\n## Diffs\n\nA `diff` fence marks lines that start with `+` as added and lines that start\nwith `-` as removed. The `+` and `-` markers stay visible, so the change does\nnot depend on color alone.\n\n````md\n```diff title=\"package.json\"\n \"scripts\": {\n- \"build\": \"vite build\"\n+ \"build\": \"npm run generate:docs && vite build\"\n }\n```\n````\n\n```diff title=\"package.json\"\n \"scripts\": {\n- \"build\": \"vite build\"\n+ \"build\": \"npm run generate:docs && vite build\"\n }\n```\n\nEvery code block keeps its copy button. The copy button copies the code\nexactly as written, including diff markers.\n\n## Content Tabs\n\nWrap tabs in `:::tabs` and start each tab with a `::tab Label` line. Tabs can\nhold any Markdown, including code blocks.\n\nAdd a group key after `tabs` to keep tab sets in sync. Choosing a tab in one\nset switches every set with the same key on the page. The choice is saved in\nthe browser, so other pages open on the same tab.\n\n````md\n:::tabs pkg\n::tab npm\n\n```bash\nnpm install papers\n```\n\n::tab pnpm\n\n```bash\npnpm add papers\n```\n\n:::\n````\n\n:::tabs pkg\n::tab npm\n\n```bash\nnpm install papers\n```\n\n::tab pnpm\n\n```bash\npnpm add papers\n```\n\n:::\n\nThis second set uses the same `pkg` key, so it follows the first one:\n\n:::tabs pkg\n::tab npm\nRun `npm run dev` to start the dev server.\n::tab pnpm\nRun `pnpm dev` to start the dev server.\n:::\n\nTabs without a group key switch on their own. If a panel contains a callout,\ngive the tabs fence more colons than the callout, for example `::::tabs`.\nTabs cannot be nested.\n", "contentFormat": "markdown", "toc": [ { @@ -23,6 +23,11 @@ "id": "nesting", "text": "Nesting" }, + { + "depth": 3, + "id": "collapsible-callouts", + "text": "Collapsible Callouts" + }, { "depth": 2, "id": "github-style-alerts", diff --git a/public/docs-content/developer-guides/callouts-in-mdx.json b/public/docs-content/developer-guides/callouts-in-mdx.json index 823bd19..f5918b8 100644 --- a/public/docs-content/developer-guides/callouts-in-mdx.json +++ b/public/docs-content/developer-guides/callouts-in-mdx.json @@ -6,7 +6,7 @@ "title": "Callouts And Code In MDX", "description": "Use callouts, GitHub alerts, and code fence titles, line highlights, and diffs in MDX pages." }, - "content": "

Callouts And Code In MDX

\n

.mdx pages support the same callout and code fence syntax as\nMarkdown pages. They are compiled\nto sanitized static HTML at build time.

\n

Callouts

\n

Write :::type Title on the first line and ::: on the last line. The five\ntypes are note, tip, info, warning, and danger. In MDX, use a\nplain-text title.

\n

Note

MDX callouts hold Markdown and code.

\n

Breaking change

generate:docs now validates frontmatter.

\n

Outer

Give the outer fence more colons to nest callouts.

Inner

Inner callout.

\n

The <Callout> shortcode renders the same callout. type accepts note,\ntip, info, warning, danger, important, or caution. Put the\nshortcode on its own lines.

\n

Shortcode form

Never commit deployment credentials.

\n

GitHub Alerts

\n

Tip

GitHub alert syntax also works in MDX.

\n

Code Fence Metadata

\n
setup.sh
npm ci\nnpm run generate:docs\nnpm run dev
\n
package.json
-    \"build\": \"vite build\"\n+    \"build\": \"npm run generate:docs && vite build\"
\n

Code blocks in MDX pages use the same viewer as Markdown pages, with a copy button\nand syntax colours.

", + "content": "

Callouts And Code In MDX

\n

.mdx pages support the same callout and code fence syntax as\nMarkdown pages. They are compiled\nto sanitized static HTML at build time.

\n

Callouts

\n

Write :::type Title on the first line and ::: on the last line. The five\ntypes are note, tip, info, warning, and danger. In MDX, use a\nplain-text title.

\n

Note

MDX callouts hold Markdown and code.

\n

Breaking change

generate:docs now validates frontmatter.

\n

Outer

Give the outer fence more colons to nest callouts.

Inner

Inner callout.

\n

:::details Title makes a collapsible callout that starts closed.

\n
Show more

Collapsible callouts work in MDX too.

\n

The <Callout> shortcode renders the same callout. type accepts note,\ntip, info, warning, danger, important, or caution. Put the\nshortcode on its own lines.

\n

Shortcode form

Never commit deployment credentials.

\n

GitHub Alerts

\n

Tip

GitHub alert syntax also works in MDX.

\n

Code Fence Metadata

\n
setup.sh
npm ci\nnpm run generate:docs\nnpm run dev
\n
package.json
-    \"build\": \"vite build\"\n+    \"build\": \"npm run generate:docs && vite build\"
\n

Code blocks in MDX pages use the same viewer as Markdown pages, with a copy button\nand syntax colours.

", "contentFormat": "html", "toc": [ { diff --git a/public/llms-full.txt b/public/llms-full.txt index 00fbfc2..68dccf5 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -1126,6 +1126,21 @@ Inner text. ::: :::: +### Collapsible Callouts + +`:::details Title` renders a callout that starts closed. Readers open it by +clicking the title or pressing Enter or Space on it. + +```md +:::details Show the full config +The full configuration lives in `shared/documentation-config.js`. +::: +``` + +:::details Show the full config +The full configuration lives in `shared/documentation-config.js`. +::: + ## GitHub-Style Alerts The GitHub alert syntax renders the same way, so READMEs and docs can share @@ -1300,6 +1315,12 @@ Inner callout. ::: :::: +`:::details Title` makes a collapsible callout that starts closed. + +:::details Show more +Collapsible callouts work in MDX too. +::: + The `` shortcode renders the same callout. `type` accepts `note`, `tip`, `info`, `warning`, `danger`, `important`, or `caution`. Put the shortcode on its own lines. diff --git a/scripts/lib/mdxAuthoring.mjs b/scripts/lib/mdxAuthoring.mjs index 9d5dac3..a6bd1f1 100644 --- a/scripts/lib/mdxAuthoring.mjs +++ b/scripts/lib/mdxAuthoring.mjs @@ -6,7 +6,7 @@ */ import { getDiffLineKind, parseCodeFenceMeta } from '../../shared/codeFence.js'; -export const ADMONITION_TYPES = ['note', 'tip', 'info', 'warning', 'danger']; +export const ADMONITION_TYPES = ['note', 'tip', 'info', 'warning', 'danger', 'details']; const DEFAULT_TITLES = { note: 'Note', @@ -14,6 +14,7 @@ const DEFAULT_TITLES = { info: 'Info', warning: 'Warning', danger: 'Danger', + details: 'Details', important: 'Important', caution: 'Caution', }; @@ -40,6 +41,29 @@ function titleNode(title) { /** Build an mdast node that renders as the shared admonition markup. */ export function createAdmonitionNode(type, title, children, extraClassName = '') { + if (type === 'details') { + return { + type: 'admonition', + data: { + hName: 'details', + hProperties: { + className: `admonition admonition-details${extraClassName ? ` ${extraClassName}` : ''}`, + }, + }, + children: [ + { + type: 'admonitionTitle', + data: { hName: 'summary', hProperties: { className: 'admonition-title' } }, + children: [{ type: 'text', value: title }], + }, + { + type: 'admonitionContent', + data: { hName: 'div', hProperties: { className: 'admonition-content' } }, + children, + }, + ], + }; + } return { type: 'admonition', data: { @@ -61,7 +85,8 @@ export function createAdmonitionNode(type, title, children, extraClassName = '') }; } -const OPENER_PATTERN = /^(:{3,})[ \t]*(note|tip|info|warning|danger)\b[ \t]*([^\n]*)(?:\n|$)/i; +const OPENER_PATTERN = + /^(:{3,})[ \t]*(note|tip|info|warning|danger|details)\b[ \t]*([^\n]*)(?:\n|$)/i; function firstText(node) { return node?.type === 'paragraph' && node.children?.[0]?.type === 'text' diff --git a/scripts/lib/mdxCompile.mjs b/scripts/lib/mdxCompile.mjs index de8f172..da04601 100644 --- a/scripts/lib/mdxCompile.mjs +++ b/scripts/lib/mdxCompile.mjs @@ -46,8 +46,15 @@ const sanitizeSchema = { 'loading', 'decoding', ], - details: [...(defaultSchema.attributes?.details || []), ['className', /^papers-tabs/], 'open'], - summary: [...(defaultSchema.attributes?.summary || []), ['className', /^papers-tabs/]], + details: [ + ...(defaultSchema.attributes?.details || []), + ['className', /^(papers-tabs|admonition)/], + 'open', + ], + summary: [ + ...(defaultSchema.attributes?.summary || []), + ['className', /^(papers-tabs|admonition-title)/], + ], // Math placeholders, typeset client-side by KaTeX (see src/utils/renderMath.ts). span: [ ...(defaultSchema.attributes?.span || []), diff --git a/src/components/MarkdownRenderer.tsx b/src/components/MarkdownRenderer.tsx index 6908698..88a45ae 100644 --- a/src/components/MarkdownRenderer.tsx +++ b/src/components/MarkdownRenderer.tsx @@ -160,7 +160,7 @@ interface RenderContext { navigate: (href: string) => void; } -const BOOLEAN_ATTRIBUTES = new Set(['checked', 'disabled', 'readonly', 'selected']); +const BOOLEAN_ATTRIBUTES = new Set(['checked', 'disabled', 'open', 'readonly', 'selected']); const EXTERNAL_PROTOCOL_PATTERN = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; let toastTimeout: ReturnType | null = null; const HEADING_TAG_PATTERN = /^h[1-6]$/; diff --git a/src/docs/content/developer-guides/callouts-and-code.md b/src/docs/content/developer-guides/callouts-and-code.md index 0db8cf7..b745f55 100644 --- a/src/docs/content/developer-guides/callouts-and-code.md +++ b/src/docs/content/developer-guides/callouts-and-code.md @@ -65,6 +65,21 @@ Inner text. ::: :::: +### Collapsible Callouts + +`:::details Title` renders a callout that starts closed. Readers open it by +clicking the title or pressing Enter or Space on it. + +```md +:::details Show the full config +The full configuration lives in `shared/documentation-config.js`. +::: +``` + +:::details Show the full config +The full configuration lives in `shared/documentation-config.js`. +::: + ## GitHub-Style Alerts The GitHub alert syntax renders the same way, so READMEs and docs can share diff --git a/src/docs/content/developer-guides/callouts-in-mdx.mdx b/src/docs/content/developer-guides/callouts-in-mdx.mdx index 72e6a3b..6b7a498 100644 --- a/src/docs/content/developer-guides/callouts-in-mdx.mdx +++ b/src/docs/content/developer-guides/callouts-in-mdx.mdx @@ -31,6 +31,12 @@ Inner callout. ::: :::: +`:::details Title` makes a collapsible callout that starts closed. + +:::details Show more +Collapsible callouts work in MDX too. +::: + The `` shortcode renders the same callout. `type` accepts `note`, `tip`, `info`, `warning`, `danger`, `important`, or `caution`. Put the shortcode on its own lines. diff --git a/src/globals.css b/src/globals.css index f98d076..c6f20e6 100644 --- a/src/globals.css +++ b/src/globals.css @@ -2020,6 +2020,25 @@ background-color: color-mix(in srgb, var(--admonition-accent) 7%, transparent); } + .admonition-details { + --admonition-accent: var(--muted-color); + } + + .admonition-details > summary.admonition-title { + margin: 0; + cursor: pointer; + list-style-position: inside; + } + + .admonition-details[open] > summary.admonition-title { + margin-bottom: var(--space-2); + } + + .admonition-details > summary.admonition-title:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + .admonition-tip { --admonition-accent: #15803d; } diff --git a/src/utils/MarkdownProcessor.tsx b/src/utils/MarkdownProcessor.tsx index 8d955ff..90fdf7f 100644 --- a/src/utils/MarkdownProcessor.tsx +++ b/src/utils/MarkdownProcessor.tsx @@ -133,6 +133,8 @@ export const processMarkdown = async ( 'span', 'i', 'button', + 'details', + 'summary', ], ALLOWED_ATTR: [ 'href', @@ -158,6 +160,7 @@ export const processMarkdown = async ( 'tabindex', 'aria-label', 'role', + 'open', 'data-tabs-group', 'data-tab-label', 'width', diff --git a/src/utils/markdownCore.ts b/src/utils/markdownCore.ts index 053c515..8ef07a0 100644 --- a/src/utils/markdownCore.ts +++ b/src/utils/markdownCore.ts @@ -151,7 +151,7 @@ const mathExtensions = [ }, ]; -export const ADMONITION_TYPES = ['note', 'tip', 'info', 'warning', 'danger'] as const; +export const ADMONITION_TYPES = ['note', 'tip', 'info', 'warning', 'danger', 'details'] as const; export type AdmonitionType = (typeof ADMONITION_TYPES)[number]; const ADMONITION_DEFAULT_TITLES: Record = { @@ -160,6 +160,7 @@ const ADMONITION_DEFAULT_TITLES: Record = { info: 'Info', warning: 'Warning', danger: 'Danger', + details: 'Details', }; // GitHub-style alerts map onto the same five visual types. @@ -173,7 +174,7 @@ const GITHUB_ALERT_TYPES: Record[ \t]*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*\n((?: {0,3}>[^\n]*(?:\n|$))*)/i; @@ -189,6 +190,10 @@ function renderAdmonition( ): string { const { admonitionType, title, tokens } = token as AdmonitionToken; const body = this.parser.parse(tokens); + if (admonitionType === 'details') { + // Collapsible callout: native
, closed by default, keyboard accessible. + return `
${escapeHtml(title)}
${body}
\n`; + } return `

${escapeHtml(title)}

${body}
\n`; } @@ -197,7 +202,7 @@ const admonitionExtensions = [ name: 'admonition', level: 'block' as const, start(src: string) { - return src.match(/^ {0,3}:{3,}[ \t]*(?:note|tip|info|warning|danger)\b/im)?.index; + return src.match(/^ {0,3}:{3,}[ \t]*(?:note|tip|info|warning|danger|details)\b/im)?.index; }, tokenizer( this: { diff --git a/test/authoring-features.test.ts b/test/authoring-features.test.ts index d3dba7d..d1c1c42 100644 --- a/test/authoring-features.test.ts +++ b/test/authoring-features.test.ts @@ -309,4 +309,22 @@ export const authoringFeatureTests: TestCase[] = [ assert.equal(resolveTabIndex(['npm', 'pnpm'], null), 0); }, }, + { + name: ':::details renders a closed native disclosure in Markdown and MDX', + run: async () => { + const { html } = await render(':::details Show more\nHidden **text**.\n:::'); + assert.match( + html, + /^
Show more<\/summary>

Hidden text<\/strong>/ + ); + assert.doesNotMatch(html, / open/); + const fallback = await render(':::details\nx\n:::'); + assert.match(fallback.html, /

Details<\/summary>/); + const mdx = await compileMdxToHtml(':::details Show more\nHidden.\n:::'); + assert.match( + mdx, + /^
Show more<\/summary>/ + ); + }, + }, ]; diff --git a/test/browser/docs.spec.mjs b/test/browser/docs.spec.mjs index 4d02496..68605fe 100644 --- a/test/browser/docs.spec.mjs +++ b/test/browser/docs.spec.mjs @@ -370,3 +370,15 @@ test('doc routes ship a per-page social preview image', async ({ page, request } /\/images\/og\/docs\/getting-started\/introduction\.svg$/ ); }); + +test('collapsible callouts open from the keyboard', async ({ page }) => { + const errors = collectRuntimeErrors(page); + await page.goto('/docs/developer-guides/callouts-and-code', { waitUntil: 'networkidle' }); + const details = page.locator('details.admonition-details').first(); + await expect(details).not.toHaveAttribute('open', ''); + await expect(details.locator('.admonition-content')).toBeHidden(); + await details.locator('summary').focus(); + await page.keyboard.press('Enter'); + await expect(details.locator('.admonition-content')).toBeVisible(); + await assertNoRuntimeErrors(errors); +}); From 3a25529c18db19ccad8943f6588c2a8b55927fe8 Mon Sep 17 00:00:00 2001 From: Thomas Vu Date: Sun, 11 Oct 2026 11:44:34 -0400 Subject: [PATCH 2/7] feat: diff syntax colours, word highlights, and line-number toggle --- .../developer-guides/callouts-and-code.json | 12 +- public/llms-full.txt | 43 +++++++ scripts/lib/mdxAuthoring.mjs | 28 ++++- scripts/lib/mdxCompile.mjs | 2 + shared/codeFence.js | 29 +++++ src/components/CodeBlock.module.css | 12 ++ src/components/CodeBlock.tsx | 105 ++++++++++++++++-- .../developer-guides/callouts-and-code.md | 43 +++++++ src/lib/generated/icon-collections.ts | 3 + src/react-syntax-highlighter.d.ts | 11 ++ src/utils/codeWords.ts | 65 +++++++++++ src/utils/htmlCodeBlocks.ts | 20 ++++ src/utils/markdownCore.ts | 17 ++- test/authoring-features.test.ts | 75 +++++++++++++ test/browser/docs.spec.mjs | 36 +++++- types/shared-code-fence.d.ts | 6 + 16 files changed, 486 insertions(+), 21 deletions(-) create mode 100644 src/utils/codeWords.ts diff --git a/public/docs-content/developer-guides/callouts-and-code.json b/public/docs-content/developer-guides/callouts-and-code.json index 9e0a1b5..b0152f1 100644 --- a/public/docs-content/developer-guides/callouts-and-code.json +++ b/public/docs-content/developer-guides/callouts-and-code.json @@ -5,7 +5,7 @@ "frontmatter": { "description": "Callouts (admonitions), GitHub-style alerts, code block titles, line highlighting, and diffs." }, - "content": "# Callouts And Code Blocks\n\nPapers Markdown supports callouts for asides and warnings, plus code fence\nmetadata for filenames, highlighted lines, and diffs. These features work in\n`.md` pages.\n\n## Callouts\n\nWrap content in `:::type` and close it with `:::`. The five types are `note`,\n`tip`, `info`, `warning`, and `danger`. Text after the type becomes the title.\n\n```md\n:::note\nCallouts can contain **Markdown**, lists, and code.\n:::\n\n:::warning Breaking change\n`generate:docs` now validates frontmatter.\n:::\n```\n\n:::note\nCallouts can contain **Markdown**, lists, and code.\n:::\n\n:::tip Faster previews\nRun `npm run dev` to regenerate artifacts and start Vite in one step.\n:::\n\n:::info\nSearch results come from the Pagefind index built with `npm run build`.\n:::\n\n:::warning Breaking change\n`generate:docs` now validates frontmatter.\n:::\n\n:::danger\nNever commit deployment credentials to `wrangler.toml`.\n:::\n\n### Nesting\n\nGive the outer fence more colons than the inner one:\n\n```md\n::::info Outer\nOuter text.\n\n:::tip Inner\nInner text.\n:::\n::::\n```\n\n::::info Outer\nOuter text.\n\n:::tip Inner\nInner text.\n:::\n::::\n\n### Collapsible Callouts\n\n`:::details Title` renders a callout that starts closed. Readers open it by\nclicking the title or pressing Enter or Space on it.\n\n```md\n:::details Show the full config\nThe full configuration lives in `shared/documentation-config.js`.\n:::\n```\n\n:::details Show the full config\nThe full configuration lives in `shared/documentation-config.js`.\n:::\n\n## GitHub-Style Alerts\n\nThe GitHub alert syntax renders the same way, so READMEs and docs can share\ncontent. `IMPORTANT` uses the info style and `CAUTION` uses the danger style.\n\n```md\n> [!TIP]\n> Alerts written for GitHub render as callouts here too.\n```\n\n> [!TIP]\n> Alerts written for GitHub render as callouts here too.\n\n> [!CAUTION]\n> Deleting `dist/` removes the last build output.\n\n## Code Block Titles\n\nAdd `title=\"...\"` after the language to show a filename in the code block header.\n\n````md\n```ts title=\"src/greet.ts\"\nexport const greet = (name: string) => `Hello, ${name}`;\n```\n````\n\n```ts title=\"src/greet.ts\"\nexport const greet = (name: string) => `Hello, ${name}`;\n```\n\n## Line Highlighting\n\nList line numbers or ranges in braces. You can combine them with a title.\n\n````md\n```ts title=\"vite.config.ts\" {2,4-5}\nimport { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n plugins: [react()],\n});\n```\n````\n\n```ts title=\"vite.config.ts\" {2,4-5}\nimport { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n plugins: [react()],\n});\n```\n\n## Diffs\n\nA `diff` fence marks lines that start with `+` as added and lines that start\nwith `-` as removed. The `+` and `-` markers stay visible, so the change does\nnot depend on color alone.\n\n````md\n```diff title=\"package.json\"\n \"scripts\": {\n- \"build\": \"vite build\"\n+ \"build\": \"npm run generate:docs && vite build\"\n }\n```\n````\n\n```diff title=\"package.json\"\n \"scripts\": {\n- \"build\": \"vite build\"\n+ \"build\": \"npm run generate:docs && vite build\"\n }\n```\n\nEvery code block keeps its copy button. The copy button copies the code\nexactly as written, including diff markers.\n\n## Content Tabs\n\nWrap tabs in `:::tabs` and start each tab with a `::tab Label` line. Tabs can\nhold any Markdown, including code blocks.\n\nAdd a group key after `tabs` to keep tab sets in sync. Choosing a tab in one\nset switches every set with the same key on the page. The choice is saved in\nthe browser, so other pages open on the same tab.\n\n````md\n:::tabs pkg\n::tab npm\n\n```bash\nnpm install papers\n```\n\n::tab pnpm\n\n```bash\npnpm add papers\n```\n\n:::\n````\n\n:::tabs pkg\n::tab npm\n\n```bash\nnpm install papers\n```\n\n::tab pnpm\n\n```bash\npnpm add papers\n```\n\n:::\n\nThis second set uses the same `pkg` key, so it follows the first one:\n\n:::tabs pkg\n::tab npm\nRun `npm run dev` to start the dev server.\n::tab pnpm\nRun `pnpm dev` to start the dev server.\n:::\n\nTabs without a group key switch on their own. If a panel contains a callout,\ngive the tabs fence more colons than the callout, for example `::::tabs`.\nTabs cannot be nested.\n", + "content": "# Callouts And Code Blocks\n\nPapers Markdown supports callouts for asides and warnings, plus code fence\nmetadata for filenames, highlighted lines, and diffs. These features work in\n`.md` pages.\n\n## Callouts\n\nWrap content in `:::type` and close it with `:::`. The five types are `note`,\n`tip`, `info`, `warning`, and `danger`. Text after the type becomes the title.\n\n```md\n:::note\nCallouts can contain **Markdown**, lists, and code.\n:::\n\n:::warning Breaking change\n`generate:docs` now validates frontmatter.\n:::\n```\n\n:::note\nCallouts can contain **Markdown**, lists, and code.\n:::\n\n:::tip Faster previews\nRun `npm run dev` to regenerate artifacts and start Vite in one step.\n:::\n\n:::info\nSearch results come from the Pagefind index built with `npm run build`.\n:::\n\n:::warning Breaking change\n`generate:docs` now validates frontmatter.\n:::\n\n:::danger\nNever commit deployment credentials to `wrangler.toml`.\n:::\n\n### Nesting\n\nGive the outer fence more colons than the inner one:\n\n```md\n::::info Outer\nOuter text.\n\n:::tip Inner\nInner text.\n:::\n::::\n```\n\n::::info Outer\nOuter text.\n\n:::tip Inner\nInner text.\n:::\n::::\n\n### Collapsible Callouts\n\n`:::details Title` renders a callout that starts closed. Readers open it by\nclicking the title or pressing Enter or Space on it.\n\n```md\n:::details Show the full config\nThe full configuration lives in `shared/documentation-config.js`.\n:::\n```\n\n:::details Show the full config\nThe full configuration lives in `shared/documentation-config.js`.\n:::\n\n## GitHub-Style Alerts\n\nThe GitHub alert syntax renders the same way, so READMEs and docs can share\ncontent. `IMPORTANT` uses the info style and `CAUTION` uses the danger style.\n\n```md\n> [!TIP]\n> Alerts written for GitHub render as callouts here too.\n```\n\n> [!TIP]\n> Alerts written for GitHub render as callouts here too.\n\n> [!CAUTION]\n> Deleting `dist/` removes the last build output.\n\n## Code Block Titles\n\nAdd `title=\"...\"` after the language to show a filename in the code block header.\n\n````md\n```ts title=\"src/greet.ts\"\nexport const greet = (name: string) => `Hello, ${name}`;\n```\n````\n\n```ts title=\"src/greet.ts\"\nexport const greet = (name: string) => `Hello, ${name}`;\n```\n\n## Line Highlighting\n\nList line numbers or ranges in braces. You can combine them with a title.\n\n````md\n```ts title=\"vite.config.ts\" {2,4-5}\nimport { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n plugins: [react()],\n});\n```\n````\n\n```ts title=\"vite.config.ts\" {2,4-5}\nimport { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n plugins: [react()],\n});\n```\n\n## Diffs\n\nA `diff` fence marks lines that start with `+` as added and lines that start\nwith `-` as removed. The `+` and `-` markers stay visible, so the change does\nnot depend on color alone.\n\n````md\n```diff title=\"package.json\"\n \"scripts\": {\n- \"build\": \"vite build\"\n+ \"build\": \"npm run generate:docs && vite build\"\n }\n```\n````\n\n```diff title=\"package.json\"\n \"scripts\": {\n- \"build\": \"vite build\"\n+ \"build\": \"npm run generate:docs && vite build\"\n }\n```\n\nTo colour the code as well, add the language after `diff-`, for example\n`diff-ts` or `diff-json`:\n\n```diff-ts title=\"greet.ts\"\n export function greet(name: string) {\n- return 'Hello ' + name;\n+ return `Hello, ${name}!`;\n }\n```\n\n## Word Highlighting\n\nPut a term in quotes after the language to highlight every place it appears.\nYou can list up to 10 terms.\n\n````md\n```ts \"apiKey\" \"fetchDocs\"\nconst apiKey = process.env.DOCS_API_KEY;\nawait fetchDocs({ apiKey });\n```\n````\n\n```ts \"apiKey\" \"fetchDocs\"\nconst apiKey = process.env.DOCS_API_KEY;\nawait fetchDocs({ apiKey });\n```\n\n## Line Numbers\n\nCode blocks show line numbers by default. Add `noLineNumbers` (or\n`showLineNumbers=false`) to hide them for one block. Readers can also show\nor hide line numbers with the button in any code block's header.\n\n````md\n```bash noLineNumbers\nnpm run build\n```\n````\n\n```bash noLineNumbers\nnpm run build\n```\n\nEvery code block keeps its copy button. The copy button copies the code\nexactly as written, including diff markers.\n\n## Content Tabs\n\nWrap tabs in `:::tabs` and start each tab with a `::tab Label` line. Tabs can\nhold any Markdown, including code blocks.\n\nAdd a group key after `tabs` to keep tab sets in sync. Choosing a tab in one\nset switches every set with the same key on the page. The choice is saved in\nthe browser, so other pages open on the same tab.\n\n````md\n:::tabs pkg\n::tab npm\n\n```bash\nnpm install papers\n```\n\n::tab pnpm\n\n```bash\npnpm add papers\n```\n\n:::\n````\n\n:::tabs pkg\n::tab npm\n\n```bash\nnpm install papers\n```\n\n::tab pnpm\n\n```bash\npnpm add papers\n```\n\n:::\n\nThis second set uses the same `pkg` key, so it follows the first one:\n\n:::tabs pkg\n::tab npm\nRun `npm run dev` to start the dev server.\n::tab pnpm\nRun `pnpm dev` to start the dev server.\n:::\n\nTabs without a group key switch on their own. If a panel contains a callout,\ngive the tabs fence more colons than the callout, for example `::::tabs`.\nTabs cannot be nested.\n", "contentFormat": "markdown", "toc": [ { @@ -48,6 +48,16 @@ "id": "diffs", "text": "Diffs" }, + { + "depth": 2, + "id": "word-highlighting", + "text": "Word Highlighting" + }, + { + "depth": 2, + "id": "line-numbers", + "text": "Line Numbers" + }, { "depth": 2, "id": "content-tabs", diff --git a/public/llms-full.txt b/public/llms-full.txt index 68dccf5..f9abd52 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -1217,6 +1217,49 @@ not depend on color alone. } ``` +To colour the code as well, add the language after `diff-`, for example +`diff-ts` or `diff-json`: + +```diff-ts title="greet.ts" + export function greet(name: string) { +- return 'Hello ' + name; ++ return `Hello, ${name}!`; + } +``` + +## Word Highlighting + +Put a term in quotes after the language to highlight every place it appears. +You can list up to 10 terms. + +````md +```ts "apiKey" "fetchDocs" +const apiKey = process.env.DOCS_API_KEY; +await fetchDocs({ apiKey }); +``` +```` + +```ts "apiKey" "fetchDocs" +const apiKey = process.env.DOCS_API_KEY; +await fetchDocs({ apiKey }); +``` + +## Line Numbers + +Code blocks show line numbers by default. Add `noLineNumbers` (or +`showLineNumbers=false`) to hide them for one block. Readers can also show +or hide line numbers with the button in any code block's header. + +````md +```bash noLineNumbers +npm run build +``` +```` + +```bash noLineNumbers +npm run build +``` + Every code block keeps its copy button. The copy button copies the code exactly as written, including diff markers. diff --git a/scripts/lib/mdxAuthoring.mjs b/scripts/lib/mdxAuthoring.mjs index a6bd1f1..0b4d6c8 100644 --- a/scripts/lib/mdxAuthoring.mjs +++ b/scripts/lib/mdxAuthoring.mjs @@ -4,7 +4,7 @@ * (`title="..."`, `{1,3-5}`, `diff`). Output uses the same `admonition-*` * classes as Markdown pages and passes through the MDX sanitizer. */ -import { getDiffLineKind, parseCodeFenceMeta } from '../../shared/codeFence.js'; +import { getDiffLineKind, parseCodeFenceMeta, parseDiffLanguage } from '../../shared/codeFence.js'; export const ADMONITION_TYPES = ['note', 'tip', 'info', 'warning', 'danger', 'details']; @@ -201,8 +201,16 @@ export function remarkAdmonitionDirectives() { function transformCodeNode(node) { const meta = parseCodeFenceMeta(node.meta || ''); const language = node.lang || 'text'; - const isDiff = language.toLowerCase() === 'diff'; - if (!meta.title && !meta.highlightLines && !isDiff) return null; + const { isDiff } = parseDiffLanguage(language); + if ( + !meta.title && + !meta.highlightLines && + !meta.highlightWords && + meta.showLineNumbers === undefined && + !isDiff + ) { + return null; + } const highlighted = new Set(meta.highlightLines || []); const lines = String(node.value || '').split('\n'); @@ -247,7 +255,19 @@ function transformCodeNode(node) { return { type: 'codeFigure', - data: { hName: 'div', hProperties: { className: 'papers-code', dataCodeLanguage: language } }, + data: { + hName: 'div', + hProperties: { + className: 'papers-code', + dataCodeLanguage: language, + ...(meta.highlightWords + ? { dataHighlightWords: encodeURIComponent(JSON.stringify(meta.highlightWords)) } + : {}), + ...(meta.showLineNumbers !== undefined + ? { dataLineNumbers: String(meta.showLineNumbers) } + : {}), + }, + }, children, }; } diff --git a/scripts/lib/mdxCompile.mjs b/scripts/lib/mdxCompile.mjs index da04601..324f42e 100644 --- a/scripts/lib/mdxCompile.mjs +++ b/scripts/lib/mdxCompile.mjs @@ -30,6 +30,8 @@ const sanitizeSchema = { 'ariaLabel', 'dataCodeLanguage', 'dataCodeTitle', + 'dataHighlightWords', + 'dataLineNumbers', 'dataCallout', 'dataMermaidChart', 'dataMathTex', diff --git a/shared/codeFence.js b/shared/codeFence.js index 6e893c7..e5557ae 100644 --- a/shared/codeFence.js +++ b/shared/codeFence.js @@ -5,6 +5,7 @@ */ const MAX_HIGHLIGHT_LINE = 10000; +const MAX_HIGHLIGHT_WORDS = 10; /** Parse `1,3-5` line ranges. Invalid, zero, reversed, or huge ranges are ignored. */ export function parseHighlightLines(spec) { @@ -32,6 +33,21 @@ export function parseCodeFenceMeta(meta) { rest = rest.replace(titleMatch[0], ' '); } + // Quoted terms such as "token" or 'apiKey' highlight every occurrence. + const words = []; + rest = rest.replace(/"([^"\n]{1,80})"|'([^'\n]{1,80})'/g, (_match, double, single) => { + const word = (double ?? single ?? '').trim(); + if (word && !words.includes(word) && words.length < MAX_HIGHLIGHT_WORDS) words.push(word); + return ' '; + }); + if (words.length > 0) result.highlightWords = words; + + if (/(?:^|\s)(?:showLineNumbers=false|noLineNumbers)(?=\s|$)/.test(rest)) { + result.showLineNumbers = false; + } else if (/(?:^|\s)showLineNumbers(?:=true)?(?=\s|$)/.test(rest)) { + result.showLineNumbers = true; + } + const rangeMatch = /\{([^}]*)\}/.exec(rest); if (rangeMatch) { const lines = parseHighlightLines(rangeMatch[1]); @@ -48,6 +64,19 @@ export function parseCodeFenceInfo(info) { return { language, ...parseCodeFenceMeta(trimmed.slice(language.length)) }; } +/** + * `diff` fences mark +/- lines; `diff-ts` (any `diff-`) also colours + * the code with that language. Returns the language used for syntax colours. + */ +export function parseDiffLanguage(language) { + const value = String(language || '').toLowerCase(); + if (value === 'diff') return { isDiff: true, syntaxLanguage: 'diff' }; + if (value.startsWith('diff-') && value.length > 5) { + return { isDiff: true, syntaxLanguage: value.slice(5) }; + } + return { isDiff: false, syntaxLanguage: value }; +} + /** Classify a diff line by its leading marker; non-diff blocks never mark lines. */ export function getDiffLineKind(line, isDiff) { if (!isDiff) return null; diff --git a/src/components/CodeBlock.module.css b/src/components/CodeBlock.module.css index eede9a7..e90fe49 100644 --- a/src/components/CodeBlock.module.css +++ b/src/components/CodeBlock.module.css @@ -87,6 +87,10 @@ overflow-wrap: anywhere; } + .codeBlockCopyBtn + .codeBlockCopyBtn { + margin-left: 0.25rem; + } + .codeBlockFilename + .codeBlockLanguage { margin-right: 0.75rem; } @@ -112,6 +116,14 @@ border-left-color: rgb(214, 60, 60); } + .codeBlockContent :global(.code-word-highlight) { + padding: 0 0.15em; + border-radius: 3px; + background-color: rgba(var(--primary-color-rgb), 0.22); + color: inherit; + outline: 1px solid rgba(var(--primary-color-rgb), 0.45); + } + .codeBlockCopyBtn { display: flex; align-items: center; diff --git a/src/components/CodeBlock.tsx b/src/components/CodeBlock.tsx index 125d8ab..8b9f2ef 100644 --- a/src/components/CodeBlock.tsx +++ b/src/components/CodeBlock.tsx @@ -9,7 +9,8 @@ import React, { import { ANIMATION_DURATION } from '../constants/ui'; import { getLanguageDisplay } from '../utils/languageUtils'; -import { getDiffLineKind } from '../utils/markdownCore'; +import { getDiffLineKind, parseDiffLanguage } from '../utils/markdownCore'; +import { createWordPattern, markWords, type HighlighterNode } from '../utils/codeWords'; import styles from './CodeBlock.module.css'; @@ -19,6 +20,8 @@ interface CodeSnippet { label?: string; title?: string; highlightLines?: number[]; + highlightWords?: string[]; + showLineNumbers?: boolean; } interface CodeBlockProps { @@ -35,6 +38,11 @@ type SyntaxHighlighterProps = { showLineNumbers?: boolean; wrapLines?: boolean; wrapLongLines?: boolean; + renderer?: (props: { + rows: HighlighterNode[]; + stylesheet: Record; + useInlineStyles: boolean; + }) => React.ReactNode; lineProps?: (lineNumber: number) => { className?: string; style?: CSSProperties; @@ -51,8 +59,16 @@ type SyntaxHighlighterComponent = ComponentType & { registerLanguage: (name: string, language: unknown) => void; }; +type CreateElement = (props: { + node: HighlighterNode; + stylesheet: Record; + useInlineStyles: boolean; + key: string; +}) => React.ReactNode; + type SyntaxModule = { SyntaxHighlighter: SyntaxHighlighterComponent; + createElement: CreateElement; }; const REGISTERED_LANGUAGES = new Set([ @@ -64,10 +80,21 @@ const REGISTERED_LANGUAGES = new Set([ 'markup', 'tsx', 'typescript', + 'diff', + 'python', + 'yaml', + 'sql', + 'go', + 'rust', ]); const LANGUAGE_ALIASES: Record = { env: 'bash', + py: 'python', + yml: 'yaml', + rs: 'rust', + sh: 'bash', + shell: 'bash', html: 'markup', js: 'javascript', md: 'markdown', @@ -155,7 +182,10 @@ const minimalSyntaxTheme: Record = { color: 'var(--code-text-color)', }, inserted: { - color: 'var(--code-text-color)', + color: 'var(--code-inserted-color, rgb(34, 140, 80))', + }, + deleted: { + color: 'var(--code-deleted-color, rgb(200, 60, 60))', }, 'attr-name': { color: 'var(--code-subtle-color)', @@ -184,9 +214,6 @@ const minimalSyntaxTheme: Record = { regex: { color: 'var(--code-subtle-color)', }, - deleted: { - color: 'var(--code-subtle-color)', - }, bold: { fontWeight: 700, }, @@ -213,6 +240,8 @@ const CodeBlock: React.FC = React.memo( ({ snippets = [], title, defaultLanguage, showLineNumbers = true, className = '' }) => { const [activeTab, setActiveTab] = useState(0); const [copied, setCopied] = useState(false); + // Per-block line-number toggle; null follows the fence/default setting. + const [lineNumberOverride, setLineNumberOverride] = useState(null); const [mounted, setMounted] = useState(false); const [syntaxModule, setSyntaxModule] = useState(null); @@ -234,6 +263,13 @@ const CodeBlock: React.FC = React.memo( markup, tsx, typescript, + diff, + python, + yaml, + sql, + go, + rust, + createElementModule, ] = await Promise.all([ import('react-syntax-highlighter/dist/esm/prism-light'), import('react-syntax-highlighter/dist/esm/languages/prism/bash'), @@ -244,6 +280,13 @@ const CodeBlock: React.FC = React.memo( import('react-syntax-highlighter/dist/esm/languages/prism/markup'), import('react-syntax-highlighter/dist/esm/languages/prism/tsx'), import('react-syntax-highlighter/dist/esm/languages/prism/typescript'), + import('react-syntax-highlighter/dist/esm/languages/prism/diff'), + import('react-syntax-highlighter/dist/esm/languages/prism/python'), + import('react-syntax-highlighter/dist/esm/languages/prism/yaml'), + import('react-syntax-highlighter/dist/esm/languages/prism/sql'), + import('react-syntax-highlighter/dist/esm/languages/prism/go'), + import('react-syntax-highlighter/dist/esm/languages/prism/rust'), + import('react-syntax-highlighter/dist/esm/create-element'), ]); const SyntaxHighlighter = PrismLight as SyntaxHighlighterComponent; @@ -255,9 +298,18 @@ const CodeBlock: React.FC = React.memo( SyntaxHighlighter.registerLanguage('markup', markup.default); SyntaxHighlighter.registerLanguage('tsx', tsx.default); SyntaxHighlighter.registerLanguage('typescript', typescript.default); + SyntaxHighlighter.registerLanguage('diff', diff.default); + SyntaxHighlighter.registerLanguage('python', python.default); + SyntaxHighlighter.registerLanguage('yaml', yaml.default); + SyntaxHighlighter.registerLanguage('sql', sql.default); + SyntaxHighlighter.registerLanguage('go', go.default); + SyntaxHighlighter.registerLanguage('rust', rust.default); if (!cancelled) { - setSyntaxModule({ SyntaxHighlighter }); + setSyntaxModule({ + SyntaxHighlighter, + createElement: createElementModule.default as unknown as CreateElement, + }); } } @@ -318,14 +370,37 @@ const CodeBlock: React.FC = React.memo( } const { SyntaxHighlighter } = syntaxModule; - const normalizedLanguage = normalizeLanguage(currentSnippet.language); + const { isDiff, syntaxLanguage } = parseDiffLanguage(currentSnippet.language); + const normalizedLanguage = normalizeLanguage(syntaxLanguage); const snippetTitle = title ?? currentSnippet.title; const trimmedCode = currentSnippet.code.trim(); - const isDiff = currentSnippet.language.toLowerCase() === 'diff'; const codeLines = trimmedCode.split('\n'); const highlighted = new Set(currentSnippet.highlightLines ?? []); // Only wrap lines when a block uses line metadata, so plain blocks render exactly as before. const needsLineProps = isDiff || highlighted.size > 0; + const wordPattern = createWordPattern(currentSnippet.highlightWords ?? []); + const lineNumbersVisible = + lineNumberOverride ?? currentSnippet.showLineNumbers ?? showLineNumbers; + const { createElement: createHighlighterElement } = syntaxModule; + const wordRenderer = wordPattern + ? ({ + rows, + stylesheet, + useInlineStyles, + }: { + rows: HighlighterNode[]; + stylesheet: Record; + useInlineStyles: boolean; + }) => + rows.map((row, index) => + createHighlighterElement({ + node: markWords(row, wordPattern), + stylesheet, + useInlineStyles, + key: `code-row-${index}`, + }) + ) + : undefined; const lineProps = (lineNumber: number) => { const classes = [styles.codeLine]; const diffKind = getDiffLineKind(codeLines[lineNumber - 1] ?? '', isDiff); @@ -366,6 +441,17 @@ const CodeBlock: React.FC = React.memo( {currentSnippet.label || getLanguageDisplay(currentSnippet.language)} +
} + > + + + ); + } + + if (tagName === 'div' && element.classList.contains('papers-tabs')) { + const panels = Array.from(element.children) + .filter((child) => child.classList.contains('papers-tabs__panel')) + .map((panel, index) => { + const summary = Array.from(panel.children).find( + (child) => child.tagName.toLowerCase() === 'summary' + ); + const content = Array.from(panel.childNodes) + .filter((child) => child !== summary) + .map((child, childIndex) => convertHtmlNode(child, `${key}-${index}-${childIndex}`)); + return { label: summary?.textContent?.trim() || `Tab ${index + 1}`, content }; + }); + if (panels.length > 0) { + return ( + + ); } - buffer += element ? element.outerHTML : (node.textContent ?? ''); } - if (buffer) segments.push({ kind: 'html', html: buffer }); - return segments; + + const props = getElementProps(element, key); + delete props.style; + + if (VOID_HTML_TAGS.has(tagName)) { + return React.createElement(tagName, props); + } + + if (!element.querySelector(INTERACTIVE_HTML_SELECTOR)) { + return React.createElement(tagName, { + ...props, + dangerouslySetInnerHTML: { __html: element.innerHTML }, + }); + } + + return React.createElement( + tagName, + props, + Array.from(element.childNodes).map((child, index) => convertHtmlNode(child, `${key}-${index}`)) + ); } export function HtmlRenderer({ html }: HtmlRendererProps) { @@ -95,7 +135,15 @@ export function HtmlRenderer({ html }: HtmlRendererProps) { () => DOMPurify.sanitize(html, HTML_RENDERER_SANITIZE_CONFIG), [html] ); - const segments = useMemo(() => splitHtmlSegments(sanitizedHtml), [sanitizedHtml]); + const children = useMemo(() => { + if (typeof document === 'undefined') return null; + const template = document.createElement('template'); + template.innerHTML = sanitizedHtml; + if (!template.content.querySelector(INTERACTIVE_HTML_SELECTOR)) return null; + return Array.from(template.content.childNodes).map((node, index) => + convertHtmlNode(node, `html-${index}`) + ); + }, [sanitizedHtml]); // Typeset math placeholders emitted by the MDX pipeline (scripts/lib/mdxCompile.mjs). useEffect(() => { @@ -108,41 +156,13 @@ export function HtmlRenderer({ html }: HtmlRendererProps) { return () => { cancelled = true; }; - }, [segments]); + }, [sanitizedHtml]); - if (segments.length === 1 && segments[0].kind === 'html') { - return
; + if (!children) { + return
; } - return ( -
- {segments.map((segment, index) => - segment.kind === 'code' ? ( - - ) : segment.kind === 'mermaid' ? ( - Rendering diagram...
- } - > - - - ) : ( -
- ) - )} -
- ); + return
{children}
; } interface ProcessedMarkdownData { diff --git a/src/docs/content/developer-guides/callouts-in-mdx.mdx b/src/docs/content/developer-guides/callouts-in-mdx.mdx index 6b7a498..a180fa8 100644 --- a/src/docs/content/developer-guides/callouts-in-mdx.mdx +++ b/src/docs/content/developer-guides/callouts-in-mdx.mdx @@ -45,6 +45,42 @@ shortcode on its own lines. Never commit deployment credentials. +## Code Inside Callouts And Tabs + +Code blocks inside callouts and `` get the same copy button and syntax +colours as top-level blocks. + +:::tip Install command + +```bash title="install.sh" +npm install papers +``` + +::: + +## Synced Tabs + +Give `` a `group` key to keep tab sets in sync. Markdown pages use the +same keys (`:::tabs pkg`), so a choice made on one page carries over to the +other. + + + + +```bash +npm run dev +``` + + + + +```bash +pnpm dev +``` + + + + ## GitHub Alerts > [!TIP] diff --git a/test/authoring-features.test.ts b/test/authoring-features.test.ts index 8b9d4e1..d25e9ae 100644 --- a/test/authoring-features.test.ts +++ b/test/authoring-features.test.ts @@ -402,4 +402,17 @@ export const authoringFeatureTests: TestCase[] = [ assert.match(diffTs, /data-line-kind="added"/); }, }, + { + name: 'MDX emits a validated sync key', + run: async () => { + const grouped = await compileMdxToHtml( + '\n x\n' + ); + assert.match(grouped, /
/); + const invalid = await compileMdxToHtml( + '\n x\n' + ); + assert.doesNotMatch(invalid, /data-tabs-group/); + }, + }, ]; diff --git a/test/browser/docs.spec.mjs b/test/browser/docs.spec.mjs index fc3a26d..6941425 100644 --- a/test/browser/docs.spec.mjs +++ b/test/browser/docs.spec.mjs @@ -414,3 +414,27 @@ test('code blocks support word highlights, diff colours, and a line-number toggl ); await assertNoRuntimeErrors(errors); }); + +test('MDX nested code blocks are interactive and MDX tabs sync with Markdown tabs', async ({ + page, +}) => { + const errors = collectRuntimeErrors(page); + await page.goto('/docs/developer-guides/callouts-in-mdx', { waitUntil: 'networkidle' }); + const callout = page.getByRole('note', { name: 'Install command' }); + await expect(callout.locator('[data-code-title]', { hasText: 'install.sh' })).toBeVisible(); + await expect(callout.getByRole('button', { name: 'Copy to clipboard' })).toBeVisible(); + + const tabs = page.locator('.papers-tabs[data-tabs-group="pkg"]'); + await tabs.getByRole('tab', { name: 'pnpm', exact: true }).click(); + await expect(tabs.getByRole('tabpanel')).toContainText('pnpm dev'); + await expect(tabs.getByRole('button', { name: 'Copy to clipboard' })).toBeVisible(); + + await page.goto('/docs/developer-guides/callouts-and-code', { waitUntil: 'networkidle' }); + await expect( + page + .locator('.md-tabs[data-tabs-group="pkg"]') + .first() + .getByRole('tab', { name: 'pnpm', exact: true }) + ).toHaveAttribute('aria-selected', 'true'); + await assertNoRuntimeErrors(errors); +}); From 9f6559eb4f2e4f7b94becf482ccfb86b806856f1 Mon Sep 17 00:00:00 2001 From: Thomas Vu Date: Sun, 11 Oct 2026 11:48:43 -0400 Subject: [PATCH 4/7] feat: add Steps and Card grid components for md and mdx --- public/collections.json | 4 +- public/docs-collection.json | 29 ++++- .../developer-guides/callouts-in-mdx.json | 25 +++- .../developer-guides/steps-and-cards.json | 65 +++++++++++ public/docs-index.json | 6 +- public/docs-references.json | 57 ++++++++- public/llms-full.txt | 108 ++++++++++++++++++ public/llms.txt | 1 + scripts/lib/mdxCompile.mjs | 7 +- scripts/lib/mdxShortcodes.mjs | 47 +++++--- shared/codeFence.js | 11 ++ shared/documentation-config.js | 6 + shared/mdxComponents.js | 10 +- src/components/MarkdownRenderer.tsx | 2 +- .../developer-guides/callouts-in-mdx.mdx | 18 +++ .../developer-guides/steps-and-cards.md | 81 +++++++++++++ src/globals.css | 68 +++++++++++ src/utils/markdownCore.ts | 101 +++++++++++++++- test/authoring-features.test.ts | 45 +++++++- test/browser/docs.spec.mjs | 17 +++ types/shared-code-fence.d.ts | 1 + 21 files changed, 680 insertions(+), 29 deletions(-) create mode 100644 public/docs-content/developer-guides/steps-and-cards.json create mode 100644 src/docs/content/developer-guides/steps-and-cards.md diff --git a/public/collections.json b/public/collections.json index 27dbd2e..ed16fcf 100644 --- a/public/collections.json +++ b/public/collections.json @@ -1,7 +1,7 @@ { "contractVersion": 1, "schemaVersion": 1, - "generated": "2026-10-11T13:11:16.429Z", + "generated": "2026-10-11T15:47:41.245Z", "collections": [ { "id": "docs", @@ -15,7 +15,7 @@ "references": "/docs-references.json" }, "schemaVersion": 1, - "count": 32 + "count": 33 } ] } diff --git a/public/docs-collection.json b/public/docs-collection.json index 97d5a44..1a95312 100644 --- a/public/docs-collection.json +++ b/public/docs-collection.json @@ -11,8 +11,8 @@ "manifest": "/docs-collection.json", "references": "/docs-references.json" }, - "generated": "2026-10-11T13:11:16.429Z", - "count": 32, + "generated": "2026-10-11T15:47:41.245Z", + "count": 33, "schema": { "title": { "type": "string" @@ -190,6 +190,12 @@ "path": "developer-guides/callouts-in-mdx", "tags": ["callouts", "admonitions", "code", "mdx"] }, + { + "type": "file", + "name": "Steps And Cards.md", + "path": "developer-guides/steps-and-cards", + "tags": ["steps", "cards", "components", "layout"] + }, { "type": "file", "name": "Math And Diagrams.md", @@ -624,6 +630,25 @@ }, "sourcePath": "src/docs/content/developer-guides/math-in-mdx.mdx" }, + { + "path": "developer-guides/steps-and-cards", + "title": "Steps And Cards", + "description": "Numbered steps and link cards for guides and landing pages.", + "frontmatter": { + "description": "Numbered steps and link cards for guides and landing pages." + }, + "metadata": { + "title": "Steps And Cards", + "description": "Numbered steps and link cards for guides and landing pages.", + "canonicalPath": "/docs/developer-guides/steps-and-cards", + "contentFormat": "markdown", + "sourcePath": "src/docs/content/developer-guides/steps-and-cards.md", + "collection": "docs", + "routePrefix": "/docs", + "schemaVersion": 1 + }, + "sourcePath": "src/docs/content/developer-guides/steps-and-cards.md" + }, { "path": "developer-guides/tailwind-integration", "title": "Tailwind Integration", diff --git a/public/docs-content/developer-guides/callouts-in-mdx.json b/public/docs-content/developer-guides/callouts-in-mdx.json index 38164c2..c4174f9 100644 --- a/public/docs-content/developer-guides/callouts-in-mdx.json +++ b/public/docs-content/developer-guides/callouts-in-mdx.json @@ -6,7 +6,7 @@ "title": "Callouts And Code In MDX", "description": "Use callouts, GitHub alerts, and code fence titles, line highlights, and diffs in MDX pages." }, - "content": "

Callouts And Code In MDX

\n

.mdx pages support the same callout and code fence syntax as\nMarkdown pages. They are compiled\nto sanitized static HTML at build time.

\n

Callouts

\n

Write :::type Title on the first line and ::: on the last line. The five\ntypes are note, tip, info, warning, and danger. In MDX, use a\nplain-text title.

\n

Note

MDX callouts hold Markdown and code.

\n

Breaking change

generate:docs now validates frontmatter.

\n

Outer

Give the outer fence more colons to nest callouts.

Inner

Inner callout.

\n

:::details Title makes a collapsible callout that starts closed.

\n
Show more

Collapsible callouts work in MDX too.

\n

The <Callout> shortcode renders the same callout. type accepts note,\ntip, info, warning, danger, important, or caution. Put the\nshortcode on its own lines.

\n

Shortcode form

Never commit deployment credentials.

\n

Code Inside Callouts And Tabs

\n

Code blocks inside callouts and <Tabs> get the same copy button and syntax\ncolours as top-level blocks.

\n

Install command

install.sh
npm install papers
\n

Synced Tabs

\n

Give <Tabs> a group key to keep tab sets in sync. Markdown pages use the\nsame keys (:::tabs pkg), so a choice made on one page carries over to the\nother.

\n
npm
npm run dev\n
pnpm
pnpm dev\n
\n

GitHub Alerts

\n

Tip

GitHub alert syntax also works in MDX.

\n

Code Fence Metadata

\n
setup.sh
npm ci\nnpm run generate:docs\nnpm run dev
\n
package.json
-    \"build\": \"vite build\"\n+    \"build\": \"npm run generate:docs && vite build\"
\n

Code blocks in MDX pages use the same viewer as Markdown pages, with a copy button\nand syntax colours.

", + "content": "

Callouts And Code In MDX

\n

.mdx pages support the same callout and code fence syntax as\nMarkdown pages. They are compiled\nto sanitized static HTML at build time.

\n

Callouts

\n

Write :::type Title on the first line and ::: on the last line. The five\ntypes are note, tip, info, warning, and danger. In MDX, use a\nplain-text title.

\n

Note

MDX callouts hold Markdown and code.

\n

Breaking change

generate:docs now validates frontmatter.

\n

Outer

Give the outer fence more colons to nest callouts.

Inner

Inner callout.

\n

:::details Title makes a collapsible callout that starts closed.

\n
Show more

Collapsible callouts work in MDX too.

\n

The <Callout> shortcode renders the same callout. type accepts note,\ntip, info, warning, danger, important, or caution. Put the\nshortcode on its own lines.

\n

Shortcode form

Never commit deployment credentials.

\n

Code Inside Callouts And Tabs

\n

Code blocks inside callouts and <Tabs> get the same copy button and syntax\ncolours as top-level blocks.

\n

Install command

install.sh
npm install papers
\n

Synced Tabs

\n

Give <Tabs> a group key to keep tab sets in sync. Markdown pages use the\nsame keys (:::tabs pkg), so a choice made on one page carries over to the\nother.

\n
npm
npm run dev\n
pnpm
pnpm dev\n
\n

Steps And Cards

\n
    \n
  1. Install the dependencies.
  2. \n
  3. Start the dev server.
  4. \n
\n

Steps and cards

The Markdown syntax for steps and cards.

Callouts and code

Callouts and code blocks in Markdown.

\n

GitHub Alerts

\n

Tip

GitHub alert syntax also works in MDX.

\n

Code Fence Metadata

\n
setup.sh
npm ci\nnpm run generate:docs\nnpm run dev
\n
package.json
-    \"build\": \"vite build\"\n+    \"build\": \"npm run generate:docs && vite build\"
\n

Code blocks in MDX pages use the same viewer as Markdown pages, with a copy button\nand syntax colours.

", "contentFormat": "html", "toc": [ { @@ -29,6 +29,11 @@ "id": "synced-tabs", "text": "Synced Tabs" }, + { + "depth": 2, + "id": "steps-and-cards", + "text": "Steps And Cards" + }, { "depth": 2, "id": "github-alerts", @@ -54,6 +59,14 @@ { "target": "/docs/developer-guides/callouts-and-code", "line": 9 + }, + { + "target": "/docs/developer-guides/steps-and-cards", + "line": 94 + }, + { + "target": "/docs/developer-guides/callouts-and-code", + "line": 97 } ], "links": [ @@ -61,6 +74,16 @@ "target": "/docs/developer-guides/callouts-and-code", "line": 9, "type": "internal" + }, + { + "target": "/docs/developer-guides/steps-and-cards", + "line": 94, + "type": "internal" + }, + { + "target": "/docs/developer-guides/callouts-and-code", + "line": 97, + "type": "internal" } ], "sourcePath": "src/docs/content/developer-guides/callouts-in-mdx.mdx" diff --git a/public/docs-content/developer-guides/steps-and-cards.json b/public/docs-content/developer-guides/steps-and-cards.json new file mode 100644 index 0000000..3fe56a7 --- /dev/null +++ b/public/docs-content/developer-guides/steps-and-cards.json @@ -0,0 +1,65 @@ +{ + "path": "developer-guides/steps-and-cards", + "title": "Steps And Cards", + "description": "Numbered steps and link cards for guides and landing pages.", + "frontmatter": { + "description": "Numbered steps and link cards for guides and landing pages." + }, + "content": "# Steps And Cards\n\nUse steps for procedures that readers follow in order, and cards to point\nreaders to other pages.\n\n## Steps\n\nWrap an ordered list in `:::steps`. Each list item becomes a numbered step.\n\n```md\n:::steps\n\n1. Install the dependencies.\n2. Start the dev server.\n3. Open the local URL.\n :::\n```\n\n:::steps\n\n1. Install the dependencies.\n\n ```bash\n npm install\n ```\n\n2. Start the dev server.\n\n ```bash\n npm run dev\n ```\n\n3. Open the URL that the dev server prints.\n\n:::\n\n## Cards\n\nPut cards in `:::cards`. Start each card with a `::card Title` line. To link\nthe card, write the title as a Markdown link. Links must be site paths that\nstart with `/` or `https://` URLs. Other links are dropped and the title shows\nas plain text.\n\n```md\n:::cards\n::card [Callouts and code](/docs/developer-guides/callouts-and-code)\nAdmonitions, code titles, and diffs.\n::card [MDX authoring](/docs/developer-guides/callouts-in-mdx)\nThe same features in `.mdx` pages.\n:::\n```\n\n:::cards\n::card [Callouts and code](/docs/developer-guides/callouts-and-code)\nAdmonitions, code titles, and diffs.\n::card [MDX authoring](/docs/developer-guides/callouts-in-mdx)\nThe same features in `.mdx` pages.\n:::\n\n## In MDX\n\nMDX pages use ``, ``, and ``:\n\n```mdx\n\n\n1. Install the dependencies.\n2. Start the dev server.\n\n\n\n\n \n Admonitions, code titles, and diffs.\n \n\n```\n", + "contentFormat": "markdown", + "toc": [ + { + "depth": 1, + "id": "steps-and-cards", + "text": "Steps And Cards" + }, + { + "depth": 2, + "id": "steps", + "text": "Steps" + }, + { + "depth": 2, + "id": "cards", + "text": "Cards" + }, + { + "depth": 2, + "id": "in-mdx", + "text": "In MDX" + } + ], + "metadata": { + "title": "Steps And Cards", + "description": "Numbered steps and link cards for guides and landing pages.", + "canonicalPath": "/docs/developer-guides/steps-and-cards", + "contentFormat": "markdown", + "sourcePath": "src/docs/content/developer-guides/steps-and-cards.md", + "collection": "docs", + "routePrefix": "/docs", + "schemaVersion": 1 + }, + "references": [ + { + "target": "/docs/developer-guides/callouts-and-code", + "line": 58 + }, + { + "target": "/docs/developer-guides/callouts-in-mdx", + "line": 60 + } + ], + "links": [ + { + "target": "/docs/developer-guides/callouts-and-code", + "line": 58, + "type": "internal" + }, + { + "target": "/docs/developer-guides/callouts-in-mdx", + "line": 60, + "type": "internal" + } + ], + "sourcePath": "src/docs/content/developer-guides/steps-and-cards.md" +} diff --git a/public/docs-index.json b/public/docs-index.json index a125116..39b1406 100644 --- a/public/docs-index.json +++ b/public/docs-index.json @@ -1,5 +1,5 @@ { - "generated": "2026-10-11T13:11:16.429Z", + "generated": "2026-10-11T15:47:41.245Z", "paths": [ "api-reference/content-pipeline", "api-reference/openapi", @@ -19,6 +19,7 @@ "developer-guides/icon-customization", "developer-guides/math-and-diagrams", "developer-guides/math-in-mdx", + "developer-guides/steps-and-cards", "developer-guides/tailwind-integration", "developer-guides/themes", "developer-guides/ui-configuration", @@ -34,7 +35,7 @@ "user-guide/interactive-map", "user-guide/troubleshooting" ], - "count": 32, + "count": 33, "titles": { "api-reference/content-pipeline": "Content Pipeline", "api-reference/openapi": "OpenAPI Explorer", @@ -54,6 +55,7 @@ "developer-guides/icon-customization": "Icon Customization", "developer-guides/math-and-diagrams": "Math And Diagrams", "developer-guides/math-in-mdx": "Math And Diagrams In MDX", + "developer-guides/steps-and-cards": "Steps And Cards", "developer-guides/tailwind-integration": "Tailwind Integration", "developer-guides/themes": "Themes", "developer-guides/ui-configuration": "UI Configuration", diff --git a/public/docs-references.json b/public/docs-references.json index 182d2ba..425f5f4 100644 --- a/public/docs-references.json +++ b/public/docs-references.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generated": "2026-10-11T13:11:16.429Z", + "generated": "2026-10-11T15:47:41.245Z", "collection": "docs", "nodes": [ { @@ -111,6 +111,12 @@ "title": "Math And Diagrams In MDX", "description": "KaTeX math and Mermaid diagrams in .mdx pages, alongside MDX shortcodes." }, + { + "path": "developer-guides/steps-and-cards", + "href": "/docs/developer-guides/steps-and-cards", + "title": "Steps And Cards", + "description": "Numbered steps and link cards for guides and landing pages." + }, { "path": "developer-guides/tailwind-integration", "href": "/docs/developer-guides/tailwind-integration", @@ -245,6 +251,30 @@ "href": "/docs/developer-guides/callouts-and-code", "line": 9 }, + { + "source": "developer-guides/callouts-in-mdx", + "target": "developer-guides/callouts-and-code", + "href": "/docs/developer-guides/callouts-and-code", + "line": 97 + }, + { + "source": "developer-guides/callouts-in-mdx", + "target": "developer-guides/steps-and-cards", + "href": "/docs/developer-guides/steps-and-cards", + "line": 94 + }, + { + "source": "developer-guides/steps-and-cards", + "target": "developer-guides/callouts-and-code", + "href": "/docs/developer-guides/callouts-and-code", + "line": 58 + }, + { + "source": "developer-guides/steps-and-cards", + "target": "developer-guides/callouts-in-mdx", + "href": "/docs/developer-guides/callouts-in-mdx", + "line": 60 + }, { "source": "developer-guides/ui-configuration", "target": "developer-guides/themes", @@ -423,9 +453,25 @@ "source": "developer-guides/callouts-in-mdx", "href": "/docs/developer-guides/callouts-in-mdx", "line": 9 + }, + { + "source": "developer-guides/callouts-in-mdx", + "href": "/docs/developer-guides/callouts-in-mdx", + "line": 97 + }, + { + "source": "developer-guides/steps-and-cards", + "href": "/docs/developer-guides/steps-and-cards", + "line": 58 + } + ], + "developer-guides/callouts-in-mdx": [ + { + "source": "developer-guides/steps-and-cards", + "href": "/docs/developer-guides/steps-and-cards", + "line": 60 } ], - "developer-guides/callouts-in-mdx": [], "developer-guides/character-notes": [], "developer-guides/code-examples": [ { @@ -444,6 +490,13 @@ "developer-guides/icon-customization": [], "developer-guides/math-and-diagrams": [], "developer-guides/math-in-mdx": [], + "developer-guides/steps-and-cards": [ + { + "source": "developer-guides/callouts-in-mdx", + "href": "/docs/developer-guides/callouts-in-mdx", + "line": 94 + } + ], "developer-guides/tailwind-integration": [], "developer-guides/themes": [ { diff --git a/public/llms-full.txt b/public/llms-full.txt index e498699..4fdde31 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -1408,6 +1408,24 @@ pnpm dev +## Steps And Cards + + + +1. Install the dependencies. +2. Start the dev server. + + + + + + The Markdown syntax for steps and cards. + + + Callouts and code blocks in Markdown. + + + ## GitHub Alerts > [!TIP] @@ -1433,6 +1451,96 @@ and syntax colours. --- +### Steps And Cards + +URL: /docs/developer-guides/steps-and-cards + +--- +description: Numbered steps and link cards for guides and landing pages. +--- + +# Steps And Cards + +Use steps for procedures that readers follow in order, and cards to point +readers to other pages. + +## Steps + +Wrap an ordered list in `:::steps`. Each list item becomes a numbered step. + +```md +:::steps + +1. Install the dependencies. +2. Start the dev server. +3. Open the local URL. + ::: +``` + +:::steps + +1. Install the dependencies. + + ```bash + npm install + ``` + +2. Start the dev server. + + ```bash + npm run dev + ``` + +3. Open the URL that the dev server prints. + +::: + +## Cards + +Put cards in `:::cards`. Start each card with a `::card Title` line. To link +the card, write the title as a Markdown link. Links must be site paths that +start with `/` or `https://` URLs. Other links are dropped and the title shows +as plain text. + +```md +:::cards +::card [Callouts and code](/docs/developer-guides/callouts-and-code) +Admonitions, code titles, and diffs. +::card [MDX authoring](/docs/developer-guides/callouts-in-mdx) +The same features in `.mdx` pages. +::: +``` + +:::cards +::card [Callouts and code](/docs/developer-guides/callouts-and-code) +Admonitions, code titles, and diffs. +::card [MDX authoring](/docs/developer-guides/callouts-in-mdx) +The same features in `.mdx` pages. +::: + +## In MDX + +MDX pages use ``, ``, and ``: + +```mdx + + +1. Install the dependencies. +2. Start the dev server. + + + + + + Admonitions, code titles, and diffs. + + +``` + + +--- + + ### Math And Diagrams URL: /docs/developer-guides/math-and-diagrams diff --git a/public/llms.txt b/public/llms.txt index 1eccf9d..fea8ca4 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -31,6 +31,7 @@ A minimal docs starter with install-ready defaults, generated content, and stati - [Code Examples](/docs/developer-guides/code-examples): This page shows the main authoring patterns supported by the current runtime. - [Callouts And Code Blocks](/docs/developer-guides/callouts-and-code): Callouts (admonitions), GitHub-style alerts, code block titles, line highlighting, and diffs. - [Callouts And Code In MDX](/docs/developer-guides/callouts-in-mdx): Use callouts, GitHub alerts, and code fence titles, line highlights, and diffs in MDX pages. +- [Steps And Cards](/docs/developer-guides/steps-and-cards): Numbered steps and link cards for guides and landing pages. - [Math And Diagrams](/docs/developer-guides/math-and-diagrams): Papers typesets TeX with KaTeX and renders Mermaid diagrams, including diagrams whose labels contain math. KaTeX loads only on pages that contain math. - [Math And Diagrams In MDX](/docs/developer-guides/math-in-mdx): KaTeX math and Mermaid diagrams in .mdx pages, alongside MDX shortcodes. - [Tailwind Integration](/docs/developer-guides/tailwind-integration): Papers is built with Tailwind CSS 4 (@tailwindcss/vite). This page explains the CSS contract so a downstream Tailwind 4 site can embed or restyle Papers... diff --git a/scripts/lib/mdxCompile.mjs b/scripts/lib/mdxCompile.mjs index 1ca22af..7737e3c 100644 --- a/scripts/lib/mdxCompile.mjs +++ b/scripts/lib/mdxCompile.mjs @@ -25,7 +25,7 @@ const sanitizeSchema = { ...(defaultSchema.attributes?.div || []), [ 'className', - /^(papers-(callout|tabs|card|character-note|code)|admonition|formula|math-display|mermaid-placeholder)/, + /^(papers-(callout|tabs|card|character-note|code|steps)|admonition|formula|math-display|mermaid-placeholder)/, ], 'ariaLabel', 'dataCodeLanguage', @@ -70,7 +70,10 @@ const sanitizeSchema = { ...(defaultSchema.attributes?.p || []), ['className', /^(papers-(callout|card|character-note)|admonition-title)/], ], - a: [...(defaultSchema.attributes?.a || []), ['target', 'rel']], + a: [ + ...(defaultSchema.attributes?.a || []), + ['target', 'rel'], + ], }, tagNames: [...(defaultSchema.tagNames || []), 'details', 'summary'], }; diff --git a/scripts/lib/mdxShortcodes.mjs b/scripts/lib/mdxShortcodes.mjs index a26acc7..3f28abf 100644 --- a/scripts/lib/mdxShortcodes.mjs +++ b/scripts/lib/mdxShortcodes.mjs @@ -1,3 +1,4 @@ +import { sanitizeCardHref } from '../../shared/codeFence.js'; import { createAdmonitionNode, defaultAdmonitionTitle, toAdmonitionType } from './mdxAuthoring.mjs'; import { characterNoteAvatars } from '../../shared/character-note-config.js'; import { MDX_COMPONENT_REGISTRY, normalizeMdxComponentNames } from '../../shared/mdxComponents.js'; @@ -255,18 +256,20 @@ function transformCharacterNote(node) { function transformCard(node) { const title = readJsxAttribute(node.attributes, 'title'); + const href = sanitizeCardHref(readJsxAttribute(node.attributes, 'href')); const children = [...normalizeChildren(node.children)]; if (title) { + const titleText = { type: 'text', value: title }; children.unshift({ type: 'paragraph', data: { hName: 'p', hProperties: { - className: 'papers-card__title', + className: href ? 'papers-card__title papers-card__title--link' : 'papers-card__title', }, }, - children: [{ type: 'text', value: title }], + children: href ? [{ type: 'link', url: href, children: [titleText] }] : [titleText], }); } @@ -279,6 +282,16 @@ function transformCard(node) { ); } +function transformCardGrid(node) { + return createElement('div', { className: 'papers-card-grid' }, [ + ...normalizeChildren(node.children), + ]); +} + +function transformSteps(node) { + return createElement('div', { className: 'papers-steps' }, [...normalizeChildren(node.children)]); +} + function transformShortcode(node) { if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') { return null; @@ -297,6 +310,10 @@ function transformShortcode(node) { return transformTab(node); case 'Card': return transformCard(node); + case 'CardGrid': + return transformCardGrid(node); + case 'Steps': + return transformSteps(node); case 'CharacterNote': return transformCharacterNote(node); default: @@ -318,7 +335,7 @@ function visitChildren(node, visitor) { }); } -const BLOCK_SHORTCODES = new Set(['Callout', 'Tabs', 'Card', 'CharacterNote']); +const BLOCK_SHORTCODES = new Set(['Callout', 'Tabs', 'Card', 'CardGrid', 'Steps', 'CharacterNote']); /** * `text` written on one line parses as a text element inside a @@ -330,25 +347,29 @@ export function hoistInlineBlockShortcodes(node) { return; } - node.children = node.children.map((child) => { + node.children = node.children.flatMap((child) => { if (child.type === 'paragraph') { const meaningful = child.children.filter( (leaf) => !(leaf.type === 'text' && leaf.value.trim() === '') ); - const [only] = meaningful; + // A paragraph holding only block shortcodes (e.g. several s on + // adjacent lines) becomes those blocks. if ( - meaningful.length === 1 && - only.type === 'mdxJsxTextElement' && - BLOCK_SHORTCODES.has(only.name) + meaningful.length > 0 && + meaningful.every( + (leaf) => leaf.type === 'mdxJsxTextElement' && BLOCK_SHORTCODES.has(leaf.name) + ) ) { - const hoisted = { ...only, type: 'mdxJsxFlowElement' }; - hoistInlineBlockShortcodes(hoisted); - return hoisted; + return meaningful.map((leaf) => { + const hoisted = { ...leaf, type: 'mdxJsxFlowElement' }; + hoistInlineBlockShortcodes(hoisted); + return hoisted; + }); } - return child; + return [child]; } hoistInlineBlockShortcodes(child); - return child; + return [child]; }); } diff --git a/shared/codeFence.js b/shared/codeFence.js index e5557ae..81052d6 100644 --- a/shared/codeFence.js +++ b/shared/codeFence.js @@ -84,3 +84,14 @@ export function getDiffLineKind(line, isDiff) { if (line.startsWith('-') && !line.startsWith('---')) return 'removed'; return null; } + +/** + * Card links accept site-relative paths (`/docs/...`) and https URLs only. + * Anything else (protocol-relative, javascript:, data:) is dropped. + */ +export function sanitizeCardHref(href) { + const value = String(href || '').trim(); + if (/^\/(?!\/)[^\s"'<>]*$/.test(value)) return value; + if (/^https:\/\/[^\s"'<>]+$/i.test(value)) return value; + return null; +} diff --git a/shared/documentation-config.js b/shared/documentation-config.js index 98626e5..eaed98b 100644 --- a/shared/documentation-config.js +++ b/shared/documentation-config.js @@ -343,6 +343,12 @@ export const documentationTree = [ path: 'developer-guides/callouts-in-mdx', tags: ['callouts', 'admonitions', 'code', 'mdx'], }, + { + type: 'file', + name: 'Steps And Cards.md', + path: 'developer-guides/steps-and-cards', + tags: ['steps', 'cards', 'components', 'layout'], + }, { type: 'file', name: 'Math And Diagrams.md', diff --git a/shared/mdxComponents.js b/shared/mdxComponents.js index 542641a..885be32 100644 --- a/shared/mdxComponents.js +++ b/shared/mdxComponents.js @@ -20,9 +20,17 @@ export const MDX_COMPONENT_REGISTRY = Object.freeze({ output: 'sanitized details panel', }), Card: Object.freeze({ - allowedProps: Object.freeze(['title']), + allowedProps: Object.freeze(['title', 'href']), output: 'sanitized card div', }), + CardGrid: Object.freeze({ + allowedProps: Object.freeze([]), + output: 'sanitized card grid div', + }), + Steps: Object.freeze({ + allowedProps: Object.freeze([]), + output: 'sanitized numbered steps div', + }), CharacterNote: Object.freeze({ allowedProps: Object.freeze(['type', 'title']), output: 'sanitized character note div', diff --git a/src/components/MarkdownRenderer.tsx b/src/components/MarkdownRenderer.tsx index 64d99dd..7bd9e4f 100644 --- a/src/components/MarkdownRenderer.tsx +++ b/src/components/MarkdownRenderer.tsx @@ -55,7 +55,7 @@ function readMermaidChart(element: Element): string | null { * callouts and tabs), Mermaid placeholders, and `` become components; every * other subtree stays static HTML so math hydration can still typeset it. */ -export function convertHtmlNode(node: ChildNode, key: string): React.ReactNode { +function convertHtmlNode(node: ChildNode, key: string): React.ReactNode { if (node.nodeType === Node.TEXT_NODE) { return node.textContent; } diff --git a/src/docs/content/developer-guides/callouts-in-mdx.mdx b/src/docs/content/developer-guides/callouts-in-mdx.mdx index a180fa8..8ae8e0d 100644 --- a/src/docs/content/developer-guides/callouts-in-mdx.mdx +++ b/src/docs/content/developer-guides/callouts-in-mdx.mdx @@ -81,6 +81,24 @@ pnpm dev +## Steps And Cards + + + +1. Install the dependencies. +2. Start the dev server. + + + + + + The Markdown syntax for steps and cards. + + + Callouts and code blocks in Markdown. + + + ## GitHub Alerts > [!TIP] diff --git a/src/docs/content/developer-guides/steps-and-cards.md b/src/docs/content/developer-guides/steps-and-cards.md new file mode 100644 index 0000000..59f1b51 --- /dev/null +++ b/src/docs/content/developer-guides/steps-and-cards.md @@ -0,0 +1,81 @@ +--- +description: Numbered steps and link cards for guides and landing pages. +--- + +# Steps And Cards + +Use steps for procedures that readers follow in order, and cards to point +readers to other pages. + +## Steps + +Wrap an ordered list in `:::steps`. Each list item becomes a numbered step. + +```md +:::steps + +1. Install the dependencies. +2. Start the dev server. +3. Open the local URL. + ::: +``` + +:::steps + +1. Install the dependencies. + + ```bash + npm install + ``` + +2. Start the dev server. + + ```bash + npm run dev + ``` + +3. Open the URL that the dev server prints. + +::: + +## Cards + +Put cards in `:::cards`. Start each card with a `::card Title` line. To link +the card, write the title as a Markdown link. Links must be site paths that +start with `/` or `https://` URLs. Other links are dropped and the title shows +as plain text. + +```md +:::cards +::card [Callouts and code](/docs/developer-guides/callouts-and-code) +Admonitions, code titles, and diffs. +::card [MDX authoring](/docs/developer-guides/callouts-in-mdx) +The same features in `.mdx` pages. +::: +``` + +:::cards +::card [Callouts and code](/docs/developer-guides/callouts-and-code) +Admonitions, code titles, and diffs. +::card [MDX authoring](/docs/developer-guides/callouts-in-mdx) +The same features in `.mdx` pages. +::: + +## In MDX + +MDX pages use ``, ``, and ``: + +```mdx + + +1. Install the dependencies. +2. Start the dev server. + + + + + + Admonitions, code titles, and diffs. + + +``` diff --git a/src/globals.css b/src/globals.css index c6f20e6..979dff9 100644 --- a/src/globals.css +++ b/src/globals.css @@ -1532,6 +1532,74 @@ color: var(--papers-text-color); } + .doc-content .papers-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: var(--space-4); + margin: var(--space-6) 0; + } + + .doc-content .papers-card-grid > .papers-card { + margin: 0; + } + + .doc-content .papers-card > :last-child { + margin-bottom: 0; + } + + .doc-content .papers-card__title--link a { + color: inherit; + text-decoration: none; + } + + .doc-content .papers-card__title--link a::after { + content: ' \2192'; + color: var(--primary-color); + } + + .doc-content .papers-card:has(.papers-card__title--link a:hover) { + border-color: var(--primary-color); + } + + /* Numbered steps (:::steps / ) */ + .doc-content .papers-steps > ol { + counter-reset: papers-step; + list-style: none; + margin: var(--space-6) 0; + padding-left: 0; + } + + .doc-content .papers-steps > ol > li { + position: relative; + counter-increment: papers-step; + margin: 0; + padding: 0 0 var(--space-5) 2.75rem; + border-left: 1px solid var(--border-unified); + margin-left: 0.875rem; + } + + .doc-content .papers-steps > ol > li:last-child { + border-left-color: transparent; + } + + .doc-content .papers-steps > ol > li::before { + content: counter(papers-step); + position: absolute; + left: -0.875rem; + top: 0; + display: grid; + place-items: center; + width: 1.75rem; + height: 1.75rem; + border: 1px solid var(--border-unified); + border-radius: 999px; + background: var(--card-color); + color: var(--papers-text-color); + font-family: var(--mono-font); + font-size: 0.8125rem; + font-weight: 700; + } + .doc-content .role-lane-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); diff --git a/src/utils/markdownCore.ts b/src/utils/markdownCore.ts index f193764..7c5e195 100644 --- a/src/utils/markdownCore.ts +++ b/src/utils/markdownCore.ts @@ -7,11 +7,18 @@ import { parseDiffLanguage, parseCodeFenceInfo, parseHighlightLines, + sanitizeCardHref, type CodeFenceInfo, type CodeLineKind, } from '../../shared/codeFence.js'; -export { getDiffLineKind, parseCodeFenceInfo, parseDiffLanguage, parseHighlightLines }; +export { + getDiffLineKind, + parseCodeFenceInfo, + parseDiffLanguage, + parseHighlightLines, + sanitizeCardHref, +}; export type { CodeFenceInfo, CodeLineKind }; export interface CodeBlockData { @@ -283,6 +290,90 @@ export function splitTabPanels(body: string): Array<{ label: string; content: st }); } +// `:::steps` wraps an ordered list as numbered steps. +const STEPS_PATTERN = /^ {0,3}(:{3,})[ \t]*steps[ \t]*\n([\s\S]*?)\n? {0,3}\1[ \t]*(?:\n+|$)/i; +// `:::cards` holds `::card Title` or `::card [Title](/docs/path)` sections. +const CARDS_PATTERN = /^ {0,3}(:{3,})[ \t]*cards[ \t]*\n([\s\S]*?)\n? {0,3}\1[ \t]*(?:\n+|$)/i; +const CARD_MARKER_PATTERN = /^ {0,3}::card[ \t]+([^\n]+)$/gm; +const CARD_LINK_PATTERN = /^\[([^\]]+)\]\((.+)\)$/; + +type BlockLexer = { + lexer: { blockTokens: (src: string, tokens?: Tokens.Generic[]) => Tokens.Generic[] }; +}; +type BlockParser = { parser: { parse: (tokens: Tokens.Generic[]) => string } }; + +/** Split `::card` sections and resolve an optional safe link from `[Title](href)`. */ +export function splitCards( + body: string +): Array<{ title: string; href: string | null; content: string }> { + const markers = Array.from(body.matchAll(CARD_MARKER_PATTERN)); + return markers.map((marker, index) => { + const start = (marker.index ?? 0) + marker[0].length; + const end = + index + 1 < markers.length ? (markers[index + 1].index ?? body.length) : body.length; + const label = marker[1].trim(); + const link = CARD_LINK_PATTERN.exec(label); + return { + title: link ? link[1].trim() : label, + href: link ? sanitizeCardHref(link[2]) : null, + content: body.slice(start, end).replace(/^\n/, ''), + }; + }); +} + +const stepsExtension = { + name: 'steps', + level: 'block' as const, + start(src: string) { + return src.match(/^ {0,3}:{3,}[ \t]*steps\b/im)?.index; + }, + tokenizer(this: BlockLexer, src: string) { + const match = STEPS_PATTERN.exec(src); + if (!match) return undefined; + return { type: 'steps', raw: match[0], tokens: this.lexer.blockTokens(match[2], []) }; + }, + renderer(this: BlockParser, token: Tokens.Generic) { + return `
${this.parser.parse(token.tokens as Tokens.Generic[])}
\n`; + }, +}; + +const cardsExtension = { + name: 'cards', + level: 'block' as const, + start(src: string) { + return src.match(/^ {0,3}:{3,}[ \t]*cards\b/im)?.index; + }, + tokenizer(this: BlockLexer, src: string) { + const match = CARDS_PATTERN.exec(src); + if (!match) return undefined; + const cards = splitCards(match[2]); + if (cards.length === 0) return undefined; + return { + type: 'cards', + raw: match[0], + cards: cards.map((card) => ({ ...card, tokens: this.lexer.blockTokens(card.content, []) })), + }; + }, + renderer(this: BlockParser, token: Tokens.Generic) { + const cards = token.cards as Array<{ + title: string; + href: string | null; + tokens: Tokens.Generic[]; + }>; + const body = cards + .map((card) => { + const title = escapeHtml(card.title); + const heading = card.href ? `${title}` : title; + const titleClass = card.href + ? 'papers-card__title papers-card__title--link' + : 'papers-card__title'; + return `

${heading}

${this.parser.parse(card.tokens)}
`; + }) + .join(''); + return `
${body}
\n`; + }, +}; + const tabsExtension = { name: 'contentTabs', level: 'block' as const, @@ -399,7 +490,13 @@ export async function buildMarkdownRenderState( } ); markdownProcessor.use({ - extensions: [tabsExtension, ...admonitionExtensions, ...mathExtensions], + extensions: [ + tabsExtension, + stepsExtension, + cardsExtension, + ...admonitionExtensions, + ...mathExtensions, + ], }); const html = await markdownProcessor.parse(stripMarkdownBom(content)); diff --git a/test/authoring-features.test.ts b/test/authoring-features.test.ts index d25e9ae..ddaf046 100644 --- a/test/authoring-features.test.ts +++ b/test/authoring-features.test.ts @@ -14,7 +14,7 @@ import { resolveTabIndex } from '../src/lib/tabSync.ts'; import { compileMdxToHtml } from '../scripts/lib/mdxCompile.mjs'; import { readHtmlCodeBlock } from '../src/utils/htmlCodeBlocks.ts'; import { createWordPattern, markWords, type HighlighterNode } from '../src/utils/codeWords.ts'; -import { parseCodeFenceMeta, parseDiffLanguage } from '../shared/codeFence.js'; +import { parseCodeFenceMeta, parseDiffLanguage, sanitizeCardHref } from '../shared/codeFence.js'; type TestCase = { name: string; run: () => void | Promise }; @@ -415,4 +415,47 @@ export const authoringFeatureTests: TestCase[] = [ assert.doesNotMatch(invalid, /data-tabs-group/); }, }, + { + name: ':::steps and :::cards render numbered steps and safe link cards', + run: async () => { + const steps = await render(':::steps\n1. One\n2. Two\n:::'); + assert.match(steps.html, /^
    \s*
  1. One<\/li>/); + const cards = await render( + ':::cards\n::card [Go](/docs/x)\nBody\n::card [Bad](javascript:alert(1))\nB\n::card Plain\nC\n:::' + ); + assert.match(cards.html, /^
    /); + assert.match( + cards.html, + /

    Body<\/p>/ + ); + assert.match(cards.html, /

    Bad<\/p>/); + assert.doesNotMatch(cards.html, /javascript:/); + assert.match(cards.html, /

    Plain<\/p>/); + }, + }, + { + name: 'sanitizeCardHref only allows site paths and https URLs', + run: () => { + assert.equal(sanitizeCardHref('/docs/a#b'), '/docs/a#b'); + assert.equal(sanitizeCardHref('https://example.com/x'), 'https://example.com/x'); + for (const bad of ['//evil.com', 'javascript:alert(1)', 'http://x', 'data:text/html,x', '']) { + assert.equal(sanitizeCardHref(bad), null, bad); + } + }, + }, + { + name: 'MDX , , and render sanitized markup', + run: async () => { + const html = await compileMdxToHtml( + '\n\n1. One\n\n\n\n\n Body\n B\n' + ); + assert.match(html, /

      /); + assert.match( + html, + /

      Bad<\/p>/); + assert.doesNotMatch(html, /javascript:/); + }, + }, ]; diff --git a/test/browser/docs.spec.mjs b/test/browser/docs.spec.mjs index 6941425..2c71f68 100644 --- a/test/browser/docs.spec.mjs +++ b/test/browser/docs.spec.mjs @@ -438,3 +438,20 @@ test('MDX nested code blocks are interactive and MDX tabs sync with Markdown tab ).toHaveAttribute('aria-selected', 'true'); await assertNoRuntimeErrors(errors); }); + +test('steps and cards render in Markdown and MDX', async ({ page }) => { + const errors = collectRuntimeErrors(page); + await page.goto('/docs/developer-guides/steps-and-cards', { waitUntil: 'networkidle' }); + await expect(page.locator('.papers-steps > ol > li')).toHaveCount(3); + await expect( + page.locator('.papers-steps').getByRole('button', { name: 'Copy to clipboard' }) + ).toHaveCount(2); + const card = page.locator('.papers-card-grid .papers-card').first(); + await card.getByRole('link', { name: 'Callouts and code' }).click(); + await expect(page).toHaveURL(/\/docs\/developer-guides\/callouts-and-code$/); + + await page.goto('/docs/developer-guides/callouts-in-mdx', { waitUntil: 'networkidle' }); + await expect(page.locator('.papers-steps > ol > li')).toHaveCount(2); + await expect(page.locator('.papers-card-grid .papers-card')).toHaveCount(2); + await assertNoRuntimeErrors(errors); +}); diff --git a/types/shared-code-fence.d.ts b/types/shared-code-fence.d.ts index bbba813..e5ae365 100644 --- a/types/shared-code-fence.d.ts +++ b/types/shared-code-fence.d.ts @@ -20,4 +20,5 @@ declare module '*/shared/codeFence.js' { syntaxLanguage: string; }; export function getDiffLineKind(line: string, isDiff: boolean): CodeLineKind; + export function sanitizeCardHref(href: string | undefined): string | null; } From 58985188540a09b6a9bc36bf3e301c73ea5b4ff0 Mon Sep 17 00:00:00 2001 From: Thomas Vu Date: Sun, 11 Oct 2026 11:59:35 -0400 Subject: [PATCH 5/7] feat(search): ranking, docs/api scopes, and variant filters Also fixes Pagefind loading. pagefind.js is an ES module and never set window.pagefind, so the palette always used the local fallback. --- .../user-guide/command-palette.json | 15 ++- public/docs-references.json | 8 +- public/llms-full.txt | 9 ++ scripts/lib/pagefindArtifacts.mjs | 10 ++ .../CommandPalette/useSearchLogic.ts | 15 ++- .../content/user-guide/command-palette.md | 9 ++ src/hooks/usePagefind.ts | 95 +++++++------------ src/lib/searchRanking.ts | 92 ++++++++++++++++++ test/browser/docs.spec.mjs | 28 ++++++ test/docs-parity.test.ts | 78 +++++++++++++++ 10 files changed, 285 insertions(+), 74 deletions(-) create mode 100644 src/lib/searchRanking.ts diff --git a/public/docs-content/user-guide/command-palette.json b/public/docs-content/user-guide/command-palette.json index af4ca5a..a84dc79 100644 --- a/public/docs-content/user-guide/command-palette.json +++ b/public/docs-content/user-guide/command-palette.json @@ -3,7 +3,7 @@ "title": "Command Palette", "description": "The command palette is the fastest way to move through the site.", "frontmatter": {}, - "content": "# Command Palette\n\nThe command palette is the fastest way to move through the site.\n\n## Open It\n\n- Mac: `Cmd + K`\n- Windows / Linux: `Ctrl + K`\n\n## What It Can Show\n\n- docs pages from the shared navigation tree\n- built-in actions such as theme changes\n- FAQ answers inline inside the palette\n- quick links like the homepage and `llms.txt`\n- Pagefind full-text results after a production build\n\n## Keyboard Controls\n\n- `Up` and `Down` move through results\n- `Enter` opens a result or previews an FAQ answer\n- `Esc` closes the palette\n\n## Related Shortcut\n\n`Cmd/Ctrl + I` toggles the theme.\n\n## Search Notes\n\nLocal navigation results are always available.\n\nPagefind results depend on the production search index, so validate those with `npm run build` rather than only local dev.\n\n## Next Steps\n\n- [Advanced Features](/docs/user-guide/advanced-features)\n- [Basic Usage](/docs/user-guide/basic-usage)\n", + "content": "# Command Palette\n\nThe command palette is the fastest way to move through the site.\n\n## Open It\n\n- Mac: `Cmd + K`\n- Windows / Linux: `Ctrl + K`\n\n## What It Can Show\n\n- docs pages from the shared navigation tree\n- built-in actions such as theme changes\n- FAQ answers inline inside the palette\n- quick links like the homepage and `llms.txt`\n- Pagefind full-text results after a production build\n\n## Keyboard Controls\n\n- `Up` and `Down` move through results\n- `Enter` opens a result or previews an FAQ answer\n- `Esc` closes the palette\n\n## Related Shortcut\n\n`Cmd/Ctrl + I` toggles the theme.\n\n## Search Notes\n\nLocal navigation results are always available.\n\nPagefind results depend on the production search index, so validate those with `npm run build` rather than only local dev.\n\n### Scoping And Ranking\n\n- Start a query with `docs:` to search only guides, or `api:` to search only\n the OpenAPI reference. For example, `api: users`.\n- On a versioned or translated page, guide results come from the same version\n and language as the page you are reading.\n- Pages whose title matches your query rank first. The ranking settings live in\n `src/lib/searchRanking.ts`.\n\n## Next Steps\n\n- [Advanced Features](/docs/user-guide/advanced-features)\n- [Basic Usage](/docs/user-guide/basic-usage)\n", "contentFormat": "markdown", "toc": [ { @@ -36,6 +36,11 @@ "id": "search-notes", "text": "Search Notes" }, + { + "depth": 3, + "id": "scoping-and-ranking", + "text": "Scoping And Ranking" + }, { "depth": 2, "id": "next-steps", @@ -55,22 +60,22 @@ "references": [ { "target": "/docs/user-guide/advanced-features", - "line": 36 + "line": 45 }, { "target": "/docs/user-guide/basic-usage", - "line": 37 + "line": 46 } ], "links": [ { "target": "/docs/user-guide/advanced-features", - "line": 36, + "line": 45, "type": "internal" }, { "target": "/docs/user-guide/basic-usage", - "line": 37, + "line": 46, "type": "internal" } ], diff --git a/public/docs-references.json b/public/docs-references.json index 425f5f4..301415f 100644 --- a/public/docs-references.json +++ b/public/docs-references.json @@ -363,13 +363,13 @@ "source": "user-guide/command-palette", "target": "user-guide/advanced-features", "href": "/docs/user-guide/advanced-features", - "line": 36 + "line": 45 }, { "source": "user-guide/command-palette", "target": "user-guide/basic-usage", "href": "/docs/user-guide/basic-usage", - "line": 37 + "line": 46 }, { "source": "user-guide/configuration", @@ -544,7 +544,7 @@ { "source": "user-guide/command-palette", "href": "/docs/user-guide/command-palette", - "line": 36 + "line": 45 } ], "user-guide/basic-usage": [ @@ -561,7 +561,7 @@ { "source": "user-guide/command-palette", "href": "/docs/user-guide/command-palette", - "line": 37 + "line": 46 }, { "source": "user-guide/interactive-map", diff --git a/public/llms-full.txt b/public/llms-full.txt index 4fdde31..590af63 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -553,6 +553,15 @@ Local navigation results are always available. Pagefind results depend on the production search index, so validate those with `npm run build` rather than only local dev. +### Scoping And Ranking + +- Start a query with `docs:` to search only guides, or `api:` to search only + the OpenAPI reference. For example, `api: users`. +- On a versioned or translated page, guide results come from the same version + and language as the page you are reading. +- Pages whose title matches your query rank first. The ranking settings live in + `src/lib/searchRanking.ts`. + ## Next Steps - [Advanced Features](/docs/user-guide/advanced-features) diff --git a/scripts/lib/pagefindArtifacts.mjs b/scripts/lib/pagefindArtifacts.mjs index 103e7f1..62b3420 100644 --- a/scripts/lib/pagefindArtifacts.mjs +++ b/scripts/lib/pagefindArtifacts.mjs @@ -66,6 +66,14 @@ function createDocumentationRecord(collection, context, docPath, document, index content, language: context.locale || 'en', meta, + // Pagefind filters: the client limits results to the reader's version + // and locale and supports `docs:` / `api:` scopes (src/lib/searchRanking.ts). + filters: { + section: ['docs'], + collection: [collection.id], + ...(context.version ? { version: [context.version] } : {}), + ...(context.locale ? { locale: [context.locale] } : {}), + }, }, }; } @@ -100,6 +108,7 @@ function createOpenApiRecords(openapiArtifacts) { title: operation.summary || `${operation.method} ${operation.path}`, kind: 'openapi-operation', }, + filters: { section: ['api'] }, }, }); } @@ -119,6 +128,7 @@ function createOpenApiRecords(openapiArtifacts) { .join('\n'), language: 'en', meta: { title: model.name, kind: 'openapi-model' }, + filters: { section: ['api'] }, }, }); } diff --git a/src/components/CommandPalette/useSearchLogic.ts b/src/components/CommandPalette/useSearchLogic.ts index 255871b..6000aa9 100644 --- a/src/components/CommandPalette/useSearchLogic.ts +++ b/src/components/CommandPalette/useSearchLogic.ts @@ -16,6 +16,7 @@ import { getLocaleLabel, i18nConfig, languageSwitcherShows } from '../../lib/chr import type { SearchResultType } from './SearchResult'; import { combineSearchResults } from './searchUtils'; +import { buildSearchFilters, parseSearchScope } from '../../lib/searchRanking.ts'; const FAQ_ITEMS = [ { @@ -122,19 +123,27 @@ export const useSearchLogic = (query: string) => { const { search: pagefindSearch, isAvailable: pagefindAvailable } = usePagefind(); const [pagefindResults, setPagefindResults] = useState([]); const latestQueryRef = useRef(''); + const { pathname } = useLocation(); + const searchVariant = useMemo(() => getCollectionRouteContext(pathname), [pathname]); useEffect(() => { const trimmedQuery = debouncedQuery.trim(); latestQueryRef.current = trimmedQuery; + const scope = parseSearchScope(trimmedQuery); - if (!pagefindAvailable || !trimmedQuery) { + if (!pagefindAvailable || !scope.query) { setPagefindResults([]); return; } let cancelled = false; + const filters = buildSearchFilters({ + section: scope.section, + version: searchVariant.activeVersion, + locale: searchVariant.activeLocale, + }); - void pagefindSearch(trimmedQuery) + void pagefindSearch(scope.query, filters) .then((results) => { if (cancelled || latestQueryRef.current !== trimmedQuery) { return; @@ -158,7 +167,7 @@ export const useSearchLogic = (query: string) => { return () => { cancelled = true; }; - }, [debouncedQuery, pagefindAvailable, pagefindSearch]); + }, [debouncedQuery, pagefindAvailable, pagefindSearch, searchVariant]); const navigate = useNavigate(); const location = useLocation(); diff --git a/src/docs/content/user-guide/command-palette.md b/src/docs/content/user-guide/command-palette.md index 7d3bbc8..ca72fdb 100644 --- a/src/docs/content/user-guide/command-palette.md +++ b/src/docs/content/user-guide/command-palette.md @@ -31,6 +31,15 @@ Local navigation results are always available. Pagefind results depend on the production search index, so validate those with `npm run build` rather than only local dev. +### Scoping And Ranking + +- Start a query with `docs:` to search only guides, or `api:` to search only + the OpenAPI reference. For example, `api: users`. +- On a versioned or translated page, guide results come from the same version + and language as the page you are reading. +- Pages whose title matches your query rank first. The ranking settings live in + `src/lib/searchRanking.ts`. + ## Next Steps - [Advanced Features](/docs/user-guide/advanced-features) diff --git a/src/hooks/usePagefind.ts b/src/hooks/usePagefind.ts index 8756a74..da7f815 100644 --- a/src/hooks/usePagefind.ts +++ b/src/hooks/usePagefind.ts @@ -6,6 +6,7 @@ import { buildCanonicalDocsPath, parseCollectionRoutePath, } from '../../shared/docsRouting.js'; +import { PAGEFIND_RANKING, rankSearchResults, type PagefindFilters } from '../lib/searchRanking.ts'; interface PagefindResult { url: string; @@ -34,10 +35,14 @@ interface PagefindSearchResult { } interface PagefindInstance { - search: (query: string) => Promise; + search: (query: string, options?: { filters?: PagefindFilters }) => Promise; init: () => Promise; + options?: (options: { ranking?: typeof PAGEFIND_RANKING }) => Promise; } +/** Results to load per query; Pagefind returns every match, but data() is per page. */ +const MAX_LOADED_RESULTS = 20; + function normalizeCollectionPrefix(routePrefix: string): string { return routePrefix.replace(/^\/+|\/+$/g, ''); } @@ -85,66 +90,28 @@ declare global { } export interface UsePagefindResult { - search: (query: string) => Promise; + search: (query: string, filters?: PagefindFilters) => Promise; isLoading: boolean; isAvailable: boolean; } -const PAGEFIND_SCRIPT_SELECTOR = 'script[data-pagefind-loader="true"]'; +const PAGEFIND_MODULE_URL = '/pagefind/pagefind.js'; -let pagefindScriptPromise: Promise | null = null; let pagefindInstancePromise: Promise | null = null; -function loadPagefindScript(): Promise { - if (typeof window === 'undefined' || window.pagefind) { - return Promise.resolve(); - } - - if (pagefindScriptPromise) { - return pagefindScriptPromise; +/** + * pagefind.js is an ES module that exports its API; it never sets + * `window.pagefind`, so a classic script tag left search on the fallback path. + * Import it directly (it only exists in production builds). + */ +async function importPagefind(): Promise { + if (window.pagefind) { + return window.pagefind as unknown as PagefindInstance; } - - pagefindScriptPromise = new Promise((resolve, reject) => { - const existingScript = document.querySelector( - PAGEFIND_SCRIPT_SELECTOR - ) as HTMLScriptElement | null; - const script = existingScript ?? document.createElement('script'); - - const cleanup = () => { - script.removeEventListener('load', handleLoad); - script.removeEventListener('error', handleError); - }; - - const handleLoad = () => { - script.dataset.loaded = 'true'; - cleanup(); - resolve(); - }; - - const handleError = () => { - cleanup(); - pagefindScriptPromise = null; - reject(new Error('Pagefind script failed to load')); - }; - - if (window.pagefind || script.dataset.loaded === 'true') { - resolve(); - return; - } - - script.addEventListener('load', handleLoad); - script.addEventListener('error', handleError); - - if (!existingScript) { - script.src = '/pagefind/pagefind.js'; - script.type = 'module'; - script.async = true; - script.dataset.pagefindLoader = 'true'; - document.head.appendChild(script); - } - }); - - return pagefindScriptPromise; + const moduleUrl = PAGEFIND_MODULE_URL; + const pagefindModule = (await import(/* @vite-ignore */ moduleUrl)) as PagefindInstance; + window.pagefind = pagefindModule as unknown as Window['pagefind']; + return pagefindModule; } async function getInitializedPagefind(): Promise { @@ -158,14 +125,14 @@ async function getInitializedPagefind(): Promise { pagefindInstancePromise = (async () => { try { - await loadPagefindScript(); - - if (!window.pagefind) { + const instance = await importPagefind(); + if (!instance) { return null; } - await window.pagefind.init(); - return window.pagefind as unknown as PagefindInstance; + await instance.options?.({ ranking: PAGEFIND_RANKING }); + await instance.init(); + return instance; } catch (error) { console.warn('Failed to load Pagefind:', error); pagefindInstancePromise = null; @@ -205,16 +172,19 @@ export function usePagefind(): UsePagefindResult { }, []); const search = useCallback( - async (query: string): Promise => { + async (query: string, filters?: PagefindFilters): Promise => { if (!pagefindInstance || !query.trim()) { return []; } try { - const results = await pagefindInstance.search(query); + const results = await pagefindInstance.search( + query, + filters && Object.keys(filters).length > 0 ? { filters } : undefined + ); - return Promise.all( - results.results.map(async (result) => { + const loaded = await Promise.all( + results.results.slice(0, MAX_LOADED_RESULTS).map(async (result) => { const data = await result.data(); const url = normalizePagefindUrl(data.url); @@ -228,6 +198,7 @@ export function usePagefind(): UsePagefindResult { }; }) ); + return rankSearchResults(loaded, query); } catch (error) { console.error('Pagefind search error:', error); return []; diff --git a/src/lib/searchRanking.ts b/src/lib/searchRanking.ts new file mode 100644 index 0000000..42b4ce5 --- /dev/null +++ b/src/lib/searchRanking.ts @@ -0,0 +1,92 @@ +/** + * Search tuning for the Pagefind index (records come from + * scripts/lib/pagefindArtifacts.mjs). + * + * - Ranking: Pagefind's defaults favour long pages less than Papers wants for + * short reference pages, so length is penalised less and exact terms more. + * - Filters: results stay in the reader's version and locale, and a `docs:` or + * `api:` prefix limits results to guides or the OpenAPI reference. + * - Title boost: pages whose title matches the query move up. + */ +export const PAGEFIND_RANKING = Object.freeze({ + termFrequency: 1.0, + pageLength: 0.5, + termSaturation: 1.4, + termSimilarity: 1.0, +}); + +export type SearchSection = 'docs' | 'api'; + +export type PagefindFilters = Record; + +const SECTION_PREFIX = /^(docs|api):\s*/i; + +/** Split an optional `docs:` / `api:` scope prefix off the query. */ +export function parseSearchScope(query: string): { query: string; section: SearchSection | null } { + const trimmed = query.trim(); + const match = SECTION_PREFIX.exec(trimmed); + if (!match) return { query: trimmed, section: null }; + return { + query: trimmed.slice(match[0].length).trim(), + section: match[1].toLowerCase() as SearchSection, + }; +} + +/** Filters for the current route. API records have no version or locale, so + * those only apply when the search is scoped to docs or the route has them. */ +export function buildSearchFilters({ + section, + version, + locale, +}: { + section: SearchSection | null; + version?: string | null; + locale?: string | null; +}): PagefindFilters { + const filters: PagefindFilters = {}; + if (section) filters.section = section; + if (section !== 'api') { + // Pagefind treats a filter as "must match", so version/locale filters would + // hide API records; scope them to docs with an `any` of the two shapes. + const docsScope: PagefindFilters = { section: 'docs' }; + if (version) docsScope.version = version; + if (locale) docsScope.locale = locale; + if (version || locale) { + if (section === 'docs') return { ...filters, ...docsScope }; + return { any: [docsScope, { section: 'api' }] } as unknown as PagefindFilters; + } + } + return filters; +} + +function normalizeText(value: string): string { + return value.toLowerCase().replace(/\s+/g, ' ').trim(); +} + +/** Multiplier for a result title against the query: exact > all terms > some terms. */ +export function titleBoost(title: string, query: string): number { + const normalizedTitle = normalizeText(title); + const normalizedQuery = normalizeText(query); + if (!normalizedQuery) return 1; + if (normalizedTitle === normalizedQuery) return 3; + const terms = normalizedQuery.split(' ').filter((term) => term.length > 1); + if (terms.length === 0) return 1; + const hits = terms.filter((term) => normalizedTitle.includes(term)).length; + if (hits === terms.length) return normalizedTitle.startsWith(normalizedQuery) ? 2.5 : 2; + return hits > 0 ? 1 + hits / terms.length / 2 : 1; +} + +/** Stable re-rank of Pagefind results by score × title boost. */ +export function rankSearchResults( + results: T[], + query: string +): T[] { + return results + .map((result, index) => ({ + result, + index, + rank: result.score * titleBoost(result.meta.title, query), + })) + .sort((left, right) => right.rank - left.rank || left.index - right.index) + .map(({ result }) => result); +} diff --git a/test/browser/docs.spec.mjs b/test/browser/docs.spec.mjs index 2c71f68..50f29b1 100644 --- a/test/browser/docs.spec.mjs +++ b/test/browser/docs.spec.mjs @@ -455,3 +455,31 @@ test('steps and cards render in Markdown and MDX', async ({ page }) => { await expect(page.locator('.papers-card-grid .papers-card')).toHaveCount(2); await assertNoRuntimeErrors(errors); }); + +test('Pagefind index supports section filters for docs: and api: scopes', async ({ page }) => { + await page.goto('/docs/getting-started/introduction', { waitUntil: 'networkidle' }); + await page.getByRole('button', { name: /Search docs/i }).click(); + await page + .getByPlaceholder('Search documentation or type a command...') + .fill('docs: content tabs'); + await expect(page.getByRole('button', { name: /Callouts And Code Blocks/i }).first()).toBeVisible( + { + timeout: 15_000, + } + ); + const counts = await page.evaluate(async () => { + const pagefind = window.pagefind; + const docs = await pagefind.search('character', { filters: { section: 'docs' } }); + const api = await pagefind.search('character', { filters: { section: 'api' } }); + const apiOnly = await pagefind.search('get', { filters: { section: 'api' } }); + const apiData = await Promise.all(apiOnly.results.slice(0, 5).map((result) => result.data())); + return { + docs: docs.results.length, + api: api.results.length, + apiKinds: Array.from(new Set(apiData.map((data) => data.meta.kind))), + }; + }); + expect(counts.docs).toBeGreaterThan(0); + expect(counts.api).toBe(0); + for (const kind of counts.apiKinds) expect(kind).toMatch(/^openapi-/); +}); diff --git a/test/docs-parity.test.ts b/test/docs-parity.test.ts index a61058f..76ddfa2 100644 --- a/test/docs-parity.test.ts +++ b/test/docs-parity.test.ts @@ -7,6 +7,14 @@ import { } from '../scripts/lib/lastUpdated.mjs'; import { DEFAULT_OG_IMAGE_PATH, socialImagePathForRoute } from '../shared/seo.js'; +import { + buildSearchFilters, + parseSearchScope, + rankSearchResults, + titleBoost, +} from '../src/lib/searchRanking.ts'; +import { createPagefindRecords } from '../scripts/lib/pagefindArtifacts.mjs'; + type TestCase = { name: string; run: () => void | Promise }; export const docsParityTests: TestCase[] = [ @@ -76,4 +84,74 @@ export const docsParityTests: TestCase[] = [ assert.equal(socialImagePathForRoute('https://x.test/docs'), DEFAULT_OG_IMAGE_PATH); }, }, + { + name: 'parseSearchScope reads docs: and api: prefixes', + run: () => { + assert.deepEqual(parseSearchScope(' api: users '), { query: 'users', section: 'api' }); + assert.deepEqual(parseSearchScope('DOCS:tabs'), { query: 'tabs', section: 'docs' }); + assert.deepEqual(parseSearchScope('tabs api:'), { query: 'tabs api:', section: null }); + }, + }, + { + name: 'buildSearchFilters keeps API results unless scoped, and pins docs to the variant', + run: () => { + assert.deepEqual(buildSearchFilters({ section: null }), {}); + assert.deepEqual(buildSearchFilters({ section: 'api', version: 'v2' }), { section: 'api' }); + assert.deepEqual(buildSearchFilters({ section: 'docs', locale: 'fr' }), { + section: 'docs', + locale: 'fr', + }); + assert.deepEqual(buildSearchFilters({ section: null, version: 'v2', locale: 'fr' }), { + any: [{ section: 'docs', version: 'v2', locale: 'fr' }, { section: 'api' }], + }); + }, + }, + { + name: 'rankSearchResults boosts title matches and keeps ties stable', + run: () => { + assert.equal(titleBoost('Content Tabs', 'content tabs'), 3); + assert.equal(titleBoost('Content Tabs Guide', 'content tabs'), 2.5); + assert.equal(titleBoost('Guide to tabs and content', 'content tabs'), 2); + assert.equal(titleBoost('Tabs', 'content tabs'), 1.25); + assert.equal(titleBoost('Other', 'content tabs'), 1); + const ranked = rankSearchResults( + [ + { score: 1, meta: { title: 'Install' } }, + { score: 0.6, meta: { title: 'Content tabs' } }, + { score: 1, meta: { title: 'Deploy' } }, + ], + 'content tabs' + ); + assert.deepEqual( + ranked.map((result) => result.meta.title), + ['Content tabs', 'Install', 'Deploy'] + ); + }, + }, + { + name: 'Pagefind records carry section, collection, version, and locale filters', + run: () => { + const records = createPagefindRecords({ + collections: [{ id: 'docs', label: 'Docs', routePrefix: 'docs' }], + artifactsByCollectionId: { + docs: { + index: { paths: ['a'], titles: { a: 'A' } }, + documents: { a: { title: 'A', content: '# A\n\nBody' } }, + }, + }, + openapiArtifacts: { + specs: [ + { + operations: [{ method: 'GET', path: '/x', summary: 'X', routePath: '/docs/x' }], + schemas: [], + }, + ], + }, + }); + const doc = records.find((record) => record.meta.kind === 'documentation'); + const api = records.find((record) => record.meta.kind === 'openapi-operation'); + assert.deepEqual(doc?.filters, { section: ['docs'], collection: ['docs'] }); + assert.deepEqual(api?.filters, { section: ['api'] }); + }, + }, ]; From 1895b410a6d61e81b9d38d78b0c74312bf6ec70c Mon Sep 17 00:00:00 2001 From: Thomas Vu Date: Wed, 14 Oct 2026 21:52:32 -0400 Subject: [PATCH 6/7] feat(i18n): translatable interface labels per locale --- public/collections.json | 4 +- public/docs-collection.json | 29 ++++++- .../translating-the-interface.json | 45 +++++++++++ public/docs-index.json | 6 +- public/docs-references.json | 9 ++- public/llms-full.txt | 76 +++++++++++++++++++ public/llms.txt | 1 + scripts/generate-docs.mjs | 13 +++- shared/documentation-config.js | 14 ++++ shared/uiStrings.js | 57 ++++++++++++++ src/components/CodeBlock.tsx | 10 ++- .../OptimizedCommandPalette.tsx | 6 +- src/components/ContentRenderer.tsx | 8 +- src/components/DocPageFooter.tsx | 11 ++- src/components/LastUpdated.tsx | 5 +- src/components/docs/DocumentBacklinks.tsx | 4 +- src/components/docs/DocumentationPage.tsx | 4 +- .../translating-the-interface.md | 67 ++++++++++++++++ src/lib/uiStrings.ts | 47 ++++++++++++ src/types/documentation-config.d.ts | 2 + test/architecture.test.ts | 11 +-- test/docs-parity.test.ts | 38 ++++++++++ types/shared-ui-strings.d.ts | 29 +++++++ 23 files changed, 467 insertions(+), 29 deletions(-) create mode 100644 public/docs-content/developer-guides/translating-the-interface.json create mode 100644 shared/uiStrings.js create mode 100644 src/docs/content/developer-guides/translating-the-interface.md create mode 100644 src/lib/uiStrings.ts create mode 100644 types/shared-ui-strings.d.ts diff --git a/public/collections.json b/public/collections.json index ed16fcf..f0b403d 100644 --- a/public/collections.json +++ b/public/collections.json @@ -1,7 +1,7 @@ { "contractVersion": 1, "schemaVersion": 1, - "generated": "2026-10-11T15:47:41.245Z", + "generated": "2026-10-11T16:01:47.501Z", "collections": [ { "id": "docs", @@ -15,7 +15,7 @@ "references": "/docs-references.json" }, "schemaVersion": 1, - "count": 33 + "count": 34 } ] } diff --git a/public/docs-collection.json b/public/docs-collection.json index 1a95312..c3a5177 100644 --- a/public/docs-collection.json +++ b/public/docs-collection.json @@ -11,8 +11,8 @@ "manifest": "/docs-collection.json", "references": "/docs-references.json" }, - "generated": "2026-10-11T15:47:41.245Z", - "count": 33, + "generated": "2026-10-11T16:01:47.501Z", + "count": 34, "schema": { "title": { "type": "string" @@ -196,6 +196,12 @@ "path": "developer-guides/steps-and-cards", "tags": ["steps", "cards", "components", "layout"] }, + { + "type": "file", + "name": "Translating The Interface.md", + "path": "developer-guides/translating-the-interface", + "tags": ["i18n", "locales", "labels", "translation"] + }, { "type": "file", "name": "Math And Diagrams.md", @@ -683,6 +689,25 @@ }, "sourcePath": "src/docs/content/developer-guides/themes.md" }, + { + "path": "developer-guides/translating-the-interface", + "title": "Translating The Interface", + "description": "Translate or rename the labels in the docs chrome, per locale.", + "frontmatter": { + "description": "Translate or rename the labels in the docs chrome, per locale." + }, + "metadata": { + "title": "Translating The Interface", + "description": "Translate or rename the labels in the docs chrome, per locale.", + "canonicalPath": "/docs/developer-guides/translating-the-interface", + "contentFormat": "markdown", + "sourcePath": "src/docs/content/developer-guides/translating-the-interface.md", + "collection": "docs", + "routePrefix": "/docs", + "schemaVersion": 1 + }, + "sourcePath": "src/docs/content/developer-guides/translating-the-interface.md" + }, { "path": "developer-guides/ui-configuration", "title": "UI Configuration", diff --git a/public/docs-content/developer-guides/translating-the-interface.json b/public/docs-content/developer-guides/translating-the-interface.json new file mode 100644 index 0000000..73550c0 --- /dev/null +++ b/public/docs-content/developer-guides/translating-the-interface.json @@ -0,0 +1,45 @@ +{ + "path": "developer-guides/translating-the-interface", + "title": "Translating The Interface", + "description": "Translate or rename the labels in the docs chrome, per locale.", + "frontmatter": { + "description": "Translate or rename the labels in the docs chrome, per locale." + }, + "content": "# Translating The Interface\n\nPage content is translated by adding locale folders. The labels around the\ncontent, such as **Previous**, **Next**, **Last updated**, and the copy button,\ncome from `shared/uiStrings.js`. You can translate or rename them per locale.\n\n## Set Labels For A Locale\n\nAdd the labels to `uiStringsConfig` in `shared/documentation-config.js`. Use\nthe locale codes from `i18nConfig.locales`. Labels that you leave out keep the\nEnglish default.\n\n```js title=\"shared/documentation-config.js\"\nexport const i18nConfig = {\n enabled: true,\n defaultLocale: 'en',\n locales: ['en', 'fr'],\n labels: { en: 'EN', fr: 'FR' },\n};\n\nexport const uiStringsConfig = {\n fr: {\n previous: 'Précédent',\n next: 'Suivant',\n lastUpdated: 'Mis à jour le',\n searchDocs: 'Rechercher',\n copyCode: 'Copier',\n },\n};\n```\n\nTo rename a label on a site with one language, add an entry for the default\nlocale, for example `en: { edit: 'Edit this page' }`.\n\nThe page language comes from the URL. Dates in the **Last updated** line use\nthe same locale.\n\n## Available Labels\n\n| Key | Default |\n| ------------------- | -------------------------------------------- |\n| `previous` | Previous |\n| `next` | Next |\n| `adjacentPages` | Adjacent documentation pages (screen reader) |\n| `lastUpdated` | Last updated |\n| `edit` | edit |\n| `issue` | issue |\n| `source` | source |\n| `pageSourceActions` | Page source actions (screen reader) |\n| `searchDocs` | Search docs |\n| `searchPlaceholder` | Search documentation or type a command... |\n| `noResults` | No results found for |\n| `copyCode` | Copy to clipboard |\n| `copied` | Copied |\n| `showLineNumbers` | Show line numbers |\n| `hideLineNumbers` | Hide line numbers |\n| `referencedBy` | Referenced by |\n\n## Validation\n\n`npm run generate:docs` (and so `npm run build`) fails if `uiStringsConfig`\nuses a locale that is not in `i18nConfig.locales`, an unknown key, or an empty\nvalue. This keeps a typo from hiding a label.\n", + "contentFormat": "markdown", + "toc": [ + { + "depth": 1, + "id": "translating-the-interface", + "text": "Translating The Interface" + }, + { + "depth": 2, + "id": "set-labels-for-a-locale", + "text": "Set Labels For A Locale" + }, + { + "depth": 2, + "id": "available-labels", + "text": "Available Labels" + }, + { + "depth": 2, + "id": "validation", + "text": "Validation" + } + ], + "metadata": { + "title": "Translating The Interface", + "description": "Translate or rename the labels in the docs chrome, per locale.", + "canonicalPath": "/docs/developer-guides/translating-the-interface", + "contentFormat": "markdown", + "sourcePath": "src/docs/content/developer-guides/translating-the-interface.md", + "collection": "docs", + "routePrefix": "/docs", + "schemaVersion": 1 + }, + "references": [], + "links": [], + "sourcePath": "src/docs/content/developer-guides/translating-the-interface.md" +} diff --git a/public/docs-index.json b/public/docs-index.json index 39b1406..c394117 100644 --- a/public/docs-index.json +++ b/public/docs-index.json @@ -1,5 +1,5 @@ { - "generated": "2026-10-11T15:47:41.245Z", + "generated": "2026-10-11T16:01:47.501Z", "paths": [ "api-reference/content-pipeline", "api-reference/openapi", @@ -22,6 +22,7 @@ "developer-guides/steps-and-cards", "developer-guides/tailwind-integration", "developer-guides/themes", + "developer-guides/translating-the-interface", "developer-guides/ui-configuration", "getting-started/installation", "getting-started/introduction", @@ -35,7 +36,7 @@ "user-guide/interactive-map", "user-guide/troubleshooting" ], - "count": 33, + "count": 34, "titles": { "api-reference/content-pipeline": "Content Pipeline", "api-reference/openapi": "OpenAPI Explorer", @@ -58,6 +59,7 @@ "developer-guides/steps-and-cards": "Steps And Cards", "developer-guides/tailwind-integration": "Tailwind Integration", "developer-guides/themes": "Themes", + "developer-guides/translating-the-interface": "Translating The Interface", "developer-guides/ui-configuration": "UI Configuration", "getting-started/installation": "Installation", "getting-started/introduction": "Introduction", diff --git a/public/docs-references.json b/public/docs-references.json index 301415f..70f3479 100644 --- a/public/docs-references.json +++ b/public/docs-references.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generated": "2026-10-11T15:47:41.245Z", + "generated": "2026-10-11T16:01:47.501Z", "collection": "docs", "nodes": [ { @@ -129,6 +129,12 @@ "title": "Themes", "description": "papers ships with installable themes under themes/. Themes override color, typography, radius, and optional icon sets without forking the docs shell." }, + { + "path": "developer-guides/translating-the-interface", + "href": "/docs/developer-guides/translating-the-interface", + "title": "Translating The Interface", + "description": "Translate or rename the labels in the docs chrome, per locale." + }, { "path": "developer-guides/ui-configuration", "href": "/docs/developer-guides/ui-configuration", @@ -505,6 +511,7 @@ "line": 30 } ], + "developer-guides/translating-the-interface": [], "developer-guides/ui-configuration": [ { "source": "user-guide/configuration", diff --git a/public/llms-full.txt b/public/llms-full.txt index 590af63..55d52e7 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -1550,6 +1550,82 @@ MDX pages use ``, ``, and ``: --- +### Translating The Interface + +URL: /docs/developer-guides/translating-the-interface + +--- +description: Translate or rename the labels in the docs chrome, per locale. +--- + +# Translating The Interface + +Page content is translated by adding locale folders. The labels around the +content, such as **Previous**, **Next**, **Last updated**, and the copy button, +come from `shared/uiStrings.js`. You can translate or rename them per locale. + +## Set Labels For A Locale + +Add the labels to `uiStringsConfig` in `shared/documentation-config.js`. Use +the locale codes from `i18nConfig.locales`. Labels that you leave out keep the +English default. + +```js title="shared/documentation-config.js" +export const i18nConfig = { + enabled: true, + defaultLocale: 'en', + locales: ['en', 'fr'], + labels: { en: 'EN', fr: 'FR' }, +}; + +export const uiStringsConfig = { + fr: { + previous: 'Précédent', + next: 'Suivant', + lastUpdated: 'Mis à jour le', + searchDocs: 'Rechercher', + copyCode: 'Copier', + }, +}; +``` + +To rename a label on a site with one language, add an entry for the default +locale, for example `en: { edit: 'Edit this page' }`. + +The page language comes from the URL. Dates in the **Last updated** line use +the same locale. + +## Available Labels + +| Key | Default | +| ------------------- | -------------------------------------------- | +| `previous` | Previous | +| `next` | Next | +| `adjacentPages` | Adjacent documentation pages (screen reader) | +| `lastUpdated` | Last updated | +| `edit` | edit | +| `issue` | issue | +| `source` | source | +| `pageSourceActions` | Page source actions (screen reader) | +| `searchDocs` | Search docs | +| `searchPlaceholder` | Search documentation or type a command... | +| `noResults` | No results found for | +| `copyCode` | Copy to clipboard | +| `copied` | Copied | +| `showLineNumbers` | Show line numbers | +| `hideLineNumbers` | Hide line numbers | +| `referencedBy` | Referenced by | + +## Validation + +`npm run generate:docs` (and so `npm run build`) fails if `uiStringsConfig` +uses a locale that is not in `i18nConfig.locales`, an unknown key, or an empty +value. This keeps a typo from hiding a label. + + +--- + + ### Math And Diagrams URL: /docs/developer-guides/math-and-diagrams diff --git a/public/llms.txt b/public/llms.txt index fea8ca4..e0e47ba 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -32,6 +32,7 @@ A minimal docs starter with install-ready defaults, generated content, and stati - [Callouts And Code Blocks](/docs/developer-guides/callouts-and-code): Callouts (admonitions), GitHub-style alerts, code block titles, line highlighting, and diffs. - [Callouts And Code In MDX](/docs/developer-guides/callouts-in-mdx): Use callouts, GitHub alerts, and code fence titles, line highlights, and diffs in MDX pages. - [Steps And Cards](/docs/developer-guides/steps-and-cards): Numbered steps and link cards for guides and landing pages. +- [Translating The Interface](/docs/developer-guides/translating-the-interface): Translate or rename the labels in the docs chrome, per locale. - [Math And Diagrams](/docs/developer-guides/math-and-diagrams): Papers typesets TeX with KaTeX and renders Mermaid diagrams, including diagrams whose labels contain math. KaTeX loads only on pages that contain math. - [Math And Diagrams In MDX](/docs/developer-guides/math-in-mdx): KaTeX math and Mermaid diagrams in .mdx pages, alongside MDX shortcodes. - [Tailwind Integration](/docs/developer-guides/tailwind-integration): Papers is built with Tailwind CSS 4 (@tailwindcss/vite). This page explains the CSS contract so a downstream Tailwind 4 site can embed or restyle Papers... diff --git a/scripts/generate-docs.mjs b/scripts/generate-docs.mjs index f343a95..845d190 100644 --- a/scripts/generate-docs.mjs +++ b/scripts/generate-docs.mjs @@ -11,7 +11,13 @@ import { getCollectionVariants, validateContentCollections, } from '../shared/content-collections.js'; -import { frameworkDocPaths, openapiConfig } from '../shared/documentation-config.js'; +import { + frameworkDocPaths, + i18nConfig, + openapiConfig, + uiStringsConfig, +} from '../shared/documentation-config.js'; +import { validateUiStringsConfig } from '../shared/uiStrings.js'; import { serializeArtifactJson } from './lib/docsArtifacts.mjs'; import { collectDocumentPaths, createGeneratedDocsArtifacts } from './lib/docsGeneration.mjs'; import { @@ -25,6 +31,11 @@ import { resolveCollectionFrameworkContentRoot, } from './lib/collectionContentRoot.mjs'; +const uiStringProblems = validateUiStringsConfig(uiStringsConfig, i18nConfig.locales || []); +if (uiStringProblems.length > 0) { + throw new Error(`Invalid uiStringsConfig:\n- ${uiStringProblems.join('\n- ')}`); +} + async function readExistingJson(filePath) { try { return JSON.parse(await readFile(filePath, 'utf8')); diff --git a/shared/documentation-config.js b/shared/documentation-config.js index eaed98b..dd4b951 100644 --- a/shared/documentation-config.js +++ b/shared/documentation-config.js @@ -58,6 +58,14 @@ export const i18nConfig = { }, }; +/** + * Interface label overrides per locale (keys from shared/uiStrings.js). Any + * label you leave out keeps its English default. Example: + * fr: { previous: 'Précédent', next: 'Suivant', lastUpdated: 'Mis à jour le' } + * @type {Record>} + */ +export const uiStringsConfig = {}; + /** * Site chrome — language switcher placement + sidebar skin. * @type {{ @@ -349,6 +357,12 @@ export const documentationTree = [ path: 'developer-guides/steps-and-cards', tags: ['steps', 'cards', 'components', 'layout'], }, + { + type: 'file', + name: 'Translating The Interface.md', + path: 'developer-guides/translating-the-interface', + tags: ['i18n', 'locales', 'labels', 'translation'], + }, { type: 'file', name: 'Math And Diagrams.md', diff --git a/shared/uiStrings.js b/shared/uiStrings.js new file mode 100644 index 0000000..d29c000 --- /dev/null +++ b/shared/uiStrings.js @@ -0,0 +1,57 @@ +/** + * Interface labels for the docs chrome (pager, footer, search, code blocks). + * English defaults live here; translate or rename them per locale with + * `uiStringsConfig` in shared/documentation-config.js. + */ +export const DEFAULT_UI_STRINGS = Object.freeze({ + previous: 'Previous', + next: 'Next', + adjacentPages: 'Adjacent documentation pages', + lastUpdated: 'Last updated', + edit: 'edit', + issue: 'issue', + source: 'source', + pageSourceActions: 'Page source actions', + searchDocs: 'Search docs', + searchPlaceholder: 'Search documentation or type a command...', + noResults: 'No results found for', + copyCode: 'Copy to clipboard', + copied: 'Copied', + showLineNumbers: 'Show line numbers', + hideLineNumbers: 'Hide line numbers', + referencedBy: 'Referenced by', +}); + +export const UI_STRING_KEYS = Object.freeze(Object.keys(DEFAULT_UI_STRINGS)); + +/** + * Merge the overrides for `locale` over the defaults. Unknown keys and + * non-string or empty values are ignored, so a typo never blanks a label. + */ +export function resolveUiStrings(locale, overridesByLocale = {}) { + const overrides = (locale && overridesByLocale?.[locale]) || {}; + const resolved = { ...DEFAULT_UI_STRINGS }; + for (const key of UI_STRING_KEYS) { + const value = overrides[key]; + if (typeof value === 'string' && value.trim()) resolved[key] = value; + } + return Object.freeze(resolved); +} + +/** Return config problems as messages (unknown locales, keys, or bad values). */ +export function validateUiStringsConfig(config, locales = []) { + const problems = []; + for (const [locale, overrides] of Object.entries(config || {})) { + if (locales.length > 0 && !locales.includes(locale)) { + problems.push(`uiStringsConfig.${locale}: locale is not in i18nConfig.locales`); + } + for (const [key, value] of Object.entries(overrides || {})) { + if (!UI_STRING_KEYS.includes(key)) { + problems.push(`uiStringsConfig.${locale}.${key}: unknown label`); + } else if (typeof value !== 'string' || !value.trim()) { + problems.push(`uiStringsConfig.${locale}.${key}: must be a non-empty string`); + } + } + } + return problems; +} diff --git a/src/components/CodeBlock.tsx b/src/components/CodeBlock.tsx index 8b9f2ef..ade068b 100644 --- a/src/components/CodeBlock.tsx +++ b/src/components/CodeBlock.tsx @@ -13,6 +13,7 @@ import { getDiffLineKind, parseDiffLanguage } from '../utils/markdownCore'; import { createWordPattern, markWords, type HighlighterNode } from '../utils/codeWords'; import styles from './CodeBlock.module.css'; +import { useUiStrings } from '../lib/uiStrings'; interface CodeSnippet { language: string; @@ -240,6 +241,7 @@ const CodeBlock: React.FC = React.memo( ({ snippets = [], title, defaultLanguage, showLineNumbers = true, className = '' }) => { const [activeTab, setActiveTab] = useState(0); const [copied, setCopied] = useState(false); + const ui = useUiStrings(); // Per-block line-number toggle; null follows the fence/default setting. const [lineNumberOverride, setLineNumberOverride] = useState(null); const [mounted, setMounted] = useState(false); @@ -444,8 +446,8 @@ const CodeBlock: React.FC = React.memo(

      diff --git a/src/components/ContentRenderer.tsx b/src/components/ContentRenderer.tsx index 668ae75..2089993 100644 --- a/src/components/ContentRenderer.tsx +++ b/src/components/ContentRenderer.tsx @@ -13,6 +13,7 @@ import { prefetchDocument } from '../lib/content'; import DocPageFooter from './DocPageFooter'; import LastUpdated from './LastUpdated'; import MarkdownRenderer, { HtmlRenderer } from './MarkdownRenderer'; +import { useUiStrings } from '../lib/uiStrings'; type ContentRendererProps = { content: string; @@ -32,6 +33,7 @@ const ContentRenderer = memo(function ContentRenderer({ collection, }: ContentRendererProps) { const location = useLocation(); + const ui = useUiStrings(); const activeCollection = collection || getContentCollection('docs'); const routePrefix = String(activeCollection.routePrefix || '/docs').replace(/\/+$/, '') || '/docs'; @@ -145,7 +147,7 @@ const ContentRenderer = memo(function ContentRenderer({ className="doc-page-bottom doc-page-bottom--anchored mt-auto w-full max-w-4xl mx-auto pt-0 pb-6 px-6 md:pb-8 md:px-8 lg:pb-12 lg:px-12" > {/* Row 1: adjacent docs — always in document flow under the article */} -