Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/base-pages-isr-seed.md
Original file line number Diff line number Diff line change
@@ -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 <title>

**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.
14 changes: 14 additions & 0 deletions .changeset/cli-truthful-build-exit-code.md
Original file line number Diff line number Diff line change
@@ -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).
17 changes: 0 additions & 17 deletions .changeset/privacy-policy-page-seed.md

This file was deleted.

7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
75 changes: 75 additions & 0 deletions docs/superpowers/plans/2026-07-11-base-cli-followups.md
Original file line number Diff line number Diff line change
@@ -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.
216 changes: 216 additions & 0 deletions docs/superpowers/specs/2026-07-05-base-pages-isr-seed-design.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 0 additions & 2 deletions packages/cms/server/src/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading