Skip to content

Commit de39b8d

Browse files
authored
Merge pull request #104 from cloudflare/feat/server-rendering
feat: server rendering
2 parents 19fdbcd + 77b34b6 commit de39b8d

118 files changed

Lines changed: 9133 additions & 2119 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/server-output-adapters.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
Add server-output support and the `@cloudflare/nimbus-docs/adapters` export.
66

7-
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 <vercel|node|netlify|cloudflare>` (alias `nimbus-docs add adapter-<id>`). 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.
7+
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 <vercel|node|netlify|cloudflare>` (alias `nimbus-docs add adapter-<id>`). 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.
88

99
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.
1010

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@cloudflare/nimbus-docs": minor
3+
"@cloudflare/create-nimbus-docs": minor
4+
---
5+
6+
Add Cloudflare request rendering for canonical content collections.
7+
8+
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.
9+
10+
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.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
name: Workers rendering acceptance
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
changes:
14+
runs-on: ubuntu-latest
15+
outputs:
16+
relevant: ${{ steps.filter.outputs.relevant }}
17+
steps:
18+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
19+
with:
20+
fetch-depth: 0
21+
- id: filter
22+
name: Detect Workers rendering changes
23+
env:
24+
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
25+
run: |
26+
relevant=false
27+
if [[ -z "$BASE_SHA" || "$BASE_SHA" == "0000000000000000000000000000000000000000" ]]; then
28+
relevant=true
29+
else
30+
changed_files="$RUNNER_TEMP/workers-rendering-changed-files.txt"
31+
git diff --name-only "$BASE_SHA" "$GITHUB_SHA" > "$changed_files"
32+
while IFS= read -r file; do
33+
case "$file" in
34+
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)
35+
relevant=true
36+
break
37+
;;
38+
esac
39+
done < "$changed_files"
40+
fi
41+
echo "relevant=$relevant" >> "$GITHUB_OUTPUT"
42+
43+
acceptance:
44+
needs: changes
45+
if: needs.changes.outputs.relevant == 'true'
46+
runs-on: ubuntu-latest
47+
timeout-minutes: 30
48+
steps:
49+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
50+
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
51+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
52+
with:
53+
node-version: 24
54+
cache: pnpm
55+
- run: pnpm install --frozen-lockfile
56+
- run: pnpm workers-feasibility:check
57+
58+
required:
59+
name: Workers rendering required
60+
needs: [changes, acceptance]
61+
if: always()
62+
runs-on: ubuntu-latest
63+
steps:
64+
- name: Require acceptance when relevant
65+
env:
66+
CHANGES_RESULT: ${{ needs.changes.result }}
67+
RELEVANT: ${{ needs.changes.outputs.relevant }}
68+
RESULT: ${{ needs.acceptance.result }}
69+
run: |
70+
if [[ "$CHANGES_RESULT" != "success" ]]; then
71+
exit 1
72+
fi
73+
if [[ "$RELEVANT" != "true" && "$RELEVANT" != "false" ]]; then
74+
exit 1
75+
fi
76+
if [[ "$RELEVANT" == "true" && "$RESULT" != "success" ]]; then
77+
exit 1
78+
fi

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ Run these inside your project:
4141

4242
Static by default — `pnpm build` emits `dist/`, which you can host anywhere.
4343

44-
Choose server output during scaffolding, or add an adapter later, when the site needs on-demand routes. Docs pages remain prerendered.
44+
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:
4545

4646
```sh
4747
pnpm exec nimbus-docs add adapter-cloudflare
48-
# adapter-vercel, adapter-netlify, or adapter-node
48+
pnpm exec nimbus-docs add adapter-cloudflare --print | claude
4949
```
5050

5151
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
8282

8383
## Built on
8484

