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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions apps/docs/app/[lang]/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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 (
<CodeBlock className='my-0'>
{await highlight(code, { lang, ...simShikiOptions, components: { pre: Pre } })}
Expand Down
50 changes: 22 additions & 28 deletions apps/docs/app/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Tabs>` 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 `<Tabs>` 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);
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
}
Comment thread
waleedlatif1 marked this conversation as resolved.

Expand Down Expand Up @@ -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);
Expand Down
15 changes: 11 additions & 4 deletions apps/docs/components/ui/api-example-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -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) => (
Expand All @@ -47,7 +54,7 @@ export function APIExampleSelector({ items, value, onValueChange }: APIExampleSe
</option>
))}
</select>
<ChevronDown className='-translate-y-1/2 pointer-events-none absolute end-2 top-1/2 size-[14px] text-[var(--text-icon)]' />
<ChipChevronDown className='-translate-y-1/2 pointer-events-none absolute end-2 top-1/2' />
</div>
{selectedItem?.description && (
<p className='text-[var(--text-muted)] text-caption'>{selectedItem.description}</p>
Expand Down
26 changes: 15 additions & 11 deletions apps/docs/components/ui/code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof FumadocsCodeBlock>) {
const figureRef = useRef<HTMLElement>(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)
Expand All @@ -65,8 +68,9 @@ export function CodeBlock({ title, ...props }: React.ComponentProps<typeof Fumad
className={cn('my-4', props.className)}
allowCopy={false}
/**
* fumadocs' own `className` is discarded rather than merged — its untitled-block variant
* carries a `backdrop-blur-lg` that goes milky over an opaque fill.
* The `className` fumadocs passes this render prop is deliberately neither destructured nor
* merged — its untitled-block variant carries a `backdrop-blur-lg` that goes milky over an
* opaque fill.
*/
Actions={() => (
<div className={cn('flex items-center', title ? '-me-1' : 'absolute top-2 right-2 z-[1]')}>
Expand Down
16 changes: 12 additions & 4 deletions apps/docs/lib/shiki-curl-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading