Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .changeset/tidy-api-delivery.md
Original file line number Diff line number Diff line change
@@ -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. Emit canonical trailing slashes for API navigation links.
20 changes: 20 additions & 0 deletions apps/www/registry/feature-route-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"/);
Expand Down
5 changes: 4 additions & 1 deletion apps/www/registry/features/new-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ import {
getBreadcrumbs,
getEditUrl,
getLastUpdated,
getRouteFlags,
getTOC,
entryRouteKey,
stripBase,
Expand All @@ -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.
Expand All @@ -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
? `/<prefix>/${routeKey}/index.md`
Expand Down
5 changes: 4 additions & 1 deletion apps/www/registry/features/new-version.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ import {
getBreadcrumbs,
getEditUrl,
getLastUpdated,
getRouteFlags,
getTOC,
entryRouteKey,
stripBase,
Expand All @@ -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,
Expand All @@ -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
? `/<slug>/${routeKey}/index.md`
Expand Down
28 changes: 16 additions & 12 deletions packages/create-nimbus-docs/src/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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.");
}
}
Expand All @@ -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}.`,
);
Expand Down
26 changes: 26 additions & 0 deletions packages/create-nimbus-docs/test/scaffold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
16 changes: 15 additions & 1 deletion packages/nimbus-docs/src/_internal/api/citation-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)) {
Expand Down
5 changes: 4 additions & 1 deletion packages/nimbus-docs/src/_internal/api/view-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
58 changes: 58 additions & 0 deletions packages/nimbus-docs/test/api-citation-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Expand Down Expand Up @@ -104,6 +112,56 @@ describe("buildCitationIndex: field coordinates resolve to <page>#<anchor>", ()
});
});

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,
Expand Down
Loading
Loading