From bd179bbe65f1c09d4375ce189865fdee90d543d7 Mon Sep 17 00:00:00 2001 From: mohamedh Date: Sun, 6 Sep 2026 21:32:04 +0100 Subject: [PATCH 1/2] fix: improve api citations, recipe toc, and cli progress --- .changeset/tidy-api-delivery.md | 6 ++ .../registry/feature-route-contract.test.ts | 20 +++++++ apps/www/registry/features/new-collection.md | 5 +- apps/www/registry/features/new-version.md | 5 +- packages/create-nimbus-docs/src/scaffold.ts | 28 +++++---- .../create-nimbus-docs/test/scaffold.test.ts | 26 +++++++++ .../src/_internal/api/citation-index.ts | 16 ++++- .../test/api-citation-index.test.ts | 58 +++++++++++++++++++ .../ui/api-field-row/ApiFieldList.astro | 2 +- .../components/ui/api-layout/ApiLayout.astro | 8 +-- 10 files changed, 154 insertions(+), 20 deletions(-) create mode 100644 .changeset/tidy-api-delivery.md diff --git a/.changeset/tidy-api-delivery.md b/.changeset/tidy-api-delivery.md new file mode 100644 index 00000000..ac3c17fc --- /dev/null +++ b/.changeset/tidy-api-delivery.md @@ -0,0 +1,6 @@ +--- +"@cloudflare/nimbus-docs": patch +"@cloudflare/create-nimbus-docs": patch +--- + +Index API response coordinates for direct citations, and keep scaffold progress readable in non-interactive terminals. diff --git a/apps/www/registry/feature-route-contract.test.ts b/apps/www/registry/feature-route-contract.test.ts index 65f71aae..b5594b8d 100644 --- a/apps/www/registry/feature-route-contract.test.ts +++ b/apps/www/registry/feature-route-contract.test.ts @@ -27,6 +27,26 @@ test("collection recipes resolve breadcrumbs from their own collection", async ( } }); +test("collection recipes guard disabled table-of-contents configuration", async () => { + for (const name of ["new-collection", "new-version"]) { + const source = await feature(name); + assert.match(source, /getRouteFlags,/); + assert.match( + source, + /const \{ tableOfContents: tocOn \} = await getRouteFlags\(entry\);/, + ); + assert.match(source, /const tocConfig = entry\.data\.tableOfContents;/); + assert.match( + source, + /const toc = tocOn && tocConfig !== false \? getTOC\(headings, tocConfig\) : false;/, + ); + assert.doesNotMatch( + source, + /getTOC\(headings, entry\.data\.tableOfContents\)/, + ); + } +}); + test("changelog serves and links its expanded source artifact", async () => { const source = await feature("changelog"); assert.match(source, /surface: "source"/); diff --git a/apps/www/registry/features/new-collection.md b/apps/www/registry/features/new-collection.md index 2ccde333..bd493e94 100644 --- a/apps/www/registry/features/new-collection.md +++ b/apps/www/registry/features/new-collection.md @@ -199,6 +199,7 @@ import { getBreadcrumbs, getEditUrl, getLastUpdated, + getRouteFlags, getTOC, entryRouteKey, stripBase, @@ -213,6 +214,7 @@ if (page instanceof Response) return page; const { entry, Content, headings } = page; const currentSlug = stripBase(Astro.url.pathname, import.meta.env.BASE_URL).replace(/\/$/, "") || "/"; +const { tableOfContents: tocOn } = await getRouteFlags(entry); // Pass collection so the sidebar/prev-next resolve against the current // collection's tree. Critical for version pages — without this, version // pages render the current docs sidebar with wrong prev/next. @@ -225,7 +227,8 @@ const breadcrumbs = await getBreadcrumbs(currentSlug, { collection: entry.collec const editUrl = await getEditUrl(entry); const lastUpdated = entry.data.lastUpdated ?? await getLastUpdated(entry); -const toc = getTOC(headings, entry.data.tableOfContents); +const tocConfig = entry.data.tableOfContents; +const toc = tocOn && tocConfig !== false ? getTOC(headings, tocConfig) : false; const routeKey = entryRouteKey(entry.id); const markdownPath = routeKey ? `//${routeKey}/index.md` diff --git a/apps/www/registry/features/new-version.md b/apps/www/registry/features/new-version.md index 0043111c..fb56e67b 100644 --- a/apps/www/registry/features/new-version.md +++ b/apps/www/registry/features/new-version.md @@ -358,6 +358,7 @@ import { getBreadcrumbs, getEditUrl, getLastUpdated, + getRouteFlags, getTOC, entryRouteKey, stripBase, @@ -372,6 +373,7 @@ if (page instanceof Response) return page; const { entry, Content, headings } = page; const currentSlug = stripBase(Astro.url.pathname, import.meta.env.BASE_URL).replace(/\/$/, "") || "/"; +const { tableOfContents: tocOn } = await getRouteFlags(entry); const sidebar = await getSidebar(currentSlug, { collection: entry.collection }); const prevNext = await getPrevNext(currentSlug, { sidebarTree: sidebar, @@ -381,7 +383,8 @@ const breadcrumbs = await getBreadcrumbs(currentSlug, { collection: entry.collec const editUrl = await getEditUrl(entry); const lastUpdated = entry.data.lastUpdated ?? await getLastUpdated(entry); -const toc = getTOC(headings, entry.data.tableOfContents); +const tocConfig = entry.data.tableOfContents; +const toc = tocOn && tocConfig !== false ? getTOC(headings, tocConfig) : false; const routeKey = entryRouteKey(entry.id); const markdownPath = routeKey ? `//${routeKey}/index.md` diff --git a/packages/create-nimbus-docs/src/scaffold.ts b/packages/create-nimbus-docs/src/scaffold.ts index c94f05f8..736dcb8e 100644 --- a/packages/create-nimbus-docs/src/scaffold.ts +++ b/packages/create-nimbus-docs/src/scaffold.ts @@ -149,6 +149,7 @@ export class ScaffoldError extends Error { /** Injectable seams for tests — real runs use the process cwd and giget. */ export interface ScaffoldInternals { cwd?: string; + stdoutIsTTY?: boolean; previewMode?: boolean; previewPr?: string | null; previewTemplatesDir?: string; @@ -232,20 +233,23 @@ export async function scaffold( realFetchTemplate(target, options, internals)); const preview = previewProvenance(internals); - const s = p.spinner(); + const s = (internals.stdoutIsTTY ?? process.stdout.isTTY) ? p.spinner() : null; + const startProgress = (message: string) => s?.start(message); + const stopProgress = (message: string) => + s ? s.stop(message) : p.log.step(message); // Fetch + transform. If anything throws mid-way (network, EACCES, disk full, // a malformed template package.json), roll back the partial target dir — we // just confirmed it didn't exist, so removing it can't clobber user data — // and rethrow a friendly error. Without the rollback, a half-written dir // blocks re-running (the existence check above hard-fails on it). - s.start("Fetching template…"); + startProgress("Fetching template…"); try { await fetchTemplate(target, options); assertNoTemplateSymlinks(target); - s.stop("Template ready."); + stopProgress("Template ready."); - s.start("Configuring project…"); + startProgress("Configuring project…"); normalizePackageManagerFiles(target, packageManager); if (options.output === "server") { await applyAdapter(target, options.adapter); @@ -271,9 +275,9 @@ export async function scaffold( }); } writeNimbusJson(target, options, preview); - s.stop("Project configured."); + stopProgress("Project configured."); } catch (err) { - s.stop("Failed."); + stopProgress("Failed."); rmSync(target, { recursive: true, force: true }); // A ScaffoldError already carries an actionable message (missing tag, // offline, rate-limited, bad --template-dir). Pass it through untouched; @@ -287,12 +291,12 @@ export async function scaffold( // 3. Git init if (git) { - s.start("Initializing git repository…"); + startProgress("Initializing git repository…"); try { await runCommand("git", ["init"], target); - s.stop("Git repository initialized."); + stopProgress("Git repository initialized."); } catch { - s.stop("Skipped git initialization."); + stopProgress("Skipped git initialization."); p.log.warn("Could not initialize a git repository."); } } @@ -303,14 +307,14 @@ export async function scaffold( return; } - s.start(`Installing dependencies via ${packageManager}…`); + startProgress(`Installing dependencies via ${packageManager}…`); try { const cmd = packageManager === "yarn" ? "yarn" : `${packageManager} install`; const [bin = packageManager, ...args] = cmd.split(" "); await runCommand(bin, args, target); - s.stop("Dependencies installed."); + stopProgress("Dependencies installed."); } catch { - s.stop("Failed to install dependencies."); + stopProgress("Failed to install dependencies."); p.log.warn( `Could not install dependencies. Run \`${packageManager} install\` manually in ${dir}.`, ); diff --git a/packages/create-nimbus-docs/test/scaffold.test.ts b/packages/create-nimbus-docs/test/scaffold.test.ts index 7c8ca80f..af440a02 100644 --- a/packages/create-nimbus-docs/test/scaffold.test.ts +++ b/packages/create-nimbus-docs/test/scaffold.test.ts @@ -139,6 +139,32 @@ test("happy path writes and transforms the project", async () => { } }); +test("non-TTY scaffolds report each completed step without spinner frames", async () => { + const cwd = makeCwd(); + const tmpl = makeTemplate(); + const chunks: string[] = []; + const write = process.stdout.write; + process.stdout.write = ((chunk: string | Uint8Array) => { + chunks.push(chunk.toString()); + return true; + }) as typeof process.stdout.write; + + try { + await scaffold( + { ...BASE_OPTIONS, dir: "my-docs" }, + { ...internals(cwd, tmpl), stdoutIsTTY: false }, + ); + } finally { + process.stdout.write = write; + cleanup(cwd, tmpl); + } + + const output = chunks.join(""); + assert.equal(output.match(/Template ready/g)?.length, 1); + assert.equal(output.match(/Project configured/g)?.length, 1); + assert.doesNotMatch(output, /\u001B\[\?25[hl]/); +}); + test("strips stale .nimbus build output so it never reaches the project", async () => { const cwd = makeCwd(); const tmpl = makeTemplate(); diff --git a/packages/nimbus-docs/src/_internal/api/citation-index.ts b/packages/nimbus-docs/src/_internal/api/citation-index.ts index e25cf3b8..477a2449 100644 --- a/packages/nimbus-docs/src/_internal/api/citation-index.ts +++ b/packages/nimbus-docs/src/_internal/api/citation-index.ts @@ -6,7 +6,12 @@ * payload). Version lives in the path via `mountPath`, never in the coordinate. */ -import { buildApiModel, getApiFieldCitations, getApiPageSlugs } from "../../api/index.js"; +import { + buildApiModel, + getApiFieldCitations, + getApiPageProps, + getApiPageSlugs, +} from "../../api/index.js"; import type { ApiSpec } from "../../types.js"; import { citationKey, isSafeCitationPath } from "./citations.js"; import { resolveSpecSource } from "./resolve-spec.js"; @@ -63,6 +68,15 @@ export async function buildCitationIndex( const targets: Array<{ coordinate: string; url: string }> = []; for (const { coordinate, slug } of getApiPageSlugs(model)) { targets.push({ coordinate, url: pageUrl(target.mountPath, slug) }); + const page = getApiPageProps(model, coordinate); + if (page.kind === "operation") { + for (const response of page.responses) { + targets.push({ + coordinate: response.coordinate, + url: `${pageUrl(target.mountPath, slug)}#${response.anchor}`, + }); + } + } } for (const { coordinate, slug, anchor } of getApiFieldCitations(model)) { diff --git a/packages/nimbus-docs/test/api-citation-index.test.ts b/packages/nimbus-docs/test/api-citation-index.test.ts index f44be1fb..f07dde70 100644 --- a/packages/nimbus-docs/test/api-citation-index.test.ts +++ b/packages/nimbus-docs/test/api-citation-index.test.ts @@ -68,6 +68,14 @@ describe("buildCitationIndex: versioned family (v2 default + v1)", () => { assert.equal(index.get("svc@v2:svc"), "/svc"); // non-default (v1): only the @v1 key exists, under /svc/v1 — no bare alias assert.equal(index.get("svc@v1:svc"), "/svc/v1"); + assert.equal( + index.get("svc:create.response.200"), + `${index.get("svc:create")}#response-200`, + ); + assert.equal( + index.get("svc@v1:create.response.200"), + `${index.get("svc@v1:create")}#response-200`, + ); assert.equal(manifest.collections.svc?.defaultVersion, "v2"); }); }); @@ -104,6 +112,56 @@ describe("buildCitationIndex: field coordinates resolve to #", () }); }); +describe("buildCitationIndex: response coordinates resolve to rendered anchors", () => { + const api: ApiSpec[] = [{ collection: "smallco", spec: fixturePath("smallco.yaml") }]; + + test("bare responses are addressable in the index and manifest", async () => { + const { index, manifest } = await buildCitationIndex(api, root); + assert.equal( + index.get("smallco:create.response.200"), + `${index.get("smallco:create")}#response-200`, + ); + assert.equal( + manifest.collections.smallco!.entries["create.response.200"]?.url, + `${index.get("smallco:create")}#response-200`, + ); + }); + + test("deviant response statuses use the exact rendered anchor", async () => { + const { index } = await buildCitationIndex( + [{ collection: "dev", spec: fixturePath("deviant.yaml") }], + root, + ); + assert.equal( + index.get("dev:listWidgets.response.4xx"), + `${index.get("dev:listWidgets")}#response-4xx`, + ); + }); + + test("punctuation in a response status is sanitized losslessly", async () => { + const spec = { + openapi: "3.0.3", + info: { title: "Odd status", version: "1.0.0" }, + paths: { + "/odd": { + get: { + operationId: "oddStatus", + responses: { "2:00": { description: "ok" } }, + }, + }, + }, + }; + const { index } = await buildCitationIndex( + [{ collection: "odd", spec }], + root, + ); + const url = index.get("odd:oddStatus.response.2:00"); + assert.ok(url); + assert.match(url, /#response-2-00--[a-z2-7]+$/); + assert.ok(!url.split("#")[1]!.includes(":")); + }); +}); + describe("ingestRemoteManifest", () => { const manifest: CoordinatesManifest = { version: 1, diff --git a/packages/nimbus-starter-source/src/components/ui/api-field-row/ApiFieldList.astro b/packages/nimbus-starter-source/src/components/ui/api-field-row/ApiFieldList.astro index c0a9626f..97ebd96f 100644 --- a/packages/nimbus-starter-source/src/components/ui/api-field-row/ApiFieldList.astro +++ b/packages/nimbus-starter-source/src/components/ui/api-field-row/ApiFieldList.astro @@ -24,7 +24,7 @@ const { collapsible, class: className, truncated, -} = Astro.props; +}: Props = Astro.props; const hasFields = fields !== undefined; if (hasFields && fields.length === 0) return; diff --git a/packages/nimbus-starter-source/src/components/ui/api-layout/ApiLayout.astro b/packages/nimbus-starter-source/src/components/ui/api-layout/ApiLayout.astro index 49127995..792df608 100644 --- a/packages/nimbus-starter-source/src/components/ui/api-layout/ApiLayout.astro +++ b/packages/nimbus-starter-source/src/components/ui/api-layout/ApiLayout.astro @@ -66,7 +66,7 @@ if (versionStatus?.isHidden) {
-
+
{pagefindDeprecated && } @@ -83,12 +83,12 @@ if (versionStatus?.isHidden) {
- {/* Below 2xl the rail reflows inline beneath the body (samples are unique + {/* Below 90rem the rail reflows inline beneath the body (samples are unique content, so unlike the TOC they stay in the no-JS HTML for crawlability); - at 2xl+ it becomes the sticky right column. 2xl:pl-1 keeps overflow-y-auto + at 90rem+ it becomes the sticky right column. The left padding keeps overflow-y-auto from clipping the LayerCard's outset ring at the scroll edge. */} {hasRail && ( -
+
Date: Mon, 7 Sep 2026 07:36:48 +0100 Subject: [PATCH 2/2] fix: missingtrailing slash --- .changeset/tidy-api-delivery.md | 2 +- .../src/_internal/api/view-model.ts | 5 +- .../nimbus-docs/test/api-view-model.test.ts | 69 +++++++++++++++++++ scripts/api-reference-check.mjs | 46 +++++++++++++ 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/.changeset/tidy-api-delivery.md b/.changeset/tidy-api-delivery.md index ac3c17fc..59b5eb43 100644 --- a/.changeset/tidy-api-delivery.md +++ b/.changeset/tidy-api-delivery.md @@ -3,4 +3,4 @@ "@cloudflare/create-nimbus-docs": patch --- -Index API response coordinates for direct citations, and keep scaffold progress readable in non-interactive terminals. +Index API response coordinates for direct citations, and keep scaffold progress readable in non-interactive terminals. Emit canonical trailing slashes for API navigation links. diff --git a/packages/nimbus-docs/src/_internal/api/view-model.ts b/packages/nimbus-docs/src/_internal/api/view-model.ts index f354da17..c8b532a1 100644 --- a/packages/nimbus-docs/src/_internal/api/view-model.ts +++ b/packages/nimbus-docs/src/_internal/api/view-model.ts @@ -739,7 +739,10 @@ function projectNavBase(model: DocsModel): ApiNavItem[] { }; // Nav-only grouping nodes (x-tagGroups categories) carry no page, so they // get no href — the row renders as a disclosure header, not a link. - if (view.hasPage(nav.coordinate)) item.href = view.href(nav.coordinate); + if (view.hasPage(nav.coordinate)) { + const href = view.href(nav.coordinate); + item.href = href.endsWith("/") ? href : `${href}/`; + } if (node?.facts.kind === "operation") { const method = protocolString(node.facts.protocol, "method"); if (method) item.method = method; diff --git a/packages/nimbus-docs/test/api-view-model.test.ts b/packages/nimbus-docs/test/api-view-model.test.ts index 5c65c9f4..7a974c29 100644 --- a/packages/nimbus-docs/test/api-view-model.test.ts +++ b/packages/nimbus-docs/test/api-view-model.test.ts @@ -16,6 +16,7 @@ import { getApiPageSlugs, apiSchemaVersion, type ApiModel, + type ApiNavItem, type ApiOperationPage, type ApiSchemaPage, } from "../src/api/index.js"; @@ -34,6 +35,19 @@ function roundTrips(value: unknown): void { ); } +function findNavItem(items: ApiNavItem[], coordinate: string): ApiNavItem | undefined { + for (const item of items) { + if (item.coordinate === coordinate) return item; + const child = findNavItem(item.children, coordinate); + if (child) return child; + } + return undefined; +} + +function flattenNavItems(items: ApiNavItem[]): ApiNavItem[] { + return items.flatMap((item) => [item, ...flattenNavItems(item.children)]); +} + function assertJsonSafe(value: unknown, path = "$"): void { if (Array.isArray(value)) { value.forEach((v, i) => assertJsonSafe(v, `${path}[${i}]`)); @@ -951,6 +965,61 @@ describe("nav: active + ancestor-expanded + verb chips", () => { assert.equal(create!.method, "POST"); }); + test("page-backed hrefs use the trailing-slash browser shape", () => { + const nav = getApiNav(smallco); + assert.equal(findNavItem(nav.items, "tags.charges")?.href, "/smallco/tags/charges/"); + assert.equal(findNavItem(nav.items, "create")?.href, "/smallco/charges/create/"); + for (const item of flattenNavItems(nav.items)) { + if (!item.href) continue; + assert.ok(item.href.endsWith("/"), `${item.coordinate} has a slashless href`); + assert.ok(!item.href.endsWith("//"), `${item.coordinate} has duplicate trailing slashes`); + } + }); + + test("a dotted operation identifier is treated as a document route", async () => { + const dotted = await buildApiModel({ + collection: "dotted", + spec: { + openapi: "3.1.0", + info: { title: "Dotted", version: "1" }, + paths: { + "/reports": { + get: { + operationId: "reports.list", + responses: { "200": { description: "ok" } }, + }, + }, + }, + }, + }); + assert.equal( + findNavItem(getApiNav(dotted).items, "reports.list")?.href, + "/dotted/reports.list/", + ); + }); + + test("nested API mounts retain one trailing slash", async () => { + const mounted = await buildApiModel({ + collection: "mounted", + spec: readFileSync(fixture("smallco.yaml"), "utf8"), + mountPath: "/core/v1", + }); + assert.equal( + findNavItem(getApiNav(mounted).items, "create")?.href, + "/core/v1/charges/create/", + ); + }); + + test("active overlays share frozen off-path navigation", () => { + const base = getApiNav(smallco); + const active = getApiNav(smallco, "create"); + const baseDisputes = findNavItem(base.items, "tags.disputes"); + const activeDisputes = findNavItem(active.items, "tags.disputes"); + assert.ok(baseDisputes); + assert.ok(Object.isFrozen(baseDisputes)); + assert.strictEqual(activeDisputes, baseDisputes); + }); + test("without an active coordinate, nothing is active/expanded", () => { const nav = getApiNav(smallco); const anyFlagged = JSON.stringify(nav).match(/"(active|expanded)":true/); diff --git a/scripts/api-reference-check.mjs b/scripts/api-reference-check.mjs index 1eedd9ce..d63ff7f1 100644 --- a/scripts/api-reference-check.mjs +++ b/scripts/api-reference-check.mjs @@ -668,6 +668,24 @@ async function applyOverlay() { await mkdir(dirname(target), { recursive: true }); await cp(join(OVERLAY, file), target); } + const astroConfigPath = join(site, "astro.config.ts"); + const astroConfig = await readFile(astroConfigPath, "utf8"); + const configMarker = "export default defineConfig({"; + assert( + occurrences(astroConfig, configMarker) === 1, + "generated astro.config.ts has an unexpected defineConfig export", + ); + assert( + !/^\s*trailingSlash\s*:/m.test(astroConfig), + "generated astro.config.ts already defines trailingSlash", + ); + await writeFile( + astroConfigPath, + astroConfig.replace( + configMarker, + 'export default defineConfig({\n trailingSlash: "always",', + ), + ); await mkdir(join(site, "src", "api"), { recursive: true }); await cp(SPEC, join(site, "src", "api", "smallco.yaml")); await writeFile( @@ -824,6 +842,21 @@ async function assertArtifactsAndSmoke(dist) { "missing Pagefind browser index", ); + const operationHtml = await readFile( + join(dist, "api", "charges", "create", "index.html"), + "utf8", + ); + const apiNav = /]*data-nb-api-nav[^>]*>([\s\S]*?)<\/nav>/.exec( + operationHtml, + )?.[1]; + assert(apiNav, "operation page has no API navigation"); + for (const href of ["/api/tags/charges/", "/api/charges/create/"]) { + assert( + apiNav.includes(`href="${href}"`), + `API navigation is missing canonical href ${href}`, + ); + } + const operationMarkdown = await readFile( join(dist, "api", "charges", "create", "index.md"), "utf8", @@ -1151,6 +1184,19 @@ async function assertBasePathMetadata() { operationHtml.includes(`href="${operationMarkdownUrl}"`), "non-root API View as Markdown link uses an unbased URL", ); + const apiNav = /]*data-nb-api-nav[^>]*>([\s\S]*?)<\/nav>/.exec( + operationHtml, + )?.[1]; + assert(apiNav, "non-root operation page has no API navigation"); + for (const href of [ + "/docs/api/tags/charges/", + "/docs/api/charges/create/", + ]) { + assert( + apiNav.includes(`href="${href}"`), + `non-root API navigation is missing canonical href ${href}`, + ); + } const directive = /]*data-ai-agent-directive[^>]*>([\s\S]*?)<\/aside>/.exec( operationHtml, )?.[1];