diff --git a/.changeset/columns-organism.md b/.changeset/columns-organism.md new file mode 100644 index 0000000..cd0e6ff --- /dev/null +++ b/.changeset/columns-organism.md @@ -0,0 +1,42 @@ +--- +'@ogs-tech/press-cms': minor +'@ogs-tech/press-web': minor +--- + +feat: `preset-organism.columns` — the first editor-composed layout block + +`@ogs-tech/press-cms`: + +- **New page-body organism `preset-organism.columns`** (admitted statically in + `page/schema.json`, body only — the hero/cta placement rule). Layout controls + are flat CLOSED enums on the organism (the `hero.align` precedent): + `ratio` (`50-50 | 33-67 | 67-33 | 33-33-33 | 25-25-25-25`), `gap` + (`compact | normal | spacious`), `verticalAlign` (`top | center | bottom`). + The raw `GridGap`/`Span` scales are never exposed to the CMS. +- **New nested-only molecule `preset-molecule.column`** (rich text + optional + image + optional `preset-atom.button` — the `navbar.cta` nesting pattern); + repeatable 2–4 per block, never a DZ member. `preset-layout` stays reserved + and empty: the top-level block is a content ORGANISM that consumes the grid + internally, so "layout is never an editor-placed top-level block" holds. +- **`buildBodyPopulate` deep-populates the columns chain.** `populate: '*'` is + shallow; a column's `image`/`button` sit TWO levels below the DZ member and + silently came back empty — the exact shape that already forced the navbar's + deep populate in the chrome zones. +- Serializer and generator needed **zero code change** — the BFS follows the + two-level chain (`columns → column → button`) generically; new tests pin that. + +`@ogs-tech/press-web`: + +- **New `Columns` renderer** (`preset-organism.columns` in `organismBlocks`, + overridable like every organism) built on Container/Grid/Column, mirroring + the Hero: stacks at base, `25-25-25-25` goes 2×2 on md before 4-up on lg, + `spacious` gap is tier-scaled (never a flat `lg` — the 11-gap 528px floor). + Tolerant: no columns renders nothing; an empty column keeps its cell; a + column beyond the ratio's slots reuses the last span instead of dropping. +- Cells render rich text via `renderBlocks` + raw ``/`` — NOT the atom + components, whose `data-block="preset-atom.*"` would match the prose-width + rule at any depth and fight the cell's own width. +- theme.css: columns section styles + the per-column button joins the shared + button family (all variants and states). +- New exported types `PresetOrganismColumns` / `PresetMoleculeColumn`; the CLI + seed demonstrates a `33-33-33` block with a nested secondary button. diff --git a/.changeset/composition-builder.md b/.changeset/composition-builder.md new file mode 100644 index 0000000..e490589 --- /dev/null +++ b/.changeset/composition-builder.md @@ -0,0 +1,32 @@ +--- +'@ogs-tech/press-shared': major +'@ogs-tech/press-cms': major +'@ogs-tech/press-web': major +'@ogs-tech/create-press': major +--- + +feat!: composition builder — a JSON PressTree replaces the dynamic-zone mechanism engine-wide + +BREAKING (wire + palette + API), no data migration (pre-release): + +- `page.body` and the new `site-setting.pageDefaults` store a `PressTree` / + `Node[]` slots via the `plugin::press-cms.builder` JSON custom field; the + page-body and chrome Dynamic Zones (and `dz-populate`) are gone. +- Per-page layout root: header/footer slots (`inherit | none | custom`), rows + with 1–4 ratio-bound columns, full recursion, adopter `custom-*` blocks + anywhere. Curated `container` attrs (`width`/`gap`/`verticalAlign`). +- `@ogs-tech/press-shared` is now a PUBLISHED runtime dependency of press-web: + it ships the tree types + the sanitizing `validatePressTree` validator used + by the cms write path and the web render path. +- Palette: new `preset-molecule.link` (label/page/url/newTab) referenced by + button/hero/cta/navbar; `preset-molecule.nav-item`, `preset-molecule.column`, + `preset-organism.columns` removed. Text atoms store curated PLAIN TEXT + (`content: text`) — the Strapi blocks AST leaves the wire (`renderBlocks` + removed). `preset-layout.{container,row,column}` descriptors drive the + builder's layout forms. +- Serve-time hydration: media `{ assetId }` and page `{ documentId }` refs are + resolved server-side (fresh URLs/slugs; rename-safe internal links). +- Web: `TreeRenderer` (header/main/footer shell) replaces `BlockRenderer`; + `PressLink`/`resolveLink` are the one link resolver; prose rail rescoped to + direct `
` children; `PageBody` generates as `PressTree`; the + `HeaderBlocks`/`FooterBlocks` unions are gone. diff --git a/CLAUDE.md b/CLAUDE.md index f8e3806..f92321c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,15 +12,21 @@ dogfoods it through `apps/playground`. ## Layout — four engine packages + the dogfood -- `packages/shared` — `@ogs-tech/press-shared`: the `PressSchema` wire contract. Ships - TS source (no build); imported **type-only** by cms + web so it never enters a - runtime artifact. +- `packages/shared` — `@ogs-tech/press-shared`: the wire contract — the (still + type-only) `PressSchema` shape PLUS the `PressTree` node types and the pure, + sanitizing `validatePressTree`/`validateNodeArray` validators. Ships TS source + (no build) but is **no longer type-only-consumed**: it is a PUBLISHED runtime + dependency — `cms` bundles it into its compiled `dist` (a devDependency there; + `strapi-plugin build` inlines it) and `web` depends on it directly (an + adopter-installed package, transpiled by the Next host's `transpilePackages` + like `press-web` itself). Zero Strapi/Next imports either way, so the same + validator runs unmodified on the cms write path and the web render path. - `packages/cms` — `@ogs-tech/press-cms`: a Strapi 5 **plugin**. Owns the `page` and `site-setting` content-types, injects the `preset-*` component palette, and serves `GET /api/press/schema`. Compiled with `strapi-plugin build`. -- `packages/web` — `@ogs-tech/press-web`: the Next.js host template, block renderer, - runtime CLI (`press dev/build/upgrade`), config helpers, and CMS→TS type-sync. Ships - TS source (no build). +- `packages/web` — `@ogs-tech/press-web`: the Next.js host template, composition-tree + renderer, runtime CLI (`press dev/build/upgrade`), config helpers, and CMS→TS + type-sync. Ships TS source (no build). - `packages/cli` — `@ogs-tech/create-press`: the run-once scaffolder behind `pnpm create @ogs-tech/press`. Pins engine versions via a generated versions file. - `apps/playground` — committed real scaffold output, consumed via `workspace:*` for a @@ -53,6 +59,59 @@ Release (changesets): add a changeset under `.changeset/` for any engine change, ## Architecture — the moving parts +### Composition trees (`PressTree`) + +Page `body` and Site Settings `pageDefaults` are no longer Dynamic Zones — both are +a JSON **composition tree**, `PressTree` (`@ogs-tech/press-shared`, `src/tree.ts`), +stored by one custom field, `plugin::press-cms.builder` (registered in +`server/src/register.ts`, `type: 'json'`; the admin Input is `builder-input.tsx`, +labelled "Composition"). `page.body` stores a full tree (`{ version, root }`); +`site-setting.pageDefaults` stores the same node shapes as a bare `{ header: +Node[]; footer: Node[] }` pair (`options: { mode: 'slots' }` on the field tells the +Input which shape to render). + +- **Node kinds** (`Node = RowNode | ColumnNode | BlockNode`): the tree root is + always a `LayoutNode` — `header`/`footer` are `Slot`s (`{ mode: 'inherit' }` | + `{ mode: 'none' }` | `{ mode: 'custom'; children }`), plus top-level `children`. + A `RowNode` carries a `ratio` (`50-50 | 33-67 | 67-33 | 33-33-33 | 25-25-25-25`) + and 1–4 `ColumnNode` children; a `ColumnNode` is the recursion point — it nests + arbitrary further `Node`s, including more rows, to unlimited depth; a `BlockNode` + is a placed component (`component`: a palette uid; `data`: validated against + that component's registry schema). +- **The ONE `container` attr surface** (`ContainerAttrs`: `width | gap | + verticalAlign`) is carried by every children-bearing node (layout root, row, + column) as an optional `container` field. An attr that doesn't apply to a node + type is ignored by the renderer and hidden by the builder form — never an + error; an absent field (or the whole group) means the engine default. + Responsiveness NEVER appears in this JSON — `ratio` and `container` are + editorial intents only; `web/src/tree/container-attrs.ts` (`RATIO_SPANS`, + `GAP_TIERS`) maps them to `Responsive` layout-primitive props. +- **Ids are builder-minted** (`crypto.randomUUID`), used only as React keys and as + the addressing scheme for builder mutations (`tree-ops.ts`) — never an `Entity`, + never a URN (see "Canonical identity" below: this is the one identity class kept + deliberately OUT of the URN system). +- **Version-gated readers.** `PRESS_TREE_VERSION` (currently `1`) is served as + `tree.version` in `/api/press/schema`; `validatePressTree` rejects any other + `version` outright (fail-to-empty) — this is the seam a future tree migration + hangs off. +- **Strict-write / tolerant-read validator split**, one shared implementation + (`validate-tree.ts`) both sides import: structural failures null the value and + land in `errors`; invalid container-attr values are stripped (attr-level failure + never becomes tree-level) and land in `warnings`; an unknown slot `mode` + degrades to `none` with a warning. **Writers reject on errors OR warnings** + (cms `beforeCreate`/`beforeUpdate` lifecycle guards, `validate-write.ts` — + the builder UI can't produce an invalid tree, so this backstops direct API + writes only). **Readers render whenever `value` is non-null** (web sanitizes + and renders best-effort: `TreeRenderer`, `map-site-settings.ts`). +- **Serve-time hydration** resolves `{ assetId }` (media) and `{ documentId }` + (page relation) references to fresh values — a schema-driven walk + (`cms/.../lib/hydrate-tree.ts`, batched per-request in `serve-hydrated.ts`) that + never hardcodes block shapes, so the wire never rots and internal links survive + page renames. This is the direct replacement for the old populate bug class: + **`dz-populate`/`buildBodyPopulate` are gone** — there is no populate depth + limit to fight because the tree carries its own refs and the cms resolves them + before the response leaves. + ### Layout primitives (`packages/web/src/layout/`) Engine-owned responsive layout is code, not content. Four React primitives — @@ -74,20 +133,37 @@ grid always carries 11 interior column-gaps, so a Grid's minimum width is 528px floor that overflows phones; organisms declare a tier-scaled gap (`{ base: 'md', lg: 'lg' }`, the Hero pattern) instead. +**`TreeRenderer` is the primitives' first consumer.** `web/src/tree/tree-renderer.tsx` +maps tree nodes onto the primitives directly: a TOP-LEVEL `RowNode` becomes +`` (width +applies only here, per the container-attrs contract above); a NESTED row (inside +a column) becomes a bare `` — it fills its parent cell, no `Container` of +its own; each `ColumnNode` becomes `` wrapping a +`[data-press-cell]` div that stacks its children (gap/align read off that +column's `container` via `container-attrs.ts`). This is the mechanism behind the +tree's row/column recursion — arbitrary nesting depth costs nothing extra +because every level is just another `Grid`/`Column` pair. + **Why two surfaces named `layout`.** (1) DEV-facing — the React primitives above, -consumed by engine organisms, future page-set-plugin templates, and adopter -custom blocks. (2) CMS-facing — the `preset-layout` Atomic Design category -stays declared in `PRESET_LAYERS` and labelled in the admin picker but ships -ZERO components today. The palette is reserved for future *nested-only* config -components (pattern: `preset-molecule.nav-item`) that a future organism admits -via a `component:` field. Layout is NEVER placed by the editor as a top-level -block — the Strapi 5 constraint "a component cannot contain a `dynamiczone`" -rules out polymorphic-child nesting inside a component. +consumed by `TreeRenderer`, engine organisms, future page-set-plugin templates, +and adopter custom blocks. (2) CMS-facing — the `preset-layout` Atomic Design +category is no longer reserved-empty: `preset-layout.container/row/column` are +the composition tree's own node-shape descriptors, pure schema the builder's +admin form generator (`admin/src/lib/form-model.ts`) reads to build the row/column +edit forms. `container` is the ONE shared `ContainerAttrs` surface — row and +column both reference it via a `component:` field, so the "Container" form +section is defined exactly once and shared by both node types. None of the three +is ever directly PLACEABLE as a tree block: the builder's "Add node" picker +excludes the whole category (`NON_PLACEABLE = /^preset-(molecule|config|layout| +template)$/` in `form-model.ts`) — a row/column is minted structurally (the "Add +Row" control, or column recursion), never chosen from the palette like a block is. **Data-attr namespace is distinct from blocks.** Primitives use `data-press-layout=""`, deliberately not `data-block="preset-*"`. -Primitives never have a `__component`, never appear in `PageBody`, never flow -through `BlockRenderer`. +Primitives are never tree nodes themselves — no `component` uid, no `id`, no +entry in the generated `PageBody` (`= PressTree`) interfaces; `TreeRenderer` +instantiates them internally (above) to realize a row/column node's layout, but +the JSON never names them. **Breakpoints are TS constants, not CSS vars.** `@media (min-width: var(--x))` is unsupported in production browsers, so `BREAKPOINTS` in @@ -102,11 +178,19 @@ queries are the two sources — `src/layout/breakpoints.test.ts` reads source of truth and cross-referencing scales makes future edits fragile. Every new var goes through `buildThemeStyle`'s single `:root` injection point. -**Shell is full-width; atoms preserve prose width via a selector.** `main` has -no `max-width`; a single rule (`main [data-block^="preset-atom."], -main [data-block^="custom-atom."] { max-width: var(--press-container-prose); -… }`) restores ~72ch editorial reading width for every preset atom AND every -custom atom — without touching a single atom `.tsx`. The `prose` token is +**Shell is full-width; atoms preserve prose width via a selector — scoped to +`
`'s DIRECT children only.** `main` has no `max-width`; a single rule +(`main > [data-block^="preset-atom."], main > [data-block^="custom-atom."] { +max-width: var(--press-container-prose); … }`) restores ~72ch editorial reading +width for every preset atom AND every custom atom placed at the TOP of the tree +(a body-level block, or the top level of an inherited/custom chrome slot) — +without touching a single atom `.tsx`. The child combinator (`>`, not a +descendant selector) is load-bearing post-tree: once composition trees let an +atom nest inside a row/column, that atom is no longer a `
`-direct child +and correctly inherits its grid cell's width instead of the prose clamp — a +`50-50` row's paragraph fills half the container, per the ratio's editorial +intent, rather than being force-clamped to a fixed 72ch that could overflow a +narrow cell. The `prose` token is **rem-anchored** (`42rem` ≈ 72ch at the 16px body size) on purpose: a `ch` value resolves against each consuming element's font, which would give a 28px heading a ~2× wider "prose" column than a paragraph — the editorial column @@ -148,17 +232,31 @@ materialized `press-config.ts` / `press.blocks.ts` inside it). ### The contract + type-sync loop 1. cms serializes its **runtime view** — the `page` and `site-setting` content-types - plus exactly the components admitted into the three engine Dynamic Zones (page - `body`, site-setting `header`/`footer`), following nested component references — - to `GET /api/press/schema` (`cms/.../lib/serialize-schema.ts`). Reading the live - registry means the schema can never disagree with what Strapi actually serves. + plus the FULL registered palette (every `preset-*` + `custom-*` component uid) + — to `GET /api/press/schema` (`cms/.../lib/serialize-schema.ts`), plus + `tree.version` (`PRESS_TREE_VERSION`). There is no Dynamic Zone admission list + left to walk: `body`/`pageDefaults` reference components by uid at arbitrary + depth via the `plugin::press-cms.builder` custom field, so the palette is + exactly "every component the registry knows about that belongs to press". + Reading the live registry means the schema can never disagree with what + Strapi actually serves. 2. web's generator (`web/src/generator/generate.ts`) turns that JSON into - framework-agnostic TS, written to the adopter's `shared/types/generated.ts`. -3. The shape — `PressSchema` — is single-sourced in `@ogs-tech/press-shared` and imported - **type-only** by both sides. The generator references **no Strapi types** on - purpose. `press dev` re-syncs whenever the schema changes (`util/watch-schema.ts`). - The ~2s poll is deliberately absent from the cms http log: the plugin drops that - one line in development (`lib/quiet-schema-log.ts`) — don't "fix" the silence. + framework-agnostic TS, written to the adopter's `shared/types/generated.ts`: + every component becomes a plain data-shaped interface — no `__component` + discriminator, no `id` (those exist only on tree `Node` wrappers, never on a + generated component type); the page-picker relation (`preset-molecule.link`'s + `page` field) emits as `PressPageRef` (`{ documentId; slug? }`); media emits as + `PressMedia`; and `PageBody = PressTree` (imported type-only from + `@ogs-tech/press-web`, which re-exports it from `@ogs-tech/press-shared`). The + old `HeaderBlocks`/`FooterBlocks` unions are gone — chrome is just `PressTree` + node slots now, not a distinct generated shape. +3. The wire shapes — `PressSchema` (still type-only) and the `PressTree`/validator + types (now a runtime dependency, see the `packages/shared` bullet above) — are + single-sourced in `@ogs-tech/press-shared`. The generator references **no + Strapi types** on purpose. `press dev` re-syncs whenever the schema changes + (`util/watch-schema.ts`). The ~2s poll is deliberately absent from the cms http + log: the plugin drops that one line in development (`lib/quiet-schema-log.ts`) + — don't "fix" the silence. ### Component palette — Atomic Design (`{owner}-{layer}.{name}`) @@ -169,74 +267,102 @@ ad-hoc `press.*`/`section.*`/`chrome.*` prefixes are gone — this model replace (a wire-breaking rename; fine pre-release). The word "press" survives only as the PRODUCT/plugin id (`plugin::press-cms.*`, `/api/press/schema`), never as a category. +The palette is now a **pure schema catalog** — nothing is "admitted" anywhere. There +is no Dynamic Zone left to gate membership, so a component's uid and its registry +schema are the entire contract; the tree references components by uid at any depth, +and the serializer/builder simply discover "every component the registry knows +about that belongs to press" (`serialize-schema.ts`, `admin/src/lib/form-model.ts`). + - **Preset (engine) — the category IS the atomic LAYER.** Injected into the components registry during `register()` (`cms/.../lib/inject-components.ts`), since Strapi only scans the *host app's* `src/components`. `PRESET_LAYERS` is the single source of truth for the layer set; each entry registers under `preset-${layer}`: - `preset-atom.*` — paragraph, heading, list, quote, image, button, separator, spacer. - - `preset-molecule.nav-item` — nested inside the navbar; never a DZ member. - - `preset-organism.*` — hero, cta (page body) **and** navbar, footer (site chrome): - one layer, unified from the old `section.*`/`chrome.*` palettes. + Text atoms store **curated PLAIN TEXT**, not a Strapi blocks-editor AST: paragraph's + `content` is `text` (a blank line starts a new paragraph — `splitParagraphs` in + `web/src/blocks/paragraph.tsx`); list's `content` is `text`, one item per line. The + blocks AST left the wire with this refactor — there is no `renderBlocks` anymore. + - `preset-molecule.link` — the engine's ONE link concept (`label`/`page`/`url`/`newTab`): + an internal page relation (survives renames, resolves to a fresh slug) takes + precedence over a raw `url`. Referenced by `preset-atom.button`, `preset-organism.hero` + (`cta`), `preset-organism.cta` (`button`), and `preset-organism.navbar` (`items[]` + + `cta`) — this is the ONE nesting pattern the retired `nav-item`/`column` molecules used + to each solve narrowly; a nav item was exactly a link, so it's gone, replaced by `link` + itself. Never placed as a top-level tree block. + - `preset-organism.*` — hero, cta (page body) **and** navbar, footer (site chrome): one + layer, unified from the old `section.*`/`chrome.*` palettes. The old `columns` organism + is RETIRED — its job (2–4 column layouts, closed `ratio`/`gap`/`verticalAlign` enums) is + now native tree recursion (`RowNode`/`ColumnNode`, unlimited depth), not a discrete block. - `preset-config.*` — seo, theme-colors, theme-radius, cookie-consent, cookie-category: - non-block settings referenced by `component:` fields on Site Settings, never a DZ member. - - `preset-layout` is RESERVED (labelled, no components yet). The Grid System - task shipped the DEV-facing layout primitives under - `packages/web/src/layout/` (see "Layout primitives" above); the CMS-facing - category stays labelled and empty, seat for a future *nested-only* config - component (pattern: `preset-molecule.nav-item`) that a future organism admits - via a `component:` field. + non-block settings referenced by `component:` fields on Site Settings, never a tree node. + - `preset-layout.{container,row,column}` — no longer reserved-empty: pure registry + descriptors the builder's admin form generator reads to build the row/column edit forms + (see "Layout primitives" above for the full mechanics). Never placed as a tree block + either — structural nodes are minted by the builder UI, not chosen from the palette. - `preset-template` is RESERVED (labelled, no components yet) — page-set plugins. -- **Preset PLACEMENT is declared statically, per content-type — NOT by the category.** - The category carries the layer; where a preset block may go is listed in each - `schema.json`: `preset-atom.*` in all three engine DZs (page `body`, site-setting - `header`/`footer`); `preset-organism.hero`/`.cta` in the page `body` only; - `preset-organism.navbar`/`.footer` in `header`/`footer` only. This is why organisms - span placements (a hero is body-only, a navbar is chrome-only) while sharing one - category — placement lives in the schema, not the uid. Because the engine names its - OWN blocks, per-block placement never violates the "never name individual blocks" rule - (that rule protects the *adopter* extension point below). -- **Custom (adopter) — the category is the atomic LAYER too; placement is UNIVERSAL.** +- **Placement is UNIVERSAL for every placeable component — preset and custom alike.** + The old per-content-type `schema.json` placement lists (and the preset/custom asymmetry + they encoded — "a hero is body-only, a navbar is chrome-only") are gone along with the + Dynamic Zones that hosted them. The builder's "Add node" picker (`paletteGroups` in + `form-model.ts`) offers the exact SAME full palette to every slot — page body, header, + footer — filtered only by one `NON_PLACEABLE` regex (`preset-(molecule|config|layout| + template)`; nested-only/settings/descriptor categories are never directly addable, + whether preset or custom). The tree has no notion of "where a component belongs" — + an editor could technically drop a navbar into the page body; that judgment is now + entirely theirs, not a schema-enforced rule. +- **Custom (adopter) — the category is the atomic LAYER too; discovery, not admission.** The adopter drops a component under `src/components/custom-${layer}/` (e.g. `custom-organism/`); Strapi derives the `custom-${layer}` category from the folder. - Every `custom-*` block (legacy bare `custom.*` still matches, for migration) is - admitted into EVERY engine DZ by `admitCustomBlocks` — the editor decides placement in - the picker. The engine NEVER names individual adopter blocks; the `custom*` category - prefix is the whole extension-point contract. (Deliberate asymmetry: the engine curates - its own blocks' placement tightly in `schema.json`; adopter blocks are unrestricted — - control differs, the layer axis is shared.) -- **Navbar/footer hydration:** `preset-organism.navbar` nests `preset-molecule.nav-item[]` - + an optional `preset-atom.button` cta; brand (logo + name) is never stored on the - block — `mapSiteSettings` hydrates it (and the resolved nav links) from Site Settings - identity before rendering (specific to `preset-organism.navbar`/`.footer`). The - serializer follows these nested refs; the generator emits nested-only components without - `__component` and adds `HeaderBlocks`/`FooterBlocks` unions. `bootstrap()` seeds - `header: [preset-organism.navbar]`, `footer: [preset-organism.footer]` exactly once - (plugin-store flag) — an editor-emptied zone is respected. The bootstrap navbar is - BARE (no items/cta); the CLI's `seed.mjs` fills it with demo navigation (Home, - external GitHub, "Get started" CTA) in the same idempotent pass that fills identity. -- **Picker presentation (admin bundle):** every engine component JSON sets `info.icon` - (Strapi's fixed icon enum); the plugin's `./strapi-admin` bundle (`cms/admin/src/index.ts`) - exists solely to `registerTrads` the category labels — the picker resolves accordion - titles via react-intl with the RAW category string as message id (`preset-atom` → - "Atoms", `preset-organism` → "Organisms", `custom-organism` → "Custom organisms", en + - pt). `preset-config`/`preset-molecule` never surface in a picker but are labelled for - completeness; `preset-layout`/`preset-template` are labelled ahead of their components. - Labels are presentation-only; uids never change for display. Adopter `src/admin/app.tsx` - translations override the engine's. + There is no more `admitCustomBlocks` step — every `custom-*` block (legacy bare + `custom.*` still matches, for migration) is discovered straight from the components + registry by `isCustomBlockUid` (`inject-components.ts`), used identically by + `serialize-schema.ts` and the builder's palette. The engine NEVER names individual + adopter blocks; the `custom-*` category prefix is the whole extension-point contract — + it no longer carries any placement meaning either, now that placement is universal. +- **Navbar/footer hydration is now ONE hydration point for the whole tree.** + `preset-organism.navbar`'s `items` are `preset-molecule.link[]` (not `nav-item[]` + — a nav item was exactly a link); its `cta` is an optional `preset-atom.button`. + Brand (logo + name) is never stored on the block — `resolveTree` + (`web/src/tree/resolve-slots.ts`) hydrates it, plus every engine link field + (`LINK_FIELDS`: navbar `items`/`cta`, `preset-atom.button.link`, + `preset-organism.hero.cta`, `preset-organism.cta.button`) via `resolveLink`, for + engine blocks WHEREVER they sit in the tree — page body, an inherited default, or + page-custom chrome all resolve through the same function; adopter data passes + through untouched (custom blocks resolve their own links via ``). + `seedSiteSetting` seeds `pageDefaults.header: [preset-organism.navbar]`, + `.footer: [preset-organism.footer]` exactly once (plugin-store flag + `pageDefaultsSeeded`) as BARE blocks (no items/cta, fresh builder-minted ids) — + Strapi can't tell a never-touched slot from an editor-emptied one, so after that + one pass the defaults are never rewritten again. The CLI's `seed.mjs` fills real + navigation (Home via a `preset-molecule.link` page ref, external GitHub, a + "Get started" CTA) in the same idempotent pass that fills identity. +- **Picker presentation:** every engine component JSON still sets `info.icon` (Strapi's + fixed icon enum) and the plugin's `./strapi-admin` bundle (`cms/admin/src/index.ts`) + still registers `preset-*`/`custom-*` category labels via `registerTrads` (kept for any + native Strapi component picker an adopter's own Dynamic Zone might use). But the + composition builder's OWN "Add node" control (`AddControls` in `admin/src/components/ + tree-editor.tsx`) has its own palette select: an `` per category, grouped by + `paletteGroups`, labelled with the RAW category string (no react-intl — this is + plugin-owned React, not Strapi's content-manager DZ UI) and excluding the + `NON_PLACEABLE` categories from the previous bullet. Labels are presentation-only; + uids never change for display. Adopter `src/admin/app.tsx` translations still override + the engine's for the legacy-picker path. - **Page templates:** the once-shipped "Privacy Policy" bootstrap seed was RETIRED; what remains is `lib/seed-page.ts` — a generic, idempotent `seedPage(strapi, opts)` primitive (flag-first, slug-collision-respecting, DRAFT-only) that is deliberately exported-but-unused, awaiting future page-seeding consumers (Plugin/Legal, archetype templates). `bootstrap()` seeds NO page today; the only page an adopter starts with is the CLI seed's published `home`. -- On the web side, `BlockRenderer` merges the engine registries with the adopter map by - `__component`: `{ ...atomBlocks, ...organismBlocks, ...components }` — engine - `preset-atom.*` atoms (`src/atom-blocks.ts`), engine `preset-organism.*` organisms - (`src/organism-blocks.ts`, sections + chrome unified), then the adopter's **explicit** - `customBlocks` map (no global registry). Adopter blocks win last, so any - `preset-organism.*` is overridable via - `components={{ 'preset-organism.hero': MyHero, 'preset-organism.navbar': MyNavbar }}`. - An unknown component is skipped with a dev-only warning, never a crash. +- On the web side, `TreeRenderer` (`web/src/tree/tree-renderer.tsx`) merges the engine + registries with the adopter map by `component` uid: `{ ...atomBlocks, ...organismBlocks, + ...components }` — engine `preset-atom.*` atoms (`src/atom-blocks.ts`), engine + `preset-organism.*` organisms (`src/organism-blocks.ts`, sections + chrome unified), + then the adopter's **explicit** `customBlocks` map (no global registry). Adopter blocks + win last, so any `preset-organism.*` is overridable via + `components={{ 'preset-organism.hero': MyHero, 'preset-organism.navbar': MyNavbar }}` — + this override contract is UNCHANGED from the old `BlockRenderer` (now `TreeRenderer`; + the DZ mechanism it replaced is gone, but the merge order and the override prop shape + are the same). An unknown component is skipped with a dev-only warning, never a crash. ### Build-time anchors vs. runtime Site Settings @@ -247,12 +373,27 @@ This split is recent and easy to get wrong: and `theme.fonts` (which `next/font` must know at build time). The engine **reads** this file but **never rewrites** it. A destructive `ThemeName` change fails `tsc` right at the `defineConfig` call site. -- **Identity, SEO, theme color/radius VALUES, and the block-composed `header`/`footer` - chrome** live in the CMS **"Site Settings"** single type — edited in the admin, - fetched at runtime by `getSiteConfig` (ISR ~60s), no redeploy. Any failure (CMS down, - malformed body) maps as if the record were *empty* → the site renders - unbranded/default-themed rather than crashing. There is **no `press.config` fallback - for identity** by design. +- **Identity, SEO, theme color/radius VALUES, and the two `pageDefaults` composition-tree + slots (header/footer)** live in the CMS **"Site Settings"** single type — edited in the + admin's "Composition" field (the same `plugin::press-cms.builder` custom field as page + `body`, in `slots` mode: a bare `{ header: Node[]; footer: Node[] }` pair), fetched at + runtime by `getSiteConfig` (ISR ~60s), no redeploy. Any failure (CMS down, malformed + record) maps as if the record were *empty* → the site renders unbranded/default-themed + AND chrome-less rather than crashing. There is **no `press.config` fallback for + identity** by design. + - **Inheritance semantics:** a page's `header`/`footer` is a `Slot` — `inherit | none | + custom`. `inherit` resolves against Site Settings `pageDefaults` at render time + (`resolveTree`), so editing the site default updates every inheriting page on the next + ISR cycle, no redeploy; `none` renders a bare page; `custom` is page-owned chrome that + never reads `pageDefaults`. Strapi can't distinguish a never-touched slot from an + editor-emptied one (both read back `[]`), so `seedSiteSetting` seeds `pageDefaults` + exactly once (plugin-store flag `pageDefaultsSeeded`) and never rewrites it again — an + editor-emptied default is respected forever. + - `ResolvedPressConfig`'s old `chrome` key is now `pageDefaults: { header: Node[]; + footer: Node[] }` — RAW, unhydrated nodes; `map-site-settings.ts` only structurally + validates each slot (`validateNodeArray`, fail-to-empty on invalid nodes). Engine-block + hydration (brand injection, link resolution) happens at exactly ONE point, `resolveTree` + (`web/src/tree/resolve-slots.ts`), never in the mapper. - Routing reads only the build-time anchor, so the `/home → /` redirect stays deterministic and CMS-independent. @@ -294,11 +435,22 @@ This split is recent and easy to get wrong: contradicting the live default — while text stays empty ("no defaults duplicated in the CMS"). It does NOT set its flag when the Site Settings record is missing, so a broken bootstrap order self-heals next boot. +- **React version + admin bundle (load-bearing):** the whole monorepo is pinned + to **React 19** via a root `pnpm.overrides` (`react`/`react-dom` = Strapi 5's + `19.2.7`) so the Next host, the engine packages, and Strapi's admin all share + ONE React. A React-18/19 split previously caused a duplicate-React admin crash + ("Cannot read properties of null (reading 'useState')"). The cms plugin ALSO + declares `react`/`react-dom` as **peerDependencies** so `@strapi/sdk-plugin`'s + build EXTERNALIZES them (its vite config externalizes only the plugin's + `dependencies`+`peerDependencies`) — otherwise the builder custom field bundles + its own React copy and crashes the admin with a null hooks dispatcher. A guard + test (`cms/server/src/lib/admin-react-externals.test.ts`) pins this, since + build/test/typecheck all pass while only the browser catches it. - **Testing note:** the banner's interactive tests (`// @vitest-environment - jsdom`) use a hand-rolled `act()`+`createRoot` harness, deliberately NOT - `@testing-library/react` — the workspace's `node-linker=hoisted` layout - (required by Strapi 5) materializes only Strapi-admin's react-19 RTL variant - at the root, which cannot render this package's react-18 elements. + jsdom`) use a hand-rolled `act()`+`createRoot` harness rather than + `@testing-library/react` — a lightweight, zero-extra-dep choice (it also + predates the React-19 unification above, which retired the earlier + react-18/react-19 rendering split). ### Canonical identity (URNs) @@ -319,12 +471,17 @@ This split is recent and easy to get wrong: palette REGISTRATION (`preset-atom.image`, `preset-organism.hero`, `preset-organism.navbar`, adopter `custom-*`). No object implements `Canonical<'component'>` — the atom/organism-block registries ARE the canonical - base; today's one consumer is `BlockRenderer`'s "no component registered" dev - warning. (3) COMPUTED: `blockKey` formats `Urn` with `__component` as the - entity segment (`urn:preset-atom.image:5`) — a per-instance key, never stored: DZ block ids - are ephemeral (unique only per component table), so block INSTANCES stay OUT of - `Entity`. Extending `Entity` is additive; widening a call site to plain - `string` is not allowed. + base; today's one consumer is `TreeRenderer`'s "no component registered" dev + warning (the `BlockRenderer`-era consumer of the same warning, renamed with the + composition-builder refactor). (3) COMPUTED — **retired, not replaced 1:1**: the + old `blockKey` formatted `Urn` with `__component` as the entity segment + (`urn:preset-atom.image:5`), keying off ephemeral DZ block ids. Tree `BlockNode`s + carry their own builder-minted `id` (`crypto.randomUUID`, see "Composition trees" + above) instead — `TreeRenderer` uses that `id` directly as the React key + (``), and it stays OUT of `Entity` the same way + `blockKey` did: a tree node id is a React-key/builder-op address, never a + durable, render-independent identity. Extending `Entity` is additive; widening a + call site to plain `string` is not allowed. ### Versioning + upgrade diff --git a/apps/playground/packages/cms/package.json b/apps/playground/packages/cms/package.json index 933d309..6fc67e3 100644 --- a/apps/playground/packages/cms/package.json +++ b/apps/playground/packages/cms/package.json @@ -31,6 +31,6 @@ "npm": ">=6.0.0" }, "strapi": { - "uuid": "3df03067-2b3d-45b9-a1e5-f942710a84a4" + "uuid": "302c4e43-d61c-44bd-8649-494d125f6d90" } } diff --git a/apps/playground/packages/cms/scripts/seed-content.mjs b/apps/playground/packages/cms/scripts/seed-content.mjs new file mode 100644 index 0000000..a24d9df --- /dev/null +++ b/apps/playground/packages/cms/scripts/seed-content.mjs @@ -0,0 +1,126 @@ +// cms/scripts/seed-content.mjs — the demo content as PURE DATA, importable by +// both seed.mjs (to write it) and the engine's CLI test suite (to validate the +// tree shape against the shared validator — the seed-regression guard). +// Plain-text content throughout (curated `content: text` decision 2026-07-20). +import { randomUUID } from 'node:crypto'; + +export const REPO_URL = 'https://github.com/ogs-tech/press'; +export const PRESS_SITE_URL = 'https://useogs.com/press'; +export const NPM_CREATE_URL = 'https://www.npmjs.com/package/@ogs-tech/create-press'; + +export const SITE_SETTINGS = { + name: 'Press', + url: 'http://localhost:3000', + locale: 'en', + seo: { + titleTemplate: '%s · Press', + title: 'Press', + description: 'A press-powered site, server-rendered end-to-end.', + }, + themeColors: { + primary: '#119350', + accent: '#D9A12C', + secondary: '#3D5CC2', + ink: '#142036', + surface: '#FAF8F3', + muted: '#7A7E89', + danger: '#C0392B', + onPrimary: '#FFFFFF', + border: 'rgba(20,32,54,0.12)', + }, + themeRadius: { xs: '6px', sm: '10px', md: '14px', lg: '20px' }, +}; + +const block = (component, data = {}) => ({ id: randomUUID(), type: 'block', component, data }); +const column = (children, container) => ({ id: randomUUID(), type: 'column', children, ...(container ? { container } : {}) }); +const row = (ratio, children, container) => ({ id: randomUUID(), type: 'row', ratio, children, ...(container ? { container } : {}) }); + +/** + * The demo home as a PressTree (Spec §4 seeds): hero → prose atoms → a 50-50 + * row whose right column nests ANOTHER row (the recursion demo) → separator/ + * button/spacer → cta banner → adopter callout. Chrome inherits pageDefaults. + */ +export const buildHomeBody = ({ heroAssetId }) => ({ + version: 1, + root: { + type: 'layout', + header: { mode: 'inherit' }, + footer: { mode: 'inherit' }, + children: [ + block('preset-organism.hero', { + eyebrow: 'Press engine', + title: 'Hello from press', + subtitle: 'A press-powered site, server-rendered end-to-end.', + image: { assetId: heroAssetId }, + cta: { label: 'Read the docs', url: REPO_URL }, + align: 'left', + }), + block('preset-atom.heading', { text: 'What ships in the box', level: '2' }), + block('preset-atom.paragraph', { + content: 'This prose lives in the CMS and renders as static HTML — no client hydration.', + }), + block('preset-atom.list', { + format: 'unordered', + content: [ + 'Atomic text blocks — paragraph, heading, list and quote.', + 'Media & structure — image, button, separator and spacer.', + 'Rows and columns — recursive layout composed in the admin.', + 'Your own custom-* blocks, usable anywhere in the tree.', + ].join('\n'), + }), + block('preset-atom.quote', { + content: 'The contract is HTML on the server.', + citation: 'The press engine', + }), + // The composition mechanism itself: a 50-50 row whose RIGHT column nests + // another 50-50 row — full recursion on the demo page. + row('50-50', [ + column([ + block('preset-atom.paragraph', { + content: 'Editor-composed layout — rows and columns arranged in the admin, rendered on the engine grid.', + }), + ]), + column([ + row('50-50', [ + column([block('preset-atom.paragraph', { content: 'Columns nest rows.' })]), + column([block('preset-atom.paragraph', { content: 'Rows nest columns.' })]), + ]), + ], { verticalAlign: 'center' }), + ], { gap: 'normal' }), + block('preset-atom.separator', { variant: 'line' }), + block('preset-atom.button', { + link: { label: 'Star on GitHub', url: REPO_URL, newTab: true }, + variant: 'secondary', + }), + block('preset-atom.spacer', { size: 'md' }), + block('preset-organism.cta', { + title: 'Ready to press publish?', + subtitle: 'Scaffold a site, open the admin, and ship your first page in minutes.', + button: { label: 'Scaffold your site', url: PRESS_SITE_URL }, + align: 'center', + }), + block('custom-organism.callout', { + message: 'Adopter callout renders via the Project-zone block map', + variant: 'success', + }), + ], + }, +}); + +/** + * Demo navigation for Site Settings pageDefaults: the Home item is a PAGE REF + * ({ documentId }) — exercising the reference-hydration path end-to-end — plus + * an external link and a CTA button. The footer keeps the bare seeded node. + */ +export const buildPageDefaults = ({ homeDocumentId }) => ({ + header: [ + block('preset-organism.navbar', { + items: [ + { label: 'Home', page: { documentId: homeDocumentId } }, + { label: 'GitHub', url: REPO_URL, newTab: true }, + ], + cta: { link: { label: 'Get started', url: NPM_CREATE_URL }, variant: 'primary' }, + }), + ], + footer: [block('preset-organism.footer', {})], +}); diff --git a/apps/playground/packages/cms/scripts/seed.mjs b/apps/playground/packages/cms/scripts/seed.mjs index e4d1694..96a1878 100644 --- a/apps/playground/packages/cms/scripts/seed.mjs +++ b/apps/playground/packages/cms/scripts/seed.mjs @@ -14,6 +14,7 @@ import { createRequire } from 'node:module'; import { mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; +import { buildHomeBody, buildPageDefaults, SITE_SETTINGS } from './seed-content.mjs'; // Load Strapi through the CommonJS resolver: its `.mjs` build does bare directory // imports (e.g. `lodash/fp`) that Node's native ESM loader rejects, whereas the CJS @@ -34,93 +35,11 @@ const PNG = Buffer.from( 'base64', ); -// Demo Site Settings. Theme values MIRROR the engine's DEFAULT_THEME -// (packages/web/src/config/default-theme.ts) so the seeded look matches the -// runtime fallback exactly — once written, this CMS record is the source of truth -// and DEFAULT_THEME only acts as the CMS-down floor. Edit freely in the admin. -const REPO_URL = 'https://github.com/ogs-tech/press'; -// The cta banner's conversion link: the press page on the OGS site. Not live -// until the site launches — the path is the launch-day contract (adjust here -// if the final route differs). The other buttons keep their own semantics: -// starring and reading the docs live on the repo, "Get started" opens the -// published create-press package (the actual scaffold quickstart). -const PRESS_SITE_URL = 'https://useogs.com/press'; -const NPM_CREATE_URL = 'https://www.npmjs.com/package/@ogs-tech/create-press'; - -const SITE_SETTINGS = { - name: 'Press', - url: 'http://localhost:3000', - locale: 'en', - seo: { - titleTemplate: '%s · Press', - title: 'Press', - description: 'A press-powered site, server-rendered end-to-end.', - }, - themeColors: { - primary: '#119350', - accent: '#D9A12C', - secondary: '#3D5CC2', - ink: '#142036', - surface: '#FAF8F3', - muted: '#7A7E89', - danger: '#C0392B', - onPrimary: '#FFFFFF', - border: 'rgba(20,32,54,0.12)', - }, - themeRadius: { xs: '6px', sm: '10px', md: '14px', lg: '20px' }, -}; - async function main() { const appContext = await compileStrapi(); const app = await createStrapi(appContext).load(); try { - // Site Settings (single type): the engine's bootstrap already seeded an EMPTY - // record (Strapi runs plugin bootstrap during app.load()), so fill THAT record - // once with demo identity/SEO + theme + a demo navbar. Runs before the page - // skip below so it is never gated by existing pages. Idempotent: skip once an - // editor set a name, so their values are never clobbered (reset by deleting - // cms/.tmp). - // - // The navbar the bootstrap seeded is BARE (no items, no cta) — without links - // the MobileNav hamburger doesn't even render, so the demo fills it. Items - // are url-based ('/' resolves through the same homeSlug anchor as the - // /home → / redirect); the page-RELATION path stays an admin-editing demo — - // the engine seeds no page besides 'home' to point one at. - const settings = await app.documents(SITE_SETTING_UID).findFirst(); - if (settings?.name) { - console.log('[seed] site settings already filled — skipping.'); - } else { - const header = [ - { - __component: 'preset-organism.navbar', - items: [ - { label: 'Home', url: '/' }, - { label: 'GitHub', url: REPO_URL, newTab: true }, - ], - cta: { label: 'Get started', href: NPM_CREATE_URL, variant: 'primary' }, - }, - ]; - if (settings) { - await app - .documents(SITE_SETTING_UID) - .update({ documentId: settings.documentId, data: { ...SITE_SETTINGS, header } }); - console.log('[seed] site settings filled (empty record updated, navbar links seeded).'); - } else { - await app.documents(SITE_SETTING_UID).create({ data: { ...SITE_SETTINGS, header } }); - console.log('[seed] site settings created (navbar links seeded).'); - } - } - - // Skip-if-empty: only seed a fresh CMS. Once any published page exists (the - // first boot's sample, or the adopter's own content), leave it untouched so - // `press dev` never discards their work. Reset by deleting cms/.tmp (the DB). - const existing = await app.documents(PAGE_UID).findMany({ status: 'published' }); - if (existing.length > 0) { - console.log(`[seed] ${existing.length} published page(s) exist — skipping seed (delete cms/.tmp to reset).`); - return; - } - const tmpDir = path.join(process.cwd(), '.tmp'); mkdirSync(tmpDir, { recursive: true }); @@ -135,116 +54,45 @@ async function main() { }); return uploaded[0].id; }; - const heroImageId = await uploadImage('press-hero.png'); - console.log(`[seed] uploaded hero image id=${heroImageId}`); - // Create the published "Hello from press" home as a layout-system showcase: - // it OPENS with preset-organism.hero (the engine's responsive 2-col grid — - // text 7 / image 5 on md+, stacked below; its media field is the single - // image on the page) and CLOSES with a centered preset-organism.cta banner, - // wrapping a ~72ch prose atom sequence — heading/paragraph/list/quote plus - // structural separator/button/spacer — and the adopter's - // custom-organism.callout. One image, one link target per block, three - // DISTINCT CTA labels: demo content stays deduplicated by design. - const page = await app.documents(PAGE_UID).create({ - data: { - title: 'Hello from press', - slug: SLUG, - body: [ - { - __component: 'preset-organism.hero', - eyebrow: 'Press engine', - title: 'Hello from press', - subtitle: 'A press-powered site, server-rendered end-to-end.', - image: heroImageId, - ctaLabel: 'Read the docs', - ctaHref: REPO_URL, - align: 'left', - }, - { __component: 'preset-atom.heading', text: 'What ships in the box', level: '2' }, - { - __component: 'preset-atom.paragraph', - content: [ - { - type: 'paragraph', - children: [ - { type: 'text', text: 'This prose lives in the CMS and renders as ' }, - { type: 'text', text: 'static HTML', bold: true }, - { type: 'text', text: ' — no client hydration.' }, - ], - }, - ], - }, - { - __component: 'preset-atom.list', - content: [ - { - type: 'list', - format: 'unordered', - children: [ - { - type: 'list-item', - children: [ - { type: 'text', text: 'Atomic text blocks', bold: true }, - { type: 'text', text: ' — paragraph, heading, list and quote.' }, - ], - }, - { - type: 'list-item', - children: [ - { type: 'text', text: 'Media & structure', bold: true }, - { type: 'text', text: ' — image, button, separator and spacer.' }, - ], - }, - { - type: 'list-item', - children: [ - { type: 'text', text: 'Layout organisms', bold: true }, - { type: 'text', text: ' — hero and call-to-action banners composed on the engine’s 12-column grid.' }, - ], - }, - { - type: 'list-item', - children: [ - { type: 'text', text: 'Your own ' }, - { type: 'text', text: 'custom-*', code: true }, - { type: 'text', text: ' blocks, admitted automatically into the page.' }, - ], - }, - ], - }, - ], - }, - { - __component: 'preset-atom.quote', - content: [ - { type: 'paragraph', children: [{ type: 'text', text: 'The contract is HTML on the server.' }] }, - ], - citation: 'The press engine', - }, - { __component: 'preset-atom.separator', variant: 'line' }, - { __component: 'preset-atom.button', label: 'Star on GitHub', href: REPO_URL, variant: 'secondary' }, - // md, not lg: the cta banner below already carries its own section - // margin — a lg spacer stacked on it read as a ~120px dead zone. - { __component: 'preset-atom.spacer', size: 'md' }, - { - __component: 'preset-organism.cta', - title: 'Ready to press publish?', - subtitle: 'Scaffold a site, open the admin, and ship your first page in minutes.', - buttonLabel: 'Scaffold your site', - buttonHref: PRESS_SITE_URL, - align: 'center', - }, - { - __component: 'custom-organism.callout', - message: 'Adopter callout renders via the Project-zone block map', - variant: 'success', - }, - ], - }, - status: 'published', - }); - console.log(`[seed] created published page documentId=${page.documentId} slug=${SLUG}`); + // 1. The published home page (skip-if-any-published-page-exists, unchanged rule). + // Created FIRST (order inverted vs the old header-then-page flow) so its + // documentId exists for pageDefaults' navbar Home item below. + let homeDocumentId; + const existing = await app.documents(PAGE_UID).findMany({ status: 'published' }); + if (existing.length > 0) { + console.log(`[seed] ${existing.length} published page(s) exist — skipping page seed (delete cms/.tmp to reset).`); + homeDocumentId = existing.find((p) => p.slug === SLUG)?.documentId; + } else { + const heroImageId = await uploadImage('press-hero.png'); + console.log(`[seed] uploaded hero image id=${heroImageId}`); + const page = await app.documents(PAGE_UID).create({ + data: { title: 'Hello from press', slug: SLUG, body: buildHomeBody({ heroAssetId: heroImageId }) }, + status: 'published', + }); + homeDocumentId = page.documentId; + console.log(`[seed] created published page documentId=${page.documentId} slug=${SLUG}`); + } + + // 2. Site Settings: identity + theme + demo pageDefaults (idempotent: skip once named). + // The engine's bootstrap already seeded an EMPTY record (Strapi runs plugin + // bootstrap during app.load()), so fill THAT record once. Idempotent: skip + // once an editor set a name, so their values are never clobbered (reset by + // deleting cms/.tmp). + const settings = await app.documents(SITE_SETTING_UID).findFirst(); + if (settings?.name) { + console.log('[seed] site settings already filled — skipping.'); + } else { + const data = { ...SITE_SETTINGS }; + if (homeDocumentId) data.pageDefaults = buildPageDefaults({ homeDocumentId }); + if (settings) { + await app.documents(SITE_SETTING_UID).update({ documentId: settings.documentId, data }); + console.log('[seed] site settings filled (pageDefaults navigation seeded).'); + } else { + await app.documents(SITE_SETTING_UID).create({ data }); + console.log('[seed] site settings created (pageDefaults navigation seeded).'); + } + } } finally { // Content is committed by here. better-sqlite3's tarn pool can throw a benign // "aborted" as it drains during app.destroy() (e.g. background image processing diff --git a/apps/playground/packages/shared/types/generated.ts b/apps/playground/packages/shared/types/generated.ts index 085b1f0..2c7c7af 100644 --- a/apps/playground/packages/shared/types/generated.ts +++ b/apps/playground/packages/shared/types/generated.ts @@ -1,6 +1,8 @@ // AUTO-GENERATED by @ogs-tech/press-web sync-types — DO NOT EDIT. // Regenerate with: pnpm --filter @ogs-tech/press-web sync-types +import type { PressTree } from '@ogs-tech/press-web'; + export interface PressMedia { url: string; width?: number; @@ -10,113 +12,102 @@ export interface PressMedia { mime?: string; } +export interface PressPageRef { + documentId: string; + slug?: string; +} + export interface PresetAtomParagraph { - __component: 'preset-atom.paragraph'; - id: number; - content: unknown; + content: string; } export interface PresetAtomHeading { - __component: 'preset-atom.heading'; - id: number; text: string; level: '1' | '2' | '3' | '4' | '5' | '6'; } export interface PresetAtomList { - __component: 'preset-atom.list'; - id: number; - content: unknown; + content: string; + format?: 'unordered' | 'ordered'; } export interface PresetAtomQuote { - __component: 'preset-atom.quote'; - id: number; - content: unknown; + content: string; citation?: string; } export interface PresetAtomImage { - __component: 'preset-atom.image'; - id: number; image: PressMedia; caption?: string; } export interface PresetAtomButton { - __component: 'preset-atom.button'; - id: number; - label: string; - href: string; - variant: 'primary' | 'secondary'; + link?: PresetMoleculeLink; + variant?: 'primary' | 'secondary'; } export interface PresetAtomSeparator { - __component: 'preset-atom.separator'; - id: number; - variant: 'line' | 'dots'; + variant?: 'line' | 'dots'; } export interface PresetAtomSpacer { - __component: 'preset-atom.spacer'; - id: number; - size: 'sm' | 'md' | 'lg' | 'xl'; + size?: 'sm' | 'md' | 'lg' | 'xl'; +} + +export interface PresetMoleculeLink { + label?: string; + page?: PressPageRef; + url?: string; + newTab?: boolean; +} + +export interface PresetLayoutContainer { + width?: 'prose' | 'lg' | 'full'; + gap?: 'compact' | 'normal' | 'spacious'; + verticalAlign?: 'top' | 'center' | 'bottom'; +} + +export interface PresetLayoutRow { + ratio?: '50-50' | '33-67' | '67-33' | '33-33-33' | '25-25-25-25'; + container?: PresetLayoutContainer; +} + +export interface PresetLayoutColumn { + container?: PresetLayoutContainer; } export interface PresetOrganismHero { - __component: 'preset-organism.hero'; - id: number; eyebrow?: string; title: string; subtitle?: string; image?: PressMedia; - ctaLabel?: string; - ctaHref?: string; + cta?: PresetMoleculeLink; align?: 'left' | 'center'; } export interface PresetOrganismCta { - __component: 'preset-organism.cta'; - id: number; title: string; subtitle?: string; - buttonLabel: string; - buttonHref: string; + button?: PresetMoleculeLink; align?: 'left' | 'center'; } -export interface CustomOrganismCallout { - __component: 'custom-organism.callout'; - id: number; - message: string; - variant?: 'info' | 'warning' | 'success'; -} - export interface PresetOrganismNavbar { - __component: 'preset-organism.navbar'; - id: number; - items?: PresetMoleculeNavItem[]; + items?: PresetMoleculeLink[]; cta?: PresetAtomButton; } export interface PresetOrganismFooter { - __component: 'preset-organism.footer'; - id: number; text?: string; } -export interface PresetMoleculeNavItem { - id: number; - label: string; - url?: string; - newTab?: boolean; +export interface CustomOrganismCallout { + message: string; + variant?: 'info' | 'warning' | 'success'; } -export type PageBody = (PresetAtomParagraph | PresetAtomHeading | PresetAtomList | PresetAtomQuote | PresetAtomImage | PresetAtomButton | PresetAtomSeparator | PresetAtomSpacer | PresetOrganismHero | PresetOrganismCta | CustomOrganismCallout)[]; - -export type HeaderBlocks = (PresetOrganismNavbar | PresetOrganismFooter | PresetAtomParagraph | PresetAtomHeading | PresetAtomList | PresetAtomQuote | PresetAtomImage | PresetAtomButton | PresetAtomSeparator | PresetAtomSpacer | CustomOrganismCallout)[]; - -export type FooterBlocks = (PresetOrganismNavbar | PresetOrganismFooter | PresetAtomParagraph | PresetAtomHeading | PresetAtomList | PresetAtomQuote | PresetAtomImage | PresetAtomButton | PresetAtomSeparator | PresetAtomSpacer | CustomOrganismCallout)[]; +/** The page body IS the composition tree (composition-builder spec §5). */ +export type PageBody = PressTree; export interface Page { id: number; diff --git a/docs/superpowers/plans/2026-07-20-composition-builder.md b/docs/superpowers/plans/2026-07-20-composition-builder.md new file mode 100644 index 0000000..f8ea6cd --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-composition-builder.md @@ -0,0 +1,5020 @@ +# Composition Builder Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the dynamic-zone composition mechanism engine-wide with a JSON `PressTree` stored in a custom page-builder field — per-page layout root (header/footer/children), rows with columns, full recursion, adopter blocks anywhere. + +**Architecture:** `@ogs-tech/press-shared` grows the tree contract + a runtime validator (pure TS) consumed by both sides. `press-cms` swaps `page.body` to a JSON custom field, adds `site-setting.pageDefaults`, keeps components as a pure schema catalog, hydrates media/page refs server-side, and ships a structural tree-editor custom field in the admin. `press-web` replaces `BlockRenderer` with a `TreeRenderer` that maps curated container attrs onto the existing layout primitives. + +**Tech Stack:** Strapi 5 plugin (custom field, db lifecycles), Next.js 15 App Router (RSC), React 18, vitest, pure-TS validator (zero deps). + +**Spec:** `docs/superpowers/specs/2026-07-20-composition-builder-design.md` + +## Decisions locked after spec approval (user-confirmed 2026-07-20) + +These refine the spec and are binding for every task below: + +1. **Curated plain-text content.** The text atoms' `content` attribute becomes Strapi + `type: "text"` (a plain string). Interpretation is plain text: a blank line + separates paragraphs (paragraph/quote); each non-empty line is one item (list). + No inline formatting in v1. The Strapi `blocks` AST leaves the wire entirely: + `renderBlocks` / `blocks-content.tsx` and the `BlocksContent`/`BlocksNode`/`BlocksText` + types are deleted. +2. **Link is a first-class shared descriptor.** New `preset-molecule.link` + (`label` / `page` relation / `url` / `newTab`). Every linking block references it via a + `component:` field: `preset-atom.button` (`href`→`link`), `preset-organism.hero` + (`ctaLabel`/`ctaHref`→`cta`), `preset-organism.cta` (`buttonLabel`/`buttonHref`→`button`), + `preset-organism.navbar` (`items` become `link[]`). `preset-molecule.nav-item` is + DELETED — it was exactly a link. The builder renders a published-pages dropdown for the + `page` relation and stores `{ documentId }`; the cms hydrates `slug` server-side (the + `{ assetId }` media pattern); the web gets one `resolveLink` + `` resolver. +3. **`@ogs-tech/press-shared` becomes a PUBLISHED package.** It is `private: true` today + and consumed type-only. The runtime validator makes it a real dependency of press-web + (adopters install it from npm) — remove `private`, add `publishConfig`, move it to + press-web `dependencies` (`workspace:*`, rewritten to an exact pin at publish). press-cms + keeps it as a devDependency so `strapi-plugin build` BUNDLES the validator into dist + (verified by a grep step in Task 5). +4. **TreeRenderer is layout-by-components.** It emits `
` / `
{children}
` / + `