85-
[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.
85+
[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.
8686

8787
## Status
8888

apps/www/registry/features/api-reference.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,10 @@ export async function GET({ props }: { props: SlugProps }) {
271271
### 4e. Scaffold the HTML route
272272

273273
The route is thin: `getApiStaticPaths` enumerates one path per page, and
274-
`getApiPage(Astro)` builds the model and projects the page props + nav in a
275-
single call — you hand both to `ApiLayout` (installed in 4a). `ApiLayout` composes `ApiSidebar` (verb chips +
274+
`getApiRoute(Astro)` reads the page props and shared navigation prepared by the
275+
content loader, then marks the current navigation path active. It never reads
276+
or parses the OpenAPI source at request time. Hand both results to `ApiLayout`
277+
(installed in 4a). `ApiLayout` composes `ApiSidebar` (verb chips +
276278
active-section pruning), `ApiFieldRow` (recursive fields with type links), and
277279
`ApiCodeRail` (server-generated code samples with a language switcher + a
278280
response-example status toggle), rendering any page
@@ -298,21 +300,25 @@ Write `src/pages/api/[...slug].astro`:
298300
<!-- api-reference-fixture:src/pages/api/[...slug].astro -->
299301
```astro
300302
---
301-
import { getApiPage, getApiStaticPaths } from "@cloudflare/nimbus-docs";
303+
import { getApiRoute, getApiStaticPaths } from "@cloudflare/nimbus-docs/runtime";
302304
import Header from "@/components/Header.astro";
303305
import { ApiLayout } from "@/components/ui/api-layout";
304306
import BaseLayout from "@/layouts/BaseLayout.astro";
305307
306308
export const prerender = true;
307309
export const getStaticPaths = getApiStaticPaths("api");
308310
309-
const { page, nav, collection, version, coordinate } = await getApiPage(Astro);
311+
const result = await getApiRoute(Astro);
312+
if (result instanceof Response) return result;
313+
const { page, nav, collection, version, coordinate } = result;
314+
const socialImage = `/og${page.href.replace(/\/$/, "")}.png`;
310315
---
311316
312317
<BaseLayout
313318
title={`${page.title} · API`}
314319
description={page.description}
315320
markdownUrl={page.markdownHref}
321+
socialImage={socialImage}
316322
collection={collection}
317323
apiVersion={version ?? undefined}
318324
coordinate={coordinate}

apps/www/registry/features/changelog.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -716,13 +716,15 @@ route in 5j entirely.
716716
import { Icon } from "astro-icon/components";
717717
import ChangelogLayout from "@/layouts/ChangelogLayout.astro";
718718
import { Badge } from "@/components/ui/badge";
719-
import { getCollectionStaticPaths, getCollectionPageProps, withBase } from "@cloudflare/nimbus-docs";
719+
import { getCollectionStaticPaths, getCollectionPage, withBase } from "@cloudflare/nimbus-docs";
720720
import { components } from "@/components";
721721
722722
export const prerender = true;
723723
export const getStaticPaths = getCollectionStaticPaths("changelog");
724724
725-
const { entry, Content } = await getCollectionPageProps<"changelog">(Astro);
725+
const page = await getCollectionPage<"changelog">(Astro);
726+
if (page instanceof Response) return page;
727+
const { entry, Content } = page;
726728
const { title, description, date, tags } = entry.data;
727729
728730
const iso = date.toISOString().slice(0, 10);

apps/www/registry/features/new-collection.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ conventions:
6161
name).
6262
- `src/pages/[...slug].astro` — read it. The new route will mirror this
6363
shape exactly except for the helper names (`getCollectionStaticPaths` /
64-
`getCollectionPageProps` instead of the `Docs` variants).
64+
`getCollectionPage` instead of the `Docs` variants).
6565
- `src/pages/[...slug]/index.md.ts` — read it. The new `.md` alternate
6666
will mirror it.
6767
- `src/layouts/DocsLayout.astro` — confirm it exists. The new route uses
@@ -191,7 +191,7 @@ Write `src/pages/<prefix>/[...slug].astro`:
191191
import DocsLayout from "../../layouts/DocsLayout.astro";
192192
import {
193193
getCollectionStaticPaths,
194-
getCollectionPageProps,
194+
getCollectionPage,
195195
getSidebar,
196196
getPrevNext,
197197
getBreadcrumbs,
@@ -205,7 +205,9 @@ import { components } from "../../components";
205205
export const prerender = true;
206206
export const getStaticPaths = getCollectionStaticPaths("<collection>");
207207
208-
const { entry, Content, headings } = await getCollectionPageProps<"<collection>">(Astro);
208+
const page = await getCollectionPage<"<collection>">(Astro);
209+
if (page instanceof Response) return page;
210+
const { entry, Content, headings } = page;
209211
210212
const currentSlug = Astro.url.pathname.replace(/\/$/, "") || "/";
211213
// Pass collection so the sidebar/prev-next resolve against the current
@@ -218,7 +220,8 @@ const prevNext = await getPrevNext(currentSlug, {
218220
});
219221
const breadcrumbs = await getBreadcrumbs(currentSlug);
220222
const editUrl = await getEditUrl(entry);
221-
const lastUpdated = entry.data.lastUpdated ?? await getLastUpdated(entry);
223+
const lastUpdated = entry.data.lastUpdated ??
224+
await getLastUpdated(entry);
222225
const toc = getTOC(headings, entry.data.tableOfContents);
223226
const markdownPath = `/<prefix>/${entry.id}/index.md`;
224227
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
414417
to a Nimbus site. Blogs, API references, changelogs, glossaries, versioned
415418
docs siblings — all the same shape underneath.
416419
- The framework helpers `getCollectionStaticPaths(collection)` and
417-
`getCollectionPageProps<C>(astro)` are sibling functions to
420+
`getCollectionPage<C>(astro)` are sibling functions to
418421
`getDocsStaticPaths`/`getDocsPageProps`. Use the `Collection` variants in
419422
scaffolded routes; the `Docs` variants stay for the primary route only.
420423
- The URL convention is intentional: primary `docs` mounts at root, every

apps/www/registry/features/new-version.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ siblings:
337337
338338
- `getCollectionStaticPaths("docs-<slug>")` — takes the collection
339339
name as an argument
340-
- `getCollectionPageProps<"docs-<slug>">(Astro)` — takes the
340+
- `getCollectionPage<"docs-<slug>">(Astro)` — takes the
341341
collection name as a TypeScript generic
342342
343343
The snippet below uses the correct helpers. Copy it verbatim and
@@ -352,7 +352,7 @@ name with the user's slug):
352352
import DocsLayout from "../../layouts/DocsLayout.astro";
353353
import {
354354
getCollectionStaticPaths,
355-
getCollectionPageProps,
355+
getCollectionPage,
356356
getSidebar,
357357
getPrevNext,
358358
getBreadcrumbs,
@@ -366,7 +366,9 @@ import { components } from "../../components";
366366
export const prerender = true;
367367
export const getStaticPaths = getCollectionStaticPaths("docs-<slug>");
368368

369-
const { entry, Content, headings } = await getCollectionPageProps<"docs-<slug>">(Astro);
369+
const page = await getCollectionPage<"docs-<slug>">(Astro);
370+
if (page instanceof Response) return page;
371+
const { entry, Content, headings } = page;
370372

371373
const currentSlug = Astro.url.pathname.replace(/\/$/, "") || "/";
372374
const sidebar = await getSidebar(currentSlug, { collection: entry.collection });
@@ -376,7 +378,8 @@ const prevNext = await getPrevNext(currentSlug, {
376378
});
377379
const breadcrumbs = await getBreadcrumbs(currentSlug);
378380
const editUrl = await getEditUrl(entry);
379-
const lastUpdated = entry.data.lastUpdated ?? await getLastUpdated(entry);
381+
const lastUpdated = entry.data.lastUpdated ??
382+
await getLastUpdated(entry);
380383
const toc = getTOC(headings, entry.data.tableOfContents);
381384
const markdownPath = `/<slug>/${entry.id}/index.md`;
382385
const basedMarkdownPath = withBase(markdownPath, import.meta.env.BASE_URL);

apps/www/src/components/ui/search/providers/pagefind.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ interface PagefindApi {
2828

2929
let pagefind: PagefindApi | undefined;
3030

31+
function withBase(url: string): string {
32+
if (!url.startsWith("/")) return url;
33+
const base = `/${(import.meta.env.BASE_URL ?? "/").replace(/^\/+|\/+$/g, "")}`;
34+
if (base === "/" || url === base || url.startsWith(`${base}/`)) return url;
35+
return `${base}${url}`;
36+
}
37+
3138
/**
3239
* Default Pagefind filters applied to every search.
3340
*
@@ -70,11 +77,11 @@ export const provider: SearchProvider = {
7077
const results = await Promise.all(search.results.slice(0, 10).map((result) => result.data()));
7178
return results.map((result): SearchResult => ({
7279
title: result.meta?.title ?? "Untitled",
73-
url: result.url,
80+
url: withBase(result.url),
7481
snippet: result.excerpt,
7582
subResults: result.sub_results
7683
?.filter((sub): sub is Required<PagefindSubResult> => Boolean(sub.title && sub.url))
77-
.map((sub) => ({ title: sub.title, url: sub.url })),
84+
.map((sub) => ({ title: sub.title, url: withBase(sub.url) })),
7885
}));
7986
},
8087
};

apps/www/src/content/docs/cli.mdx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,33 @@ Use `--print` to force the markdown output, skipping detection.
6666

6767
### Server adapters
6868

69-
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.
69+
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.
7070

7171
<PackageManagers pkg="@cloudflare/nimbus-docs" type="dlx" args="add adapter-cloudflare" />
7272

73+
`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.
74+
75+
After completing the adapter install, expect:
76+
77+
```ts
78+
const nimbusConfig = defineNimbusConfig({
79+
rendering: { default: "request" },
80+
// ...
81+
});
82+
83+
export default defineConfig({
84+
output: "server",
85+
adapter: cloudflare({ prerenderEnvironment: "node" }),
86+
integrations: [nimbus(nimbusConfig)],
87+
});
88+
```
89+
90+
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:
91+
92+
<PackageManagers pkg="@cloudflare/nimbus-docs" type="dlx" args="add adapter-cloudflare --print | claude" />
93+
94+
Always run the project’s production build afterward. See [Rendering policy](/configuration#rendering-policy) for per-collection build/request overrides.
95+
7396
The equivalent long form is `nimbus-docs add server-output --adapter <cloudflare|vercel|netlify|node>`.
7497

7598
## `nimbus-docs init`

0 commit comments

Comments
 (0)