diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 058155fd9..81c540434 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -13,9 +13,9 @@ ".cloudflare", ".wrangler", "Cargo.toml", - "packages/cloudflare-api-site/src/components/ui/**/*.ts", + "packages/cloudflare-api-site/src/components/ui/**", "packages/cloudflare-api-site/src/generated", - "packages/cloudflare-api-site/src/lib/cn.ts", + "packages/cloudflare-api-site/src/lib/**", "packages/cloudflare-api-site/src/styles/globals.css" ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 1d38db095..cb7830771 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -9,7 +9,15 @@ "typescript/no-floating-promises": "warn", "typescript/no-misused-promises": "warn" }, - "ignorePatterns": ["node_modules", "dist", "**/_generated/**", ".cloudflare", ".wrangler"], + "ignorePatterns": [ + "node_modules", + "dist", + "**/_generated/**", + ".cloudflare", + ".wrangler", + "packages/cloudflare-api-site/src/components/ui/**", + "packages/cloudflare-api-site/src/lib/**" + ], "overrides": [ { "files": ["packages/astro-fern/**", "packages/cloudflare-api-site/**", "packages/fern-forge/**"], diff --git a/packages/cloudflare-api-site/.gitignore b/packages/cloudflare-api-site/.gitignore index 1aa5cd537..9c03428b2 100644 --- a/packages/cloudflare-api-site/.gitignore +++ b/packages/cloudflare-api-site/.gitignore @@ -2,10 +2,5 @@ dist/ .astro/ .cloudflare/ .wrangler/ -public/_astro-fern/ - -# The repo-root .gitignore ignores any `docs/` directory; the Nimbus docs -# collection lives at src/content/docs/, so re-include it here. -!src/content/docs/ -!src/content/docs/** .nimbus/ +.forge/ diff --git a/packages/cloudflare-api-site/README.md b/packages/cloudflare-api-site/README.md index 51345a2e6..9587aa3fe 100644 --- a/packages/cloudflare-api-site/README.md +++ b/packages/cloudflare-api-site/README.md @@ -3,152 +3,172 @@ 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 + | + 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 three 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`. -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. 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. +- `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. Its frontmatter + leaves out the description, which the body repeats. +- `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=curl ``` -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=curl -/api/zero-trust/devices/ip-profiles/methods/list.md -/api/zero-trust/devices/ip-profiles/methods/list.md?lang=curl +/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 +176,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 +191,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. diff --git a/packages/cloudflare-api-site/astro.config.mjs b/packages/cloudflare-api-site/astro.config.mjs index 66da6ec2b..9d00c87ef 100644 --- a/packages/cloudflare-api-site/astro.config.mjs +++ b/packages/cloudflare-api-site/astro.config.mjs @@ -3,7 +3,7 @@ import cloudflare from '@astrojs/cloudflare'; import nimbus, { defineConfig as defineNimbusConfig } from '@cloudflare/nimbus-docs'; import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'astro/config'; -import astroFern from 'astro-fern'; +import { forgeApi } from './src/forge-api.ts'; const site = 'https://docs.experiments.devprod.cloudflare.dev'; @@ -17,6 +17,8 @@ if (internalSiteDomain) { allowedDomains.push({ protocol: 'https', hostname: internalSiteDomain }); } +const api = await forgeApi(); + const nimbusConfig = defineNimbusConfig({ site, title: 'Cloudflare API', @@ -24,9 +26,8 @@ const nimbusConfig = defineNimbusConfig({ locale: 'en', github: null, search: false, - // Authored pages read the API catalog and the selected version at request - // time, so the docs collection renders on demand like the API pages. rendering: { default: 'request' }, + api: [api], }); export default defineConfig({ @@ -37,21 +38,37 @@ export default defineConfig({ security: { allowedDomains }, vite: { plugins: [tailwindcss()], - // astro-fern builds operation artifact URLs from `site`, and in dev the - // Cloudflare Vite plugin forwards ASSETS fetches to this server with that - // host. Allow it so Vite's host check doesn't reject them with a 403. - server: { allowedHosts: [new URL(site).hostname] }, }, 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', 'nimbus/internal-link': 'error', }, }), - astroFern({ - routing: { base: '/', target: 'hash' }, - agents: { injectRoutes: false, markdown: false, llms: false }, - }), ], }); diff --git a/packages/cloudflare-api-site/e2e/site.e2e.ts b/packages/cloudflare-api-site/e2e/site.e2e.ts index 771c268b7..435baf7ea 100644 --- a/packages/cloudflare-api-site/e2e/site.e2e.ts +++ b/packages/cloudflare-api-site/e2e/site.e2e.ts @@ -3,71 +3,193 @@ 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=curl`, 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=curl', 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/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/'], + ['/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).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('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('operation page renders the operation and its samples', async ({ page }) => { +test('operation page renders the operation, its samples, its response example and the version', 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( + 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="cf"]').first()).toBeVisible(); + await expect(page.locator('[data-nb-api-coderail] [data-nb-sample-lang="cf"]').first()).toContainText( 'cf zero-trust devices ip-profiles list', ); + 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('execution target selector switches the code sample', async ({ page }) => { +test('code sample picker defaults to the cf CLI and switches samples', async ({ page }) => { await page.goto(operation); - const target = page.locator('select[data-forge-target]'); - await expect(target).toHaveValue('cf'); - await expect(target.locator('option')).toHaveText(['cf CLI', 'curl', 'Terraform']); - await target.selectOption('curl'); + 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', 'Terraform']); + await picker.selectOption('curl'); await expect(page).toHaveURL(/[?&]lang=curl\b/); - await expect(page.locator('select[data-forge-target]')).toHaveValue('curl'); - await expect(page.locator('.forge-sample-code code.language-bash').first()).toContainText( - 'https://api.cloudflare.com/client/v4/accounts/', - ); + await expect(page.locator('[data-nb-api-coderail] [data-nb-sample-lang="curl"]').first()).toBeVisible(); }); -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.split('\n---\n')[0]).not.toContain('description:'); + expect(markdown).toContain('Lists WARP Device 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 705d7921e..74ccb7dcc 100644 --- a/packages/cloudflare-api-site/e2e/visual.e2e.ts +++ b/packages/cloudflare-api-site/e2e/visual.e2e.ts @@ -5,6 +5,7 @@ const pages = { product: '/api/zero-trust/', resource: '/api/zero-trust/devices/ip-profiles/', operation: '/api/zero-trust/devices/ip-profiles/methods/list/', + 'operation-terraform': '/api/dns/records/methods/create/?lang=hcl', 'operation-curl': '/api/zero-trust/devices/ip-profiles/methods/list/?lang=curl', tooling: '/api/tooling/', 'not-found': '/api/nope.txt', @@ -12,15 +13,7 @@ const pages = { 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)) { diff --git a/packages/cloudflare-api-site/nimbus.json b/packages/cloudflare-api-site/nimbus.json index cf64c5455..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.18.0", + "lastReviewedNimbusVersion": "0.19.3", "templatesTag": "templates-v0.7.12", "variant": "empty", "registry": "https://nimbus-docs.com/registry", @@ -14,5 +14,214 @@ "@/*": "src/*" } }, - "components": [] + "components": [ + { + "slug": "cn", + "type": "registry:lib", + "version": "0.19.0", + "source": "https://nimbus-docs.com/registry", + "hash": "sha256:7c54894573a1398333b41a63838db5de40ba05ad3fdb71048414baf912bb0070", + "files": ["src/lib/cn.ts"], + "fileHashes": { + "src/lib/cn.ts": "sha256:4575f5bcc29804b00c7cc1e3090aec2194b462bdd3c33a5620dc47d09bfe26d8" + } + }, + { + "slug": "collapsible", + "type": "registry:ui", + "version": "0.19.0", + "source": "https://nimbus-docs.com/registry", + "hash": "sha256:b0e52e1cb2631065fe013b1171be0970ced4bf8fa7658c1b7cba60c01fe18d45", + "files": [ + "src/components/ui/collapsible/Collapsible.astro", + "src/components/ui/collapsible/CollapsibleContent.astro", + "src/components/ui/collapsible/CollapsibleTrigger.astro", + "src/components/ui/collapsible/collapsible.client.ts", + "src/components/ui/collapsible/index.ts" + ], + "fileHashes": { + "src/components/ui/collapsible/Collapsible.astro": "sha256:148476eea68d73e8ac8874797dab242efcbb3ab1c92f5c2eefb7326965fc4e60", + "src/components/ui/collapsible/CollapsibleContent.astro": "sha256:51bedf24e5a9404f18f1801c6c9cbdcdccba829abfb9c7279a6e202177cd6106", + "src/components/ui/collapsible/CollapsibleTrigger.astro": "sha256:7d59886fd63bd29717a09227eb53e77efa383338fec9913b838926fb424307bf", + "src/components/ui/collapsible/collapsible.client.ts": "sha256:fb861d33e17cc5db74bd6a932269d3cff6775ce17959509f97e211eba25708bd", + "src/components/ui/collapsible/index.ts": "sha256:ee9f123942f7e2348f4e6ea3b42e52e55964c77e5fedb2ab24b7bfae75b0bbfd" + } + }, + { + "slug": "api-sidebar", + "type": "registry:ui", + "version": "0.19.0", + "source": "https://nimbus-docs.com/registry", + "hash": "sha256:41d432bb3e12a07b704c475b717d9f5942eeb84de9d0190ef77ad5ef7dc0ebfb", + "files": [ + "src/components/ui/api-sidebar/ApiMethodChip.astro", + "src/components/ui/api-sidebar/ApiSidebar.astro", + "src/components/ui/api-sidebar/ApiSidebarItem.astro", + "src/components/ui/api-sidebar/api-nav-filter.client.ts", + "src/components/ui/api-sidebar/index.ts", + "src/components/ui/api-sidebar/method.ts" + ], + "fileHashes": { + "src/components/ui/api-sidebar/ApiMethodChip.astro": "sha256:c40ee4ee67b0f2b66db30760756d0bc1fb3f4d37a3046272f1663dc7a9d7988d", + "src/components/ui/api-sidebar/ApiSidebar.astro": "sha256:64ae08b7d41823f9f6b7aa76ce0a5772006dcbf895cc19af25c7be076fbe10dc", + "src/components/ui/api-sidebar/ApiSidebarItem.astro": "sha256:a9a7c97c61ab7f6b0b4b4582e4448f40abbf0567b353b7d3744c546192b19588", + "src/components/ui/api-sidebar/api-nav-filter.client.ts": "sha256:099654abe0d02aa2c12b8da40f08be46858e2fb4f552cfc640965e915e258c74", + "src/components/ui/api-sidebar/index.ts": "sha256:1d027fa7b01ffaa4461b0b0165d2fcb7bed9fa0f44dac72d5fc3e4c275877573", + "src/components/ui/api-sidebar/method.ts": "sha256:5b68e560c4c2729e569ac03468269c31c1d16468651061730e77dbea9900c130" + } + }, + { + "slug": "api-field-row", + "type": "registry:ui", + "version": "0.19.0", + "source": "https://nimbus-docs.com/registry", + "hash": "sha256:d25541a78637136378e70027c1975700cff60678d6a8e7e1a627995f732fabd4", + "files": [ + "src/components/ui/api-field-row/ApiFieldList.astro", + "src/components/ui/api-field-row/ApiFieldRow.astro", + "src/components/ui/api-field-row/ApiUnionExplorer.astro", + "src/components/ui/api-field-row/api-field-row.client.ts", + "src/components/ui/api-field-row/field-list.css", + "src/components/ui/api-field-row/index.ts", + "src/components/ui/api-field-row/type-display.ts" + ], + "fileHashes": { + "src/components/ui/api-field-row/ApiFieldList.astro": "sha256:cc400943531f5c8d32a7d4558f8de5eff6bda39a0ae1807560a5da1aeed6ee1a", + "src/components/ui/api-field-row/ApiFieldRow.astro": "sha256:5b6bced35cb72c32451d5fcc8166c3b42e72b0a526fad2613057365a0fb863d9", + "src/components/ui/api-field-row/ApiUnionExplorer.astro": "sha256:73084f8b2bde5abe2adb765df3b0ed4afb6c51aaee88fb3496d84a078953d0b2", + "src/components/ui/api-field-row/api-field-row.client.ts": "sha256:c6622936f6f652556f0b138785f1ca8795576a4e8f802526db4603e5962201ba", + "src/components/ui/api-field-row/field-list.css": "sha256:5419b59655d8bd95c8d2c3a9a4e60ea0bd756ce873b06b9021cd847bb0cdc19b", + "src/components/ui/api-field-row/index.ts": "sha256:48442d96649611ff858e93ad18155e267ba7137ef5dfb9d6e83420d9fff50cb2", + "src/components/ui/api-field-row/type-display.ts": "sha256:f572ebafa6775316008c1186075060b333df58d2a04242ce521773a0f04cd18f" + } + }, + { + "slug": "layer-card", + "type": "registry:ui", + "version": "0.19.0", + "source": "https://nimbus-docs.com/registry", + "hash": "sha256:ff1f8173d09b8523284fb8777be508b71a7519553a3960e340387426c3d24b1f", + "files": [ + "src/components/ui/layer-card/LayerCard.astro", + "src/components/ui/layer-card/LayerCardContent.astro", + "src/components/ui/layer-card/LayerCardHeader.astro", + "src/components/ui/layer-card/index.ts" + ], + "fileHashes": { + "src/components/ui/layer-card/LayerCard.astro": "sha256:0803c1f9fe69649466526a7266e64a1cf83edf7a8d06978fb2629e57f961bb9d", + "src/components/ui/layer-card/LayerCardContent.astro": "sha256:702b2d01f6219fa4feb02ca14475bdb6ad8736c4e800b24a30f3b65495c22638", + "src/components/ui/layer-card/LayerCardHeader.astro": "sha256:97bf782f09930c2e7a6158531f20291d2acf6830d0b61c0ec233c2f5d5b0db53", + "src/components/ui/layer-card/index.ts": "sha256:165fe2d8ac54c9dd5679725bb6767e11bf57c3301b5c630a8ec9afa6ae47bc8e" + } + }, + { + "slug": "api-code-rail", + "type": "registry:ui", + "version": "0.19.0", + "source": "https://nimbus-docs.com/registry", + "hash": "sha256:08bbd9c0bb6a97b08a89941d581ef848d1f7db16529cabaf4dd1b3e9ccc777a0", + "files": [ + "src/components/ui/api-code-rail/ApiCodeRail.astro", + "src/components/ui/api-code-rail/code-rail.client.ts", + "src/components/ui/api-code-rail/code-rail.css", + "src/components/ui/api-code-rail/index.ts" + ], + "fileHashes": { + "src/components/ui/api-code-rail/ApiCodeRail.astro": "sha256:92fed11d92fc4d3f5e15741cb226704335197f04f864d2211df531ec48b8613f", + "src/components/ui/api-code-rail/code-rail.client.ts": "sha256:e460f5ed2895b64153cac02a7c51918435496aa6139eb413bddf5c16a9863bdb", + "src/components/ui/api-code-rail/code-rail.css": "sha256:3fe78830f81bd692834fde2165414be9a815037209d8ffe1a4adf3500bc9151f", + "src/components/ui/api-code-rail/index.ts": "sha256:e42620fbe3b0b530139aa50748cab8f4521a58a2522515696d212f11381f2a98" + } + }, + { + "slug": "pagination", + "type": "registry:ui", + "version": "0.19.0", + "source": "https://nimbus-docs.com/registry", + "hash": "sha256:4774f88a71e134759faf409114f5d8efdbfa6dd963214815a990667cdf3771c3", + "files": [ + "src/components/ui/pagination/Pagination.astro", + "src/components/ui/pagination/PaginationKey.astro", + "src/components/ui/pagination/PaginationLink.astro", + "src/components/ui/pagination/index.ts", + "src/components/ui/pagination/pagination.client.ts" + ], + "fileHashes": { + "src/components/ui/pagination/Pagination.astro": "sha256:0429c94c1d33b36043083a13497ae334e50b24c2f16548c3d587f8661ea03a3e", + "src/components/ui/pagination/PaginationKey.astro": "sha256:862c2d088f6a1c5d1f88134a1cb391bd9b58d8ed0870f85425fb6e619b2278ef", + "src/components/ui/pagination/PaginationLink.astro": "sha256:3a54023a27297913de445dba4c875e76d1abbdbe11015fc34561430bcab8bd1d", + "src/components/ui/pagination/index.ts": "sha256:a56cc406fa1d608dc4b1b174de1c4d9b8fed3559e8a3bc23d9bc7d13af52683d", + "src/components/ui/pagination/pagination.client.ts": "sha256:2a5b7cc123810ec8670ae09f58404a870c41d795b110bde5208af1bead4f0715" + } + }, + { + "slug": "popover", + "type": "registry:ui", + "version": "0.19.0", + "source": "https://nimbus-docs.com/registry", + "hash": "sha256:1e9c5da6177a5cd296f2fcd3be00b3ff25dca92eb5259bf5f9f938b9784c929d", + "files": [ + "src/components/ui/popover/Popover.astro", + "src/components/ui/popover/PopoverContent.astro", + "src/components/ui/popover/PopoverTrigger.astro", + "src/components/ui/popover/index.ts", + "src/components/ui/popover/popover.client.ts" + ], + "fileHashes": { + "src/components/ui/popover/Popover.astro": "sha256:e17e73037fb07134a905181a53507396825a0295ce6624d3d604d92836b4993b", + "src/components/ui/popover/PopoverContent.astro": "sha256:11df5954a76f0e8be53f5a87402683dd26381d984f42f6beb9f2d4e99b94254f", + "src/components/ui/popover/PopoverTrigger.astro": "sha256:51ebfe033cadd5a287657eca1aa39ff201739dc885415dff37e278da42ebfb83", + "src/components/ui/popover/index.ts": "sha256:3ebe8e8356065dc37af316224a4b266c80234641eea82c29b8c6e7e3d372c5fc", + "src/components/ui/popover/popover.client.ts": "sha256:63255680808d8ce01b59dcfd041c1e66e3b7ccde8ea5fc04e9149fabdf15ca3e" + } + }, + { + "slug": "version-switcher", + "type": "registry:ui", + "version": "0.19.0", + "source": "https://nimbus-docs.com/registry", + "hash": "sha256:b12bbdb26b876403cddd83472c7a75653041c1722e0a283cfae4b09afd3118d2", + "files": [ + "src/components/ui/version-switcher/VersionSwitcher.astro", + "src/components/ui/version-switcher/index.ts" + ], + "fileHashes": { + "src/components/ui/version-switcher/VersionSwitcher.astro": "sha256:acdbd5e2da6a77df70db0cf461b4bc7c94cd87355d4b17c22695ce48afdd243c", + "src/components/ui/version-switcher/index.ts": "sha256:32762ad0d535d1bcaca0babda65e812cc245df2b40e9bc2408ec39f8f16e5289" + } + }, + { + "slug": "api-layout", + "type": "registry:ui", + "version": "0.19.0", + "source": "https://nimbus-docs.com/registry", + "hash": "sha256:7d4a0179429f39f6762cde98e62c44249cf526426ab8fe30a2346b25244b83c4", + "files": [ + "src/components/ui/api-layout/ApiBody.astro", + "src/components/ui/api-layout/ApiEndpointCard.astro", + "src/components/ui/api-layout/ApiHome.astro", + "src/components/ui/api-layout/ApiHomeChunk.astro", + "src/components/ui/api-layout/ApiLayout.astro", + "src/components/ui/api-layout/ApiOperationList.astro", + "src/components/ui/api-layout/auth.ts", + "src/components/ui/api-layout/home.ts", + "src/components/ui/api-layout/index.ts", + "src/components/ui/api-layout/request-examples.client.ts", + "src/components/ui/api-layout/responses.client.ts" + ], + "fileHashes": { + "src/components/ui/api-layout/ApiBody.astro": "sha256:e494d7e104230efbe1c9d511f4d188cd8f906c74a21ac6fc88a1eb40a2bd19c1", + "src/components/ui/api-layout/ApiEndpointCard.astro": "sha256:c2c9452b6437ab28b2bc0e05eef46adb7d0b8d7fa00b03a8a86a0bb1a950c33e", + "src/components/ui/api-layout/ApiHome.astro": "sha256:1faab0929f99a40dbdb21a6931fc3cc6f7803cbace5f8c13b570fdaabe600780", + "src/components/ui/api-layout/ApiHomeChunk.astro": "sha256:cef7d05de7afe020533d99caf47dd4d6af3f5d00f9221570d11bfcd649bee479", + "src/components/ui/api-layout/ApiLayout.astro": "sha256:b69a947c22e3e2f353c059127e8884ca193738122b929d31351189131bfa8f8b", + "src/components/ui/api-layout/ApiOperationList.astro": "sha256:583e40d40d7f03eaa81a276513f2ae80eeec176026277fb092f587913e7d893c", + "src/components/ui/api-layout/auth.ts": "sha256:11f778ac6e5836488b4984e33ed7b4267b6cb58aee6b6e7c03e5af4d892b8903", + "src/components/ui/api-layout/home.ts": "sha256:43f3cead23c5f82f3e0794e50568286acb8c7eec8a0a258eddf71adeff4beb4a", + "src/components/ui/api-layout/index.ts": "sha256:0015f42dc002939832a70dbf40b7866b6a005afda4631b0e3cbe500bae04d130", + "src/components/ui/api-layout/request-examples.client.ts": "sha256:d658f5fadff046fd74b4c97f9748547397fdd1d0b4028540849dbd313c4680a3", + "src/components/ui/api-layout/responses.client.ts": "sha256:b3de1837790b97a2ddc4e2589238a4fc64dbd4e2c1a08af0a6cfd30539940bfc" + } + } + ] } diff --git a/packages/cloudflare-api-site/package.json b/packages/cloudflare-api-site/package.json index 0fc7a4566..3e1defbba 100644 --- a/packages/cloudflare-api-site/package.json +++ b/packages/cloudflare-api-site/package.json @@ -15,18 +15,19 @@ }, "dependencies": { "@astrojs/cloudflare": "^15.0.0-beta.2", - "@astrojs/prism": "^4.0.2", - "@cloudflare/nimbus-docs": "^0.18.0", + "@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", "@iconify-json/simple-icons": "^1.2.99", + "@scalar/openapi-parser": "0.28.12", "astro": "^7.4.1", "astro-fern": "workspace:*", "cf": "1.0.0-beta.27", "clsx": "^2.1.1", "fern-forge": "workspace:*", "hast-util-sanitize": "^5.0.2", + "openapi-sampler": "^1.7.4", "satteri": "^0.10.5", "tailwind-merge": "^3.7.0" }, 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, + }, }); 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 28ce8cef6..000000000 --- a/packages/cloudflare-api-site/src/api-server.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { base } from 'astro:config/server'; -import type { FernPageSchema } from 'astro-fern'; -import type { FernArtifactFetcher } from 'astro-fern/live'; -import { - type FernOperationLoadOptions, - getFernContentCatalog, - getFernOperation, - getFernPage, - getFernRoutePlan, -} from 'astro-fern/server'; -import { - type ApiOperationSelection, - type ApiPageSelection, - type ApiProductSelection, - type ApiResourceSelection, - ApiRouter, - type ApiSectionSelection, -} from './api-routing.ts'; -import { requireAssetFetcher } from './artifact-fetcher.ts'; -import { assertToolingRouteNamespace } from './command-reference/routing.ts'; -import { withoutForgeAliases } from './forge-aliases.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((source) => { - const plan = { ...source, catalog: withoutForgeAliases(source.catalog) }; - 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; -} - -/** - * The content catalog limited to the products the router serves, so agent - * indexes omit products that exist only through Forge aliases. Alias - * operations inside served products have no route and are skipped. - */ -export async function getApiContentCatalog(): Promise>> { - const [router, catalog] = await Promise.all([getApiRouter(), getFernContentCatalog()]); - const served = new Set(router.plan.catalog.products.map((product) => product.id)); - return { ...catalog, products: catalog.products.filter((product) => served.has(product.id)) }; -} - -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?.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/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/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/Header.astro b/packages/cloudflare-api-site/src/components/Header.astro index 13f93c548..72a1efafc 100644 --- a/packages/cloudflare-api-site/src/components/Header.astro +++ b/packages/cloudflare-api-site/src/components/Header.astro @@ -2,7 +2,7 @@ import Icon from '@cloudflare/nimbus-docs/components/Icon.astro'; import { ThemeToggle } from './ui/theme-toggle'; import SiteTitle from './SiteTitle.astro'; -import VersionSelect from './VersionSelect.astro'; +import { VersionSwitcher } from './ui/version-switcher'; interface Props { /** Whether the page has a sidebar the mobile menu button can open. */ @@ -19,7 +19,7 @@ const { showSidebar = true } = Astro.props;
- + { showSidebar && ( @@ -44,12 +44,13 @@ const { showSidebar = true } = Astro.props; } .site-header-bar { + --site-header-inset: 1rem; display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; - height: 3.5rem; - padding-inline: 1rem; + height: var(--nb-header-height); + padding-inline: var(--site-header-inset); } .site-header-brand { @@ -99,7 +100,7 @@ const { showSidebar = true } = Astro.props; @media (min-width: 64rem) { .site-header-bar { - padding-inline: 1.5rem; + --site-header-inset: 1.5rem; } } 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/SiteTitle.astro b/packages/cloudflare-api-site/src/components/SiteTitle.astro index 42a3b015c..316910a71 100644 --- a/packages/cloudflare-api-site/src/components/SiteTitle.astro +++ b/packages/cloudflare-api-site/src/components/SiteTitle.astro @@ -39,7 +39,6 @@ const links = [ .cloudflare-site-title { min-width: 0; - gap: 1rem; } .cloudflare-brand { @@ -117,6 +116,15 @@ const links = [ } @media (min-width: 64rem) { + .cloudflare-site-title { + display: grid; + grid-template-columns: calc(var(--nb-sidebar-width) - var(--site-header-inset)) auto; + } + + .cloudflare-brand { + justify-self: start; + } + nav { display: flex; } diff --git a/packages/cloudflare-api-site/src/components/TerraformDeclarations.astro b/packages/cloudflare-api-site/src/components/TerraformDeclarations.astro index a86802b06..f2d8c2e58 100644 --- a/packages/cloudflare-api-site/src/components/TerraformDeclarations.astro +++ b/packages/cloudflare-api-site/src/components/TerraformDeclarations.astro @@ -3,11 +3,10 @@ import { terraformRolesSummary, type TerraformOperationData } from '../terraform import TerraformFields from './TerraformFields.astro'; interface Props { - operation?: TerraformOperationData; - declarationOffset?: number; + operation: TerraformOperationData; } -const { operation, declarationOffset = 0 } = Astro.props; +const { operation } = Astro.props; const groups = [ { id: 'required', label: 'Required', fields: 'required' as const }, { id: 'optional', label: 'Optional', fields: 'optional' as const }, @@ -22,35 +21,30 @@ const declarationKindLabels = { --- { - operation ? ( - operation.declarations.map((declaration, declarationIndex) => ( -
-
- {declarationKindLabels[declaration.kind]} -

{declaration.name}

-

{terraformRolesSummary(declaration)}

-
- {declaration.descriptionHtml ? ( -
- ) : declaration.description ? ( -

{declaration.description}

- ) : null} - {groups.map((group) => - declaration[group.fields].length > 0 ? ( -
-

{group.label}

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

Terraform

-

This operation is not implemented in Terraform.

+ operation.declarations.map((declaration, index) => ( +
+
+ {declarationKindLabels[declaration.kind]} +

+ {declaration.name} +

+

{terraformRolesSummary(declaration)}

+
+ {declaration.descriptionHtml ? ( +
+ ) : declaration.description ? ( +

{declaration.description}

+ ) : null} + {groups.map((group) => + declaration[group.fields].length > 0 ? ( +
+

{group.label}

+ +
+ ) : null, + )}
- ) + )) } diff --git a/packages/cloudflare-api-site/src/components/ui/api-code-rail/ApiCodeRail.astro b/packages/cloudflare-api-site/src/components/ui/api-code-rail/ApiCodeRail.astro new file mode 100644 index 000000000..f5103865e --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-code-rail/ApiCodeRail.astro @@ -0,0 +1,173 @@ +--- +/** + * ApiCodeRail — the operation's request/response sample rail: + * a faint outer LayerCard wrapping a RAISED request + * surface (a header carrying the method chip + title on the left and a language + * : every sample + * ships in the HTML (crawlable — the first is visible, the rest reachable once + * `code-rail.client` wires the select's change handler). Options and panels are + * keyed by `sample.id`, not `sample.lang`, so two samples in one language (an + * SDK call and a raw request) each get their own option; each panel carries its + * `lang` so the client can save the reader's language across pages. + */ +import { cn } from "@/lib/cn"; +import ApiMethodChip from "@/components/ui/api-sidebar/ApiMethodChip.astro"; +import { LayerCard, LayerCardHeader } from "@/components/ui/layer-card"; +import "./code-rail.css"; +import type { + ApiExampleView, + ApiOperationPage, + ApiResponseView, +} from "@cloudflare/nimbus-docs/api"; + +interface Props + extends Pick { + /** Operation title, shown in the request card header. */ + title: string; + class?: string; +} + +const { responses, samples, title, method, coordinate, class: className } = Astro.props; + +// A sample's picker key. Packages before 0.16.0 give samples no `id`; their +// language is unique per operation there, so it serves as the key. +function sampleKey(sample: { lang: string; id?: string }): string { + return sample.id ?? sample.lang; +} + +function preparedCode(value: { highlightedHtml?: string }): string { + if (!value.highlightedHtml) { + throw new Error("Nimbus API code was not prepared during content sync."); + } + return value.highlightedHtml; +} +// Colour the status by class: 2xx success, 3xx info, 4xx/5xx danger, else neutral. +function statusTone(status: string): string { + switch (status.trim()[0]) { + case "2": + return "nb-cr-2xx"; + case "3": + return "nb-cr-3xx"; + case "4": + case "5": + return "nb-cr-err"; + default: + return ""; + } +} +const bodied = responses + .filter((r): r is ApiResponseView & { example: ApiExampleView } => r.example !== undefined) + .map((r) => ({ response: r, example: r.example })); + +// The region label reflects what the rail actually holds — a webhook (no +// request sample) announces its response examples, not a "Request sample". +const railLabel = + samples.length > 0 && bodied.length > 0 + ? "Request sample and response examples" + : samples.length > 0 + ? "Request sample" + : "Response examples"; + +const respUid = `nb-cr-resp-${encodeURIComponent(coordinate)}`; +const respTabId = (i: number) => `${respUid}-tab-${i}`; +const respPanelId = (i: number) => `${respUid}-panel-${i}`; +--- + + + + 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..8384e38b9 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-code-rail/code-rail.client.ts @@ -0,0 +1,117 @@ +/** + * 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: 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; + /* The sample's box scrolls both ways, so both scrollbars sit on its edges. */ + overflow: visible; + width: max-content; + min-width: 100%; +} +[data-nb-api-coderail] .nb-cr-content { + overflow: auto; +} + +/* 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..e70819154 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-layout/ApiLayout.astro @@ -0,0 +1,345 @@ +--- +/** + * 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 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"; +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/ApiPageHeader.astro b/packages/cloudflare-api-site/src/components/ui/api-layout/ApiPageHeader.astro new file mode 100644 index 000000000..85e9fec66 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-layout/ApiPageHeader.astro @@ -0,0 +1,93 @@ +--- +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 { 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 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 → endpoint → page actions. The page's + description opens the body, where long ones don't push the endpoint down; + the API home's subtitle is the site description. */} +
+
+

{title}

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

{subtitle}

} + + {page.kind === "operation" && ( + + )} + + {!homeChunk && } +
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/hash-target.client.ts b/packages/cloudflare-api-site/src/components/ui/api-layout/hash-target.client.ts new file mode 100644 index 000000000..1f48b9904 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-layout/hash-target.client.ts @@ -0,0 +1,25 @@ +const revealHashTarget = () => { + let id: string; + try { + id = decodeURIComponent(location.hash.slice(1)); + } catch { + return; + } + const target = id ? document.getElementById(id) : null; + if (!target?.closest("[data-api-section], [data-api-responses]")) return; + let opened = false; + const open = (details: HTMLDetailsElement | null) => { + if (details && !details.open) { + details.open = true; + opened = true; + } + }; + for (let node = target.parentElement; node; node = node.parentElement) { + if (node instanceof HTMLDetailsElement) open(node); + } + open(target.querySelector(":scope > details")); + if (opened) target.scrollIntoView({ block: "start" }); +}; + +window.addEventListener("hashchange", revealHashTarget); +revealHashTarget(); 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..089cf5456 --- /dev/null +++ b/packages/cloudflare-api-site/src/components/ui/api-layout/home.ts @@ -0,0 +1,5 @@ +/** + * 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; 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/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/content.config.ts b/packages/cloudflare-api-site/src/content.config.ts index 1144efa4e..67abc2e15 100644 --- a/packages/cloudflare-api-site/src/content.config.ts +++ b/packages/cloudflare-api-site/src/content.config.ts @@ -1,57 +1,9 @@ import { defineCollection } from 'astro:content'; -import { docsCollection } from '@cloudflare/nimbus-docs/content'; -import { defineFernManifest, type FernContentOptions, type FernManifestProvider } from 'astro-fern'; -import { defineFernCollections } from 'astro-fern/collections'; -import { forgeExtension, hasRemovedForgeOperationFields, hoistForgeCommands } from 'fern-forge'; -import { - CLOUDFLARE_RESPONSE_PAYLOAD_KEY, - CLOUDFLARE_TARGETS, - cloudflareFormatIdentifier, - cloudflareOperationRoutingPreference, - cloudflareSnippets, -} from './cloudflare.ts'; +import { apiCollection } from '@cloudflare/nimbus-docs/content'; import { cfCommandCatalog } from './command-reference/cf-commands.ts'; import { commandCatalogLoader } from './command-reference/loader.ts'; -import { loadForgeOpenApi } from './openapi-source.ts'; -import { renderDescription } from './description-markdown.ts'; -import { cloudflareTerraformExtension } from './terraform-extension.ts'; -import { cloudflareApiVersionLabel, cloudflareApiVersionSlug, parseCloudflareApiVersion } from './version.ts'; -const source = async () => hoistForgeCommands(await loadForgeOpenApi()); - -const air = parseCloudflareApiVersion('2026-11-30.air'); - -const manifest: FernManifestProvider = (_openapi, { discoverProducts }) => - defineFernManifest({ - targets: CLOUDFLARE_TARGETS, - products: discoverProducts(), - }); - -// The API reference stores one project descriptor plus immutable operation -// snapshot artifacts. `astro-fern/server` composes exact snapshot-selected pages at runtime. export const collections = { - docs: defineCollection(docsCollection()), + api: defineCollection(apiCollection()), cfCommands: defineCollection({ loader: commandCatalogLoader(() => cfCommandCatalog) }), - ...defineFernCollections({ - source: { - kind: 'snapshots', - snapshots: [ - { - id: air.wire, - source, - label: cloudflareApiVersionLabel(air), - slug: cloudflareApiVersionSlug(air), - default: true, - }, - ], - }, - responsePayloadKey: CLOUDFLARE_RESPONSE_PAYLOAD_KEY, - manifest, - formatIdentifier: cloudflareFormatIdentifier, - operationRoutingPreference: cloudflareOperationRoutingPreference, - isOperationHidden: hasRemovedForgeOperationFields, - snippets: cloudflareSnippets, - renderMarkdown: renderDescription, - extensions: [forgeExtension(), cloudflareTerraformExtension], - } satisfies FernContentOptions), }; 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/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 `