Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b6a784e
chore(docs): upgrade to nimbus-docs 0.19.0
MohamedH1998 Oct 23, 2026
38ed28b
chore(docs): move the docs site to nimbus-docs 0.19.1
MohamedH1998 Oct 26, 2026
a92697d
build: use nimbus-docs 0.19.2 and openapi-sampler
MohamedH1998 Nov 2, 2026
cc5d5ec
feat: prepare the forge spec for nimbus
MohamedH1998 Nov 2, 2026
6c992d3
feat: add the nimbus api components with cloudflare notices and terra…
MohamedH1998 Nov 2, 2026
ffa72a9
feat: serve api pages, markdown and llms.txt with nimbus
MohamedH1998 Nov 2, 2026
7398e8e
feat: move command pages onto the command sidebar
MohamedH1998 Nov 2, 2026
7fb3c57
refactor: delete the site's own api routing, sidebar and page components
MohamedH1998 Nov 2, 2026
94fcc05
test: update e2e for nimbus api pages
MohamedH1998 Nov 2, 2026
c56714c
docs: update the site readme for nimbus
MohamedH1998 Nov 2, 2026
1b444eb
test: run the e2e preview server in the foreground
MohamedH1998 Nov 2, 2026
7a33dce
perf: inline only the icons the site uses
MohamedH1998 Nov 3, 2026
a0a474f
feat: give api headers the full width beside the code rail
MohamedH1998 Nov 3, 2026
54dc69e
style: hide idle scrollbars like the starter
MohamedH1998 Nov 3, 2026
27baea0
build: use nimbus-docs 0.19.3
MohamedH1998 Nov 5, 2026
6819dd5
test: check the o11y redirect on an operation the spec still has
MohamedH1998 Nov 5, 2026
7987cc0
Merge remote-tracking branch 'origin/main' into feat/api-pages-on-nimbus
MohamedH1998 Nov 5, 2026
14f6c5b
fix: address api page review feedback
MohamedH1998 Nov 5, 2026
3c410bd
feat: show the page description as prose in the body
MohamedH1998 Nov 5, 2026
5f62134
fix: drop the repeated description from markdown frontmatter
MohamedH1998 Nov 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
10 changes: 9 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"],
Expand Down
7 changes: 1 addition & 6 deletions packages/cloudflare-api-site/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
286 changes: 150 additions & 136 deletions packages/cloudflare-api-site/README.md

Large diffs are not rendered by default.

39 changes: 28 additions & 11 deletions packages/cloudflare-api-site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -17,16 +17,17 @@ if (internalSiteDomain) {
allowedDomains.push({ protocol: 'https', hostname: internalSiteDomain });
}

const api = await forgeApi();

const nimbusConfig = defineNimbusConfig({
site,
title: 'Cloudflare API',
description: 'API reference generated from the Cloudflare OpenAPI spec by Forge.',
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({
Expand All @@ -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 },
}),
],
});
180 changes: 151 additions & 29 deletions packages/cloudflare-api-site/e2e/site.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
11 changes: 2 additions & 9 deletions packages/cloudflare-api-site/e2e/visual.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,15 @@ 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',
};

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)) {
Expand Down
Loading
Loading