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 `<title>` (+ 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 `<title>`** (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. `<head>` metadata reduced to `<title>` (+ 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<void> { + 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 `<title>` (`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 `<head>` 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 `<head>` 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 = `<title>` (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<string, unknown> = {}) { - const creates: Array<{ data: any }> = []; - const store = new Map<string, unknown>(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<void> { - 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<string, unknown> = {}) { + const creates: Array<{ data: any }> = []; + const store = new Map<string, unknown>(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<void> { + 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<void> { 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 `<title>` 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: `<title>` + 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 `<title>` 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<any>) { + 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<string[]> { + 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<any>) { + 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<Page | null> { - 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<string, string | undefined>; } +/** + * 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<void> { 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[] }>; }