From b6a784eb63de0968e05becb05aa8495d191b12f8 Mon Sep 17 00:00:00 2001 From: mohamedh Date: Fri, 23 Oct 2026 16:21:01 +0100 Subject: [PATCH 01/19] chore(docs): upgrade to nimbus-docs 0.19.0 --- packages/cloudflare-api-site/nimbus.json | 2 +- packages/cloudflare-api-site/package.json | 2 +- .../src/description-markdown.test.ts | 16 ++-- .../src/description-markdown.ts | 17 ++-- .../src/pages/[...slug].astro | 6 -- .../src/pages/[...slug]/index.md.ts | 3 +- .../src/pages/[...slug]/index.mdx.ts | 3 +- .../src/pages/llms-full.txt.ts | 1 - pnpm-lock.yaml | 85 +++++++------------ pnpm-workspace.yaml | 1 + 10 files changed, 52 insertions(+), 84 deletions(-) diff --git a/packages/cloudflare-api-site/nimbus.json b/packages/cloudflare-api-site/nimbus.json index cf64c5455..6bc49ab79 100644 --- a/packages/cloudflare-api-site/nimbus.json +++ b/packages/cloudflare-api-site/nimbus.json @@ -1,7 +1,7 @@ { "$schema": "https://nimbus-docs.com/schema/nimbus.json", "version": "0.7.12", - "lastReviewedNimbusVersion": "0.18.0", + "lastReviewedNimbusVersion": "0.19.0", "templatesTag": "templates-v0.7.12", "variant": "empty", "registry": "https://nimbus-docs.com/registry", diff --git a/packages/cloudflare-api-site/package.json b/packages/cloudflare-api-site/package.json index 6fa706588..8338cbf38 100644 --- a/packages/cloudflare-api-site/package.json +++ b/packages/cloudflare-api-site/package.json @@ -16,7 +16,7 @@ "dependencies": { "@astrojs/cloudflare": "^15.0.0-beta.2", "@astrojs/prism": "^4.0.2", - "@cloudflare/nimbus-docs": "^0.18.0", + "@cloudflare/nimbus-docs": "^0.19.0", "@fontsource-variable/inter": "5.3.0", "@fontsource-variable/jetbrains-mono": "5.3.0", "@iconify-json/ph": "^1.2.2", diff --git a/packages/cloudflare-api-site/src/description-markdown.test.ts b/packages/cloudflare-api-site/src/description-markdown.test.ts index c51478b11..420bff6cb 100644 --- a/packages/cloudflare-api-site/src/description-markdown.test.ts +++ b/packages/cloudflare-api-site/src/description-markdown.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { keepPlaceholders, renderDescription } from './description-markdown.ts'; +import { keepDroppedHtml, renderDescription } from './description-markdown.ts'; const ASTRO_FERN_PROBE = ' [probe](javascript:probe)'; @@ -19,13 +19,13 @@ test('placeholders inside code are left alone', () => { '~~~\nmetadata.\n~~~', 'Para\n\n metadata.\n\nafter', ]) { - assert.equal(keepPlaceholders(markdown), markdown); + assert.equal(keepDroppedHtml(markdown), markdown); } }); -test('a leading indent or blank lines are trimmed, as Nimbus does, so the placeholder is escaped', () => { - assert.equal(keepPlaceholders(' metadata. leading indent'), 'metadata.<key> leading indent'); - assert.equal(keepPlaceholders('\n\nmetadata. after blank lines'), 'metadata.<key> after blank lines'); +test('a leading indent or blank lines are trimmed, as Nimbus does, so the comment is escaped', () => { + assert.equal(keepDroppedHtml(' leading indent'), '<!--sse--> leading indent'); + assert.equal(keepDroppedHtml('\n\n after blank lines'), '<!--sse--> after blank lines'); }); test('escaped backticks around a placeholder still escape it', () => { @@ -38,7 +38,7 @@ test('already-escaped placeholders, autolinks and angle-bracket links are unchan 'Autolink ok', 'Link [a]() ok', ]) { - assert.equal(keepPlaceholders(markdown), markdown); + assert.equal(keepDroppedHtml(markdown), markdown); } assert.match( renderDescription(''), @@ -62,8 +62,8 @@ test('allowed tags still render, including a placeholder named like one', () => assert.equal(renderDescription('metadata.bold'), '

metadata.bold

\n'); }); -test('offsets count string characters, so text before a placeholder can be non-ASCII', () => { - assert.equal(keepPlaceholders('Ünïcödé — “quotes” then metadata.'), 'Ünïcödé — “quotes” then metadata.<key>'); +test('offsets count string characters, so text before a comment can be non-ASCII', () => { + assert.equal(keepDroppedHtml('Ünïcödé — “quotes” then '), 'Ünïcödé — “quotes” then <!--sse-->'); }); test('unsafe HTML and links stay inert', () => { diff --git a/packages/cloudflare-api-site/src/description-markdown.ts b/packages/cloudflare-api-site/src/description-markdown.ts index 35cf444fd..173b6630a 100644 --- a/packages/cloudflare-api-site/src/description-markdown.ts +++ b/packages/cloudflare-api-site/src/description-markdown.ts @@ -8,7 +8,7 @@ interface MdastNode { children?: MdastNode[]; } -const ALLOWED_TAGS = new Set((defaultSchema.tagNames ?? []).map((name) => name.toLowerCase())); +const DROPPED_TAGS = new Set((defaultSchema.strip ?? []).map((name) => name.toLowerCase())); const TAG_NAME = /^<\/?([A-Za-z][A-Za-z0-9-]*)/; function htmlRanges(node: MdastNode, ranges: [number, number][]): [number, number][] { @@ -19,18 +19,15 @@ function htmlRanges(node: MdastNode, ranges: [number, number][]): [number, numbe return ranges; } -function escapeUnknownTags(html: string): string { +function escapeDroppedTags(html: string): string { return html.replace(/ { const name = TAG_NAME.exec(html.slice(offset))?.[1]?.toLowerCase(); - return name !== undefined && ALLOWED_TAGS.has(name) ? lt : '<'; + return name === undefined || DROPPED_TAGS.has(name) ? '<' : lt; }); } -/** - * Escapes raw HTML the sanitizer would drop, such as `metadata.` placeholders and comments, so - * it shows as text. Trims like Nimbus's renderer, so both parse the same block structure. - */ -export function keepPlaceholders(markdown: string): string { +// Nimbus keeps unknown tags such as `metadata.` as text but drops comments and ` diff --git a/packages/cloudflare-api-site/src/components/ui/api-code-rail/code-rail.client.ts b/packages/cloudflare-api-site/src/components/ui/api-code-rail/code-rail.client.ts new file mode 100644 index 000000000..a1ff307cd --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-code-rail/code-rail.client.ts @@ -0,0 +1,104 @@ +/** + * Wires the code rail's sample , over + * a raised (bg-card) content layer holding the code body. The code body reuses + * the shared `` figure; we reset its chrome to sit flush, add gutter line + * numbers, and lift the injected copy button up into the recessed header (it + * escapes to the LayerCard root — see the copy rule). + * + * Loaded as a plain global stylesheet (imported by ApiCodeRail.astro), so the + * `[data-nb-api-coderail]` scope and the `.nb-code-*` overrides apply exactly as + * an `is:global` block would. + */ +[data-nb-api-coderail] { + --nb-cr-border: color-mix(in oklab, var(--nb-foreground) 14%, transparent); + /* Focus-ring fallback accent — the house info token (blue); used only where + `--color-ring` isn't set. Sourced from the token so it follows the theme. */ + --nb-cr-accent: var(--color-info); + font-size: 0.8125rem; +} +/* LayerCard sets text-sm; keep the rail's tighter size on its subtree. */ +[data-nb-api-coderail] .nb-cr-layer { + font-size: 0.8125rem; +} + +/* Recessed header row (LayerCardHeader). Right pad reserves the always-visible + copy button's column + gap; the title reads at full foreground weight. */ +[data-nb-api-coderail] .nb-cr-head { + gap: 0.5rem; + /* Left inset matches the 0.5rem block padding and the copy button's inset. */ + padding-left: 0.5rem; + padding-right: 2.75rem; + color: var(--color-foreground); +} +[data-nb-api-coderail] .nb-cr-titlewrap { + flex: 1 1 auto; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 0.375rem; +} +[data-nb-api-coderail] .nb-cr-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + color: var(--color-foreground); +} +/* Language selector — a native can't paint its chevron from `currentColor`, so the arrow + is a data-URI stroke; a dark-mode override keeps it legible on dark chrome. */ +[data-mode="dark"] [data-nb-api-coderail] .nb-cr-langselect { + background-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%20fill='none'%20stroke='%23a1a1aa'%20stroke-width='2'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cpath%20d='m6%209%206%206%206-6'/%3E%3C/svg%3E"); +} +/* A hidden sample panel drops out entirely — no stray copy button, no gap. */ +[data-nb-api-coderail] [data-nb-lang-panel][hidden] { + display: none; +} + +/* Raised content layer of the request card — the LayerCard's raised body + (`overflow-hidden rounded-lg bg-card ring ring-border`, set in the markup so + it matches the canonical LayerCardContent radius/ring exactly). Its rounded + top corners tuck under the recessed header for the nested-radius look. Left + un-positioned (no `relative`) so the copy button escapes past it to the + LayerCard root and lands in the header; its overflow:hidden doesn't clip + that button, whose containing block (the LayerCard) is an ancestor. */ + +/* The response example lives INSIDE the request LayerCard as a recessed bottom. + Unlike the request (recessed head over a RAISED bg-card body), the whole + response block stays on the card's muted layer: the label row ("Response + example" + status toggle) AND the code body share one recessed surface, so + the informational response reads as sunken beside the raised, interactive + request card. The status toggle swaps the visible response panel (see + code-rail.client). Responses carry no copy button. */ +/* Recessed code body — flush on the LayerCard's muted layer (no bg-card / ring), + so it's continuous with the label row above. The figure's background + is already reset to transparent below, so the muted layer shows through. No + radius of its own: it runs edge-to-edge and the LayerCard's overflow-hidden + clips it to the card's rounded bottom corners. */ +[data-nb-api-coderail] .nb-cr-respcontent { + overflow: hidden; +} +[data-nb-api-coderail] .nb-cr-resphead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + /* Same 0.5rem padding as the request header. */ + padding: 0.5rem; +} +/* Muted text on the muted track needs a step toward the foreground for + 4.5:1 contrast; in dark mode that only raises it further. */ +[data-nb-api-coderail] .nb-cr-resptext { + color: color-mix(in oklch, var(--color-muted-foreground) 85%, var(--color-foreground)); + font-weight: 500; +} +/* Status toggle — a compact segmented control: a 26px recessed track with 6px + corners; the selected status sits on a raised 4px pill in its class colour + (2xx success, 3xx info, 4xx/5xx danger). */ +[data-nb-api-coderail] .nb-cr-respswitch { + display: inline-flex; + align-items: stretch; + height: 1.625rem; + padding: 0 0.125rem; + border-radius: 6px; + background: var(--nb-muted); + box-shadow: 0 0 0 1px var(--nb-border); +} +[data-nb-api-coderail] .nb-cr-respbtn { + appearance: none; + border: 0; + background: transparent; + margin: 0.125rem 0; + padding: 0 0.5rem; + border-radius: 4px; + font-family: inherit; + font-size: 0.75rem; + font-weight: 500; + color: color-mix(in oklch, var(--color-muted-foreground) 85%, var(--color-foreground)); + cursor: pointer; +} +[data-nb-api-coderail] .nb-cr-respbtn:hover { + color: var(--color-foreground); +} +[data-nb-api-coderail] .nb-cr-respbtn[aria-selected="true"] { + background: var(--nb-card); + box-shadow: var(--nb-shadow-sm), 0 0 0 1px var(--nb-border-strong); + color: var(--color-foreground); +} +[data-nb-api-coderail] .nb-cr-respbtn.nb-cr-2xx[aria-selected="true"] { + color: var(--color-success); +} +[data-nb-api-coderail] .nb-cr-respbtn.nb-cr-3xx[aria-selected="true"] { + color: var(--color-info); +} +[data-nb-api-coderail] .nb-cr-respbtn.nb-cr-err[aria-selected="true"] { + color: var(--color-danger); +} +[data-nb-api-coderail] .nb-cr-respbtn:focus-visible { + outline: 2px solid var(--color-ring, var(--nb-cr-accent)); + outline-offset: -2px; +} +/* Hidden response panel drops out entirely. */ +[data-nb-api-coderail] [data-nb-resp-panel][hidden] { + display: none; +} +/* Responses show no copy button — suppress the one the primitive injects. */ +[data-nb-api-coderail] .nb-cr-respsection .nb-code-copy { + display: none; +} +/* The response body scrolls tall JSON; scrollbars come from globals.css. */ +[data-nb-api-coderail] .nb-cr-respbody { + max-height: 20rem; + overflow-y: auto; +} + +/* Reset the shared figure so it sits flush inside the surface. The + figure goes position:static so its injected copy button escapes to the + nearest positioned ancestor — the LayerCard root (the only `.relative` here) + — landing top-right in the request header rather than over the first code + line. Only the request card renders a copy button; the response suppresses + it (see the .nb-cr-respsection .nb-code-copy rule). `codeCopy` sets + `figure.style.position = "relative"` inline at runtime; the non-important + inline write loses to this important stylesheet declaration, so the escape + holds. Neither the raised content nor the surface (overflow:hidden) clips + the escaped button — its containing block (the LayerCard) is an ancestor. */ +[data-nb-api-coderail] .nb-cr-body .nb-code-figure { + position: static !important; + margin: 0; + border: 0; + border-radius: 0; + background: transparent; + overflow: visible; +} +/* Suppress the figure's data-nb-lang badge — the header already labels it. + Outweighs the global badge selector on specificity, so order doesn't matter. */ +[data-nb-api-coderail] .nb-cr-body .nb-code-figure[data-nb-lang]::before { + content: none; +} +[data-nb-api-coderail] .nb-cr-body pre.astro-code { + margin: 0; + border: 0; + border-radius: 0; + background: transparent; + padding: 0.75rem; + font-size: 0.8125rem; + /* 13/20: a whole-pixel line, so the rail lines up with itself. */ + line-height: 1.25rem; +} + +/* Gutter line numbers, counted off Shiki's per-line spans. Shiki separates + lines with literal "\n" text nodes, which a
 already renders as breaks;
+   leaving `.line` inline avoids a second break (block box + newline = the
+   double-spacing bug). The counter still increments once per `.line`. */
+[data-nb-api-coderail] pre.astro-code code {
+  counter-reset: nb-cr-line;
+  display: block;
+}
+[data-nb-api-coderail] pre.astro-code .line::before {
+  counter-increment: nb-cr-line;
+  content: counter(nb-cr-line);
+  display: inline-block;
+  width: 1.25rem;
+  margin-right: 1rem;
+  text-align: right;
+  color: color-mix(in oklab, var(--nb-muted-foreground) 65%, transparent);
+  user-select: none;
+}
+/* Shiki emits a trailing empty line; don't number a blank last row. */
+[data-nb-api-coderail] pre.astro-code .line:last-child:empty {
+  display: none;
+}
+
+/* The copy button (injected by codeCopy) is pinned into the header row as the
+   right-most control: same 1.75rem box and same top as the language 
+      
+    
+
+    
+ +
+ + {later.length > 0 && ( + + Next: {later[0]!.label} + + )} + + {/* Filter results are built in the browser; search indexes the page's own list. */} + +

+ + {/* One result row per method, cloned and filled in by the filter. */} + {methods.map((method) => ( + + ))} + +)} + + + + diff --git a/packages/cloudflare-api-site/src/components/ui/api-layout/ApiHomeChunk.astro b/packages/cloudflare-api-site/src/components/ui/api-layout/ApiHomeChunk.astro new file mode 100644 index 000000000..82f2a40ea --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-layout/ApiHomeChunk.astro @@ -0,0 +1,143 @@ +--- +/** + * ApiHomeChunk — one part of the API home's list: each tag group a heading, + * each section its linked title, description and endpoints, name first; then + * untagged endpoints and webhooks. The home page renders the first part; each + * later part is a page of its own, served by the API route at + * `/index//`, which the home fetches and appends as the reader + * scrolls. Edits here show in both. + */ +import ApiOperationList from "./ApiOperationList.astro"; +import { withBase } from "@cloudflare/nimbus-docs/runtime"; +import type { ApiIndexChunk } from "@cloudflare/nimbus-docs/runtime"; + +interface Props { + chunk: ApiIndexChunk; + /** Whether the spec has tag groups; without them, sections in no group get + * no "More" heading. */ + grouped: boolean; +} + +const { chunk, grouped } = Astro.props; +const href = (path: string) => withBase(path, import.meta.env.BASE_URL); +const Heading = (depth: number) => `h${Math.min(6, depth)}` as "h2"; +const first = chunk.groups[0]; +--- + +
+ {chunk.groups.map((group) => { + const label = group.label ?? (grouped ? "More" : null); + const level = label ? 3 : 2; + return ( +
+ {label && (group.continued ? ( +

{label}, continued

+ ) : ( +

+ {group.ref ? {label} : label} +

+ ))} + {group.operations.length > 0 && } + {group.sections.map((section) => { + const Tag = Heading(level + section.depth); + return ( +
+ + {section.ref.label} + {section.continued && (continued)} + + {section.description &&

{section.description}

} + {section.operations.length > 0 && } +
+ ); + })} +
+ ); + })} + + {chunk.operations && ( +
+

+ Other endpoints{chunk.operations.continued && (continued)} +

+ +
+ )} + + {chunk.webhooks && ( +
+

+ Webhooks{chunk.webhooks.continued && (continued)} +

+ +
+ )} +
+ + diff --git a/packages/cloudflare-api-site/src/components/ui/api-layout/ApiLayout.astro b/packages/cloudflare-api-site/src/components/ui/api-layout/ApiLayout.astro new file mode 100644 index 000000000..a10a4ac78 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-layout/ApiLayout.astro @@ -0,0 +1,336 @@ +--- +/** + * ApiLayout — the three-column reference shell (nav · content · code rail), + * composing ApiSidebar + ApiFieldList + ApiCodeRail. It renders any + * `ApiPageProps` by kind, reading the frozen view-model only. Drop it inside a + * page that provides the document shell; widths inherit the site's layout + * tokens (`--nb-sidebar-width`, `--nb-content-max`, `--nb-code-rail-width`). + */ +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import { ApiSidebar } from "@/components/ui/api-sidebar"; +import ApiMethodChip from "@/components/ui/api-sidebar/ApiMethodChip.astro"; +import { SidebarFilter, SidebarGroup, SidebarLink } from "@/components/ui/sidebar"; +import { getToolingSidebar } from "@/command-sidebar"; +import { ApiCodeRail } from "@/components/ui/api-code-rail"; +import { Banner } from "@/components/ui/banner"; +import { Pagination } from "@/components/ui/pagination"; +import ApiBody from "./ApiBody.astro"; +import { cn } from "@/lib/cn"; +import { getApiVersionHead, getVersionStatus, navBuildId, navStateScript, withBase } from "@cloudflare/nimbus-docs/runtime"; +import type { ApiNav, ApiPageProps } from "@cloudflare/nimbus-docs/api"; +import type { ApiHome } from "@cloudflare/nimbus-docs/runtime"; + +interface Props { + page: ApiPageProps; + nav: ApiNav; + /** API family + this page's version/coordinate — enables the version + * picker and the deprecated-version banner. Omit for an unversioned API. */ + collection?: string; + version?: string | null; + coordinate?: string; + /** On the API home and its later parts, from `getApiRoute`: the part to + * show, and every part. */ + home?: ApiHome; +} + +const { page, nav, collection, version, coordinate, home } = Astro.props; +const homePart = home && home.part.number > 1; +const tooling = await getToolingSidebar(Astro.url); +// Sidebar state is shared per API version, across both sidebar copies. The +// base keeps two sites on one origin apart. +const navKey = `${import.meta.env.BASE_URL}|${nav.collection}${version ? `@${version}` : ""}`; + +const versionStatus = + collection && version + ? await getVersionStatus(`${collection}@${version}`) + : null; +const currentSiblingUrl = + collection && version && coordinate + ? ((await getApiVersionHead(collection, version, coordinate, Astro.request))?.canonical + ?.url ?? null) + : null; +const currentSiblingHref = withBase(currentSiblingUrl ?? "/", import.meta.env.BASE_URL); + +// The rail column is reserved ONLY when it has content — a request sample or a +// response example (mirrors ApiCodeRail's own gate). A bodyless GET with no +// samples otherwise pins an empty sticky column at the page's right edge. +const hasRail = + page.kind === "operation" && + (page.samples.length > 0 || page.responses.some((r) => r.example !== undefined)); + +const pagefindAttrs: Record = {}; +const pagefindDeprecated = !versionStatus?.isHidden && Boolean(versionStatus?.isDeprecated); +// A later part of the API home repeats the home's list. +if (versionStatus?.isHidden || homePart) { + pagefindAttrs["data-pagefind-ignore"] = ""; +} else { + pagefindAttrs["data-pagefind-body"] = ""; + if (versionStatus) { + pagefindAttrs["data-pagefind-filter"] = `version:${versionStatus.version}`; + } +} +--- + +{/* Mobile navigation — native for free focus trap, escape, backdrop. + It comes before the rail so the inline restore script after the rail + finds both sidebar copies already parsed. */} + +
+
+ Navigation + +
+ {/* The rail's tree is moved here while the drawer is open — one tree + per page, one open/scroll state. */} +
+
+
+ +
+ {/* The rail keeps its open groups and scroll across pages: the inline script + below restores them before paint, and `initNavSidebar` records them. It is + excluded from the page transition so it never fades or replays. */} + + {/* Inline and unbundled (`aria-hidden` keeps Astro from hoisting it) so it + runs before the page paints and before the disclosures mount. */} + diff --git a/packages/cloudflare-api-site/src/components/ui/api-layout/ApiOperationList.astro b/packages/cloudflare-api-site/src/components/ui/api-layout/ApiOperationList.astro new file mode 100644 index 000000000..0fbad785d --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-layout/ApiOperationList.astro @@ -0,0 +1,39 @@ +--- +/** + * ApiOperationList — operations, name first: each name is the link (underlined + * on hover), with the method and path set small beneath it. Used by section + * pages and the API home. Paths and methods stay left to right in a + * right-to-left page, as code does. + */ +import ApiMethodChip from "@/components/ui/api-sidebar/ApiMethodChip.astro"; +import { Badge } from "@/components/ui/badge"; +import { withBase } from "@cloudflare/nimbus-docs/runtime"; +import type { ApiOperationRef } from "@cloudflare/nimbus-docs/api"; + +interface Props { + operations: ApiOperationRef[]; +} + +const { operations } = Astro.props; +--- + + diff --git a/packages/cloudflare-api-site/src/components/ui/api-layout/auth.ts b/packages/cloudflare-api-site/src/components/ui/api-layout/auth.ts new file mode 100644 index 000000000..5ec33819a --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-layout/auth.ts @@ -0,0 +1,76 @@ +import type { ApiAuthView, ApiFieldView } from "@cloudflare/nimbus-docs/api"; + +function authTypeLabel(a: ApiAuthView): string { + if (a.type === "http") { + // `ApiAuthView` doesn't carry the HTTP sub-scheme (basic vs bearer), and a + // bearer token needn't declare a format — so only claim Bearer when a + // `bearerFormat` proves it; otherwise stay generic rather than mislabel. + return a.bearerFormat ? `HTTP Bearer (${a.bearerFormat})` : "HTTP"; + } + if (a.type === "apiKey") return `API key${a.in ? ` in ${a.in}` : ""}`; + if (a.type === "oauth2") return "OAuth 2.0"; + if (a.type === "openIdConnect") return "OpenID Connect"; + if (a.type === "mutualTLS") return "Mutual TLS"; + return a.type ?? a.scheme; +} + +function authExample(a: ApiAuthView): string | null { + if (!a.headerName) return null; + const prefix = a.type === "http" && a.bearerFormat ? "Bearer " : ""; + return `${a.headerName}: ${prefix}`; +} + +// OpenAPI restricts scheme keys to [A-Za-z0-9._-]; belt-and-braces for a +// non-conformant spec. +const authAnchor = (scheme: string): string => + `auth-${scheme.replace(/[^A-Za-z0-9._-]/g, "-")}`; + +interface AuthAgg { + view: ApiAuthView; + scopes: Set; + required: boolean; +} + +function toField(agg: AuthAgg): ApiFieldView { + const a = agg.view; + const scopes = [...agg.scopes]; + const example = authExample(a); + const parts = [ + example ? `\`${example}\`` : "", + scopes.length > 0 ? `Scopes: ${scopes.map((s) => `\`${s}\``).join(", ")}` : "", + ].filter(Boolean); + return { + coordinate: `auth:${a.scheme}`, + name: a.scheme, + type: authTypeLabel(a), + required: agg.required, + anchor: authAnchor(a.scheme), + children: [], + childCount: 0, + truncated: false, + description: parts.length > 0 ? parts.join(" · ") : undefined, + }; +} + +// Every distinct scheme across the alternatives as one field list. `auth` is +// `ApiAuthView[][]` (outer OR, inner AND): a scheme is `required` only when it +// appears in EVERY alternative, and scopes are unioned. First-seen order. +export function authFields(auth: ApiAuthView[][]): ApiFieldView[] { + const order: string[] = []; + const byScheme = new Map(); + for (const alt of auth) { + for (const a of alt) { + let agg = byScheme.get(a.scheme); + if (!agg) { + agg = { view: a, scopes: new Set(), required: true }; + byScheme.set(a.scheme, agg); + order.push(a.scheme); + } + for (const s of a.scopes) agg.scopes.add(s); + } + } + for (const [scheme, agg] of byScheme) { + agg.required = auth.every((alt) => alt.some((a) => a.scheme === scheme)); + } + return order.map((s) => toField(byScheme.get(s)!)); +} diff --git a/packages/cloudflare-api-site/src/components/ui/api-layout/home.ts b/packages/cloudflare-api-site/src/components/ui/api-layout/home.ts new file mode 100644 index 000000000..8d8f19d9f --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-layout/home.ts @@ -0,0 +1,10 @@ +import release from "../../../../.forge/release.json"; + +/** + * Endpoints per part of the API home's list. The API route passes it to + * `getApiStaticPaths` and `getApiRoute`, which split the list by it. + */ +export const API_HOME_CHUNK_SIZE = 150; + +/** When the Forge spec last changed, when its source said so. */ +export const API_HOME_LAST_MODIFIED = release.lastModified ? new Date(release.lastModified) : undefined; diff --git a/packages/cloudflare-api-site/src/components/ui/api-layout/index.ts b/packages/cloudflare-api-site/src/components/ui/api-layout/index.ts new file mode 100644 index 000000000..cae8c1f7b --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-layout/index.ts @@ -0,0 +1,3 @@ +export { default as ApiLayout } from "./ApiLayout.astro"; +export { default as ApiEndpointCard } from "./ApiEndpointCard.astro"; +export { API_HOME_CHUNK_SIZE } from "./home"; diff --git a/packages/cloudflare-api-site/src/components/ui/api-layout/request-examples.client.ts b/packages/cloudflare-api-site/src/components/ui/api-layout/request-examples.client.ts new file mode 100644 index 000000000..dd84f6db1 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-layout/request-examples.client.ts @@ -0,0 +1,12 @@ +import { initTabs, mount } from "@cloudflare/nimbus-docs/client"; + +function initRequestExamples(container: HTMLElement): () => void { + const tabs = initTabs({ + container, + tabSelector: "[data-request-example-trigger]", + panelSelector: "[data-request-example-panel]", + }); + return () => tabs.destroy(); +} + +mount("[data-request-examples]", initRequestExamples); diff --git a/packages/cloudflare-api-site/src/components/ui/api-layout/responses.client.ts b/packages/cloudflare-api-site/src/components/ui/api-layout/responses.client.ts new file mode 100644 index 000000000..14048707c --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-layout/responses.client.ts @@ -0,0 +1,9 @@ +/** Opens a closed error response when the URL links to it. Field links inside + * one already open it through their own field group. */ + +import { mount, initDisclosureGroup } from "@cloudflare/nimbus-docs/client"; + +mount("[data-api-responses]", (root) => { + const instance = initDisclosureGroup({ root, deepLink: true }); + return () => instance.destroy(); +}); diff --git a/packages/cloudflare-api-site/src/components/ui/api-layout/terraform-section.client.ts b/packages/cloudflare-api-site/src/components/ui/api-layout/terraform-section.client.ts new file mode 100644 index 000000000..a85a4e6fc --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-layout/terraform-section.client.ts @@ -0,0 +1,16 @@ +/** Shows the Terraform section only while the code rail's HCL sample is + * selected, following the rail's own panel switching. */ + +import { mount } from "@cloudflare/nimbus-docs/client"; + +mount("[data-api-terraform]", (section) => { + const panels = Array.from(document.querySelectorAll("[data-nb-lang-panel]")); + if (panels.length === 0) return () => {}; + const sync = () => { + section.hidden = !panels.some((panel) => !panel.hidden && panel.dataset.nbSampleLang === "hcl"); + }; + const observer = new MutationObserver(sync); + for (const panel of panels) observer.observe(panel, { attributes: true, attributeFilter: ["hidden"] }); + sync(); + return () => observer.disconnect(); +}); diff --git a/packages/cloudflare-api-site/src/components/ui/api-sidebar/ApiMethodChip.astro b/packages/cloudflare-api-site/src/components/ui/api-sidebar/ApiMethodChip.astro new file mode 100644 index 000000000..77cb0bd5c --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-sidebar/ApiMethodChip.astro @@ -0,0 +1,141 @@ +--- +/** + * ApiMethodChip — the method indicator for an API operation. Colours come from + * the shared `--nb-m-*` palette (`globals.css`); the variant key comes from + * `methodVariant` (`./method`), shared across the sidebar, endpoint card, and + * code rail so they can never disagree on a method's colour. + * + * `display` selects the presentation: + * - "glyph" — direction arrow only, in a 20×20 square (code rail, card). + * - "label" — the verb word only (sidebar). + * + * `decorative` (glyph only) drops the `role="img"`/`aria-label` and marks the + * arrow `aria-hidden` — for when a visible verb word sits beside it (endpoint + * card header) and should own the accessible name instead, avoiding a + * double-announce. + * + * Label chips share one uniform width (`--nb-api-verb`, sized to the widest + * verb) so operation rows align into a clean column; long verbs are abbreviated + * to stay inside it. The full verb is preserved as the accessible name. POST, + * PUT, and PATCH intentionally share one "write" arrow (PATCH folds to PUT's + * colour) — the word disambiguates them everywhere except the glyph rail, which + * sits beside a labelled endpoint card. + * + * The label chip is a bare `GET`: a sidebar repeats it on + * every operation row, so it carries the full method (for colour and for the + * sidebar filter, which matches `DELETE` on a `DEL` chip) and nothing else. + * Styles are global for the same reason: a scoped style would stamp a + * `data-astro-cid-*` attribute on every chip. + * + * Renders nothing for a missing/blank method. + */ +import { methodLabel, methodVariant } from "./method"; + +interface Props { + method?: string; + display?: "glyph" | "label"; + size?: "sm" | "md"; + decorative?: boolean; +} + +const { method, display = "glyph", size = "sm", decorative = false } = Astro.props; +const verb = method?.toLowerCase(); +const name = verb?.toUpperCase(); +const variant = methodVariant(method); + +const label = methodLabel(method); + +const S = `width:1.08em;height:1.08em;flex:none`; +const A = `viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="${S}" aria-hidden="true"`; +const glyphs: Record = { + get: ``, + post: ``, + put: ``, + delete: ``, + other: ``, +}; +--- + +{ + variant && + (display === "glyph" ? ( + + + + ) : ( + {label} + )) +} + + diff --git a/packages/cloudflare-api-site/src/components/ui/api-sidebar/ApiSidebar.astro b/packages/cloudflare-api-site/src/components/ui/api-sidebar/ApiSidebar.astro new file mode 100644 index 000000000..5be4a6450 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-sidebar/ApiSidebar.astro @@ -0,0 +1,42 @@ +--- +/** + * ApiSidebar — the reference-collection nav rail over the API view-model's + * `ApiNavItem[]`. It is the docs sidebar rendered from API data: each node is an + * `ApiSidebarItem`, which composes the shared `SidebarGroupHeader`/`SidebarLink` + * atoms plus a method chip. The tree math (active/expanded, ordering) lives in + * `getApiNav`; this owns only the top-level list shell. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import ApiSidebarItem from "./ApiSidebarItem.astro"; +import type { ApiNavItem } from "@cloudflare/nimbus-docs/api"; +import type { SidebarItem } from "@cloudflare/nimbus-docs/types"; + +interface Props extends HTMLAttributes<"nav"> { + items: ApiNavItem[]; + label?: string; + /** Extra groups listed inside a section's group, by the section's `href`. */ + groups?: Map; +} + +const { items, label, groups, class: className, ...attrs } = Astro.props; +const labelId = label ? "api-nav-label" : undefined; +--- + + diff --git a/packages/cloudflare-api-site/src/components/ui/api-sidebar/ApiSidebarItem.astro b/packages/cloudflare-api-site/src/components/ui/api-sidebar/ApiSidebarItem.astro new file mode 100644 index 000000000..ce6bc5b11 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-sidebar/ApiSidebarItem.astro @@ -0,0 +1,78 @@ +--- +/** + * ApiSidebarItem — the API rail's per-node walker. It renders `ApiNavItem`s + * through the SAME atoms as the docs sidebar (`SidebarGroupHeader`, + * `SidebarLink`) so the two rails are one visual language; the only + * API-specific decoration is the leading `ApiMethodChip` on operation leaves. + * Groups are native `
`; a group with its own page lists it as the + * group's first child, labelled "Overview". Open/active state is pre-resolved + * by `getApiNav` (`expanded` marks the active trail, `active` the current + * node), so this maps those flags straight onto the shared atoms. + * + * Under `sidebar: "on-demand"`, a collapsed group arrives `deferred`, with no + * children and a `childrenHref`: the page whose sidebar lists them. It renders + * as a closed group whose list holds only the Overview row; `initNavSidebar` + * (mounted by `ApiLayout`) fills the list from that page when the group + * opens. Without JavaScript, the Overview row still reaches its page. + */ +import SidebarGroup from "@/components/ui/sidebar/SidebarGroup.astro"; +import SidebarGroupHeader from "@/components/ui/sidebar/SidebarGroupHeader.astro"; +import SidebarLink from "@/components/ui/sidebar/SidebarLink.astro"; +import ApiMethodChip from "./ApiMethodChip.astro"; +import type { ApiNavItem } from "@cloudflare/nimbus-docs/api"; +import type { SidebarItem } from "@cloudflare/nimbus-docs/types"; +import { withBase } from "@cloudflare/nimbus-docs/runtime"; + +interface Props { + item: ApiNavItem; + /** Extra groups listed after a section's Overview row, by the section's `href`. */ + groups?: Map; +} + +const { item, groups } = Astro.props; +const extra = item.href ? groups?.get(item.href) : undefined; +const deferred = Boolean(item.deferred && item.childrenHref); +const hasChildren = item.children.length > 0 || deferred; +// `expanded` ⟺ ancestor of the active node; `active` ⟺ the current node. Both +// mean "on the active trail" for header emphasis, and either opens the group. +const onActiveTrail = Boolean(item.expanded || item.active); +--- + +{ + hasChildren ? ( +
+ +
    + {item.href && ( +
  • + +
  • + )} + {extra && ( +
  • + +
  • + )} + {!deferred && + item.children.map((child) => ( +
  • + +
  • + ))} +
+
+ ) : ( + + + + ) +} diff --git a/packages/cloudflare-api-site/src/components/ui/api-sidebar/api-nav-filter.client.ts b/packages/cloudflare-api-site/src/components/ui/api-sidebar/api-nav-filter.client.ts new file mode 100644 index 000000000..8e6f7c8bc --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-sidebar/api-nav-filter.client.ts @@ -0,0 +1,102 @@ +/** The sidebar filter on an on-demand API sidebar, which holds only part of + * its tree. Focusing or typing loads the version's page list once; matches + * replace the tree until the filter is cleared. */ + +import { + loadNavList, + matchNavList, + mount, + type NavListRow, +} from "@cloudflare/nimbus-docs/client"; +import { methodLabel, methodVariant } from "./method"; + +const MAX_RESULTS = 100; +const base = import.meta.env.BASE_URL.replace(/\/$/, ""); + +mount("[data-nb-nav-list]", (filter) => { + const input = filter.querySelector("input"); + const scope = filter.parentElement; + const tree = scope?.querySelector("[data-nb-api-nav]"); + const results = scope?.querySelector("[data-nb-nav-results]"); + const status = scope?.querySelector("[data-nb-nav-status]"); + const template = scope?.querySelector( + "template[data-nb-nav-result]", + ); + const url = filter.dataset.nbNavList; + if (!input || !tree || !results || !status || !template || !url) + return () => {}; + + // The status region stays in the page so screen readers announce changes. + const say = (message: string) => { + status.textContent = message; + }; + + const row = ({ title, method, url }: NavListRow): Node => { + const item = template.content.cloneNode(true) as DocumentFragment; + const link = item.querySelector("a"); + if (link) link.href = base + url; + // The template's row is a bare link holding a method chip: the title + // replaces the link's text, and the chip goes back in front of it. + const chip = link?.querySelector("i[data-m]"); + if (link) link.textContent = title; + if (link && chip && methodVariant(method)) { + chip.dataset.m = method!.toLowerCase(); + chip.textContent = methodLabel(method)!; + link.prepend(chip); + } + return item; + }; + + const show = (rows: NavListRow[]) => { + tree.hidden = true; + results.hidden = false; + results.replaceChildren(...rows.slice(0, MAX_RESULTS).map(row)); + say( + rows.length === 0 + ? "No matching pages" + : rows.length > MAX_RESULTS + ? `Showing ${MAX_RESULTS} of ${rows.length}. Keep typing to narrow.` + : `${rows.length} ${rows.length === 1 ? "page" : "pages"}`, + ); + }; + + const reset = () => { + tree.hidden = false; + results.hidden = true; + results.replaceChildren(); + say(""); + }; + + const update = async () => { + const query = input.value.trim(); + if (!query) return reset(); + let rows: NavListRow[]; + try { + rows = await loadNavList(url); + } catch { + reset(); + return say("Filter unavailable"); + } + // A later keystroke has already drawn its own results. + if (input.value.trim() === query) show(matchNavList(rows, query)); + }; + + const load = () => + void loadNavList(url).catch(() => say("Filter unavailable")); + const onKeydown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + input.value = ""; + reset(); + input.blur(); + }; + + input.addEventListener("focus", load); + input.addEventListener("input", update); + input.addEventListener("keydown", onKeydown); + return () => { + input.removeEventListener("focus", load); + input.removeEventListener("input", update); + input.removeEventListener("keydown", onKeydown); + reset(); + }; +}); diff --git a/packages/cloudflare-api-site/src/components/ui/api-sidebar/index.ts b/packages/cloudflare-api-site/src/components/ui/api-sidebar/index.ts new file mode 100644 index 000000000..4fecda4d9 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-sidebar/index.ts @@ -0,0 +1,3 @@ +export { default as ApiSidebar } from "./ApiSidebar.astro"; +export { default as ApiSidebarItem } from "./ApiSidebarItem.astro"; +export { default as ApiMethodChip } from "./ApiMethodChip.astro"; diff --git a/packages/cloudflare-api-site/src/components/ui/api-sidebar/method.ts b/packages/cloudflare-api-site/src/components/ui/api-sidebar/method.ts new file mode 100644 index 000000000..be92f4573 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-sidebar/method.ts @@ -0,0 +1,27 @@ +export type MethodVariant = "get" | "post" | "put" | "delete" | "other"; + +// Fold an HTTP method onto its `--nb-m-*` palette key; "" for a missing method. +export function methodVariant(method: string | undefined): MethodVariant | "" { + switch (method?.toLowerCase()) { + case "get": + case "head": + return "get"; + case "post": + return "post"; + case "put": + case "patch": + return "put"; + case "delete": + return "delete"; + default: + return method ? "other" : ""; + } +} + +const abbreviations: Record = { delete: "DEL", options: "OPT", connect: "CONN" }; + +// The verb a label chip shows: long verbs abbreviated to fit its fixed width. +export function methodLabel(method: string | undefined): string | undefined { + const verb = method?.toLowerCase(); + return verb ? (abbreviations[verb] ?? verb.toUpperCase()) : undefined; +} diff --git a/packages/cloudflare-api-site/src/components/ui/collapsible/Collapsible.astro b/packages/cloudflare-api-site/src/components/ui/collapsible/Collapsible.astro new file mode 100644 index 000000000..16f5e94ad --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/collapsible/Collapsible.astro @@ -0,0 +1,27 @@ +--- +/** Collapsible — disclosure. Compose with CollapsibleTrigger + CollapsibleContent. */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> { + /** Start open. Default false. */ + open?: boolean; +} + +// A composing component can pass its own `data-slot` in place of this one. +const { open = false, class: className, "data-slot": slot = "collapsible", ...attrs } = Astro.props; +--- + +
+ +
+ + diff --git a/packages/cloudflare-api-site/src/components/ui/collapsible/CollapsibleContent.astro b/packages/cloudflare-api-site/src/components/ui/collapsible/CollapsibleContent.astro new file mode 100644 index 000000000..b3201f9be --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/collapsible/CollapsibleContent.astro @@ -0,0 +1,31 @@ +--- +/** + * CollapsibleContent — the panel that animates open/closed. + * Uses `grid-template-rows: 0fr → 1fr` for smooth height transition. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +// A composing component can pass its own `data-slot` in place of this one. +const { class: className, "data-slot": slot = "collapsible-content", ...attrs } = Astro.props; +const isOpen = attrs["data-nb-state"] === "open"; +--- + +
+
+ +
+
diff --git a/packages/cloudflare-api-site/src/components/ui/collapsible/CollapsibleTrigger.astro b/packages/cloudflare-api-site/src/components/ui/collapsible/CollapsibleTrigger.astro new file mode 100644 index 000000000..fb40999bf --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/collapsible/CollapsibleTrigger.astro @@ -0,0 +1,23 @@ +--- +/** + * CollapsibleTrigger — the button that toggles the Collapsible. + * Slot accepts arbitrary content; component author provides full visuals. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"button"> {} + +// A composing component can pass its own `data-slot` in place of this one. +const { class: className, type, "data-slot": slot = "collapsible-trigger", ...attrs } = Astro.props; +--- + + diff --git a/packages/cloudflare-api-site/src/components/ui/collapsible/collapsible.client.ts b/packages/cloudflare-api-site/src/components/ui/collapsible/collapsible.client.ts new file mode 100644 index 000000000..71a1af40c --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/collapsible/collapsible.client.ts @@ -0,0 +1,22 @@ +/** Wires Collapsible via the disclosure module. */ + +import { mount, makeDisclosure } from "@cloudflare/nimbus-docs/client"; + +function initCollapsible(root: HTMLElement): () => void { + const trigger = root.querySelector("[data-nb-collapsible-trigger]"); + const content = root.querySelector("[data-nb-collapsible-content]"); + + if (!trigger || !content) return () => {}; + + const defaultOpen = root.dataset.nbDefaultOpen === "true"; + + const disclosure = makeDisclosure({ + trigger, + content, + defaultOpen, + }); + + return () => disclosure.destroy(); +} + +mount("[data-nb-collapsible]", initCollapsible); diff --git a/packages/cloudflare-api-site/src/components/ui/collapsible/index.ts b/packages/cloudflare-api-site/src/components/ui/collapsible/index.ts new file mode 100644 index 000000000..a4c422168 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/collapsible/index.ts @@ -0,0 +1,3 @@ +export { default as Collapsible } from "./Collapsible.astro"; +export { default as CollapsibleTrigger } from "./CollapsibleTrigger.astro"; +export { default as CollapsibleContent } from "./CollapsibleContent.astro"; diff --git a/packages/cloudflare-api-site/src/components/ui/layer-card/LayerCard.astro b/packages/cloudflare-api-site/src/components/ui/layer-card/LayerCard.astro new file mode 100644 index 000000000..34745266d --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/layer-card/LayerCard.astro @@ -0,0 +1,36 @@ +--- +/** + * LayerCard — two-layer card (recessed header + raised content). + * + * + * Title or tabs + * Main content + * + * + * `orientation="horizontal"` puts the header on the left and lets the + * content fill the rest of the row. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> { + /** @default "vertical" */ + orientation?: "vertical" | "horizontal"; +} + +// A composing component can pass its own `data-slot` in place of this one. +const { orientation = "vertical", class: className, "data-slot": slot = "layer-card", ...attrs } = Astro.props; +--- + +
+ +
diff --git a/packages/cloudflare-api-site/src/components/ui/layer-card/LayerCardContent.astro b/packages/cloudflare-api-site/src/components/ui/layer-card/LayerCardContent.astro new file mode 100644 index 000000000..f411fa224 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/layer-card/LayerCardContent.astro @@ -0,0 +1,22 @@ +--- +/** LayerCardContent — raised content layer of a LayerCard. */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +// A composing component can pass its own `data-slot` in place of this one. +const { class: className, "data-slot": slot = "layer-card-content", ...attrs } = Astro.props; +--- + +
+ +
diff --git a/packages/cloudflare-api-site/src/components/ui/layer-card/LayerCardHeader.astro b/packages/cloudflare-api-site/src/components/ui/layer-card/LayerCardHeader.astro new file mode 100644 index 000000000..117862900 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/layer-card/LayerCardHeader.astro @@ -0,0 +1,22 @@ +--- +/** LayerCardHeader — recessed header layer of a LayerCard. */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +// A composing component can pass its own `data-slot` in place of this one. +const { class: className, "data-slot": slot = "layer-card-header", ...attrs } = Astro.props; +--- + +
+ +
diff --git a/packages/cloudflare-api-site/src/components/ui/layer-card/index.ts b/packages/cloudflare-api-site/src/components/ui/layer-card/index.ts new file mode 100644 index 000000000..dd347fe4e --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/layer-card/index.ts @@ -0,0 +1,3 @@ +export { default as LayerCard } from "./LayerCard.astro"; +export { default as LayerCardHeader } from "./LayerCardHeader.astro"; +export { default as LayerCardContent } from "./LayerCardContent.astro"; diff --git a/packages/cloudflare-api-site/src/components/ui/pagination/Pagination.astro b/packages/cloudflare-api-site/src/components/ui/pagination/Pagination.astro new file mode 100644 index 000000000..63decf7ee --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/pagination/Pagination.astro @@ -0,0 +1,113 @@ +--- +/** + * Pagination — previous/next page links as one surface. A highlight slides to + * whichever half you point at, and [ / ] move between pages from the keyboard. + * + * Pass `prevNext` for the docs default, or compose your own links: + * + * + * + * + * + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import type { PrevNext } from "@cloudflare/nimbus-docs/types"; +import { withBase } from "@cloudflare/nimbus-docs/runtime"; +import PaginationLink from "./PaginationLink.astro"; + +interface Props extends HTMLAttributes<"nav"> { + /** The page's neighbours. Ignored when links are passed as children. */ + prevNext?: PrevNext; + /** Caption above the previous page's title. */ + prevLabel?: string; + /** Caption above the next page's title. */ + nextLabel?: string; + /** `[` and `]` move between pages while focus is in the bar. */ + keys?: boolean; +} + +const { + prevNext, + prevLabel = "Previous", + nextLabel = "Next", + keys = true, + class: className, + ...attrs +} = Astro.props; +const composed = Astro.slots.has("default"); +const prev = composed ? undefined : prevNext?.prev; +const next = composed ? undefined : prevNext?.next; +--- + +{(composed || prev || next) && ( + +)} + + + + diff --git a/packages/cloudflare-api-site/src/components/ui/pagination/PaginationKey.astro b/packages/cloudflare-api-site/src/components/ui/pagination/PaginationKey.astro new file mode 100644 index 000000000..19bfad3f8 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/pagination/PaginationKey.astro @@ -0,0 +1,52 @@ +--- +/** + * PaginationKey — the key cap that moves to this page. Hidden on touch + * screens, and when the bar has `keys={false}`. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +type Props = HTMLAttributes<"kbd">; + +const { class: className, ...attrs } = Astro.props; +--- + + + + diff --git a/packages/cloudflare-api-site/src/components/ui/pagination/PaginationLink.astro b/packages/cloudflare-api-site/src/components/ui/pagination/PaginationLink.astro new file mode 100644 index 000000000..f19ee20ad --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/pagination/PaginationLink.astro @@ -0,0 +1,77 @@ +--- +/** + * PaginationLink — one half of the pagination bar: a caption, the page title + * (the default slot), and the key that reaches it. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import PaginationKey from "./PaginationKey.astro"; + +interface Props extends Omit, "rel"> { + rel: "prev" | "next"; + href: string; + /** Caption above the title. Defaults to "Previous" or "Next". */ + label?: string; +} + +const { rel, href, label, class: className, ...attrs } = Astro.props; +const isNext = rel === "next"; +const caption = label ?? (isNext ? "Next" : "Previous"); +--- + + + {!isNext && [} + + {caption} + + + {isNext && ]} + + + diff --git a/packages/cloudflare-api-site/src/components/ui/pagination/index.ts b/packages/cloudflare-api-site/src/components/ui/pagination/index.ts new file mode 100644 index 000000000..12dcb22da --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/pagination/index.ts @@ -0,0 +1,3 @@ +export { default as Pagination } from "./Pagination.astro"; +export { default as PaginationLink } from "./PaginationLink.astro"; +export { default as PaginationKey } from "./PaginationKey.astro"; diff --git a/packages/cloudflare-api-site/src/components/ui/pagination/pagination.client.ts b/packages/cloudflare-api-site/src/components/ui/pagination/pagination.client.ts new file mode 100644 index 000000000..360df7d37 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/pagination/pagination.client.ts @@ -0,0 +1,92 @@ +import { mount } from "@cloudflare/nimbus-docs/client"; + +// Warm the next page the moment its key goes down, so it's ready when the +// press finishes. A plain prefetch link, so sites that turn off Astro's +// prefetch still build. +function warm(href: string) { + if (document.head.querySelector(`link[rel="prefetch"][href="${CSS.escape(href)}"]`)) return; + const link = document.createElement("link"); + link.rel = "prefetch"; + link.href = href; + document.head.append(link); +} + +function initPagination(root: HTMLElement): () => void { + const highlight = root.querySelector("[data-slot='pagination-highlight']"); + const items = Array.from(root.querySelectorAll("[data-nb-pagination-item]")); + const cleanups: Array<() => void> = []; + let current: HTMLElement | null = null; + + // The highlight takes the box of the half being pointed at or focused. + const show = (item: HTMLElement) => { + current = item; + if (!highlight) return; + highlight.style.left = `${item.offsetLeft}px`; + highlight.style.width = `${item.offsetWidth}px`; + highlight.dataset.state = "visible"; + }; + const hide = () => { + current = null; + if (highlight) highlight.dataset.state = "hidden"; + }; + + for (const item of items) { + const onShow = () => { + show(item); + // A cut-off title shows in full on hover. + const title = item.querySelector("[data-slot='pagination-title']"); + if (title) { + const cut = title.scrollWidth > title.clientWidth || title.scrollHeight > title.clientHeight; + if (cut) item.title = title.textContent?.trim() ?? ""; + else item.removeAttribute("title"); + } + }; + item.addEventListener("pointerenter", onShow); + item.addEventListener("focus", onShow); + cleanups.push(() => { + item.removeEventListener("pointerenter", onShow); + item.removeEventListener("focus", onShow); + }); + } + const onLeave = () => hide(); + const onFocusOut = (e: FocusEvent) => { + if (!root.contains(e.relatedTarget as Node)) hide(); + }; + root.addEventListener("pointerleave", onLeave); + root.addEventListener("focusout", onFocusOut); + + // The highlight follows its half when the layout shifts under it. + const resize = new ResizeObserver(() => { + if (current) show(current); + }); + resize.observe(root); + + // [ and ] press the matching key, then go: only while focus is in this bar, + // so single-key shortcuts never fire from elsewhere on the page (WCAG + // 2.1.4), and never with a modifier held. + const onKey = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey || e.repeat || e.defaultPrevented) return; + const item = items.find((i) => i.dataset.nbPaginationKey === e.key); + if (!item) return; + e.preventDefault(); + warm(item.href); + item.querySelector("[data-slot='pagination-key']")?.setAttribute("data-down", ""); + show(item); + window.setTimeout(() => item.click(), 140); + }; + const listens = root.dataset.keys !== "false"; + if (listens) { + for (const item of items) item.setAttribute("aria-keyshortcuts", item.dataset.nbPaginationKey ?? ""); + root.addEventListener("keydown", onKey); + } + + return () => { + cleanups.forEach((fn) => fn()); + root.removeEventListener("pointerleave", onLeave); + root.removeEventListener("focusout", onFocusOut); + resize.disconnect(); + if (listens) root.removeEventListener("keydown", onKey); + }; +} + +mount("[data-nb-pagination]", initPagination); diff --git a/packages/cloudflare-api-site/src/components/ui/popover/Popover.astro b/packages/cloudflare-api-site/src/components/ui/popover/Popover.astro new file mode 100644 index 000000000..10aecf68c --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/popover/Popover.astro @@ -0,0 +1,34 @@ +--- +/** + * Popover — anchored floating content. Compose with PopoverTrigger + + * PopoverContent. Built on the native Popover API; positioned via JS + * at open time and on scroll/resize. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +type Props = HTMLAttributes<"div">; + +// A composing component can pass its own `data-slot` in place of this one. +const { class: className, "data-slot": slot = "popover", ...attrs } = Astro.props; +// Deterministic ID — per-page counter on Astro.locals. Order is stable +// within a page render, so two builds of the same page produce the same +// IDs. Required for incremental builds: `crypto.randomUUID()` would make +// warm output differ from cold. +const locals = Astro.locals as typeof Astro.locals & { + __nbCounters?: Map; +}; +const counters = + locals.__nbCounters ?? (locals.__nbCounters = new Map()); +const n = (counters.get("popover") ?? 0) + 1; +counters.set("popover", n); +const popoverId = `popover-${n.toString(16).padStart(8, "0")}`; +--- + +
+ +
+ + diff --git a/packages/cloudflare-api-site/src/components/ui/popover/PopoverContent.astro b/packages/cloudflare-api-site/src/components/ui/popover/PopoverContent.astro new file mode 100644 index 000000000..10ca585c3 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/popover/PopoverContent.astro @@ -0,0 +1,46 @@ +--- +/** + * PopoverContent — floating panel. Set `placement` to one of + * `bottom-start` (default) | `bottom-end` | `top-start` | `top-end`. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +type Placement = "bottom-start" | "bottom-end" | "top-start" | "top-end"; + +interface Props extends HTMLAttributes<"div"> { + placement?: Placement; +} + +// A composing component can pass its own `data-slot` in place of this one. +const { placement = "bottom-start", class: className, "data-slot": slot = "popover-content", ...attrs } = Astro.props; +--- + +
+ +
+ + diff --git a/packages/cloudflare-api-site/src/components/ui/popover/PopoverTrigger.astro b/packages/cloudflare-api-site/src/components/ui/popover/PopoverTrigger.astro new file mode 100644 index 000000000..4a16a32a1 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/popover/PopoverTrigger.astro @@ -0,0 +1,23 @@ +--- +/** + * PopoverTrigger — button that toggles the popover. + * Must be a direct child of Popover. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"button"> {} + +// A composing component can pass its own `data-slot` in place of this one. +const { class: className, "data-slot": slot = "popover-trigger", ...attrs } = Astro.props; +--- + + diff --git a/packages/cloudflare-api-site/src/components/ui/popover/index.ts b/packages/cloudflare-api-site/src/components/ui/popover/index.ts new file mode 100644 index 000000000..292ba4b02 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/popover/index.ts @@ -0,0 +1,3 @@ +export { default as Popover } from "./Popover.astro"; +export { default as PopoverTrigger } from "./PopoverTrigger.astro"; +export { default as PopoverContent } from "./PopoverContent.astro"; diff --git a/packages/cloudflare-api-site/src/components/ui/popover/popover.client.ts b/packages/cloudflare-api-site/src/components/ui/popover/popover.client.ts new file mode 100644 index 000000000..5ca4952b0 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/popover/popover.client.ts @@ -0,0 +1,89 @@ +import { mount } from "@cloudflare/nimbus-docs/client"; + +type Placement = "bottom-start" | "bottom-end" | "top-start" | "top-end"; + +const GAP = 4; +const VIEWPORT_PADDING = 8; + +function anchor(trigger: HTMLElement, content: HTMLElement) { + const placement = (content.dataset.placement as Placement | undefined) ?? "bottom-start"; + const triggerRect = trigger.getBoundingClientRect(); + const contentRect = content.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + + let top: number; + if (placement.startsWith("bottom")) { + top = triggerRect.bottom + GAP; + if (top + contentRect.height > vh - VIEWPORT_PADDING) { + top = Math.max(VIEWPORT_PADDING, triggerRect.top - GAP - contentRect.height); + } + } else { + top = triggerRect.top - GAP - contentRect.height; + if (top < VIEWPORT_PADDING) { + top = Math.min(vh - contentRect.height - VIEWPORT_PADDING, triggerRect.bottom + GAP); + } + } + + // start and end are inline sides: right to left, start is the right edge. + const rtl = getComputedStyle(trigger).direction === "rtl"; + let left: number; + if (placement.endsWith("end") !== rtl) { + left = triggerRect.right - contentRect.width; + } else { + left = triggerRect.left; + } + left = Math.max( + VIEWPORT_PADDING, + Math.min(left, vw - contentRect.width - VIEWPORT_PADDING), + ); + + content.style.top = `${top}px`; + content.style.left = `${left}px`; +} + +mount("[data-popover-root]", (root) => { + const id = root.dataset.popoverId!; + const trigger = root.querySelector("[data-popover-trigger]"); + const content = root.querySelector("[data-popover-content]"); + + if (!trigger || !content) return () => {}; + + content.id = id; + content.setAttribute("popover", "auto"); + trigger.setAttribute("popovertarget", id); + trigger.setAttribute("aria-expanded", "false"); + + // Every listener wired here is registered with this controller's signal, so + // a single abort() in teardown removes the toggle listener and any window + // scroll/resize listeners still attached from an open popover. + const controller = new AbortController(); + const { signal } = controller; + + let reposition: (() => void) | null = null; + + content.addEventListener( + "toggle", + (e) => { + const isOpen = (e as ToggleEvent).newState === "open"; + trigger.setAttribute("aria-expanded", String(isOpen)); + + if (isOpen) { + // Anchor twice — initial measurement, then once layout settles. + anchor(trigger, content); + requestAnimationFrame(() => anchor(trigger, content)); + + reposition = () => anchor(trigger, content); + window.addEventListener("scroll", reposition, { passive: true, capture: true, signal }); + window.addEventListener("resize", reposition, { passive: true, signal }); + } else if (reposition) { + window.removeEventListener("scroll", reposition, { capture: true }); + window.removeEventListener("resize", reposition); + reposition = null; + } + }, + { signal }, + ); + + return () => controller.abort(); +}); diff --git a/packages/cloudflare-api-site/src/components/ui/sidebar/Sidebar.astro b/packages/cloudflare-api-site/src/components/ui/sidebar/Sidebar.astro index afe2b959c..74d936973 100644 --- a/packages/cloudflare-api-site/src/components/ui/sidebar/Sidebar.astro +++ b/packages/cloudflare-api-site/src/components/ui/sidebar/Sidebar.astro @@ -8,7 +8,6 @@ import SidebarGroup from "./SidebarGroup.astro"; import SidebarLink from "./SidebarLink.astro"; import type { SidebarItem } from "@cloudflare/nimbus-docs/types"; import { sidebarHash } from "@cloudflare/nimbus-docs/runtime"; -import "./sidebar.css"; interface Props extends HTMLAttributes<"div"> { items: SidebarItem[]; diff --git a/packages/cloudflare-api-site/src/components/ui/sidebar/SidebarFilter.astro b/packages/cloudflare-api-site/src/components/ui/sidebar/SidebarFilter.astro index 4655b5d9f..46d0a7f03 100644 --- a/packages/cloudflare-api-site/src/components/ui/sidebar/SidebarFilter.astro +++ b/packages/cloudflare-api-site/src/components/ui/sidebar/SidebarFilter.astro @@ -7,7 +7,7 @@ interface Props extends HTMLAttributes<"div"> { placeholder?: string; } -const { class: className, placeholder = "Filter…", ...attrs } = Astro.props; +const { class: className, placeholder = "Filter sidebar", ...attrs } = Astro.props; ---
@@ -16,7 +16,7 @@ const { class: className, placeholder = "Filter…", ...attrs } = Astro.props; type="search" placeholder={placeholder} class="peer w-full rounded-md border border-border bg-card py-1.5 pl-3 pr-9 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus-visible:border-brand focus-visible:outline-2 focus-visible:outline-ring [&:not(:placeholder-shown)]:pr-2" - aria-label="Filter navigation" + aria-label="Filter sidebar" autocomplete="off" /> {/* Shortcut hint — press "/" to focus. Hidden once the input is focused or has a value. */} diff --git a/packages/cloudflare-api-site/src/components/ui/sidebar/SidebarGroupHeader.astro b/packages/cloudflare-api-site/src/components/ui/sidebar/SidebarGroupHeader.astro new file mode 100644 index 000000000..514c66584 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/sidebar/SidebarGroupHeader.astro @@ -0,0 +1,37 @@ +--- +/** + * SidebarGroupHeader — a sidebar group's `` row: label, optional + * leading icon, badge, and a caret drawn by CSS (the summary's `::after` + * mask, rotated by `details[open]`). The caller renders it as the first child + * of a sidebar `
`. + * + * The summary is label-only: a group's landing page renders as the group's + * first child row ("Overview"), so keyboard and screen-reader users get one + * disclosure control and one link, never a link nested in a control. + * + * `deprecated` strikes the label through. The row, caret, and child-list + * styling lives in `src/styles/globals.css` ("Sidebar rows"). + */ +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import { Badge } from "@/components/ui/badge"; +import type { SidebarBadge } from "@cloudflare/nimbus-docs/types"; + +interface Props { + label: string; + /** True when this group is on the active trail — emphasizes the label. */ + hasActive?: boolean; + badge?: SidebarBadge; + icon?: string; + /** Strike the label through for deprecated sections. */ + deprecated?: boolean; +} + +const { label, hasActive, badge, icon, deprecated } = Astro.props; +--- + + + {icon && } + {deprecated ? {label} : label} + {badge && + (typeof badge === "string" ? : )} + diff --git a/packages/cloudflare-api-site/src/components/ui/sidebar/sidebar.css b/packages/cloudflare-api-site/src/components/ui/sidebar/sidebar.css index d697c559a..07dff4b2d 100644 --- a/packages/cloudflare-api-site/src/components/ui/sidebar/sidebar.css +++ b/packages/cloudflare-api-site/src/components/ui/sidebar/sidebar.css @@ -1,9 +1,9 @@ /* * Sidebar tree. Rows carry no classes or data attributes — the API sidebar has * thousands of them per page — so everything is styled structurally under the - * `[data-nb-sidebar]` root. + * `[data-nb-sidebar]` and `[data-nb-api-nav]` roots. */ -[data-nb-sidebar] ul { +:is([data-nb-sidebar], [data-nb-api-nav]) ul { display: flex; flex-direction: column; gap: 1px; @@ -12,18 +12,18 @@ list-style: none; } -[data-nb-sidebar] .top-level { +:is([data-nb-sidebar], [data-nb-api-nav]) .top-level { gap: 0.125rem; } -[data-nb-sidebar] details > ul { +:is([data-nb-sidebar], [data-nb-api-nav]) details > ul { margin: 0.125rem 0 0 0.75rem; border-inline-start: 1px solid var(--nb-border); padding-inline-start: 0.5rem; } -[data-nb-sidebar] li > a, -[data-nb-sidebar] summary { +:is([data-nb-sidebar], [data-nb-api-nav]) li > a, +:is([data-nb-sidebar], [data-nb-api-nav]) summary { display: flex; align-items: center; gap: 0.5rem; @@ -38,34 +38,34 @@ overflow-wrap: anywhere; } -[data-nb-sidebar] li > a:hover, -[data-nb-sidebar] summary:hover { +:is([data-nb-sidebar], [data-nb-api-nav]) li > a:hover, +:is([data-nb-sidebar], [data-nb-api-nav]) summary:hover { color: var(--nb-foreground); background: var(--nb-accent); } -[data-nb-sidebar] li > a:focus-visible, -[data-nb-sidebar] summary:focus-visible { +:is([data-nb-sidebar], [data-nb-api-nav]) li > a:focus-visible, +:is([data-nb-sidebar], [data-nb-api-nav]) summary:focus-visible { outline-offset: -2px; } -[data-nb-sidebar] li > a[aria-current='page'] { +:is([data-nb-sidebar], [data-nb-api-nav]) li > a[aria-current='page'] { color: var(--nb-foreground); background: var(--nb-accent); font-weight: 600; } -[data-nb-sidebar] summary { +:is([data-nb-sidebar], [data-nb-api-nav]) summary { cursor: pointer; user-select: none; list-style: none; } -[data-nb-sidebar] summary::-webkit-details-marker { +:is([data-nb-sidebar], [data-nb-api-nav]) summary::-webkit-details-marker { display: none; } -[data-nb-sidebar] summary > a { +:is([data-nb-sidebar], [data-nb-api-nav]) summary > a { flex: 1; min-width: 0; color: inherit; @@ -73,11 +73,12 @@ } /* Groups on the current page's trail. */ -[data-nb-sidebar] details:has([aria-current='page']) > summary { +:is([data-nb-sidebar], [data-nb-api-nav]) details:has([aria-current='page']) > summary, +:is([data-nb-sidebar], [data-nb-api-nav]) summary[data-nb-active] { color: var(--nb-foreground); } -[data-nb-sidebar] summary::after { +:is([data-nb-sidebar], [data-nb-api-nav]) summary::after { content: ''; flex-shrink: 0; width: 1rem; @@ -94,19 +95,19 @@ opacity 250ms cubic-bezier(0.87, 0, 0.13, 1); } -[data-nb-sidebar] summary:hover::after { +:is([data-nb-sidebar], [data-nb-api-nav]) summary:hover::after { opacity: 1; } -[data-nb-sidebar] details[open] > summary::after { +:is([data-nb-sidebar], [data-nb-api-nav]) details[open] > summary::after { rotate: 90deg; } -[dir='rtl'] [data-nb-sidebar] summary::after { +[dir='rtl'] :is([data-nb-sidebar], [data-nb-api-nav]) summary::after { rotate: 180deg; } -[dir='rtl'] [data-nb-sidebar] details[open] > summary::after { +[dir='rtl'] :is([data-nb-sidebar], [data-nb-api-nav]) details[open] > summary::after { rotate: 90deg; } @@ -115,7 +116,7 @@ } @media (prefers-reduced-motion: reduce) { - [data-nb-sidebar] summary::after { + :is([data-nb-sidebar], [data-nb-api-nav]) summary::after { transition: none; } } diff --git a/packages/cloudflare-api-site/src/components/ui/version-switcher/VersionSwitcher.astro b/packages/cloudflare-api-site/src/components/ui/version-switcher/VersionSwitcher.astro new file mode 100644 index 000000000..7eb1a5cf1 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/version-switcher/VersionSwitcher.astro @@ -0,0 +1,226 @@ +--- +/** Picker for switching between docs versions or API-family versions. */ +import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import { + getVersionSwitchUrl, + getApiVersions, + getCurrentVersion, + getVersionLandingUrl, + getVersions, + stripBase, + withBase, +} from "@cloudflare/nimbus-docs/runtime"; +import { Popover, PopoverContent, PopoverTrigger } from "../popover"; +import { cn } from "@/lib/cn"; + +interface Props { + collection?: string; + entryId?: string; + /** API mode: the family, the current page's version + coordinate (from + * `getApiStaticPaths`). Set to switch API versions instead of docs versions. */ + apiCollection?: string; + apiVersion?: string | null; + coordinate?: string; + variant?: "header" | "sidebar"; + class?: string; +} + +const { + collection, + entryId, + apiCollection, + apiVersion, + coordinate, + variant = "header", + class: className, +} = Astro.props; + +interface Entry { + version: string; + label: string; + href: string; + isCurrent: boolean; + isActive: boolean; +} + +let entries: Entry[] = []; +let activeLabel = ""; +const currentPath = stripBase(Astro.url.pathname, import.meta.env.BASE_URL); + +if (apiCollection) { + const apiVersions = (await getApiVersions(apiCollection)) ?? []; + const visible = apiVersions.filter((v) => !v.hidden); + if (visible.length > 0) { + const defaultVersion = apiVersions.find((v) => v.isDefault)?.version ?? null; + const activeVersion = apiVersion ?? defaultVersion; + entries = await Promise.all(visible.map(async (v) => { + const isActive = v.version === activeVersion; + // The active entry stays on this page, keeping that version's + // `?version=` (other query params are dropped); others switch to the + // same operation in their version, or its landing. + const href = isActive + ? currentPath + new URL(v.url, "http://x").search + : coordinate && activeVersion + ? await getVersionSwitchUrl({ collection: apiCollection, sourceVersion: activeVersion, id: coordinate, targetVersion: v.version }) + : v.url; + return { version: v.version, label: v.label, href, isCurrent: v.isDefault, isActive }; + })); + activeLabel = + entries.find((e) => e.isActive)?.label ?? + visible.find((v) => v.isDefault)?.label ?? + ""; + } +} else { + await buildDocsEntries(); +} + +async function buildDocsEntries() { +const versions = await getVersions(); + +// A page in a collection outside the docs versions (an API reference, a blog) +// gets no docs picker: it has no version to switch. +const currentPageVersion = collection ? await getCurrentVersion(collection) : null; +if (versions && versions.all.length >= 2 && !(collection && currentPageVersion === null)) { + const visible = versions.all.filter((v) => !versions.hidden.includes(v)); + + // Fallback landing URL per version, used when the current page has no same-logical-page sibling. + const landingByVersion = new Map( + await Promise.all( + visible.map(async (v) => [v, await getVersionLandingUrl(v)] as const), + ), + ); + + entries = await Promise.all(visible + // Drop entries with no resolvable destination — that version has + // no indexed pages, so there's nowhere to send the reader. Two + // exceptions are always kept: + // - The currently-active version (reader is already on a page + // in it; the picker entry is a no-op nav). + // - The current version overall (the home page `/` is the + // canonical landing and is shipped by the starter). + .filter((v) => + v === currentPageVersion || + v === versions.current || + landingByVersion.get(v) != null, + ) + .map(async (v) => { + const isCurrent = v === versions.current; + const isActive = v === currentPageVersion; + + let href: string; + if (isActive) { + // No-op nav: stay on this page. + href = currentPath; + } else if (collection && entryId && currentPageVersion) { + // The same page in that version, else that version's landing. + href = await getVersionSwitchUrl({ collection, sourceVersion: currentPageVersion, id: entryId, targetVersion: v }); + } else if (isCurrent) { + // Current version always lands at the site root. `/` is the + // canonical "go to current" target — readers expect the home + // page, not a deep link into the first-by-URL indexed entry. + href = "/"; + } else { + // Older version — use the resolved landing URL (real entry, + // not the bare URL prefix that would 404). + const landing = landingByVersion.get(v); + href = landing ?? `/${v}/`; + } + + return { version: v, label: v, href, isCurrent, isActive }; + })); + + const activeEntry = entries.find((e) => e.isActive); + activeLabel = activeEntry + ? activeEntry.version + : (versions.current ?? ""); +} +} + +entries = entries.map((entry) => ({ + ...entry, + href: withBase(entry.href, import.meta.env.BASE_URL), +})); + +const labelId = `nb-version-picker-${Math.random().toString(36).slice(2, 9)}`; +--- + +{entries.length > 0 && variant === "header" && ( + + + {/* Phones keep the label and caret; the icon returns from 640px. */} + + + + + +)} + +{entries.length > 0 && variant === "sidebar" && ( +
+

+ + Version +

+ +
+)} diff --git a/packages/cloudflare-api-site/src/components/ui/version-switcher/index.ts b/packages/cloudflare-api-site/src/components/ui/version-switcher/index.ts new file mode 100644 index 000000000..58f733965 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/version-switcher/index.ts @@ -0,0 +1 @@ +export { default as VersionSwitcher } from "./VersionSwitcher.astro"; diff --git a/packages/cloudflare-api-site/src/layouts/DocsLayout.astro b/packages/cloudflare-api-site/src/layouts/DocsLayout.astro index c159b70e4..1fe144af3 100644 --- a/packages/cloudflare-api-site/src/layouts/DocsLayout.astro +++ b/packages/cloudflare-api-site/src/layouts/DocsLayout.astro @@ -8,7 +8,6 @@ import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; import BaseLayout from "./BaseLayout.astro"; import Header from "@/components/Header.astro"; -import VersionSelect from "@/components/VersionSelect.astro"; import { Banner } from "@/components/ui/banner"; import { Sidebar, SidebarFilter } from "@/components/ui/sidebar"; import { TOC, MobileTOC } from "@/components/ui/toc"; @@ -72,7 +71,7 @@ const hasContentFooter = Astro.slots.has("content-footer"); ) : ( @@ -232,8 +231,6 @@ const hasContentFooter = Astro.slots.has("content-footer");
{/* The desktop rail's
diff --git a/packages/cloudflare-api-site/src/styles/cloudflare.css b/packages/cloudflare-api-site/src/styles/cloudflare.css index da9daf92f..b2dfd9c03 100644 --- a/packages/cloudflare-api-site/src/styles/cloudflare.css +++ b/packages/cloudflare-api-site/src/styles/cloudflare.css @@ -43,8 +43,11 @@ --cf-purple: oklch(0.82 0.1 300); } +/* Opaque, since Safari blends a translucent selection colour with the system + blue; in sRGB, since the grey background's hue would pull an oklch mix pink. */ ::selection { - background: color-mix(in oklch, var(--cf-primary) 15%, transparent); + background: color-mix(in srgb, var(--cf-primary) 30%, var(--cf-background)); + color: var(--cf-foreground); } @media print { diff --git a/packages/cloudflare-api-site/src/styles/globals.css b/packages/cloudflare-api-site/src/styles/globals.css index 6ac3299c1..94dde9add 100644 --- a/packages/cloudflare-api-site/src/styles/globals.css +++ b/packages/cloudflare-api-site/src/styles/globals.css @@ -1,4 +1,5 @@ @import 'tailwindcss'; +@import '../components/ui/sidebar/sidebar.css'; /* ============================================================ * Nimbus Theme — THE file you edit to customize your site. @@ -88,7 +89,7 @@ --nb-toc-width: 18rem; --nb-content-max: 43.5rem; /* Used only by the API reference (registry: api-layout); harmless elsewhere. */ - --nb-code-rail-width: 34rem; + --nb-code-rail-width: 30rem; /* Typography — self-hosted via @fontsource-variable in BaseLayout.astro */ --nb-font-sans: 'Inter Variable', 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; From ffa72a9938b767673497e5bebcb6cded5b94134a Mon Sep 17 00:00:00 2001 From: mohamedh Date: Mon, 2 Nov 2026 02:16:48 +0100 Subject: [PATCH 06/19] feat: serve api pages, markdown and llms.txt with nimbus --- .../src/content/docs/index.mdx | 12 -- .../cloudflare-api-site/src/middleware.ts | 14 ++ .../src/pages/[...section]/llms.txt.ts | 3 + .../src/pages/[...slug].astro | 91 +++------ .../src/pages/[...slug].md.ts | 11 + .../src/pages/[...slug]/index.md.ts | 3 - .../src/pages/[...slug]/index.mdx.ts | 3 - .../src/pages/[product]/[...document].md.ts | 15 -- .../src/pages/[product]/[...slug].astro | 188 ------------------ .../src/pages/[product]/llms.txt.ts | 22 -- .../cloudflare-api-site/src/pages/llms.txt.ts | 27 +-- 11 files changed, 57 insertions(+), 332 deletions(-) delete mode 100644 packages/cloudflare-api-site/src/content/docs/index.mdx create mode 100644 packages/cloudflare-api-site/src/middleware.ts create mode 100644 packages/cloudflare-api-site/src/pages/[...section]/llms.txt.ts create mode 100644 packages/cloudflare-api-site/src/pages/[...slug].md.ts delete mode 100644 packages/cloudflare-api-site/src/pages/[...slug]/index.md.ts delete mode 100644 packages/cloudflare-api-site/src/pages/[...slug]/index.mdx.ts delete mode 100644 packages/cloudflare-api-site/src/pages/[product]/[...document].md.ts delete mode 100644 packages/cloudflare-api-site/src/pages/[product]/[...slug].astro delete mode 100644 packages/cloudflare-api-site/src/pages/[product]/llms.txt.ts diff --git a/packages/cloudflare-api-site/src/content/docs/index.mdx b/packages/cloudflare-api-site/src/content/docs/index.mdx deleted file mode 100644 index 1efa79b19..000000000 --- a/packages/cloudflare-api-site/src/content/docs/index.mdx +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: API Reference -description: API reference generated from the Cloudflare OpenAPI spec by Forge. -tableOfContents: false ---- - -import ApiOverview from '../../components/ApiOverview.astro'; -import LibraryCards from '../../components/LibraryCards.astro'; - - - - diff --git a/packages/cloudflare-api-site/src/middleware.ts b/packages/cloudflare-api-site/src/middleware.ts new file mode 100644 index 000000000..c48157b92 --- /dev/null +++ b/packages/cloudflare-api-site/src/middleware.ts @@ -0,0 +1,14 @@ +import { defineMiddleware } from 'astro:middleware'; +import redirects from '../.forge/redirects.json'; + +const base = import.meta.env.BASE_URL.replace(/\/$/, ''); +const moved: Record = redirects; + +/** Section ids, alias operation URLs and moved products 302 to the pages Nimbus draws. */ +export const onRequest = defineMiddleware(({ url, redirect }, next) => { + if (!url.pathname.startsWith(`${base}/`)) return next(); + const path = url.pathname.slice(base.length); + const target = moved[path]; + if (target) return redirect(`${base}${target}${url.search}`, 302); + return next(); +}); diff --git a/packages/cloudflare-api-site/src/pages/[...section]/llms.txt.ts b/packages/cloudflare-api-site/src/pages/[...section]/llms.txt.ts new file mode 100644 index 000000000..177041e1d --- /dev/null +++ b/packages/cloudflare-api-site/src/pages/[...section]/llms.txt.ts @@ -0,0 +1,3 @@ +import { llmsSectionRoute } from '@cloudflare/nimbus-docs/agent-endpoints'; + +export const { GET } = llmsSectionRoute(); diff --git a/packages/cloudflare-api-site/src/pages/[...slug].astro b/packages/cloudflare-api-site/src/pages/[...slug].astro index 44f2be212..bb73e344c 100644 --- a/packages/cloudflare-api-site/src/pages/[...slug].astro +++ b/packages/cloudflare-api-site/src/pages/[...slug].astro @@ -1,69 +1,32 @@ --- -import DocsLayout from "../layouts/DocsLayout.astro"; -import { - getDocsPage, - getRouteFlags, - getBreadcrumbs, - getEditUrl, - getLastUpdated, - getTOC, - stripBase, -} from "@cloudflare/nimbus-docs/runtime"; -import { components } from "../components"; -import { getApiRouter } from "../api-server.ts"; -import { getPageSidebar } from "../page-sidebar.ts"; +import { getApiRoute } from '@cloudflare/nimbus-docs/runtime'; +import Header from '@/components/Header.astro'; +import { API_HOME_CHUNK_SIZE, ApiLayout } from '@/components/ui/api-layout'; +import BaseLayout from '@/layouts/BaseLayout.astro'; +import { toPlainText } from '@/plain-text'; -// The overview honours the site-wide `?version=` / `?lang=` selection; reject -// empty, duplicate, or unknown values like the API routes do. -const router = await getApiRouter(); -if (router.isSitePath(Astro.url.pathname) && !router.resolveSiteSelection(Astro.url)) { - return new Response("Not found", { status: 404 }); -} - -const page = await getDocsPage(Astro); -if (page instanceof Response) return page; -const { entry, Content, headings, markdownUrl, ogImageUrl } = page; - -const currentSlug = stripBase(Astro.url.pathname, import.meta.env.BASE_URL).replace(/\/$/, "") || "/"; - -const { sidebar: sidebarOn, tableOfContents: tocOn } = await getRouteFlags(entry); - -// The rail is the API navigation for the selected version and target, shared -// with the API and tooling routes, rather than a tree of authored pages. -const sidebar = sidebarOn ? await getPageSidebar(Astro.url) : false; -// Pass `collection` so versioned pages (docs-) get version-prefixed -// breadcrumb hrefs. -const breadcrumbs = await getBreadcrumbs(currentSlug, { collection: entry.collection }); -const editUrl = await getEditUrl(entry); -// Frontmatter wins; git is the fallback. -const lastUpdated = entry.data.lastUpdated ?? - await getLastUpdated(entry); -// `tocOn` already implies `tableOfContents !== false`, but TS can't carry -// that boolean narrowing to the value here — re-check it so `getTOC` only -// ever sees its options object (or undefined), never `false`. -const tocConfig = entry.data.tableOfContents; -const toc = tocOn && tocConfig !== false ? getTOC(headings, tocConfig) : false; -const socialImage = entry.data.socialImage ?? ogImageUrl; +const result = await getApiRoute(Astro, 'api', { homeChunkSize: API_HOME_CHUNK_SIZE }); +if (result instanceof Response) return result; +const { page, nav, collection, version, coordinate, home } = result; +const part = home && home.part.number > 1 ? home.part : undefined; +const described = page.descriptionHtml ? toPlainText(page.descriptionHtml) : page.description; +const { title, description } = + page.kind === 'api' + ? { title: page.title, description: undefined } + : page.kind === 'section' + ? { title: page.title, description: described ?? `${page.title} API resources and endpoints.` } + : { title: page.title, description: described }; --- - - - +
+ + diff --git a/packages/cloudflare-api-site/src/pages/[...slug].md.ts b/packages/cloudflare-api-site/src/pages/[...slug].md.ts new file mode 100644 index 000000000..6b730b36b --- /dev/null +++ b/packages/cloudflare-api-site/src/pages/[...slug].md.ts @@ -0,0 +1,11 @@ +import { markdownRoute } from '@cloudflare/nimbus-docs/agent-endpoints'; +import type { APIRoute } from 'astro'; + +const markdown = markdownRoute(); + +/** Serves Nimbus's `//index.md` and 301s this site's old `/.md` URLs to it. */ +export const GET: APIRoute = (context) => { + const slug = context.params.slug ?? ''; + if (slug.split('/').at(-1) === 'index') return markdown.GET(context); + return context.redirect(`${import.meta.env.BASE_URL}${slug}/index.md${context.url.search}`, 301); +}; diff --git a/packages/cloudflare-api-site/src/pages/[...slug]/index.md.ts b/packages/cloudflare-api-site/src/pages/[...slug]/index.md.ts deleted file mode 100644 index a0a393140..000000000 --- a/packages/cloudflare-api-site/src/pages/[...slug]/index.md.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { markdownRoute } from '@cloudflare/nimbus-docs/agent-endpoints'; - -export const { GET } = markdownRoute(); diff --git a/packages/cloudflare-api-site/src/pages/[...slug]/index.mdx.ts b/packages/cloudflare-api-site/src/pages/[...slug]/index.mdx.ts deleted file mode 100644 index b6206e97b..000000000 --- a/packages/cloudflare-api-site/src/pages/[...slug]/index.mdx.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { markdownSourceRoute } from '@cloudflare/nimbus-docs/agent-endpoints'; - -export const { GET } = markdownSourceRoute(); diff --git a/packages/cloudflare-api-site/src/pages/[product]/[...document].md.ts b/packages/cloudflare-api-site/src/pages/[product]/[...document].md.ts deleted file mode 100644 index 41a18320f..000000000 --- a/packages/cloudflare-api-site/src/pages/[product]/[...document].md.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { APIRoute } from 'astro'; -import { renderPageMarkdown } from 'astro-fern/agents'; -import { getApiOperation, getApiRouter } from '../../api-server.ts'; - -export const GET: APIRoute = async ({ locals, request, url }) => { - const resolved = await getApiOperation(url, request, locals, 'markdown'); - if (!resolved) return new Response('Not found', { status: 404 }); - const router = await getApiRouter(); - const document = renderPageMarkdown(resolved.page, { - ...(resolved.lang ? { target: resolved.lang } : {}), - directive: router.plan.agents.directive, - resolveHref: router.resolveHref, - }); - return new Response(document.body, { headers: { 'Content-Type': document.contentType } }); -}; diff --git a/packages/cloudflare-api-site/src/pages/[product]/[...slug].astro b/packages/cloudflare-api-site/src/pages/[product]/[...slug].astro deleted file mode 100644 index d7a5fafb0..000000000 --- a/packages/cloudflare-api-site/src/pages/[product]/[...slug].astro +++ /dev/null @@ -1,188 +0,0 @@ ---- -import { stripBase } from '@cloudflare/nimbus-docs/runtime'; -import DocsLayout from '../../layouts/DocsLayout.astro'; -import PageTitle from '../../components/PageTitle.astro'; -import ApiIndex from '../../components/ApiIndex.astro'; -import CommandReference from '../../components/commands/CommandReference.astro'; -import Operation from '../../components/Operation.astro'; -import { getApiPage, getApiRouter } from '../../api-server.ts'; -import { commandTitle } from '../../command-reference/routing.ts'; -import { getCommandRouter } from '../../command-reference/server.ts'; -import { getPageSidebar } from '../../page-sidebar.ts'; -import { toPlainText } from '../../plain-text.ts'; - -const [resolved, router, commandRouter] = await Promise.all([ - getApiPage(Astro.url, Astro.request, Astro.locals), - getApiRouter(), - getCommandRouter(), -]); -const command = resolved ? undefined : commandRouter.resolve(Astro.url, router.plan.catalog.products); -// Product tooling pages carry the site-wide `?version=` / `?lang=` selection; -// reject invalid values like the API pages do. -if (!resolved && (!command || !router.resolveSiteSelection(Astro.url))) { - return new Response('Not found', { status: 404 }); -} -// Built only for pages that exist: the API sidebar has thousands of entries. -const sidebar = await getPageSidebar(Astro.url); -const target = resolved?.lang; -const page = resolved?.kind === 'operation' ? resolved.page : undefined; - -// Markdown is a sister of the current route: on a per-target page link to that -// target's Markdown (which carries that target's code sample); otherwise the -// canonical page Markdown (which carries every target's sample). -// `agentLinks.markdown` is absent when Markdown routes are disabled, so this may -// be undefined; the alternate and the visible link are omitted in that case. -const markdownHref = page - ? router.operationHref( - page.product.id, - page.snapshot.id, - page.operation.operationId, - 'markdown', - target, - page.operation.placement?.projectionId, - ) - : undefined; - -let title: string; -let metaDescription: string; -if (command) { - title = commandTitle(command); - metaDescription = command.description; -} else { - if (!resolved) throw new Error('Expected an API page or tooling command'); - title = - resolved.kind === 'product' - ? resolved.product.title - : resolved.kind === 'section' - ? resolved.section.title - : resolved.kind === 'resource' - ? resolved.resource.title - : resolved.operation.title; - if (resolved.kind === 'operation') metaDescription = toPlainText(resolved.page.operation.description.html); - else if (resolved.kind === 'product') metaDescription = resolved.product.description; - else if (resolved.kind === 'resource') metaDescription = `${resolved.resource.title} API resources and methods.`; - else metaDescription = `${resolved.section.title} API operations.`; -} - -const description = metaDescription ? metaDescription.slice(0, 160) : undefined; ---- - - - {resolved ? : null} - { - command ? ( - - ) : resolved?.kind === 'operation' && page ? ( - - ) : resolved && resolved.kind !== 'operation' ? ( - - ) : null - } - - - diff --git a/packages/cloudflare-api-site/src/pages/[product]/llms.txt.ts b/packages/cloudflare-api-site/src/pages/[product]/llms.txt.ts deleted file mode 100644 index c65324c4e..000000000 --- a/packages/cloudflare-api-site/src/pages/[product]/llms.txt.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { APIRoute } from 'astro'; -import { getLlmsPayload } from '@cloudflare/nimbus-docs/agent-endpoints'; -import { renderLlmsIndexFromCatalog } from 'astro-fern/agents'; -import { getApiRouter, getFernContentCatalog } from '../../api-server.ts'; - -// `//llms.txt` is shared: API products resolve through astro-fern, and -// any other segment falls back to the Nimbus index of that authored section. -export const prerender = false; - -export const GET: APIRoute = async ({ params, request, url }) => { - const product = params.product; - if (!product) return new Response('Not found', { status: 404 }); - const router = await getApiRouter(); - const selection = router.resolveLlms(product, url.searchParams); - if (selection) { - const document = renderLlmsIndexFromCatalog(await getFernContentCatalog(), selection, router.resolveHref); - return new Response(document.body, { headers: { 'Content-Type': document.contentType } }); - } - const section = await getLlmsPayload({ scope: 'section', surface: 'index', section: product }, { request }); - if (!section) return new Response('Not found', { status: 404 }); - return new Response(section.body, { headers: { 'Content-Type': section.mediaType } }); -}; diff --git a/packages/cloudflare-api-site/src/pages/llms.txt.ts b/packages/cloudflare-api-site/src/pages/llms.txt.ts index dafecf091..c18c57544 100644 --- a/packages/cloudflare-api-site/src/pages/llms.txt.ts +++ b/packages/cloudflare-api-site/src/pages/llms.txt.ts @@ -1,26 +1,3 @@ -import type { APIRoute } from 'astro'; -import { getLlmsPayload } from '@cloudflare/nimbus-docs/agent-endpoints'; -import { renderLlmsIndexFromCatalog } from 'astro-fern/agents'; -import { getApiRouter, getFernContentCatalog } from '../api-server.ts'; +import { llmsRoute } from '@cloudflare/nimbus-docs/agent-endpoints'; -// Rendered on request: the authored pages come from the index Nimbus prepares -// at build time, followed by the API products astro-fern resolves per request. -export const prerender = false; - -/** Drops a Markdown document's leading H1 so its sections can follow another document. */ -function withoutTitle(body: string): string { - return body.replace(/^# [^\n]*\n+/, ''); -} - -export const GET: APIRoute = async ({ request, url }) => { - const router = await getApiRouter(); - // The site index has no variants, but invalid selection values still 404 like other agent routes. - if (!router.resolveSiteSelection(url)) return new Response('Not found', { status: 404 }); - const [docs, catalog] = await Promise.all([ - getLlmsPayload({ scope: 'site', surface: 'index' }, { request }), - getFernContentCatalog(), - ]); - const api = renderLlmsIndexFromCatalog(catalog, { kind: 'llms', scope: 'site' }, router.resolveHref); - const body = docs ? `${docs.body.trimEnd()}\n\n${withoutTitle(api.body)}` : api.body; - return new Response(body, { headers: { 'Content-Type': api.contentType } }); -}; +export const { GET } = llmsRoute(); From 7398e8e846897647f27cffe7041f5a27a8db5875 Mon Sep 17 00:00:00 2001 From: mohamedh Date: Mon, 2 Nov 2026 02:16:48 +0100 Subject: [PATCH 07/19] feat: move command pages onto the command sidebar --- .../src/command-reference/routing.ts | 32 +---- .../src/command-routing.test.ts | 34 +---- .../src/command-sidebar.ts | 125 ++++++++++++++++++ .../pages/[product]/tooling/[...slug].astro | 23 ++++ .../src/pages/tooling/[...path].astro | 17 +-- .../src/pages/tooling/index.astro | 11 +- 6 files changed, 171 insertions(+), 71 deletions(-) create mode 100644 packages/cloudflare-api-site/src/command-sidebar.ts create mode 100644 packages/cloudflare-api-site/src/pages/[product]/tooling/[...slug].astro diff --git a/packages/cloudflare-api-site/src/command-reference/routing.ts b/packages/cloudflare-api-site/src/command-reference/routing.ts index f618775d6..b9cbc9520 100644 --- a/packages/cloudflare-api-site/src/command-reference/routing.ts +++ b/packages/cloudflare-api-site/src/command-reference/routing.ts @@ -42,31 +42,17 @@ export function commandPath(command: Pick): string return command.fullPath.map(encodeURIComponent).join('/'); } +/** The API and the command pages share one base, so no product or operation may take a `tooling` segment. */ export function assertToolingRouteNamespace( - products: readonly { - id: string; - slug: string; - snapshots?: readonly { pages: readonly { pathname: string }[] }[]; - }[], - apiBase: string, - commandBase: string, + products: readonly CommandProductRoute[], + operationSlugs: readonly string[], ): void { - const toolingNamespace = toolingPath(commandBase); const conflict = products.find((product) => product.slug === TOOLING_SEGMENT); - if (toolingPath(apiBase) === toolingNamespace && conflict) { - throw new Error(`API product "${conflict.id}" conflicts with the ${toolingNamespace}/ route`); - } - if (normalizePathname(apiBase) !== normalizePathname(commandBase)) return; - + if (conflict) throw new Error(`API product "${conflict.id}" conflicts with the /${TOOLING_SEGMENT}/ route`); for (const product of products) { - const productToolingPath = `${normalizePathname(apiBase)}/${product.slug}/${TOOLING_SEGMENT}`.replace('//', '/'); - const route = product.snapshots - ?.flatMap((snapshot) => snapshot.pages) - .find((page) => { - const pathname = normalizePathname(page.pathname); - return pathname === productToolingPath || pathname.startsWith(`${productToolingPath}/`); - }); - if (route) throw new Error(`API route "${route.pathname}" conflicts with the ${productToolingPath}/ route`); + const prefix = `${product.slug}/${TOOLING_SEGMENT}`; + const route = operationSlugs.find((slug) => slug === prefix || slug.startsWith(`${prefix}/`)); + if (route) throw new Error(`API route "${route}" conflicts with the /${prefix}/ route`); } } @@ -104,10 +90,6 @@ export class CommandRouter { return `${this.#basePath}/${commandPath(command)}/`; } - commandsForProduct(productId: string): readonly CommandMetadata[] { - return this.commands.filter((command) => command.fullPath[0] === productId); - } - resolve(url: URL | string, products: readonly CommandProductRoute[] = []): CommandMetadata | undefined { const pathname = typeof url === 'string' ? url : url.pathname; const normalizedPathname = normalizePathname(pathname); diff --git a/packages/cloudflare-api-site/src/command-routing.test.ts b/packages/cloudflare-api-site/src/command-routing.test.ts index 2c9185a0a..6d257bc99 100644 --- a/packages/cloudflare-api-site/src/command-routing.test.ts +++ b/packages/cloudflare-api-site/src/command-routing.test.ts @@ -44,18 +44,6 @@ test('command titles humanize kebab and snake case names', () => { assert.equal(commandTitle({ name: 'who_am_i' }), 'Who Am I'); }); -test('commands are associated with products by exact visible namespace', () => { - assert.deepEqual( - router.commandsForProduct('d1').map((command) => command.command), - ['cf d1 migrations apply', 'cf d1 migrations create', 'cf d1 migrations list'], - ); - assert.deepEqual( - router.commandsForProduct('workers').map((command) => command.command), - ['cf workers triggers deploy'], - ); - assert.deepEqual(router.commandsForProduct('builds'), []); -}); - test('hidden commands cannot be resolved', () => { const hidden = new CommandRouter( { @@ -69,23 +57,13 @@ test('hidden commands cannot be resolved', () => { }); test('the tooling namespace rejects a colliding API product', () => { - assert.throws(() => assertToolingRouteNamespace([{ id: 'tooling', slug: 'tooling' }], '/api', '/api'), /conflicts/); - assert.throws(() => assertToolingRouteNamespace([{ id: 'Tools', slug: 'tooling' }], '/api', '/api'), /conflicts/); + assert.throws(() => assertToolingRouteNamespace([{ id: 'tooling', slug: 'tooling' }], []), /conflicts/); + assert.throws(() => assertToolingRouteNamespace([{ id: 'Tools', slug: 'tooling' }], []), /conflicts/); assert.throws( - () => - assertToolingRouteNamespace( - [ - { - id: 'workers', - slug: 'workers', - snapshots: [{ pages: [{ pathname: '/api/workers/tooling/methods/list/' }] }], - }, - ], - '/api', - '/api', - ), + () => assertToolingRouteNamespace([{ id: 'workers', slug: 'workers' }], ['workers/tooling/methods/list']), /conflicts/, ); - assert.doesNotThrow(() => assertToolingRouteNamespace([{ id: 'tooling', slug: 'tooling' }], '/api', '/docs/api')); - assert.doesNotThrow(() => assertToolingRouteNamespace([{ id: 'workers', slug: 'workers' }], '/api', '/api')); + assert.doesNotThrow(() => + assertToolingRouteNamespace([{ id: 'workers', slug: 'workers' }], ['workers/tools/methods/list']), + ); }); diff --git a/packages/cloudflare-api-site/src/command-sidebar.ts b/packages/cloudflare-api-site/src/command-sidebar.ts new file mode 100644 index 000000000..d42249a84 --- /dev/null +++ b/packages/cloudflare-api-site/src/command-sidebar.ts @@ -0,0 +1,125 @@ +import { stripBase } from '@cloudflare/nimbus-docs/runtime'; +import type { SidebarItem } from '@cloudflare/nimbus-docs/types'; +import products from '../.forge/products.json'; +import { type CommandProductRoute, type CommandRouter, commandTitle } from './command-reference/routing.ts'; +import type { CommandMetadata } from './command-reference/schema.ts'; +import { getCommandRouter } from './command-reference/server.ts'; + +type Item = { label: string; link: string } | { label: string; items: Item[]; collapsed: boolean }; + +interface Node { + name: string; + command?: CommandMetadata; + children: Map; +} + +export const commandProducts: readonly (CommandProductRoute & { title: string })[] = products; + +function tree(commands: readonly CommandMetadata[]): Map { + const roots = new Map(); + for (const command of commands) { + let siblings = roots; + command.fullPath.forEach((name, index) => { + let node = siblings.get(name); + if (!node) { + node = { name, children: new Map() }; + siblings.set(name, node); + } + if (index === command.fullPath.length - 1) node.command = command; + siblings = node.children; + }); + } + return roots; +} + +function render(router: CommandRouter, node: Node, product?: CommandProductRoute): Item { + const children = [...node.children.values()].map((child) => render(router, child, product)); + if (node.command && children.length === 0) { + return { label: commandTitle(node.command), link: router.commandHref(node.command, product) }; + } + return { + label: commandTitle(node), + collapsed: true, + items: [ + ...(node.command ? [{ label: 'Overview', link: router.commandHref(node.command, product) }] : []), + ...children, + ], + }; +} + +interface CommandGroups { + /** Non-product commands, after an Overview link to the directory. */ + global: Item[]; + /** Each product's commands, by product. */ + products: Map; +} + +function commandGroups(router: CommandRouter): CommandGroups { + const byId = new Map(commandProducts.map((product) => [product.id, product])); + const global: Item[] = [{ label: 'Overview', link: router.directoryHref }]; + const products = new Map(); + for (const root of tree(router.commands).values()) { + const product = byId.get(root.name); + if (!product) { + global.push(render(router, root)); + continue; + } + products.set( + product, + [render(router, root, product)].flatMap((item) => ('items' in item ? item.items : [item])), + ); + } + return { global, products }; +} + +/** The command directory: non-product commands, then one group per product with commands. */ +function commandSidebar(router: CommandRouter, pathname: string): SidebarItem[] { + const { global, products } = commandGroups(router); + const productItems: Item[] = [...products].map(([product, items]) => ({ + label: product.title, + collapsed: true, + items, + })); + const current = currentPath(pathname); + return [...global, ...productItems].map((item, order) => toSidebarItem(item, current, order)); +} + +function currentPath(pathname: string): string { + return pathname.replace(/\/+$/, '') || '/'; +} + +function toSidebarItem(item: Item, current: string, order: number): SidebarItem { + if ('link' in item) { + const href = stripBase(item.link, import.meta.env.BASE_URL); + return { type: 'link', label: item.label, href, isCurrent: item.link.replace(/\/+$/, '') === current, order }; + } + return { + type: 'group', + label: item.label, + collapsed: item.collapsed, + children: item.items.map((child, index) => toSidebarItem(child, current, index)), + order, + }; +} + +export async function getCommandSidebar(url: URL): Promise { + return commandSidebar(await getCommandRouter(), url.pathname); +} + +export interface ToolingSidebar { + /** The site-wide "Developer Tooling" group. */ + global: SidebarItem[]; + /** A product's "Developer Tooling" group, by the product's section path (`/d1/`). */ + products: Map; +} + +/** The command groups the API sidebar lists beside the spec's sections. */ +export async function getToolingSidebar(url: URL): Promise { + const { global, products } = commandGroups(await getCommandRouter()); + const current = currentPath(url.pathname); + const toItems = (items: Item[]) => items.map((item, order) => toSidebarItem(item, current, order)); + return { + global: toItems(global), + products: new Map([...products].map(([product, items]) => [`/${product.slug}/`, toItems(items)])), + }; +} diff --git a/packages/cloudflare-api-site/src/pages/[product]/tooling/[...slug].astro b/packages/cloudflare-api-site/src/pages/[product]/tooling/[...slug].astro new file mode 100644 index 000000000..0a87ab719 --- /dev/null +++ b/packages/cloudflare-api-site/src/pages/[product]/tooling/[...slug].astro @@ -0,0 +1,23 @@ +--- +import DocsLayout from '../../../layouts/DocsLayout.astro'; +import CommandReference from '../../../components/commands/CommandReference.astro'; +import { commandProducts, getCommandSidebar } from '../../../command-sidebar.ts'; +import { commandTitle } from '../../../command-reference/routing.ts'; +import { getCommandRouter } from '../../../command-reference/server.ts'; + +const router = await getCommandRouter(); +const command = router.resolve(Astro.url, commandProducts); +if (!command) return new Response('Not found', { status: 404 }); +const sidebar = await getCommandSidebar(Astro.url); +--- + + + + diff --git a/packages/cloudflare-api-site/src/pages/tooling/[...path].astro b/packages/cloudflare-api-site/src/pages/tooling/[...path].astro index 39231e24a..d168b786f 100644 --- a/packages/cloudflare-api-site/src/pages/tooling/[...path].astro +++ b/packages/cloudflare-api-site/src/pages/tooling/[...path].astro @@ -1,21 +1,19 @@ --- import DocsLayout from '../../layouts/DocsLayout.astro'; import CommandReference from '../../components/commands/CommandReference.astro'; -import { getApiRouter } from '../../api-server.ts'; +import { commandProducts, getCommandSidebar } from '../../command-sidebar.ts'; import { commandPath, commandTitle } from '../../command-reference/routing.ts'; import { getCommandRouter } from '../../command-reference/server.ts'; import type { CommandMetadata } from '../../command-reference/schema.ts'; -import { getPageSidebar } from '../../page-sidebar.ts'; export const prerender = true; export async function getStaticPaths() { - const [apiRouter, router] = await Promise.all([getApiRouter(), getCommandRouter()]); - const productIds = new Set(apiRouter.plan.catalog.products.map((product) => product.id)); - return router.commands.filter((command) => !productIds.has(command.fullPath[0])).map((command) => ({ - params: { path: commandPath(command) }, - props: { command }, - })); + const router = await getCommandRouter(); + const productIds = new Set(commandProducts.map((product) => product.id)); + return router.commands + .filter((command) => !productIds.has(command.fullPath[0])) + .map((command) => ({ params: { path: commandPath(command) }, props: { command } })); } interface Props { @@ -23,8 +21,7 @@ interface Props { } const { command } = Astro.props; - -const sidebar = await getPageSidebar(Astro.url); +const sidebar = await getCommandSidebar(Astro.url); --- product.id)); +const [router, sidebar] = await Promise.all([getCommandRouter(), getCommandSidebar(Astro.url)]); +const productIds = new Set(commandProducts.map((product) => product.id)); const commands = router.commands.filter((command) => !productIds.has(command.fullPath[0])); --- From 7fb3c5768f6b9243afd018059e6a9b6eeceff3b0 Mon Sep 17 00:00:00 2001 From: mohamedh Date: Mon, 2 Nov 2026 02:16:57 +0100 Subject: [PATCH 08/19] refactor: delete the site's own api routing, sidebar and page components --- .../src/api-routing.test.ts | 603 ---------- .../cloudflare-api-site/src/api-routing.ts | 613 ---------- .../cloudflare-api-site/src/api-server.ts | 79 -- .../src/artifact-fetcher.test.ts | 11 - .../src/artifact-fetcher.ts | 9 - .../cloudflare-api-site/src/components.ts | 11 - .../src/components/ApiIndex.astro | 359 ------ .../src/components/ApiOverview.astro | 37 - .../src/components/CodeSample.astro | 1057 ----------------- .../src/components/CopyableCode.astro | 48 - .../src/components/Operation.astro | 371 ------ .../src/components/PageTitle.astro | 289 ----- .../src/components/SchemaSectionIsland.astro | 28 - .../src/components/SchemaTree.astro | 235 ---- .../src/components/VersionSelect.astro | 150 --- .../src/operation-sections.test.ts | 66 - .../src/operation-sections.ts | 178 --- .../cloudflare-api-site/src/page-sidebar.ts | 32 - .../cloudflare-api-site/src/sidebar.test.ts | 437 ------- packages/cloudflare-api-site/src/sidebar.ts | 270 ----- 20 files changed, 4883 deletions(-) delete mode 100644 packages/cloudflare-api-site/src/api-routing.test.ts delete mode 100644 packages/cloudflare-api-site/src/api-routing.ts delete mode 100644 packages/cloudflare-api-site/src/api-server.ts delete mode 100644 packages/cloudflare-api-site/src/artifact-fetcher.test.ts delete mode 100644 packages/cloudflare-api-site/src/artifact-fetcher.ts delete mode 100644 packages/cloudflare-api-site/src/components.ts delete mode 100644 packages/cloudflare-api-site/src/components/ApiIndex.astro delete mode 100644 packages/cloudflare-api-site/src/components/ApiOverview.astro delete mode 100644 packages/cloudflare-api-site/src/components/CodeSample.astro delete mode 100644 packages/cloudflare-api-site/src/components/CopyableCode.astro delete mode 100644 packages/cloudflare-api-site/src/components/Operation.astro delete mode 100644 packages/cloudflare-api-site/src/components/PageTitle.astro delete mode 100644 packages/cloudflare-api-site/src/components/SchemaSectionIsland.astro delete mode 100644 packages/cloudflare-api-site/src/components/SchemaTree.astro delete mode 100644 packages/cloudflare-api-site/src/components/VersionSelect.astro delete mode 100644 packages/cloudflare-api-site/src/operation-sections.test.ts delete mode 100644 packages/cloudflare-api-site/src/operation-sections.ts delete mode 100644 packages/cloudflare-api-site/src/page-sidebar.ts delete mode 100644 packages/cloudflare-api-site/src/sidebar.test.ts delete mode 100644 packages/cloudflare-api-site/src/sidebar.ts diff --git a/packages/cloudflare-api-site/src/api-routing.test.ts b/packages/cloudflare-api-site/src/api-routing.test.ts deleted file mode 100644 index 594f2d4f8..000000000 --- a/packages/cloudflare-api-site/src/api-routing.test.ts +++ /dev/null @@ -1,603 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import type { FernRoutePlan } from 'astro-fern'; -import { ApiRouter } from './api-routing.ts'; - -const operation = { - entryId: 'operation:records-list', - operationId: 'records_list', - slug: 'sections/records/operations/records-list', - section: { id: 'records', tag: 'Records', title: 'Records' }, - title: 'List records', - description: 'Lists records.', - httpMethod: 'get', -}; - -const plan: Pick = { - catalog: { - snapshots: [ - { - id: 'legacy', - slug: 'legacy', - label: 'Legacy', - default: false, - targets: [{ id: 'curl', kind: 'http', label: 'curl', language: 'bash' }], - }, - { - id: '2026-11-30.air', - slug: '2026-11-30', - label: '2026-11-30 (Air)', - default: false, - targets: [ - { id: 'curl', kind: 'http', label: 'curl', language: 'bash' }, - { id: 'typescript', kind: 'sdk', label: 'TypeScript', language: 'typescript' }, - ], - }, - { - id: '2027-01-31.breeze', - slug: '2027-01-31', - label: '2027-01-31 (Breeze)', - default: true, - targets: [ - { id: 'curl', kind: 'http', label: 'curl', language: 'bash' }, - { id: 'typescript', kind: 'sdk', label: 'TypeScript', language: 'typescript' }, - ], - }, - ], - products: [ - { - id: 'dns', - slug: 'dns', - title: 'DNS', - description: 'DNS API.', - snapshots: [ - { - id: 'legacy', - slug: 'legacy', - label: 'Legacy', - default: false, - targets: [{ id: 'curl', kind: 'http', label: 'curl', language: 'bash' }], - pages: [ - { - id: 'page:dns:legacy:records-list', - pathname: '/api/dns/legacy/sections/records/operations/records-list/', - ...operation, - }, - { - id: 'page:dns:legacy:retired-list', - pathname: '/api/dns/legacy/sections/retired/operations/retired-list/', - ...operation, - entryId: 'operation:retired-list', - operationId: 'retired_list', - slug: 'sections/retired/operations/retired-list', - section: { id: 'retired', tag: 'Retired', title: 'Retired' }, - title: 'List retired records', - }, - ], - }, - { - id: '2026-11-30.air', - slug: '2026-11-30', - label: '2026-11-30 (Air)', - default: false, - targets: [ - { id: 'curl', kind: 'http', label: 'curl', language: 'bash' }, - { id: 'typescript', kind: 'sdk', label: 'TypeScript', language: 'typescript' }, - ], - pages: [ - { - id: 'page:dns:air:records-list', - pathname: '/api/dns/2026-11-30/sections/records/operations/records-read/', - ...operation, - slug: 'sections/records/operations/records-read', - }, - ], - }, - { - id: '2027-01-31.breeze', - slug: '2027-01-31', - label: '2027-01-31 (Breeze)', - default: true, - targets: [ - { id: 'curl', kind: 'http', label: 'curl', language: 'bash' }, - { id: 'typescript', kind: 'sdk', label: 'TypeScript', language: 'typescript' }, - ], - pages: [ - { - id: 'page:dns:breeze:records-list', - pathname: '/api/dns/sections/records/operations/records-list/', - ...operation, - }, - ], - }, - ], - }, - { - id: 'zones', - slug: 'zones', - title: 'Zones', - description: 'Zones API.', - snapshots: [ - { - id: '2027-01-31.breeze', - slug: '2027-01-31', - label: '2027-01-31 (Breeze)', - default: true, - targets: [ - { id: 'curl', kind: 'http', label: 'curl', language: 'bash' }, - { id: 'go', kind: 'sdk', label: 'Go', language: 'go' }, - ], - pages: [ - { - id: 'page:zones:breeze:records-list', - pathname: '/api/zones/sections/records/operations/records-list/', - ...operation, - entryId: 'operation:zones-records-list', - }, - ], - }, - ], - }, - ], - }, - routing: { base: '/api', target: 'hash' }, - agents: { injectRoutes: false, markdown: false, llms: false, directive: false }, -}; - -const router = new ApiRouter(plan); - -test('canonical operation paths use configured defaults when query parameters are absent', () => { - const selection = router.resolveOperation( - new URL('https://example.com/api/dns/sections/records/operations/records-list/'), - ); - assert.equal(selection?.snapshot.id, '2027-01-31.breeze'); - assert.equal(selection?.lang, undefined); - assert.equal(selection?.pageId, 'page:dns:breeze:records-list'); -}); - -test('resolves canonical product and section landing pages', () => { - const product = router.resolvePage(new URL('https://example.com/api/dns/')); - assert.equal(product?.kind, 'product'); - assert.equal(product?.product.title, 'DNS'); - assert.equal(product?.snapshot.id, '2027-01-31.breeze'); - assert.equal(product?.kind === 'product' ? product.sections[0]?.title : undefined, 'Records'); - - const section = router.resolvePage(new URL('https://example.com/api/dns/sections/records')); - assert.equal(section?.kind, 'section'); - assert.equal(section?.kind === 'section' ? section.section.operations[0]?.title : undefined, 'List records'); -}); - -test('product and section pages validate and preserve selections', () => { - const product = router.resolvePage(new URL('https://example.com/api/dns?lang=typescript&version=2026-11-30.air')); - assert.equal(product?.kind, 'product'); - assert.equal(product?.snapshot.id, '2026-11-30.air'); - assert.equal(product?.lang, 'typescript'); - - assert.equal(router.resolvePage(new URL('https://example.com/api/dns/?lang=missing')), undefined); - assert.equal(router.resolvePage(new URL('https://example.com/api/dns/sections/missing/')), undefined); - assert.equal(router.resolvePage(new URL('https://example.com/api/missing/')), undefined); -}); - -test('landing pages include historical-only sections and operations', () => { - const product = router.resolvePage(new URL('https://example.com/api/dns/?version=legacy')); - assert.equal(product?.kind, 'product'); - assert.deepEqual(product?.kind === 'product' ? product.sections.map(({ id }) => id) : [], ['records', 'retired']); - assert.equal(router.sectionHref('dns', 'legacy', 'retired'), '/api/dns/sections/retired/?version=legacy'); - assert.equal( - router.resolveOperation( - new URL('https://example.com/api/dns/sections/retired/operations/retired-list/?version=legacy'), - )?.pageId, - 'page:dns:legacy:retired-list', - ); -}); - -test('the version query uses the canonical snapshot ID and lang uses the target ID', () => { - const selection = router.resolveOperation( - new URL( - 'https://example.com/api/dns/sections/records/operations/records-read/?lang=typescript&version=2026-11-30.air', - ), - ); - assert.equal(selection?.snapshot.id, '2026-11-30.air'); - assert.equal(selection?.lang, 'typescript'); - assert.equal(selection?.pageId, 'page:dns:air:records-list'); -}); - -test('operation resolution uses the slug declared by the selected snapshot', () => { - assert.equal( - router.resolveOperation( - new URL('https://example.com/api/dns/sections/records/operations/records-list/?version=2026-11-30.air'), - ), - undefined, - ); - assert.equal( - router.resolveOperation( - new URL('https://example.com/api/dns/sections/records/operations/records-read/?version=2026-11-30.air'), - )?.pageId, - 'page:dns:air:records-list', - ); -}); - -test('invalid, empty, and duplicate selection parameters do not fall back', () => { - const path = 'https://example.com/api/dns/sections/records/operations/records-list/'; - assert.equal(router.resolveOperation(new URL(`${path}?version=2026-11-30`)), undefined); - assert.equal(router.resolveOperation(new URL(`${path}?lang=bash`)), undefined); - assert.equal(router.resolveOperation(new URL(`${path}?lang=`)), undefined); - assert.equal(router.resolveOperation(new URL(`${path}?version=legacy&version=2026-11-30.air`)), undefined); -}); - -test('site snapshot options select a snapshot without requiring an operation', () => { - assert.equal(router.resolveSiteSnapshot(new URL('https://example.com/'))?.id, '2027-01-31.breeze'); - assert.equal(router.siteSnapshotHref('2026-11-30.air'), '/?version=2026-11-30.air'); - assert.equal(router.siteSnapshotHref('2027-01-31.breeze'), '/'); - assert.deepEqual(router.siteSnapshotOptions(new URL('https://example.com/?version=2026-11-30.air')), [ - { label: 'Legacy', value: '/?version=legacy', selected: false }, - { label: '2026-11-30 (Air)', value: '/?version=2026-11-30.air', selected: true }, - { label: '2027-01-31 (Breeze)', value: '/', selected: false }, - ]); -}); - -test('site-level links honor the Astro deployment base', () => { - const based = new ApiRouter(plan, '/api'); - - assert.equal(based.siteHref, '/api/'); - assert.equal(based.siteSnapshotHref('2026-11-30.air'), '/api/?version=2026-11-30.air'); - assert.equal(based.resolveHref({ kind: 'llms', scope: 'site' }), '/api/llms.txt'); - assert.equal(based.isSitePath('/api/'), true); - assert.equal(based.isSitePath('/'), false); -}); - -test('site snapshot options retain supported languages and drop unsupported ones', () => { - assert.deepEqual(router.siteSnapshotOptions(new URL('https://example.com/?lang=typescript')), [ - { label: 'Legacy', value: '/?version=legacy', selected: false }, - { label: '2026-11-30 (Air)', value: '/?lang=typescript&version=2026-11-30.air', selected: false }, - { label: '2027-01-31 (Breeze)', value: '/?lang=typescript', selected: true }, - ]); -}); - -test('invalid site snapshot parameters do not fall back', () => { - assert.equal(router.resolveSiteSnapshot(new URL('https://example.com/?version=missing')), undefined); - assert.equal( - router.resolveSiteSnapshot(new URL('https://example.com/?version=legacy&version=2026-11-30.air')), - undefined, - ); - assert.deepEqual(router.siteSnapshotOptions(new URL('https://example.com/?version=')), []); -}); - -test('old snapshot and target path routes are gone', () => { - assert.equal( - router.resolveOperation( - new URL('https://example.com/api/dns/2026-11-30/sections/records/operations/records-list/'), - ), - undefined, - ); - assert.equal( - router.resolveOperation( - new URL('https://example.com/api/dns/typescript/sections/records/operations/records-list/'), - ), - undefined, - ); -}); - -test('Markdown resolution rejects paths without the Markdown extension', () => { - assert.equal( - router.resolveOperation( - new URL('https://example.com/api/dns/sections/records/operations/records-list/'), - 'markdown', - ), - undefined, - ); -}); - -test('human and Markdown hrefs share the snapshot path and stable query parameters', () => { - assert.equal( - router.operationHref('dns', '2026-11-30.air', 'records_list', 'human', 'typescript'), - '/api/dns/sections/records/operations/records-read/?lang=typescript&version=2026-11-30.air', - ); - const markdown = router.operationHref('dns', '2026-11-30.air', 'records_list', 'markdown', 'typescript'); - assert.equal(markdown, '/api/dns/sections/records/operations/records-read.md?lang=typescript&version=2026-11-30.air'); - assert.equal( - router.resolveOperation(new URL(`https://example.com${markdown}`), 'markdown')?.pageId, - 'page:dns:air:records-list', - ); -}); - -test('product and section hrefs use canonical hierarchy paths', () => { - assert.equal(router.productHref('dns', '2027-01-31.breeze'), '/api/dns/'); - assert.equal( - router.productHref('dns', '2026-11-30.air', 'typescript'), - '/api/dns/?lang=typescript&version=2026-11-30.air', - ); - assert.equal( - router.sectionHref('dns', '2026-11-30.air', 'records', 'typescript'), - '/api/dns/sections/records/?lang=typescript&version=2026-11-30.air', - ); - assert.equal(router.sectionHref('dns', '2027-01-31.breeze', 'missing'), undefined); -}); - -test('breadcrumbs link every ancestor and leave only the current page unlinked', () => { - const operation = router.resolvePage( - new URL('https://example.com/api/dns/sections/records/operations/records-list/?lang=typescript'), - ); - assert.ok(operation); - assert.deepEqual(router.breadcrumbs(operation), [ - { label: 'API Reference', href: '/?lang=typescript' }, - { label: 'DNS', href: '/api/dns/?lang=typescript' }, - { label: 'Records', href: '/api/dns/sections/records/?lang=typescript' }, - { label: 'List records' }, - ]); - - const section = router.resolvePage(new URL('https://example.com/api/dns/sections/records/?version=legacy')); - assert.ok(section); - assert.deepEqual(router.breadcrumbs(section), [ - { label: 'API Reference', href: '/?version=legacy' }, - { label: 'DNS', href: '/api/dns/?version=legacy' }, - { label: 'Records' }, - ]); - - const product = router.resolvePage(new URL('https://example.com/api/dns/')); - assert.ok(product); - assert.deepEqual(router.breadcrumbs(product), [{ label: 'API Reference', href: '/' }, { label: 'DNS' }]); - - const productWithSiteUnsupportedLanguage = router.resolvePage(new URL('https://example.com/api/zones/?lang=go')); - assert.ok(productWithSiteUnsupportedLanguage); - assert.equal(router.breadcrumbs(productWithSiteUnsupportedLanguage)[0]?.href, '/'); -}); - -test('llms snapshot and target scopes collapse onto the product route', () => { - assert.equal( - router.href({ kind: 'llms', scope: 'snapshot', productId: 'dns', snapshotId: '2026-11-30.air' }), - '/api/dns/llms.txt?version=2026-11-30.air', - ); - assert.equal( - router.href({ - kind: 'llms', - scope: 'target', - productId: 'dns', - snapshotId: '2026-11-30.air', - targetId: 'typescript', - }), - '/api/dns/llms.txt?lang=typescript&version=2026-11-30.air', - ); - assert.deepEqual(router.resolveLlms('dns', new URLSearchParams('lang=typescript&version=2026-11-30.air')), { - kind: 'llms', - scope: 'target', - productId: 'dns', - snapshotId: '2026-11-30.air', - targetId: 'typescript', - }); -}); - -test('operation identity remains scoped to its product', () => { - assert.equal( - router.resolveOperation(new URL('https://example.com/api/zones/sections/records/operations/records-list/'))?.pageId, - 'page:zones:breeze:records-list', - ); - assert.equal(router.operationHref('zones', '2027-01-31.breeze', 'records_list', 'human', 'typescript'), undefined); -}); - -test('a product unavailable in the global default snapshot never falls back to another snapshot', () => { - const sparsePlan = structuredClone(plan); - const source = sparsePlan.catalog.products[0]; - const legacy = source?.snapshots.find((snapshot) => snapshot.id === 'legacy'); - assert.ok(source && legacy); - sparsePlan.catalog.products = [ - { ...source, id: 'archives', slug: 'archives', title: 'Archives', snapshots: [legacy] }, - ]; - const sparse = new ApiRouter(sparsePlan); - - assert.equal(sparse.resolvePage(new URL('https://example.com/api/archives/')), undefined); - assert.equal(sparse.resolvePage(new URL('https://example.com/api/archives/?version=legacy'))?.snapshot.id, 'legacy'); - assert.equal(sparse.href({ kind: 'llms', scope: 'product', productId: 'archives' }), undefined); - assert.equal( - sparse.href({ kind: 'llms', scope: 'snapshot', productId: 'archives', snapshotId: 'legacy' }), - '/api/archives/llms.txt?version=legacy', - ); -}); - -test('every language selector value resolves back to the selected target', () => { - const product = plan.catalog.products[0]; - const snapshot = product?.snapshots.find((candidate) => candidate.default); - const page = snapshot?.pages[0]; - assert.ok(product && snapshot && page); - for (const target of snapshot.targets) { - const href = router.operationHref(product.id, snapshot.id, page.operationId, 'human', target.id); - assert.ok(href); - assert.equal(router.resolveOperation(new URL(href, 'https://example.com'))?.lang, target.id); - } -}); - -const sdkPrimary = { - id: 'page:zero-trust:devices-list:primary', - entryId: 'operation:zero-trust:devices-list:primary', - operationId: 'devices_list', - slug: 'devices/ip-profiles/methods/list', - pathname: '/api/zero-trust/devices/ip-profiles/methods/list/', - placement: { - projectionId: 'primary', - resourcePath: [ - { id: 'devices', slug: 'devices', title: 'Devices' }, - { id: 'ip_profiles', slug: 'ip-profiles', title: 'IP Profiles' }, - ], - method: { id: 'list', slug: 'list' }, - }, - section: { id: 'devices', tag: 'Zero Trust devices', title: 'Internal ownership tag' }, - title: 'List IP profiles', - description: 'Lists device IP profiles.', - httpMethod: 'get', -}; - -const sdkAlias = { - ...sdkPrimary, - id: 'page:zero-trust:devices-list:alias', - entryId: 'operation:zero-trust:devices-list:alias', - slug: 'device-settings/methods/list', - pathname: '/api/zero-trust/device-settings/methods/list/', - placement: { - projectionId: 'forge:alias:zero-trust.device_settings/list', - resourcePath: [{ id: 'device_settings', slug: 'device-settings', title: 'Device Settings' }], - method: { id: 'list', slug: 'list' }, - }, -}; - -const sdkPlan: Pick = { - catalog: { - snapshots: [{ id: 'current', slug: 'current', label: 'Current', default: true, targets: [] }], - products: [ - { - id: 'zero-trust', - slug: 'zero-trust', - title: 'Zero Trust', - description: 'Zero Trust API.', - snapshots: [ - { - id: 'current', - slug: 'current', - label: 'Current', - default: true, - targets: [], - pages: [sdkPrimary, sdkAlias], - }, - ], - }, - ], - }, - routing: { base: '/api', target: 'hash' }, - agents: { injectRoutes: false, markdown: false, llms: false, directive: false }, -}; - -test('SDK hierarchy resolves product, resource overview, and kebab-case method routes', () => { - const sdkRouter = new ApiRouter(sdkPlan); - const product = sdkRouter.resolvePage(new URL('https://example.com/api/zero-trust/')); - assert.equal(product?.kind, 'product'); - assert.equal(product?.kind === 'product' ? product.resources[0]?.title : undefined, 'Devices'); - - const resource = sdkRouter.resolvePage(new URL('https://example.com/api/zero-trust/devices/ip-profiles/')); - assert.equal(resource?.kind, 'resource'); - assert.equal(resource?.kind === 'resource' ? resource.resource.title : undefined, 'IP Profiles'); - - const operation = sdkRouter.resolveOperation( - new URL('https://example.com/api/zero-trust/devices/ip-profiles/methods/list/'), - ); - assert.equal(operation?.pageId, sdkPrimary.id); - assert.equal(sdkRouter.resolvePage(new URL('https://example.com/api/zero-trust/devices/ip_profiles/')), undefined); - assert.equal(sdkRouter.resolvePage(new URL('https://example.com/api/zero-trust/sections/devices/')), undefined); - assert.equal( - sdkRouter.resolvePage( - new URL( - 'https://example.com/api/resources/zero-trust/subresources/devices/subresources/ip-profiles/methods/list/', - ), - ), - undefined, - ); -}); - -test('SDK aliases resolve independently by projection identity', () => { - const sdkRouter = new ApiRouter(sdkPlan); - assert.equal(sdkRouter.operationHref('zero-trust', 'current', 'devices_list', 'human'), sdkPrimary.pathname); - assert.equal( - sdkRouter.operationHref( - 'zero-trust', - 'current', - 'devices_list', - 'human', - undefined, - 'forge:alias:zero-trust.device_settings/list', - ), - sdkAlias.pathname, - ); -}); - -test('product URL slugs resolve back to semantic product IDs', () => { - const slugPlan = structuredClone(sdkPlan); - const product = slugPlan.catalog.products[0]; - assert.ok(product); - product.id = 'analytics_engine'; - product.slug = 'analytics-engine'; - product.title = 'Analytics Engine'; - const slugRouter = new ApiRouter(slugPlan); - - const selection = slugRouter.resolvePage(new URL('https://example.com/api/analytics-engine/')); - assert.equal(selection?.product.id, 'analytics_engine'); - assert.equal(selection?.pathname, '/api/analytics-engine/'); - assert.equal(slugRouter.resolvePage(new URL('https://example.com/api/analytics_engine/')), undefined); - assert.deepEqual(slugRouter.resolveLlms('analytics-engine', new URLSearchParams()), { - kind: 'llms', - scope: 'product', - productId: 'analytics_engine', - }); - assert.equal(slugRouter.resolveLlms('analytics_engine', new URLSearchParams()), undefined); - assert.equal( - slugRouter.href({ kind: 'llms', scope: 'product', productId: 'analytics_engine' }), - '/api/analytics-engine/llms.txt', - ); -}); - -test('SDK resource overviews cannot shadow operation routes', () => { - const collisionPlan = structuredClone(sdkPlan); - const snapshot = collisionPlan.catalog.products[0]?.snapshots[0]; - assert.ok(snapshot); - snapshot.pages.push({ - ...sdkPrimary, - id: 'page:zero-trust:nested-list:primary', - entryId: 'operation:zero-trust:nested-list:primary', - operationId: 'nested_list', - slug: 'devices/ip-profiles/methods/list/methods/get', - placement: { - projectionId: 'primary', - resourcePath: [ - ...sdkPrimary.placement.resourcePath, - { id: 'methods', slug: 'methods', title: 'Methods' }, - { id: 'list', slug: 'list', title: 'List' }, - ], - method: { id: 'get', slug: 'get' }, - }, - }); - - assert.throws(() => new ApiRouter(collisionPlan), /maps SDK resource .* and operation .* to .*methods.*reserved/); -}); - -test('SDK breadcrumbs follow resource ownership instead of OpenAPI tags', () => { - const sdkRouter = new ApiRouter(sdkPlan); - const selection = sdkRouter.resolveOperation(new URL(`https://example.com${sdkPrimary.pathname}`)); - assert.ok(selection); - assert.deepEqual(sdkRouter.breadcrumbs(selection), [ - { label: 'API Reference', href: '/' }, - { label: 'Zero Trust', href: '/api/zero-trust/' }, - { label: 'Devices', href: '/api/zero-trust/devices/' }, - { - label: 'IP Profiles', - href: '/api/zero-trust/devices/ip-profiles/', - }, - { label: 'List IP profiles' }, - ]); -}); - -test('version selector values resolve the same operation and preserve only supported languages', () => { - const selection = router.resolveOperation( - new URL('https://example.com/api/dns/sections/records/operations/records-list/?lang=typescript'), - ); - assert.ok(selection); - const options = router.snapshotOptions(selection); - assert.equal(options.length, 3); - assert.equal(options.filter((option) => option.selected).length, 1); - for (const option of options) { - const resolved = router.resolveOperation(new URL(option.value, 'https://example.com')); - assert.equal(resolved?.operation.operationId, selection.operation.operationId); - assert.equal(resolved?.snapshot.label, option.label); - assert.equal(resolved?.lang, option.label === 'Legacy' ? undefined : 'typescript'); - } -}); - -test('version selector only includes snapshots where the operation is available', () => { - const selection = router.resolveOperation( - new URL('https://example.com/api/dns/sections/retired/operations/retired-list/?version=legacy'), - ); - assert.ok(selection); - assert.deepEqual(router.snapshotOptions(selection), [ - { - label: 'Legacy', - value: '/api/dns/sections/retired/operations/retired-list/?version=legacy', - selected: true, - }, - ]); -}); diff --git a/packages/cloudflare-api-site/src/api-routing.ts b/packages/cloudflare-api-site/src/api-routing.ts deleted file mode 100644 index f1989c8cd..000000000 --- a/packages/cloudflare-api-site/src/api-routing.ts +++ /dev/null @@ -1,613 +0,0 @@ -import type { - FernCatalogPage, - FernCatalogProduct, - FernCatalogSiteSnapshot, - FernCatalogSnapshot, - FernRoutePlan, -} from 'astro-fern'; -import type { SemanticHrefRequest, SemanticHrefResolver, SemanticLlmsHrefRequest } from 'astro-fern/agents'; - -type RoutePlan = Pick; - -export interface ApiOperationSelection { - kind: 'operation'; - pageId: string; - pathname: string; - product: FernCatalogProduct; - snapshot: FernCatalogSnapshot; - operation: FernCatalogPage; - lang?: string; -} - -export interface ApiCatalogSection { - id: string; - tag: string; - title: string; - operations: FernCatalogPage[]; -} - -type ApiResourceSegment = NonNullable['resourcePath'][number]; - -export interface ApiCatalogResource extends ApiResourceSegment { - key: string; - path: ApiResourceSegment[]; - resources: ApiCatalogResource[]; - operations: FernCatalogPage[]; -} - -export interface ApiProductSelection { - kind: 'product'; - pathname: string; - product: FernCatalogProduct; - snapshot: FernCatalogSnapshot; - sections: ApiCatalogSection[]; - resources: ApiCatalogResource[]; - operations: FernCatalogPage[]; - lang?: string; -} - -export interface ApiSectionSelection { - kind: 'section'; - pathname: string; - product: FernCatalogProduct; - snapshot: FernCatalogSnapshot; - section: ApiCatalogSection; - lang?: string; -} - -export interface ApiResourceSelection { - kind: 'resource'; - pathname: string; - product: FernCatalogProduct; - snapshot: FernCatalogSnapshot; - resource: ApiCatalogResource; - lang?: string; -} - -export type ApiPageSelection = ApiProductSelection | ApiSectionSelection | ApiResourceSelection | ApiOperationSelection; - -export interface ApiBreadcrumb { - label: string; - href?: string; -} - -export interface ApiVersionOption { - label: string; - value: string; - selected: boolean; -} - -interface OperationLocation { - pathname: string; - product: FernCatalogProduct; - snapshot: FernCatalogSnapshot; - operation: FernCatalogPage; -} - -interface SectionLocation { - pathname: string; - product: FernCatalogProduct; - sectionId: string; -} - -interface ResourceLocation { - pathname: string; - product: FernCatalogProduct; - snapshots: Map; -} - -const INVALID = Symbol('invalid-query-parameter'); - -function normalizePath(pathname: string): string { - const clean = pathname.replace(/\/+$/, ''); - return clean === '' ? '/' : clean; -} - -function baseHref(base: string): string { - const normalized = normalizePath(base); - return normalized === '/' ? '/' : `${normalized}/`; -} - -function parseSingleQueryValue(searchParams: URLSearchParams, name: string): string | undefined | typeof INVALID { - const values = searchParams.getAll(name); - if (values.length === 0) return undefined; - if (values.length !== 1 || values[0] === '') return INVALID; - return values[0]; -} - -function markdownPath(pathname: string): string { - return `${pathname.replace(/\/$/, '')}.md`; -} - -function withSelection(pathname: string, defaultSnapshotId: string, snapshotId: string, lang?: string): string { - const search = new URLSearchParams(); - if (lang !== undefined) search.set('lang', lang); - if (snapshotId !== defaultSnapshotId) search.set('version', snapshotId); - const query = search.toString(); - return query ? `${pathname}?${query}` : pathname; -} - -function hierarchyPath(base: string, ...segments: string[]): string { - const prefix = normalizePath(base); - return `${prefix === '/' ? '' : prefix}/${segments.map(encodeURIComponent).join('/')}/`; -} - -function operationPath(base: string, productSlug: string, operationSlug: string): string { - return hierarchyPath(base, productSlug, ...operationSlug.split('/').filter(Boolean)); -} - -function resourcePathname(base: string, productSlug: string, resourcePath: readonly ApiResourceSegment[]): string { - return hierarchyPath(base, productSlug, ...resourcePath.map((resource) => resource.slug)); -} - -function resourceKey(path: readonly ApiResourceSegment[]): string { - return path.map((segment) => `${segment.id.length}:${segment.id}`).join(''); -} - -function operationKey(operationId: string, projectionId = 'primary'): string { - return `${operationId.length}:${operationId}${projectionId.length}:${projectionId}`; -} - -function sectionsForSnapshot(snapshot: FernCatalogSnapshot): ApiCatalogSection[] { - const sections = new Map(); - for (const operation of snapshot.pages) { - if (operation.placement) continue; - let section = sections.get(operation.section.id); - if (!section) { - section = { ...operation.section, operations: [] }; - sections.set(section.id, section); - } - section.operations.push(operation); - } - return [...sections.values()]; -} - -function resourcesForSnapshot(snapshot: FernCatalogSnapshot): ApiCatalogResource[] { - const roots: ApiCatalogResource[] = []; - for (const operation of snapshot.pages) { - const placement = operation.placement; - if (!placement) continue; - let siblings = roots; - const path: ApiResourceSegment[] = []; - let resource: ApiCatalogResource | undefined; - for (const segment of placement.resourcePath) { - path.push(segment); - resource = siblings.find((candidate) => candidate.id === segment.id); - if (!resource) { - resource = { ...segment, key: resourceKey(path), path: [...path], resources: [], operations: [] }; - siblings.push(resource); - } - siblings = resource.resources; - } - resource?.operations.push(operation); - } - return roots; -} - -function visitResources(resources: ApiCatalogResource[], visit: (resource: ApiCatalogResource) => void): void { - for (const resource of resources) { - visit(resource); - visitResources(resource.resources, visit); - } -} - -function findResource(resources: ApiCatalogResource[], key: string): ApiCatalogResource | undefined { - for (const resource of resources) { - if (resource.key === key) return resource; - const child = findResource(resource.resources, key); - if (child) return child; - } - return undefined; -} - -function resolveSelection( - product: FernCatalogProduct, - searchParams: URLSearchParams, - defaultSnapshotId: string, -): { snapshot: FernCatalogSnapshot; lang?: string } | undefined { - const snapshotValue = parseSingleQueryValue(searchParams, 'version'); - const langValue = parseSingleQueryValue(searchParams, 'lang'); - if (snapshotValue === INVALID || langValue === INVALID) return undefined; - - const snapshot = - snapshotValue === undefined - ? product.snapshots.find((candidate) => candidate.id === defaultSnapshotId) - : product.snapshots.find((candidate) => candidate.id === snapshotValue); - if (!snapshot) return undefined; - if (langValue !== undefined && !snapshot.targets.some((target) => target.id === langValue)) return undefined; - return { snapshot, ...(langValue !== undefined ? { lang: langValue } : {}) }; -} - -/** Resolves the docs site's query-selected global snapshot against sparse product catalogs. */ -export class ApiRouter { - readonly plan: RoutePlan; - readonly resolveHref: SemanticHrefResolver; - readonly siteHref: string; - readonly #defaultSnapshotId: string; - readonly #operationsByPath = new Map>(); - readonly #operationsBySnapshot = new Map>(); - readonly #productsByPath = new Map(); - readonly #sectionsByPath = new Map(); - readonly #sectionsBySnapshot = new Map(); - readonly #resourcesByPath = new Map(); - readonly #resourcesBySnapshot = new Map(); - readonly #products = new Map(); - readonly #productsBySlug = new Map(); - - constructor(plan: RoutePlan, siteBase = '/') { - this.plan = plan; - this.siteHref = baseHref(siteBase); - this.resolveHref = (request) => this.href(request); - const defaultSnapshots = plan.catalog.snapshots.filter((snapshot) => snapshot.default); - const defaultSnapshot = defaultSnapshots[0]; - if (defaultSnapshots.length !== 1 || !defaultSnapshot) { - throw new Error( - `cloudflare-api-site: the global catalog has ${defaultSnapshots.length} default snapshots; configure exactly one default snapshot in the source config`, - ); - } - this.#defaultSnapshotId = defaultSnapshot.id; - - for (const product of plan.catalog.products) { - this.#products.set(product.id, product); - this.#productsBySlug.set(product.slug, product); - for (const snapshot of product.snapshots) { - this.#operationsBySnapshot.set( - snapshot, - new Map(snapshot.pages.map((page) => [operationKey(page.operationId, page.placement?.projectionId), page])), - ); - this.#sectionsBySnapshot.set(snapshot, sectionsForSnapshot(snapshot)); - const resources = resourcesForSnapshot(snapshot); - this.#resourcesBySnapshot.set(snapshot, resources); - for (const section of this.#sectionsBySnapshot.get(snapshot) ?? []) { - const pathname = hierarchyPath(plan.routing.base, product.slug, 'sections', section.id); - this.#sectionsByPath.set(normalizePath(pathname), { pathname, product, sectionId: section.id }); - } - visitResources(resources, (resource) => { - const pathname = resourcePathname(plan.routing.base, product.slug, resource.path); - const normalized = normalizePath(pathname); - let location = this.#resourcesByPath.get(normalized); - if (!location) { - location = { pathname, product, snapshots: new Map() }; - this.#resourcesByPath.set(normalized, location); - } - location.snapshots.set(snapshot.id, resource); - }); - for (const operation of snapshot.pages) { - const pathname = operationPath(plan.routing.base, product.slug, operation.slug); - const normalized = normalizePath(pathname); - const resource = this.#resourcesByPath.get(normalized)?.snapshots.get(snapshot.id); - if (resource) { - throw new Error( - `cloudflare-api-site: snapshot "${snapshot.id}" maps SDK resource "${resource.path.map((segment) => segment.id).join('.')}" and operation "${operation.operationId}" to "${pathname}"; rename the resource because "methods" is reserved between SDK resources and method names`, - ); - } - let snapshots = this.#operationsByPath.get(normalized); - if (!snapshots) { - snapshots = new Map(); - this.#operationsByPath.set(normalized, snapshots); - } - if (snapshots.has(snapshot.id)) { - throw new Error( - `cloudflare-api-site: snapshot "${snapshot.id}" maps more than one operation to "${pathname}"; give the operations distinct OpenAPI operationIds or source config section IDs so their slugs differ`, - ); - } - snapshots.set(snapshot.id, { pathname, product, snapshot, operation }); - } - } - const productPathname = hierarchyPath(plan.routing.base, product.slug); - this.#productsByPath.set(normalizePath(productPathname), product); - } - } - - /** Resolves an operation only when the requested path is its selected snapshot's own slug. */ - resolveOperation(url: URL, representation: 'human' | 'markdown' = 'human'): ApiOperationSelection | undefined { - if (representation === 'markdown' && !url.pathname.endsWith('.md')) return undefined; - const requestPath = representation === 'markdown' ? url.pathname.slice(0, -3) : url.pathname; - const snapshotValue = parseSingleQueryValue(url.searchParams, 'version'); - if (snapshotValue === INVALID) return undefined; - const snapshotId = snapshotValue ?? this.#defaultSnapshotId; - const location = this.#operationsByPath.get(normalizePath(requestPath))?.get(snapshotId); - if (!location) return undefined; - - const selection = resolveSelection(location.product, url.searchParams, this.#defaultSnapshotId); - if (!selection) return undefined; - return { - kind: 'operation', - pageId: location.operation.id, - pathname: location.pathname, - product: location.product, - snapshot: selection.snapshot, - operation: location.operation, - ...(selection.lang !== undefined ? { lang: selection.lang } : {}), - }; - } - - resolvePage(url: URL): ApiPageSelection | undefined { - const operation = this.resolveOperation(url); - if (operation) return operation; - - const pathname = normalizePath(url.pathname); - const product = this.#productsByPath.get(pathname); - if (product) { - const selection = resolveSelection(product, url.searchParams, this.#defaultSnapshotId); - if (!selection) return undefined; - return { - kind: 'product', - pathname: hierarchyPath(this.plan.routing.base, product.slug), - product, - snapshot: selection.snapshot, - sections: this.pageSections(selection.snapshot), - resources: this.pageResources(selection.snapshot), - operations: selection.snapshot.pages.filter( - (page) => page.placement !== undefined && page.placement.resourcePath.length === 0, - ), - ...(selection.lang !== undefined ? { lang: selection.lang } : {}), - }; - } - - const sectionLocation = this.#sectionsByPath.get(pathname); - if (sectionLocation) { - const selection = resolveSelection(sectionLocation.product, url.searchParams, this.#defaultSnapshotId); - if (!selection) return undefined; - const section = this.pageSections(selection.snapshot).find( - (candidate) => candidate.id === sectionLocation.sectionId, - ); - if (!section) return undefined; - return { - kind: 'section', - pathname: sectionLocation.pathname, - product: sectionLocation.product, - snapshot: selection.snapshot, - section, - ...(selection.lang !== undefined ? { lang: selection.lang } : {}), - }; - } - - const resourceLocation = this.#resourcesByPath.get(pathname); - if (!resourceLocation) return undefined; - const selection = resolveSelection(resourceLocation.product, url.searchParams, this.#defaultSnapshotId); - if (!selection) return undefined; - const resource = resourceLocation.snapshots.get(selection.snapshot.id); - if (!resource) return undefined; - return { - kind: 'resource', - pathname: resourceLocation.pathname, - product: resourceLocation.product, - snapshot: selection.snapshot, - resource, - ...(selection.lang !== undefined ? { lang: selection.lang } : {}), - }; - } - - resolveSiteSelection(url: URL): { snapshot: FernCatalogSiteSnapshot; lang?: string } | undefined { - const snapshotValue = parseSingleQueryValue(url.searchParams, 'version'); - const langValue = parseSingleQueryValue(url.searchParams, 'lang'); - if (snapshotValue === INVALID || langValue === INVALID) return undefined; - const snapshot = this.plan.catalog.snapshots.find( - (candidate) => candidate.id === (snapshotValue ?? this.#defaultSnapshotId), - ); - if (!snapshot) return undefined; - if (langValue !== undefined && !snapshot.targets.some((target) => target.id === langValue)) return undefined; - return { snapshot, ...(langValue !== undefined ? { lang: langValue } : {}) }; - } - - isSitePath(pathname: string): boolean { - return normalizePath(pathname) === normalizePath(this.siteHref); - } - - resolveSiteSnapshot(url: URL): FernCatalogSiteSnapshot | undefined { - return this.resolveSiteSelection(url)?.snapshot; - } - - siteSnapshotHref(snapshotId: string, lang?: string): string | undefined { - const snapshot = this.plan.catalog.snapshots.find((candidate) => candidate.id === snapshotId); - if (!snapshot || (lang !== undefined && !snapshot.targets.some((target) => target.id === lang))) { - return undefined; - } - return withSelection(this.siteHref, this.#defaultSnapshotId, snapshotId, lang); - } - - siteSnapshotOptions(url: URL): ApiVersionOption[] { - const selected = this.resolveSiteSelection(url); - if (!selected) return []; - return this.plan.catalog.snapshots.map((snapshot) => ({ - label: snapshot.label, - value: - this.siteSnapshotHref(snapshot.id, selected.lang) ?? - withSelection(this.siteHref, this.#defaultSnapshotId, snapshot.id), - selected: snapshot.id === selected.snapshot.id, - })); - } - - resolveLlms(productSlug: string, searchParams: URLSearchParams): SemanticLlmsHrefRequest | undefined { - const product = this.#productsBySlug.get(productSlug); - if (!product) return undefined; - const snapshotValue = parseSingleQueryValue(searchParams, 'version'); - const langValue = parseSingleQueryValue(searchParams, 'lang'); - if (snapshotValue === INVALID || langValue === INVALID) return undefined; - - const snapshot = - snapshotValue === undefined - ? product.snapshots.find((candidate) => candidate.id === this.#defaultSnapshotId) - : product.snapshots.find((candidate) => candidate.id === snapshotValue); - if (!snapshot) return undefined; - if (langValue !== undefined) { - if (!snapshot.targets.some((target) => target.id === langValue)) return undefined; - return { - kind: 'llms', - scope: 'target', - productId: product.id, - snapshotId: snapshot.id, - targetId: langValue, - }; - } - return snapshot.id === this.#defaultSnapshotId - ? { kind: 'llms', scope: 'product', productId: product.id } - : { kind: 'llms', scope: 'snapshot', productId: product.id, snapshotId: snapshot.id }; - } - - /** Returns only snapshots containing the selected product, section, or semantic operation. */ - snapshotOptions(selection: ApiPageSelection): ApiVersionOption[] { - return selection.product.snapshots.flatMap((snapshot) => { - const value = this.pageHref(selection, snapshot.id, selection.lang) ?? this.pageHref(selection, snapshot.id); - return value ? [{ label: snapshot.label, value, selected: snapshot.id === selection.snapshot.id }] : []; - }); - } - - productHref(productId: string, snapshotId: string, lang?: string): string | undefined { - const product = this.#products.get(productId); - if (!product) return undefined; - const snapshot = product.snapshots.find((candidate) => candidate.id === snapshotId); - if (!snapshot || (lang !== undefined && !snapshot.targets.some((target) => target.id === lang))) return undefined; - return withSelection( - hierarchyPath(this.plan.routing.base, product.slug), - this.#defaultSnapshotId, - snapshot.id, - lang, - ); - } - - sectionHref(productId: string, snapshotId: string, sectionId: string, lang?: string): string | undefined { - const product = this.#products.get(productId); - if (!product) return undefined; - const snapshot = product.snapshots.find((candidate) => candidate.id === snapshotId); - if (!snapshot || (lang !== undefined && !snapshot.targets.some((target) => target.id === lang))) return undefined; - if (!this.pageSections(snapshot).some((candidate) => candidate.id === sectionId)) return undefined; - const pathname = hierarchyPath(this.plan.routing.base, product.slug, 'sections', sectionId); - return withSelection(pathname, this.#defaultSnapshotId, snapshot.id, lang); - } - - resourceHref( - productId: string, - snapshotId: string, - path: readonly ApiResourceSegment[], - lang?: string, - ): string | undefined { - const product = this.#products.get(productId); - if (!product) return undefined; - const snapshot = product.snapshots.find((candidate) => candidate.id === snapshotId); - if (!snapshot || (lang !== undefined && !snapshot.targets.some((target) => target.id === lang))) return undefined; - if (!findResource(this.pageResources(snapshot), resourceKey(path))) return undefined; - return withSelection( - resourcePathname(this.plan.routing.base, product.slug, path), - this.#defaultSnapshotId, - snapshot.id, - lang, - ); - } - - operationHref( - productId: string, - snapshotId: string, - operationId: string, - representation: 'human' | 'markdown', - lang?: string, - projectionId = 'primary', - ): string | undefined { - const product = this.#products.get(productId); - if (!product) return undefined; - const snapshot = product.snapshots.find((candidate) => candidate.id === snapshotId); - if (!snapshot || (lang !== undefined && !snapshot.targets.some((target) => target.id === lang))) return undefined; - const operation = this.#operationsBySnapshot.get(snapshot)?.get(operationKey(operationId, projectionId)); - if (!operation) return undefined; - const pathname = operationPath(this.plan.routing.base, product.slug, operation.slug); - return withSelection( - representation === 'markdown' ? markdownPath(pathname) : pathname, - this.#defaultSnapshotId, - snapshot.id, - lang, - ); - } - - breadcrumbs(selection: ApiPageSelection): ApiBreadcrumb[] { - const productHref = this.productHref(selection.product.id, selection.snapshot.id, selection.lang); - const siteHref = - this.siteSnapshotHref(selection.snapshot.id, selection.lang) ?? - this.siteSnapshotHref(selection.snapshot.id) ?? - this.siteHref; - const breadcrumbs: ApiBreadcrumb[] = [ - { label: 'API Reference', href: siteHref }, - { label: selection.product.title, ...(productHref ? { href: productHref } : {}) }, - ]; - if (selection.kind === 'product') - return breadcrumbs.map((item, index) => (index === breadcrumbs.length - 1 ? { label: item.label } : item)); - - const resourcePath = - selection.kind === 'resource' - ? selection.resource.path - : selection.kind === 'operation' - ? selection.operation.placement?.resourcePath - : undefined; - if (resourcePath) { - resourcePath.forEach((resource, index) => { - const path = resourcePath.slice(0, index + 1); - const href = this.resourceHref(selection.product.id, selection.snapshot.id, path, selection.lang); - const current = selection.kind === 'resource' && index === resourcePath.length - 1; - breadcrumbs.push({ label: resource.title, ...(!current && href ? { href } : {}) }); - }); - if (selection.kind === 'operation') breadcrumbs.push({ label: selection.operation.title }); - return breadcrumbs; - } - if (selection.kind === 'resource') return breadcrumbs; - - const section = selection.kind === 'section' ? selection.section : selection.operation.section; - const sectionHref = this.sectionHref(selection.product.id, selection.snapshot.id, section.id, selection.lang); - breadcrumbs.push({ label: section.title, ...(sectionHref ? { href: sectionHref } : {}) }); - if (selection.kind === 'operation') breadcrumbs.push({ label: selection.operation.title }); - else breadcrumbs[breadcrumbs.length - 1] = { label: section.title }; - return breadcrumbs; - } - - private pageHref(selection: ApiPageSelection, snapshotId: string, lang?: string): string | undefined { - if (selection.kind === 'product') return this.productHref(selection.product.id, snapshotId, lang); - if (selection.kind === 'section') { - return this.sectionHref(selection.product.id, snapshotId, selection.section.id, lang); - } - if (selection.kind === 'resource') { - return this.resourceHref(selection.product.id, snapshotId, selection.resource.path, lang); - } - return this.operationHref( - selection.product.id, - snapshotId, - selection.operation.operationId, - 'human', - lang, - selection.operation.placement?.projectionId, - ); - } - - private pageSections(snapshot: FernCatalogSnapshot): ApiCatalogSection[] { - return this.#sectionsBySnapshot.get(snapshot) ?? []; - } - - private pageResources(snapshot: FernCatalogSnapshot): ApiCatalogResource[] { - return this.#resourcesBySnapshot.get(snapshot) ?? []; - } - - href(request: SemanticHrefRequest): string | undefined { - if (request.kind === 'operation') { - return this.operationHref( - request.productId, - request.snapshotId, - request.operationId, - request.representation, - request.targetId, - request.projectionId, - ); - } - if (request.scope === 'site') return `${this.siteHref}llms.txt`; - const product = this.#products.get(request.productId); - if (!product) return undefined; - const pathname = `${hierarchyPath(this.plan.routing.base, product.slug)}llms.txt`; - if (request.scope === 'product') { - return product.snapshots.some((snapshot) => snapshot.id === this.#defaultSnapshotId) ? pathname : undefined; - } - const snapshot = product.snapshots.find((candidate) => candidate.id === request.snapshotId); - if (!snapshot) return undefined; - if (request.scope === 'snapshot') { - return withSelection(pathname, this.#defaultSnapshotId, request.snapshotId); - } - if (!snapshot.targets.some((target) => target.id === request.targetId)) return undefined; - return withSelection(pathname, this.#defaultSnapshotId, request.snapshotId, request.targetId); - } -} diff --git a/packages/cloudflare-api-site/src/api-server.ts b/packages/cloudflare-api-site/src/api-server.ts deleted file mode 100644 index b3d35459f..000000000 --- a/packages/cloudflare-api-site/src/api-server.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { FernPageSchema } from 'astro-fern'; -import type { FernArtifactFetcher } from 'astro-fern/live'; -import { base } from 'astro:config/server'; -import { - getFernContentCatalog, - getFernOperation, - getFernPage, - getFernRoutePlan, - type FernOperationLoadOptions, -} from 'astro-fern/server'; -import { - ApiRouter, - type ApiOperationSelection, - type ApiPageSelection, - type ApiProductSelection, - type ApiResourceSelection, - type ApiSectionSelection, -} from './api-routing.ts'; -import { requireAssetFetcher } from './artifact-fetcher.ts'; -import { assertToolingRouteNamespace } from './command-reference/routing.ts'; -import { withTerraformTarget } from './terraform.ts'; - -export type ResolvedApiPage = - | ApiProductSelection - | ApiSectionSelection - | ApiResourceSelection - | (ApiOperationSelection & { page: FernPageSchema }); - -let routerPromise: Promise | undefined; - -export function getApiRouter(): Promise { - if (routerPromise) return routerPromise; - const promise = getFernRoutePlan().then((plan) => { - assertToolingRouteNamespace(plan.catalog.products, plan.routing.base, base); - return new ApiRouter(plan, base); - }); - routerPromise = promise; - void promise.catch(() => { - if (routerPromise === promise) routerPromise = undefined; - }); - return promise; -} - -export { getFernContentCatalog }; - -async function operationLoadOptions(request: Request, locals: App.Locals): Promise { - const { env } = await import('cloudflare:workers'); - const fetcher = requireAssetFetcher((env as unknown as { ASSETS?: FernArtifactFetcher }).ASSETS); - return { - request, - fetcher, - ...(locals.fernRequestCache ? { cache: locals.fernRequestCache } : {}), - }; -} - -export async function getApiPage(url: URL, request: Request, locals: App.Locals): Promise { - const selection: ApiPageSelection | undefined = (await getApiRouter()).resolvePage(url); - if (!selection || selection.kind !== 'operation') return selection; - const page = await getFernPage(selection.pageId, await operationLoadOptions(request, locals)); - return { ...selection, page: withTerraformTarget(page) }; -} - -export async function getApiOperation( - url: URL, - request: Request, - locals: App.Locals, - representation: 'human' | 'markdown' = 'human', -): Promise<(ApiOperationSelection & { page: FernPageSchema }) | undefined> { - const router = await getApiRouter(); - const selection = router.resolveOperation(url, representation); - if (!selection) return undefined; - const page = await getFernPage(selection.pageId, await operationLoadOptions(request, locals)); - return { ...selection, page: withTerraformTarget(page) }; -} - -/** Loads one exact operation snapshot for deferred server-island rendering. */ -export async function loadApiOperation(entryId: string, snapshotId: string, request: Request, locals: App.Locals) { - return getFernOperation(entryId, snapshotId, await operationLoadOptions(request, locals)); -} diff --git a/packages/cloudflare-api-site/src/artifact-fetcher.test.ts b/packages/cloudflare-api-site/src/artifact-fetcher.test.ts deleted file mode 100644 index a45a70aad..000000000 --- a/packages/cloudflare-api-site/src/artifact-fetcher.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { requireAssetFetcher } from './artifact-fetcher.ts'; - -test('operation loading requires the Cloudflare ASSETS binding', () => { - assert.throws(() => requireAssetFetcher(undefined), /ASSETS binding is required/); - assert.throws(() => requireAssetFetcher({} as never), /ASSETS binding is required/); - - const assets = { fetch: async () => new Response() }; - assert.equal(requireAssetFetcher(assets), assets); -}); diff --git a/packages/cloudflare-api-site/src/artifact-fetcher.ts b/packages/cloudflare-api-site/src/artifact-fetcher.ts deleted file mode 100644 index 644b50e2e..000000000 --- a/packages/cloudflare-api-site/src/artifact-fetcher.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { FernArtifactFetcher } from 'astro-fern/live'; - -/** Requires the static-asset binding used by this Cloudflare deployment. */ -export function requireAssetFetcher(value: FernArtifactFetcher | undefined): FernArtifactFetcher { - if (!value || typeof value.fetch !== 'function') { - throw new Error('cloudflare-api-site: the Cloudflare ASSETS binding is required to load API operation artifacts'); - } - return value; -} diff --git a/packages/cloudflare-api-site/src/components.ts b/packages/cloudflare-api-site/src/components.ts deleted file mode 100644 index 64e181df4..000000000 --- a/packages/cloudflare-api-site/src/components.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * MDX globals registry — components available inside MDX without `import`. - * Wired via `` in `[...slug].astro`. - * Add new components here as you build (or install) them. - */ - -import { Aside } from './components/ui/aside'; - -export const components = { - Aside, -}; diff --git a/packages/cloudflare-api-site/src/components/ApiIndex.astro b/packages/cloudflare-api-site/src/components/ApiIndex.astro deleted file mode 100644 index d296c3fed..000000000 --- a/packages/cloudflare-api-site/src/components/ApiIndex.astro +++ /dev/null @@ -1,359 +0,0 @@ ---- -import type { - ApiProductSelection, - ApiResourceSelection, - ApiRouter, - ApiSectionSelection, -} from '../api-routing.ts'; -import type { CommandRouter } from '../command-reference/routing.ts'; - -interface Props { - selection: ApiProductSelection | ApiSectionSelection | ApiResourceSelection; - router: ApiRouter; - commandRouter?: CommandRouter; -} - -const { selection, router, commandRouter } = Astro.props; -const productCommands = - selection.kind === 'product' && commandRouter ? commandRouter.commandsForProduct(selection.product.id) : []; -const sections = - selection.kind === 'product' ? selection.sections : selection.kind === 'section' ? [selection.section] : []; -const resources = - selection.kind === 'product' - ? selection.resources - : selection.kind === 'resource' - ? selection.resource.resources - : []; -const operations = - selection.kind === 'product' - ? selection.operations - : selection.kind === 'resource' - ? selection.resource.operations - : []; -const operationHref = (operation: (typeof selection.snapshot.pages)[number]) => - router.operationHref( - selection.product.id, - selection.snapshot.id, - operation.operationId, - 'human', - selection.lang, - operation.placement?.projectionId, - ); ---- - -
- { - selection.kind === 'product' && selection.product.description ? ( -

{selection.product.description}

- ) : null - } -
- { - productCommands.length > 0 ? ( -
-
-

- Developer Tooling -

- {productCommands.length} {productCommands.length === 1 ? 'command' : 'commands'} -
- -
- ) : null - } - { - resources.length > 0 ? ( -
-
-

Resources

- {resources.length} {resources.length === 1 ? 'resource' : 'resources'} -
-
    - {resources.map((resource) => { - const href = router.resourceHref( - selection.product.id, - selection.snapshot.id, - resource.path, - selection.lang, - ); - return href ? ( -
  • - - - {resource.title} - - - -
  • - ) : null; - })} -
-
- ) : null - } - { - operations.length > 0 ? ( -
-
-

Methods

- {operations.length} {operations.length === 1 ? 'operation' : 'operations'} -
- -
- ) : null - } - { - sections.map((section) => { - const sectionHref = router.sectionHref( - selection.product.id, - selection.snapshot.id, - section.id, - selection.lang, - ); - return ( -
- {selection.kind === 'product' ? ( -
-

- {sectionHref ? {section.title} : section.title} -

- {section.operations.length} {section.operations.length === 1 ? 'operation' : 'operations'} -
- ) : null} - -
- ); - }) - } -
-
- - diff --git a/packages/cloudflare-api-site/src/components/ApiOverview.astro b/packages/cloudflare-api-site/src/components/ApiOverview.astro deleted file mode 100644 index 091881ede..000000000 --- a/packages/cloudflare-api-site/src/components/ApiOverview.astro +++ /dev/null @@ -1,37 +0,0 @@ ---- -import { getFernCatalog } from 'astro-fern/server'; - -const { description } = await getFernCatalog(); ---- - -{description?.html ?
: null} - - diff --git a/packages/cloudflare-api-site/src/components/CodeSample.astro b/packages/cloudflare-api-site/src/components/CodeSample.astro deleted file mode 100644 index c821b313e..000000000 --- a/packages/cloudflare-api-site/src/components/CodeSample.astro +++ /dev/null @@ -1,1057 +0,0 @@ ---- -import type { FernExecutionTargetSchema } from 'astro-fern'; -import { - classifyResponseStatus, - isJsonMediaType, - preferredResponse, - preferredResponseRepresentation, - type JsonValueSchema, - type OperationCodeSampleSchema, - type OperationExampleSchema, - type RenderedOperationResponseSchema, - type RenderedResponseExampleSchema, -} from 'astro-fern/content'; -import { terraformDeclarationLabel, type TerraformOperationData } from '../terraform.ts'; -import { LEGACY_API_VERSION } from '../version.ts'; -import CopyableCode from './CopyableCode.astro'; - -interface Props { - targets: FernExecutionTargetSchema[]; - selectedTarget?: string; - snapshotId: string; - preserveVersionQuery?: boolean; - httpMethod: string; - examples: OperationExampleSchema[]; - responses: RenderedOperationResponseSchema[]; - terraform?: TerraformOperationData; -} - -type AssociatedRequestPart = [string, JsonValueSchema | undefined, string | undefined]; - -// Render the current query-selected target, falling back to the first configured target. -const { - targets, - selectedTarget, - snapshotId, - preserveVersionQuery = false, - httpMethod, - examples, - responses, - terraform, -} = Astro.props; -const current = targets.find((target) => target.id === selectedTarget) ?? targets[0]; -const preferredStatus = preferredResponse(responses); -const preferredStatusIndex = preferredStatus ? responses.indexOf(preferredStatus) : 0; -const responseViews = responses.map((response) => { - const representation = preferredResponseRepresentation(response.representations); - return { - response, - preferredRepresentationIndex: representation ? response.representations.indexOf(representation) : 0, - }; -}); - -function responseSyntax(mediaType: string): string { - if (isJsonMediaType(mediaType)) return 'json'; - const essence = mediaType.split(';', 1)[0]?.trim().toLowerCase(); - if (essence === 'text/html' || essence?.endsWith('+xml') || essence?.includes('/xml')) return 'markup'; - if (essence === 'text/css') return 'css'; - if (essence?.includes('javascript')) return 'javascript'; - return 'text'; -} - -function formatResponseExample(value: JsonValueSchema, mediaType: string): { code: string; syntax: string } { - if (typeof value === 'string' && !isJsonMediaType(mediaType)) { - return { code: value, syntax: responseSyntax(mediaType) }; - } - return { code: JSON.stringify(value, null, 2) ?? 'null', syntax: 'json' }; -} - -function exampleTitle(example: RenderedResponseExampleSchema, index: number): string { - return example.summary ?? example.name ?? `Example ${index + 1}`; -} - -function safeExternalHref(value: string): string | undefined { - try { - const url = new URL(value); - return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : undefined; - } catch { - return undefined; - } -} - -function jsonExample(value: JsonValueSchema): string { - return JSON.stringify(value, null, 2) ?? 'null'; -} - -function codeSampleLabel(sample: OperationCodeSampleSchema): string { - if (sample.kind === 'language') return sample.language; - if (sample.sdk === 'csharp') return 'C#'; - if (sample.sdk === 'go') return 'Go'; - if (sample.sdk === 'java') return 'Java'; - if (sample.sdk === 'javascript') return 'JavaScript'; - if (sample.sdk === 'ruby') return 'Ruby'; - if (sample.sdk === 'typescript') return 'TypeScript'; - return sample.sdk; -} - -function codeSampleSyntax(sample: OperationCodeSampleSchema): string { - const language = sample.kind === 'sdk' ? sample.sdk : sample.language.toLowerCase(); - const aliases: Record = { - csharp: 'csharp', - 'c#': 'csharp', - curl: 'bash', - go: 'go', - java: 'java', - javascript: 'javascript', - js: 'javascript', - python: 'python', - ruby: 'ruby', - shell: 'bash', - typescript: 'typescript', - ts: 'typescript', - }; - return aliases[language] ?? 'text'; -} ---- - -{ - current || examples.length > 0 || responses.length > 0 ? ( - - {examples.map((example, index) => { - const requestParts: AssociatedRequestPart[] = [ - ['Path parameters', example.request?.pathParameters, undefined], - ['Query parameters', example.request?.queryParameters, undefined], - ['Headers', example.request?.headers, undefined], - ['Body', example.request?.body, example.request?.bodyMediaType], - ]; - const populatedRequestParts = requestParts.filter( - (part): part is [string, JsonValueSchema, string | undefined] => part[1] !== undefined, - ); - return ( -
-
-
{example.name ?? `Example ${index + 1}`}
- Authored example -
- {populatedRequestParts.length > 0 ? ( -
-

Request

- {populatedRequestParts.map(([label, value, mediaType]) => { - const formatted = mediaType ? formatResponseExample(value, mediaType) : undefined; - return ( -
-

{label}

- -
- ); - })} -
- ) : null} - {example.codeSamples.map((sample) => ( -
-

{codeSampleLabel(sample)} custom sample

- {sample.kind === 'language' && sample.install ? ( -
-

Install

- -
- ) : null} - -
- ))} - {example.response - ? (() => { - const formatted = formatResponseExample( - example.response.body, - example.response.mediaType ?? 'application/json', - ); - return ( -
-

Response

- -
- ); - })() - : null} - {populatedRequestParts.length === 0 && example.codeSamples.length === 0 && !example.response ? ( -

No values are declared for this example.

- ) : null} -
- ); - })} - {current ? ( -
0 || undefined}> -
-
- {current.id === 'terraform' ? ( - Terraform - ) : ( - <> - {httpMethod} - Request - - )} -
-
- - {preserveVersionQuery && } -
-
-
- {current.id === 'terraform' ? ( - terraform ? ( -
- {terraform.declarations.map((declaration) => ( -
-

{terraformDeclarationLabel(declaration)}

- {declaration.example ? ( - - ) : ( -

No HCL example is available for this declaration.

- )} - {declaration.importExample ? ( - <> -

Import

- - - ) : null} -
- ))} -
- ) : ( -

This operation is not implemented in Terraform.

- ) - ) : current.code ? ( - - ) : ( -

{current.label} samples are coming soon.

- )} -
- {current.id !== 'terraform' && snapshotId !== LEGACY_API_VERSION && ( -

- Samples are version-neutral. Version selection currently changes documentation only. -

- )} -
- ) : null} - {responses.length > 0 ? ( -
-
-
Responses
-
- -
- {responseViews.map(({ response }, index) => { - const selected = index === preferredStatusIndex; - return ( - - ); - })} -
-
- {responseViews.map(({ response, preferredRepresentationIndex }, responseIndex) => ( -
-

Response {response.status}

- {response.description.html ? ( -
- ) : null} - {response.representations.length > 1 ? ( - - ) : response.representations[0] ? ( -

{response.representations[0].mediaType}

- ) : null} - {response.representations.length === 0 ? ( -

No response body is declared for this status.

- ) : ( - response.representations.map((representation, representationIndex) => ( -
- {response.representations.length > 1 ? ( -

- {representation.mediaType} -

- ) : null} - {representation.examples.length > 0 ? ( -
- {representation.examples.map((example, exampleIndex) => { - const formatted = - example.value !== undefined - ? formatResponseExample(example.value, representation.mediaType) - : undefined; - const externalHref = example.externalValue - ? safeExternalHref(example.externalValue) - : undefined; - return ( -
-
- {exampleTitle(example, exampleIndex)} - {example.summary && example.name ? {example.name} : null} -
- {example.description?.html ? ( -
- ) : null} - {example.externalValue ? ( -

- {externalHref ? ( - Open external example - ) : ( - {example.externalValue} - )} -

- ) : null} - {formatted ? ( -
- -
- ) : null} -
- ); - })} -
- ) : representation.generatedExample !== undefined ? ( -
-
- Example response - Generated from schema -
-
- -
-
- ) : ( -

No inline example is declared for this representation.

- )} -
- )) - )} -
- ))} -
-
-
- ) : null} -
- ) : null -} - - - - diff --git a/packages/cloudflare-api-site/src/components/CopyableCode.astro b/packages/cloudflare-api-site/src/components/CopyableCode.astro deleted file mode 100644 index 2f06c8d75..000000000 --- a/packages/cloudflare-api-site/src/components/CopyableCode.astro +++ /dev/null @@ -1,48 +0,0 @@ ---- -import { Prism } from '@astrojs/prism'; -import CopyButton from './CopyButton.astro'; - -interface Props { - code: string; - lang: string; - label?: string; - class?: string; -} - -const { code, lang, label = 'Copy code', class: className } = Astro.props; ---- - -
- - -
- - diff --git a/packages/cloudflare-api-site/src/components/Operation.astro b/packages/cloudflare-api-site/src/components/Operation.astro deleted file mode 100644 index 08f5bdbdb..000000000 --- a/packages/cloudflare-api-site/src/components/Operation.astro +++ /dev/null @@ -1,371 +0,0 @@ ---- -import { Aside } from '@/components/ui/aside'; -import type { FernPageSchema } from 'astro-fern'; -import { DEFER_THRESHOLD, nodeCount, operationView, responseSummary, topLevelNodes } from '../operation-sections.ts'; -import { getTerraformOperationData } from '../terraform.ts'; -import CodeSample from './CodeSample.astro'; -import SchemaSectionIsland from './SchemaSectionIsland.astro'; -import SchemaTree from './SchemaTree.astro'; -import TerraformDeclarations from './TerraformDeclarations.astro'; - -interface Props { - page: FernPageSchema; - selectedTarget?: string; -} - -const { page, selectedTarget } = Astro.props; -const { operation: op } = page; -const view = operationView(op); -const responseNodes = topLevelNodes(view.response); -const returnsSummary = responseSummary(view.response, view.responseIsArray); -const showResponseSchemaSource = - op.responses.length > 1 || (view.responseStatus?.representations.length ?? 0) > 1; -const requestMediaTypes = op.requestBody?.representations.map(({ mediaType }) => mediaType) ?? []; -const requestContentTypeLabel = requestMediaTypes.length === 1 ? 'Content type' : 'Content types'; -const deprecationMessage = op.deprecated ? op.availability?.message : undefined; -const availabilityMessage = !op.deprecated ? op.availability?.message : undefined; -const terraform = getTerraformOperationData(op); -const showTerraform = selectedTarget === 'terraform'; ---- - -
-
- { - op.deprecated ? ( - - ) : null - } - { - availabilityMessage ? ( - - ) : null - } - { - op.requireConfirmation ? ( - - ) : null - } - {op.description.html ?
: null} -
- - { - showTerraform ? ( -
- {terraform ? ( - terraform.declarations.map((declaration, declarationIndex) => ( -
-
- -
- -
- )) - ) : ( -
-
- -
- -
- )} -
- ) : ( - <> - - -
- { - view.fieldSections.map((section) => ( -
-

- {section.title} -

- {section.section === 'body' && op.requestBody ? ( -
- {op.requestBody.description?.html ? ( -
- ) : null} -
-
-
Required
-
{op.requestBody.required ? 'Yes' : 'No'}
-
-
-
{requestContentTypeLabel}
-
- {requestMediaTypes.length > 0 ? ( -
    - {requestMediaTypes.map((mediaType) =>
  • {mediaType}
  • )} -
- ) : ( - 'None declared' - )} -
-
- {requestMediaTypes.length > 1 && view.requestRepresentation?.schema ? ( -
-
Schema shown for
-
{view.requestRepresentation.mediaType}
-
- ) : null} -
-
- ) : null} - {section.nodes.length === 0 ? null : nodeCount(section.nodes) > DEFER_THRESHOLD ? ( - -

- Loading {section.title.toLowerCase()}… -

-
- ) : ( - - )} -
- )) - } - - { - view.response ? ( -
-

Returns

- {returnsSummary ?

{returnsSummary}

: null} - {showResponseSchemaSource && view.responseStatus && view.responseRepresentation ? ( -

- Schema shown for {view.responseStatus.status} ·{' '} - {view.responseRepresentation.mediaType} -

- ) : null} - {nodeCount(responseNodes) > DEFER_THRESHOLD ? ( - -

- Loading response fields… -

-
- ) : ( - - )} -
- ) : null - } -
- - ) - } -
- - diff --git a/packages/cloudflare-api-site/src/components/PageTitle.astro b/packages/cloudflare-api-site/src/components/PageTitle.astro deleted file mode 100644 index 582049f4b..000000000 --- a/packages/cloudflare-api-site/src/components/PageTitle.astro +++ /dev/null @@ -1,289 +0,0 @@ ---- -import { getApiPage, getApiRouter } from '../api-server.ts'; - -const router = await getApiRouter(); -const resolved = await getApiPage(Astro.url, Astro.request, Astro.locals); -const page = resolved?.kind === 'operation' ? resolved.page : undefined; -const op = page?.operation; -const title = - resolved?.kind === 'product' - ? resolved.product.title - : resolved?.kind === 'section' - ? resolved.section.title - : resolved?.kind === 'resource' - ? resolved.resource.title - : op?.title; -const breadcrumbs = resolved ? router.breadcrumbs(resolved) : []; -const markdownHref = - resolved && page - ? router.operationHref( - page.product.id, - page.snapshot.id, - page.operation.operationId, - 'markdown', - resolved.lang, - page.operation.placement?.projectionId, - ) - : undefined; -const availabilityLabels: Record = { - alpha: 'Alpha', - beta: 'Beta', - preview: 'Preview', - 'generally-available': 'Generally Available', - deprecated: 'Deprecated', - legacy: 'Legacy', -}; -const availabilityStatus = op?.deprecated ? 'deprecated' : op?.availability?.status; -const availability = availabilityStatus - ? (availabilityLabels[availabilityStatus] ?? availabilityStatus.replaceAll('-', ' ')) - : undefined; ---- - -{ - resolved && title ? ( -
-
- - {markdownHref ? ( - - View as Markdown - - ) : null} -
-
-

{title}

- { - op && availability ? ( - - {availability} - - ) : null - } -
- {op ? ( -
- {op.httpMethod} - {op.path} -
- ) : null} -
- ) : null -} - - diff --git a/packages/cloudflare-api-site/src/components/SchemaSectionIsland.astro b/packages/cloudflare-api-site/src/components/SchemaSectionIsland.astro deleted file mode 100644 index e80297154..000000000 --- a/packages/cloudflare-api-site/src/components/SchemaSectionIsland.astro +++ /dev/null @@ -1,28 +0,0 @@ ---- -/** - * Server island: renders one operation section's schema tree on demand. - * - * Used (via `server:defer`) only for very large sections (big `oneOf` unions), - * so their thousands of rows stay out of the initial HTML. Props are tiny - * (operation entry ID + snapshot ID + section); the heavy tree is read directly - * from the exact live operation snapshot rather than rebuilding the source. - */ -import { loadApiOperation } from '../api-server.ts'; -import { type SectionId, sectionNodes } from '../operation-sections.ts'; -import SchemaTree from './SchemaTree.astro'; - -interface Props { - entryId: string; - snapshotId: string; - section: SectionId; - context: 'request' | 'response'; -} - -const { entryId, snapshotId, section, context } = Astro.props; -const nodes = sectionNodes( - (await loadApiOperation(entryId, snapshotId, Astro.request, Astro.locals)).operation, - section, -); ---- - - diff --git a/packages/cloudflare-api-site/src/components/SchemaTree.astro b/packages/cloudflare-api-site/src/components/SchemaTree.astro deleted file mode 100644 index e59fb41a2..000000000 --- a/packages/cloudflare-api-site/src/components/SchemaTree.astro +++ /dev/null @@ -1,235 +0,0 @@ ---- -import type { RenderedSchemaNodeSchema } from 'astro-fern/content'; -import { fieldTypeLabel } from '../operation-sections.ts'; -import SchemaTree from './SchemaTree.astro'; - -interface Props { - nodes: RenderedSchemaNodeSchema[]; - /** - * Request fields show required/optional (the caller supplies them). Response - * fields only show "optional" for maybe-absent fields — a response field is - * never something the caller "must" provide, so no "required" badge. - */ - context?: 'request' | 'response'; -} - -const { nodes, context = 'request' } = Astro.props; - -interface Expansion { - label: string; - nodes: RenderedSchemaNodeSchema[]; -} - -/** What (if anything) to reveal under a node: object props, union variants, or array-element structure. */ -function expansionOf(n: RenderedSchemaNodeSchema): Expansion | null { - if (n.children && n.children.length > 0) { - return { label: `${n.children.length} ${n.children.length === 1 ? 'property' : 'properties'}`, nodes: n.children }; - } - if (n.variants && n.variants.length > 0) { - return { label: `${n.variants.length} ${n.variants.length === 1 ? 'variant' : 'variants'}`, nodes: n.variants }; - } - const it = n.items; - if (it?.children && it.children.length > 0) { - return { label: `array — ${it.children.length} ${it.children.length === 1 ? 'property' : 'properties'}`, nodes: it.children }; - } - if (it?.variants && it.variants.length > 0) { - return { label: `array — ${it.variants.length} variants`, nodes: it.variants }; - } - return null; -} - -/** Requirement badge, context-aware. Responses never show "required". */ -function requirement(n: RenderedSchemaNodeSchema): { text: string; cls: string } | null { - if (!n.name) return null; - if (context === 'response') return n.required ? null : { text: 'optional', cls: 'forge-optional' }; - return n.required ? { text: 'required', cls: 'forge-required' } : { text: 'optional', cls: 'forge-optional' }; -} ---- - -
    - { - nodes.map((n) => { - const exp = expansionOf(n); - const req = requirement(n); - const typeText = fieldTypeLabel(n); - return ( -
  • -
    - {n.name ? {n.name} : null} - {!n.name && n.title ? {n.title} : null} - {typeText ? {typeText} : null} - {req ? {req.text} : null} - {n.deprecated ? deprecated : null} -
    - {n.enumValues && n.enumValueMetadata?.length ? ( -
    - Allowed values: -
      - {n.enumValues.map((value) => { - const metadata = n.enumValueMetadata?.find((entry) => Object.is(entry.value, value)); - return ( -
    • -
      - {String(value)} - {metadata?.deprecated ? deprecated : null} -
      - {metadata?.description ?
      : null} -
    • - ); - })} -
    -
    - ) : n.enumValues ? ( -
    - Allowed values:{' '} - {n.enumValues.map((value, index) => ( - <> - {index > 0 ? ', ' : null} - {String(value)} - - ))} -
    - ) : null} - {n.default !== undefined ?
    Default: {JSON.stringify(n.default)}
    : null} - {n.description ?
    : null} - {exp ? ( -
    - Show {exp.label} - -
    - ) : null} -
  • - ); - }) - } -
