diff --git a/apps/docs/app/[lang]/[[...slug]]/page.tsx b/apps/docs/app/[lang]/[[...slug]]/page.tsx index 7b3f114b592..87436d3f664 100644 --- a/apps/docs/app/[lang]/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/[[...slug]]/page.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { getHighlighter, highlight } from 'fumadocs-core/highlight' +import { highlight } from 'fumadocs-core/highlight' import type { Root } from 'fumadocs-core/page-tree' import { findNeighbour } from 'fumadocs-core/page-tree' import type { ApiPageProps } from 'fumadocs-openapi/ui' @@ -18,7 +18,6 @@ import { Heading } from '@/components/ui/heading' import { ResponseSection } from '@/components/ui/response-section' import { i18n } from '@/lib/i18n' import { getApiSpecContent, getAuthenticatedCodeSamples, openapi } from '@/lib/openapi' -import { curlJsonBodyGrammar } from '@/lib/shiki-curl-json' import { simShikiOptions } from '@/lib/shiki-theme' import { type PageData, source } from '@/lib/source' import { DOCS_BASE_URL } from '@/lib/urls' @@ -77,17 +76,8 @@ function stripLocalePrefix(url: string, lang: string): string { * rather than fumadocs-openapi's built-in one, so those blocks get the emcn copy control * instead of fumadocs' lucide clipboard. Mirrors the default renderer — same `highlight` call, * same `Pre` component, same `my-0` — differing only in which shell wraps the result. - * - * One asymmetry: `highlight` resolves fumadocs' shared `defaultShikiFactory`, while the renderer - * this replaces uses whatever `shiki` factory the page was configured with. They are the same - * object because that factory is also the default; passing a custom one would be honored on API - * markdown and ignored here. */ async function ApiCodeBlock({ lang, code }: { lang: string; code: string }) { - // Registers the injection on the shared highlighter `highlight` resolves; an injection is a - // property of the highlighter, not a per-call option. Idempotent — already-loaded grammars are - // skipped. - await getHighlighter('js', { langs: [curlJsonBodyGrammar] }) return ( {await highlight(code, { lang, ...simShikiOptions, components: { pre: Pre } })} diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index 8d1dfc1277e..4e493df5ce9 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -1603,15 +1603,17 @@ main article tbody tr:last-child td { /* Code blocks — platform field chrome. - The shell itself (radius, border, fill) is owned by components/ui/code-block.tsx, the way - an emcn component owns its chrome. What remains here is the styling of fumadocs internals - that component cannot reach: the title row it renders from a `title` prop, the scroll - viewport, and the tab strip that `` puts above a fence. */ - -/* Local aliases, not new design values: each is an existing platform token whose light and - dark halves differ. Naming the pair once lets the rules below be written without a `.dark` - twin, which would otherwise have to restate their `:has()` selectors — the double-`:has()` - the tabbed group is matched by, and the sibling-combinator one the gutter is matched by. */ + Three renderers emit these figures and only two go through components/ui/code-block.tsx, so + the shell and the fumadocs internals that component cannot reach — the title row, the scroll + viewport, the tab strip `` puts above a fence — are all defined here. The component + keeps only the copy control and the prose margin. */ + +/* Local aliases, not new design values: each names an existing platform token pair whose light + and dark halves differ (`--surface-5`/`--code-bg`, `--text-muted`/`--code-line-number`). + + `--code-surface` has three consumers. `--code-gutter` has one, and is an alias anyway because + a `.dark` twin would have to restate the sibling-combinator `:has()` its consumer is matched + by — the expensive form, which re-checks on every line insertion. */ :root { --code-surface: var(--surface-5); --code-gutter: var(--text-muted); @@ -1689,10 +1691,9 @@ figure.shiki > div:first-child:has(figcaption) svg { The `!important` and the viewport selector are belt-and-braces, not strictly required — fumadocs' declaration is (0,2,0) and these selectors are (0,3,1) and (0,4,1), so they - already win, and nothing declares this property on the viewport. They are kept because - getting this wrong puts the line numbers on top of the code, which shipped once already, and - because the specificity of `:has()` and `:not()` is easy to miscount in exactly the - direction that reintroduces it. Remove them only alongside a visual check. + already win, and nothing declares this property on the viewport. They are kept because the + specificity of `:has()` and `:not()` is easy to miscount in the direction that puts the line + numbers on top of the code. Remove them only alongside a visual check. */ figure.shiki:has(.line ~ .line), figure.shiki:has(.line ~ .line) > div[role="region"], @@ -1733,6 +1734,9 @@ figure.shiki code:has(.line ~ .line) .line::before { the code surface. The viewport is the one box all three renderers agree on. */ figure.shiki > div[role="region"] { background-color: var(--code-surface); + /* fumadocs ships 14px of vertical padding, the platform's viewer 8px; 10px splits them and + keeps a single-line fence from looking hollow at the tighter 21px line box. */ + padding-block: 10px; } /* Shiki emits `--shiki-*-bg` custom properties under `defaultColor: false`; keep the `pre` clear @@ -1741,13 +1745,6 @@ figure.shiki pre { background-color: transparent; } -/* Viewport padding — fumadocs ships 14px vertical; the platform's viewer uses 8px. Split the - difference at 10px, which keeps a single-line fence from looking hollow at the tighter - 21px line box. */ -figure.shiki > div[role="region"] { - padding-block: 10px; -} - /* Untitled blocks float the copy control over the code, so the last column has to clear it: an 8px offset plus emcn's 20px icon button, with room to breathe. fumadocs reserves 32px, which the glyphs run into. @@ -1802,12 +1799,6 @@ figure.shiki button[aria-label="Copied Text"]:has(> svg[class*="lucide"]) { color 150ms; } -figure.shiki button[aria-label="Copy Text"] > svg[class*="lucide"], -figure.shiki button[aria-label="Copied Text"] > svg[class*="lucide"] { - width: 14px; - height: 14px; -} - figure.shiki button[aria-label="Copy Text"]:has(> svg[class*="lucide"]) { @variant hover-hover { background-color: var(--surface-active); @@ -1849,11 +1840,11 @@ figure.shiki button[aria-label$="Text"]:has(> svg[class*="lucide"])::before { -webkit-mask-size: contain; } -figure.shiki button[aria-label="Copy Text"] { +figure.shiki button[aria-label="Copy Text"]:has(> svg[class*="lucide"]) { --copy-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-1 -2 24 24' fill='none' stroke='%23000' stroke-width='1.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14.25 0.75H2.75C1.64543 0.75 0.75 1.64543 0.75 2.75V14.25'/%3E%3Crect x='5.25' y='5.25' width='14' height='14' rx='2'/%3E%3C/svg%3E"); } -figure.shiki button[aria-label="Copied Text"] { +figure.shiki button[aria-label="Copied Text"]:has(> svg[class*="lucide"]) { --copy-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-1 -2 24 24' fill='none' stroke='%23000' stroke-width='1.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18.25 2.75L7.25 15.75L1.75 10.25'/%3E%3C/svg%3E"); } @@ -1901,6 +1892,9 @@ div:has(> div > figure.shiki) > [role="tablist"] button { color 150ms; } +/* Kept as its own rule rather than nested into the block above: biome's + `noDuplicateProperties` reads a nested `@variant` setting the same property as a duplicate + declaration and fails the build. */ div:has(> div > figure.shiki) > [role="tablist"] button { @variant hover-hover { color: var(--text-body); diff --git a/apps/docs/components/ui/api-example-selector.tsx b/apps/docs/components/ui/api-example-selector.tsx index b59743385f5..7f517ceba88 100644 --- a/apps/docs/components/ui/api-example-selector.tsx +++ b/apps/docs/components/ui/api-example-selector.tsx @@ -2,8 +2,14 @@ import type { ComponentProps } from 'react' import { useId } from 'react' -import { chipFieldSurfaceClass, chipFieldTextClass, chipHoverSurfaceClass, cn } from '@sim/emcn' -import { ChevronDown } from '@sim/emcn/icons' +import { + ChipChevronDown, + chipFieldSurfaceClass, + chipFieldTextClass, + chipGeometryClass, + chipHoverSurfaceClass, + cn, +} from '@sim/emcn' import type { APIPageClientOptions } from 'fumadocs-openapi/ui/client' type FumadocsAPIExampleSelector = NonNullable< @@ -35,10 +41,11 @@ export function APIExampleSelector({ items, value, onValueChange }: APIExampleSe * screen-reader behavior a custom listbox would have to rebuild. */ className={cn( + chipGeometryClass, chipFieldSurfaceClass, chipFieldTextClass, chipHoverSurfaceClass, - 'h-[30px] w-full appearance-none ps-2 pe-8 text-left' + 'w-full appearance-none pe-8' )} > {items.map((item) => ( @@ -47,7 +54,7 @@ export function APIExampleSelector({ items, value, onValueChange }: APIExampleSe ))} - + {selectedItem?.description && (

{selectedItem.description}

diff --git a/apps/docs/components/ui/code-block.tsx b/apps/docs/components/ui/code-block.tsx index f01416fd231..43239464dae 100644 --- a/apps/docs/components/ui/code-block.tsx +++ b/apps/docs/components/ui/code-block.tsx @@ -33,21 +33,24 @@ function CopyButton({ getCode }: { getCode: () => string }) { } /** - * Docs code block for prose fences, wired into the MDX `pre` mapping. + * Docs code block for prose fences and the API reference's request/response samples — the MDX + * `pre` mapping and fumadocs-openapi's `renderCodeBlock` both render it. * - * The shell — radius, hairline, fill — is not set here. Request and response samples in the - * API reference come from fumadocs-openapi's own renderer, so the two share chrome through a - * `figure.shiki` rule in `global.css` instead; see the note there. What stays here is the - * part only this path has: the copy control, and the `my-4` prose rhythm that API samples, - * which sit flush in their panel, must not inherit. + * The shell — radius, hairline, fill — is not set here. A third renderer, fumadocs-openapi's + * `UsageTab`, emits these figures without going through any component, so all three share + * chrome through a `figure.shiki` rule in `global.css` instead; see the note there. What stays + * here is the copy control, and the `my-4` prose rhythm that API samples, which sit flush in + * their panel, override with `my-0`. */ export function CodeBlock({ title, ...props }: React.ComponentProps) { const figureRef = useRef(null) /** - * Reads the block's text the way fumadocs does: from a clone, with `.nd-copy-ignore` nodes - * replaced by newlines. Those nodes carry rendered gutter and diff markers, so copying the - * live `textContent` would paste line numbers along with the code. + * Reads the block's text the way fumadocs' own `CopyButton` does: from a clone, with + * `.nd-copy-ignore` nodes replaced by newlines — kept in step with upstream so a fence that + * gains such a node copies the same text there and here. (The line-number gutter is a + * `::before`, and pseudo-element content never reaches `textContent`, so it is not what this + * guards.) */ function getCode() { const pre = figureRef.current?.getElementsByTagName('pre').item(0) @@ -65,8 +68,9 @@ export function CodeBlock({ title, ...props }: React.ComponentProps (
diff --git a/apps/docs/lib/shiki-curl-json.ts b/apps/docs/lib/shiki-curl-json.ts index 9cb2b6b0275..14ea4a52660 100644 --- a/apps/docs/lib/shiki-curl-json.ts +++ b/apps/docs/lib/shiki-curl-json.ts @@ -14,10 +14,18 @@ import type { LanguageRegistration } from 'shiki' * when it owns the opening brace. Entering mid-string, keys keep `string.quoted.double.json` * and stay string-colored, which is the entire difference this exists to remove. Hence the * hand-written patterns below, which name that scope directly. - * - **A Shiki transformer.** A transformer can re-tokenize the body correctly, but it is a - * function, and the API reference's request tabs highlight in the browser off a `shikiOptions` - * object passed through RSC — where "Functions cannot be passed directly to Client - * Components". A grammar is plain data, so it reaches the client path too. + * - **A Shiki transformer.** A transformer re-tokenizes the body correctly, but it is a function, + * and `shikiOptions` is forwarded into a client component — "Functions cannot be passed + * directly to Client Components" takes down every API reference page. A grammar is plain data, + * so it survives that boundary. + * + * Applies to prose fences only, via `langs` on the MDX pipeline. Not the API reference: + * fumadocs-openapi calls `renderCodeBlock` with a hard-coded `"json"` for request and response + * samples, so a shell injection can never fire there, and its cURL usage tabs highlight in the + * browser off fumadocs' own factory — `ClientCodeBlockProvider` sits in a `"use client"` module + * the package does not expose through its `exports` map, so reaching it means importing + * `fumadocs-openapi/ui/base` from client code and dragging `remark` and + * `@fumari/json-schema-ts` into the browser bundle. That broke the deployment once. * * The opening brace requires a `}`, a quoted key, or end-of-line after it. That is what keeps * `awk '{print $1}'` out, while still matching a body whose brace ends the line — Oniguruma