diff --git a/.changeset/base-pages-isr-seed.md b/.changeset/base-pages-isr-seed.md
new file mode 100644
index 0000000..a2a47d2
--- /dev/null
+++ b/.changeset/base-pages-isr-seed.md
@@ -0,0 +1,33 @@
+---
+'@ogs-tech/press-web': minor
+'@ogs-tech/press-cms': minor
+---
+
+feat: BASE/PAGES foundation — ISR intact, generic page seed, metadata reduced to
+
+**press-web — ISR restored, pages prerendered.** `getPage` now fetches ISR-cached
+(`next: { revalidate: 60 }`, mirroring `getSiteConfig`) instead of
+`cache: 'no-store'` — a single no-store fetch had opted the whole catch-all route
+into per-request dynamic rendering. The route also declares
+`export const revalidate = 60` and a `generateStaticParams` that lists published
+slugs from the CMS (new `getPageSlugs`/`getStaticPageParams`), so published pages
+are prerendered as static ISR at build (the route reports `● (SSG)` with a 60s
+revalidate window instead of `ƒ (Dynamic)`). `dynamicParams` stays true, so a slug
+added after the build renders on-demand and caches; and `getPageSlugs` fails to
+empty, so an unreachable CMS at build yields zero prerendered pages (every page
+renders on-demand) rather than failing the build — the CMS is a build-time
+optimization, not a hard build dependency. `/home → /` and `notFound()` are
+unchanged.
+
+**press-web — metadata reduced (behavior change).** `buildMetadata` now emits
+only `` (+ favicon). `description`, `openGraph`, and `alternates`
+(canonical) are removed — the old canonical was always the site root, i.e. wrong
+for every non-home page, so dropping it is a net improvement. All SEO/social
+metadata is deferred to a future Plugin/SEO. `ResolvedPressConfig.seo` is
+unchanged (`defaultDescription`/`defaultOgImage` go unconsumed for now).
+
+**press-cms — generic seed, privacy removed.** New generic idempotent
+`seedPage({ slug, title, body, flagKey })` (flag-first, slug-collision → adopter
+wins, created as DRAFT) replaces the bespoke `seedPrivacyPolicyPage`. Base does
+not seed a privacy-policy page anymore — that belongs to a future Plugin/Legal.
+`seedPage` is exported-but-unused public API until the first consumer lands.
diff --git a/.changeset/cli-truthful-build-exit-code.md b/.changeset/cli-truthful-build-exit-code.md
new file mode 100644
index 0000000..f17f886
--- /dev/null
+++ b/.changeset/cli-truthful-build-exit-code.md
@@ -0,0 +1,14 @@
+---
+'@ogs-tech/press-web': patch
+---
+
+fix(cli): `press build` surfaces the failing subprocess's real exit code
+
+`util/run.ts` now rejects with a `SubprocessError` carrying the child's real
+`code` (and `signal`), and `bin/press.ts` re-exits with it instead of a blanket
+`1`. Before, any failing step in `press build` (`strapi build` / `next build`)
+collapsed to exit `1`, weakening the "truthful failure" guarantee that `dev.ts`
+already upholds via `waitForReadyOrExit` — a CMS build failing with a specific
+code was indistinguishable from any other error in CI. `press dev`'s pre-boot
+`seed`/`sync-types` steps become truthful for free (same `run()` helper). No
+behavior change on success (exit 0).
diff --git a/.changeset/privacy-policy-page-seed.md b/.changeset/privacy-policy-page-seed.md
deleted file mode 100644
index a1694ad..0000000
--- a/.changeset/privacy-policy-page-seed.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@ogs-tech/press-cms': minor
----
-
-feat(cms): seed a Privacy Policy page template once at bootstrap
-
-`bootstrap()` now seeds a "Privacy Policy" page (slug `privacy-policy`) exactly
-once, following the chrome-seed semantics: a `privacyPageSeeded` plugin-store
-flag makes the pass literal-once, an adopter page already occupying the slug
-wins (seed marks itself done without writing), and an editor-deleted page is
-respected forever. The page is created as a DRAFT — the engine never publishes
-content on its own.
-
-The template is structure + placeholder guidance composed from existing
-`press.*` atoms (intro, Data We Collect, Cookies, How We Use Your Data, Data
-Sharing, Your Rights, Contact) — no legal boilerplate is embedded; the editor
-writes and owns the actual policy text.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ec54e37..1cb6b1a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,12 +20,15 @@ jobs:
- run: pnpm install --frozen-lockfile
+ # Guard the committed versions.generated.ts BEFORE `pnpm build` runs — the cli's
+ # build script is `gen:versions && tsc` (WRITE mode), which would regenerate the
+ # file in the runner's tree and mask a stale commit. Must stay above `pnpm build`.
+ - run: pnpm --filter @ogs-tech/create-press gen:versions --check
+
- run: pnpm build
- run: pnpm -r --if-present typecheck
- run: pnpm -r test
- - run: pnpm --filter @ogs-tech/press-cli gen:versions --check
-
- run: pnpm pack:check
diff --git a/docs/superpowers/plans/2026-07-11-base-cli-followups.md b/docs/superpowers/plans/2026-07-11-base-cli-followups.md
new file mode 100644
index 0000000..97ca554
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-11-base-cli-followups.md
@@ -0,0 +1,75 @@
+# BASE/CLI — tracked follow-ups (2026-07-11)
+
+Surfaced by the BASE/CLI closing review. These are **not** blockers for closing
+the CRM task — the DONE criteria are met (see the review summary). They are the
+robustness/cleanup gaps we consciously deferred. Each is independently shippable
+as its own PR (do not bundle — separate concerns).
+
+The task-closing PR fixed only the two "truthful failure" defects:
+- CI `gen:versions --check` gate made real (wrong `--filter` name + step ordered
+ after `pnpm build`, which regenerated the file before the check could see drift).
+- `press build` now surfaces the subprocess's real exit code (`SubprocessError`
+ in `util/run.ts` + `bin/press.ts`), matching `dev.ts`.
+
+---
+
+## 1. End-to-end + orchestration tests
+
+**Gap.** The two most important commands — `devCommand` (`packages/web/src/commands/dev.ts`)
+and `buildCommand` (`build.ts`) — have **no direct tests**. Their primitives
+(`waitForReadyOrExit`, `watchSchema`, `materialize`, `run`) are well covered, but
+the orchestration itself (6-step boot sequence, `killAll`, SIGINT/SIGTERM
+handlers, the `cms.exit`/`web.exit` race, the `shuttingDown` flag) is validated
+only by manual `pnpm dev` dogfooding. Likewise, `@ogs-tech/create-press` has no
+test that runs the **published binary** (`bin/create-press.js` → `dist/cli.js` →
+real `pnpm install`); `scaffold()` is tested in isolation and only exercised from
+TS source via `scripts/create-playground.ts`.
+
+**Do.**
+- Unit-level orchestration tests for `dev.ts`/`build.ts` by injecting a spawn/`run`
+ seam (mock children like `wait-for-ready-or-exit.test.ts`'s `FakeChild`): assert
+ the boot order, that a cms crash aborts before booting web, that SIGINT exits 0,
+ and that an unexpected child exit re-exits with its real code.
+- A Playwright/e2e smoke (already named as planned in the root README): scaffold a
+ temp project via the built binary, `press dev`, assert `:1337` + `:3000` serve
+ and `shared/types/generated.ts` is written.
+
+**Acceptance.** `dev.ts`/`build.ts` have coverage; a CI smoke boots a scaffolded
+project end-to-end.
+
+## 2. Documentation drift + dead code
+
+**Gap.**
+- **`press.config.ts` naming.** CLAUDE.md and the README describe build-time
+ anchors as living in `press.config.ts` at the repo root, but the scaffold emits
+ `packages/web/config.ts` (its header comment even says `// press.config.ts`).
+ No generated file is literally named/located `press.config.ts`. There is also a
+ stray `press.config.ts` at this monorepo's root with no functional link to the
+ CLI. Harmless, but it misleads anyone auditing literally.
+- **`VERSIONS.pressCli` dead field.** Computed and tested in `compute-versions.ts`
+ but never consumed by `scaffold.ts` (a scaffolded project never depends on
+ `@ogs-tech/create-press`, by design).
+- **Stale `packages/cli/dist/`.** Contains artifacts from a prior architecture
+ (`dist/commands/{dev,build,deploy}.js`, `dist/materialize.js`, …) with no `src/`
+ counterpart. `dist/` is gitignored so publish is unaffected, but it confuses a
+ manual `node bin/create-press.js` without a fresh rebuild.
+
+**Do.** Reconcile the docs to the real path (or rename the emitted file — decide
+which is canonical); drop `pressCli` from `ScaffoldVersions` (or wire it if a use
+appears); note that `dist/` must be rebuilt before manual bin inspection.
+
+**Acceptance.** Docs match the scaffold output; no computed-but-unused version field.
+
+## 3. Derive `NEXT_PIN` instead of hardcoding
+
+**Gap.** `compute-versions.ts:41` hardcodes `NEXT_PIN = '^15.1.0'` — the only
+scaffold pin not derived from a real manifest (react/strapi come from engine
+`devDependencies`; the follow-up is already noted in-code). `@ogs-tech/press-web`
+declares only an open `next: '>=15'` peer range, useless as a pin.
+
+**Do.** Add `next` to `@ogs-tech/press-web` `devDependencies` (the version the
+engine is actually tested against) and derive the pin via `requireDevDep`, exactly
+like react/strapi. Removes the last manual knob and closes the drift loop for Next.
+
+**Acceptance.** `computeVersions` derives `next` from a manifest; no `NEXT_PIN`
+constant.
diff --git a/docs/superpowers/specs/2026-07-05-base-pages-isr-seed-design.md b/docs/superpowers/specs/2026-07-05-base-pages-isr-seed-design.md
new file mode 100644
index 0000000..179a453
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-05-base-pages-isr-seed-design.md
@@ -0,0 +1,216 @@
+# BASE/PAGES — foundation rescoped (ISR + generic seed; metadata → Plugin/SEO)
+
+**Date:** 2026-07-05
+**CRM item:** `[OGS] [PRESS] BASE / PAGES`
+**Status:** design approved, pending spec review
+
+## Context
+
+The `page` content-type, the RSC catch-all route, `mapPage` (`urn:page:{documentId}`),
+and the `/home → /` redirect already exist. Reconnaissance against the CRM DONE found
+that most of the item is already shipped. The real work is narrow:
+
+- **One real bug:** `getPage` fetches with `cache: 'no-store'`, which opts the whole
+ route into per-request dynamic rendering — so **ISR is NOT intact**, directly failing
+ the DONE criterion "rota não-dynamic (ISR intacto)". This is the same trap the
+ cookie-consent design deliberately avoids (never force the route dynamic / poison ISR).
+- **One generalization:** page seeding is bespoke (`seedPrivacyPolicyPage`, hardcoded
+ constant + `privacyPageSeeded` flag). The CRM wants a reusable idempotent seed
+ mechanism and states privacy-policy is **not** a base seed — it belongs to a future
+ Plugin/Legal.
+
+### Scope decision (locked during brainstorming)
+
+Metadata (SEO **and** social/share) is pulled OUT of this item into a **new Plugin/SEO**
+roadmap item (sibling of Plugin/Legal), following the engine's thin-base + plugin-family
+philosophy. BASE/PAGES becomes a pure foundation:
+
+| Decision | Choice |
+| --- | --- |
+| Privacy-policy seed | **Remove from base**; ship generic `seedPage()` API |
+| Metadata depth in this item | **None beyond ``** (favicon kept — it is identity, not SEO) |
+| Per-page SEO/social override fields | Deferred to Plugin/SEO (not added to `page` schema here) |
+| Plugin/SEO roadmap card | Created (drafted below; user pastes into Zoho — no MCP access) |
+| ISR revalidate window | 60s (mirrors `getSiteConfig`) |
+| `seedPage()` consumer in base | None yet — exported-but-unused API until Plugin/Legal / archetypes consume it |
+
+## Goals
+
+1. Route renders SSR (full HTML in view-source) **as ISR**, not forced-dynamic.
+2. `/home → /` stays deterministic and CMS-independent (already true — verify only).
+3. A generic idempotent `seedPage({ slug, title, body, flagKey })` helper exists as
+ public API for Plugin/Legal and archetype templates.
+4. Privacy-policy seeding removed from base.
+5. `` metadata reduced to `` (+ favicon); everything else handed to Plugin/SEO.
+
+## Non-goals
+
+- Any per-page SEO/social schema fields, canonical, `metadataBase`, Open Graph, Twitter
+ card, robots, JSON-LD, hreflang — all Plugin/SEO.
+- i18n / per-locale — separate downstream item.
+- Touching the `preset-config.seo` component or `mapSiteSettings` (left as-is, harmless).
+- Changing the `getPage` fetch URL / by-slug mechanism (out of scope — it works today).
+
+## Design
+
+### A. ISR intact (`packages/web`)
+
+**`src/get-page.ts`** — the one-line core fix:
+
+- `fetch(..., { cache: 'no-store' })` → `fetch(..., { next: { revalidate: 60 } })`.
+ The cached fetch makes the route eligible for static generation + ISR: first request
+ renders SSR and caches; Next revalidates in the background every 60s.
+- Fix the header comment (`get-page.ts:9-11`): it currently claims `no-store` keeps the
+ contract test deterministic. Re-state the ISR intent and note the route stays
+ CMS-free at build time.
+
+**`templates/host/app/[[...slug]]/page.tsx`**:
+
+- Keep **no** `generateStaticParams` and `dynamicParams` at its default (`true`) so pages
+ render on-demand and the build never touches a live CMS (preserves the property in
+ `src/commands/build.ts:10-18`).
+
+ > **Superseded during implementation (2026-07-06).** An empirical `next build`
+ > showed the optional catch-all `[[...slug]]` stays `ƒ (Dynamic)` with no
+ > `generateStaticParams` (a paramless optional catch-all has no paths to
+ > prerender in Next 15), so "rota não-dynamic" was NOT met. Decision reversed:
+ > the route now DOES declare `generateStaticParams`, listing published slugs
+ > from the CMS via `getPageSlugs`, and the build flips to `● (SSG)` + ISR. The
+ > CMS stays a build-time OPTIMIZATION, not a hard dependency — `getPageSlugs`
+ > fails to empty (CMS unreachable at build → `[]` → every page renders
+ > on-demand, `dynamicParams` still `true`). The `build.ts:10-18` property
+ > becomes "build calls the CMS opportunistically; unreachable → on-demand".
+- Optionally add `export const revalidate = 60` for an explicit segment default; decide
+ during implementation whether the fetch-level `revalidate` alone reads clearly enough.
+- `/home → /` (`permanentRedirect`) and `notFound()` run inside the render and are
+ unchanged — verify they still fire under ISR.
+- Update the stale "dynamic RSC, never stale" comments in `src/commands/build.ts:16-17`.
+
+**Materialization:** edit the `templates/host/**` source only. `apps/playground/.press/web`
+is engine-owned and regenerated — never hand-edited.
+
+### B. Generic `seedPage()` + remove privacy-policy (`packages/cms`)
+
+**New `server/src/lib/seed-page.ts`** — extract the idempotent pattern verbatim from
+`seedPrivacyPolicyPage`:
+
+```ts
+export const PAGE_UID = 'plugin::press-cms.page';
+
+export async function seedPage(
+ strapi: Core.Strapi,
+ opts: { slug: string; title: string; body: unknown[]; flagKey: string },
+): Promise {
+ const store = pluginStore(strapi);
+ if (await store.get({ key: opts.flagKey })) return; // seeded once, then never again
+ const docs = strapi.documents(PAGE_UID);
+ const existing = await docs.findFirst({ filters: { slug: opts.slug } } as any);
+ if (!existing) {
+ await docs.create({ data: { title: opts.title, slug: opts.slug, body: opts.body } as any });
+ }
+ await store.set({ key: opts.flagKey, value: true }); // draft only — engine never publishes
+}
+```
+
+Preserves all three invariants: flag-first (editor-deleted page respected forever), slug
+collision → adopter wins, created as DRAFT.
+
+**Remove:**
+- Delete `server/src/lib/seed-page-privacy-policy.ts`.
+- Remove the import + `await seedPrivacyPolicyPage(strapi)` from `server/src/bootstrap.ts`.
+- Relocate `PAGE_UID` (currently exported from the deleted file) into `seed-page.ts`;
+ grep for other importers of `PAGE_UID` and repoint them.
+
+**Export surface:** ensure `seedPage` is reachable by future consumers (Plugin/Legal,
+archetype templates) from the cms server module — confirm the package's server export path
+during implementation.
+
+### C. Metadata reduced to `` (`packages/web`)
+
+**`src/config/build-metadata.ts`** — reduce `buildMetadata` to:
+
+```ts
+type PageMeta = { title?: string } | null;
+
+export function buildMetadata(resolved: ResolvedPressConfig, page: PageMeta): Metadata {
+ const { brand, seo } = resolved;
+ const title = page?.title ? seo.titleTemplate.replace('%s', page.title) : seo.defaultTitle;
+ return {
+ title,
+ ...(brand.favicon ? { icons: { icon: brand.favicon } } : {}),
+ };
+}
+```
+
+Removed: `description`, `alternates.canonical` (was wrong anyway — always the site root),
+`openGraph`, and the `images` derivation. The `ResolvedPressConfig.seo` shape and
+`mapSiteSettings` stay untouched (`defaultDescription`/`defaultOgImage` simply go
+unconsumed until Plugin/SEO). The route already forwards only `{ title }`, so no route
+change is needed.
+
+**Honest consequence:** until Plugin/SEO ships, the site emits no canonical and no OG.
+Removing a *wrong* canonical is a net improvement; the removed logic is captured in the
+Plugin/SEO card so nothing is lost.
+
+### D. Plugin/SEO roadmap card (drafted — user pastes into Zoho)
+
+> **[OGS] [PRESS] PLUGIN / SEO**
+>
+> **TIPO:** engine plugin (família PressPlugin) — metadata de `` para ranking + share.
+> **DEPENDE DE:** BASE/PAGES (content-type + render ISR), Base/Canonical.
+> **HABILITA:** Site OGS, i18n (hreflang per-locale).
+>
+> **OBJETIVO:** entregar toda a metadata de `` como plugin opt-in, em dois grupos:
+> - **SEO (ranking/indexação):** `metadataBase`, canonical correto por página, robots
+> (noindex por página), JSON-LD (WebPage/Organization), hreflang (single-locale stub;
+> full quando i18n chegar).
+> - **Social/share (unfurl):** Open Graph (`og:title/description/image/url/type/site_name`)
+> + Twitter card — default no Site Settings, override por página.
+>
+> **ESCOPO:**
+> - Campos de override SEO/social **por página** no `page` schema (componente `preset-config`
+> dedicado ou estendendo o `preset-config.seo`; decidir no brainstorming do item).
+> - Defaults no Site Settings (reusa o `preset-config.seo` já existente).
+> - Seam de integração: mapper puro `buildSeoMetadata(resolved, page)` alimentando o
+> `generateMetadata` do host — **não** é mount de componente como o cookie-consent
+> (metadata é export de route/layout no Next, não componente montado no `layout.tsx`).
+> - Fail-to-empty consistente com identity/SEO (CMS fora → metadata default, nunca crash).
+>
+> **DONE:** página compartilhada mostra card OG/Twitter com título+imagem corretos;
+> canonical aponta pra própria URL da página; robots/JSON-LD presentes no view-source;
+> ISR permanece intacto (metadata não força a rota dynamic).
+
+## Testing
+
+- **Unit — `seedPage` idempotency** (`packages/cms`): flag set → no-op; slug already
+ exists → no write, marks done; absent → creates a DRAFT with the given title/slug/body.
+- **Unit — `buildMetadata`** (`packages/web`): update the existing test to the title-only
+ (+ favicon) shape; assert `description`/`openGraph`/`alternates` are gone.
+- **Blast radius:** grep tests/e2e for any assertion that the privacy-policy page is
+ seeded, and for `PAGE_UID`/`seedPrivacyPolicyPage`/`buildMetadata` output; adjust or
+ remove. The reproducible-seed e2e (`5f850e7`) uses its own fixture, not privacy-policy —
+ verify it is unaffected.
+- **ISR verification (DONE gate):** drive the app (`press dev` / `/run`) — confirm a
+ CMS-created page renders full HTML in view-source, `/home` 308-redirects to `/`, and the
+ page route is served as ISR/static (not forced-dynamic). No route-level cache-busting.
+
+## Verification against DONE (amended)
+
+| DONE (amended) | How |
+| --- | --- |
+| Página renderiza SSR — HTML completo no view-source | view-source on a CMS page |
+| **Rota não-dynamic / ISR intacto** | `getPage` cached (`revalidate: 60`); no forced-dynamic |
+| `/home → /` | existing `permanentRedirect`, verified under ISR |
+| `seedPage()` genérico idempotente disponível | unit test + exported API |
+| Privacy-policy removida do base | `bootstrap.ts` no longer calls it; file deleted |
+| Metadata = `` (resto → Plugin/SEO) | `buildMetadata` reduced; card D created |
+
+Original DONE said "metadata SEO + social" — that is now delivered by the **Plugin/SEO**
+item (card D), not by BASE/PAGES.
+
+## Rollout notes
+
+- Engine change → add a changeset under `.changeset/` (press-web + press-cms both touched).
+- Removing OG/canonical is a visible behavior change pre-Plugin/SEO; acceptable pre-release.
+- `press-web` metadata reduction + `press-cms` seed removal are independent patches but ship
+ together to keep the item coherent.
diff --git a/packages/cms/server/src/bootstrap.ts b/packages/cms/server/src/bootstrap.ts
index 4af50da..39a6762 100644
--- a/packages/cms/server/src/bootstrap.ts
+++ b/packages/cms/server/src/bootstrap.ts
@@ -1,14 +1,12 @@
import type { Core } from '@strapi/strapi';
import { seedSiteSetting } from './lib/seed-site-setting';
import { seedCookieConsent } from './lib/seed-cookie-consent';
-import { seedPrivacyPolicyPage } from './lib/seed-page-privacy-policy';
const bootstrap = async ({ strapi }: { strapi: Core.Strapi }) => {
await seedSiteSetting(strapi);
// Order matters: seedCookieConsent updates the record seedSiteSetting creates
// (and self-heals — without marking its flag — if the record is absent).
await seedCookieConsent(strapi);
- await seedPrivacyPolicyPage(strapi);
};
export default bootstrap;
diff --git a/packages/cms/server/src/lib/seed-page-privacy-policy.test.ts b/packages/cms/server/src/lib/seed-page-privacy-policy.test.ts
deleted file mode 100644
index b94a565..0000000
--- a/packages/cms/server/src/lib/seed-page-privacy-policy.test.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { PAGE_UID, PRIVACY_PAGE, seedPrivacyPolicyPage } from './seed-page-privacy-policy';
-
-/**
- * Minimal Document-Service + plugin-store fake: a mutable page list keyed by
- * slug, recording creates, and a Map-backed store for the run-once flag.
- */
-function fakeStrapi(pages: any[] = [], flags: Record = {}) {
- const creates: Array<{ data: any }> = [];
- const store = new Map(Object.entries(flags));
- const strapi = {
- documents: (uid: string) => {
- expect(uid).toBe(PAGE_UID); // helper must target the page collection UID
- return {
- findFirst: async (params: any) => {
- // The seed must look up by slug — anything else could match the wrong page.
- expect(params?.filters).toMatchObject({ slug: PRIVACY_PAGE.slug });
- return pages.find((page) => page.slug === PRIVACY_PAGE.slug) ?? null;
- },
- create: async (params: { data: any }) => {
- creates.push(params);
- pages.push({ documentId: `doc-${pages.length + 1}`, ...params.data });
- return pages[pages.length - 1];
- },
- };
- },
- store: ({ type, name }: { type: string; name: string }) => {
- expect(type).toBe('plugin');
- expect(name).toBe('press-cms');
- return {
- get: async ({ key }: { key: string }) => store.get(key),
- set: async ({ key, value }: { key: string; value: unknown }) => void store.set(key, value),
- };
- },
- } as any;
- return { strapi, creates, store };
-}
-
-describe('seedPrivacyPolicyPage — page template seed', () => {
- it('creates the template page on a fresh DB and marks the seed done', async () => {
- const { strapi, creates, store } = fakeStrapi();
- await seedPrivacyPolicyPage(strapi);
- expect(creates).toEqual([{ data: PRIVACY_PAGE }]);
- expect(store.get('privacyPageSeeded')).toBe(true);
- });
-
- it('never publishes — the created data carries no publishedAt/status', async () => {
- // The engine seeds a DRAFT: documents.create without publish leaves the
- // page unpublished for an editor to review the placeholders first.
- const { strapi, creates } = fakeStrapi();
- await seedPrivacyPolicyPage(strapi);
- expect(creates[0].data).not.toHaveProperty('publishedAt');
- expect(creates[0].data).not.toHaveProperty('status');
- });
-
- it('composes the body from preset-atom.* atoms only (structure + placeholders)', async () => {
- const components = new Set(PRIVACY_PAGE.body.map((block: any) => block.__component));
- expect([...components].sort()).toEqual(['preset-atom.heading', 'preset-atom.paragraph']);
-
- // preset-atom.paragraph content is Strapi rich-text blocks JSON, not a plain string.
- const firstParagraph = PRIVACY_PAGE.body.find((b: any) => b.__component === 'preset-atom.paragraph') as any;
- expect(firstParagraph.content[0]).toMatchObject({
- type: 'paragraph',
- children: [{ type: 'text' }],
- });
-
- // Section headings sit under the page title: level 2, standard sections present.
- const headings = PRIVACY_PAGE.body.filter((b: any) => b.__component === 'preset-atom.heading') as any[];
- expect(headings.every((h) => h.level === '2')).toBe(true);
- expect(headings.map((h) => h.text)).toContain('Your Rights');
- });
-
- it('respects an adopter page already occupying the slug — no create, seed marked done', async () => {
- const { strapi, creates, store } = fakeStrapi([{ documentId: 'doc-9', slug: 'privacy-policy' }]);
- await seedPrivacyPolicyPage(strapi);
- expect(creates).toEqual([]);
- expect(store.get('privacyPageSeeded')).toBe(true);
- });
-
- it('respects an editor-deleted page once the seed has run (flag set → no writes)', async () => {
- const { strapi, creates } = fakeStrapi([], { privacyPageSeeded: true });
- await seedPrivacyPolicyPage(strapi);
- expect(creates).toEqual([]);
- });
-
- it('is idempotent across repeated runs — one create, no later writes', async () => {
- const { strapi, creates } = fakeStrapi();
- await seedPrivacyPolicyPage(strapi);
- await seedPrivacyPolicyPage(strapi);
- await seedPrivacyPolicyPage(strapi);
- expect(creates).toHaveLength(1);
- });
-});
diff --git a/packages/cms/server/src/lib/seed-page-privacy-policy.ts b/packages/cms/server/src/lib/seed-page-privacy-policy.ts
deleted file mode 100644
index ebe9118..0000000
--- a/packages/cms/server/src/lib/seed-page-privacy-policy.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import type { Core } from '@strapi/strapi';
-import { pluginStore } from './plugin-store';
-
-/** UID of the engine's page collection type (plugin name `press-cms`). */
-export const PAGE_UID = 'plugin::press-cms.page';
-
-const PRIVACY_SEED_KEY = 'privacyPageSeeded';
-
-/** A preset-atom.heading section title (level 2 — the page title itself is the h1). */
-const heading = (text: string) => ({ __component: 'preset-atom.heading', text, level: '2' });
-
-/** A preset-atom.paragraph block; `content` is Strapi rich-text blocks JSON, not a plain string. */
-const paragraph = (text: string) => ({
- __component: 'preset-atom.paragraph',
- content: [{ type: 'paragraph', children: [{ type: 'text', text }] }],
-});
-
-/**
- * Privacy-policy page template: the standard section structure with short
- * placeholder guidance, no legal boilerplate — the engine scaffolds the
- * document, the adopter's editor writes (and owns) the actual policy.
- */
-export const PRIVACY_PAGE = {
- title: 'Privacy Policy',
- slug: 'privacy-policy',
- body: [
- paragraph(
- 'Introduce this policy: who you are, what this site does, and your commitment to protecting personal data. State when the policy was last updated.',
- ),
- heading('Data We Collect'),
- paragraph(
- 'Describe the personal data this site collects — e.g. contact-form submissions, analytics identifiers, newsletter e-mail addresses — and how it is collected.',
- ),
- heading('Cookies'),
- paragraph(
- 'List the cookies and similar technologies in use, what each one is for, and how visitors can manage or refuse them.',
- ),
- heading('How We Use Your Data'),
- paragraph(
- 'Explain the purposes your data is used for and the legal basis for each (consent, contract, legitimate interest…).',
- ),
- heading('Data Sharing'),
- paragraph(
- 'Name the third parties that may receive visitor data (hosting, analytics, e-mail providers) and why.',
- ),
- heading('Your Rights'),
- paragraph(
- 'Describe the rights visitors have over their data — access, correction, deletion, portability — and how to exercise them.',
- ),
- heading('Contact'),
- paragraph('Provide a contact channel (e-mail or form) for privacy questions and data requests.'),
- ],
-};
-
-/**
- * Seeds the privacy-policy page template exactly once:
- *
- * 1. Plugin-store flag first: after the one seeding pass the page is never
- * written again — an editor-deleted page is respected forever (same
- * semantics as the chrome seed).
- * 2. Slug collision → the adopter's own page wins: the seed marks itself done
- * without writing.
- * 3. The page is created as a DRAFT — the engine never publishes content on
- * its own; an editor reviews the placeholders and publishes.
- */
-export async function seedPrivacyPolicyPage(strapi: Core.Strapi): Promise {
- const store = pluginStore(strapi);
- if (await store.get({ key: PRIVACY_SEED_KEY })) return;
-
- const docs = strapi.documents(PAGE_UID);
- const existing = await docs.findFirst({ filters: { slug: PRIVACY_PAGE.slug } } as any);
- if (!existing) {
- await docs.create({ data: PRIVACY_PAGE as any });
- }
-
- await store.set({ key: PRIVACY_SEED_KEY, value: true });
-}
diff --git a/packages/cms/server/src/lib/seed-page.test.ts b/packages/cms/server/src/lib/seed-page.test.ts
new file mode 100644
index 0000000..8c2a0b2
--- /dev/null
+++ b/packages/cms/server/src/lib/seed-page.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest';
+import { PAGE_UID, seedPage } from './seed-page';
+
+const OPTS = {
+ slug: 'demo',
+ title: 'Demo',
+ flagKey: 'demoPageSeeded',
+ body: [{ __component: 'preset-atom.paragraph', content: [] }],
+};
+
+/**
+ * Minimal Document-Service + plugin-store fake: a mutable page list keyed by
+ * slug, recording creates, and a Map-backed store for the run-once flag.
+ */
+function fakeStrapi(pages: any[] = [], flags: Record = {}) {
+ const creates: Array<{ data: any }> = [];
+ const store = new Map(Object.entries(flags));
+ const strapi = {
+ documents: (uid: string) => {
+ expect(uid).toBe(PAGE_UID); // helper must target the page collection UID
+ return {
+ findFirst: async (params: any) => {
+ // The seed must look up by the requested slug — never a broad match.
+ expect(params?.filters).toMatchObject({ slug: OPTS.slug });
+ return pages.find((page) => page.slug === OPTS.slug) ?? null;
+ },
+ create: async (params: { data: any }) => {
+ creates.push(params);
+ pages.push({ documentId: `doc-${pages.length + 1}`, ...params.data });
+ return pages[pages.length - 1];
+ },
+ };
+ },
+ store: ({ type, name }: { type: string; name: string }) => {
+ expect(type).toBe('plugin');
+ expect(name).toBe('press-cms');
+ return {
+ get: async ({ key }: { key: string }) => store.get(key),
+ set: async ({ key, value }: { key: string; value: unknown }) => void store.set(key, value),
+ };
+ },
+ } as any;
+ return { strapi, creates, store };
+}
+
+describe('seedPage — generic idempotent page seed', () => {
+ it('creates the page as a DRAFT on a fresh DB and marks the flag done', async () => {
+ const { strapi, creates, store } = fakeStrapi();
+ await seedPage(strapi, OPTS);
+ expect(creates).toEqual([{ data: { title: OPTS.title, slug: OPTS.slug, body: OPTS.body } }]);
+ // DRAFT: create carries no publish signal for an editor to review first.
+ expect(creates[0].data).not.toHaveProperty('publishedAt');
+ expect(creates[0].data).not.toHaveProperty('status');
+ expect(store.get('demoPageSeeded')).toBe(true);
+ });
+
+ it('respects an adopter page already on the slug — no create, flag still set', async () => {
+ const { strapi, creates, store } = fakeStrapi([{ documentId: 'doc-9', slug: 'demo' }]);
+ await seedPage(strapi, OPTS);
+ expect(creates).toEqual([]);
+ expect(store.get('demoPageSeeded')).toBe(true);
+ });
+
+ it('is a no-op once the flag is set — editor-deleted page respected forever', async () => {
+ const { strapi, creates } = fakeStrapi([], { demoPageSeeded: true });
+ await seedPage(strapi, OPTS);
+ expect(creates).toEqual([]);
+ });
+
+ it('is idempotent across repeated runs — one create, no later writes', async () => {
+ const { strapi, creates } = fakeStrapi();
+ await seedPage(strapi, OPTS);
+ await seedPage(strapi, OPTS);
+ await seedPage(strapi, OPTS);
+ expect(creates).toHaveLength(1);
+ });
+});
diff --git a/packages/cms/server/src/lib/seed-page.ts b/packages/cms/server/src/lib/seed-page.ts
new file mode 100644
index 0000000..64c44ec
--- /dev/null
+++ b/packages/cms/server/src/lib/seed-page.ts
@@ -0,0 +1,36 @@
+import type { Core } from '@strapi/strapi';
+import { pluginStore } from './plugin-store';
+
+/** UID of the engine's page collection type (plugin name `press-cms`). */
+export const PAGE_UID = 'plugin::press-cms.page';
+
+/**
+ * Generic, idempotent page seed — the reusable primitive behind future
+ * page-seeding consumers (Plugin/Legal, archetype templates). Base itself does
+ * not call it yet: exported-but-unused public API by design.
+ *
+ * Three invariants, identical to the retired privacy-policy seed:
+ *
+ * 1. Flag-first: `opts.flagKey` in the plugin store makes the pass literal-once.
+ * After the single seeding pass the page is never written again — an
+ * editor-deleted page is respected forever.
+ * 2. Slug collision → the adopter's own page wins: the seed marks itself done
+ * without writing.
+ * 3. The page is created as a DRAFT (`documents.create` without publish) — the
+ * engine never publishes content on its own; an editor reviews and publishes.
+ */
+export async function seedPage(
+ strapi: Core.Strapi,
+ opts: { slug: string; title: string; body: unknown[]; flagKey: string },
+): Promise {
+ const store = pluginStore(strapi);
+ if (await store.get({ key: opts.flagKey })) return;
+
+ const docs = strapi.documents(PAGE_UID);
+ const existing = await docs.findFirst({ filters: { slug: opts.slug } } as any);
+ if (!existing) {
+ await docs.create({ data: { title: opts.title, slug: opts.slug, body: opts.body } as any });
+ }
+
+ await store.set({ key: opts.flagKey, value: true });
+}
diff --git a/packages/web/bin/press.ts b/packages/web/bin/press.ts
index 10fa0ff..25b965d 100755
--- a/packages/web/bin/press.ts
+++ b/packages/web/bin/press.ts
@@ -1,7 +1,10 @@
#!/usr/bin/env tsx
import { run } from '../src/runtime-cli';
+import { SubprocessError } from '../src/util/run';
run(process.argv).catch((err) => {
console.error(err?.message ?? err);
- process.exit(1);
+ // Surface the failing subprocess's REAL exit code (never a blanket 1) so build
+ // failures are truthful in CI; a signal-killed child (code null) still exits non-zero.
+ process.exit(err instanceof SubprocessError ? (err.code ?? 1) : 1);
});
diff --git a/packages/web/src/commands/build.ts b/packages/web/src/commands/build.ts
index 45dcc7c..513bdc9 100644
--- a/packages/web/src/commands/build.ts
+++ b/packages/web/src/commands/build.ts
@@ -11,10 +11,12 @@ const CMS_URL = 'http://localhost:1337';
* Builds both halves into deployable artifacts (spec §5): materialize the host,
* `strapi build` the cms host, then `next build` the materialized web host. The
* built web output reflects press.config.ts (the Spec 2 SEO/identity surfaces in
- * the production render, not just dev). The catch-all route declares no
- * generateStaticParams, so Next never calls getPage at build time — no live cms
- * is required to build. At runtime the route fetches with cache:'no-store'
- * (dynamic RSC, never stale).
+ * the production render, not just dev). The catch-all route prerenders published
+ * pages at build via generateStaticParams (it lists published slugs from the
+ * CMS), so a reachable CMS lets the build emit static ISR pages; if the CMS is
+ * unreachable at build the list fails to empty and every page renders on-demand.
+ * Either way pages are ISR-cached (revalidate: 60): revalidated every 60s, not
+ * forced dynamic per request.
*/
export async function buildCommand(opts: BuildOptions): Promise {
const root = opts.cwd;
diff --git a/packages/web/src/config/build-metadata.test.ts b/packages/web/src/config/build-metadata.test.ts
index d16c53b..4ce0415 100644
--- a/packages/web/src/config/build-metadata.test.ts
+++ b/packages/web/src/config/build-metadata.test.ts
@@ -32,7 +32,6 @@ describe('buildMetadata', () => {
it('applies the title template to a page title (AC1)', () => {
const m = buildMetadata(resolved, { title: 'E2E Home' });
expect(m.title).toBe('E2E Home | Acme');
- expect(m.openGraph?.title).toBe('E2E Home | Acme');
});
it('uses defaultTitle when there is no page (layout base)', () => {
@@ -40,32 +39,21 @@ describe('buildMetadata', () => {
expect(m.title).toBe('Acme');
});
- it('falls back to defaultDescription when the page has none', () => {
- const m = buildMetadata(resolved, { title: 'E2E Home' });
- expect(m.description).toBe('An Acme content site.');
- });
-
- it('emits an absolute canonical and OG image', () => {
- const m = buildMetadata(resolved, { title: 'E2E Home' });
- expect(m.alternates?.canonical).toBe('https://acme.test');
- expect(m.openGraph?.images).toEqual([{ url: 'https://acme.test/og.png' }]);
- });
-
it('derives the favicon icon from brand.favicon (AC2)', () => {
const m = buildMetadata(resolved, null);
expect(m.icons).toEqual({ icon: '/favicon.ico' });
});
- it('uses the page description when provided', () => {
- const m = buildMetadata(resolved, { title: 'E2E Home', description: 'Custom desc' });
- expect(m.description).toBe('Custom desc');
- expect(m.openGraph?.description).toBe('Custom desc');
+ it('omits the favicon when brand.favicon is empty', () => {
+ const noFavicon: ResolvedPressConfig = { ...resolved, brand: { ...resolved.brand, favicon: '' } };
+ const m = buildMetadata(noFavicon, null);
+ expect(m.icons).toBeUndefined();
});
- it('omits description when the resolved default is empty', () => {
- const noDesc: ResolvedPressConfig = { ...resolved, seo: { ...resolved.seo, defaultDescription: '' } };
- const m = buildMetadata(noDesc, null);
+ it('emits no SEO/social metadata — deferred to Plugin/SEO', () => {
+ const m = buildMetadata(resolved, { title: 'E2E Home' });
expect(m.description).toBeUndefined();
- expect(m.openGraph?.description).toBeUndefined();
+ expect(m.openGraph).toBeUndefined();
+ expect(m.alternates).toBeUndefined();
});
});
diff --git a/packages/web/src/config/build-metadata.ts b/packages/web/src/config/build-metadata.ts
index e8b1af5..5737e8c 100644
--- a/packages/web/src/config/build-metadata.ts
+++ b/packages/web/src/config/build-metadata.ts
@@ -1,35 +1,25 @@
import type { Metadata } from 'next';
import type { ResolvedPressConfig } from './types';
-type PageMeta = { title?: string; description?: string } | null;
+type PageMeta = { title?: string } | null;
/**
- * Produces a Next `Metadata` object from the resolved config (Spec §4.2). With
- * a `page`, the title is `seo.titleTemplate` with `%s` replaced by the page
- * title; with no page (the layout base) it is `seo.defaultTitle`. The title is
- * a plain string (not Next's template object) so the rendered `` is
- * deterministic and directly assertable (AC1/AC3). The OG image is already
- * absolute (resolved against `site.url` in `resolveConfig`). Pure — no I/O.
+ * Produces a minimal Next `Metadata` object: `` + favicon only. With a
+ * `page`, the title is `seo.titleTemplate` with `%s` replaced by the page title;
+ * with no page (the layout base) it is `seo.defaultTitle`. The title is a plain
+ * string (not Next's template object) so the rendered `` is deterministic
+ * and directly assertable. The favicon is identity, not SEO, so it stays.
+ *
+ * Everything else — description, canonical, Open Graph, Twitter, robots, JSON-LD —
+ * is deferred to Plugin/SEO (see the BASE/PAGES design). `ResolvedPressConfig.seo`
+ * keeps `defaultDescription`/`defaultOgImage`; they simply go unconsumed here
+ * until that plugin ships. Pure — no I/O.
*/
export function buildMetadata(resolved: ResolvedPressConfig, page: PageMeta): Metadata {
- const { brand, site, seo } = resolved;
- const title = page?.title
- ? seo.titleTemplate.replace('%s', page.title)
- : seo.defaultTitle;
- const description = page?.description ?? seo.defaultDescription;
- const images = seo.defaultOgImage ? [{ url: seo.defaultOgImage }] : undefined;
-
+ const { brand, seo } = resolved;
+ const title = page?.title ? seo.titleTemplate.replace('%s', page.title) : seo.defaultTitle;
return {
title,
- ...(description ? { description } : {}),
- ...(site.url ? { alternates: { canonical: site.url } } : {}),
- openGraph: {
- title,
- ...(description ? { description } : {}),
- siteName: brand.name,
- ...(site.url ? { url: site.url } : {}),
- ...(images ? { images } : {}),
- },
...(brand.favicon ? { icons: { icon: brand.favicon } } : {}),
};
}
diff --git a/packages/web/src/get-page-slugs.test.ts b/packages/web/src/get-page-slugs.test.ts
new file mode 100644
index 0000000..f1c6d5c
--- /dev/null
+++ b/packages/web/src/get-page-slugs.test.ts
@@ -0,0 +1,89 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { getPageSlugs, getStaticPageParams } from './get-page-slugs';
+
+afterEach(() => vi.unstubAllGlobals());
+
+function stubFetch(impl: (...args: any[]) => Promise) {
+ const mock = vi.fn(impl);
+ vi.stubGlobal('fetch', mock);
+ return mock;
+}
+
+describe('getPageSlugs', () => {
+ it('fetches the page list with the ISR revalidate option — never no-store', async () => {
+ const mock = stubFetch(async () => ({ ok: true, status: 200, json: async () => ({ data: [] }) }));
+ await getPageSlugs();
+ expect(mock).toHaveBeenCalledWith(
+ expect.stringContaining('/api/pages'),
+ { next: { revalidate: 60 } },
+ );
+ });
+
+ it('returns the slugs of every published page', async () => {
+ stubFetch(async () => ({
+ ok: true,
+ status: 200,
+ json: async () => ({ data: [{ documentId: 'd1', slug: 'home' }, { documentId: 'd2', slug: 'about' }] }),
+ }));
+ expect(await getPageSlugs()).toEqual(['home', 'about']);
+ });
+
+ it('skips entries with a missing or empty slug', async () => {
+ stubFetch(async () => ({
+ ok: true,
+ status: 200,
+ json: async () => ({ data: [{ slug: 'about' }, { documentId: 'no-slug' }, { slug: '' }] }),
+ }));
+ expect(await getPageSlugs()).toEqual(['about']);
+ });
+
+ it('fails to empty on a non-OK response — build stays green, pages render on-demand', async () => {
+ stubFetch(async () => ({ ok: false, status: 500, json: async () => ({}) }));
+ expect(await getPageSlugs()).toEqual([]);
+ });
+
+ it('fails to empty on a network error — CMS unreachable at build', async () => {
+ stubFetch(async () => {
+ throw new Error('ECONNREFUSED');
+ });
+ expect(await getPageSlugs()).toEqual([]);
+ });
+
+ it('tolerates a null data body', async () => {
+ stubFetch(async () => ({ ok: true, status: 200, json: async () => ({ data: null }) }));
+ expect(await getPageSlugs()).toEqual([]);
+ });
+
+ it('fails to empty on a malformed (non-JSON) body', async () => {
+ stubFetch(async () => ({
+ ok: true,
+ status: 200,
+ json: async () => {
+ throw new SyntaxError('Unexpected token');
+ },
+ }));
+ expect(await getPageSlugs()).toEqual([]);
+ });
+});
+
+describe('getStaticPageParams', () => {
+ it('maps the home slug to the site root ({ slug: [] }) and others to path segments', async () => {
+ stubFetch(async () => ({
+ ok: true,
+ status: 200,
+ json: async () => ({ data: [{ slug: 'home' }, { slug: 'about' }, { slug: 'legal/privacy' }] }),
+ }));
+ expect(await getStaticPageParams('home')).toEqual([
+ { slug: [] },
+ { slug: ['about'] },
+ { slug: ['legal', 'privacy'] },
+ ]);
+ });
+
+ it('returns [] when the CMS is unavailable at build — every page then renders on-demand', async () => {
+ stubFetch(async () => {
+ throw new Error('down');
+ });
+ expect(await getStaticPageParams('home')).toEqual([]);
+ });
+});
diff --git a/packages/web/src/get-page-slugs.ts b/packages/web/src/get-page-slugs.ts
new file mode 100644
index 0000000..354bec4
--- /dev/null
+++ b/packages/web/src/get-page-slugs.ts
@@ -0,0 +1,46 @@
+const CMS_URL = process.env.CMS_URL ?? 'http://localhost:1337';
+
+// Next.js augments RequestInit with `next.revalidate` at the host; the engine
+// package typechecks with only @types/node, so name the option locally.
+type RevalidateInit = RequestInit & { next?: { revalidate?: number } };
+
+/**
+ * Fetches the slugs of every PUBLISHED page from the engine list route
+ * (GET /api/pages), for build-time prerendering via the route's
+ * generateStaticParams. ISR-cached (`revalidate: 60`, mirroring
+ * getPage/getSiteConfig).
+ *
+ * FAIL-TO-EMPTY (the engine's identity/SEO precedent — mirrors getSiteConfig's
+ * empty-on-failure mapping): any failure — CMS unreachable at build, non-OK,
+ * malformed body — yields []. With
+ * no params the build prerenders nothing and every page renders on-demand via
+ * ISR (the route keeps dynamicParams at its default, true). So a reachable CMS
+ * is a build-time OPTIMIZATION that prerenders known pages, never a hard build
+ * dependency.
+ */
+export async function getPageSlugs(): Promise {
+ try {
+ const init: RevalidateInit = { next: { revalidate: 60 } };
+ const res = await fetch(`${CMS_URL}/api/pages`, init);
+ if (!res.ok) return [];
+ const json = (await res.json()) as { data: Array<{ slug?: string }> | null };
+ return (json.data ?? [])
+ .map((p) => p?.slug)
+ .filter((slug): slug is string => typeof slug === 'string' && slug.length > 0);
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Build-time params for the catch-all page route's generateStaticParams. The
+ * home page's content is served at the site root ('/'), so its slug maps to
+ * `{ slug: [] }`; a direct hit on its own slug permanentRedirects to '/' and is
+ * intentionally not prerendered. Every other slug maps to its path segments.
+ * Empty when the CMS is unavailable at build (see getPageSlugs) — the route
+ * then renders entirely on-demand via ISR.
+ */
+export async function getStaticPageParams(homeSlug: string): Promise<{ slug: string[] }[]> {
+ const slugs = await getPageSlugs();
+ return slugs.map((slug) => (slug === homeSlug ? { slug: [] } : { slug: slug.split('/') }));
+}
diff --git a/packages/web/src/get-page.test.ts b/packages/web/src/get-page.test.ts
new file mode 100644
index 0000000..d811343
--- /dev/null
+++ b/packages/web/src/get-page.test.ts
@@ -0,0 +1,48 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { getPage } from './get-page';
+
+afterEach(() => vi.unstubAllGlobals());
+
+function stubFetch(impl: (...args: any[]) => Promise) {
+ const mock = vi.fn(impl);
+ vi.stubGlobal('fetch', mock);
+ return mock;
+}
+
+describe('getPage', () => {
+ it('fetches the page with the ISR revalidate option — never no-store (ISR intact)', async () => {
+ const mock = stubFetch(async () => ({
+ ok: true,
+ status: 200,
+ json: async () => ({ data: null }),
+ }));
+ await getPage('home');
+ expect(mock).toHaveBeenCalledWith(
+ expect.stringContaining('/api/pages/home'),
+ { next: { revalidate: 60 } },
+ );
+ });
+
+ it('returns null for a 404 (missing/unpublished slug → notFound in the route)', async () => {
+ stubFetch(async () => ({ ok: false, status: 404, json: async () => ({ data: null }) }));
+ expect(await getPage('nope')).toBeNull();
+ });
+
+ it('maps a 200 body through mapPage, attaching the canonical urn', async () => {
+ const raw = {
+ id: 1,
+ documentId: 'doc-abc',
+ title: 'Home',
+ slug: 'home',
+ body: [{ __component: 'preset-atom.paragraph', id: 3 }],
+ };
+ stubFetch(async () => ({ ok: true, status: 200, json: async () => ({ data: raw }) }));
+ const page = await getPage('home');
+ expect(page).toEqual({ ...raw, urn: 'urn:page:doc-abc' });
+ });
+
+ it('throws on a non-404 error status', async () => {
+ stubFetch(async () => ({ ok: false, status: 500, json: async () => ({}) }));
+ await expect(getPage('boom')).rejects.toThrow('getPage("boom") failed: 500');
+ });
+});
diff --git a/packages/web/src/get-page.ts b/packages/web/src/get-page.ts
index a1ec3ac..c2af9ae 100644
--- a/packages/web/src/get-page.ts
+++ b/packages/web/src/get-page.ts
@@ -3,15 +3,24 @@ import { mapPage, type RawPage } from './map-page';
const CMS_URL = process.env.CMS_URL ?? 'http://localhost:1337';
+// Next.js augments RequestInit with `next.revalidate` at the host; the engine
+// package typechecks with only @types/node, so name the option locally.
+type RevalidateInit = RequestInit & { next?: { revalidate?: number } };
+
/**
* Fetches a PUBLISHED page by slug over REST (Spec §5.1). Runs server-side (RSC),
* so there is no browser CORS surface for the data fetch. A missing/unpublished
* slug yields the engine's 404 → returns null, which the route turns into
- * notFound(). `cache: 'no-store'` keeps the contract test deterministic.
- * Thin fetcher: identity attachment lives in mapPage (canonical-urn Spec §2).
+ * notFound(). ISR-cached (`revalidate: 60`, mirroring getSiteConfig): a cached
+ * fetch keeps the route eligible for static generation + ISR instead of forcing
+ * it dynamic per request. Published pages are prerendered at build via the
+ * route's generateStaticParams (getPageSlugs); a slug added later renders
+ * on-demand and caches. Thin fetcher: identity attachment lives in mapPage
+ * (canonical-urn Spec §2).
*/
export async function getPage(slug: string): Promise {
- const res = await fetch(`${CMS_URL}/api/pages/${encodeURIComponent(slug)}`, { cache: 'no-store' });
+ const init: RevalidateInit = { next: { revalidate: 60 } };
+ const res = await fetch(`${CMS_URL}/api/pages/${encodeURIComponent(slug)}`, init);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`getPage("${slug}") failed: ${res.status}`);
const json = (await res.json()) as { data: RawPage | null };
diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts
index bed31be..3930fdc 100644
--- a/packages/web/src/index.ts
+++ b/packages/web/src/index.ts
@@ -1,5 +1,6 @@
export { BlockRenderer } from './block-renderer';
export { getPage } from './get-page';
+export { getPageSlugs, getStaticPageParams } from './get-page-slugs';
export { getSiteConfig } from './get-site-config';
export { atomBlocks } from './atom-blocks';
export { renderBlocks } from './blocks/blocks-content';
diff --git a/packages/web/src/util/run.test.ts b/packages/web/src/util/run.test.ts
new file mode 100644
index 0000000..de14477
--- /dev/null
+++ b/packages/web/src/util/run.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+import { run, SubprocessError } from './run';
+
+describe('run', () => {
+ it('resolves when the command exits 0', async () => {
+ await expect(run('node', ['-e', 'process.exit(0)'])).resolves.toBeUndefined();
+ });
+
+ it('rejects with a SubprocessError that carries the REAL exit code', async () => {
+ // The whole point of press build/dev "truthful failures": a non-zero
+ // subprocess must surface ITS code, never a generic 1.
+ const err = await run('node', ['-e', 'process.exit(3)']).catch((e) => e);
+ expect(err).toBeInstanceOf(SubprocessError);
+ expect((err as SubprocessError).code).toBe(3);
+ });
+
+ it('rejects (not resolves) when the command cannot be spawned', async () => {
+ const err = await run('this-binary-does-not-exist-press', []).catch((e) => e);
+ expect(err).toBeInstanceOf(Error);
+ });
+});
diff --git a/packages/web/src/util/run.ts b/packages/web/src/util/run.ts
index bd9b18d..9e492ff 100644
--- a/packages/web/src/util/run.ts
+++ b/packages/web/src/util/run.ts
@@ -9,6 +9,23 @@ export interface RunOptions {
env?: Record;
}
+/**
+ * A subprocess that exited non-zero, carrying the REAL exit code (and signal, if
+ * killed). bin/press.ts re-exits with this code so `press build` surfaces the
+ * failing tool's own code instead of a generic 1 — the same "truthful failure"
+ * guarantee dev.ts already gives via waitForReadyOrExit.
+ */
+export class SubprocessError extends Error {
+ constructor(
+ readonly command: string,
+ readonly code: number | null,
+ readonly signal: NodeJS.Signals | null = null,
+ ) {
+ super(`${command} exited ${code ?? `(signal ${signal})`}`);
+ this.name = 'SubprocessError';
+ }
+}
+
/** Runs a command, inheriting stdio, and resolves on exit 0 (rejects otherwise). */
export function run(cmd: string, args: string[], opts: RunOptions = {}): Promise {
return new Promise((resolve, reject) => {
@@ -18,8 +35,10 @@ export function run(cmd: string, args: string[], opts: RunOptions = {}): Promise
stdio: 'inherit',
});
child.on('error', reject);
- child.on('exit', (code) =>
- code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(' ')} exited ${code}`)),
+ child.on('exit', (code, signal) =>
+ code === 0
+ ? resolve()
+ : reject(new SubprocessError(`${cmd} ${args.join(' ')}`, code, signal)),
);
});
}
diff --git a/packages/web/templates/host/app/[[...slug]]/page.tsx b/packages/web/templates/host/app/[[...slug]]/page.tsx
index 6e691eb..5008631 100644
--- a/packages/web/templates/host/app/[[...slug]]/page.tsx
+++ b/packages/web/templates/host/app/[[...slug]]/page.tsx
@@ -1,8 +1,26 @@
import { notFound, permanentRedirect } from 'next/navigation';
-import { BlockRenderer, buildMetadata, getPage, getSiteConfig } from '@ogs-tech/press-web';
+import {
+ BlockRenderer,
+ buildMetadata,
+ getPage,
+ getSiteConfig,
+ getStaticPageParams,
+} from '@ogs-tech/press-web';
import { customBlocks } from '../../press.blocks';
import { buildTime } from '../../press-config';
+// ISR: published pages are prerendered at build — generateStaticParams lists
+// their slugs from the CMS — and revalidated every 60s (mirrors getPage/
+// getSiteConfig). dynamicParams stays at its default (true), so a slug added
+// after the build — or every slug when the CMS is unreachable at build (the
+// list fails to empty) — renders on-demand and caches. /home → / and notFound()
+// run inside the render, unchanged under ISR.
+export const revalidate = 60;
+
+export async function generateStaticParams() {
+ return getStaticPageParams(buildTime.routes.home);
+}
+
interface PageProps {
params: Promise<{ slug?: string[] }>;
}