- - diff --git a/packages/cloudflare-api-site/src/components/VersionSelect.astro b/packages/cloudflare-api-site/src/components/VersionSelect.astro deleted file mode 100644 index b46ea5f21..000000000 --- a/packages/cloudflare-api-site/src/components/VersionSelect.astro +++ /dev/null @@ -1,150 +0,0 @@ ---- -import Icon from '@cloudflare/nimbus-docs/components/Icon.astro'; -import { getApiRouter } from '../api-server.ts'; - -interface Props { - /** - * `header` is the compact control shown in the header from the `md` breakpoint up; - * `drawer` fills the top of the mobile navigation drawer. - */ - variant?: 'header' | 'drawer'; -} - -const { variant = 'header' } = Astro.props; - -const router = await getApiRouter(); -const selection = router.resolvePage(Astro.url); -const availableVersions = selection - ? router.snapshotOptions(selection) - : router.isSitePath(Astro.url.pathname) - ? router.siteSnapshotOptions(Astro.url) - : []; ---- - -{ - availableVersions.length > 0 && ( - - - - ) -} - - - - diff --git a/packages/cloudflare-api-site/src/operation-sections.test.ts b/packages/cloudflare-api-site/src/operation-sections.test.ts deleted file mode 100644 index 03db240d1..000000000 --- a/packages/cloudflare-api-site/src/operation-sections.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import type { RenderedOperationSchema } from 'astro-fern/content'; -import { operationView, sectionNodes } from './operation-sections.ts'; - -const richText = (markdown: string) => ({ markdown, html: `

${markdown}

` }); - -test('selects and unwraps the preferred response schema for Returns', () => { - const operation: RenderedOperationSchema = { - operationId: 'create-widget', - slug: 'create-widget', - title: 'Create widget', - httpMethod: 'POST', - path: '/widgets', - description: richText('Creates a widget.'), - deprecated: false, - extensions: {}, - pathParams: [], - queryParams: [], - requestBody: null, - responses: [ - { - status: '4XX', - description: richText('Request failed.'), - representations: [], - }, - { - status: '200', - description: richText('Request succeeded.'), - representations: [ - { - mediaType: 'application/json', - payloadKey: 'result', - examples: [], - schema: { - type: 'object', - required: false, - children: [ - { - name: 'result', - type: 'widget[]', - required: true, - items: { - type: 'widget', - required: false, - children: [{ name: 'id', type: 'string', required: true }], - }, - }, - ], - }, - }, - ], - }, - ], - }; - - const view = operationView(operation); - assert.equal(view.responseStatus?.status, '200'); - assert.equal(view.responseRepresentation?.mediaType, 'application/json'); - assert.equal(view.responseIsArray, true); - assert.equal(view.response?.type, 'widget'); - assert.deepEqual( - sectionNodes(operation, 'response').map(({ name }) => name), - ['id'], - ); -}); diff --git a/packages/cloudflare-api-site/src/operation-sections.ts b/packages/cloudflare-api-site/src/operation-sections.ts deleted file mode 100644 index 752cc117f..000000000 --- a/packages/cloudflare-api-site/src/operation-sections.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Single source of truth for an operation page's sections, so the static - * render and the server-island render agree on which section is which. - */ -import { - preferredResponse, - preferredResponseRepresentation, - preferredRequestRepresentation, - responsePayloadSchema, - type RenderedOperationResponseSchema, - type RenderedOperationSchema, - type RenderedRequestRepresentationSchema, - type RenderedResponseRepresentationSchema, - type RenderedSchemaNodeSchema, -} from 'astro-fern/content'; - -export type SectionId = 'path' | 'query' | 'body' | 'response'; - -export interface FieldSection { - section: SectionId; - slug: string; - title: string; - nodes: RenderedSchemaNodeSchema[]; -} - -export interface OperationView { - fieldSections: FieldSection[]; - responseSlug: string; - response: RenderedSchemaNodeSchema | null; - responseIsArray: boolean; - responseStatus: RenderedOperationResponseSchema | null; - responseRepresentation: RenderedResponseRepresentationSchema | null; - requestRepresentation: RenderedRequestRepresentationSchema | null; -} - -/** - * Top-level rows for a body/response root: an object's fields, a union's - * variants, else the node itself. Unwrapping a union root avoids rendering a - * nameless placeholder row (the "unknown" the union label would sit on) — the - * variants are the meaningful content, and `responseSummary` supplies the - * "one of N" framing above them. - */ -export function topLevelNodes(node: RenderedSchemaNodeSchema | null): RenderedSchemaNodeSchema[] { - if (!node) return []; - if (node.children && node.children.length > 0) return node.children; - if (node.variants && node.variants.length > 0) return node.variants; - return [node]; -} - -/** - * Human lead-in shown under the "Returns" heading. Frames arrays and unions - * ("Array of X", "Array — each item is one of N types:", "One of N types:") and - * returns null for a plain object/scalar root, whose fields speak for themselves. - */ -export function responseSummary(node: RenderedSchemaNodeSchema | null, isArray: boolean): string | null { - if (!node) return null; - const variantCount = node.variants?.length ?? 0; - const isUnion = variantCount > 0 && !(node.children && node.children.length > 0); - if (isArray) { - if (isUnion) return `Array — each item is one of ${variantCount} variants:`; - const t = cleanTypeName(node.type); - return t === 'unknown' ? 'Array' : `Array of ${t}`; - } - if (isUnion) return `One of ${variantCount} variants:`; - return null; -} - -/** - * Type label shown on a field row. Unions — and arrays of unions — read as - * "one of N variants" rather than the raw "one of N" / "one of N[]" the - * resolver stores; the expansion below the row ("Show array — N variants") - * already conveys array-ness. A residual untyped node returns null so the - * badge is omitted entirely instead of printing "unknown". - */ -export function fieldTypeLabel(n: RenderedSchemaNodeSchema): string | null { - const variants = n.variants ?? n.items?.variants; - if (variants && variants.length > 0) return `one of ${variants.length} variants`; - const t = cleanTypeName(n.type); - return t === 'unknown' ? null : t; -} - -// Memoize per operation object so deferred section helpers reuse the same walk. -const viewCache = new WeakMap(); - -export function operationView(op: RenderedOperationSchema): OperationView { - const cached = viewCache.get(op); - if (cached) return cached; - const fieldSections: FieldSection[] = []; - if (op.pathParams.length > 0) { - fieldSections.push({ section: 'path', slug: 'path-parameters', title: 'Path parameters', nodes: op.pathParams }); - } - if (op.queryParams.length > 0) { - fieldSections.push({ - section: 'query', - slug: 'query-parameters', - title: 'Query parameters', - nodes: op.queryParams, - }); - } - const requestRepresentation = preferredRequestRepresentation(op.requestBody?.representations ?? []) ?? null; - if (op.requestBody) { - fieldSections.push({ - section: 'body', - slug: 'body-parameters', - title: 'Request body', - nodes: topLevelNodes(requestRepresentation?.schema ?? null), - }); - } - - const responseStatus = preferredResponse(op.responses) ?? null; - const responseRepresentation = preferredResponseRepresentation(responseStatus?.representations ?? []) ?? null; - const responsePayload = responseRepresentation - ? responsePayloadSchema(responseRepresentation) - : { schema: null, isArray: false }; - - const view: OperationView = { - fieldSections, - responseSlug: 'returns', - response: responsePayload.schema, - responseIsArray: responsePayload.isArray, - responseStatus, - responseRepresentation, - requestRepresentation, - }; - viewCache.set(op, view); - return view; -} - -/** - * Strip the internal api-prefix from a schema name for display, - * e.g. "dns-records_dns-record-response" -> "dns-record-response". Primitive - * types (no underscore) pass through unchanged. - */ -export function cleanTypeName(name: string | undefined): string { - if (typeof name !== 'string' || name.length === 0) return 'unknown'; - const i = name.indexOf('_'); - return i > 0 ? name.slice(i + 1) : name; -} - -//#region Server-island support - -/** Sections with more than this many total schema nodes are deferred to a server island. */ -export const DEFER_THRESHOLD = 150; - -/** - * Total node count across the given subtrees. Iterative (explicit stack) rather - * than recursive: schema trees for large `oneOf` unions can nest deeply, so this - * avoids any call-stack growth proportional to schema depth. - */ -export function nodeCount(nodes: RenderedSchemaNodeSchema[]): number { - let count = 0; - const stack: RenderedSchemaNodeSchema[] = [...nodes]; - while (stack.length > 0) { - const n = stack.pop(); - if (!n) break; - count++; - if (n.children) for (const child of n.children) stack.push(child); - if (n.items) stack.push(n.items); - if (n.variants) for (const v of n.variants) stack.push(v); - } - return count; -} - -/** The top-level nodes for a given section — shared by the static render and the island. */ -export function sectionNodes(op: RenderedOperationSchema, section: SectionId): RenderedSchemaNodeSchema[] { - switch (section) { - case 'path': - return op.pathParams; - case 'query': - return op.queryParams; - case 'body': - return topLevelNodes(operationView(op).requestRepresentation?.schema ?? null); - case 'response': - return topLevelNodes(operationView(op).response); - } -} - -//#endregion diff --git a/packages/cloudflare-api-site/src/page-sidebar.ts b/packages/cloudflare-api-site/src/page-sidebar.ts deleted file mode 100644 index c5f6970f8..000000000 --- a/packages/cloudflare-api-site/src/page-sidebar.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { stripBase } from '@cloudflare/nimbus-docs/runtime'; -import type { SidebarItem } from '@cloudflare/nimbus-docs/types'; -import { getApiRouter } from './api-server.ts'; -import { getCommandRouter } from './command-reference/server.ts'; -import { forgeRouteSidebar } from './sidebar.ts'; - -/** Nimbus sidebar components add the deployment base, so items carry unbased paths. */ -function unbased(items: SidebarItem[]): SidebarItem[] { - return items.map((item) => - item.type === 'group' - ? { ...item, children: unbased(item.children) } - : item.type === 'link' - ? { ...item, href: stripBase(item.href, import.meta.env.BASE_URL) } - : item, - ); -} - -/** Builds the request-scoped sidebar for the API version and execution target selected by `url`. */ -export async function getPageSidebar(url: URL): Promise { - const [router, commandRouter] = await Promise.all([getApiRouter(), getCommandRouter()]); - const selection = router.resolvePage(url); - const siteSelection = router.resolveSiteSelection(url); - return unbased( - forgeRouteSidebar( - router, - commandRouter, - selection?.snapshot.id ?? siteSelection?.snapshot.id ?? '', - selection?.lang ?? siteSelection?.lang, - url.pathname, - ), - ); -} diff --git a/packages/cloudflare-api-site/src/sidebar.test.ts b/packages/cloudflare-api-site/src/sidebar.test.ts deleted file mode 100644 index de193d4aa..000000000 --- a/packages/cloudflare-api-site/src/sidebar.test.ts +++ /dev/null @@ -1,437 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import type { FernRoutePlan } from 'astro-fern'; -import { ApiRouter } from './api-routing.ts'; -import { cfCommandCatalog } from './command-reference/cf-commands.ts'; -import { CommandRouter } from './command-reference/routing.ts'; -import { forgeRouteSidebar, forgeSidebar } from './sidebar.ts'; - -const plan: Pick = { - catalog: { - snapshots: [ - { - id: 'current', - slug: 'current', - label: 'Current', - default: true, - targets: [{ id: 'python', kind: 'sdk', label: 'Python', language: 'python' }], - }, - ], - products: [ - { - id: 'reference', - slug: 'reference', - title: 'Reference', - description: '', - snapshots: [ - { - id: 'current', - slug: 'current', - label: 'Current', - default: true, - targets: [{ id: 'python', kind: 'sdk', label: 'Python', language: 'python' }], - pages: [ - { - id: 'page:list-records', - entryId: 'operation:list-records', - operationId: 'list-records', - slug: 'sections/records/operations/list-records', - pathname: '/api/reference/sections/records/operations/list-records/', - section: { id: 'records', tag: 'Human DNS Section', title: 'DNS Records' }, - title: 'List records', - description: '', - httpMethod: 'get', - }, - ], - }, - ], - }, - ], - }, - routing: { base: '/api', target: 'hash' }, - agents: { injectRoutes: false, markdown: false, llms: false, directive: false }, -}; -const router = new ApiRouter(plan, '/api'); -const commandRouter = new CommandRouter(cfCommandCatalog, { base: '/api' }); - -test('sidebar groups composed catalog pages by their OpenAPI-tag section', () => { - assert.deepEqual(forgeSidebar(router, 'current'), [ - { - label: 'Reference', - collapsed: true, - items: [ - { - label: 'Overview', - link: '/api/reference/', - }, - { - label: 'DNS Records', - collapsed: true, - items: [ - { - label: 'Overview', - link: '/api/reference/sections/records/', - }, - { - label: 'List records', - link: '/api/reference/sections/records/operations/list-records/', - }, - ], - }, - ], - }, - ]); -}); - -test('sidebar renders SDK resources recursively and omits ownership tags', () => { - const sdkPlan: Pick = { - catalog: { - snapshots: [{ id: 'current', slug: 'current', label: 'Current', default: true, targets: [] }], - products: [ - { - id: 'zero-trust', - slug: 'zero-trust', - title: 'Zero Trust', - description: '', - snapshots: [ - { - id: 'current', - slug: 'current', - label: 'Current', - default: true, - targets: [], - pages: [ - { - id: 'page:list-profiles', - entryId: 'operation:list-profiles:primary', - operationId: 'profiles_list', - slug: 'devices/ip-profiles/methods/list', - pathname: '/api/zero-trust/devices/ip-profiles/methods/list/', - placement: { - projectionId: 'primary', - resourcePath: [ - { id: 'devices', slug: 'devices', title: 'Devices' }, - { id: 'ip_profiles', slug: 'ip-profiles', title: 'IP Profiles' }, - ], - method: { id: 'list', slug: 'list' }, - }, - section: { id: 'internal', tag: 'Internal ownership', title: 'Internal ownership' }, - title: 'List IP profiles', - description: '', - httpMethod: 'get', - }, - ], - }, - ], - }, - ], - }, - routing: { base: '/api', target: 'hash' }, - agents: { injectRoutes: false, markdown: false, llms: false, directive: false }, - }; - const sdkRouter = new ApiRouter(sdkPlan, '/api'); - - assert.deepEqual(forgeSidebar(sdkRouter, 'current'), [ - { - label: 'Zero Trust', - collapsed: true, - items: [ - { label: 'Overview', link: '/api/zero-trust/' }, - { - label: 'Devices', - collapsed: true, - items: [ - { label: 'Overview', link: '/api/zero-trust/devices/' }, - { - label: 'IP Profiles', - collapsed: true, - items: [ - { - label: 'Overview', - link: '/api/zero-trust/devices/ip-profiles/', - }, - { - label: 'List IP profiles', - link: '/api/zero-trust/devices/ip-profiles/methods/list/', - }, - ], - }, - ], - }, - ], - }, - ]); -}); - -test('sidebar omits products unavailable in the selected snapshot', () => { - const sparsePlan = structuredClone(plan); - const currentSnapshot = sparsePlan.catalog.products[0]?.snapshots[0]; - assert.ok(currentSnapshot); - sparsePlan.catalog.snapshots.unshift({ - id: 'legacy', - slug: 'legacy', - label: 'Legacy', - default: false, - targets: [], - }); - sparsePlan.catalog.products.push({ - id: 'retired', - slug: 'retired', - title: 'Retired', - description: '', - snapshots: [{ ...currentSnapshot, id: 'legacy', slug: 'legacy', label: 'Legacy', default: false }], - }); - const sparseRouter = new ApiRouter(sparsePlan); - - assert.deepEqual( - forgeSidebar(sparseRouter, 'current').map(({ label }) => label), - ['Reference'], - ); - assert.deepEqual( - forgeSidebar(sparseRouter, 'legacy').map(({ label }) => label), - ['Retired'], - ); -}); - -test('sidebar preserves the selected execution target when its route exists', () => { - const sidebar = forgeSidebar(router, 'current', 'python'); - const product = sidebar[0]; - assert.ok(product && 'items' in product); - assert.deepEqual(product.items[0], { label: 'Overview', link: '/api/reference/?lang=python' }); - const section = product.items[1]; - assert.ok(section && 'items' in section); - assert.deepEqual(section.items, [ - { - label: 'Overview', - link: '/api/reference/sections/records/?lang=python', - }, - { - label: 'List records', - link: '/api/reference/sections/records/operations/list-records/?lang=python', - }, - ]); -}); - -test('sidebar overview preserves the selected execution target', () => { - const sidebar = forgeRouteSidebar(router, commandRouter, 'current', 'python', '/api/reference/'); - assert.equal(sidebar[0]?.type, 'link'); - assert.equal(sidebar[0]?.type === 'link' ? sidebar[0].href : undefined, '/api/?lang=python'); -}); - -test('route sidebar exposes the complete navigation on non-operation pages', () => { - const sidebar = forgeRouteSidebar(router, commandRouter, 'current', undefined, '/api/'); - - assert.equal(sidebar[0]?.type, 'link'); - assert.equal(sidebar[0]?.isCurrent, true); - assert.equal(sidebar[1]?.type, 'group'); - assert.deepEqual(sidebar[1]?.type === 'group' ? sidebar[1].children.map((entry) => entry.label) : [], [ - 'Overview', - 'Agent Context', - 'Ai', - 'Auth', - 'Build', - 'Complete', - 'D1', - 'Deploy', - 'Dev', - 'Schema', - 'Workers', - ]); - assert.equal(sidebar[2]?.type, 'group'); - assert.equal(sidebar[2]?.label, 'Reference'); -}); - -test('route sidebar moves matching command namespaces under product developer tooling', () => { - const overlapPlan = structuredClone(plan); - const snapshot = overlapPlan.catalog.products[0]?.snapshots[0]; - assert.ok(snapshot); - overlapPlan.catalog.products.push( - ...[ - { id: 'ai', title: 'AI' }, - { id: 'd1', title: 'D1' }, - { id: 'workers', title: 'Workers' }, - ].map(({ id, title }) => ({ - id, - slug: id, - title, - description: '', - snapshots: [{ ...snapshot, pages: [] }], - })), - ); - const overlapRouter = new ApiRouter(overlapPlan, '/api'); - const sidebar = forgeRouteSidebar( - overlapRouter, - commandRouter, - 'current', - undefined, - '/api/workers/tooling/triggers/deploy/', - ); - const globalTooling = sidebar.find((entry) => entry.label === 'Developer Tooling'); - assert.deepEqual(globalTooling?.type === 'group' ? globalTooling.children.map((entry) => entry.label) : [], [ - 'Overview', - 'Agent Context', - 'Auth', - 'Build', - 'Complete', - 'Deploy', - 'Dev', - 'Schema', - ]); - - const ai = sidebar.find((entry) => entry.label === 'AI'); - assert.deepEqual(ai?.type === 'group' ? ai.children.map((entry) => entry.label) : [], [ - 'Overview', - 'Developer Tooling', - ]); - const aiTooling = ai?.type === 'group' ? ai.children[1] : undefined; - assert.deepEqual(aiTooling?.type === 'group' ? aiTooling.children.map((entry) => entry.label) : [], ['Run']); - - const d1 = sidebar.find((entry) => entry.label === 'D1'); - assert.deepEqual(d1?.type === 'group' ? d1.children.map((entry) => entry.label) : [], [ - 'Overview', - 'Developer Tooling', - ]); - const d1Tooling = d1?.type === 'group' ? d1.children[1] : undefined; - const migrations = d1Tooling?.type === 'group' ? d1Tooling.children[0] : undefined; - assert.equal(migrations?.label, 'Migrations'); - assert.deepEqual(migrations?.type === 'group' ? migrations.children.map((entry) => entry.label) : [], [ - 'Apply', - 'Create', - 'List', - ]); - - const workers = sidebar.find((entry) => entry.label === 'Workers'); - const workersTooling = workers?.type === 'group' ? workers.children[1] : undefined; - const triggers = workersTooling?.type === 'group' ? workersTooling.children[0] : undefined; - const deploy = triggers?.type === 'group' ? triggers.children[0] : undefined; - assert.equal(deploy?.type, 'link'); - assert.equal(deploy?.type === 'link' ? deploy.href : undefined, '/api/workers/tooling/triggers/deploy/'); - assert.equal(deploy?.type === 'link' ? deploy.isCurrent : false, true); -}); - -test('route sidebar keeps commands global when their product is unavailable in the selected snapshot', () => { - const sparsePlan = structuredClone(plan); - const snapshot = sparsePlan.catalog.products[0]?.snapshots[0]; - assert.ok(snapshot); - sparsePlan.catalog.snapshots.push({ - id: 'legacy', - slug: 'legacy', - label: 'Legacy', - default: false, - targets: [], - }); - sparsePlan.catalog.products.push({ - id: 'd1', - slug: 'd1', - title: 'D1', - description: '', - snapshots: [{ ...snapshot, id: 'legacy', slug: 'legacy', label: 'Legacy', default: false, pages: [] }], - }); - const sparseRouter = new ApiRouter(sparsePlan, '/api'); - - const currentSidebar = forgeRouteSidebar(sparseRouter, commandRouter, 'current', undefined, '/api/'); - const currentTooling = currentSidebar.find((entry) => entry.label === 'Developer Tooling'); - assert.equal(currentTooling?.type === 'group' && currentTooling.children.some((entry) => entry.label === 'D1'), true); - - const legacySidebar = forgeRouteSidebar(sparseRouter, commandRouter, 'legacy', undefined, '/api/'); - const legacyTooling = legacySidebar.find((entry) => entry.label === 'Developer Tooling'); - assert.equal(legacyTooling?.type === 'group' && legacyTooling.children.some((entry) => entry.label === 'D1'), false); - const d1 = legacySidebar.find((entry) => entry.label === 'D1'); - assert.deepEqual(d1?.type === 'group' ? d1.children.map((entry) => entry.label) : [], [ - 'Overview', - 'Developer Tooling', - ]); -}); - -test('route sidebar retains default navigation when site query selection is invalid', () => { - const sidebar = forgeRouteSidebar(router, commandRouter, '', undefined, '/api/tooling/'); - - assert.equal(sidebar[0]?.type === 'link' ? sidebar[0].href : undefined, '/api/'); - assert.equal(sidebar[2]?.type, 'group'); - assert.equal(sidebar[2]?.label, 'Reference'); -}); - -test('route sidebar marks a CLI command child as current', () => { - const sidebar = forgeRouteSidebar(router, commandRouter, 'current', undefined, '/api/tooling/deploy/'); - const commands = sidebar[1]; - const deploy = commands?.type === 'group' ? commands.children.find((entry) => entry.label === 'Deploy') : undefined; - - assert.equal(deploy?.type, 'link'); - assert.equal(deploy?.isCurrent, true); -}); - -test('route sidebar preserves every level of nested command paths', () => { - const nestedRouter = new CommandRouter( - { - ...cfCommandCatalog, - commands: [ - { - ...cfCommandCatalog.commands[0], - command: 'cf context agents list', - name: 'list', - fullPath: ['context', 'agents', 'list'], - usage: 'cf context agents list', - }, - { - ...cfCommandCatalog.commands[1], - command: 'cf context get', - name: 'get', - fullPath: ['context', 'get'], - usage: 'cf context get', - }, - ], - }, - { base: '/api' }, - ); - const sidebar = forgeRouteSidebar(router, nestedRouter, 'current', undefined, '/api/tooling/context/agents/list/'); - const commands = sidebar[1]; - const context = commands?.type === 'group' ? commands.children[1] : undefined; - const agents = context?.type === 'group' ? context.children[0] : undefined; - const list = agents?.type === 'group' ? agents.children[0] : undefined; - - assert.equal(context?.label, 'Context'); - assert.equal(agents?.label, 'Agents'); - assert.equal(list?.type, 'link'); - assert.equal(list?.type === 'link' ? list.href : undefined, '/api/tooling/context/agents/list/'); - assert.equal(list?.type === 'link' ? list.isCurrent : false, true); -}); - -test('route sidebar marks the selected target operation as current', () => { - const sidebar = forgeRouteSidebar( - router, - commandRouter, - 'current', - 'python', - '/api/reference/sections/records/operations/list-records/', - ); - const product = sidebar[2]; - assert.equal(product?.type, 'group'); - const section = product?.type === 'group' ? product.children[1] : undefined; - assert.equal(section?.type, 'group'); - const operation = section?.type === 'group' ? section.children[1] : undefined; - - assert.equal(operation?.type, 'link'); - assert.equal(operation?.isCurrent, true); -}); - -test('route sidebar links and marks product and section overview pages', () => { - const productSidebar = forgeRouteSidebar(router, commandRouter, 'current', undefined, '/api/reference/'); - const product = productSidebar[2]; - assert.equal(product?.type, 'group'); - const productOverview = product?.type === 'group' ? product.children[0] : undefined; - assert.equal(productOverview?.type, 'link'); - assert.equal(productOverview?.isCurrent, true); - - const sectionSidebar = forgeRouteSidebar( - router, - commandRouter, - 'current', - undefined, - '/api/reference/sections/records/', - ); - const sectionProduct = sectionSidebar[2]; - const section = sectionProduct?.type === 'group' ? sectionProduct.children[1] : undefined; - const sectionOverview = section?.type === 'group' ? section.children[0] : undefined; - assert.equal(sectionOverview?.type, 'link'); - assert.equal(sectionOverview?.isCurrent, true); -}); diff --git a/packages/cloudflare-api-site/src/sidebar.ts b/packages/cloudflare-api-site/src/sidebar.ts deleted file mode 100644 index b11548514..000000000 --- a/packages/cloudflare-api-site/src/sidebar.ts +++ /dev/null @@ -1,270 +0,0 @@ -import type { SidebarItem } from '@cloudflare/nimbus-docs/types'; -import type { ApiCatalogResource, ApiRouter } from './api-routing.ts'; -import { type CommandProductRoute, type CommandRouter, commandTitle } from './command-reference/routing.ts'; -import type { CommandMetadata } from './command-reference/schema.ts'; - -export type ForgeSidebarItem = - | { label: string; link: string } - | { label: string; items: ForgeSidebarItem[]; collapsed: boolean }; - -interface ProductSidebarItem { - product: CommandProductRoute; - item: ForgeSidebarItem; -} - -const sidebarCache = new WeakMap>(); - -function operationSidebarItem( - router: ApiRouter, - productId: string, - snapshotId: string, - page: ApiRouter['plan']['catalog']['products'][number]['snapshots'][number]['pages'][number], - lang?: string, -): ForgeSidebarItem | undefined { - const projectionId = page.placement?.projectionId; - const link = - router.operationHref(productId, snapshotId, page.operationId, 'human', lang, projectionId) ?? - router.operationHref(productId, snapshotId, page.operationId, 'human', undefined, projectionId); - return link ? { label: page.title, link } : undefined; -} - -function productSidebar(router: ApiRouter, snapshotId: string, lang?: string): ProductSidebarItem[] { - let routerCache = sidebarCache.get(router); - if (!routerCache) { - routerCache = new Map(); - sidebarCache.set(router, routerCache); - } - const cacheKey = JSON.stringify([snapshotId, lang]); - const cached = routerCache.get(cacheKey); - if (cached) return cached; - - const sidebar = router.plan.catalog.products.flatMap((product) => { - const snapshot = product.snapshots.find((candidate) => candidate.id === snapshotId); - if (!snapshot) return []; - const sections = new Map(); - for (const page of snapshot.pages) { - if (page.placement) continue; - let section = sections.get(page.section.id); - if (!section) { - section = { id: page.section.id, title: page.section.title, items: [] }; - sections.set(page.section.id, section); - } - const item = operationSidebarItem(router, product.id, snapshot.id, page, lang); - if (item) section.items.push(item); - } - const productLink = - router.productHref(product.id, snapshot.id, lang) ?? router.productHref(product.id, snapshot.id); - const sectionGroups = [...sections.values()].flatMap((section) => { - const sectionLink = - router.sectionHref(product.id, snapshot.id, section.id, lang) ?? - router.sectionHref(product.id, snapshot.id, section.id); - const items = [...(sectionLink ? [{ label: 'Overview', link: sectionLink }] : []), ...section.items]; - return items.length > 0 ? [{ label: section.title, collapsed: true, items }] : []; - }); - const resourceRoots = resourceSidebar(router, product.id, snapshot, lang); - const directOperations = snapshot.pages.flatMap((page): ForgeSidebarItem[] => { - if (!page.placement || page.placement.resourcePath.length > 0) return []; - const item = operationSidebarItem(router, product.id, snapshot.id, page, lang); - return item ? [item] : []; - }); - return [ - { - product: { id: product.id, slug: product.slug }, - item: { - label: product.title, - collapsed: true, - items: [ - ...(productLink ? [{ label: 'Overview', link: productLink }] : []), - ...directOperations, - ...resourceRoots, - ...sectionGroups, - ], - }, - }, - ]; - }); - routerCache.set(cacheKey, sidebar); - return sidebar; -} - -export function forgeSidebar(router: ApiRouter, snapshotId: string, lang?: string): ForgeSidebarItem[] { - return productSidebar(router, snapshotId, lang).map(({ item }) => item); -} - -function resourceSidebar( - router: ApiRouter, - productId: string, - snapshot: ApiRouter['plan']['catalog']['products'][number]['snapshots'][number], - lang?: string, -): ForgeSidebarItem[] { - const roots = new Map(); - for (const page of snapshot.pages) { - const placement = page.placement; - const first = placement?.resourcePath[0]; - if (!placement || !first) continue; - let resource = roots.get(first.id); - if (!resource) { - resource = { ...first, key: '', path: [first], resources: [], operations: [] }; - roots.set(first.id, resource); - } - let current = resource; - for (const segment of placement.resourcePath.slice(1)) { - let child = current.resources.find((candidate) => candidate.id === segment.id); - if (!child) { - child = { ...segment, key: '', path: [...current.path, segment], resources: [], operations: [] }; - current.resources.push(child); - } - current = child; - } - current.operations.push(page); - } - - function render(resource: ApiCatalogResource): ForgeSidebarItem { - const overview = - router.resourceHref(productId, snapshot.id, resource.path, lang) ?? - router.resourceHref(productId, snapshot.id, resource.path); - const operations = resource.operations.flatMap((page): ForgeSidebarItem[] => { - const item = operationSidebarItem(router, productId, snapshot.id, page, lang); - return item ? [item] : []; - }); - return { - label: resource.title, - collapsed: true, - items: [ - ...(overview ? [{ label: 'Overview', link: overview }] : []), - ...operations, - ...resource.resources.map(render), - ], - }; - } - - return [...roots.values()].map(render); -} - -function normalizePath(pathname: string): string { - const clean = pathname.replace(/[?#].*$/, '').replace(/\/+$/, ''); - return clean === '' ? '/' : clean; -} - -function toRouteEntry(item: ForgeSidebarItem, currentPath: string, order: number): SidebarItem { - if ('link' in item) { - return { - type: 'link', - label: item.label, - href: item.link, - isCurrent: normalizePath(item.link) === currentPath, - order, - }; - } - - return { - type: 'group', - label: item.label, - children: item.items.map((entry, index) => toRouteEntry(entry, currentPath, index)), - collapsed: item.collapsed, - order, - }; -} - -export function forgeRouteSidebar( - router: ApiRouter, - commandRouter: CommandRouter, - snapshotId: string, - lang: string | undefined, - pathname: string, -): SidebarItem[] { - const selectedSnapshotId = router.plan.catalog.snapshots.some((snapshot) => snapshot.id === snapshotId) - ? snapshotId - : (router.plan.catalog.snapshots.find((snapshot) => snapshot.default)?.id ?? snapshotId); - const currentPath = normalizePath(pathname); - const overviewLink = - router.siteSnapshotHref(selectedSnapshotId, lang) ?? router.siteSnapshotHref(selectedSnapshotId) ?? router.siteHref; - const products = productSidebar(router, selectedSnapshotId, lang); - const allProducts = new Map( - router.plan.catalog.products.map((product) => [product.id, { id: product.id, slug: product.slug }]), - ); - const commands = commandSidebar(commandRouter, allProducts, new Set(products.map(({ product }) => product.id))); - return [ - { label: 'Overview', link: overviewLink }, - { label: 'Developer Tooling', collapsed: true, items: commands.globalItems }, - ...products.map(({ product, item }) => { - const productCommands = commands.productItems.get(product.id); - if (!productCommands || !('items' in item)) return item; - - const overviewIndex = item.items.findIndex((entry) => 'link' in entry && entry.label === 'Overview'); - const insertIndex = overviewIndex + 1; - return { - ...item, - items: [ - ...item.items.slice(0, insertIndex), - { label: 'Developer Tooling', collapsed: true, items: productCommands }, - ...item.items.slice(insertIndex), - ], - }; - }), - ].map((entry, index) => toRouteEntry(entry, currentPath, index)); -} - -interface CommandSidebarNode { - name: string; - command?: CommandMetadata; - children: Map; -} - -interface CommandSidebarItems { - globalItems: ForgeSidebarItem[]; - productItems: Map; -} - -function commandSidebar( - router: CommandRouter, - products: ReadonlyMap, - availableProductIds: ReadonlySet, -): CommandSidebarItems { - const roots = new Map(); - - for (const command of router.commands) { - let siblings = roots; - command.fullPath.forEach((name, index) => { - let node = siblings.get(name); - if (!node) { - node = { name, children: new Map() }; - siblings.set(name, node); - } - if (index === command.fullPath.length - 1) node.command = command; - siblings = node.children; - }); - } - - function renderNode(node: CommandSidebarNode, product?: CommandProductRoute): ForgeSidebarItem { - const children = [...node.children.values()].map((child) => renderNode(child, product)); - if (node.command && children.length === 0) { - return { label: commandTitle(node.command), link: router.commandHref(node.command, product) }; - } - - return { - label: commandTitle(node), - collapsed: true, - items: [ - ...(node.command ? [{ label: 'Overview', link: router.commandHref(node.command, product) }] : []), - ...children, - ], - }; - } - - const globalItems: ForgeSidebarItem[] = [{ label: 'Overview', link: router.directoryHref }]; - const productItems = new Map(); - for (const root of roots.values()) { - const product = products.get(root.name); - if (!product || !availableProductIds.has(root.name)) { - globalItems.push(renderNode(root, product)); - continue; - } - - productItems.set(root.name, [ - ...(root.command ? [{ label: 'Overview', link: router.commandHref(root.command, product) }] : []), - ...[...root.children.values()].map((child) => renderNode(child, product)), - ]); - } - return { globalItems, productItems }; -} From 94fcc054a6e2451b48a32b75d273f709f3561259 Mon Sep 17 00:00:00 2001 From: mohamedh Date: Mon, 2 Nov 2026 02:16:57 +0100 Subject: [PATCH 09/19] test: update e2e for nimbus api pages --- packages/cloudflare-api-site/e2e/site.e2e.ts | 175 ++++++++++++++---- .../cloudflare-api-site/e2e/visual.e2e.ts | 12 +- 2 files changed, 145 insertions(+), 42 deletions(-) diff --git a/packages/cloudflare-api-site/e2e/site.e2e.ts b/packages/cloudflare-api-site/e2e/site.e2e.ts index 6f05c6cc9..a0017ccbd 100644 --- a/packages/cloudflare-api-site/e2e/site.e2e.ts +++ b/packages/cloudflare-api-site/e2e/site.e2e.ts @@ -3,68 +3,179 @@ import { expect, test } from '@playwright/test'; const operation = '/api/zero-trust/devices/ip-profiles/methods/list/'; test.describe('routes', () => { - const routes: [path: string, status: number, contentType: string][] = [ + const routes: [path: string, status: number, contentType: string, location?: string][] = [ ['/api/', 200, 'text/html'], ['/api/zero-trust/', 200, 'text/html'], ['/api/zero-trust/devices/ip-profiles/', 200, 'text/html'], + ['/api/zero-trust/devices/ip-profiles/?version=2026-11-30.air', 200, 'text/html'], + ['/api/tags/zero-trust/', 404, 'text/html'], + ['/api/tagging/', 200, 'text/html'], [operation, 200, 'text/html'], - [`${operation}?lang=typescript`, 200, 'text/html'], - [`${operation}?lang=nope`, 404, 'text/plain'], - ['/api/zero-trust/devices/ip-profiles/methods/list.md', 200, 'text/markdown'], - ['/api/zero-trust/devices/ip-profiles/methods/list.md?lang=typescript', 200, 'text/markdown'], - ['/api/llms.txt', 200, 'text/markdown'], - ['/api/zero-trust/llms.txt', 200, 'text/markdown'], + [`${operation}?lang=hcl`, 200, 'text/html'], + [`${operation}?lang=nope`, 200, 'text/html'], + [`${operation}?version=2026-11-30.air`, 200, 'text/html'], + [`${operation}?version=nope`, 404, 'text/html'], + [ + '/api/zero-trust/devices/ip-profiles/methods/list.md', + 301, + '', + '/api/zero-trust/devices/ip-profiles/methods/list/index.md', + ], + ['/api/zero-trust/devices/ip-profiles/methods/list/index.md', 200, 'text/markdown'], + ['/api/index.md', 200, 'text/markdown'], + ['/api/o11y/agents/sessions/methods/list/', 302, '', '/api/observability/agents/sessions/methods/list/'], + ['/api/hyperdrive/integration/planetscale/', 302, '', '/api/hyperdrive/'], + ['/api/tags/', 302, '', '/api/tagging/'], + ['/api/tags/keys/methods/list/', 302, '', '/api/tagging/keys/methods/list/'], + ['/api/llms.txt', 200, 'text/plain'], + ['/api/zero-trust/llms.txt', 200, 'text/plain'], ['/api/tooling/', 200, 'text/html'], + ['/api/d1/tooling/migrations/list/', 200, 'text/html'], ['/api/.well-known/ard.json', 200, 'application/json'], ['/api/nope.txt', 404, 'text/html'], ]; - for (const [path, status, contentType] of routes) { - test(`${path} responds ${status} ${contentType}`, async ({ request }) => { + for (const [path, status, contentType, location] of routes) { + test(`${path} responds ${status}${location ? ` → ${location}` : ` ${contentType}`}`, async ({ request }) => { const response = await request.get(path, { maxRedirects: 0 }); expect(response.status()).toBe(status); - expect(response.headers()['content-type']).toContain(contentType); + if (location) + expect( + new URL(response.headers()['location']!, 'http://x').pathname + + new URL(response.headers()['location']!, 'http://x').search, + ).toBe(location); + else expect(response.headers()['content-type']).toContain(contentType); }); } }); -test('overview renders with product navigation', async ({ page }) => { +test("home keeps its title, description and the sidebar's top entries", async ({ page }) => { await page.goto('/api/'); await expect(page).toHaveTitle('API Reference | Cloudflare API'); - await expect(page.locator('main h1')).toBeVisible(); - // Products are listed in the sidebar, collapsed by default. - await expect(page.locator('a[href="/api/zero-trust/"]').first()).toBeAttached(); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + 'content', + 'API reference generated from the Cloudflare OpenAPI spec by Forge.', + ); + const header = page.locator('main [data-slot="page-header"]'); + await expect(header.locator('h1')).toHaveText('API Reference'); + await expect(header.locator('[data-slot="page-description"]')).toHaveText( + 'API reference generated from the Cloudflare OpenAPI spec by Forge.', + ); + await expect(header.locator('[data-nb-page-actions] time[datetime]')).toBeVisible(); + await expect(header).not.toContainText('Welcome to Cloudflare'); + await expect(page.locator('main')).toContainText("Welcome to Cloudflare's API documentation site"); + const nav = page.locator('[data-nb-api-nav]'); + await expect(nav.locator('> ul > li').first().locator('a[href="/api/"]')).toHaveAttribute('aria-current', 'page'); + await expect(nav.locator('> ul > li').nth(1).locator('> details > summary')).toHaveText('Developer Tooling'); + await expect(nav.locator('a[href="/api/tooling/"]').first()).toBeAttached(); + await expect(nav.locator('a[href="/api/zero-trust/"]').first()).toBeAttached(); }); -test('operation page renders the operation and its samples', async ({ page }) => { - await page.goto(operation); - await expect(page).toHaveTitle('List IP profiles | Cloudflare API'); - await expect(page.locator('h1')).toHaveText('List IP profiles'); - await expect(page.locator('.forge-verb')).toHaveText('GET'); - await expect(page.locator('.forge-sample-code code.language-bash').first()).toContainText( - 'https://api.cloudflare.com/client/v4/accounts/', - ); +test('a product with commands lists its Developer Tooling group', async ({ page }) => { + await page.goto('/api/d1/'); + const group = page + .locator('details[data-nb-nav-group="tags.d1"] > ul > li > details') + .filter({ has: page.locator('summary', { hasText: 'Developer Tooling' }) }); + await expect(group.locator('a[href^="/api/d1/tooling/"]').first()).toBeAttached(); }); -test('execution target selector switches the code sample', async ({ page }) => { +test('operation page renders the operation, its samples, its response example and the version', async ({ page }) => { await page.goto(operation); - const target = page.locator('select[data-forge-target]'); - await expect(target).toHaveValue('curl'); - await target.selectOption('typescript'); - await expect(page).toHaveURL(/[?&]lang=typescript\b/); - await expect(page.locator('select[data-forge-target]')).toHaveValue('typescript'); - await expect(page.locator('.forge-sample-code code.language-bash')).toHaveCount(0); + await expect(page).toHaveTitle('List IP profiles | Cloudflare API'); + await expect(page.locator('h1')).toHaveText('List IP profiles'); + await expect(page.locator('main nav[aria-label="Breadcrumb"] a').first()).toHaveText('API Reference'); + await expect(page.locator('[data-nb-api-coderail]')).toContainText('https://api.cloudflare.com/client/v4/accounts/'); + await expect(page.locator('[data-nb-api-coderail] [data-nb-sample-lang="curl"]').first()).toBeAttached(); + await expect(page.locator('[data-nb-api-coderail] [data-nb-sample-lang="go"]').first()).toBeAttached(); + await expect(page.locator('[data-nb-api-coderail]')).toContainText('Response example'); + await expect(page.locator('header [data-slot="version-switcher"]')).toContainText('2026-11-30 (Air)'); + const groupRow = page.locator('[data-nb-api-nav] details > summary').first(); + expect(await groupRow.evaluate((el) => getComputedStyle(el).listStyleType)).toBe('none'); }); -test('operation links its agent Markdown', async ({ page, request }) => { +test('operation links its agent Markdown, which carries the cURL sample', async ({ page, request }) => { await page.goto(operation); const href = await page.locator('link[rel="alternate"][type="text/markdown"]').getAttribute('href'); expect(href).not.toBeNull(); const response = await request.get(new URL(href!).pathname); expect(response.status()).toBe(200); const markdown = await response.text(); - expect(markdown).toContain('# List IP profiles'); - expect(markdown).toContain('- Path: `/accounts/{account_id}/devices/ip-profiles`'); + expect(markdown).toContain('title: "List IP profiles"'); + expect(markdown).toContain('```bash'); + expect(markdown).toContain('/accounts/{account_id}/devices/ip-profiles'); +}); + +test('a Terraform-backed operation shows its declarations only with the HCL sample', async ({ page, request }) => { + await page.goto('/api/dns/records/methods/create/'); + await expect(page.locator('[data-api-terraform]')).toBeHidden(); + await page.locator('[data-nb-lang-select]').selectOption('hcl'); + await expect( + page.locator('[data-api-terraform]').getByRole('heading', { name: 'Terraform', exact: true }), + ).toBeVisible(); + await expect(page.locator('[data-api-terraform] h3').first()).toContainText('cloudflare_dns_record'); + await page.goto('/api/dns/records/methods/create/?lang=hcl'); + await expect(page.locator('[data-api-terraform]')).toBeVisible(); + const markdown = await (await request.get('/api/dns/records/methods/create/index.md')).text(); + expect(markdown).toContain('```hcl'); +}); + +test('a resource page lists its endpoints, then its child resources', async ({ page }) => { + await page.goto('/api/zero-trust/devices/'); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + 'content', + 'Devices API resources and endpoints.', + ); + const headings = page.locator('main h2'); + await expect(headings.nth(0)).toHaveText(/^Endpoints\s*4 endpoints$/); + await expect(headings.nth(1)).toHaveText('Resources'); + await expect(page.locator('main a[href="/api/zero-trust/devices/ip-profiles/"]')).toHaveText('IP Profiles'); +}); + +test('a resource marks its deprecated endpoints', async ({ page }) => { + await page.goto('/api/accounts/roles/'); + const row = page.locator('main [data-slot="api-operation-list"] li').first(); + await expect(row).toContainText('List Roles'); + await expect(row.getByText('Deprecated', { exact: true })).toBeVisible(); +}); + +test('a resource lists its endpoints in lifecycle order, on the page and in the sidebar', async ({ page }) => { + await page.goto('/api/zero-trust/devices/ip-profiles/'); + const lifecycle = [ + 'List IP profiles', + 'Get IP profile', + 'Create IP profile', + 'Update IP profile', + 'Delete IP profile', + ]; + await expect(page.locator('main [data-slot="api-operation-name"]')).toHaveText(lifecycle); + await expect(page.locator('[data-nb-api-nav] a[href^="/api/zero-trust/devices/ip-profiles/methods/"]')).toHaveText( + lifecycle, + ); +}); + +test('a request body opens a nested union down to its record types', async ({ page }) => { + await page.goto('/api/dns/records/methods/create/'); + const body = page.locator('main section[data-api-section]', { hasText: 'Request body' }).first(); + const groups = body.locator('.fl-list').first().locator('> li'); + await expect(groups).toHaveCount(2); + await expect(groups.first().locator('.fl-count-text').first()).toHaveText('8 variants'); + const records = groups.first().locator('.fl-children--union').first(); + await expect(records.locator('.fl-union-label').first()).toHaveText('One of the following:'); + const record = records.locator('.fl-list').first().locator('> li').first(); + await expect(record.locator('.fl-name').first()).toHaveText('A Record'); + await expect(record.locator('.fl-count-text').first()).toHaveText('9 properties'); +}); + +test('an empty object body still shows its Request body section', async ({ page }) => { + await page.goto('/api/cloudforce-one/threat-signals/articles/skills/methods/run/'); + const body = page.locator('main section[data-api-section]', { hasText: 'Request body' }).first(); + await expect(body).toContainText('No documented fields.'); +}); + +test('command pages keep their URLs with the command sidebar', async ({ page }) => { + await page.goto('/api/d1/tooling/migrations/list/'); + await expect(page.locator('h1')).toBeVisible(); + await expect(page.locator('a[href="/api/tooling/"]').first()).toBeAttached(); }); test('llms.txt indexes the API', async ({ request }) => { diff --git a/packages/cloudflare-api-site/e2e/visual.e2e.ts b/packages/cloudflare-api-site/e2e/visual.e2e.ts index 13fe22017..aea5ae237 100644 --- a/packages/cloudflare-api-site/e2e/visual.e2e.ts +++ b/packages/cloudflare-api-site/e2e/visual.e2e.ts @@ -5,22 +5,14 @@ const pages = { product: '/api/zero-trust/', resource: '/api/zero-trust/devices/ip-profiles/', operation: '/api/zero-trust/devices/ip-profiles/methods/list/', - 'operation-typescript': '/api/zero-trust/devices/ip-profiles/methods/list/?lang=typescript', + 'operation-terraform': '/api/dns/records/methods/create/?lang=hcl', tooling: '/api/tooling/', 'not-found': '/api/nope.txt', }; const viewports = { desktop: { width: 1440, height: 900 }, mobile: { width: 390, height: 844 } }; -const descriptions = [ - '.api-overview', - '.forge-operation-desc', - '.forge-request-body-description', - '.forge-desc', - '.forge-enum-description', - '.forge-response-description', - '.forge-response-example-description', -].join(', '); +const descriptions = '.nb-md'; for (const [name, path] of Object.entries(pages)) { for (const [viewport, size] of Object.entries(viewports)) { From c56714c518d9c1df01fb19d0d5564a08cc9eb72b Mon Sep 17 00:00:00 2001 From: mohamedh Date: Mon, 2 Nov 2026 02:16:58 +0100 Subject: [PATCH 10/19] docs: update the site readme for nimbus --- packages/cloudflare-api-site/README.md | 285 +++++++++++++------------ 1 file changed, 149 insertions(+), 136 deletions(-) diff --git a/packages/cloudflare-api-site/README.md b/packages/cloudflare-api-site/README.md index 7589f8f00..682cd20d0 100644 --- a/packages/cloudflare-api-site/README.md +++ b/packages/cloudflare-api-site/README.md @@ -3,152 +3,171 @@ Cloudflare's deployable API reference, built with Astro, [Nimbus](https://nimbus-docs.com), and `astro-fern`. -This package intentionally owns the renderer. Nimbus scaffolds its layouts, -components, and styles into `src/` as files the site owns; sidebar -construction, schema presentation, execution-target controls, and CSS live here -too. `astro-fern` supplies the normalized content and lazy server APIs without -depending on Nimbus. +Nimbus renders the API pages, their Markdown twins, and `llms.txt`. `astro-fern` +reads the Forge OpenAPI spec at build time and decides products, resources, and +operation URLs. `src/forge-api.ts` translates one into the other; it adds +Cloudflare data to the spec and never renders anything itself. ## Data flow ```text -OpenAPI source or version-bound snapshots - + - src/content.config.ts manifest - | - v - route-neutral apiReference - + - astro.config.mjs runtime routing - | - v - astro-fern server composition - / | \ - API operation pages | agent Markdown - | and llms.txt - target metadata +openapi.forge.json (latest stable Forge release) + | + v + astro-fern buildFernContent + products, resources, routes, Terraform data + | + v + prepareForgeApi (src/forge-api.ts) + | + v + .forge/ spec, products, redirects, release + | + v + Nimbus api collection + / | \ + pages Markdown llms.txt ``` -The OpenAPI specification is loaded from the `openapi.forge.json` asset on the -latest stable Forge GitHub release. Set `FORGE_OPENAPI_RELEASE` to a release tag -to build against that exact revision. The released artifact keeps normal docs -development free of Fern generation, Docker, and registry access. Fern SDK group -and method metadata defines each public product, resource hierarchy, and method -route. OpenAPI tags remain internal ownership metadata and do not appear in -SDK-backed URLs. +The spec is the `openapi.forge.json` asset on the latest stable Forge GitHub +release, loaded by `src/openapi-source.ts`. Fern SDK group and method metadata defines each public product, +resource hierarchy, and method route. OpenAPI tags remain internal ownership +metadata and do not appear in URLs. -### Terraform +`prepareForgeApi` writes four files to the git-ignored `.forge/` directory: -The Terraform target shows, on each operation, the Terraform resources and data sources that call -it. The data is `src/generated/terraform-docs.json`, which `pnpm generate:terraform-docs`, run from the repository root, writes -with the generator from the `cloudflare-terraform-docs` package (see its README for the sources it -reads). `.github/workflows/sync-terraform-docs.yml` keeps it current. +- `openapi-.json`: the spec Nimbus renders, with Cloudflare titles, + `x-nimbus-order` for lifecycle order, request samples, synthesized response + examples, and Terraform data under `x-cloudflare-terraform`; +- `products.json`: the products and their slugs, read by the command sidebar; +- `redirects.json`: old section, alias-operation, and moved-product URLs, read by + `src/middleware.ts`; +- `release.json`: the spec's release date, shown on the API home page. -The site renders it with: +Operations with removed Forge metadata are left out with a build warning. +Operations `astro-fern` gives no URL are left out silently. -- `src/terraform-extension.ts`: the astro-fern extension that, at build time, attaches declarations - to OpenAPI operations and renders their Markdown descriptions with `renderDescription`; -- `src/terraform.ts`: operation data, endpoint matching, and helpers such as `withTerraformTarget`, - which adds the HCL to agent Markdown; -- `TerraformDeclarations.astro` and `TerraformFields.astro`, plus the Terraform branches of - `Operation.astro` and `CodeSample.astro`. - -This is temporary: the provider is generated by Stainless. Once Forge supports Terraform natively, -remove it: - -1. Delete `packages/cloudflare-terraform-docs`, `.github/workflows/sync-terraform-docs.yml` and the - root `package.json`'s `generate:terraform-docs` script. -2. Here, delete `src/generated/terraform-docs.json`, `src/terraform.ts`, `src/terraform.test.ts`, - `src/terraform-extension.ts`, `TerraformDeclarations.astro` and `TerraformFields.astro`. -3. Remove their uses: the extension in `content.config.ts`, `withTerraformTarget` in - `api-server.ts`, and the Terraform branches and `.forge-terraform-*` styles in `Operation.astro` - and `CodeSample.astro`. -4. Remove `packages/cloudflare-api-site/src/generated` from `.oxfmtrc.json`, then run - `pnpm install`. +Until upstream SDK addresses distinguish account and zone operations that share +one route, equal-priority collisions prefer the account-scoped operation +(`cloudflareOperationRoutingPreference` in `src/cloudflare.ts`). Forge approval +metadata remains authoritative, so a hidden account mapping still loses to an +approved zone mapping. ## Project ownership -- `src/content.config.ts` selects OpenAPI sources, discovers SDK products and ownership sections, configures snapshots and execution targets, and registers the canonical, route-neutral API collection. It renders OpenAPI descriptions with `src/description-markdown.ts`: Nimbus's sanitizing renderer, after escaping placeholders like `metadata.` so they show as text. astro-fern fails the build if descriptions would render raw HTML or unsafe URLs. -- `astro.config.mjs` independently configures routing and installs Nimbus, `astro-fern`, and the Cloudflare adapter. -- `src/api-routing.ts` owns the query-parameter URL policy and semantic link resolution. -- `src/api-server.ts` binds that policy to `astro-fern`'s lazy server APIs. -- `src/pages/[product]/[...slug].astro` owns the SSR human operation route and renderer; `src/plain-text.ts` turns description HTML into its meta description. -- `src/pages/[...slug].astro` renders authored pages from `src/content/docs/` on request, with the same API sidebar. -- `src/pages/[product]/[...document].md.ts` and the `llms.txt` endpoints own the SSR agent routes. `llms.txt` and `[product]/llms.txt` combine the astro-fern API index with the authored-page index Nimbus prepares at build time; `[...slug]/index.md`, `index.mdx`, and `llms-full.txt` are Nimbus routes for authored pages. -- `src/layouts/` and `src/components/ui/` are the Nimbus page shells and UI components; `src/components/` also contains the operation, schema, and code UI. -- `src/sidebar.ts` maps the selected version and execution target into Nimbus sidebar items, and `src/page-sidebar.ts` resolves them for a request. -- Nimbus publishes agent discovery (`.well-known/ard.json`, `ai-catalog.json`, and a `rel="ard"` homepage `Link`) at the origin root and under `/api`. The root copy is correct while this Worker owns its host; if the site moves to a domain shared with other sites, its entries belong in the root site's discovery files instead. -- `src/styles/globals.css` holds the Cloudflare palette as Nimbus `--nb-*` tokens; `src/styles/cloudflare.css` aliases them as the `--cf-*` tokens used by the API components. +- `astro.config.mjs` installs Nimbus with the API entry from `forgeApi()` and the + Cloudflare adapter. API pages render on request. +- `src/forge-api.ts` builds the `astro-fern` content and prepares the spec, as + above. `src/cloudflare.ts` holds the execution targets, snippets, and routing + preferences it uses, and `src/version.ts` the API version. +- `src/content.config.ts` registers Nimbus's `api` collection and the `cf` + command catalog; `src/live.config.ts` registers Nimbus's live API pages. +- `src/pages/[...slug].astro` renders every API page with Nimbus's + `getApiRoute`; `src/plain-text.ts` turns a description into its meta + description. +- `src/pages/[...slug].md.ts`, `llms.txt.ts`, `[...section]/llms.txt.ts`, and + `llms-full.txt.ts` are Nimbus's agent routes. A Markdown twin lives at + `//index.md`; the old `/.md` URLs 301 to it. +- `src/middleware.ts` 302s the URLs in `.forge/redirects.json` to their pages. +- `src/command-reference/`, `src/command-sidebar.ts`, `src/pages/tooling/`, and + `src/pages/[product]/tooling/` own the `cf` command pages and their sidebar. + The catalog in `src/command-reference/_generated/` is copied from + `cloudflare/cf`; replace it to refresh the commands. +- `src/components/ui/` holds the Nimbus starter components the site owns, + including the API layout, sidebar, field rows, and code rail, with Cloudflare + notices, Terraform, and tooling added. +- `src/layouts/` are the Nimbus page shells. +- `src/styles/globals.css` holds the Cloudflare palette as Nimbus `--nb-*` + tokens; `src/styles/cloudflare.css` aliases them as the `--cf-*` tokens. +- `src/sidebar-filter.ts` filters the sidebar as the reader types. +- Nimbus publishes agent discovery files at the origin root and under `/api`; + on a host shared with other sites they belong in the root site's files. + +The published spec download is off: Nimbus pretty-prints it, and at 36 MB it is +over the Workers asset limit. MDX validation is off because the site has no +authored MDX pages. ### Styling -Files copied from Nimbus (`src/layouts/`, `src/components/ui/`, and `src/styles/globals.css`) keep Nimbus's Tailwind classes so registry updates still apply. Nimbus's starter `prose.css` is not included: the only authored page renders components, so add prose styles back when authored guides are written. Everything else — the API reference, the header, and the sidebar rows — uses scoped or plain CSS with the `--cf-*` and `--nb-*` tokens. Do not put utility classes on markup that repeats at scale: the API sidebar renders thousands of rows per page, and per-row class strings multiply page size. +Files copied from Nimbus (`src/layouts/`, `src/components/ui/`, and +`src/styles/globals.css`) keep Nimbus's Tailwind classes so starter updates +still apply. Everything else uses scoped or plain CSS with the `--cf-*` and +`--nb-*` tokens. Do not put utility classes on markup that repeats at scale: the +API sidebar renders thousands of rows per page, and per-row class strings +multiply page size. + +## Terraform + +Each operation lists the Terraform resources and data sources that call it. The +data is `src/generated/terraform-docs.json`, which `pnpm generate:terraform-docs`, +run from the repository root, writes with the generator from the +`cloudflare-terraform-docs` package (see its README for the sources it reads). +`.github/workflows/sync-terraform-docs.yml` keeps it current. + +The site renders it with: -Products are discovered across the configured OpenAPI source set. Routes, -navigation, agent indexes, and server-island lookups are composed from that -content and the independent runtime config. +- `src/terraform-extension.ts`: the `astro-fern` extension that attaches + declarations to operations and renders their Markdown descriptions with + `src/description-markdown.ts`; +- `src/terraform.ts`: operation data, endpoint matching, and declaration labels; +- `src/forge-api.ts`: copies the data to `x-cloudflare-terraform` and turns each + declaration example into an HCL request sample; +- `TerraformDeclarations.astro` and `TerraformFields.astro`, drawn by + `ApiBody.astro`. `terraform-section.client.ts` shows that section only while + the HCL sample is selected. + +This is temporary: the provider is generated by Stainless. Once Forge supports +Terraform natively, remove it: + +1. Delete `packages/cloudflare-terraform-docs`, + `.github/workflows/sync-terraform-docs.yml` and the root `package.json`'s + `generate:terraform-docs` script. + Remove its entry from the root `AGENTS.md`. +2. Here, delete `src/generated/`, `src/terraform.ts`, `src/terraform.test.ts`, + `src/terraform-extension.ts`, `src/description-markdown.ts` and its test, + `src/components/TerraformDeclarations.astro`, + `src/components/TerraformFields.astro`, and + `src/components/ui/api-layout/terraform-section.client.ts`. +3. Remove their uses: + - the extension, `x-cloudflare-terraform`, and the HCL samples in + `src/forge-api.ts`, and their cases in `src/forge-api.test.ts`; + - the Terraform section and the `terraform-section.client` import in + `ApiBody.astro`; + - the `x-cloudflare-terraform` entry and the `terraform.ts` import in + `src/nimbus-api.d.ts`; + - the `renderDescription` fixture in `src/plain-text.test.ts`; + - the Terraform checks in `e2e/site.e2e.ts` and the `operation-terraform` + shot in `e2e/visual.e2e.ts`. +4. Decide on the `terraform` target in `src/cloudflare.ts`: drop it with its + case in `src/cloudflare.test.ts`, or keep it for Forge's own Terraform + samples. Kept without samples, every operation shows a placeholder. +5. Remove `satteri` and `hast-util-sanitize` from `package.json`, and + `packages/cloudflare-api-site/src/generated` from `.oxfmtrc.json`. +6. Run `pnpm install`, then `test`, `check`, and `test:e2e`. ## Routes -Human operation pages are rendered on demand by the Astro catch-all route. -`lang` selects an execution target and `version` selects an exact documentation -snapshot: +API pages render on request under `/api/`. Products, resources, and +operations follow the Fern SDK hierarchy: ```text +/api/zero-trust/ /api/zero-trust/devices/ip-profiles/ /api/zero-trust/devices/ip-profiles/methods/list/ -/api/zero-trust/devices/ip-profiles/methods/list/?lang=typescript ``` -Omitting `version` selects the configured default version; omitting `lang` -selects the first configured target for HTML while agent Markdown remains -aggregate. On API and agent routes, empty, duplicate, or unknown selection -parameters return 404 rather than silently falling back. Previous version/target -path variants are not routes. - -Each snapshot owns its products, resources, operations, and operation slugs. A -historical-only operation remains reachable at its historical slug with -`?version=`. Switching versions follows the same OpenAPI `operationId` and -SDK projection identity when that placement exists in the target snapshot. -Products absent from the selected snapshot are omitted from navigation; the -router never substitutes another product snapshot. - -The current recipe exposes one catalog snapshot backed by a released OpenAPI -artifact. The API-version selector remains visible with that one option. Selector -options come from the generated catalog and include only snapshots available to -the current page. Generated request samples remain version-neutral and do not -send an `api-version` header until API version enforcement is implemented -upstream. +`?version=` selects an API version; omitting it selects the default. The +current build has one version, and the version picker still shows it. -Until upstream SDK addresses distinguish account and zone operations that share -one route, equal-priority collisions temporarily prefer the account-scoped -operation. Forge approval metadata remains authoritative, so a hidden account -mapping still loses to an approved zone mapping. - -Agent routes use the same query policy and are rendered on demand: +Agent routes: ```text /api/llms.txt /api/zero-trust/llms.txt -/api/zero-trust/llms.txt?lang=typescript -/api/zero-trust/devices/ip-profiles/methods/list.md -/api/zero-trust/devices/ip-profiles/methods/list.md?lang=typescript +/api/llms-full.txt +/api/zero-trust/devices/ip-profiles/methods/list/index.md ``` -Canonical human pages link to aggregate Markdown; a selected execution target -links to the matching target Markdown. - -## Server islands - -Operation content is rendered on demand. Every operation page and deferred schema island -loads by both semantic entry ID and selected version so it resolves the exact -snapshot artifact. Schema sections above the local node-count threshold use -Astro server islands to keep very large unions out of the initial HTML. The -Cloudflare adapter supplies the Worker runtime; pages and islands read the -already-generated `astro-fern` content collections and never access the -filesystem. Code samples use Prism's JavaScript highlighter because Shiki's -WebAssembly runtime is unavailable in the deployed Worker. - ## Run it From the repository root after `pnpm install`: @@ -156,6 +175,8 @@ From the repository root after `pnpm install`: ```bash pnpm --filter cloudflare-api-site dev # cf dev pnpm --filter cloudflare-api-site check +pnpm --filter cloudflare-api-site test # unit tests +pnpm --filter cloudflare-api-site test:e2e # serves the last build pnpm --filter cloudflare-api-site build # cf build pnpm --filter cloudflare-api-site preview # serve the last build locally pnpm --filter cloudflare-api-site deploy # cf deploy @@ -169,29 +190,21 @@ runs `astro build` and writes Build Output to `.cloudflare/output/v0/`. served at `-docs..workers.dev` (`previewUrls` in `cloudflare.config.ts`). `.github/workflows/preview-docs.yml` deploys one for every same-repository pull -request and comments its URL. - -To reproduce a release-triggered build, pin the release asset: - -```bash -FORGE_OPENAPI_RELEASE='openapi@' pnpm --filter cloudflare-api-site build -``` - -Set `FORGE_OPENAPI_SPEC` to an already-downloaded artifact to build without -downloading it again. - -Astro trusts the `X-Forwarded-Host` header only for hosts listed in -[`security.allowedDomains`](https://docs.astro.build/en/reference/configuration-reference/#securityalloweddomains) -(`cloudflare.com` and its subdomains, over HTTPS). For an internal deployment, set -`INTERNAL_SITE_DOMAIN` to its hostname (for example `docs.internal.example`) -when building to add it to the list: - -```bash -INTERNAL_SITE_DOMAIN='' pnpm --filter cloudflare-api-site build -``` - -`check` validates the Astro components. `build` verifies the Cloudflare SSR -bundle. +request and comments its URL. `.github/workflows/deploy-docs.yml` deploys the +site on every push to `main`, on each stable Forge release that carries +`openapi.forge.json`, and on manual dispatch. + +Build-time environment variables: + +- `FORGE_OPENAPI_RELEASE`: a release tag such as `openapi@`, + to build against that exact spec, as a release-triggered deploy does. +- `FORGE_OPENAPI_SPEC`: an already-downloaded spec, to build without + downloading it again. +- `INTERNAL_SITE_DOMAIN`: an internal deployment's hostname. Astro trusts the + `X-Forwarded-Host` header only for hosts in + [`security.allowedDomains`](https://docs.astro.build/en/reference/configuration-reference/#securityalloweddomains): + one subdomain level of `cloudflare.com` and any subdomain of `cloudflare.dev`, + over HTTPS. This adds the internal host. `test:visual` compares screenshots of key pages against local baselines. It serves the last build with `astro preview`, or uses `VISUAL_BASE_URL` when set. From 1b444ebf409fdb63cbac792f6084e78f66e87a84 Mon Sep 17 00:00:00 2001 From: mohamedh Date: Mon, 2 Nov 2026 02:35:00 +0100 Subject: [PATCH 11/19] test: run the e2e preview server in the foreground --- packages/cloudflare-api-site/playwright.config.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare-api-site/playwright.config.ts b/packages/cloudflare-api-site/playwright.config.ts index 68bc37939..742977c0b 100644 --- a/packages/cloudflare-api-site/playwright.config.ts +++ b/packages/cloudflare-api-site/playwright.config.ts @@ -16,5 +16,10 @@ export default defineConfig({ use: { baseURL, trace: 'retain-on-failure' }, webServer: process.env.VISUAL_BASE_URL ? undefined - : { command: 'astro preview --port 8788', url: `${baseURL}/api/`, reuseExistingServer: true, timeout: 120_000 }, + : { + command: 'astro preview --port 8788 --ignore-lock', + url: `${baseURL}/api/`, + reuseExistingServer: true, + timeout: 120_000, + }, }); From 7a33dce8ec237aa965cd8c74c8e2800e61920fed Mon Sep 17 00:00:00 2001 From: mohamedh Date: Tue, 3 Nov 2026 11:05:00 +0000 Subject: [PATCH 12/19] perf: inline only the icons the site uses --- packages/cloudflare-api-site/README.md | 3 ++- packages/cloudflare-api-site/astro.config.mjs | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare-api-site/README.md b/packages/cloudflare-api-site/README.md index 682cd20d0..a23145829 100644 --- a/packages/cloudflare-api-site/README.md +++ b/packages/cloudflare-api-site/README.md @@ -56,7 +56,8 @@ approved zone mapping. ## Project ownership - `astro.config.mjs` installs Nimbus with the API entry from `forgeApi()` and the - Cloudflare adapter. API pages render on request. + Cloudflare adapter. API pages render on request. It lists the icons the site + uses, so only those reach the Worker; add an icon there before using it. - `src/forge-api.ts` builds the `astro-fern` content and prepares the spec, as above. `src/cloudflare.ts` holds the execution targets, snippets, and routing preferences it uses, and `src/version.ts` the API version. diff --git a/packages/cloudflare-api-site/astro.config.mjs b/packages/cloudflare-api-site/astro.config.mjs index a7e002fd9..9d00c87ef 100644 --- a/packages/cloudflare-api-site/astro.config.mjs +++ b/packages/cloudflare-api-site/astro.config.mjs @@ -41,6 +41,29 @@ export default defineConfig({ }, integrations: [ nimbus(nimbusConfig, { + icons: { + include: { + ph: [ + 'caret-down', + 'caret-right', + 'check', + 'clock', + 'copy', + 'git-branch', + 'info', + 'lightbulb', + 'list', + 'markdown-logo', + 'moon', + 'pencil-simple', + 'sun', + 'warning', + 'warning-circle', + 'x', + ], + 'simple-icons': ['go', 'python', 'terraform', 'typescript'], + }, + }, validateMdx: false, rules: { 'nimbus/frontmatter-shape': 'error', From a0a474f583fc89e95cde3d9d3baab37e53875eda Mon Sep 17 00:00:00 2001 From: mohamedh Date: Tue, 3 Nov 2026 15:04:56 +0000 Subject: [PATCH 13/19] feat: give api headers the full width beside the code rail --- .../components/ui/api-layout/ApiBody.astro | 90 +---------------- .../components/ui/api-layout/ApiLayout.astro | 55 ++++++----- .../ui/api-layout/ApiPageHeader.astro | 98 +++++++++++++++++++ 3 files changed, 133 insertions(+), 110 deletions(-) create mode 100644 packages/cloudflare-api-site/src/components/ui/api-layout/ApiPageHeader.astro diff --git a/packages/cloudflare-api-site/src/components/ui/api-layout/ApiBody.astro b/packages/cloudflare-api-site/src/components/ui/api-layout/ApiBody.astro index 0391724ed..562598e8f 100644 --- a/packages/cloudflare-api-site/src/components/ui/api-layout/ApiBody.astro +++ b/packages/cloudflare-api-site/src/components/ui/api-layout/ApiBody.astro @@ -9,17 +9,10 @@ * * The status helper and `.nb-md` styles are kept inline here (rather than shared * lib/CSS files) so a docs-only site carries no extra API-specific files; the - * auth→field projection lives in the co-located `./auth`. The operation's - * request line is the `ApiEndpointCard` component. + * auth→field projection lives in the co-located `./auth`. */ -import { Aside } from "@/components/ui/aside"; -import { Badge } from "@/components/ui/badge"; -import { Banner } from "@/components/ui/banner"; import TerraformDeclarations from "@/components/TerraformDeclarations.astro"; -import { Breadcrumbs } from "@/components/ui/breadcrumbs"; import { ApiFieldList, ApiUnionExplorer } from "@/components/ui/api-field-row"; -import { PageActions } from "@/components/ui/page-actions"; -import ApiEndpointCard from "./ApiEndpointCard.astro"; import ApiHome from "./ApiHome.astro"; import ApiOperationList from "./ApiOperationList.astro"; import ApiHomeChunk from "./ApiHomeChunk.astro"; @@ -27,8 +20,6 @@ import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; import { authFields } from "./auth"; import { cn } from "@/lib/cn"; import { withBase } from "@cloudflare/nimbus-docs/runtime"; -import { API_HOME_LAST_MODIFIED } from "./home"; -import { config } from "virtual:nimbus/config"; import type { ApiPageProps, ApiResponseView } from "@cloudflare/nimbus-docs/api"; import type { ApiHome as ApiHomeView } from "@cloudflare/nimbus-docs/runtime"; @@ -44,11 +35,8 @@ const { page, home } = Astro.props; // reader without script can walk the whole list. const homeChunk = page.kind === "api" && home && home.part.number > 1 ? home.part : undefined; const homeNext = homeChunk && home?.parts[homeChunk.number]; -const title = homeChunk ? `${page.title}: endpoints ${homeChunk.start}–${homeChunk.end}` : page.title; -const markdownUrl = page.markdownHref; const isHome = page.kind === "api"; const intro = homeChunk ? undefined : page.descriptionHtml; -const subtitle = isHome && !homeChunk ? config.description : undefined; const detail = (v: unknown): string => JSON.stringify(v); @@ -100,82 +88,10 @@ const requestExamples = ? [{ ...page.example, id: "example", label: "Example" }] : []; -// Cloudflare notices, from the Forge extensions the prepared spec keeps. -const extensions = page.kind === "operation" ? page.extensions : undefined; -const rawAvailability = extensions?.["x-fern-availability"]; -const availability = typeof rawAvailability === "string" ? { status: rawAvailability } : rawAvailability; -const availabilityNote = !page.deprecated && availability?.message ? availability : undefined; -const AVAILABILITY: Record = { - alpha: { text: "Alpha", variant: "info" }, - beta: { text: "Beta", variant: "info" }, - preview: { text: "Preview", variant: "info" }, - "generally-available": { text: "Generally Available", variant: "success" }, - deprecated: { text: "Deprecated", variant: "caution" }, - legacy: { text: "Legacy", variant: "default" }, -}; -const availabilityStatus = page.kind === "operation" && page.deprecated ? "deprecated" : availability?.status; -const availabilityBadge = availabilityStatus - ? (AVAILABILITY[availabilityStatus] ?? { text: availabilityStatus.replaceAll("-", " "), variant: "default" as const }) - : undefined; -const requireConfirmation = extensions?.["x-forge-require-confirmation"]; -const terraform = extensions?.["x-cloudflare-terraform"]; - -// Deprecation notice, built as sanitized HTML for the shared Banner (which takes -// a content string, mirroring DocsLayout's version-deprecation banner). -const esc = (s: string): string => - s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); -const deprecationDetails = page.deprecated - ? [ - availability?.message && ` ${esc(availability.message)}`, - page.deprecation?.successor && - ` Use ${esc(page.deprecation.successor.label)} instead.`, - page.deprecation?.migrationHref && ` See the migration guide.`, - ].filter(Boolean) - : []; -const deprecationHtml = !page.deprecated - ? null - : deprecationDetails.length > 0 - ? `Deprecated.${deprecationDetails.join("")}` - : "This operation is deprecated."; +const terraform = page.kind === "operation" ? page.extensions?.["x-cloudflare-terraform"] : undefined; --- -
- - {deprecationHtml && } - {availabilityNote && ( - - )} - {requireConfirmation && ( - - )} -
- -{/* Header order (locked): title → description → endpoint → page actions. The - endpoint bar carries the sidebar method chip + verb + path; page actions sit - beneath so they read as page-level utilities, not part of the endpoint. The - API home's description is its subtitle; the spec's intro follows as body. */} -
-
-

