mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
improvement(docs): align code blocks with the platform design system (#6810)
* improvement(docs): align code blocks with the platform design system
Docs code blocks rendered in stock `github-light`/`github-dark` on fumadocs
chrome, sharing no colors, typeface, metrics, or corner radius with the app.
- Add Sim Shiki themes transcribed from emcn's Prism token colors, shared by
the MDX pipeline and fumadocs-openapi (which highlights through its own
instance, so the API reference was left on the GitHub palette).
- Use the mono stack the app actually renders. `tailwind.config.ts` points
`font-mono` at `--font-martian-mono`, but nothing defines that variable, so
every code surface in the product resolves to the system stack.
- Give blocks the platform's field chrome — `rounded-lg`, a `--border-1`
hairline, a `--surface-5`/`--code-bg` fill — and the 13px/21px metrics of
`Code.Viewer`. The rule keys on `figure.shiki` because two renderers emit
these figures and that is the only join point they share.
- Number every line, from the same tokens as the in-app gutter. Padding sits
on `.line` rather than fumadocs' `--padding-left`: that property is
re-declared on the inner `pre` for API samples, which dropped the digits on
top of the code.
- Collapse tabbed fences into one box with the strip as the title row, and
align the inline-code chip with the app's markdown renderer.
- Reuse emcn's `Button`, `useCopyToClipboard`, and chip chrome constants
instead of re-deriving them, and drop ~90 lines of `!important` overrides,
including a rule that could never match.
* fix(docs): stop line numbers overlapping code, unify the copy glyph
The gutter opened its column by setting `padding-left` on `.line`, which never
applied: fumadocs' own rule is `.shiki:not(.not-fumadocs-codeblock *) .line`,
and `:not()` carries its argument's specificity, putting it at (0,3,0). Every
code block rendered its line number on top of the first characters.
- Drive fumadocs' `--padding-left` / `--padding-right` instead of overriding
`.line`. Declared on the figure, the viewport, and any inner `.shiki`,
because the variable is inherited and the nearest declaration wins — the
class sits on the figure alone for prose fences but on the figure and the
inner `pre` for API samples, and `--padding-right` is also written as an
inline style on the viewport.
- Route API request/response samples through the docs `CodeBlock` via
fumadocs-openapi's `renderCodeBlock`, so they carry the emcn copy control
rather than fumadocs' lucide clipboard.
- Mask the emcn glyph over the one block `renderCodeBlock` cannot reach — the
usage tabs hardcode `ClientCodeBlock` and `OperationClientOptions` exposes
only `APIExampleSelector` — so the copy icon is identical everywhere.
* improvement(docs): reserve the gutter column without numbering one-liners
A line number on a single-line shell command has nothing to reference, and the
CLI pages are mostly single-line commands. Dropping the gutter on those blocks
was the original behaviour, but it made adjacent fences start their code 28px
apart wherever a command sat next to its output.
Reserve the column on every block so all code shares a left edge, and paint the
digit only when the fence has more than one line.
* fix(docs): drop the gutter entirely on single-line fences
Reserving the column but leaving it blank gave one-line commands a 44px indent
with nothing in it, which reads as a rendering fault rather than as alignment.
Gate the column and the digit together, so a single-line fence keeps fumadocs'
default padding and a multi-line one gets both.
* fix(docs): stop the copy-button CSS restyling emcn's own Button
The rules added for fumadocs' copy button matched on `aria-label` alone, so
they also hit the emcn `Button` this app renders — re-declaring geometry,
radius, color, and a `background: none` that killed its hover, and pinning docs
to today's `buttonVariants` values with no failure signal if those change.
- Qualify every one with `:has(> svg[class*="lucide"])`, the same scoping the
mask rules already used, so they reach only the block fumadocs renders.
- Stroke the masked glyph at 1.25 to match `Button size='icon'`, which
overrides the icon's authored 1.55. The two copy glyphs were rendering at
different weights — the mismatch the mask exists to remove.
- Drop `.line::after { content: none }`: it cannot outrank fumadocs' (0,4,1)
rule, and no fence in `content/` uses the `lines` meta it guarded against.
- Drop the `!important` and the redundant viewport selector on `--padding-left`;
nothing declares it between the figure and the region, and nothing contests
it at equal specificity. `--padding-right` keeps both — its inline style is
real.
- Use emcn's `cn` where emcn class constants are merged, so they go through the
merger that knows the `text-micro|caption|small|md` scale.
- Correct the comments the review disproved: two claimed the API reference
still renders fumadocs' CodeBlock, which `renderCodeBlock` changed.
* fix(docs): keep the gutter padding override belt-and-braces
A simplify pass removed the `!important` and the viewport selector from
`--padding-left` as provably redundant, and on the numbers they are: fumadocs
declares the property at (0,2,0) while these selectors are (0,3,1) and (0,4,1),
and nothing declares it on the viewport.
Restore both anyway. Getting this wrong paints the line numbers on top of the
code — a regression this PR already shipped once — and the specificity of
`:has()` and `:not()` is easy to miscount in exactly that direction. The comment
now records both that the override is redundant on paper and why it stays.
* feat(docs): highlight the curl JSON request body as JSON
A `curl -d '{…}'` payload is one single-quoted string to a shell, so the same
JSON that renders with colored keys in a response sample rendered as one flat
block of string color in the request sample directly above it.
Fixed with a TextMate injection rather than the two approaches that don't work:
- `{ include: 'source.json' }` attaches the JSON grammar but its object pattern
only assigns `support.type.property-name.json` — the scope that colors keys —
when it owns the opening brace. Entering mid-string, keys stay string-colored,
which is the whole difference. So the key/value/array patterns are written out
and name that scope directly.
- A Shiki transformer tokenizes it correctly but is a function, and the request
tabs highlight in the browser off a `shikiOptions` object passed through RSC,
where functions cannot cross. A grammar is plain data and reaches both sides.
An injection has to be registered on the highlighter, not passed per call, so
the API page moves to `createAPIPage` from `fumadocs-openapi/ui/base` with our
own factory, and `ApiShikiProvider` hands that same factory to the client code
blocks — both public API. The MDX pipeline preloads it through `langs`.
The opening brace requires `}`, a quoted key, or end-of-line after it, which
keeps `awk '{print $1}'` out; the end-of-line case is needed because Oniguruma
matches line by line. Verified against `jq '.[0]'`, `awk '{print $1}'`,
`grep -o 'foo'` and `echo '{}'` — none are re-colored.
* fix(docs): paint the code fill on the viewport, not the figure
The request and response panels on an API reference page rendered on different
backgrounds. Sampled from screenshots: the request panel showed the page
background (#ffffff light, --bg dark) while the response panel showed the code
surface (--surface-5 / --code-bg).
The fill was left to show through from the figure or the tab group, and those
diverge per renderer. fumadocs gives a standalone figure `bg-fd-card` but an
in-tab figure `bg-fd-secondary`, and this app forces
`--color-fd-card: transparent` on API reference pages — so zeroing the in-tab
figure's fill, expecting its group to supply one, left the request panel
transparent while the response panel's `bg-fd-secondary` group kept ours.
Paint it on the scroll viewport instead. That is the innermost box all three
renderers wrap code in, so it cannot diverge, and it no longer matters what any
ancestor sets.
* fix(docs): hide the code tab strip's scrollbar
fumadocs makes the strip `overflow-x-auto`, and an endpoint with ten status
codes overflows it in the API reference's narrow rail — leaving a scrollbar
across the bottom of a 34px header, which reads as the header being clipped
rather than as something scrollable.
Hidden the way the platform hides it on a scrolling tab strip: emcn's `TabStrip`
carries `overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden`.
The code viewport below keeps its scrollbar. There the overflow is content, and
the platform's own `Code.Container` shows one for the same reason — hiding it
would hide that a line continues.
* fix(docs): keep fumadocs-openapi's server graph out of the client bundle
The Vercel deployment went red at the commit that added `ApiShikiProvider`, and
stayed red for three commits. That component is `'use client'` and imported
`ClientCodeBlockProvider` from `fumadocs-openapi/ui/base` — an entry that also
pulls `remark`, `remark-rehype`, `@fumari/json-schema-ts` and `github-slugger`.
Importing it from a client module forces that whole server graph into the
browser bundle. Measured in `.next/static/chunks`: `json-schema-ts` in 1 chunk,
`github-slugger` in 3, `remark-rehype` in 4. A local build tolerates the weight;
a deployment with size limits does not.
`ClientCodeBlockProvider` lives in a `"use client"` module that the package does
not expose through its `exports` map, so there is no client-safe path to it.
Back to `createAPIPage` from `fumadocs-openapi/ui`, dropping the custom factory
and the provider. After: `json-schema-ts` 0 chunks, `github-slugger` 1,
`remark-rehype` 1 — the remainder is fumadocs' own client-side markdown.
The server path keeps the injection by registering it on the shared highlighter
`highlight` already resolves. What is given up is the API reference's cURL usage
tabs, which highlight in the browser off fumadocs' own factory. Prose fences
keep it, and that is where the `curl -d '{…}'` examples live — getting-started,
authentication, workflows/deployment, passing-files, triggers/webhook, in every
locale.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type React from 'react'
|
||||
import { getHighlighter, 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'
|
||||
@@ -17,6 +18,8 @@ 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'
|
||||
|
||||
@@ -69,9 +72,40 @@ function stripLocalePrefix(url: string, lang: string): string {
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the API reference's request and response samples through the docs' own `CodeBlock`
|
||||
* 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 } })}
|
||||
</CodeBlock>
|
||||
)
|
||||
}
|
||||
|
||||
const APIPage = createAPIPage(openapi, {
|
||||
renderCodeBlock: (props) => <ApiCodeBlock {...props} />,
|
||||
playground: { enabled: false },
|
||||
generateCodeSamples: getAuthenticatedCodeSamples,
|
||||
/**
|
||||
* fumadocs-openapi highlights its request and response samples through its own Shiki
|
||||
* instance, not the MDX pipeline, so it does not inherit `source.config.ts`. Left alone,
|
||||
* every API reference page renders `github-light` / `github-dark` while the rest of the docs
|
||||
* render the platform palette.
|
||||
*/
|
||||
shikiOptions: simShikiOptions,
|
||||
client: {
|
||||
operation: { APIExampleSelector },
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
|
||||
import { defineI18nUI } from 'fumadocs-ui/i18n'
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs'
|
||||
import { RootProvider } from 'fumadocs-ui/provider/next'
|
||||
import { Geist_Mono, Inter } from 'next/font/google'
|
||||
import { Inter } from 'next/font/google'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import {
|
||||
SidebarFolder,
|
||||
@@ -25,12 +25,6 @@ const inter = Inter({
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-geist-mono',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
const { provider } = defineI18nUI(i18n, {
|
||||
translations: {
|
||||
en: {
|
||||
@@ -85,11 +79,7 @@ export default async function Layout({ children, params }: LayoutProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={lang}
|
||||
className={`${inter.variable} ${geistMono.variable} ${season.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<html lang={lang} className={`${inter.variable} ${season.variable}`} suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
id='website-json-ld'
|
||||
|
||||
+344
-110
@@ -47,8 +47,15 @@ body {
|
||||
--text-caption and --text-small, because the mono face reads small at 12px. */
|
||||
--text-code: 0.78125rem;
|
||||
|
||||
--font-mono-stack: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
/* The platform's code face. `apps/sim/tailwind.config.ts` lists `var(--font-martian-mono)`
|
||||
first, but nothing in the app ever defines that variable — `apps/sim/app/layout.tsx`
|
||||
applies only `season.variable` — so every code surface in the product resolves to the
|
||||
system stack below. Docs match what the app actually renders rather than the token it
|
||||
nominally references; loading a webfont here would make docs the odd one out, not the
|
||||
aligned one. If the app ever wires that font up for real, add the var back in both
|
||||
places at once. */
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
}
|
||||
|
||||
/* Pure white light mode background */
|
||||
@@ -240,10 +247,6 @@ body {
|
||||
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: var(--font-mono-stack);
|
||||
}
|
||||
|
||||
/* Platform UI font — Season Sans, used by the chip chrome to match the main app */
|
||||
.font-season {
|
||||
font-family: var(--font-season), system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
||||
@@ -651,33 +654,26 @@ aside[data-sidebar],
|
||||
writing-mode: horizontal-tb !important;
|
||||
}
|
||||
|
||||
/* Apply Geist Mono to code elements */
|
||||
code,
|
||||
pre,
|
||||
pre code {
|
||||
font-family: var(--font-mono-stack);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Inline code — neutral colors aligned with sim design system */
|
||||
/* Inline code chip, matching the one the app renders for markdown
|
||||
(`apps/sim/lib/content/mdx.tsx`).
|
||||
|
||||
Two things here are deliberate rather than oversights. The border is gone because the app's
|
||||
chip has none, and at inline size a hairline reads as a boxed-in label rather than a
|
||||
highlight. The color is unset so the chip composites over whatever encloses it — inline
|
||||
code inside a link picks up the link color instead of sitting on it as a grey island, which
|
||||
is the behavior the app's chip was written for. */
|
||||
:not(pre) > code {
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Inline code — platform surface-4 / text-body / border-1 */
|
||||
:not(pre) > code {
|
||||
background-color: var(--surface-4);
|
||||
color: var(--text-body);
|
||||
border: 1px solid var(--border-1);
|
||||
}
|
||||
|
||||
/* Remove heavy shadows globally — aligned with sim design system (shadow-card: 0 1px 3px rgba(0,0,0,0.04)) */
|
||||
figure.shiki,
|
||||
figure[data-rehype-pretty-code-figure],
|
||||
.shiki {
|
||||
box-shadow: none !important;
|
||||
font-weight: 400;
|
||||
background-color: var(--surface-5);
|
||||
}
|
||||
|
||||
/* Search dialog — lighter shadow + constrained size */
|
||||
@@ -697,9 +693,11 @@ figure[data-rehype-pretty-code-figure],
|
||||
z-index: 60 !important;
|
||||
}
|
||||
|
||||
/* Code metrics — the platform's `Code.Viewer` runs 13px on a 21px line box
|
||||
(`text-small` / `leading-[21px]`); identical snippets should not read larger here. */
|
||||
pre {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7;
|
||||
font-size: var(--text-small);
|
||||
line-height: 21px;
|
||||
tab-size: 2;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
@@ -929,7 +927,9 @@ span.font-mono.font-medium[data-method="delete"] {
|
||||
background-color: var(--badge-error-bg);
|
||||
}
|
||||
|
||||
/* Sidebar links with method badges — match regular item padding */
|
||||
/* Sidebar links with method badges — match regular item padding. Radius follows the
|
||||
platform's two-tier convention (documented on emcn's `DropdownMenu`): floating surfaces
|
||||
take 12px, rows inside one take 8px. These are rows. */
|
||||
html #nd-sidebar a:has(span.font-mono.font-medium) {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
@@ -938,7 +938,7 @@ html #nd-sidebar a:has(span.font-mono.font-medium) {
|
||||
font-size: 0.875rem !important;
|
||||
line-height: 1.4 !important;
|
||||
font-weight: 450 !important;
|
||||
border-radius: 0.75rem !important;
|
||||
border-radius: 0.5rem !important;
|
||||
}
|
||||
|
||||
/* Sidebar method badges — fixed-width for right-aligned labels */
|
||||
@@ -970,11 +970,6 @@ html #nd-sidebar a:has(span.font-mono.font-medium) {
|
||||
--color-fd-card: transparent !important;
|
||||
}
|
||||
|
||||
#nd-page:has(.api-page-header) figure.shiki {
|
||||
border-radius: 0.75rem !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* API page code example panels — transparent */
|
||||
#nd-page:has(.api-page-header) .rounded-xl.border.bg-fd-card {
|
||||
background-color: transparent !important;
|
||||
@@ -1069,19 +1064,14 @@ div.flex.flex-row.items-start.bg-fd-secondary.border.rounded-lg.text-xs {
|
||||
background-color: var(--badge-error-bg) !important;
|
||||
}
|
||||
|
||||
/* Path text inside method+path bar — monospace, bright like Gumloop */
|
||||
/* The path inside the method+path bar is a heading, not a chip — strip the inline-code
|
||||
surface so it reads as the endpoint itself. */
|
||||
#nd-page:has(.api-page-header) div.flex.flex-row.items-center.rounded-xl.border.not-prose code {
|
||||
color: var(--text-body) !important;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
font-size: var(--text-small) !important;
|
||||
}
|
||||
/* Inline code in API pages — neutral color aligned with sim design.
|
||||
Exclude code inside the method+path bar (handled above). */
|
||||
#nd-page:has(.api-page-header) .prose :not(pre) > code {
|
||||
color: var(--text-body) !important;
|
||||
}
|
||||
|
||||
/* Response Section — custom dropdown-based rendering (Mintlify style) */
|
||||
|
||||
@@ -1162,7 +1152,7 @@ div.flex.flex-row.items-start.bg-fd-secondary.border.rounded-lg.text-xs {
|
||||
font-size: var(--text-code);
|
||||
line-height: 1.25rem;
|
||||
font-weight: 400;
|
||||
font-family: var(--font-mono-stack);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Status-code trigger — matches the content-type label beside it. */
|
||||
@@ -1303,7 +1293,7 @@ div.flex.flex-row.items-start.bg-fd-secondary.border.rounded-lg.text-xs {
|
||||
font-size: var(--text-code);
|
||||
line-height: 1;
|
||||
font-weight: 400;
|
||||
font-family: var(--font-mono-stack);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Everything inside a type token inherits the token's own face, size, and ink.
|
||||
@@ -1469,7 +1459,7 @@ div.flex.flex-row.items-start.bg-fd-secondary.border.rounded-lg.text-xs {
|
||||
font-size: var(--text-code) !important;
|
||||
line-height: 1.25rem !important;
|
||||
font-weight: 400 !important;
|
||||
font-family: var(--font-mono-stack) !important;
|
||||
font-family: var(--font-mono) !important;
|
||||
}
|
||||
|
||||
/* Section headings (Authorization, Path Parameters, etc.) — consistent top spacing */
|
||||
@@ -1611,97 +1601,341 @@ main article tbody tr:last-child td {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
/* Code blocks — aligned with platform chat code block styling */
|
||||
/* Code blocks — platform field chrome.
|
||||
|
||||
/* Standalone code block figure — border + rounded */
|
||||
figure.shiki:not(div > figure.shiki),
|
||||
figure[data-rehype-pretty-code-figure]:not(div > figure[data-rehype-pretty-code-figure]),
|
||||
figure:has(pre):not(div > figure:has(pre)) {
|
||||
border-radius: 0.5rem !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
overflow: hidden !important;
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
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. */
|
||||
:root {
|
||||
--code-surface: var(--surface-5);
|
||||
--code-gutter: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Code block inside tabs (API examples) — no extra border, inherits from parent */
|
||||
div > figure.shiki {
|
||||
border: none !important;
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
.dark {
|
||||
--code-surface: var(--code-bg);
|
||||
--code-gutter: var(--code-line-number);
|
||||
}
|
||||
|
||||
/* Tabbed code block container (cURL/JS/Go tabs) */
|
||||
/* Code block shell — `chipFieldSurfaceClass`'s `rounded-lg` and `--border-1` hairline, over
|
||||
the `--surface-5` / `--code-bg` fill. (`--surface-5` is `chipFilledFillTokens`' light fill;
|
||||
the dark half is the code surface emcn's `Code` container paints. The chip field uses
|
||||
`--surface-4` on dark, which is not a code surface.)
|
||||
|
||||
Three renderers emit these figures: the MDX `pre` mapping and fumadocs-openapi's
|
||||
`renderCodeBlock` both go through `components/ui/code-block.tsx`, but its `UsageTab` renders
|
||||
`ClientCodeBlock` directly and takes no override. The shell could be a class on the
|
||||
component for the first two, at the cost of defining it twice — once there and once here for
|
||||
the third. One definition covering all three is the simpler seam.
|
||||
|
||||
Vertical margin is deliberately not set here: prose fences want `my-4` (the component
|
||||
supplies it) and API samples sit flush inside their panel with `my-0`.
|
||||
|
||||
@see packages/emcn/src/components/chip/chip-chrome.ts — `chipFieldSurfaceClass`, the constant
|
||||
this mirrors. Keep the two in step by hand. */
|
||||
figure.shiki,
|
||||
div:has(> [role="tablist"]):has(> div > figure.shiki) {
|
||||
border-radius: 0.5rem !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
overflow: hidden !important;
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-1);
|
||||
background-color: var(--code-surface);
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Tab list bar — match platform surface-4 */
|
||||
/* Title row — the platform's `--surface-4`-over-`--surface-5` pairing, the same split
|
||||
ChipModal uses between its outer frame and inner panel. */
|
||||
figure.shiki > div:first-child:has(figcaption),
|
||||
div:has(> div > figure.shiki) > [role="tablist"] {
|
||||
background: none !important;
|
||||
border-bottom: 1px solid var(--surface-active) !important;
|
||||
}
|
||||
|
||||
/* Tab triggers — muted text, active gets foreground */
|
||||
div:has(> div > figure.shiki) > [role="tablist"] button {
|
||||
color: var(--text-muted) !important;
|
||||
font-weight: 470 !important;
|
||||
}
|
||||
|
||||
div:has(> div > figure.shiki) > [role="tablist"] button:hover {
|
||||
color: var(--text-body) !important;
|
||||
}
|
||||
|
||||
div:has(> div > figure.shiki) > [role="tablist"] button[data-state="active"] {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Code block title header (figcaption with filename) */
|
||||
figure.shiki > div:first-child:has(figcaption) {
|
||||
background-color: var(--surface-4) !important;
|
||||
border-bottom: 1px solid var(--surface-active) !important;
|
||||
height: 34px;
|
||||
padding-inline: 0.625rem;
|
||||
background-color: var(--surface-4);
|
||||
border-bottom: 1px solid var(--border-1);
|
||||
}
|
||||
|
||||
figure.shiki figcaption {
|
||||
color: var(--text-muted) !important;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* Code block pre element — transparent bg */
|
||||
figure.shiki pre {
|
||||
background-color: transparent !important;
|
||||
/* fumadocs derives a file-type icon from the title's extension and leaves it at
|
||||
`currentColor`; the platform paints row icons `--text-icon`. */
|
||||
figure.shiki > div:first-child:has(figcaption) svg {
|
||||
color: var(--text-icon);
|
||||
}
|
||||
|
||||
/* Code viewport scroll area — transparent bg */
|
||||
/* Line-number gutter, mirroring the one `Code.Viewer` draws in the app.
|
||||
|
||||
Only multi-line fences get one, column and digit together. A line number on a one-line shell
|
||||
command references nothing, and these docs are mostly one-line commands. Reserving the column
|
||||
but leaving it blank was tried and is worse: the code picks up a 44px indent with nothing in
|
||||
it, which reads as a rendering fault rather than as alignment. Single-line fences therefore
|
||||
keep fumadocs' default padding, so adjacent blocks can start their text 28px apart —
|
||||
acceptable, because each fence is its own bordered card, and differing padding between cards
|
||||
is far less noticeable than an unexplained indent inside one.
|
||||
|
||||
The column is opened through fumadocs' own `--padding-left`, not by overriding its `.line`
|
||||
rule: that rule is `.shiki:not(.not-fumadocs-codeblock *) .line`, and `:not()` carries its
|
||||
argument's specificity, so it lands at (0,3,0) — above anything reasonable to write here.
|
||||
|
||||
Declared at every level the variable could be shadowed at, because it is inherited and the
|
||||
nearest declaration wins: fumadocs sets it on `.shiki`, which is on the `figure` for prose
|
||||
fences but on the `figure` AND the inner `pre` for API samples.
|
||||
|
||||
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.
|
||||
*/
|
||||
figure.shiki:has(.line ~ .line),
|
||||
figure.shiki:has(.line ~ .line) > div[role="region"],
|
||||
figure.shiki:has(.line ~ .line) .shiki {
|
||||
--padding-left: 2.75rem !important;
|
||||
}
|
||||
|
||||
figure.shiki code {
|
||||
counter-reset: line;
|
||||
}
|
||||
|
||||
/* Gated on the same condition as the digit these anchor: without it, every line of every
|
||||
single-line fence becomes a positioned containing block for a pseudo-element that never
|
||||
renders. */
|
||||
figure.shiki code:has(.line ~ .line) .line {
|
||||
position: relative;
|
||||
counter-increment: line;
|
||||
}
|
||||
|
||||
figure.shiki code:has(.line ~ .line) .line::before {
|
||||
content: counter(line);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 1.75rem;
|
||||
text-align: right;
|
||||
color: var(--code-gutter);
|
||||
font-size: var(--text-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* The code fill is painted here, on the scroll viewport, rather than left to show through from
|
||||
the figure or the tab group. Those diverge per renderer: fumadocs gives a standalone figure
|
||||
`bg-fd-card` but an in-tab figure `bg-fd-secondary`, and this app forces
|
||||
`--color-fd-card: transparent` on API reference pages. The result was measurable — a request
|
||||
panel showing the page background (#fff light, `--bg` dark) beside a response panel showing
|
||||
the code surface. The viewport is the one box all three renderers agree on. */
|
||||
figure.shiki > div[role="region"] {
|
||||
background-color: transparent !important;
|
||||
background-color: var(--code-surface);
|
||||
}
|
||||
|
||||
/* Copy button — minimal ghost style */
|
||||
figure.shiki button[aria-label="Copy Text"],
|
||||
figure.shiki button[aria-label="Copied Text"] {
|
||||
color: var(--text-muted) !important;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
backdrop-filter: none !important;
|
||||
/* Shiki emits `--shiki-*-bg` custom properties under `defaultColor: false`; keep the `pre` clear
|
||||
so nothing paints over the viewport fill. */
|
||||
figure.shiki pre {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
figure.shiki button[aria-label="Copy Text"]:hover {
|
||||
color: var(--text-icon) !important;
|
||||
/* 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.
|
||||
|
||||
`!important` is load-bearing: fumadocs writes this same custom property as an inline style
|
||||
on the viewport, and an inline declaration outranks any stylesheet rule without it. */
|
||||
figure.shiki:not(:has(figcaption)),
|
||||
figure.shiki:not(:has(figcaption)) > div[role="region"],
|
||||
figure.shiki:not(:has(figcaption)) .shiki {
|
||||
--padding-right: 2.75rem !important;
|
||||
}
|
||||
|
||||
/* Copy control on the one block this app does not render.
|
||||
|
||||
Prose fences and the API reference's request/response samples both render
|
||||
`components/ui/code-block.tsx`, which uses an emcn `Button`. Only fumadocs-openapi's
|
||||
`UsageTab` is left — it renders `ClientCodeBlock` directly and takes no override — so these
|
||||
rules bring its button to the same geometry and color as emcn's `variant='quiet' size='icon'`.
|
||||
|
||||
Every rule here is qualified with `:has(> svg[class*="lucide"])`. Without it they also match
|
||||
the emcn `Button`, whose `aria-label` is identical, and re-declare chrome that component
|
||||
owns — silently pinning docs to today's `buttonVariants` values.
|
||||
|
||||
The container carries an opaque `--code-surface` pad rather than fumadocs' `backdrop-blur-lg`.
|
||||
The blur went milky over a filled block, but something has to sit behind the button: these
|
||||
fences scroll horizontally and a long line runs straight under a transparent one. Matching
|
||||
the block's own fill hides the code cleanly instead of frosting it. */
|
||||
figure.shiki > div:has(> button[aria-label$="Text"] > svg[class*="lucide"]) {
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
padding-left: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--code-surface);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
figure.shiki button[aria-label="Copy Text"]:has(> svg[class*="lucide"]),
|
||||
figure.shiki button[aria-label="Copied Text"]:has(> svg[class*="lucide"]) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-icon-muted);
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
transition:
|
||||
background-color 150ms,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
figure.shiki button[aria-label="Copied Text"]:has(> svg[class*="lucide"]) {
|
||||
color: var(--brand-accent);
|
||||
}
|
||||
|
||||
/* One block per API reference page — the request "usage tabs" (cURL / JavaScript / Go / …) —
|
||||
is rendered by fumadocs-openapi's `UsageTab`, which hardcodes its own `ClientCodeBlock`.
|
||||
`renderCodeBlock` covers every other sample, and `OperationClientOptions` exposes only
|
||||
`APIExampleSelector`, so there is no supported prop that reaches this one. Its lucide
|
||||
clipboard is masked over with emcn's `Duplicate` / `Check` artwork so the copy glyph is the
|
||||
same everywhere.
|
||||
|
||||
Scoped to `svg.lucide-*`: any block that already renders the real emcn icon is untouched,
|
||||
and if fumadocs ever adds an override for these tabs, these rules simply stop matching.
|
||||
The paths are copied from `packages/emcn/src/icons/{duplicate,check}.tsx` — a mask cannot
|
||||
reference a component — so they must be updated together if that artwork changes. */
|
||||
figure.shiki button[aria-label$="Text"] > svg[class*="lucide"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
figure.shiki button[aria-label$="Text"]:has(> svg[class*="lucide"])::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background-color: currentColor;
|
||||
mask-image: var(--copy-glyph);
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
-webkit-mask-image: var(--copy-glyph);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
-webkit-mask-size: contain;
|
||||
}
|
||||
|
||||
figure.shiki button[aria-label="Copy Text"] {
|
||||
--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"] {
|
||||
color: var(--brand-accent) !important;
|
||||
--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");
|
||||
}
|
||||
|
||||
/* Copy button container — remove backdrop blur bg */
|
||||
figure.shiki > div:has(> button[aria-label]) {
|
||||
background: none !important;
|
||||
backdrop-filter: none !important;
|
||||
/* Tabbed fences (`<Tabs>` around code) — collapse the strip and the figure into one box so
|
||||
the group reads as a single control rather than a bar stacked on a card.
|
||||
|
||||
Every rule in this group carries the full `:has(> div > figure.shiki)` qualifier. Dropping
|
||||
it on the panel rules would match prose `<Tabs>` too — nine pages use those — and strip the
|
||||
padding they rely on. */
|
||||
/* The strip becomes the group's title row; it and a titled fence's caption row are the same
|
||||
object, so they share the rule above rather than restating its body.
|
||||
|
||||
Its scrollbar is hidden, matching what the platform does for a scrolling tab strip
|
||||
(`overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden` on emcn's `TabStrip`).
|
||||
fumadocs makes the strip `overflow-x-auto`, and an endpoint with ten status codes overflows it
|
||||
in the API reference's narrow rail — leaving a scrollbar across the bottom of a 34px header,
|
||||
which reads as the header being clipped rather than as something scrollable. The code viewport
|
||||
below keeps its scrollbar: there the overflow is content, and hiding it would hide that a line
|
||||
continues. */
|
||||
div:has(> div > figure.shiki) > [role="tablist"] {
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding-inline: 0.375rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
div:has(> div > figure.shiki) > [role="tablist"]::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Triggers read as chips — a `--surface-active` pad on the selected tab, at `ChipTag`'s 6px
|
||||
inline-tag corner — rather than fumadocs' underline, which drew a hard black rule the
|
||||
platform uses nowhere. */
|
||||
div:has(> div > figure.shiki) > [role="tablist"] button {
|
||||
height: 22px;
|
||||
padding-inline: 0.5rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 470;
|
||||
transition:
|
||||
background-color 150ms,
|
||||
color 150ms;
|
||||
}
|
||||
|
||||
div:has(> div > figure.shiki) > [role="tablist"] button {
|
||||
@variant hover-hover {
|
||||
color: var(--text-body);
|
||||
}
|
||||
}
|
||||
|
||||
/* The API reference's request-sample tabs draw their active indicator as an absolutely
|
||||
positioned hairline child rather than a border, so the chip pad below would sit under a
|
||||
second, redundant underline. Matched structurally — it is the only element fumadocs renders
|
||||
before the trigger's label — rather than by its utility classes, which are upstream's to
|
||||
change. */
|
||||
div:has(> div > figure.shiki) > [role="tablist"] button > div:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div:has(> div > figure.shiki) > [role="tablist"] button[data-state="active"] {
|
||||
background-color: var(--surface-active);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* The panel is scaffolding, not chrome. fumadocs gives it `p-4`, its own `rounded-xl`, and a
|
||||
`bg-fd-background` fill that would paint white inside the group's surface; strip all three
|
||||
so the fence sits flush and the group reads as one box. */
|
||||
div:has(> [role="tablist"]):has(> div > figure.shiki) > [role="tabpanel"] {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* A fence inside a panel is already inside the group's box — drop its own shell. Its
|
||||
viewport padding supplies the inset the panel's `p-4` used to. */
|
||||
div:has(> [role="tablist"]):has(> div > figure.shiki) > [role="tabpanel"] > figure.shiki {
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Cards container — vertical list with line dividers instead of grid boxes */
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useId } from 'react'
|
||||
import { chipFieldSurfaceClass, chipFieldTextClass, chipHoverSurfaceClass, cn } from '@sim/emcn'
|
||||
import { ChevronDown } from '@sim/emcn/icons'
|
||||
import type { APIPageClientOptions } from 'fumadocs-openapi/ui/client'
|
||||
|
||||
@@ -29,7 +30,16 @@ export function APIExampleSelector({ items, value, onValueChange }: APIExampleSe
|
||||
id={id}
|
||||
value={selectedValue}
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
className='w-full appearance-none rounded-md border bg-fd-secondary py-2 ps-3 pe-9 text-start font-medium text-fd-secondary-foreground text-sm outline-none transition-colors hover:bg-fd-accent focus-visible:ring-2 focus-visible:ring-fd-ring'
|
||||
/**
|
||||
* Dressed as an emcn chip trigger, but kept a native `select` for the keyboard and
|
||||
* screen-reader behavior a custom listbox would have to rebuild.
|
||||
*/
|
||||
className={cn(
|
||||
chipFieldSurfaceClass,
|
||||
chipFieldTextClass,
|
||||
chipHoverSurfaceClass,
|
||||
'h-[30px] w-full appearance-none ps-2 pe-8 text-left'
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
@@ -37,10 +47,10 @@ export function APIExampleSelector({ items, value, onValueChange }: APIExampleSe
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className='-translate-y-1/2 pointer-events-none absolute end-3 top-1/2 size-[14px] text-fd-muted-foreground' />
|
||||
<ChevronDown className='-translate-y-1/2 pointer-events-none absolute end-2 top-1/2 size-[14px] text-[var(--text-icon)]' />
|
||||
</div>
|
||||
{selectedItem?.description && (
|
||||
<p className='text-fd-muted-foreground text-xs'>{selectedItem.description}</p>
|
||||
<p className='text-[var(--text-muted)] text-caption'>{selectedItem.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,42 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import { Button, cn, useCopyToClipboard } from '@sim/emcn'
|
||||
import { Check, Duplicate } from '@sim/emcn/icons'
|
||||
import { CodeBlock as FumadocsCodeBlock } from 'fumadocs-ui/components/codeblock'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function CodeBlock(props: React.ComponentProps<typeof FumadocsCodeBlock>) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
/** Copy control for a code block — emcn's canonical icon button for a lone glyph affordance. */
|
||||
function CopyButton({ getCode }: { getCode: () => string }) {
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
return (
|
||||
<Button
|
||||
type='button'
|
||||
variant='quiet'
|
||||
size='icon'
|
||||
aria-label={copied ? 'Copied Text' : 'Copy Text'}
|
||||
onClick={() => copy(getCode())}
|
||||
/**
|
||||
* Tailwind v4's preflight sets `button { cursor: default }`. `apps/sim` is on v3, where
|
||||
* buttons keep the UA pointer, so `buttonVariants` never had to declare one — without
|
||||
* this the same control feels inert here and live there.
|
||||
*/
|
||||
className='cursor-pointer'
|
||||
>
|
||||
{copied ? (
|
||||
<Check className='size-[14px] text-[var(--brand-accent)]' />
|
||||
) : (
|
||||
<Duplicate className='size-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Docs code block for prose fences, wired into the MDX `pre` mapping.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
function getCode() {
|
||||
const pre = figureRef.current?.getElementsByTagName('pre').item(0)
|
||||
if (!pre) return ''
|
||||
const clone = pre.cloneNode(true) as HTMLElement
|
||||
clone.querySelectorAll('.nd-copy-ignore').forEach((node) => node.replaceWith('\n'))
|
||||
return clone.textContent ?? ''
|
||||
}
|
||||
|
||||
return (
|
||||
<FumadocsCodeBlock
|
||||
ref={figureRef}
|
||||
title={title}
|
||||
{...props}
|
||||
className={cn('!border !border-[var(--border)] !shadow-none', props.className)}
|
||||
Actions={({ className }) => (
|
||||
<div className={cn('empty:hidden', className)}>
|
||||
<button
|
||||
type='button'
|
||||
aria-label={copied ? 'Copied Text' : 'Copy Text'}
|
||||
onClick={(e) => {
|
||||
const pre = (e.currentTarget as HTMLElement).closest('figure')?.querySelector('pre')
|
||||
if (pre) handleCopy(pre.textContent || '')
|
||||
}}
|
||||
className='cursor-pointer rounded-md p-2 text-[var(--text-muted)] transition-colors hover:text-[var(--text-icon)]'
|
||||
>
|
||||
<span className='flex items-center justify-center'>
|
||||
{copied ? (
|
||||
<Check className='size-[16px] text-[var(--brand-accent)]' />
|
||||
) : (
|
||||
<Duplicate className='size-[16px] text-[var(--text-muted)]' />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
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.
|
||||
*/
|
||||
Actions={() => (
|
||||
<div className={cn('flex items-center', title ? '-me-1' : 'absolute top-2 right-2 z-[1]')}>
|
||||
<CopyButton getCode={getCode} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { LanguageRegistration } from 'shiki'
|
||||
|
||||
/**
|
||||
* TextMate injection that highlights a `curl` JSON request body as JSON.
|
||||
*
|
||||
* To a shell, `curl -d '{...}'` is a single-quoted string — one token spanning the whole body.
|
||||
* So the same JSON that renders with colored keys in a response sample rendered as one flat
|
||||
* block of string color in the request sample directly above it.
|
||||
*
|
||||
* Two things this is deliberately NOT:
|
||||
*
|
||||
* - **`{ include: 'source.json' }`.** Injecting the real JSON grammar attaches it, but its
|
||||
* object pattern only assigns `support.type.property-name.json` — the scope that colors keys —
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* matches line by line, so without the `$` alternative the outer brace of a formatted body never
|
||||
* begins a match.
|
||||
*
|
||||
* Verified against `jq '.[0]'`, `awk '{print $1}'`, `grep -o 'foo'` and `echo '{}'`: none are
|
||||
* re-colored.
|
||||
*/
|
||||
export const curlJsonBodyGrammar: LanguageRegistration = {
|
||||
name: 'curl-json-body',
|
||||
scopeName: 'inject.curl-json-body',
|
||||
injectionSelector: 'L:string.quoted.single.shell',
|
||||
injectTo: ['source.shell'],
|
||||
patterns: [{ include: '#object' }],
|
||||
repository: {
|
||||
object: {
|
||||
begin: '\\{(?=\\s*(?:\\}|"|$))',
|
||||
end: '\\}',
|
||||
beginCaptures: { 0: { name: 'punctuation.definition.dictionary.begin.json' } },
|
||||
endCaptures: { 0: { name: 'punctuation.definition.dictionary.end.json' } },
|
||||
patterns: [
|
||||
{ include: '#key' },
|
||||
{ match: ':', name: 'punctuation.separator.dictionary.key-value.json' },
|
||||
{ match: ',', name: 'punctuation.separator.dictionary.pair.json' },
|
||||
{ include: '#value' },
|
||||
],
|
||||
},
|
||||
/** Matched before `#value` so a `"foo":` reads as a key rather than a string. */
|
||||
key: { match: '"[^"]*"(?=\\s*:)', name: 'support.type.property-name.json' },
|
||||
array: {
|
||||
begin: '\\[',
|
||||
end: '\\]',
|
||||
beginCaptures: { 0: { name: 'punctuation.definition.array.begin.json' } },
|
||||
endCaptures: { 0: { name: 'punctuation.definition.array.end.json' } },
|
||||
patterns: [{ include: '#value' }, { match: ',', name: 'punctuation.separator.array.json' }],
|
||||
},
|
||||
value: {
|
||||
patterns: [
|
||||
{ include: '#object' },
|
||||
{ include: '#array' },
|
||||
{ match: '"[^"]*"', name: 'string.quoted.double.json' },
|
||||
{ match: '\\b(?:true|false|null)\\b', name: 'constant.language.json' },
|
||||
{ match: '-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?', name: 'constant.numeric.json' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { ThemeRegistrationRaw } from 'shiki'
|
||||
|
||||
/**
|
||||
* Sim's syntax theme for docs code blocks.
|
||||
*
|
||||
* The platform highlights code with PrismJS through `@sim/emcn`'s `Code` component, whose
|
||||
* token colors live in `packages/emcn/src/components/code/code.css`. Docs highlight with
|
||||
* Shiki, which is TextMate-scoped rather than Prism-tokenized, so parity has to be
|
||||
* expressed as a scope-to-color mapping rather than a shared stylesheet. Every color below
|
||||
* is copied from `code.css` — when that file changes, change these to match.
|
||||
*
|
||||
* The mapping was derived by highlighting the same snippets through both engines and
|
||||
* comparing output. Two places where the grammars genuinely disagree, and the choice made:
|
||||
*
|
||||
* - Prism only colors a shell command when it recognises the binary, so `npm` highlights
|
||||
* but `sim` does not. TextMate scopes the first word of a statement as the command
|
||||
* regardless, so `sim` highlights too. Shiki's reading is the correct one and is kept.
|
||||
* - TextMate scopes every bare shell argument as `string.unquoted.argument`, which would
|
||||
* paint most of a command line in the string color. Prism leaves those plain, so the
|
||||
* scope is pinned back to the base foreground and only genuinely quoted strings carry
|
||||
* the string color.
|
||||
*
|
||||
* Bare `variable` is deliberately unmapped. TextMate scopes declared JavaScript
|
||||
* identifiers as `variable.other.*`, and coloring those would make JS and TS far busier
|
||||
* than the platform editor, which leaves them at the base foreground. Only the things
|
||||
* Prism actually calls out — object and config keys, shell flags and shell expansions —
|
||||
* are mapped to the variable color.
|
||||
*/
|
||||
|
||||
/** Light-mode token colors, from the `.code-editor-theme` rules in emcn's `code.css`. */
|
||||
const LIGHT = {
|
||||
/** `--text-primary` — the platform's base code foreground. */
|
||||
foreground: '#1a1a1a',
|
||||
/** `--surface-5` — the fill `chipFieldSurfaceClass` puts under a code surface. */
|
||||
background: '#f3f3f3',
|
||||
comment: '#16a34a',
|
||||
punctuation: '#383838',
|
||||
variable: '#0891b2',
|
||||
constant: '#16a34a',
|
||||
string: '#b45309',
|
||||
keyword: '#2f55ff',
|
||||
function: '#ca8a04',
|
||||
regex: '#e11d48',
|
||||
deleted: '#dc2626',
|
||||
} as const
|
||||
|
||||
/** Dark-mode token colors, from the `.dark .code-editor-theme` rules in emcn's `code.css`. */
|
||||
const DARK = {
|
||||
/** `--code-foreground`. */
|
||||
foreground: '#eeeeee',
|
||||
/** `--code-bg`. */
|
||||
background: '#1f1f1f',
|
||||
comment: '#6ec97d',
|
||||
punctuation: '#d4d4d4',
|
||||
variable: '#4fc3f7',
|
||||
constant: '#a5d6a7',
|
||||
string: '#f39c6b',
|
||||
keyword: '#2fa1ff',
|
||||
function: '#fbbf24',
|
||||
regex: '#f87171',
|
||||
deleted: '#f87171',
|
||||
} as const
|
||||
|
||||
type TokenColors = typeof LIGHT | typeof DARK
|
||||
|
||||
/**
|
||||
* Builds a Shiki theme from one palette. Scope order matters: TextMate resolves a token by
|
||||
* longest matching scope, so the narrower entries below (`string.unquoted.argument`,
|
||||
* `keyword.operator`) intentionally follow and override the broader ones.
|
||||
*/
|
||||
function buildTheme(name: string, type: 'light' | 'dark', c: TokenColors): ThemeRegistrationRaw {
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
settings: [
|
||||
{ settings: { background: c.background, foreground: c.foreground } },
|
||||
|
||||
/** Prism `comment` / `block-comment` / `prolog` / `doctype` / `cdata`. */
|
||||
{
|
||||
scope: ['comment', 'punctuation.definition.comment', 'string.comment'],
|
||||
settings: { foreground: c.comment },
|
||||
},
|
||||
|
||||
/** Prism `keyword` / `atrule` — plus TextMate's storage scopes, which Prism folds into keywords. */
|
||||
{
|
||||
scope: [
|
||||
'keyword',
|
||||
'keyword.control',
|
||||
'keyword.other',
|
||||
'storage',
|
||||
'storage.type',
|
||||
'storage.modifier',
|
||||
'meta.import keyword',
|
||||
'meta.export keyword',
|
||||
],
|
||||
settings: { foreground: c.keyword },
|
||||
},
|
||||
|
||||
/** Prism `function` and `class-name`. */
|
||||
{
|
||||
scope: [
|
||||
'entity.name.function',
|
||||
'entity.name.command',
|
||||
'support.function',
|
||||
'meta.function-call.generic',
|
||||
'entity.name.class',
|
||||
'entity.name.type',
|
||||
'support.class',
|
||||
'support.type',
|
||||
],
|
||||
settings: { foreground: c.function },
|
||||
},
|
||||
|
||||
/**
|
||||
* Prism `property` / `attr-name` / `variable` — object and config keys, markup
|
||||
* attributes, shell flags and shell expansions. Bare `variable` is excluded on
|
||||
* purpose; see the module docblock.
|
||||
*/
|
||||
{
|
||||
scope: [
|
||||
'support.type.property-name',
|
||||
'meta.object-literal.key',
|
||||
'entity.name.tag.yaml',
|
||||
'entity.other.attribute-name',
|
||||
'constant.other.option',
|
||||
'variable.other.normal',
|
||||
'variable.other.special',
|
||||
'punctuation.definition.variable',
|
||||
],
|
||||
settings: { foreground: c.variable },
|
||||
},
|
||||
|
||||
/** Prism `number` / `boolean` / `constant` / `tag`. */
|
||||
{
|
||||
scope: [
|
||||
'constant.numeric',
|
||||
'constant.language',
|
||||
'constant.character',
|
||||
'support.constant',
|
||||
'entity.name.tag',
|
||||
'variable.language',
|
||||
],
|
||||
settings: { foreground: c.constant },
|
||||
},
|
||||
|
||||
/** Prism `string` / `char` / `inserted`. */
|
||||
{
|
||||
scope: [
|
||||
'string',
|
||||
'string.quoted',
|
||||
'string.template',
|
||||
'punctuation.definition.string',
|
||||
'markup.inserted',
|
||||
],
|
||||
settings: { foreground: c.string },
|
||||
},
|
||||
|
||||
/**
|
||||
* Bare shell arguments. TextMate calls these strings; Prism leaves them plain, and
|
||||
* plain is what keeps a command line readable. Declared after `string` so it wins.
|
||||
*/
|
||||
{ scope: ['string.unquoted.argument'], settings: { foreground: c.foreground } },
|
||||
|
||||
/** Prism `punctuation` / `operator` / `entity` / `symbol`. */
|
||||
{
|
||||
scope: [
|
||||
'punctuation',
|
||||
'meta.brace',
|
||||
'keyword.operator',
|
||||
'punctuation.separator',
|
||||
'punctuation.terminator',
|
||||
'punctuation.accessor',
|
||||
],
|
||||
settings: { foreground: c.punctuation },
|
||||
},
|
||||
|
||||
/** Prism `regex` / `important`. */
|
||||
{
|
||||
scope: ['string.regexp', 'constant.regexp', 'keyword.other.important'],
|
||||
settings: { foreground: c.regex },
|
||||
},
|
||||
|
||||
/** Prism `deleted`, plus TextMate's invalid scopes. */
|
||||
{
|
||||
scope: ['markup.deleted', 'invalid', 'invalid.illegal'],
|
||||
settings: { foreground: c.deleted },
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const simShikiLight = buildTheme('sim-light', 'light', LIGHT)
|
||||
const simShikiDark = buildTheme('sim-dark', 'dark', DARK)
|
||||
|
||||
/**
|
||||
* The Shiki configuration shared by both highlighting pipelines — the MDX one in
|
||||
* `source.config.ts` and fumadocs-openapi's separate instance in the docs page. They must
|
||||
* agree or the API reference renders in a different palette from the rest of the site, so the
|
||||
* options object is defined once here rather than written out at each call site.
|
||||
*
|
||||
* `defaultColor: false` is fumadocs' own default and has to be restated, because supplying
|
||||
* `themes` replaces that default block wholesale. It is what emits the paired
|
||||
* `--shiki-light` / `--shiki-dark` custom properties that let the theme toggle recolor code
|
||||
* without re-highlighting.
|
||||
*/
|
||||
export const simShikiOptions = {
|
||||
themes: { light: simShikiLight, dark: simShikiDark },
|
||||
defaultColor: false,
|
||||
} as const
|
||||
@@ -1,4 +1,6 @@
|
||||
import { defineConfig, defineDocs, frontmatterSchema } from 'fumadocs-mdx/config'
|
||||
import { curlJsonBodyGrammar } from './lib/shiki-curl-json'
|
||||
import { simShikiOptions } from './lib/shiki-theme'
|
||||
|
||||
export const docs = defineDocs({
|
||||
dir: 'content/docs',
|
||||
@@ -10,4 +12,17 @@ export const docs = defineDocs({
|
||||
},
|
||||
})
|
||||
|
||||
export default defineConfig()
|
||||
export default defineConfig({
|
||||
mdxOptions: {
|
||||
/**
|
||||
* Shiki defaults to `github-light` / `github-dark`, whose blues and purples appear nowhere
|
||||
* in the product. These themes carry the platform's own token colors instead — see
|
||||
* `lib/shiki-theme.ts`.
|
||||
*/
|
||||
rehypeCodeOptions: {
|
||||
...simShikiOptions,
|
||||
/** Preloads the injection that colors a `curl -d '{…}'` body — see lib/shiki-curl-json.ts. */
|
||||
langs: [curlJsonBodyGrammar],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user