From efdf492803fd1d9d30a27e74114f5a290e0d1c3e Mon Sep 17 00:00:00 2001 From: mohamedh Date: Tue, 1 Sep 2026 20:43:03 +0100 Subject: [PATCH 01/16] temp: workers feasibility check --- .../src/content/docs/navigation/sidebar.mdx | 8 +- package.json | 1 + packages/nimbus-docs/test/markdown.test.ts | 15 + .../workers-feasibility/astro.config.ts | 45 ++ .../workers-feasibility/nimbus.config.ts | 11 + .../workers-feasibility/src/content.config.ts | 54 +++ .../src/content/api/openapi.json | 55 +++ .../src/content/docs/runtime.mdx | 19 + .../src/content/partials/worker-partial.mdx | 3 + .../src/pages/[...slug].astro | 74 ++++ .../src/pages/api/[...slug].astro | 38 ++ .../src/worker-safe-markdown.ts | 15 + .../src/worker-safe-partial-headings.ts | 78 ++++ scripts/workers-feasibility-check.mjs | 391 ++++++++++++++++++ 14 files changed, 803 insertions(+), 4 deletions(-) create mode 100644 scripts/fixtures/workers-feasibility/astro.config.ts create mode 100644 scripts/fixtures/workers-feasibility/nimbus.config.ts create mode 100644 scripts/fixtures/workers-feasibility/src/content.config.ts create mode 100644 scripts/fixtures/workers-feasibility/src/content/api/openapi.json create mode 100644 scripts/fixtures/workers-feasibility/src/content/docs/runtime.mdx create mode 100644 scripts/fixtures/workers-feasibility/src/content/partials/worker-partial.mdx create mode 100644 scripts/fixtures/workers-feasibility/src/pages/[...slug].astro create mode 100644 scripts/fixtures/workers-feasibility/src/pages/api/[...slug].astro create mode 100644 scripts/fixtures/workers-feasibility/src/worker-safe-markdown.ts create mode 100644 scripts/fixtures/workers-feasibility/src/worker-safe-partial-headings.ts create mode 100644 scripts/workers-feasibility-check.mjs diff --git a/apps/www/src/content/docs/navigation/sidebar.mdx b/apps/www/src/content/docs/navigation/sidebar.mdx index 747f5530..8481ad36 100644 --- a/apps/www/src/content/docs/navigation/sidebar.mdx +++ b/apps/www/src/content/docs/navigation/sidebar.mdx @@ -10,7 +10,7 @@ Nimbus builds a typed sidebar tree from your content. Start with the filesystem, ## Generate from the filesystem -With no `items`, the rail mirrors `src/content/docs/`: files become links, directories become groups, and a directory's `index.mdx` becomes the group's landing page. Child order is alphabetical unless frontmatter sets `sidebar.order`. +The sidebar is generated from `src/content/docs/` by default. Set `sidebar.items` in `astro.config.ts` only when you need to override that structure. Use `sidebar.order` in a page's frontmatter, such as `src/content/docs/get-started.mdx`, to control ordering; otherwise entries are alphabetical. ```yaml title="src/content/docs/get-started.mdx" --- @@ -28,7 +28,7 @@ Frontmatter can also hide entries, customize directory groups, redirect links, o ## Define the structure -Use `sidebar.items` when the filesystem should not define the entire rail. Config items can be nested and can mix manual links with generated content: +In `astro.config.ts`, use `sidebar.items` when the filesystem should not define the entire rail. Config items can be nested and can mix manual links with generated content: ```ts title="astro.config.ts" sidebar: { @@ -102,7 +102,7 @@ const sections = await getSidebarSections(currentSlug, { ## Control group landing pages -A directory `index.mdx` is its group's landing page. Configure how those landings appear across the site: +A directory `index.mdx` is its group's landing page. Configure how those landings appear across the site in `astro.config.ts`: ```ts title="astro.config.ts" sidebar: { @@ -191,4 +191,4 @@ Use frontmatter for local changes: | `sidebar.hideChildren` or `hideChildren` | Collapses the directory to its landing link. | | `external_link` | Rewrites the sidebar destination to another internal path or an external URL. | -Set `features.sidebar: false` in Nimbus config to disable the rail site-wide. See [Frontmatter](/writing/frontmatter) for complete field shapes and badge variants. +Set `features.sidebar: false` in `astro.config.ts` to disable the rail site-wide. See [Frontmatter](/writing/frontmatter) for complete field shapes and badge variants. diff --git a/package.json b/package.json index 17d6cc56..45e11c6b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dev": "pnpm --filter nimbus-starter-source dev", "build:templates": "node packages/create-nimbus-docs/scripts/copy-template.mjs", "api-reference:check": "node scripts/api-reference-check.mjs", + "workers-feasibility:check": "node scripts/workers-feasibility-check.mjs", "templates:check": "node scripts/templates-check.mjs", "templates:sync": "node scripts/sync-templates-repo.mjs", "local": "node scripts/local.mjs", diff --git a/packages/nimbus-docs/test/markdown.test.ts b/packages/nimbus-docs/test/markdown.test.ts index 26306517..2af4fb00 100644 --- a/packages/nimbus-docs/test/markdown.test.ts +++ b/packages/nimbus-docs/test/markdown.test.ts @@ -164,6 +164,21 @@ describe("renderMarkdown: CommonMark → HTML for spec descriptions", () => { assert.match(html, /primary<\/strong>/); }); + test("preserves GFM used by existing API descriptions", () => { + const html = renderMarkdown( + "~~old~~\n\n| Name | Value |\n| --- | --- |\n| one | two |\n\n1. [x] done\n\n More detail.", + ); + assert.match(html, /old<\/del>/); + assert.match(html, //); + assert.match(html, /
two<\/td>/); + assert.match(html, /
    /); + assert.match(html, /
  1. /); + assert.match( + html, + /

    ]*type="checkbox")(?=[^>]*checked)(?=[^>]*disabled)[^>]*> done<\/p>/, + ); + }); + test("empty/nullish input yields an empty string (no stray markup)", () => { assert.equal(renderMarkdown(undefined), ""); assert.equal(renderMarkdown(null), ""); diff --git a/scripts/fixtures/workers-feasibility/astro.config.ts b/scripts/fixtures/workers-feasibility/astro.config.ts new file mode 100644 index 00000000..5a6b1ce1 --- /dev/null +++ b/scripts/fixtures/workers-feasibility/astro.config.ts @@ -0,0 +1,45 @@ +import cloudflare from "@astrojs/cloudflare"; +import { defineConfig } from "astro/config"; +import tailwindcss from "@tailwindcss/vite"; +import nimbus from "@cloudflare/nimbus-docs"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import nimbusConfig from "./nimbus.config"; + +const rendering = JSON.parse( + readFileSync(new URL("./.nimbus/feasibility-rendering.json", import.meta.url), "utf8"), +) as Record; +const integration = nimbus(nimbusConfig); +const hooks = { ...integration.hooks }; +if (rendering.docs === "request" || rendering.api === "request") { + delete hooks["astro:build:done"]; +} +const routePolicy = { + name: "workers-feasibility:route-policy", + hooks: { + "astro:route:setup": ({ route }: { route: { component: string; prerender?: boolean } }) => { + const component = route.component.replaceAll("\\", "/"); + if (component.endsWith("src/pages/api/[...slug].astro")) { + route.prerender = rendering.api !== "request"; + } else if (component.endsWith("src/pages/[...slug].astro")) { + route.prerender = rendering.docs !== "request"; + } + }, + }, +}; + +export default defineConfig({ + output: "server", + adapter: cloudflare({ prerenderEnvironment: "node" }), + vite: { + plugins: [tailwindcss()], + resolve: { + alias: { + "@cloudflare/nimbus-docs/markdown": fileURLToPath( + new URL("./src/worker-safe-markdown.ts", import.meta.url), + ), + }, + }, + }, + integrations: [routePolicy, { ...integration, hooks }], +}); diff --git a/scripts/fixtures/workers-feasibility/nimbus.config.ts b/scripts/fixtures/workers-feasibility/nimbus.config.ts new file mode 100644 index 00000000..db82c8f1 --- /dev/null +++ b/scripts/fixtures/workers-feasibility/nimbus.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "@cloudflare/nimbus-docs/config"; + +export default defineConfig({ + site: "https://workers-feasibility.test", + title: "Workers feasibility", + description: "BG-1c.0 request-rendering fixture.", + locale: "en", + github: null, + search: false, + socialImage: "/og.png", +}); diff --git a/scripts/fixtures/workers-feasibility/src/content.config.ts b/scripts/fixtures/workers-feasibility/src/content.config.ts new file mode 100644 index 00000000..6d6d32f5 --- /dev/null +++ b/scripts/fixtures/workers-feasibility/src/content.config.ts @@ -0,0 +1,54 @@ +import { defineCollection } from "astro:content"; +import { z } from "astro/zod"; +import { readFile } from "node:fs/promises"; +import { docsCollection, partialsCollection } from "@cloudflare/nimbus-docs/content"; +import { + buildApiModel, + getApiNav, + getApiPageProps, + getApiPageSlugs, + type ApiNav, + type ApiPageProps, +} from "@cloudflare/nimbus-docs/api"; + +const source = { + collection: "api", + mountPath: "/api", + label: "Feasibility API", +}; + +const api = defineCollection({ + loader: { + name: "workers-feasibility:prepared-api", + async load({ store, parseData }) { + store.clear(); + const spec = JSON.parse( + await readFile(new URL("./content/api/openapi.json", import.meta.url), "utf8"), + ); + const model = await buildApiModel({ ...source, spec }); + for (const { coordinate, slug } of getApiPageSlugs(model)) { + const id = slug || "index"; + const data = await parseData({ + id, + data: { + coordinate, + page: getApiPageProps(model, coordinate), + nav: getApiNav(model, coordinate), + }, + }); + store.set({ id, data }); + } + }, + }, + schema: z.object({ + coordinate: z.string(), + page: z.custom(), + nav: z.custom(), + }), +}); + +export const collections = { + docs: defineCollection(docsCollection()), + partials: defineCollection(partialsCollection()), + api, +}; diff --git a/scripts/fixtures/workers-feasibility/src/content/api/openapi.json b/scripts/fixtures/workers-feasibility/src/content/api/openapi.json new file mode 100644 index 00000000..7b85817c --- /dev/null +++ b/scripts/fixtures/workers-feasibility/src/content/api/openapi.json @@ -0,0 +1,55 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Feasibility API", + "version": "1.0.0", + "description": "API data prepared during content sync." + }, + "x-feasibility-source-only": "raw-openapi-must-not-ship", + "tags": [ + { + "name": "Health", + "description": "Health operations." + } + ], + "paths": { + "/ping": { + "get": { + "operationId": "ping", + "summary": "Ping", + "description": "Returns a **healthy** response.", + "tags": ["Health"], + "responses": { + "200": { + "description": "Healthy response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ping" + }, + "example": { + "ok": true + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Ping": { + "type": "object", + "description": "A prepared schema page.", + "required": ["ok"], + "properties": { + "ok": { + "type": "boolean", + "description": "Service health." + } + } + } + } + } +} diff --git a/scripts/fixtures/workers-feasibility/src/content/docs/runtime.mdx b/scripts/fixtures/workers-feasibility/src/content/docs/runtime.mdx new file mode 100644 index 00000000..c13af91d --- /dev/null +++ b/scripts/fixtures/workers-feasibility/src/content/docs/runtime.mdx @@ -0,0 +1,19 @@ +--- +title: Workers request prose +description: Request-rendered MDX using the current docs layout. +lastUpdated: 2026-09-01 +--- + +Request prose body. + +## Prose heading + +

    + +```ts title="worker.ts" +export default { fetch: () => new Response("healthy") }; +``` + + diff --git a/scripts/fixtures/workers-feasibility/src/content/partials/worker-partial.mdx b/scripts/fixtures/workers-feasibility/src/content/partials/worker-partial.mdx new file mode 100644 index 00000000..70a40165 --- /dev/null +++ b/scripts/fixtures/workers-feasibility/src/content/partials/worker-partial.mdx @@ -0,0 +1,3 @@ +## Partial heading + +This content rendered from a reusable partial. diff --git a/scripts/fixtures/workers-feasibility/src/pages/[...slug].astro b/scripts/fixtures/workers-feasibility/src/pages/[...slug].astro new file mode 100644 index 00000000..70cf8747 --- /dev/null +++ b/scripts/fixtures/workers-feasibility/src/pages/[...slug].astro @@ -0,0 +1,74 @@ +--- +import DocsLayout from "../layouts/DocsLayout.astro"; +import { render, type CollectionEntry } from "astro:content"; +import { + getBreadcrumbs, + getDocsStaticPaths, + getEditUrl, + getPrevNext, + getRouteFlags, + getSidebar, + getTOC, + getVisibleEntry, + withBase, +} from "@cloudflare/nimbus-docs"; +import { components } from "../components"; +import { mergeFixturePartialHeadings } from "../worker-safe-partial-headings"; + +export const prerender = true; +export const getStaticPaths = getDocsStaticPaths; + +const staticEntry = (Astro.props as { entry?: CollectionEntry<"docs"> }).entry; +const entry = staticEntry ?? await getVisibleEntry("docs", Astro.params.slug ?? "index"); +if (!entry) return new Response("Not found", { status: 404 }); + +const { Content, headings: ownHeadings } = await render(entry); +const headings = await mergeFixturePartialHeadings(entry.body, ownHeadings); +const currentSlug = Astro.url.pathname.replace(/\/$/, "") || "/"; +const { sidebar: sidebarOn, tableOfContents: tocOn } = await getRouteFlags(entry); +const sidebar = sidebarOn + ? await getSidebar(currentSlug, { collection: entry.collection }) + : false; +const prevNext = await getPrevNext(currentSlug, { + sidebarTree: sidebar === false ? [] : sidebar, + overrides: { prev: entry.data.prev, next: entry.data.next }, +}); +const breadcrumbs = await getBreadcrumbs(currentSlug, { collection: entry.collection }); +const editUrl = await getEditUrl(entry); +const tocConfig = entry.data.tableOfContents; +const toc = tocOn && tocConfig !== false ? getTOC(headings, tocConfig) : false; +const markdownPath = entry.id === "index" ? "/index.md" : `/${entry.id}/index.md`; +const basedMarkdownPath = withBase(markdownPath, import.meta.env.BASE_URL); +const markdownUrl = Astro.site ? new URL(basedMarkdownPath, Astro.site).href : basedMarkdownPath; +const socialImage = entry.data.socialImage ?? `/og/${entry.id}.png`; +const requestProbe = Astro.request.headers.get("x-nimbus-probe") ?? ""; +--- + + + + + diff --git a/scripts/fixtures/workers-feasibility/src/pages/api/[...slug].astro b/scripts/fixtures/workers-feasibility/src/pages/api/[...slug].astro new file mode 100644 index 00000000..ac83c862 --- /dev/null +++ b/scripts/fixtures/workers-feasibility/src/pages/api/[...slug].astro @@ -0,0 +1,38 @@ +--- +import { getCollection, getEntry, type CollectionEntry } from "astro:content"; +import Header from "@/components/Header.astro"; +import { ApiLayout } from "@/components/ui/api-layout"; +import BaseLayout from "@/layouts/BaseLayout.astro"; + +export const prerender = true; +export async function getStaticPaths() { + const entries = await getCollection("api"); + return entries.map((entry) => ({ + params: { slug: entry.id === "index" ? undefined : entry.id }, + props: { entry }, + })); +} + +const requestEntry = await getEntry("api", Astro.params.slug ?? "index"); +const entry = (Astro.props.entry ?? requestEntry) as CollectionEntry<"api"> | undefined; +if (!entry) return new Response("Not found", { status: 404 }); + +const { page, nav, coordinate } = entry.data; +const requestProbe = Astro.request.headers.get("x-nimbus-probe") ?? ""; +--- + + +
    + + + diff --git a/scripts/fixtures/workers-feasibility/src/worker-safe-markdown.ts b/scripts/fixtures/workers-feasibility/src/worker-safe-markdown.ts new file mode 100644 index 00000000..e090e388 --- /dev/null +++ b/scripts/fixtures/workers-feasibility/src/worker-safe-markdown.ts @@ -0,0 +1,15 @@ +import { fromHtml } from "hast-util-from-html"; +import { defaultSchema, sanitize } from "hast-util-sanitize"; +import { toHtml } from "hast-util-to-html"; +import { micromark } from "micromark"; +import { gfm, gfmHtml } from "micromark-extension-gfm"; + +export function renderMarkdown(source: string | undefined | null): string { + if (!source?.trim()) return ""; + const raw = micromark(source.trim(), { + allowDangerousHtml: true, + extensions: [gfm()], + htmlExtensions: [gfmHtml()], + }); + return toHtml(sanitize(fromHtml(raw, { fragment: true }), defaultSchema)); +} diff --git a/scripts/fixtures/workers-feasibility/src/worker-safe-partial-headings.ts b/scripts/fixtures/workers-feasibility/src/worker-safe-partial-headings.ts new file mode 100644 index 00000000..5f03f40e --- /dev/null +++ b/scripts/fixtures/workers-feasibility/src/worker-safe-partial-headings.ts @@ -0,0 +1,78 @@ +import { getEntry, render } from "astro:content"; +import remarkMdx from "remark-mdx"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; + +interface Heading { + depth: number; + text: string; + slug: string; +} + +interface Node { + type: string; + name?: string | null; + attributes?: unknown[]; + children?: Node[]; +} + +interface Attribute { + type: string; + name: string; + value?: string | null | { type: string; value: string }; +} + +type Slot = { kind: "heading" } | { kind: "render"; file?: string }; + +const parser = unified().use(remarkParse).use(remarkMdx); + +export async function mergeFixturePartialHeadings( + body: string | undefined, + headings: Heading[], +): Promise { + if (!body) return headings; + + const slots: Slot[] = []; + collectSlots(parser.parse(body) as unknown as Node, slots); + const merged: Heading[] = []; + let headingIndex = 0; + + for (const slot of slots) { + if (slot.kind === "heading") { + const heading = headings[headingIndex++]; + if (heading) merged.push(heading); + continue; + } + + if (!slot.file) continue; + const partial = await getEntry("partials", slot.file); + if (!partial) continue; + const rendered = await render(partial); + merged.push( + ...(await mergeFixturePartialHeadings(partial.body, rendered.headings)), + ); + } + + merged.push(...headings.slice(headingIndex)); + return merged; +} + +function collectSlots(node: Node, slots: Slot[]): void { + if (node.type === "heading") { + slots.push({ kind: "heading" }); + return; + } + + if ( + (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") && + node.name === "Render" + ) { + const file = (node.attributes as Attribute[] | undefined)?.find( + (attribute) => attribute.type === "mdxJsxAttribute" && attribute.name === "file", + )?.value; + slots.push({ kind: "render", file: typeof file === "string" ? file : undefined }); + return; + } + + for (const child of node.children ?? []) collectSlots(child, slots); +} diff --git a/scripts/workers-feasibility-check.mjs b/scripts/workers-feasibility-check.mjs new file mode 100644 index 00000000..9a110edb --- /dev/null +++ b/scripts/workers-feasibility-check.mjs @@ -0,0 +1,391 @@ +#!/usr/bin/env node + +import { spawn, spawnSync } from "node:child_process"; +import { + cpSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { generateTemplates } from "../packages/create-nimbus-docs/scripts/copy-template.mjs"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const GENERATED = join(ROOT, ".generated", "templates"); +const SCAFFOLDER = join(ROOT, "packages", "create-nimbus-docs", "dist", "index.js"); +const NIMBUS_PACKAGE = join(ROOT, "packages", "nimbus-docs", "package.json"); +const FIXTURE = join(ROOT, "scripts", "fixtures", "workers-feasibility"); +const STARTER = join(ROOT, "packages", "nimbus-starter-source", "src"); +const PREFIX = "[workers-feasibility]"; +const cleanup = []; + +process.on("exit", () => { + for (const path of cleanup) rmSync(path, { recursive: true, force: true }); +}); + +function fail(message) { + throw new Error(`${PREFIX} ${message}`); +} + +function assert(condition, message) { + if (!condition) fail(message); +} + +function run(bin, args, options = {}) { + const result = spawnSync(bin, args, { + cwd: options.cwd ?? ROOT, + env: { ...process.env, ...options.env }, + stdio: "inherit", + }); + if (result.status !== 0) { + fail(`command failed: ${bin} ${args.join(" ")}`); + } +} + +function filesUnder(path) { + return readdirSync(path, { withFileTypes: true }).flatMap((entry) => { + const child = join(path, entry.name); + return entry.isDirectory() ? filesUnder(child) : [child]; + }); +} + +function outputText(path) { + return filesUnder(path) + .map((file) => readFileSync(file).toString("utf8")) + .join("\n"); +} + +function routeForHtml(clientRoot, path) { + const local = relative(clientRoot, path).split(sep).join("/"); + if (local === "index.html") return "/"; + if (local.endsWith("/index.html")) return `/${local.slice(0, -"index.html".length)}`; + return `/${local.slice(0, -".html".length)}`; +} + +function captureStaticPages(site) { + const clientRoot = join(site, "dist", "client"); + const pages = new Map(); + for (const path of filesUnder(clientRoot).filter((file) => file.endsWith(".html"))) { + pages.set(routeForHtml(clientRoot, path), readFileSync(path, "utf8")); + } + return pages; +} + +function findMarkedPages(pages, attribute) { + return [...pages].filter(([, html]) => html.includes(attribute)); +} + +function apiKinds(pages) { + const found = new Map(); + for (const [route, html] of findMarkedPages(pages, "data-feasibility-api-kind")) { + const kind = html.match(/data-feasibility-api-kind="([^"]+)"/)?.[1]; + if (kind) found.set(kind, { route, html }); + } + return found; +} + +function assertProse(html) { + assert(html.includes("Request prose body."), "prose body did not render"); + assert(html.includes("Registered component"), "registered MDX component did not render"); + assert(html.includes("This content rendered from a reusable partial."), "partial did not render"); + assert( + html.includes("data-heading-slugs=\"prose-heading,partial-heading\""), + "compiled MDX and partial headings did not render", + ); + assert(html.includes("class=\"astro-code"), "syntax-highlighted code did not render"); + assert(html.includes("nb-shiki-"), "syntax-highlighted tokens did not render"); +} + +function assertPreparedApi(html, kind) { + assert( + html.includes(`data-feasibility-api-kind="${kind}"`), + `${kind} API page did not render`, + ); + assert(html.includes("Feasibility API") || html.includes("Ping"), `${kind} API page is empty`); + const bodyEvidence = { + api: "API data prepared during content sync.", + section: "Health operations.", + operation: "Returns a healthy response.", + schema: "A prepared schema page.", + }[kind]; + assert(html.includes(bodyEvidence), `${kind} API layout body did not render`); + if (kind === "operation") { + assert(html.includes("/ping"), "operation endpoint did not render"); + assert(html.includes("Healthy response."), "operation response did not render"); + } + if (kind === "schema") { + assert(html.includes("Service health."), "schema field tree did not render"); + } +} + +function assertProbe(html, value) { + assert(html.includes(`data-request-probe="${value}"`), `request probe ${value} was not rendered`); +} + +function assertNoProbe(html, value) { + assert(!html.includes(`data-request-probe="${value}"`), `static page rendered request probe ${value}`); +} + +async function freePort() { + return await new Promise((resolvePort, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : null; + server.close(() => (port ? resolvePort(port) : reject(new Error("no free port")))); + }); + }); +} + +async function stop(child) { + if (child.exitCode !== null || !child.pid) return; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + await Promise.race([ + new Promise((resolveClose) => child.once("close", resolveClose)), + new Promise((resolveTimeout) => setTimeout(resolveTimeout, 5_000)), + ]); +} + +async function withWorkerd(site, check) { + const port = await freePort(); + const child = spawn("pnpm", ["exec", "wrangler", "dev", "--port", String(port)], { + cwd: site, + detached: process.platform !== "win32", + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + let logs = ""; + child.stdout.on("data", (chunk) => (logs += chunk.toString())); + child.stderr.on("data", (chunk) => (logs += chunk.toString())); + const origin = `http://127.0.0.1:${port}`; + + try { + const deadline = Date.now() + 30_000; + let ready = false; + while (Date.now() < deadline) { + if (child.exitCode !== null) fail(`wrangler exited before serving\n${logs}`); + try { + await fetch(`${origin}/runtime/`); + ready = true; + break; + } catch { + await new Promise((resolveWait) => setTimeout(resolveWait, 250)); + } + } + if (!ready) fail(`wrangler did not become ready\n${logs}`); + await check(origin); + } catch (error) { + fail(`${error instanceof Error ? error.message : String(error)}\n${logs}`); + } finally { + await stop(child); + } +} + +async function request(origin, route, probe) { + const response = await fetch(`${origin}${route}`, { + headers: probe ? { "x-nimbus-probe": probe } : {}, + redirect: "manual", + }); + return { response, html: await response.text() }; +} + +function build(site, policy) { + writeRenderingPolicy(site, policy); + run("pnpm", ["build"], { cwd: site }); +} + +function writeRenderingPolicy(site, policy) { + mkdirSync(join(site, ".nimbus"), { recursive: true }); + writeFileSync( + join(site, ".nimbus", "feasibility-rendering.json"), + `${JSON.stringify(policy, null, 2)}\n`, + ); +} + +console.log(`${PREFIX} building packages and generating the starter`); +const nimbusPackage = JSON.parse(readFileSync(NIMBUS_PACKAGE, "utf8")); +for (const dependency of [ + "micromark", + "micromark-extension-gfm", + "remark-mdx", + "remark-parse", +]) { + for (const field of ["dependencies", "optionalDependencies", "peerDependencies"]) { + assert( + !nimbusPackage[field]?.[dependency], + `${dependency} must remain fixture-local, not a published Nimbus ${field} entry`, + ); + } +} +run("pnpm", ["--filter", "./packages/nimbus-docs", "--filter", "./packages/create-nimbus-docs", "build"]); +generateTemplates(GENERATED); + +const packRoot = mkdtempSync(join(tmpdir(), "nimbus-workers-pack-")); +cleanup.push(packRoot); +run("pnpm", ["--filter", "./packages/nimbus-docs", "exec", "pnpm", "pack", "--pack-destination", packRoot]); +const tarballName = readdirSync(packRoot).find((name) => name.endsWith(".tgz")); +assert(tarballName, "nimbus tarball was not created"); + +const workRoot = mkdtempSync(join(tmpdir(), "nimbus-workers-feasibility-")); +cleanup.push(workRoot); +run("node", [ + SCAFFOLDER, + "site", + "--yes", + "--skip-install", + "--no-git", + "--content", + "starter", + "--adapter", + "cloudflare", + "--template-dir", + GENERATED, +], { cwd: workRoot }); + +const site = join(workRoot, "site"); +rmSync(join(site, "src", "content", "docs"), { recursive: true, force: true }); +rmSync(join(site, "src", "content", "partials"), { recursive: true, force: true }); +for (const component of ["api-code-rail", "api-field-row", "api-layout", "api-sidebar"]) { + cpSync( + join(STARTER, "components", "ui", component), + join(site, "src", "components", "ui", component), + { recursive: true }, + ); +} +cpSync(FIXTURE, site, { recursive: true }); + +const packagePath = join(site, "package.json"); +const packageJson = JSON.parse(readFileSync(packagePath, "utf8")); +packageJson.dependencies["@cloudflare/nimbus-docs"] = `file:${join(packRoot, tarballName)}`; +packageJson.dependencies["@bruits/satteri-wasm32-wasi"] = "0.9.5"; +packageJson.dependencies["@readme/httpsnippet"] = "11.4.0"; +packageJson.dependencies["@scalar/openapi-parser"] = "0.28.12"; +packageJson.dependencies["hast-util-from-html"] = "2.0.3"; +packageJson.dependencies["hast-util-sanitize"] = "5.0.2"; +packageJson.dependencies["hast-util-to-html"] = "9.0.5"; +packageJson.dependencies.micromark = "4.0.2"; +packageJson.dependencies["micromark-extension-gfm"] = "3.0.0"; +packageJson.dependencies["openapi-sampler"] = "1.7.4"; +packageJson.dependencies["remark-mdx"] = "3.1.1"; +packageJson.dependencies["remark-parse"] = "11.0.0"; +packageJson.dependencies.unified = "11.0.5"; +writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); +mkdirSync(join(site, "src", "pages", "api"), { recursive: true }); + +console.log(`${PREFIX} installing and typechecking the generated consumer`); +run("pnpm", ["install", "--no-frozen-lockfile"], { cwd: site }); +writeRenderingPolicy(site, { docs: "build", api: "build" }); +run("pnpm", ["typecheck"], { cwd: site }); + +console.log(`${PREFIX} establishing the all-build baseline`); +build(site, { docs: "build", api: "build" }); +const staticPages = captureStaticPages(site); +const proseStatic = findMarkedPages(staticPages, "data-feasibility-prose"); +assert(proseStatic.length === 1, `expected one prose fixture, found ${proseStatic.length}`); +assertProse(proseStatic[0][1]); +const staticKinds = apiKinds(staticPages); +for (const kind of ["api", "section", "operation", "schema"]) { + assert(staticKinds.has(kind), `all-build baseline omitted the ${kind} API page`); + assertPreparedApi(staticKinds.get(kind).html, kind); +} +const shikiCss = readFileSync(join(site, "dist", "client", "_nimbus", "shiki.css"), "utf8"); +assert(shikiCss.includes(".nb-shiki-"), "all-build baseline omitted Shiki token styles"); + +function restoreShikiCss() { + const cssDir = join(site, "dist", "client", "_nimbus"); + mkdirSync(cssDir, { recursive: true }); + writeFileSync(join(cssDir, "shiki.css"), shikiCss); +} + +console.log(`${PREFIX} proving request prose beside build-rendered API pages`); +build(site, { docs: "request", api: "build" }); +restoreShikiCss(); +const requestProsePages = captureStaticPages(site); +assert(findMarkedPages(requestProsePages, "data-feasibility-prose").length === 0, "request prose emitted static HTML"); +assert(apiKinds(requestProsePages).size === 4, "build API pages were not emitted beside request prose"); +await withWorkerd(site, async (origin) => { + const first = await request(origin, proseStatic[0][0], "prose-one"); + const second = await request(origin, proseStatic[0][0], "prose-two"); + assert(first.response.status === 200 && second.response.status === 200, "request prose was not 200"); + assertProse(first.html); + assertProbe(first.html, "prose-one"); + assertProbe(second.html, "prose-two"); + const missing = await request(origin, "/missing-prose/", "missing"); + assert(missing.response.status === 404, "unknown request prose was not 404"); + const styles = await request(origin, "/_nimbus/shiki.css"); + assert(styles.response.status === 200 && styles.html.includes(".nb-shiki-"), "Shiki styles were not served"); + for (const { route } of staticKinds.values()) { + const response = await request(origin, route, "static-api"); + assert(response.response.status === 200, `build-rendered API route ${route} was not 200`); + assertNoProbe(response.html, "static-api"); + } +}); + +console.log(`${PREFIX} proving request API pages beside build-rendered prose`); +build(site, { docs: "build", api: "request" }); +restoreShikiCss(); +const requestApiPages = captureStaticPages(site); +assert(findMarkedPages(requestApiPages, "data-feasibility-prose").length === 1, "build prose was not emitted beside request API pages"); +assert(apiKinds(requestApiPages).size === 0, "request API emitted static HTML"); +const serverSource = outputText(join(site, "dist", "server")); +assert(serverSource.includes("Feasibility API"), "prepared API data is absent from the Worker bundle"); +assert(!serverSource.includes("raw-openapi-must-not-ship"), "raw OpenAPI leaked into the Worker bundle"); + +await withWorkerd(site, async (origin) => { + const prose = await request(origin, proseStatic[0][0], "static-prose"); + assert(prose.response.status === 200, "build-rendered prose was not 200"); + assertProse(prose.html); + assertNoProbe(prose.html, "static-prose"); + + for (const [kind, { route }] of staticKinds) { + const first = await request(origin, route, `${kind}-one`); + const second = await request(origin, route, `${kind}-two`); + assert( + first.response.status === 200 && second.response.status === 200, + `${kind} API route ${route} returned ${first.response.status}/${second.response.status}: ${first.html.slice(0, 500)}`, + ); + assertPreparedApi(first.html, kind); + assertProbe(first.html, `${kind}-one`); + assertProbe(second.html, `${kind}-two`); + } + const missing = await request(origin, "/api/missing/", "missing"); + assert(missing.response.status === 404, "unknown request API page was not 404"); +}); + +console.log(`${PREFIX} proving both route families in request mode`); +build(site, { docs: "request", api: "request" }); +restoreShikiCss(); +const requestOnlyPages = captureStaticPages(site); +assert(findMarkedPages(requestOnlyPages, "data-feasibility-prose").length === 0, "request-only build emitted prose HTML"); +assert(apiKinds(requestOnlyPages).size === 0, "request-only build emitted API HTML"); +const requestOnlyServerSource = outputText(join(site, "dist", "server")); +assert(requestOnlyServerSource.includes("Feasibility API"), "prepared API data is absent from the request-only Worker bundle"); +assert(!requestOnlyServerSource.includes("raw-openapi-must-not-ship"), "raw OpenAPI leaked into the request-only Worker bundle"); +rmSync(join(site, "src", "content", "api", "openapi.json")); + +await withWorkerd(site, async (origin) => { + const prose = await request(origin, proseStatic[0][0], "both-prose"); + assert(prose.response.status === 200, "request-only prose was not 200"); + assertProse(prose.html); + assertProbe(prose.html, "both-prose"); + + for (const [kind, { route }] of staticKinds) { + const api = await request(origin, route, `both-${kind}`); + assert(api.response.status === 200, `request-only ${kind} API route was not 200`); + assertPreparedApi(api.html, kind); + assertProbe(api.html, `both-${kind}`); + } +}); + +console.log(`${PREFIX} OK - technical build/request matrix passed on workerd`); From dbaaa74ca93bbece50cee6306f2036aa5ab2113b Mon Sep 17 00:00:00 2001 From: mohamedh Date: Tue, 1 Sep 2026 20:43:39 +0100 Subject: [PATCH 02/16] feat: unify static and request page resolution --- .../src/_internal/page-resolution.ts | 226 ++++++++++++ packages/nimbus-docs/src/index.ts | 145 ++++++-- .../nimbus-docs/test/page-resolution.test.ts | 349 ++++++++++++++++++ 3 files changed, 691 insertions(+), 29 deletions(-) create mode 100644 packages/nimbus-docs/src/_internal/page-resolution.ts create mode 100644 packages/nimbus-docs/test/page-resolution.test.ts diff --git a/packages/nimbus-docs/src/_internal/page-resolution.ts b/packages/nimbus-docs/src/_internal/page-resolution.ts new file mode 100644 index 00000000..c8fb4958 --- /dev/null +++ b/packages/nimbus-docs/src/_internal/page-resolution.ts @@ -0,0 +1,226 @@ +import type { CollectionEntry } from "astro:content"; +import type { AstroComponentFactory } from "astro/runtime/server/index.js"; + +import type { ApiNav, ApiPageProps } from "../api/index.js"; +import type { ApiSpec } from "../types.js"; +import { + collectionMountPrefix, + PRIMARY_COLLECTION, + type VersionInfo, +} from "./collection-mount.js"; +import { resolveApiVersion } from "./api/resolve-versions.js"; +import type { ProjectionContext } from "./projection.js"; +import { toRouteKey } from "./url.js"; + +export interface PageIdentity { + pathname: string; + collection: string; + locale?: string; +} + +export interface ProsePage { + kind: "prose"; + identity: PageIdentity; + entry: CollectionEntry; + Content: AstroComponentFactory; + headings: { depth: number; text: string; slug: string }[]; +} + +export interface ApiPage { + kind: "api"; + identity: PageIdentity; + page: ApiPageProps; + nav: ApiNav; + collection: string; + version: string | null; + coordinate: string; +} + +export type PageResolution

    = + | { status: "found"; page: P } + | { status: "redirect"; location: string; permanent: boolean } + | { status: "not-found" }; + +export interface PageResolutionContext { + props: Record; + params: Record; + url: URL; + projection?: ProjectionContext; +} + +interface ProseResolutionDependencies { + getVisibleEntry( + collection: string, + id: string, + projection?: ProjectionContext, + ): Promise | null>; + getVersions(): Promise; + render(entry: CollectionEntry): Promise<{ + Content: AstroComponentFactory; + headings: { depth: number; text: string; slug: string }[]; + }>; +} + +interface ApiResolutionDependencies { + getApiSpecs(): Promise; + getVisibleEntry( + collection: string, + id: string, + projection?: ProjectionContext, + ): Promise | null>; + render( + collection: string, + version: string | null, + coordinate: string, + ): Promise<{ page: ApiPageProps; nav: ApiNav }>; +} + +function normalizedPathname(url: URL): string { + return toRouteKey(url.pathname); +} + +function normalizedPageId(param: string | undefined): string { + if (!param) return "index"; + const route = toRouteKey(`/${param}`); + return route === "/" ? "index" : route.slice(1); +} + +function mountedCollectionSegment(context: PageResolutionContext): string | null { + const pathname = normalizedPathname(context.url); + const pathSegments = pathname.split("/").filter(Boolean); + const param = context.params.slug; + if (!param) return pathSegments.at(-1) ?? null; + + const paramSegments = normalizedPageId(param).split("/"); + const suffix = pathSegments.slice(-paramSegments.length); + if (suffix.join("/") !== paramSegments.join("/")) return null; + return pathSegments.at(-(paramSegments.length + 1)) ?? null; +} + +async function requestProseIdentity( + context: PageResolutionContext, + collection: string | undefined, + getVersions: () => Promise, +): Promise<{ collection: string; id: string } | null> { + const id = normalizedPageId(context.params.slug); + const versions = await getVersions(); + const [first, ...rest] = id === "index" ? [] : id.split("/"); + const mount = mountedCollectionSegment(context); + if ( + first && + versions?.others.includes(first) && + ((!collection && !mount) || + collection === PRIMARY_COLLECTION || + collection === `${PRIMARY_COLLECTION}-${first}`) + ) { + return { + collection: `${PRIMARY_COLLECTION}-${first}`, + id: rest.join("/") || "index", + }; + } + + if (collection) return { collection, id }; + if (!mount) return null; + + const candidate = versions?.others.includes(mount) + ? `${PRIMARY_COLLECTION}-${mount}` + : mount; + return collectionMountPrefix(candidate, versions) === `/${mount}` + ? { collection: candidate, id } + : null; +} + +export async function resolveProsePage( + context: PageResolutionContext, + options: { collection?: string }, + dependencies: ProseResolutionDependencies, +): Promise> { + const staticEntry = context.props.entry as CollectionEntry | undefined; + const requestIdentity = staticEntry + ? null + : await requestProseIdentity( + context, + options.collection, + dependencies.getVersions, + ); + const collection = staticEntry?.collection ?? requestIdentity?.collection; + if (!collection || (!staticEntry && !requestIdentity)) { + return { status: "not-found" }; + } + + const entry = + staticEntry ?? + (await dependencies.getVisibleEntry( + collection, + requestIdentity!.id, + context.projection, + )); + if (!entry) return { status: "not-found" }; + + const rendered = await dependencies.render(entry); + return { + status: "found", + page: { + kind: "prose", + identity: { pathname: normalizedPathname(context.url), collection }, + entry, + ...rendered, + }, + }; +} + +export async function resolveApiPage( + context: PageResolutionContext, + options: { collection?: string }, + dependencies: ApiResolutionDependencies, +): Promise> { + const staticCollection = + typeof context.props.collection === "string" ? context.props.collection : undefined; + const staticCoordinate = + typeof context.props.coordinate === "string" ? context.props.coordinate : undefined; + const hasStaticIdentity = staticCollection !== undefined && staticCoordinate !== undefined; + + let collection = staticCollection ?? options.collection; + let version = + typeof context.props.version === "string" ? context.props.version : null; + let coordinate = staticCoordinate; + + if (!hasStaticIdentity) { + const api = await dependencies.getApiSpecs(); + collection ??= mountedCollectionSegment(context) ?? undefined; + if (!collection || !(api ?? []).some((entry) => entry.collection === collection)) { + return { status: "not-found" }; + } + + const id = normalizedPageId(context.params.slug); + if (id === "index" && context.params.slug !== undefined) { + return { status: "not-found" }; + } + const entry = await dependencies.getVisibleEntry( + collection, + id, + context.projection, + ); + if (!entry) return { status: "not-found" }; + + coordinate = + typeof entry.data.coordinate === "string" ? entry.data.coordinate : undefined; + version = typeof entry.data.version === "string" ? entry.data.version : null; + if (!coordinate || !resolveApiVersion(api, collection, version)) { + return { status: "not-found" }; + } + } + + const rendered = await dependencies.render(collection!, version, coordinate!); + return { + status: "found", + page: { + kind: "api", + identity: { pathname: normalizedPathname(context.url), collection: collection! }, + ...rendered, + collection: collection!, + version, + coordinate: coordinate!, + }, + }; +} diff --git a/packages/nimbus-docs/src/index.ts b/packages/nimbus-docs/src/index.ts index 429c1e13..c4555398 100644 --- a/packages/nimbus-docs/src/index.ts +++ b/packages/nimbus-docs/src/index.ts @@ -72,6 +72,12 @@ import { clearValidInternalLinksCache, getValidInternalLinks, } from "./_internal/valid-internal-links.js"; +import { + resolveApiPage, + resolveProsePage, + type PageResolutionContext, + type ProsePage, +} from "./_internal/page-resolution.js"; import type { ApiVersionStatus, @@ -992,6 +998,54 @@ export function getTOC( import type { AstroGlobal, GetStaticPaths } from "astro"; +function pageResolutionContext(astro: AstroGlobal): PageResolutionContext { + const audience = ( + astro.locals as { + nimbus?: { audience?: NonNullable }; + } + ).nimbus?.audience; + return { + props: astro.props as Record, + params: astro.params, + url: astro.url, + projection: audience ? { audience } : undefined, + }; +} + +async function resolveAstroProsePage( + astro: AstroGlobal, + collection: string | undefined, + partialHeadings: PartialHeadingOptions | undefined, +): Promise { + const context = pageResolutionContext(astro); + const result = await resolveProsePage( + context, + { collection }, + { + getVisibleEntry: getVisibleEntry as ( + collection: string, + id: string, + ctx?: ProjectionContext, + ) => Promise | null>, + getVersions, + async render(entry) { + const { render } = await import("astro:content"); + const { Content, headings } = await render(entry); + const merged = await mergePartialHeadings( + entry.body, + headings, + (partialCollection: string, id: string) => + getVisibleEntry(partialCollection, id, context.projection), + render as (entry: unknown) => Promise<{ headings: typeof headings }>, + partialHeadings, + ); + return { Content, headings: merged }; + }, + }, + ); + return result.status === "found" ? result.page : null; +} + /** * `getStaticPaths` implementation for a docs catch-all route. * @@ -1062,8 +1116,9 @@ export async function getDocsPageProps( Content: import("astro/runtime/server/index.js").AstroComponentFactory; headings: { depth: number; text: string; slug: string }[]; }> { - const entry = (astro.props as { entry?: import("astro:content").CollectionEntry<"docs"> }) - .entry; + const entry = (astro.props as { + entry?: import("astro:content").CollectionEntry<"docs">; + }).entry; if (!entry) { throw new Error( "getDocsPageProps(): expected `entry` in Astro.props. " + @@ -1071,16 +1126,19 @@ export async function getDocsPageProps( "(or passes an entry via custom getStaticPaths).", ); } - const { render } = await import("astro:content"); - const { Content, headings } = await render(entry); - const merged = await mergePartialHeadings( - entry.body, - headings, - getVisibleEntry as (collection: string, id: string) => Promise, - render as (entry: unknown) => Promise<{ headings: typeof headings }>, + const page = await resolveAstroProsePage( + astro, + PRIMARY_COLLECTION, options?.partialHeadings, ); - return { entry, Content, headings: merged }; + if (!page) { + throw new Error(`getDocsPageProps(): could not resolve entry "${entry.id}".`); + } + return { + entry: page.entry as import("astro:content").CollectionEntry<"docs">, + Content: page.Content, + headings: page.headings, + }; } /** @@ -1165,24 +1223,28 @@ export async function getCollectionPageProps( Content: import("astro/runtime/server/index.js").AstroComponentFactory; headings: { depth: number; text: string; slug: string }[]; }> { - const entry = (astro.props as { entry?: import("astro:content").CollectionEntry }) - .entry; + const entry = (astro.props as { + entry?: import("astro:content").CollectionEntry; + }).entry; if (!entry) { throw new Error( "getCollectionPageProps(): expected `entry` in Astro.props. " + "Ensure your route uses `getStaticPaths = getCollectionStaticPaths()`.", ); } - const { render } = await import("astro:content"); - const { Content, headings } = await render(entry); - const merged = await mergePartialHeadings( - entry.body, - headings, - getVisibleEntry as (collection: string, id: string) => Promise, - render as (entry: unknown) => Promise<{ headings: typeof headings }>, + const page = await resolveAstroProsePage( + astro, + undefined, options?.partialHeadings, ); - return { entry, Content, headings: merged }; + if (!page) { + throw new Error(`getCollectionPageProps(): could not resolve entry "${entry.id}".`); + } + return { + entry: page.entry as import("astro:content").CollectionEntry, + Content: page.Content, + headings: page.headings, + }; } // --------------------------------------------------------------------------- @@ -1282,25 +1344,50 @@ export async function getApiPage(astro: AstroGlobal): Promise<{ version: string | null; coordinate: string; }> { - const { collection, version, coordinate } = astro.props as { + const props = astro.props as { collection?: string; version?: string | null; coordinate?: string; }; - if (!collection || !coordinate) { + if (!props.collection || !props.coordinate) { + throw new Error( + "getApiPage(): expected `collection` and `coordinate` in Astro.props. " + + "Ensure your route uses `getStaticPaths = getApiStaticPaths()`.", + ); + } + + const result = await resolveApiPage(pageResolutionContext(astro), {}, { + async getApiSpecs() { + return (await loadNimbusConfig()).api; + }, + getVisibleEntry: getVisibleEntry as ( + collection: string, + id: string, + ctx?: ProjectionContext, + ) => Promise | null>, + async render(collection, version, coordinate) { + const { getApiModel, getApiPageProps, getApiNav } = await import( + "./api/index.js" + ); + const model = await getApiModel(collection, version ?? undefined); + return { + page: getApiPageProps(model, coordinate), + nav: getApiNav(model, coordinate), + }; + }, + }); + if (result.status !== "found") { throw new Error( "getApiPage(): expected `collection` and `coordinate` in Astro.props. " + "Ensure your route uses `getStaticPaths = getApiStaticPaths()`.", ); } - const { getApiModel, getApiPageProps, getApiNav } = await import("./api/index.js"); - const model = await getApiModel(collection, version ?? undefined); return { - page: getApiPageProps(model, coordinate), - nav: getApiNav(model, coordinate), - collection, - version: version ?? null, - coordinate, + page: result.page.page, + nav: result.page.nav, + collection: result.page.collection, + version: result.page.version, + coordinate: result.page.coordinate, }; } diff --git a/packages/nimbus-docs/test/page-resolution.test.ts b/packages/nimbus-docs/test/page-resolution.test.ts new file mode 100644 index 00000000..ff1f480a --- /dev/null +++ b/packages/nimbus-docs/test/page-resolution.test.ts @@ -0,0 +1,349 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; + +import type { CollectionEntry } from "astro:content"; + +import type { ApiNav, ApiPageProps } from "../src/api/index.js"; +import { + resolveApiPage, + resolveProsePage, + type PageResolution, + type PageResolutionContext, + type ProsePage, +} from "../src/_internal/page-resolution.js"; +import type { ApiSpec } from "../src/types.js"; +import { + getApiPage, + getCollectionPageProps, + getDocsPageProps, +} from "../src/index.js"; + +function context( + pathname: string, + slug: string | undefined, + props: Record = {}, +): PageResolutionContext { + return { + props, + params: { slug }, + url: new URL(pathname, "https://example.com"), + projection: { audience: { key: "test" } }, + }; +} + +function entry( + collection: string, + id: string, + data: Record = {}, +): CollectionEntry { + return { collection, id, data, body: `# ${id}` }; +} + +const Content = (() => undefined) as unknown as ProsePage["Content"]; + +test("the shared contract represents redirect outcomes", () => { + const resolution: PageResolution = { + status: "redirect", + location: "/current/", + permanent: true, + }; + assert.equal(resolution.status, "redirect"); +}); + +test("public static helpers preserve missing-prop diagnostics", async () => { + await assert.rejects( + () => getDocsPageProps({ props: {} } as never), + /expected `entry` in Astro\.props/, + ); + await assert.rejects( + () => getCollectionPageProps({ props: {} } as never), + /expected `entry` in Astro\.props/, + ); + await assert.rejects( + () => getApiPage({ props: {} } as never), + /expected `collection` and `coordinate` in Astro\.props/, + ); +}); + +describe("prose page resolution", () => { + const entries = new Map([ + ["docs:index", entry("docs", "index")], + ["docs:guides/setup", entry("docs", "guides/setup")], + ["docs-v1:index", entry("docs-v1", "index")], + ["docs-v1:v1", entry("docs-v1", "v1")], + ["docs-v1:guides/setup", entry("docs-v1", "guides/setup")], + ["blog:v1", entry("blog", "v1")], + ]); + let lookups: Array<{ collection: string; id: string; audience?: string }> = []; + const dependencies = { + async getVisibleEntry( + collection: string, + id: string, + projection?: PageResolutionContext["projection"], + ) { + lookups.push({ collection, id, audience: projection?.audience?.key }); + return entries.get(`${collection}:${id}`) ?? null; + }, + async getVersions() { + return { others: ["v1"] }; + }, + async render(value: CollectionEntry) { + return { + Content, + headings: [{ depth: 1, text: value.id, slug: value.id.replaceAll("/", "-") }], + }; + }, + }; + + test("uses an existing static entry without a collection lookup", async () => { + lookups = []; + const staticEntry = entry("docs", "static"); + const result = await resolveProsePage( + context("/static/", "wrong", { entry: staticEntry }), + { collection: "docs" }, + dependencies, + ); + + assert.equal(result.status, "found"); + if (result.status !== "found") return; + assert.equal(result.page.entry, staticEntry); + assert.equal(result.page.identity.pathname, "/static"); + assert.deepEqual(lookups, []); + }); + + test("resolves the root and nested docs paths through visible content", async () => { + lookups = []; + const root = await resolveProsePage( + context("/", undefined), + { collection: "docs" }, + dependencies, + ); + const nested = await resolveProsePage( + context("/guides/setup/", "guides/setup"), + { collection: "docs" }, + dependencies, + ); + + assert.equal(root.status, "found"); + assert.equal(nested.status, "found"); + assert.deepEqual(lookups, [ + { collection: "docs", id: "index", audience: "test" }, + { collection: "docs", id: "guides/setup", audience: "test" }, + ]); + }); + + test("strips the leading prose version from root catch-all IDs", async () => { + const versionRoot = await resolveProsePage( + context("/v1/", "v1"), + { collection: "docs" }, + dependencies, + ); + const versionLeaf = await resolveProsePage( + context("/v1/guides/setup/", "v1/guides/setup"), + { collection: "docs" }, + dependencies, + ); + const missing = await resolveProsePage( + context("/missing/", "missing"), + { collection: "docs" }, + dependencies, + ); + + assert.equal(versionRoot.status, "found"); + if (versionRoot.status === "found") { + assert.equal(versionRoot.page.identity.collection, "docs-v1"); + assert.equal(versionRoot.page.entry.id, "index"); + } + assert.equal(versionLeaf.status, "found"); + if (versionLeaf.status === "found") { + assert.equal(versionLeaf.page.identity.collection, "docs-v1"); + assert.equal(versionLeaf.page.entry.id, "guides/setup"); + } + assert.deepEqual(missing, { status: "not-found" }); + }); + + test("does not strip version-like IDs inside mounted collection routes", async () => { + const versionLeaf = await resolveProsePage( + context("/v1/v1/", "v1"), + {}, + dependencies, + ); + const blogLeaf = await resolveProsePage( + context("/blog/v1/", "v1"), + {}, + dependencies, + ); + + assert.equal(versionLeaf.status, "found"); + if (versionLeaf.status === "found") { + assert.equal(versionLeaf.page.identity.collection, "docs-v1"); + assert.equal(versionLeaf.page.entry.id, "v1"); + } + assert.equal(blogLeaf.status, "found"); + if (blogLeaf.status === "found") { + assert.equal(blogLeaf.page.identity.collection, "blog"); + assert.equal(blogLeaf.page.entry.id, "v1"); + } + }); +}); + +describe("API page resolution", () => { + const api: ApiSpec[] = [ + { + collection: "api", + versions: [ + { version: "v2", spec: {}, default: true }, + { version: "v1", spec: {} }, + ], + }, + ]; + const entries = new Map([ + ["api:index", entry("api", "index", { coordinate: "root", version: "v2" })], + [ + "api:charges/create", + entry("api", "charges/create", { coordinate: "createCharge", version: "v2" }), + ], + ["api:v1", entry("api", "v1", { coordinate: "root", version: "v1" })], + [ + "api:v1/charges/create", + entry("api", "v1/charges/create", { + coordinate: "createCharge", + version: "v1", + }), + ], + ]); + let lookups: Array<{ collection: string; id: string; audience?: string }> = []; + let specLoads = 0; + const dependencies = { + async getApiSpecs() { + specLoads++; + return api; + }, + async getVisibleEntry( + collection: string, + id: string, + projection?: PageResolutionContext["projection"], + ) { + lookups.push({ collection, id, audience: projection?.audience?.key }); + return entries.get(`${collection}:${id}`) ?? null; + }, + async render(collection: string, version: string | null, coordinate: string) { + const page: ApiPageProps = { + apiSchemaVersion: 1, + kind: "api", + collection, + coordinate, + href: "/api", + markdownHref: "/api/index.md", + title: coordinate, + breadcrumbs: [], + servers: [], + sections: [], + ...(version ? { version } : {}), + }; + const nav: ApiNav = { apiSchemaVersion: 1, collection, items: [] }; + return { page, nav }; + }, + }; + + test("uses existing static API identity without request lookup", async () => { + lookups = []; + specLoads = 0; + const result = await resolveApiPage( + context("/api/charges/create/", "ignored", { + collection: "api", + version: "v2", + coordinate: "createCharge", + }), + {}, + dependencies, + ); + + assert.equal(result.status, "found"); + if (result.status !== "found") return; + assert.equal(result.page.coordinate, "createCharge"); + assert.equal(result.page.version, "v2"); + assert.deepEqual(lookups, []); + assert.equal(specLoads, 0); + }); + + test("resolves default root and nested API request paths", async () => { + lookups = []; + const root = await resolveApiPage( + context("/base/api/", undefined), + {}, + dependencies, + ); + const nested = await resolveApiPage( + context("/base/api/charges/create/", "charges/create"), + {}, + dependencies, + ); + + assert.equal(root.status, "found"); + assert.equal(nested.status, "found"); + assert.deepEqual(lookups, [ + { collection: "api", id: "index", audience: "test" }, + { collection: "api", id: "charges/create", audience: "test" }, + ]); + }); + + test("resolves version roots and leaves from their indexed store IDs", async () => { + const root = await resolveApiPage( + context("/api/v1/", "v1"), + {}, + dependencies, + ); + const leaf = await resolveApiPage( + context("/api/v1/charges/create/", "v1/charges/create"), + {}, + dependencies, + ); + + assert.equal(root.status, "found"); + assert.equal(leaf.status, "found"); + if (root.status === "found") assert.equal(root.page.version, "v1"); + if (leaf.status === "found") assert.equal(leaf.page.coordinate, "createCharge"); + }); + + test("returns not-found for missing and unknown API paths", async () => { + const missing = await resolveApiPage( + context("/api/missing/", "missing"), + {}, + dependencies, + ); + const unknown = await resolveApiPage( + context("/unknown/", undefined), + {}, + dependencies, + ); + const rootAlias = await resolveApiPage( + context("/api/index/", "index"), + {}, + dependencies, + ); + + assert.deepEqual(missing, { status: "not-found" }); + assert.deepEqual(unknown, { status: "not-found" }); + assert.deepEqual(rootAlias, { status: "not-found" }); + }); + + test("preserves an unversioned API's null version", async () => { + const result = await resolveApiPage( + context("/legacy/", undefined), + { collection: "legacy" }, + { + ...dependencies, + async getApiSpecs() { + return [{ collection: "legacy", spec: {} }]; + }, + async getVisibleEntry() { + return entry("legacy", "index", { coordinate: "root" }); + }, + }, + ); + + assert.equal(result.status, "found"); + if (result.status === "found") assert.equal(result.page.version, null); + }); +}); From 6ee928e5be794ab0a7711395f2ed6f299cb271cc Mon Sep 17 00:00:00 2001 From: mohamedh Date: Wed, 2 Sep 2026 12:37:47 +0100 Subject: [PATCH 03/16] feat: add collection rendering policy --- .changeset/rendering-policy.md | 7 + packages/nimbus-docs/package.json | 8 +- .../src/_internal/api/api-view-types.ts | 3 + .../nimbus-docs/src/_internal/api/prepared.ts | 67 + .../src/_internal/api/runtime-build-config.ts | 3 + .../src/_internal/api/view-model.ts | 16 +- .../nimbus-docs/src/_internal/build-report.ts | 28 +- .../_internal/default-markdown-processor.ts | 17 + .../src/_internal/page-resolution.ts | 64 +- .../_internal/parse-content-collections.ts | 47 +- .../src/_internal/partial-headings.ts | 28 +- .../src/_internal/register-code-styles.ts | 20 + .../src/_internal/rendering-policy.ts | 66 + .../src/_internal/request-route-inventory.ts | 41 + .../src/_internal/request-route-url.ts | 8 + .../src/_internal/runtime-config.ts | 24 +- .../src/_internal/scan-code-langs.ts | 76 +- .../nimbus-docs/src/_internal/validate.ts | 20 + .../src/_internal/virtual-api-build-config.ts | 31 + .../src/_internal/virtual-config.ts | 35 +- .../src/_internal/worker-partial-headings.ts | 135 ++ packages/nimbus-docs/src/api/index.ts | 46 +- packages/nimbus-docs/src/check/structure.ts | 8 +- .../src/components/NimbusHead.astro | 32 +- packages/nimbus-docs/src/content.ts | 70 +- packages/nimbus-docs/src/index.ts | 1703 +-------------- packages/nimbus-docs/src/integration.ts | 447 +++- packages/nimbus-docs/src/runtime.ts | 1936 +++++++++++++++++ packages/nimbus-docs/src/types.ts | 14 + .../src/types/virtual-modules.d.ts | 9 + packages/nimbus-docs/test/api-loader.test.ts | 346 ++- .../nimbus-docs/test/api-view-model.test.ts | 22 + .../nimbus-docs/test/build-report.test.ts | 24 +- .../test/fixtures/api/smallco.yaml | 1 + .../nimbus-docs/test/page-resolution.test.ts | 40 +- .../nimbus-docs/test/partial-headings.test.ts | 25 + .../nimbus-docs/test/rendering-policy.test.ts | 617 ++++++ .../nimbus-docs/test/scan-code-langs.test.ts | 87 +- .../nimbus-docs/test/virtual-config.test.ts | 46 + packages/nimbus-docs/tsdown.config.ts | 5 + pnpm-lock.yaml | 27 +- 41 files changed, 4234 insertions(+), 2015 deletions(-) create mode 100644 .changeset/rendering-policy.md create mode 100644 packages/nimbus-docs/src/_internal/api/prepared.ts create mode 100644 packages/nimbus-docs/src/_internal/api/runtime-build-config.ts create mode 100644 packages/nimbus-docs/src/_internal/default-markdown-processor.ts create mode 100644 packages/nimbus-docs/src/_internal/register-code-styles.ts create mode 100644 packages/nimbus-docs/src/_internal/rendering-policy.ts create mode 100644 packages/nimbus-docs/src/_internal/request-route-inventory.ts create mode 100644 packages/nimbus-docs/src/_internal/request-route-url.ts create mode 100644 packages/nimbus-docs/src/_internal/virtual-api-build-config.ts create mode 100644 packages/nimbus-docs/src/_internal/worker-partial-headings.ts create mode 100644 packages/nimbus-docs/src/runtime.ts create mode 100644 packages/nimbus-docs/test/rendering-policy.test.ts create mode 100644 packages/nimbus-docs/test/virtual-config.test.ts diff --git a/.changeset/rendering-policy.md b/.changeset/rendering-policy.md new file mode 100644 index 00000000..cdaaeb9b --- /dev/null +++ b/.changeset/rendering-policy.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/nimbus-docs": minor +--- + +Add collection-level build and request rendering policy. + +Configure a default rendering mode and per-collection overrides. Nimbus validates collection names and production server compatibility, applies the policy only to canonical collection catch-all routes, and explains intentional request routes in build diagnostics. Request-rendered prose and API routes use response-aware page helpers, with API page models prepared during content sync so Workers never read or parse the source OpenAPI spec. diff --git a/packages/nimbus-docs/package.json b/packages/nimbus-docs/package.json index 7f24ca24..a5b9df44 100644 --- a/packages/nimbus-docs/package.json +++ b/packages/nimbus-docs/package.json @@ -33,6 +33,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./runtime": { + "types": "./dist/runtime.d.ts", + "import": "./dist/runtime.js" + }, "./config": { "types": "./dist/config.d.ts", "import": "./dist/config.js" @@ -161,13 +165,15 @@ "@mdx-js/mdx": "^3.1.1", "@readme/httpsnippet": "^11.4.0", "@scalar/openapi-parser": "^0.28.12", - "openapi-sampler": "^1.7.4", "@types/picomatch": "^4.0.3", "@types/react": "^19.2.6", "@types/react-dom": "^19.2.3", "@types/semver": "^7.7.1", "astro": "~7.0.9", "jsdom": "^29.1.1", + "openapi-sampler": "^1.7.4", + "remark-mdx": "^3.1.1", + "remark-parse": "^11.0.0", "tsdown": "^0.20.3", "tsx": "^4.22.3", "typescript": "^5.8.3" diff --git a/packages/nimbus-docs/src/_internal/api/api-view-types.ts b/packages/nimbus-docs/src/_internal/api/api-view-types.ts index 8e2490a7..ef77258a 100644 --- a/packages/nimbus-docs/src/_internal/api/api-view-types.ts +++ b/packages/nimbus-docs/src/_internal/api/api-view-types.ts @@ -76,6 +76,7 @@ export interface ApiFieldView { enum?: JsonValue[]; example?: JsonValue; description?: string; + descriptionHtml?: string; anchor: string; children: ApiFieldView[]; childCount: number; @@ -99,6 +100,7 @@ export interface ApiPageBase { tokenCount?: number; title: string; description?: string; + descriptionHtml?: string; deprecated?: boolean; deprecation?: { successor?: ApiRef; migrationHref?: string }; breadcrumbs: ApiBreadcrumb[]; @@ -129,6 +131,7 @@ export interface ApiResponseView { status: string; statusClass?: "info" | "success" | "redirect" | "client-error" | "server-error"; description?: string; + descriptionHtml?: string; anchor: string; headers?: ApiFieldView[]; fields: ApiFieldView[]; diff --git a/packages/nimbus-docs/src/_internal/api/prepared.ts b/packages/nimbus-docs/src/_internal/api/prepared.ts new file mode 100644 index 00000000..38b6e054 --- /dev/null +++ b/packages/nimbus-docs/src/_internal/api/prepared.ts @@ -0,0 +1,67 @@ +import type { ApiNav, ApiNavItem, ApiPageProps } from "./api-view-types.js"; + +export const preparedApiVersion = 2; + +export interface PreparedApiNav { + version: typeof preparedApiVersion; + nav: ApiNav; + paths: Record; +} + +export interface PreparedApiPage { + version: typeof preparedApiVersion; + page: ApiPageProps; + navEntryId: string; + nav?: PreparedApiNav; +} + +export function prepareApiNav(nav: ApiNav): PreparedApiNav { + const paths: Record = Object.create(null); + const visit = (item: ApiNavItem, parentPath: string[]) => { + const path = [...parentPath, item.coordinate]; + paths[item.coordinate] = path; + for (const child of item.children) visit(child, path); + }; + for (const item of nav.items) visit(item, []); + return { version: preparedApiVersion, nav, paths }; +} + +export function activatePreparedApiNav( + prepared: PreparedApiNav, + coordinate: string, +): ApiNav { + const path = prepared.paths[coordinate]; + if (!path) return prepared.nav; + const onPath = new Set(path); + const overlay = (item: ApiNavItem): ApiNavItem => { + if (!onPath.has(item.coordinate)) return item; + const next = { ...item, children: item.children.map(overlay) }; + if (item.coordinate === coordinate) next.active = true; + else next.expanded = true; + return next; + }; + return { ...prepared.nav, items: prepared.nav.items.map(overlay) }; +} + +export function isPreparedApiPage(value: unknown): value is PreparedApiPage { + if (!value || typeof value !== "object") return false; + const prepared = value as Partial; + return ( + prepared.version === preparedApiVersion && + typeof prepared.navEntryId === "string" && + !!prepared.page && + typeof prepared.page === "object" + ); +} + +export function isPreparedApiNav(value: unknown): value is PreparedApiNav { + if (!value || typeof value !== "object") return false; + const prepared = value as Partial; + return ( + prepared.version === preparedApiVersion && + !!prepared.nav && + typeof prepared.nav === "object" && + !!prepared.paths && + typeof prepared.paths === "object" + ); +} diff --git a/packages/nimbus-docs/src/_internal/api/runtime-build-config.ts b/packages/nimbus-docs/src/_internal/api/runtime-build-config.ts new file mode 100644 index 00000000..779423f7 --- /dev/null +++ b/packages/nimbus-docs/src/_internal/api/runtime-build-config.ts @@ -0,0 +1,3 @@ +export async function loadApiBuildConfig() { + return import("virtual:nimbus/api-build-config"); +} diff --git a/packages/nimbus-docs/src/_internal/api/view-model.ts b/packages/nimbus-docs/src/_internal/api/view-model.ts index 8b713e97..02f25655 100644 --- a/packages/nimbus-docs/src/_internal/api/view-model.ts +++ b/packages/nimbus-docs/src/_internal/api/view-model.ts @@ -50,6 +50,7 @@ import { type ApiVariant, type JsonValue, } from "./api-view-types.js"; +import { renderMarkdown } from "../../markdown/render.js"; export * from "./api-view-types.js"; @@ -430,7 +431,10 @@ function fieldView( const example = jsonOrOmit(f.example); if (example !== undefined) out.example = example; const description = descriptionOf(node); - if (description) out.description = description; + if (description) { + out.description = description; + out.descriptionHtml = renderMarkdown(description); + } if (f.union) out.union = unionView(view, f.union, allowInline); if (f.typeRef?.coordinate && view.node(f.typeRef.coordinate)) { out.typeRef = { label: f.typeRef.label, href: view.href(f.typeRef.coordinate) }; @@ -531,7 +535,10 @@ function responseViews(view: ModelView, opCoord: Coordinate): ApiResponseView[] const statusClass = statusClassOf(f.status); if (statusClass) out.statusClass = statusClass; if (bounded.truncated) out.truncated = { total: bounded.total }; - if (f.description) out.description = f.description; + if (f.description) { + out.description = f.description; + out.descriptionHtml = renderMarkdown(f.description); + } if (f.union) out.bodyUnion = unionView(view, f.union, true); if (f.example) { const value = jsonOrOmit(f.example.value); @@ -571,7 +578,10 @@ function base(view: ModelView, node: Node): ApiPageBase { breadcrumbs: breadcrumbs(view, node), }; const description = descriptionOf(node); - if (description) out.description = description; + if (description) { + out.description = description; + out.descriptionHtml = renderMarkdown(description); + } return out; } diff --git a/packages/nimbus-docs/src/_internal/build-report.ts b/packages/nimbus-docs/src/_internal/build-report.ts index f5f9502f..b790e0de 100644 --- a/packages/nimbus-docs/src/_internal/build-report.ts +++ b/packages/nimbus-docs/src/_internal/build-report.ts @@ -1,9 +1,9 @@ /** * Prerender invariant reporter. From the routes Astro resolves at build, it - * asserts the bidirectional invariant — every public doc route stays - * prerendered, and every on-demand route is *explained* — and produces the - * build summary line. An unexplained on-demand route is a build failure, not a - * warning. + * asserts the bidirectional invariant — every public doc route is prerendered + * or explicitly request-rendered, and every on-demand route is *explained* — + * and produces the build summary line. An unexplained on-demand route is a + * build failure, not a warning. * * Astro's own internal routes are excluded by route provenance. Project and * integration routes are explained only if they're declared feature routes. @@ -21,7 +21,9 @@ export interface BuildReportInput { adapterName: string | null; routes: readonly ResolvedRouteLike[]; prerenderedPageCount: number; + requestRenderedPageCount?: number; declaredFeatureRoutes?: readonly string[]; + declaredRequestRoutes?: readonly string[]; serverFeatures?: readonly string[]; } @@ -33,7 +35,8 @@ export interface BuildReport { } export function analyzeBuild(input: BuildReportInput): BuildReport { - const declared = new Set(input.declaredFeatureRoutes ?? []); + const declaredFeatures = new Set(input.declaredFeatureRoutes ?? []); + const declaredRequests = new Set(input.declaredRequestRoutes ?? []); const routable = input.routes.filter( (r) => r.type === "page" || r.type === "endpoint", ); @@ -41,8 +44,13 @@ export function analyzeBuild(input: BuildReportInput): BuildReport { const nonInfraOnDemand = reportable.filter((r) => !r.isPrerendered); const onDemandDocRoutes = nonInfraOnDemand.map((r) => r.pattern); const violations = nonInfraOnDemand - .filter((r) => !declared.has(r.pattern)) + .filter( + (r) => + !declaredFeatures.has(r.pattern) && + !declaredRequests.has(r.pattern), + ) .map((r) => r.pattern); + const moved = input.requestRenderedPageCount ?? 0; const fatal = input.outputMode === "server" && reportable.length === 0 @@ -53,7 +61,7 @@ export function analyzeBuild(input: BuildReportInput): BuildReport { : null; return { - summaryLine: formatSummary(input, onDemandDocRoutes, violations.length), + summaryLine: formatSummary(input, onDemandDocRoutes, moved), violations, onDemandDocRoutes, fatal, @@ -93,8 +101,8 @@ export function formatInvariantFailure(violations: readonly string[]): string { `nimbus: prerender invariant FAILED — ${violations.length} unexplained ` + `on-demand route${violations.length === 1 ? "" : "s"}:\n` + violations.map((p) => ` - ${p}`).join("\n") + - `\n\nEvery public doc route must stay prerendered (\`export const prerender = true\`). ` + - `A route is on-demand because it opted out — restore its prerender export, or ` + - `(if it's a server feature endpoint) declare it so the reporter can explain it.` + `\n\nEvery public doc route must be prerendered or selected by the Nimbus rendering policy. ` + + `Restore prerendering, configure the route's collection for request rendering, or ` + + `(for a server feature endpoint) declare it so the reporter can explain it.` ); } diff --git a/packages/nimbus-docs/src/_internal/default-markdown-processor.ts b/packages/nimbus-docs/src/_internal/default-markdown-processor.ts new file mode 100644 index 00000000..afee4384 --- /dev/null +++ b/packages/nimbus-docs/src/_internal/default-markdown-processor.ts @@ -0,0 +1,17 @@ +import { satteri } from "@astrojs/markdown-satteri"; +import type { + HastPluginDefinition, + HastPluginInput, + MdastPluginDefinition, + MdastPluginInput, +} from "satteri"; + +export function createDefaultMarkdownProcessor(options: { + hastPlugins?: HastPluginInput[]; + mdastPlugins?: MdastPluginInput[]; +}): ReturnType { + return satteri({ + hastPlugins: (options.hastPlugins ?? []) as HastPluginDefinition[], + mdastPlugins: (options.mdastPlugins ?? []) as MdastPluginDefinition[], + }); +} diff --git a/packages/nimbus-docs/src/_internal/page-resolution.ts b/packages/nimbus-docs/src/_internal/page-resolution.ts index c8fb4958..a02a58a4 100644 --- a/packages/nimbus-docs/src/_internal/page-resolution.ts +++ b/packages/nimbus-docs/src/_internal/page-resolution.ts @@ -2,13 +2,11 @@ import type { CollectionEntry } from "astro:content"; import type { AstroComponentFactory } from "astro/runtime/server/index.js"; import type { ApiNav, ApiPageProps } from "../api/index.js"; -import type { ApiSpec } from "../types.js"; import { collectionMountPrefix, PRIMARY_COLLECTION, type VersionInfo, } from "./collection-mount.js"; -import { resolveApiVersion } from "./api/resolve-versions.js"; import type { ProjectionContext } from "./projection.js"; import { toRouteKey } from "./url.js"; @@ -36,7 +34,9 @@ export interface ApiPage { coordinate: string; } -export type PageResolution

    = +export type PageResolution< + P extends ProsePage | ApiPage = ProsePage | ApiPage, +> = | { status: "found"; page: P } | { status: "redirect"; location: string; permanent: boolean } | { status: "not-found" }; @@ -62,7 +62,7 @@ interface ProseResolutionDependencies { } interface ApiResolutionDependencies { - getApiSpecs(): Promise; + getApiCollections(): Promise; getVisibleEntry( collection: string, id: string, @@ -72,6 +72,7 @@ interface ApiResolutionDependencies { collection: string, version: string | null, coordinate: string, + entry?: CollectionEntry, ): Promise<{ page: ApiPageProps; nav: ApiNav }>; } @@ -85,7 +86,9 @@ function normalizedPageId(param: string | undefined): string { return route === "/" ? "index" : route.slice(1); } -function mountedCollectionSegment(context: PageResolutionContext): string | null { +function mountedCollectionSegment( + context: PageResolutionContext, +): string | null { const pathname = normalizedPathname(context.url); const pathSegments = pathname.split("/").filter(Boolean); const param = context.params.slug; @@ -135,7 +138,8 @@ export async function resolveProsePage( options: { collection?: string }, dependencies: ProseResolutionDependencies, ): Promise> { - const staticEntry = context.props.entry as CollectionEntry | undefined; + const staticEntry = context.props.entry as + CollectionEntry | undefined; const requestIdentity = staticEntry ? null : await requestProseIdentity( @@ -175,20 +179,26 @@ export async function resolveApiPage( dependencies: ApiResolutionDependencies, ): Promise> { const staticCollection = - typeof context.props.collection === "string" ? context.props.collection : undefined; + typeof context.props.collection === "string" + ? context.props.collection + : undefined; const staticCoordinate = - typeof context.props.coordinate === "string" ? context.props.coordinate : undefined; - const hasStaticIdentity = staticCollection !== undefined && staticCoordinate !== undefined; + typeof context.props.coordinate === "string" + ? context.props.coordinate + : undefined; + const hasStaticIdentity = + staticCollection !== undefined && staticCoordinate !== undefined; let collection = staticCollection ?? options.collection; let version = typeof context.props.version === "string" ? context.props.version : null; let coordinate = staticCoordinate; + let entry = context.props.entry as CollectionEntry | undefined; if (!hasStaticIdentity) { - const api = await dependencies.getApiSpecs(); + const apiCollections = await dependencies.getApiCollections(); collection ??= mountedCollectionSegment(context) ?? undefined; - if (!collection || !(api ?? []).some((entry) => entry.collection === collection)) { + if (!collection || !apiCollections.includes(collection)) { return { status: "not-found" }; } @@ -196,27 +206,39 @@ export async function resolveApiPage( if (id === "index" && context.params.slug !== undefined) { return { status: "not-found" }; } - const entry = await dependencies.getVisibleEntry( - collection, - id, - context.projection, - ); + entry = + (await dependencies.getVisibleEntry( + collection, + id, + context.projection, + )) ?? undefined; if (!entry) return { status: "not-found" }; coordinate = - typeof entry.data.coordinate === "string" ? entry.data.coordinate : undefined; - version = typeof entry.data.version === "string" ? entry.data.version : null; - if (!coordinate || !resolveApiVersion(api, collection, version)) { + typeof entry.data.coordinate === "string" + ? entry.data.coordinate + : undefined; + version = + typeof entry.data.version === "string" ? entry.data.version : null; + if (!coordinate) { return { status: "not-found" }; } } - const rendered = await dependencies.render(collection!, version, coordinate!); + const rendered = await dependencies.render( + collection!, + version, + coordinate!, + entry, + ); return { status: "found", page: { kind: "api", - identity: { pathname: normalizedPathname(context.url), collection: collection! }, + identity: { + pathname: normalizedPathname(context.url), + collection: collection!, + }, ...rendered, collection: collection!, version, diff --git a/packages/nimbus-docs/src/_internal/parse-content-collections.ts b/packages/nimbus-docs/src/_internal/parse-content-collections.ts index e80554f6..9d85021f 100644 --- a/packages/nimbus-docs/src/_internal/parse-content-collections.ts +++ b/packages/nimbus-docs/src/_internal/parse-content-collections.ts @@ -25,11 +25,12 @@ * tooling) can still see it. * * Returns: - * - `string[]` of registered names when the file exists and the - * pattern matches. - * - `null` when the file is missing OR present but doesn't expose a - * parseable `export const collections = { ... }`. Callers decide - * whether to warn or fall back to `["docs"]`. + * - the statically known names and whether every registration was resolved + * when the file exists and the pattern matches. + * - an incomplete empty result when the file exists but doesn't expose a + * parseable object-literal registration. + * - `null` when the file is missing. Callers decide whether to warn or fall + * back to `["docs"]`. */ import fs from "node:fs/promises"; @@ -45,9 +46,14 @@ import { const EXPORT_PREFIX_PATTERN = /export\s+const\s+collections\s*(?::\s*[^=]+)?=\s*\{/; +export interface ParsedContentCollections { + names: string[]; + complete: boolean; +} + export async function parseContentCollections( filePath: string, -): Promise { +): Promise { let source: string; try { source = await fs.readFile(filePath, "utf8"); @@ -58,18 +64,23 @@ export async function parseContentCollections( const stripped = stripComments(source); const prefixMatch = stripped.match(EXPORT_PREFIX_PATTERN); - if (!prefixMatch || prefixMatch.index === undefined) return null; + if (!prefixMatch || prefixMatch.index === undefined) { + return { names: [], complete: false }; + } const objectStart = prefixMatch.index + prefixMatch[0].length; const objectEnd = findMatchingBrace(stripped, objectStart - 1); - if (objectEnd === -1) return null; + if (objectEnd === -1) return { names: [], complete: false }; const body = stripped.slice(objectStart, objectEnd); const names: string[] = []; + let complete = true; for (const raw of splitTopLevelCommas(body)) { const entry = raw.trim(); if (!entry) continue; - if (entry.startsWith("...")) continue; - if (entry.startsWith("[")) continue; + if (entry.startsWith("...") || entry.startsWith("[")) { + complete = false; + continue; + } const colonIdx = entry.indexOf(":"); const rawKey = colonIdx === -1 ? entry : entry.slice(0, colonIdx); @@ -82,9 +93,23 @@ export async function parseContentCollections( // a leading letter or underscore (the `_*` underscore convention for // hidden-from-indexing collections stays intact). if (/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key)) names.push(key); + else complete = false; + } + + const remainder = stripped.slice(objectEnd + 1); + if ( + /\bcollections\s*(?:\.\s*[A-Za-z_$][\w$]*|\[[^\]]+\])\s*=/.test( + remainder, + ) || + /\bdelete\s+collections\s*(?:\.|\[)/.test(remainder) || + /\b(?:Object\.(?:assign|definePropert(?:y|ies))|Reflect\.set)\s*\(\s*collections\b/.test( + remainder, + ) + ) { + complete = false; } - return names; + return { names, complete }; } /** diff --git a/packages/nimbus-docs/src/_internal/partial-headings.ts b/packages/nimbus-docs/src/_internal/partial-headings.ts index 2fa5a606..900e4aa2 100644 --- a/packages/nimbus-docs/src/_internal/partial-headings.ts +++ b/packages/nimbus-docs/src/_internal/partial-headings.ts @@ -36,8 +36,6 @@ * the build-time "not found" error with its helpful suggestions. */ -import { mdxToMdast } from "satteri"; - // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -110,12 +108,32 @@ export async function mergePartialHeadings( getEntry: (collection: string, id: string) => Promise, render: (entry: unknown) => Promise<{ headings: Heading[] }>, options?: PartialHeadingOptions, +): Promise { + const { mdxToMdast } = await import("satteri"); + return mergePartialHeadingsWithParser( + body, + astroHeadings, + getEntry, + render, + (source) => mdxToMdast(source) as unknown as MdNode, + options, + ); +} + +export async function mergePartialHeadingsWithParser( + body: string | undefined, + astroHeadings: Heading[], + getEntry: (collection: string, id: string) => Promise, + render: (entry: unknown) => Promise<{ headings: Heading[] }>, + parse: (body: string) => MdNode, + options?: PartialHeadingOptions, ): Promise { return mergePartialHeadingsInternal( body, astroHeadings, getEntry, render, + parse, options, new Set(), ); @@ -126,6 +144,7 @@ async function mergePartialHeadingsInternal( astroHeadings: Heading[], getEntry: (collection: string, id: string) => Promise, render: (entry: unknown) => Promise<{ headings: Heading[] }>, + parse: (body: string) => MdNode, options: PartialHeadingOptions | undefined, seen: Set, ): Promise { @@ -133,7 +152,7 @@ async function mergePartialHeadingsInternal( let tree: MdNode; try { - tree = mdxToMdast(body) as unknown as MdNode; + tree = parse(body); } catch { // If the body doesn't parse, fall back to Astro's headings — // the build will fail elsewhere with a proper diagnostic. @@ -164,6 +183,7 @@ async function mergePartialHeadingsInternal( slot.product, getEntry, render, + parse, resolve, seen, ); @@ -266,6 +286,7 @@ async function collectFromPartial( product: string | undefined, getEntry: (collection: string, id: string) => Promise, render: (entry: unknown) => Promise<{ headings: Heading[] }>, + parse: (body: string) => MdNode, resolve: (attrs: { file: string | undefined; product: string | undefined; @@ -324,6 +345,7 @@ async function collectFromPartial( partialHeadings, getEntry, render, + parse, { resolvePartialId: resolve }, seen, ); diff --git a/packages/nimbus-docs/src/_internal/register-code-styles.ts b/packages/nimbus-docs/src/_internal/register-code-styles.ts new file mode 100644 index 00000000..6d4d83bb --- /dev/null +++ b/packages/nimbus-docs/src/_internal/register-code-styles.ts @@ -0,0 +1,20 @@ +import { codeToHtml } from "shiki"; + +import { + getCodeStyleTransformer, + NIMBUS_DEFAULT_SHIKI_THEMES, +} from "./code-style-registry.js"; +import type { ScannedCodeBlock } from "./scan-code-langs.js"; + +export async function registerCodeBlockStyles( + blocks: readonly ScannedCodeBlock[], +): Promise { + for (const block of blocks) { + await codeToHtml(block.code, { + lang: block.lang, + themes: NIMBUS_DEFAULT_SHIKI_THEMES, + defaultColor: false, + transformers: [getCodeStyleTransformer()], + }); + } +} diff --git a/packages/nimbus-docs/src/_internal/rendering-policy.ts b/packages/nimbus-docs/src/_internal/rendering-policy.ts new file mode 100644 index 00000000..b88224f6 --- /dev/null +++ b/packages/nimbus-docs/src/_internal/rendering-policy.ts @@ -0,0 +1,66 @@ +import path from "node:path"; + +import type { RenderingConfig, RenderingMode } from "../types.js"; +import { + collectionMountPrefix, + type VersionInfo, +} from "./collection-mount.js"; + +export interface CompiledRenderingPolicy { + default: RenderingMode; + collections: Readonly>; +} + +export function compileRenderingPolicy( + rendering: RenderingConfig | undefined, + canonicalCollections: readonly string[], +): CompiledRenderingPolicy { + const known = new Set(canonicalCollections); + const overrides = rendering?.collections ?? {}; + const unknown = Object.keys(overrides).filter( + (collection) => !known.has(collection), + ); + if (unknown.length > 0) { + throw new Error( + `nimbus-docs: rendering.collections references collection${unknown.length === 1 ? "" : "s"} without a registered canonical catch-all route:\n` + + unknown.map((collection) => ` - "${collection}"`).join("\n") + + "\n\nRegister each collection and add its canonical catch-all route before configuring its rendering mode.", + ); + } + + const defaultMode = rendering?.default ?? "build"; + return { + default: defaultMode, + collections: Object.fromEntries( + canonicalCollections.map((collection) => [ + collection, + overrides[collection] ?? defaultMode, + ]), + ), + }; +} + +export function canonicalCollectionRouteComponent( + srcDir: string, + collection: string, + versions?: VersionInfo | null, +): string { + const mount = collectionMountPrefix(collection, versions).slice(1); + return path.join(srcDir, "pages", mount, "[...slug].astro"); +} + +export function routeComponentKeys( + projectRoot: string, + component: string, +): string[] { + const absolute = normalizeRouteComponent(component); + const relative = normalizeRouteComponent(path.relative(projectRoot, component)); + return [absolute, relative]; +} + +export function normalizeRouteComponent(component: string): string { + return component + .replace(/[?#].*$/, "") + .replaceAll("\\", "/") + .replace(/^\.\//, ""); +} diff --git a/packages/nimbus-docs/src/_internal/request-route-inventory.ts b/packages/nimbus-docs/src/_internal/request-route-inventory.ts new file mode 100644 index 00000000..a180bcc7 --- /dev/null +++ b/packages/nimbus-docs/src/_internal/request-route-inventory.ts @@ -0,0 +1,41 @@ +import { getCollection } from "astro:content"; + +import { collectionMountPrefix } from "./collection-mount.js"; +import { requestInventoryEntryUrl } from "./request-route-url.js"; +import { + loadApiCollections, + loadRequestRenderingCollections, + loadNimbusConfig, +} from "./runtime-config.js"; + +export const prerender = true; + +export async function GET() { + const config = await loadNimbusConfig(); + const collections = await loadRequestRenderingCollections(); + const apiCollections = new Set(await loadApiCollections()); + const versions = config.versions + ? { others: config.versions.others ?? [] } + : null; + const routes: Array<{ collection: string; url: string }> = []; + + for (const collection of collections) { + const prefix = collectionMountPrefix(collection, versions); + const entries = await getCollection(collection as never); + for (const entry of entries) { + if ((entry.data as { draft?: unknown }).draft === true) continue; + routes.push({ + collection, + url: requestInventoryEntryUrl( + prefix, + entry.id, + apiCollections.has(collection), + ), + }); + } + } + + return new Response(JSON.stringify(routes), { + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/packages/nimbus-docs/src/_internal/request-route-url.ts b/packages/nimbus-docs/src/_internal/request-route-url.ts new file mode 100644 index 00000000..b575b09a --- /dev/null +++ b/packages/nimbus-docs/src/_internal/request-route-url.ts @@ -0,0 +1,8 @@ +export function requestInventoryEntryUrl( + prefix: string, + entryId: string, + api: boolean, +): string { + const id = api && entryId === "index" ? "" : entryId; + return id === "" ? prefix || "/" : `${prefix}/${id}`; +} diff --git a/packages/nimbus-docs/src/_internal/runtime-config.ts b/packages/nimbus-docs/src/_internal/runtime-config.ts index 59808e34..4f7109d9 100644 --- a/packages/nimbus-docs/src/_internal/runtime-config.ts +++ b/packages/nimbus-docs/src/_internal/runtime-config.ts @@ -22,9 +22,9 @@ import type { VersionAlternatesTable } from "./version-alternates.js"; let _cached: NimbusConfig | null = null; let _cachedCollections: readonly string[] | null = null; +let _cachedRequestRenderingCollections: readonly string[] | null = null; let _cachedAlternates: VersionAlternatesTable | null = null; let _cachedApiCollections: readonly string[] | null = null; -let _cachedRoot: string | null = null; export async function loadNimbusConfig(): Promise { if (_cached) return _cached; @@ -50,6 +50,14 @@ export async function loadIndexedCollections(): Promise { return value; } +export async function loadRequestRenderingCollections(): Promise { + if (_cachedRequestRenderingCollections) return _cachedRequestRenderingCollections; + const mod = await import("virtual:nimbus/config"); + const value = mod.requestRenderingCollections ?? []; + _cachedRequestRenderingCollections = value; + return value; +} + /** * Build-time-resolved alternates table for cross-version SEO links. * Returns the same object on every call (cached after first load). @@ -79,17 +87,3 @@ export async function loadApiCollections(): Promise { _cachedApiCollections = value; return value; } - -/** - * Absolute project root — the base the `apiCollection()` loader resolves - * specs against. `getApiModel` uses it so render-time spec resolution matches - * the loader regardless of `process.cwd()`. Falls back to `process.cwd()` - * when the virtual module predates the field. - */ -export async function loadProjectRoot(): Promise { - if (_cachedRoot) return _cachedRoot; - const mod = await import("virtual:nimbus/config"); - const value = mod.root ?? process.cwd(); - _cachedRoot = value; - return value; -} diff --git a/packages/nimbus-docs/src/_internal/scan-code-langs.ts b/packages/nimbus-docs/src/_internal/scan-code-langs.ts index bf91ec93..2b13dab5 100644 --- a/packages/nimbus-docs/src/_internal/scan-code-langs.ts +++ b/packages/nimbus-docs/src/_internal/scan-code-langs.ts @@ -8,15 +8,30 @@ * grammar, which makes cold-build output non-deterministic. */ import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { extname, resolve } from "node:path"; import { bundledLanguagesInfo, isSpecialLang } from "shiki"; +import { markdownToMdast, mdxToMdast } from "satteri"; import { walkFiles } from "./fs-walk.js"; -// Opening backtick fence + language token. CommonMark forbids backticks in a -// backtick fence's info string, so `[^\n`]*$` rejects a line with a later -// backtick — i.e. inline `` ```x``` ``, not a block. -const FENCE_RE = /^[ \t]*```([a-zA-Z][a-zA-Z0-9_+\-]*)[^\n`]*$/gm; +function fencedCodeBlocks(content: string, extension: string): ScannedCodeBlock[] { + const blocks: ScannedCodeBlock[] = []; + const tree = (extension === ".mdx" ? mdxToMdast(content) : markdownToMdast(content)) as { + type: string; + lang?: string | null; + value?: string; + children?: unknown[]; + }; + const visit = (node: typeof tree) => { + if (node.type === "code" && node.lang && typeof node.value === "string") { + blocks.push({ lang: node.lang.toLowerCase(), code: `${node.value}\n` }); + } + for (const child of node.children ?? []) visit(child as typeof tree); + }; + visit(tree); + + return blocks; +} // Grammars Shiki can resolve (bundled ids + aliases). Tokens outside this set // are dropped before reaching Shiki, which throws on grammars it can't load; @@ -50,10 +65,14 @@ export async function scanCodeBlockLanguages( } catch { continue; } - // Reset stateful regex iterator across files. - FENCE_RE.lastIndex = 0; - for (const m of content.matchAll(FENCE_RE)) { - const raw = m[1]!.toLowerCase(); + let parsed: ScannedCodeBlock[]; + try { + parsed = fencedCodeBlocks(content, extname(abs)); + } catch { + continue; + } + for (const block of parsed) { + const raw = block.lang; const mapped = langAlias[raw] ?? raw; if (SHIKI_KNOWN.has(mapped) || isSpecialLang(mapped)) langs.add(mapped); } @@ -61,3 +80,42 @@ export async function scanCodeBlockLanguages( return Array.from(langs).sort(); } + +export interface ScannedCodeBlock { + lang: string; + code: string; +} + +export async function scanCodeBlocks( + projectRoot: string, + langAlias: Record = {}, +): Promise { + const blocks: ScannedCodeBlock[] = []; + const contentRoot = resolve(projectRoot, "src/content"); + + for await (const { abs } of walkFiles(contentRoot, { + extensions: [".mdx", ".md"], + onReadError: "lenient", + })) { + let content: string; + try { + content = await readFile(abs, "utf8"); + } catch { + continue; + } + let parsed: ScannedCodeBlock[]; + try { + parsed = fencedCodeBlocks(content, extname(abs)); + } catch { + continue; + } + for (const block of parsed) { + const raw = block.lang; + const lang = langAlias[raw] ?? raw; + if (!SHIKI_KNOWN.has(lang) && !isSpecialLang(lang)) continue; + blocks.push({ lang, code: block.code }); + } + } + + return blocks; +} diff --git a/packages/nimbus-docs/src/_internal/validate.ts b/packages/nimbus-docs/src/_internal/validate.ts index 8deb0cd5..89f7acc4 100644 --- a/packages/nimbus-docs/src/_internal/validate.ts +++ b/packages/nimbus-docs/src/_internal/validate.ts @@ -78,6 +78,25 @@ const searchSchema = z ]) .optional(); +const renderingModeSchema = z.enum(["build", "request"], { + error: 'rendering mode must be either "build" or "request"', +}); + +const renderingSchema = withStrictKeys( + z.object({ + default: renderingModeSchema.optional(), + collections: z + .record( + z.string().min(1, { + message: "rendering collection names must not be empty", + }), + renderingModeSchema, + ) + .optional(), + }), + { removedKeys: {}, contextLabel: "rendering sub-key" }, +).optional(); + // Sidebar items are intentionally loose — the sidebar builder accepts the // shapes documented in types.ts; tightening here adds friction for users // without catching real errors that the builder doesn't already catch. @@ -478,6 +497,7 @@ const nimbusConfigSchema = withStrictKeys( versions: versionsSchema, api: apiSchema, apiReferences: apiReferencesSchema, + rendering: renderingSchema, }), { removedKeys: REMOVED_CONFIG_KEYS, diff --git a/packages/nimbus-docs/src/_internal/virtual-api-build-config.ts b/packages/nimbus-docs/src/_internal/virtual-api-build-config.ts new file mode 100644 index 00000000..3d64a366 --- /dev/null +++ b/packages/nimbus-docs/src/_internal/virtual-api-build-config.ts @@ -0,0 +1,31 @@ +import type { ApiSpec } from "../types.js"; +import type { VitePluginLike } from "./virtual-config.js"; + +const VIRTUAL_ID = "virtual:nimbus/api-build-config"; +const RESOLVED_ID = `\0${VIRTUAL_ID}`; + +function jsStringLiteral(value: unknown): string { + return JSON.stringify(JSON.stringify(value)) + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); +} + +export function virtualApiBuildConfigPlugin( + api: ApiSpec[] | undefined, + root: string, +): VitePluginLike { + return { + name: "nimbus-docs:virtual-api-build-config", + resolveId(id) { + if (id === VIRTUAL_ID) return RESOLVED_ID; + return undefined; + }, + load(id) { + if (id !== RESOLVED_ID) return undefined; + return ( + `export const api = JSON.parse(${jsStringLiteral(api ?? [])});\n` + + `export const root = ${JSON.stringify(root)};\n` + ); + }, + }; +} diff --git a/packages/nimbus-docs/src/_internal/virtual-config.ts b/packages/nimbus-docs/src/_internal/virtual-config.ts index e41f3891..636666df 100644 --- a/packages/nimbus-docs/src/_internal/virtual-config.ts +++ b/packages/nimbus-docs/src/_internal/virtual-config.ts @@ -37,6 +37,7 @@ export interface VirtualConfigExtras { * works. */ indexedCollections: string[]; + requestRenderingCollections: string[]; /** * Build-time alternates table for cross-version SEO links. Empty `{}` * when the site is unversioned or has only the current version. @@ -49,19 +50,34 @@ export interface VirtualConfigExtras { * server endpoints — never a client component. */ apiCollections: string[]; - /** - * Absolute project root — the same base the `apiCollection()` loader - * resolves specs against (`fileURLToPath(astroConfig.root)`). `getApiModel` - * uses it so render-time resolution matches the loader regardless of - * `process.cwd()`. Build/server-only; never a client component. - */ - root: string; + headDefaults: { + favicon: { file: string; type: string }; + socialImage: string; + }; } export function virtualConfigPlugin( config: NimbusConfig, extras: VirtualConfigExtras, ): VitePluginLike { + const runtimeConfig: NimbusConfig = { + ...config, + ...(config.api + ? { + api: config.api.map((entry) => + entry.versions + ? { + ...entry, + versions: entry.versions.map((version) => ({ + ...version, + spec: {}, + })), + } + : { ...entry, spec: {} }, + ), + } + : {}), + }; return { name: "nimbus-docs:virtual-config", resolveId(id: string) { @@ -71,11 +87,12 @@ export function virtualConfigPlugin( load(id: string) { if (id === RESOLVED_ID) { return ( - `export const config = ${JSON.stringify(config)};\n` + + `export const config = ${JSON.stringify(runtimeConfig)};\n` + `export const indexedCollections = ${JSON.stringify(extras.indexedCollections)};\n` + + `export const requestRenderingCollections = ${JSON.stringify(extras.requestRenderingCollections)};\n` + `export const versionAlternates = ${JSON.stringify(extras.versionAlternates)};\n` + `export const apiCollections = ${JSON.stringify(extras.apiCollections)};\n` + - `export const root = ${JSON.stringify(extras.root)};\n` + `export const headDefaults = ${JSON.stringify(extras.headDefaults)};\n` ); } return undefined; diff --git a/packages/nimbus-docs/src/_internal/worker-partial-headings.ts b/packages/nimbus-docs/src/_internal/worker-partial-headings.ts new file mode 100644 index 00000000..3fda2606 --- /dev/null +++ b/packages/nimbus-docs/src/_internal/worker-partial-headings.ts @@ -0,0 +1,135 @@ +import remarkMdx from "remark-mdx"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; + +import type { + Heading, + PartialHeadingOptions, +} from "./partial-headings.js"; + +interface MdNode { + type: string; + name?: string | null; + attributes?: unknown[]; + children?: MdNode[]; +} + +interface JsxAttribute { + type: string; + name: string; + value?: string | null | { type: string; value: string }; +} + +type Slot = + | { kind: "heading" } + | { kind: "render"; file?: string; product?: string }; + +const parser = unified().use(remarkParse).use(remarkMdx); + +export function mergeWorkerPartialHeadings( + body: string | undefined, + astroHeadings: Heading[], + getEntry: (collection: string, id: string) => Promise, + render: (entry: unknown) => Promise<{ headings: Heading[] }>, + options?: PartialHeadingOptions, +): Promise { + return merge(body, astroHeadings, getEntry, render, options, new Set()); +} + +async function merge( + body: string | undefined, + astroHeadings: Heading[], + getEntry: (collection: string, id: string) => Promise, + render: (entry: unknown) => Promise<{ headings: Heading[] }>, + options: PartialHeadingOptions | undefined, + seen: Set, +): Promise { + if (!body) return astroHeadings; + + let tree: MdNode; + try { + tree = parser.parse(body) as unknown as MdNode; + } catch { + return astroHeadings; + } + + const slots: Slot[] = []; + collectSlots(tree, slots); + const merged: Heading[] = []; + let headingIndex = 0; + + for (const slot of slots) { + if (slot.kind === "heading") { + const heading = astroHeadings[headingIndex++]; + if (heading) merged.push(heading); + continue; + } + + const id = (options?.resolvePartialId ?? ((attrs) => attrs.file))({ + file: slot.file, + product: slot.product, + }); + if (!id) continue; + if (seen.has(id)) { + throw new Error( + `[nimbus-docs] Circular partial include: ${[...seen, id].join(" -> ")}. ` + + "Check for a partial that renders itself directly or transitively.", + ); + } + + let partial: unknown; + try { + partial = await getEntry("partials", id); + } catch { + continue; + } + if (!partial) continue; + + seen.add(id); + try { + const rendered = await render(partial); + merged.push( + ...(await merge( + (partial as { body?: string }).body, + rendered.headings, + getEntry, + render, + options, + seen, + )), + ); + } catch (error) { + if (error instanceof Error && error.message.includes("Circular ")) { + throw error; + } + } finally { + seen.delete(id); + } + } + + merged.push(...astroHeadings.slice(headingIndex)); + return merged; +} + +function collectSlots(node: MdNode, slots: Slot[]): void { + if (node.type === "heading") { + slots.push({ kind: "heading" }); + return; + } + if ( + (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") && + node.name === "Render" + ) { + const attributes = node.attributes as JsxAttribute[] | undefined; + const value = (name: string) => { + const attribute = attributes?.find( + (candidate) => + candidate.type === "mdxJsxAttribute" && candidate.name === name, + )?.value; + return typeof attribute === "string" ? attribute : undefined; + }; + slots.push({ kind: "render", file: value("file"), product: value("product") }); + return; + } + for (const child of node.children ?? []) collectSlots(child, slots); +} diff --git a/packages/nimbus-docs/src/api/index.ts b/packages/nimbus-docs/src/api/index.ts index 139ffccc..cd2fc930 100644 --- a/packages/nimbus-docs/src/api/index.ts +++ b/packages/nimbus-docs/src/api/index.ts @@ -78,7 +78,8 @@ function specDigest(raw: string): string { /** Deterministic JSON with object keys sorted at every depth, so a route policy * keys the model cache by *value*, not by authoring key order. */ function stableStringify(value: unknown): string { - if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null"; + if (value === null || typeof value !== "object") + return JSON.stringify(value) ?? "null"; if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; const entries = Object.entries(value as Record) .filter(([, v]) => v !== undefined) @@ -167,19 +168,16 @@ export async function getApiModel( collection: string, version?: string, ): Promise { - const { loadNimbusConfig, loadProjectRoot } = await import( - "../_internal/runtime-config.js" - ); - const { resolveSpecSource } = await import("../_internal/api/resolve-spec.js"); - const { resolveApiVersion } = await import( - "../_internal/api/resolve-versions.js" - ); - const config = await loadNimbusConfig(); - const resolved = resolveApiVersion(config.api, collection, version ?? null); + const { loadApiBuildConfig } = + await import("../_internal/api/runtime-build-config.js"); + const { resolveSpecSource } = + await import("../_internal/api/resolve-spec.js"); + const { resolveApiVersion } = + await import("../_internal/api/resolve-versions.js"); + const { api, root } = await loadApiBuildConfig(); + const resolved = resolveApiVersion(api, collection, version ?? null); if (!resolved) { - const suffix = version - ? ` version "${version}"` - : ""; + const suffix = version ? ` version "${version}"` : ""; throw new Error( `nimbus-docs api: no spec registered for collection "${collection}"${suffix}. ` + `Declare it in \`nimbus.config.ts\`: api: [{ collection: "${collection}", spec: "./openapi.yaml" }].`, @@ -194,18 +192,16 @@ export async function getApiModel( // Resolve against the loader's base (astroConfig.root), not process.cwd() — // they differ under monorepo/subpackage/`--root`/Cloudflare builds. - const promise = loadProjectRoot().then((root) => - resolveSpecSource( - { - collection: resolved.namespace, - spec: resolved.spec, - label: resolved.label, - mountPath: resolved.mountPath, - requireOperationId: resolved.requireOperationId, - routes: resolved.routes, - }, - root, - ), + const promise = resolveSpecSource( + { + collection: resolved.namespace, + spec: resolved.spec, + label: resolved.label, + mountPath: resolved.mountPath, + requireOperationId: resolved.requireOperationId, + routes: resolved.routes, + }, + root, ); sourceCache.set(cacheKey, promise); // Never leave a rejected resolution cached — a transient read failure (an diff --git a/packages/nimbus-docs/src/check/structure.ts b/packages/nimbus-docs/src/check/structure.ts index 9ffd0817..db797f84 100644 --- a/packages/nimbus-docs/src/check/structure.ts +++ b/packages/nimbus-docs/src/check/structure.ts @@ -101,10 +101,14 @@ async function checkDuplicateRoutes( if (!existsSync(contentRoot)) return; const contentConfigPath = path.join(srcDir, "content.config.ts"); - const rawCollections = await parseContentCollections(contentConfigPath); + const parsedCollections = await parseContentCollections(contentConfigPath); + const rawCollections = parsedCollections?.names ?? null; const collectionBases = await parseCollectionBases(contentConfigPath); const indexedCollections = - rawCollections === null ? ["docs"] : filterIndexableCollections(rawCollections); + rawCollections === null || + (parsedCollections?.complete === false && rawCollections.length === 0) + ? ["docs"] + : filterIndexableCollections(rawCollections); const indexedSet = new Set(indexedCollections); const versions = diff --git a/packages/nimbus-docs/src/components/NimbusHead.astro b/packages/nimbus-docs/src/components/NimbusHead.astro index 161ffc91..c2564c29 100644 --- a/packages/nimbus-docs/src/components/NimbusHead.astro +++ b/packages/nimbus-docs/src/components/NimbusHead.astro @@ -26,16 +26,14 @@ * re-doing their head tags. */ -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { config } from "virtual:nimbus/config"; +import { config, headDefaults } from "virtual:nimbus/config"; import { getApiVersionAlternates, getCollectionLlmsUrl, getVersionAlternates, getVersionStatus, withBase, -} from "@cloudflare/nimbus-docs"; +} from "@cloudflare/nimbus-docs/runtime"; import type { HeadElement } from "@cloudflare/nimbus-docs/types"; export interface Props { @@ -164,35 +162,15 @@ const lang = config.locale ?? "en"; const isHomePage = Astro.url.pathname === "/"; const ogType = isHomePage ? "website" : "article"; -// `process.cwd()` resolves to the consumer's project root at build time. -// The checks below let users drop files into `public/` and have them -// picked up automatically — no config touch needed. -const publicDir = join(process.cwd(), "public"); - // Favicon precedence: svg > ico > png. The first file that exists wins; // when none exists we still emit the svg link so users who drop one in // later don't need to touch the layout. -const faviconCandidates: ReadonlyArray<{ file: string; type: string }> = [ - { file: "favicon.svg", type: "image/svg+xml" }, - { file: "favicon.ico", type: "image/x-icon" }, - { file: "favicon.png", type: "image/png" }, -]; -const matchedFavicon = - faviconCandidates.find((c) => existsSync(join(publicDir, c.file))) ?? - faviconCandidates[0]; -const faviconHref = `${import.meta.env.BASE_URL}${matchedFavicon.file}`; -const faviconType = matchedFavicon.type; +const faviconHref = `${import.meta.env.BASE_URL}${headDefaults.favicon.file}`; +const faviconType = headDefaults.favicon.type; // Social image precedence: page prop > config > `public/opengraph.png` > // `public/logo.png` > generated `/og.png` fallback. -const userOpenGraphImage = join(publicDir, "opengraph.png"); -const userLogoImage = join(publicDir, "logo.png"); -const defaultSocialImage = existsSync(userOpenGraphImage) - ? "/opengraph.png" - : existsSync(userLogoImage) - ? "/logo.png" - : "/og.png"; -const socialImagePath = socialImage ?? config.socialImage ?? defaultSocialImage; +const socialImagePath = socialImage ?? config.socialImage ?? headDefaults.socialImage; const ogImage = socialImagePath ? Astro.site ? new URL(withBase(socialImagePath, baseUrl), Astro.site).href diff --git a/packages/nimbus-docs/src/content.ts b/packages/nimbus-docs/src/content.ts index e8587af2..c0a39f09 100644 --- a/packages/nimbus-docs/src/content.ts +++ b/packages/nimbus-docs/src/content.ts @@ -43,7 +43,11 @@ export { partialsSchema, componentsSchema, } from "./schemas.js"; -export type { DefineSchemaOptions, DocSchemaConfig, ComponentProp } from "./schemas.js"; +export type { + DefineSchemaOptions, + DocSchemaConfig, + ComponentProp, +} from "./schemas.js"; export interface DocsCollectionOptions< TFields extends Record = Record, @@ -166,7 +170,9 @@ export interface ComponentsCollectionOptions { * matching `` / `` MDX wrappers and the `/components` * route. Frontmatter shape: `{ title, tagline, props }`. */ -export function componentsCollection(options: ComponentsCollectionOptions = {}) { +export function componentsCollection( + options: ComponentsCollectionOptions = {}, +) { const base = `./src/content/${options.base ?? "components"}`; const pattern = options.pattern ?? DEFAULT_PATTERN; @@ -202,12 +208,9 @@ export interface ApiCollectionOptions { /** * Content-collection config for one OpenAPI reference spec. The loader is a - * thin **index**: it parses the spec once at build time and writes one small - * DataStore entry per page (`{ id: slug, data: { coordinate, title, - * description? } }`). Only routing + display metadata is stored — the heavy - * parsed model is NOT, so render re-derives it from the same spec via - * `getApiModel()` and nothing depends on a cache surviving the content-sync → - * render phase boundary. + * build artifact: it parses the spec once and writes one DataStore entry per + * page with its JSON-safe view model. One root entry per version also carries + * the shared navigation tree used by static and request-rendered routes. * * // src/content.config.ts * import nimbus from "./nimbus.config"; @@ -234,29 +237,53 @@ export function apiCollection(options: ApiCollectionOptions): { title: string; description?: string; version?: string; + prepared: import("./_internal/api/prepared.js").PreparedApiPage; }>; } { - const { collection, spec, label, versions, requireOperationId, routes } = options; + const { collection, spec, label, versions, requireOperationId, routes } = + options; const loader: Loader = { name: "nimbus-docs:api", async load(context) { - const { logger, store, parseData, config: astroConfig, watcher } = context; + const { + logger, + store, + parseData, + config: astroConfig, + watcher, + } = context; assertSupportedNode(); const [ - { buildApiModel, getApiPageIndex, getApiRouteProvenance, clearApiModelCache }, + { + buildApiModel, + getApiNav, + getApiPageIndex, + getApiPageProps, + getApiRouteProvenance, + clearApiModelCache, + }, { resolveSpecSource }, { resolveApiFamily, apiPageRoute }, + { prepareApiNav, preparedApiVersion }, ] = await Promise.all([ import("./api/index.js"), import("./_internal/api/resolve-spec.js"), import("./_internal/api/resolve-versions.js"), + import("./_internal/api/prepared.js"), ]); const rootDir = fileURLToPath(astroConfig.root); - const targets = resolveApiFamily({ collection, spec, label, versions, requireOperationId, routes }); + const targets = resolveApiFamily({ + collection, + spec, + label, + versions, + requireOperationId, + routes, + }); // M4: a non-default version id must not collide with a top-level page // slug of the default version (both would claim `//`). @@ -306,9 +333,14 @@ export function apiCollection(options: ApiCollectionOptions): { } const provenance = getApiRouteProvenance(model); - for (const { coordinate, slug, title, description } of getApiPageIndex( - model, - )) { + const navEntryId = apiPageRoute(target, "").storeId; + const preparedNav = prepareApiNav(getApiNav(model)); + for (const { + coordinate, + slug, + title, + description, + } of getApiPageIndex(model)) { if (target.isDefault && slug !== "") { const top = slug.split("/")[0]!; const kinds = defaultTopSegments.get(top) ?? new Set(); @@ -339,6 +371,12 @@ export function apiCollection(options: ApiCollectionOptions): { title, ...(description === undefined ? {} : { description }), ...(target.version ? { version: target.version } : {}), + prepared: { + version: preparedApiVersion, + page: getApiPageProps(model, coordinate), + navEntryId, + ...(id === navEntryId ? { nav: preparedNav } : {}), + }, }, }); store.set({ id, data }); @@ -416,6 +454,8 @@ export function apiCollection(options: ApiCollectionOptions): { title: z.string(), description: z.string().optional(), version: z.string().optional(), + prepared: + z.custom(), }), }; } diff --git a/packages/nimbus-docs/src/index.ts b/packages/nimbus-docs/src/index.ts index c4555398..76a9beea 100644 --- a/packages/nimbus-docs/src/index.ts +++ b/packages/nimbus-docs/src/index.ts @@ -1,1704 +1,3 @@ -/** - * Main entry for `nimbus-docs`. - * - * Exports the Astro integration (default), config helper, the data helpers - * (sidebar, prev/next, breadcrumbs, TOC), and the page composition helpers - * (`getDocsStaticPaths`, `getDocsPageProps`). - * - * Helpers read the user's config from `virtual:nimbus/config` (provided - * by our Vite plugin) and content entries from `astro:content`. Both - * are external in tsdown and resolved at the consumer's build time. - */ - -import { - loadApiCollections, - loadIndexedCollections, - loadNimbusConfig, - loadVersionAlternates, -} from "./_internal/runtime-config.js"; -import { loadCollectionOrWarn } from "./_internal/load-collection.js"; -import { runtimeWarn } from "./_internal/runtime-warn.js"; -import { - getVisibleEntry, - getVisibleEntries, - getVisibleEntriesByCollection, - clearContentCaches, -} from "./_internal/content.js"; -import { - audienceCacheKey, - resolveAudience, - type ProjectionContext, -} from "./_internal/projection.js"; -import { - applyOverviewLeaf, - buildSidebarTree, - collectSidebarCollectionRefs, - cloneSidebarTree, - deriveSidebarSections, - deriveTransformCtx, - findActivePath, - isolateToBoundary, - markActiveState, - scopeToCurrentSection, - sidebarHash, -} from "./_internal/sidebar.js"; -import { entryRouteUrl } from "./_internal/astro-slug.js"; -import { toBrowserHref, withBase } from "./_internal/url.js"; -import { - PRIMARY_COLLECTION, - collectionLabel as resolveCollectionSlug, - collectionMountPrefix as resolveCollectionPrefix, -} from "./_internal/collection-mount.js"; -import { - renderEntryAsMarkdown, - type RenderEntryAsMarkdownOptions, -} from "./_internal/transform.js"; -import { buildCorpusMarkdown } from "./_internal/corpus.js"; -import { isDiscoverable } from "./_internal/discoverability.js"; -import { - assembleBreadcrumbs, - breadcrumbsFromUrl, - composeRouteBreadcrumbs, - getPrevNext as buildPrevNext, - type BreadcrumbOptions, -} from "./_internal/navigation.js"; -import { getHeadings } from "./_internal/toc.js"; -import { - mergePartialHeadings, - type PartialHeadingOptions, -} from "./_internal/partial-headings.js"; -import { getLastUpdatedFromGit } from "./_internal/git-last-updated.js"; -import { - clearValidInternalLinksCache, - getValidInternalLinks, -} from "./_internal/valid-internal-links.js"; -import { - resolveApiPage, - resolveProsePage, - type PageResolutionContext, - type ProsePage, -} from "./_internal/page-resolution.js"; - -import type { - ApiVersionStatus, - Breadcrumb, - CoordinatesManifest, - PrevNext, - PrevNextOverrides, - ResolvedVersions, - SidebarItem, - SidebarSection, - SidebarTransform, - TOCItem, - VersionAlternateRecord, - VersionStatus, -} from "./types.js"; - export { nimbus as default } from "./integration.js"; export type { NimbusIntegrationOptions } from "./integration.js"; - -export type { - ApiVersionSpec, - ApiVersionStatus, - BadgeVariant, - Breadcrumb, - NimbusConfig, - PrevNext, - PrevNextLink, - PrevNextOverrides, - ResolvedVersions, - SearchProvider, - SearchResult, - SidebarBadge, - SidebarConfig, - SidebarConfigItem, - SidebarExternalLinkItem, - SidebarGroupItem, - SidebarItem, - SidebarLinkItem, - SidebarSection, - SidebarTransform, - TOCItem, - VersionAlternateRecord, - VersionAlternatesTable, - VersionPageRef, - VersionStatus, - VersionsConfig, -} from "./types.js"; - -export type { PartialHeadingOptions } from "./_internal/partial-headings.js"; -export type { Heading } from "./_internal/partial-headings.js"; - -/** - * Collect headings from rendered HTML (see {@link getHeadingsFromHtml}). - * - * Use when a page's `Content` is rendered to a string so that runtime - * headings (e.g. those injected via `set:html`) reach the TOC. Feed the - * result to {@link getTOC}. - */ -export { getHeadingsFromHtml } from "./_internal/rendered-headings.js"; - -/** - * Define a typed Nimbus config. - */ -export { defineConfig } from "./config.js"; - -/** Deterministic short hash of the sidebar structure (for sessionStorage invalidation). */ -export { sidebarHash }; - -/** Prefix a site-root-relative URL with Astro's configured base path. */ -export { withBase }; - -/** The `noindex` visibility contract — filter custom index/corpus routes with this. */ -export { isDiscoverable }; - -/** Render an Astro content entry's raw MDX body as clean markdown. */ -export { renderEntryAsMarkdown }; - -/** - * `renderEntryAsMarkdown` with the coordinate-citation index loaded and - * passed through, so `api.ref:` citations resolve. Prerendered routes only. - */ -export async function getEntryMarkdown( - entry: Parameters[0], - options: Omit = {}, -): Promise { - const { loadCitationIndex } = await import("./_internal/api/load-citation-index.js"); - return renderEntryAsMarkdown(entry, { - ...options, - citationIndex: await loadCitationIndex(), - }); -} - -/** - * This site's published coordinate manifest (local collections only), served at - * `/nimbus-api/coordinates.json`. Prerendered routes only. - */ -export async function getCoordinatesManifest(): Promise { - const { loadCoordinatesManifest } = await import( - "./_internal/api/load-citation-index.js" - ); - return loadCoordinatesManifest(); -} - -/** - * The canonical Shiki transformer chain — diff / highlight / focus / - * error-level / word notations, meta highlight, plus the title-frame + - * language-badge transformer. Pre-wired into the markdown pipeline for - * fenced MDX blocks; re-export it so `Code.astro` can pass the same - * list to Astro's built-in `` component (which accepts - * `transformers` as a prop but doesn't auto-read `shikiConfig`). - */ -export { defaultCodeTransformers } from "./_internal/code-transformers.js"; - -/** - * Return visible entries across the user's configured `collections`. - * Drafts are filtered in production builds. Pass an explicit - * `collections` argument to scope the query to a subset. - * - * Literal collection names preserve their Astro `CollectionEntry` types; - * runtime-derived names return the union of registered collection entries. - */ -export { getVisibleEntry, getVisibleEntries }; - -// --------------------------------------------------------------------------- -// Agent-facing indexing -// --------------------------------------------------------------------------- - -export interface IndexedEntry { - /** - * The Astro CollectionEntry, widened to the union of every registered - * collection (`CollectionKey`). Using the bare `string` argument here - * resolves to `never` in a consumer's project — Astro's real - * `CollectionEntry` has no string index - * signature, so `CollectionEntry` collapses and `.id`/`.data` - * vanish. `CollectionKey` keeps the field usable across collections. - */ - entry: import("astro:content").CollectionEntry; - /** Collection this entry belongs to (e.g. `"docs"`, `"blog"`). */ - collection: string; - /** Display title — schema field if present, otherwise the entry id. */ - title: string; - /** Description — undefined when the schema doesn't expose one or it's empty. */ - description: string | undefined; - /** - * Site-relative page URL (no origin), with a trailing slash on HTML - * document routes. The primary docs collection mounts at root; every - * other collection mounts under its name (`/blog/my-first-post/`). For - * `.md` alternates use `markdownUrl`, not this field — the root-index - * case (`/`) needs a different shape. - */ - url: string; - /** - * Site-relative URL of the page's clean-markdown alternate, e.g. - * `/getting-started/index.md` (or `/index.md` for the root entry). - * Consumers should use this directly rather than synthesizing from `url`. - */ - markdownUrl: string; - /** - * Site-relative URL of the page's raw-source alternate, e.g. - * `/getting-started/index.mdx`. Twin grammar: `index.md` is the - * downleveled render for reading, `index.mdx` is the authored source. - * `undefined` when the entry has no string body to serve (data-loader - * collections) — such entries get no `.mdx` route. - */ - sourceUrl: string | undefined; - /** - * Version label this entry belongs to, resolved through the site's - * `versions` manifest: `versions.current` for the primary `docs` - * collection, `` for a registered `docs-` collection. `undefined` - * when the site is unversioned or the collection is not a docs version - * (`blog`, `api`, …) — surfaces must emit nothing in that case, so - * unversioned sites stay byte-identical. - */ - version: string | undefined; -} - -export interface IndexedTopLevelGroup { - /** Top-level slug — first URL segment under root. */ - slug: string; - /** Display label (today: identical to `slug`; reserved for future sidebar-label integration). */ - label: string; - /** Entries inside this group, sorted alphabetically by url. */ - members: IndexedEntry[]; - /** - * What kind of group this is: - * - `"primary"` — a folder inside the primary `docs` collection. - * - `"secondary"` — a separate non-version collection (`blog`, `api`, …). - * - `"version"` — an older docs version (`docs-v1`, …). The root - * `/llms.txt` typically filters these out; per-section files - * include them so `/v1/llms.txt` still ships. - */ - kind: "primary" | "secondary" | "version"; - /** - * True for a version collection listed in `versions.hidden`. Hidden - * versions stay URL-reachable but are kept off indexing surfaces - * (root and per-section `llms.txt`); per-section routes should skip - * groups where `hidden === true`. - */ - hidden: boolean; -} - -export interface IndexedTopLevel { - /** - * Single-entry top-level items — the root `/llms.txt` links directly - * to each leaf's `.md` alternate. Sorted alphabetically by `url`. - */ - leaves: IndexedEntry[]; - /** - * Multi-entry top-level items — each becomes a section file at - * `//llms.txt`. Sorted alphabetically by `slug`. - */ - groups: IndexedTopLevelGroup[]; -} - -/** - * Cross-collection entry list backing the agent-facing routes - * (`llms.txt`, per-page `.md` alternates, `llms-full.txt`) and internal - * link validation. Implements the indexing baseline of the two-layer - * architecture documented at `/features/llms-txt`: - * - * - **Multi-collection by default, zero opt-in.** Iterates every - * collection registered in `src/content.config.ts` except names - * matching `partials` or starting with `_` (reserved). - * - **Schema-tolerant.** Reads `title` and `description` if present; - * falls back to the entry id for the title and omits the - * description otherwise. - * - **Drops `draft: true` only.** Keeps `noindex: true` pages — they - * stay valid link targets, version landing pages, and navigable. - * Discovery surfaces filter them via `isDiscoverable`, not here. - * - * The returned shape is identical regardless of which factory created - * the collection: hand-rolled `defineCollection({ loader, schema })` - * collections work without modification. - */ -// Cached across pages (dev too); cleared on content change. Partitioned by -// audience key so a per-identity projection can't poison the public cache. -const indexedEntriesCache = new Map(); - -export async function getIndexedEntries( - ctx?: ProjectionContext, -): Promise { - const audience = resolveAudience(ctx); - const cacheKey = audienceCacheKey(audience); - const cached = indexedEntriesCache.get(cacheKey); - if (cached) return cached; - const { getCollection } = await import("astro:content"); - const collectionNames = await loadIndexedCollections(); - // Fall back to the primary collection name if the build-time parse - // came up empty. Belt-and-braces: the integration also defaults to - // ["docs"] when content.config.ts is missing. - const names = collectionNames.length > 0 ? collectionNames : [PRIMARY_COLLECTION]; - const versions = await getVersions(); - - const indexed: IndexedEntry[] = []; - for (const name of names) { - // Surfaces a failed registered collection instead of silently dropping it. - const { entries, warning } = await loadCollectionOrWarn< - import("astro:content").CollectionEntry - >(name, (n) => getCollection(n as any)); - if (warning) runtimeWarn(warning); - const prefix = resolveCollectionPrefix(name, versions); - const collectionVersion = await getCurrentVersion(name); - for (const entry of entries) { - const data = (entry.data ?? {}) as Record; - if (data.draft === true) continue; - - // A versioned API family stamps a per-entry `data.version`; prefer it over - // the docs-axis `getCurrentVersion` (which is null for API collections). - const entryVersion = - typeof data.version === "string" - ? data.version - : (collectionVersion ?? undefined); - - const title = - typeof data.title === "string" && data.title.length > 0 - ? data.title - : entry.id; - const rawDescription = data.description; - const description = - typeof rawDescription === "string" && rawDescription.length > 0 - ? rawDescription - : undefined; - - // `entry.id` is the final store id, which `getDocsStaticPaths` routes - // on verbatim, so use `entryRouteUrl` (no re-slug — see astro-slug.ts). - // `toBrowserHref` adds the trailing slash so `url` consumers can emit - // the value straight into `` without a redirect. - const canonicalUrl = entryRouteUrl(prefix, entry.id); - // The `.md` alternate lives at `/index.md`. For the root index - // of a collection (canonical URL is the bare prefix or `/`), append - // directly rather than re-derive from the trailing-slash form — the - // strip-trailing-slash recipe collapses `/` to `""` and produces the - // wrong path. - const markdownUrl = - canonicalUrl === "/" ? "/index.md" : `${canonicalUrl}/index.md`; - // The raw-source twin exists only for entries with a string body — - // data-loader collections without one get no `.mdx` alternate. - const sourceUrl = - typeof entry.body === "string" && entry.body.length > 0 - ? canonicalUrl === "/" - ? "/index.mdx" - : `${canonicalUrl}/index.mdx` - : undefined; - indexed.push({ - entry, - collection: name, - title, - description, - url: toBrowserHref(canonicalUrl), - markdownUrl, - sourceUrl, - version: entryVersion, - }); - } - } - indexedEntriesCache.set(cacheKey, indexed); - return indexed; -} - -/** - * Partition the indexed entries into the shape the root `/llms.txt` - * and `/[section]/llms.txt` routes need. - * - * Convention: - * - Primary `"docs"` entries follow the leaf/group rule based on - * their `entry.id` top segment (matches single-collection behavior). - * - Every other collection becomes a single top-level group named - * after the collection, regardless of how many entries it has. - * This matches the URL convention (`/api/...`, `/blog/...`). - */ -export async function getIndexedTopLevel(): Promise { - const items = (await getIndexedEntries()).filter((item) => - isDiscoverable(item.entry), - ); - const versions = await getVersions(); - - // Build two buckets keyed by their URL-facing slug: - // - primary: top-level slug under the `docs` collection - // - secondary: every other collection (versions tagged separately - // below so consumers can filter the root listing) - const primaryBuckets = new Map(); - const secondaryBuckets = new Map(); - const versionSlugs = new Set(versions?.others ?? []); - const hiddenSlugs = new Set(versions?.hidden ?? []); - - for (const item of items) { - if (item.collection === PRIMARY_COLLECTION) { - const top = item.entry.id.split("/")[0]!; - const bucket = primaryBuckets.get(top); - if (bucket) bucket.push(item); - else primaryBuckets.set(top, [item]); - } else { - // Bucket secondary collections by their URL-facing slug (version - // slug for `docs-` collections, raw collection ID otherwise) so - // the emitted group label and URL prefix match the route shape. - const slug = resolveCollectionSlug(item.collection, versions); - const bucket = secondaryBuckets.get(slug); - if (bucket) bucket.push(item); - else secondaryBuckets.set(slug, [item]); - } - } - - const leaves: IndexedEntry[] = []; - const groups: IndexedTopLevelGroup[] = []; - - for (const [slug, members] of primaryBuckets) { - const isLeaf = members.length === 1 && members[0]!.entry.id === slug; - if (isLeaf) { - leaves.push(members[0]!); - } else { - groups.push({ slug, label: slug, members, kind: "primary", hidden: false }); - } - } - for (const [slug, members] of secondaryBuckets) { - const kind: "version" | "secondary" = versionSlugs.has(slug) - ? "version" - : "secondary"; - groups.push({ slug, label: slug, members, kind, hidden: hiddenSlugs.has(slug) }); - } - - leaves.sort((a, b) => a.url.localeCompare(b.url)); - groups.sort((a, b) => a.slug.localeCompare(b.slug)); - for (const g of groups) { - g.members.sort((a, b) => a.url.localeCompare(b.url)); - } - - return { leaves, groups }; -} - -/** - * Render one indexed entry to clean Markdown, dispatching by collection. - * Prose entries render their MDX body via `renderEntryAsMarkdown`. OpenAPI - * reference entries carry no body, so their frozen view-model is projected and - * emitted through the `./api` seam — dynamic-imported so the engine and its - * parser stay out of the main bundle for prose-only sites. Both the corpus and - * the served `.md` twin route go through here, so the two never drift. - */ -export async function renderIndexedEntryMarkdown(item: IndexedEntry): Promise { - const apiCollections = await loadApiCollections(); - if (!apiCollections.includes(item.collection)) { - const { loadCitationIndex } = await import("./_internal/api/load-citation-index.js"); - return renderEntryAsMarkdown(item.entry, { citationIndex: await loadCitationIndex() }); - } - const { getApiModel, getApiPageProps, renderApiPageMarkdown } = await import( - "./api/index.js" - ); - const apiData = item.entry.data as { coordinate?: string; version?: string }; - const coordinate = apiData.coordinate; - if (typeof coordinate !== "string") { - throw new Error( - `nimbus-docs: API entry "${item.entry.id}" in collection "${item.collection}" ` + - `is missing its coordinate — the apiCollection() loader should have set it.`, - ); - } - // A versioned family stamps `data.version`; pass it so the `.md` twin renders - // from the same version's model the HTML page does. - const model = await getApiModel(item.collection, apiData.version); - return renderApiPageMarkdown(getApiPageProps(model, coordinate)); -} - -/** - * Render the full published corpus as one markdown document — the body of - * the `llms-full.txt` route. One fetch hands an agent (or a RAG ingestion - * job) every page as clean markdown, no crawling. - * - * Scope matches the root `llms.txt`: the primary `docs` collection plus - * every secondary collection, **excluding** non-current version collections - * (`docs-`) — old versions keep their own per-version surfaces and never - * multiply this document — and **excluding** `noindex: true` pages (see - * {@link isDiscoverable}), which stay addressable but off discovery surfaces. - * - * Contract (see `buildCorpusMarkdown` for the collation rules): - * - Entries are sorted by `url`; output is deterministic across rebuilds. - * - Each entry is a `#`-level block (bodies render at `##` and below). - * - The document header cross-references `/llms.txt`. - * - * The starter route stays policy-free and ~10 lines; a site that wants a - * different corpus (per-version, filtered, chunked) reshapes its own route - * on top of `getIndexedEntries()` + `renderEntryAsMarkdown()`. Pass Astro's - * `import.meta.env.BASE_URL` as `base` when the site supports sub-path deploys. - */ -export async function renderCorpusMarkdown(options?: { base?: string }): Promise { - const config = await loadNimbusConfig(); - const versions = await getVersions(); - const entries = await getIndexedEntries(); - - // Exclude non-current version collections — same predicate the root - // `llms.txt` applies via its `kind === "version"` skip (hidden versions - // are a subset of `others`, so this covers them too). - const versionSlugs = new Set(versions?.others ?? []); - const included = entries.filter( - (item) => - isDiscoverable(item.entry) && - (item.collection === PRIMARY_COLLECTION || - !versionSlugs.has(resolveCollectionSlug(item.collection, versions))), - ); - - const blocks = await Promise.all( - included.map(async (item) => ({ - title: item.title, - description: item.description, - url: item.url, - markdownUrl: item.markdownUrl, - markdown: await renderIndexedEntryMarkdown(item), - })), - ); - - return buildCorpusMarkdown(blocks, { - title: config.title, - description: config.description, - site: config.site, - base: options?.base, - }); -} - -// --------------------------------------------------------------------------- -// Data helpers -// --------------------------------------------------------------------------- - -/** - * Build the sidebar tree for the given current path, scoped to the - * top-level section containing that page. - * - * Reads `sidebar` from the user's nimbus.config. If `sidebar.items` is set, - * resolves config-driven sidebar. Otherwise auto-generates from filesystem - * (i.e. the `docs` collection's entry IDs). - * - * Returned shape depends on `sidebar.scope` in `nimbus.config.ts`: - * - `"full"` (default) — every top-level item on every page. - * - `"section"` — only the current top-level section's children. Use - * the header section-tab strip (via `getSidebarSections`) for - * cross-section nav when this mode is on. - * - * **Versioning awareness.** When the page is in a version collection - * (`docs-` where `` is in `versions.others`), pass `collection` as - * the second argument. The sidebar build will swap any - * `{ autogenerate: { collection: "docs" } }` items to autogenerate from - * that version's collection instead, and treat it as the primary for - * the build. Without this, version pages render the current-version - * sidebar and prev/next derives from the wrong tree. - * - * @param currentSlug - The current page's URL path (e.g. "/getting-started"). - * Used to set `isCurrent` on matching links and to pick - * which top-level section to surface when scoping. - * @param options.collection - The current page's Astro collection ID. - * Pass `entry.collection` from your route. - */ -export async function getSidebar( - currentSlug: string, - options?: { collection?: string; transform?: SidebarTransform }, -): Promise { - const config = await loadNimbusConfig(); - const structural = await buildStructuralTree(options?.collection); - - // 1. Scope + materialize. - let tree: SidebarItem[]; - if (config.sidebar?.scope === "section") { - tree = scopeToCurrentSection(structural, currentSlug); - } else { - tree = cloneSidebarTree(structural); - markActiveState(tree, currentSlug); - } - - // 2. Isolate further to a boundary sub-tree (if configured). Runs after - // scope, over the already-materialized (mutable) tree. - const boundaries = config.sidebar?.isolate?.boundaries; - if (boundaries && boundaries.length > 0) { - tree = isolateToBoundary(tree, currentSlug, boundaries); - } - - // 3. Consumer transform (call-site). Ctx is derived read-only from the - // frozen structural tree. - if (options?.transform) { - const ctx = deriveTransformCtx(structural, currentSlug); - tree = await options.transform({ tree, currentSlug, ...ctx }); - } - - // 4. Overview-leaf display mode (opt-in) — runs last so it sees the - // transform's output (e.g. badges keyed off `indexHref`) and only - // reshapes this returned tree, never the cached structural one. - if (config.sidebar?.indexDisplay === "overview-leaf") { - const label = - typeof config.sidebar.overviewLabel === "string" - ? config.sidebar.overviewLabel - : "Overview"; - const sectionSlug = currentSlug.split("/").filter(Boolean)[0] ?? ""; - tree = applyOverviewLeaf(tree, sectionSlug, label); - } - - return tree; -} - -/** - * Derive one section per top-level group in the sidebar — used by - * `Header.astro` to render the section tab strip (and by any other - * cross-section navigation). - * - * Reads the un-scoped tree so every section is visible, then collapses - * each top-level group to `{ label, href, isActive }`. - * - * Accepts the same `collection` option as `getSidebar` so version pages - * see version-scoped section tabs. - */ -export async function getSidebarSections( - currentSlug: string, - options?: { collection?: string }, -): Promise { - // Read-only over the frozen structural tree — no per-page clone. Active - // state is computed from `currentSlug` inside `deriveSidebarSections`. - const tree = await buildStructuralTree(options?.collection); - return deriveSidebarSections(tree, currentSlug); -} - -// A path that matches no real href, so the cached tree is built with every -// active flag inert; flags are stamped per page by `markActiveState`. -const NO_ACTIVE_PATH = "\u0000__nimbus_structural__"; - -// Structural tree cached per effective-primary (the only input that changes -// the tree's shape). Cached in dev too — rebuilding the full nav per request -// makes dev unusably slow on large trees; the dev server clears it on content -// change via `clearNavCaches`. -const structuralTreeCache = new Map(); - -/** Drop all nav caches (dev content-change invalidation). */ -export function clearNavCaches(): void { - structuralTreeCache.clear(); - indexedEntriesCache.clear(); - clearValidInternalLinksCache(); - clearContentCaches(); -} - -function deepFreeze(items: readonly SidebarItem[]): void { - for (const item of items) { - if (item.type === "group") deepFreeze(item.children); - Object.freeze(item); - } - Object.freeze(items); -} - -/** - * Build the un-scoped, un-marked sidebar tree, cached per effective-primary - * collection. Callers needing active-state clone it and run `markActiveState` - * (never mutate the cache). - * - * When `pageCollection` is a registered version collection (`docs-`), that - * collection becomes the primary: autogen items referencing `docs` are - * rewritten to it and `primaryPrefix` is set, so version pages get the right - * tree and prev/next ordering. - */ -async function buildStructuralTree( - pageCollection?: string, -): Promise { - const runtimeConfig = await loadNimbusConfig(); - const versions = await getVersions(); - - // Resolve the effective "primary" collection for THIS sidebar build. - // For pages in a non-current version collection, the primary IS that - // collection (the sidebar should walk docs-v0, not docs). - let effectivePrimary = PRIMARY_COLLECTION; - let primaryPrefix = ""; - if ( - versions && - pageCollection && - pageCollection.startsWith("docs-") && - versions.others.includes(pageCollection.slice("docs-".length)) - ) { - effectivePrimary = pageCollection; - primaryPrefix = resolveCollectionPrefix(pageCollection, versions); - } - - const cached = structuralTreeCache.get(effectivePrimary); - if (cached) return cached; - - // Rewrite sidebar items so `{ autogenerate: { collection: "docs" } }` - // becomes `{ autogenerate: { collection: "docs-v0" } }` on v0 pages. - // Items that name a different collection (api, blog) are untouched — - // they keep their global scope. - // Cast at the boundary: `runtimeConfig.sidebar?.items` is `unknown[] | undefined` - // because runtimeConfig is loaded through a virtual module whose data is - // already Zod-validated at integration setup (`validateNimbusConfig`). - // The cast restores the shape downstream functions expect. - const rewrittenItems = ( - effectivePrimary !== PRIMARY_COLLECTION - ? rewriteSidebarItemsForVersion( - runtimeConfig.sidebar?.items, - effectivePrimary, - ) - : runtimeConfig.sidebar?.items - ) as Parameters[0]; - - const referenced = collectSidebarCollectionRefs(rewrittenItems); - const collections = [ - effectivePrimary, - ...referenced.filter((c) => c !== effectivePrimary), - ]; - const entriesByCollection = await getVisibleEntriesByCollection(collections); - const tree = buildSidebarTree( - // Cast: `astro:content` `CollectionEntry` has `data: Record` - // in our stub; sidebar.ts's local `CollectionEntry` shapes `data` with `title` - // required. Runtime entries always carry `title` (schema-enforced); the cast - // documents that guarantee. `unknown` bridge is required because the two - // CollectionEntry shapes don't structurally overlap on the `data` field. - entriesByCollection as unknown as Parameters[0], - effectivePrimary, - NO_ACTIVE_PATH, - runtimeConfig.sidebar - ? { ...runtimeConfig.sidebar, items: rewrittenItems } - : undefined, - primaryPrefix, - ); - - // Frozen because it's shared across pages and its nodes reach user - // `resolveLabel` via `getBreadcrumbs`; consumers clone before stamping. - deepFreeze(tree); - structuralTreeCache.set(effectivePrimary, tree); - return tree; -} - -/** - * Substitute the primary collection (`docs`) for `effectivePrimary` - * inside any sidebar item that autogenerates from a named collection. - * Used by `buildStructuralTree` to make version pages render their - * own collection's sidebar instead of the current version's. - */ -function rewriteSidebarItemsForVersion( - items: unknown[] | undefined, - effectivePrimary: string, -): unknown[] | undefined { - if (!items) return items; - return items.map((item) => { - if (!item || typeof item !== "object") return item; - const o = item as Record; - const autogen = o.autogenerate as { collection?: string; directory?: string } | undefined; - if (autogen && autogen.collection === PRIMARY_COLLECTION) { - return { ...o, autogenerate: { ...autogen, collection: effectivePrimary } }; - } - // Nested groups recurse so per-group autogen items rewrite too. - if (Array.isArray(o.items)) { - return { ...o, items: rewriteSidebarItemsForVersion(o.items, effectivePrimary) }; - } - return item; - }); -} - -/** - * Resolve prev/next links for the current page. - * - * Walks the flattened sidebar; returns the surrounding entries. Honors - * `prev`/`next` frontmatter overrides if provided. - * - * When an override uses the object form with an internal `link` - * (e.g. `prev: { link: "/getting-started" }`), the link is validated - * against every visible content entry's URL at build time. A pointer - * to a missing page fails the build with a clear error — the same - * staleness-detection mechanism used for `previousSlug` in versioning. - * The string form (`prev: "Custom label"`) is a label-only override - * and doesn't go through link validation. - */ -export async function getPrevNext( - currentSlug: string, - options?: { - overrides?: PrevNextOverrides; - sidebarTree?: SidebarItem[]; - }, -): Promise { - const tree = options?.sidebarTree ?? (await getSidebar(currentSlug)); - // Build the set of valid internal route keys (slashless) from indexed - // entries so object-form `prev: { link: "/x" }` overrides fail loudly - // when the target doesn't exist. The set holds route keys, not browser - // hrefs, so a `/cli`, `/cli/`, or `/cli/?ref=x` override all resolve - // to the same canonical entry. Cheap: indexed entries are cached per - // build. - const indexed = await getIndexedEntries(); - const validInternalLinks = getValidInternalLinks(indexed); - return buildPrevNext(currentSlug, tree, options?.overrides, validInternalLinks); -} - -/** - * Build the breadcrumb trail from the active node's ancestry in the nav - * tree. Labels come from nav nodes, hrefs from each node's landing — so a - * section crumb links to its real landing page and segments with no node - * never appear. Index-less folders render as non-interactive crumbs. - * - * - `collection` — the page's Astro collection; pass `entry.collection` so - * versioned pages get version-prefixed hrefs. - * - `root` — the leading crumb (default `{ label: "Home", href: "/" }`). - * - `resolveLabel` — override a crumb label, or return `null` to drop it. - * - * Falls back to URL-segment derivation when the page has no node in the - * tree, so a stray page still gets a root-anchored trail. - */ -export async function getBreadcrumbs( - currentSlug: string, - options?: { collection?: string } & BreadcrumbOptions, -): Promise { - // `findActivePath` matches by href, so the un-marked tree suffices (no clone). - const tree = await buildStructuralTree(options?.collection); - const path = findActivePath(tree, currentSlug); - - if (path.length > 0) { - const root = options?.root ?? { label: "Home", href: "/" }; - const labels = await Promise.all( - path.map((node) => - Promise.resolve(options?.resolveLabel?.({ node, slug: currentSlug })), - ), - ); - return assembleBreadcrumbs(root, path, labels); - } - - return breadcrumbsFromUrl(currentSlug, options?.root?.label ?? "Home"); -} - -/** Resolves a section's display titles. May be async. */ -export type SectionTitleResolver = (ctx: { - sectionSlug: string; - module?: string; - indexEntryId?: string; -}) => SectionTitle | undefined | Promise; - -/** A section's rail and breadcrumb titles, which may differ. */ -export interface SectionTitle { - rail?: string; - breadcrumb?: string; -} - -/** - * Resolve a section's display title(s) for the current page, decoupled so - * the rail header and the breadcrumb can differ. - * - * Derives `sectionSlug` (seg0) and `module` (seg1) from the slug and passes - * them to a caller-supplied resolver. The resolver is an argument rather - * than config because config is JSON-serialized and cannot carry functions. - * `indexEntryId` is currently always `undefined`. - */ -export async function getSectionTitle( - currentSlug: string, - resolve: SectionTitleResolver, -): Promise { - const segs = currentSlug.split("/").filter(Boolean); - const sectionSlug = segs[0]; - if (!sectionSlug) return undefined; - return resolve({ sectionSlug, module: segs[1], indexEntryId: undefined }); -} - -export interface RouteNavigationOptions { - /** The current route's pathname. */ - path: string; - /** A real nav node URL to mark active and end the ancestry trail at. */ - section: string; - /** Crumbs appended after the section trail; a leaf with no href is current. */ - trail?: Breadcrumb[]; - /** When `false` (default), prev/next is omitted. */ - prevNext?: boolean; - /** The page's collection, for version-prefixed hrefs. */ - collection?: string; - /** Forwarded to the internal breadcrumb build. */ - resolveLabel?: BreadcrumbOptions["resolveLabel"]; -} - -export interface RouteNavigation { - breadcrumbs: Breadcrumb[]; - sidebar: SidebarItem[]; - /** The href marked active in the sidebar (the `section`). */ - activeHref: string; - prevNext?: PrevNext; -} - -/** - * Navigation (breadcrumbs, sidebar active-state, optional prev/next) for a - * data-driven route with no content entry of its own — e.g. a catalog page - * under `src/pages/[...].astro`. - * - * Builds the breadcrumb trail to `section` (a real nav node) and appends - * `trail` (the leaf). The sidebar is built with `section` as the active - * path, so the section node highlights even though the leaf is not in the - * tree — the leaf is never injected, keeping the tree and prev/next clean. - */ -export async function getRouteNavigation( - options: RouteNavigationOptions, -): Promise { - const { section, trail = [], prevNext = false, collection, resolveLabel } = options; - - const sidebar = await getSidebar(section, { collection }); - const sectionCrumbs = await getBreadcrumbs(section, { collection, resolveLabel }); - const breadcrumbs = composeRouteBreadcrumbs(sectionCrumbs, trail); - - let pn: PrevNext | undefined; - if (prevNext) { - pn = await getPrevNext(section, { sidebarTree: sidebar }); - } - - return { breadcrumbs, sidebar, activeHref: section, prevNext: pn }; -} - -/** - * Build an edit URL for a content entry using `config.editPattern`. - * - * `{path}` is replaced with the entry's source path when Astro provides it, - * falling back to the default docs collection path convention. - */ -export async function getEditUrl(entry: { - id: string; - filePath?: string; -}): Promise { - const runtimeConfig = await loadNimbusConfig(); - if (!runtimeConfig.editPattern) return undefined; - - const path = entry.filePath ?? `src/content/docs/${entry.id}.mdx`; - return runtimeConfig.editPattern.replace("{path}", path); -} - -/** - * Resolve a content entry's `lastUpdated` date from `git log`. - * - * Reads the author date (`%aI`) of the most recent commit that touched - * the entry's source file. Author date is stable across rebases — the - * value reflects when the content was actually changed, not when the - * commit happened to land in this branch. - * - * Returns `undefined` when git can't answer (no `.git`, shallow clone, - * file untracked, command not on PATH, etc.) so the caller can chain a - * fallback: - * - * const lastUpdated = entry.data.lastUpdated ?? await getLastUpdated(entry); - * - * Frontmatter always wins. Per-process cached so repeated calls for - * the same entry don't re-spawn `git`. - * - * Production note: most CI/CD systems do shallow clones by default - * (Vercel, Cloudflare Pages, GitHub Actions checkout@v4) — set - * `fetch-depth: 0` to make full history available, otherwise git - * returns nothing and the helper falls back to frontmatter or nothing. - */ -export async function getLastUpdated(entry: { - id: string; - filePath?: string; -}): Promise { - const path = entry.filePath ?? `src/content/docs/${entry.id}.mdx`; - return getLastUpdatedFromGit(path); -} - -/** - * Filter heading list to the configured min/max heading levels. - * - * @param headings - Raw `headings` from Astro's `render(entry)` return value. - * @param options - Override min/max heading levels. Defaults: min=2, max=3. - */ -export function getTOC( - headings: { depth: number; text: string; slug: string }[], - options?: { minHeadingLevel?: number; maxHeadingLevel?: number }, -): TOCItem[] { - return getHeadings(headings, options); -} - -// --------------------------------------------------------------------------- -// Page composition helpers -// --------------------------------------------------------------------------- - -import type { AstroGlobal, GetStaticPaths } from "astro"; - -function pageResolutionContext(astro: AstroGlobal): PageResolutionContext { - const audience = ( - astro.locals as { - nimbus?: { audience?: NonNullable }; - } - ).nimbus?.audience; - return { - props: astro.props as Record, - params: astro.params, - url: astro.url, - projection: audience ? { audience } : undefined, - }; -} - -async function resolveAstroProsePage( - astro: AstroGlobal, - collection: string | undefined, - partialHeadings: PartialHeadingOptions | undefined, -): Promise { - const context = pageResolutionContext(astro); - const result = await resolveProsePage( - context, - { collection }, - { - getVisibleEntry: getVisibleEntry as ( - collection: string, - id: string, - ctx?: ProjectionContext, - ) => Promise | null>, - getVersions, - async render(entry) { - const { render } = await import("astro:content"); - const { Content, headings } = await render(entry); - const merged = await mergePartialHeadings( - entry.body, - headings, - (partialCollection: string, id: string) => - getVisibleEntry(partialCollection, id, context.projection), - render as (entry: unknown) => Promise<{ headings: typeof headings }>, - partialHeadings, - ); - return { Content, headings: merged }; - }, - }, - ); - return result.status === "found" ? result.page : null; -} - -/** - * `getStaticPaths` implementation for a docs catch-all route. - * - * Returns one path per visible entry in the `docs` collection. Drafts are - * filtered in production. Each path passes `{ entry }` as props so the - * page component can access it via `getDocsPageProps(Astro)`. - * - * A `cacheKey` derived from the entry's `digest` is included on each path - * so Astro's experimental incremental build cache can skip re-rendering - * unchanged pages. This is a no-op when `experimental.incrementalBuild` is - * not enabled in `astro.config.ts`. - * - * Usage: - * - * // src/pages/[...slug].astro - * export const prerender = true; - * export const getStaticPaths = getDocsStaticPaths; - * - * The entry's `id` is used verbatim as the slug. So `docs/index.mdx` → - * `/index`, `docs/guides/setup.mdx` → `/guides/setup`. If you want a docs - * entry at the root URL, name it appropriately and decide whether to use - * a static `pages/index.astro` or let the catch-all handle root. - */ -export const getDocsStaticPaths: GetStaticPaths = async () => { - // Docs-specific helper: always reads the `docs` collection. Other - // collections require their own `pages//[...slug].astro` with - // a one-line `getCollection("")`-based getStaticPaths. - const entries = await getVisibleEntries(["docs"]); - return entries.map((entry) => ({ - params: { slug: entry.id }, - props: { entry }, - cacheKey: String(entry.digest), - })); -}; - -/** - * Read the current entry from `Astro.props`, render it, and return the - * pieces a docs page needs: the typed entry, the renderable `` - * component, and the headings list (for TOC generation). - * - * Headings from `` partials are recursively merged - * into the returned list in document order. Pass `partialHeadings: - * { resolvePartialId }` to customise how `` attributes map to - * a partial collection id (e.g. cloudflare-docs' `product` convention). - * - * Pass the page's `Astro` global. Throws if `Astro.props.entry` is missing, - * which indicates the page didn't wire `getDocsStaticPaths` (or a custom - * equivalent) correctly. - * - * Usage: - * - * const { entry, Content, headings } = await getDocsPageProps(Astro); - * - * With a custom partial-id resolver: - * - * const { entry, Content, headings } = await getDocsPageProps(Astro, { - * partialHeadings: { - * resolvePartialId: ({ file, product }) => - * product ? `${product}/${file}` : file, - * }, - * }); - */ -export async function getDocsPageProps( - astro: AstroGlobal, - options?: { partialHeadings?: PartialHeadingOptions }, -): Promise<{ - entry: import("astro:content").CollectionEntry<"docs">; - Content: import("astro/runtime/server/index.js").AstroComponentFactory; - headings: { depth: number; text: string; slug: string }[]; -}> { - const entry = (astro.props as { - entry?: import("astro:content").CollectionEntry<"docs">; - }).entry; - if (!entry) { - throw new Error( - "getDocsPageProps(): expected `entry` in Astro.props. " + - "Ensure your route uses `getStaticPaths = getDocsStaticPaths` " + - "(or passes an entry via custom getStaticPaths).", - ); - } - const page = await resolveAstroProsePage( - astro, - PRIMARY_COLLECTION, - options?.partialHeadings, - ); - if (!page) { - throw new Error(`getDocsPageProps(): could not resolve entry "${entry.id}".`); - } - return { - entry: page.entry as import("astro:content").CollectionEntry<"docs">, - Content: page.Content, - headings: page.headings, - }; -} - -/** - * Resolve a docs route's layout flags: merge the site-wide feature toggles with - * per-page frontmatter into the single source of truth for whether a page gets - * a sidebar / TOC column, so layouts stay presentational. - */ -export async function getRouteFlags(entry: { - data: { mode?: string; sidebar?: unknown; tableOfContents?: unknown }; -}): Promise<{ sidebar: boolean; tableOfContents: boolean }> { - const config = await loadNimbusConfig(); - const isCustom = entry.data.mode === "custom"; - return { - sidebar: - !isCustom && - config.features?.sidebar !== false && - entry.data.sidebar !== false, - tableOfContents: - !isCustom && - config.features?.tableOfContents !== false && - entry.data.tableOfContents !== false, - }; -} - -/** - * `getStaticPaths` implementation for a catch-all route over a non-primary - * collection (`api`, `blog`, …). Companion to `getDocsStaticPaths`. - * - * Returns one path per visible entry in the named collection. Drafts are - * filtered in production (same rule as `getDocsStaticPaths`). Each path - * passes `{ entry }` as props for `getCollectionPageProps()`. - * - * A `cacheKey` derived from the entry's `digest` is included on each path - * so Astro's experimental incremental build cache can skip re-rendering - * unchanged pages. This is a no-op when `experimental.incrementalBuild` is - * not enabled in `astro.config.ts`. - * - * Usage: - * - * // src/pages/api/[...slug].astro - * export const prerender = true; - * export const getStaticPaths = getCollectionStaticPaths("api"); - * - * Why a sibling helper instead of an option on `getDocsStaticPaths`: the - * `Docs` name carries the "primary collection mounted at root" semantic. - * Non-primary collections mount under their own URL namespace - * (`//...`) by convention; the helper name reflects that. - */ -export function getCollectionStaticPaths(collection: string): GetStaticPaths { - return async () => { - const entries = await getVisibleEntries([collection]); - return entries.map((entry) => ({ - params: { slug: entry.id }, - props: { entry }, - cacheKey: String(entry.digest), - })); - }; -} - -/** - * Read the current entry from `Astro.props`, render it, and return the - * pieces a docs-style page needs — typed for an arbitrary collection. - * - * Companion to `getCollectionStaticPaths`. Use this in routes mounted at - * non-primary collections (`api`, `blog`, …) instead of `getDocsPageProps`, - * which is typed to the `docs` collection. - * - * Headings from `` partials are recursively merged - * into the returned list in document order. See `getDocsPageProps` for - * the `partialHeadings` option. - * - * Pass the collection name as a type parameter for the entry's data - * shape to narrow correctly: - * - * const { entry, Content, headings } = await getCollectionPageProps<"api">(Astro); - */ -export async function getCollectionPageProps( - astro: AstroGlobal, - options?: { partialHeadings?: PartialHeadingOptions }, -): Promise<{ - entry: import("astro:content").CollectionEntry; - Content: import("astro/runtime/server/index.js").AstroComponentFactory; - headings: { depth: number; text: string; slug: string }[]; -}> { - const entry = (astro.props as { - entry?: import("astro:content").CollectionEntry; - }).entry; - if (!entry) { - throw new Error( - "getCollectionPageProps(): expected `entry` in Astro.props. " + - "Ensure your route uses `getStaticPaths = getCollectionStaticPaths()`.", - ); - } - const page = await resolveAstroProsePage( - astro, - undefined, - options?.partialHeadings, - ); - if (!page) { - throw new Error(`getCollectionPageProps(): could not resolve entry "${entry.id}".`); - } - return { - entry: page.entry as import("astro:content").CollectionEntry, - Content: page.Content, - headings: page.headings, - }; -} - -// --------------------------------------------------------------------------- -// API reference (version-aware routing) -// --------------------------------------------------------------------------- - -/** One picker-facing version of an API family. Serializable — no engine types. */ -export interface ApiVersionInfo { - /** Version id (URL segment for non-default versions). */ - version: string; - /** Display label for the picker (defaults to `version`). */ - label: string; - /** Whether this is the family default (owns the bare `/` URL). */ - isDefault: boolean; - /** Maturity status, or `null` when unset. */ - status: ApiVersionStatus | null; - /** Hidden from picker/search/sitemap; reachable by direct URL. */ - hidden: boolean; - /** Landing URL for this version (`/` or `//`). */ - url: string; -} - -/** - * `getStaticPaths` for an API reference route, spanning every version of the - * family. Companion to `getCollectionStaticPaths`, but version-aware: it emits - * one path per page per version, with the version segment already joined into - * the slug so a single `pages//[...slug].astro` catch-all serves - * the default at `//...` and each other version at - * `///...`. Hidden versions are still generated (they stay - * reachable by direct URL) — the picker and sitemap omit them separately. - * - * Each path carries `{ collection, version, coordinate }` props; render the - * page with a single `getApiPage(Astro)` call (or, by hand, `getApiModel` + - * `getApiPageProps` + `getApiNav`). - * - * Usage: - * - * // src/pages/api/[...slug].astro - * export const prerender = true; - * export const getStaticPaths = getApiStaticPaths("api"); - */ -export function getApiStaticPaths(collection: string): GetStaticPaths { - return async () => { - const config = await loadNimbusConfig(); - const entry = (config.api ?? []).find((a) => a.collection === collection); - if (!entry) { - throw new Error( - `nimbus-docs: getApiStaticPaths("${collection}") found no matching api collection in nimbus.config.ts.`, - ); - } - const { resolveApiFamily, apiPageRoute } = await import( - "./_internal/api/resolve-versions.js" - ); - const { getApiModel, getApiPageSlugs } = await import("./api/index.js"); - const targets = resolveApiFamily(entry); - const paths: { - params: { slug: string | undefined }; - props: { collection: string; version: string | null; coordinate: string }; - }[] = []; - for (const target of targets) { - const model = await getApiModel(collection, target.version ?? undefined); - for (const { coordinate, slug } of getApiPageSlugs(model)) { - const { param } = apiPageRoute(target, slug); - paths.push({ - params: { slug: param }, - props: { collection, version: target.version, coordinate }, - }); - } - } - return paths; - }; -} - -/** - * Read an API route's `{ collection, version, coordinate }` from `Astro.props`, - * resolve the model for that version, and return the page + nav a route needs — - * the one-call companion to `getApiStaticPaths`, mirroring `getDocsPageProps`. - * - * Collapses the per-page model→props→nav dance and normalises the `version` - * `null`→`undefined` hand-off that `getApiModel` expects. Lives here (not on the - * `nimbus-docs/api` seam) so the seam's runtime surface stays fixed; it reaches - * the seam lazily, like `getApiStaticPaths`. - * - * Usage: - * - * export const getStaticPaths = getApiStaticPaths("api"); - * const { page, nav, collection, version, coordinate } = await getApiPage(Astro); - * - * `collection`/`version`/`coordinate` are echoed back so a versioned layout can - * drive its version picker and deprecated-version banner from the same one call - * (they originate in the route props `getApiStaticPaths` stamps). - */ -export async function getApiPage(astro: AstroGlobal): Promise<{ - page: import("./api/index.js").ApiPageProps; - nav: import("./api/index.js").ApiNav; - collection: string; - version: string | null; - coordinate: string; -}> { - const props = astro.props as { - collection?: string; - version?: string | null; - coordinate?: string; - }; - if (!props.collection || !props.coordinate) { - throw new Error( - "getApiPage(): expected `collection` and `coordinate` in Astro.props. " + - "Ensure your route uses `getStaticPaths = getApiStaticPaths()`.", - ); - } - - const result = await resolveApiPage(pageResolutionContext(astro), {}, { - async getApiSpecs() { - return (await loadNimbusConfig()).api; - }, - getVisibleEntry: getVisibleEntry as ( - collection: string, - id: string, - ctx?: ProjectionContext, - ) => Promise | null>, - async render(collection, version, coordinate) { - const { getApiModel, getApiPageProps, getApiNav } = await import( - "./api/index.js" - ); - const model = await getApiModel(collection, version ?? undefined); - return { - page: getApiPageProps(model, coordinate), - nav: getApiNav(model, coordinate), - }; - }, - }); - if (result.status !== "found") { - throw new Error( - "getApiPage(): expected `collection` and `coordinate` in Astro.props. " + - "Ensure your route uses `getStaticPaths = getApiStaticPaths()`.", - ); - } - return { - page: result.page.page, - nav: result.page.nav, - collection: result.page.collection, - version: result.page.version, - coordinate: result.page.coordinate, - }; -} - -/** - * Return the versions of an API family for a picker, or `null` when the - * collection is unversioned or unknown. Ordered as declared; the default is - * flagged. Serializable — carries no engine internals. - */ -export async function getApiVersions( - collection: string, -): Promise { - const config = await loadNimbusConfig(); - const entry = (config.api ?? []).find((a) => a.collection === collection); - if (!entry || !entry.versions) return null; - const { resolveApiFamily } = await import( - "./_internal/api/resolve-versions.js" - ); - return resolveApiFamily(entry).map((t) => ({ - version: t.version!, - label: t.label, - isDefault: t.isDefault, - status: t.status, - hidden: t.hidden, - // Trailing-slashed; a bare `/family/v2` would 307-redirect under directory builds. - url: toBrowserHref(t.mountPath), - })); -} - -// --------------------------------------------------------------------------- -// Versioning (data layer) -// --------------------------------------------------------------------------- - -/** - * Return the resolved versioning manifest for the current site, or `null` - * if the site is unversioned (`nimbus.config.ts` has no `versions` block). - * - * Optional fields are normalised to empty arrays (`deprecated`, `hidden`) - * and `all` is `[current, ...others]` in manifest order — convenient for - * picker enumeration or anywhere you need every known version slug. - * - * Usage: - * - * const versions = await getVersions(); - * if (versions) { - * for (const slug of versions.all) { - * // …enumerate - * } - * } - * - * Reads from `virtual:nimbus/config`, so the cost is one cached dynamic - * import per build. - */ -export async function getVersions(): Promise { - const config = await loadNimbusConfig(); - const v = config.versions; - if (!v) return null; - const others = v.others ?? []; - return { - current: v.current, - others, - deprecated: v.deprecated ?? [], - hidden: v.hidden ?? [], - all: [v.current, ...others], - }; -} - -/** - * Return the version slug a given Astro content collection ID belongs to, - * or `null` if the collection is not a version of the primary docs. - * - * Rules: - * - `"docs"` → `versions.current` (the current version's label). - * - `"docs-"` where `` appears in `versions.current` or - * `versions.others` → ``. - * - Anything else (e.g. `"blog"`, `"api"`, `"docs-archive"` when - * `archive` isn't in the manifest) → `null`. - * - * Returns `null` whenever the site has no `versions` config at all, - * regardless of collection ID. - * - * Usage in a route: - * - * const { entry } = Astro.props; - * const version = await getCurrentVersion(entry.collection); - * // version === "v3" for entries in `docs`, "v2" for entries in `docs-v2`, … - */ -export async function getCurrentVersion( - collectionId: string, -): Promise { - const versions = await getVersions(); - if (!versions) return null; - if (collectionId === PRIMARY_COLLECTION) return versions.current; - if (!collectionId.startsWith("docs-")) return null; - const suffix = collectionId.slice("docs-".length); - return versions.all.includes(suffix) ? suffix : null; -} - -/** - * Look up the cross-version alternates for a given Astro entry. - * - * Returns `null` when the entry is not part of a versioning manifest - * (unversioned site, non-`docs` collection like `blog`/`api`, or the - * lookup misses for any other reason). Otherwise returns a record with: - * - * - `self`: the entry being looked up, expressed as a `VersionPageRef`. - * - `alternates`: every other version's sibling page for the same - * logical content (same slug or linked via `previousSlug`). Sorted - * in manifest version order. - * - `canonical`: the current-version sibling when one exists and - * isn't `self`. `null` when `self` is already the current version - * or no current-version sibling exists. - * - * Routes inject `` for every entry in - * `alternates`, and `` pointing at `canonical.url` - * when canonical is non-null. - * - * Usage in a route: - * - * const { entry } = Astro.props; - * const alts = await getVersionAlternates(entry.collection, entry.id); - * - * {alts?.alternates.map((a) => ( - * - * ))} - * {alts?.canonical && } - */ -export async function getVersionAlternates( - collectionId: string, - entryId: string, -): Promise { - const table = await loadVersionAlternates(); - const key = `${collectionId}:${entryId}`; - return table[key] ?? null; -} - -/** - * API-family variant of {@link getVersionAlternates}. API alternates are keyed - * by `family@version:coordinate`, which the `(collection, entryId)` accessor - * cannot address. Pass the `version` and `coordinate` from - * {@link getApiStaticPaths}. Returns `null` for an unversioned family. - */ -export async function getApiVersionAlternates( - collection: string, - version: string | null, - coordinate: string, -): Promise { - if (version == null) return null; - const config = await loadNimbusConfig(); - const { resolveApiVersion } = await import( - "./_internal/api/resolve-versions.js" - ); - const target = resolveApiVersion(config.api, collection, version); - if (!target) return null; - const table = await loadVersionAlternates(); - return table[`${target.versionKey}:${coordinate}`] ?? null; -} - -/** - * Convenience wrapper: returns just the canonical URL for an entry, or - * `null` when none applies. Equivalent to - * `(await getVersionAlternates(c, e))?.canonical?.url ?? null` — handy - * when a route only needs the canonical and not the full alternates list. - */ -export async function getCanonicalUrl( - collectionId: string, - entryId: string, -): Promise { - const record = await getVersionAlternates(collectionId, entryId); - return record?.canonical?.url ?? null; -} - -/** - * Return the agent index URL path (the `/llms.txt` route) that - * corresponds to a given Astro collection. The path is mount-point - * aware: pages in version collections point at the per-version index, - * pages in non-primary collections point at their per-collection index, - * and the primary `docs` collection points at the root. - * - * - `"docs"` → `"/llms.txt"` - * - `"docs-v1"` → `"/v1/llms.txt"` (when `v1` is in `versions.others`) - * - `"blog"` → `"/blog/llms.txt"` - * - `"api"` → `"/api/llms.txt"` - * - `"docs-archive"` (unrecognised version slug) → `"/docs-archive/llms.txt"` - * - * Returns a path with a leading slash and no trailing slash. Routes - * resolve it against `Astro.site` to produce a full URL. - * - * Used by `BaseLayout` and `AgentDirective` to surface the correct - * agent index hint on every page — readers landing on `/v1/foo` get - * pointed at `/v1/llms.txt`, not `/llms.txt`, so agents don't crawl - * the wrong section. - */ -export async function getCollectionLlmsUrl( - collectionId: string, -): Promise { - if (collectionId === PRIMARY_COLLECTION) return "/llms.txt"; - const versions = await getVersions(); - if (versions && collectionId.startsWith("docs-")) { - const slug = collectionId.slice("docs-".length); - if (versions.others.includes(slug)) { - // Hidden versions do NOT emit a per-section //llms.txt — the - // [section] route filters them out. Pointing readers at a 404 - // breaks the agent-discovery contract. Fall back to the root - // index for hidden version pages instead. - if (versions.hidden.includes(slug)) return "/llms.txt"; - return `/${slug}/llms.txt`; - } - } - return `/${collectionId}/llms.txt`; -} - -/** - * Look up the versioning status for a page's collection — what the - * layout needs to decide whether to render the deprecation banner, - * apply the Pagefind facet filters, or exclude the page from search - * entirely. - * - * Returns `null` when the site is unversioned or the page is not part - * of a version collection (regular `docs`, `blog`, `api`, …). Layouts - * treat that as "no versioning UI to apply" — render normally. - * - * Usage: - * - * const status = await getVersionStatus(entry.collection); - * if (status?.isDeprecated) { - * // render the deprecation banner - * } - */ -/** - * Resolve a URL that's guaranteed to exist within a given version's - * collection. Used by the picker (and any other "jump to that version" - * surface) to avoid landing readers on a 404 when the current page has - * no same-logical-page sibling in the target version. - * - * Resolution order: - * 1. If `docs-/index` exists, return its URL (the conventional - * "version landing page"). - * 2. If `docs-/overview` exists, return its URL (common alternate - * name for a landing page). - * 3. Otherwise return the first indexed entry's URL in that version, - * sorted by URL — matches `getIndexedTopLevel()`'s sort so the - * choice is deterministic across builds. - * 4. If the version has no indexed entries at all, return `null`. - * Callers should treat that as "this version has nothing to link - * to" and either omit the picker entry or fall back to the - * version's URL prefix root (which may still 404, but that's the - * authoring problem to fix, not the picker's). - * - * `version` is the manifest slug (e.g. `"v0"`), NOT the collection ID - * (`"docs-v0"`). For the current version, returns `"/"` when at least - * one current-version entry exists, else `null`. - * - * Reads from `getIndexedEntries()`, so the cost is one cached lookup - * per build (the indexed list is computed once per page render). - */ -export async function getVersionLandingUrl( - version: string, -): Promise { - const versions = await getVersions(); - if (!versions) return null; - if (!versions.all.includes(version)) return null; - - const targetCollection = - version === versions.current ? PRIMARY_COLLECTION : `docs-${version}`; - const items = await getIndexedEntries(); - const inVersion = items.filter((i) => i.collection === targetCollection); - if (inVersion.length === 0) return null; - - const byId = new Map(inVersion.map((i) => [i.entry.id, i])); - // Prefer index / overview by convention. - const preferred = byId.get("index") ?? byId.get("overview"); - // `IndexedEntry.url` is already the trailing-slash browser-href form - // the version picker renders, so no extra normalization here. - if (preferred) return preferred.url; - // Else first by URL (sort is alphabetical → deterministic). - inVersion.sort((a, b) => a.url.localeCompare(b.url)); - return inVersion[0]!.url; -} - -export async function getVersionStatus( - collectionId: string, -): Promise { - // API version key (`family@version`): version ids and family names never - // contain `@`, so a single `@` unambiguously marks the API axis. Resolve its - // status from the family so the head can `noindex` hidden versions and - // layouts can render the deprecated banner. - const at = collectionId.indexOf("@"); - if (at > 0) { - const family = collectionId.slice(0, at); - const version = collectionId.slice(at + 1); - const apiVersions = await getApiVersions(family); - if (apiVersions) { - const match = apiVersions.find((v) => v.version === version); - if (!match) return null; - return { - version, - isCurrent: match.isDefault, - isDeprecated: match.status === "deprecated", - isHidden: match.hidden, - }; - } - } - - const versions = await getVersions(); - if (!versions) return null; - const version = await getCurrentVersion(collectionId); - if (version === null) return null; - return { - version, - isCurrent: version === versions.current, - isDeprecated: versions.deprecated.includes(version), - isHidden: versions.hidden.includes(version), - }; -} +export * from "./runtime.js"; diff --git a/packages/nimbus-docs/src/integration.ts b/packages/nimbus-docs/src/integration.ts index bd446d2d..3d791ade 100644 --- a/packages/nimbus-docs/src/integration.ts +++ b/packages/nimbus-docs/src/integration.ts @@ -35,13 +35,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AstroIntegration, ShikiConfig } from "astro"; import mdx from "@astrojs/mdx"; -import { satteri } from "@astrojs/markdown-satteri"; -import type { - HastPluginDefinition, - HastPluginInput, - MdastPluginDefinition, - MdastPluginInput, -} from "satteri"; +import type { HastPluginInput, MdastPluginInput } from "satteri"; import sitemap from "@astrojs/sitemap"; import { admonitionPlugin } from "./_internal/admonition-vite-plugin.js"; import { @@ -84,6 +78,7 @@ import { makeHiddenSitemapFilter, } from "./_internal/hidden-sitemap.js"; import { virtualConfigPlugin } from "./_internal/virtual-config.js"; +import { virtualApiBuildConfigPlugin } from "./_internal/virtual-api-build-config.js"; import { virtualCoordinatesPlugin } from "./_internal/virtual-coordinates.js"; import { citationPlugin } from "./_internal/api/citation-vite-plugin.js"; import { @@ -91,8 +86,11 @@ import { type CoordinatesManifest, } from "./_internal/api/citation-index.js"; import { ingestApiReferences } from "./_internal/api/ingest-references.js"; -import { iconVirtualPlugin, type IconPluginOptions } from "./_internal/icon-virtual.js"; -import { scanCodeBlockLanguages } from "./_internal/scan-code-langs.js"; +import { + iconVirtualPlugin, + type IconPluginOptions, +} from "./_internal/icon-virtual.js"; +import { scanCodeBlocks } from "./_internal/scan-code-langs.js"; import { clearCodeStyleRegistry, getCodeStyleCSS, @@ -116,7 +114,13 @@ import { type RedirectConfigLike, } from "./_internal/redirect-emitters.js"; import { resolveSite } from "./_internal/site-detect.js"; -import type { NimbusConfig } from "./types.js"; +import { + canonicalCollectionRouteComponent, + compileRenderingPolicy, + normalizeRouteComponent, + routeComponentKeys, +} from "./_internal/rendering-policy.js"; +import type { NimbusConfig, RenderingMode } from "./types.js"; /** * Common shorthand fences that Shiki doesn't recognise out of the box. @@ -130,6 +134,12 @@ const SHIKI_LANG_ALIAS: Record = { shellsession: "shellscript", }; +const REQUEST_ROUTE_INVENTORY_PATTERN = "/_nimbus/request-route-inventory.json"; +const REQUEST_ROUTE_INVENTORY_ENTRYPOINT = new URL( + `./_internal/request-route-inventory.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`, + import.meta.url, +); + export interface SitemapOptions { serialize?: SitemapSerialize; customPages?: string[]; @@ -259,9 +269,7 @@ export interface NimbusIntegrationOptions { * - `false`: disable the icon plugin entirely. * - `{ iconDir, include, svgoOptions }`: explicit configuration. */ - icons?: - | boolean - | IconPluginOptions; + icons?: boolean | IconPluginOptions; /** * Authoring-lint severity overrides for `nimbus-docs lint`. Maps a rule * code to `"error" | "warn" | "off"` or a `[severity, options]` tuple. @@ -317,22 +325,56 @@ export function nimbus( let resolvedRoutesForBuild: ResolvedRouteLike[] = []; // Resolved `redirects` (user ∪ version-alternate) for the platform emitter. let redirectsForBuild: Record = {}; + let renderingRoutes = new Map(); + let requestRenderingConfigured = false; + let requestRenderingCollections = new Set(); + let requestRoutePatterns = new Set(); + let building = false; // Built eagerly at config:setup, reassigned by the dev re-bake; both the // citation plugin and virtual:nimbus/coordinates read it through a getter. let citationIndex = new Map(); - let coordinatesManifest: CoordinatesManifest = { version: 1, collections: {} }; + let coordinatesManifest: CoordinatesManifest = { + version: 1, + collections: {}, + }; return { name: "@cloudflare/nimbus-docs", hooks: { "astro:config:setup": async (params) => { - const { updateConfig, config: astroConfig, logger, command } = params; + const { + updateConfig, + injectRoute, + config: astroConfig, + logger, + command, + } = params; + building = command === "build"; // App files (content.config.ts, pages/, components.ts) follow srcDir; // content/assets stay root-relative via their collection bases. const srcDir = fileURLToPath(astroConfig.srcDir); const projectRoot = fileURLToPath(astroConfig.root); + const publicDir = astroConfig.publicDir + ? fileURLToPath(astroConfig.publicDir) + : path.join(projectRoot, "public"); + const faviconCandidates = [ + { file: "favicon.svg", type: "image/svg+xml" }, + { file: "favicon.ico", type: "image/x-icon" }, + { file: "favicon.png", type: "image/png" }, + ]; + const favicon = + faviconCandidates.find(({ file }) => + fs.existsSync(path.join(publicDir, file)), + ) ?? faviconCandidates[0]!; + const defaultSocialImage = fs.existsSync( + path.join(publicDir, "opengraph.png"), + ) + ? "/opengraph.png" + : fs.existsSync(path.join(publicDir, "logo.png")) + ? "/logo.png" + : "/og.png"; // Resolve `site` from platform env when it's still a placeholder, before // anything reads it. Mutating the validated config propagates the origin @@ -385,9 +427,9 @@ export function nimbus( `Create the file with \`export const components = { /* ... */ };\` or set \`validateMdx: false\` to silence this warning.`, ); } else { - const contentDirs = (validateOpts.contentDirs ?? ["src/content"]).map( - (d) => (path.isAbsolute(d) ? d : path.join(projectRoot, d)), - ); + const contentDirs = ( + validateOpts.contentDirs ?? ["src/content"] + ).map((d) => (path.isAbsolute(d) ? d : path.join(projectRoot, d))); const failures = await validateMdxContent({ globals, contentDirs, @@ -426,18 +468,29 @@ export function nimbus( // so Shiki eager-loads grammars at startup. This makes cold-build // output stable regardless of file processing order (Shiki's lazy // load otherwise depends on which file hits a grammar first). - const codeBlockLangs = await scanCodeBlockLanguages( - projectRoot, - SHIKI_LANG_ALIAS, - ); + const codeBlocks = await scanCodeBlocks(projectRoot, SHIKI_LANG_ALIAS); + const codeBlockLangs = [ + ...new Set(codeBlocks.map(({ lang }) => lang)), + ].sort(); const userShikiConfig = astroConfig.markdown?.shikiConfig as - | Record - | undefined; + Record | undefined; const classShikiTokens = shouldClassShikiTokens(userShikiConfig); const hasCustomTheme = hasCustomShikiTheme(userShikiConfig); const useNimbusDefaultThemes = !hasCustomTheme; - const useNimbusDefaultColor = !hasCustomTheme && - !hasCustomShikiDefaultColor(userShikiConfig); + const useNimbusDefaultColor = + !hasCustomTheme && !hasCustomShikiDefaultColor(userShikiConfig); + clearCodeStyleRegistry(); + if ( + classShikiTokens && + (config.rendering?.default === "request" || + Object.values(config.rendering?.collections ?? {}).includes( + "request", + )) + ) { + const { registerCodeBlockStyles } = + await import("./_internal/register-code-styles.js"); + await registerCodeBlockStyles(codeBlocks); + } // Parse `content.config.ts` up front: we need // - the registered collection set (for `virtual:nimbus/config`'s @@ -447,34 +500,123 @@ export function nimbus( // scanned at the right on-disk location rather than being // silently skipped). const contentConfigPath = path.join(srcDir, "content.config.ts"); - const rawCollections = await parseContentCollections(contentConfigPath); + const parsedCollections = + await parseContentCollections(contentConfigPath); + const rawCollections = parsedCollections?.names ?? null; const collectionBases = await parseCollectionBases(contentConfigPath); // API collections carry no MDX body, but they DO reach the agent index: // their `.md` twins are served by `renderApiPageMarkdown` (dispatched in // `renderIndexedEntryMarkdown`), so llms.txt/corpus links resolve. The // reserved-name filter still applies; `null` (no parseable config) falls // back to `["docs"]`, matching `getIndexedEntries()`. - const indexedCollections = - rawCollections === null ? ["docs"] : filterIndexableCollections(rawCollections); // Which of those are API collections — render-time dispatch (prose vs // emitter) keys off this, and `getApiModel` resolves specs against // `projectRoot` (declared above — the loader's base), not `process.cwd()`. - const apiCollections = (config.api ?? []).map((entry) => entry.collection); + const apiCollections = (config.api ?? []).map( + (entry) => entry.collection, + ); + const parsedIndexedCollections = + rawCollections === null || + (parsedCollections?.complete === false && rawCollections.length === 0) + ? ["docs"] + : filterIndexableCollections(rawCollections); + const indexedCollections = [ + ...new Set([...parsedIndexedCollections, ...apiCollections]), + ]; + + renderingRoutes = new Map(); + requestRenderingConfigured = false; + requestRenderingCollections = new Set(); + requestRoutePatterns = new Set(); + if (config.rendering) { + const versions = config.versions + ? { others: config.versions.others ?? [] } + : null; + const candidates = new Set([ + ...indexedCollections, + ...(config.versions?.others ?? []).map( + (version) => `docs-${version}`, + ), + ]); + const unresolvedOverrides = Object.keys( + config.rendering.collections ?? {}, + ).filter((collection) => !candidates.has(collection)); + if ( + parsedCollections?.complete === false && + (config.rendering.default === "request" || + unresolvedOverrides.length > 0) + ) { + throw new Error( + "nimbus-docs: rendering policy cannot safely enumerate collections because " + + "`src/content.config.ts` contains registrations Nimbus cannot identify statically. " + + "Use explicit top-level collection keys before applying a request default " + + "or overriding a collection Nimbus cannot statically identify.", + ); + } + const canonicalCollections = [...candidates].filter((collection) => + fs.existsSync( + canonicalCollectionRouteComponent(srcDir, collection, versions), + ), + ); + const policy = compileRenderingPolicy( + config.rendering, + canonicalCollections, + ); + requestRenderingConfigured = Object.values( + policy.collections, + ).includes("request"); + requestRenderingCollections = new Set( + Object.entries(policy.collections) + .filter(([, mode]) => mode === "request") + .map(([collection]) => collection), + ); + for (const [collection, mode] of Object.entries(policy.collections)) { + const component = canonicalCollectionRouteComponent( + srcDir, + collection, + versions, + ); + for (const key of routeComponentKeys(projectRoot, component)) { + renderingRoutes.set(key, mode); + } + } + if (building && requestRenderingConfigured) { + injectRoute({ + pattern: REQUEST_ROUTE_INVENTORY_PATTERN, + entrypoint: REQUEST_ROUTE_INVENTORY_ENTRYPOINT, + prerender: true, + }); + } + } // Remote refs fold into the citation index but not the manifest (which republishes // only local collections). { - const { index, manifest } = await buildCitationIndex(config.api, projectRoot); - await ingestApiReferences(config.apiReferences, index, projectRoot, logger); + const { index, manifest } = await buildCitationIndex( + config.api, + projectRoot, + ); + await ingestApiReferences( + config.apiReferences, + index, + projectRoot, + logger, + ); citationIndex = index; coordinatesManifest = manifest; } if (rawCollections === null) { logger.warn( - `nimbus-docs: \`src/content.config.ts\` is missing or doesn't expose a parseable \`export const collections = { ... }\`. ` + + `nimbus-docs: \`src/content.config.ts\` is missing. ` + `Falling back to indexing the \`docs\` collection only.`, ); + } else if (parsedCollections?.complete === false) { + logger.warn( + "nimbus-docs: `src/content.config.ts` contains collection registrations " + + "that cannot be identified statically. Only explicit top-level keys " + + "are available to collection-aware tooling.", + ); } // Build validator `nimbus/duplicate-slug`: two sources that resolve @@ -551,7 +693,7 @@ export function nimbus( // collection named `docs-`. We can only check this when we // actually parsed content.config.ts — if `rawCollections` is null // the user is on a brand-new project and we already warned. - if (config.versions && rawCollections !== null) { + if (config.versions && parsedCollections?.complete === true) { const registered = new Set(rawCollections); const missing = config.versions.others.filter( (slug) => !registered.has(`docs-${slug}`), @@ -608,9 +750,8 @@ export function nimbus( // every docs key, so the merge is a plain spread. Runs even when the // site has no docs versions. if (config.api?.some((e) => e.versions && e.versions.length > 1)) { - const { buildApiVersionAlternates } = await import( - "./_internal/api/api-alternates.js" - ); + const { buildApiVersionAlternates } = + await import("./_internal/api/api-alternates.js"); const apiAlternates = await buildApiVersionAlternates( config.api, projectRoot, @@ -656,12 +797,14 @@ export function nimbus( // Admonition transform plugin: only constructed when enabled // (default on). Same `contentDirs` defaulting as the MDX // validator — keeps the two scans aligned. - const admonitionVitePlugins = [] as Array>; + const admonitionVitePlugins = [] as Array< + ReturnType + >; if (options.admonitions !== false) { const admoOpts = typeof options.admonitions === "object" ? options.admonitions : {}; - const contentDirs = (admoOpts.contentDirs ?? ["src/content"]).map((d) => - path.isAbsolute(d) ? d : path.join(projectRoot, d), + const contentDirs = (admoOpts.contentDirs ?? ["src/content"]).map( + (d) => (path.isAbsolute(d) ? d : path.join(projectRoot, d)), ); admonitionVitePlugins.push( admonitionPlugin({ @@ -676,6 +819,15 @@ export function nimbus( path.isAbsolute(d) ? d : path.join(projectRoot, d), ); + const markdownProcessor = + options.markdown?.processor ?? + ( + await import("./_internal/default-markdown-processor.js") + ).createDefaultMarkdownProcessor({ + hastPlugins: options.markdown?.hastPlugins, + mdastPlugins: options.markdown?.mdastPlugins, + }); + updateConfig({ // Bridge `nimbusConfig.site` → Astro's top-level `site`. The // sitemap integration and `Astro.site` both read this; without @@ -707,13 +859,7 @@ export function nimbus( // applies), so existing sites are unaffected. A full `processor` // override bypasses this. The `*Input[]` → `*Definition[]` cast is // safe: `markdownToHtml` resolves factory entries at runtime. - processor: (options.markdown?.processor ?? - satteri({ - hastPlugins: (options.markdown?.hastPlugins ?? - []) as HastPluginDefinition[], - mdastPlugins: (options.markdown?.mdastPlugins ?? - []) as MdastPluginDefinition[], - })) as never, + processor: markdownProcessor as never, // Dual-theme Shiki output. `defaultColor: false` makes Shiki // emit BOTH themes as inline CSS variables (`--shiki-light`, // `--shiki-dark`, `--shiki-light-bg`, `--shiki-dark-bg`) @@ -753,7 +899,9 @@ export function nimbus( // Shiki resolves bundled-language *names* (strings) at runtime, // but Astro's `shikiConfig.langs` type only admits // `LanguageRegistration` objects — cast the scanned names here. - langs: codeBlockLangs as unknown as NonNullable, + langs: codeBlockLangs as unknown as NonNullable< + ShikiConfig["langs"] + >, }, }, // Versioning: auto-redirects from old-version URLs whose @@ -791,17 +939,21 @@ export function nimbus( coordinates: Object.fromEntries(citationIndex), manifest: coordinatesManifest, })), + virtualApiBuildConfigPlugin(config.api, projectRoot), virtualConfigPlugin(config, { indexedCollections, + requestRenderingCollections: [...requestRenderingCollections], versionAlternates, apiCollections, - root: projectRoot, + headDefaults: { favicon, socialImage: defaultSocialImage }, }), ...(options.icons !== false ? [ iconVirtualPlugin({ root: fileURLToPath(astroConfig.root), - ...(typeof options.icons === "object" ? options.icons : {}), + ...(typeof options.icons === "object" + ? options.icons + : {}), }), ] : []), @@ -842,11 +994,46 @@ export function nimbus( }, }); }, - "astro:config:done": ({ injectTypes, config: astroConfig }) => { - outputModeForBuild = astroConfig.output === "server" ? "server" : "static"; + "astro:route:setup": ({ route }) => { + const mode = renderingRoutes.get( + normalizeRouteComponent(route.component), + ); + if (!mode) return; + route.prerender = mode === "build"; + }, + "astro:config:done": ({ + injectTypes, + config: astroConfig, + buildOutput, + }) => { + outputModeForBuild = + buildOutput ?? + (astroConfig.output === "server" ? "server" : "static"); adapterNameForBuild = astroConfig.adapter?.name ?? null; - redirectsForBuild = (astroConfig.redirects ?? - {}) as Record; + if ( + building && + requestRenderingConfigured && + (outputModeForBuild !== "server" || !adapterNameForBuild) + ) { + throw new Error( + 'nimbus-docs: rendering mode "request" requires Astro `output: "server"` and a compatible adapter for production builds. ' + + `Received output=${outputModeForBuild}, adapter=${adapterNameForBuild ?? "none"}.`, + ); + } + if ( + building && + requestRenderingConfigured && + adapterNameForBuild?.replace(/^@astrojs\//, "") !== "cloudflare" + ) { + throw new Error( + 'nimbus-docs: rendering mode "request" currently requires `@astrojs/cloudflare`. ' + + `Received adapter=${adapterNameForBuild}. Other production adapters are deferred until BG-1c.7.`, + ); + } + redirectsForBuild = (astroConfig.redirects ?? {}) as Record< + string, + RedirectConfigLike + >; // TypeScript declaration for the virtual module. Written to // `.astro/integrations/nimbus-docs/virtual-config.d.ts` and // auto-referenced by the project tsconfig via Astro's generated @@ -859,12 +1046,14 @@ export function nimbus( " export const config: NimbusConfig;", " /** Build-time list of indexable collection names. See `getIndexedEntries()`. */", " export const indexedCollections: readonly string[];", + " /** Collections whose canonical routes render on request. Build-only. */", + " export const requestRenderingCollections: readonly string[];", " /** Build-time cross-version alternates table. See `getVersionAlternates()`. */", " export const versionAlternates: VersionAlternatesTable;", " /** Subset of `indexedCollections` that are OpenAPI reference collections. Server-only. */", " export const apiCollections: readonly string[];", - " /** Absolute project root (the loader's spec-resolution base). Build/server-only. */", - " export const root: string;", + " /** Build-time defaults derived from Astro's public directory. */", + " export const headDefaults: { favicon: { file: string; type: string }; socialImage: string };", "}", "", ].join("\n"), @@ -873,7 +1062,7 @@ export function nimbus( filename: "virtual-icons.d.ts", content: [ 'declare module "virtual:nimbus/icons" {', - " import type { IconifyJSON } from \"@iconify/types\";", + ' import type { IconifyJSON } from "@iconify/types";', " export type Icon = string;", " export const config: { include: Record };", " const icons: Record;", @@ -884,9 +1073,9 @@ export function nimbus( }); }, "astro:server:setup": ({ server }) => { - clearCodeStyleRegistry(); server.middlewares.use((req, res, next) => { - const pathname = new URL(req.url ?? "/", "http://nimbus.local").pathname; + const pathname = new URL(req.url ?? "/", "http://nimbus.local") + .pathname; // Match by suffix so the shiki stylesheet is served regardless of // how Vite's dev server presents `base` on `req.url` at a non-root // base (the build serves this file statically, so it's dev-only). @@ -922,7 +1111,10 @@ export function nimbus( // re-executes load-citation-index.ts. const rebakePaths = new Set([ ...collectSpecFilePaths(config.api, projectRootForBuild), - ...collectLocalManifestPaths(config.apiReferences, projectRootForBuild), + ...collectLocalManifestPaths( + config.apiReferences, + projectRootForBuild, + ), ]); if (rebakePaths.size > 0) { const rebakeCitationIndex = async (file: string) => { @@ -953,13 +1145,23 @@ export function nimbus( } }, "astro:build:start": async () => { - // Reset the per-build code-style registry so Shiki token classes - // don't leak across builds in a long-lived dev/CI process. - clearCodeStyleRegistry(); const { clearNavCaches } = await import("./index.js"); clearNavCaches(); }, "astro:routes:resolved": ({ routes }) => { + requestRoutePatterns = + renderingRoutes.size === 0 + ? new Set() + : new Set( + routes + .filter( + (route) => + renderingRoutes.get( + normalizeRouteComponent(route.entrypoint), + ) === "request", + ) + .map((route) => route.pattern), + ); resolvedRoutesForBuild = routes.map((r) => ({ pattern: r.pattern, type: r.type, @@ -968,6 +1170,25 @@ export function nimbus( })); }, "astro:build:done": async ({ dir, pages, logger }) => { + const distDir = fileURLToPath(dir); + const publicPages = requestRenderingConfigured + ? pages.filter( + ({ pathname }) => + !isRequestRouteInventoryPath(pathname, astroBaseForBuild), + ) + : pages; + const prerenderedRoutes = new Set( + publicPages.map(({ pathname }) => canonicalizePathname(pathname)), + ); + const requestRoutes = ( + requestRenderingConfigured + ? readRequestRouteInventory( + distDir, + astroBaseForBuild, + requestRenderingCollections, + ) + : [] + ).filter((pathname) => !prerenderedRoutes.has(pathname)); // Materialize the site's route truth from Astro's emitted `pages` // array — the single source of truth: every URL on this list is a // page Astro just wrote to disk. No reconstruction, no slug @@ -982,7 +1203,8 @@ export function nimbus( materializeRouteTruthFromPages( projectRootForBuild, astroBaseForBuild, - pages, + publicPages, + requestRoutes, logger, ); @@ -1001,8 +1223,10 @@ export function nimbus( outputMode: outputModeForBuild, adapterName: adapterNameForBuild, routes: resolvedRoutes, - prerenderedPageCount: pages.length, + prerenderedPageCount: publicPages.length, + requestRenderedPageCount: requestRoutes.length, declaredFeatureRoutes: footprintRoutes(footprint), + declaredRequestRoutes: [...requestRoutePatterns], serverFeatures: footprint.map((f) => f.id), }); logger.info(report.summaryLine); @@ -1019,8 +1243,6 @@ export function nimbus( logger, ); - const distDir = fileURLToPath(dir); - // Emit platform redirects only with no adapter; an adapter emits its // own (and static-output-with-adapter is a valid combo). if (outputModeForBuild === "static" && !adapterNameForBuild) { @@ -1076,9 +1298,9 @@ function materializeLintConfig( } /** - * Write the site's route truth to `/.nimbus/routes.json` from the - * `pages` array Astro hands us at `astro:build:done`. Each entry in `pages` - * is a real emitted URL — no reconstruction, no slug mirroring. + * Write the site's route truth to `/.nimbus/routes.json` from Astro's + * emitted pages plus the concrete inventory produced for request-rendered + * collections. * * Best-effort write, same as `materializeLintConfig`. When the file is * missing (e.g. lint ran before any `astro build`), `internal-link` skips @@ -1093,6 +1315,7 @@ function materializeRouteTruthFromPages( projectRoot: string, base: string, pages: readonly { pathname: string }[], + requestRoutes: readonly string[], logger: { warn: (msg: string) => void; debug?: (msg: string) => void }, ): void { // Normalize and dedupe pathnames into the canonical `/foo` form used by @@ -1104,14 +1327,16 @@ function materializeRouteTruthFromPages( for (const { pathname } of pages) { canonical.add(canonicalizePathname(pathname)); } + for (const pathname of requestRoutes) { + canonical.add(canonicalizePathname(pathname)); + } const truth: RouteTruth = { version: 1, base, knownRoutes: [...canonical].sort(), - // With `pages` as the truth, every emitted URL is in `knownRoutes` — - // there are no opaque namespaces. The field stays in the schema for - // forward-compat with future SSR-route handling. + // Nimbus collections remain enumerable even when their HTML is rendered + // on request, so broad opaque namespaces would only hide broken links. opaqueNamespaces: [], }; @@ -1130,6 +1355,72 @@ function materializeRouteTruthFromPages( } } +function isRequestRouteInventoryPath(pathname: string, base: string): boolean { + const canonical = canonicalizePathname(pathname); + const normalizedBase = canonicalizePathname(base); + const basedPattern = + normalizedBase === "/" + ? REQUEST_ROUTE_INVENTORY_PATTERN + : `${normalizedBase}${REQUEST_ROUTE_INVENTORY_PATTERN}`; + return ( + canonical === REQUEST_ROUTE_INVENTORY_PATTERN || canonical === basedPattern + ); +} + +function readRequestRouteInventory( + distDir: string, + base: string, + requestCollections: ReadonlySet, +): string[] { + const relativeInventoryPath = REQUEST_ROUTE_INVENTORY_PATTERN.slice(1); + const basePath = base.replace(/^\/+|\/+$/g, ""); + const candidates = [ + path.join(distDir, relativeInventoryPath), + ...(basePath ? [path.join(distDir, basePath, relativeInventoryPath)] : []), + ]; + const inventoryPath = candidates.find((candidate) => + fs.existsSync(candidate), + ); + if (!inventoryPath) { + throw new Error( + "nimbus-docs: request route inventory was not emitted; cannot materialize exact route truth.", + ); + } + + let inventory: unknown; + try { + inventory = JSON.parse(fs.readFileSync(inventoryPath, "utf8")); + } catch (err) { + throw new Error( + `nimbus-docs: request route inventory is invalid: ${(err as Error).message}`, + ); + } + if (!Array.isArray(inventory)) { + throw new Error("nimbus-docs: request route inventory must be an array."); + } + + const routes = new Set(); + for (const entry of inventory) { + if ( + typeof entry !== "object" || + entry === null || + typeof (entry as { collection?: unknown }).collection !== "string" || + typeof (entry as { url?: unknown }).url !== "string" + ) { + throw new Error( + "nimbus-docs: request route inventory contains an invalid entry.", + ); + } + const { collection, url } = entry as { collection: string; url: string }; + if (requestCollections.has(collection)) { + routes.add(canonicalizePathname(url)); + } + } + + fs.rmSync(inventoryPath, { force: true }); + return [...routes]; +} + /** Absolute paths of every local spec file backing `config.api`. */ function collectSpecFilePaths( api: NimbusConfig["api"], @@ -1155,7 +1446,10 @@ function collectLocalManifestPaths( ): Set { const paths = new Set(); for (const ref of apiReferences ?? []) { - if (typeof ref.manifest === "string" && !/^https:\/\//i.test(ref.manifest)) { + if ( + typeof ref.manifest === "string" && + !/^https:\/\//i.test(ref.manifest) + ) { paths.add(path.resolve(root, ref.manifest)); } } @@ -1239,7 +1533,10 @@ async function emitPlatformRedirects({ }): Promise { if (!shouldEmitRedirects(detectDeploySignals(projectRoot))) return; - const { redirects: normalized, skipped } = normalizeRedirects(redirects, base); + const { redirects: normalized, skipped } = normalizeRedirects( + redirects, + base, + ); if (skipped.length > 0) { logger.warn( `nimbus: ${skipped.length} dynamic redirect${skipped.length === 1 ? "" : "s"} ` + diff --git a/packages/nimbus-docs/src/runtime.ts b/packages/nimbus-docs/src/runtime.ts new file mode 100644 index 00000000..dbefc179 --- /dev/null +++ b/packages/nimbus-docs/src/runtime.ts @@ -0,0 +1,1936 @@ +/** + * Main entry for `nimbus-docs`. + * + * Exports the config helper, data helpers + * (sidebar, prev/next, breadcrumbs, TOC), and the page composition helpers + * (`getDocsStaticPaths`, `getDocsPageProps`). + * + * Helpers read the user's config from `virtual:nimbus/config` (provided + * by our Vite plugin) and content entries from `astro:content`. Both + * are external in tsdown and resolved at the consumer's build time. + */ + +import { + loadApiCollections, + loadIndexedCollections, + loadNimbusConfig, + loadVersionAlternates, +} from "./_internal/runtime-config.js"; +import { loadCollectionOrWarn } from "./_internal/load-collection.js"; +import { runtimeWarn } from "./_internal/runtime-warn.js"; +import { + getVisibleEntry, + getVisibleEntries, + getVisibleEntriesByCollection, + clearContentCaches, +} from "./_internal/content.js"; +import { + audienceCacheKey, + resolveAudience, + type ProjectionContext, +} from "./_internal/projection.js"; +import { + applyOverviewLeaf, + buildSidebarTree, + collectSidebarCollectionRefs, + cloneSidebarTree, + deriveSidebarSections, + deriveTransformCtx, + findActivePath, + isolateToBoundary, + markActiveState, + scopeToCurrentSection, + sidebarHash, +} from "./_internal/sidebar.js"; +import { entryRouteUrl } from "./_internal/astro-slug.js"; +import { toBrowserHref, withBase } from "./_internal/url.js"; +import { + PRIMARY_COLLECTION, + collectionLabel as resolveCollectionSlug, + collectionMountPrefix as resolveCollectionPrefix, +} from "./_internal/collection-mount.js"; +import { + renderEntryAsMarkdown, + type RenderEntryAsMarkdownOptions, +} from "./_internal/transform.js"; +import { buildCorpusMarkdown } from "./_internal/corpus.js"; +import { isDiscoverable } from "./_internal/discoverability.js"; +import { + assembleBreadcrumbs, + breadcrumbsFromUrl, + composeRouteBreadcrumbs, + getPrevNext as buildPrevNext, + type BreadcrumbOptions, +} from "./_internal/navigation.js"; +import { getHeadings } from "./_internal/toc.js"; +import type { PartialHeadingOptions } from "./_internal/partial-headings.js"; +import { + clearValidInternalLinksCache, + getValidInternalLinks, +} from "./_internal/valid-internal-links.js"; +import { + resolveApiPage, + resolveProsePage, + type PageResolution, + type PageResolutionContext, + type ProsePage, +} from "./_internal/page-resolution.js"; + +import type { + ApiVersionStatus, + Breadcrumb, + CoordinatesManifest, + PrevNext, + PrevNextOverrides, + ResolvedVersions, + SidebarItem, + SidebarSection, + SidebarTransform, + TOCItem, + VersionAlternateRecord, + VersionStatus, +} from "./types.js"; + +export type { + ApiVersionSpec, + ApiVersionStatus, + BadgeVariant, + Breadcrumb, + NimbusConfig, + PrevNext, + PrevNextLink, + PrevNextOverrides, + ResolvedVersions, + RenderingConfig, + RenderingMode, + SearchProvider, + SearchResult, + SidebarBadge, + SidebarConfig, + SidebarConfigItem, + SidebarExternalLinkItem, + SidebarGroupItem, + SidebarItem, + SidebarLinkItem, + SidebarSection, + SidebarTransform, + TOCItem, + VersionAlternateRecord, + VersionAlternatesTable, + VersionPageRef, + VersionStatus, + VersionsConfig, +} from "./types.js"; + +export type { PartialHeadingOptions } from "./_internal/partial-headings.js"; +export type { Heading } from "./_internal/partial-headings.js"; + +/** + * Collect headings from rendered HTML (see {@link getHeadingsFromHtml}). + * + * Use when a page's `Content` is rendered to a string so that runtime + * headings (e.g. those injected via `set:html`) reach the TOC. Feed the + * result to {@link getTOC}. + */ +export { getHeadingsFromHtml } from "./_internal/rendered-headings.js"; + +/** + * Define a typed Nimbus config. + */ +export { defineConfig } from "./config.js"; + +/** Deterministic short hash of the sidebar structure (for sessionStorage invalidation). */ +export { sidebarHash }; + +/** Prefix a site-root-relative URL with Astro's configured base path. */ +export { withBase }; + +/** The `noindex` visibility contract — filter custom index/corpus routes with this. */ +export { isDiscoverable }; + +/** Render an Astro content entry's raw MDX body as clean markdown. */ +export { renderEntryAsMarkdown }; + +/** + * `renderEntryAsMarkdown` with the coordinate-citation index loaded and + * passed through, so `api.ref:` citations resolve. Prerendered routes only. + */ +export async function getEntryMarkdown( + entry: Parameters[0], + options: Omit = {}, +): Promise { + const { loadCitationIndex } = + await import("./_internal/api/load-citation-index.js"); + return renderEntryAsMarkdown(entry, { + ...options, + citationIndex: await loadCitationIndex(), + }); +} + +/** + * This site's published coordinate manifest (local collections only), served at + * `/nimbus-api/coordinates.json`. Prerendered routes only. + */ +export async function getCoordinatesManifest(): Promise { + const { loadCoordinatesManifest } = + await import("./_internal/api/load-citation-index.js"); + return loadCoordinatesManifest(); +} + +/** + * The canonical Shiki transformer chain — diff / highlight / focus / + * error-level / word notations, meta highlight, plus the title-frame + + * language-badge transformer. Pre-wired into the markdown pipeline for + * fenced MDX blocks; re-export it so `Code.astro` can pass the same + * list to Astro's built-in `` component (which accepts + * `transformers` as a prop but doesn't auto-read `shikiConfig`). + */ +export { defaultCodeTransformers } from "./_internal/code-transformers.js"; + +/** + * Return visible entries across the user's configured `collections`. + * Drafts are filtered in production builds. Pass an explicit + * `collections` argument to scope the query to a subset. + * + * Literal collection names preserve their Astro `CollectionEntry` types; + * runtime-derived names return the union of registered collection entries. + */ +export { getVisibleEntry, getVisibleEntries }; + +// --------------------------------------------------------------------------- +// Agent-facing indexing +// --------------------------------------------------------------------------- + +export interface IndexedEntry { + /** + * The Astro CollectionEntry, widened to the union of every registered + * collection (`CollectionKey`). Using the bare `string` argument here + * resolves to `never` in a consumer's project — Astro's real + * `CollectionEntry` has no string index + * signature, so `CollectionEntry` collapses and `.id`/`.data` + * vanish. `CollectionKey` keeps the field usable across collections. + */ + entry: import("astro:content").CollectionEntry< + import("astro:content").CollectionKey + >; + /** Collection this entry belongs to (e.g. `"docs"`, `"blog"`). */ + collection: string; + /** Display title — schema field if present, otherwise the entry id. */ + title: string; + /** Description — undefined when the schema doesn't expose one or it's empty. */ + description: string | undefined; + /** + * Site-relative page URL (no origin), with a trailing slash on HTML + * document routes. The primary docs collection mounts at root; every + * other collection mounts under its name (`/blog/my-first-post/`). For + * `.md` alternates use `markdownUrl`, not this field — the root-index + * case (`/`) needs a different shape. + */ + url: string; + /** + * Site-relative URL of the page's clean-markdown alternate, e.g. + * `/getting-started/index.md` (or `/index.md` for the root entry). + * Consumers should use this directly rather than synthesizing from `url`. + */ + markdownUrl: string; + /** + * Site-relative URL of the page's raw-source alternate, e.g. + * `/getting-started/index.mdx`. Twin grammar: `index.md` is the + * downleveled render for reading, `index.mdx` is the authored source. + * `undefined` when the entry has no string body to serve (data-loader + * collections) — such entries get no `.mdx` route. + */ + sourceUrl: string | undefined; + /** + * Version label this entry belongs to, resolved through the site's + * `versions` manifest: `versions.current` for the primary `docs` + * collection, `` for a registered `docs-` collection. `undefined` + * when the site is unversioned or the collection is not a docs version + * (`blog`, `api`, …) — surfaces must emit nothing in that case, so + * unversioned sites stay byte-identical. + */ + version: string | undefined; +} + +export interface IndexedTopLevelGroup { + /** Top-level slug — first URL segment under root. */ + slug: string; + /** Display label (today: identical to `slug`; reserved for future sidebar-label integration). */ + label: string; + /** Entries inside this group, sorted alphabetically by url. */ + members: IndexedEntry[]; + /** + * What kind of group this is: + * - `"primary"` — a folder inside the primary `docs` collection. + * - `"secondary"` — a separate non-version collection (`blog`, `api`, …). + * - `"version"` — an older docs version (`docs-v1`, …). The root + * `/llms.txt` typically filters these out; per-section files + * include them so `/v1/llms.txt` still ships. + */ + kind: "primary" | "secondary" | "version"; + /** + * True for a version collection listed in `versions.hidden`. Hidden + * versions stay URL-reachable but are kept off indexing surfaces + * (root and per-section `llms.txt`); per-section routes should skip + * groups where `hidden === true`. + */ + hidden: boolean; +} + +export interface IndexedTopLevel { + /** + * Single-entry top-level items — the root `/llms.txt` links directly + * to each leaf's `.md` alternate. Sorted alphabetically by `url`. + */ + leaves: IndexedEntry[]; + /** + * Multi-entry top-level items — each becomes a section file at + * `//llms.txt`. Sorted alphabetically by `slug`. + */ + groups: IndexedTopLevelGroup[]; +} + +/** + * Cross-collection entry list backing the agent-facing routes + * (`llms.txt`, per-page `.md` alternates, `llms-full.txt`) and internal + * link validation. Implements the indexing baseline of the two-layer + * architecture documented at `/features/llms-txt`: + * + * - **Multi-collection by default, zero opt-in.** Iterates every + * collection registered in `src/content.config.ts` except names + * matching `partials` or starting with `_` (reserved). + * - **Schema-tolerant.** Reads `title` and `description` if present; + * falls back to the entry id for the title and omits the + * description otherwise. + * - **Drops `draft: true` only.** Keeps `noindex: true` pages — they + * stay valid link targets, version landing pages, and navigable. + * Discovery surfaces filter them via `isDiscoverable`, not here. + * + * The returned shape is identical regardless of which factory created + * the collection: hand-rolled `defineCollection({ loader, schema })` + * collections work without modification. + */ +// Cached across pages (dev too); cleared on content change. Partitioned by +// audience key so a per-identity projection can't poison the public cache. +const indexedEntriesCache = new Map(); + +export async function getIndexedEntries( + ctx?: ProjectionContext, +): Promise { + const audience = resolveAudience(ctx); + const cacheKey = audienceCacheKey(audience); + const cached = indexedEntriesCache.get(cacheKey); + if (cached) return cached; + const { getCollection } = await import("astro:content"); + const collectionNames = await loadIndexedCollections(); + // Fall back to the primary collection name if the build-time parse + // came up empty. Belt-and-braces: the integration also defaults to + // ["docs"] when content.config.ts is missing. + const names = + collectionNames.length > 0 ? collectionNames : [PRIMARY_COLLECTION]; + const versions = await getVersions(); + + const indexed: IndexedEntry[] = []; + for (const name of names) { + // Surfaces a failed registered collection instead of silently dropping it. + const { entries, warning } = await loadCollectionOrWarn< + import("astro:content").CollectionEntry + >(name, (n) => getCollection(n as any)); + if (warning) runtimeWarn(warning); + const prefix = resolveCollectionPrefix(name, versions); + const collectionVersion = await getCurrentVersion(name); + for (const entry of entries) { + const data = (entry.data ?? {}) as Record; + if (data.draft === true) continue; + + // A versioned API family stamps a per-entry `data.version`; prefer it over + // the docs-axis `getCurrentVersion` (which is null for API collections). + const entryVersion = + typeof data.version === "string" + ? data.version + : (collectionVersion ?? undefined); + + const title = + typeof data.title === "string" && data.title.length > 0 + ? data.title + : entry.id; + const rawDescription = data.description; + const description = + typeof rawDescription === "string" && rawDescription.length > 0 + ? rawDescription + : undefined; + + // `entry.id` is the final store id, which `getDocsStaticPaths` routes + // on verbatim, so use `entryRouteUrl` (no re-slug — see astro-slug.ts). + // `toBrowserHref` adds the trailing slash so `url` consumers can emit + // the value straight into `` without a redirect. + const canonicalUrl = entryRouteUrl(prefix, entry.id); + // The `.md` alternate lives at `/index.md`. For the root index + // of a collection (canonical URL is the bare prefix or `/`), append + // directly rather than re-derive from the trailing-slash form — the + // strip-trailing-slash recipe collapses `/` to `""` and produces the + // wrong path. + const markdownUrl = + canonicalUrl === "/" ? "/index.md" : `${canonicalUrl}/index.md`; + // The raw-source twin exists only for entries with a string body — + // data-loader collections without one get no `.mdx` alternate. + const sourceUrl = + typeof entry.body === "string" && entry.body.length > 0 + ? canonicalUrl === "/" + ? "/index.mdx" + : `${canonicalUrl}/index.mdx` + : undefined; + indexed.push({ + entry, + collection: name, + title, + description, + url: toBrowserHref(canonicalUrl), + markdownUrl, + sourceUrl, + version: entryVersion, + }); + } + } + indexedEntriesCache.set(cacheKey, indexed); + return indexed; +} + +/** + * Partition the indexed entries into the shape the root `/llms.txt` + * and `/[section]/llms.txt` routes need. + * + * Convention: + * - Primary `"docs"` entries follow the leaf/group rule based on + * their `entry.id` top segment (matches single-collection behavior). + * - Every other collection becomes a single top-level group named + * after the collection, regardless of how many entries it has. + * This matches the URL convention (`/api/...`, `/blog/...`). + */ +export async function getIndexedTopLevel(): Promise { + const items = (await getIndexedEntries()).filter((item) => + isDiscoverable(item.entry), + ); + const versions = await getVersions(); + + // Build two buckets keyed by their URL-facing slug: + // - primary: top-level slug under the `docs` collection + // - secondary: every other collection (versions tagged separately + // below so consumers can filter the root listing) + const primaryBuckets = new Map(); + const secondaryBuckets = new Map(); + const versionSlugs = new Set(versions?.others ?? []); + const hiddenSlugs = new Set(versions?.hidden ?? []); + + for (const item of items) { + if (item.collection === PRIMARY_COLLECTION) { + const top = item.entry.id.split("/")[0]!; + const bucket = primaryBuckets.get(top); + if (bucket) bucket.push(item); + else primaryBuckets.set(top, [item]); + } else { + // Bucket secondary collections by their URL-facing slug (version + // slug for `docs-` collections, raw collection ID otherwise) so + // the emitted group label and URL prefix match the route shape. + const slug = resolveCollectionSlug(item.collection, versions); + const bucket = secondaryBuckets.get(slug); + if (bucket) bucket.push(item); + else secondaryBuckets.set(slug, [item]); + } + } + + const leaves: IndexedEntry[] = []; + const groups: IndexedTopLevelGroup[] = []; + + for (const [slug, members] of primaryBuckets) { + const isLeaf = members.length === 1 && members[0]!.entry.id === slug; + if (isLeaf) { + leaves.push(members[0]!); + } else { + groups.push({ + slug, + label: slug, + members, + kind: "primary", + hidden: false, + }); + } + } + for (const [slug, members] of secondaryBuckets) { + const kind: "version" | "secondary" = versionSlugs.has(slug) + ? "version" + : "secondary"; + groups.push({ + slug, + label: slug, + members, + kind, + hidden: hiddenSlugs.has(slug), + }); + } + + leaves.sort((a, b) => a.url.localeCompare(b.url)); + groups.sort((a, b) => a.slug.localeCompare(b.slug)); + for (const g of groups) { + g.members.sort((a, b) => a.url.localeCompare(b.url)); + } + + return { leaves, groups }; +} + +/** + * Render one indexed entry to clean Markdown, dispatching by collection. + * Prose entries render their MDX body via `renderEntryAsMarkdown`. OpenAPI + * reference entries carry no body, so their frozen view-model is projected and + * emitted through the `./api` seam — dynamic-imported so the engine and its + * parser stay out of the main bundle for prose-only sites. Both the corpus and + * the served `.md` twin route go through here, so the two never drift. + */ +export async function renderIndexedEntryMarkdown( + item: IndexedEntry, +): Promise { + const apiCollections = await loadApiCollections(); + if (!apiCollections.includes(item.collection)) { + const { loadCitationIndex } = + await import("./_internal/api/load-citation-index.js"); + return renderEntryAsMarkdown(item.entry, { + citationIndex: await loadCitationIndex(), + }); + } + const { renderApiPageMarkdown } = await import("./_internal/api/markdown.js"); + const { isPreparedApiPage } = await import("./_internal/api/prepared.js"); + const apiData = item.entry.data as { + coordinate?: string; + prepared?: unknown; + }; + const coordinate = apiData.coordinate; + if (typeof coordinate !== "string") { + throw new Error( + `nimbus-docs: API entry "${item.entry.id}" in collection "${item.collection}" ` + + `is missing its coordinate — the apiCollection() loader should have set it.`, + ); + } + if (!isPreparedApiPage(apiData.prepared)) { + throw new Error( + `nimbus-docs: API entry "${item.entry.id}" in collection "${item.collection}" ` + + "is missing its prepared page data — rebuild the apiCollection() index.", + ); + } + return renderApiPageMarkdown(apiData.prepared.page); +} + +/** + * Render the full published corpus as one markdown document — the body of + * the `llms-full.txt` route. One fetch hands an agent (or a RAG ingestion + * job) every page as clean markdown, no crawling. + * + * Scope matches the root `llms.txt`: the primary `docs` collection plus + * every secondary collection, **excluding** non-current version collections + * (`docs-`) — old versions keep their own per-version surfaces and never + * multiply this document — and **excluding** `noindex: true` pages (see + * {@link isDiscoverable}), which stay addressable but off discovery surfaces. + * + * Contract (see `buildCorpusMarkdown` for the collation rules): + * - Entries are sorted by `url`; output is deterministic across rebuilds. + * - Each entry is a `#`-level block (bodies render at `##` and below). + * - The document header cross-references `/llms.txt`. + * + * The starter route stays policy-free and ~10 lines; a site that wants a + * different corpus (per-version, filtered, chunked) reshapes its own route + * on top of `getIndexedEntries()` + `renderEntryAsMarkdown()`. Pass Astro's + * `import.meta.env.BASE_URL` as `base` when the site supports sub-path deploys. + */ +export async function renderCorpusMarkdown(options?: { + base?: string; +}): Promise { + const config = await loadNimbusConfig(); + const versions = await getVersions(); + const entries = await getIndexedEntries(); + + // Exclude non-current version collections — same predicate the root + // `llms.txt` applies via its `kind === "version"` skip (hidden versions + // are a subset of `others`, so this covers them too). + const versionSlugs = new Set(versions?.others ?? []); + const included = entries.filter( + (item) => + isDiscoverable(item.entry) && + (item.collection === PRIMARY_COLLECTION || + !versionSlugs.has(resolveCollectionSlug(item.collection, versions))), + ); + + const blocks = await Promise.all( + included.map(async (item) => ({ + title: item.title, + description: item.description, + url: item.url, + markdownUrl: item.markdownUrl, + markdown: await renderIndexedEntryMarkdown(item), + })), + ); + + return buildCorpusMarkdown(blocks, { + title: config.title, + description: config.description, + site: config.site, + base: options?.base, + }); +} + +// --------------------------------------------------------------------------- +// Data helpers +// --------------------------------------------------------------------------- + +/** + * Build the sidebar tree for the given current path, scoped to the + * top-level section containing that page. + * + * Reads `sidebar` from the user's nimbus.config. If `sidebar.items` is set, + * resolves config-driven sidebar. Otherwise auto-generates from filesystem + * (i.e. the `docs` collection's entry IDs). + * + * Returned shape depends on `sidebar.scope` in `nimbus.config.ts`: + * - `"full"` (default) — every top-level item on every page. + * - `"section"` — only the current top-level section's children. Use + * the header section-tab strip (via `getSidebarSections`) for + * cross-section nav when this mode is on. + * + * **Versioning awareness.** When the page is in a version collection + * (`docs-` where `` is in `versions.others`), pass `collection` as + * the second argument. The sidebar build will swap any + * `{ autogenerate: { collection: "docs" } }` items to autogenerate from + * that version's collection instead, and treat it as the primary for + * the build. Without this, version pages render the current-version + * sidebar and prev/next derives from the wrong tree. + * + * @param currentSlug - The current page's URL path (e.g. "/getting-started"). + * Used to set `isCurrent` on matching links and to pick + * which top-level section to surface when scoping. + * @param options.collection - The current page's Astro collection ID. + * Pass `entry.collection` from your route. + */ +export async function getSidebar( + currentSlug: string, + options?: { collection?: string; transform?: SidebarTransform }, +): Promise { + const config = await loadNimbusConfig(); + const structural = await buildStructuralTree(options?.collection); + + // 1. Scope + materialize. + let tree: SidebarItem[]; + if (config.sidebar?.scope === "section") { + tree = scopeToCurrentSection(structural, currentSlug); + } else { + tree = cloneSidebarTree(structural); + markActiveState(tree, currentSlug); + } + + // 2. Isolate further to a boundary sub-tree (if configured). Runs after + // scope, over the already-materialized (mutable) tree. + const boundaries = config.sidebar?.isolate?.boundaries; + if (boundaries && boundaries.length > 0) { + tree = isolateToBoundary(tree, currentSlug, boundaries); + } + + // 3. Consumer transform (call-site). Ctx is derived read-only from the + // frozen structural tree. + if (options?.transform) { + const ctx = deriveTransformCtx(structural, currentSlug); + tree = await options.transform({ tree, currentSlug, ...ctx }); + } + + // 4. Overview-leaf display mode (opt-in) — runs last so it sees the + // transform's output (e.g. badges keyed off `indexHref`) and only + // reshapes this returned tree, never the cached structural one. + if (config.sidebar?.indexDisplay === "overview-leaf") { + const label = + typeof config.sidebar.overviewLabel === "string" + ? config.sidebar.overviewLabel + : "Overview"; + const sectionSlug = currentSlug.split("/").filter(Boolean)[0] ?? ""; + tree = applyOverviewLeaf(tree, sectionSlug, label); + } + + return tree; +} + +/** + * Derive one section per top-level group in the sidebar — used by + * `Header.astro` to render the section tab strip (and by any other + * cross-section navigation). + * + * Reads the un-scoped tree so every section is visible, then collapses + * each top-level group to `{ label, href, isActive }`. + * + * Accepts the same `collection` option as `getSidebar` so version pages + * see version-scoped section tabs. + */ +export async function getSidebarSections( + currentSlug: string, + options?: { collection?: string }, +): Promise { + // Read-only over the frozen structural tree — no per-page clone. Active + // state is computed from `currentSlug` inside `deriveSidebarSections`. + const tree = await buildStructuralTree(options?.collection); + return deriveSidebarSections(tree, currentSlug); +} + +// A path that matches no real href, so the cached tree is built with every +// active flag inert; flags are stamped per page by `markActiveState`. +const NO_ACTIVE_PATH = "\u0000__nimbus_structural__"; + +// Structural tree cached per effective-primary (the only input that changes +// the tree's shape). Cached in dev too — rebuilding the full nav per request +// makes dev unusably slow on large trees; the dev server clears it on content +// change via `clearNavCaches`. +const structuralTreeCache = new Map(); + +/** Drop all nav caches (dev content-change invalidation). */ +export function clearNavCaches(): void { + structuralTreeCache.clear(); + indexedEntriesCache.clear(); + clearValidInternalLinksCache(); + clearContentCaches(); +} + +function deepFreeze(items: readonly SidebarItem[]): void { + for (const item of items) { + if (item.type === "group") deepFreeze(item.children); + Object.freeze(item); + } + Object.freeze(items); +} + +/** + * Build the un-scoped, un-marked sidebar tree, cached per effective-primary + * collection. Callers needing active-state clone it and run `markActiveState` + * (never mutate the cache). + * + * When `pageCollection` is a registered version collection (`docs-`), that + * collection becomes the primary: autogen items referencing `docs` are + * rewritten to it and `primaryPrefix` is set, so version pages get the right + * tree and prev/next ordering. + */ +async function buildStructuralTree( + pageCollection?: string, +): Promise { + const runtimeConfig = await loadNimbusConfig(); + const versions = await getVersions(); + + // Resolve the effective "primary" collection for THIS sidebar build. + // For pages in a non-current version collection, the primary IS that + // collection (the sidebar should walk docs-v0, not docs). + let effectivePrimary = PRIMARY_COLLECTION; + let primaryPrefix = ""; + if ( + versions && + pageCollection && + pageCollection.startsWith("docs-") && + versions.others.includes(pageCollection.slice("docs-".length)) + ) { + effectivePrimary = pageCollection; + primaryPrefix = resolveCollectionPrefix(pageCollection, versions); + } + + const cached = structuralTreeCache.get(effectivePrimary); + if (cached) return cached; + + // Rewrite sidebar items so `{ autogenerate: { collection: "docs" } }` + // becomes `{ autogenerate: { collection: "docs-v0" } }` on v0 pages. + // Items that name a different collection (api, blog) are untouched — + // they keep their global scope. + // Cast at the boundary: `runtimeConfig.sidebar?.items` is `unknown[] | undefined` + // because runtimeConfig is loaded through a virtual module whose data is + // already Zod-validated at integration setup (`validateNimbusConfig`). + // The cast restores the shape downstream functions expect. + const rewrittenItems = ( + effectivePrimary !== PRIMARY_COLLECTION + ? rewriteSidebarItemsForVersion( + runtimeConfig.sidebar?.items, + effectivePrimary, + ) + : runtimeConfig.sidebar?.items + ) as Parameters[0]; + + const referenced = collectSidebarCollectionRefs(rewrittenItems); + const collections = [ + effectivePrimary, + ...referenced.filter((c) => c !== effectivePrimary), + ]; + const entriesByCollection = await getVisibleEntriesByCollection(collections); + const tree = buildSidebarTree( + // Cast: `astro:content` `CollectionEntry` has `data: Record` + // in our stub; sidebar.ts's local `CollectionEntry` shapes `data` with `title` + // required. Runtime entries always carry `title` (schema-enforced); the cast + // documents that guarantee. `unknown` bridge is required because the two + // CollectionEntry shapes don't structurally overlap on the `data` field. + entriesByCollection as unknown as Parameters[0], + effectivePrimary, + NO_ACTIVE_PATH, + runtimeConfig.sidebar + ? { ...runtimeConfig.sidebar, items: rewrittenItems } + : undefined, + primaryPrefix, + ); + + // Frozen because it's shared across pages and its nodes reach user + // `resolveLabel` via `getBreadcrumbs`; consumers clone before stamping. + deepFreeze(tree); + structuralTreeCache.set(effectivePrimary, tree); + return tree; +} + +/** + * Substitute the primary collection (`docs`) for `effectivePrimary` + * inside any sidebar item that autogenerates from a named collection. + * Used by `buildStructuralTree` to make version pages render their + * own collection's sidebar instead of the current version's. + */ +function rewriteSidebarItemsForVersion( + items: unknown[] | undefined, + effectivePrimary: string, +): unknown[] | undefined { + if (!items) return items; + return items.map((item) => { + if (!item || typeof item !== "object") return item; + const o = item as Record; + const autogen = o.autogenerate as + { collection?: string; directory?: string } | undefined; + if (autogen && autogen.collection === PRIMARY_COLLECTION) { + return { + ...o, + autogenerate: { ...autogen, collection: effectivePrimary }, + }; + } + // Nested groups recurse so per-group autogen items rewrite too. + if (Array.isArray(o.items)) { + return { + ...o, + items: rewriteSidebarItemsForVersion(o.items, effectivePrimary), + }; + } + return item; + }); +} + +/** + * Resolve prev/next links for the current page. + * + * Walks the flattened sidebar; returns the surrounding entries. Honors + * `prev`/`next` frontmatter overrides if provided. + * + * When an override uses the object form with an internal `link` + * (e.g. `prev: { link: "/getting-started" }`), the link is validated + * against every visible content entry's URL at build time. A pointer + * to a missing page fails the build with a clear error — the same + * staleness-detection mechanism used for `previousSlug` in versioning. + * The string form (`prev: "Custom label"`) is a label-only override + * and doesn't go through link validation. + */ +export async function getPrevNext( + currentSlug: string, + options?: { + overrides?: PrevNextOverrides; + sidebarTree?: SidebarItem[]; + }, +): Promise { + const tree = options?.sidebarTree ?? (await getSidebar(currentSlug)); + // Build the set of valid internal route keys (slashless) from indexed + // entries so object-form `prev: { link: "/x" }` overrides fail loudly + // when the target doesn't exist. The set holds route keys, not browser + // hrefs, so a `/cli`, `/cli/`, or `/cli/?ref=x` override all resolve + // to the same canonical entry. Cheap: indexed entries are cached per + // build. + const indexed = await getIndexedEntries(); + const validInternalLinks = getValidInternalLinks(indexed); + return buildPrevNext( + currentSlug, + tree, + options?.overrides, + validInternalLinks, + ); +} + +/** + * Build the breadcrumb trail from the active node's ancestry in the nav + * tree. Labels come from nav nodes, hrefs from each node's landing — so a + * section crumb links to its real landing page and segments with no node + * never appear. Index-less folders render as non-interactive crumbs. + * + * - `collection` — the page's Astro collection; pass `entry.collection` so + * versioned pages get version-prefixed hrefs. + * - `root` — the leading crumb (default `{ label: "Home", href: "/" }`). + * - `resolveLabel` — override a crumb label, or return `null` to drop it. + * + * Falls back to URL-segment derivation when the page has no node in the + * tree, so a stray page still gets a root-anchored trail. + */ +export async function getBreadcrumbs( + currentSlug: string, + options?: { collection?: string } & BreadcrumbOptions, +): Promise { + // `findActivePath` matches by href, so the un-marked tree suffices (no clone). + const tree = await buildStructuralTree(options?.collection); + const path = findActivePath(tree, currentSlug); + + if (path.length > 0) { + const root = options?.root ?? { label: "Home", href: "/" }; + const labels = await Promise.all( + path.map((node) => + Promise.resolve(options?.resolveLabel?.({ node, slug: currentSlug })), + ), + ); + return assembleBreadcrumbs(root, path, labels); + } + + return breadcrumbsFromUrl(currentSlug, options?.root?.label ?? "Home"); +} + +/** Resolves a section's display titles. May be async. */ +export type SectionTitleResolver = (ctx: { + sectionSlug: string; + module?: string; + indexEntryId?: string; +}) => SectionTitle | undefined | Promise; + +/** A section's rail and breadcrumb titles, which may differ. */ +export interface SectionTitle { + rail?: string; + breadcrumb?: string; +} + +/** + * Resolve a section's display title(s) for the current page, decoupled so + * the rail header and the breadcrumb can differ. + * + * Derives `sectionSlug` (seg0) and `module` (seg1) from the slug and passes + * them to a caller-supplied resolver. The resolver is an argument rather + * than config because config is JSON-serialized and cannot carry functions. + * `indexEntryId` is currently always `undefined`. + */ +export async function getSectionTitle( + currentSlug: string, + resolve: SectionTitleResolver, +): Promise { + const segs = currentSlug.split("/").filter(Boolean); + const sectionSlug = segs[0]; + if (!sectionSlug) return undefined; + return resolve({ sectionSlug, module: segs[1], indexEntryId: undefined }); +} + +export interface RouteNavigationOptions { + /** The current route's pathname. */ + path: string; + /** A real nav node URL to mark active and end the ancestry trail at. */ + section: string; + /** Crumbs appended after the section trail; a leaf with no href is current. */ + trail?: Breadcrumb[]; + /** When `false` (default), prev/next is omitted. */ + prevNext?: boolean; + /** The page's collection, for version-prefixed hrefs. */ + collection?: string; + /** Forwarded to the internal breadcrumb build. */ + resolveLabel?: BreadcrumbOptions["resolveLabel"]; +} + +export interface RouteNavigation { + breadcrumbs: Breadcrumb[]; + sidebar: SidebarItem[]; + /** The href marked active in the sidebar (the `section`). */ + activeHref: string; + prevNext?: PrevNext; +} + +/** + * Navigation (breadcrumbs, sidebar active-state, optional prev/next) for a + * data-driven route with no content entry of its own — e.g. a catalog page + * under `src/pages/[...].astro`. + * + * Builds the breadcrumb trail to `section` (a real nav node) and appends + * `trail` (the leaf). The sidebar is built with `section` as the active + * path, so the section node highlights even though the leaf is not in the + * tree — the leaf is never injected, keeping the tree and prev/next clean. + */ +export async function getRouteNavigation( + options: RouteNavigationOptions, +): Promise { + const { + section, + trail = [], + prevNext = false, + collection, + resolveLabel, + } = options; + + const sidebar = await getSidebar(section, { collection }); + const sectionCrumbs = await getBreadcrumbs(section, { + collection, + resolveLabel, + }); + const breadcrumbs = composeRouteBreadcrumbs(sectionCrumbs, trail); + + let pn: PrevNext | undefined; + if (prevNext) { + pn = await getPrevNext(section, { sidebarTree: sidebar }); + } + + return { breadcrumbs, sidebar, activeHref: section, prevNext: pn }; +} + +/** + * Build an edit URL for a content entry using `config.editPattern`. + * + * `{path}` is replaced with the entry's source path when Astro provides it, + * falling back to the default docs collection path convention. + */ +export async function getEditUrl(entry: { + id: string; + filePath?: string; +}): Promise { + const runtimeConfig = await loadNimbusConfig(); + if (!runtimeConfig.editPattern) return undefined; + + const path = entry.filePath ?? `src/content/docs/${entry.id}.mdx`; + return runtimeConfig.editPattern.replace("{path}", path); +} + +/** + * Resolve a content entry's `lastUpdated` date from `git log`. + * + * Reads the author date (`%aI`) of the most recent commit that touched + * the entry's source file. Author date is stable across rebases — the + * value reflects when the content was actually changed, not when the + * commit happened to land in this branch. + * + * Returns `undefined` when git can't answer (no `.git`, shallow clone, + * file untracked, command not on PATH, etc.) so the caller can chain a + * fallback: + * + * const lastUpdated = entry.data.lastUpdated ?? await getLastUpdated(entry); + * + * Frontmatter always wins. Per-process cached so repeated calls for + * the same entry don't re-spawn `git`. + * + * Production note: most CI/CD systems do shallow clones by default + * (Vercel, Cloudflare Pages, GitHub Actions checkout@v4) — set + * `fetch-depth: 0` to make full history available, otherwise git + * returns nothing and the helper falls back to frontmatter or nothing. + */ +export async function getLastUpdated(entry: { + id: string; + filePath?: string; +}): Promise { + const path = entry.filePath ?? `src/content/docs/${entry.id}.mdx`; + const { getLastUpdatedFromGit } = + await import("./_internal/git-last-updated.js"); + return getLastUpdatedFromGit(path); +} + +/** + * Filter heading list to the configured min/max heading levels. + * + * @param headings - Raw `headings` from Astro's `render(entry)` return value. + * @param options - Override min/max heading levels. Defaults: min=2, max=3. + */ +export function getTOC( + headings: { depth: number; text: string; slug: string }[], + options?: { minHeadingLevel?: number; maxHeadingLevel?: number }, +): TOCItem[] { + return getHeadings(headings, options); +} + +// --------------------------------------------------------------------------- +// Page composition helpers +// --------------------------------------------------------------------------- + +import type { AstroGlobal, GetStaticPaths } from "astro"; + +function pageResolutionContext(astro: AstroGlobal): PageResolutionContext { + const audience = ( + astro.locals as { + nimbus?: { audience?: NonNullable }; + } + ).nimbus?.audience; + return { + props: astro.props as Record, + params: astro.params, + url: astro.url, + projection: audience ? { audience } : undefined, + }; +} + +async function resolveAstroProsePage( + astro: AstroGlobal, + collection: string | undefined, + partialHeadings: PartialHeadingOptions | undefined, + mergePartialHeadings: ( + body: string | undefined, + headings: { depth: number; text: string; slug: string }[], + getEntry: (collection: string, id: string) => Promise, + render: (entry: unknown) => Promise<{ + headings: { depth: number; text: string; slug: string }[]; + }>, + options?: PartialHeadingOptions, + ) => Promise<{ depth: number; text: string; slug: string }[]>, +): Promise> { + const context = pageResolutionContext(astro); + const result = await resolveProsePage( + context, + { collection }, + { + getVisibleEntry: getVisibleEntry as ( + collection: string, + id: string, + ctx?: ProjectionContext, + ) => Promise | null>, + getVersions, + async render(entry) { + const { render } = await import("astro:content"); + let rendered: Awaited>; + try { + rendered = await render(entry); + } catch (error) { + throw new Error( + `nimbus-docs: failed to render prose entry "${entry.collection}:${entry.id}".`, + { cause: error }, + ); + } + const { Content, headings } = rendered; + let merged: typeof headings; + try { + merged = await mergePartialHeadings( + entry.body, + headings, + (partialCollection: string, id: string) => + getVisibleEntry(partialCollection, id, context.projection), + render as ( + entry: unknown, + ) => Promise<{ headings: typeof headings }>, + partialHeadings, + ); + } catch (error) { + throw new Error( + `nimbus-docs: failed to merge partial headings for "${entry.collection}:${entry.id}".`, + { cause: error }, + ); + } + return { Content, headings: merged }; + }, + }, + ); + return result; +} + +type ProsePageProps = { + entry: import("astro:content").CollectionEntry; + Content: import("astro/runtime/server/index.js").AstroComponentFactory; + headings: { depth: number; text: string; slug: string }[]; +}; + +function proseResolutionResponse( + astro: AstroGlobal, + result: Exclude, { status: "found" }>, +): Response { + if (result.status === "redirect") { + return astro.redirect(result.location, result.permanent ? 308 : 307); + } + return new Response(null, { + status: 404, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +} + +async function resolveProseRoute( + astro: AstroGlobal, + collection: string | undefined, + partialHeadings: PartialHeadingOptions | undefined, + missingPropsMessage: string, + loadPartialHeadingMerger: () => Promise< + Parameters[3] + >, +): Promise | Response> { + const entry = ( + astro.props as { + entry?: import("astro:content").CollectionEntry; + } + ).entry; + if (!entry && astro.isPrerendered !== false) { + throw new Error(missingPropsMessage); + } + const mergePartialHeadings = await loadPartialHeadingMerger(); + const result = await resolveAstroProsePage( + astro, + collection, + partialHeadings, + mergePartialHeadings, + ); + if (result.status !== "found") return proseResolutionResponse(astro, result); + return { + entry: result.page.entry as import("astro:content").CollectionEntry, + Content: result.page.Content, + headings: result.page.headings, + }; +} + +/** + * `getStaticPaths` implementation for a docs catch-all route. + * + * Returns one path per visible entry in the `docs` collection. Drafts are + * filtered in production. Each path passes `{ entry }` as props so the + * page component can access it via `getDocsPageProps(Astro)`. + * + * A `cacheKey` derived from the entry's `digest` is included on each path + * so Astro's experimental incremental build cache can skip re-rendering + * unchanged pages. This is a no-op when `experimental.incrementalBuild` is + * not enabled in `astro.config.ts`. + * + * Usage: + * + * // src/pages/[...slug].astro + * export const prerender = true; + * export const getStaticPaths = getDocsStaticPaths; + * + * The entry's `id` is used verbatim as the slug. So `docs/index.mdx` → + * `/index`, `docs/guides/setup.mdx` → `/guides/setup`. If you want a docs + * entry at the root URL, name it appropriately and decide whether to use + * a static `pages/index.astro` or let the catch-all handle root. + */ +export const getDocsStaticPaths: GetStaticPaths = async () => { + // Docs-specific helper: always reads the `docs` collection. Other + // collections require their own `pages//[...slug].astro` with + // a one-line `getCollection("")`-based getStaticPaths. + const entries = await getVisibleEntries(["docs"]); + return entries.map((entry) => ({ + params: { slug: entry.id }, + props: { entry }, + cacheKey: String(entry.digest), + })); +}; + +/** + * Read the current entry from `Astro.props`, render it, and return the + * pieces a docs page needs: the typed entry, the renderable `` + * component, and the headings list (for TOC generation). + * + * Headings from `` partials are recursively merged + * into the returned list in document order. Pass `partialHeadings: + * { resolvePartialId }` to customise how `` attributes map to + * a partial collection id (e.g. cloudflare-docs' `product` convention). + * + * Pass the page's `Astro` global. Throws if `Astro.props.entry` is missing, + * which indicates the page didn't wire `getDocsStaticPaths` (or a custom + * equivalent) correctly. + * + * Usage: + * + * const { entry, Content, headings } = await getDocsPageProps(Astro); + * + * With a custom partial-id resolver: + * + * const { entry, Content, headings } = await getDocsPageProps(Astro, { + * partialHeadings: { + * resolvePartialId: ({ file, product }) => + * product ? `${product}/${file}` : file, + * }, + * }); + */ +export async function getDocsPageProps( + astro: AstroGlobal, + options?: { partialHeadings?: PartialHeadingOptions }, +): Promise<{ + entry: import("astro:content").CollectionEntry<"docs">; + Content: import("astro/runtime/server/index.js").AstroComponentFactory; + headings: { depth: number; text: string; slug: string }[]; +}> { + const staticEntry = (astro.props as { entry?: unknown }).entry; + const page = await resolveProseRoute<"docs">( + astro, + PRIMARY_COLLECTION, + options?.partialHeadings, + "getDocsPageProps(): expected `entry` in Astro.props. " + + "Ensure your route uses `getStaticPaths = getDocsStaticPaths` " + + "(or passes an entry via custom getStaticPaths).", + staticEntry + ? async () => + (await import("./_internal/partial-headings.js")).mergePartialHeadings + : async () => + (await import("./_internal/worker-partial-headings.js")) + .mergeWorkerPartialHeadings, + ); + if (page instanceof Response) { + throw new Error( + `getDocsPageProps(): could not resolve request path "${astro.url.pathname}". ` + + "Use getDocsPage(Astro) in request-rendered routes to preserve the 404 response.", + ); + } + return page; +} + +export function getDocsPage( + astro: AstroGlobal, + options?: { partialHeadings?: PartialHeadingOptions }, +): Promise | Response> { + const staticEntry = (astro.props as { entry?: unknown }).entry; + return resolveProseRoute( + astro, + PRIMARY_COLLECTION, + options?.partialHeadings, + "getDocsPageProps(): expected `entry` in Astro.props. " + + "Ensure your route uses `getStaticPaths = getDocsStaticPaths` " + + "(or passes an entry via custom getStaticPaths).", + staticEntry + ? async () => + (await import("./_internal/partial-headings.js")).mergePartialHeadings + : async () => + (await import("./_internal/worker-partial-headings.js")) + .mergeWorkerPartialHeadings, + ); +} + +/** + * Resolve a docs route's layout flags: merge the site-wide feature toggles with + * per-page frontmatter into the single source of truth for whether a page gets + * a sidebar / TOC column, so layouts stay presentational. + */ +export async function getRouteFlags(entry: { + data: { mode?: string; sidebar?: unknown; tableOfContents?: unknown }; +}): Promise<{ sidebar: boolean; tableOfContents: boolean }> { + const config = await loadNimbusConfig(); + const isCustom = entry.data.mode === "custom"; + return { + sidebar: + !isCustom && + config.features?.sidebar !== false && + entry.data.sidebar !== false, + tableOfContents: + !isCustom && + config.features?.tableOfContents !== false && + entry.data.tableOfContents !== false, + }; +} + +/** + * `getStaticPaths` implementation for a catch-all route over a non-primary + * collection (`api`, `blog`, …). Companion to `getDocsStaticPaths`. + * + * Returns one path per visible entry in the named collection. Drafts are + * filtered in production (same rule as `getDocsStaticPaths`). Each path + * passes `{ entry }` as props for `getCollectionPageProps()`. + * + * A `cacheKey` derived from the entry's `digest` is included on each path + * so Astro's experimental incremental build cache can skip re-rendering + * unchanged pages. This is a no-op when `experimental.incrementalBuild` is + * not enabled in `astro.config.ts`. + * + * Usage: + * + * // src/pages/api/[...slug].astro + * export const prerender = true; + * export const getStaticPaths = getCollectionStaticPaths("api"); + * + * Why a sibling helper instead of an option on `getDocsStaticPaths`: the + * `Docs` name carries the "primary collection mounted at root" semantic. + * Non-primary collections mount under their own URL namespace + * (`//...`) by convention; the helper name reflects that. + */ +export function getCollectionStaticPaths(collection: string): GetStaticPaths { + return async () => { + const entries = await getVisibleEntries([collection]); + return entries.map((entry) => ({ + params: { slug: entry.id }, + props: { entry }, + cacheKey: String(entry.digest), + })); + }; +} + +/** + * Read the current entry from `Astro.props`, render it, and return the + * pieces a docs-style page needs — typed for an arbitrary collection. + * + * Companion to `getCollectionStaticPaths`. Use this in routes mounted at + * non-primary collections (`api`, `blog`, …) instead of `getDocsPageProps`, + * which is typed to the `docs` collection. + * + * Headings from `` partials are recursively merged + * into the returned list in document order. See `getDocsPageProps` for + * the `partialHeadings` option. + * + * Pass the collection name as a type parameter for the entry's data + * shape to narrow correctly: + * + * const { entry, Content, headings } = await getCollectionPageProps<"api">(Astro); + */ +export async function getCollectionPageProps( + astro: AstroGlobal, + options?: { partialHeadings?: PartialHeadingOptions }, +): Promise<{ + entry: import("astro:content").CollectionEntry; + Content: import("astro/runtime/server/index.js").AstroComponentFactory; + headings: { depth: number; text: string; slug: string }[]; +}> { + const staticEntry = (astro.props as { entry?: unknown }).entry; + const page = await resolveProseRoute( + astro, + undefined, + options?.partialHeadings, + "getCollectionPageProps(): expected `entry` in Astro.props. " + + "Ensure your route uses `getStaticPaths = getCollectionStaticPaths()`.", + staticEntry + ? async () => + (await import("./_internal/partial-headings.js")).mergePartialHeadings + : async () => + (await import("./_internal/worker-partial-headings.js")) + .mergeWorkerPartialHeadings, + ); + if (page instanceof Response) { + throw new Error( + `getCollectionPageProps(): could not resolve request path "${astro.url.pathname}". ` + + "Use getCollectionPage(Astro) in request-rendered routes to preserve the 404 response.", + ); + } + return page; +} + +export function getCollectionPage( + astro: AstroGlobal, + options?: { partialHeadings?: PartialHeadingOptions }, +): Promise | Response> { + const staticEntry = (astro.props as { entry?: unknown }).entry; + return resolveProseRoute( + astro, + undefined, + options?.partialHeadings, + "getCollectionPageProps(): expected `entry` in Astro.props. " + + "Ensure your route uses `getStaticPaths = getCollectionStaticPaths()`.", + staticEntry + ? async () => + (await import("./_internal/partial-headings.js")).mergePartialHeadings + : async () => + (await import("./_internal/worker-partial-headings.js")) + .mergeWorkerPartialHeadings, + ); +} + +// --------------------------------------------------------------------------- +// API reference (version-aware routing) +// --------------------------------------------------------------------------- + +/** One picker-facing version of an API family. Serializable — no engine types. */ +export interface ApiVersionInfo { + /** Version id (URL segment for non-default versions). */ + version: string; + /** Display label for the picker (defaults to `version`). */ + label: string; + /** Whether this is the family default (owns the bare `/` URL). */ + isDefault: boolean; + /** Maturity status, or `null` when unset. */ + status: ApiVersionStatus | null; + /** Hidden from picker/search/sitemap; reachable by direct URL. */ + hidden: boolean; + /** Landing URL for this version (`/` or `//`). */ + url: string; +} + +/** + * `getStaticPaths` for an API reference route, spanning every version of the + * family. Companion to `getCollectionStaticPaths`, but version-aware: it emits + * one path per page per version, with the version segment already joined into + * the slug so a single `pages//[...slug].astro` catch-all serves + * the default at `//...` and each other version at + * `///...`. Hidden versions are still generated (they stay + * reachable by direct URL) — the picker and sitemap omit them separately. + * + * Each path carries `{ collection, version, coordinate }` props; render static + * routes with `getApiPage(Astro)` and request-capable routes with + * `getApiRoute(Astro)`. + * + * Usage: + * + * // src/pages/api/[...slug].astro + * export const prerender = true; + * export const getStaticPaths = getApiStaticPaths("api"); + */ +export function getApiStaticPaths(collection: string): GetStaticPaths { + return async () => { + const apiCollections = await loadApiCollections(); + if (!apiCollections.includes(collection)) { + throw new Error( + `nimbus-docs: getApiStaticPaths("${collection}") found no matching api collection in nimbus.config.ts.`, + ); + } + const entries = await getVisibleEntries([collection]); + return entries.map((entry) => { + const data = entry.data as { coordinate?: string; version?: string }; + if (!data.coordinate) { + throw new Error( + `nimbus-docs: API entry "${entry.id}" in collection "${collection}" is missing its coordinate.`, + ); + } + return { + params: { slug: entry.id === "index" ? undefined : entry.id }, + props: { + entry, + collection, + version: data.version ?? null, + coordinate: data.coordinate, + }, + }; + }); + }; +} + +/** + * Read an API route's `{ collection, version, coordinate }` from `Astro.props`, + * resolve the model for that version, and return the page + nav a route needs — + * the one-call companion to `getApiStaticPaths`, mirroring `getDocsPageProps`. + * + * Collapses the per-page model→props→nav dance and normalises the `version` + * `null`→`undefined` hand-off that `getApiModel` expects. Lives here (not on the + * `nimbus-docs/api` seam) so the seam's runtime surface stays fixed; it reaches + * the seam lazily, like `getApiStaticPaths`. + * + * Usage: + * + * export const getStaticPaths = getApiStaticPaths("api"); + * const { page, nav, collection, version, coordinate } = await getApiPage(Astro); + * + * `collection`/`version`/`coordinate` are echoed back so a versioned layout can + * drive its version picker and deprecated-version banner from the same one call + * (they originate in the route props `getApiStaticPaths` stamps). + */ +interface ApiRouteProps { + page: import("./api/index.js").ApiPageProps; + nav: import("./api/index.js").ApiNav; + collection: string; + version: string | null; + coordinate: string; +} + +export async function getApiPage(astro: AstroGlobal): Promise { + const props = astro.props as { + collection?: string; + version?: string | null; + coordinate?: string; + }; + if (!props.collection || !props.coordinate) { + throw new Error( + "getApiPage(): expected `collection` and `coordinate` in Astro.props. " + + "Ensure your route uses `getStaticPaths = getApiStaticPaths()`.", + ); + } + const result = await resolveApiRoute(astro); + if (result instanceof Response) { + throw new Error( + `getApiPage(): could not resolve request path "${astro.url.pathname}". ` + + "Use getApiRoute(Astro) in request-rendered routes to preserve the 404 response.", + ); + } + return result; +} + +/** Resolve a static or request-rendered API route, preserving redirects and + * misses as responses that Astro can pass through to custom error pages. */ +export function getApiRoute( + astro: AstroGlobal, +): Promise { + const props = astro.props as { + collection?: string; + coordinate?: string; + }; + if ( + (!props.collection || !props.coordinate) && + astro.isPrerendered !== false + ) { + throw new Error( + "getApiRoute(): expected `collection` and `coordinate` in Astro.props. " + + "Ensure your route uses `getStaticPaths = getApiStaticPaths()`.", + ); + } + return resolveApiRoute(astro); +} + +async function resolveApiRoute( + astro: AstroGlobal, +): Promise { + + const result = await resolveApiPage( + pageResolutionContext(astro), + {}, + { + getApiCollections: loadApiCollections, + getVisibleEntry: getVisibleEntry as ( + collection: string, + id: string, + ctx?: ProjectionContext, + ) => Promise | null>, + async render(collection, version, coordinate, resolvedEntry) { + const projection = pageResolutionContext(astro).projection; + const entry = + resolvedEntry ?? + (await getVisibleEntries([collection], projection)).find( + (candidate) => { + const data = candidate.data as { + coordinate?: string; + version?: string; + }; + return ( + data.coordinate === coordinate && + (data.version ?? null) === version + ); + }, + ); + if (!entry) { + throw new Error(`Missing prepared API entry for "${coordinate}".`); + } + const { activatePreparedApiNav, isPreparedApiNav, isPreparedApiPage } = + await import("./_internal/api/prepared.js"); + const prepared = (entry.data as { prepared?: unknown }).prepared; + if (!isPreparedApiPage(prepared)) { + throw new Error( + `nimbus-docs: API entry "${entry.id}" is missing prepared page data. Rebuild the content collection.`, + ); + } + const navEntry = + entry.id === prepared.navEntryId + ? entry + : await getVisibleEntry( + collection, + prepared.navEntryId, + projection, + ); + const preparedNav = ( + navEntry?.data as { prepared?: { nav?: unknown } } | undefined + )?.prepared?.nav; + if (!isPreparedApiNav(preparedNav)) { + throw new Error( + `nimbus-docs: API collection "${collection}" is missing prepared navigation for "${coordinate}".`, + ); + } + return { + page: prepared.page, + nav: activatePreparedApiNav(preparedNav, coordinate), + }; + }, + }, + ); + if (result.status !== "found") { + return proseResolutionResponse(astro, result); + } + return { + page: result.page.page, + nav: result.page.nav, + collection: result.page.collection, + version: result.page.version, + coordinate: result.page.coordinate, + }; +} + +/** + * Return the versions of an API family for a picker, or `null` when the + * collection is unversioned or unknown. Ordered as declared; the default is + * flagged. Serializable — carries no engine internals. + */ +export async function getApiVersions( + collection: string, +): Promise { + const config = await loadNimbusConfig(); + const entry = (config.api ?? []).find((a) => a.collection === collection); + if (!entry || !entry.versions) return null; + const { resolveApiFamily } = + await import("./_internal/api/resolve-versions.js"); + return resolveApiFamily(entry).map((t) => ({ + version: t.version!, + label: t.label, + isDefault: t.isDefault, + status: t.status, + hidden: t.hidden, + // Trailing-slashed; a bare `/family/v2` would 307-redirect under directory builds. + url: toBrowserHref(t.mountPath), + })); +} + +// --------------------------------------------------------------------------- +// Versioning (data layer) +// --------------------------------------------------------------------------- + +/** + * Return the resolved versioning manifest for the current site, or `null` + * if the site is unversioned (`nimbus.config.ts` has no `versions` block). + * + * Optional fields are normalised to empty arrays (`deprecated`, `hidden`) + * and `all` is `[current, ...others]` in manifest order — convenient for + * picker enumeration or anywhere you need every known version slug. + * + * Usage: + * + * const versions = await getVersions(); + * if (versions) { + * for (const slug of versions.all) { + * // …enumerate + * } + * } + * + * Reads from `virtual:nimbus/config`, so the cost is one cached dynamic + * import per build. + */ +export async function getVersions(): Promise { + const config = await loadNimbusConfig(); + const v = config.versions; + if (!v) return null; + const others = v.others ?? []; + return { + current: v.current, + others, + deprecated: v.deprecated ?? [], + hidden: v.hidden ?? [], + all: [v.current, ...others], + }; +} + +/** + * Return the version slug a given Astro content collection ID belongs to, + * or `null` if the collection is not a version of the primary docs. + * + * Rules: + * - `"docs"` → `versions.current` (the current version's label). + * - `"docs-"` where `` appears in `versions.current` or + * `versions.others` → ``. + * - Anything else (e.g. `"blog"`, `"api"`, `"docs-archive"` when + * `archive` isn't in the manifest) → `null`. + * + * Returns `null` whenever the site has no `versions` config at all, + * regardless of collection ID. + * + * Usage in a route: + * + * const { entry } = Astro.props; + * const version = await getCurrentVersion(entry.collection); + * // version === "v3" for entries in `docs`, "v2" for entries in `docs-v2`, … + */ +export async function getCurrentVersion( + collectionId: string, +): Promise { + const versions = await getVersions(); + if (!versions) return null; + if (collectionId === PRIMARY_COLLECTION) return versions.current; + if (!collectionId.startsWith("docs-")) return null; + const suffix = collectionId.slice("docs-".length); + return versions.all.includes(suffix) ? suffix : null; +} + +/** + * Look up the cross-version alternates for a given Astro entry. + * + * Returns `null` when the entry is not part of a versioning manifest + * (unversioned site, non-`docs` collection like `blog`/`api`, or the + * lookup misses for any other reason). Otherwise returns a record with: + * + * - `self`: the entry being looked up, expressed as a `VersionPageRef`. + * - `alternates`: every other version's sibling page for the same + * logical content (same slug or linked via `previousSlug`). Sorted + * in manifest version order. + * - `canonical`: the current-version sibling when one exists and + * isn't `self`. `null` when `self` is already the current version + * or no current-version sibling exists. + * + * Routes inject `` for every entry in + * `alternates`, and `` pointing at `canonical.url` + * when canonical is non-null. + * + * Usage in a route: + * + * const { entry } = Astro.props; + * const alts = await getVersionAlternates(entry.collection, entry.id); + * + * {alts?.alternates.map((a) => ( + * + * ))} + * {alts?.canonical && } + */ +export async function getVersionAlternates( + collectionId: string, + entryId: string, +): Promise { + const table = await loadVersionAlternates(); + const key = `${collectionId}:${entryId}`; + return table[key] ?? null; +} + +/** + * API-family variant of {@link getVersionAlternates}. API alternates are keyed + * by `family@version:coordinate`, which the `(collection, entryId)` accessor + * cannot address. Pass the `version` and `coordinate` from + * {@link getApiStaticPaths}. Returns `null` for an unversioned family. + */ +export async function getApiVersionAlternates( + collection: string, + version: string | null, + coordinate: string, +): Promise { + if (version == null) return null; + const config = await loadNimbusConfig(); + const { resolveApiVersion } = + await import("./_internal/api/resolve-versions.js"); + const target = resolveApiVersion(config.api, collection, version); + if (!target) return null; + const table = await loadVersionAlternates(); + return table[`${target.versionKey}:${coordinate}`] ?? null; +} + +/** + * Convenience wrapper: returns just the canonical URL for an entry, or + * `null` when none applies. Equivalent to + * `(await getVersionAlternates(c, e))?.canonical?.url ?? null` — handy + * when a route only needs the canonical and not the full alternates list. + */ +export async function getCanonicalUrl( + collectionId: string, + entryId: string, +): Promise { + const record = await getVersionAlternates(collectionId, entryId); + return record?.canonical?.url ?? null; +} + +/** + * Return the agent index URL path (the `/llms.txt` route) that + * corresponds to a given Astro collection. The path is mount-point + * aware: pages in version collections point at the per-version index, + * pages in non-primary collections point at their per-collection index, + * and the primary `docs` collection points at the root. + * + * - `"docs"` → `"/llms.txt"` + * - `"docs-v1"` → `"/v1/llms.txt"` (when `v1` is in `versions.others`) + * - `"blog"` → `"/blog/llms.txt"` + * - `"api"` → `"/api/llms.txt"` + * - `"docs-archive"` (unrecognised version slug) → `"/docs-archive/llms.txt"` + * + * Returns a path with a leading slash and no trailing slash. Routes + * resolve it against `Astro.site` to produce a full URL. + * + * Used by `BaseLayout` and `AgentDirective` to surface the correct + * agent index hint on every page — readers landing on `/v1/foo` get + * pointed at `/v1/llms.txt`, not `/llms.txt`, so agents don't crawl + * the wrong section. + */ +export async function getCollectionLlmsUrl( + collectionId: string, +): Promise { + if (collectionId === PRIMARY_COLLECTION) return "/llms.txt"; + const versions = await getVersions(); + if (versions && collectionId.startsWith("docs-")) { + const slug = collectionId.slice("docs-".length); + if (versions.others.includes(slug)) { + // Hidden versions do NOT emit a per-section //llms.txt — the + // [section] route filters them out. Pointing readers at a 404 + // breaks the agent-discovery contract. Fall back to the root + // index for hidden version pages instead. + if (versions.hidden.includes(slug)) return "/llms.txt"; + return `/${slug}/llms.txt`; + } + } + return `/${collectionId}/llms.txt`; +} + +/** + * Look up the versioning status for a page's collection — what the + * layout needs to decide whether to render the deprecation banner, + * apply the Pagefind facet filters, or exclude the page from search + * entirely. + * + * Returns `null` when the site is unversioned or the page is not part + * of a version collection (regular `docs`, `blog`, `api`, …). Layouts + * treat that as "no versioning UI to apply" — render normally. + * + * Usage: + * + * const status = await getVersionStatus(entry.collection); + * if (status?.isDeprecated) { + * // render the deprecation banner + * } + */ +/** + * Resolve a URL that's guaranteed to exist within a given version's + * collection. Used by the picker (and any other "jump to that version" + * surface) to avoid landing readers on a 404 when the current page has + * no same-logical-page sibling in the target version. + * + * Resolution order: + * 1. If `docs-/index` exists, return its URL (the conventional + * "version landing page"). + * 2. If `docs-/overview` exists, return its URL (common alternate + * name for a landing page). + * 3. Otherwise return the first indexed entry's URL in that version, + * sorted by URL — matches `getIndexedTopLevel()`'s sort so the + * choice is deterministic across builds. + * 4. If the version has no indexed entries at all, return `null`. + * Callers should treat that as "this version has nothing to link + * to" and either omit the picker entry or fall back to the + * version's URL prefix root (which may still 404, but that's the + * authoring problem to fix, not the picker's). + * + * `version` is the manifest slug (e.g. `"v0"`), NOT the collection ID + * (`"docs-v0"`). For the current version, returns `"/"` when at least + * one current-version entry exists, else `null`. + * + * Reads from `getIndexedEntries()`, so the cost is one cached lookup + * per build (the indexed list is computed once per page render). + */ +export async function getVersionLandingUrl( + version: string, +): Promise { + const versions = await getVersions(); + if (!versions) return null; + if (!versions.all.includes(version)) return null; + + const targetCollection = + version === versions.current ? PRIMARY_COLLECTION : `docs-${version}`; + const items = await getIndexedEntries(); + const inVersion = items.filter((i) => i.collection === targetCollection); + if (inVersion.length === 0) return null; + + const byId = new Map(inVersion.map((i) => [i.entry.id, i])); + // Prefer index / overview by convention. + const preferred = byId.get("index") ?? byId.get("overview"); + // `IndexedEntry.url` is already the trailing-slash browser-href form + // the version picker renders, so no extra normalization here. + if (preferred) return preferred.url; + // Else first by URL (sort is alphabetical → deterministic). + inVersion.sort((a, b) => a.url.localeCompare(b.url)); + return inVersion[0]!.url; +} + +export async function getVersionStatus( + collectionId: string, +): Promise { + // API version key (`family@version`): version ids and family names never + // contain `@`, so a single `@` unambiguously marks the API axis. Resolve its + // status from the family so the head can `noindex` hidden versions and + // layouts can render the deprecated banner. + const at = collectionId.indexOf("@"); + if (at > 0) { + const family = collectionId.slice(0, at); + const version = collectionId.slice(at + 1); + const apiVersions = await getApiVersions(family); + if (apiVersions) { + const match = apiVersions.find((v) => v.version === version); + if (!match) return null; + return { + version, + isCurrent: match.isDefault, + isDeprecated: match.status === "deprecated", + isHidden: match.hidden, + }; + } + } + + const versions = await getVersions(); + if (!versions) return null; + const version = await getCurrentVersion(collectionId); + if (version === null) return null; + return { + version, + isCurrent: version === versions.current, + isDeprecated: versions.deprecated.includes(version), + isHidden: versions.hidden.includes(version), + }; +} diff --git a/packages/nimbus-docs/src/types.ts b/packages/nimbus-docs/src/types.ts index 80ebdf2c..da18cdd0 100644 --- a/packages/nimbus-docs/src/types.ts +++ b/packages/nimbus-docs/src/types.ts @@ -92,6 +92,20 @@ export interface NimbusConfig { * wedges this site's build (unlike a broken *local* citation, which fails). */ apiReferences?: ApiReference[]; + /** + * Where each routed collection renders. Omit to preserve the all-build + * default. Overrides are collection names, not URL patterns or API versions. + */ + rendering?: RenderingConfig; +} + +export type RenderingMode = "build" | "request"; + +export interface RenderingConfig { + /** Mode for canonical collection routes without an explicit override. */ + default?: RenderingMode; + /** Per-collection overrides keyed by registered Astro collection name. */ + collections?: Record; } /** diff --git a/packages/nimbus-docs/src/types/virtual-modules.d.ts b/packages/nimbus-docs/src/types/virtual-modules.d.ts index d4b3b37a..68f13e8b 100644 --- a/packages/nimbus-docs/src/types/virtual-modules.d.ts +++ b/packages/nimbus-docs/src/types/virtual-modules.d.ts @@ -18,8 +18,17 @@ declare module "virtual:nimbus/config" { export const config: import("../types.js").NimbusConfig; export const indexedCollections: readonly string[]; + export const requestRenderingCollections: readonly string[]; export const versionAlternates: import("../_internal/version-alternates.js").VersionAlternatesTable; export const apiCollections: readonly string[]; + export const headDefaults: { + favicon: { file: string; type: string }; + socialImage: string; + }; +} + +declare module "virtual:nimbus/api-build-config" { + export const api: import("../types.js").ApiSpec[]; export const root: string; } diff --git a/packages/nimbus-docs/test/api-loader.test.ts b/packages/nimbus-docs/test/api-loader.test.ts index 593c8a93..68627dbc 100644 --- a/packages/nimbus-docs/test/api-loader.test.ts +++ b/packages/nimbus-docs/test/api-loader.test.ts @@ -1,8 +1,6 @@ -// Guards the `apiCollection()` loader: it is a thin index (one small entry per -// page carrying routing + display metadata, no body), the root's empty slug -// maps to Astro's `index` id, buildApiModel is content-addressed (parse-once + -// hot-reload eviction), and two specs compose without aliasing. If this goes -// red, the loader/render contract moved. +// Guards the `apiCollection()` loader: each entry carries its prepared page, +// the root carries shared navigation, and the root's empty slug maps to Astro's +// `index` id. It also covers content-addressed parsing and multi-spec isolation. import { test, describe, before } from "node:test"; import assert from "node:assert/strict"; @@ -10,6 +8,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath, pathToFileURL } from "node:url"; import { apiCollection } from "../src/content.js"; +import { activatePreparedApiNav } from "../src/_internal/api/prepared.js"; import { buildApiModel, clearApiModelCache, @@ -63,16 +62,24 @@ function makeStore() { } function makeContext(collection: string, store: ReturnType) { const logs: { level: string; msg: string }[] = []; - const log = (level: string) => (msg: string) => void logs.push({ level, msg }); + const log = (level: string) => (msg: string) => + void logs.push({ level, msg }); return { logs, context: { collection, store: store as unknown as import("astro/loaders").LoaderContext["store"], meta: { get: () => undefined, set() {}, has: () => false, delete() {} }, - logger: { info: log("info"), warn: log("warn"), error: log("error"), debug: log("debug"), label: "t", fork: () => makeContext(collection, store).context.logger } as never, + logger: { + info: log("info"), + warn: log("warn"), + error: log("error"), + debug: log("debug"), + label: "t", + fork: () => makeContext(collection, store).context.logger, + } as never, config: { root: pathToFileURL(ROOT) } as never, - parseData: async ({ data }: { data: T }) => data, + parseData: async ({ data }: { data: T }) => data, renderMarkdown: async () => ({ html: "" }), generateDigest: (v: unknown) => JSON.stringify(v).length.toString(36), watcher: undefined, @@ -80,7 +87,10 @@ function makeContext(collection: string, store: ReturnType) { }; } -async function runLoader(collection: string, spec: string | Record) { +async function runLoader( + collection: string, + spec: string | Record, +) { const store = makeStore(); const { logs, context } = makeContext(collection, store); const { loader } = apiCollection({ collection, spec }); @@ -90,10 +100,13 @@ async function runLoader(collection: string, spec: string | Record { - smallco = await buildApiModel({ collection: "api", spec: fixtureText("smallco.yaml") }); + smallco = await buildApiModel({ + collection: "api", + spec: fixtureText("smallco.yaml"), + }); }); -describe("apiCollection loader — thin index", () => { +describe("apiCollection loader — prepared index", () => { test("writes exactly one entry per page slug, root mapped to `index`", async () => { const slugs = getApiPageSlugs(smallco); // The loader reads its own spec via the path form to exercise fs + resolve. @@ -109,26 +122,81 @@ describe("apiCollection loader — thin index", () => { } }); - test("entries are thin: routing + display metadata only, no body/rendered", async () => { + test("entries carry prepared page data and only roots carry shared navigation", async () => { const { store } = await runLoader("api", "test/fixtures/api/smallco.yaml"); for (const entry of store.values()) { const keys = Object.keys(entry.data); assert.ok(keys.includes("coordinate"), "carries coordinate"); assert.ok(keys.includes("title"), "carries title"); - // description is optional (omitted when the page has none). - for (const key of keys) assert.ok(["coordinate", "title", "description"].includes(key), `unexpected data key "${key}"`); + assert.ok(keys.includes("prepared"), "carries prepared runtime data"); + for (const key of keys) + assert.ok( + ["coordinate", "title", "description", "prepared"].includes(key), + `unexpected data key "${key}"`, + ); assert.equal(typeof entry.data.coordinate, "string"); assert.equal(typeof entry.data.title, "string"); - assert.equal(entry.body, undefined, "no MDX body — render re-derives the model"); + const prepared = entry.data.prepared as { + page: { coordinate: string }; + nav?: unknown; + }; + assert.equal(prepared.page.coordinate, entry.data.coordinate); + assert.equal("nav" in prepared, entry.id === "index"); + assert.equal(entry.body, undefined, "no MDX body"); assert.equal(entry.rendered, undefined); } }); - test("thin entries stay small (well under a 1 KB/entry budget)", async () => { + test("prepared entries stay bounded", async () => { const { store } = await runLoader("api", "test/fixtures/api/smallco.yaml"); const bytes = Buffer.byteLength(JSON.stringify(store.values())); const perEntry = bytes / store.keys().length; - assert.ok(perEntry < 1024, `~${perEntry.toFixed(0)} B/entry should be < 1 KB`); + assert.ok( + perEntry < 4096, + `~${perEntry.toFixed(0)} B/entry should be < 4 KB`, + ); + }); + + test("versioned prepared navigation matches direct model projection", async () => { + const spec = smallcoAsObject(); + const { store } = await runLoaderOpts({ + collection: "api", + versions: [ + { version: "v2", spec, default: true }, + { version: "v1", spec }, + ], + }); + + for (const { version, rootId, prefix, mountPath } of [ + { version: "v2", rootId: "index", prefix: "", mountPath: "/api" }, + { version: "v1", rootId: "v1", prefix: "v1/", mountPath: "/api/v1" }, + ]) { + const root = store.get(rootId); + assert.ok(root); + const preparedRoot = root.data.prepared as { + nav: Parameters[0]; + }; + const model = await buildApiModel({ collection: "api", spec, mountPath }); + + for (const entry of store.values()) { + const belongsToVersion = prefix + ? entry.id === rootId || entry.id.startsWith(prefix) + : entry.id !== "v1" && !entry.id.startsWith("v1/"); + if (!belongsToVersion) continue; + const prepared = entry.data.prepared as { + page: { coordinate: string }; + }; + assert.equal(entry.data.version, version); + assert.deepEqual( + prepared.page, + getApiPageProps(model, prepared.page.coordinate), + ); + assert.deepEqual( + activatePreparedApiNav(preparedRoot.nav, prepared.page.coordinate), + getApiNav(model, prepared.page.coordinate), + ); + } + } }); test("inline-object spec works without touching the filesystem", async () => { @@ -147,7 +215,11 @@ describe("apiCollection loader — thin index", () => { info: { title: "Collide", version: "1.0.0" }, paths: { "/index": { - get: { operationId: "index", summary: "Index", responses: { "200": { description: "ok" } } }, + get: { + operationId: "index", + summary: "Index", + responses: { "200": { description: "ok" } }, + }, }, }, }; @@ -164,12 +236,26 @@ describe("canonical routing — one URL per page, no duplicate or /index alias", const root = slugs.find((s) => s.slug === ""); assert.ok(root, "model exposes an api-root page (empty slug)"); const base = getApiPageProps(smallco, root!.coordinate).href; - assert.ok(!/\/index$/.test(base), `root URL "${base}" must be the bare collection path, not an /index alias`); + assert.ok( + !/\/index$/.test(base), + `root URL "${base}" must be the bare collection path, not an /index alias`, + ); const hrefs = slugs.map((s) => getApiPageProps(smallco, s.coordinate).href); - assert.equal(new Set(hrefs).size, hrefs.length, "two coordinates share a URL — the SEO duplicate a canonical would have to paper over"); - assert.equal(hrefs.filter((h) => h === base).length, 1, "exactly one page owns the canonical root URL"); - assert.ok(!hrefs.includes(`${base}/index`), "no page is served at the /index duplicate of the root"); + assert.equal( + new Set(hrefs).size, + hrefs.length, + "two coordinates share a URL — the SEO duplicate a canonical would have to paper over", + ); + assert.equal( + hrefs.filter((h) => h === base).length, + 1, + "exactly one page owns the canonical root URL", + ); + assert.ok( + !hrefs.includes(`${base}/index`), + "no page is served at the /index duplicate of the root", + ); }); test("the root's markdown twin lives at /index.md without minting an HTML /index route", () => { @@ -192,7 +278,9 @@ describe("round-trip completeness (pageSlugs ⊆ domain(pageProps))", () => { test("nav marks each page's coordinate active along a real ancestor path", () => { const slugs = getApiPageSlugs(smallco); - const findActive = (items: ReturnType["items"]): string | undefined => { + const findActive = ( + items: ReturnType["items"], + ): string | undefined => { for (const item of items) { if (item.active) return item.coordinate; const nested = item.children ? findActive(item.children) : undefined; @@ -235,17 +323,23 @@ describe("buildApiModel — content-addressed cache", () => { test("edited content busts the cache (hot-reload correctness)", async () => { const original = fixtureText("smallco.yaml"); const h1 = await buildApiModel({ collection: "edit", spec: original }); - const h2 = await buildApiModel({ collection: "edit", spec: original + "\n# edited\n" }); + const h2 = await buildApiModel({ + collection: "edit", + spec: original + "\n# edited\n", + }); assert.notEqual(h1, h2, "different bytes → different key → reparsed"); }); test("a broken spec fails with a legible error, both attempts (reject not cached)", async () => { const broken = { collection: "broken", spec: fixtureText("broken.yaml") }; - await assert.rejects(() => buildApiModel(broken), (err: Error) => { - assert.ok(err instanceof Error); - assert.ok(err.message.length > 0); - return true; - }); + await assert.rejects( + () => buildApiModel(broken), + (err: Error) => { + assert.ok(err instanceof Error); + assert.ok(err.message.length > 0); + return true; + }, + ); await assert.rejects(() => buildApiModel({ ...broken })); }); }); @@ -262,12 +356,26 @@ describe("composition — two collections, no aliasing", () => { // Either way each is only used with its own model. The guarantee that // matters is disjoint URL spaces: every href carries its collection prefix, // so no two collections can ever mint the same page URL. - const aHrefs = getApiPageSlugs(a).map((s) => getApiPageProps(a, s.coordinate).href); - const bHrefs = getApiPageSlugs(b).map((s) => getApiPageProps(b, s.coordinate).href); + const aHrefs = getApiPageSlugs(a).map( + (s) => getApiPageProps(a, s.coordinate).href, + ); + const bHrefs = getApiPageSlugs(b).map( + (s) => getApiPageProps(b, s.coordinate).href, + ); assert.ok(aHrefs.length > 0); - assert.ok(aHrefs.every((h) => h === "/alpha" || h.startsWith("/alpha/")), "all under /alpha"); - assert.ok(bHrefs.every((h) => h === "/beta" || h.startsWith("/beta/")), "all under /beta"); - assert.equal(aHrefs.filter((h) => bHrefs.includes(h)).length, 0, "URL spaces are disjoint"); + assert.ok( + aHrefs.every((h) => h === "/alpha" || h.startsWith("/alpha/")), + "all under /alpha", + ); + assert.ok( + bHrefs.every((h) => h === "/beta" || h.startsWith("/beta/")), + "all under /beta", + ); + assert.equal( + aHrefs.filter((h) => bHrefs.includes(h)).length, + 0, + "URL spaces are disjoint", + ); }); test("independent DataStores: two loaders don't bleed ids", async () => { @@ -290,7 +398,8 @@ async function runLoaderOpts(options: Parameters[0]) { async function captureWarnings(fn: () => Promise): Promise { const warnings: string[] = []; const real = console.warn; - console.warn = (...args: unknown[]) => void warnings.push(args.map(String).join(" ")); + console.warn = (...args: unknown[]) => + void warnings.push(args.map(String).join(" ")); try { await fn(); } finally { @@ -303,7 +412,9 @@ function missingOpIdSpec(): Record { return { openapi: "3.0.0", info: { title: "No opId", version: "1.0.0" }, - paths: { "/widgets": { get: { responses: { "200": { description: "ok" } } } } }, + paths: { + "/widgets": { get: { responses: { "200": { description: "ok" } } } }, + }, }; } @@ -311,11 +422,19 @@ describe("apiCollection — missing operationId is lenient by default, strict on test("default: an operationId-less op indexes via a path-derived fallback page + aggregate warning", async () => { let store!: Awaited>["store"]; const warnings = await captureWarnings(async () => { - ({ store } = await runLoaderOpts({ collection: "leni", spec: missingOpIdSpec() })); + ({ store } = await runLoaderOpts({ + collection: "leni", + spec: missingOpIdSpec(), + })); }); - assert.ok(store.has("get/widgets"), "the fallback page is indexed (build did not abort)"); assert.ok( - warnings.some((w) => /lack a usable operationId and fell back to/i.test(w)), + store.has("get/widgets"), + "the fallback page is indexed (build did not abort)", + ); + assert.ok( + warnings.some((w) => + /lack a usable operationId and fell back to/i.test(w), + ), "the guaranteed aggregate line is surfaced", ); }); @@ -323,9 +442,15 @@ describe("apiCollection — missing operationId is lenient by default, strict on test("the aggregate survives the per-op warning cap (25 missing ids > 20-line cap)", async () => { const paths: Record = {}; for (let i = 0; i < 25; i++) { - paths[`/w${i}`] = { get: { responses: { "200": { description: "ok" } } } }; + paths[`/w${i}`] = { + get: { responses: { "200": { description: "ok" } } }, + }; } - const spec = { openapi: "3.0.0", info: { title: "Many", version: "1.0.0" }, paths }; + const spec = { + openapi: "3.0.0", + info: { title: "Many", version: "1.0.0" }, + paths, + }; const warnings = await captureWarnings(async () => { await runLoaderOpts({ collection: "captest", spec }); }); @@ -334,14 +459,21 @@ describe("apiCollection — missing operationId is lenient by default, strict on "per-op warnings are truncated by the cap", ); assert.ok( - warnings.some((w) => /25 operation\(s\) lack a usable operationId/.test(w)), + warnings.some((w) => + /25 operation\(s\) lack a usable operationId/.test(w), + ), "the aggregate survives truncation and reports the full count", ); }); test("requireOperationId: true reaches the LOADER path and fails the build (regression: flag was dropped)", async () => { await assert.rejects( - () => runLoaderOpts({ collection: "stricti", spec: missingOpIdSpec(), requireOperationId: true }), + () => + runLoaderOpts({ + collection: "stricti", + spec: missingOpIdSpec(), + requireOperationId: true, + }), /operationId/i, "strict must abort via apiCollection(), not silently warn", ); @@ -352,19 +484,34 @@ describe("apiCollection — missing operationId is lenient by default, strict on const lenient = await buildApiModel({ collection: "cachesep", spec }); assert.ok(lenient, "lenient builds"); await assert.rejects( - () => buildApiModel({ collection: "cachesep", spec, requireOperationId: true }), + () => + buildApiModel({ + collection: "cachesep", + spec, + requireOperationId: true, + }), /operationId/i, "strict is a distinct cache key, so it re-parses and fails", ); }); test("a collision involving a synthesized fallback names the real fix (add an operationId)", async () => { - const idParam = { name: "id", in: "path", required: true, schema: { type: "string" } }; + const idParam = { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }; const spec = { openapi: "3.0.0", info: { title: "Collide", version: "1.0.0" }, paths: { - "/a/{id}": { get: { parameters: [idParam], responses: { "200": { description: "ok" } } } }, + "/a/{id}": { + get: { + parameters: [idParam], + responses: { "200": { description: "ok" } }, + }, + }, "/a/id": { get: { responses: { "200": { description: "ok" } } } }, }, }; @@ -372,9 +519,21 @@ describe("apiCollection — missing operationId is lenient by default, strict on () => buildApiModel({ collection: "synthcollide", spec }), (err: unknown) => { const msg = (err as Error).message; - assert.match(msg, /duplicate operation coordinate "get\/a\/id"/i, "the failure is the coordinate collision"); - assert.match(msg, /add an operationId/i, "points at adding an operationId, not just 'rename'"); - assert.doesNotMatch(msg, /path parameter/i, "no unrelated spec-deviation noise"); + assert.match( + msg, + /duplicate operation coordinate "get\/a\/id"/i, + "the failure is the coordinate collision", + ); + assert.match( + msg, + /add an operationId/i, + "points at adding an operationId, not just 'rename'", + ); + assert.doesNotMatch( + msg, + /path parameter/i, + "no unrelated spec-deviation noise", + ); return true; }, ); @@ -386,12 +545,34 @@ describe("apiCollection — missing operationId is lenient by default, strict on info: { title: "Edge", version: "1.0.0" }, paths, }); - const root = await buildApiModel({ collection: "edgeroot", spec: base({ "/": { get: { responses: { "200": { description: "ok" } } } } }) }); - assert.ok(getApiPageSlugs(root).some((s) => s.coordinate === "get"), "root op folds to `get`"); - const dbl = await buildApiModel({ collection: "edgeslash", spec: base({ "/a//b": { get: { responses: { "200": { description: "ok" } } } } }) }); - assert.ok(getApiPageSlugs(dbl).some((s) => s.coordinate === "get/a/b"), "`/a//b` folds to `get/a/b`"); + const root = await buildApiModel({ + collection: "edgeroot", + spec: base({ + "/": { get: { responses: { "200": { description: "ok" } } } }, + }), + }); + assert.ok( + getApiPageSlugs(root).some((s) => s.coordinate === "get"), + "root op folds to `get`", + ); + const dbl = await buildApiModel({ + collection: "edgeslash", + spec: base({ + "/a//b": { get: { responses: { "200": { description: "ok" } } } }, + }), + }); + assert.ok( + getApiPageSlugs(dbl).some((s) => s.coordinate === "get/a/b"), + "`/a//b` folds to `get/a/b`", + ); await assert.rejects( - () => buildApiModel({ collection: "edgebrace", spec: base({ "/x/{}": { get: { responses: { "200": { description: "ok" } } } } }) }), + () => + buildApiModel({ + collection: "edgebrace", + spec: base({ + "/x/{}": { get: { responses: { "200": { description: "ok" } } } }, + }), + }), /route|path segment|escape|empty/i, "a `{}` param is malformed and fails, never a broken slug", ); @@ -418,18 +599,25 @@ describe("apiCollection — a non-default version id shadowing a default-version { version: "stable", default: true, - spec: versionSpec({ "/charges": { get: { operationId: "listCharges", ...OK } } }), + spec: versionSpec({ + "/charges": { get: { operationId: "listCharges", ...OK } }, + }), routes: { convention: "resource-action-v1" }, }, { version: "charges", - spec: versionSpec({ "/widgets": { get: { operationId: "listWidgets", ...OK } } }), + spec: versionSpec({ + "/widgets": { get: { operationId: "listWidgets", ...OK } }, + }), routes: { convention: "resource-action-v1" }, }, ], }), (err: Error) => { - assert.match(err.message, /version "charges" of collection "shadow" collides/); + assert.match( + err.message, + /version "charges" of collection "shadow" collides/, + ); assert.match(err.message, /routes\.operations` override/); assert.doesNotMatch(err.message, /the colliding operation\/tag/); return true; @@ -446,19 +634,32 @@ describe("apiCollection — a non-default version id shadowing a default-version { version: "stable", default: true, - spec: versionSpec({ "/charges": { get: { operationId: "listCharges", ...OK } } }), - routes: { convention: "resource-action-v1", operations: { listCharges: "charges/summary" } }, + spec: versionSpec({ + "/charges": { get: { operationId: "listCharges", ...OK } }, + }), + routes: { + convention: "resource-action-v1", + operations: { listCharges: "charges/summary" }, + }, }, { version: "charges", - spec: versionSpec({ "/widgets": { get: { operationId: "listWidgets", ...OK } } }), + spec: versionSpec({ + "/widgets": { get: { operationId: "listWidgets", ...OK } }, + }), routes: { convention: "resource-action-v1" }, }, ], }), (err: Error) => { - assert.match(err.message, /version "charges" of collection "shadow3" collides/); - assert.match(err.message, /adjust the `routes\.operations` override target/i); + assert.match( + err.message, + /version "charges" of collection "shadow3" collides/, + ); + assert.match( + err.message, + /adjust the `routes\.operations` override target/i, + ); assert.doesNotMatch(err.message, /the colliding operation\/tag/); return true; }, @@ -476,16 +677,25 @@ describe("apiCollection — a non-default version id shadowing a default-version { version: "stable", default: true, - spec: versionSpec({ "/a": { get: { operationId: "list", tags: ["charges"], ...OK } } }), + spec: versionSpec({ + "/a": { + get: { operationId: "list", tags: ["charges"], ...OK }, + }, + }), }, { version: "charges", - spec: versionSpec({ "/widgets": { get: { operationId: "listWidgets", ...OK } } }), + spec: versionSpec({ + "/widgets": { get: { operationId: "listWidgets", ...OK } }, + }), }, ], }), (err: Error) => { - assert.match(err.message, /version "charges" of collection "shadow2" collides/); + assert.match( + err.message, + /version "charges" of collection "shadow2" collides/, + ); assert.match(err.message, /the colliding operation\/tag/); assert.doesNotMatch(err.message, /routes\.operations` override/); return true; @@ -501,7 +711,11 @@ function smallcoAsObject(): Record { info: { title: "Inline", version: "1.0.0" }, paths: { "/things": { - get: { operationId: "listThings", summary: "List things", responses: { "200": { description: "ok" } } }, + get: { + operationId: "listThings", + summary: "List things", + responses: { "200": { description: "ok" } }, + }, }, }, }; diff --git a/packages/nimbus-docs/test/api-view-model.test.ts b/packages/nimbus-docs/test/api-view-model.test.ts index 5fd2b6ca..9931bb6d 100644 --- a/packages/nimbus-docs/test/api-view-model.test.ts +++ b/packages/nimbus-docs/test/api-view-model.test.ts @@ -79,6 +79,28 @@ describe("seam: serializable + version-stamped across page kinds", () => { assert.equal(create.markdownHref, `${create.href}/index.md`); }); + test("prepares sanitized HTML for page, response, and field descriptions", () => { + const root = getApiPageProps(smallco, "smallco"); + assert.match( + root.descriptionHtml ?? "", + /^

    A deliberately small API that still trips every grammar edge case\.<\/p>/, + ); + + const create = getApiPageProps(smallco, "create") as ApiOperationPage; + const response = create.responses.find((item) => item.status === "200"); + assert.match( + response?.descriptionHtml ?? "", + /^

    The created charge\.<\/p>/, + ); + + const charge = getApiPageProps(smallco, "Charge") as ApiSchemaPage; + const amount = charge.fields.find((field) => field.name === "amount"); + assert.match( + amount?.descriptionHtml ?? "", + /^

    Amount to collect in cents\.<\/p>/, + ); + }); + test("getApiPageIndex covers every page slug with projection-identical title/description", () => { const index = getApiPageIndex(smallco); const slugs = getApiPageSlugs(smallco); diff --git a/packages/nimbus-docs/test/build-report.test.ts b/packages/nimbus-docs/test/build-report.test.ts index 41e82b17..04004fe1 100644 --- a/packages/nimbus-docs/test/build-report.test.ts +++ b/packages/nimbus-docs/test/build-report.test.ts @@ -104,7 +104,7 @@ test("a doc route forced on-demand fails the invariant", () => { prerenderedPageCount: 1, }); assert.deepEqual(r.violations, ["/llms.txt"]); - assert.match(r.summaryLine, /\(1 moved\)/); + assert.match(r.summaryLine, /\(0 moved\)/); }); test("declared feature routes explain a non-`/_` on-demand route", () => { @@ -126,6 +126,28 @@ test("declared feature routes explain a non-`/_` on-demand route", () => { assert.match(r.summaryLine, /server features=\[hosted-mcp\]/); }); +test("declared request routes are explained and counted as moved docs", () => { + const r = analyzeBuild({ + outputMode: "server", + adapterName: "cloudflare", + routes: [ + { + pattern: "/[...slug]", + type: "page", + isPrerendered: false, + origin: "project", + }, + ], + prerenderedPageCount: 3, + requestRenderedPageCount: 100, + declaredRequestRoutes: ["/[...slug]"], + }); + + assert.deepEqual(r.violations, []); + assert.deepEqual(r.onDemandDocRoutes, ["/[...slug]"]); + assert.match(r.summaryLine, /docs prerendered=3\/103 \(100 moved\)/); +}); + test("static build: adapter=none, on-demand routes=0, no server-features field", () => { const routes: ResolvedRouteLike[] = [ { pattern: "/", type: "page", isPrerendered: true, origin: "project" }, diff --git a/packages/nimbus-docs/test/fixtures/api/smallco.yaml b/packages/nimbus-docs/test/fixtures/api/smallco.yaml index ca68d0b5..eb26b97f 100644 --- a/packages/nimbus-docs/test/fixtures/api/smallco.yaml +++ b/packages/nimbus-docs/test/fixtures/api/smallco.yaml @@ -190,6 +190,7 @@ components: type: string amount: type: integer + description: Amount to collect in cents. source: $ref: "#/components/schemas/Card" Card: diff --git a/packages/nimbus-docs/test/page-resolution.test.ts b/packages/nimbus-docs/test/page-resolution.test.ts index ff1f480a..dc960d09 100644 --- a/packages/nimbus-docs/test/page-resolution.test.ts +++ b/packages/nimbus-docs/test/page-resolution.test.ts @@ -74,7 +74,8 @@ describe("prose page resolution", () => { ["docs-v1:guides/setup", entry("docs-v1", "guides/setup")], ["blog:v1", entry("blog", "v1")], ]); - let lookups: Array<{ collection: string; id: string; audience?: string }> = []; + let lookups: Array<{ collection: string; id: string; audience?: string }> = + []; const dependencies = { async getVisibleEntry( collection: string, @@ -90,7 +91,9 @@ describe("prose page resolution", () => { async render(value: CollectionEntry) { return { Content, - headings: [{ depth: 1, text: value.id, slug: value.id.replaceAll("/", "-") }], + headings: [ + { depth: 1, text: value.id, slug: value.id.replaceAll("/", "-") }, + ], }; }, }; @@ -201,7 +204,10 @@ describe("API page resolution", () => { ["api:index", entry("api", "index", { coordinate: "root", version: "v2" })], [ "api:charges/create", - entry("api", "charges/create", { coordinate: "createCharge", version: "v2" }), + entry("api", "charges/create", { + coordinate: "createCharge", + version: "v2", + }), ], ["api:v1", entry("api", "v1", { coordinate: "root", version: "v1" })], [ @@ -212,12 +218,13 @@ describe("API page resolution", () => { }), ], ]); - let lookups: Array<{ collection: string; id: string; audience?: string }> = []; - let specLoads = 0; + let lookups: Array<{ collection: string; id: string; audience?: string }> = + []; + let collectionLoads = 0; const dependencies = { - async getApiSpecs() { - specLoads++; - return api; + async getApiCollections() { + collectionLoads++; + return api.map(({ collection }) => collection); }, async getVisibleEntry( collection: string, @@ -227,7 +234,11 @@ describe("API page resolution", () => { lookups.push({ collection, id, audience: projection?.audience?.key }); return entries.get(`${collection}:${id}`) ?? null; }, - async render(collection: string, version: string | null, coordinate: string) { + async render( + collection: string, + version: string | null, + coordinate: string, + ) { const page: ApiPageProps = { apiSchemaVersion: 1, kind: "api", @@ -248,7 +259,7 @@ describe("API page resolution", () => { test("uses existing static API identity without request lookup", async () => { lookups = []; - specLoads = 0; + collectionLoads = 0; const result = await resolveApiPage( context("/api/charges/create/", "ignored", { collection: "api", @@ -264,7 +275,7 @@ describe("API page resolution", () => { assert.equal(result.page.coordinate, "createCharge"); assert.equal(result.page.version, "v2"); assert.deepEqual(lookups, []); - assert.equal(specLoads, 0); + assert.equal(collectionLoads, 0); }); test("resolves default root and nested API request paths", async () => { @@ -303,7 +314,8 @@ describe("API page resolution", () => { assert.equal(root.status, "found"); assert.equal(leaf.status, "found"); if (root.status === "found") assert.equal(root.page.version, "v1"); - if (leaf.status === "found") assert.equal(leaf.page.coordinate, "createCharge"); + if (leaf.status === "found") + assert.equal(leaf.page.coordinate, "createCharge"); }); test("returns not-found for missing and unknown API paths", async () => { @@ -334,8 +346,8 @@ describe("API page resolution", () => { { collection: "legacy" }, { ...dependencies, - async getApiSpecs() { - return [{ collection: "legacy", spec: {} }]; + async getApiCollections() { + return ["legacy"]; }, async getVisibleEntry() { return entry("legacy", "index", { coordinate: "root" }); diff --git a/packages/nimbus-docs/test/partial-headings.test.ts b/packages/nimbus-docs/test/partial-headings.test.ts index b6d5de19..15a5bdff 100644 --- a/packages/nimbus-docs/test/partial-headings.test.ts +++ b/packages/nimbus-docs/test/partial-headings.test.ts @@ -8,6 +8,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { mergePartialHeadings } from "../src/_internal/partial-headings.js"; +import { mergeWorkerPartialHeadings } from "../src/_internal/worker-partial-headings.js"; import type { Heading } from "../src/_internal/partial-headings.js"; @@ -66,6 +67,30 @@ test("partial heading is inserted between parent headings in document order", as ); }); +test("Worker parser preserves partial heading order", async () => { + const partials: Record = { + mid: { + id: "mid", + body: "## Worker partial\n", + headings: [{ depth: 2, text: "Worker partial", slug: "worker-partial" }], + }, + }; + const result = await mergeWorkerPartialHeadings( + '## Before\n\n\n\n## After\n', + [ + { depth: 2, text: "Before", slug: "before" }, + { depth: 2, text: "After", slug: "after" }, + ], + makeGetEntry(partials), + makeRender(partials), + ); + assert.deepEqual(result.map(({ slug }) => slug), [ + "before", + "worker-partial", + "after", + ]); +}); + test("nested partial headings are included recursively", async () => { const parentBody = `## Parent\n\n\n`; const parentHeadings: Heading[] = [ diff --git a/packages/nimbus-docs/test/rendering-policy.test.ts b/packages/nimbus-docs/test/rendering-policy.test.ts new file mode 100644 index 00000000..811c7a93 --- /dev/null +++ b/packages/nimbus-docs/test/rendering-policy.test.ts @@ -0,0 +1,617 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { test, type TestContext } from "node:test"; + +import nimbus from "../src/index.js"; +import { + canonicalCollectionRouteComponent, + compileRenderingPolicy, + normalizeRouteComponent, + routeComponentKeys, +} from "../src/_internal/rendering-policy.js"; +import { getCodeStyleCSS } from "../src/_internal/code-style-registry.js"; +import { parseContentCollections } from "../src/_internal/parse-content-collections.js"; +import { requestInventoryEntryUrl } from "../src/_internal/request-route-url.js"; +import { validateNimbusConfig } from "../src/_internal/validate.js"; +import type { NimbusConfig, RenderingConfig } from "../src/types.js"; + +const baseConfig = (rendering?: RenderingConfig): NimbusConfig => ({ + site: "https://example.test", + title: "Docs", + search: false, + ...(rendering ? { rendering } : {}), +}); + +test("request inventory preserves prose ids and only collapses the API root", () => { + assert.equal(requestInventoryEntryUrl("", "index", false), "/index"); + assert.equal( + requestInventoryEntryUrl("", "guides/index", false), + "/guides/index", + ); + assert.equal(requestInventoryEntryUrl("/blog", "index", false), "/blog/index"); + assert.equal(requestInventoryEntryUrl("/api", "index", true), "/api"); + assert.equal( + requestInventoryEntryUrl("/api", "guides/index", true), + "/api/guides/index", + ); +}); + +test("rendering config is optional and validates only build/request modes", () => { + assert.equal(validateNimbusConfig(baseConfig()).rendering, undefined); + assert.deepEqual( + validateNimbusConfig( + baseConfig({ default: "request", collections: { docs: "build" } }), + ).rendering, + { default: "request", collections: { docs: "build" } }, + ); + + assert.throws( + () => validateNimbusConfig(baseConfig({ default: "invalid" as never })), + /rendering\.default: rendering mode must be either "build" or "request"/, + ); + assert.throws( + () => + validateNimbusConfig( + baseConfig({ collections: { docs: "invalid" as never } }), + ), + /rendering\.collections\.docs: rendering mode must be either "build" or "request"/, + ); + assert.throws( + () => + validateNimbusConfig({ + ...baseConfig(), + rendering: { default: "build", paths: {} }, + }), + /Unknown rendering sub-key "paths"/, + ); +}); + +test("compiled policy applies the build default and collection overrides", () => { + assert.deepEqual(compileRenderingPolicy(undefined, ["docs", "api"]), { + default: "build", + collections: { docs: "build", api: "build" }, + }); + assert.deepEqual( + compileRenderingPolicy( + { default: "request", collections: { docs: "build" } }, + ["docs", "api"], + ), + { + default: "request", + collections: { docs: "build", api: "request" }, + }, + ); +}); + +test("compiled policy rejects overrides without canonical collection routes", () => { + assert.throws( + () => + compileRenderingPolicy({ collections: { typo: "request" } }, ["docs"]), + /without a registered canonical catch-all route:[\s\S]*"typo"/, + ); +}); + +test("canonical route keys respect collection mounts and custom srcDir", () => { + const root = path.join(path.sep, "workspace"); + const srcDir = path.join(root, "app"); + const versions = { others: ["v1"] }; + + assert.equal( + canonicalCollectionRouteComponent(srcDir, "docs", versions), + path.join(srcDir, "pages", "[...slug].astro"), + ); + assert.equal( + canonicalCollectionRouteComponent(srcDir, "docs-v1", versions), + path.join(srcDir, "pages", "v1", "[...slug].astro"), + ); + assert.equal( + canonicalCollectionRouteComponent(srcDir, "api", versions), + path.join(srcDir, "pages", "api", "[...slug].astro"), + ); + assert.deepEqual( + routeComponentKeys( + root, + path.join(srcDir, "pages", "api", "[...slug].astro"), + ), + [ + normalizeRouteComponent( + path.join(srcDir, "pages", "api", "[...slug].astro"), + ), + "app/pages/api/[...slug].astro", + ], + ); +}); + +async function setupIntegration( + t: TestContext, + rendering?: RenderingConfig, + command: "dev" | "build" = "dev", + contentConfig = 'export const collections = { docs: {}, blog: {}, "docs-v1": {} };\n', + api?: NimbusConfig["api"], +) { + const root = await mkdtemp(path.join(tmpdir(), "nimbus-rendering-policy-")); + t.after(() => rm(root, { recursive: true, force: true })); + + const write = async (relative: string, body: string) => { + const file = path.join(root, relative); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, body, "utf8"); + }; + await write("src/content.config.ts", contentConfig); + await write("src/components.ts", "export const components = {};\n"); + await write("src/pages/[...slug].astro", "---\n---\n"); + await write("src/pages/blog/[...slug].astro", "---\n---\n"); + await write("src/pages/v1/[...slug].astro", "---\n---\n"); + await write("src/pages/api/[...slug].astro", "---\n---\n"); + await write( + "src/content/docs/index.mdx", + "# Docs\n\n```js\nconst requestRendered = true;\n```\n", + ); + + const integration = nimbus( + { + ...baseConfig(rendering), + versions: { current: "v2", others: ["v1"] }, + ...(api ? { api } : {}), + }, + { + validateMdx: false, + admonitions: false, + sitemap: false, + markdown: { processor: {} as never }, + }, + ); + const setup = integration.hooks["astro:config:setup"]; + assert.ok(setup); + const configUpdates: Array> = []; + const injectedRoutes: unknown[] = []; + await setup!({ + updateConfig: (update: Record) => { + configUpdates.push(update); + return {} as never; + }, + injectRoute: (route: unknown) => injectedRoutes.push(route), + config: { + root: pathToFileURL(`${root}${path.sep}`), + srcDir: pathToFileURL(`${path.join(root, "src")}${path.sep}`), + cacheDir: pathToFileURL(`${path.join(root, ".cache")}${path.sep}`), + base: "", + }, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + fork() { + return this; + }, + }, + command, + } as never); + + const routeSetup = integration.hooks["astro:route:setup"]; + const configDone = integration.hooks["astro:config:done"]; + const routesResolved = integration.hooks["astro:routes:resolved"]; + const serverSetup = integration.hooks["astro:server:setup"]; + const buildStart = integration.hooks["astro:build:start"]; + const buildDone = integration.hooks["astro:build:done"]; + assert.ok(routeSetup); + assert.ok(configDone); + assert.ok(routesResolved); + assert.ok(serverSetup); + assert.ok(buildStart); + assert.ok(buildDone); + return { + root, + configUpdates, + injectedRoutes, + routeSetup: routeSetup!, + configDone: configDone!, + routesResolved: routesResolved!, + serverSetup: serverSetup!, + buildStart: buildStart!, + buildDone: buildDone!, + }; +} + +test("route policy independently selects canonical collection catch-alls", async (t) => { + const { routeSetup } = await setupIntegration(t, { + default: "request", + collections: { docs: "build" }, + }); + const docs = { component: "src/pages/[...slug].astro", prerender: false }; + const blog = { component: "src/pages/blog/[...slug].astro", prerender: true }; + const version = { + component: "src/pages/v1/[...slug].astro", + prerender: true, + }; + const nearMatch = { + component: "src/pages/blog/[...path].astro", + prerender: true, + }; + + await routeSetup({ route: docs } as never); + await routeSetup({ route: blog } as never); + await routeSetup({ route: version } as never); + await routeSetup({ route: nearMatch } as never); + + assert.equal(docs.prerender, true); + assert.equal(blog.prerender, false); + assert.equal(version.prerender, false); + assert.equal(nearMatch.prerender, true); +}); + +test("omitted rendering policy leaves existing route decisions untouched", async (t) => { + const integration = await setupIntegration(t, undefined, "build"); + const docs = { component: "src/pages/[...slug].astro", prerender: false }; + const blog = { component: "src/pages/blog/[...slug].astro", prerender: true }; + + await integration.routeSetup({ route: docs } as never); + await integration.routeSetup({ route: blog } as never); + + assert.equal(docs.prerender, false); + assert.equal(blog.prerender, true); + assert.equal(integration.injectedRoutes.length, 0); + + integration.configDone({ + injectTypes: () => new URL("file:///noop"), + config: { output: "static" }, + buildOutput: "static", + } as never); + integration.routesResolved({ routes: [] } as never); + await integration.buildDone({ + dir: pathToFileURL(`${path.join(integration.root, "dist")}${path.sep}`), + pages: [{ pathname: "/_nimbus/request-route-inventory.json" }], + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + fork() { + return this; + }, + }, + } as never); + const routeTruth = JSON.parse( + await readFile(path.join(integration.root, ".nimbus/routes.json"), "utf8"), + ); + assert.deepEqual(routeTruth.knownRoutes, [ + "/_nimbus/request-route-inventory.json", + ]); +}); + +test("opaque version registrations still reach the request inventory", async (t) => { + const integration = await setupIntegration( + t, + { collections: { "docs-v1": "request" } }, + "build", + "const collections = makeCollections(); export { collections };\n", + ); + const plugins = integration.configUpdates.flatMap( + (update) => + (update.vite as { plugins?: unknown[] } | undefined)?.plugins ?? [], + ) as Array<{ + name?: string; + resolveId?(id: string): string | undefined; + load?(id: string): string | undefined; + }>; + const virtualConfig = plugins.find( + (plugin) => plugin.name === "nimbus-docs:virtual-config", + ); + assert.ok(virtualConfig?.resolveId && virtualConfig.load); + const resolved = virtualConfig.resolveId("virtual:nimbus/config"); + assert.ok(resolved); + assert.match( + virtualConfig.load(resolved) ?? "", + /requestRenderingCollections = \["docs-v1"\]/, + ); +}); + +test("an explicitly empty rendering policy applies the build default", async (t) => { + const { routeSetup } = await setupIntegration(t, {}); + const docs = { component: "src/pages/[...slug].astro", prerender: false }; + const blog = { + component: "src/pages/blog/[...slug].astro", + prerender: false, + }; + + await routeSetup({ route: docs } as never); + await routeSetup({ route: blog } as never); + + assert.equal(docs.prerender, true); + assert.equal(blog.prerender, true); +}); + +test("production request rendering requires server output and an adapter", async (t) => { + const staticBuild = await setupIntegration( + t, + { collections: { docs: "request" } }, + "build", + ); + assert.throws( + () => + staticBuild.configDone({ + injectTypes: () => new URL("file:///noop"), + config: { output: "static", adapter: null }, + buildOutput: "static", + } as never), + /requires Astro `output: "server"` and a compatible adapter.*output=static, adapter=none/, + ); + + const adapterlessBuild = await setupIntegration( + t, + { collections: { docs: "request" } }, + "build", + ); + assert.throws( + () => + adapterlessBuild.configDone({ + injectTypes: () => new URL("file:///noop"), + config: { output: "server", adapter: null }, + buildOutput: "server", + } as never), + /output=server, adapter=none/, + ); + + const serverBuild = await setupIntegration( + t, + { collections: { docs: "request" } }, + "build", + ); + assert.doesNotThrow(() => + serverBuild.configDone({ + injectTypes: () => new URL("file:///noop"), + config: { output: "server", adapter: { name: "cloudflare" } }, + buildOutput: "server", + } as never), + ); + + assert.throws( + () => + serverBuild.configDone({ + injectTypes: () => new URL("file:///noop"), + config: { output: "server", adapter: { name: "node" } }, + buildOutput: "server", + } as never), + /currently requires `@astrojs\/cloudflare`/, + ); +}); + +test("production API request rendering is accepted with model packaging", async (t) => { + const integration = await setupIntegration( + t, + { collections: { api: "request" } }, + "build", + 'export const collections = { docs: {}, "docs-v1": {}, api: {} };\n', + [ + { + collection: "api", + spec: { + openapi: "3.1.0", + info: { title: "API", version: "1" }, + paths: { + "/ping": { + get: { + operationId: "ping", + responses: { "200": { description: "OK" } }, + }, + }, + }, + }, + }, + ], + ); + assert.doesNotThrow(() => + integration.configDone({ + injectTypes: () => new URL("file:///noop"), + config: { output: "server", adapter: { name: "cloudflare" } }, + buildOutput: "server", + } as never), + ); +}); + +test("configured request routes are explained to the build invariant", async (t) => { + const integration = await setupIntegration( + t, + { collections: { docs: "request" } }, + "build", + ); + const route = { + component: "src/pages/[...slug].astro", + prerender: true, + }; + await integration.routeSetup({ route } as never); + integration.configDone({ + injectTypes: () => new URL("file:///noop"), + config: { output: "server", adapter: { name: "cloudflare" } }, + buildOutput: "server", + } as never); + integration.routesResolved({ + routes: [ + { + pattern: "/[...slug]", + entrypoint: "src/pages/[...slug].astro", + type: "page", + isPrerendered: false, + origin: "project", + }, + ], + } as never); + await integration.buildStart({} as never); + + const dist = path.join(integration.root, "dist"); + await mkdir(path.join(dist, "_nimbus"), { recursive: true }); + await writeFile( + path.join(dist, "_nimbus/request-route-inventory.json"), + JSON.stringify([ + { collection: "docs", url: "/guide/" }, + { collection: "docs", url: "/built/" }, + { collection: "blog", url: "/blog/post/" }, + ]), + "utf8", + ); + const infos: string[] = []; + await assert.doesNotReject(() => + integration.buildDone({ + dir: pathToFileURL(`${dist}${path.sep}`), + pages: [ + { pathname: "/built" }, + { pathname: "/foo/_nimbus/request-route-inventory.json" }, + { pathname: "/_nimbus/request-route-inventory.json" }, + ], + logger: { + info: (message: string) => infos.push(message), + warn: () => {}, + error: () => {}, + debug: () => {}, + fork() { + return this; + }, + }, + } as never), + ); + assert.equal(route.prerender, false); + assert.ok( + infos.some((message) => /docs prerendered=2\/3 \(1 moved\)/.test(message)), + ); + assert.deepEqual( + JSON.parse( + await readFile( + path.join(integration.root, ".nimbus/routes.json"), + "utf8", + ), + ), + { + version: 1, + base: "", + knownRoutes: [ + "/built", + "/foo/_nimbus/request-route-inventory.json", + "/guide", + ], + opaqueNamespaces: [], + }, + ); + assert.equal( + integration.injectedRoutes.some( + (candidate) => + (candidate as { pattern?: string }).pattern === + "/_nimbus/request-route-inventory.json", + ), + true, + ); + await assert.rejects(() => + readFile(path.join(dist, "_nimbus/request-route-inventory.json"), "utf8"), + ); + assert.match( + await readFile(path.join(dist, "_nimbus/shiki.css"), "utf8"), + /\.nb-shiki-/, + ); +}); + +test("dev setup preserves pre-registered request styles", async (t) => { + const integration = await setupIntegration(t, { + collections: { docs: "request" }, + }); + assert.match(getCodeStyleCSS(), /\.nb-shiki-/); + await integration.serverSetup({ + server: { + middlewares: { use: () => {} }, + watcher: { on: () => {} }, + config: { logger: { error: () => {} } }, + }, + } as never); + assert.match(getCodeStyleCSS(), /\.nb-shiki-/); +}); + +test("collection parsing reports whether registrations are complete", async (t) => { + const root = await mkdtemp(path.join(tmpdir(), "nimbus-collection-parse-")); + t.after(() => rm(root, { recursive: true, force: true })); + const file = path.join(root, "content.config.ts"); + + await writeFile(file, "export const collections = { docs: {}, blog: {} };\n"); + assert.deepEqual(await parseContentCollections(file), { + names: ["docs", "blog"], + complete: true, + }); + + await writeFile( + file, + "export const collections = { docs: {}, ...extras, [name]: value };\n", + ); + assert.deepEqual(await parseContentCollections(file), { + names: ["docs"], + complete: false, + }); + + await writeFile(file, "const all = {}; export { all as collections };\n"); + assert.deepEqual(await parseContentCollections(file), { + names: [], + complete: false, + }); + + await writeFile( + file, + "export const collections = { docs: {} }; collections.blog = {};\n", + ); + assert.deepEqual(await parseContentCollections(file), { + names: ["docs"], + complete: false, + }); + + await writeFile( + file, + "export const collections = { docs: {} }; type Name = keyof typeof collections;\n", + ); + assert.deepEqual(await parseContentCollections(file), { + names: ["docs"], + complete: true, + }); +}); + +test("opaque registrations cannot silently absorb request policy", async (t) => { + const contentConfig = + 'const extras = {}; export const collections = { docs: {}, "docs-v1": {}, ...extras };\n'; + await assert.rejects( + () => setupIntegration(t, { default: "request" }, "build", contentConfig), + /cannot safely enumerate collections.*cannot identify statically/, + ); + await assert.rejects( + () => + setupIntegration( + t, + { default: "request" }, + "build", + "const all = {}; export { all as collections };\n", + ), + /cannot safely enumerate collections.*cannot identify statically/, + ); + await assert.rejects( + () => + setupIntegration( + t, + { collections: { blog: "request" } }, + "build", + contentConfig, + ), + /cannot safely enumerate collections.*cannot identify statically/, + ); + + const knownOverride = await setupIntegration( + t, + { collections: { docs: "request" } }, + "build", + contentConfig, + ); + const docs = { component: "src/pages/[...slug].astro", prerender: true }; + await knownOverride.routeSetup({ route: docs } as never); + assert.equal(docs.prerender, false); + const injected = knownOverride.injectedRoutes[0] as { + entrypoint: URL; + }; + assert.equal(injected.entrypoint.protocol, "file:"); + assert.match(injected.entrypoint.pathname, /request-route-inventory\.ts$/); +}); diff --git a/packages/nimbus-docs/test/scan-code-langs.test.ts b/packages/nimbus-docs/test/scan-code-langs.test.ts index 875ff618..ae3829d2 100644 --- a/packages/nimbus-docs/test/scan-code-langs.test.ts +++ b/packages/nimbus-docs/test/scan-code-langs.test.ts @@ -1,5 +1,5 @@ /** - * `scanCodeBlockLanguages` feeds `shikiConfig.langs`, which Shiki eager-loads. + * The code-block scanner feeds Shiki's eager-loaded languages and generated CSS. * Shiki throws on grammars it can't resolve, so the scanner must (1) not mistake * inline `` ```x``` `` for a fenced block, and (2) drop unknown languages — * unknown code renders as plaintext (like Expressive Code), never a build crash. @@ -11,7 +11,10 @@ import { mkdtemp, writeFile, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { scanCodeBlockLanguages } from "../src/_internal/scan-code-langs.js"; +import { + scanCodeBlockLanguages, + scanCodeBlocks, +} from "../src/_internal/scan-code-langs.js"; async function scan(body: string, langAlias?: Record) { const root = await mkdtemp(path.join(tmpdir(), "nimbus-scanlang-")); @@ -50,6 +53,59 @@ test("keeps a real fence that carries a metadata info string", async () => { assert.deepEqual(langs, ["js"]); }); +test("collects tilde and long backtick fences", async () => { + const body = + "~~~js\nconst tilde = true;\n~~~\n\n````python\nlong = True\n`````\n"; + assert.deepEqual(await scan(body), ["js", "python"]); +}); + +test("requires a matching fence marker at least as long as the opener", async () => { + const root = await mkdtemp(path.join(tmpdir(), "nimbus-scanblock-")); + await mkdir(path.join(root, "src/content"), { recursive: true }); + await writeFile( + path.join(root, "src/content/a.mdx"), + "````js\nconst stillOpen = true;\n```\n````\n\n~~~python\nvalue = 1\n```\n~~~\n", + "utf8", + ); + assert.deepEqual(await scanCodeBlocks(root), [ + { lang: "js", code: "const stillOpen = true;\n```\n" }, + { lang: "python", code: "value = 1\n```\n" }, + ]); +}); + +test("collects container and EOF-terminated fences", async () => { + const root = await mkdtemp(path.join(tmpdir(), "nimbus-scanblock-")); + await mkdir(path.join(root, "src/content"), { recursive: true }); + await writeFile( + path.join(root, "src/content/a.mdx"), + "> ~~~python\n> quoted = True\n> ~~~\n\n100. item\n\n ```js\n listed = true\n ```\n\n~~~css\n.unclosed {}", + "utf8", + ); + assert.deepEqual(await scanCodeBlocks(root), [ + { lang: "python", code: "quoted = True\n" }, + { lang: "js", code: "listed = true\n" }, + { lang: "css", code: ".unclosed {}\n" }, + ]); +}); + +test("does not detect fence-like text inside an unlabeled outer fence", async () => { + const body = "```\n~~~js\nnot a real nested block\n~~~\n```\n\n```js{1}\nunknown token\n```\n"; + assert.deepEqual(await scan(body), []); +}); + +test("normalizes CRLF and de-indents code using CommonMark rules", async () => { + const root = await mkdtemp(path.join(tmpdir(), "nimbus-scanblock-")); + await mkdir(path.join(root, "src/content"), { recursive: true }); + await writeFile( + path.join(root, "src/content/a.mdx"), + " ~~~ts\r\n const value = true;\r\n ~~~\r\n", + "utf8", + ); + assert.deepEqual(await scanCodeBlocks(root), [ + { lang: "ts", code: "const value = true;\n" }, + ]); +}); + test("keeps special languages (text/plaintext) and applies langAlias", async () => { const langs = await scan("```text\nplain\n```\n\n```console\n$ ls\n```\n", { console: "shellsession", @@ -58,3 +114,30 @@ test("keeps special languages (text/plaintext) and applies langAlias", async () assert.ok(langs.includes("shellsession")); assert.ok(!langs.includes("console")); }); + +test("collects source for build-derived request-rendering styles", async () => { + const root = await mkdtemp(path.join(tmpdir(), "nimbus-scanblock-")); + await mkdir(path.join(root, "src/content"), { recursive: true }); + await writeFile( + path.join(root, "src/content/a.mdx"), + "```console\n$ nimbus build\n```\n\n```unknown\nnope\n```\n", + "utf8", + ); + assert.deepEqual(await scanCodeBlocks(root, { console: "shellsession" }), [ + { lang: "shellsession", code: "$ nimbus build\n" }, + ]); +}); + +test("scans valid Markdown with HTML comments", async () => { + const root = await mkdtemp(path.join(tmpdir(), "nimbus-scanblock-")); + await mkdir(path.join(root, "src/content"), { recursive: true }); + await writeFile( + path.join(root, "src/content/a.md"), + "\n\n```js\nconst visible = true;\n```\n", + "utf8", + ); + assert.deepEqual(await scanCodeBlockLanguages(root), ["js"]); + assert.deepEqual(await scanCodeBlocks(root), [ + { lang: "js", code: "const visible = true;\n" }, + ]); +}); diff --git a/packages/nimbus-docs/test/virtual-config.test.ts b/packages/nimbus-docs/test/virtual-config.test.ts new file mode 100644 index 00000000..dde81296 --- /dev/null +++ b/packages/nimbus-docs/test/virtual-config.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { virtualApiBuildConfigPlugin } from "../src/_internal/virtual-api-build-config.js"; +import { virtualConfigPlugin } from "../src/_internal/virtual-config.js"; +import type { NimbusConfig } from "../src/types.js"; + +const sentinel = "raw-openapi-must-not-ship"; +const api = [ + { + collection: "api", + spec: { + openapi: "3.1.0", + info: { title: "API", version: "1" }, + paths: {}, + sentinel, + }, + }, +]; + +test("runtime config strips inline API specs", () => { + const config = { api } as NimbusConfig; + const plugin = virtualConfigPlugin(config, { + indexedCollections: ["api"], + requestRenderingCollections: ["api"], + versionAlternates: {}, + apiCollections: ["api"], + headDefaults: { + favicon: { file: "/favicon.ico", type: "image/x-icon" }, + socialImage: "/opengraph.png", + }, + }); + const source = plugin.load("\0virtual:nimbus/config"); + + assert.ok(source); + assert.ok(!source.includes(sentinel)); + assert.match(source, /"spec":\{\}/); +}); + +test("build-only API config retains specs and the project root", () => { + const plugin = virtualApiBuildConfigPlugin(api, "/project"); + const source = plugin.load("\0virtual:nimbus/api-build-config"); + + assert.ok(source); + assert.ok(source.includes(sentinel)); + assert.ok(source.includes("/project")); +}); diff --git a/packages/nimbus-docs/tsdown.config.ts b/packages/nimbus-docs/tsdown.config.ts index 28dbba3b..58ebf5b0 100644 --- a/packages/nimbus-docs/tsdown.config.ts +++ b/packages/nimbus-docs/tsdown.config.ts @@ -8,6 +8,7 @@ const pkg = JSON.parse( export default defineConfig({ entry: { index: "src/index.ts", + runtime: "src/runtime.ts", config: "src/config.ts", content: "src/content.ts", schemas: "src/schemas.ts", @@ -20,6 +21,8 @@ export default defineConfig({ api: "src/api/index.ts", "lib/pkgm": "src/lib/pkgm.ts", "cli/index": "src/cli/index.ts", + "_internal/request-route-inventory": + "src/_internal/request-route-inventory.ts", }, format: "esm", dts: true, @@ -55,6 +58,8 @@ export default defineConfig({ // the same way Astro's content layer does. noExternal: [ "github-slugger", + "remark-mdx", + "remark-parse", "unified", "vfile", /^remark-lint-/, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79732fe0..a7c9a172 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,19 @@ settings: importers: .: + dependencies: + eslint-scope: + specifier: ^9.1.2 + version: 9.1.2 + eslint-visitor-keys: + specifier: ^5.0.1 + version: 5.0.1 + prettier: + specifier: ^3.9.6 + version: 3.9.6 + volar-service-prettier: + specifier: ^0.0.71 + version: 0.0.71(@volar/language-service@2.4.28)(prettier@3.9.6) devDependencies: '@changesets/changelog-github': specifier: ^0.7.0 @@ -293,6 +306,12 @@ importers: openapi-sampler: specifier: ^1.7.4 version: 1.7.4 + remark-mdx: + specifier: ^3.1.1 + version: 3.1.1 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 tsdown: specifier: ^0.20.3 version: 0.20.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(typescript@5.9.3) @@ -8053,7 +8072,7 @@ snapshots: esast-util-from-js@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - acorn: 8.16.0 + acorn: 8.18.0 esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 @@ -9004,7 +9023,7 @@ snapshots: mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 ccount: 2.0.1 @@ -9197,8 +9216,8 @@ snapshots: micromark-extension-mdxjs@3.0.0: dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) micromark-extension-mdx-expression: 3.0.1 micromark-extension-mdx-jsx: 3.0.2 micromark-extension-mdx-md: 2.0.0 From 862df4ac2786fbc46e0e40a5517c27f4dc39e8da Mon Sep 17 00:00:00 2001 From: mohamedh Date: Wed, 2 Sep 2026 12:38:06 +0100 Subject: [PATCH 04/16] feat: update starters for request rendering --- .changeset/workers-prose-rendering.md | 6 ++++++ apps/www/registry/features/api-reference.md | 12 ++++++++---- apps/www/src/pages/[...slug].astro | 6 ++++-- .../src/components/Header.astro | 2 +- .../src/components/Render.astro | 2 +- .../components/ui/api-field-row/ApiFieldRow.astro | 5 ++--- .../src/components/ui/api-layout/ApiBody.astro | 11 +++++------ .../src/components/ui/api-layout/ApiLayout.astro | 2 +- .../src/components/ui/code/Code.astro | 2 +- .../src/components/ui/sidebar/Sidebar.astro | 2 +- .../ui/version-switcher/VersionSwitcher.astro | 2 +- .../src/layouts/BaseLayout.astro | 2 +- .../src/layouts/DocsLayout.astro | 2 +- .../nimbus-starter-source/src/pages/[...slug].astro | 11 +++++++---- .../src/pages/[...slug]/index.md.ts | 2 +- .../src/pages/[...slug]/index.mdx.ts | 2 +- .../src/pages/[section]/llms.txt.ts | 2 +- .../nimbus-starter-source/src/pages/llms-full.txt.ts | 2 +- packages/nimbus-starter-source/src/pages/llms.txt.ts | 2 +- .../src/pages/nimbus-api/coordinates.json.ts | 2 +- .../nimbus-starter-source/src/pages/og/[...slug].ts | 2 +- .../nimbus-starter-source/src/pages/robots.txt.ts | 2 +- .../api-reference/src/pages/api/[...slug].astro | 6 ++++-- 23 files changed, 52 insertions(+), 37 deletions(-) create mode 100644 .changeset/workers-prose-rendering.md diff --git a/.changeset/workers-prose-rendering.md b/.changeset/workers-prose-rendering.md new file mode 100644 index 00000000..36b87c32 --- /dev/null +++ b/.changeset/workers-prose-rendering.md @@ -0,0 +1,6 @@ +--- +"@cloudflare/nimbus-docs": minor +"@cloudflare/create-nimbus-docs": minor +--- + +Render selected prose collections through the Cloudflare Workers adapter with request-safe partial headings, 404 responses, and build-derived syntax-highlighting assets. diff --git a/apps/www/registry/features/api-reference.md b/apps/www/registry/features/api-reference.md index bd33e893..365530b0 100644 --- a/apps/www/registry/features/api-reference.md +++ b/apps/www/registry/features/api-reference.md @@ -271,8 +271,10 @@ export async function GET({ props }: { props: SlugProps }) { ### 4e. Scaffold the HTML route The route is thin: `getApiStaticPaths` enumerates one path per page, and -`getApiPage(Astro)` builds the model and projects the page props + nav in a -single call — you hand both to `ApiLayout` (installed in 4a). `ApiLayout` composes `ApiSidebar` (verb chips + +`getApiRoute(Astro)` reads the page props and shared navigation prepared by the +content loader, then marks the current navigation path active. It never reads +or parses the OpenAPI source at request time. Hand both results to `ApiLayout` +(installed in 4a). `ApiLayout` composes `ApiSidebar` (verb chips + active-section pruning), `ApiFieldRow` (recursive fields with type links), and `ApiCodeRail` (server-generated code samples with a language switcher + a response-example status toggle), rendering any page @@ -298,7 +300,7 @@ Write `src/pages/api/[...slug].astro`: ```astro --- -import { getApiPage, getApiStaticPaths } from "@cloudflare/nimbus-docs"; +import { getApiRoute, getApiStaticPaths } from "@cloudflare/nimbus-docs/runtime"; import Header from "@/components/Header.astro"; import { ApiLayout } from "@/components/ui/api-layout"; import BaseLayout from "@/layouts/BaseLayout.astro"; @@ -306,7 +308,9 @@ import BaseLayout from "@/layouts/BaseLayout.astro"; export const prerender = true; export const getStaticPaths = getApiStaticPaths("api"); -const { page, nav, collection, version, coordinate } = await getApiPage(Astro); +const result = await getApiRoute(Astro); +if (result instanceof Response) return result; +const { page, nav, collection, version, coordinate } = result; --- { ); }; -const { entry, Content, headings } = await getDocsPageProps(Astro); +const page = await getDocsPage(Astro); +if (page instanceof Response) return page; +const { entry, Content, headings } = page; const currentSlug = Astro.url.pathname.replace(/\/$/, "") || "/"; diff --git a/packages/nimbus-starter-source/src/components/Header.astro b/packages/nimbus-starter-source/src/components/Header.astro index 7f22d052..33d2bf15 100644 --- a/packages/nimbus-starter-source/src/components/Header.astro +++ b/packages/nimbus-starter-source/src/components/Header.astro @@ -6,7 +6,7 @@ import { LinkButton } from "./ui/link-button"; import { ThemeToggle } from "./ui/theme-toggle"; import SearchTrigger from "./ui/search/SearchTrigger.astro"; import { config } from "virtual:nimbus/config"; -import { getSidebarSections } from "@cloudflare/nimbus-docs"; +import { getSidebarSections } from "@cloudflare/nimbus-docs/runtime"; interface Props { /** Astro collection id for the current page, forwarded from DocsLayout. */ diff --git a/packages/nimbus-starter-source/src/components/Render.astro b/packages/nimbus-starter-source/src/components/Render.astro index f9c80672..285f2341 100644 --- a/packages/nimbus-starter-source/src/components/Render.astro +++ b/packages/nimbus-starter-source/src/components/Render.astro @@ -9,7 +9,7 @@ * partial frontmatter (`params: [runtime, version?]`); required params * fail at build time, optional use a `?` suffix. */ -import { getVisibleEntries, getVisibleEntry } from "@cloudflare/nimbus-docs"; +import { getVisibleEntries, getVisibleEntry } from "@cloudflare/nimbus-docs/runtime"; import { render } from "astro:content"; import { components } from "../components"; diff --git a/packages/nimbus-starter-source/src/components/ui/api-field-row/ApiFieldRow.astro b/packages/nimbus-starter-source/src/components/ui/api-field-row/ApiFieldRow.astro index dd71890c..205af617 100644 --- a/packages/nimbus-starter-source/src/components/ui/api-field-row/ApiFieldRow.astro +++ b/packages/nimbus-starter-source/src/components/ui/api-field-row/ApiFieldRow.astro @@ -1,7 +1,6 @@ --- import ApiUnionExplorer from "./ApiUnionExplorer.astro"; import { typeTokens, constraintPairs, isExpandable, hasChildPreview } from "./type-display"; -import { renderMarkdown } from "@cloudflare/nimbus-docs/markdown"; import type { ApiFieldView } from "@cloudflare/nimbus-docs/api"; import "./field-list.css"; @@ -59,8 +58,8 @@ const linkSvg = `required} - {field.description && ( -

    + {field.descriptionHtml && ( +
    )} {constraints.length > 0 && ( diff --git a/packages/nimbus-starter-source/src/components/ui/api-layout/ApiBody.astro b/packages/nimbus-starter-source/src/components/ui/api-layout/ApiBody.astro index 03237486..042a4d23 100644 --- a/packages/nimbus-starter-source/src/components/ui/api-layout/ApiBody.astro +++ b/packages/nimbus-starter-source/src/components/ui/api-layout/ApiBody.astro @@ -20,8 +20,7 @@ import Code from "@/components/ui/code/Code.astro"; import ApiEndpointCard from "./ApiEndpointCard.astro"; import { authFields } from "./auth"; import { cn } from "@/lib/cn"; -import { renderMarkdown } from "@cloudflare/nimbus-docs/markdown"; -import { withBase } from "@cloudflare/nimbus-docs"; +import { withBase } from "@cloudflare/nimbus-docs/runtime"; import type { ApiPageProps } from "@cloudflare/nimbus-docs/api"; interface Props { @@ -89,8 +88,8 @@ const deprecationHtml = page.deprecated

    {page.title}

    - {page.description && ( -
    + {page.descriptionHtml && ( +
    )} {page.kind === "operation" && ( @@ -151,8 +150,8 @@ const deprecationHtml = page.deprecated needed. Description sits inline as a sibling, not inside the h3. */}

    {r.status}

    - {r.description && ( -
    + {r.descriptionHtml && ( +
    )}
    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 f5b26a12..dc292ce7 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 @@ -11,7 +11,7 @@ import { ApiSidebar } from "@/components/ui/api-sidebar"; import { ApiCodeRail } from "@/components/ui/api-code-rail"; import { Banner } from "@/components/ui/banner"; import ApiBody from "./ApiBody.astro"; -import { getApiVersionAlternates, getVersionStatus } from "@cloudflare/nimbus-docs"; +import { getApiVersionAlternates, getVersionStatus } from "@cloudflare/nimbus-docs/runtime"; import type { ApiNav, ApiPageProps } from "@cloudflare/nimbus-docs/api"; interface Props { diff --git a/packages/nimbus-starter-source/src/components/ui/code/Code.astro b/packages/nimbus-starter-source/src/components/ui/code/Code.astro index 34feef72..b25916c7 100644 --- a/packages/nimbus-starter-source/src/components/ui/code/Code.astro +++ b/packages/nimbus-starter-source/src/components/ui/code/Code.astro @@ -6,7 +6,7 @@ * */ import { Code as AstroCode } from "astro:components"; -import { defaultCodeTransformers } from "@cloudflare/nimbus-docs"; +import { defaultCodeTransformers } from "@cloudflare/nimbus-docs/runtime"; type Props = Parameters[0]; const rawProps = Astro.props as Props; diff --git a/packages/nimbus-starter-source/src/components/ui/sidebar/Sidebar.astro b/packages/nimbus-starter-source/src/components/ui/sidebar/Sidebar.astro index 5795b0bb..157b0502 100644 --- a/packages/nimbus-starter-source/src/components/ui/sidebar/Sidebar.astro +++ b/packages/nimbus-starter-source/src/components/ui/sidebar/Sidebar.astro @@ -9,7 +9,7 @@ import type { HTMLAttributes } from "astro/types"; import SidebarGroup from "./SidebarGroup.astro"; import SidebarLink from "./SidebarLink.astro"; import type { SidebarItem } from "@cloudflare/nimbus-docs/types"; -import { sidebarHash } from "@cloudflare/nimbus-docs"; +import { sidebarHash } from "@cloudflare/nimbus-docs/runtime"; interface Props extends HTMLAttributes<"div"> { items: SidebarItem[]; diff --git a/packages/nimbus-starter-source/src/components/ui/version-switcher/VersionSwitcher.astro b/packages/nimbus-starter-source/src/components/ui/version-switcher/VersionSwitcher.astro index ddd427cb..6d07fae5 100644 --- a/packages/nimbus-starter-source/src/components/ui/version-switcher/VersionSwitcher.astro +++ b/packages/nimbus-starter-source/src/components/ui/version-switcher/VersionSwitcher.astro @@ -8,7 +8,7 @@ import { getVersionAlternates, getVersionLandingUrl, getVersions, -} from "@cloudflare/nimbus-docs"; +} from "@cloudflare/nimbus-docs/runtime"; import { Popover, PopoverContent, PopoverTrigger } from "../popover"; import { cn } from "@/lib/cn"; diff --git a/packages/nimbus-starter-source/src/layouts/BaseLayout.astro b/packages/nimbus-starter-source/src/layouts/BaseLayout.astro index 0140d774..5f219228 100644 --- a/packages/nimbus-starter-source/src/layouts/BaseLayout.astro +++ b/packages/nimbus-starter-source/src/layouts/BaseLayout.astro @@ -9,7 +9,7 @@ import { getCollectionLlmsUrl, getVersionStatus, withBase, -} from "@cloudflare/nimbus-docs"; +} from "@cloudflare/nimbus-docs/runtime"; import AgentDirective from "@/components/AgentDirective.astro"; import { SearchDialog } from "@/components/ui/search"; import NimbusHead from "@cloudflare/nimbus-docs/components/NimbusHead.astro"; diff --git a/packages/nimbus-starter-source/src/layouts/DocsLayout.astro b/packages/nimbus-starter-source/src/layouts/DocsLayout.astro index a3bc3f4e..9182c07c 100644 --- a/packages/nimbus-starter-source/src/layouts/DocsLayout.astro +++ b/packages/nimbus-starter-source/src/layouts/DocsLayout.astro @@ -16,7 +16,7 @@ import { Pagination } from "@/components/ui/pagination"; import { PageActions } from "@/components/ui/page-actions"; import { Badge } from "@/components/ui/badge"; import type { DocsPageProps } from "@cloudflare/nimbus-docs/types"; -import { getVersionStatus, getVersionAlternates } from "@cloudflare/nimbus-docs"; +import { getVersionStatus, getVersionAlternates } from "@cloudflare/nimbus-docs/runtime"; type Props = DocsPageProps & { audience?: "human" }; diff --git a/packages/nimbus-starter-source/src/pages/[...slug].astro b/packages/nimbus-starter-source/src/pages/[...slug].astro index b5cff563..ea17fcf8 100644 --- a/packages/nimbus-starter-source/src/pages/[...slug].astro +++ b/packages/nimbus-starter-source/src/pages/[...slug].astro @@ -2,7 +2,7 @@ import DocsLayout from "../layouts/DocsLayout.astro"; import { getDocsStaticPaths, - getDocsPageProps, + getDocsPage, getRouteFlags, getSidebar, getPrevNext, @@ -11,13 +11,15 @@ import { getLastUpdated, getTOC, withBase, -} from "@cloudflare/nimbus-docs"; +} from "@cloudflare/nimbus-docs/runtime"; import { components } from "../components"; export const prerender = true; export const getStaticPaths = getDocsStaticPaths; -const { entry, Content, headings } = await getDocsPageProps(Astro); +const page = await getDocsPage(Astro); +if (page instanceof Response) return page; +const { entry, Content, headings } = page; const currentSlug = Astro.url.pathname.replace(/\/$/, "") || "/"; @@ -35,7 +37,8 @@ const prevNext = await getPrevNext(currentSlug, { const breadcrumbs = await getBreadcrumbs(currentSlug, { collection: entry.collection }); const editUrl = await getEditUrl(entry); // Frontmatter wins; git is the fallback. -const lastUpdated = entry.data.lastUpdated ?? await getLastUpdated(entry); +const lastUpdated = entry.data.lastUpdated ?? + (Astro.isPrerendered ? await getLastUpdated(entry) : undefined); // `tocOn` already implies `tableOfContents !== false`, but TS can't carry // that boolean narrowing to the value here — re-check it so `getTOC` only // ever sees its options object (or undefined), never `false`. diff --git a/packages/nimbus-starter-source/src/pages/[...slug]/index.md.ts b/packages/nimbus-starter-source/src/pages/[...slug]/index.md.ts index d8894856..fcb34bda 100644 --- a/packages/nimbus-starter-source/src/pages/[...slug]/index.md.ts +++ b/packages/nimbus-starter-source/src/pages/[...slug]/index.md.ts @@ -14,7 +14,7 @@ import { getIndexedEntries, type IndexedEntry, withBase, -} from "@cloudflare/nimbus-docs"; +} from "@cloudflare/nimbus-docs/runtime"; import { config } from "virtual:nimbus/config"; export const prerender = true; diff --git a/packages/nimbus-starter-source/src/pages/[...slug]/index.mdx.ts b/packages/nimbus-starter-source/src/pages/[...slug]/index.mdx.ts index 243e2386..a97a92db 100644 --- a/packages/nimbus-starter-source/src/pages/[...slug]/index.mdx.ts +++ b/packages/nimbus-starter-source/src/pages/[...slug]/index.mdx.ts @@ -16,7 +16,7 @@ import { getIndexedEntries, type IndexedEntry, withBase, -} from "@cloudflare/nimbus-docs"; +} from "@cloudflare/nimbus-docs/runtime"; import { config } from "virtual:nimbus/config"; export const prerender = true; diff --git a/packages/nimbus-starter-source/src/pages/[section]/llms.txt.ts b/packages/nimbus-starter-source/src/pages/[section]/llms.txt.ts index 4e63576e..8de9fc52 100644 --- a/packages/nimbus-starter-source/src/pages/[section]/llms.txt.ts +++ b/packages/nimbus-starter-source/src/pages/[section]/llms.txt.ts @@ -20,7 +20,7 @@ import { getIndexedTopLevel, type IndexedEntry, withBase, -} from "@cloudflare/nimbus-docs"; +} from "@cloudflare/nimbus-docs/runtime"; import { config } from "virtual:nimbus/config"; export const prerender = true; diff --git a/packages/nimbus-starter-source/src/pages/llms-full.txt.ts b/packages/nimbus-starter-source/src/pages/llms-full.txt.ts index 1b18d9cd..f6134e5d 100644 --- a/packages/nimbus-starter-source/src/pages/llms-full.txt.ts +++ b/packages/nimbus-starter-source/src/pages/llms-full.txt.ts @@ -1,7 +1,7 @@ // Full-corpus markdown for AI agents — every published page in one // document. Scope and collation live in the framework helper; reshape or // delete this route to change the site's corpus policy. -import { renderCorpusMarkdown } from "@cloudflare/nimbus-docs"; +import { renderCorpusMarkdown } from "@cloudflare/nimbus-docs/runtime"; export const prerender = true; diff --git a/packages/nimbus-starter-source/src/pages/llms.txt.ts b/packages/nimbus-starter-source/src/pages/llms.txt.ts index 2008462e..5500a54c 100644 --- a/packages/nimbus-starter-source/src/pages/llms.txt.ts +++ b/packages/nimbus-starter-source/src/pages/llms.txt.ts @@ -1,5 +1,5 @@ // Root /llms.txt — sectioned index for AI agents. -import { getIndexedTopLevel, withBase } from "@cloudflare/nimbus-docs"; +import { getIndexedTopLevel, withBase } from "@cloudflare/nimbus-docs/runtime"; import { config } from "virtual:nimbus/config"; export const prerender = true; diff --git a/packages/nimbus-starter-source/src/pages/nimbus-api/coordinates.json.ts b/packages/nimbus-starter-source/src/pages/nimbus-api/coordinates.json.ts index 932305b0..533bbc74 100644 --- a/packages/nimbus-starter-source/src/pages/nimbus-api/coordinates.json.ts +++ b/packages/nimbus-starter-source/src/pages/nimbus-api/coordinates.json.ts @@ -3,7 +3,7 @@ * fetched by other sites that cite its APIs via `apiReferences[]`. */ -import { getCoordinatesManifest } from "@cloudflare/nimbus-docs"; +import { getCoordinatesManifest } from "@cloudflare/nimbus-docs/runtime"; export const prerender = true; diff --git a/packages/nimbus-starter-source/src/pages/og/[...slug].ts b/packages/nimbus-starter-source/src/pages/og/[...slug].ts index 07db2919..91329cb6 100644 --- a/packages/nimbus-starter-source/src/pages/og/[...slug].ts +++ b/packages/nimbus-starter-source/src/pages/og/[...slug].ts @@ -1,4 +1,4 @@ -import { getVisibleEntries } from "@cloudflare/nimbus-docs"; +import { getVisibleEntries } from "@cloudflare/nimbus-docs/runtime"; import { OGImageRoute } from "astro-og-canvas"; import { ogCardConfig } from "./_og-card-config"; diff --git a/packages/nimbus-starter-source/src/pages/robots.txt.ts b/packages/nimbus-starter-source/src/pages/robots.txt.ts index d4bda344..1d5fa64b 100644 --- a/packages/nimbus-starter-source/src/pages/robots.txt.ts +++ b/packages/nimbus-starter-source/src/pages/robots.txt.ts @@ -1,4 +1,4 @@ -import { withBase } from "@cloudflare/nimbus-docs"; +import { withBase } from "@cloudflare/nimbus-docs/runtime"; import { config } from "virtual:nimbus/config"; export const prerender = true; diff --git a/scripts/fixtures/api-reference/src/pages/api/[...slug].astro b/scripts/fixtures/api-reference/src/pages/api/[...slug].astro index e89fb7ac..08b54b96 100644 --- a/scripts/fixtures/api-reference/src/pages/api/[...slug].astro +++ b/scripts/fixtures/api-reference/src/pages/api/[...slug].astro @@ -1,5 +1,5 @@ --- -import { getApiPage, getApiStaticPaths } from "@cloudflare/nimbus-docs"; +import { getApiRoute, getApiStaticPaths } from "@cloudflare/nimbus-docs/runtime"; import Header from "@/components/Header.astro"; import { ApiLayout } from "@/components/ui/api-layout"; import BaseLayout from "@/layouts/BaseLayout.astro"; @@ -7,7 +7,9 @@ import BaseLayout from "@/layouts/BaseLayout.astro"; export const prerender = true; export const getStaticPaths = getApiStaticPaths("api"); -const { page, nav, collection, version, coordinate } = await getApiPage(Astro); +const result = await getApiRoute(Astro); +if (result instanceof Response) return result; +const { page, nav, collection, version, coordinate } = result; --- Date: Wed, 2 Sep 2026 12:38:36 +0100 Subject: [PATCH 05/16] test: harden workers rendering acceptance --- .../workers-feasibility/astro.config.ts | 34 ++------ .../workers-feasibility/nimbus.config.ts | 9 ++- .../workers-feasibility/public/opengraph.png | 1 + .../workers-feasibility/src/content.config.ts | 54 ++----------- .../src/content/docs/runtime.mdx | 4 +- .../src/pages/[...slug].astro | 31 ++++---- .../src/pages/api/[...slug].astro | 18 ++--- .../src/worker-safe-markdown.ts | 15 ---- .../src/worker-safe-partial-headings.ts | 78 ------------------- scripts/workers-feasibility-check.mjs | 30 +++---- 10 files changed, 57 insertions(+), 217 deletions(-) create mode 100644 scripts/fixtures/workers-feasibility/public/opengraph.png delete mode 100644 scripts/fixtures/workers-feasibility/src/worker-safe-markdown.ts delete mode 100644 scripts/fixtures/workers-feasibility/src/worker-safe-partial-headings.ts diff --git a/scripts/fixtures/workers-feasibility/astro.config.ts b/scripts/fixtures/workers-feasibility/astro.config.ts index 5a6b1ce1..9fe9aa16 100644 --- a/scripts/fixtures/workers-feasibility/astro.config.ts +++ b/scripts/fixtures/workers-feasibility/astro.config.ts @@ -3,43 +3,21 @@ import { defineConfig } from "astro/config"; import tailwindcss from "@tailwindcss/vite"; import nimbus from "@cloudflare/nimbus-docs"; import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; import nimbusConfig from "./nimbus.config"; const rendering = JSON.parse( readFileSync(new URL("./.nimbus/feasibility-rendering.json", import.meta.url), "utf8"), -) as Record; -const integration = nimbus(nimbusConfig); -const hooks = { ...integration.hooks }; -if (rendering.docs === "request" || rendering.api === "request") { - delete hooks["astro:build:done"]; -} -const routePolicy = { - name: "workers-feasibility:route-policy", - hooks: { - "astro:route:setup": ({ route }: { route: { component: string; prerender?: boolean } }) => { - const component = route.component.replaceAll("\\", "/"); - if (component.endsWith("src/pages/api/[...slug].astro")) { - route.prerender = rendering.api !== "request"; - } else if (component.endsWith("src/pages/[...slug].astro")) { - route.prerender = rendering.docs !== "request"; - } - }, - }, -}; +) as Record; +const integration = nimbus({ + ...nimbusConfig, + rendering: { collections: rendering }, +}); export default defineConfig({ output: "server", adapter: cloudflare({ prerenderEnvironment: "node" }), vite: { plugins: [tailwindcss()], - resolve: { - alias: { - "@cloudflare/nimbus-docs/markdown": fileURLToPath( - new URL("./src/worker-safe-markdown.ts", import.meta.url), - ), - }, - }, }, - integrations: [routePolicy, { ...integration, hooks }], + integrations: [integration], }); diff --git a/scripts/fixtures/workers-feasibility/nimbus.config.ts b/scripts/fixtures/workers-feasibility/nimbus.config.ts index db82c8f1..d229e96d 100644 --- a/scripts/fixtures/workers-feasibility/nimbus.config.ts +++ b/scripts/fixtures/workers-feasibility/nimbus.config.ts @@ -7,5 +7,12 @@ export default defineConfig({ locale: "en", github: null, search: false, - socialImage: "/og.png", + rendering: { collections: { api: "request" } }, + api: [ + { + collection: "api", + spec: "src/content/api/openapi.json", + label: "Feasibility API", + }, + ], }); diff --git a/scripts/fixtures/workers-feasibility/public/opengraph.png b/scripts/fixtures/workers-feasibility/public/opengraph.png new file mode 100644 index 00000000..c4ca2677 --- /dev/null +++ b/scripts/fixtures/workers-feasibility/public/opengraph.png @@ -0,0 +1 @@ +workers-feasibility-placeholder diff --git a/scripts/fixtures/workers-feasibility/src/content.config.ts b/scripts/fixtures/workers-feasibility/src/content.config.ts index 6d6d32f5..be01e595 100644 --- a/scripts/fixtures/workers-feasibility/src/content.config.ts +++ b/scripts/fixtures/workers-feasibility/src/content.config.ts @@ -1,54 +1,16 @@ import { defineCollection } from "astro:content"; -import { z } from "astro/zod"; -import { readFile } from "node:fs/promises"; -import { docsCollection, partialsCollection } from "@cloudflare/nimbus-docs/content"; import { - buildApiModel, - getApiNav, - getApiPageProps, - getApiPageSlugs, - type ApiNav, - type ApiPageProps, -} from "@cloudflare/nimbus-docs/api"; + apiCollection, + docsCollection, + partialsCollection, +} from "@cloudflare/nimbus-docs/content"; +import nimbusConfig from "../nimbus.config"; -const source = { - collection: "api", - mountPath: "/api", - label: "Feasibility API", -}; - -const api = defineCollection({ - loader: { - name: "workers-feasibility:prepared-api", - async load({ store, parseData }) { - store.clear(); - const spec = JSON.parse( - await readFile(new URL("./content/api/openapi.json", import.meta.url), "utf8"), - ); - const model = await buildApiModel({ ...source, spec }); - for (const { coordinate, slug } of getApiPageSlugs(model)) { - const id = slug || "index"; - const data = await parseData({ - id, - data: { - coordinate, - page: getApiPageProps(model, coordinate), - nav: getApiNav(model, coordinate), - }, - }); - store.set({ id, data }); - } - }, - }, - schema: z.object({ - coordinate: z.string(), - page: z.custom(), - nav: z.custom(), - }), -}); +const api = nimbusConfig.api?.find((entry) => entry.collection === "api"); +if (!api) throw new Error('Missing the "api" collection in nimbus.config.ts'); export const collections = { docs: defineCollection(docsCollection()), partials: defineCollection(partialsCollection()), - api, + api: defineCollection(apiCollection(api)), }; diff --git a/scripts/fixtures/workers-feasibility/src/content/docs/runtime.mdx b/scripts/fixtures/workers-feasibility/src/content/docs/runtime.mdx index c13af91d..8ab9de97 100644 --- a/scripts/fixtures/workers-feasibility/src/content/docs/runtime.mdx +++ b/scripts/fixtures/workers-feasibility/src/content/docs/runtime.mdx @@ -12,8 +12,8 @@ Request prose body. This component rendered from the MDX registry. -```ts title="worker.ts" +~~~~ts title="worker.ts" export default { fetch: () => new Response("healthy") }; -``` +~~~~ diff --git a/scripts/fixtures/workers-feasibility/src/pages/[...slug].astro b/scripts/fixtures/workers-feasibility/src/pages/[...slug].astro index 70cf8747..115ac841 100644 --- a/scripts/fixtures/workers-feasibility/src/pages/[...slug].astro +++ b/scripts/fixtures/workers-feasibility/src/pages/[...slug].astro @@ -1,31 +1,30 @@ --- import DocsLayout from "../layouts/DocsLayout.astro"; -import { render, type CollectionEntry } from "astro:content"; import { getBreadcrumbs, getDocsStaticPaths, + getDocsPage, + getDocsPageProps, getEditUrl, getPrevNext, getRouteFlags, getSidebar, getTOC, - getVisibleEntry, withBase, -} from "@cloudflare/nimbus-docs"; +} from "@cloudflare/nimbus-docs/runtime"; import { components } from "../components"; -import { mergeFixturePartialHeadings } from "../worker-safe-partial-headings"; export const prerender = true; export const getStaticPaths = getDocsStaticPaths; -const staticEntry = (Astro.props as { entry?: CollectionEntry<"docs"> }).entry; -const entry = staticEntry ?? await getVisibleEntry("docs", Astro.params.slug ?? "index"); -if (!entry) return new Response("Not found", { status: 404 }); - -const { Content, headings: ownHeadings } = await render(entry); -const headings = await mergeFixturePartialHeadings(entry.body, ownHeadings); +const page = Astro.url.pathname === "/runtime/" + ? await getDocsPageProps(Astro) + : await getDocsPage(Astro); +if (page instanceof Response) return page; +const { entry, Content, headings } = page; const currentSlug = Astro.url.pathname.replace(/\/$/, "") || "/"; -const { sidebar: sidebarOn, tableOfContents: tocOn } = await getRouteFlags(entry); +const { sidebar: sidebarOn, tableOfContents: tocOn } = + await getRouteFlags(entry); const sidebar = sidebarOn ? await getSidebar(currentSlug, { collection: entry.collection }) : false; @@ -33,14 +32,18 @@ const prevNext = await getPrevNext(currentSlug, { sidebarTree: sidebar === false ? [] : sidebar, overrides: { prev: entry.data.prev, next: entry.data.next }, }); -const breadcrumbs = await getBreadcrumbs(currentSlug, { collection: entry.collection }); +const breadcrumbs = await getBreadcrumbs(currentSlug, { + collection: entry.collection, +}); const editUrl = await getEditUrl(entry); const tocConfig = entry.data.tableOfContents; const toc = tocOn && tocConfig !== false ? getTOC(headings, tocConfig) : false; const markdownPath = entry.id === "index" ? "/index.md" : `/${entry.id}/index.md`; const basedMarkdownPath = withBase(markdownPath, import.meta.env.BASE_URL); -const markdownUrl = Astro.site ? new URL(basedMarkdownPath, Astro.site).href : basedMarkdownPath; -const socialImage = entry.data.socialImage ?? `/og/${entry.id}.png`; +const markdownUrl = Astro.site + ? new URL(basedMarkdownPath, Astro.site).href + : basedMarkdownPath; +const socialImage = entry.data.socialImage; const requestProbe = Astro.request.headers.get("x-nimbus-probe") ?? ""; --- diff --git a/scripts/fixtures/workers-feasibility/src/pages/api/[...slug].astro b/scripts/fixtures/workers-feasibility/src/pages/api/[...slug].astro index ac83c862..8da29bc2 100644 --- a/scripts/fixtures/workers-feasibility/src/pages/api/[...slug].astro +++ b/scripts/fixtures/workers-feasibility/src/pages/api/[...slug].astro @@ -1,23 +1,15 @@ --- -import { getCollection, getEntry, type CollectionEntry } from "astro:content"; +import { getApiRoute, getApiStaticPaths } from "@cloudflare/nimbus-docs/runtime"; import Header from "@/components/Header.astro"; import { ApiLayout } from "@/components/ui/api-layout"; import BaseLayout from "@/layouts/BaseLayout.astro"; export const prerender = true; -export async function getStaticPaths() { - const entries = await getCollection("api"); - return entries.map((entry) => ({ - params: { slug: entry.id === "index" ? undefined : entry.id }, - props: { entry }, - })); -} +export const getStaticPaths = getApiStaticPaths("api"); -const requestEntry = await getEntry("api", Astro.params.slug ?? "index"); -const entry = (Astro.props.entry ?? requestEntry) as CollectionEntry<"api"> | undefined; -if (!entry) return new Response("Not found", { status: 404 }); - -const { page, nav, coordinate } = entry.data; +const result = await getApiRoute(Astro); +if (result instanceof Response) return result; +const { page, nav, coordinate } = result; const requestProbe = Astro.request.headers.get("x-nimbus-probe") ?? ""; --- diff --git a/scripts/fixtures/workers-feasibility/src/worker-safe-markdown.ts b/scripts/fixtures/workers-feasibility/src/worker-safe-markdown.ts deleted file mode 100644 index e090e388..00000000 --- a/scripts/fixtures/workers-feasibility/src/worker-safe-markdown.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { fromHtml } from "hast-util-from-html"; -import { defaultSchema, sanitize } from "hast-util-sanitize"; -import { toHtml } from "hast-util-to-html"; -import { micromark } from "micromark"; -import { gfm, gfmHtml } from "micromark-extension-gfm"; - -export function renderMarkdown(source: string | undefined | null): string { - if (!source?.trim()) return ""; - const raw = micromark(source.trim(), { - allowDangerousHtml: true, - extensions: [gfm()], - htmlExtensions: [gfmHtml()], - }); - return toHtml(sanitize(fromHtml(raw, { fragment: true }), defaultSchema)); -} diff --git a/scripts/fixtures/workers-feasibility/src/worker-safe-partial-headings.ts b/scripts/fixtures/workers-feasibility/src/worker-safe-partial-headings.ts deleted file mode 100644 index 5f03f40e..00000000 --- a/scripts/fixtures/workers-feasibility/src/worker-safe-partial-headings.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { getEntry, render } from "astro:content"; -import remarkMdx from "remark-mdx"; -import remarkParse from "remark-parse"; -import { unified } from "unified"; - -interface Heading { - depth: number; - text: string; - slug: string; -} - -interface Node { - type: string; - name?: string | null; - attributes?: unknown[]; - children?: Node[]; -} - -interface Attribute { - type: string; - name: string; - value?: string | null | { type: string; value: string }; -} - -type Slot = { kind: "heading" } | { kind: "render"; file?: string }; - -const parser = unified().use(remarkParse).use(remarkMdx); - -export async function mergeFixturePartialHeadings( - body: string | undefined, - headings: Heading[], -): Promise { - if (!body) return headings; - - const slots: Slot[] = []; - collectSlots(parser.parse(body) as unknown as Node, slots); - const merged: Heading[] = []; - let headingIndex = 0; - - for (const slot of slots) { - if (slot.kind === "heading") { - const heading = headings[headingIndex++]; - if (heading) merged.push(heading); - continue; - } - - if (!slot.file) continue; - const partial = await getEntry("partials", slot.file); - if (!partial) continue; - const rendered = await render(partial); - merged.push( - ...(await mergeFixturePartialHeadings(partial.body, rendered.headings)), - ); - } - - merged.push(...headings.slice(headingIndex)); - return merged; -} - -function collectSlots(node: Node, slots: Slot[]): void { - if (node.type === "heading") { - slots.push({ kind: "heading" }); - return; - } - - if ( - (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") && - node.name === "Render" - ) { - const file = (node.attributes as Attribute[] | undefined)?.find( - (attribute) => attribute.type === "mdxJsxAttribute" && attribute.name === "file", - )?.value; - slots.push({ kind: "render", file: typeof file === "string" ? file : undefined }); - return; - } - - for (const child of node.children ?? []) collectSlots(child, slots); -} diff --git a/scripts/workers-feasibility-check.mjs b/scripts/workers-feasibility-check.mjs index 9a110edb..99614c70 100644 --- a/scripts/workers-feasibility-check.mjs +++ b/scripts/workers-feasibility-check.mjs @@ -100,6 +100,11 @@ function assertProse(html) { ); assert(html.includes("class=\"astro-code"), "syntax-highlighted code did not render"); assert(html.includes("nb-shiki-"), "syntax-highlighted tokens did not render"); + assert(html.includes('href="/favicon.ico"'), "build-derived favicon metadata did not render"); + assert( + html.includes('content="https://workers-feasibility.test/opengraph.png"'), + "build-derived social metadata did not render", + ); } function assertPreparedApi(html, kind) { @@ -218,8 +223,6 @@ const nimbusPackage = JSON.parse(readFileSync(NIMBUS_PACKAGE, "utf8")); for (const dependency of [ "micromark", "micromark-extension-gfm", - "remark-mdx", - "remark-parse", ]) { for (const field of ["dependencies", "optionalDependencies", "peerDependencies"]) { assert( @@ -271,15 +274,7 @@ packageJson.dependencies["@cloudflare/nimbus-docs"] = `file:${join(packRoot, tar packageJson.dependencies["@bruits/satteri-wasm32-wasi"] = "0.9.5"; packageJson.dependencies["@readme/httpsnippet"] = "11.4.0"; packageJson.dependencies["@scalar/openapi-parser"] = "0.28.12"; -packageJson.dependencies["hast-util-from-html"] = "2.0.3"; -packageJson.dependencies["hast-util-sanitize"] = "5.0.2"; -packageJson.dependencies["hast-util-to-html"] = "9.0.5"; -packageJson.dependencies.micromark = "4.0.2"; -packageJson.dependencies["micromark-extension-gfm"] = "3.0.0"; packageJson.dependencies["openapi-sampler"] = "1.7.4"; -packageJson.dependencies["remark-mdx"] = "3.1.1"; -packageJson.dependencies["remark-parse"] = "11.0.0"; -packageJson.dependencies.unified = "11.0.5"; writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); mkdirSync(join(site, "src", "pages", "api"), { recursive: true }); @@ -302,27 +297,24 @@ for (const kind of ["api", "section", "operation", "schema"]) { const shikiCss = readFileSync(join(site, "dist", "client", "_nimbus", "shiki.css"), "utf8"); assert(shikiCss.includes(".nb-shiki-"), "all-build baseline omitted Shiki token styles"); -function restoreShikiCss() { - const cssDir = join(site, "dist", "client", "_nimbus"); - mkdirSync(cssDir, { recursive: true }); - writeFileSync(join(cssDir, "shiki.css"), shikiCss); -} - console.log(`${PREFIX} proving request prose beside build-rendered API pages`); build(site, { docs: "request", api: "build" }); -restoreShikiCss(); const requestProsePages = captureStaticPages(site); assert(findMarkedPages(requestProsePages, "data-feasibility-prose").length === 0, "request prose emitted static HTML"); assert(apiKinds(requestProsePages).size === 4, "build API pages were not emitted beside request prose"); await withWorkerd(site, async (origin) => { const first = await request(origin, proseStatic[0][0], "prose-one"); const second = await request(origin, proseStatic[0][0], "prose-two"); - assert(first.response.status === 200 && second.response.status === 200, "request prose was not 200"); + assert( + first.response.status === 200 && second.response.status === 200, + `request prose returned ${first.response.status}/${second.response.status}: ${first.html.slice(0, 500)}`, + ); assertProse(first.html); assertProbe(first.html, "prose-one"); assertProbe(second.html, "prose-two"); const missing = await request(origin, "/missing-prose/", "missing"); assert(missing.response.status === 404, "unknown request prose was not 404"); + assert(missing.html.includes("Page not found"), "unknown request prose bypassed the custom 404 page"); const styles = await request(origin, "/_nimbus/shiki.css"); assert(styles.response.status === 200 && styles.html.includes(".nb-shiki-"), "Shiki styles were not served"); for (const { route } of staticKinds.values()) { @@ -334,7 +326,6 @@ await withWorkerd(site, async (origin) => { console.log(`${PREFIX} proving request API pages beside build-rendered prose`); build(site, { docs: "build", api: "request" }); -restoreShikiCss(); const requestApiPages = captureStaticPages(site); assert(findMarkedPages(requestApiPages, "data-feasibility-prose").length === 1, "build prose was not emitted beside request API pages"); assert(apiKinds(requestApiPages).size === 0, "request API emitted static HTML"); @@ -365,7 +356,6 @@ await withWorkerd(site, async (origin) => { console.log(`${PREFIX} proving both route families in request mode`); build(site, { docs: "request", api: "request" }); -restoreShikiCss(); const requestOnlyPages = captureStaticPages(site); assert(findMarkedPages(requestOnlyPages, "data-feasibility-prose").length === 0, "request-only build emitted prose HTML"); assert(apiKinds(requestOnlyPages).size === 0, "request-only build emitted API HTML"); From 8ea8a85a40e873083d009c678c37161ebcc47e09 Mon Sep 17 00:00:00 2001 From: mohamedh Date: Wed, 2 Sep 2026 16:40:50 +0100 Subject: [PATCH 06/16] feat: workers rendering --- .changeset/warm-plants-search.md | 6 + .github/workflows/workers-feasibility.yml | 78 ++++ apps/www/registry/features/api-reference.md | 2 + apps/www/registry/features/changelog.md | 6 +- apps/www/registry/features/new-collection.md | 13 +- apps/www/registry/features/new-version.md | 11 +- .../ui/search/providers/pagefind.ts | 11 +- .../src/_internal/git-last-updated.ts | 20 +- .../src/_internal/last-updated-virtual.ts | 37 ++ .../src/_internal/pagefind-document.ts | 79 ++++ .../src/_internal/request-route-inventory.ts | 62 +++- .../src/_internal/request-route-url.ts | 22 ++ .../src/_internal/virtual-config.ts | 3 +- .../src/_internal/worker-partial-headings.ts | 350 +++++++++++++++++- packages/nimbus-docs/src/integration.ts | 256 ++++++++++--- packages/nimbus-docs/src/runtime.ts | 20 +- .../nimbus-docs/test/git-last-updated.test.ts | 9 + .../test/pagefind-document.test.ts | 34 ++ .../nimbus-docs/test/partial-headings.test.ts | 87 ++++- .../nimbus-docs/test/rendering-policy.test.ts | 41 +- .../nimbus-docs/test/virtual-config.test.ts | 23 ++ packages/nimbus-docs/tsdown.config.ts | 1 + .../ui/api-code-rail/ApiCodeRail.astro | 7 +- .../components/ui/api-layout/ApiLayout.astro | 1 + .../ui/search/providers/pagefind.ts | 11 +- .../src/pages/[...slug].astro | 2 +- .../src/pages/og/[...slug].ts | 31 +- .../src/pages/api/[...slug].astro | 2 + .../workers-feasibility/astro.config.ts | 3 + .../workers-feasibility/nimbus.config.ts | 1 - .../src/content/docs/runtime.mdx | 1 - .../src/pages/[...slug].astro | 9 +- .../src/pages/api/[...slug]/index.md.ts | 46 +++ scripts/workers-feasibility-check.mjs | 146 +++++++- 34 files changed, 1300 insertions(+), 131 deletions(-) create mode 100644 .changeset/warm-plants-search.md create mode 100644 .github/workflows/workers-feasibility.yml create mode 100644 packages/nimbus-docs/src/_internal/last-updated-virtual.ts create mode 100644 packages/nimbus-docs/src/_internal/pagefind-document.ts create mode 100644 packages/nimbus-docs/test/pagefind-document.test.ts create mode 100644 scripts/fixtures/workers-feasibility/src/pages/api/[...slug]/index.md.ts diff --git a/.changeset/warm-plants-search.md b/.changeset/warm-plants-search.md new file mode 100644 index 00000000..bfae9321 --- /dev/null +++ b/.changeset/warm-plants-search.md @@ -0,0 +1,6 @@ +--- +"@cloudflare/nimbus-docs": minor +"@cloudflare/create-nimbus-docs": patch +--- + +Preserve sitemap and search discovery for request-rendered pages, and generate cross-collection Open Graph images in new starters. diff --git a/.github/workflows/workers-feasibility.yml b/.github/workflows/workers-feasibility.yml new file mode 100644 index 00000000..319d798e --- /dev/null +++ b/.github/workflows/workers-feasibility.yml @@ -0,0 +1,78 @@ +name: Workers rendering acceptance + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + relevant: ${{ steps.filter.outputs.relevant }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - id: filter + name: Detect Workers rendering changes + env: + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + run: | + relevant=false + if [[ -z "$BASE_SHA" || "$BASE_SHA" == "0000000000000000000000000000000000000000" ]]; then + relevant=true + else + changed_files="$RUNNER_TEMP/workers-rendering-changed-files.txt" + git diff --name-only "$BASE_SHA" "$GITHUB_SHA" > "$changed_files" + while IFS= read -r file; do + case "$file" in + packages/nimbus-docs/*|packages/nimbus-docs/**|packages/nimbus-starter-source/*|packages/nimbus-starter-source/**|packages/create-nimbus-docs/*|packages/create-nimbus-docs/**|apps/www/registry/features/*|apps/www/registry/features/**|scripts/workers-feasibility-check.mjs|scripts/fixtures/workers-feasibility/*|scripts/fixtures/workers-feasibility/**|package.json|pnpm-lock.yaml|pnpm-workspace.yaml|.npmrc|tsconfig.base.json|.github/workflows/workers-feasibility.yml) + relevant=true + break + ;; + esac + done < "$changed_files" + fi + echo "relevant=$relevant" >> "$GITHUB_OUTPUT" + + acceptance: + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm workers-feasibility:check + + required: + name: Workers rendering required + needs: [changes, acceptance] + if: always() + runs-on: ubuntu-latest + steps: + - name: Require acceptance when relevant + env: + CHANGES_RESULT: ${{ needs.changes.result }} + RELEVANT: ${{ needs.changes.outputs.relevant }} + RESULT: ${{ needs.acceptance.result }} + run: | + if [[ "$CHANGES_RESULT" != "success" ]]; then + exit 1 + fi + if [[ "$RELEVANT" != "true" && "$RELEVANT" != "false" ]]; then + exit 1 + fi + if [[ "$RELEVANT" == "true" && "$RESULT" != "success" ]]; then + exit 1 + fi diff --git a/apps/www/registry/features/api-reference.md b/apps/www/registry/features/api-reference.md index 365530b0..3568d233 100644 --- a/apps/www/registry/features/api-reference.md +++ b/apps/www/registry/features/api-reference.md @@ -311,12 +311,14 @@ export const getStaticPaths = getApiStaticPaths("api"); const result = await getApiRoute(Astro); if (result instanceof Response) return result; const { page, nav, collection, version, coordinate } = result; +const socialImage = `/og${page.href.replace(/\/$/, "")}.png`; --- (Astro); +const page = await getCollectionPage<"changelog">(Astro); +if (page instanceof Response) return page; +const { entry, Content } = page; const { title, description, date, tags } = entry.data; const iso = date.toISOString().slice(0, 10); diff --git a/apps/www/registry/features/new-collection.md b/apps/www/registry/features/new-collection.md index 9024fa34..da2a515e 100644 --- a/apps/www/registry/features/new-collection.md +++ b/apps/www/registry/features/new-collection.md @@ -61,7 +61,7 @@ conventions: name). - `src/pages/[...slug].astro` — read it. The new route will mirror this shape exactly except for the helper names (`getCollectionStaticPaths` / - `getCollectionPageProps` instead of the `Docs` variants). + `getCollectionPage` instead of the `Docs` variants). - `src/pages/[...slug]/index.md.ts` — read it. The new `.md` alternate will mirror it. - `src/layouts/DocsLayout.astro` — confirm it exists. The new route uses @@ -191,7 +191,7 @@ Write `src/pages//[...slug].astro`: import DocsLayout from "../../layouts/DocsLayout.astro"; import { getCollectionStaticPaths, - getCollectionPageProps, + getCollectionPage, getSidebar, getPrevNext, getBreadcrumbs, @@ -205,7 +205,9 @@ import { components } from "../../components"; export const prerender = true; export const getStaticPaths = getCollectionStaticPaths(""); -const { entry, Content, headings } = await getCollectionPageProps<"">(Astro); +const page = await getCollectionPage<"">(Astro); +if (page instanceof Response) return page; +const { entry, Content, headings } = page; const currentSlug = Astro.url.pathname.replace(/\/$/, "") || "/"; // Pass collection so the sidebar/prev-next resolve against the current @@ -218,7 +220,8 @@ const prevNext = await getPrevNext(currentSlug, { }); const breadcrumbs = await getBreadcrumbs(currentSlug); const editUrl = await getEditUrl(entry); -const lastUpdated = entry.data.lastUpdated ?? await getLastUpdated(entry); +const lastUpdated = entry.data.lastUpdated ?? + await getLastUpdated(entry); const toc = getTOC(headings, entry.data.tableOfContents); const markdownPath = `//${entry.id}/index.md`; const basedMarkdownPath = withBase(markdownPath, import.meta.env.BASE_URL); @@ -414,7 +417,7 @@ Ask the user whether to replace, skip, or show a diff first. The to a Nimbus site. Blogs, API references, changelogs, glossaries, versioned docs siblings — all the same shape underneath. - The framework helpers `getCollectionStaticPaths(collection)` and - `getCollectionPageProps(astro)` are sibling functions to + `getCollectionPage(astro)` are sibling functions to `getDocsStaticPaths`/`getDocsPageProps`. Use the `Collection` variants in scaffolded routes; the `Docs` variants stay for the primary route only. - The URL convention is intentional: primary `docs` mounts at root, every diff --git a/apps/www/registry/features/new-version.md b/apps/www/registry/features/new-version.md index 32219ee1..4515260a 100644 --- a/apps/www/registry/features/new-version.md +++ b/apps/www/registry/features/new-version.md @@ -337,7 +337,7 @@ siblings: - `getCollectionStaticPaths("docs-")` — takes the collection name as an argument -- `getCollectionPageProps<"docs-">(Astro)` — takes the +- `getCollectionPage<"docs-">(Astro)` — takes the collection name as a TypeScript generic The snippet below uses the correct helpers. Copy it verbatim and @@ -352,7 +352,7 @@ name with the user's slug): import DocsLayout from "../../layouts/DocsLayout.astro"; import { getCollectionStaticPaths, - getCollectionPageProps, + getCollectionPage, getSidebar, getPrevNext, getBreadcrumbs, @@ -366,7 +366,9 @@ import { components } from "../../components"; export const prerender = true; export const getStaticPaths = getCollectionStaticPaths("docs-"); -const { entry, Content, headings } = await getCollectionPageProps<"docs-">(Astro); +const page = await getCollectionPage<"docs-">(Astro); +if (page instanceof Response) return page; +const { entry, Content, headings } = page; const currentSlug = Astro.url.pathname.replace(/\/$/, "") || "/"; const sidebar = await getSidebar(currentSlug, { collection: entry.collection }); @@ -376,7 +378,8 @@ const prevNext = await getPrevNext(currentSlug, { }); const breadcrumbs = await getBreadcrumbs(currentSlug); const editUrl = await getEditUrl(entry); -const lastUpdated = entry.data.lastUpdated ?? await getLastUpdated(entry); +const lastUpdated = entry.data.lastUpdated ?? + await getLastUpdated(entry); const toc = getTOC(headings, entry.data.tableOfContents); const markdownPath = `//${entry.id}/index.md`; const basedMarkdownPath = withBase(markdownPath, import.meta.env.BASE_URL); diff --git a/apps/www/src/components/ui/search/providers/pagefind.ts b/apps/www/src/components/ui/search/providers/pagefind.ts index 3e9dc49a..934dcbcc 100644 --- a/apps/www/src/components/ui/search/providers/pagefind.ts +++ b/apps/www/src/components/ui/search/providers/pagefind.ts @@ -28,6 +28,13 @@ interface PagefindApi { let pagefind: PagefindApi | undefined; +function withBase(url: string): string { + if (!url.startsWith("/")) return url; + const base = `/${(import.meta.env.BASE_URL ?? "/").replace(/^\/+|\/+$/g, "")}`; + if (base === "/" || url === base || url.startsWith(`${base}/`)) return url; + return `${base}${url}`; +} + /** * Default Pagefind filters applied to every search. * @@ -70,11 +77,11 @@ export const provider: SearchProvider = { const results = await Promise.all(search.results.slice(0, 10).map((result) => result.data())); return results.map((result): SearchResult => ({ title: result.meta?.title ?? "Untitled", - url: result.url, + url: withBase(result.url), snippet: result.excerpt, subResults: result.sub_results ?.filter((sub): sub is Required => Boolean(sub.title && sub.url)) - .map((sub) => ({ title: sub.title, url: sub.url })), + .map((sub) => ({ title: sub.title, url: withBase(sub.url) })), })); }, }; diff --git a/packages/nimbus-docs/src/_internal/git-last-updated.ts b/packages/nimbus-docs/src/_internal/git-last-updated.ts index 9e22efd5..c92bf126 100644 --- a/packages/nimbus-docs/src/_internal/git-last-updated.ts +++ b/packages/nimbus-docs/src/_internal/git-last-updated.ts @@ -66,7 +66,7 @@ async function detectShallow(): Promise { // One streaming `git log` over the content tree, indexed into `cache`. `spawn` // (not `execFile`) avoids a maxBuffer cap on large histories. Rejects on spawn // error (e.g. git missing) or non-zero exit (e.g. not a repository). -function streamBulk(): Promise { +function streamBulk(map: DateSink = cache, cwd?: string): Promise { return new Promise((resolve, reject) => { const child = spawn( "git", @@ -80,10 +80,10 @@ function streamBulk(): Promise { "--", CONTENT_PATHSPEC, ], - { stdio: ["ignore", "pipe", "ignore"], windowsHide: true }, + { cwd, stdio: ["ignore", "pipe", "ignore"], windowsHide: true }, ); - const index = createIndexer(cache); + const index = createIndexer(map); let buf = ""; const consume = (chunk: string, flush: boolean) => { buf += chunk; @@ -111,6 +111,20 @@ function streamBulk(): Promise { }); } +export async function buildLastUpdatedIndex( + cwd: string, +): Promise> { + const dates = new Map(); + try { + await streamBulk(dates, cwd); + } catch { + return {}; + } + return Object.fromEntries( + [...dates].map(([filePath, date]) => [filePath, date.toISOString()]), + ); +} + async function doBulkLoad(): Promise { try { isShallow = await detectShallow(); diff --git a/packages/nimbus-docs/src/_internal/last-updated-virtual.ts b/packages/nimbus-docs/src/_internal/last-updated-virtual.ts new file mode 100644 index 00000000..ef4bdb46 --- /dev/null +++ b/packages/nimbus-docs/src/_internal/last-updated-virtual.ts @@ -0,0 +1,37 @@ +import type { VitePluginLike } from "./virtual-config.js"; + +const RESOLVED_ID = "\0virtual:nimbus/last-updated"; + +export function virtualLastUpdatedPlugin( + dates: Record | null, +): VitePluginLike { + return { + name: "nimbus-docs:virtual-last-updated", + enforce: "pre", + resolveId(id: string, importer?: string) { + const normalizedId = id.replace(/\\/g, "/").replace(/\?.*$/, ""); + const normalizedImporter = importer + ?.replace(/\\/g, "/") + .replace(/\?.*$/, ""); + if ( + dates && + (normalizedId.endsWith("/_internal/git-last-updated.js") || + /(?:^|\/)git-last-updated-[\w-]+\.js$/.test(normalizedId)) && + normalizedImporter?.endsWith("/runtime.js") + ) { + return RESOLVED_ID; + } + return undefined; + }, + load(id: string) { + if (id !== RESOLVED_ID || !dates) return undefined; + return ( + `const dates = ${JSON.stringify(dates)};\n` + + "export async function getLastUpdatedFromGit(path) {\n" + + ' const value = dates[path.replace(/\\\\/g, "/")];\n' + + " return value ? new Date(value) : undefined;\n" + + "}\n" + ); + }, + }; +} diff --git a/packages/nimbus-docs/src/_internal/pagefind-document.ts b/packages/nimbus-docs/src/_internal/pagefind-document.ts new file mode 100644 index 00000000..6a169481 --- /dev/null +++ b/packages/nimbus-docs/src/_internal/pagefind-document.ts @@ -0,0 +1,79 @@ +import GithubSlugger from "github-slugger"; + +import type { RequestRouteInventoryEntry } from "./request-route-url.js"; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function headingText(markdown: string): string { + return markdown + .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/<[^>]+>/g, "") + .replace(/[`*_~]/g, "") + .trim(); +} + +export function pagefindMarkdown(markdown: string): string { + const slugger = new GithubSlugger(); + const html: string[] = []; + let prose: string[] = []; + let fence: "`" | "~" | undefined; + + const flush = () => { + if (prose.length === 0) return; + html.push(`
    ${escapeHtml(prose.join("\n"))}
    `); + prose = []; + }; + + for (const line of markdown.split("\n")) { + const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/); + if (fence) { + prose.push(line); + if (fenceMatch?.[1]?.startsWith(fence)) fence = undefined; + continue; + } + if (fenceMatch?.[1]) { + fence = fenceMatch[1][0] as "`" | "~"; + prose.push(line); + continue; + } + + const heading = line.match(/^(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/); + if (!heading?.[1] || !heading[2]) { + prose.push(line); + continue; + } + flush(); + const depth = heading[1].length; + const text = headingText(heading[2]); + html.push( + `${escapeHtml(text)}`, + ); + } + flush(); + return html.join(""); +} + +export function pagefindDocument(entry: RequestRouteInventoryEntry): string { + const versionFilter = entry.version + ? ` data-pagefind-filter="version:${escapeHtml(entry.version)}"` + : ""; + return [ + ``, + `${escapeHtml(entry.title)}`, + `
    `, + entry.deprecated + ? '' + : "", + `

    ${escapeHtml(entry.title)}

    `, + entry.description ? `

    ${escapeHtml(entry.description)}

    ` : "", + pagefindMarkdown(entry.content ?? ""), + `
    `, + ].join(""); +} diff --git a/packages/nimbus-docs/src/_internal/request-route-inventory.ts b/packages/nimbus-docs/src/_internal/request-route-inventory.ts index a180bcc7..b2b2492e 100644 --- a/packages/nimbus-docs/src/_internal/request-route-inventory.ts +++ b/packages/nimbus-docs/src/_internal/request-route-inventory.ts @@ -1,38 +1,68 @@ -import { getCollection } from "astro:content"; - import { collectionMountPrefix } from "./collection-mount.js"; -import { requestInventoryEntryUrl } from "./request-route-url.js"; +import { + requestInventoryEntryUrl, + requestInventoryVersionStatusKey, + type RequestRouteInventoryEntry, +} from "./request-route-url.js"; import { loadApiCollections, loadRequestRenderingCollections, loadNimbusConfig, } from "./runtime-config.js"; +import { + getIndexedEntries, + getVersionStatus, + renderIndexedEntryMarkdown, +} from "../runtime.js"; export const prerender = true; export async function GET() { const config = await loadNimbusConfig(); - const collections = await loadRequestRenderingCollections(); + const requestCollections = new Set(await loadRequestRenderingCollections()); const apiCollections = new Set(await loadApiCollections()); + const entries = await getIndexedEntries(); const versions = config.versions ? { others: config.versions.others ?? [] } : null; - const routes: Array<{ collection: string; url: string }> = []; + const routes: RequestRouteInventoryEntry[] = []; - for (const collection of collections) { + for (const item of entries) { + const collection = item.collection; const prefix = collectionMountPrefix(collection, versions); - const entries = await getCollection(collection as never); - for (const entry of entries) { - if ((entry.data as { draft?: unknown }).draft === true) continue; - routes.push({ + const data = (item.entry.data ?? {}) as Record; + const versionStatus = await getVersionStatus( + requestInventoryVersionStatusKey( collection, - url: requestInventoryEntryUrl( - prefix, - entry.id, - apiCollections.has(collection), - ), - }); + apiCollections.has(collection), + item.version, + ), + ); + const discoverable = data.noindex !== true && !versionStatus?.isHidden; + const searchable = + !versionStatus?.isHidden && + (data.searchable === true || + (data.searchable !== false && data.noindex !== true)); + const route: RequestRouteInventoryEntry = { + collection, + url: requestInventoryEntryUrl( + prefix, + item.entry.id, + apiCollections.has(collection), + ), + request: requestCollections.has(collection), + discoverable, + searchable, + title: item.title, + language: (config.locale ?? "en").split("-")[0]!, + }; + if (item.description) route.description = item.description; + if (item.version) route.version = item.version; + if (versionStatus?.isDeprecated) route.deprecated = true; + if (route.request && searchable) { + route.content = await renderIndexedEntryMarkdown(item); } + routes.push(route); } return new Response(JSON.stringify(routes), { diff --git a/packages/nimbus-docs/src/_internal/request-route-url.ts b/packages/nimbus-docs/src/_internal/request-route-url.ts index b575b09a..e2503b86 100644 --- a/packages/nimbus-docs/src/_internal/request-route-url.ts +++ b/packages/nimbus-docs/src/_internal/request-route-url.ts @@ -6,3 +6,25 @@ export function requestInventoryEntryUrl( const id = api && entryId === "index" ? "" : entryId; return id === "" ? prefix || "/" : `${prefix}/${id}`; } + +export function requestInventoryVersionStatusKey( + collection: string, + api: boolean, + version?: string, +): string { + return api && version ? `${collection}@${version}` : collection; +} + +export interface RequestRouteInventoryEntry { + collection: string; + url: string; + request: boolean; + discoverable: boolean; + searchable: boolean; + title: string; + description?: string; + content?: string; + language: string; + version?: string; + deprecated?: boolean; +} diff --git a/packages/nimbus-docs/src/_internal/virtual-config.ts b/packages/nimbus-docs/src/_internal/virtual-config.ts index 636666df..47ada1e6 100644 --- a/packages/nimbus-docs/src/_internal/virtual-config.ts +++ b/packages/nimbus-docs/src/_internal/virtual-config.ts @@ -25,7 +25,8 @@ const RESOLVED_ID = `\0${VIRTUAL_ID}`; export interface VitePluginLike { name: string; - resolveId(id: string): string | undefined; + enforce?: "pre" | "post"; + resolveId(id: string, importer?: string): string | undefined; load(id: string): string | undefined; } diff --git a/packages/nimbus-docs/src/_internal/worker-partial-headings.ts b/packages/nimbus-docs/src/_internal/worker-partial-headings.ts index 3fda2606..c3814c5f 100644 --- a/packages/nimbus-docs/src/_internal/worker-partial-headings.ts +++ b/packages/nimbus-docs/src/_internal/worker-partial-headings.ts @@ -2,30 +2,336 @@ import remarkMdx from "remark-mdx"; import remarkParse from "remark-parse"; import { unified } from "unified"; -import type { - Heading, - PartialHeadingOptions, -} from "./partial-headings.js"; +import type { Heading, PartialHeadingOptions } from "./partial-headings.js"; interface MdNode { type: string; name?: string | null; attributes?: unknown[]; children?: MdNode[]; + position?: { + start: { offset?: number }; + end: { offset?: number }; + }; } interface JsxAttribute { type: string; name: string; - value?: string | null | { type: string; value: string }; + value?: string | null | MdxExpression; +} + +interface MdxExpression { + type: string; + value: string; + data?: { estree?: EstreeProgram }; +} + +interface EstreeProgram { + body?: Array<{ type: string; expression?: EstreeExpression }>; +} + +interface EstreeExpression { + type: string; + value?: unknown; + name?: string; + computed?: boolean; + operator?: string; + argument?: EstreeExpression; + object?: EstreeExpression; + property?: EstreeExpression; + properties?: EstreeProperty[]; + elements?: Array; + expressions?: EstreeExpression[]; + quasis?: Array<{ value?: { cooked?: string | null } }>; +} + +interface EstreeProperty { + type: string; + computed?: boolean; + key?: EstreeExpression; + value?: EstreeExpression; } type Slot = - | { kind: "heading" } - | { kind: "render"; file?: string; product?: string }; + { kind: "heading" } | { kind: "render"; file?: string; product?: string }; const parser = unified().use(remarkParse).use(remarkMdx); +export function expandWorkerPartials( + body: string, + getEntry: (collection: string, id: string) => Promise, +): Promise { + return expandPartials(body, getEntry, {}, new Set()); +} + +async function expandPartials( + body: string, + getEntry: (collection: string, id: string) => Promise, + props: Record, + seen: Set, +): Promise { + let tree: MdNode; + try { + tree = parser.parse(body) as unknown as MdNode; + } catch { + return body; + } + + const renders: MdNode[] = []; + collectRenderNodes(tree, renders); + const replacements: Array<{ start: number; end: number; body: string }> = []; + + for (const node of renders) { + const id = attributeValue(node, "file"); + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (!id || start === undefined || end === undefined) continue; + if (seen.has(id)) { + throw new Error( + `[nimbus-docs] Circular partial include: ${[...seen, id].join(" -> ")}. ` + + "Check for a partial that renders itself directly or transitively.", + ); + } + + let partial: unknown; + try { + partial = await getEntry("partials", id); + } catch { + partial = null; + } + const partialBody = (partial as { body?: unknown } | null)?.body; + if (typeof partialBody !== "string") continue; + const partialProps = renderParams(node, props); + validatePartialParams(id, partial, partialProps); + + seen.add(id); + try { + replacements.push({ + start, + end, + body: await expandPartials( + applyPartialParams(partialBody, partialProps), + getEntry, + partialProps, + seen, + ), + }); + } finally { + seen.delete(id); + } + } + + let expanded = body; + for (const replacement of replacements.reverse()) { + expanded = + expanded.slice(0, replacement.start) + + replacement.body + + expanded.slice(replacement.end); + } + return expanded; +} + +function renderParams( + node: MdNode, + props: Record, +): Record { + const attribute = (node.attributes as JsxAttribute[] | undefined)?.find( + (candidate) => + candidate.type === "mdxJsxAttribute" && candidate.name === "params", + ); + if (!attribute) return {}; + if (!attribute.value || typeof attribute.value === "string") { + throw new Error( + "[nimbus-docs] params must be an object expression.", + ); + } + const expression = estreeExpression(attribute.value); + if (expression?.type !== "ObjectExpression") { + throw new Error( + "[nimbus-docs] params must be a statically resolvable object expression.", + ); + } + return evaluateObject(expression, props); +} + +function validatePartialParams( + id: string, + partial: unknown, + props: Record, +): void { + const declared = (partial as { data?: { params?: unknown } } | null)?.data + ?.params; + if ( + !Array.isArray(declared) || + !declared.every((item) => typeof item === "string") + ) { + return; + } + const required = declared.filter((param) => !param.endsWith("?")); + const names = new Set( + declared.map((param) => (param.endsWith("?") ? param.slice(0, -1) : param)), + ); + const received = Object.keys(props); + const missing = required.filter((param) => !received.includes(param)); + if (missing.length > 0) { + throw new Error( + `[Render] Missing required params ${JSON.stringify(missing)} for "${id}". ` + + `Expected: ${JSON.stringify(declared)}, received: ${JSON.stringify(received)}`, + ); + } + const unexpected = received.filter((param) => !names.has(param)); + if (unexpected.length > 0) { + throw new Error( + `[Render] Unexpected params ${JSON.stringify(unexpected)} for "${id}". ` + + `Declared: ${JSON.stringify(declared)}`, + ); + } +} + +function applyPartialParams( + body: string, + props: Record, +): string { + let tree: MdNode; + try { + tree = parser.parse(body) as unknown as MdNode; + } catch { + return body; + } + const expressions: MdNode[] = []; + collectExpressions(tree, expressions); + const replacements: Array<{ start: number; end: number; body: string }> = []; + for (const node of expressions) { + const expression = estreeExpression(node as unknown as MdxExpression); + const name = propsMemberName(expression); + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (!name || start === undefined || end === undefined) continue; + replacements.push({ start, end, body: markdownValue(props[name]) }); + } + let rendered = body; + for (const replacement of replacements.reverse()) { + rendered = + rendered.slice(0, replacement.start) + + replacement.body + + rendered.slice(replacement.end); + } + return rendered; +} + +function collectExpressions(node: MdNode, expressions: MdNode[]): void { + if (node.type === "mdxTextExpression" || node.type === "mdxFlowExpression") { + expressions.push(node); + return; + } + for (const child of node.children ?? []) + collectExpressions(child, expressions); +} + +function estreeExpression( + expression: MdxExpression, +): EstreeExpression | undefined { + const statement = expression.data?.estree?.body?.[0]; + return statement?.type === "ExpressionStatement" + ? statement.expression + : undefined; +} + +function propsMemberName( + expression: EstreeExpression | undefined, +): string | undefined { + if ( + expression?.type !== "MemberExpression" || + expression.object?.type !== "Identifier" || + expression.object.name !== "props" + ) { + return undefined; + } + if (!expression.computed && expression.property?.type === "Identifier") { + return expression.property.name; + } + return expression.computed && expression.property?.type === "Literal" + ? String(expression.property.value) + : undefined; +} + +function evaluateObject( + expression: EstreeExpression, + props: Record, +): Record { + const result: Record = {}; + for (const property of expression.properties ?? []) { + if ( + property.type !== "Property" || + property.computed || + !property.key || + !property.value + ) { + throw new Error( + "[nimbus-docs] params contains an unsupported property.", + ); + } + const key = + property.key.type === "Identifier" + ? property.key.name + : property.key.type === "Literal" + ? String(property.key.value) + : undefined; + if (!key) { + throw new Error( + "[nimbus-docs] params contains an unsupported property name.", + ); + } + result[key] = evaluateExpression(property.value, props); + } + return result; +} + +function evaluateExpression( + expression: EstreeExpression, + props: Record, +): unknown { + if (expression.type === "Literal") return expression.value; + const prop = propsMemberName(expression); + if (prop) return props[prop]; + if (expression.type === "Identifier" && expression.name === "undefined") { + return undefined; + } + if (expression.type === "ArrayExpression") { + return (expression.elements ?? []).map((item) => + item ? evaluateExpression(item, props) : undefined, + ); + } + if (expression.type === "ObjectExpression") + return evaluateObject(expression, props); + if (expression.type === "UnaryExpression" && expression.argument) { + const value = evaluateExpression(expression.argument, props); + if (expression.operator === "-") return -Number(value); + if (expression.operator === "+") return Number(value); + if (expression.operator === "!") return !value; + } + if ( + expression.type === "TemplateLiteral" && + expression.expressions?.length === 0 + ) { + return expression.quasis?.[0]?.value?.cooked ?? ""; + } + throw new Error( + "[nimbus-docs] params must contain only literals or props. references.", + ); +} + +function markdownValue(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "object" || typeof value === "function") { + throw new Error( + "[nimbus-docs] Partial props interpolated into Markdown must be primitive values.", + ); + } + return String(value).replace(/([\\`*_[\]{}()<>#+.!|~-])/g, "\\$1"); +} + export function mergeWorkerPartialHeadings( body: string | undefined, astroHeadings: Heading[], @@ -99,7 +405,10 @@ async function merge( )), ); } catch (error) { - if (error instanceof Error && error.message.includes("Circular ")) { + if ( + error instanceof Error && + error.message.includes("Circular ") + ) { throw error; } } finally { @@ -128,8 +437,31 @@ function collectSlots(node: MdNode, slots: Slot[]): void { )?.value; return typeof attribute === "string" ? attribute : undefined; }; - slots.push({ kind: "render", file: value("file"), product: value("product") }); + slots.push({ + kind: "render", + file: value("file"), + product: value("product"), + }); return; } for (const child of node.children ?? []) collectSlots(child, slots); } + +function collectRenderNodes(node: MdNode, renders: MdNode[]): void { + if ( + (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") && + node.name === "Render" + ) { + renders.push(node); + return; + } + for (const child of node.children ?? []) collectRenderNodes(child, renders); +} + +function attributeValue(node: MdNode, name: string): string | undefined { + const attribute = (node.attributes as JsxAttribute[] | undefined)?.find( + (candidate) => + candidate.type === "mdxJsxAttribute" && candidate.name === name, + )?.value; + return typeof attribute === "string" ? attribute : undefined; +} diff --git a/packages/nimbus-docs/src/integration.ts b/packages/nimbus-docs/src/integration.ts index 3d791ade..a0ddab83 100644 --- a/packages/nimbus-docs/src/integration.ts +++ b/packages/nimbus-docs/src/integration.ts @@ -99,7 +99,10 @@ import { NIMBUS_DEFAULT_SHIKI_THEMES, shouldClassShikiTokens, } from "./_internal/code-style-registry.js"; -import type { SitemapSerialize } from "./_internal/sitemap-types.js"; +import type { + SitemapItem, + SitemapSerialize, +} from "./_internal/sitemap-types.js"; import { scanVersionFrontmatter } from "./_internal/scan-version-frontmatter.js"; import { buildVersionAlternates, @@ -120,6 +123,11 @@ import { normalizeRouteComponent, routeComponentKeys, } from "./_internal/rendering-policy.js"; +import type { RequestRouteInventoryEntry } from "./_internal/request-route-url.js"; +import { safeDecode, withBase } from "./_internal/url.js"; +import { buildLastUpdatedIndex } from "./_internal/git-last-updated.js"; +import { virtualLastUpdatedPlugin } from "./_internal/last-updated-virtual.js"; +import { pagefindDocument } from "./_internal/pagefind-document.js"; import type { NimbusConfig, RenderingMode } from "./types.js"; /** @@ -329,6 +337,9 @@ export function nimbus( let requestRenderingConfigured = false; let requestRenderingCollections = new Set(); let requestRoutePatterns = new Set(); + let sitemapRequestPages: string[] = []; + let sitemapExcludedPaths = new Set(); + let sitemapTrailingSlash: "always" | "never" | "ignore" = "ignore"; let building = false; // Built eagerly at config:setup, reassigned by the dev re-bake; both the @@ -395,6 +406,9 @@ export function nimbus( } const integrationsToAdd: AstroIntegration[] = []; + sitemapRequestPages = []; + sitemapExcludedPaths = new Set(); + sitemapTrailingSlash = astroConfig.trailingSlash; // Materialize the resolved lint config so the standalone // `nimbus-docs lint` CLI can read severities authored here. Guarded @@ -521,7 +535,13 @@ export function nimbus( ? ["docs"] : filterIndexableCollections(rawCollections); const indexedCollections = [ - ...new Set([...parsedIndexedCollections, ...apiCollections]), + ...new Set([ + ...parsedIndexedCollections, + ...(config.versions?.others ?? []).map( + (version) => `docs-${version}`, + ), + ...apiCollections, + ]), ]; renderingRoutes = new Map(); @@ -771,27 +791,55 @@ export function nimbus( config, astroConfig.base, ); - integrationsToAdd.push( - sitemap({ - // Our public `SitemapSerialize` types `changefreq` as a - // string-literal union and may return `null` to drop an entry. - // @astrojs/sitemap types `changefreq` as its own `EnumChangefreq` - // and drops on any falsy return (so `null` is correct at - // runtime). The values are identical — the gap is purely nominal, - // so cast at this boundary. - ...(sitemapOpts?.serialize && { - serialize: sitemapOpts.serialize as unknown as NonNullable< - Parameters[0] - >["serialize"], - }), - ...(sitemapOpts?.customPages && { - customPages: sitemapOpts.customPages, - }), - ...(hiddenPrefixes.length > 0 && { - filter: makeHiddenSitemapFilter(config, astroConfig.base), - }), - }), + const customPages = [...(sitemapOpts?.customPages ?? [])]; + const hiddenFilter = makeHiddenSitemapFilter( + config, + astroConfig.base, ); + const sitemapIntegration = sitemap({ + // Our public `SitemapSerialize` types `changefreq` as a + // string-literal union and may return `null` to drop an entry. + // @astrojs/sitemap types `changefreq` as its own `EnumChangefreq` + // and drops on any falsy return (so `null` is correct at + // runtime). The values are identical — the gap is purely nominal, + // so cast at this boundary. + ...(sitemapOpts?.serialize && { + serialize: sitemapOpts.serialize as unknown as NonNullable< + Parameters[0] + >["serialize"], + }), + ...((sitemapOpts?.customPages || requestRenderingConfigured) && { + customPages, + }), + ...((hiddenPrefixes.length > 0 || requestRenderingConfigured) && { + filter: (url: string) => + hiddenFilter(url) && + !isRequestRouteInventoryPath( + new URL(url, config.site).pathname, + astroConfig.base, + ) && + !sitemapExcludedPaths.has( + canonicalizePathname( + safeDecode(new URL(url, config.site).pathname), + ), + ), + }), + }); + if (requestRenderingConfigured) { + const buildDone = sitemapIntegration.hooks["astro:build:done"]; + if (!buildDone) { + throw new Error("@astrojs/sitemap is missing astro:build:done"); + } + sitemapIntegration.hooks["astro:build:done"] = async (params) => { + await buildDone(params); + await appendRequestSitemapPages( + params.dir, + sitemapRequestPages, + sitemapOpts?.serialize, + ); + }; + } + integrationsToAdd.push(sitemapIntegration); } // Admonition transform plugin: only constructed when enabled @@ -818,6 +866,9 @@ export function nimbus( const citationContentDirs = ["src/content"].map((d) => path.isAbsolute(d) ? d : path.join(projectRoot, d), ); + const lastUpdatedByPath = requestRenderingConfigured + ? await buildLastUpdatedIndex(projectRoot) + : null; const markdownProcessor = options.markdown?.processor ?? @@ -940,6 +991,7 @@ export function nimbus( manifest: coordinatesManifest, })), virtualApiBuildConfigPlugin(config.api, projectRoot), + virtualLastUpdatedPlugin(lastUpdatedByPath), virtualConfigPlugin(config, { indexedCollections, requestRenderingCollections: [...requestRenderingCollections], @@ -1180,15 +1232,31 @@ export function nimbus( const prerenderedRoutes = new Set( publicPages.map(({ pathname }) => canonicalizePathname(pathname)), ); - const requestRoutes = ( - requestRenderingConfigured - ? readRequestRouteInventory( - distDir, - astroBaseForBuild, - requestRenderingCollections, - ) - : [] - ).filter((pathname) => !prerenderedRoutes.has(pathname)); + const inventory = requestRenderingConfigured + ? readRequestRouteInventory( + distDir, + astroBaseForBuild, + requestRenderingCollections, + ) + : { entries: [], path: null }; + const requestRoutes = inventory.entries + .filter((entry) => entry.request) + .map((entry) => canonicalizePathname(entry.url)) + .filter((pathname) => !prerenderedRoutes.has(pathname)); + for (const entry of inventory.entries) { + const pathname = canonicalizePathname( + safeDecode(withBase(entry.url, astroBaseForBuild)), + ); + if (!entry.discoverable) sitemapExcludedPaths.add(pathname); + if (entry.request && entry.discoverable) { + const basedPath = withBase(entry.url, astroBaseForBuild); + const sitemapPath = + sitemapTrailingSlash === "never" + ? basedPath + : `${basedPath.replace(/\/$/, "")}/`; + sitemapRequestPages.push(new URL(sitemapPath, config.site).href); + } + } // Materialize the site's route truth from Astro's emitted `pages` // array — the single source of truth: every URL on this list is a // page Astro just wrote to disk. No reconstruction, no slug @@ -1257,12 +1325,16 @@ export function nimbus( await writeShikiStyleSheet({ distDir, logger }); - if (config.search === false || config.search?.provider === "custom") { - return; + if (config.search !== false && config.search?.provider !== "custom") { + await runPagefind( + distDir, + inventory.entries.filter( + (entry) => entry.request && entry.searchable, + ), + ); } - // Pagefind reindexes the full dist on every run. - await runPagefind(distDir); + if (inventory.path) fs.rmSync(inventory.path, { force: true }); }, }, }; @@ -1371,7 +1443,7 @@ function readRequestRouteInventory( distDir: string, base: string, requestCollections: ReadonlySet, -): string[] { +): { entries: RequestRouteInventoryEntry[]; path: string | null } { const relativeInventoryPath = REQUEST_ROUTE_INVENTORY_PATTERN.slice(1); const basePath = base.replace(/^\/+|\/+$/g, ""); const candidates = [ @@ -1399,7 +1471,7 @@ function readRequestRouteInventory( throw new Error("nimbus-docs: request route inventory must be an array."); } - const routes = new Set(); + const entries: RequestRouteInventoryEntry[] = []; for (const entry of inventory) { if ( typeof entry !== "object" || @@ -1411,14 +1483,26 @@ function readRequestRouteInventory( "nimbus-docs: request route inventory contains an invalid entry.", ); } - const { collection, url } = entry as { collection: string; url: string }; - if (requestCollections.has(collection)) { - routes.add(canonicalizePathname(url)); - } + const value = entry as Partial & { + collection: string; + url: string; + }; + entries.push({ + collection: value.collection, + url: value.url, + request: value.request ?? requestCollections.has(value.collection), + discoverable: value.discoverable ?? true, + searchable: value.searchable ?? false, + title: value.title ?? value.url, + language: value.language ?? "en", + ...(value.description ? { description: value.description } : {}), + ...(value.content ? { content: value.content } : {}), + ...(value.version ? { version: value.version } : {}), + ...(value.deprecated ? { deprecated: true } : {}), + }); } - fs.rmSync(inventoryPath, { force: true }); - return [...routes]; + return { entries, path: inventoryPath }; } /** Absolute paths of every local spec file backing `config.api`. */ @@ -1560,10 +1644,94 @@ async function emitPlatformRedirects({ } } -function runPagefind(siteDir: string): Promise { +export async function appendRequestSitemapPages( + dir: URL, + pages: readonly string[], + serialize?: SitemapSerialize, +): Promise { + const sitemapFiles = await collectSitemapFiles(fileURLToPath(dir)); + const target = sitemapFiles.find( + (file) => path.basename(file) !== "sitemap-index.xml", + ); + if (!target) return; + let xml = await fs.promises.readFile(target, "utf8"); + const additions: SitemapItem[] = []; + for (const url of new Set(pages)) { + if (xml.includes(`${escapeXml(url)}`)) continue; + const item = serialize ? await serialize({ url }) : { url }; + if (item) additions.push(item); + } + if (additions.length === 0) return; + if ( + additions.some((item) => item.links?.length) && + !xml.includes("xmlns:xhtml=") + ) { + xml = xml.replace( + "", + `${additions.map(sitemapItemXml).join("")}\n`, + ); + await fs.promises.writeFile(target, xml, "utf8"); +} + +async function collectSitemapFiles(directory: string): Promise { + const files: string[] = []; + for (const entry of await fs.promises.readdir(directory, { + withFileTypes: true, + })) { + const child = path.join(directory, entry.name); + if (entry.isDirectory()) files.push(...(await collectSitemapFiles(child))); + else if (/^sitemap.*\.xml$/.test(entry.name)) files.push(child); + } + return files; +} + +function sitemapItemXml(item: SitemapItem): string { + return [ + "", + `${escapeXml(item.url)}`, + item.lastmod ? `${escapeXml(item.lastmod)}` : "", + item.changefreq ? `${item.changefreq}` : "", + item.priority !== undefined ? `${item.priority}` : "", + ...(item.links ?? []).map( + (link) => + ``, + ), + "", + ].join(""); +} + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function runPagefind( + siteDir: string, + requestEntries: readonly RequestRouteInventoryEntry[], +): Promise { + const syntheticFiles: string[] = []; + for (const entry of requestEntries) { + const route = entry.url.replace(/^\/+|\/+$/g, ""); + const file = path.join(siteDir, route, "index.html"); + if (fs.existsSync(file)) continue; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, pagefindDocument(entry), "utf8"); + syntheticFiles.push(file); + } + const bin = process.platform === "win32" ? "pagefind.cmd" : "pagefind"; return new Promise((resolve) => { execFile(bin, ["--site", siteDir], (error, stdout, stderr) => { + for (const file of syntheticFiles) fs.rmSync(file, { force: true }); if (stdout) process.stdout.write(stdout); if (stderr) process.stderr.write(stderr); if (error) { diff --git a/packages/nimbus-docs/src/runtime.ts b/packages/nimbus-docs/src/runtime.ts index dbefc179..4902a763 100644 --- a/packages/nimbus-docs/src/runtime.ts +++ b/packages/nimbus-docs/src/runtime.ts @@ -161,7 +161,18 @@ export async function getEntryMarkdown( ): Promise { const { loadCitationIndex } = await import("./_internal/api/load-citation-index.js"); - return renderEntryAsMarkdown(entry, { + let expandedEntry = entry; + if (entry.body?.includes(" + getVisibleEntry(collection, id), + ), + }; + } + return renderEntryAsMarkdown(expandedEntry, { ...options, citationIndex: await loadCitationIndex(), }); @@ -491,11 +502,7 @@ export async function renderIndexedEntryMarkdown( ): Promise { const apiCollections = await loadApiCollections(); if (!apiCollections.includes(item.collection)) { - const { loadCitationIndex } = - await import("./_internal/api/load-citation-index.js"); - return renderEntryAsMarkdown(item.entry, { - citationIndex: await loadCitationIndex(), - }); + return getEntryMarkdown(item.entry); } const { renderApiPageMarkdown } = await import("./_internal/api/markdown.js"); const { isPreparedApiPage } = await import("./_internal/api/prepared.js"); @@ -1552,7 +1559,6 @@ export function getApiRoute( async function resolveApiRoute( astro: AstroGlobal, ): Promise { - const result = await resolveApiPage( pageResolutionContext(astro), {}, diff --git a/packages/nimbus-docs/test/git-last-updated.test.ts b/packages/nimbus-docs/test/git-last-updated.test.ts index 2b6cbb06..9e6fe772 100644 --- a/packages/nimbus-docs/test/git-last-updated.test.ts +++ b/packages/nimbus-docs/test/git-last-updated.test.ts @@ -13,6 +13,7 @@ import { afterEach, beforeEach, test } from "node:test"; import { __resetLastUpdatedForTests, + buildLastUpdatedIndex, getLastUpdatedFromGit, getLastUpdatedStats, parseGitLog, @@ -136,6 +137,14 @@ test("e2e: single bulk spawn serves many lookups (all hits, no misses)", async ( assert.equal(getLastUpdatedStats().missCount, 0); }); +test("e2e: prepared index uses an explicit project root", async () => { + const index = await buildLastUpdatedIndex(repo); + assert.equal( + index["src/content/docs/guide/page.mdx"], + "2023-01-02T03:04:05.000Z", + ); +}); + test("e2e: untracked file → undefined, miss counted, no throw", async () => { writeFileSync(join(repo, "src/content/docs/untracked.mdx"), "# new\n"); const d = await getLastUpdatedFromGit("src/content/docs/untracked.mdx"); diff --git a/packages/nimbus-docs/test/pagefind-document.test.ts b/packages/nimbus-docs/test/pagefind-document.test.ts new file mode 100644 index 00000000..a0c4cb50 --- /dev/null +++ b/packages/nimbus-docs/test/pagefind-document.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + pagefindDocument, + pagefindMarkdown, +} from "../src/_internal/pagefind-document.js"; + +test("synthetic Pagefind Markdown preserves headings as subresult anchors", () => { + const html = pagefindMarkdown( + "Intro.\n\n## Partial heading\n\nBody.\n\n## Partial heading\n\n```md\n## Not a heading\n```", + ); + + assert.match(html, /

    Partial heading<\/h2>/); + assert.match(html, /

    Partial heading<\/h2>/); + assert.doesNotMatch(html, /

    /); + assert.match(html, /
    [\s\S]*## Not a heading[\s\S]*<\/pre>/);
    +});
    +
    +test("synthetic Pagefind document retains route metadata and heading HTML", () => {
    +  const html = pagefindDocument({
    +    url: "/v1/runtime/",
    +    title: "Runtime",
    +    description: "Request-rendered content",
    +    content: "## Configure it\n\nSearchable text.",
    +    language: "en",
    +    version: "v1",
    +    deprecated: true,
    +  });
    +
    +  assert.match(html, /data-pagefind-filter="version:v1"/);
    +  assert.match(html, /data-pagefind-filter="status:deprecated"/);
    +  assert.match(html, /

    Configure it<\/h2>/); +}); diff --git a/packages/nimbus-docs/test/partial-headings.test.ts b/packages/nimbus-docs/test/partial-headings.test.ts index 15a5bdff..a73f0b17 100644 --- a/packages/nimbus-docs/test/partial-headings.test.ts +++ b/packages/nimbus-docs/test/partial-headings.test.ts @@ -8,7 +8,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { mergePartialHeadings } from "../src/_internal/partial-headings.js"; -import { mergeWorkerPartialHeadings } from "../src/_internal/worker-partial-headings.js"; +import { + expandWorkerPartials, + mergeWorkerPartialHeadings, +} from "../src/_internal/worker-partial-headings.js"; import type { Heading } from "../src/_internal/partial-headings.js"; @@ -20,6 +23,7 @@ interface MockEntry { id: string; body: string; headings: Heading[]; + data?: { params?: string[] }; } function makeGetEntry(partials: Record) { @@ -84,11 +88,74 @@ test("Worker parser preserves partial heading order", async () => { makeGetEntry(partials), makeRender(partials), ); - assert.deepEqual(result.map(({ slug }) => slug), [ - "before", - "worker-partial", - "after", - ]); + assert.deepEqual( + result.map(({ slug }) => slug), + ["before", "worker-partial", "after"], + ); +}); + +test("Worker markdown expansion preserves nested partial content in place", async () => { + const partials: Record = { + outer: { + id: "outer", + body: 'Outer before.\n\n\n\nOuter after.', + headings: [], + }, + inner: { + id: "inner", + body: "## Inner heading\n\nSearchable partial sentence.", + headings: [], + }, + }; + const expanded = await expandWorkerPartials( + 'Page before.\n\n\n\nPage after.', + makeGetEntry(partials), + ); + + assert.equal( + expanded, + "Page before.\n\nOuter before.\n\n## Inner heading\n\nSearchable partial sentence.\n\nOuter after.\n\nPage after.", + ); +}); + +test("Worker markdown expansion resolves and forwards declared partial params", async () => { + const partials: Record = { + outer: { + id: "outer", + body: 'Outer targets {props.runtime}.\n\n', + headings: [], + data: { params: ["runtime"] }, + }, + inner: { + id: "inner", + body: "Inner targets {props.runtime}.", + headings: [], + data: { params: ["runtime"] }, + }, + }; + const expanded = await expandWorkerPartials( + '', + makeGetEntry(partials), + ); + + assert.equal(expanded, "Outer targets node.\n\nInner targets node."); +}); + +test("Worker markdown expansion validates required partial params", async () => { + const partials: Record = { + runtime: { + id: "runtime", + body: "Runtime: {props.runtime}", + headings: [], + data: { params: ["runtime"] }, + }, + }; + + await assert.rejects( + () => + expandWorkerPartials('', makeGetEntry(partials)), + /Missing required params \["runtime"\] for "runtime"/, + ); }); test("nested partial headings are included recursively", async () => { @@ -202,7 +269,9 @@ test("custom resolvePartialId is used (product convention)", async () => { "bots/snippet": { id: "bots/snippet", body: "## Snippet heading\n", - headings: [{ depth: 2, text: "Snippet heading", slug: "snippet-heading" }], + headings: [ + { depth: 2, text: "Snippet heading", slug: "snippet-heading" }, + ], }, }; @@ -244,9 +313,7 @@ test("extra Astro headings without source nodes are appended (e.g. footnote-labe }); test("entry with no body returns Astro headings unchanged", async () => { - const parentHeadings: Heading[] = [ - { depth: 2, text: "Foo", slug: "foo" }, - ]; + const parentHeadings: Heading[] = [{ depth: 2, text: "Foo", slug: "foo" }]; const result = await mergePartialHeadings( undefined, diff --git a/packages/nimbus-docs/test/rendering-policy.test.ts b/packages/nimbus-docs/test/rendering-policy.test.ts index 811c7a93..cdbe9eda 100644 --- a/packages/nimbus-docs/test/rendering-policy.test.ts +++ b/packages/nimbus-docs/test/rendering-policy.test.ts @@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url"; import { test, type TestContext } from "node:test"; import nimbus from "../src/index.js"; +import { appendRequestSitemapPages } from "../src/integration.js"; import { canonicalCollectionRouteComponent, compileRenderingPolicy, @@ -14,7 +15,10 @@ import { } from "../src/_internal/rendering-policy.js"; import { getCodeStyleCSS } from "../src/_internal/code-style-registry.js"; import { parseContentCollections } from "../src/_internal/parse-content-collections.js"; -import { requestInventoryEntryUrl } from "../src/_internal/request-route-url.js"; +import { + requestInventoryEntryUrl, + requestInventoryVersionStatusKey, +} from "../src/_internal/request-route-url.js"; import { validateNimbusConfig } from "../src/_internal/validate.js"; import type { NimbusConfig, RenderingConfig } from "../src/types.js"; @@ -37,6 +41,37 @@ test("request inventory preserves prose ids and only collapses the API root", () requestInventoryEntryUrl("/api", "guides/index", true), "/api/guides/index", ); + assert.equal(requestInventoryVersionStatusKey("docs-v1", false, "v1"), "docs-v1"); + assert.equal(requestInventoryVersionStatusKey("api", true, "v1"), "api@v1"); +}); + +test("request sitemap finalizer preserves namespaces and serialized fields", async (t) => { + const root = await mkdtemp(path.join(tmpdir(), "nimbus-request-sitemap-")); + t.after(() => rm(root, { recursive: true, force: true })); + const sitemap = path.join(root, "sitemap-0.xml"); + await writeFile( + sitemap, + 'https://example.test/', + "utf8", + ); + + await appendRequestSitemapPages( + pathToFileURL(`${root}${path.sep}`), + ["https://example.test/runtime/", "https://example.test/runtime/"], + ({ url }) => ({ + url, + changefreq: "daily", + priority: 0.7, + links: [{ lang: "fr", url: "https://example.test/fr/runtime/?a=1&b=2" }], + }), + ); + + const xml = await readFile(sitemap, "utf8"); + assert.equal(xml.match(/xmlns:xhtml=/g)?.length, 1); + assert.equal(xml.match(/https:\/\/example\.test\/runtime\/<\/loc>/g)?.length, 1); + assert.match(xml, /daily<\/changefreq>/); + assert.match(xml, /0\.7<\/priority>/); + assert.match(xml, /href="https:\/\/example\.test\/fr\/runtime\/\?a=1&b=2"/); }); test("rendering config is optional and validates only build/request modes", () => { @@ -308,6 +343,10 @@ test("opaque version registrations still reach the request inventory", async (t) virtualConfig.load(resolved) ?? "", /requestRenderingCollections = \["docs-v1"\]/, ); + assert.match( + virtualConfig.load(resolved) ?? "", + /indexedCollections = \["docs","docs-v1"\]/, + ); }); test("an explicitly empty rendering policy applies the build default", async (t) => { diff --git a/packages/nimbus-docs/test/virtual-config.test.ts b/packages/nimbus-docs/test/virtual-config.test.ts index dde81296..91c432d7 100644 --- a/packages/nimbus-docs/test/virtual-config.test.ts +++ b/packages/nimbus-docs/test/virtual-config.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { virtualApiBuildConfigPlugin } from "../src/_internal/virtual-api-build-config.js"; import { virtualConfigPlugin } from "../src/_internal/virtual-config.js"; +import { virtualLastUpdatedPlugin } from "../src/_internal/last-updated-virtual.js"; import type { NimbusConfig } from "../src/types.js"; const sentinel = "raw-openapi-must-not-ship"; @@ -44,3 +45,25 @@ test("build-only API config retains specs and the project root", () => { assert.ok(source.includes(sentinel)); assert.ok(source.includes("/project")); }); + +test("request rendering resolves last-updated from prepared data", () => { + const plugin = virtualLastUpdatedPlugin({ + "src/content/docs/index.mdx": "2026-01-01T00:00:00.000Z", + }); + assert.equal(plugin.enforce, "pre"); + const id = plugin.resolveId( + "./git-last-updated-D5zEYWjA.js", + "/package/dist/runtime.js", + ); + assert.equal(id, "\0virtual:nimbus/last-updated"); + assert.equal( + plugin.resolveId( + "/package/dist/git-last-updated-D5zEYWjA.js?commonjs-proxy", + "/package/dist/runtime.js?astro", + ), + "\0virtual:nimbus/last-updated", + ); + const source = plugin.load(id!); + assert.match(source!, /2026-01-01T00:00:00\.000Z/); + assert.doesNotMatch(source!, /node:child_process/); +}); diff --git a/packages/nimbus-docs/tsdown.config.ts b/packages/nimbus-docs/tsdown.config.ts index 58ebf5b0..1daa25cc 100644 --- a/packages/nimbus-docs/tsdown.config.ts +++ b/packages/nimbus-docs/tsdown.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ "cli/index": "src/cli/index.ts", "_internal/request-route-inventory": "src/_internal/request-route-inventory.ts", + "_internal/git-last-updated": "src/_internal/git-last-updated.ts", }, format: "esm", dts: true, diff --git a/packages/nimbus-starter-source/src/components/ui/api-code-rail/ApiCodeRail.astro b/packages/nimbus-starter-source/src/components/ui/api-code-rail/ApiCodeRail.astro index 6f0dc835..2e1ea8f0 100644 --- a/packages/nimbus-starter-source/src/components/ui/api-code-rail/ApiCodeRail.astro +++ b/packages/nimbus-starter-source/src/components/ui/api-code-rail/ApiCodeRail.astro @@ -36,13 +36,13 @@ import type { type CodeLang = NonNullable[0]["lang"]>; interface Props - extends Pick { + extends Pick { /** Operation title, shown in the request card header. */ title: string; class?: string; } -const { responses, samples, title, method, class: className } = Astro.props; +const { responses, samples, title, method, coordinate, class: className } = Astro.props; // Highlighter language for a sample's advertised language. cURL is shell; the // node/fetch snippet is TypeScript-labelled but valid TS. A spec-authored @@ -94,8 +94,7 @@ const railLabel = ? "Request sample" : "Response examples"; -// Per-instance ids so multiple rails on a page keep distinct tab↔panel wiring. -const respUid = `nb-cr-resp-${Math.random().toString(36).slice(2, 10)}`; +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/nimbus-starter-source/src/components/ui/api-layout/ApiLayout.astro b/packages/nimbus-starter-source/src/components/ui/api-layout/ApiLayout.astro index dc292ce7..0b6dffca 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 @@ -93,6 +93,7 @@ if (versionStatus?.isHidden) { samples={page.samples} title={page.title} method={page.method} + coordinate={page.coordinate} />

    )} diff --git a/packages/nimbus-starter-source/src/components/ui/search/providers/pagefind.ts b/packages/nimbus-starter-source/src/components/ui/search/providers/pagefind.ts index 3e9dc49a..934dcbcc 100644 --- a/packages/nimbus-starter-source/src/components/ui/search/providers/pagefind.ts +++ b/packages/nimbus-starter-source/src/components/ui/search/providers/pagefind.ts @@ -28,6 +28,13 @@ interface PagefindApi { let pagefind: PagefindApi | undefined; +function withBase(url: string): string { + if (!url.startsWith("/")) return url; + const base = `/${(import.meta.env.BASE_URL ?? "/").replace(/^\/+|\/+$/g, "")}`; + if (base === "/" || url === base || url.startsWith(`${base}/`)) return url; + return `${base}${url}`; +} + /** * Default Pagefind filters applied to every search. * @@ -70,11 +77,11 @@ export const provider: SearchProvider = { const results = await Promise.all(search.results.slice(0, 10).map((result) => result.data())); return results.map((result): SearchResult => ({ title: result.meta?.title ?? "Untitled", - url: result.url, + url: withBase(result.url), snippet: result.excerpt, subResults: result.sub_results ?.filter((sub): sub is Required => Boolean(sub.title && sub.url)) - .map((sub) => ({ title: sub.title, url: sub.url })), + .map((sub) => ({ title: sub.title, url: withBase(sub.url) })), })); }, }; diff --git a/packages/nimbus-starter-source/src/pages/[...slug].astro b/packages/nimbus-starter-source/src/pages/[...slug].astro index ea17fcf8..3d4ec817 100644 --- a/packages/nimbus-starter-source/src/pages/[...slug].astro +++ b/packages/nimbus-starter-source/src/pages/[...slug].astro @@ -38,7 +38,7 @@ const breadcrumbs = await getBreadcrumbs(currentSlug, { collection: entry.collec const editUrl = await getEditUrl(entry); // Frontmatter wins; git is the fallback. const lastUpdated = entry.data.lastUpdated ?? - (Astro.isPrerendered ? await getLastUpdated(entry) : undefined); + await getLastUpdated(entry); // `tocOn` already implies `tableOfContents !== false`, but TS can't carry // that boolean narrowing to the value here — re-check it so `getTOC` only // ever sees its options object (or undefined), never `false`. diff --git a/packages/nimbus-starter-source/src/pages/og/[...slug].ts b/packages/nimbus-starter-source/src/pages/og/[...slug].ts index 91329cb6..b2cbf027 100644 --- a/packages/nimbus-starter-source/src/pages/og/[...slug].ts +++ b/packages/nimbus-starter-source/src/pages/og/[...slug].ts @@ -1,4 +1,7 @@ -import { getVisibleEntries } from "@cloudflare/nimbus-docs/runtime"; +import { + getIndexedEntries, + isDiscoverable, +} from "@cloudflare/nimbus-docs/runtime"; import { OGImageRoute } from "astro-og-canvas"; import { ogCardConfig } from "./_og-card-config"; @@ -9,16 +12,26 @@ export const prerender = true; // Enumerate via the framework projection (not a raw `getCollection`) so draft // entries are excluded uniformly — a draft page emits no route, so its // `/og/.png` shouldn't either. -const entries = await getVisibleEntries(["docs"]); +const entries = (await getIndexedEntries()).filter((entry) => + isDiscoverable(entry.entry), +); const pages = Object.fromEntries( - entries.map((entry) => [ - entry.id, - { - title: entry.data.title, - description: entry.data.description ?? "", - }, - ]), + entries.map((entry) => { + const routeId = entry.entry.id.replace(/(?:^|\/)index$/, ""); + const pathname = entry.url.replace(/\/$/, ""); + const prefix = routeId ? pathname.slice(0, -routeId.length) : pathname; + return [ + `${prefix.replace(/\/$/, "")}/${entry.entry.id}`.replace( + /^\/+|\/+$/g, + "", + ), + { + title: entry.title, + description: entry.description ?? "", + }, + ]; + }), ); export const { getStaticPaths, GET } = await OGImageRoute({ diff --git a/scripts/fixtures/api-reference/src/pages/api/[...slug].astro b/scripts/fixtures/api-reference/src/pages/api/[...slug].astro index 08b54b96..03cfe4e1 100644 --- a/scripts/fixtures/api-reference/src/pages/api/[...slug].astro +++ b/scripts/fixtures/api-reference/src/pages/api/[...slug].astro @@ -10,12 +10,14 @@ export const getStaticPaths = getApiStaticPaths("api"); const result = await getApiRoute(Astro); if (result instanceof Response) return result; const { page, nav, collection, version, coordinate } = result; +const socialImage = `/og${page.href.replace(/\/$/, "")}.png`; --- + new URL(withBase(path, import.meta.env.BASE_URL), config.site).href; + +export async function getStaticPaths() { + return (await getIndexedEntries()) + .filter((item) => item.collection === "api") + .map((item) => ({ + params: { slug: item.entry.id === "index" ? undefined : item.entry.id }, + props: { item } as SlugProps, + })); +} + +export async function GET({ props }: { props: SlugProps }) { + const { item } = props; + const markdown = await renderIndexedEntryMarkdown(item); + return new Response( + [ + "---", + `title: ${JSON.stringify(item.title)}`, + ...(item.description + ? [`description: ${JSON.stringify(item.description)}`] + : []), + "---", + "", + markdown, + "", + `Source: ${absoluteUrl(item.markdownUrl)}`, + "", + ].join("\n"), + { headers: { "Content-Type": "text/markdown; charset=utf-8" } }, + ); +} diff --git a/scripts/workers-feasibility-check.mjs b/scripts/workers-feasibility-check.mjs index 99614c70..600d6dd7 100644 --- a/scripts/workers-feasibility-check.mjs +++ b/scripts/workers-feasibility-check.mjs @@ -3,6 +3,7 @@ import { spawn, spawnSync } from "node:child_process"; import { cpSync, + existsSync, mkdtempSync, mkdirSync, readFileSync, @@ -81,6 +82,112 @@ function findMarkedPages(pages, attribute) { return [...pages].filter(([, html]) => html.includes(attribute)); } +function prosePages(pages) { + return findMarkedPages(pages, "data-feasibility-prose").filter(([, html]) => + html.includes("Request prose body."), + ); +} + +function normalizedHtml(html) { + const sensitive = []; + const protectedHtml = html.replace( + /<(pre|code|textarea)(?:\s[^>]*)?>[\s\S]*?<\/\1>/gi, + (value) => { + const token = `NIMBUSSENSITIVE${sensitive.length}END`; + sensitive.push(value); + return token; + }, + ); + return protectedHtml + .replace(/data-request-probe="[^"]*"/g, 'data-request-probe=""') + .replace(/]*)?>[\s\S]*?<\/style>/g, "") + .replace(/]*>/g, "") + .replace( + /(\/_astro\/[^"'<>\s]+?)\.[A-Za-z0-9_-]{8}(\.(?:css|js|mjs))/g, + "$1.HASH$2", + ) + .replace(/\s([\w:-]+)=""/g, " $1") + .replace(/\s+/g, " ") + .trim() + .replace( + /NIMBUSSENSITIVE(\d+)END/g, + (_, index) => sensitive[Number(index)], + ); +} + +function assertGeneratedAssetsExist(site, html, label) { + for (const match of html.matchAll(/(?:src|href)="(\/_astro\/[^"?#]+)["?#]/g)) { + const asset = join(site, "dist", "client", match[1].slice(1)); + assert(existsSync(asset), `${label} references missing asset ${match[1]}`); + } +} + +function assertEquivalent(actual, expected, label) { + const actualNormalized = normalizedHtml(actual); + const expectedNormalized = normalizedHtml(expected); + if (actualNormalized === expectedNormalized) return; + let index = 0; + while ( + index < actualNormalized.length && + actualNormalized[index] === expectedNormalized[index] + ) { + index += 1; + } + fail( + `${label} changed between build and request rendering at byte ${index}: ` + + `${JSON.stringify(expectedNormalized.slice(index, index + 180))} !== ` + + JSON.stringify(actualNormalized.slice(index, index + 180)), + ); +} + +function assertDiscoverySurfaces(site) { + const client = join(site, "dist", "client"); + const sitemap = filesUnder(client) + .filter((file) => /sitemap.*\.xml$/.test(file)) + .map((file) => readFileSync(file, "utf8")) + .join("\n"); + assert(/https:\/\/workers-feasibility\.test\/runtime\/?<\/loc>/.test(sitemap), "sitemap omitted prose"); + assert(/https:\/\/workers-feasibility\.test\/api\/Health\/ping\/?<\/loc>/.test(sitemap), "sitemap omitted API operation"); + assert(!sitemap.includes("https://workers-feasibility.test/private/"), "sitemap included noindex prose"); + assert(!sitemap.includes("request-route-inventory"), "sitemap exposed the transient inventory"); + + const pagefindFiles = filesUnder(join(client, "pagefind")); + assert(pagefindFiles.some((file) => file.endsWith(".pf_meta")), "Pagefind metadata was not generated"); + assert(pagefindFiles.some((file) => file.endsWith(".pf_index")), "Pagefind index was not generated"); + assert( + pagefindFiles.filter((file) => file.endsWith(".pf_fragment")).length === 5, + "Pagefind did not preserve the five searchable routes", + ); + assert(existsSync(join(client, "og", "runtime.png")), "prose Open Graph image was not generated"); + assert( + existsSync(join(client, "og", "api", "Health", "ping.png")), + "API Open Graph image was not generated", + ); + assert(!existsSync(join(client, "og", "private.png")), "noindex Open Graph image was generated"); + assert( + !existsSync(join(client, "_nimbus", "request-route-inventory.json")), + "request inventory leaked into the deploy output", + ); +} + +async function assertStaticSurfaces(origin) { + for (const [route, evidence] of [ + ["/runtime/index.md", "This content rendered from a reusable partial."], + ["/runtime/index.mdx", '