{title}

- {availabilityBadge && } -
- - {subtitle &&

{subtitle}

} - - {!isHome && intro &&
} - - {page.kind === "operation" && ( - - )} - - {!homeChunk && } -
- -
+
{isHome && intro &&
} {page.kind === "operation" && ( <> diff --git a/packages/cloudflare-api-site/src/components/ui/api-layout/ApiLayout.astro b/packages/cloudflare-api-site/src/components/ui/api-layout/ApiLayout.astro index a10a4ac78..661d36ba4 100644 --- a/packages/cloudflare-api-site/src/components/ui/api-layout/ApiLayout.astro +++ b/packages/cloudflare-api-site/src/components/ui/api-layout/ApiLayout.astro @@ -15,6 +15,7 @@ import { ApiCodeRail } from "@/components/ui/api-code-rail"; import { Banner } from "@/components/ui/banner"; import { Pagination } from "@/components/ui/pagination"; import ApiBody from "./ApiBody.astro"; +import ApiPageHeader from "./ApiPageHeader.astro"; import { cn } from "@/lib/cn"; import { getApiVersionHead, getVersionStatus, navBuildId, navStateScript, withBase } from "@cloudflare/nimbus-docs/runtime"; import type { ApiNav, ApiPageProps } from "@cloudflare/nimbus-docs/api"; @@ -154,33 +155,41 @@ if (versionStatus?.isHidden || homePart) { {/* Reserve both desktop tracks before the streamed body/rail HTML arrives. Flex only reserves the rail's width once its later sibling is parsed, making long API bodies reflow sideways during navigation. */} -
-
-
- {pagefindDeprecated && } - {versionStatus?.isDeprecated && ( -
- ${versionStatus.version} is deprecated. See the latest version.`} - /> -
- )} - - {nav.prevNext && !hasRail && } -
-
+
+
+ {pagefindDeprecated && } + {versionStatus?.isDeprecated && ( +
+ ${versionStatus.version} is deprecated. See the latest version.`} + /> +
+ )} + +
+ +
+ + {nav.prevNext && !hasRail && } +
{/* Below 90rem the rail reflows inline beneath the body (samples are unique content, so they stay in the no-JS HTML for crawlability); at 90rem+ it becomes the sticky right column. The left padding keeps overflow-y-auto from clipping the LayerCard's outset ring at the scroll edge. */} {hasRail && ( -
+
+
)} -
+
diff --git a/packages/cloudflare-api-site/src/components/ui/api-layout/ApiPageHeader.astro b/packages/cloudflare-api-site/src/components/ui/api-layout/ApiPageHeader.astro new file mode 100644 index 000000000..3e1d02af0 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-layout/ApiPageHeader.astro @@ -0,0 +1,98 @@ +--- +import { Aside } from "@/components/ui/aside"; +import { Badge } from "@/components/ui/badge"; +import { Banner } from "@/components/ui/banner"; +import { Breadcrumbs } from "@/components/ui/breadcrumbs"; +import { PageActions } from "@/components/ui/page-actions"; +import ApiEndpointCard from "./ApiEndpointCard.astro"; +import { withBase } from "@cloudflare/nimbus-docs/runtime"; +import { API_HOME_LAST_MODIFIED } from "./home"; +import { config } from "virtual:nimbus/config"; +import type { ApiPageProps } from "@cloudflare/nimbus-docs/api"; +import type { ApiHome as ApiHomeView } from "@cloudflare/nimbus-docs/runtime"; + +interface Props { + page: ApiPageProps; + home?: ApiHomeView; +} + +const { page, home } = Astro.props; +const homeChunk = page.kind === "api" && home && home.part.number > 1 ? home.part : undefined; +const title = homeChunk ? `${page.title}: endpoints ${homeChunk.start}–${homeChunk.end}` : page.title; +const markdownUrl = page.markdownHref; +const isHome = page.kind === "api"; +const intro = homeChunk ? undefined : page.descriptionHtml; +const subtitle = isHome && !homeChunk ? config.description : undefined; + +// Cloudflare notices, from the Forge extensions the prepared spec keeps. +const extensions = page.kind === "operation" ? page.extensions : undefined; +const rawAvailability = extensions?.["x-fern-availability"]; +const availability = typeof rawAvailability === "string" ? { status: rawAvailability } : rawAvailability; +const availabilityNote = !page.deprecated && availability?.message ? availability : undefined; +const AVAILABILITY: Record = { + alpha: { text: "Alpha", variant: "info" }, + beta: { text: "Beta", variant: "info" }, + preview: { text: "Preview", variant: "info" }, + "generally-available": { text: "Generally Available", variant: "success" }, + deprecated: { text: "Deprecated", variant: "caution" }, + legacy: { text: "Legacy", variant: "default" }, +}; +const availabilityStatus = page.kind === "operation" && page.deprecated ? "deprecated" : availability?.status; +const availabilityBadge = availabilityStatus + ? (AVAILABILITY[availabilityStatus] ?? { text: availabilityStatus.replaceAll("-", " "), variant: "default" as const }) + : undefined; +const requireConfirmation = extensions?.["x-forge-require-confirmation"]; +// Deprecation notice, built as sanitized HTML for the shared Banner (which takes +// a content string, mirroring DocsLayout's version-deprecation banner). +const esc = (s: string): string => + s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +const deprecationDetails = page.deprecated + ? [ + availability?.message && ` ${esc(availability.message)}`, + page.deprecation?.successor && + ` Use ${esc(page.deprecation.successor.label)} instead.`, + page.deprecation?.migrationHref && ` See the migration guide.`, + ].filter(Boolean) + : []; +const deprecationHtml = !page.deprecated + ? null + : deprecationDetails.length > 0 + ? `Deprecated.${deprecationDetails.join("")}` + : "This operation is deprecated."; +--- + +
+ + {deprecationHtml && } + {availabilityNote && ( + + )} + {requireConfirmation && ( + + )} +
+ +{/* Header order (locked): title → description → endpoint → page actions. The + endpoint bar carries the sidebar method chip + verb + path; page actions sit + beneath so they read as page-level utilities, not part of the endpoint. The + API home's description is its subtitle; the spec's intro follows as body. */} +
+
+

{title}

+ {availabilityBadge && } +
+ + {subtitle &&

{subtitle}

} + + {!isHome && intro &&
} + + {page.kind === "operation" && ( + + )} + + {!homeChunk && } +
From 54dc69e0327fe190fec3ca5b87b12a52ab959cc4 Mon Sep 17 00:00:00 2001 From: mohamedh Date: Tue, 3 Nov 2026 15:04:56 +0000 Subject: [PATCH 14/19] style: hide idle scrollbars like the starter --- packages/cloudflare-api-site/src/styles/globals.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cloudflare-api-site/src/styles/globals.css b/packages/cloudflare-api-site/src/styles/globals.css index 94dde9add..83be08698 100644 --- a/packages/cloudflare-api-site/src/styles/globals.css +++ b/packages/cloudflare-api-site/src/styles/globals.css @@ -278,6 +278,10 @@ body { } /* ---- Scrollbar ---- */ +* { + scrollbar-width: thin; + scrollbar-color: color-mix(in oklch, var(--nb-foreground) 20%, transparent) transparent; +} ::-webkit-scrollbar { width: 6px; height: 6px; From 27baea04b4a562bc436ec7b61ead4fc63d9337da Mon Sep 17 00:00:00 2001 From: mohamedh Date: Thu, 5 Nov 2026 10:52:39 +0000 Subject: [PATCH 15/19] build: use nimbus-docs 0.19.3 --- packages/cloudflare-api-site/nimbus.json | 2 +- packages/cloudflare-api-site/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- pnpm-workspace.yaml | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cloudflare-api-site/nimbus.json b/packages/cloudflare-api-site/nimbus.json index b31f49dcb..96878febb 100644 --- a/packages/cloudflare-api-site/nimbus.json +++ b/packages/cloudflare-api-site/nimbus.json @@ -1,7 +1,7 @@ { "$schema": "https://nimbus-docs.com/schema/nimbus.json", "version": "0.7.12", - "lastReviewedNimbusVersion": "0.19.2", + "lastReviewedNimbusVersion": "0.19.3", "templatesTag": "templates-v0.7.12", "variant": "empty", "registry": "https://nimbus-docs.com/registry", diff --git a/packages/cloudflare-api-site/package.json b/packages/cloudflare-api-site/package.json index 5cfc76b0d..2fd4d0ee1 100644 --- a/packages/cloudflare-api-site/package.json +++ b/packages/cloudflare-api-site/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@astrojs/cloudflare": "^15.0.0-beta.2", - "@cloudflare/nimbus-docs": "^0.19.2", + "@cloudflare/nimbus-docs": "^0.19.3", "@fontsource-variable/inter": "5.3.0", "@fontsource-variable/jetbrains-mono": "5.3.0", "@iconify-json/ph": "^1.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fb001b6f..f3976b99f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,8 +206,8 @@ importers: specifier: ^15.0.0-beta.2 version: 15.0.0-beta.2(@types/node@26.0.1)(astro@7.4.1(@types/node@26.0.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.1))(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(workerd@1.20261019.1)(yaml@2.9.1) '@cloudflare/nimbus-docs': - specifier: ^0.19.2 - version: 0.19.2(@readme/httpsnippet@11.4.0)(@scalar/openapi-parser@0.28.12)(@types/node@26.0.1)(astro@7.4.1(@types/node@26.0.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.1))(esbuild@0.28.2)(jiti@2.7.0)(openapi-sampler@1.7.4)(supports-color@10.2.2)(tsx@4.22.4) + specifier: ^0.19.3 + version: 0.19.3(@readme/httpsnippet@11.4.0)(@scalar/openapi-parser@0.28.12)(@types/node@26.0.1)(astro@7.4.1(@types/node@26.0.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.1))(esbuild@0.28.2)(jiti@2.7.0)(openapi-sampler@1.7.4)(supports-color@10.2.2)(tsx@4.22.4) '@fontsource-variable/inter': specifier: 5.3.0 version: 5.3.0 @@ -808,8 +808,8 @@ packages: '@cloudflare/config@0.27.0': resolution: {integrity: sha512-wNnL0z4lj5Gm2fze5qXTOvRPbUOi4jlMmwPk7ArCUakI5oVxgjfl3Rhnfd/Mmzo3g86x/ofH75yPIhsa5msqdQ==} - '@cloudflare/nimbus-docs@0.19.2': - resolution: {integrity: sha512-qlmwCcgI6sNdGAgbd8OGbH4asfxYy965DThKxrO6ek88JCnj5v/d+WxxYRghCkXEC1B6C4ZOcoPkMQHyHEOPCA==} + '@cloudflare/nimbus-docs@0.19.3': + resolution: {integrity: sha512-Y6J3smVzBOs+RNLjji0dvFFHXj2v/L13sbuDDW0YYTfa5nak2kGprGqj4h5Tf/P7QomIYcQuAFxgmPwqkpXlpA==} engines: {node: '>=22.12.0'} hasBin: true peerDependencies: @@ -5215,7 +5215,7 @@ snapshots: dependencies: zod: 4.6.5 - '@cloudflare/nimbus-docs@0.19.2(@readme/httpsnippet@11.4.0)(@scalar/openapi-parser@0.28.12)(@types/node@26.0.1)(astro@7.4.1(@types/node@26.0.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.1))(esbuild@0.28.2)(jiti@2.7.0)(openapi-sampler@1.7.4)(supports-color@10.2.2)(tsx@4.22.4)': + '@cloudflare/nimbus-docs@0.19.3(@readme/httpsnippet@11.4.0)(@scalar/openapi-parser@0.28.12)(@types/node@26.0.1)(astro@7.4.1(@types/node@26.0.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.1))(esbuild@0.28.2)(jiti@2.7.0)(openapi-sampler@1.7.4)(supports-color@10.2.2)(tsx@4.22.4)': dependencies: '@astrojs/markdown-satteri': 0.4.4 '@astrojs/mdx': 8.0.3(@astrojs/markdown-satteri@0.4.4)(astro@7.4.1(@types/node@26.0.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.1)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 87905fb3b..c9910db39 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,4 +10,4 @@ minimumReleaseAgeExclude: - astro@7.4.1 - cf@1.0.0-beta.19 - miniflare@5.20261019.0-beta - - '@cloudflare/nimbus-docs@0.19.2' + - '@cloudflare/nimbus-docs@0.19.3' From 6819dd5daa2f59ade4eba8ba6bff6ab594ece830 Mon Sep 17 00:00:00 2001 From: mohamedh Date: Thu, 5 Nov 2026 11:00:05 +0000 Subject: [PATCH 16/19] test: check the o11y redirect on an operation the spec still has --- packages/cloudflare-api-site/e2e/site.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cloudflare-api-site/e2e/site.e2e.ts b/packages/cloudflare-api-site/e2e/site.e2e.ts index a0017ccbd..065790d57 100644 --- a/packages/cloudflare-api-site/e2e/site.e2e.ts +++ b/packages/cloudflare-api-site/e2e/site.e2e.ts @@ -23,7 +23,7 @@ test.describe('routes', () => { ], ['/api/zero-trust/devices/ip-profiles/methods/list/index.md', 200, 'text/markdown'], ['/api/index.md', 200, 'text/markdown'], - ['/api/o11y/agents/sessions/methods/list/', 302, '', '/api/observability/agents/sessions/methods/list/'], + ['/api/o11y/agents/traces/methods/list/', 302, '', '/api/observability/agents/traces/methods/list/'], ['/api/hyperdrive/integration/planetscale/', 302, '', '/api/hyperdrive/'], ['/api/tags/', 302, '', '/api/tagging/'], ['/api/tags/keys/methods/list/', 302, '', '/api/tagging/keys/methods/list/'], From 14f6c5b42f0ff8dcd5f44a32a6107a2786b6abab Mon Sep 17 00:00:00 2001 From: mohamedh Date: Thu, 5 Nov 2026 17:34:51 +0000 Subject: [PATCH 17/19] fix: address api page review feedback --- packages/cloudflare-api-site/README.md | 7 ++-- packages/cloudflare-api-site/e2e/site.e2e.ts | 8 +---- .../src/components/Header.astro | 2 +- .../ui/api-code-rail/code-rail.client.ts | 13 +++++++ .../components/ui/api-code-rail/code-rail.css | 30 +++++++++++++++- .../ui/api-field-row/api-field-row.client.ts | 5 ++- .../src/components/ui/api-field-row/index.ts | 6 ++-- .../components/ui/api-layout/ApiBody.astro | 2 +- .../ui/api-layout/ApiEndpointCard.astro | 8 ++--- .../components/ui/api-layout/ApiLayout.astro | 2 +- .../ui/api-layout/ApiPageHeader.astro | 3 +- .../ui/api-layout/hash-target.client.ts | 25 +++++++++++++ .../src/components/ui/api-layout/home.ts | 5 --- .../ui/api-layout/responses.client.ts | 9 ----- .../cloudflare-api-site/src/forge-api.test.ts | 7 ++-- packages/cloudflare-api-site/src/forge-api.ts | 17 +++------ .../src/openapi-source.test.ts | 36 +++++-------------- .../cloudflare-api-site/src/openapi-source.ts | 25 ++++--------- .../src/styles/globals.css | 1 + .../cloudflare-api-site/src/terraform.test.ts | 3 -- packages/cloudflare-api-site/src/terraform.ts | 4 --- 21 files changed, 110 insertions(+), 108 deletions(-) create mode 100644 packages/cloudflare-api-site/src/components/ui/api-layout/hash-target.client.ts delete mode 100644 packages/cloudflare-api-site/src/components/ui/api-layout/responses.client.ts diff --git a/packages/cloudflare-api-site/README.md b/packages/cloudflare-api-site/README.md index a23145829..6c235bbf6 100644 --- a/packages/cloudflare-api-site/README.md +++ b/packages/cloudflare-api-site/README.md @@ -21,7 +21,7 @@ openapi.forge.json (latest stable Forge release) prepareForgeApi (src/forge-api.ts) | v - .forge/ spec, products, redirects, release + .forge/ spec, products, redirects | v Nimbus api collection @@ -34,15 +34,14 @@ release, loaded by `src/openapi-source.ts`. Fern SDK group and method metadata d resource hierarchy, and method route. OpenAPI tags remain internal ownership metadata and do not appear in URLs. -`prepareForgeApi` writes four files to the git-ignored `.forge/` directory: +`prepareForgeApi` writes three files to the git-ignored `.forge/` directory: - `openapi-.json`: the spec Nimbus renders, with Cloudflare titles, `x-nimbus-order` for lifecycle order, request samples, synthesized response examples, and Terraform data under `x-cloudflare-terraform`; - `products.json`: the products and their slugs, read by the command sidebar; - `redirects.json`: old section, alias-operation, and moved-product URLs, read by - `src/middleware.ts`; -- `release.json`: the spec's release date, shown on the API home page. + `src/middleware.ts`. Operations with removed Forge metadata are left out with a build warning. Operations `astro-fern` gives no URL are left out silently. diff --git a/packages/cloudflare-api-site/e2e/site.e2e.ts b/packages/cloudflare-api-site/e2e/site.e2e.ts index bb523022b..5dcb86dc5 100644 --- a/packages/cloudflare-api-site/e2e/site.e2e.ts +++ b/packages/cloudflare-api-site/e2e/site.e2e.ts @@ -61,7 +61,6 @@ test("home keeps its title, description and the sidebar's top entries", async ({ await expect(header.locator('[data-slot="page-description"]')).toHaveText( 'API reference generated from the Cloudflare OpenAPI spec by Forge.', ); - await expect(header.locator('[data-nb-page-actions] time[datetime]')).toBeVisible(); await expect(header).not.toContainText('Welcome to Cloudflare'); await expect(page.locator('main')).toContainText("Welcome to Cloudflare's API documentation site"); const nav = page.locator('[data-nb-api-nav]'); @@ -100,12 +99,7 @@ test('code sample picker defaults to the cf CLI and switches samples', async ({ await page.goto(operation); const picker = page.locator('[data-nb-api-coderail] select[data-nb-lang-select]').first(); await expect(picker).toHaveValue('cf'); - await expect(picker.locator('option')).toHaveText([ - 'cf CLI', - 'curl', - 'data cloudflare_zero_trust_device_ip_profile', - 'data cloudflare_zero_trust_device_ip_profiles', - ]); + await expect(picker.locator('option')).toHaveText(['cf CLI', 'curl', 'Terraform']); await picker.selectOption('curl'); await expect(page).toHaveURL(/[?&]lang=curl\b/); await expect(page.locator('[data-nb-api-coderail] [data-nb-sample-lang="curl"]').first()).toBeVisible(); diff --git a/packages/cloudflare-api-site/src/components/Header.astro b/packages/cloudflare-api-site/src/components/Header.astro index f05a307f6..72a1efafc 100644 --- a/packages/cloudflare-api-site/src/components/Header.astro +++ b/packages/cloudflare-api-site/src/components/Header.astro @@ -49,7 +49,7 @@ const { showSidebar = true } = Astro.props; align-items: center; justify-content: space-between; gap: 0.75rem; - height: 3.5rem; + height: var(--nb-header-height); padding-inline: var(--site-header-inset); } diff --git a/packages/cloudflare-api-site/src/components/ui/api-code-rail/code-rail.client.ts b/packages/cloudflare-api-site/src/components/ui/api-code-rail/code-rail.client.ts index a1ff307cd..8384e38b9 100644 --- a/packages/cloudflare-api-site/src/components/ui/api-code-rail/code-rail.client.ts +++ b/packages/cloudflare-api-site/src/components/ui/api-code-rail/code-rail.client.ts @@ -45,6 +45,18 @@ function initCodeRail(container: HTMLElement): () => void { }; rails.add(showLanguage); + // The copy button sits in the header but is injected after its code; move it + // ahead so Tab reaches it before the code it copies. + const raiseCopyButtons = () => { + for (const panel of panels) { + const button = panel.querySelector(".nb-cr-body .nb-code-copy"); + if (button) panel.prepend(button); + } + }; + raiseCopyButtons(); + const copyButtons = new MutationObserver(raiseCopyButtons); + for (const panel of panels) copyButtons.observe(panel, { childList: true, subtree: true }); + const onChange = () => { const panel = panelFor(select.value); if (!panel) return; @@ -80,6 +92,7 @@ function initCodeRail(container: HTMLElement): () => void { return () => { rails.delete(showLanguage); + copyButtons.disconnect(); select.removeEventListener("change", onChange); window.removeEventListener("storage", onStorage); }; diff --git a/packages/cloudflare-api-site/src/components/ui/api-code-rail/code-rail.css b/packages/cloudflare-api-site/src/components/ui/api-code-rail/code-rail.css index ecadbb0e4..4c810e173 100644 --- a/packages/cloudflare-api-site/src/components/ui/api-code-rail/code-rail.css +++ b/packages/cloudflare-api-site/src/components/ui/api-code-rail/code-rail.css @@ -81,6 +81,11 @@ outline: 2px solid var(--color-ring, var(--nb-cr-accent)); outline-offset: -1px; } +[data-nb-api-coderail] .nb-cr-body pre:focus-visible, +[data-nb-api-coderail] .nb-cr-respbody:focus-visible { + outline: 2px solid var(--color-ring, var(--nb-cr-accent)); + outline-offset: -2px; +} /* A native