diff --git a/.changeset/server-output-adapters.md b/.changeset/server-output-adapters.md index 76e70fd4..d5167a5c 100644 --- a/.changeset/server-output-adapters.md +++ b/.changeset/server-output-adapters.md @@ -4,7 +4,7 @@ Add server-output support and the `@cloudflare/nimbus-docs/adapters` export. -Nimbus can now target on-request (server) output in addition to static. A new `@cloudflare/nimbus-docs/adapters` public export ships the adapter recipes plus the shared `astro.config` and `wrangler.jsonc` emitters, and two new CLI verbs opt an existing site in: `nimbus-docs add server-output --adapter ` (alias `nimbus-docs add adapter-`). The installer rewrites `astro.config` at the `// nimbus:adapter` marker and, for Cloudflare, creates a server `wrangler.jsonc` or replaces an exact Nimbus static config. Custom and alternate Wrangler configs are preserved with manual adaptation instructions. +Nimbus can now target on-request (server) output in addition to static. A new `@cloudflare/nimbus-docs/adapters` public export ships the adapter recipes plus the shared `astro.config` and `wrangler.jsonc` emitters, and two new CLI verbs opt an existing site in: `nimbus-docs add server-output --adapter ` (alias `nimbus-docs add adapter-`). The installer rewrites `astro.config` at the `// nimbus:adapter` marker and, for Cloudflare, creates a server `wrangler.jsonc` or replaces an exact Nimbus static config. Cloudflare installs add request rendering when the active Nimbus config has no explicit rendering policy; explicit or ambiguous policies are preserved and receive an agent-ready handoff. Adapter dependencies are saved at their exact resolved versions so subsequent runs accept the installed declaration. Custom and alternate Wrangler configs are preserved with manual adaptation instructions. Withdraw the `gated` config option because it did not hold as a confidentiality boundary. Existing `gated` config now fails with a migration error; to keep a page out of the build, move the page out of a routed content collection. diff --git a/.changeset/workers-prose-rendering.md b/.changeset/workers-prose-rendering.md new file mode 100644 index 00000000..b1484373 --- /dev/null +++ b/.changeset/workers-prose-rendering.md @@ -0,0 +1,10 @@ +--- +"@cloudflare/nimbus-docs": minor +"@cloudflare/create-nimbus-docs": minor +--- + +Add Cloudflare request rendering for canonical content collections. + +Nimbus now supports collection-level build and request rendering policies with validated defaults and per-collection overrides. Request-rendered prose and API routes use response-aware page helpers, prepared API models, request-safe partial headings, 404 responses, and build-derived syntax-highlighting assets without shipping source OpenAPI specs to Workers. Cloudflare server scaffolds enable request rendering by default, and generated pnpm configuration installs Satteri's WASI fallback alongside the current architecture. + +Preserve sitemap, Pagefind, Markdown, and agent-index discovery for request-rendered routes. Pin the tested sitemap integration, clean up synthetic Pagefind staging files transactionally, 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/README.md b/README.md index f83c747e..31ee9613 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,11 @@ Run these inside your project: Static by default — `pnpm build` emits `dist/`, which you can host anywhere. -Choose server output during scaffolding, or add an adapter later, when the site needs on-demand routes. Docs pages remain prerendered. +Choose Cloudflare server output during scaffolding to render canonical content collection routes on request. Existing projects can wire the adapter, then hand the project-specific rendering edit to a coding agent: ```sh pnpm exec nimbus-docs add adapter-cloudflare -# adapter-vercel, adapter-netlify, or adapter-node +pnpm exec nimbus-docs add adapter-cloudflare --print | claude ``` Cloudflare is the first-class target: the default scaffold ships a `wrangler.jsonc`. @@ -82,7 +82,7 @@ Components and utilities copy in as editable files. Features hand off a recipe y ## Built on -[Astro 7](https://astro.build) · Sätteri (Rust-based markdown) · Tailwind v4 · optional React 19. Static by default, with opt-in server output for Cloudflare, Vercel, Netlify, and Node. +[Astro 7](https://astro.build) · Sätteri (Rust-based markdown) · Tailwind v4 · optional React 19. Static output deploys anywhere; request-rendered server output currently targets Cloudflare. ## Status diff --git a/apps/www/registry/features/api-reference.md b/apps/www/registry/features/api-reference.md index bd33e893..3568d233 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,13 +308,17 @@ 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 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/apps/www/src/content/docs/cli.mdx b/apps/www/src/content/docs/cli.mdx index 28ca4efc..c91984ca 100644 --- a/apps/www/src/content/docs/cli.mdx +++ b/apps/www/src/content/docs/cli.mdx @@ -66,10 +66,33 @@ Use `--print` to force the markdown output, skipping detection. ### Server adapters -For `adapter-cloudflare`, `adapter-vercel`, `adapter-netlify`, and `adapter-node`, the CLI installs the Astro adapter and rewrites the marked `output` block in `astro.config`. For Cloudflare, it creates a server-compatible `wrangler.jsonc` when none exists or replaces an exact Nimbus static config. Custom JSONC and alternate JSON/TOML configs stay untouched, with the required server settings printed for manual adaptation. +For `adapter-cloudflare`, `adapter-vercel`, `adapter-netlify`, and `adapter-node`, the CLI installs the Astro adapter and rewrites the marked `output` block in `astro.config`. It refuses to replace a different adapter or a non-literal `output` value. +`adapter-cloudflare` adds request rendering when the Nimbus config has no explicit rendering policy. Existing policies are preserved; imported or ambiguous configurations receive a coding-agent handoff instead of a speculative rewrite. The command creates a server-compatible `wrangler.jsonc` when none exists or replaces an unchanged Nimbus static config; custom JSONC and alternate JSON/TOML configs stay untouched. + +After completing the adapter install, expect: + +```ts +const nimbusConfig = defineNimbusConfig({ + rendering: { default: "request" }, + // ... +}); + +export default defineConfig({ + output: "server", + adapter: cloudflare({ prerenderEnvironment: "node" }), + integrations: [nimbus(nimbusConfig)], +}); +``` + +When the command runs inside a detected coding agent, it emits a versioned runbook so the agent can safely adapt project-owned or split configuration. From a regular shell, use `--print` to request that runbook explicitly: + + + +Always run the project’s production build afterward. See [Rendering policy](/configuration#rendering-policy) for per-collection build/request overrides. + The equivalent long form is `nimbus-docs add server-output --adapter `. ## `nimbus-docs init` diff --git a/apps/www/src/content/docs/configuration.mdx b/apps/www/src/content/docs/configuration.mdx index 49c4ea2e..5665d3cf 100644 --- a/apps/www/src/content/docs/configuration.mdx +++ b/apps/www/src/content/docs/configuration.mdx @@ -48,6 +48,92 @@ export default defineConfig({ | `features` | object | `sidebar` / `tableOfContents` kill switches. See [Layouts](/styling/layouts). | | `search` | object \| `false` | Search backend. See [Search](/navigation/search). | | `versions` | object | Versioning manifest — each version is its own content collection. | +| `rendering` | object | Build or request rendering policy for canonical content collection routes. | + +### Rendering policy + +`rendering` controls canonical content collection routes: the catch-all routes Nimbus uses for docs, API references, and other registered collections. It does not change custom files under `src/pages/`. + +| Mode | Behavior | Deployment requirement | +| --- | --- | --- | +| `"build"` | Prerenders the route during `astro build`. This is the default. | Any static or server deployment. | +| `"request"` | Renders the route when a visitor requests it. | Astro server output with the Cloudflare adapter. | + +Omitting `rendering` is equivalent to `rendering: { default: "build" }`. + +#### Enable request rendering + +Choose **Server** and **Cloudflare** when creating a new site. For an existing site, run the adapter installer from the project root: + + + +The installer wires the adapter and server Wrangler configuration, then adds `rendering: { default: "request" }` when the active Nimbus config has no rendering policy. Existing policies are preserved. If the config is imported or otherwise cannot be edited safely, the installer prints a coding-agent runbook instead: + +```sh +pnpm dlx @cloudflare/nimbus-docs add adapter-cloudflare --print | claude +``` + +The resulting configuration has these three parts: + +```ts title="astro.config.ts" +import cloudflare from "@astrojs/cloudflare"; +import { defineConfig } from "astro/config"; +import nimbus, { + defineConfig as defineNimbusConfig, +} from "@cloudflare/nimbus-docs"; + +const nimbusConfig = defineNimbusConfig({ + site: "https://docs.example.com", + title: "Acme", + rendering: { + default: "request", + }, +}); + +export default defineConfig({ + output: "server", + adapter: cloudflare({ prerenderEnvironment: "node" }), + integrations: [nimbus(nimbusConfig)], +}); +``` + +Run `pnpm build` after changing output mode. A successful production build is the authoritative check that the adapter and Wrangler configuration agree. + +#### Mix build and request rendering + +`rendering.default` applies to every registered collection with a canonical catch-all route. `rendering.collections` overrides that mode by Astro collection name, not by URL or version. + +Render most collections on request but keep an archive prerendered: + +```ts +rendering: { + default: "request", + collections: { + archived: "build", + }, +}, +``` + +Or keep the site static except for one collection: + +```ts +rendering: { + default: "build", + collections: { + api: "request", + }, +}, +``` + +Every override must name a registered collection with its own canonical catch-all route. Nimbus fails the build for unknown collection names rather than silently ignoring them. + +Request-rendered routes are still added to the sitemap and Pagefind search index during the production build. Nimbus uses the content source to create those build-time discovery artifacts; no extra sitemap or search configuration is required. + + + +See [Server adapters](/cli#server-adapters) for installer behavior and safety checks. ## Integration options diff --git a/apps/www/src/content/docs/installation.mdx b/apps/www/src/content/docs/installation.mdx index 4a7c2085..4bdb9215 100644 --- a/apps/www/src/content/docs/installation.mdx +++ b/apps/www/src/content/docs/installation.mdx @@ -28,10 +28,10 @@ Pick a directory name, then choose your preferences: Two choices — **full** (core components, demo content, populated theme tokens) or **empty** (the minimum to run, ready for your own content). - **Static** prerenders the entire site and deploys anywhere. **Server** keeps docs pages prerendered while adding an adapter so optional on-demand routes can run. + **Static** prerenders every content collection route and deploys anywhere. **Server** uses Cloudflare to render those routes on request. You can override individual collections later. - Static output supports Cloudflare or another static host. Server output supports Cloudflare, Vercel, Netlify, or Node through their Astro adapters. + Static output supports Cloudflare or another static host. Interactive server setup currently offers Cloudflare Workers; explicit `--adapter` flags also support Vercel, Netlify, and Node. The scaffolder runs your package manager automatically. Skip if you want to edit `package.json` first. @@ -67,7 +67,7 @@ Then open the printed URL. -Static scaffolds emit `dist/` for any static host. Server scaffolds use the adapter selected during setup; follow that platform's deployment flow. Cloudflare scaffolds include the appropriate `wrangler.jsonc` and a `deploy` script. Node scaffolds include a `start` script that runs the built standalone server. +Static scaffolds emit `dist/` for any static host. Cloudflare server scaffolds request-render canonical content collection routes and include the appropriate `wrangler.jsonc` and a `deploy` script.