From d960b1065998215dfc0f11293bdb4100deb296a7 Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Mon, 20 Jul 2026 16:04:43 -0300
Subject: [PATCH 01/31] feat: add preset-organism.columns for editor-composed
multi-column layouts
- Introduced `preset-organism.columns`, allowing editors to create 2-4 column layouts with rich text, images, and buttons.
- Added `preset-molecule.column` as a nested component within `preset-organism.columns`, supporting rich text, optional images, and buttons.
- Implemented deep population in `buildBodyPopulate` to ensure nested components are correctly populated.
- Updated serializers and generators to handle the new two-level nesting without requiring code changes.
- Created a new `Columns` renderer that manages layout and rendering of columns based on specified ratios and gaps.
- Enhanced theme styles to accommodate the new columns layout and button styles.
- Added comprehensive tests for the new columns functionality, ensuring correct rendering and behavior.
---
.changeset/columns-organism.md | 42 ++++++
CLAUDE.md | 17 ++-
apps/playground/packages/cms/scripts/seed.mjs | 45 ++++++
.../packages/shared/types/generated.ts | 18 ++-
packages/cli/templates/cms/scripts/seed.mjs | 45 ++++++
.../src/components/molecules/column.json | 21 +++
.../src/components/organisms/columns.json | 23 ++++
.../server/src/content-types/page/schema.json | 3 +-
.../cms/server/src/lib/dz-populate.test.ts | 15 ++
packages/cms/server/src/lib/dz-populate.ts | 17 ++-
.../server/src/lib/inject-components.test.ts | 44 +++++-
.../cms/server/src/lib/inject-components.ts | 4 +
.../server/src/lib/serialize-schema.test.ts | 80 +++++++++++
packages/web/src/generator/generate.test.ts | 67 +++++++++
packages/web/src/index.ts | 3 +
packages/web/src/organism-blocks.ts | 2 +
packages/web/src/sections/columns.test.ts | 129 ++++++++++++++++++
packages/web/src/sections/columns.tsx | 104 ++++++++++++++
packages/web/src/types/base.ts | 24 ++++
packages/web/theme.css | 64 +++++++--
20 files changed, 749 insertions(+), 18 deletions(-)
create mode 100644 .changeset/columns-organism.md
create mode 100644 packages/cms/server/src/components/molecules/column.json
create mode 100644 packages/cms/server/src/components/organisms/columns.json
create mode 100644 packages/web/src/sections/columns.test.ts
create mode 100644 packages/web/src/sections/columns.tsx
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/CLAUDE.md b/CLAUDE.md
index f8e3806..9389545 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -175,8 +175,19 @@ PRODUCT/plugin id (`plugin::press-cms.*`, `/api/press/schema`), never as a categ
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.
+ - `preset-molecule.column` — nested inside `preset-organism.columns` (rich text +
+ optional image + optional nested `preset-atom.button`, the navbar-cta pattern);
+ never a DZ member.
+ - `preset-organism.*` — hero, cta, columns (page body) **and** navbar, footer
+ (site chrome): one layer, unified from the old `section.*`/`chrome.*` palettes.
+ `columns` is the editor-composed 2–4 column layout block: its controls are flat
+ CLOSED enums (`ratio`/`gap`/`verticalAlign`, the `hero.align` precedent — raw
+ `Span`/`GridGap` scales are never exposed to the CMS); the renderer maps them to
+ the layout primitives. Its per-column `image`/`button` sit TWO levels below the
+ DZ member, past what `populate: '*'` reaches — `buildBodyPopulate`
+ (`lib/dz-populate.ts`) deep-populates that chain explicitly (the navbar-populate
+ situation, body-side); without it they silently vanish from the wire while the
+ admin still shows them.
- `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
@@ -189,7 +200,7 @@ PRODUCT/plugin id (`plugin::press-cms.*`, `/api/press/schema`), never as a categ
- **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;
+ `header`/`footer`); `preset-organism.hero`/`.cta`/`.columns` 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
diff --git a/apps/playground/packages/cms/scripts/seed.mjs b/apps/playground/packages/cms/scripts/seed.mjs
index e4d1694..454c812 100644
--- a/apps/playground/packages/cms/scripts/seed.mjs
+++ b/apps/playground/packages/cms/scripts/seed.mjs
@@ -222,6 +222,51 @@ async function main() {
],
citation: 'The press engine',
},
+ // The editor-composed columns organism: thirds on desktop, stacked on
+ // phones — demonstrates the closed ratio/gap/verticalAlign enums and a
+ // per-column nested button (the navbar.cta pattern).
+ {
+ __component: 'preset-organism.columns',
+ ratio: '33-33-33',
+ gap: 'normal',
+ verticalAlign: 'top',
+ columns: [
+ {
+ content: [
+ {
+ type: 'paragraph',
+ children: [
+ { type: 'text', text: 'Server-rendered', bold: true },
+ { type: 'text', text: ' — every column ships as HTML, zero client JS.' },
+ ],
+ },
+ ],
+ },
+ {
+ content: [
+ {
+ type: 'paragraph',
+ children: [
+ { type: 'text', text: 'Editor-composed', bold: true },
+ { type: 'text', text: ' — 2–4 columns arranged in the admin, no code.' },
+ ],
+ },
+ ],
+ },
+ {
+ content: [
+ {
+ type: 'paragraph',
+ children: [
+ { type: 'text', text: 'Themed automatically', bold: true },
+ { type: 'text', text: ' — pure token consumers, born branded.' },
+ ],
+ },
+ ],
+ button: { label: 'See the engine', href: REPO_URL, variant: 'secondary' },
+ },
+ ],
+ },
{ __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
diff --git a/apps/playground/packages/shared/types/generated.ts b/apps/playground/packages/shared/types/generated.ts
index 085b1f0..0a69f2c 100644
--- a/apps/playground/packages/shared/types/generated.ts
+++ b/apps/playground/packages/shared/types/generated.ts
@@ -85,6 +85,15 @@ export interface PresetOrganismCta {
align?: 'left' | 'center';
}
+export interface PresetOrganismColumns {
+ __component: 'preset-organism.columns';
+ id: number;
+ ratio?: '50-50' | '33-67' | '67-33' | '33-33-33' | '25-25-25-25';
+ gap?: 'compact' | 'normal' | 'spacious';
+ verticalAlign?: 'top' | 'center' | 'bottom';
+ columns?: PresetMoleculeColumn[];
+}
+
export interface CustomOrganismCallout {
__component: 'custom-organism.callout';
id: number;
@@ -105,6 +114,13 @@ export interface PresetOrganismFooter {
text?: string;
}
+export interface PresetMoleculeColumn {
+ id: number;
+ content?: unknown;
+ image?: PressMedia;
+ button?: PresetAtomButton;
+}
+
export interface PresetMoleculeNavItem {
id: number;
label: string;
@@ -112,7 +128,7 @@ export interface PresetMoleculeNavItem {
newTab?: boolean;
}
-export type PageBody = (PresetAtomParagraph | PresetAtomHeading | PresetAtomList | PresetAtomQuote | PresetAtomImage | PresetAtomButton | PresetAtomSeparator | PresetAtomSpacer | PresetOrganismHero | PresetOrganismCta | CustomOrganismCallout)[];
+export type PageBody = (PresetAtomParagraph | PresetAtomHeading | PresetAtomList | PresetAtomQuote | PresetAtomImage | PresetAtomButton | PresetAtomSeparator | PresetAtomSpacer | PresetOrganismHero | PresetOrganismCta | PresetOrganismColumns | CustomOrganismCallout)[];
export type HeaderBlocks = (PresetOrganismNavbar | PresetOrganismFooter | PresetAtomParagraph | PresetAtomHeading | PresetAtomList | PresetAtomQuote | PresetAtomImage | PresetAtomButton | PresetAtomSeparator | PresetAtomSpacer | CustomOrganismCallout)[];
diff --git a/packages/cli/templates/cms/scripts/seed.mjs b/packages/cli/templates/cms/scripts/seed.mjs
index e4d1694..454c812 100644
--- a/packages/cli/templates/cms/scripts/seed.mjs
+++ b/packages/cli/templates/cms/scripts/seed.mjs
@@ -222,6 +222,51 @@ async function main() {
],
citation: 'The press engine',
},
+ // The editor-composed columns organism: thirds on desktop, stacked on
+ // phones — demonstrates the closed ratio/gap/verticalAlign enums and a
+ // per-column nested button (the navbar.cta pattern).
+ {
+ __component: 'preset-organism.columns',
+ ratio: '33-33-33',
+ gap: 'normal',
+ verticalAlign: 'top',
+ columns: [
+ {
+ content: [
+ {
+ type: 'paragraph',
+ children: [
+ { type: 'text', text: 'Server-rendered', bold: true },
+ { type: 'text', text: ' — every column ships as HTML, zero client JS.' },
+ ],
+ },
+ ],
+ },
+ {
+ content: [
+ {
+ type: 'paragraph',
+ children: [
+ { type: 'text', text: 'Editor-composed', bold: true },
+ { type: 'text', text: ' — 2–4 columns arranged in the admin, no code.' },
+ ],
+ },
+ ],
+ },
+ {
+ content: [
+ {
+ type: 'paragraph',
+ children: [
+ { type: 'text', text: 'Themed automatically', bold: true },
+ { type: 'text', text: ' — pure token consumers, born branded.' },
+ ],
+ },
+ ],
+ button: { label: 'See the engine', href: REPO_URL, variant: 'secondary' },
+ },
+ ],
+ },
{ __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
diff --git a/packages/cms/server/src/components/molecules/column.json b/packages/cms/server/src/components/molecules/column.json
new file mode 100644
index 0000000..3af34ff
--- /dev/null
+++ b/packages/cms/server/src/components/molecules/column.json
@@ -0,0 +1,21 @@
+{
+ "collectionName": "components_preset_molecule_columns",
+ "info": {
+ "displayName": "Column",
+ "icon": "stack",
+ "description": "One column of a Columns section: rich text, an optional image, and an optional button"
+ },
+ "options": {},
+ "attributes": {
+ "content": { "type": "blocks" },
+ "image": { "type": "media", "multiple": false, "allowedTypes": ["images"] },
+ "button": { "type": "component", "repeatable": false, "component": "preset-atom.button" }
+ },
+ "config": {
+ "metadatas": {
+ "content": { "edit": { "label": "Content" } },
+ "image": { "edit": { "label": "Image", "description": "Optional image rendered above the button." } },
+ "button": { "edit": { "label": "Button", "description": "Optional call-to-action rendered at the end of the column." } }
+ }
+ }
+}
diff --git a/packages/cms/server/src/components/organisms/columns.json b/packages/cms/server/src/components/organisms/columns.json
new file mode 100644
index 0000000..553a1a2
--- /dev/null
+++ b/packages/cms/server/src/components/organisms/columns.json
@@ -0,0 +1,23 @@
+{
+ "collectionName": "components_preset_organism_columns",
+ "info": {
+ "displayName": "Columns",
+ "icon": "grid",
+ "description": "An editor-composed multi-column section shipped by the press engine: 2-4 columns of rich text, image, and button"
+ },
+ "options": {},
+ "attributes": {
+ "ratio": { "type": "enumeration", "enum": ["50-50", "33-67", "67-33", "33-33-33", "25-25-25-25"], "default": "50-50" },
+ "gap": { "type": "enumeration", "enum": ["compact", "normal", "spacious"], "default": "normal" },
+ "verticalAlign": { "type": "enumeration", "enum": ["top", "center", "bottom"], "default": "top" },
+ "columns": { "type": "component", "repeatable": true, "component": "preset-molecule.column", "min": 2, "max": 4 }
+ },
+ "config": {
+ "metadatas": {
+ "ratio": { "edit": { "label": "Column layout", "description": "How the columns split the width on desktop; every layout stacks on phones." } },
+ "gap": { "edit": { "label": "Gap", "description": "Spacing between columns." } },
+ "verticalAlign": { "edit": { "label": "Vertical alignment", "description": "How columns of different heights line up." } },
+ "columns": { "edit": { "label": "Columns" } }
+ }
+ }
+}
diff --git a/packages/cms/server/src/content-types/page/schema.json b/packages/cms/server/src/content-types/page/schema.json
index 7b5dfce..8c40e07 100644
--- a/packages/cms/server/src/content-types/page/schema.json
+++ b/packages/cms/server/src/content-types/page/schema.json
@@ -26,7 +26,8 @@
"preset-atom.separator",
"preset-atom.spacer",
"preset-organism.hero",
- "preset-organism.cta"
+ "preset-organism.cta",
+ "preset-organism.columns"
]
}
}
diff --git a/packages/cms/server/src/lib/dz-populate.test.ts b/packages/cms/server/src/lib/dz-populate.test.ts
index 02f7710..0fd13f6 100644
--- a/packages/cms/server/src/lib/dz-populate.test.ts
+++ b/packages/cms/server/src/lib/dz-populate.test.ts
@@ -13,6 +13,21 @@ describe('buildBodyPopulate', () => {
});
});
+ it('deep-populates preset-organism.columns, whose column image/button sit two levels down', () => {
+ // `populate: '*'` is SHALLOW: it reaches the repeatable `columns` component
+ // itself but NOT the media/component refs inside each column — the exact
+ // navbar situation in buildChromeDzPopulate. Without this, a column's
+ // image/button is silently absent from the wire while the admin shows it.
+ expect(buildBodyPopulate(['preset-organism.columns', 'preset-organism.hero'])).toEqual({
+ body: {
+ on: {
+ 'preset-organism.columns': { populate: { columns: { populate: { image: true, button: true } } } },
+ 'preset-organism.hero': { populate: '*' },
+ },
+ },
+ });
+ });
+
it('produces an empty `on` map when the dynamic zone has no components', () => {
expect(buildBodyPopulate([])).toEqual({ body: { on: {} } });
});
diff --git a/packages/cms/server/src/lib/dz-populate.ts b/packages/cms/server/src/lib/dz-populate.ts
index 639d783..9fbdb4e 100644
--- a/packages/cms/server/src/lib/dz-populate.ts
+++ b/packages/cms/server/src/lib/dz-populate.ts
@@ -9,10 +9,23 @@
* The component list is passed in (read by the caller from the page content-type
* at request time) so the engine stays generic: it never hardcodes `custom-*`
* block names — only what the registry currently admits.
+ *
+ * EXCEPT `preset-organism.columns`: its per-column `image` media and nested
+ * `button` component sit TWO levels below the DZ member (organism → repeatable
+ * `columns` → media/component), one past what `'*'` reaches — the same shape
+ * that forces the navbar's deep populate in buildChromeDzPopulate. Without
+ * this, a column's image/button silently comes back empty on the wire (the
+ * admin still shows them). `content` is a `blocks` JSON scalar — no populate key.
*/
-export const buildBodyPopulate = (components: string[]): { body: { on: Record } } => ({
+export const buildBodyPopulate = (components: string[]): { body: { on: Record } } => ({
body: {
- on: Object.fromEntries(components.map((uid) => [uid, { populate: '*' as const }])),
+ on: Object.fromEntries(
+ components.map((uid) =>
+ uid === 'preset-organism.columns'
+ ? [uid, { populate: { columns: { populate: { image: true, button: true } } } }]
+ : [uid, { populate: '*' as const }],
+ ),
+ ),
},
});
diff --git a/packages/cms/server/src/lib/inject-components.test.ts b/packages/cms/server/src/lib/inject-components.test.ts
index c905554..03c6452 100644
--- a/packages/cms/server/src/lib/inject-components.test.ts
+++ b/packages/cms/server/src/lib/inject-components.test.ts
@@ -183,8 +183,9 @@ describe('injectComponents', () => {
const expected = [
'preset-atom.paragraph', 'preset-atom.heading', 'preset-atom.list', 'preset-atom.quote',
'preset-atom.image', 'preset-atom.button', 'preset-atom.separator', 'preset-atom.spacer',
- 'preset-molecule.nav-item',
- 'preset-organism.hero', 'preset-organism.cta', 'preset-organism.navbar', 'preset-organism.footer',
+ 'preset-molecule.nav-item', 'preset-molecule.column',
+ 'preset-organism.hero', 'preset-organism.cta', 'preset-organism.columns',
+ 'preset-organism.navbar', 'preset-organism.footer',
'preset-config.seo', 'preset-config.theme-colors', 'preset-config.theme-radius',
'preset-config.cookie-category', 'preset-config.cookie-consent',
];
@@ -248,6 +249,43 @@ describe('injectComponents', () => {
expect(components.get('preset-organism.cta')?.globalId).toBe('ComponentPresetOrganismCta');
});
+ it('injects preset-organism.columns with its nested preset-molecule.column (never a DZ member)', () => {
+ // Same nesting contract as navbar/nav-item: the organism is a DZ block, the
+ // molecule exists only inside it. `columns` nests the repeatable molecule;
+ // the molecule reuses preset-atom.button (the navbar.cta pattern).
+ const components = new Map();
+ const page = pageWithBody(['preset-atom.paragraph']);
+ const contentTypes = new Map([
+ [PAGE_UID, page],
+ [SITE_SETTING_UID, siteSettingWithChrome()],
+ ]);
+ const strapi = {
+ get: (key: string) =>
+ key === 'components' ? components : key === 'content-types' ? contentTypes : undefined,
+ log: { warn() {}, info() {}, debug() {}, error() {} },
+ } as any;
+
+ injectComponents({ strapi });
+ admitCustomBlocks({ strapi });
+
+ expect(components.get('preset-organism.columns')?.category).toBe('preset-organism');
+ expect(components.get('preset-organism.columns')?.globalId).toBe('ComponentPresetOrganismColumns');
+ expect(components.get('preset-organism.columns')?.attributes).toMatchObject({
+ ratio: { type: 'enumeration', enum: ['50-50', '33-67', '67-33', '33-33-33', '25-25-25-25'], default: '50-50' },
+ gap: { type: 'enumeration', enum: ['compact', 'normal', 'spacious'], default: 'normal' },
+ verticalAlign: { type: 'enumeration', enum: ['top', 'center', 'bottom'], default: 'top' },
+ columns: { type: 'component', repeatable: true, component: 'preset-molecule.column' },
+ });
+ expect(components.get('preset-molecule.column')?.category).toBe('preset-molecule');
+ expect(components.get('preset-molecule.column')?.attributes).toMatchObject({
+ content: { type: 'blocks' },
+ image: { type: 'media', multiple: false, allowedTypes: ['images'] },
+ button: { type: 'component', repeatable: false, component: 'preset-atom.button' },
+ });
+ // Nested-only: the molecule never leaks into any Dynamic Zone.
+ expect(page.attributes.body.components).not.toContain('preset-molecule.column');
+ });
+
it('injects the organism chrome (navbar/footer) under category "preset-organism" with a derived globalId', () => {
// navbar/footer are organisms too (unified from the old chrome.* palette); the
// placement split lives in schema.json, not the category.
@@ -277,6 +315,7 @@ describe('page body dynamic zone (static organism admission)', () => {
const components = pageSchema.attributes.body.components as string[];
expect(components).toContain('preset-organism.hero');
expect(components).toContain('preset-organism.cta');
+ expect(components).toContain('preset-organism.columns');
// The atoms remain admitted, unchanged.
expect(components).toContain('preset-atom.paragraph');
expect(components).toContain('preset-atom.image');
@@ -341,6 +380,7 @@ describe('site-setting chrome dynamic zones (static admission)', () => {
expect(components).toContain('preset-atom.button');
expect(components).not.toContain('preset-organism.hero');
expect(components).not.toContain('preset-organism.cta');
+ expect(components).not.toContain('preset-organism.columns');
}
});
diff --git a/packages/cms/server/src/lib/inject-components.ts b/packages/cms/server/src/lib/inject-components.ts
index 4271151..cc704c6 100644
--- a/packages/cms/server/src/lib/inject-components.ts
+++ b/packages/cms/server/src/lib/inject-components.ts
@@ -8,8 +8,10 @@ import buttonSchema from '../components/atoms/button.json';
import separatorSchema from '../components/atoms/separator.json';
import spacerSchema from '../components/atoms/spacer.json';
import navItemSchema from '../components/molecules/nav-item.json';
+import columnSchema from '../components/molecules/column.json';
import heroSchema from '../components/organisms/hero.json';
import ctaSchema from '../components/organisms/cta.json';
+import columnsSchema from '../components/organisms/columns.json';
import navbarSchema from '../components/organisms/navbar.json';
import footerSchema from '../components/organisms/footer.json';
import seoSchema from '../components/config/seo.json';
@@ -75,6 +77,7 @@ const ENGINE_COMPONENTS: Array<{ layer: PresetLayer; name: string; schema: Recor
{ layer: 'atom', name: 'spacer', schema: spacerSchema as Record },
// Molecules — small composed units nested inside organisms (e.g. a navbar's links).
{ layer: 'molecule', name: 'nav-item', schema: navItemSchema as Record },
+ { layer: 'molecule', name: 'column', schema: columnSchema as Record },
// Organisms — composed sections for the page body (hero/cta) and the site chrome
// (navbar/footer). One unified layer (the old section.*/chrome.* palettes); the
// placement split (body vs header/footer) is declared per content-type
@@ -82,6 +85,7 @@ const ENGINE_COMPONENTS: Array<{ layer: PresetLayer; name: string; schema: Recor
// so editors cannot break the chrome.
{ layer: 'organism', name: 'hero', schema: heroSchema as Record },
{ layer: 'organism', name: 'cta', schema: ctaSchema as Record },
+ { layer: 'organism', name: 'columns', schema: columnsSchema as Record },
{ layer: 'organism', name: 'navbar', schema: navbarSchema as Record },
{ layer: 'organism', name: 'footer', schema: footerSchema as Record },
// Config — non-block settings referenced by the Site Settings single type (seo /
diff --git a/packages/cms/server/src/lib/serialize-schema.test.ts b/packages/cms/server/src/lib/serialize-schema.test.ts
index 8e4b272..1effe97 100644
--- a/packages/cms/server/src/lib/serialize-schema.test.ts
+++ b/packages/cms/server/src/lib/serialize-schema.test.ts
@@ -255,3 +255,83 @@ describe('serializeSchema — chrome dynamic zones', () => {
expect(() => serializeSchema(strapi)).toThrow(/plugin::press-cms\.site-setting.*not registered/);
});
});
+
+describe('serializeSchema — two-level nesting (preset-organism.columns)', () => {
+ // The navbar coverage above proves ONE level of nested refs; the columns chain
+ // is the first TWO-level chain (DZ member → repeatable molecule → nested atom).
+ // This pins the BFS as genuinely transitive, not accidentally depth-1.
+ const columnsStrapi = () => {
+ const components = new Map([
+ ['preset-organism.columns', {
+ uid: 'preset-organism.columns',
+ attributes: {
+ ratio: { type: 'enumeration', enum: ['50-50', '33-67', '67-33', '33-33-33', '25-25-25-25'], default: '50-50' },
+ gap: { type: 'enumeration', enum: ['compact', 'normal', 'spacious'], default: 'normal' },
+ verticalAlign: { type: 'enumeration', enum: ['top', 'center', 'bottom'], default: 'top' },
+ columns: { type: 'component', repeatable: true, component: 'preset-molecule.column' },
+ },
+ }],
+ ['preset-molecule.column', {
+ uid: 'preset-molecule.column',
+ attributes: {
+ content: { type: 'blocks' },
+ image: { type: 'media', multiple: false, allowedTypes: ['images'] },
+ button: { type: 'component', repeatable: false, component: 'preset-atom.button' },
+ },
+ }],
+ ['preset-atom.button', {
+ uid: 'preset-atom.button',
+ attributes: {
+ label: { type: 'string', required: true },
+ href: { type: 'string', required: true },
+ variant: { type: 'enumeration', enum: ['primary', 'secondary'], default: 'primary', required: true },
+ },
+ }],
+ ]);
+ const contentTypes: Record = {
+ 'plugin::press-cms.page': {
+ uid: 'plugin::press-cms.page',
+ info: {},
+ attributes: { body: { type: 'dynamiczone', components: ['preset-organism.columns'] } },
+ },
+ 'plugin::press-cms.site-setting': {
+ uid: 'plugin::press-cms.site-setting',
+ info: {},
+ attributes: {
+ header: { type: 'dynamiczone', components: [] },
+ footer: { type: 'dynamiczone', components: [] },
+ },
+ },
+ };
+ return {
+ contentType: (uid: string) => contentTypes[uid],
+ get: (key: string) => (key === 'components' ? components : undefined),
+ } as any;
+ };
+
+ it('follows the chain columns → column → button: all three enter the map from one DZ admission', () => {
+ const out = serializeSchema(columnsStrapi());
+ expect(Object.keys(out.components).sort()).toEqual([
+ 'preset-atom.button', 'preset-molecule.column', 'preset-organism.columns',
+ ]);
+ // The layout enums survive verbatim (KEEP: type/enum/default).
+ expect(out.components['preset-organism.columns'].attributes.ratio).toEqual({
+ type: 'enumeration', enum: ['50-50', '33-67', '67-33', '33-33-33', '25-25-25-25'], default: '50-50',
+ });
+ // The nested refs keep component/repeatable so the generator can type them.
+ expect(out.components['preset-organism.columns'].attributes.columns).toEqual({
+ type: 'component', repeatable: true, component: 'preset-molecule.column',
+ });
+ expect(out.components['preset-molecule.column'].attributes.button).toEqual({
+ type: 'component', repeatable: false, component: 'preset-atom.button',
+ });
+ // min/max are authoring guards, never part of the wire contract (KEEP excludes them).
+ expect(out.components['preset-organism.columns'].attributes.columns).not.toHaveProperty('min');
+ });
+
+ it('fail-fast reaches depth 2: a missing second-level ref (the button) throws', () => {
+ const strapi = columnsStrapi();
+ (strapi.get('components') as Map).delete('preset-atom.button');
+ expect(() => serializeSchema(strapi)).toThrow(/preset-atom\.button.*absent from the components registry/);
+ });
+});
diff --git a/packages/web/src/generator/generate.test.ts b/packages/web/src/generator/generate.test.ts
index 59ed4e5..6dfa952 100644
--- a/packages/web/src/generator/generate.test.ts
+++ b/packages/web/src/generator/generate.test.ts
@@ -288,3 +288,70 @@ describe('generateTypes with chrome organisms (site-setting DZs)', () => {
expect(legacy).toContain('export type PageBody');
});
});
+
+describe('generateTypes with the two-level columns chain (preset-organism.columns)', () => {
+ // The navbar coverage above pins ONE level of nesting; the columns chain is the
+ // first TWO-level one (DZ member → repeatable molecule → nested atom). Pins that
+ // the generator needs zero code change for it (Spec §2 / §7).
+ const schema = {
+ contentTypes: {
+ 'plugin::press-cms.page': {
+ uid: 'plugin::press-cms.page',
+ info: { singularName: 'page' },
+ attributes: {
+ title: { type: 'string', required: true },
+ body: { type: 'dynamiczone', components: ['preset-organism.columns'] },
+ },
+ },
+ },
+ components: {
+ 'preset-organism.columns': {
+ uid: 'preset-organism.columns',
+ attributes: {
+ ratio: { type: 'enumeration', enum: ['50-50', '33-67', '67-33', '33-33-33', '25-25-25-25'], default: '50-50' },
+ gap: { type: 'enumeration', enum: ['compact', 'normal', 'spacious'], default: 'normal' },
+ verticalAlign: { type: 'enumeration', enum: ['top', 'center', 'bottom'], default: 'top' },
+ columns: { type: 'component', repeatable: true, component: 'preset-molecule.column' },
+ },
+ },
+ 'preset-molecule.column': {
+ uid: 'preset-molecule.column',
+ attributes: {
+ content: { type: 'blocks' },
+ image: { type: 'media', multiple: false, allowedTypes: ['images'] },
+ button: { type: 'component', repeatable: false, component: 'preset-atom.button' },
+ },
+ },
+ 'preset-atom.button': {
+ uid: 'preset-atom.button',
+ attributes: {
+ label: { type: 'string', required: true },
+ href: { type: 'string', required: true },
+ variant: { type: 'enumeration', enum: ['primary', 'secondary'], default: 'primary', required: true },
+ },
+ },
+ },
+ };
+
+ const out = generateTypes(schema);
+
+ it('emits the organism with the closed layout enums and the repeatable nested column array', () => {
+ expect(out).toContain("__component: 'preset-organism.columns'");
+ expect(out).toContain("ratio?: '50-50' | '33-67' | '67-33' | '33-33-33' | '25-25-25-25';");
+ expect(out).toContain("gap?: 'compact' | 'normal' | 'spacious';");
+ expect(out).toContain("verticalAlign?: 'top' | 'center' | 'bottom';");
+ expect(out).toContain('columns?: PresetMoleculeColumn[];');
+ });
+
+ it('emits the nested-only column without __component, typing media and the nested button ref', () => {
+ expect(out).toContain('export interface PresetMoleculeColumn {');
+ expect(out).not.toContain("__component: 'preset-molecule.column'");
+ expect(out).toContain('content?: unknown;'); // blocks → unknown fallback (documented)
+ expect(out).toContain('image?: PressMedia;'); // media survives at nesting depth 2
+ expect(out).toContain('button?: PresetAtomButton;');
+ });
+
+ it('keeps the DZ member alone in PageBody — nested components never enter the union', () => {
+ expect(out).toContain('export type PageBody = (PresetOrganismColumns)[];');
+ });
+});
diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts
index 3087e85..d6ce06a 100644
--- a/packages/web/src/index.ts
+++ b/packages/web/src/index.ts
@@ -14,6 +14,7 @@ export { Separator } from './blocks/separator';
export { Spacer } from './blocks/spacer';
export { Hero } from './sections/hero';
export { Cta } from './sections/cta';
+export { Columns } from './sections/columns';
export { Navbar } from './chrome/navbar';
export { Footer } from './chrome/footer';
export { organismBlocks } from './organism-blocks';
@@ -62,6 +63,8 @@ export type {
PresetAtomSpacer,
PresetOrganismHero,
PresetOrganismCta,
+ PresetOrganismColumns,
+ PresetMoleculeColumn,
} from './types/base';
export type {
PressConfig,
diff --git a/packages/web/src/organism-blocks.ts b/packages/web/src/organism-blocks.ts
index f99f1a1..be3f427 100644
--- a/packages/web/src/organism-blocks.ts
+++ b/packages/web/src/organism-blocks.ts
@@ -1,6 +1,7 @@
import type { ComponentType } from 'react';
import { Hero } from './sections/hero';
import { Cta } from './sections/cta';
+import { Columns } from './sections/columns';
import { Navbar } from './chrome/navbar';
import { Footer } from './chrome/footer';
@@ -15,6 +16,7 @@ import { Footer } from './chrome/footer';
export const organismBlocks: Record> = {
'preset-organism.hero': Hero,
'preset-organism.cta': Cta,
+ 'preset-organism.columns': Columns,
'preset-organism.navbar': Navbar,
'preset-organism.footer': Footer,
};
diff --git a/packages/web/src/sections/columns.test.ts b/packages/web/src/sections/columns.test.ts
new file mode 100644
index 0000000..020317b
--- /dev/null
+++ b/packages/web/src/sections/columns.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, it } from 'vitest';
+import { renderToStaticMarkup } from 'react-dom/server';
+import type { PresetMoleculeColumn } from '../types/base';
+import { Columns } from './columns';
+
+// Mirrors the hero contract tests: renderers are called as functions and resolve
+// media absolute against CMS_URL (unset here → engine default).
+const render = (props: Record): string =>
+ renderToStaticMarkup(Columns({ __component: 'preset-organism.columns', id: 1, ...(props as any) }));
+
+const text = (t: string): PresetMoleculeColumn['content'] => [
+ { type: 'paragraph', children: [{ type: 'text', text: t }] },
+];
+
+const col = (overrides: Partial = {}, id = 1): PresetMoleculeColumn =>
+ ({ id, ...overrides }) as PresetMoleculeColumn;
+
+describe('Columns renderer', () => {
+ it('renders nothing when columns is missing or empty (tolerant draft)', () => {
+ expect(render({})).toBe('');
+ expect(render({ columns: [] })).toBe('');
+ });
+
+ it('wraps output in a Container carrying data-block (the Hero shell pattern)', () => {
+ const html = render({ columns: [col({ content: text('a') }, 1), col({ content: text('b') }, 2)] });
+ expect(html.startsWith(' {
+ const html = render({ columns: [col({}, 1), col({}, 2)] });
+ expect(html.match(/--press-col-span:12/g)).toHaveLength(2);
+ expect(html.match(/--press-col-span-md:6/g)).toHaveLength(2);
+ });
+
+ it('maps 33-67 / 67-33 to asymmetric md spans', () => {
+ const narrowWide = render({ ratio: '33-67', columns: [col({}, 1), col({}, 2)] });
+ expect(narrowWide).toContain('--press-col-span-md:4');
+ expect(narrowWide).toContain('--press-col-span-md:8');
+ const wideNarrow = render({ ratio: '67-33', columns: [col({}, 1), col({}, 2)] });
+ expect(wideNarrow.indexOf('--press-col-span-md:8')).toBeLessThan(wideNarrow.indexOf('--press-col-span-md:4'));
+ });
+
+ it('maps 33-33-33 to three md:4 cells', () => {
+ const html = render({ ratio: '33-33-33', columns: [col({}, 1), col({}, 2), col({}, 3)] });
+ expect(html.match(/--press-col-span-md:4/g)).toHaveLength(3);
+ });
+
+ it('25-25-25-25 goes 2×2 on md (span 6) before 4-up on lg (span 3)', () => {
+ const html = render({
+ ratio: '25-25-25-25',
+ columns: [col({}, 1), col({}, 2), col({}, 3), col({}, 4)],
+ });
+ expect(html.match(/--press-col-span-md:6/g)).toHaveLength(4);
+ expect(html.match(/--press-col-span-lg:3/g)).toHaveLength(4);
+ });
+
+ it('renders FEWER columns than the ratio has slots without phantom cells', () => {
+ const html = render({ ratio: '33-33-33', columns: [col({}, 1), col({}, 2)] });
+ expect(html.match(/data-column="cell"/g)).toHaveLength(2);
+ });
+
+ it('a column beyond the ratio slots reuses the last span instead of being dropped', () => {
+ const html = render({ ratio: '50-50', columns: [col({}, 1), col({}, 2), col({}, 3)] });
+ expect(html.match(/data-column="cell"/g)).toHaveLength(3);
+ expect(html.match(/--press-col-span-md:6/g)).toHaveLength(3);
+ });
+
+ it('defaults gap to "normal" (md at every tier) and maps compact/spacious', () => {
+ const two = [col({}, 1), col({}, 2)];
+ expect(render({ columns: two })).toContain('--press-grid-gap-current:var(--press-grid-gap-md)');
+ expect(render({ gap: 'compact', columns: two })).toContain('--press-grid-gap-current:var(--press-grid-gap-sm)');
+ const spacious = render({ gap: 'spacious', columns: two });
+ // Tier-scaled like the Hero: md at base (11-gap floor stays phone-safe), lg from lg up.
+ expect(spacious).toContain('--press-grid-gap-current:var(--press-grid-gap-md)');
+ expect(spacious).toContain('--press-grid-gap-current-lg:var(--press-grid-gap-lg)');
+ });
+
+ it('maps verticalAlign editorial vocabulary to Grid alignItems (default top → start)', () => {
+ const two = [col({}, 1), col({}, 2)];
+ expect(render({ columns: two })).toContain('data-align-items="start"');
+ expect(render({ verticalAlign: 'center', columns: two })).toContain('data-align-items="center"');
+ expect(render({ verticalAlign: 'bottom', columns: two })).toContain('data-align-items="end"');
+ });
+
+ it('renders rich text content via the shared blocks renderer', () => {
+ const html = render({ columns: [col({ content: text('Server-rendered') }, 1), col({}, 2)] });
+ expect(html).toContain('data-column="content"');
+ expect(html).toContain('Server-rendered
');
+ });
+
+ it('resolves the column image absolute against CMS_URL', () => {
+ const html = render({ columns: [col({ image: { url: '/uploads/c.png', alternativeText: 'Chart' } }, 1), col({}, 2)] });
+ expect(html).toContain('src="http://localhost:1337/uploads/c.png"');
+ expect(html).toContain('alt="Chart"');
+ });
+
+ it('renders the button only when BOTH label and href are present, defaulting variant to primary', () => {
+ const withButton = render({
+ columns: [col({ button: { label: 'Go', href: '/go' } as any }, 1), col({}, 2)],
+ });
+ expect(withButton).toContain('data-column="button"');
+ expect(withButton).toContain('data-variant="primary"');
+ expect(withButton).toContain('href="/go"');
+ expect(render({ columns: [col({ button: { label: 'Go' } as any }, 1), col({}, 2)] })).not.toContain('data-column="button"');
+ expect(render({ columns: [col({ button: { href: '/go' } as any }, 1), col({}, 2)] })).not.toContain('data-column="button"');
+ });
+
+ it('an EMPTY column still renders its cell — it holds the track, siblings must not reflow', () => {
+ const html = render({ columns: [col({ content: text('a') }, 1), col({}, 2)] });
+ expect(html.match(/data-column="cell"/g)).toHaveLength(2);
+ expect(html.match(/data-column="content"/g)).toHaveLength(1);
+ });
+
+ it('never renders atom components inside cells (no nested data-block="preset-atom.*")', () => {
+ // The prose-width rule matches `main [data-block^="preset-atom."]` at any
+ // depth — an atom component inside a cell would fight the cell's own width.
+ const html = render({
+ columns: [
+ col({ content: text('a'), image: { url: '/uploads/c.png' }, button: { label: 'Go', href: '/go' } as any }, 1),
+ col({}, 2),
+ ],
+ });
+ expect(html).not.toContain('data-block="preset-atom.');
+ });
+});
diff --git a/packages/web/src/sections/columns.tsx b/packages/web/src/sections/columns.tsx
new file mode 100644
index 0000000..788ceb5
--- /dev/null
+++ b/packages/web/src/sections/columns.tsx
@@ -0,0 +1,104 @@
+import type { Responsive } from '../layout/breakpoints';
+import type { Span } from '../layout/column';
+import type { GridAlignItems, GridGap } from '../layout/grid';
+import type { PresetMoleculeColumn, PresetOrganismColumns } from '../types/base';
+import { Container } from '../layout/container';
+import { Grid } from '../layout/grid';
+import { Column } from '../layout/column';
+import { renderBlocks } from '../blocks/blocks-content';
+
+const CMS_URL = process.env.CMS_URL ?? 'http://localhost:1337';
+
+type Ratio = NonNullable;
+type Gap = NonNullable;
+type VerticalAlign = NonNullable;
+
+/**
+ * CMS ratio → per-slot spans. The editor's vocabulary is a CLOSED enum key the
+ * renderer owns the meaning of — spans can evolve (e.g. better tablet behavior)
+ * without touching stored content. Every layout stacks at base; `25-25-25-25`
+ * goes 2×2 on md before 4-up on lg (4 × span-3 is too tight at 768px).
+ */
+const RATIO_SPANS: Record[]> = {
+ '50-50': [{ base: 12, md: 6 }, { base: 12, md: 6 }],
+ '33-67': [{ base: 12, md: 4 }, { base: 12, md: 8 }],
+ '67-33': [{ base: 12, md: 8 }, { base: 12, md: 4 }],
+ '33-33-33': [{ base: 12, md: 4 }, { base: 12, md: 4 }, { base: 12, md: 4 }],
+ '25-25-25-25': [
+ { base: 12, md: 6, lg: 3 },
+ { base: 12, md: 6, lg: 3 },
+ { base: 12, md: 6, lg: 3 },
+ { base: 12, md: 6, lg: 3 },
+ ],
+};
+
+/**
+ * Semantic gap → GridGap tiers. The raw GridGap scale is never exposed to the
+ * CMS: a 12-track grid always carries 11 interior column-gaps, so a flat 'lg'
+ * (48px) means a 528px floor that overflows phones — 'spacious' therefore uses
+ * the Hero's tier-scaled pattern ('md' at base, 'lg' from lg up).
+ */
+const GAP_TIERS: Record> = {
+ compact: 'sm',
+ normal: 'md',
+ spacious: { base: 'md', lg: 'lg' },
+};
+
+/** CMS editorial vocabulary (top/center/bottom) → Grid's technical axis values. */
+const ALIGN_ITEMS: Record = {
+ top: 'start',
+ center: 'center',
+ bottom: 'end',
+};
+
+function ColumnCell({ column, span }: { column: PresetMoleculeColumn; span: Responsive }) {
+ const hasImage = Boolean(column.image?.url);
+ const hasButton = Boolean(column.button?.label && column.button?.href);
+ return (
+
+ {/* renderBlocks + raw / on purpose — NOT the Paragraph/Image/Button
+ atom components: those emit data-block="preset-atom.*", which the
+ prose-width rule (`main [data-block^="preset-atom."]` in theme.css)
+ matches at ANY depth and would fight the cell's own span/width. Same
+ reason the navbar inlines its cta anchor. */}
+ {column.content?.length ? {renderBlocks(column.content)}
: null}
+ {hasImage ? (
+
+ ) : null}
+ {hasButton ? (
+
+ {column.button!.label}
+
+ ) : null}
+
+ );
+}
+
+/**
+ * Engine organism `preset-organism.columns` — an editor-composed 2–4 column
+ * section built on Container/Grid/Column, mirroring the Hero pattern. The
+ * layout controls (`ratio`/`gap`/`verticalAlign`) are closed CMS enums mapped
+ * to primitive props above.
+ *
+ * Tolerant, mirroring hero/cta: no columns renders nothing; an EMPTY column
+ * still renders its cell (it holds the ratio's track — dropping it would
+ * reflow siblings); a column beyond the ratio's slots reuses the last slot's
+ * span and wraps to the next row rather than being discarded.
+ */
+export function Columns({ ratio, gap, verticalAlign, columns }: PresetOrganismColumns) {
+ if (!columns?.length) return null;
+ const spans = RATIO_SPANS[ratio ?? '50-50'] ?? RATIO_SPANS['50-50'];
+ return (
+
+
+ {columns.map((col, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/packages/web/src/types/base.ts b/packages/web/src/types/base.ts
index 8544407..a9c2b52 100644
--- a/packages/web/src/types/base.ts
+++ b/packages/web/src/types/base.ts
@@ -132,6 +132,30 @@ export interface PresetOrganismCta {
align?: 'left' | 'center';
}
+/**
+ * Molecule `preset-molecule.column` — nested-only inside preset-organism.columns
+ * (mirrors cms molecules/column.json). Never a DZ member; the wire omits
+ * `__component` on nested component instances (the navbar `cta` situation), but
+ * the shared PresetAtomButton shape is kept for consistency with the generated
+ * types — the renderer reads only label/href/variant.
+ */
+export interface PresetMoleculeColumn {
+ id: number;
+ content?: BlocksContent;
+ image?: PressMedia;
+ button?: PresetAtomButton;
+}
+
+/** Organism `preset-organism.columns` — engine-owned (mirrors cms organisms/columns.json). */
+export interface PresetOrganismColumns {
+ __component: 'preset-organism.columns';
+ id: number;
+ ratio?: '50-50' | '33-67' | '67-33' | '33-33-33' | '25-25-25-25';
+ gap?: 'compact' | 'normal' | 'spacious';
+ verticalAlign?: 'top' | 'center' | 'bottom';
+ columns?: PresetMoleculeColumn[];
+}
+
/**
* Structural shape every dynamic-zone entry satisfies. The renderer only reads
* `__component`/`id` and spreads the rest, so the generic envelope is enough for
diff --git a/packages/web/theme.css b/packages/web/theme.css
index 00d25f1..fcc4d49 100644
--- a/packages/web/theme.css
+++ b/packages/web/theme.css
@@ -213,7 +213,8 @@ h6[data-block="preset-atom.heading"] { font-size: var(--press-text-body); }
/* Button family — ONE visual contract for every button-shaped link the engine
renders: the button atom, the navbar CTA (desktop bar + mobile drawer), the
- hero CTA, and the cta-banner button. The always-present transparent border
+ hero CTA, the cta-banner button, and the per-column button of
+ preset-organism.columns. The always-present transparent border
keeps the filled and outlined voices the same height. The secondary border
rides on currentColor — it self-themes with ANY adopter palette (a neutral
--press-color-border stroke read as unfinished next to primary-colored
@@ -224,7 +225,8 @@ h6[data-block="preset-atom.heading"] { font-size: var(--press-text-body); }
[data-block="preset-atom.button"] a,
[data-block="preset-organism.navbar"] [data-navbar="cta"],
[data-block="preset-organism.hero"] [data-hero="cta"],
-[data-block="preset-organism.cta"] [data-cta="button"] {
+[data-block="preset-organism.cta"] [data-cta="button"],
+[data-block="preset-organism.columns"] [data-column="button"] {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -244,40 +246,47 @@ h6[data-block="preset-atom.heading"] { font-size: var(--press-text-body); }
[data-block="preset-atom.button"] a[data-variant="primary"],
[data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="primary"],
[data-block="preset-organism.hero"] [data-hero="cta"],
-[data-block="preset-organism.cta"] [data-cta="button"] {
+[data-block="preset-organism.cta"] [data-cta="button"],
+[data-block="preset-organism.columns"] [data-column="button"][data-variant="primary"] {
background: var(--press-color-primary);
color: var(--press-color-on-primary);
}
[data-block="preset-atom.button"] a[data-variant="primary"]:hover,
[data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="primary"]:hover,
[data-block="preset-organism.hero"] [data-hero="cta"]:hover,
-[data-block="preset-organism.cta"] [data-cta="button"]:hover {
+[data-block="preset-organism.cta"] [data-cta="button"]:hover,
+[data-block="preset-organism.columns"] [data-column="button"][data-variant="primary"]:hover {
background: color-mix(in srgb, var(--press-color-primary) 85%, var(--press-color-ink));
}
[data-block="preset-atom.button"] a[data-variant="primary"]:active,
[data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="primary"]:active,
[data-block="preset-organism.hero"] [data-hero="cta"]:active,
-[data-block="preset-organism.cta"] [data-cta="button"]:active {
+[data-block="preset-organism.cta"] [data-cta="button"]:active,
+[data-block="preset-organism.columns"] [data-column="button"][data-variant="primary"]:active {
background: color-mix(in srgb, var(--press-color-primary) 72%, var(--press-color-ink));
}
[data-block="preset-atom.button"] a[data-variant="secondary"],
-[data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="secondary"] {
+[data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="secondary"],
+[data-block="preset-organism.columns"] [data-column="button"][data-variant="secondary"] {
background: transparent;
color: var(--press-color-primary);
border-color: currentColor;
}
[data-block="preset-atom.button"] a[data-variant="secondary"]:hover,
-[data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="secondary"]:hover {
+[data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="secondary"]:hover,
+[data-block="preset-organism.columns"] [data-column="button"][data-variant="secondary"]:hover {
background: color-mix(in srgb, currentColor 10%, transparent);
}
[data-block="preset-atom.button"] a[data-variant="secondary"]:active,
-[data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="secondary"]:active {
+[data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="secondary"]:active,
+[data-block="preset-organism.columns"] [data-column="button"][data-variant="secondary"]:active {
background: color-mix(in srgb, currentColor 18%, transparent);
}
[data-block="preset-atom.button"] a:focus-visible,
[data-block="preset-organism.navbar"] [data-navbar="cta"]:focus-visible,
[data-block="preset-organism.hero"] [data-hero="cta"]:focus-visible,
-[data-block="preset-organism.cta"] [data-cta="button"]:focus-visible {
+[data-block="preset-organism.cta"] [data-cta="button"]:focus-visible,
+[data-block="preset-organism.columns"] [data-column="button"]:focus-visible {
outline: 2px solid var(--press-color-primary);
outline-offset: 2px;
}
@@ -358,6 +367,43 @@ h6[data-block="preset-atom.heading"] { font-size: var(--press-text-body); }
}
/* [data-cta="button"] is styled by the shared button family (always primary). */
+/* Engine section: preset-organism.columns — an editor-composed 2–4 column
+ layout on the 12-track grid (ratio/gap/verticalAlign are closed CMS enums
+ resolved by the renderer into the layout primitives' vars). Pure token
+ consumer (Spec §5.2). */
+[data-block="preset-organism.columns"] {
+ margin-block: var(--press-space-7);
+}
+/* Rhythm inside a cell: content / image / button stack with one gap, whatever
+ subset the editor filled in. */
+[data-block="preset-organism.columns"] [data-column="cell"] > * + * {
+ margin-top: var(--press-space-4);
+}
+[data-block="preset-organism.columns"] [data-column="content"] p {
+ margin: 0;
+ font-size: var(--press-text-body);
+ line-height: 1.7;
+}
+[data-block="preset-organism.columns"] [data-column="content"] p + p {
+ margin-top: var(--press-space-4);
+}
+[data-block="preset-organism.columns"] [data-column="content"] ul,
+[data-block="preset-organism.columns"] [data-column="content"] ol {
+ margin: 0;
+ padding-left: var(--press-space-5);
+}
+[data-block="preset-organism.columns"] [data-column="content"] a {
+ color: var(--press-color-primary);
+ text-underline-offset: 2px;
+}
+[data-block="preset-organism.columns"] [data-column="image"] {
+ display: block;
+ width: 100%;
+ height: auto;
+ border-radius: var(--press-radius-md);
+}
+/* [data-column="button"] is styled by the shared button family (variant-aware). */
+
/* Engine plugin: cookie-consent — the consent banner (cookie-consent Spec §5).
Fixed bottom bar, 1px stroke over shadow, pure token consumer. The html
attribute rule hides the banner BEFORE first paint for a visitor whose
From fbcec585014da30dad580769fc28f2ec1fbdb6b4 Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Mon, 20 Jul 2026 16:52:58 -0300
Subject: [PATCH 02/31] docs: composition-builder design spec (JSON tree
builder replaces DZ mechanism)
Co-Authored-By: Claude Fable 5
---
.../2026-07-20-composition-builder-design.md | 241 ++++++++++++++++++
1 file changed, 241 insertions(+)
create mode 100644 docs/superpowers/specs/2026-07-20-composition-builder-design.md
diff --git a/docs/superpowers/specs/2026-07-20-composition-builder-design.md b/docs/superpowers/specs/2026-07-20-composition-builder-design.md
new file mode 100644
index 0000000..bc1556c
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-20-composition-builder-design.md
@@ -0,0 +1,241 @@
+# Composition Builder — design spec
+
+Date: 2026-07-20
+Status: approved direction, pre-implementation
+Breaking: YES — replaces the dynamic-zone composition mechanism engine-wide (pre-release; authorized)
+
+## 1. Problem
+
+Page composition today is a flat Strapi dynamic zone. Editors cannot express layout
+structure beyond the closed `preset-organism.columns` block, because Strapi 5 forbids
+dynamic zones inside components — polymorphic children are only legal on content-types.
+The product vision requires: a per-page layout root (header/footer/children, so pages
+can drop or replace chrome), rows with N arbitrary children, columns that nest rows
+(full recursion), and adopter custom blocks usable anywhere in that tree.
+
+## 2. Decisions (brainstorm outcomes)
+
+| Decision | Choice |
+| --- | --- |
+| Scope | One spec, complete refactor of the composition mechanism (body + chrome) |
+| Architecture family | Custom page-builder field storing a JSON tree (family C) |
+| Recursion | Full — Row inside Column, unbounded depth |
+| v1 admin UX | Structural: collapsible tree + per-node forms (no WYSIWYG canvas) |
+| Block schema source | Registry-driven: Strapi components remain the schema catalog |
+| Responsive control | Curated presets only (`ratio`/`gap`/`width`/`verticalAlign`); engine maps to base/md/lg |
+| Site chrome | Site Settings header/footer DZs die; `pageDefaults` JSON holds the default chrome pages inherit |
+| Compatibility | Breaking wire + palette change; no data migration (pre-release) |
+
+## 3. Data model — `PressTree` (`@ogs-tech/press-shared`)
+
+```ts
+interface PressTree { version: 1; root: LayoutNode }
+
+interface LayoutNode { type: 'layout'; header: Slot; footer: Slot; children: Node[] }
+
+type Slot =
+ | { mode: 'inherit' } // resolve against Site Settings pageDefaults
+ | { mode: 'none' } // bare page
+ | { mode: 'custom'; children: Node[] }; // page-owned chrome
+
+type Node = RowNode | ColumnNode | BlockNode;
+
+interface RowNode {
+ id: string; // stable node id, generated by the builder UI
+ type: 'row';
+ width: 'prose' | 'lg' | 'full'; // Container tier at top level; ignored when nested
+ ratio: Ratio; // closed enum, the current columns scale
+ gap: Gap; // 'compact' | 'normal' | 'spacious'
+ verticalAlign: VerticalAlign; // 'top' | 'center' | 'bottom'
+ children: ColumnNode[]; // 1..4, ratio-bound
+}
+
+interface ColumnNode { id: string; type: 'column'; children: Node[] } // recursion point
+
+interface BlockNode {
+ id: string;
+ type: 'block';
+ component: string; // palette uid, e.g. 'preset-atom.paragraph', 'custom-organism.callout'
+ data: Record; // validated against the component's registry schema
+}
+```
+
+Rules baked into the model:
+
+- **Responsiveness never appears in the JSON.** `ratio`/`gap`/`width` are editorial
+ intents; the web renderer maps them to `Responsive` values (the `RATIO_SPANS` /
+ `GAP_TIERS` maps inherited from `sections/columns.tsx`). The 11-gap floor and the
+ 25-25-25-25 two-stage md/lg behavior stay engine-owned and untouchable by editors.
+- **Media inside `data` is a reference, not a snapshot**: `{ assetId: number }`. The
+ cms plugin hydrates url/alt/dimensions server-side when serving pages and site
+ settings alike, so the wire
+ always carries fresh asset URLs and the JSON never rots.
+- **`id` is for React keys and builder operations only** — never an `Entity`; block
+ instances stay out of the URN system (same rationale as `blockKey` today).
+- `version` gates future migrations; readers reject unknown versions (fail-to-empty).
+
+### press-shared changes nature
+
+`press-shared` today is imported type-only. It now additionally ships the **runtime
+tree validator** (pure TS, no deps) used by both sides: cms compiles it into its dist
+(save-time validation), web consumes it as source (render-time guard). This is a
+deliberate contract change: shared = wire types + pure wire validators, still zero
+Strapi/Next imports.
+
+## 4. CMS side (`@ogs-tech/press-cms`)
+
+### Storage
+
+- `page.body`: dynamic zone → **custom field** (`plugin::press-cms.builder`, storage
+ type `json`) holding a `PressTree`. Draft & publish is untouched — the tree versions
+ with the page entry.
+- `site-setting.pageDefaults`: new JSON field `{ header: Node[]; footer: Node[] }` —
+ the default chrome every page inherits. The `header`/`footer` DZs are removed.
+ Identity, SEO, theme values, cookie consent stay exactly as they are.
+
+### Components become a schema catalog
+
+- `preset-atom.*`, `preset-organism.*` (hero, cta, navbar, footer) and `custom-*`
+ stay registered via `injectComponents`, but no dynamic zone references them. They
+ are now pure schema descriptors: the builder generates per-block forms from them,
+ and the type-sync generator keeps emitting per-block `data` interfaces from them.
+- `preset-layout` finally gains components: **`preset-layout.row`** and
+ **`preset-layout.column`** — schema descriptors for the layout nodes' own forms
+ (the `ratio`/`gap`/`width`/`verticalAlign` enums live there), so layout-node forms
+ are registry-driven exactly like block forms.
+- **Removed:** `preset-organism.columns`, `preset-molecule.column` (superseded by the
+ mechanism), all DZ admission arrays in `page`/`site-setting` schema.json, and
+ `dz-populate.ts` entirely — JSON needs no populate, which kills the whole
+ "vanished from the wire but visible in the admin" bug class.
+- `admitCustomBlocks` shrinks to custom-block *discovery*: it feeds the `custom-*`
+ catalog instead of pushing uids into DZ arrays (category labels remain the admin
+ bundle's `registerTrads` job, unchanged). The adopter
+ contract is unchanged: drop a component JSON under `src/components/custom-/`
+ and it appears in the builder's block palette on every slot.
+- `serialize-schema` serves the full registered palette (it can no longer walk DZ
+ admission lists) plus the tree contract version, still at `GET /api/press/schema`.
+
+### Admin custom field (v1, structural)
+
+- Registered via `app.customFields.register` in the plugin's `strapi-admin` bundle.
+- Collapsible tree editor: add / remove / reorder / indent-outdent nodes via buttons;
+ per-node form generated from the registry schema; media picked with Strapi's native
+ asset picker (stores `assetId`); node ids minted client-side (`crypto.randomUUID`).
+- Structural invariants enforced by construction in the UI: Column only under Row,
+ Row children are Columns only, slot editors accept `Node[]`.
+- Site Settings `pageDefaults` reuses the same field component scoped to a single
+ `Node[]` slot (no layout root).
+
+### Validation & seeds
+
+- Lifecycle `beforeCreate`/`beforeUpdate` on `page` and `site-setting` run the shared
+ validator; invalid trees are rejected with actionable messages (the UI should make
+ this unreachable; the lifecycle is the backstop for API writes).
+- `bootstrap()` seeds `pageDefaults` once (plugin-store flag `pageDefaultsSeeded`)
+ with bare navbar/footer block nodes; an editor-emptied slot is respected forever.
+ The flag is not set when the Site Settings record is missing (self-healing order,
+ the `seedCookieConsent` precedent).
+- The CLI `seed.mjs` writes the demo home as a `PressTree` (hero, prose blocks, a
+ 50-50 row demonstrating recursion, cta, custom callout) and fills demo navigation
+ into `pageDefaults` in the same idempotent pass that fills identity.
+
+## 5. Web side (`@ogs-tech/press-web`)
+
+### TreeRenderer replaces BlockRenderer
+
+- `LayoutNode` → emits `` (resolved slot), `` (children), ``.
+- `RowNode` at top level → ``;
+ nested inside a Column → bare `` filling the cell.
+- `ColumnNode` → `` with spans derived from the parent row's `ratio`
+ preset; extra columns beyond the ratio's slots reuse the last span (current
+ columns tolerance rules carry over).
+- `BlockNode` → the existing registry merge, unchanged:
+ `{ ...atomBlocks, ...organismBlocks, ...customBlocks }`; adopter overrides via
+ `components={{ 'preset-organism.hero': MyHero }}` keep working verbatim; unknown
+ component → skip + dev-only warning.
+
+### Host template
+
+- `RootLayout` drops ``/`` (it cannot see the slug); keeps html/head,
+ theme style injection, consent bootstrap, cookie banner.
+- `app/[[...slug]]/page.tsx` fetches page + site config, resolves `inherit` slots
+ against `site.pageDefaults`, renders the tree. ISR semantics (revalidate 60,
+ generateStaticParams) unchanged.
+- Navbar/footer block instances — wherever they sit (defaults or custom slots) — are
+ still hydrated by `mapSiteSettings` (brand + resolved links from identity).
+
+### Type-sync
+
+- Tree node types are static exports of `press-shared` (not generated).
+- The generator keeps emitting per-block `data` interfaces from the schema; `PageBody`
+ becomes `PressTree`; the `HeaderBlocks`/`FooterBlocks` unions die.
+
+### Load-bearing CSS change
+
+The global prose-width rule currently matches preset/custom atoms at any depth under
+`main`. Atoms inside Column cells must fill their cell, so the rule is rescoped to
+direct children of `main` only (`main > [data-block^="preset-atom."]`, same for
+`custom-atom.`) — top-level BlockNodes render as direct `` children, so the
+editorial prose column survives exactly where it applies today. This
+replaces the old workaround where `columns.tsx` avoided atom components entirely —
+in the tree world, cells render the real atom components.
+
+## 6. Settings behavior (inheritance semantics)
+
+1. **Editing** — Site Settings shows the two `pageDefaults` slots with the same tree
+ UI and full palette; no special rules.
+2. **Inheritance** — `mode: 'inherit'` resolves at render via `getSiteConfig`
+ (ISR ~60s): editing the default header updates every inheriting page, no redeploy,
+ no per-page touch. `none`/`custom` pages are unaffected.
+3. **Failure** — CMS unreachable or malformed `pageDefaults` → treated as empty →
+ inheriting pages render bare (unbranded over synthetic, never crash).
+4. **Emptied is respected** — the engine never re-seeds an editor-emptied slot.
+5. **Out of scope for v1** — `pageDefaults` carries chrome only; a default *body*
+ template for new pages is a named future seam (that is where page templates and
+ the reserved `preset-template` layer will plug in).
+
+## 7. Error handling
+
+| Failure | Behavior |
+| --- | --- |
+| Malformed / wrong-version tree on the wire | Body renders empty + dev warning (fail-to-empty; never crash) |
+| Unknown `component` uid in a BlockNode | Skip node + dev-only warning (BlockRenderer precedent) |
+| Unknown node `type` | Skip subtree + dev warning |
+| Slot resolution failure | Treat as `none` |
+| Invalid tree submitted via API | Lifecycle validator rejects with actionable message |
+| CMS down at build | Static params fail to empty; pages render on demand (ISR unchanged) |
+
+## 8. Testing
+
+- **press-shared** — validator unit tests: valid/invalid fixtures, version gate,
+ slot modes, deeply recursive trees (recursion is unbounded and must validate),
+ ratio/children arity.
+- **press-web** — TreeRenderer: recursion, ratio/gap/width mappings (including the
+ spacious tier-scaling and 25-25-25-25 two-stage behavior), slot resolution matrix
+ (inherit/none/custom × defaults present/absent), tolerance rules, the rescoped
+ prose selector; generator tests updated for `PressTree`.
+- **press-cms** — serialize-schema over the full palette; lifecycle validation;
+ `pageDefaults` seeding flag + idempotency; admin tree editor smoke tests with the
+ hand-rolled `act()`+`createRoot` harness (react-19 RTL constraint).
+- **CLI** — assert the seeded home's tree shape (closes the missing seed-regression
+ guard found during exploration).
+
+## 9. Out of scope (named, deliberate)
+
+- WYSIWYG canvas / drag-and-drop builder (v2 evolution on the same JSON model).
+- Per-breakpoint editor controls (curated presets only).
+- Page templates / default body trees in `pageDefaults` (future seam, `preset-template`).
+- i18n of tree content.
+- Reusable cross-page fragments (would require content-type-backed nodes).
+
+## 10. Impact & sequencing
+
+Estimated effort 3–5 weeks. Changesets: major on `press-shared`, `press-cms`,
+`press-web`, `create-press`. Implementation phases for the plan:
+
+1. `press-shared`: tree contract + validator.
+2. `press-cms`: storage swap, catalog serialization, lifecycle validation, seeds.
+3. `press-cms` admin: builder custom field (tree + forms + asset picker).
+4. `press-web`: TreeRenderer, host template, type-sync, CSS rescope.
+5. CLI seed + playground regeneration; kill list cleanup; CLAUDE.md rewrite of the
+ affected sections.
From 71f32ec25ff028faf40be141255e46fdca044386 Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Mon, 20 Jul 2026 17:03:44 -0300
Subject: [PATCH 03/31] docs(spec): unify layout-node attrs into one shared
curated container group
Co-Authored-By: Claude Fable 5
---
.../2026-07-20-composition-builder-design.md | 82 +++++++++++++------
1 file changed, 57 insertions(+), 25 deletions(-)
diff --git a/docs/superpowers/specs/2026-07-20-composition-builder-design.md b/docs/superpowers/specs/2026-07-20-composition-builder-design.md
index bc1556c..adfc447 100644
--- a/docs/superpowers/specs/2026-07-20-composition-builder-design.md
+++ b/docs/superpowers/specs/2026-07-20-composition-builder-design.md
@@ -22,7 +22,8 @@ can drop or replace chrome), rows with N arbitrary children, columns that nest r
| Recursion | Full — Row inside Column, unbounded depth |
| v1 admin UX | Structural: collapsible tree + per-node forms (no WYSIWYG canvas) |
| Block schema source | Registry-driven: Strapi components remain the schema catalog |
-| Responsive control | Curated presets only (`ratio`/`gap`/`width`/`verticalAlign`); engine maps to base/md/lg |
+| Responsive control | Curated presets only; engine maps to base/md/lg |
+| Container attrs | One shared curated `container` group (`width`/`gap`/`verticalAlign`) on every children-bearing node; `ratio` stays Row-only |
| Site chrome | Site Settings header/footer DZs die; `pageDefaults` JSON holds the default chrome pages inherit |
| Compatibility | Breaking wire + palette change; no data migration (pre-release) |
@@ -31,7 +32,22 @@ can drop or replace chrome), rows with N arbitrary children, columns that nest r
```ts
interface PressTree { version: 1; root: LayoutNode }
-interface LayoutNode { type: 'layout'; header: Slot; footer: Slot; children: Node[] }
+interface LayoutNode {
+ type: 'layout';
+ header: Slot;
+ footer: Slot;
+ container?: ContainerAttrs; // only `gap` applies: rhythm between top-level children
+ children: Node[];
+}
+
+// The ONE attribute surface for every children-bearing node. Closed enums; an
+// absent field (or the whole group absent) means the engine default. Attrs that
+// don't apply to a node type are ignored by the renderer and hidden by the form.
+interface ContainerAttrs {
+ width?: 'prose' | 'lg' | 'full'; // Container tier — top-level Rows only; ignored when nested
+ gap?: Gap; // 'compact' | 'normal' | 'spacious' — Row: track gap; Column/Layout: stack rhythm
+ verticalAlign?: VerticalAlign; // 'top' | 'center' | 'bottom' — Row: aligns cells; Column: content within the cell
+}
type Slot =
| { mode: 'inherit' } // resolve against Site Settings pageDefaults
@@ -43,14 +59,12 @@ type Node = RowNode | ColumnNode | BlockNode;
interface RowNode {
id: string; // stable node id, generated by the builder UI
type: 'row';
- width: 'prose' | 'lg' | 'full'; // Container tier at top level; ignored when nested
- ratio: Ratio; // closed enum, the current columns scale
- gap: Gap; // 'compact' | 'normal' | 'spacious'
- verticalAlign: VerticalAlign; // 'top' | 'center' | 'bottom'
+ ratio: Ratio; // Row-only: defines the column split (closed enum, the current columns scale)
+ container?: ContainerAttrs;
children: ColumnNode[]; // 1..4, ratio-bound
}
-interface ColumnNode { id: string; type: 'column'; children: Node[] } // recursion point
+interface ColumnNode { id: string; type: 'column'; container?: ContainerAttrs; children: Node[] } // recursion point
interface BlockNode {
id: string;
@@ -62,10 +76,15 @@ interface BlockNode {
Rules baked into the model:
-- **Responsiveness never appears in the JSON.** `ratio`/`gap`/`width` are editorial
- intents; the web renderer maps them to `Responsive` values (the `RATIO_SPANS` /
- `GAP_TIERS` maps inherited from `sections/columns.tsx`). The 11-gap floor and the
- 25-25-25-25 two-stage md/lg behavior stay engine-owned and untouchable by editors.
+- **One attribute surface, not per-node bespoke props.** Every children-bearing node
+ carries the same optional `container` group; the builder renders it as one reusable
+ form section, and defaults live in one place. `ratio` is the deliberate exception —
+ it defines the column split, so it cannot generalize beyond Row.
+- **Responsiveness never appears in the JSON.** `ratio` and the `container` attrs are
+ editorial intents; the web renderer maps them to `Responsive` values (the
+ `RATIO_SPANS` / `GAP_TIERS` maps inherited from `sections/columns.tsx`). The 11-gap
+ floor and the 25-25-25-25 two-stage md/lg behavior stay engine-owned and
+ untouchable by editors.
- **Media inside `data` is a reference, not a snapshot**: `{ assetId: number }`. The
cms plugin hydrates url/alt/dimensions server-side when serving pages and site
settings alike, so the wire
@@ -99,10 +118,13 @@ Strapi/Next imports.
stay registered via `injectComponents`, but no dynamic zone references them. They
are now pure schema descriptors: the builder generates per-block forms from them,
and the type-sync generator keeps emitting per-block `data` interfaces from them.
-- `preset-layout` finally gains components: **`preset-layout.row`** and
- **`preset-layout.column`** — schema descriptors for the layout nodes' own forms
- (the `ratio`/`gap`/`width`/`verticalAlign` enums live there), so layout-node forms
- are registry-driven exactly like block forms.
+- `preset-layout` finally gains components: **`preset-layout.container`** — the shared
+ `ContainerAttrs` descriptor (`width`/`gap`/`verticalAlign` enums) — plus
+ **`preset-layout.row`** (the `ratio` enum + a `container` component field) and
+ **`preset-layout.column`** (a `container` component field). Row and column reference
+ the shared descriptor via `component:` fields (the `nav-item` nesting pattern), so
+ layout-node forms are registry-driven exactly like block forms and the container
+ section is defined once.
- **Removed:** `preset-organism.columns`, `preset-molecule.column` (superseded by the
mechanism), all DZ admission arrays in `page`/`site-setting` schema.json, and
`dz-populate.ts` entirely — JSON needs no populate, which kills the whole
@@ -119,8 +141,11 @@ Strapi/Next imports.
- Registered via `app.customFields.register` in the plugin's `strapi-admin` bundle.
- Collapsible tree editor: add / remove / reorder / indent-outdent nodes via buttons;
- per-node form generated from the registry schema; media picked with Strapi's native
- asset picker (stores `assetId`); node ids minted client-side (`crypto.randomUUID`).
+ per-node form generated from the registry schema — the "Container" section is
+ generated once from `preset-layout.container` and reused on every children-bearing
+ node, showing only the attrs that apply to that node type; media picked with
+ Strapi's native asset picker (stores `assetId`); node ids minted client-side
+ (`crypto.randomUUID`).
- Structural invariants enforced by construction in the UI: Column only under Row,
Row children are Columns only, slot editors accept `Node[]`.
- Site Settings `pageDefaults` reuses the same field component scoped to a single
@@ -143,12 +168,16 @@ Strapi/Next imports.
### TreeRenderer replaces BlockRenderer
-- `LayoutNode` → emits `` (resolved slot), `` (children), ``.
-- `RowNode` at top level → ``;
- nested inside a Column → bare `` filling the cell.
+- `LayoutNode` → emits `` (resolved slot), `` (children), ``;
+ its `container.gap` sets the vertical rhythm between top-level children.
+- `RowNode` at top level → `` from the container attrs; nested inside a Column → bare ``
+ filling the cell (`width` ignored). An absent group or attr resolves to the same
+ engine defaults the columns organism uses today.
- `ColumnNode` → `` with spans derived from the parent row's `ratio`
preset; extra columns beyond the ratio's slots reuse the last span (current
- columns tolerance rules carry over).
+ columns tolerance rules carry over). Its own `container` styles the cell: `gap`
+ is the stack rhythm, `verticalAlign` places content within the cell height.
- `BlockNode` → the existing registry merge, unchanged:
`{ ...atomBlocks, ...organismBlocks, ...customBlocks }`; adopter overrides via
`components={{ 'preset-organism.hero': MyHero }}` keep working verbatim; unknown
@@ -201,6 +230,7 @@ in the tree world, cells render the real atom components.
| Malformed / wrong-version tree on the wire | Body renders empty + dev warning (fail-to-empty; never crash) |
| Unknown `component` uid in a BlockNode | Skip node + dev-only warning (BlockRenderer precedent) |
| Unknown node `type` | Skip subtree + dev warning |
+| Invalid `container` attr value on the wire | Treat that attr as absent → engine default (attr-level failure, never tree-level) |
| Slot resolution failure | Treat as `none` |
| Invalid tree submitted via API | Lifecycle validator rejects with actionable message |
| CMS down at build | Static params fail to empty; pages render on demand (ISR unchanged) |
@@ -210,10 +240,12 @@ in the tree world, cells render the real atom components.
- **press-shared** — validator unit tests: valid/invalid fixtures, version gate,
slot modes, deeply recursive trees (recursion is unbounded and must validate),
ratio/children arity.
-- **press-web** — TreeRenderer: recursion, ratio/gap/width mappings (including the
- spacious tier-scaling and 25-25-25-25 two-stage behavior), slot resolution matrix
- (inherit/none/custom × defaults present/absent), tolerance rules, the rescoped
- prose selector; generator tests updated for `PressTree`.
+- **press-web** — TreeRenderer: recursion, ratio + container-attr mappings (including
+ the spacious tier-scaling and 25-25-25-25 two-stage behavior), container defaults
+ (absent group/attr → current engine defaults) and non-applicable attrs ignored per
+ node type, slot resolution matrix (inherit/none/custom × defaults present/absent),
+ tolerance rules, the rescoped prose selector; generator tests updated for
+ `PressTree`.
- **press-cms** — serialize-schema over the full palette; lifecycle validation;
`pageDefaults` seeding flag + idempotency; admin tree editor smoke tests with the
hand-rolled `act()`+`createRoot` harness (react-19 RTL constraint).
From 56012ab60b63dc6d2566b5d2a720c4aab85629e8 Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Mon, 20 Jul 2026 19:05:04 -0300
Subject: [PATCH 04/31] feat(shared): publishable press-shared + PressTree wire
contract
Co-authored-by: Cursor
---
packages/shared/.npmignore | 1 +
packages/shared/package.json | 13 ++++-
packages/shared/src/index.ts | 5 ++
packages/shared/src/tree.ts | 85 ++++++++++++++++++++++++++++
packages/shared/src/validate-tree.ts | 1 +
packages/shared/vitest.config.ts | 7 +++
pnpm-lock.yaml | 3 +
7 files changed, 112 insertions(+), 3 deletions(-)
create mode 100644 packages/shared/.npmignore
create mode 100644 packages/shared/src/tree.ts
create mode 100644 packages/shared/src/validate-tree.ts
create mode 100644 packages/shared/vitest.config.ts
diff --git a/packages/shared/.npmignore b/packages/shared/.npmignore
new file mode 100644
index 0000000..b09b41e
--- /dev/null
+++ b/packages/shared/.npmignore
@@ -0,0 +1 @@
+src/**/*.test.ts
diff --git a/packages/shared/package.json b/packages/shared/package.json
index c5fc2ba..822d654 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -1,11 +1,16 @@
{
"name": "@ogs-tech/press-shared",
"version": "0.1.0",
- "private": true,
- "description": "press — framework-agnostic contract types shared by the engine packages",
+ "description": "press — wire contract types and pure wire validators shared by the engine packages",
"license": "MIT",
"author": "Odenir Gomes",
"type": "module",
+ "publishConfig": { "access": "public" },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/ogs-tech/press.git",
+ "directory": "packages/shared"
+ },
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
@@ -15,9 +20,11 @@
"files": ["src"],
"scripts": {
"build": "echo '@ogs-tech/press-shared ships TS source; nothing to build'",
+ "test": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
- "typescript": "^5"
+ "typescript": "^5",
+ "vitest": "^2.1.0"
}
}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 746da65..1c5de5c 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -31,6 +31,11 @@ export interface Attr {
}
export interface PressSchema {
+ /** The composition-tree contract version served by this cms (absent on pre-tree engines). */
+ tree?: { version: number };
contentTypes: Record }>;
components: Record }>;
}
+
+export * from './tree';
+export * from './validate-tree';
diff --git a/packages/shared/src/tree.ts b/packages/shared/src/tree.ts
new file mode 100644
index 0000000..557dde0
--- /dev/null
+++ b/packages/shared/src/tree.ts
@@ -0,0 +1,85 @@
+/**
+ * The press composition tree — the JSON stored by the `plugin::press-cms.builder`
+ * custom field (page `body`) and, as bare `Node[]` slots, by Site Settings
+ * `pageDefaults` (Spec §3). Pure wire types: no Strapi, no React.
+ *
+ * Responsiveness NEVER appears in this JSON — `ratio` and the `container` attrs
+ * are editorial intents; the web renderer maps them to Responsive values.
+ */
+
+/** Readers reject any other version (fail-to-empty); gates future migrations. */
+export const PRESS_TREE_VERSION = 1;
+
+/** Row-only: defines the column split. The closed scale inherited from the retired columns organism. */
+export type Ratio = '50-50' | '33-67' | '67-33' | '33-33-33' | '25-25-25-25';
+export type Gap = 'compact' | 'normal' | 'spacious';
+export type VerticalAlign = 'top' | 'center' | 'bottom';
+export type ContainerWidth = 'prose' | 'lg' | 'full';
+
+/**
+ * The ONE attribute surface for every children-bearing node (Spec §3). An
+ * absent field (or the whole group absent) means the engine default. Attrs
+ * that don't apply to a node type are ignored by the renderer and hidden by
+ * the builder form — never an error.
+ */
+export interface ContainerAttrs {
+ /** Container tier — top-level Rows only; ignored when nested. */
+ width?: ContainerWidth;
+ /** Row: track gap; Column/Layout: stack rhythm. */
+ gap?: Gap;
+ /** Row: aligns cells; Column: content within the cell height. */
+ verticalAlign?: VerticalAlign;
+}
+
+/**
+ * A placed block: `component` is a palette uid (`preset-atom.paragraph`,
+ * `custom-organism.callout`); `data` is validated against that component's
+ * registry schema. Media inside `data` is a REFERENCE (`{ assetId: number }`),
+ * page links are `{ documentId: string }` — the cms hydrates both server-side
+ * so the wire never rots (Spec §3).
+ */
+export interface BlockNode {
+ /** Builder-minted (crypto.randomUUID); React keys + builder ops only — never an Entity. */
+ id: string;
+ type: 'block';
+ component: string;
+ data: Record;
+}
+
+/** The recursion point: a column nests arbitrary nodes, including further rows. */
+export interface ColumnNode {
+ id: string;
+ type: 'column';
+ container?: ContainerAttrs;
+ children: Node[];
+}
+
+export interface RowNode {
+ id: string;
+ type: 'row';
+ ratio: Ratio;
+ container?: ContainerAttrs;
+ /** 1..4, ratio-bound; extra columns beyond the ratio's slots reuse the last span. */
+ children: ColumnNode[];
+}
+
+export type Node = RowNode | ColumnNode | BlockNode;
+
+export type Slot =
+ | { mode: 'inherit' } // resolve against Site Settings pageDefaults
+ | { mode: 'none' } // bare page
+ | { mode: 'custom'; children: Node[] }; // page-owned chrome
+
+export interface LayoutNode {
+ type: 'layout';
+ header: Slot;
+ footer: Slot;
+ /** Only `gap` applies: rhythm between top-level children. */
+ container?: ContainerAttrs;
+ children: Node[];
+}
+
+export interface PressTree {
+ version: typeof PRESS_TREE_VERSION;
+ root: LayoutNode;
+}
diff --git a/packages/shared/src/validate-tree.ts b/packages/shared/src/validate-tree.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/shared/src/validate-tree.ts
@@ -0,0 +1 @@
+export {};
diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts
new file mode 100644
index 0000000..6ec74ee
--- /dev/null
+++ b/packages/shared/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ include: ['src/**/*.test.ts'],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 43cfefd..b3ef92a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -156,6 +156,9 @@ importers:
typescript:
specifier: ^5
version: 5.9.3
+ vitest:
+ specifier: ^2.1.0
+ version: 2.1.9(@types/node@20.19.43)(jsdom@25.0.1)(terser@5.48.0)
packages/web:
dependencies:
From 660a57a3e90910d0bfddcd1fa16549bfad84c950 Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Mon, 20 Jul 2026 19:09:09 -0300
Subject: [PATCH 05/31] feat(shared): sanitizing PressTree runtime validator
Co-authored-by: Cursor
---
packages/shared/src/validate-tree.test.ts | 159 +++++++++++++++++
packages/shared/src/validate-tree.ts | 203 +++++++++++++++++++++-
2 files changed, 361 insertions(+), 1 deletion(-)
create mode 100644 packages/shared/src/validate-tree.test.ts
diff --git a/packages/shared/src/validate-tree.test.ts b/packages/shared/src/validate-tree.test.ts
new file mode 100644
index 0000000..f96943e
--- /dev/null
+++ b/packages/shared/src/validate-tree.test.ts
@@ -0,0 +1,159 @@
+import { describe, expect, it } from 'vitest';
+import { validateNodeArray, validatePressTree } from './validate-tree';
+import type { PressTree } from './tree';
+
+const block = (component: string, data: Record = {}) => ({
+ id: `id-${component}`,
+ type: 'block' as const,
+ component,
+ data,
+});
+
+const validTree = (): PressTree => ({
+ version: 1,
+ root: {
+ type: 'layout',
+ header: { mode: 'inherit' },
+ footer: { mode: 'none' },
+ container: { gap: 'normal' },
+ children: [
+ block('preset-organism.hero', { title: 'Hi' }),
+ {
+ id: 'row-1',
+ type: 'row',
+ ratio: '50-50',
+ container: { width: 'lg', gap: 'compact', verticalAlign: 'center' },
+ children: [
+ { id: 'col-1', type: 'column', children: [block('preset-atom.paragraph', { content: 'a' })] },
+ {
+ id: 'col-2',
+ type: 'column',
+ container: { gap: 'spacious', verticalAlign: 'bottom' },
+ // the recursion point: a row INSIDE a column (Spec §8: must validate at depth)
+ children: [
+ {
+ id: 'row-2',
+ type: 'row',
+ ratio: '33-67',
+ children: [
+ { id: 'col-3', type: 'column', children: [block('custom-organism.callout')] },
+ { id: 'col-4', type: 'column', children: [] },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+});
+
+describe('validatePressTree', () => {
+ it('accepts a valid deeply recursive tree and returns a sanitized copy', () => {
+ const input = validTree();
+ const out = validatePressTree(input);
+ expect(out.errors).toEqual([]);
+ expect(out.warnings).toEqual([]);
+ expect(out.value).toEqual(input);
+ expect(out.value).not.toBe(input); // deep copy, never the input reference
+ });
+
+ it('rejects non-objects and unknown versions (fail-to-empty gate)', () => {
+ expect(validatePressTree(null).value).toBeNull();
+ expect(validatePressTree('[]').value).toBeNull();
+ const v2 = { ...validTree(), version: 2 };
+ const out = validatePressTree(v2);
+ expect(out.value).toBeNull();
+ expect(out.errors[0].path).toBe('$.version');
+ });
+
+ it('rejects a root that is not a layout node', () => {
+ const out = validatePressTree({ version: 1, root: block('preset-atom.paragraph') });
+ expect(out.value).toBeNull();
+ expect(out.errors[0].path).toBe('$.root');
+ });
+
+ it('strips invalid container attr values as warnings, never errors (Spec §7)', () => {
+ const input = validTree();
+ (input.root.children[1] as any).container = { width: 'xl', gap: 'normal', verticalAlign: 'middle' };
+ const out = validatePressTree(input);
+ expect(out.errors).toEqual([]);
+ expect(out.warnings.map((w) => w.path)).toEqual([
+ '$.root.children[1].container.width',
+ '$.root.children[1].container.verticalAlign',
+ ]);
+ expect((out.value!.root.children[1] as any).container).toEqual({ gap: 'normal' });
+ });
+
+ it('errors on a column outside a row and on unknown node types', () => {
+ const stray = validatePressTree({
+ version: 1,
+ root: { type: 'layout', header: { mode: 'none' }, footer: { mode: 'none' }, children: [
+ { id: 'c', type: 'column', children: [] },
+ ] },
+ });
+ expect(stray.value).toBeNull();
+ expect(stray.errors[0].message).toMatch(/only legal directly under a row/);
+
+ const unknown = validatePressTree({
+ version: 1,
+ root: { type: 'layout', header: { mode: 'none' }, footer: { mode: 'none' }, children: [
+ { id: 'x', type: 'mystery' },
+ ] },
+ });
+ expect(unknown.value).toBeNull();
+ });
+
+ it('enforces row arity 1..4 and column-only row children', () => {
+ const tooMany = validTree();
+ (tooMany.root.children[1] as any).children = Array.from({ length: 5 }, (_, i) => ({
+ id: `c${i}`, type: 'column', children: [],
+ }));
+ expect(validatePressTree(tooMany).value).toBeNull();
+
+ const notColumn = validTree();
+ (notColumn.root.children[1] as any).children = [block('preset-atom.paragraph')];
+ expect(validatePressTree(notColumn).value).toBeNull();
+ });
+
+ it('requires block ids and component uids', () => {
+ const noId = validTree();
+ delete (noId.root.children[0] as any).id;
+ expect(validatePressTree(noId).value).toBeNull();
+
+ const noComponent = validTree();
+ delete (noComponent.root.children[0] as any).component;
+ expect(validatePressTree(noComponent).value).toBeNull();
+ });
+
+ it('coerces an unknown slot mode to none with a warning (render: treat as none)', () => {
+ const input = validTree();
+ (input.root as any).header = { mode: 'mystery' };
+ const out = validatePressTree(input);
+ expect(out.errors).toEqual([]);
+ expect(out.warnings.some((w) => w.path === '$.root.header.mode')).toBe(true);
+ expect(out.value!.root.header).toEqual({ mode: 'none' });
+ });
+
+ it('validates custom slot children recursively', () => {
+ const input = validTree();
+ (input.root as any).footer = { mode: 'custom', children: [block('preset-organism.footer')] };
+ const out = validatePressTree(input);
+ expect(out.errors).toEqual([]);
+ expect(out.value!.root.footer).toEqual({ mode: 'custom', children: [block('preset-organism.footer')] });
+ });
+});
+
+describe('validateNodeArray', () => {
+ it('accepts a bare Node[] (the pageDefaults slot shape)', () => {
+ const nodes = [block('preset-organism.navbar')];
+ const out = validateNodeArray(nodes);
+ expect(out.errors).toEqual([]);
+ expect(out.value).toEqual(nodes);
+ });
+
+ it('rejects non-arrays and invalid members', () => {
+ expect(validateNodeArray({}).value).toBeNull();
+ expect(validateNodeArray([{ id: 'x', type: 'column', children: [] }]).value).toBeNull();
+ });
+});
diff --git a/packages/shared/src/validate-tree.ts b/packages/shared/src/validate-tree.ts
index cb0ff5c..0605f86 100644
--- a/packages/shared/src/validate-tree.ts
+++ b/packages/shared/src/validate-tree.ts
@@ -1 +1,202 @@
-export {};
+/**
+ * Runtime PressTree validator (Spec §3 "press-shared changes nature"): pure TS,
+ * zero deps. Compiled into press-cms dist (save-time backstop) and consumed as
+ * source by press-web (render-time guard).
+ *
+ * Contract: `value` is a SANITIZED deep copy — structural failures null it and
+ * land in `errors`; invalid container-attr values are stripped and land in
+ * `warnings` (attr-level failure never becomes tree-level, Spec §7); an unknown
+ * slot mode degrades to `none` with a warning. Writers reject on errors OR
+ * warnings (strict write); readers render whenever `value` is non-null
+ * (tolerant read).
+ */
+import type { ColumnNode, ContainerAttrs, LayoutNode, Node, PressTree, RowNode, Slot } from './tree';
+import { PRESS_TREE_VERSION } from './tree';
+
+export interface TreeIssue {
+ path: string;
+ message: string;
+}
+
+export interface TreeResult {
+ value: T | null;
+ errors: TreeIssue[];
+ warnings: TreeIssue[];
+}
+
+export const MAX_ROW_COLUMNS = 4;
+
+const RATIOS: readonly string[] = ['50-50', '33-67', '67-33', '33-33-33', '25-25-25-25'];
+const WIDTHS: readonly string[] = ['prose', 'lg', 'full'];
+const GAPS: readonly string[] = ['compact', 'normal', 'spacious'];
+const VERTICAL_ALIGNS: readonly string[] = ['top', 'center', 'bottom'];
+
+interface Ctx {
+ errors: TreeIssue[];
+ warnings: TreeIssue[];
+}
+
+const fail = (ctx: Ctx, path: string, message: string): null => {
+ ctx.errors.push({ path, message });
+ return null;
+};
+
+const warn = (ctx: Ctx, path: string, message: string): void => {
+ ctx.warnings.push({ path, message });
+};
+
+const isRecord = (v: unknown): v is Record =>
+ typeof v === 'object' && v !== null && !Array.isArray(v);
+
+function sanitizeContainer(input: unknown, path: string, ctx: Ctx): ContainerAttrs | undefined {
+ if (input === undefined || input === null) return undefined;
+ if (!isRecord(input)) {
+ warn(ctx, path, 'container must be an object — ignored');
+ return undefined;
+ }
+ const out: ContainerAttrs = {};
+ const pick = (key: keyof ContainerAttrs, allowed: readonly string[]): void => {
+ const v = input[key];
+ if (v === undefined) return;
+ if (typeof v === 'string' && allowed.includes(v)) {
+ (out as Record)[key] = v;
+ } else {
+ warn(ctx, `${path}.${key}`, `invalid value ${JSON.stringify(v)} — attribute dropped`);
+ }
+ };
+ pick('width', WIDTHS);
+ pick('gap', GAPS);
+ pick('verticalAlign', VERTICAL_ALIGNS);
+ return Object.keys(out).length > 0 ? out : undefined;
+}
+
+function requireId(input: Record, path: string, ctx: Ctx): string | null {
+ if (typeof input.id !== 'string' || input.id.length === 0) {
+ return fail(ctx, `${path}.id`, 'node id must be a non-empty string');
+ }
+ return input.id;
+}
+
+function validateColumn(input: unknown, path: string, ctx: Ctx): ColumnNode | null {
+ if (!isRecord(input) || input.type !== 'column') {
+ return fail(ctx, path, `row children must be column nodes, got ${JSON.stringify(isRecord(input) ? input.type : input)}`);
+ }
+ const id = requireId(input, path, ctx);
+ if (id === null) return null;
+ const children = validateChildren(input.children, `${path}.children`, ctx);
+ if (children === null) return null;
+ const node: ColumnNode = { id, type: 'column', children };
+ const container = sanitizeContainer(input.container, `${path}.container`, ctx);
+ if (container) node.container = container;
+ return node;
+}
+
+/** A generic children position (layout root / column) admits block | row only. */
+function validateChildren(input: unknown, path: string, ctx: Ctx): Node[] | null {
+ if (!Array.isArray(input)) return fail(ctx, path, 'children must be an array');
+ const out: Node[] = [];
+ input.forEach((child, i) => {
+ const node = validateNode(child, `${path}[${i}]`, ctx);
+ if (node) out.push(node);
+ });
+ return out;
+}
+
+function validateNode(input: unknown, path: string, ctx: Ctx): Node | null {
+ if (!isRecord(input)) return fail(ctx, path, 'node must be an object');
+ switch (input.type) {
+ case 'block': {
+ const id = requireId(input, path, ctx);
+ if (id === null) return null;
+ if (typeof input.component !== 'string' || input.component.length === 0) {
+ return fail(ctx, `${path}.component`, 'block component must be a non-empty palette uid');
+ }
+ let data: Record = {};
+ if (input.data === undefined) {
+ // tolerated: an attribute-less block (bare navbar seed)
+ } else if (isRecord(input.data)) {
+ data = structuredClone(input.data) as Record;
+ } else {
+ warn(ctx, `${path}.data`, 'block data must be an object — reset to {}');
+ }
+ return { id, type: 'block', component: input.component, data };
+ }
+ case 'row': {
+ const id = requireId(input, path, ctx);
+ if (id === null) return null;
+ if (typeof input.ratio !== 'string' || !RATIOS.includes(input.ratio)) {
+ return fail(ctx, `${path}.ratio`, `ratio must be one of ${RATIOS.join(' | ')}`);
+ }
+ if (!Array.isArray(input.children)) {
+ return fail(ctx, `${path}.children`, 'row children must be an array of columns');
+ }
+ if (input.children.length < 1 || input.children.length > MAX_ROW_COLUMNS) {
+ return fail(ctx, `${path}.children`, `a row carries 1–${MAX_ROW_COLUMNS} columns, got ${input.children.length}`);
+ }
+ const columns: ColumnNode[] = [];
+ input.children.forEach((c, i) => {
+ const col = validateColumn(c, `${path}.children[${i}]`, ctx);
+ if (col) columns.push(col);
+ });
+ const node: RowNode = { id, type: 'row', ratio: input.ratio as RowNode['ratio'], children: columns };
+ const container = sanitizeContainer(input.container, `${path}.container`, ctx);
+ if (container) node.container = container;
+ return node;
+ }
+ case 'column':
+ return fail(ctx, path, 'a column node is only legal directly under a row');
+ default:
+ return fail(ctx, `${path}.type`, `unknown node type ${JSON.stringify(input.type)}`);
+ }
+}
+
+function validateSlot(input: unknown, path: string, ctx: Ctx): Slot {
+ if (!isRecord(input)) {
+ warn(ctx, path, 'slot must be an object — treated as none');
+ return { mode: 'none' };
+ }
+ if (input.mode === 'inherit') return { mode: 'inherit' };
+ if (input.mode === 'none') return { mode: 'none' };
+ if (input.mode === 'custom') {
+ const children = validateChildren(input.children, `${path}.children`, ctx);
+ return { mode: 'custom', children: children ?? [] };
+ }
+ warn(ctx, `${path}.mode`, `unknown slot mode ${JSON.stringify(input.mode)} — treated as none`);
+ return { mode: 'none' };
+}
+
+export function validatePressTree(input: unknown): TreeResult {
+ const ctx: Ctx = { errors: [], warnings: [] };
+ if (!isRecord(input)) {
+ return { value: null, errors: [{ path: '$', message: 'tree must be an object' }], warnings: [] };
+ }
+ if (input.version !== PRESS_TREE_VERSION) {
+ return {
+ value: null,
+ errors: [{ path: '$.version', message: `unsupported tree version ${JSON.stringify(input.version)} (expected ${PRESS_TREE_VERSION})` }],
+ warnings: [],
+ };
+ }
+ const root = input.root;
+ if (!isRecord(root) || root.type !== 'layout') {
+ return { value: null, errors: [{ path: '$.root', message: "root must be a node of type 'layout'" }], warnings: ctx.warnings };
+ }
+ const header = validateSlot(root.header, '$.root.header', ctx);
+ const footer = validateSlot(root.footer, '$.root.footer', ctx);
+ const children = validateChildren(root.children, '$.root.children', ctx) ?? [];
+ const layout: LayoutNode = { type: 'layout', header, footer, children };
+ const container = sanitizeContainer(root.container, '$.root.container', ctx);
+ if (container) layout.container = container;
+ return {
+ value: ctx.errors.length === 0 ? { version: PRESS_TREE_VERSION, root: layout } : null,
+ errors: ctx.errors,
+ warnings: ctx.warnings,
+ };
+}
+
+/** Validates a bare Node[] — the shape of one Site Settings pageDefaults slot. */
+export function validateNodeArray(input: unknown): TreeResult {
+ const ctx: Ctx = { errors: [], warnings: [] };
+ const nodes = validateChildren(input, '$', ctx);
+ return { value: ctx.errors.length === 0 ? nodes : null, errors: ctx.errors, warnings: ctx.warnings };
+}
From 5c2f58271a4529f70def400af9a1349e61aa6729 Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Mon, 20 Jul 2026 19:24:20 -0300
Subject: [PATCH 06/31] feat(cms): preset-molecule.link descriptor + curated
plain-text atoms; retire nav-item/column/columns
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../server/src/components/atoms/button.json | 11 ++-
.../cms/server/src/components/atoms/list.json | 9 ++-
.../src/components/atoms/paragraph.json | 7 +-
.../server/src/components/atoms/quote.json | 8 ++-
.../src/components/molecules/column.json | 21 ------
.../molecules/{nav-item.json => link.json} | 8 +--
.../src/components/organisms/columns.json | 23 -------
.../server/src/components/organisms/cta.json | 3 +-
.../server/src/components/organisms/hero.json | 3 +-
.../src/components/organisms/navbar.json | 2 +-
.../server/src/lib/inject-components.test.ts | 67 +++++++------------
.../cms/server/src/lib/inject-components.ts | 8 +--
12 files changed, 62 insertions(+), 108 deletions(-)
delete mode 100644 packages/cms/server/src/components/molecules/column.json
rename packages/cms/server/src/components/molecules/{nav-item.json => link.json} (74%)
delete mode 100644 packages/cms/server/src/components/organisms/columns.json
diff --git a/packages/cms/server/src/components/atoms/button.json b/packages/cms/server/src/components/atoms/button.json
index ef2046a..b392870 100644
--- a/packages/cms/server/src/components/atoms/button.json
+++ b/packages/cms/server/src/components/atoms/button.json
@@ -3,8 +3,13 @@
"info": { "displayName": "Button", "icon": "cursor", "description": "A call-to-action button shipped by the press engine" },
"options": {},
"attributes": {
- "label": { "type": "string", "required": true },
- "href": { "type": "string", "required": true },
- "variant": { "type": "enumeration", "enum": ["primary", "secondary"], "default": "primary", "required": true }
+ "link": { "type": "component", "repeatable": false, "component": "preset-molecule.link" },
+ "variant": { "type": "enumeration", "enum": ["primary", "secondary"], "default": "primary" }
+ },
+ "config": {
+ "metadatas": {
+ "link": { "edit": { "label": "Link" } },
+ "variant": { "edit": { "label": "Variant" } }
+ }
}
}
diff --git a/packages/cms/server/src/components/atoms/list.json b/packages/cms/server/src/components/atoms/list.json
index a07abb1..9dc1335 100644
--- a/packages/cms/server/src/components/atoms/list.json
+++ b/packages/cms/server/src/components/atoms/list.json
@@ -3,6 +3,13 @@
"info": { "displayName": "List", "icon": "bulletList", "description": "An ordered or unordered list shipped by the press engine" },
"options": {},
"attributes": {
- "content": { "type": "blocks", "required": true }
+ "content": { "type": "text", "required": true },
+ "format": { "type": "enumeration", "enum": ["unordered", "ordered"], "default": "unordered" }
+ },
+ "config": {
+ "metadatas": {
+ "content": { "edit": { "label": "Items", "description": "One item per line." } },
+ "format": { "edit": { "label": "Format" } }
+ }
}
}
diff --git a/packages/cms/server/src/components/atoms/paragraph.json b/packages/cms/server/src/components/atoms/paragraph.json
index 57677f3..a37282b 100644
--- a/packages/cms/server/src/components/atoms/paragraph.json
+++ b/packages/cms/server/src/components/atoms/paragraph.json
@@ -3,6 +3,11 @@
"info": { "displayName": "Paragraph", "icon": "write", "description": "A prose paragraph shipped by the press engine" },
"options": {},
"attributes": {
- "content": { "type": "blocks", "required": true }
+ "content": { "type": "text", "required": true }
+ },
+ "config": {
+ "metadatas": {
+ "content": { "edit": { "label": "Content", "description": "Plain text. A blank line starts a new paragraph." } }
+ }
}
}
diff --git a/packages/cms/server/src/components/atoms/quote.json b/packages/cms/server/src/components/atoms/quote.json
index b079466..34b4c2e 100644
--- a/packages/cms/server/src/components/atoms/quote.json
+++ b/packages/cms/server/src/components/atoms/quote.json
@@ -3,7 +3,13 @@
"info": { "displayName": "Quote", "icon": "quote", "description": "A block quotation with an optional citation shipped by the press engine" },
"options": {},
"attributes": {
- "content": { "type": "blocks", "required": true },
+ "content": { "type": "text", "required": true },
"citation": { "type": "string" }
+ },
+ "config": {
+ "metadatas": {
+ "content": { "edit": { "label": "Quote", "description": "Plain text. A blank line starts a new paragraph." } },
+ "citation": { "edit": { "label": "Citation" } }
+ }
}
}
diff --git a/packages/cms/server/src/components/molecules/column.json b/packages/cms/server/src/components/molecules/column.json
deleted file mode 100644
index 3af34ff..0000000
--- a/packages/cms/server/src/components/molecules/column.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "collectionName": "components_preset_molecule_columns",
- "info": {
- "displayName": "Column",
- "icon": "stack",
- "description": "One column of a Columns section: rich text, an optional image, and an optional button"
- },
- "options": {},
- "attributes": {
- "content": { "type": "blocks" },
- "image": { "type": "media", "multiple": false, "allowedTypes": ["images"] },
- "button": { "type": "component", "repeatable": false, "component": "preset-atom.button" }
- },
- "config": {
- "metadatas": {
- "content": { "edit": { "label": "Content" } },
- "image": { "edit": { "label": "Image", "description": "Optional image rendered above the button." } },
- "button": { "edit": { "label": "Button", "description": "Optional call-to-action rendered at the end of the column." } }
- }
- }
-}
diff --git a/packages/cms/server/src/components/molecules/nav-item.json b/packages/cms/server/src/components/molecules/link.json
similarity index 74%
rename from packages/cms/server/src/components/molecules/nav-item.json
rename to packages/cms/server/src/components/molecules/link.json
index 3c4c0b8..1fad603 100644
--- a/packages/cms/server/src/components/molecules/nav-item.json
+++ b/packages/cms/server/src/components/molecules/link.json
@@ -1,13 +1,13 @@
{
- "collectionName": "components_preset_molecule_nav_items",
+ "collectionName": "components_preset_molecule_links",
"info": {
- "displayName": "Nav Item",
+ "displayName": "Link",
"icon": "link",
- "description": "A single navigation entry: an internal page link or an external URL"
+ "description": "The engine's one link concept: an internal page reference (survives renames) or a URL, with a label"
},
"options": {},
"attributes": {
- "label": { "type": "string", "required": true },
+ "label": { "type": "string" },
"page": { "type": "relation", "relation": "oneToOne", "target": "plugin::press-cms.page" },
"url": { "type": "string" },
"newTab": { "type": "boolean", "default": false }
diff --git a/packages/cms/server/src/components/organisms/columns.json b/packages/cms/server/src/components/organisms/columns.json
deleted file mode 100644
index 553a1a2..0000000
--- a/packages/cms/server/src/components/organisms/columns.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "collectionName": "components_preset_organism_columns",
- "info": {
- "displayName": "Columns",
- "icon": "grid",
- "description": "An editor-composed multi-column section shipped by the press engine: 2-4 columns of rich text, image, and button"
- },
- "options": {},
- "attributes": {
- "ratio": { "type": "enumeration", "enum": ["50-50", "33-67", "67-33", "33-33-33", "25-25-25-25"], "default": "50-50" },
- "gap": { "type": "enumeration", "enum": ["compact", "normal", "spacious"], "default": "normal" },
- "verticalAlign": { "type": "enumeration", "enum": ["top", "center", "bottom"], "default": "top" },
- "columns": { "type": "component", "repeatable": true, "component": "preset-molecule.column", "min": 2, "max": 4 }
- },
- "config": {
- "metadatas": {
- "ratio": { "edit": { "label": "Column layout", "description": "How the columns split the width on desktop; every layout stacks on phones." } },
- "gap": { "edit": { "label": "Gap", "description": "Spacing between columns." } },
- "verticalAlign": { "edit": { "label": "Vertical alignment", "description": "How columns of different heights line up." } },
- "columns": { "edit": { "label": "Columns" } }
- }
- }
-}
diff --git a/packages/cms/server/src/components/organisms/cta.json b/packages/cms/server/src/components/organisms/cta.json
index a55292b..d0f114b 100644
--- a/packages/cms/server/src/components/organisms/cta.json
+++ b/packages/cms/server/src/components/organisms/cta.json
@@ -5,8 +5,7 @@
"attributes": {
"title": { "type": "string", "required": true },
"subtitle": { "type": "text" },
- "buttonLabel": { "type": "string", "required": true },
- "buttonHref": { "type": "string", "required": true },
+ "button": { "type": "component", "repeatable": false, "component": "preset-molecule.link" },
"align": { "type": "enumeration", "enum": ["left", "center"], "default": "left" }
}
}
diff --git a/packages/cms/server/src/components/organisms/hero.json b/packages/cms/server/src/components/organisms/hero.json
index aa252c6..f176b4f 100644
--- a/packages/cms/server/src/components/organisms/hero.json
+++ b/packages/cms/server/src/components/organisms/hero.json
@@ -7,8 +7,7 @@
"title": { "type": "string", "required": true },
"subtitle": { "type": "text" },
"image": { "type": "media", "multiple": false, "allowedTypes": ["images"] },
- "ctaLabel": { "type": "string" },
- "ctaHref": { "type": "string" },
+ "cta": { "type": "component", "repeatable": false, "component": "preset-molecule.link" },
"align": { "type": "enumeration", "enum": ["left", "center"], "default": "left" }
}
}
diff --git a/packages/cms/server/src/components/organisms/navbar.json b/packages/cms/server/src/components/organisms/navbar.json
index 7f526a2..492bac4 100644
--- a/packages/cms/server/src/components/organisms/navbar.json
+++ b/packages/cms/server/src/components/organisms/navbar.json
@@ -7,7 +7,7 @@
},
"options": {},
"attributes": {
- "items": { "type": "component", "repeatable": true, "component": "preset-molecule.nav-item" },
+ "items": { "type": "component", "repeatable": true, "component": "preset-molecule.link" },
"cta": { "type": "component", "repeatable": false, "component": "preset-atom.button" }
},
"config": {
diff --git a/packages/cms/server/src/lib/inject-components.test.ts b/packages/cms/server/src/lib/inject-components.test.ts
index 03c6452..5f16980 100644
--- a/packages/cms/server/src/lib/inject-components.test.ts
+++ b/packages/cms/server/src/lib/inject-components.test.ts
@@ -183,8 +183,8 @@ describe('injectComponents', () => {
const expected = [
'preset-atom.paragraph', 'preset-atom.heading', 'preset-atom.list', 'preset-atom.quote',
'preset-atom.image', 'preset-atom.button', 'preset-atom.separator', 'preset-atom.spacer',
- 'preset-molecule.nav-item', 'preset-molecule.column',
- 'preset-organism.hero', 'preset-organism.cta', 'preset-organism.columns',
+ 'preset-molecule.link',
+ 'preset-organism.hero', 'preset-organism.cta',
'preset-organism.navbar', 'preset-organism.footer',
'preset-config.seo', 'preset-config.theme-colors', 'preset-config.theme-radius',
'preset-config.cookie-category', 'preset-config.cookie-consent',
@@ -211,10 +211,10 @@ describe('injectComponents', () => {
expect(components.get('preset-config.seo')?.modelType).toBe('component'); // others still injected
});
- it('injects preset-molecule.nav-item but never admits it into the page Dynamic Zone', () => {
- // nav-item is a molecule nested inside the navbar. Injecting it registers the
- // component, but it must NOT leak into the page block palette — only custom-*
- // is admitted into the page body Dynamic Zone.
+ it('injects preset-molecule.link but never admits it into the page Dynamic Zone', () => {
+ // link is a molecule nested inside button/hero/cta/navbar. Injecting it
+ // registers the component, but it must NOT leak into the page block palette —
+ // only custom-* is admitted into the page body Dynamic Zone.
const components = new Map();
const page = pageWithBody(['preset-atom.paragraph']);
const contentTypes = new Map([
@@ -231,9 +231,9 @@ describe('injectComponents', () => {
components.set('custom-organism.callout', { uid: 'custom-organism.callout' }); // a real custom block
admitCustomBlocks({ strapi });
- expect(components.get('preset-molecule.nav-item')?.modelType).toBe('component'); // injected
- expect(page.attributes.body.components).toContain('custom-organism.callout'); // custom admitted
- expect(page.attributes.body.components).not.toContain('preset-molecule.nav-item'); // never admitted
+ expect(components.get('preset-molecule.link')?.modelType).toBe('component'); // injected
+ expect(page.attributes.body.components).toContain('custom-organism.callout'); // custom admitted
+ expect(page.attributes.body.components).not.toContain('preset-molecule.link'); // never admitted
});
it('injects the organism sections (hero/cta) under category "preset-organism" with a derived globalId', () => {
@@ -249,41 +249,21 @@ describe('injectComponents', () => {
expect(components.get('preset-organism.cta')?.globalId).toBe('ComponentPresetOrganismCta');
});
- it('injects preset-organism.columns with its nested preset-molecule.column (never a DZ member)', () => {
- // Same nesting contract as navbar/nav-item: the organism is a DZ block, the
- // molecule exists only inside it. `columns` nests the repeatable molecule;
- // the molecule reuses preset-atom.button (the navbar.cta pattern).
- const components = new Map();
- const page = pageWithBody(['preset-atom.paragraph']);
- const contentTypes = new Map([
- [PAGE_UID, page],
- [SITE_SETTING_UID, siteSettingWithChrome()],
- ]);
- const strapi = {
- get: (key: string) =>
- key === 'components' ? components : key === 'content-types' ? contentTypes : undefined,
- log: { warn() {}, info() {}, debug() {}, error() {} },
- } as any;
-
+ it('injects preset-molecule.link under category "preset-molecule" with the shared link shape', () => {
+ // link is the engine's one link concept, nested inside preset-atom.button,
+ // preset-organism.hero/.cta, and preset-organism.navbar.items[] — never a DZ
+ // member itself.
+ const { strapi, components } = makeStrapi();
injectComponents({ strapi });
- admitCustomBlocks({ strapi });
- expect(components.get('preset-organism.columns')?.category).toBe('preset-organism');
- expect(components.get('preset-organism.columns')?.globalId).toBe('ComponentPresetOrganismColumns');
- expect(components.get('preset-organism.columns')?.attributes).toMatchObject({
- ratio: { type: 'enumeration', enum: ['50-50', '33-67', '67-33', '33-33-33', '25-25-25-25'], default: '50-50' },
- gap: { type: 'enumeration', enum: ['compact', 'normal', 'spacious'], default: 'normal' },
- verticalAlign: { type: 'enumeration', enum: ['top', 'center', 'bottom'], default: 'top' },
- columns: { type: 'component', repeatable: true, component: 'preset-molecule.column' },
- });
- expect(components.get('preset-molecule.column')?.category).toBe('preset-molecule');
- expect(components.get('preset-molecule.column')?.attributes).toMatchObject({
- content: { type: 'blocks' },
- image: { type: 'media', multiple: false, allowedTypes: ['images'] },
- button: { type: 'component', repeatable: false, component: 'preset-atom.button' },
+ expect(components.get('preset-molecule.link')?.category).toBe('preset-molecule');
+ expect(components.get('preset-molecule.link')?.globalId).toBe('ComponentPresetMoleculeLink');
+ expect(components.get('preset-molecule.link')?.attributes).toMatchObject({
+ label: { type: 'string' },
+ page: { type: 'relation', relation: 'oneToOne', target: 'plugin::press-cms.page' },
+ url: { type: 'string' },
+ newTab: { type: 'boolean', default: false },
});
- // Nested-only: the molecule never leaks into any Dynamic Zone.
- expect(page.attributes.body.components).not.toContain('preset-molecule.column');
});
it('injects the organism chrome (navbar/footer) under category "preset-organism" with a derived globalId', () => {
@@ -295,9 +275,10 @@ describe('injectComponents', () => {
expect(components.get('preset-organism.navbar')?.modelType).toBe('component');
expect(components.get('preset-organism.navbar')?.category).toBe('preset-organism');
expect(components.get('preset-organism.navbar')?.globalId).toBe('ComponentPresetOrganismNavbar');
- // Composite shape: nested nav items + optional CTA, no brand fields.
+ // Composite shape: nested nav items (the shared link descriptor) + optional
+ // CTA (a button, already a labeled link + variant), no brand fields.
expect(components.get('preset-organism.navbar')?.attributes).toMatchObject({
- items: { type: 'component', repeatable: true, component: 'preset-molecule.nav-item' },
+ items: { type: 'component', repeatable: true, component: 'preset-molecule.link' },
cta: { type: 'component', repeatable: false, component: 'preset-atom.button' },
});
diff --git a/packages/cms/server/src/lib/inject-components.ts b/packages/cms/server/src/lib/inject-components.ts
index cc704c6..cdde098 100644
--- a/packages/cms/server/src/lib/inject-components.ts
+++ b/packages/cms/server/src/lib/inject-components.ts
@@ -7,11 +7,9 @@ import imageSchema from '../components/atoms/image.json';
import buttonSchema from '../components/atoms/button.json';
import separatorSchema from '../components/atoms/separator.json';
import spacerSchema from '../components/atoms/spacer.json';
-import navItemSchema from '../components/molecules/nav-item.json';
-import columnSchema from '../components/molecules/column.json';
+import linkSchema from '../components/molecules/link.json';
import heroSchema from '../components/organisms/hero.json';
import ctaSchema from '../components/organisms/cta.json';
-import columnsSchema from '../components/organisms/columns.json';
import navbarSchema from '../components/organisms/navbar.json';
import footerSchema from '../components/organisms/footer.json';
import seoSchema from '../components/config/seo.json';
@@ -76,8 +74,7 @@ const ENGINE_COMPONENTS: Array<{ layer: PresetLayer; name: string; schema: Recor
{ layer: 'atom', name: 'separator', schema: separatorSchema as Record },
{ layer: 'atom', name: 'spacer', schema: spacerSchema as Record },
// Molecules — small composed units nested inside organisms (e.g. a navbar's links).
- { layer: 'molecule', name: 'nav-item', schema: navItemSchema as Record },
- { layer: 'molecule', name: 'column', schema: columnSchema as Record },
+ { layer: 'molecule', name: 'link', schema: linkSchema as Record },
// Organisms — composed sections for the page body (hero/cta) and the site chrome
// (navbar/footer). One unified layer (the old section.*/chrome.* palettes); the
// placement split (body vs header/footer) is declared per content-type
@@ -85,7 +82,6 @@ const ENGINE_COMPONENTS: Array<{ layer: PresetLayer; name: string; schema: Recor
// so editors cannot break the chrome.
{ layer: 'organism', name: 'hero', schema: heroSchema as Record },
{ layer: 'organism', name: 'cta', schema: ctaSchema as Record },
- { layer: 'organism', name: 'columns', schema: columnsSchema as Record },
{ layer: 'organism', name: 'navbar', schema: navbarSchema as Record },
{ layer: 'organism', name: 'footer', schema: footerSchema as Record },
// Config — non-block settings referenced by the Site Settings single type (seo /
From 0e93a11eef30056c851eec8e5a4d35781916dec2 Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Mon, 20 Jul 2026 19:37:46 -0300
Subject: [PATCH 07/31] feat(cms)!: page.body + site-setting.pageDefaults
become the builder JSON custom field; Dynamic Zones and dz-populate removed
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../server/src/content-types/page/schema.json | 16 +-
.../content-types/site-setting/schema.json | 36 +--
packages/cms/server/src/controllers/page.ts | 59 ++--
.../src/controllers/site-setting.test.ts | 42 +--
.../server/src/controllers/site-setting.ts | 48 ++--
.../cms/server/src/lib/dz-populate.test.ts | 53 ----
packages/cms/server/src/lib/dz-populate.ts | 49 ----
.../server/src/lib/inject-components.test.ts | 261 ++----------------
.../cms/server/src/lib/inject-components.ts | 86 +-----
packages/cms/server/src/register.ts | 10 +-
10 files changed, 95 insertions(+), 565 deletions(-)
delete mode 100644 packages/cms/server/src/lib/dz-populate.test.ts
delete mode 100644 packages/cms/server/src/lib/dz-populate.ts
diff --git a/packages/cms/server/src/content-types/page/schema.json b/packages/cms/server/src/content-types/page/schema.json
index 8c40e07..58fbd99 100644
--- a/packages/cms/server/src/content-types/page/schema.json
+++ b/packages/cms/server/src/content-types/page/schema.json
@@ -15,20 +15,8 @@
"title": { "type": "string", "required": true },
"slug": { "type": "uid", "targetField": "title" },
"body": {
- "type": "dynamiczone",
- "components": [
- "preset-atom.paragraph",
- "preset-atom.heading",
- "preset-atom.list",
- "preset-atom.quote",
- "preset-atom.image",
- "preset-atom.button",
- "preset-atom.separator",
- "preset-atom.spacer",
- "preset-organism.hero",
- "preset-organism.cta",
- "preset-organism.columns"
- ]
+ "type": "customField",
+ "customField": "plugin::press-cms.builder"
}
}
}
diff --git a/packages/cms/server/src/content-types/site-setting/schema.json b/packages/cms/server/src/content-types/site-setting/schema.json
index cfc426d..3e92dbd 100644
--- a/packages/cms/server/src/content-types/site-setting/schema.json
+++ b/packages/cms/server/src/content-types/site-setting/schema.json
@@ -19,35 +19,10 @@
"themeColors": { "type": "component", "repeatable": false, "component": "preset-config.theme-colors" },
"themeRadius": { "type": "component", "repeatable": false, "component": "preset-config.theme-radius" },
"cookieConsent": { "type": "component", "repeatable": false, "component": "preset-config.cookie-consent" },
- "header": {
- "type": "dynamiczone",
- "components": [
- "preset-organism.navbar",
- "preset-organism.footer",
- "preset-atom.paragraph",
- "preset-atom.heading",
- "preset-atom.list",
- "preset-atom.quote",
- "preset-atom.image",
- "preset-atom.button",
- "preset-atom.separator",
- "preset-atom.spacer"
- ]
- },
- "footer": {
- "type": "dynamiczone",
- "components": [
- "preset-organism.navbar",
- "preset-organism.footer",
- "preset-atom.paragraph",
- "preset-atom.heading",
- "preset-atom.list",
- "preset-atom.quote",
- "preset-atom.image",
- "preset-atom.button",
- "preset-atom.separator",
- "preset-atom.spacer"
- ]
+ "pageDefaults": {
+ "type": "customField",
+ "customField": "plugin::press-cms.builder",
+ "options": { "mode": "slots" }
}
},
"config": {
@@ -61,8 +36,7 @@
"themeColors": { "edit": { "label": "Theme Colors" } },
"themeRadius": { "edit": { "label": "Theme Radius" } },
"cookieConsent": { "edit": { "label": "Cookie Consent", "description": "Cookie banner shown to visitors. Category keys (necessary/analytics/marketing) are engine-fixed; toggles and copy are yours." } },
- "header": { "edit": { "label": "Header", "description": "Block-composed site header. The Navbar block renders brand + links + CTA." } },
- "footer": { "edit": { "label": "Footer", "description": "Block-composed site footer." } }
+ "pageDefaults": { "edit": { "label": "Page defaults", "description": "The default header and footer every page inherits. Pages can override or drop them per-slot." } }
}
}
}
diff --git a/packages/cms/server/src/controllers/page.ts b/packages/cms/server/src/controllers/page.ts
index f57fcd2..dbd19c7 100644
--- a/packages/cms/server/src/controllers/page.ts
+++ b/packages/cms/server/src/controllers/page.ts
@@ -1,48 +1,29 @@
import type { Core } from '@strapi/strapi';
-import { buildBodyPopulate } from '../lib/dz-populate';
const PAGE_UID = 'plugin::press-cms.page';
/**
- * Engine-owned page controller. The adopter never defines this — it ships the
- * wire shape the front-end consumes (Spec §5.1).
- *
- * Published-only + 404 (Spec decision 2026-06-11): every read filters to the
- * published view; a missing/unpublished slug is a 404, surfaced by `getPage` as
- * the App Router's notFound().
+ * Engine-owned page controller. `body` is a JSON custom field now — no dynamic
+ * zone, no populate tree: the whole "vanished from the wire but visible in the
+ * admin" bug class is gone (Spec §4). Published-only + 404 semantics unchanged.
+ * Media/page-ref hydration is layered on in lib/serve-hydrated (Task 6).
*/
-const page = ({ strapi }: { strapi: Core.Strapi }) => {
- // Returns the document-service `populate` VALUE for the page body DZ — i.e.
- // `{ body: { on: {...} } }`. It must be assigned to the `populate` KEY of the
- // query (not spread into the query root); spreading drops it and Strapi omits
- // the dynamic zone entirely from the response.
- const bodyPopulate = () => {
- const ct = strapi.contentType(PAGE_UID as any) as any;
- const components: string[] = ct?.attributes?.body?.components ?? [];
- return buildBodyPopulate(components);
- };
+const page = ({ strapi }: { strapi: Core.Strapi }) => ({
+ async find(ctx: any) {
+ const data = await strapi.documents(PAGE_UID as any).findMany({ status: 'published' });
+ ctx.body = { data };
+ },
- return {
- async find(ctx: any) {
- const data = await strapi.documents(PAGE_UID as any).findMany({
- status: 'published',
- populate: bodyPopulate(),
- });
- ctx.body = { data };
- },
-
- async findOne(ctx: any) {
- const { slug } = ctx.params;
- const [doc] = await strapi.documents(PAGE_UID as any).findMany({
- filters: { slug },
- status: 'published',
- limit: 1,
- populate: bodyPopulate(),
- });
- if (!doc) return ctx.notFound();
- ctx.body = { data: doc };
- },
- };
-};
+ async findOne(ctx: any) {
+ const { slug } = ctx.params;
+ const [doc] = await strapi.documents(PAGE_UID as any).findMany({
+ filters: { slug },
+ status: 'published',
+ limit: 1,
+ });
+ if (!doc) return ctx.notFound();
+ ctx.body = { data: doc };
+ },
+});
export default page;
diff --git a/packages/cms/server/src/controllers/site-setting.test.ts b/packages/cms/server/src/controllers/site-setting.test.ts
index 0a21a77..f22813d 100644
--- a/packages/cms/server/src/controllers/site-setting.test.ts
+++ b/packages/cms/server/src/controllers/site-setting.test.ts
@@ -6,23 +6,16 @@ const SITE_SETTING_UID = 'plugin::press-cms.site-setting';
/**
* The engine owns the wire shape: the controller computes the populate
* server-side and `ctx.query` is never honored. `populate: '*'` is SHALLOW — it
- * would leave `preset-organism.navbar's items.page` (and `seo.image`) unpopulated, so every internal
- * nav link silently falls back to its raw `url` and an internal page link 404s.
- * These tests pin the deep-populate contract the web resolver (mapSiteSettings)
- * depends on.
+ * would leave `seo.image` unpopulated. `header`/`footer` are gone (Spec §4: the
+ * composition-builder JSON custom field replaces the chrome Dynamic Zones); these
+ * tests pin the remaining deep-populate contract the web resolver
+ * (mapSiteSettings) depends on.
*/
describe('site-setting controller', () => {
function run(record: unknown = { name: 'Acme' }) {
const findFirst = vi.fn().mockResolvedValue(record);
const documents = vi.fn(() => ({ findFirst }));
- const contentType = vi.fn(() => ({
- uid: SITE_SETTING_UID,
- attributes: {
- header: { type: 'dynamiczone', components: ['preset-organism.navbar', 'preset-atom.paragraph', 'custom-organism.callout'] },
- footer: { type: 'dynamiczone', components: ['preset-organism.footer', 'custom-organism.callout'] },
- },
- }));
- const strapi = { documents, contentType } as any;
+ const strapi = { documents } as any;
const ctx: any = {};
return { strapi, ctx, documents, findFirst };
}
@@ -34,30 +27,15 @@ describe('site-setting controller', () => {
expect(ctx.body).toEqual({ data: { name: 'Acme' } });
});
- it('populates both chrome DZs with a per-component `on` map read from the live content-type', async () => {
- const { strapi, ctx, findFirst } = run();
- await siteSetting({ strapi }).find(ctx);
- const { populate } = findFirst.mock.calls[0][0];
- // custom-* flows through with the same shallow '*' as body blocks.
- expect(populate.header.on['preset-atom.paragraph']).toEqual({ populate: '*' });
- expect(populate.header.on['custom-organism.callout']).toEqual({ populate: '*' });
- expect(populate.footer.on['preset-organism.footer']).toEqual({ populate: '*' });
- });
-
- it('deep-populates preset-organism.navbar (items.page slug + cta) so internal nav links resolve to their slug', async () => {
- const { strapi, ctx, findFirst } = run();
- await siteSetting({ strapi }).find(ctx);
- const { populate } = findFirst.mock.calls[0][0];
- expect(populate.header.on['preset-organism.navbar']).toEqual({
- populate: { items: { populate: { page: { fields: ['slug'] } } }, cta: true },
- });
- });
-
- it('no longer populates the removed headerNav (BREAKING, Spec §Migration)', async () => {
+ it('no longer populates the removed header/footer dynamic zones (BREAKING, Spec §4)', async () => {
const { strapi, ctx, findFirst } = run();
await siteSetting({ strapi }).find(ctx);
const { populate } = findFirst.mock.calls[0][0];
+ expect(populate.header).toBeUndefined();
+ expect(populate.footer).toBeUndefined();
expect(populate.headerNav).toBeUndefined();
+ // pageDefaults is a JSON custom field scalar — no populate key at all.
+ expect(populate.pageDefaults).toBeUndefined();
});
it('deep-populates seo.image — media nested in a component is not reached by populate:*', async () => {
diff --git a/packages/cms/server/src/controllers/site-setting.ts b/packages/cms/server/src/controllers/site-setting.ts
index b360a76..e3ddd46 100644
--- a/packages/cms/server/src/controllers/site-setting.ts
+++ b/packages/cms/server/src/controllers/site-setting.ts
@@ -1,5 +1,4 @@
import type { Core } from '@strapi/strapi';
-import { buildChromeDzPopulate } from '../lib/dz-populate';
const SITE_SETTING_UID = 'plugin::press-cms.site-setting';
@@ -10,42 +9,33 @@ const SITE_SETTING_UID = 'plugin::press-cms.site-setting';
*
* The engine owns the populate (Spec §5.1 of the site-settings spec): `ctx.query`
* is NOT honored (public `auth: false` route). `populate: '*'` is SHALLOW, so
- * `seo.image` and the chrome DZs' nested content (`preset-organism.navbar`
- * items.page + cta) are deep-populated explicitly. The chrome DZ component lists
- * are read from the live content-type at request time — like the page controller
- * — so admitted custom-* blocks populate too.
+ * `seo.image` is deep-populated explicitly. `pageDefaults` is a JSON custom field
+ * (Spec §4) — a scalar on the wire, no populate key needed.
*/
const siteSetting = ({ strapi }: { strapi: Core.Strapi }) => {
- const chromePopulate = () => {
- const ct = strapi.contentType(SITE_SETTING_UID as any) as any;
- const header: string[] = ct?.attributes?.header?.components ?? [];
- const footer: string[] = ct?.attributes?.footer?.components ?? [];
- return {
- logo: true,
- favicon: true,
- seo: { populate: { image: true } },
- themeColors: true,
- themeRadius: true,
- // Nested category components + the privacy page's slug sit one level below
- // what a shallow populate reaches — same reason as seo.image above.
- cookieConsent: {
- populate: {
- necessary: true,
- analytics: true,
- marketing: true,
- privacyPage: { fields: ['slug'] },
- },
+ const settingsPopulate = () => ({
+ logo: true,
+ favicon: true,
+ seo: { populate: { image: true } },
+ themeColors: true,
+ themeRadius: true,
+ // Nested category components + the privacy page's slug sit one level below
+ // what a shallow populate reaches — same reason as seo.image above.
+ cookieConsent: {
+ populate: {
+ necessary: true,
+ analytics: true,
+ marketing: true,
+ privacyPage: { fields: ['slug'] },
},
- header: buildChromeDzPopulate(header),
- footer: buildChromeDzPopulate(footer),
- };
- };
+ },
+ });
return {
async find(ctx: any) {
const data = await strapi
.documents(SITE_SETTING_UID as any)
- .findFirst({ populate: chromePopulate() as any });
+ .findFirst({ populate: settingsPopulate() as any });
ctx.body = { data };
},
};
diff --git a/packages/cms/server/src/lib/dz-populate.test.ts b/packages/cms/server/src/lib/dz-populate.test.ts
deleted file mode 100644
index 0fd13f6..0000000
--- a/packages/cms/server/src/lib/dz-populate.test.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { buildBodyPopulate, buildChromeDzPopulate } from './dz-populate';
-
-describe('buildBodyPopulate', () => {
- it('builds a per-component `on` map populating one level (media included) for each DZ component', () => {
- expect(buildBodyPopulate(['preset-atom.image', 'custom-organism.callout'])).toEqual({
- body: {
- on: {
- 'preset-atom.image': { populate: '*' },
- 'custom-organism.callout': { populate: '*' },
- },
- },
- });
- });
-
- it('deep-populates preset-organism.columns, whose column image/button sit two levels down', () => {
- // `populate: '*'` is SHALLOW: it reaches the repeatable `columns` component
- // itself but NOT the media/component refs inside each column — the exact
- // navbar situation in buildChromeDzPopulate. Without this, a column's
- // image/button is silently absent from the wire while the admin shows it.
- expect(buildBodyPopulate(['preset-organism.columns', 'preset-organism.hero'])).toEqual({
- body: {
- on: {
- 'preset-organism.columns': { populate: { columns: { populate: { image: true, button: true } } } },
- 'preset-organism.hero': { populate: '*' },
- },
- },
- });
- });
-
- it('produces an empty `on` map when the dynamic zone has no components', () => {
- expect(buildBodyPopulate([])).toEqual({ body: { on: {} } });
- });
-});
-
-describe('buildChromeDzPopulate', () => {
- it("populates one level ('*') per component, EXCEPT preset-organism.navbar which needs a deep populate", () => {
- // `populate: '*'` is SHALLOW: the navbar's `items.page` relation (internal
- // link → slug) and `cta` component sit one level deeper — without the deep
- // populate every internal nav link silently falls back to its raw url (Spec §1/§3).
- expect(buildChromeDzPopulate(['preset-organism.navbar', 'preset-organism.footer', 'custom-organism.callout'])).toEqual({
- on: {
- 'preset-organism.navbar': { populate: { items: { populate: { page: { fields: ['slug'] } } }, cta: true } },
- 'preset-organism.footer': { populate: '*' },
- 'custom-organism.callout': { populate: '*' },
- },
- });
- });
-
- it('produces an empty `on` map when the dynamic zone has no components', () => {
- expect(buildChromeDzPopulate([])).toEqual({ on: {} });
- });
-});
diff --git a/packages/cms/server/src/lib/dz-populate.ts b/packages/cms/server/src/lib/dz-populate.ts
deleted file mode 100644
index 9fbdb4e..0000000
--- a/packages/cms/server/src/lib/dz-populate.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * Builds the document-service `populate` for the page `body` dynamic zone.
- *
- * Strapi 5 populates dynamic zones via a per-component `on` map (see Document
- * Service `populate` docs). `populate: '*'` on each component pulls that
- * component's first-level relations and MEDIA — which is what makes the
- * `preset-atom.image` media cross the REST contract (Spec §5.2 "Media").
- *
- * The component list is passed in (read by the caller from the page content-type
- * at request time) so the engine stays generic: it never hardcodes `custom-*`
- * block names — only what the registry currently admits.
- *
- * EXCEPT `preset-organism.columns`: its per-column `image` media and nested
- * `button` component sit TWO levels below the DZ member (organism → repeatable
- * `columns` → media/component), one past what `'*'` reaches — the same shape
- * that forces the navbar's deep populate in buildChromeDzPopulate. Without
- * this, a column's image/button silently comes back empty on the wire (the
- * admin still shows them). `content` is a `blocks` JSON scalar — no populate key.
- */
-export const buildBodyPopulate = (components: string[]): { body: { on: Record } } => ({
- body: {
- on: Object.fromEntries(
- components.map((uid) =>
- uid === 'preset-organism.columns'
- ? [uid, { populate: { columns: { populate: { image: true, button: true } } } }]
- : [uid, { populate: '*' as const }],
- ),
- ),
- },
-});
-
-/**
- * Builds the document-service `populate` VALUE for one site-setting chrome
- * dynamic zone (`header`/`footer`). Like the body, each admitted component gets
- * `populate: '*'` — EXCEPT `preset-organism.navbar`: its `items.page` relation
- * (internal link, resolved to its slug by the web side) and its `cta` component
- * sit one level below what `'*'` reaches, so they are deep-populated explicitly
- * (Spec §1/§3). Without this, every internal nav link silently falls back to
- * its raw `url` field — the exact failure the old headerNav populate prevented.
- */
-export const buildChromeDzPopulate = (components: string[]): { on: Record } => ({
- on: Object.fromEntries(
- components.map((uid) =>
- uid === 'preset-organism.navbar'
- ? [uid, { populate: { items: { populate: { page: { fields: ['slug'] } } }, cta: true } }]
- : [uid, { populate: '*' as const }],
- ),
- ),
-});
diff --git a/packages/cms/server/src/lib/inject-components.test.ts b/packages/cms/server/src/lib/inject-components.test.ts
index 5f16980..da09d6a 100644
--- a/packages/cms/server/src/lib/inject-components.test.ts
+++ b/packages/cms/server/src/lib/inject-components.test.ts
@@ -1,172 +1,8 @@
import { describe, expect, it } from 'vitest';
-import { admitCustomBlocks, injectComponents } from './inject-components';
+import { injectComponents } from './inject-components';
import pageSchema from '../content-types/page/schema.json';
import siteSettingSchema from '../content-types/site-setting/schema.json';
-const SITE_SETTING_UID = 'plugin::press-cms.site-setting';
-
-const PAGE_UID = 'plugin::press-cms.page';
-
-/**
- * Minimal Strapi double exposing only what admitCustomBlocks touches: the
- * content-types + components registries (`strapi.get`) and a no-op logger.
- */
-const makeStrapi = (opts: { page?: any; siteSetting?: any; componentUids?: string[] }) => {
- const contentTypes = new Map();
- if (opts.page) contentTypes.set(PAGE_UID, opts.page);
- if (opts.siteSetting) contentTypes.set(SITE_SETTING_UID, opts.siteSetting);
- const components = new Map((opts.componentUids ?? []).map((uid) => [uid, { uid }]));
- return {
- get: (key: string) =>
- key === 'content-types' ? contentTypes : key === 'components' ? components : undefined,
- log: { warn() {}, info() {}, debug() {}, error() {} },
- } as any;
-};
-
-const pageWithBody = (components: string[]) => ({
- uid: PAGE_UID,
- attributes: { body: { type: 'dynamiczone', components } },
-});
-
-const siteSettingWithChrome = (
- header: string[] = ['preset-organism.navbar'],
- footer: string[] = ['preset-organism.footer'],
-) => ({
- uid: SITE_SETTING_UID,
- attributes: {
- header: { type: 'dynamiczone', components: header },
- footer: { type: 'dynamiczone', components: footer },
- },
-});
-
-describe('admitCustomBlocks', () => {
- it('admits every custom block into the page body AND both chrome DZs (universal placement)', () => {
- const page = pageWithBody(['preset-atom.paragraph']);
- const siteSetting = siteSettingWithChrome();
- const strapi = makeStrapi({
- page,
- siteSetting,
- componentUids: ['preset-atom.paragraph', 'custom-organism.callout', 'custom-atom.badge'],
- });
-
- admitCustomBlocks({ strapi });
-
- // The adopter contract: the `custom*` category is stable and flows into ALL
- // three engine DZs. Placement is not a category concern on the custom side —
- // the editor decides where each block goes (unified-components).
- expect(page.attributes.body.components).toEqual([
- 'preset-atom.paragraph', 'custom-organism.callout', 'custom-atom.badge',
- ]);
- expect(siteSetting.attributes.header.components).toEqual([
- 'preset-organism.navbar', 'custom-organism.callout', 'custom-atom.badge',
- ]);
- expect(siteSetting.attributes.footer.components).toEqual([
- 'preset-organism.footer', 'custom-organism.callout', 'custom-atom.badge',
- ]);
- });
-
- it('is idempotent: an already-admitted custom block is not duplicated in any DZ', () => {
- const page = pageWithBody(['preset-atom.paragraph', 'custom-organism.callout']);
- const siteSetting = siteSettingWithChrome(
- ['preset-organism.navbar', 'custom-organism.callout'],
- ['preset-organism.footer', 'custom-organism.callout'],
- );
- const strapi = makeStrapi({
- page,
- siteSetting,
- componentUids: ['preset-atom.paragraph', 'custom-organism.callout'],
- });
-
- admitCustomBlocks({ strapi });
-
- expect(page.attributes.body.components).toEqual(['preset-atom.paragraph', 'custom-organism.callout']);
- expect(siteSetting.attributes.header.components).toEqual(['preset-organism.navbar', 'custom-organism.callout']);
- expect(siteSetting.attributes.footer.components).toEqual(['preset-organism.footer', 'custom-organism.callout']);
- });
-
- it('never admits a preset (non-custom) component — only the engine schema.json places those', () => {
- const page = pageWithBody(['preset-atom.paragraph']);
- const siteSetting = siteSettingWithChrome();
- const strapi = makeStrapi({
- page,
- siteSetting,
- componentUids: ['preset-atom.paragraph', 'preset-organism.navbar', 'preset-organism.hero'],
- });
-
- admitCustomBlocks({ strapi });
-
- // preset-* blocks are curated statically per content-type; admitCustomBlocks
- // touches only `custom*` categories, so nothing changes here.
- expect(page.attributes.body.components).toEqual(['preset-atom.paragraph']);
- expect(siteSetting.attributes.header.components).toEqual(['preset-organism.navbar']);
- expect(siteSetting.attributes.footer.components).toEqual(['preset-organism.footer']);
- });
-
- it('admits every custom LAYER (atom/molecule/organism) into every zone alike', () => {
- const page = pageWithBody(['preset-atom.paragraph']);
- const siteSetting = siteSettingWithChrome();
- const strapi = makeStrapi({
- page,
- siteSetting,
- componentUids: ['custom-atom.badge', 'custom-molecule.field', 'custom-organism.pricing'],
- });
-
- admitCustomBlocks({ strapi });
-
- // The atomic LAYER is organization only (picker grouping + type names); it
- // does NOT restrict placement — every custom-* lands in all three DZs.
- for (const zone of [
- page.attributes.body.components,
- siteSetting.attributes.header.components,
- siteSetting.attributes.footer.components,
- ]) {
- expect(zone).toContain('custom-atom.badge');
- expect(zone).toContain('custom-molecule.field');
- expect(zone).toContain('custom-organism.pricing');
- }
- });
-
- it('still admits a legacy bare custom.* block (forgiving migration path)', () => {
- const page = pageWithBody(['preset-atom.paragraph']);
- const siteSetting = siteSettingWithChrome();
- const strapi = makeStrapi({ page, siteSetting, componentUids: ['custom.legacy'] });
-
- admitCustomBlocks({ strapi });
-
- expect(page.attributes.body.components).toContain('custom.legacy');
- expect(siteSetting.attributes.header.components).toContain('custom.legacy');
- expect(siteSetting.attributes.footer.components).toContain('custom.legacy');
- });
-
- it('throws (aborts boot) when the page content-type is absent from the registry', () => {
- const strapi = makeStrapi({ siteSetting: siteSettingWithChrome(), componentUids: ['custom-organism.callout'] });
- expect(() => admitCustomBlocks({ strapi })).toThrow(/plugin::press-cms\.page.*absent/);
- });
-
- it('throws (aborts boot) when the site-setting content-type is absent from the registry', () => {
- const strapi = makeStrapi({ page: pageWithBody(['preset-atom.paragraph']), componentUids: ['custom-organism.callout'] });
- expect(() => admitCustomBlocks({ strapi })).toThrow(/plugin::press-cms\.site-setting.*absent/);
- });
-
- it('throws (aborts boot) when page.body is not a dynamic zone', () => {
- const strapi = makeStrapi({
- page: { uid: PAGE_UID, attributes: { body: { type: 'string' } } },
- siteSetting: siteSettingWithChrome(),
- componentUids: ['custom-organism.callout'],
- });
- expect(() => admitCustomBlocks({ strapi })).toThrow(/no 'body' dynamic zone/);
- });
-
- it('throws (aborts boot) when a chrome DZ is missing or malformed', () => {
- const strapi = makeStrapi({
- page: pageWithBody(['preset-atom.paragraph']),
- siteSetting: { uid: SITE_SETTING_UID, attributes: { header: { type: 'string' } } },
- componentUids: ['custom-organism.callout'],
- });
- expect(() => admitCustomBlocks({ strapi })).toThrow(/no 'header' dynamic zone/);
- });
-});
-
describe('injectComponents', () => {
const makeStrapi = () => {
const components = new Map();
@@ -211,31 +47,6 @@ describe('injectComponents', () => {
expect(components.get('preset-config.seo')?.modelType).toBe('component'); // others still injected
});
- it('injects preset-molecule.link but never admits it into the page Dynamic Zone', () => {
- // link is a molecule nested inside button/hero/cta/navbar. Injecting it
- // registers the component, but it must NOT leak into the page block palette —
- // only custom-* is admitted into the page body Dynamic Zone.
- const components = new Map();
- const page = pageWithBody(['preset-atom.paragraph']);
- const contentTypes = new Map([
- [PAGE_UID, page],
- [SITE_SETTING_UID, siteSettingWithChrome()],
- ]);
- const strapi = {
- get: (key: string) =>
- key === 'components' ? components : key === 'content-types' ? contentTypes : undefined,
- log: { warn() {}, info() {}, debug() {}, error() {} },
- } as any;
-
- injectComponents({ strapi });
- components.set('custom-organism.callout', { uid: 'custom-organism.callout' }); // a real custom block
- admitCustomBlocks({ strapi });
-
- expect(components.get('preset-molecule.link')?.modelType).toBe('component'); // injected
- expect(page.attributes.body.components).toContain('custom-organism.callout'); // custom admitted
- expect(page.attributes.body.components).not.toContain('preset-molecule.link'); // never admitted
- });
-
it('injects the organism sections (hero/cta) under category "preset-organism" with a derived globalId', () => {
const { strapi, components } = makeStrapi();
injectComponents({ strapi });
@@ -289,37 +100,37 @@ describe('injectComponents', () => {
});
});
-describe('page body dynamic zone (static organism admission)', () => {
- it('lists preset-organism.hero and preset-organism.cta alongside the preset-atom.* atoms', () => {
- // Body organisms are engine-owned and deterministic, so they are admitted
- // STATICALLY in the page schema (not via the dynamic custom-* push).
- const components = pageSchema.attributes.body.components as string[];
- expect(components).toContain('preset-organism.hero');
- expect(components).toContain('preset-organism.cta');
- expect(components).toContain('preset-organism.columns');
- // The atoms remain admitted, unchanged.
- expect(components).toContain('preset-atom.paragraph');
- expect(components).toContain('preset-atom.image');
- // Chrome organisms are NOT page-body blocks (placement: header/footer only).
- expect(components).not.toContain('preset-organism.navbar');
- expect(components).not.toContain('preset-organism.footer');
+describe('page.body customField (composition-builder storage, Spec §4)', () => {
+ it('points body at the builder JSON custom field — no dynamic zone, no component list', () => {
+ expect((pageSchema.attributes as any).body).toEqual({
+ type: 'customField',
+ customField: 'plugin::press-cms.builder',
+ });
+ });
+});
+
+describe('site-setting.pageDefaults customField (composition-builder storage, Spec §4)', () => {
+ it('points pageDefaults at the builder JSON custom field in slots mode', () => {
+ expect((siteSettingSchema.attributes as any).pageDefaults).toEqual({
+ type: 'customField',
+ customField: 'plugin::press-cms.builder',
+ options: { mode: 'slots' },
+ });
+ });
+
+ it('no longer carries the header/footer dynamic zones (BREAKING, Spec §4)', () => {
+ expect((siteSettingSchema.attributes as any).header).toBeUndefined();
+ expect((siteSettingSchema.attributes as any).footer).toBeUndefined();
});
});
describe('site-setting cookie-consent attribute (cookie-consent Spec §1)', () => {
- it('attaches preset-config.cookie-consent as a config component, never a DZ member', () => {
+ it('attaches preset-config.cookie-consent as a config component', () => {
expect((siteSettingSchema.attributes as any).cookieConsent).toEqual({
type: 'component',
repeatable: false,
component: 'preset-config.cookie-consent',
});
- // Config components stay out of every Dynamic Zone (the preset-config rule).
- for (const zone of ['header', 'footer'] as const) {
- const components = (siteSettingSchema.attributes as any)[zone].components as string[];
- expect(components).not.toContain('preset-config.cookie-consent');
- expect(components).not.toContain('preset-config.cookie-category');
- }
- expect(pageSchema.attributes.body.components).not.toContain('preset-config.cookie-consent');
});
it('nests the three engine-fixed category components (closed key set, Spec §2)', () => {
@@ -348,29 +159,3 @@ describe('site-setting cookie-consent attribute (cookie-consent Spec §1)', () =
});
});
});
-
-describe('site-setting chrome dynamic zones (static admission)', () => {
- it('admits preset-organism.navbar/footer + preset-atom.* atoms into header and footer, statically', () => {
- // Chrome DZs admit the chrome organisms and every atom (custom-* arrives
- // dynamically). Body organisms (hero/cta) are NOT chrome blocks.
- for (const zone of ['header', 'footer'] as const) {
- const components = (siteSettingSchema.attributes as any)[zone].components as string[];
- expect(components).toContain('preset-organism.navbar');
- expect(components).toContain('preset-organism.footer');
- expect(components).toContain('preset-atom.paragraph');
- expect(components).toContain('preset-atom.button');
- expect(components).not.toContain('preset-organism.hero');
- expect(components).not.toContain('preset-organism.cta');
- expect(components).not.toContain('preset-organism.columns');
- }
- });
-
- it('no longer carries the removed headerNav attribute (BREAKING, Spec §Migration)', () => {
- expect((siteSettingSchema.attributes as any).headerNav).toBeUndefined();
- });
-
- it('keeps the chrome organisms out of the page body Dynamic Zone', () => {
- expect(pageSchema.attributes.body.components).not.toContain('preset-organism.navbar');
- expect(pageSchema.attributes.body.components).not.toContain('preset-organism.footer');
- });
-});
diff --git a/packages/cms/server/src/lib/inject-components.ts b/packages/cms/server/src/lib/inject-components.ts
index cdde098..c597c48 100644
--- a/packages/cms/server/src/lib/inject-components.ts
+++ b/packages/cms/server/src/lib/inject-components.ts
@@ -39,8 +39,9 @@ export type PresetLayer = (typeof PRESET_LAYERS)[number];
* `config`. An adopter drops a component under `src/components/custom-${layer}/`;
* Strapi derives the `custom-${layer}` category from that folder name. Unlike
* preset, placement is NOT a category concern on the custom side — every custom
- * block is admitted into every engine Dynamic Zone (see admitCustomBlocks); the
- * editor decides placement in the picker.
+ * block is discovered straight from the components registry (`custom-*` category
+ * prefix, see `isCustomBlockUid`) by the builder palette and `serialize-schema`;
+ * the editor decides placement in the composition tree.
*/
export const CUSTOM_LAYERS = ['atom', 'molecule', 'organism', 'layout', 'template'] as const;
export type CustomLayer = (typeof CUSTOM_LAYERS)[number];
@@ -56,7 +57,10 @@ export type CustomLayer = (typeof CUSTOM_LAYERS)[number];
* Boot order (see @strapi/core/dist/Strapi.js `load`):
* 1. providers.register -> loadApplicationContext (app components AND plugin
* content-types loaded in parallel; module.load() registers CTs before register)
- * 2. plugins REGISTER -> THIS hook (inject the preset-* palette, then admit custom-*)
+ * 2. plugins REGISTER -> THIS hook (inject the preset-* palette). Custom blocks
+ * are never "admitted" anywhere — the builder palette and `serialize-schema`
+ * discover them straight from the components registry (the `custom-*` category
+ * prefix stays the whole extension-point contract).
* 3. bootstrap -> transformContentTypesToModels([...contentTypes, ...components])
*
* The injected object mirrors the exact shape produced by Strapi's own loader
@@ -130,79 +134,5 @@ export const injectComponents = ({ strapi }: { strapi: Core.Strapi }): void => {
}
};
-/**
- * The engine Dynamic Zones that admit adopter blocks: the page `body` and the two
- * site-setting chrome zones. Every adopter component (category `custom-*`) is
- * admitted into ALL of them — placement is not a category concern on the custom
- * side (custom is organized by atomic LAYER; the editor decides placement in the
- * picker). The engine never restricts adopter blocks; it only curates the
- * placement of its OWN preset blocks, statically, in each content-type schema.json.
- */
-const ENGINE_DZ_TARGETS: Array<{ uid: string; attribute: string }> = [
- { uid: 'plugin::press-cms.page', attribute: 'body' },
- { uid: 'plugin::press-cms.site-setting', attribute: 'header' },
- { uid: 'plugin::press-cms.site-setting', attribute: 'footer' },
-];
-
/** An adopter block: any registered component under a `custom` / `custom-${layer}` category. */
-const isCustomBlockUid = (uid: string): boolean => uid.startsWith('custom.') || uid.startsWith('custom-');
-
-/**
- * Admits adopter components into EVERY engine Dynamic Zone.
- *
- * Contract: the folder an adopter drops a component under
- * (/src/components/custom-${layer}/) declares its atomic LAYER — used for
- * palette grouping and generated type names — while the block itself is usable in
- * any zone. The engine NEVER names specific adopter blocks; the `custom*` category
- * prefix is the whole extension-point contract.
- *
- * Timing: loadApplicationContext runs loadPlugins + loadComponents in parallel
- * (Promise.all). module.load() registers plugin content-types synchronously when
- * the plugin module is added, so both engine content-types ARE present in the
- * content-types registry by the time plugin register() fires.
- */
-export const admitCustomBlocks = ({ strapi }: { strapi: Core.Strapi }): void => {
- const componentRegistry = strapi.get('components');
- const customUids = [...componentRegistry.keys()].filter(isCustomBlockUid);
-
- for (const { uid, attribute } of ENGINE_DZ_TARGETS) {
- const contentType = strapi.get('content-types').get(uid);
-
- // Invariant: the engine ships both content-types, so they MUST be registered
- // by the time this register hook fires. If one isn't, custom block admission
- // cannot happen and the engine would boot half-broken (blocks silently absent
- // from the DZ → incomplete types → unknown components). Fail loud, abort boot.
- if (!contentType) {
- throw new Error(
- `[press-cms] invariant violated: '${uid}' is absent from the content-types ` +
- 'registry at register time — custom blocks cannot be admitted, aborting boot. ' +
- 'Likely an engine content-type load failure or a Strapi version mismatch.',
- );
- }
-
- const dzAttr = (contentType.attributes as Record)?.[attribute];
-
- if (!dzAttr || dzAttr.type !== 'dynamiczone' || !Array.isArray(dzAttr.components)) {
- throw new Error(
- `[press-cms] invariant violated: '${uid}' has no '${attribute}' dynamic zone ` +
- '(or it has an unexpected shape) at register time. The engine Dynamic Zones are the ' +
- 'extension point for custom blocks — aborting boot. Likely a changed schema or a ' +
- 'Strapi version mismatch.',
- );
- }
-
- const admitted: string[] = [];
- for (const customUid of customUids) {
- if (!dzAttr.components.includes(customUid)) {
- dzAttr.components.push(customUid);
- admitted.push(customUid);
- }
- }
-
- if (admitted.length > 0) {
- strapi.log.info(`[press-cms] admitted custom blocks into ${uid}#${attribute}: ${admitted.join(', ')}`);
- } else {
- strapi.log.debug(`[press-cms] no custom blocks to admit into ${uid}#${attribute}`);
- }
- }
-};
+export const isCustomBlockUid = (uid: string): boolean => uid.startsWith('custom.') || uid.startsWith('custom-');
diff --git a/packages/cms/server/src/register.ts b/packages/cms/server/src/register.ts
index 6ac8951..b1dd988 100644
--- a/packages/cms/server/src/register.ts
+++ b/packages/cms/server/src/register.ts
@@ -1,11 +1,17 @@
import type { Core } from '@strapi/strapi';
import { injectComponents } from './lib/inject-components';
-import { admitCustomBlocks } from './lib/inject-components';
import { quietSchemaHttpLog } from './lib/quiet-schema-log';
const register = ({ strapi }: { strapi: Core.Strapi }) => {
+ // The composition-builder storage primitive (Spec §4): a JSON custom field.
+ // Declared before content-types are transformed into models so the
+ // `plugin::press-cms.builder` reference in page/site-setting schema.json resolves.
+ strapi.customFields.register({
+ name: 'builder',
+ plugin: 'press-cms',
+ type: 'json',
+ });
injectComponents({ strapi });
- admitCustomBlocks({ strapi });
quietSchemaHttpLog(strapi);
};
From 651c71d98bcee2b36720c046dcb529d175803303 Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Mon, 20 Jul 2026 19:47:42 -0300
Subject: [PATCH 08/31] feat(cms): preset-layout descriptors + full-palette
schema serialization with tree contract version
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../server/src/components/layout/column.json | 17 +
.../src/components/layout/container.json | 21 ++
.../cms/server/src/components/layout/row.json | 19 ++
.../cms/server/src/lib/inject-components.ts | 18 +-
.../server/src/lib/serialize-schema.test.ts | 320 ++----------------
.../cms/server/src/lib/serialize-schema.ts | 58 ++--
6 files changed, 118 insertions(+), 335 deletions(-)
create mode 100644 packages/cms/server/src/components/layout/column.json
create mode 100644 packages/cms/server/src/components/layout/container.json
create mode 100644 packages/cms/server/src/components/layout/row.json
diff --git a/packages/cms/server/src/components/layout/column.json b/packages/cms/server/src/components/layout/column.json
new file mode 100644
index 0000000..5dc71e2
--- /dev/null
+++ b/packages/cms/server/src/components/layout/column.json
@@ -0,0 +1,17 @@
+{
+ "collectionName": "components_preset_layout_columns",
+ "info": {
+ "displayName": "Column",
+ "icon": "stack",
+ "description": "A tree column: the recursion point — nests blocks and further rows"
+ },
+ "options": {},
+ "attributes": {
+ "container": { "type": "component", "repeatable": false, "component": "preset-layout.container" }
+ },
+ "config": {
+ "metadatas": {
+ "container": { "edit": { "label": "Container" } }
+ }
+ }
+}
diff --git a/packages/cms/server/src/components/layout/container.json b/packages/cms/server/src/components/layout/container.json
new file mode 100644
index 0000000..6a92629
--- /dev/null
+++ b/packages/cms/server/src/components/layout/container.json
@@ -0,0 +1,21 @@
+{
+ "collectionName": "components_preset_layout_containers",
+ "info": {
+ "displayName": "Container",
+ "icon": "expand",
+ "description": "The shared curated attribute surface (width / gap / vertical alignment) carried by every children-bearing tree node"
+ },
+ "options": {},
+ "attributes": {
+ "width": { "type": "enumeration", "enum": ["prose", "lg", "full"] },
+ "gap": { "type": "enumeration", "enum": ["compact", "normal", "spacious"] },
+ "verticalAlign": { "type": "enumeration", "enum": ["top", "center", "bottom"] }
+ },
+ "config": {
+ "metadatas": {
+ "width": { "edit": { "label": "Width", "description": "Top-level rows only; ignored when nested." } },
+ "gap": { "edit": { "label": "Gap", "description": "Row: space between columns. Column/page: vertical rhythm." } },
+ "verticalAlign": { "edit": { "label": "Vertical alignment", "description": "Row: aligns cells. Column: places content within the cell." } }
+ }
+ }
+}
diff --git a/packages/cms/server/src/components/layout/row.json b/packages/cms/server/src/components/layout/row.json
new file mode 100644
index 0000000..7857d5c
--- /dev/null
+++ b/packages/cms/server/src/components/layout/row.json
@@ -0,0 +1,19 @@
+{
+ "collectionName": "components_preset_layout_rows",
+ "info": {
+ "displayName": "Row",
+ "icon": "apps",
+ "description": "A tree row: the ratio defines the column split; container carries the shared layout attributes"
+ },
+ "options": {},
+ "attributes": {
+ "ratio": { "type": "enumeration", "enum": ["50-50", "33-67", "67-33", "33-33-33", "25-25-25-25"], "default": "50-50" },
+ "container": { "type": "component", "repeatable": false, "component": "preset-layout.container" }
+ },
+ "config": {
+ "metadatas": {
+ "ratio": { "edit": { "label": "Column layout", "description": "How the columns split the width on desktop; every layout stacks on phones." } },
+ "container": { "edit": { "label": "Container" } }
+ }
+ }
+}
diff --git a/packages/cms/server/src/lib/inject-components.ts b/packages/cms/server/src/lib/inject-components.ts
index c597c48..bd80d3e 100644
--- a/packages/cms/server/src/lib/inject-components.ts
+++ b/packages/cms/server/src/lib/inject-components.ts
@@ -12,6 +12,9 @@ import heroSchema from '../components/organisms/hero.json';
import ctaSchema from '../components/organisms/cta.json';
import navbarSchema from '../components/organisms/navbar.json';
import footerSchema from '../components/organisms/footer.json';
+import layoutContainerSchema from '../components/layout/container.json';
+import layoutRowSchema from '../components/layout/row.json';
+import layoutColumnSchema from '../components/layout/column.json';
import seoSchema from '../components/config/seo.json';
import themeColorsSchema from '../components/config/theme-colors.json';
import themeRadiusSchema from '../components/config/theme-radius.json';
@@ -26,10 +29,12 @@ import { toGlobalId } from './global-id';
* Declaring the layers here is the single source of truth for the palette's shape
* — categories are never spelled as ad-hoc string literals scattered across files.
*
- * `layout` and `template` are RESERVED — no components ship in this task:
- * - `layout` ← delivered by the Grid System task (container/grid/row/column).
+ * `template` is RESERVED — no components ship in this task:
* - `template` ← installed by plugins (e.g. Site/Plugin for Company).
- * They are declared so the model is complete and picker labels are ready.
+ * It is declared so the model is complete and picker labels are ready.
+ * `layout` is no longer reserved: `preset-layout.container/row/column` are the
+ * composition-tree node descriptors (Spec §4) the admin builder drives its
+ * layout-node forms from.
*/
export const PRESET_LAYERS = ['atom', 'molecule', 'organism', 'config', 'layout', 'template'] as const;
export type PresetLayer = (typeof PRESET_LAYERS)[number];
@@ -88,6 +93,13 @@ const ENGINE_COMPONENTS: Array<{ layer: PresetLayer; name: string; schema: Recor
{ layer: 'organism', name: 'cta', schema: ctaSchema as Record },
{ layer: 'organism', name: 'navbar', schema: navbarSchema as Record },
{ layer: 'organism', name: 'footer', schema: footerSchema as Record },
+ // Layout — the tree-node descriptors (Spec §4): pure schema for the builder's
+ // layout-node forms. `preset-layout.container` is the shared ContainerAttrs
+ // surface, referenced by row/column via `component:` fields (the link/nav-item
+ // nesting pattern) so the "Container" form section is defined exactly once.
+ { layer: 'layout', name: 'container', schema: layoutContainerSchema as Record },
+ { layer: 'layout', name: 'row', schema: layoutRowSchema as Record },
+ { layer: 'layout', name: 'column', schema: layoutColumnSchema as Record },
// Config — non-block settings referenced by the Site Settings single type (seo /
// theme behave like a settings form; cookie-consent is plugin #1's editable
// surface). Injected like the rest but never admitted into a Dynamic Zone.
diff --git a/packages/cms/server/src/lib/serialize-schema.test.ts b/packages/cms/server/src/lib/serialize-schema.test.ts
index 1effe97..15bf17e 100644
--- a/packages/cms/server/src/lib/serialize-schema.test.ts
+++ b/packages/cms/server/src/lib/serialize-schema.test.ts
@@ -3,29 +3,12 @@ import { serializeSchema } from './serialize-schema';
const fakeStrapi = () => {
const components = new Map([
- ['preset-atom.paragraph', {
- uid: 'preset-atom.paragraph',
- attributes: {
- content: { type: 'blocks', required: true },
- // noise that must be stripped:
- createdAt: { type: 'datetime', private: true },
- },
- }],
- ['preset-atom.image', {
- uid: 'preset-atom.image',
- attributes: {
- image: { type: 'media', multiple: false, allowedTypes: ['images'], required: true },
- caption: { type: 'string' },
- },
- }],
- ['custom-organism.callout', {
- uid: 'custom-organism.callout',
- attributes: {
- message: { type: 'string', required: true },
- variant: { type: 'enumeration', enum: ['info', 'warning', 'success'], default: 'info' },
- },
- }],
- ['preset-atom.unused', { uid: 'preset-atom.unused', attributes: { x: { type: 'string' } } }],
+ ['preset-atom.paragraph', { uid: 'preset-atom.paragraph', attributes: { content: { type: 'text', required: true }, createdAt: { type: 'datetime', private: true } } }],
+ ['preset-molecule.link', { uid: 'preset-molecule.link', attributes: { label: { type: 'string' }, page: { type: 'relation', relation: 'oneToOne', target: 'plugin::press-cms.page' }, url: { type: 'string' }, newTab: { type: 'boolean', default: false } } }],
+ ['preset-layout.container', { uid: 'preset-layout.container', attributes: { width: { type: 'enumeration', enum: ['prose', 'lg', 'full'] } } }],
+ ['custom-organism.callout', { uid: 'custom-organism.callout', attributes: { message: { type: 'string', required: true } } }],
+ // NOT part of the palette — must be excluded:
+ ['admin.something', { uid: 'admin.something', attributes: { x: { type: 'string' } } }],
]);
const contentTypes: Record = {
'plugin::press-cms.page': {
@@ -34,7 +17,7 @@ const fakeStrapi = () => {
attributes: {
title: { type: 'string', required: true },
slug: { type: 'uid', targetField: 'title' },
- body: { type: 'dynamiczone', components: ['preset-atom.paragraph', 'preset-atom.image', 'custom-organism.callout'] },
+ body: { type: 'customField', customField: 'plugin::press-cms.builder' },
},
},
'plugin::press-cms.site-setting': {
@@ -42,8 +25,7 @@ const fakeStrapi = () => {
info: { singularName: 'site-setting', pluralName: 'site-settings', displayName: 'Site Settings' },
attributes: {
name: { type: 'string' },
- header: { type: 'dynamiczone', components: [] },
- footer: { type: 'dynamiczone', components: [] },
+ pageDefaults: { type: 'customField', customField: 'plugin::press-cms.builder' },
},
},
};
@@ -54,284 +36,34 @@ const fakeStrapi = () => {
};
describe('serializeSchema', () => {
- it('emits the page content-type and only the DZ-admitted components (runtime view)', () => {
+ it('serves the tree contract version', () => {
+ expect(serializeSchema(fakeStrapi()).tree).toEqual({ version: 1 });
+ });
+
+ it('serves the FULL registered palette — every preset-* and custom-* uid, nothing else', () => {
const out = serializeSchema(fakeStrapi());
- expect(Object.keys(out.contentTypes).sort()).toEqual([
- 'plugin::press-cms.page',
- 'plugin::press-cms.site-setting',
- ]);
- // preset-atom.unused is registered but NOT in page.body → excluded
expect(Object.keys(out.components).sort()).toEqual([
- 'custom-organism.callout', 'preset-atom.image', 'preset-atom.paragraph',
+ 'custom-organism.callout',
+ 'preset-atom.paragraph',
+ 'preset-layout.container',
+ 'preset-molecule.link',
]);
});
- it('keeps only the contract attribute keys and drops private/internal noise', () => {
+ it('keeps contract attribute keys (target included, for page refs) and drops noise', () => {
const out = serializeSchema(fakeStrapi());
- // paragraph: the `blocks` type and `required` flag survive; createdAt noise is dropped.
expect(out.components['preset-atom.paragraph'].attributes).toEqual({
- content: { type: 'blocks', required: true },
- });
- // image: single required media + optional caption.
- expect(out.components['preset-atom.image'].attributes).toEqual({
- image: { type: 'media', multiple: false, allowedTypes: ['images'], required: true },
- caption: { type: 'string' },
- });
- expect(out.components['custom-organism.callout'].attributes.variant).toEqual({
- type: 'enumeration', enum: ['info', 'warning', 'success'], default: 'info',
- });
- expect(out.contentTypes['plugin::press-cms.page'].attributes.body).toEqual({
- type: 'dynamiczone', components: ['preset-atom.paragraph', 'preset-atom.image', 'custom-organism.callout'],
- });
- });
-
- it('serializes preset-organism.hero and preset-organism.cta with their flat attributes (runtime view)', () => {
- const components = new Map([
- ['preset-organism.hero', {
- uid: 'preset-organism.hero',
- attributes: {
- eyebrow: { type: 'string' },
- title: { type: 'string', required: true },
- subtitle: { type: 'text' },
- image: { type: 'media', multiple: false, allowedTypes: ['images'] },
- ctaLabel: { type: 'string' },
- ctaHref: { type: 'string' },
- align: { type: 'enumeration', enum: ['left', 'center'], default: 'left' },
- },
- }],
- ['preset-organism.cta', {
- uid: 'preset-organism.cta',
- attributes: {
- title: { type: 'string', required: true },
- subtitle: { type: 'text' },
- buttonLabel: { type: 'string', required: true },
- buttonHref: { type: 'string', required: true },
- align: { type: 'enumeration', enum: ['left', 'center'], default: 'left' },
- },
- }],
- ]);
- const strapi = {
- contentType: () => ({
- uid: 'plugin::press-cms.page',
- info: {},
- attributes: { body: { type: 'dynamiczone', components: ['preset-organism.hero', 'preset-organism.cta'] } },
- }),
- get: (key: string) => (key === 'components' ? components : undefined),
- } as any;
-
- const out = serializeSchema(strapi);
- expect(Object.keys(out.components).sort()).toEqual(['preset-organism.cta', 'preset-organism.hero']);
- // Flat fields survive verbatim — no serialize-schema change is needed (Spec §5.1/§7).
- expect(out.components['preset-organism.hero'].attributes.title).toEqual({ type: 'string', required: true });
- expect(out.components['preset-organism.hero'].attributes.align).toEqual({
- type: 'enumeration', enum: ['left', 'center'], default: 'left',
- });
- expect(out.components['preset-organism.cta'].attributes.buttonHref).toEqual({ type: 'string', required: true });
- });
-
- it('throws (not a cryptic null-deref) when the page content-type is not registered', () => {
- const strapi = { contentType: () => undefined, get: () => new Map() } as any;
- expect(() => serializeSchema(strapi)).toThrow(/plugin::press-cms\.page.*not registered/);
- });
-
- it('throws instead of silently dropping a DZ-admitted component missing from the registry', () => {
- const strapi = {
- contentType: () => ({
- uid: 'plugin::press-cms.page',
- info: {},
- attributes: { body: { type: 'dynamiczone', components: ['custom-organism.ghost'] } },
- }),
- get: (key: string) => (key === 'components' ? new Map() : undefined),
- } as any;
- expect(() => serializeSchema(strapi)).toThrow(/custom-organism\.ghost.*absent from the components registry/);
- });
-});
-
-describe('serializeSchema — chrome dynamic zones', () => {
- const chromeStrapi = () => {
- const components = new Map([
- ['preset-organism.navbar', {
- uid: 'preset-organism.navbar',
- attributes: {
- items: { type: 'component', repeatable: true, component: 'preset-molecule.nav-item' },
- cta: { type: 'component', repeatable: false, component: 'preset-atom.button' },
- },
- }],
- ['preset-organism.footer', { uid: 'preset-organism.footer', attributes: { text: { type: 'string' } } }],
- ['preset-molecule.nav-item', {
- uid: 'preset-molecule.nav-item',
- attributes: {
- label: { type: 'string', required: true },
- page: { type: 'relation', relation: 'oneToOne', target: 'plugin::press-cms.page' },
- url: { type: 'string' },
- newTab: { type: 'boolean', default: false },
- },
- }],
- ['preset-atom.button', {
- uid: 'preset-atom.button',
- attributes: {
- label: { type: 'string', required: true },
- href: { type: 'string', required: true },
- variant: { type: 'enumeration', enum: ['primary', 'secondary'], default: 'primary', required: true },
- },
- }],
- ['preset-atom.paragraph', { uid: 'preset-atom.paragraph', attributes: { content: { type: 'blocks', required: true } } }],
- ]);
- const contentTypes: Record = {
- 'plugin::press-cms.page': {
- uid: 'plugin::press-cms.page',
- info: {},
- attributes: {
- title: { type: 'string', required: true },
- body: { type: 'dynamiczone', components: ['preset-atom.paragraph', 'preset-atom.button'] },
- },
- },
- 'plugin::press-cms.site-setting': {
- uid: 'plugin::press-cms.site-setting',
- info: {},
- attributes: {
- name: { type: 'string' },
- header: { type: 'dynamiczone', components: ['preset-organism.navbar', 'preset-atom.paragraph'] },
- footer: { type: 'dynamiczone', components: ['preset-organism.footer'] },
- },
- },
- };
- return {
- contentType: (uid: string) => contentTypes[uid],
- get: (key: string) => (key === 'components' ? components : undefined),
- } as any;
- };
-
- it('serializes the site-setting content-type with its two chrome DZ attributes', () => {
- const out = serializeSchema(chromeStrapi());
- const siteSetting = out.contentTypes['plugin::press-cms.site-setting'];
- expect(siteSetting.attributes.header).toEqual({
- type: 'dynamiczone', components: ['preset-organism.navbar', 'preset-atom.paragraph'],
- });
- expect(siteSetting.attributes.footer).toEqual({
- type: 'dynamiczone', components: ['preset-organism.footer'],
- });
- });
-
- it('walks all three DZs into the components map', () => {
- const out = serializeSchema(chromeStrapi());
- for (const uid of ['preset-atom.paragraph', 'preset-atom.button', 'preset-organism.navbar', 'preset-organism.footer']) {
- expect(out.components[uid]).toBeDefined();
- }
- });
-
- it('follows nested component references — preset-molecule.nav-item enters the map without being a DZ member (Spec §2)', () => {
- const out = serializeSchema(chromeStrapi());
- expect(out.components['preset-molecule.nav-item']).toBeDefined();
- expect(out.components['preset-molecule.nav-item'].attributes.label).toEqual({ type: 'string', required: true });
- // The nested reference keeps its component/repeatable keys so the generator
- // can type it (Spec §2).
- expect(out.components['preset-organism.navbar'].attributes.items).toEqual({
- type: 'component', repeatable: true, component: 'preset-molecule.nav-item',
- });
- expect(out.components['preset-organism.navbar'].attributes.cta).toEqual({
- type: 'component', repeatable: false, component: 'preset-atom.button',
- });
- });
-
- it('does NOT pull site-setting config components (preset-config.seo & co.) into the map', () => {
- // Nested-ref walking starts from DZ admissions only — content-type component
- // attributes (seo, themeColors…) are not part of the block contract.
- const out = serializeSchema(chromeStrapi());
- expect(out.components['preset-config.seo']).toBeUndefined();
- });
-
- it('fail-fast covers nested refs: a referenced component missing from the registry throws', () => {
- const strapi = chromeStrapi();
- (strapi.get('components') as Map).delete('preset-molecule.nav-item');
- expect(() => serializeSchema(strapi)).toThrow(/preset-molecule\.nav-item.*absent from the components registry/);
- });
-
- it('throws when the site-setting content-type is not registered', () => {
- const strapi = chromeStrapi();
- const inner = strapi.contentType;
- strapi.contentType = (uid: string) =>
- uid === 'plugin::press-cms.site-setting' ? undefined : inner(uid);
- expect(() => serializeSchema(strapi)).toThrow(/plugin::press-cms\.site-setting.*not registered/);
- });
-});
-
-describe('serializeSchema — two-level nesting (preset-organism.columns)', () => {
- // The navbar coverage above proves ONE level of nested refs; the columns chain
- // is the first TWO-level chain (DZ member → repeatable molecule → nested atom).
- // This pins the BFS as genuinely transitive, not accidentally depth-1.
- const columnsStrapi = () => {
- const components = new Map([
- ['preset-organism.columns', {
- uid: 'preset-organism.columns',
- attributes: {
- ratio: { type: 'enumeration', enum: ['50-50', '33-67', '67-33', '33-33-33', '25-25-25-25'], default: '50-50' },
- gap: { type: 'enumeration', enum: ['compact', 'normal', 'spacious'], default: 'normal' },
- verticalAlign: { type: 'enumeration', enum: ['top', 'center', 'bottom'], default: 'top' },
- columns: { type: 'component', repeatable: true, component: 'preset-molecule.column' },
- },
- }],
- ['preset-molecule.column', {
- uid: 'preset-molecule.column',
- attributes: {
- content: { type: 'blocks' },
- image: { type: 'media', multiple: false, allowedTypes: ['images'] },
- button: { type: 'component', repeatable: false, component: 'preset-atom.button' },
- },
- }],
- ['preset-atom.button', {
- uid: 'preset-atom.button',
- attributes: {
- label: { type: 'string', required: true },
- href: { type: 'string', required: true },
- variant: { type: 'enumeration', enum: ['primary', 'secondary'], default: 'primary', required: true },
- },
- }],
- ]);
- const contentTypes: Record = {
- 'plugin::press-cms.page': {
- uid: 'plugin::press-cms.page',
- info: {},
- attributes: { body: { type: 'dynamiczone', components: ['preset-organism.columns'] } },
- },
- 'plugin::press-cms.site-setting': {
- uid: 'plugin::press-cms.site-setting',
- info: {},
- attributes: {
- header: { type: 'dynamiczone', components: [] },
- footer: { type: 'dynamiczone', components: [] },
- },
- },
- };
- return {
- contentType: (uid: string) => contentTypes[uid],
- get: (key: string) => (key === 'components' ? components : undefined),
- } as any;
- };
-
- it('follows the chain columns → column → button: all three enter the map from one DZ admission', () => {
- const out = serializeSchema(columnsStrapi());
- expect(Object.keys(out.components).sort()).toEqual([
- 'preset-atom.button', 'preset-molecule.column', 'preset-organism.columns',
- ]);
- // The layout enums survive verbatim (KEEP: type/enum/default).
- expect(out.components['preset-organism.columns'].attributes.ratio).toEqual({
- type: 'enumeration', enum: ['50-50', '33-67', '67-33', '33-33-33', '25-25-25-25'], default: '50-50',
- });
- // The nested refs keep component/repeatable so the generator can type them.
- expect(out.components['preset-organism.columns'].attributes.columns).toEqual({
- type: 'component', repeatable: true, component: 'preset-molecule.column',
+ content: { type: 'text', required: true },
});
- expect(out.components['preset-molecule.column'].attributes.button).toEqual({
- type: 'component', repeatable: false, component: 'preset-atom.button',
+ expect(out.components['preset-molecule.link'].attributes.page).toEqual({
+ type: 'relation', relation: 'oneToOne', target: 'plugin::press-cms.page',
});
- // min/max are authoring guards, never part of the wire contract (KEEP excludes them).
- expect(out.components['preset-organism.columns'].attributes.columns).not.toHaveProperty('min');
});
- it('fail-fast reaches depth 2: a missing second-level ref (the button) throws', () => {
- const strapi = columnsStrapi();
- (strapi.get('components') as Map).delete('preset-atom.button');
- expect(() => serializeSchema(strapi)).toThrow(/preset-atom\.button.*absent from the components registry/);
+ it('still fails loud when an engine content-type is missing', () => {
+ const broken = fakeStrapi();
+ const orig = broken.contentType;
+ broken.contentType = (uid: string) => (uid === 'plugin::press-cms.page' ? undefined : orig(uid));
+ expect(() => serializeSchema(broken)).toThrow(/not registered/);
});
});
diff --git a/packages/cms/server/src/lib/serialize-schema.ts b/packages/cms/server/src/lib/serialize-schema.ts
index 300a5df..c5dd902 100644
--- a/packages/cms/server/src/lib/serialize-schema.ts
+++ b/packages/cms/server/src/lib/serialize-schema.ts
@@ -1,5 +1,7 @@
import type { Core } from '@strapi/strapi';
import type { Attr, PressSchema } from '@ogs-tech/press-shared';
+import { PRESS_TREE_VERSION } from '@ogs-tech/press-shared';
+import { isCustomBlockUid } from './inject-components';
// Re-exported so the type stays importable from this module, while the single
// source of truth lives in @ogs-tech/press-shared (shared with @ogs-tech/press-web's generator).
@@ -11,7 +13,10 @@ const SITE_SETTING_UID = 'plugin::press-cms.site-setting';
// The only attribute keys that are part of the public type-sync contract. Any
// other key Strapi attaches (private flags, column hints, plugin internals) is
// deliberately dropped so the generated types stay stable across Strapi patches.
-const KEEP = ['type', 'required', 'enum', 'default', 'components', 'multiple', 'allowedTypes', 'repeatable', 'component'] as const;
+const KEEP = ['type', 'required', 'enum', 'default', 'components', 'multiple', 'allowedTypes', 'repeatable', 'component', 'relation', 'target'] as const;
+
+/** Palette membership: engine presets + adopter customs; Strapi-internal categories never serve. */
+const isPaletteUid = (uid: string): boolean => uid.startsWith('preset-') || isCustomBlockUid(uid);
const pickAttributes = (attributes: Record): Record => {
const out: Record = {};
@@ -44,53 +49,30 @@ const requireContentType = (strapi: Core.Strapi, uid: string) => {
};
/**
- * Serializes the engine's RUNTIME view (Spec §5.2 golden rule of the type-sync
- * loop): the page AND site-setting content-types plus exactly the components
- * currently admitted into the three engine Dynamic Zones (page `body`,
- * site-setting `header`/`footer`), FOLLOWING nested component references —
- * `preset-organism.navbar` references `preset-molecule.nav-item` and
- * `preset-atom.button`, so those enter the map even though they are not direct DZ
- * members (Spec §2). Reading the live
- * registry (not loose JSON on disk) means the generator can never disagree with
- * what Strapi actually serves.
+ * Serializes the engine's RUNTIME view: the page AND site-setting content-types
+ * plus the FULL registered palette (Spec §4/§5.2) — every `preset-*` + `custom-*`
+ * component uid. The `body`/`pageDefaults` composition tree references components
+ * by uid at arbitrary depth via the `plugin::press-cms.builder` custom field, so
+ * there is no Dynamic Zone admission list left to walk; the palette is exactly
+ * "every component the registry knows about that belongs to press" (Strapi's own
+ * `admin.*`/other-plugin categories never serve). Reading the live registry (not
+ * loose JSON on disk) means the generator can never disagree with what Strapi
+ * actually serves. Also carries `tree.version` (`PRESS_TREE_VERSION`) so the
+ * builder/generator can refuse a tree version they don't understand.
*/
export const serializeSchema = (strapi: Core.Strapi): PressSchema => {
const page = requireContentType(strapi, PAGE_UID);
const siteSetting = requireContentType(strapi, SITE_SETTING_UID);
const registry = strapi.get('components') as Map;
- const dzComponents: string[] = [
- ...(page.attributes?.body?.components ?? []),
- ...(siteSetting.attributes?.header?.components ?? []),
- ...(siteSetting.attributes?.footer?.components ?? []),
- ];
-
const components: PressSchema['components'] = {};
- // Breadth-first over DZ admissions + nested `type: 'component'` references, so
- // nested-only components (preset-molecule.nav-item) enter the map exactly once.
- const queue = [...new Set(dzComponents)];
- while (queue.length > 0) {
- const uid = queue.shift()!;
- if (components[uid]) continue;
- const comp = registry.get(uid);
- // A uid reachable from an engine DZ but missing from the components registry
- // is a contract violation (Spec §5.2: the schema must never disagree with
- // what Strapi serves). Fail loud rather than silently emit incomplete types.
- if (!comp) {
- throw new Error(
- `[press-cms] cannot serialize schema: component '${uid}' is admitted into an engine ` +
- 'Dynamic Zone (or referenced by an admitted component) but absent from the components ' +
- 'registry — the generated types would be incomplete. Aborting the schema response.',
- );
- }
- const attributes = pickAttributes(comp.attributes);
- components[uid] = { uid, attributes };
- for (const attr of Object.values(attributes)) {
- if (attr.type === 'component' && typeof attr.component === 'string') queue.push(attr.component);
- }
+ for (const [uid, comp] of registry.entries()) {
+ if (!isPaletteUid(uid)) continue;
+ components[uid] = { uid, attributes: pickAttributes(comp.attributes) };
}
return {
+ tree: { version: PRESS_TREE_VERSION },
contentTypes: {
[page.uid]: { uid: page.uid, info: page.info, attributes: pickAttributes(page.attributes) },
[siteSetting.uid]: { uid: siteSetting.uid, info: siteSetting.info, attributes: pickAttributes(siteSetting.attributes) },
From 46d1071c7daf92ad8aec8ceabc4442d10c150734 Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Mon, 20 Jul 2026 19:56:53 -0300
Subject: [PATCH 09/31] test(cms): cover preset-layout descriptor registration
+ container/row enum values
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../server/src/lib/inject-components.test.ts | 29 +++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/packages/cms/server/src/lib/inject-components.test.ts b/packages/cms/server/src/lib/inject-components.test.ts
index da09d6a..b08ffa3 100644
--- a/packages/cms/server/src/lib/inject-components.test.ts
+++ b/packages/cms/server/src/lib/inject-components.test.ts
@@ -22,6 +22,7 @@ describe('injectComponents', () => {
'preset-molecule.link',
'preset-organism.hero', 'preset-organism.cta',
'preset-organism.navbar', 'preset-organism.footer',
+ 'preset-layout.container', 'preset-layout.row', 'preset-layout.column',
'preset-config.seo', 'preset-config.theme-colors', 'preset-config.theme-radius',
'preset-config.cookie-category', 'preset-config.cookie-consent',
];
@@ -98,6 +99,34 @@ describe('injectComponents', () => {
expect(components.get('preset-organism.footer')?.globalId).toBe('ComponentPresetOrganismFooter');
expect(components.get('preset-organism.footer')?.attributes).toMatchObject({ text: { type: 'string' } });
});
+
+ it('injects the preset-layout tree-node descriptors (container/row) with the real JSON enum values', () => {
+ // preset-layout.container is the shared curated attribute surface (width/gap/
+ // verticalAlign) referenced by row/column via `component:` fields; row's ratio
+ // is the closed column-split enum. These are JSON-sourced (no TS shape check),
+ // so a typo like "spacius" would otherwise pass every other check.
+ const { strapi, components } = makeStrapi();
+ injectComponents({ strapi });
+
+ expect(components.get('preset-layout.container')?.modelType).toBe('component');
+ expect(components.get('preset-layout.container')?.category).toBe('preset-layout');
+ expect(components.get('preset-layout.container')?.attributes).toMatchObject({
+ width: { type: 'enumeration', enum: ['prose', 'lg', 'full'] },
+ gap: { type: 'enumeration', enum: ['compact', 'normal', 'spacious'] },
+ verticalAlign: { type: 'enumeration', enum: ['top', 'center', 'bottom'] },
+ });
+
+ expect(components.get('preset-layout.row')?.modelType).toBe('component');
+ expect(components.get('preset-layout.row')?.category).toBe('preset-layout');
+ expect(components.get('preset-layout.row')?.attributes).toMatchObject({
+ ratio: {
+ type: 'enumeration',
+ enum: ['50-50', '33-67', '67-33', '33-33-33', '25-25-25-25'],
+ default: '50-50',
+ },
+ container: { type: 'component', repeatable: false, component: 'preset-layout.container' },
+ });
+ });
});
describe('page.body customField (composition-builder storage, Spec §4)', () => {
From a0f1f7debec936b9fe92fad89a1cad6594c4899f Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Mon, 20 Jul 2026 20:04:43 -0300
Subject: [PATCH 10/31] feat(cms): serve-time hydration of media assetId and
page documentId refs in composition trees
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/cms/server/src/controllers/page.ts | 5 +-
.../server/src/controllers/site-setting.ts | 3 +-
.../cms/server/src/lib/hydrate-tree.test.ts | 93 ++++++++++++++
packages/cms/server/src/lib/hydrate-tree.ts | 120 ++++++++++++++++++
packages/cms/server/src/lib/serve-hydrated.ts | 66 ++++++++++
5 files changed, 284 insertions(+), 3 deletions(-)
create mode 100644 packages/cms/server/src/lib/hydrate-tree.test.ts
create mode 100644 packages/cms/server/src/lib/hydrate-tree.ts
create mode 100644 packages/cms/server/src/lib/serve-hydrated.ts
diff --git a/packages/cms/server/src/controllers/page.ts b/packages/cms/server/src/controllers/page.ts
index dbd19c7..32a476c 100644
--- a/packages/cms/server/src/controllers/page.ts
+++ b/packages/cms/server/src/controllers/page.ts
@@ -1,4 +1,5 @@
import type { Core } from '@strapi/strapi';
+import { hydratePageDoc, hydratePageDocs } from '../lib/serve-hydrated';
const PAGE_UID = 'plugin::press-cms.page';
@@ -11,7 +12,7 @@ const PAGE_UID = 'plugin::press-cms.page';
const page = ({ strapi }: { strapi: Core.Strapi }) => ({
async find(ctx: any) {
const data = await strapi.documents(PAGE_UID as any).findMany({ status: 'published' });
- ctx.body = { data };
+ ctx.body = { data: await hydratePageDocs(strapi, data as any[]) };
},
async findOne(ctx: any) {
@@ -22,7 +23,7 @@ const page = ({ strapi }: { strapi: Core.Strapi }) => ({
limit: 1,
});
if (!doc) return ctx.notFound();
- ctx.body = { data: doc };
+ ctx.body = { data: await hydratePageDoc(strapi, doc as any) };
},
});
diff --git a/packages/cms/server/src/controllers/site-setting.ts b/packages/cms/server/src/controllers/site-setting.ts
index e3ddd46..b6a0338 100644
--- a/packages/cms/server/src/controllers/site-setting.ts
+++ b/packages/cms/server/src/controllers/site-setting.ts
@@ -1,4 +1,5 @@
import type { Core } from '@strapi/strapi';
+import { hydrateSiteSetting } from '../lib/serve-hydrated';
const SITE_SETTING_UID = 'plugin::press-cms.site-setting';
@@ -36,7 +37,7 @@ const siteSetting = ({ strapi }: { strapi: Core.Strapi }) => {
const data = await strapi
.documents(SITE_SETTING_UID as any)
.findFirst({ populate: settingsPopulate() as any });
- ctx.body = { data };
+ ctx.body = { data: await hydrateSiteSetting(strapi, data as any) };
},
};
};
diff --git a/packages/cms/server/src/lib/hydrate-tree.test.ts b/packages/cms/server/src/lib/hydrate-tree.test.ts
new file mode 100644
index 0000000..bb7321d
--- /dev/null
+++ b/packages/cms/server/src/lib/hydrate-tree.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from 'vitest';
+import { collectNodeRefs, collectTreeRefs, hydrateNodeArray, hydrateTree } from './hydrate-tree';
+
+const SCHEMAS: Record = {
+ 'preset-atom.image': { attributes: { image: { type: 'media', multiple: false }, caption: { type: 'string' } } },
+ 'preset-atom.button': { attributes: { link: { type: 'component', component: 'preset-molecule.link' }, variant: { type: 'enumeration' } } },
+ 'preset-molecule.link': { attributes: { label: { type: 'string' }, page: { type: 'relation', relation: 'oneToOne', target: 'plugin::press-cms.page' }, url: { type: 'string' }, newTab: { type: 'boolean' } } },
+ 'preset-organism.navbar': { attributes: { items: { type: 'component', repeatable: true, component: 'preset-molecule.link' }, cta: { type: 'component', component: 'preset-atom.button' } } },
+};
+const getSchema = (uid: string) => SCHEMAS[uid];
+
+const resolvers = {
+ media: (assetId: number) => (assetId === 7 ? { assetId: 7, url: '/uploads/x.png', width: 480, height: 270, alternativeText: null, name: 'x.png', mime: 'image/png' } : null),
+ page: (documentId: string) => (documentId === 'home-doc' ? { documentId: 'home-doc', slug: 'home' } : { documentId }),
+};
+
+const nodes = [
+ { id: 'b1', type: 'block', component: 'preset-atom.image', data: { image: { assetId: 7 }, caption: 'c' } },
+ {
+ id: 'r1', type: 'row', ratio: '50-50', children: [
+ { id: 'c1', type: 'column', children: [
+ { id: 'b2', type: 'block', component: 'preset-organism.navbar', data: {
+ items: [{ label: 'Home', page: { documentId: 'home-doc' } }, { label: 'Ext', url: 'https://x' }],
+ cta: { link: { label: 'Go', page: { documentId: 'gone-doc' } }, variant: 'primary' },
+ } },
+ ] },
+ { id: 'c2', type: 'column', children: [] },
+ ],
+ },
+ { id: 'b3', type: 'block', component: 'unknown.block', data: { anything: true } },
+];
+
+describe('collectNodeRefs', () => {
+ it('collects media + page refs through rows, columns and nested components', () => {
+ const refs = collectNodeRefs(nodes, getSchema);
+ expect(refs.assetIds).toEqual([7]);
+ expect(refs.pageDocumentIds.sort()).toEqual(['gone-doc', 'home-doc']);
+ });
+
+ it('returns empty refs for malformed input', () => {
+ expect(collectNodeRefs(null, getSchema)).toEqual({ assetIds: [], pageDocumentIds: [] });
+ expect(collectNodeRefs('nope', getSchema)).toEqual({ assetIds: [], pageDocumentIds: [] });
+ });
+});
+
+describe('hydrateNodeArray', () => {
+ it('replaces refs with resolved shapes, deep, without mutating input', () => {
+ const out = hydrateNodeArray(nodes, getSchema, resolvers) as any[];
+ expect(out[0].data.image).toEqual({ assetId: 7, url: '/uploads/x.png', width: 480, height: 270, alternativeText: null, name: 'x.png', mime: 'image/png' });
+ const navbar = out[1].children[0].children[0].data;
+ expect(navbar.items[0].page).toEqual({ documentId: 'home-doc', slug: 'home' });
+ expect(navbar.items[1]).toEqual({ label: 'Ext', url: 'https://x' });
+ expect(navbar.cta.link.page).toEqual({ documentId: 'gone-doc' }); // unpublished → ref kept, no slug
+ expect((nodes[0] as any).data.image).toEqual({ assetId: 7 }); // input untouched
+ });
+
+ it('nulls a media ref whose asset is gone and leaves unknown components untouched', () => {
+ const out = hydrateNodeArray(
+ [{ id: 'x', type: 'block', component: 'preset-atom.image', data: { image: { assetId: 999 } } }],
+ getSchema,
+ resolvers,
+ ) as any[];
+ expect(out[0].data.image).toBeNull();
+ const unknown = hydrateNodeArray([nodes[2]], getSchema, resolvers) as any[];
+ expect(unknown[0]).toEqual(nodes[2]);
+ });
+});
+
+describe('tree-level helpers', () => {
+ const tree = {
+ version: 1,
+ root: {
+ type: 'layout',
+ header: { mode: 'custom', children: [nodes[0]] },
+ footer: { mode: 'inherit' },
+ children: [nodes[1]],
+ },
+ };
+
+ it('collects and hydrates root children AND custom slot children', () => {
+ const refs = collectTreeRefs(tree, getSchema);
+ expect(refs.assetIds).toEqual([7]);
+ expect(refs.pageDocumentIds.sort()).toEqual(['gone-doc', 'home-doc']);
+ const out = hydrateTree(tree, getSchema, resolvers) as any;
+ expect(out.root.header.children[0].data.image.url).toBe('/uploads/x.png');
+ expect(out.root.footer).toEqual({ mode: 'inherit' });
+ });
+
+ it('passes malformed trees through untouched', () => {
+ expect(hydrateTree(null, getSchema, resolvers)).toBeNull();
+ expect(hydrateTree({ nope: 1 }, getSchema, resolvers)).toEqual({ nope: 1 });
+ });
+});
diff --git a/packages/cms/server/src/lib/hydrate-tree.ts b/packages/cms/server/src/lib/hydrate-tree.ts
new file mode 100644
index 0000000..e2409d6
--- /dev/null
+++ b/packages/cms/server/src/lib/hydrate-tree.ts
@@ -0,0 +1,120 @@
+/**
+ * Media/page-ref hydration for composition trees (Spec §3 "Media inside `data`
+ * is a reference, not a snapshot"). The builder stores `{ assetId }` for media
+ * and `{ documentId }` for page relations; this walker — driven by the
+ * components registry so it never hardcodes block shapes — swaps them for fresh
+ * values at serve time, so the wire never rots.
+ *
+ * Pure: resolvers are injected; the strapi-facing batching lives in
+ * serve-hydrated.ts. Never mutates its input; unknown components and malformed
+ * values pass through untouched (the web renderer owns tolerance).
+ */
+
+export interface TreeRefs {
+ assetIds: number[];
+ pageDocumentIds: string[];
+}
+
+export interface TreeResolvers {
+ media(assetId: number): Record | null;
+ page(documentId: string): Record | null;
+}
+
+export type SchemaLookup = (uid: string) => { attributes?: Record } | undefined;
+
+const PAGE_TARGET = 'plugin::press-cms.page';
+
+const isRecord = (v: unknown): v is Record =>
+ typeof v === 'object' && v !== null && !Array.isArray(v);
+
+type Visitor = {
+ media(assetId: number): unknown;
+ page(documentId: string): unknown;
+};
+
+/** Walks one component's data guided by its schema attributes; returns a new object. */
+function walkData(data: unknown, attributes: Record | undefined, getSchema: SchemaLookup, visit: Visitor): unknown {
+ if (!isRecord(data) || !attributes) return data;
+ const out: Record = { ...data };
+ for (const [name, attr] of Object.entries(attributes)) {
+ const value = out[name];
+ if (value === undefined || value === null) continue;
+ if (attr?.type === 'media' && isRecord(value) && typeof value.assetId === 'number') {
+ out[name] = visit.media(value.assetId);
+ } else if (attr?.type === 'relation' && attr?.target === PAGE_TARGET && isRecord(value) && typeof value.documentId === 'string') {
+ out[name] = visit.page(value.documentId);
+ } else if (attr?.type === 'component' && typeof attr?.component === 'string') {
+ const nested = getSchema(attr.component)?.attributes;
+ out[name] = attr.repeatable && Array.isArray(value)
+ ? value.map((item) => walkData(item, nested, getSchema, visit))
+ : walkData(value, nested, getSchema, visit);
+ }
+ }
+ return out;
+}
+
+function walkNodes(nodes: unknown, getSchema: SchemaLookup, visit: Visitor): unknown {
+ if (!Array.isArray(nodes)) return nodes;
+ return nodes.map((node) => {
+ if (!isRecord(node)) return node;
+ if (node.type === 'block' && typeof node.component === 'string') {
+ const attributes = getSchema(node.component)?.attributes;
+ return { ...node, data: walkData(node.data, attributes, getSchema, visit) };
+ }
+ if ((node.type === 'row' || node.type === 'column') && Array.isArray(node.children)) {
+ return { ...node, children: walkNodes(node.children, getSchema, visit) };
+ }
+ return node;
+ });
+}
+
+function slotChildren(tree: unknown): { root: Record | null; slots: Array<'header' | 'footer'> } {
+ if (!isRecord(tree) || !isRecord(tree.root)) return { root: null, slots: [] };
+ const slots: Array<'header' | 'footer'> = [];
+ for (const key of ['header', 'footer'] as const) {
+ const slot = (tree.root as Record)[key];
+ if (isRecord(slot) && slot.mode === 'custom') slots.push(key);
+ }
+ return { root: tree.root as Record, slots };
+}
+
+export function collectNodeRefs(nodes: unknown, getSchema: SchemaLookup): TreeRefs {
+ const assetIds = new Set();
+ const pageDocumentIds = new Set();
+ walkNodes(nodes, getSchema, {
+ media: (assetId) => (assetIds.add(assetId), { assetId }),
+ page: (documentId) => (pageDocumentIds.add(documentId), { documentId }),
+ });
+ return { assetIds: [...assetIds], pageDocumentIds: [...pageDocumentIds] };
+}
+
+export function hydrateNodeArray(nodes: unknown, getSchema: SchemaLookup, resolvers: TreeResolvers): unknown {
+ return walkNodes(nodes, getSchema, {
+ media: (assetId) => resolvers.media(assetId),
+ page: (documentId) => resolvers.page(documentId),
+ });
+}
+
+export function collectTreeRefs(tree: unknown, getSchema: SchemaLookup): TreeRefs {
+ const { root, slots } = slotChildren(tree);
+ if (!root) return { assetIds: [], pageDocumentIds: [] };
+ const all = [
+ collectNodeRefs(root.children, getSchema),
+ ...slots.map((key) => collectNodeRefs((root[key] as Record).children, getSchema)),
+ ];
+ return {
+ assetIds: [...new Set(all.flatMap((r) => r.assetIds))],
+ pageDocumentIds: [...new Set(all.flatMap((r) => r.pageDocumentIds))],
+ };
+}
+
+export function hydrateTree(tree: unknown, getSchema: SchemaLookup, resolvers: TreeResolvers): unknown {
+ const { root, slots } = slotChildren(tree);
+ if (!root) return tree;
+ const nextRoot: Record = { ...root, children: hydrateNodeArray(root.children, getSchema, resolvers) };
+ for (const key of slots) {
+ const slot = root[key] as Record;
+ nextRoot[key] = { ...slot, children: hydrateNodeArray(slot.children, getSchema, resolvers) };
+ }
+ return { ...(tree as Record), root: nextRoot };
+}
diff --git a/packages/cms/server/src/lib/serve-hydrated.ts b/packages/cms/server/src/lib/serve-hydrated.ts
new file mode 100644
index 0000000..e5c9a97
--- /dev/null
+++ b/packages/cms/server/src/lib/serve-hydrated.ts
@@ -0,0 +1,66 @@
+import type { Core } from '@strapi/strapi';
+import { collectNodeRefs, collectTreeRefs, hydrateNodeArray, hydrateTree, type SchemaLookup, type TreeRefs, type TreeResolvers } from './hydrate-tree';
+
+const PAGE_UID = 'plugin::press-cms.page';
+
+const schemaLookup = (strapi: Core.Strapi): SchemaLookup => {
+ const registry = strapi.get('components') as Map;
+ return (uid) => registry.get(uid);
+};
+
+/** One batched query per ref kind; a missing asset → null, a missing/unpublished page → ref without slug. */
+async function buildResolvers(strapi: Core.Strapi, refs: TreeRefs): Promise {
+ const files = refs.assetIds.length
+ ? await strapi.db.query('plugin::upload.file').findMany({ where: { id: { $in: refs.assetIds } } })
+ : [];
+ const fileById = new Map>(
+ files.map((f: any) => [f.id, {
+ assetId: f.id, url: f.url, width: f.width, height: f.height,
+ alternativeText: f.alternativeText ?? null, name: f.name, mime: f.mime,
+ }]),
+ );
+ const pages = refs.pageDocumentIds.length
+ ? await strapi.documents(PAGE_UID as any).findMany({
+ filters: { documentId: { $in: refs.pageDocumentIds } },
+ status: 'published',
+ fields: ['slug'],
+ })
+ : [];
+ const pageByDoc = new Map>(
+ (pages as any[]).map((p) => [p.documentId, { documentId: p.documentId, slug: p.slug }]),
+ );
+ return {
+ media: (assetId) => fileById.get(assetId) ?? null,
+ page: (documentId) => pageByDoc.get(documentId) ?? { documentId },
+ };
+}
+
+export async function hydratePageDoc(strapi: Core.Strapi, doc: T | null): Promise {
+ if (!doc || doc.body === undefined || doc.body === null) return doc;
+ const getSchema = schemaLookup(strapi);
+ const resolvers = await buildResolvers(strapi, collectTreeRefs(doc.body, getSchema));
+ return { ...doc, body: hydrateTree(doc.body, getSchema, resolvers) };
+}
+
+export async function hydratePageDocs(strapi: Core.Strapi, docs: T[]): Promise {
+ return Promise.all(docs.map((doc) => hydratePageDoc(strapi, doc) as Promise));
+}
+
+export async function hydrateSiteSetting(strapi: Core.Strapi, data: T | null): Promise {
+ const pd = data?.pageDefaults as { header?: unknown; footer?: unknown } | null | undefined;
+ if (!data || !pd || typeof pd !== 'object') return data;
+ const getSchema = schemaLookup(strapi);
+ const refs = [collectNodeRefs(pd.header, getSchema), collectNodeRefs(pd.footer, getSchema)];
+ const resolvers = await buildResolvers(strapi, {
+ assetIds: [...new Set(refs.flatMap((r) => r.assetIds))],
+ pageDocumentIds: [...new Set(refs.flatMap((r) => r.pageDocumentIds))],
+ });
+ return {
+ ...data,
+ pageDefaults: {
+ ...pd,
+ header: hydrateNodeArray(pd.header, getSchema, resolvers),
+ footer: hydrateNodeArray(pd.footer, getSchema, resolvers),
+ },
+ };
+}
From 1dc540f634e87b8c96ae191a2e93ed9b5e94efff Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Tue, 21 Jul 2026 19:29:06 -0300
Subject: [PATCH 11/31] fix(cms): batch page-list hydration into one query pair
+ serve-hydrated coverage
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/cms/server/src/controllers/page.ts | 2 +-
.../cms/server/src/lib/serve-hydrated.test.ts | 131 ++++++++++++++++++
packages/cms/server/src/lib/serve-hydrated.ts | 24 +++-
3 files changed, 152 insertions(+), 5 deletions(-)
create mode 100644 packages/cms/server/src/lib/serve-hydrated.test.ts
diff --git a/packages/cms/server/src/controllers/page.ts b/packages/cms/server/src/controllers/page.ts
index 32a476c..3de8c22 100644
--- a/packages/cms/server/src/controllers/page.ts
+++ b/packages/cms/server/src/controllers/page.ts
@@ -23,7 +23,7 @@ const page = ({ strapi }: { strapi: Core.Strapi }) => ({
limit: 1,
});
if (!doc) return ctx.notFound();
- ctx.body = { data: await hydratePageDoc(strapi, doc as any) };
+ ctx.body = { data: await hydratePageDoc(strapi, doc) };
},
});
diff --git a/packages/cms/server/src/lib/serve-hydrated.test.ts b/packages/cms/server/src/lib/serve-hydrated.test.ts
new file mode 100644
index 0000000..f72b850
--- /dev/null
+++ b/packages/cms/server/src/lib/serve-hydrated.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect, it, vi } from 'vitest';
+import { hydratePageDoc, hydratePageDocs, hydrateSiteSetting } from './serve-hydrated';
+
+/** Mirrors the fixtures in hydrate-tree.test.ts: image → media, link → relation→page, navbar → repeatable+nested. */
+const SCHEMAS: Record = {
+ 'preset-atom.image': { attributes: { image: { type: 'media', multiple: false }, caption: { type: 'string' } } },
+ 'preset-atom.button': { attributes: { link: { type: 'component', component: 'preset-molecule.link' }, variant: { type: 'enumeration' } } },
+ 'preset-molecule.link': { attributes: { label: { type: 'string' }, page: { type: 'relation', relation: 'oneToOne', target: 'plugin::press-cms.page' }, url: { type: 'string' }, newTab: { type: 'boolean' } } },
+ 'preset-organism.navbar': { attributes: { items: { type: 'component', repeatable: true, component: 'preset-molecule.link' }, cta: { type: 'component', component: 'preset-atom.button' } } },
+};
+
+type File = { id: number; url: string; width: number; height: number; alternativeText: string | null; name: string; mime: string };
+type PageRow = { documentId: string; slug: string };
+
+/**
+ * A fake strapi exposing exactly what serve-hydrated calls: `get('components')`
+ * for the schema registry, `db.query('plugin::upload.file').findMany` for
+ * assets, `documents(PAGE_UID).findMany` for page refs. Both findMany stubs are
+ * counting spies (vi.fn tracks every call in `.mock.calls`) that return canned
+ * rows filtered to the requested `$in` ids — enough to prove BOTH the wire
+ * shapes AND (via `.mock.calls.length`) that a whole doc list shares one query
+ * pair instead of one per doc.
+ */
+function makeStrapi(files: File[], pages: PageRow[]) {
+ const components = new Map(Object.entries(SCHEMAS));
+ const fileFindMany = vi.fn(async (args: any) => {
+ const ids: number[] = args.where.id.$in;
+ return files.filter((f) => ids.includes(f.id));
+ });
+ const pageFindMany = vi.fn(async (args: any) => {
+ const ids: string[] = args.filters.documentId.$in;
+ return pages.filter((p) => ids.includes(p.documentId));
+ });
+ const strapi = {
+ get: (key: string) => (key === 'components' ? components : undefined),
+ db: { query: (_uid: string) => ({ findMany: fileFindMany }) },
+ documents: (_uid: string) => ({ findMany: pageFindMany }),
+ } as any;
+ return { strapi, fileFindMany, pageFindMany };
+}
+
+const imageBlock = (id: string, assetId: number) => ({ id, type: 'block', component: 'preset-atom.image', data: { image: { assetId }, caption: id } });
+const linkBlock = (id: string, documentId: string) => ({ id, type: 'block', component: 'preset-atom.button', data: { link: { label: id, page: { documentId } }, variant: 'primary' } });
+const bodyOf = (children: unknown[]) => ({ version: 1, root: { type: 'layout', children } });
+
+describe('hydratePageDocs — batched across the whole list', () => {
+ const FILES: File[] = [{ id: 7, url: '/uploads/x.png', width: 480, height: 270, alternativeText: null, name: 'x.png', mime: 'image/png' }];
+ const PAGES: PageRow[] = [{ documentId: 'a', slug: 'a-page' }];
+
+ const docA = { documentId: 'a', slug: 'a-page', body: bodyOf([imageBlock('b1', 7)]) };
+ const docB = { documentId: 'b', slug: 'b-page', body: bodyOf([linkBlock('b2', 'a'), imageBlock('b3', 999)]) };
+ const docC = { documentId: 'c', slug: 'c-page', body: bodyOf([linkBlock('b4', 'zzz-missing')]) };
+ const docNoBody = { documentId: 'd', slug: 'd-page', body: null };
+
+ it('issues exactly one upload.file findMany and one pages findMany for the whole list (the N+1 regression guard)', async () => {
+ const { strapi, fileFindMany, pageFindMany } = makeStrapi(FILES, PAGES);
+ await hydratePageDocs(strapi, [docA, docB, docC, docNoBody, null as any]);
+ expect(fileFindMany).toHaveBeenCalledTimes(1);
+ expect(pageFindMany).toHaveBeenCalledTimes(1);
+ // combined refs across ALL docs, not just the first
+ expect(fileFindMany.mock.calls[0][0].where.id.$in.sort()).toEqual([7, 999]);
+ expect(pageFindMany.mock.calls[0][0].filters.documentId.$in.sort()).toEqual(['a', 'zzz-missing']);
+ });
+
+ it('hydrates each doc to the exact wire shape and passes null/body-less docs through untouched', async () => {
+ const { strapi } = makeStrapi(FILES, PAGES);
+ const out = await hydratePageDocs(strapi, [docA, docB, docC, docNoBody, null as any]);
+
+ expect((out[0] as any).body.root.children[0].data.image).toEqual({
+ assetId: 7, url: '/uploads/x.png', width: 480, height: 270, alternativeText: null, name: 'x.png', mime: 'image/png',
+ });
+
+ const bDoc = out[1] as any;
+ expect(bDoc.body.root.children[0].data.link.page).toEqual({ documentId: 'a', slug: 'a-page' }); // published → slug attached
+ expect(bDoc.body.root.children[1].data.image).toBeNull(); // missing asset → null
+
+ const cDoc = out[2] as any;
+ const missingPageRef = cDoc.body.root.children[0].data.link.page;
+ expect(missingPageRef).toEqual({ documentId: 'zzz-missing' });
+ expect(missingPageRef).not.toHaveProperty('slug'); // unpublished/missing → ref kept, no slug key
+
+ expect(out[3]).toBe(docNoBody); // body-less doc: same reference, untouched
+ expect(out[4]).toBeNull(); // null doc entry: passed through
+ });
+});
+
+describe('hydratePageDoc — single doc still resolves correctly', () => {
+ it('hydrates media and page refs for one doc', async () => {
+ const { strapi } = makeStrapi(
+ [{ id: 7, url: '/uploads/x.png', width: 480, height: 270, alternativeText: null, name: 'x.png', mime: 'image/png' }],
+ [{ documentId: 'a', slug: 'a-page' }],
+ );
+ const doc = { documentId: 'a', slug: 'a-page', body: bodyOf([imageBlock('b1', 7)]) };
+ const out = await hydratePageDoc(strapi, doc);
+ expect((out as any).body.root.children[0].data.image.url).toBe('/uploads/x.png');
+ });
+
+ it('passes a null doc through untouched', async () => {
+ const { strapi } = makeStrapi([], []);
+ expect(await hydratePageDoc(strapi, null)).toBeNull();
+ });
+});
+
+describe('hydrateSiteSetting', () => {
+ const FILES: File[] = [{ id: 7, url: '/uploads/x.png', width: 480, height: 270, alternativeText: null, name: 'x.png', mime: 'image/png' }];
+ const PAGES: PageRow[] = [{ documentId: 'a', slug: 'a-page' }];
+
+ it('hydrates pageDefaults.header/.footer bare Node[]', async () => {
+ const { strapi } = makeStrapi(FILES, PAGES);
+ const data = {
+ name: 'Acme',
+ pageDefaults: { header: [imageBlock('h1', 7)], footer: [linkBlock('f1', 'a')] },
+ };
+ const out = await hydrateSiteSetting(strapi, data);
+ expect((out as any).pageDefaults.header[0].data.image).toEqual({
+ assetId: 7, url: '/uploads/x.png', width: 480, height: 270, alternativeText: null, name: 'x.png', mime: 'image/png',
+ });
+ expect((out as any).pageDefaults.footer[0].data.link.page).toEqual({ documentId: 'a', slug: 'a-page' });
+ });
+
+ it('is tolerant of a null record — passes through, no throw', async () => {
+ const { strapi } = makeStrapi([], []);
+ await expect(hydrateSiteSetting(strapi, null)).resolves.toBeNull();
+ });
+
+ it('is tolerant of a pageDefaults-less record — passes through, no throw', async () => {
+ const { strapi } = makeStrapi([], []);
+ const data: { name: string; pageDefaults?: unknown } = { name: 'Acme' };
+ await expect(hydrateSiteSetting(strapi, data)).resolves.toEqual(data);
+ });
+});
diff --git a/packages/cms/server/src/lib/serve-hydrated.ts b/packages/cms/server/src/lib/serve-hydrated.ts
index e5c9a97..e80f037 100644
--- a/packages/cms/server/src/lib/serve-hydrated.ts
+++ b/packages/cms/server/src/lib/serve-hydrated.ts
@@ -35,15 +35,31 @@ async function buildResolvers(strapi: Core.Strapi, refs: TreeRefs): Promise(strapi: Core.Strapi, doc: T | null): Promise {
+/** Applies already-built resolvers to one doc's body — the shared last step of both hydration paths below. */
+function hydrateBody>(doc: T, getSchema: SchemaLookup, resolvers: TreeResolvers): T {
+ return { ...doc, body: hydrateTree(doc.body, getSchema, resolvers) };
+}
+
+export async function hydratePageDoc>(strapi: Core.Strapi, doc: T | null): Promise {
if (!doc || doc.body === undefined || doc.body === null) return doc;
const getSchema = schemaLookup(strapi);
const resolvers = await buildResolvers(strapi, collectTreeRefs(doc.body, getSchema));
- return { ...doc, body: hydrateTree(doc.body, getSchema, resolvers) };
+ return hydrateBody(doc, getSchema, resolvers);
}
-export async function hydratePageDocs(strapi: Core.Strapi, docs: T[]): Promise {
- return Promise.all(docs.map((doc) => hydratePageDoc(strapi, doc) as Promise));
+/** One resolver build for the WHOLE list (kills the per-doc N+1 on the unpaginated `GET /pages`): collect refs across every doc's body, resolve once, then hydrate each doc against the shared resolvers. */
+export async function hydratePageDocs>(strapi: Core.Strapi, docs: T[]): Promise {
+ const getSchema = schemaLookup(strapi);
+ const combinedRefs = docs.reduce((refs, doc) => {
+ if (!doc || doc.body === undefined || doc.body === null) return refs;
+ const docRefs = collectTreeRefs(doc.body, getSchema);
+ return {
+ assetIds: [...refs.assetIds, ...docRefs.assetIds],
+ pageDocumentIds: [...refs.pageDocumentIds, ...docRefs.pageDocumentIds],
+ };
+ }, { assetIds: [], pageDocumentIds: [] });
+ const resolvers = await buildResolvers(strapi, combinedRefs);
+ return docs.map((doc) => (!doc || doc.body === undefined || doc.body === null ? doc : hydrateBody(doc, getSchema, resolvers)));
}
export async function hydrateSiteSetting(strapi: Core.Strapi, data: T | null): Promise {
From 8e6537a79f5951530cd2f8721aa4ccf7d3722672 Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Tue, 21 Jul 2026 19:31:02 -0300
Subject: [PATCH 12/31] feat(cms): lifecycle validation backstop for
composition-tree writes
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/cms/server/src/bootstrap.ts | 17 +++++++
.../cms/server/src/lib/validate-write.test.ts | 43 ++++++++++++++++++
packages/cms/server/src/lib/validate-write.ts | 44 +++++++++++++++++++
3 files changed, 104 insertions(+)
create mode 100644 packages/cms/server/src/lib/validate-write.test.ts
create mode 100644 packages/cms/server/src/lib/validate-write.ts
diff --git a/packages/cms/server/src/bootstrap.ts b/packages/cms/server/src/bootstrap.ts
index 39a6762..0b514a2 100644
--- a/packages/cms/server/src/bootstrap.ts
+++ b/packages/cms/server/src/bootstrap.ts
@@ -1,8 +1,25 @@
import type { Core } from '@strapi/strapi';
import { seedSiteSetting } from './lib/seed-site-setting';
import { seedCookieConsent } from './lib/seed-cookie-consent';
+import { assertValidPageWrite, assertValidSiteSettingWrite } from './lib/validate-write';
const bootstrap = async ({ strapi }: { strapi: Core.Strapi }) => {
+ // Write-path backstop (Spec §4): the admin builder can't produce an invalid
+ // tree; raw API writes are rejected here with actionable messages.
+ const guard = (event: any): void => {
+ if (event.model?.uid === 'plugin::press-cms.page') assertValidPageWrite(event.params?.data);
+ else assertValidSiteSettingWrite(event.params?.data);
+ };
+ strapi.db.lifecycles.subscribe({
+ models: ['plugin::press-cms.page', 'plugin::press-cms.site-setting'],
+ beforeCreate(event: any) {
+ guard(event);
+ },
+ beforeUpdate(event: any) {
+ guard(event);
+ },
+ } as any);
+
await seedSiteSetting(strapi);
// Order matters: seedCookieConsent updates the record seedSiteSetting creates
// (and self-heals — without marking its flag — if the record is absent).
diff --git a/packages/cms/server/src/lib/validate-write.test.ts b/packages/cms/server/src/lib/validate-write.test.ts
new file mode 100644
index 0000000..e652311
--- /dev/null
+++ b/packages/cms/server/src/lib/validate-write.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+import { assertValidPageWrite, assertValidSiteSettingWrite } from './validate-write';
+
+const validTree = {
+ version: 1,
+ root: { type: 'layout', header: { mode: 'inherit' }, footer: { mode: 'inherit' }, children: [] },
+};
+
+describe('assertValidPageWrite', () => {
+ it('passes valid trees and skips writes without a body (partial update)', () => {
+ expect(() => assertValidPageWrite({ body: validTree })).not.toThrow();
+ expect(() => assertValidPageWrite({ title: 'x' })).not.toThrow();
+ expect(() => assertValidPageWrite(undefined)).not.toThrow();
+ });
+
+ it('rejects structural errors AND stripped-attr warnings (strict write)', () => {
+ expect(() => assertValidPageWrite({ body: { version: 99 } })).toThrow(/unsupported tree version/);
+ const warned = {
+ version: 1,
+ root: { type: 'layout', header: { mode: 'none' }, footer: { mode: 'none' }, children: [
+ { id: 'r', type: 'row', ratio: '50-50', container: { width: 'xl' }, children: [
+ { id: 'c', type: 'column', children: [] },
+ ] },
+ ] },
+ };
+ expect(() => assertValidPageWrite({ body: warned })).toThrow(/width/);
+ });
+
+ it('tolerates a JSON string body (db layer serialization)', () => {
+ expect(() => assertValidPageWrite({ body: JSON.stringify(validTree) })).not.toThrow();
+ expect(() => assertValidPageWrite({ body: 'not json {' })).toThrow(/invalid composition tree/);
+ });
+});
+
+describe('assertValidSiteSettingWrite', () => {
+ it('validates each pageDefaults slot as a Node[] and skips absent slots', () => {
+ expect(() => assertValidSiteSettingWrite({ pageDefaults: { header: [], footer: [] } })).not.toThrow();
+ expect(() => assertValidSiteSettingWrite({ name: 'x' })).not.toThrow();
+ expect(() =>
+ assertValidSiteSettingWrite({ pageDefaults: { header: [{ id: 'c', type: 'column', children: [] }] } }),
+ ).toThrow(/only legal directly under a row/);
+ });
+});
diff --git a/packages/cms/server/src/lib/validate-write.ts b/packages/cms/server/src/lib/validate-write.ts
new file mode 100644
index 0000000..1c9fa48
--- /dev/null
+++ b/packages/cms/server/src/lib/validate-write.ts
@@ -0,0 +1,44 @@
+/**
+ * Write-path validation backstop (Spec §4): the builder UI makes invalid trees
+ * unreachable; these lifecycle guards protect direct API writes. STRICT: any
+ * error OR warning rejects — sanitize-and-accept is the READ side's job.
+ */
+import { validateNodeArray, validatePressTree, type TreeIssue } from '@ogs-tech/press-shared';
+
+const format = (issues: TreeIssue[]): string => issues.map((i) => ` ${i.path}: ${i.message}`).join('\n');
+
+const parseMaybeJson = (value: unknown): unknown => {
+ if (typeof value !== 'string') return value;
+ try {
+ return JSON.parse(value);
+ } catch {
+ return Symbol('unparseable'); // guaranteed to fail validation with a clear message
+ }
+};
+
+export function assertValidPageWrite(data: Record | undefined): void {
+ const body = data?.body;
+ if (body === undefined || body === null) return;
+ const { errors, warnings } = validatePressTree(parseMaybeJson(body));
+ const issues = [...errors, ...warnings];
+ if (issues.length > 0) {
+ throw new Error(`[press-cms] invalid composition tree in page.body — write rejected:\n${format(issues)}`);
+ }
+}
+
+export function assertValidSiteSettingWrite(data: Record | undefined): void {
+ const pd = parseMaybeJson(data?.pageDefaults);
+ if (pd === undefined || pd === null) return;
+ if (typeof pd !== 'object' || Array.isArray(pd)) {
+ throw new Error('[press-cms] pageDefaults must be an object of { header, footer } node arrays — write rejected');
+ }
+ for (const key of ['header', 'footer'] as const) {
+ const slot = (pd as Record)[key];
+ if (slot === undefined || slot === null) continue;
+ const { errors, warnings } = validateNodeArray(slot);
+ const issues = [...errors, ...warnings];
+ if (issues.length > 0) {
+ throw new Error(`[press-cms] invalid nodes in pageDefaults.${key} — write rejected:\n${format(issues)}`);
+ }
+ }
+}
From 29e3add1ce33373950e353679375c694ff6de834 Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Tue, 21 Jul 2026 19:33:59 -0300
Subject: [PATCH 13/31] feat(cms): seed pageDefaults once with bare
navbar/footer block nodes
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../server/src/lib/seed-site-setting.test.ts | 62 ++++++++++---------
.../cms/server/src/lib/seed-site-setting.ts | 58 ++++++++---------
2 files changed, 62 insertions(+), 58 deletions(-)
diff --git a/packages/cms/server/src/lib/seed-site-setting.test.ts b/packages/cms/server/src/lib/seed-site-setting.test.ts
index 12d0f65..05ba01b 100644
--- a/packages/cms/server/src/lib/seed-site-setting.test.ts
+++ b/packages/cms/server/src/lib/seed-site-setting.test.ts
@@ -1,24 +1,17 @@
import { describe, expect, it } from 'vitest';
-import { DEFAULT_CHROME, seedSiteSetting, SITE_SETTING_UID } from './seed-site-setting';
+import { seedSiteSetting, SITE_SETTING_UID } from './seed-site-setting';
-/**
- * Minimal Document-Service + plugin-store fake: a mutable record, recording
- * create/update, and a Map-backed store for the run-once chrome flag.
- */
+/** Minimal Document-Service + plugin-store fake (pre-tree harness, populate pin dropped). */
function fakeStrapi(record: any = null, flags: Record = {}) {
- const creates: Array<{ data: unknown }> = [];
- const updates: Array<{ documentId: string; data: unknown }> = [];
+ const creates: Array<{ data: any }> = [];
+ const updates: Array<{ documentId: string; data: any }> = [];
let current = record;
const store = new Map(Object.entries(flags));
const strapi = {
documents: (uid: string) => {
- expect(uid).toBe(SITE_SETTING_UID); // helper must target the single-type UID
+ expect(uid).toBe(SITE_SETTING_UID);
return {
- findFirst: async (params: any) => {
- // The chrome DZs are invisible without populate — pin that the seed asks.
- expect(params?.populate).toMatchObject({ header: true, footer: true });
- return current;
- },
+ findFirst: async () => current,
create: async (params: { data: any }) => {
creates.push(params);
current = { documentId: 'doc-1', ...params.data };
@@ -43,33 +36,45 @@ function fakeStrapi(record: any = null, flags: Record = {}) {
return { strapi, creates, updates, store };
}
-describe('seedSiteSetting — chrome composition (Spec §4)', () => {
- it('creates the record WITH the default chrome on a fresh DB and marks the seed done', async () => {
+const expectBareChrome = (pd: any) => {
+ expect(pd.header).toHaveLength(1);
+ expect(pd.header[0]).toMatchObject({ type: 'block', component: 'preset-organism.navbar', data: {} });
+ expect(typeof pd.header[0].id).toBe('string');
+ expect(pd.footer[0]).toMatchObject({ type: 'block', component: 'preset-organism.footer', data: {} });
+};
+
+describe('seedSiteSetting — pageDefaults (composition-builder Spec §4)', () => {
+ it('creates the record WITH bare pageDefaults on a fresh DB and marks the seed done', async () => {
const { strapi, creates, updates, store } = fakeStrapi(null);
await seedSiteSetting(strapi);
- expect(creates).toEqual([{ data: DEFAULT_CHROME }]);
+ expect(creates).toHaveLength(1);
+ expectBareChrome(creates[0].data.pageDefaults);
expect(updates).toEqual([]);
- expect(store.get('chromeSeeded')).toBe(true);
+ expect(store.get('pageDefaultsSeeded')).toBe(true);
});
- it('fills still-empty DZs on an existing record (upgrade path) exactly once', async () => {
- const { strapi, updates, store } = fakeStrapi({ documentId: 'doc-1', header: [], footer: [] });
+ it('fills still-empty slots on an existing record exactly once', async () => {
+ const { strapi, updates, store } = fakeStrapi({ documentId: 'doc-1', pageDefaults: { header: [], footer: [] } });
await seedSiteSetting(strapi);
- expect(updates).toEqual([{ documentId: 'doc-1', data: DEFAULT_CHROME }]);
- expect(store.get('chromeSeeded')).toBe(true);
+ expect(updates).toHaveLength(1);
+ expectBareChrome(updates[0].data.pageDefaults);
+ expect(store.get('pageDefaultsSeeded')).toBe(true);
});
- it('never overwrites a composed DZ — only the empty sibling is seeded', async () => {
- const composed = [{ __component: 'preset-organism.navbar', id: 7, items: [{ label: 'Docs' }] }];
- const { strapi, updates } = fakeStrapi({ documentId: 'doc-1', header: composed, footer: [] });
+ it('never overwrites a composed slot — only the empty sibling is seeded', async () => {
+ const composed = [{ id: 'n1', type: 'block', component: 'preset-organism.navbar', data: { items: [{ label: 'Docs' }] } }];
+ const { strapi, updates } = fakeStrapi({ documentId: 'doc-1', pageDefaults: { header: composed, footer: [] } });
await seedSiteSetting(strapi);
- expect(updates).toEqual([{ documentId: 'doc-1', data: { footer: DEFAULT_CHROME.footer } }]);
+ expect(updates).toHaveLength(1);
+ const pd = updates[0].data.pageDefaults as any;
+ expect(pd.header).toEqual(composed);
+ expect(pd.footer[0]).toMatchObject({ component: 'preset-organism.footer' });
});
- it('respects an editor-emptied [] once the seed has run (flag set → no writes)', async () => {
+ it('respects an editor-emptied slot once the seed has run (flag set → no writes)', async () => {
const { strapi, creates, updates } = fakeStrapi(
- { documentId: 'doc-1', header: [], footer: [] },
- { chromeSeeded: true },
+ { documentId: 'doc-1', pageDefaults: { header: [], footer: [] } },
+ { pageDefaultsSeeded: true },
);
await seedSiteSetting(strapi);
expect(creates).toEqual([]);
@@ -80,7 +85,6 @@ describe('seedSiteSetting — chrome composition (Spec §4)', () => {
const { strapi, creates, updates } = fakeStrapi(null);
await seedSiteSetting(strapi);
await seedSiteSetting(strapi);
- await seedSiteSetting(strapi);
expect(creates).toHaveLength(1);
expect(updates).toEqual([]);
});
diff --git a/packages/cms/server/src/lib/seed-site-setting.ts b/packages/cms/server/src/lib/seed-site-setting.ts
index 6cb6c97..169f77c 100644
--- a/packages/cms/server/src/lib/seed-site-setting.ts
+++ b/packages/cms/server/src/lib/seed-site-setting.ts
@@ -1,50 +1,50 @@
+import { randomUUID } from 'node:crypto';
import type { Core } from '@strapi/strapi';
import { pluginStore } from './plugin-store';
/** UID of the engine's Site Settings single type (plugin name `press-cms`). */
export const SITE_SETTING_UID = 'plugin::press-cms.site-setting';
-/** Default chrome composition (Spec §4): a navbar (empty items) + a footer (empty text). */
-export const DEFAULT_CHROME = {
- header: [{ __component: 'preset-organism.navbar' }],
- footer: [{ __component: 'preset-organism.footer' }],
-};
+const PAGE_DEFAULTS_SEED_KEY = 'pageDefaultsSeeded';
-const CHROME_SEED_KEY = 'chromeSeeded';
+/**
+ * Default chrome (Spec §4): a bare navbar and a bare footer BlockNode per slot.
+ * BARE on purpose (no items/cta/text) — the CLI's seed.mjs fills demo content;
+ * "no defaults duplicated in the CMS". Fresh ids per call: node ids are
+ * builder-scoped React keys, never identity.
+ */
+export const buildDefaultPageDefaults = () => ({
+ header: [{ id: randomUUID(), type: 'block', component: 'preset-organism.navbar', data: {} }],
+ footer: [{ id: randomUUID(), type: 'block', component: 'preset-organism.footer', data: {} }],
+});
/**
- * Seeds the Site Settings single type (Spec §4/§5 of the site-settings spec):
- *
- * 1. Fresh DB → exactly one record, created WITH the default chrome composition.
- * Identity/SEO stay empty on purpose: no defaults duplicated in the CMS.
- * 2. Existing record (upgrade path: the chrome DZs just appeared via schema
- * sync) → a single seeding pass fills each still-empty DZ.
- *
- * "Runs once; never overwrites" (Spec §4) is made literal with a plugin-store
- * flag: Strapi cannot distinguish a never-touched DZ from an editor-emptied one
- * (both read back as []), so after the one seeding pass the DZs are never
- * written again — an editor-emptied [] is respected forever.
+ * Seeds Site Settings pageDefaults exactly once (plugin-store flag): Strapi
+ * cannot distinguish a never-touched slot from an editor-emptied one (both read
+ * back as []), so after the one seeding pass the slots are never written again.
*/
export async function seedSiteSetting(strapi: Core.Strapi): Promise {
const docs = strapi.documents(SITE_SETTING_UID);
const store = pluginStore(strapi);
- // DZ content is invisible without populate — findFirst({}) would report the
- // zones as undefined and the seed could clobber real content.
- const existing = (await docs.findFirst({ populate: { header: true, footer: true } as any })) as any;
+ // pageDefaults is a JSON scalar — visible without populate.
+ const existing = (await docs.findFirst()) as any;
if (!existing) {
- await docs.create({ data: { ...DEFAULT_CHROME } as any });
- } else if (!(await store.get({ key: CHROME_SEED_KEY }))) {
- const data: Record = {};
- if (!existing.header?.length) data.header = DEFAULT_CHROME.header;
- if (!existing.footer?.length) data.footer = DEFAULT_CHROME.footer;
- if (Object.keys(data).length > 0) {
- await docs.update({ documentId: existing.documentId, data: data as any });
+ await docs.create({ data: { pageDefaults: buildDefaultPageDefaults() } as any });
+ } else if (!(await store.get({ key: PAGE_DEFAULTS_SEED_KEY }))) {
+ const pd = (existing.pageDefaults ?? {}) as { header?: unknown[]; footer?: unknown[] };
+ const defaults = buildDefaultPageDefaults();
+ const next: Record = { ...pd };
+ let changed = false;
+ if (!Array.isArray(pd.header) || pd.header.length === 0) { next.header = defaults.header; changed = true; }
+ if (!Array.isArray(pd.footer) || pd.footer.length === 0) { next.footer = defaults.footer; changed = true; }
+ if (changed) {
+ await docs.update({ documentId: existing.documentId, data: { pageDefaults: next } as any });
}
} else {
- return; // seeded before — never touch the chrome again
+ return; // seeded before — never touch the defaults again
}
- await store.set({ key: CHROME_SEED_KEY, value: true });
+ await store.set({ key: PAGE_DEFAULTS_SEED_KEY, value: true });
}
From 8025316091be051c5b20fe934b67ed2c1c8c7a64 Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Tue, 21 Jul 2026 19:37:38 -0300
Subject: [PATCH 14/31] feat(cms-admin): pure immutable tree operations with
structural invariants
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/cms/admin/src/lib/tree-ops.test.ts | 82 ++++++++++
packages/cms/admin/src/lib/tree-ops.ts | 164 ++++++++++++++++++++
2 files changed, 246 insertions(+)
create mode 100644 packages/cms/admin/src/lib/tree-ops.test.ts
create mode 100644 packages/cms/admin/src/lib/tree-ops.ts
diff --git a/packages/cms/admin/src/lib/tree-ops.test.ts b/packages/cms/admin/src/lib/tree-ops.test.ts
new file mode 100644
index 0000000..178a0f6
--- /dev/null
+++ b/packages/cms/admin/src/lib/tree-ops.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest';
+import {
+ addColumn, getNode, insertNode, moveNode, newBlockNode, newColumnNode, newRowNode,
+ RATIO_SLOTS, removeNode, setBlockData, setContainerAttr, setRowRatio, type Forest,
+} from './tree-ops';
+
+const forest = (): Forest => {
+ const row = newRowNode('50-50');
+ return [newBlockNode('preset-organism.hero'), row];
+};
+
+describe('node factories', () => {
+ it('mints unique string ids and ratio-sized rows', () => {
+ const a = newBlockNode('preset-atom.paragraph');
+ const b = newBlockNode('preset-atom.paragraph');
+ expect(a.id).not.toBe(b.id);
+ expect(a).toMatchObject({ type: 'block', component: 'preset-atom.paragraph', data: {} });
+ const row = newRowNode('33-33-33');
+ expect(row.children).toHaveLength(RATIO_SLOTS['33-33-33']);
+ expect(row.children.every((c) => c.type === 'column')).toBe(true);
+ });
+});
+
+describe('insertNode invariants (by construction)', () => {
+ it('inserts blocks and rows at the root and inside columns', () => {
+ const f = forest();
+ const out = insertNode(f, null, 0, newBlockNode('preset-atom.spacer'));
+ expect(out).toHaveLength(3);
+ expect((out[0] as any).component).toBe('preset-atom.spacer');
+ expect(f).toHaveLength(2); // immutable
+
+ const intoColumn = insertNode(out, [2, 0], 0, newRowNode('50-50')); // row INSIDE a column: the recursion point
+ expect((getNode(intoColumn, [2, 0, 0]) as any).type).toBe('row');
+ });
+
+ it('refuses a column outside a row and a non-column inside a row', () => {
+ const f = forest();
+ expect(() => insertNode(f, null, 0, newColumnNode())).toThrow(/column/i);
+ expect(() => insertNode(f, [1], 0, newBlockNode('preset-atom.spacer'))).toThrow(/row/i);
+ });
+
+ it('caps a row at 4 columns', () => {
+ let f: Forest = [newRowNode('25-25-25-25')];
+ expect(() => insertNode(f, [0], 4, newColumnNode())).toThrow(/4/);
+ f = [newRowNode('50-50')];
+ f = addColumn(f, [0]);
+ f = addColumn(f, [0]);
+ expect((f[0] as any).children).toHaveLength(4);
+ expect(() => addColumn(f, [0])).toThrow(/4/);
+ });
+});
+
+describe('remove / move / update', () => {
+ it('removes at depth and moves within siblings (clamped)', () => {
+ const f = forest();
+ expect(removeNode(f, [0])).toHaveLength(1);
+ const moved = moveNode(f, [1], -1);
+ expect((moved[0] as any).type).toBe('row');
+ expect(moveNode(moved, [0], -1)).toEqual(moved); // clamped at the edge
+ });
+
+ it('sets block data and container attrs immutably', () => {
+ const f = forest();
+ const withData = setBlockData(f, [0], { title: 'Hi' });
+ expect((withData[0] as any).data).toEqual({ title: 'Hi' });
+ expect((f[0] as any).data).toEqual({});
+
+ const withAttr = setContainerAttr(f, [1], 'gap', 'compact');
+ expect((withAttr[1] as any).container).toEqual({ gap: 'compact' });
+ const cleared = setContainerAttr(withAttr, [1], 'gap', undefined);
+ expect((cleared[1] as any).container).toBeUndefined();
+ });
+
+ it('setRowRatio grows children to the slot count but never shrinks', () => {
+ let f: Forest = [newRowNode('25-25-25-25')];
+ f = setRowRatio(f, [0], '50-50');
+ expect((f[0] as any).children).toHaveLength(4); // never shrinks (renderer tolerance)
+ let g: Forest = [newRowNode('50-50')];
+ g = setRowRatio(g, [0], '33-33-33');
+ expect((g[0] as any).children).toHaveLength(3);
+ });
+});
diff --git a/packages/cms/admin/src/lib/tree-ops.ts b/packages/cms/admin/src/lib/tree-ops.ts
new file mode 100644
index 0000000..8aba0b5
--- /dev/null
+++ b/packages/cms/admin/src/lib/tree-ops.ts
@@ -0,0 +1,164 @@
+/**
+ * Pure, immutable operations on a composition forest (a slot's Node[]). The
+ * builder UI calls ONLY these — the structural invariants (Spec §4: Column only
+ * under Row, Row children are Columns only, 1–4 columns) are enforced here by
+ * construction, which is what makes the lifecycle validator "unreachable" from
+ * the admin. No React, no Strapi: unit-tested without a DOM.
+ */
+import type { BlockNode, ColumnNode, Node, Ratio, RowNode } from '@ogs-tech/press-shared';
+
+export type Forest = Node[];
+export type NodePath = number[];
+
+export const RATIO_SLOTS: Record = {
+ '50-50': 2,
+ '33-67': 2,
+ '67-33': 2,
+ '33-33-33': 3,
+ '25-25-25-25': 4,
+};
+
+export const MAX_COLUMNS = 4;
+
+const uuid = (): string => globalThis.crypto.randomUUID();
+
+export const newBlockNode = (component: string): BlockNode => ({ id: uuid(), type: 'block', component, data: {} });
+
+export const newColumnNode = (): ColumnNode => ({ id: uuid(), type: 'column', children: [] });
+
+export const newRowNode = (ratio: Ratio): RowNode => ({
+ id: uuid(),
+ type: 'row',
+ ratio,
+ children: Array.from({ length: RATIO_SLOTS[ratio] }, () => newColumnNode()),
+});
+
+const childrenOf = (node: Node): Node[] =>
+ node.type === 'block' ? [] : (node.children as Node[]);
+
+export function getNode(forest: Forest, path: NodePath): Node | null {
+ let list: Node[] = forest;
+ let node: Node | null = null;
+ for (const index of path) {
+ node = list[index] ?? null;
+ if (!node) return null;
+ list = childrenOf(node);
+ }
+ return node;
+}
+
+/** Rebuilds the spine along `path`, applying `update` to the addressed sibling list. */
+function updateList(forest: Forest, parentPath: NodePath | null, update: (siblings: Node[], parent: Node | null) => Node[]): Forest {
+ if (parentPath === null || parentPath.length === 0) {
+ if (parentPath === null) return update([...forest], null);
+ }
+ const walk = (list: Node[], path: NodePath): Node[] => {
+ if (path.length === 0) return update([...list], null);
+ const [head, ...rest] = path;
+ return list.map((node, i) => {
+ if (i !== head) return node;
+ if (node.type === 'block') throw new Error('[press-cms] a block node has no children');
+ if (rest.length === 0) {
+ return { ...node, children: update([...(node.children as Node[])], node) } as Node;
+ }
+ return { ...node, children: walk(node.children as Node[], rest) } as Node;
+ });
+ };
+ return walk(forest, parentPath ?? []);
+}
+
+function assertLegalChild(parent: Node | null, child: Node): void {
+ if (parent === null || parent.type === 'column') {
+ if (child.type === 'column') throw new Error('[press-cms] a column is only legal directly under a row');
+ return;
+ }
+ if (parent.type === 'row') {
+ if (child.type !== 'column') throw new Error('[press-cms] row children must be columns');
+ return;
+ }
+ throw new Error('[press-cms] a block node cannot take children');
+}
+
+export function insertNode(forest: Forest, parentPath: NodePath | null, index: number, node: Node): Forest {
+ const parent = parentPath === null ? null : getNode(forest, parentPath);
+ if (parentPath !== null && !parent) throw new Error('[press-cms] insert parent not found');
+ assertLegalChild(parent, node);
+ if (parent?.type === 'row' && (parent.children as Node[]).length >= MAX_COLUMNS) {
+ throw new Error(`[press-cms] a row carries at most ${MAX_COLUMNS} columns`);
+ }
+ return updateList(forest, parentPath, (siblings) => {
+ siblings.splice(Math.max(0, Math.min(index, siblings.length)), 0, node);
+ return siblings;
+ });
+}
+
+export function removeNode(forest: Forest, path: NodePath): Forest {
+ const parentPath = path.slice(0, -1);
+ const index = path[path.length - 1];
+ return updateList(forest, parentPath.length ? parentPath : null, (siblings) => {
+ siblings.splice(index, 1);
+ return siblings;
+ });
+}
+
+export function moveNode(forest: Forest, path: NodePath, delta: -1 | 1): Forest {
+ const parentPath = path.slice(0, -1);
+ const index = path[path.length - 1];
+ return updateList(forest, parentPath.length ? parentPath : null, (siblings) => {
+ const target = index + delta;
+ if (target < 0 || target >= siblings.length) return siblings;
+ const [node] = siblings.splice(index, 1);
+ siblings.splice(target, 0, node);
+ return siblings;
+ });
+}
+
+function patchNode(forest: Forest, path: NodePath, patch: (node: Node) => Node): Forest {
+ const parentPath = path.slice(0, -1);
+ const index = path[path.length - 1];
+ return updateList(forest, parentPath.length ? parentPath : null, (siblings) => {
+ if (!siblings[index]) throw new Error('[press-cms] node not found at path');
+ siblings[index] = patch(siblings[index]);
+ return siblings;
+ });
+}
+
+export function setBlockData(forest: Forest, path: NodePath, data: Record): Forest {
+ return patchNode(forest, path, (node) => {
+ if (node.type !== 'block') throw new Error('[press-cms] setBlockData targets block nodes');
+ return { ...node, data };
+ });
+}
+
+export function setContainerAttr(
+ forest: Forest,
+ path: NodePath,
+ key: 'width' | 'gap' | 'verticalAlign',
+ value: string | undefined,
+): Forest {
+ return patchNode(forest, path, (node) => {
+ if (node.type === 'block') throw new Error('[press-cms] blocks carry no container attrs');
+ const container = { ...(node.container ?? {}) } as Record;
+ if (value === undefined) delete container[key];
+ else container[key] = value;
+ const next = { ...node } as Node & { container?: Record };
+ if (Object.keys(container).length === 0) delete next.container;
+ else next.container = container;
+ return next as Node;
+ });
+}
+
+export function setRowRatio(forest: Forest, path: NodePath, ratio: Ratio): Forest {
+ return patchNode(forest, path, (node) => {
+ if (node.type !== 'row') throw new Error('[press-cms] setRowRatio targets row nodes');
+ const children = [...node.children];
+ while (children.length < RATIO_SLOTS[ratio]) children.push(newColumnNode());
+ return { ...node, ratio, children };
+ });
+}
+
+export function addColumn(forest: Forest, rowPath: NodePath): Forest {
+ const row = getNode(forest, rowPath);
+ if (!row || row.type !== 'row') throw new Error('[press-cms] addColumn targets row nodes');
+ return insertNode(forest, rowPath, row.children.length, newColumnNode());
+}
From d8565f4cbf0d58f956bf16c5fe0805c7ecad1ce5 Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Tue, 21 Jul 2026 19:40:13 -0300
Subject: [PATCH 15/31] feat(cms-admin): registry-driven form model
(descriptors, container applicability, palette groups)
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/cms/admin/src/lib/form-model.test.ts | 58 ++++++++++++
packages/cms/admin/src/lib/form-model.ts | 90 +++++++++++++++++++
2 files changed, 148 insertions(+)
create mode 100644 packages/cms/admin/src/lib/form-model.test.ts
create mode 100644 packages/cms/admin/src/lib/form-model.ts
diff --git a/packages/cms/admin/src/lib/form-model.test.ts b/packages/cms/admin/src/lib/form-model.test.ts
new file mode 100644
index 0000000..452ed16
--- /dev/null
+++ b/packages/cms/admin/src/lib/form-model.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest';
+import { applicableContainerAttrs, fieldsFor, paletteGroups } from './form-model';
+
+describe('fieldsFor', () => {
+ it('maps the contract attribute types to field kinds', () => {
+ const fields = fieldsFor({
+ title: { type: 'string', required: true },
+ content: { type: 'text' },
+ align: { type: 'enumeration', enum: ['left', 'center'] },
+ newTab: { type: 'boolean' },
+ image: { type: 'media', multiple: false },
+ page: { type: 'relation', relation: 'oneToOne', target: 'plugin::press-cms.page' } as any,
+ cta: { type: 'component', component: 'preset-molecule.link', repeatable: false },
+ items: { type: 'component', component: 'preset-molecule.link', repeatable: true },
+ blob: { type: 'json' },
+ mystery: { type: 'password' },
+ });
+ expect(fields).toEqual([
+ { name: 'title', kind: 'text', required: true },
+ { name: 'content', kind: 'textarea', required: false },
+ { name: 'align', kind: 'select', required: false, options: ['left', 'center'] },
+ { name: 'newTab', kind: 'checkbox', required: false },
+ { name: 'image', kind: 'media', required: false },
+ { name: 'page', kind: 'pageRef', required: false },
+ { name: 'cta', kind: 'component', required: false, component: 'preset-molecule.link', repeatable: false },
+ { name: 'items', kind: 'component', required: false, component: 'preset-molecule.link', repeatable: true },
+ { name: 'blob', kind: 'json', required: false },
+ ]);
+ });
+});
+
+describe('applicableContainerAttrs', () => {
+ it('shows only the attrs that apply per node type (Spec §3)', () => {
+ expect(applicableContainerAttrs('layout', true)).toEqual(['gap']);
+ expect(applicableContainerAttrs('row', true)).toEqual(['width', 'gap', 'verticalAlign']);
+ expect(applicableContainerAttrs('row', false)).toEqual(['gap', 'verticalAlign']); // width is top-level-only
+ expect(applicableContainerAttrs('column', false)).toEqual(['gap', 'verticalAlign']);
+ });
+});
+
+describe('paletteGroups', () => {
+ it('groups placeable uids by category, excluding nested-only and config layers', () => {
+ const schema = {
+ contentTypes: {},
+ components: Object.fromEntries([
+ 'preset-atom.paragraph', 'preset-atom.heading',
+ 'preset-organism.hero', 'preset-organism.navbar',
+ 'preset-molecule.link', 'preset-config.seo', 'preset-layout.container',
+ 'custom-organism.callout',
+ ].map((uid) => [uid, { uid, attributes: {} }])),
+ } as any;
+ expect(paletteGroups(schema)).toEqual([
+ { category: 'custom-organism', uids: ['custom-organism.callout'] },
+ { category: 'preset-atom', uids: ['preset-atom.heading', 'preset-atom.paragraph'] },
+ { category: 'preset-organism', uids: ['preset-organism.hero', 'preset-organism.navbar'] },
+ ]);
+ });
+});
diff --git a/packages/cms/admin/src/lib/form-model.ts b/packages/cms/admin/src/lib/form-model.ts
new file mode 100644
index 0000000..8a093c4
--- /dev/null
+++ b/packages/cms/admin/src/lib/form-model.ts
@@ -0,0 +1,90 @@
+/**
+ * Registry schema → form descriptors. This is the whole "forms are generated
+ * from the schema catalog" mechanism (Spec §4): node-form.tsx renders from
+ * these descriptors and nothing else, so a new block (preset or custom) gets a
+ * working form with zero admin code. `preset-molecule.link` needs NO special
+ * case — its `page` relation maps to the pageRef dropdown like any other.
+ */
+import type { Attr, PressSchema } from '@ogs-tech/press-shared';
+
+export type FieldKind =
+ | 'text' | 'textarea' | 'select' | 'checkbox' | 'number'
+ | 'media' | 'pageRef' | 'component' | 'json';
+
+export interface FieldDescriptor {
+ name: string;
+ kind: FieldKind;
+ required: boolean;
+ options?: string[];
+ component?: string;
+ repeatable?: boolean;
+}
+
+const PAGE_TARGET = 'plugin::press-cms.page';
+const NUMBERS = new Set(['integer', 'biginteger', 'float', 'decimal']);
+
+export function fieldsFor(attributes: Record): FieldDescriptor[] {
+ const out: FieldDescriptor[] = [];
+ for (const [name, attr] of Object.entries(attributes ?? {})) {
+ const base = { name, required: attr.required === true };
+ switch (attr.type) {
+ case 'string':
+ case 'uid':
+ out.push({ ...base, kind: 'text' });
+ break;
+ case 'text':
+ out.push({ ...base, kind: 'textarea' });
+ break;
+ case 'enumeration':
+ out.push({ ...base, kind: 'select', options: attr.enum ?? [] });
+ break;
+ case 'boolean':
+ out.push({ ...base, kind: 'checkbox' });
+ break;
+ case 'media':
+ out.push({ ...base, kind: 'media' });
+ break;
+ case 'relation':
+ if ((attr as Record).target === PAGE_TARGET) out.push({ ...base, kind: 'pageRef' });
+ break;
+ case 'component':
+ if (typeof attr.component === 'string') {
+ out.push({ ...base, kind: 'component', component: attr.component, repeatable: attr.repeatable === true });
+ }
+ break;
+ case 'json':
+ out.push({ ...base, kind: 'json' });
+ break;
+ default:
+ if (attr.type && NUMBERS.has(attr.type)) out.push({ ...base, kind: 'number' });
+ // anything else (password, dynamiczone, …) is not form-editable — skipped
+ }
+ }
+ return out;
+}
+
+/** Which shared container attrs the form shows per node type (Spec §3: non-applicable attrs are hidden). */
+export function applicableContainerAttrs(
+ nodeType: 'layout' | 'row' | 'column',
+ topLevel: boolean,
+): Array<'width' | 'gap' | 'verticalAlign'> {
+ if (nodeType === 'layout') return ['gap'];
+ if (nodeType === 'row') return topLevel ? ['width', 'gap', 'verticalAlign'] : ['gap', 'verticalAlign'];
+ return ['gap', 'verticalAlign'];
+}
+
+/** Categories whose components are never PLACED as blocks (nested-only / settings / descriptors). */
+const NON_PLACEABLE = /^preset-(molecule|config|layout|template)$/;
+
+export function paletteGroups(schema: PressSchema): Array<{ category: string; uids: string[] }> {
+ const byCategory = new Map();
+ for (const uid of Object.keys(schema.components ?? {})) {
+ const category = uid.split('.')[0];
+ if (NON_PLACEABLE.test(category)) continue;
+ if (!byCategory.has(category)) byCategory.set(category, []);
+ byCategory.get(category)!.push(uid);
+ }
+ return [...byCategory.entries()]
+ .map(([category, uids]) => ({ category, uids: uids.sort() }))
+ .sort((a, b) => a.category.localeCompare(b.category));
+}
From e2721a7a3385b12ceb235166b3a647ff9b1e2a91 Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Tue, 21 Jul 2026 19:51:23 -0300
Subject: [PATCH 16/31] feat(cms-admin): structural composition-builder custom
field (tree + slots modes, registry-driven forms)
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../src/components/builder-input.test.tsx | 86 +++++++++
.../admin/src/components/builder-input.tsx | 163 ++++++++++++++++++
.../cms/admin/src/components/node-form.tsx | 134 ++++++++++++++
.../cms/admin/src/components/tree-editor.tsx | 141 +++++++++++++++
packages/cms/admin/src/index.ts | 18 +-
packages/cms/admin/src/lib/press-data.ts | 38 ++++
6 files changed, 578 insertions(+), 2 deletions(-)
create mode 100644 packages/cms/admin/src/components/builder-input.test.tsx
create mode 100644 packages/cms/admin/src/components/builder-input.tsx
create mode 100644 packages/cms/admin/src/components/node-form.tsx
create mode 100644 packages/cms/admin/src/components/tree-editor.tsx
create mode 100644 packages/cms/admin/src/lib/press-data.ts
diff --git a/packages/cms/admin/src/components/builder-input.test.tsx b/packages/cms/admin/src/components/builder-input.test.tsx
new file mode 100644
index 0000000..31507f2
--- /dev/null
+++ b/packages/cms/admin/src/components/builder-input.test.tsx
@@ -0,0 +1,86 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import BuilderInput from './builder-input';
+import { resetPressDataCache } from '../lib/press-data';
+
+vi.mock('@strapi/strapi/admin', () => ({
+ useStrapiApp: () => undefined, // no media-library in tests → assetId input fallback
+}));
+
+const SCHEMA = {
+ tree: { version: 1 },
+ contentTypes: {},
+ components: {
+ 'preset-atom.paragraph': { uid: 'preset-atom.paragraph', attributes: { content: { type: 'text', required: true } } },
+ 'preset-organism.navbar': { uid: 'preset-organism.navbar', attributes: { items: { type: 'component', component: 'preset-molecule.link', repeatable: true } } },
+ 'preset-molecule.link': { uid: 'preset-molecule.link', attributes: { label: { type: 'string' }, page: { type: 'relation', relation: 'oneToOne', target: 'plugin::press-cms.page' }, url: { type: 'string' }, newTab: { type: 'boolean' } } },
+ },
+};
+
+let container: HTMLDivElement;
+let root: Root;
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+beforeEach(() => {
+ resetPressDataCache();
+ vi.stubGlobal('fetch', vi.fn(async (url: string) => ({
+ ok: true,
+ json: async () => (String(url).includes('/api/press/schema') ? SCHEMA : { data: [{ documentId: 'home-doc', title: 'Home', slug: 'home' }] }),
+ })));
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+});
+
+const flush = async () => act(async () => { await Promise.resolve(); });
+
+describe('BuilderInput (tree mode)', () => {
+ it('renders slot editors + body forest from an empty value and emits a tree on add', async () => {
+ const onChange = vi.fn();
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+ await flush();
+ expect(container.textContent).toContain('header');
+ expect(container.textContent).toContain('body');
+
+ const select = container.querySelector('[data-press-slot="body"] select[aria-label="Add node"]') as HTMLSelectElement;
+ const addButton = select.parentElement!.querySelector('button') as HTMLButtonElement;
+ await act(async () => {
+ select.value = 'preset-atom.paragraph';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ await act(async () => { addButton.click(); });
+
+ const emitted = onChange.mock.calls.at(-1)![0].target;
+ expect(emitted.type).toBe('json');
+ expect(emitted.value.version).toBe(1);
+ expect(emitted.value.root.children).toHaveLength(1);
+ expect(emitted.value.root.children[0]).toMatchObject({ type: 'block', component: 'preset-atom.paragraph', data: {} });
+ expect(typeof emitted.value.root.children[0].id).toBe('string');
+ });
+});
+
+describe('BuilderInput (slots mode)', () => {
+ it('edits { header, footer } node arrays', async () => {
+ const onChange = vi.fn();
+ const value = { header: [{ id: 'n1', type: 'block', component: 'preset-organism.navbar', data: {} }], footer: [] };
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+ await flush();
+ expect(container.textContent).toContain('preset-organism.navbar');
+ });
+});
diff --git a/packages/cms/admin/src/components/builder-input.tsx b/packages/cms/admin/src/components/builder-input.tsx
new file mode 100644
index 0000000..4111ef9
--- /dev/null
+++ b/packages/cms/admin/src/components/builder-input.tsx
@@ -0,0 +1,163 @@
+/**
+ * The `plugin::press-cms.builder` custom-field Input (Spec §4). Two shapes:
+ * - default: a full PressTree (page body) with header/footer slot-mode editors
+ * - options.mode === 'slots': the Site Settings pageDefaults `{ header, footer }`
+ * Value tolerance: Strapi hands the form value as an object (or a JSON string on
+ * some paths) — normalize on the way in, always emit an object with type 'json'.
+ */
+import { useEffect, useState } from 'react';
+import { useStrapiApp } from '@strapi/strapi/admin';
+import type { Node, PressSchema, PressTree, Slot } from '@ogs-tech/press-shared';
+import { fetchPressSchema } from '../lib/press-data';
+import type { Forest } from '../lib/tree-ops';
+import { TreeEditor } from './tree-editor';
+
+interface BuilderInputProps {
+ name: string;
+ attribute: { options?: { mode?: string } };
+ value?: unknown;
+ disabled?: boolean;
+ label?: string;
+ hint?: string;
+ error?: string;
+ onChange(event: { target: { name: string; value: unknown; type: string } }): void;
+}
+
+const isRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v);
+
+const parseValue = (value: unknown): unknown => {
+ if (typeof value !== 'string') return value;
+ try { return JSON.parse(value); } catch { return undefined; }
+};
+
+const emptyTree = (): PressTree => ({
+ version: 1,
+ root: { type: 'layout', header: { mode: 'inherit' }, footer: { mode: 'inherit' }, children: [] },
+});
+
+/** Media field: Strapi's media-library dialog when registered, else a bare asset-id input. Stores { assetId }. */
+function MediaField({ value, disabled, onChange }: { value: unknown; disabled?: boolean; onChange(v: unknown): void }) {
+ const components = useStrapiApp('PressBuilderMediaField', (state: any) => state.components);
+ const MediaLibraryDialog = components?.['media-library'];
+ const [open, setOpen] = useState(false);
+ const assetId = isRecord(value) && typeof value.assetId === 'number' ? value.assetId : undefined;
+ if (!MediaLibraryDialog) {
+ return (
+ onChange(e.target.value === '' ? undefined : { assetId: Number(e.target.value) })}
+ />
+ );
+ }
+ return (
+
+ setOpen(true)}>
+ {assetId ? `Asset #${assetId} — change` : 'Pick media'}
+
+ {assetId ? onChange(undefined)}>Clear : null}
+ {open ? (
+ setOpen(false)}
+ onSelectAssets={(assets: Array<{ id: number }>) => {
+ if (assets[0]) onChange({ assetId: assets[0].id });
+ setOpen(false);
+ }}
+ />
+ ) : null}
+
+ );
+}
+
+function SlotEditor({ title, slot, schema, disabled, onChange }: {
+ title: string;
+ slot: Slot;
+ schema: PressSchema;
+ disabled?: boolean;
+ onChange(slot: Slot): void;
+}) {
+ return (
+
+ {title}
+ {
+ const mode = e.target.value as Slot['mode'];
+ onChange(mode === 'custom' ? { mode, children: slot.mode === 'custom' ? slot.children : [] } : { mode });
+ }}
+ >
+ inherit site defaults
+ none (bare page)
+ custom
+
+ {slot.mode === 'custom' ? (
+ onChange({ mode: 'custom', children: children as Node[] })} MediaField={MediaField} />
+ ) : null}
+
+ );
+}
+
+export default function BuilderInput({ name, attribute, value, disabled, label, hint, error, onChange }: BuilderInputProps) {
+ const [schema, setSchema] = useState(null);
+ const [loadError, setLoadError] = useState(null);
+ useEffect(() => {
+ let live = true;
+ fetchPressSchema()
+ .then((s) => live && setSchema(s))
+ .catch((e) => live && setLoadError(String(e)));
+ return () => { live = false; };
+ }, []);
+
+ const emit = (next: unknown): void => onChange({ target: { name, value: next, type: 'json' } });
+ const parsed = parseValue(value);
+
+ if (loadError) return press builder: schema unavailable ({loadError})
;
+ if (!schema) return Loading press schema…
;
+
+ const slotsMode = attribute.options?.mode === 'slots';
+
+ if (slotsMode) {
+ const pd = isRecord(parsed) ? parsed : {};
+ const header = Array.isArray(pd.header) ? (pd.header as Forest) : [];
+ const footer = Array.isArray(pd.footer) ? (pd.footer as Forest) : [];
+ return (
+
+ {label ?
{label} : null}
+
header
+ emit({ ...pd, header: next })} MediaField={MediaField} />
+
+
footer
+ emit({ ...pd, footer: next })} MediaField={MediaField} />
+
+ {hint ?
{hint} : null}
+ {error ?
{error}
: null}
+
+ );
+ }
+
+ const tree: PressTree = isRecord(parsed) && isRecord(parsed.root) ? (parsed as unknown as PressTree) : emptyTree();
+ const setRoot = (patch: Partial): void => emit({ ...tree, root: { ...tree.root, ...patch } });
+
+ return (
+
+ {label ?
{label} : null}
+
setRoot({ header })} />
+
+ body
+ setRoot({ children: children as Node[] })} MediaField={MediaField} />
+
+ setRoot({ footer })} />
+ {hint ? {hint} : null}
+ {error ? {error}
: null}
+
+ );
+}
diff --git a/packages/cms/admin/src/components/node-form.tsx b/packages/cms/admin/src/components/node-form.tsx
new file mode 100644
index 0000000..41c7b7f
--- /dev/null
+++ b/packages/cms/admin/src/components/node-form.tsx
@@ -0,0 +1,134 @@
+/**
+ * Per-block form generated from the schema catalog (Spec §4). Renders one input
+ * per FieldDescriptor; nested `component` descriptors recurse with the
+ * referenced component's own descriptors — so preset-molecule.link, the navbar
+ * cta chain (navbar → button → link) and any custom nesting all work with zero
+ * per-block code. Plain HTML elements on purpose (no design-system dep).
+ */
+import { useEffect, useState } from 'react';
+import type { PressSchema } from '@ogs-tech/press-shared';
+import { fieldsFor, type FieldDescriptor } from '../lib/form-model';
+import { fetchPages, type PageOption } from '../lib/press-data';
+
+interface NodeFormProps {
+ componentUid: string;
+ schema: PressSchema;
+ data: Record;
+ disabled?: boolean;
+ onChange(data: Record): void;
+ /** Injectable media picker (tests stub it; production wires the media-library dialog). */
+ MediaField: (props: { value: unknown; disabled?: boolean; onChange(v: unknown): void }) => JSX.Element;
+}
+
+const isRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v);
+
+function PageRefField({ value, disabled, onChange }: { value: unknown; disabled?: boolean; onChange(v: unknown): void }) {
+ const [pages, setPages] = useState([]);
+ useEffect(() => {
+ let live = true;
+ fetchPages().then((p) => live && setPages(p)).catch(() => undefined);
+ return () => { live = false; };
+ }, []);
+ const current = isRecord(value) && typeof value.documentId === 'string' ? value.documentId : '';
+ return (
+ onChange(e.target.value ? { documentId: e.target.value } : undefined)}
+ >
+ — none —
+ {pages.map((p) => (
+ {p.title} (/{p.slug})
+ ))}
+
+ );
+}
+
+function Field({ field, schema, value, disabled, onChange, MediaField }: {
+ field: FieldDescriptor;
+ schema: PressSchema;
+ value: unknown;
+ disabled?: boolean;
+ onChange(v: unknown): void;
+ MediaField: NodeFormProps['MediaField'];
+}) {
+ switch (field.kind) {
+ case 'text':
+ return onChange(e.target.value || undefined)} />;
+ case 'textarea':
+ return ');
- });
-
- it('keeps an internal/anchor link clean (no rel)', () => {
- const html = render([para({ type: 'link', url: '/about', children: [text('about')] } as any)]);
- expect(html).toContain('href="/about"');
- expect(html).not.toContain('rel=');
- });
-
- it('neutralizes a javascript: URL to # (no executable href)', () => {
- const html = render([para({ type: 'link', url: 'javascript:alert(1)', children: [text('x')] } as any)]);
- expect(html).not.toContain('javascript:');
- expect(html).toContain('href="#"');
- });
- });
-
- describe('text marks', () => {
- it('renders bold as ', () => {
- expect(render([para(text('b', { bold: true }))])).toContain('b ');
- });
- it('renders italic as ', () => {
- expect(render([para(text('i', { italic: true }))])).toContain('i ');
- });
- it('renders underline as ', () => {
- expect(render([para(text('u', { underline: true }))])).toContain('u ');
- });
- it('renders strikethrough as ', () => {
- expect(render([para(text('s', { strikethrough: true }))])).toContain('s ');
- });
- it('renders inline code as ', () => {
- expect(render([para(text('c', { code: true }))])).toContain('c');
- });
- it('combines multiple marks on one run (both tags present, nesting order free)', () => {
- const html = render([para(text('x', { bold: true, italic: true }))]);
- expect(html).toContain('');
- expect(html).toContain('');
- expect(html).toContain('x');
- });
- it('renders plain text with no mark wrapper', () => {
- const html = render([para(text('plain'))]);
- expect(html).toContain('plain
');
- expect(html).not.toContain('');
- });
- });
-
- describe('tolerance (mirrors BlockRenderer: unknown → skip, never throw)', () => {
- it('skips an unknown block node without throwing', () => {
- expect(() => render([{ type: 'mystery', children: [text('?')] } as any, para(text('ok'))])).not.toThrow();
- expect(render([{ type: 'mystery', children: [text('?')] } as any, para(text('ok'))])).toContain('ok
');
- });
- it('skips an embedded image node (images belong in preset-atom.image)', () => {
- const html = render([{ type: 'image', image: { url: '/x.png' } } as any, para(text('caption-less'))]);
- expect(html).not.toContain(' caption-less
');
- });
- });
-});
diff --git a/packages/web/src/blocks/blocks-content.tsx b/packages/web/src/blocks/blocks-content.tsx
deleted file mode 100644
index 05254d2..0000000
--- a/packages/web/src/blocks/blocks-content.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import { Fragment, type ReactNode } from 'react';
-import type { BlocksContent, BlocksNode, BlocksText } from '../types/base';
-
-/**
- * Shared in-house renderer for the Strapi `blocks` AST — deliberately NOT
- * `@strapi/blocks-react-renderer` (that package is `"use client"`, so it would turn
- * static prose into a hydrated client island and add a runtime dependency, both
- * against the engine's server-rendered, JS-free block principle).
- *
- * Used by the text atom blocks (preset-atom.paragraph / preset-atom.list /
- * preset-atom.quote) whose `content` is a Strapi blocks field. Tolerance mirrors
- * BlockRenderer: an unknown node type (or an embedded `image` node — images belong
- * in preset-atom.image) renders nothing rather than throwing.
- */
-
-type Inline = BlocksNode | BlocksText;
-
-const isText = (node: Inline): node is BlocksText => node.type === 'text';
-
-/**
- * Wraps a single text run in its semantic mark elements.
- *
- * The Strapi blocks `text` node carries up to five boolean marks
- * (bold/italic/underline/strikethrough/code). Each maps to a semantic element;
- * they compose from the inside out, so `bold` ends up outermost when several are
- * set. An unmarked run renders as bare text (the keyed Fragment just carries the
- * React key needed inside the parent's `.map`).
- */
-const MARKS: ReadonlyArray ReactNode]> = [
- ['code', (c) => {c}],
- ['underline', (c) => {c} ],
- ['strikethrough', (c) => {c} ],
- ['italic', (c) => {c} ],
- ['bold', (c) => {c} ],
-];
-
-function renderText(node: BlocksText, key: number): ReactNode {
- let content: ReactNode = node.text;
- for (const [mark, wrap] of MARKS) {
- if (node[mark]) content = wrap(content);
- }
- return {content} ;
-}
-
-/**
- * Neutralizes dangerous URL protocols (javascript:/data:/vbscript:) a CMS editor
- * could type into a link, returning '#' instead. http(s)/mailto/tel, relative
- * paths and anchors pass through untouched. Cheap defense-in-depth: link content
- * is editor-authored, not public input, but an executable href is never wanted.
- */
-function safeHref(url: string | undefined): string {
- const trimmed = (url ?? '').trim();
- if (!trimmed) return '#';
- return /^(?:javascript|data|vbscript):/i.test(trimmed) ? '#' : trimmed;
-}
-
-/** Renders an inline node: either a text run (with marks) or an inline link. */
-function renderInline(node: Inline, i: number): ReactNode {
- if (isText(node)) return renderText(node, i);
- if (node.type === 'link') {
- const href = safeHref(node.url);
- // Only outbound http(s) links get rel; internal/anchor links stay clean.
- const external = /^https?:\/\//i.test(href);
- return (
-
- {(node.children ?? []).map(renderInline)}
-
- );
- }
- return null; // tolerant: unknown inline node → skip
-}
-
-/** Renders one list item (its children are inline). */
-function renderListItem(node: BlocksNode, i: number): ReactNode {
- return {(node.children ?? []).map(renderInline)} ;
-}
-
-/** Renders one block-level node. Unknown / out-of-scope (`image`) → null. */
-function renderNode(node: BlocksNode, i: number): ReactNode {
- const children = node.children ?? [];
- switch (node.type) {
- case 'paragraph':
- return {children.map(renderInline)}
;
- case 'heading': {
- const level = Math.min(Math.max(node.level ?? 1, 1), 6);
- const Tag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
- return {children.map(renderInline)} ;
- }
- case 'list': {
- const Tag = node.format === 'ordered' ? 'ol' : 'ul';
- return {children.map(renderListItem)} ;
- }
- case 'quote':
- return {children.map(renderInline)} ;
- case 'code':
- return (
-
- {children.map(renderInline)}
-
- );
- default:
- return null; // tolerant: unknown block (incl. embedded `image`) → skip
- }
-}
-
-/** Renders a Strapi blocks AST array to semantic HTML nodes. */
-export function renderBlocks(content: BlocksContent): ReactNode {
- return content.map(renderNode);
-}
diff --git a/packages/web/src/blocks/button.test.ts b/packages/web/src/blocks/button.test.ts
index fea61b8..116f398 100644
--- a/packages/web/src/blocks/button.test.ts
+++ b/packages/web/src/blocks/button.test.ts
@@ -1,24 +1,24 @@
import { describe, expect, it } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
-import type { PresetAtomButton } from '../types/base';
+import { createElement } from 'react';
import { Button } from './button';
-const render = (props: { label: string; href: string; variant?: PresetAtomButton['variant'] }): string =>
- renderToStaticMarkup(Button({ __component: 'preset-atom.button', id: 1, ...(props as any) }));
-
-describe('Button renderer', () => {
- it('renders an anchor with the label and href inside the data-block wrapper', () => {
- const html = render({ label: 'Get started', href: '/start', variant: 'primary' });
- expect(html).toContain('
);
diff --git a/packages/web/src/sections/hero.test.ts b/packages/web/src/sections/hero.test.ts
index 30b1f25..5eb9898 100644
--- a/packages/web/src/sections/hero.test.ts
+++ b/packages/web/src/sections/hero.test.ts
@@ -64,9 +64,12 @@ describe('Hero renderer', () => {
expect(render({ title: 'T' })).not.toContain('--press-col-span-md:5');
});
- it('renders the CTA only when BOTH ctaLabel and ctaHref are present (Spec §8)', () => {
- expect(render({ title: 'T', ctaLabel: 'Go', ctaHref: '/go' })).toContain('href="/go"');
- expect(render({ title: 'T', ctaLabel: 'Go' })).not.toContain('data-hero="cta"');
- expect(render({ title: 'T', ctaHref: '/go' })).not.toContain('data-hero="cta"');
+ it('renders the CTA only when a complete link is present (Spec §8)', () => {
+ const withCta = render({ title: 'T', cta: { label: 'Go', url: '/go' } });
+ expect(withCta).toContain('data-hero="cta"');
+ expect(withCta).toContain('href="/go"');
+ expect(withCta).toContain('Go');
+ expect(render({ title: 'T', cta: { label: 'Go' } })).not.toContain('data-hero="cta"');
+ expect(render({ title: 'T' })).not.toContain('data-hero="cta"');
});
});
diff --git a/packages/web/src/sections/hero.tsx b/packages/web/src/sections/hero.tsx
index 132e249..4d0e077 100644
--- a/packages/web/src/sections/hero.tsx
+++ b/packages/web/src/sections/hero.tsx
@@ -2,6 +2,8 @@ import type { PresetOrganismHero } from '../types/base';
import { Container } from '../layout/container';
import { Grid } from '../layout/grid';
import { Column } from '../layout/column';
+import { coerceLink } from '../link';
+import { PressLink } from '../press-link';
const CMS_URL = process.env.CMS_URL ?? 'http://localhost:1337';
@@ -23,12 +25,11 @@ export function Hero({
title,
subtitle,
image,
- ctaLabel,
- ctaHref,
+ cta,
align,
}: PresetOrganismHero) {
if (!title) return null;
- const hasCta = Boolean(ctaLabel && ctaHref);
+ const hasCta = Boolean(coerceLink(cta));
const hasImage = Boolean(image?.url);
return (
{eyebrow} : null}
{title}
{subtitle ? {subtitle}
: null}
- {hasCta ? (
-
- {ctaLabel}
-
- ) : null}
+ {hasCta ? : null}
{hasImage ? (
From 191096405478f8dc51119a10935c66cc905333fe Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Tue, 21 Jul 2026 20:14:55 -0300
Subject: [PATCH 20/31] =?UTF-8?q?feat(web):=20curated=20container-attr=20?=
=?UTF-8?q?=E2=86=92=20layout-primitive=20mappings=20for=20the=20tree=20re?=
=?UTF-8?q?nderer?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/web/src/tree/container-attrs.test.ts | 38 +++++++++++
packages/web/src/tree/container-attrs.ts | 66 +++++++++++++++++++
2 files changed, 104 insertions(+)
create mode 100644 packages/web/src/tree/container-attrs.test.ts
create mode 100644 packages/web/src/tree/container-attrs.ts
diff --git a/packages/web/src/tree/container-attrs.test.ts b/packages/web/src/tree/container-attrs.test.ts
new file mode 100644
index 0000000..345e07b
--- /dev/null
+++ b/packages/web/src/tree/container-attrs.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+import { cellAlign, RATIO_SPANS, rowAlign, rowGap, rowWidth, spanFor, stackGap } from './container-attrs';
+
+describe('spanFor', () => {
+ it('keeps the columns-organism ratio scale, including 25-25-25-25 two-stage md/lg', () => {
+ expect(RATIO_SPANS['25-25-25-25'][0]).toEqual({ base: 12, md: 6, lg: 3 });
+ expect(spanFor('33-67', 1)).toEqual({ base: 12, md: 8 });
+ });
+
+ it('defaults to 50-50 and reuses the last span past the ratio slots (tolerance)', () => {
+ expect(spanFor(undefined, 0)).toEqual({ base: 12, md: 6 });
+ expect(spanFor('50-50', 5)).toEqual({ base: 12, md: 6 });
+ });
+});
+
+describe('container pickers (absent attr → engine default)', () => {
+ it('maps gap tiers with the 11-gap-floor rule (spacious is tier-scaled)', () => {
+ expect(rowGap()).toBe('md');
+ expect(rowGap({ gap: 'compact' })).toBe('sm');
+ expect(rowGap({ gap: 'spacious' })).toEqual({ base: 'md', lg: 'lg' });
+ });
+
+ it('maps alignment and width', () => {
+ expect(rowAlign()).toBe('start');
+ expect(rowAlign({ verticalAlign: 'bottom' })).toBe('end');
+ expect(rowWidth()).toBe('lg');
+ expect(rowWidth({ width: 'full' })).toBe('full');
+ });
+
+ it('stack gap is a CSS var only when declared; cell align skips top', () => {
+ expect(stackGap()).toBeUndefined();
+ expect(stackGap({ gap: 'spacious' })).toBe('var(--press-space-7)');
+ expect(cellAlign()).toBeUndefined();
+ expect(cellAlign({ verticalAlign: 'top' })).toBeUndefined();
+ expect(cellAlign({ verticalAlign: 'center' })).toBe('center');
+ expect(cellAlign({ verticalAlign: 'bottom' })).toBe('end');
+ });
+});
diff --git a/packages/web/src/tree/container-attrs.ts b/packages/web/src/tree/container-attrs.ts
new file mode 100644
index 0000000..3e2834f
--- /dev/null
+++ b/packages/web/src/tree/container-attrs.ts
@@ -0,0 +1,66 @@
+/**
+ * Curated container attrs → layout-primitive props (Spec §5). This module is
+ * where editorial intent meets the responsive system: the JSON never carries
+ * breakpoints — RATIO_SPANS/GAP_TIERS (inherited verbatim from the retired
+ * columns organism) own the base/md/lg mapping, so the 11-gap floor and the
+ * 25-25-25-25 two-stage md/lg behavior stay engine-owned and untouchable.
+ * Every picker treats an ABSENT attr as the engine default (Spec §3).
+ */
+import type { ContainerAttrs, Gap, Ratio } from '@ogs-tech/press-shared';
+import type { Responsive } from '../layout/breakpoints';
+import type { Span } from '../layout/column';
+import type { ContainerMaxWidth } from '../layout/container';
+import type { GridAlignItems, GridGap } from '../layout/grid';
+
+export const RATIO_SPANS: Record[]> = {
+ '50-50': [{ base: 12, md: 6 }, { base: 12, md: 6 }],
+ '33-67': [{ base: 12, md: 4 }, { base: 12, md: 8 }],
+ '67-33': [{ base: 12, md: 8 }, { base: 12, md: 4 }],
+ '33-33-33': [{ base: 12, md: 4 }, { base: 12, md: 4 }, { base: 12, md: 4 }],
+ '25-25-25-25': [
+ { base: 12, md: 6, lg: 3 },
+ { base: 12, md: 6, lg: 3 },
+ { base: 12, md: 6, lg: 3 },
+ { base: 12, md: 6, lg: 3 },
+ ],
+};
+
+/** Column i of a row: extra columns beyond the ratio's slots reuse the last span (columns tolerance). */
+export function spanFor(ratio: Ratio | undefined, index: number): Responsive {
+ const spans = (ratio && RATIO_SPANS[ratio]) || RATIO_SPANS['50-50'];
+ return spans[Math.min(index, spans.length - 1)];
+}
+
+/** Semantic gap → GridGap tiers: a 12-track grid carries 11 interior gaps, so 'spacious' tier-scales. */
+const GAP_TIERS: Record> = {
+ compact: 'sm',
+ normal: 'md',
+ spacious: { base: 'md', lg: 'lg' },
+};
+
+export const rowGap = (attrs?: ContainerAttrs): Responsive => GAP_TIERS[attrs?.gap ?? 'normal'];
+
+const ALIGN_ITEMS: Record, GridAlignItems> = {
+ top: 'start',
+ center: 'center',
+ bottom: 'end',
+};
+
+export const rowAlign = (attrs?: ContainerAttrs): GridAlignItems => ALIGN_ITEMS[attrs?.verticalAlign ?? 'top'];
+
+export const rowWidth = (attrs?: ContainerAttrs): ContainerMaxWidth => attrs?.width ?? 'lg';
+
+/** Stack rhythm (layout root / column cells): a CSS space token consumed by theme.css stack rules. */
+export const STACK_GAPS: Record = {
+ compact: 'var(--press-space-3)',
+ normal: 'var(--press-space-5)',
+ spacious: 'var(--press-space-7)',
+};
+
+/** undefined when undeclared — the renderer then emits NO stack attr and legacy per-block margins apply. */
+export const stackGap = (attrs?: ContainerAttrs): string | undefined =>
+ attrs?.gap ? STACK_GAPS[attrs.gap] : undefined;
+
+/** Cell content placement; 'top' is the flex default so it emits nothing. */
+export const cellAlign = (attrs?: ContainerAttrs): 'center' | 'end' | undefined =>
+ attrs?.verticalAlign === 'center' ? 'center' : attrs?.verticalAlign === 'bottom' ? 'end' : undefined;
From 39e8aec6730a6412893b34c27215b08a8d8da94a Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Tue, 21 Jul 2026 20:25:08 -0300
Subject: [PATCH 21/31] feat(web)!: pageDefaults slots + one-point engine-block
hydration (resolve-slots)
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/web/package.json | 2 +-
.../web/src/config/build-metadata.test.ts | 2 +-
.../web/src/config/build-theme-style.test.ts | 2 +-
packages/web/src/config/types.ts | 40 ++-----
packages/web/src/get-site-config.test.ts | 25 ++---
packages/web/src/get-site-config.ts | 4 +-
packages/web/src/index.ts | 2 -
packages/web/src/map-site-settings.test.ts | 106 ++----------------
packages/web/src/map-site-settings.ts | 83 +++-----------
packages/web/src/tree/resolve-slots.test.ts | 98 ++++++++++++++++
packages/web/src/tree/resolve-slots.ts | 85 ++++++++++++++
pnpm-lock.yaml | 6 +-
12 files changed, 238 insertions(+), 217 deletions(-)
create mode 100644 packages/web/src/tree/resolve-slots.test.ts
create mode 100644 packages/web/src/tree/resolve-slots.ts
diff --git a/packages/web/package.json b/packages/web/package.json
index 3f47fc7..23f3d9f 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -41,11 +41,11 @@
"next": ">=15"
},
"dependencies": {
+ "@ogs-tech/press-shared": "workspace:*",
"commander": "^13.0.0",
"tsx": "^4.19.0"
},
"devDependencies": {
- "@ogs-tech/press-shared": "workspace:*",
"@types/node": "^20",
"@types/react": "^18",
"jsdom": "^25.0.1",
diff --git a/packages/web/src/config/build-metadata.test.ts b/packages/web/src/config/build-metadata.test.ts
index 4ce0415..ec304a8 100644
--- a/packages/web/src/config/build-metadata.test.ts
+++ b/packages/web/src/config/build-metadata.test.ts
@@ -24,7 +24,7 @@ const resolved: ResolvedPressConfig = {
fonts: {},
radius: { xs: '6px', sm: '10px', md: '14px', lg: '20px' },
},
- chrome: { header: [], footer: [] },
+ pageDefaults: { header: [], footer: [] },
plugins: { cookieConsent: mapCookieConsent(null, 'home') },
};
diff --git a/packages/web/src/config/build-theme-style.test.ts b/packages/web/src/config/build-theme-style.test.ts
index a9b2cf1..9564550 100644
--- a/packages/web/src/config/build-theme-style.test.ts
+++ b/packages/web/src/config/build-theme-style.test.ts
@@ -16,7 +16,7 @@ const baseResolved: ResolvedPressConfig = {
fonts: {},
radius: { ...DEFAULT_THEME.radius },
},
- chrome: { header: [], footer: [] },
+ pageDefaults: { header: [], footer: [] },
plugins: { cookieConsent: mapCookieConsent(null, 'home') },
};
diff --git a/packages/web/src/config/types.ts b/packages/web/src/config/types.ts
index c1d40b4..da7e82f 100644
--- a/packages/web/src/config/types.ts
+++ b/packages/web/src/config/types.ts
@@ -1,3 +1,4 @@
+import type { Node } from '@ogs-tech/press-shared';
import type { Canonical } from '../urn';
import type { RawCookieConsent, ResolvedCookieConsentPlugin } from '../plugins/cookie-consent/types';
import type { ResolvedLink } from '../link';
@@ -58,15 +59,6 @@ export interface PressConfig {
/** A fully-resolved navigation link (page relation already collapsed to an href). */
export type ResolvedNavLink = ResolvedLink;
-/**
- * A chrome dynamic-zone entry. Loose by design: the zones admit preset-atom.* /
- * preset-organism.* / custom-* blocks the engine cannot enumerate, and the
- * renderer only dispatches on `__component`. The engine chrome organisms
- * (preset-organism.navbar/footer) gain `brand`/`links` during hydration
- * (mapSiteSettings, Spec §3).
- */
-export type ChromeBlock = { __component: string; id: number; [k: string]: unknown };
-
/** Hydrated `preset-organism.navbar` — the exact props the Navbar renderer receives (Spec §3). */
export interface ResolvedChromeNavbar {
/** Injected from Site Settings identity — never stored on the block (Spec §1). */
@@ -76,14 +68,6 @@ export interface ResolvedChromeNavbar {
cta?: (ResolvedLink & { variant?: 'primary' | 'secondary' }) | null;
}
-/** Hydrated `preset-organism.footer` — brand injected for the copyright fallback (Spec §1). */
-export interface ResolvedChromeFooter {
- __component: 'preset-organism.footer';
- id: number;
- text?: string | null;
- brand: { name: string };
-}
-
/**
* Fully-resolved config: every default applied, ready for the engine helpers.
* A canonical entity with a SYNTHETIC identity (canonical-urn Spec §3): Site
@@ -124,16 +108,14 @@ export interface ResolvedPressConfig extends Canonical<'site-setting'> {
radius: ThemeRadius;
};
/**
- * Site chrome (Spec §3): the two Site-Settings Dynamic Zones, HYDRATED —
- * preset-organism.navbar entries carry the resolved brand + links and
- * preset-organism.footer entries carry the brand for the copyright fallback;
- * all other blocks pass
- * through untouched so BlockRenderer stays intentionally dumb. Empty when the
- * CMS is empty/unreachable/malformed (unbranded over synthetic — Spec §4).
+ * The two Site-Settings pageDefaults slots (Spec §3/§6), RAW nodes — engine
+ * blocks are hydrated exactly once, by `resolveTree` (tree/resolve-slots.ts),
+ * never here. Empty when the CMS is empty/unreachable/malformed (unbranded
+ * over synthetic — Spec §4).
*/
- chrome: {
- header: ChromeBlock[];
- footer: ChromeBlock[];
+ pageDefaults: {
+ header: Node[];
+ footer: Node[];
};
/**
* Engine plugins (cookie-consent Spec §1): a NAMED map — one required key
@@ -141,7 +123,7 @@ export interface ResolvedPressConfig extends Canonical<'site-setting'> {
* or unreachable. Not an array: plugins are fixed engine features, not
* editor-composed content (that is what the Dynamic Zones are for). Each
* new plugin adds a required key — a deliberate press-web major, the same
- * discipline as `urn`/`chrome`.
+ * discipline as `urn`/`pageDefaults`.
*/
plugins: {
cookieConsent: ResolvedCookieConsentPlugin;
@@ -186,6 +168,6 @@ export interface SiteSettingsData {
themeColors?: Partial | null;
themeRadius?: Partial | null;
cookieConsent?: RawCookieConsent | null;
- header?: ChromeBlock[] | null;
- footer?: ChromeBlock[] | null;
+ /** The two page-default slots (Spec §6): bare Node[] arrays, validated by mapSiteSettings. */
+ pageDefaults?: { header?: unknown; footer?: unknown } | null;
}
diff --git a/packages/web/src/get-site-config.test.ts b/packages/web/src/get-site-config.test.ts
index b60b37e..8d39166 100644
--- a/packages/web/src/get-site-config.test.ts
+++ b/packages/web/src/get-site-config.test.ts
@@ -56,30 +56,23 @@ describe('getSiteConfig', () => {
expect(r.brand.name).toBe('');
});
- it('maps a body with chrome data end-to-end', async () => {
+ it('maps a body with pageDefaults through end-to-end (raw — hydration happens at resolveTree)', async () => {
+ const navbarNode = {
+ id: 'nav', type: 'block', component: 'preset-organism.navbar',
+ data: { items: [{ label: 'About', page: { slug: 'about' }, newTab: false }] },
+ };
+ const footerNode = { id: 'f', type: 'block', component: 'preset-organism.footer', data: {} };
stubFetch(async () => ({
ok: true,
json: async () => ({
data: {
name: 'Acme',
- header: [{
- __component: 'preset-organism.navbar',
- id: 1,
- items: [
- { label: 'About', page: { slug: 'about' }, newTab: false },
- { label: 'Docs', url: 'https://docs.test', newTab: true },
- ],
- }],
- footer: [{ __component: 'preset-organism.footer', id: 2 }],
+ pageDefaults: { header: [navbarNode], footer: [footerNode] },
},
}),
}));
const r = await getSiteConfig(buildTime);
- expect((r.chrome.header[0] as any).brand).toEqual({ name: 'Acme', logo: undefined });
- expect((r.chrome.header[0] as any).links).toEqual([
- { label: 'About', href: '/about', external: false, newTab: false },
- { label: 'Docs', href: 'https://docs.test', external: true, newTab: true },
- ]);
- expect((r.chrome.footer[0] as any).brand).toEqual({ name: 'Acme' });
+ expect(r.pageDefaults.header).toEqual([navbarNode]);
+ expect(r.pageDefaults.footer).toEqual([footerNode]);
});
});
diff --git a/packages/web/src/get-site-config.ts b/packages/web/src/get-site-config.ts
index 0bb4a1f..bf0ef0a 100644
--- a/packages/web/src/get-site-config.ts
+++ b/packages/web/src/get-site-config.ts
@@ -11,8 +11,8 @@ type RevalidateInit = RequestInit & { next?: { revalidate?: number } };
* Fetches the Site Settings single type and maps it into the full
* ResolvedPressConfig, combining it with the build-time anchors (routes,
* theme.name, theme.fonts). The populate tree (media + config components +
- * chrome DZs → navbar items' page slugs) is owned by the site-setting controller, not requested
- * here (spec §5.1) — the engine owns the wire shape. ISR-cached (~60s) so editor
+ * pageDefaults slots → navbar items' page slugs) is owned by the site-setting
+ * controller, not requested here (spec §5.1) — the engine owns the wire shape. ISR-cached (~60s) so editor
* changes appear without a deploy. Any failure — non-OK, network error, malformed
* body — maps as if the record were EMPTY: engine-default theme (DEFAULT_THEME) +
* empty identity. There is NO press.config fallback for identity/SEO by design
diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts
index 4c866be..cfd6780 100644
--- a/packages/web/src/index.ts
+++ b/packages/web/src/index.ts
@@ -64,10 +64,8 @@ export type {
ResolvedPressConfig,
BuildTimeConfig,
ThemeName,
- ChromeBlock,
ResolvedNavLink,
ResolvedChromeNavbar,
- ResolvedChromeFooter,
} from './config/types';
export { Container, Grid, Row, Column, BREAKPOINTS } from './layout';
export type {
diff --git a/packages/web/src/map-site-settings.test.ts b/packages/web/src/map-site-settings.test.ts
index 021e90c..e6e029f 100644
--- a/packages/web/src/map-site-settings.test.ts
+++ b/packages/web/src/map-site-settings.test.ts
@@ -27,8 +27,8 @@ describe('mapSiteSettings', () => {
expect(r.routes.home).toBe('home');
expect(r.theme.name).toBe('default');
expect(r.theme.fonts).toEqual({ body: 'Inter' });
- // chrome: empty zones when the CMS is empty (Spec §4)
- expect(r.chrome).toEqual({ header: [], footer: [] });
+ // pageDefaults: empty slots when the CMS is empty (Spec §4)
+ expect(r.pageDefaults).toEqual({ header: [], footer: [] });
});
it('maps an empty {} CMS identically to null', () => {
@@ -88,103 +88,17 @@ describe('mapSiteSettings', () => {
expect(r.theme.fonts).toEqual(buildTime.theme.fonts);
expect(r.routes).toEqual(buildTime.routes);
});
-});
-
-describe('mapSiteSettings — chrome hydration (Spec §3)', () => {
- const navbar = (extra: Record = {}) =>
- ({ __component: 'preset-organism.navbar', id: 1, ...extra });
-
- const headerWith = (items: unknown[]) =>
- mapSiteSettings(buildTime, { name: 'Acme', header: [navbar({ items })] });
-
- const linksOf = (r: ReturnType) =>
- (r.chrome.header[0] as any).links;
-
- it('injects the resolved brand (name + logo) into preset-organism.navbar — never stored on the block', () => {
- const r = mapSiteSettings(buildTime, {
- name: 'Acme',
- logo: { url: '/uploads/logo.png' },
- header: [navbar()],
- });
- expect((r.chrome.header[0] as any).brand).toEqual({
- name: 'Acme',
- logo: 'http://localhost:1337/uploads/logo.png',
- });
- });
-
- it('resolves an internal page item to /slug, external false', () => {
- const r = headerWith([{ label: 'About', page: { slug: 'about' }, newTab: false }]);
- expect(linksOf(r)).toEqual([{ label: 'About', href: '/about', external: false, newTab: false }]);
- });
-
- it('collapses the home slug to /', () => {
- const r = headerWith([{ label: 'Home', page: { slug: 'home' } }]); // buildTime.routes.home === 'home'
- expect(linksOf(r)[0].href).toBe('/');
- });
-
- it('resolves an external url with external:true and honors newTab', () => {
- const r = headerWith([{ label: 'Docs', url: 'https://docs.test', newTab: true }]);
- expect(linksOf(r)).toEqual([{ label: 'Docs', href: 'https://docs.test', external: true, newTab: true }]);
- });
-
- it('treats a non-http url as internal-style (external: false)', () => {
- const r = headerWith([{ label: 'Contact', url: '/contact' }]);
- expect(linksOf(r)).toEqual([{ label: 'Contact', href: '/contact', external: false, newTab: false }]);
- });
-
- it('lets page win over url when both are set (precedence)', () => {
- const r = headerWith([{ label: 'Both', page: { slug: 'about' }, url: 'https://ignored.test' }]);
- expect(linksOf(r)[0]).toEqual({ label: 'Both', href: '/about', external: false, newTab: false });
- });
-
- it('drops an item with neither page nor url', () => {
- const r = headerWith([
- { label: 'Keep', url: '/keep' },
- { label: 'Drop' },
- { label: 'DropToo', page: null, url: '' },
- ]);
- expect(linksOf(r).map((l: any) => l.label)).toEqual(['Keep']);
- });
- it('hydrates a navbar with no items to empty links (the seeded default renders brand-only)', () => {
- const r = mapSiteSettings(buildTime, { name: 'Acme', header: [navbar()] });
- expect(linksOf(r)).toEqual([]);
- });
-
- it('keeps the navbar cta untouched (renderer consumes it as-is)', () => {
- const cta = { label: 'Sign up', href: '/signup', variant: 'primary' };
- const r = mapSiteSettings(buildTime, { header: [navbar({ cta })] });
- expect((r.chrome.header[0] as any).cta).toEqual(cta);
- });
-
- it('injects the brand into preset-organism.footer for the copyright fallback', () => {
- const r = mapSiteSettings(buildTime, {
- name: 'Acme',
- footer: [{ __component: 'preset-organism.footer', id: 2, text: '' }],
- });
- expect((r.chrome.footer[0] as any).brand).toEqual({ name: 'Acme' });
- });
-
- it('passes non-chrome blocks through untouched (BlockRenderer stays dumb)', () => {
- const hero = { __component: 'preset-organism.hero', id: 3, title: 'Big' };
- const r = mapSiteSettings(buildTime, { header: [hero] });
- expect(r.chrome.header).toEqual([hero]);
- });
-
- it('hydrates the footer zone with the same rules as the header (a navbar works in either zone)', () => {
- const r = mapSiteSettings(buildTime, {
- name: 'Acme',
- footer: [navbar({ items: [{ label: 'About', page: { slug: 'about' } }] })],
- });
- expect((r.chrome.footer[0] as any).links).toEqual([
- { label: 'About', href: '/about', external: false, newTab: false },
- ]);
+ it('maps valid pageDefaults slots through and fails invalid slots to empty', () => {
+ const nodes = [{ id: 'n', type: 'block', component: 'preset-organism.navbar', data: {} }];
+ const ok = mapSiteSettings(buildTime, { pageDefaults: { header: nodes, footer: [] } } as any);
+ expect(ok.pageDefaults.header).toEqual(nodes);
+ const bad = mapSiteSettings(buildTime, { pageDefaults: { header: [{ id: 'c', type: 'column', children: [] }] } } as any);
+ expect(bad.pageDefaults.header).toEqual([]);
});
- it('maps absent / empty zones and a null CMS to empty arrays', () => {
- expect(mapSiteSettings(buildTime, { header: [], footer: [] }).chrome).toEqual({ header: [], footer: [] });
- expect(mapSiteSettings(buildTime, {}).chrome).toEqual({ header: [], footer: [] });
- expect(mapSiteSettings(buildTime, null).chrome).toEqual({ header: [], footer: [] });
+ it('maps an absent/unreachable CMS to empty pageDefaults', () => {
+ expect(mapSiteSettings(buildTime, null).pageDefaults).toEqual({ header: [], footer: [] });
});
});
diff --git a/packages/web/src/map-site-settings.ts b/packages/web/src/map-site-settings.ts
index bdcdbfd..67328bc 100644
--- a/packages/web/src/map-site-settings.ts
+++ b/packages/web/src/map-site-settings.ts
@@ -1,10 +1,5 @@
-import type {
- BuildTimeConfig,
- ChromeBlock,
- ResolvedNavLink,
- ResolvedPressConfig,
- SiteSettingsData,
-} from './config/types';
+import { validateNodeArray, type Node } from '@ogs-tech/press-shared';
+import type { BuildTimeConfig, ResolvedPressConfig, SiteSettingsData } from './config/types';
import { DEFAULT_THEME } from './config/default-theme';
import { mapCookieConsent } from './plugins/cookie-consent/map-cookie-consent';
import { buildUrn } from './urn';
@@ -19,62 +14,17 @@ function mediaUrl(media: { url?: string } | null | undefined): string | undefine
return url.startsWith('http') ? url : `${CMS_URL}${url}`;
}
-/** A raw `preset-organism.navbar` nav item as populated by the site-setting controller. */
-interface RawNavItem {
- label?: string;
- page?: { slug?: string } | null;
- url?: string;
- newTab?: boolean;
-}
-
-/**
- * Resolves a CMS nav item into a final link (Spec §3). Precedence: `page` wins
- * over `url`. An internal page collapses to '/' when its slug is the home slug
- * (reusing the same routes.home anchor as the /home → / redirect —
- * CMS-independent). An item with neither page nor url is dropped (returns null).
- * The external flag is true only for http(s) URLs.
- */
-function resolveNavItem(item: RawNavItem, homeSlug: string): ResolvedNavLink | null {
- const label = item.label ?? '';
- const newTab = item.newTab ?? false;
- const slug = item.page?.slug;
- if (slug) {
- return { label, href: slug === homeSlug ? '/' : `/${slug}`, external: false, newTab };
- }
- if (item.url) {
- return { label, href: item.url, external: item.url.startsWith('http'), newTab };
- }
- return null;
-}
-
-/**
- * Hydrates one chrome dynamic zone (Spec §3): `preset-organism.navbar` gains the
- * resolved brand + links (page > url precedence, home slug → '/', external flag)
- * and `preset-organism.footer` gains the brand for its copyright fallback —
- * identity is never stored on a block (Spec §1). Every other block passes through
- * untouched so BlockRenderer stays intentionally dumb.
- */
-function hydrateChromeBlocks(
- blocks: ChromeBlock[] | null | undefined,
- brand: ResolvedPressConfig['brand'],
- homeSlug: string,
-): ChromeBlock[] {
- return (blocks ?? []).map((block) => {
- if (block.__component === 'preset-organism.navbar') {
- const items = (block.items as RawNavItem[] | null | undefined) ?? [];
- return {
- ...block,
- brand: { name: brand.name, logo: brand.logo },
- links: items
- .map((item) => resolveNavItem(item, homeSlug))
- .filter((link): link is ResolvedNavLink => link !== null),
- };
- }
- if (block.__component === 'preset-organism.footer') {
- return { ...block, brand: { name: brand.name } };
+/** One pageDefaults slot: fail-to-empty on invalid nodes (Spec §6.3), dev-only warning. */
+function mapSlot(input: unknown, slot: string): Node[] {
+ if (input === undefined || input === null) return [];
+ const { value, errors } = validateNodeArray(input);
+ if (!value) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.warn(`[press/web] invalid pageDefaults.${slot} — rendering empty`, errors);
}
- return block;
- });
+ return [];
+ }
+ return value;
}
/**
@@ -85,7 +35,8 @@ function hydrateChromeBlocks(
* field" unambiguously means empty (AC2/AC3). Theme colours/radii resolve over
* DEFAULT_THEME per key — the engine's shipped base, never empty (AC4). Build-time
* anchors (routes, theme.name, theme.fonts) come from `buildTime` (AC8). The
- * chrome DZs are hydrated here (Spec §3) so the renderers stay dumb.
+ * pageDefaults slots are validated (fail-to-empty, Spec §6.3) but stored RAW —
+ * engine-block hydration happens exactly once, in `resolveTree`.
*/
export function mapSiteSettings(
buildTime: BuildTimeConfig,
@@ -122,9 +73,9 @@ export function mapSiteSettings(
fonts: buildTime.theme.fonts,
radius: { ...DEFAULT_THEME.radius, ...(c.themeRadius ?? {}) },
},
- chrome: {
- header: hydrateChromeBlocks(c.header, brand, buildTime.routes.home),
- footer: hydrateChromeBlocks(c.footer, brand, buildTime.routes.home),
+ pageDefaults: {
+ header: mapSlot(c.pageDefaults?.header, 'header'),
+ footer: mapSlot(c.pageDefaults?.footer, 'footer'),
},
plugins: {
// Fails OPEN (cookie-consent Spec §3) — unlike identity/SEO, an
diff --git a/packages/web/src/tree/resolve-slots.test.ts b/packages/web/src/tree/resolve-slots.test.ts
new file mode 100644
index 0000000..2e5e58e
--- /dev/null
+++ b/packages/web/src/tree/resolve-slots.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from 'vitest';
+import type { Node, PressTree } from '@ogs-tech/press-shared';
+import { hydrateEngineBlocks, resolveTree } from './resolve-slots';
+
+const brand = { name: 'Press', logo: 'http://cms/logo.png' };
+
+const navbarNode = (): Node => ({
+ id: 'nav', type: 'block', component: 'preset-organism.navbar',
+ data: {
+ items: [
+ { label: 'Home', page: { documentId: 'd1', slug: 'home' } },
+ { label: 'GH', url: 'https://github.com', newTab: true },
+ { label: 'dead' }, // unresolvable → dropped from links
+ ],
+ cta: { link: { label: 'Go', url: '/go' }, variant: 'secondary' },
+ },
+});
+
+const site = (defaults: { header?: Node[]; footer?: Node[] }) =>
+ ({
+ brand: { ...brand, favicon: '' },
+ routes: { home: 'home' },
+ pageDefaults: { header: defaults.header ?? [], footer: defaults.footer ?? [] },
+ }) as any;
+
+const tree = (header: any, footer: any, children: Node[] = []): PressTree => ({
+ version: 1,
+ root: { type: 'layout', header, footer, children },
+});
+
+describe('hydrateEngineBlocks', () => {
+ it('hydrates navbar brand/links/cta with home-slug collapse, at any depth', () => {
+ const nested: Node[] = [{
+ id: 'r', type: 'row', ratio: '50-50', children: [
+ { id: 'c', type: 'column', children: [navbarNode()] },
+ { id: 'c2', type: 'column', children: [] },
+ ],
+ }];
+ const [row] = hydrateEngineBlocks(nested, brand, 'home') as any[];
+ const nav = row.children[0].children[0].data;
+ expect(nav.brand).toEqual(brand);
+ expect(nav.links).toEqual([
+ { label: 'Home', href: '/', external: false, newTab: false },
+ { label: 'GH', href: 'https://github.com', external: true, newTab: true },
+ ]);
+ expect(nav.cta).toMatchObject({ label: 'Go', href: '/go', variant: 'secondary' });
+ });
+
+ it('resolves button/hero/cta link fields and injects the footer brand', () => {
+ const nodes: Node[] = [
+ { id: 'b', type: 'block', component: 'preset-atom.button', data: { link: { label: 'Docs', page: { documentId: 'd9', slug: 'docs' } }, variant: 'primary' } },
+ { id: 'h', type: 'block', component: 'preset-organism.hero', data: { title: 'T', cta: { label: 'Read', url: '/read' } } },
+ { id: 'f', type: 'block', component: 'preset-organism.footer', data: {} },
+ { id: 'x', type: 'block', component: 'custom-organism.callout', data: { message: 'untouched' } },
+ ];
+ const out = hydrateEngineBlocks(nodes, brand, 'home') as any[];
+ expect(out[0].data.link).toEqual({ label: 'Docs', href: '/docs', external: false, newTab: false });
+ expect(out[1].data.cta.href).toBe('/read');
+ expect(out[2].data.brand).toEqual({ name: 'Press' });
+ expect(out[3].data).toEqual({ message: 'untouched' }); // adopter data is never touched
+ expect(nodes[0].type === 'block' && (nodes[0].data as any).link.page.slug).toBe('docs'); // input not mutated
+ });
+});
+
+describe('resolveTree slot matrix', () => {
+ const defaults = { header: [navbarNode()], footer: [{ id: 'f', type: 'block', component: 'preset-organism.footer', data: {} } as Node] };
+
+ it('inherit pulls (and hydrates) pageDefaults; none is empty; custom wins', () => {
+ const inherited = resolveTree(tree({ mode: 'inherit' }, { mode: 'inherit' }), site(defaults));
+ expect((inherited.header[0] as any).data.brand).toEqual(brand);
+ expect((inherited.footer[0] as any).data.brand).toEqual({ name: 'Press' });
+
+ const bare = resolveTree(tree({ mode: 'none' }, { mode: 'none' }), site(defaults));
+ expect(bare.header).toEqual([]);
+ expect(bare.footer).toEqual([]);
+
+ const custom = resolveTree(
+ tree({ mode: 'custom', children: [{ id: 'p', type: 'block', component: 'preset-atom.paragraph', data: { content: 'x' } }] }, { mode: 'none' }),
+ site(defaults),
+ );
+ expect((custom.header[0] as any).component).toBe('preset-atom.paragraph');
+ });
+
+ it('inherit against absent defaults renders bare (fail-to-empty)', () => {
+ const out = resolveTree(tree({ mode: 'inherit' }, { mode: 'inherit' }), site({}));
+ expect(out.header).toEqual([]);
+ });
+
+ it('carries the root container and hydrates body children too', () => {
+ const t = tree({ mode: 'none' }, { mode: 'none' }, [
+ { id: 'b', type: 'block', component: 'preset-atom.button', data: { link: { label: 'Go', url: '/g' } } },
+ ]);
+ t.root.container = { gap: 'spacious' };
+ const out = resolveTree(t, site({}));
+ expect(out.rootContainer).toEqual({ gap: 'spacious' });
+ expect((out.children[0] as any).data.link.href).toBe('/g');
+ });
+});
diff --git a/packages/web/src/tree/resolve-slots.ts b/packages/web/src/tree/resolve-slots.ts
new file mode 100644
index 0000000..f7b7577
--- /dev/null
+++ b/packages/web/src/tree/resolve-slots.ts
@@ -0,0 +1,85 @@
+/**
+ * Slot resolution + engine-block hydration — the ONE hydration point (Spec §5/§6).
+ * `inherit` resolves against Site Settings pageDefaults at render (ISR ~60s:
+ * editing the default header updates every inheriting page, no redeploy);
+ * `none` is a bare page; `custom` is page-owned chrome. All three lists — and
+ * the body — then get engine blocks hydrated WHEREVER they sit: navbar/footer
+ * gain brand (identity is never stored on a block), and every engine link
+ * field resolves with the homeSlug collapse. The engine names only its OWN
+ * blocks here — adopter data passes through untouched (custom blocks render
+ * links via themselves).
+ */
+import type { ContainerAttrs, Node, PressTree, Slot } from '@ogs-tech/press-shared';
+import type { ResolvedPressConfig } from '../config/types';
+import { resolveLink, type PressLinkData, type ResolvedLink } from '../link';
+
+export interface ResolvedTree {
+ header: Node[];
+ children: Node[];
+ footer: Node[];
+ rootContainer?: ContainerAttrs;
+}
+
+type Brand = { name: string; logo?: string };
+
+const isRecord = (v: unknown): v is Record =>
+ typeof v === 'object' && v !== null && !Array.isArray(v);
+
+/** Engine blocks whose data carries ONE link field to resolve in place. */
+const LINK_FIELDS: Record = {
+ 'preset-atom.button': 'link',
+ 'preset-organism.hero': 'cta',
+ 'preset-organism.cta': 'button',
+};
+
+function hydrateBlockData(component: string, data: Record, brand: Brand, homeSlug: string): Record {
+ if (component === 'preset-organism.navbar') {
+ const items = Array.isArray(data.items) ? (data.items as PressLinkData[]) : [];
+ const links = items
+ .map((item) => resolveLink(item, homeSlug))
+ .filter((link): link is ResolvedLink => link !== null);
+ const rawCta = isRecord(data.cta) ? data.cta : undefined;
+ const ctaLink = rawCta ? resolveLink(rawCta.link as PressLinkData, homeSlug) : null;
+ return {
+ ...data,
+ brand: { name: brand.name, logo: brand.logo },
+ links,
+ cta: ctaLink ? { ...ctaLink, variant: rawCta?.variant } : null,
+ };
+ }
+ if (component === 'preset-organism.footer') {
+ return { ...data, brand: { name: brand.name } };
+ }
+ const linkField = LINK_FIELDS[component];
+ if (linkField && data[linkField] !== undefined && data[linkField] !== null) {
+ return { ...data, [linkField]: resolveLink(data[linkField] as PressLinkData, homeSlug) };
+ }
+ return data;
+}
+
+export function hydrateEngineBlocks(nodes: Node[], brand: Brand, homeSlug: string): Node[] {
+ return nodes.map((node) => {
+ if (node.type === 'block') {
+ return { ...node, data: hydrateBlockData(node.component, node.data, brand, homeSlug) };
+ }
+ return { ...node, children: hydrateEngineBlocks(node.children as Node[], brand, homeSlug) } as Node;
+ });
+}
+
+function slotNodes(slot: Slot, defaults: Node[]): Node[] {
+ if (slot.mode === 'inherit') return defaults;
+ if (slot.mode === 'custom') return slot.children;
+ return [];
+}
+
+export function resolveTree(tree: PressTree, site: ResolvedPressConfig): ResolvedTree {
+ const brand: Brand = { name: site.brand.name, logo: site.brand.logo };
+ const homeSlug = site.routes.home;
+ const hydrate = (nodes: Node[]): Node[] => hydrateEngineBlocks(nodes, brand, homeSlug);
+ return {
+ header: hydrate(slotNodes(tree.root.header, site.pageDefaults.header)),
+ children: hydrate(tree.root.children),
+ footer: hydrate(slotNodes(tree.root.footer, site.pageDefaults.footer)),
+ rootContainer: tree.root.container,
+ };
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b3ef92a..1de6b26 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -162,6 +162,9 @@ importers:
packages/web:
dependencies:
+ '@ogs-tech/press-shared':
+ specifier: workspace:*
+ version: link:../shared
commander:
specifier: ^13.0.0
version: 13.1.0
@@ -172,9 +175,6 @@ importers:
specifier: ^4.19.0
version: 4.22.4
devDependencies:
- '@ogs-tech/press-shared':
- specifier: workspace:*
- version: link:../shared
'@types/node':
specifier: ^20
version: 20.19.43
From 331efedc67389d2a9d9dfc7c9bbce51025543eff Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Tue, 21 Jul 2026 20:33:34 -0300
Subject: [PATCH 22/31] feat(web)!: TreeRenderer replaces BlockRenderer;
columns organism and blockKey retired
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/web/src/atom-blocks.ts | 2 +-
packages/web/src/block-key.test.ts | 22 ---
packages/web/src/block-key.ts | 20 ---
packages/web/src/block-renderer.test.tsx | 70 ----------
packages/web/src/block-renderer.tsx | 49 -------
packages/web/src/chrome/footer.tsx | 3 +-
packages/web/src/chrome/navbar.tsx | 4 +-
packages/web/src/index.ts | 21 ++-
packages/web/src/organism-blocks.ts | 4 +-
.../cookie-consent/cookie-consent-banner.tsx | 2 +-
packages/web/src/sections/columns.test.ts | 129 ------------------
packages/web/src/sections/columns.tsx | 104 --------------
packages/web/src/tree/tree-renderer.test.tsx | 103 ++++++++++++++
packages/web/src/tree/tree-renderer.tsx | 119 ++++++++++++++++
packages/web/src/urn.test.ts | 2 +-
packages/web/src/urn.ts | 23 ++--
16 files changed, 260 insertions(+), 417 deletions(-)
delete mode 100644 packages/web/src/block-key.test.ts
delete mode 100644 packages/web/src/block-key.ts
delete mode 100644 packages/web/src/block-renderer.test.tsx
delete mode 100644 packages/web/src/block-renderer.tsx
delete mode 100644 packages/web/src/sections/columns.test.ts
delete mode 100644 packages/web/src/sections/columns.tsx
create mode 100644 packages/web/src/tree/tree-renderer.test.tsx
create mode 100644 packages/web/src/tree/tree-renderer.tsx
diff --git a/packages/web/src/atom-blocks.ts b/packages/web/src/atom-blocks.ts
index 676ed61..8f54db9 100644
--- a/packages/web/src/atom-blocks.ts
+++ b/packages/web/src/atom-blocks.ts
@@ -12,7 +12,7 @@ import { Spacer } from './blocks/spacer';
* Engine-owned ATOM registry — the Atomic Design base layer: atomic text blocks
* (paragraph/heading/list/quote), media (image) and structural
* (button/separator/spacer). Adopter `custom-*` blocks are never named here —
- * they arrive via the explicit `components` prop on .
+ * they arrive via the explicit `components` prop on .
*/
export const atomBlocks: Record> = {
'preset-atom.paragraph': Paragraph,
diff --git a/packages/web/src/block-key.test.ts b/packages/web/src/block-key.test.ts
deleted file mode 100644
index 9529b54..0000000
--- a/packages/web/src/block-key.test.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { blockKey } from './block-key';
-import { buildUrn } from './urn';
-
-describe('blockKey', () => {
- it('qualifies the id by __component so cross-type id collisions stay unique', () => {
- // Strapi DZ ids are unique only per component table: an image and a callout can
- // both be id 5. Keying by id alone would collide (the real "duplicate key 5" bug).
- const image = { __component: 'preset-atom.image', id: 5 };
- const callout = { __component: 'custom-organism.callout', id: 5 };
- expect(blockKey(image, 0)).not.toBe(blockKey(callout, 1));
- expect(blockKey(image, 0)).toBe('urn:preset-atom.image:5');
- });
-
- it('formats through the canonical urn primitive — one identity format, single-sourced', () => {
- expect(blockKey({ __component: 'preset-atom.image', id: 5 }, 0)).toBe(buildUrn('preset-atom.image', 5));
- });
-
- it('falls back to the array index when id is absent', () => {
- expect(blockKey({ __component: 'preset-atom.paragraph' }, 3)).toBe('urn:preset-atom.paragraph:3');
- });
-});
diff --git a/packages/web/src/block-key.ts b/packages/web/src/block-key.ts
deleted file mode 100644
index 378e841..0000000
--- a/packages/web/src/block-key.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { buildUrn, type Urn } from './urn';
-
-/**
- * Stable React key for a single dynamic-zone entry.
- *
- * Keying by `id` ALONE is wrong for Strapi dynamic zones: a component instance's
- * `id` is unique only within its own component table, so two entries of different
- * `__component` types can legitimately share the same numeric `id` (e.g. a
- * `preset-atom.image` and a `custom-organism.callout` both id 5). React then sees duplicate keys
- * and warns / drops children. Qualifying the id with `__component` makes the key
- * unique across the whole zone. Falls back to the array index when `id` is absent.
- *
- * The key is formatted through the canonical urn primitive with `__component`
- * as the entity segment (canonical-urn Spec §4) — a COMPUTED identity, never
- * stored on the block: a DZ entry's id is ephemeral, so the engine promises
- * nothing beyond the current render.
- */
-export function blockKey(block: { __component: string; id?: number | null }, index: number): Urn {
- return buildUrn(block.__component, block.id ?? index);
-}
diff --git a/packages/web/src/block-renderer.test.tsx b/packages/web/src/block-renderer.test.tsx
deleted file mode 100644
index 3eae2b1..0000000
--- a/packages/web/src/block-renderer.test.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import { renderToStaticMarkup } from 'react-dom/server';
-import { BlockRenderer } from './block-renderer';
-
-vi.mock('next/navigation', () => ({ usePathname: () => '/' }));
-
-describe('BlockRenderer — organism blocks (sections)', () => {
- it('resolves an organism block from the organismBlocks registry', () => {
- const blocks = [{ __component: 'preset-organism.hero', id: 1, title: 'Ship faster' } as any];
- const out = renderToStaticMarkup( );
- expect(out).toContain(' {
- const blocks = [{ __component: 'preset-organism.hero', id: 1, title: 'Ship faster' } as any];
- const MyHero = ({ title }: { title: string }) => {title}
;
- const out = renderToStaticMarkup( );
- expect(out).toContain('data-block="custom-hero"');
- expect(out).not.toContain('data-block="preset-organism.hero"');
- });
-
- it('still resolves preset-atom.* atoms (organisms are additive)', () => {
- const blocks = [{ __component: 'preset-atom.button', id: 1, label: 'Go', href: '/go', variant: 'primary' } as any];
- const out = renderToStaticMarkup( );
- expect(out).toContain('data-block="preset-atom.button"');
- });
-
- it('skips an unknown component without crashing', () => {
- const blocks = [{ __component: 'preset-organism.does-not-exist', id: 1 } as any];
- expect(() => renderToStaticMarkup( )).not.toThrow();
- expect(renderToStaticMarkup( )).toBe('');
- });
-
- it('warns with the canonical component urn for an unknown block (identity class 2)', () => {
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
- const blocks = [{ __component: 'preset-organism.does-not-exist', id: 1 } as any];
- renderToStaticMarkup( );
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('urn:component:preset-organism.does-not-exist'));
- warn.mockRestore();
- });
-});
-
-describe('BlockRenderer — chrome organisms', () => {
- it('resolves the chrome organisms (navbar/footer) from the organismBlocks registry', () => {
- const blocks = [
- { __component: 'preset-organism.navbar', id: 1, brand: { name: 'Acme' }, links: [] } as any,
- { __component: 'preset-organism.footer', id: 2, text: 'hello', brand: { name: 'Acme' } } as any,
- ];
- const out = renderToStaticMarkup( );
- expect(out).toContain('data-block="preset-organism.navbar"');
- expect(out).toContain('data-block="preset-organism.footer"');
- });
-
- it('lets an adopter components map override a chrome renderer (adopter wins last, Spec §3)', () => {
- const blocks = [{ __component: 'preset-organism.navbar', id: 1, brand: { name: 'Acme' }, links: [] } as any];
- const MyNavbar = () =>
;
- const out = renderToStaticMarkup(
- ,
- );
- expect(out).toContain('data-block="custom-navbar"');
- expect(out).not.toContain('data-block="preset-organism.navbar"');
- });
-
- it('skips an unknown component in a chrome zone without crashing', () => {
- const blocks = [{ __component: 'preset-organism.does-not-exist', id: 1 } as any];
- expect(() => renderToStaticMarkup( )).not.toThrow();
- expect(renderToStaticMarkup( )).toBe('');
- });
-});
diff --git a/packages/web/src/block-renderer.tsx b/packages/web/src/block-renderer.tsx
deleted file mode 100644
index dfdf287..0000000
--- a/packages/web/src/block-renderer.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import type { ComponentType } from 'react';
-import { atomBlocks } from './atom-blocks';
-import { organismBlocks } from './organism-blocks';
-import { blockKey } from './block-key';
-import { componentUrn } from './urn';
-
-// Minimal structural shape the renderer needs from a dynamic-zone entry. No index
-// signature: the sync-generated component interfaces (PresetAtomParagraph, PresetAtomImage, CustomOrganismCallout, …)
-// have none, and a type with an index signature can't accept one without it — so
-// `PageBody` (the call-site type) would fail to satisfy `Block[]`. The renderer only
-// reads `__component` and `id` and spreads the rest, so the extra fields aren't typed here.
-interface Block {
- __component: string;
- id: number;
-}
-
-interface BlockRendererProps {
- /** The page's dynamic-zone array (typed as PageBody at the call site). */
- blocks: Block[];
- /** Adopter custom blocks, passed EXPLICITLY (no global mutable registry — Spec §5.3). */
- components?: Record>;
-}
-
-/**
- * Iterates the dynamic zone, picks a component by `__component`, renders it with
- * the block's typed props. Engine blocks merge first; adopter blocks override
- * by key. Unknown `__component` → tolerant fallback (render nothing + a dev-only
- * warning), never a crash — mirroring the engine's tolerant admission (Spec §5.3).
- */
-export function BlockRenderer({ blocks, components = {} }: BlockRendererProps) {
- // Layer merge: preset-atom.* atoms, then preset-organism.* organisms (sections
- // + chrome), then the adopter's explicit components — adopter wins last for
- // per-key override.
- const registry = { ...atomBlocks, ...organismBlocks, ...components };
- return (
- <>
- {blocks.map((block, i) => {
- const Component = registry[block.__component];
- if (!Component) {
- if (process.env.NODE_ENV !== 'production') {
- console.warn(`[press/web] no component registered for ${componentUrn(block.__component)} — skipping`);
- }
- return null;
- }
- return ;
- })}
- >
- );
-}
diff --git a/packages/web/src/chrome/footer.tsx b/packages/web/src/chrome/footer.tsx
index 743a0d8..8d4504a 100644
--- a/packages/web/src/chrome/footer.tsx
+++ b/packages/web/src/chrome/footer.tsx
@@ -1,4 +1,3 @@
-import type { ResolvedChromeFooter } from '../config/types';
import { Container } from '../layout/container';
/**
@@ -11,7 +10,7 @@ import { Container } from '../layout/container';
* exactly what the old hardcoded footer rendered. Brand arrives via hydration;
* missing brand degrades to "· year", never a crash.
*/
-export function Footer({ text, brand }: ResolvedChromeFooter) {
+export function Footer({ text, brand }: { text?: string; brand?: { name: string; logo?: string } }) {
return (
{text || `${brand?.name ?? ''} · ${new Date().getFullYear()}`}
diff --git a/packages/web/src/chrome/navbar.tsx b/packages/web/src/chrome/navbar.tsx
index cf8202d..a2fd0f3 100644
--- a/packages/web/src/chrome/navbar.tsx
+++ b/packages/web/src/chrome/navbar.tsx
@@ -18,9 +18,9 @@ import { PressLink } from '../press-link';
* hamburger drawer takes over. Both surfaces receive the same
* `links` + `cta` data.
*
- * Receives HYDRATED props (Spec §3): mapSiteSettings resolved the links and
+ * Receives HYDRATED props (Spec §3): resolveTree resolved the links and
* injected the brand, so this stays a dumb server component. Tolerant of an
- * un-hydrated block (direct BlockRenderer use): missing brand/links degrade,
+ * un-hydrated block (direct TreeRenderer use): missing brand/links degrade,
* never crash.
*/
export function Navbar({ brand, links, cta }: ResolvedChromeNavbar) {
diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts
index cfd6780..11b6ee9 100644
--- a/packages/web/src/index.ts
+++ b/packages/web/src/index.ts
@@ -1,4 +1,3 @@
-export { BlockRenderer } from './block-renderer';
export { getPage } from './get-page';
export { getPageSlugs, getStaticPageParams } from './get-page-slugs';
export { getSiteConfig } from './get-site-config';
@@ -13,10 +12,28 @@ export { Separator } from './blocks/separator';
export { Spacer } from './blocks/spacer';
export { Hero } from './sections/hero';
export { Cta } from './sections/cta';
-export { Columns } from './sections/columns';
export { Navbar } from './chrome/navbar';
export { Footer } from './chrome/footer';
export { organismBlocks } from './organism-blocks';
+export { TreeRenderer } from './tree/tree-renderer';
+export { resolveTree, hydrateEngineBlocks } from './tree/resolve-slots';
+export { PressLink } from './press-link';
+export { resolveLink, coerceLink } from './link';
+export type { PressLinkData, ResolvedLink } from './link';
+export type {
+ PressTree,
+ LayoutNode,
+ Node,
+ RowNode,
+ ColumnNode,
+ BlockNode,
+ Slot,
+ ContainerAttrs,
+ Ratio,
+ Gap,
+ VerticalAlign,
+ ContainerWidth,
+} from '@ogs-tech/press-shared';
export { defineConfig } from './config/define-config';
export { resolveConfig } from './config/resolve-config';
export { buildMetadata } from './config/build-metadata';
diff --git a/packages/web/src/organism-blocks.ts b/packages/web/src/organism-blocks.ts
index be3f427..21c370b 100644
--- a/packages/web/src/organism-blocks.ts
+++ b/packages/web/src/organism-blocks.ts
@@ -1,7 +1,6 @@
import type { ComponentType } from 'react';
import { Hero } from './sections/hero';
import { Cta } from './sections/cta';
-import { Columns } from './sections/columns';
import { Navbar } from './chrome/navbar';
import { Footer } from './chrome/footer';
@@ -9,14 +8,13 @@ import { Footer } from './chrome/footer';
* Engine-owned ORGANISM registry — composed sections for the page body
* (hero/cta) and the site chrome (navbar/footer), unified into one Atomic Design
* layer (the old `section.*` / `chrome.*` split). Kept SEPARATE from atomBlocks so the
- * layer boundary is mirrored in code. BlockRenderer merges this after the atoms
+ * layer boundary is mirrored in code. TreeRenderer merges this after the atoms
* and before the adopter map — every organism is overridable via the explicit
* `components` prop (e.g. `{ 'preset-organism.navbar': MyNavbar }`).
*/
export const organismBlocks: Record> = {
'preset-organism.hero': Hero,
'preset-organism.cta': Cta,
- 'preset-organism.columns': Columns,
'preset-organism.navbar': Navbar,
'preset-organism.footer': Footer,
};
diff --git a/packages/web/src/plugins/cookie-consent/cookie-consent-banner.tsx b/packages/web/src/plugins/cookie-consent/cookie-consent-banner.tsx
index acecc71..0d51813 100644
--- a/packages/web/src/plugins/cookie-consent/cookie-consent-banner.tsx
+++ b/packages/web/src/plugins/cookie-consent/cookie-consent-banner.tsx
@@ -18,7 +18,7 @@ const OPTIONAL_CATEGORIES = ['analytics', 'marketing'] as const;
* snapshot), so hydration can never mismatch.
*
* The component is intentionally dumb about config: `plugin` arrives TOTAL
- * from mapCookieConsent — no defaulting here (the BlockRenderer "renderers
+ * from mapCookieConsent — no defaulting here (the TreeRenderer "renderers
* stay dumb" discipline).
*/
export function CookieConsentBanner({ plugin }: { plugin: ResolvedCookieConsentPlugin }) {
diff --git a/packages/web/src/sections/columns.test.ts b/packages/web/src/sections/columns.test.ts
deleted file mode 100644
index 020317b..0000000
--- a/packages/web/src/sections/columns.test.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { renderToStaticMarkup } from 'react-dom/server';
-import type { PresetMoleculeColumn } from '../types/base';
-import { Columns } from './columns';
-
-// Mirrors the hero contract tests: renderers are called as functions and resolve
-// media absolute against CMS_URL (unset here → engine default).
-const render = (props: Record): string =>
- renderToStaticMarkup(Columns({ __component: 'preset-organism.columns', id: 1, ...(props as any) }));
-
-const text = (t: string): PresetMoleculeColumn['content'] => [
- { type: 'paragraph', children: [{ type: 'text', text: t }] },
-];
-
-const col = (overrides: Partial = {}, id = 1): PresetMoleculeColumn =>
- ({ id, ...overrides }) as PresetMoleculeColumn;
-
-describe('Columns renderer', () => {
- it('renders nothing when columns is missing or empty (tolerant draft)', () => {
- expect(render({})).toBe('');
- expect(render({ columns: [] })).toBe('');
- });
-
- it('wraps output in a Container carrying data-block (the Hero shell pattern)', () => {
- const html = render({ columns: [col({ content: text('a') }, 1), col({ content: text('b') }, 2)] });
- expect(html.startsWith(' {
- const html = render({ columns: [col({}, 1), col({}, 2)] });
- expect(html.match(/--press-col-span:12/g)).toHaveLength(2);
- expect(html.match(/--press-col-span-md:6/g)).toHaveLength(2);
- });
-
- it('maps 33-67 / 67-33 to asymmetric md spans', () => {
- const narrowWide = render({ ratio: '33-67', columns: [col({}, 1), col({}, 2)] });
- expect(narrowWide).toContain('--press-col-span-md:4');
- expect(narrowWide).toContain('--press-col-span-md:8');
- const wideNarrow = render({ ratio: '67-33', columns: [col({}, 1), col({}, 2)] });
- expect(wideNarrow.indexOf('--press-col-span-md:8')).toBeLessThan(wideNarrow.indexOf('--press-col-span-md:4'));
- });
-
- it('maps 33-33-33 to three md:4 cells', () => {
- const html = render({ ratio: '33-33-33', columns: [col({}, 1), col({}, 2), col({}, 3)] });
- expect(html.match(/--press-col-span-md:4/g)).toHaveLength(3);
- });
-
- it('25-25-25-25 goes 2×2 on md (span 6) before 4-up on lg (span 3)', () => {
- const html = render({
- ratio: '25-25-25-25',
- columns: [col({}, 1), col({}, 2), col({}, 3), col({}, 4)],
- });
- expect(html.match(/--press-col-span-md:6/g)).toHaveLength(4);
- expect(html.match(/--press-col-span-lg:3/g)).toHaveLength(4);
- });
-
- it('renders FEWER columns than the ratio has slots without phantom cells', () => {
- const html = render({ ratio: '33-33-33', columns: [col({}, 1), col({}, 2)] });
- expect(html.match(/data-column="cell"/g)).toHaveLength(2);
- });
-
- it('a column beyond the ratio slots reuses the last span instead of being dropped', () => {
- const html = render({ ratio: '50-50', columns: [col({}, 1), col({}, 2), col({}, 3)] });
- expect(html.match(/data-column="cell"/g)).toHaveLength(3);
- expect(html.match(/--press-col-span-md:6/g)).toHaveLength(3);
- });
-
- it('defaults gap to "normal" (md at every tier) and maps compact/spacious', () => {
- const two = [col({}, 1), col({}, 2)];
- expect(render({ columns: two })).toContain('--press-grid-gap-current:var(--press-grid-gap-md)');
- expect(render({ gap: 'compact', columns: two })).toContain('--press-grid-gap-current:var(--press-grid-gap-sm)');
- const spacious = render({ gap: 'spacious', columns: two });
- // Tier-scaled like the Hero: md at base (11-gap floor stays phone-safe), lg from lg up.
- expect(spacious).toContain('--press-grid-gap-current:var(--press-grid-gap-md)');
- expect(spacious).toContain('--press-grid-gap-current-lg:var(--press-grid-gap-lg)');
- });
-
- it('maps verticalAlign editorial vocabulary to Grid alignItems (default top → start)', () => {
- const two = [col({}, 1), col({}, 2)];
- expect(render({ columns: two })).toContain('data-align-items="start"');
- expect(render({ verticalAlign: 'center', columns: two })).toContain('data-align-items="center"');
- expect(render({ verticalAlign: 'bottom', columns: two })).toContain('data-align-items="end"');
- });
-
- it('renders rich text content via the shared blocks renderer', () => {
- const html = render({ columns: [col({ content: text('Server-rendered') }, 1), col({}, 2)] });
- expect(html).toContain('data-column="content"');
- expect(html).toContain('Server-rendered
');
- });
-
- it('resolves the column image absolute against CMS_URL', () => {
- const html = render({ columns: [col({ image: { url: '/uploads/c.png', alternativeText: 'Chart' } }, 1), col({}, 2)] });
- expect(html).toContain('src="http://localhost:1337/uploads/c.png"');
- expect(html).toContain('alt="Chart"');
- });
-
- it('renders the button only when BOTH label and href are present, defaulting variant to primary', () => {
- const withButton = render({
- columns: [col({ button: { label: 'Go', href: '/go' } as any }, 1), col({}, 2)],
- });
- expect(withButton).toContain('data-column="button"');
- expect(withButton).toContain('data-variant="primary"');
- expect(withButton).toContain('href="/go"');
- expect(render({ columns: [col({ button: { label: 'Go' } as any }, 1), col({}, 2)] })).not.toContain('data-column="button"');
- expect(render({ columns: [col({ button: { href: '/go' } as any }, 1), col({}, 2)] })).not.toContain('data-column="button"');
- });
-
- it('an EMPTY column still renders its cell — it holds the track, siblings must not reflow', () => {
- const html = render({ columns: [col({ content: text('a') }, 1), col({}, 2)] });
- expect(html.match(/data-column="cell"/g)).toHaveLength(2);
- expect(html.match(/data-column="content"/g)).toHaveLength(1);
- });
-
- it('never renders atom components inside cells (no nested data-block="preset-atom.*")', () => {
- // The prose-width rule matches `main [data-block^="preset-atom."]` at any
- // depth — an atom component inside a cell would fight the cell's own width.
- const html = render({
- columns: [
- col({ content: text('a'), image: { url: '/uploads/c.png' }, button: { label: 'Go', href: '/go' } as any }, 1),
- col({}, 2),
- ],
- });
- expect(html).not.toContain('data-block="preset-atom.');
- });
-});
diff --git a/packages/web/src/sections/columns.tsx b/packages/web/src/sections/columns.tsx
deleted file mode 100644
index 788ceb5..0000000
--- a/packages/web/src/sections/columns.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import type { Responsive } from '../layout/breakpoints';
-import type { Span } from '../layout/column';
-import type { GridAlignItems, GridGap } from '../layout/grid';
-import type { PresetMoleculeColumn, PresetOrganismColumns } from '../types/base';
-import { Container } from '../layout/container';
-import { Grid } from '../layout/grid';
-import { Column } from '../layout/column';
-import { renderBlocks } from '../blocks/blocks-content';
-
-const CMS_URL = process.env.CMS_URL ?? 'http://localhost:1337';
-
-type Ratio = NonNullable;
-type Gap = NonNullable;
-type VerticalAlign = NonNullable;
-
-/**
- * CMS ratio → per-slot spans. The editor's vocabulary is a CLOSED enum key the
- * renderer owns the meaning of — spans can evolve (e.g. better tablet behavior)
- * without touching stored content. Every layout stacks at base; `25-25-25-25`
- * goes 2×2 on md before 4-up on lg (4 × span-3 is too tight at 768px).
- */
-const RATIO_SPANS: Record[]> = {
- '50-50': [{ base: 12, md: 6 }, { base: 12, md: 6 }],
- '33-67': [{ base: 12, md: 4 }, { base: 12, md: 8 }],
- '67-33': [{ base: 12, md: 8 }, { base: 12, md: 4 }],
- '33-33-33': [{ base: 12, md: 4 }, { base: 12, md: 4 }, { base: 12, md: 4 }],
- '25-25-25-25': [
- { base: 12, md: 6, lg: 3 },
- { base: 12, md: 6, lg: 3 },
- { base: 12, md: 6, lg: 3 },
- { base: 12, md: 6, lg: 3 },
- ],
-};
-
-/**
- * Semantic gap → GridGap tiers. The raw GridGap scale is never exposed to the
- * CMS: a 12-track grid always carries 11 interior column-gaps, so a flat 'lg'
- * (48px) means a 528px floor that overflows phones — 'spacious' therefore uses
- * the Hero's tier-scaled pattern ('md' at base, 'lg' from lg up).
- */
-const GAP_TIERS: Record> = {
- compact: 'sm',
- normal: 'md',
- spacious: { base: 'md', lg: 'lg' },
-};
-
-/** CMS editorial vocabulary (top/center/bottom) → Grid's technical axis values. */
-const ALIGN_ITEMS: Record = {
- top: 'start',
- center: 'center',
- bottom: 'end',
-};
-
-function ColumnCell({ column, span }: { column: PresetMoleculeColumn; span: Responsive }) {
- const hasImage = Boolean(column.image?.url);
- const hasButton = Boolean(column.button?.label && column.button?.href);
- return (
-
- {/* renderBlocks + raw / on purpose — NOT the Paragraph/Image/Button
- atom components: those emit data-block="preset-atom.*", which the
- prose-width rule (`main [data-block^="preset-atom."]` in theme.css)
- matches at ANY depth and would fight the cell's own span/width. Same
- reason the navbar inlines its cta anchor. */}
- {column.content?.length ? {renderBlocks(column.content)}
: null}
- {hasImage ? (
-
- ) : null}
- {hasButton ? (
-
- {column.button!.label}
-
- ) : null}
-
- );
-}
-
-/**
- * Engine organism `preset-organism.columns` — an editor-composed 2–4 column
- * section built on Container/Grid/Column, mirroring the Hero pattern. The
- * layout controls (`ratio`/`gap`/`verticalAlign`) are closed CMS enums mapped
- * to primitive props above.
- *
- * Tolerant, mirroring hero/cta: no columns renders nothing; an EMPTY column
- * still renders its cell (it holds the ratio's track — dropping it would
- * reflow siblings); a column beyond the ratio's slots reuses the last slot's
- * span and wraps to the next row rather than being discarded.
- */
-export function Columns({ ratio, gap, verticalAlign, columns }: PresetOrganismColumns) {
- if (!columns?.length) return null;
- const spans = RATIO_SPANS[ratio ?? '50-50'] ?? RATIO_SPANS['50-50'];
- return (
-
-
- {columns.map((col, i) => (
-
- ))}
-
-
- );
-}
diff --git a/packages/web/src/tree/tree-renderer.test.tsx b/packages/web/src/tree/tree-renderer.test.tsx
new file mode 100644
index 0000000..b9dba79
--- /dev/null
+++ b/packages/web/src/tree/tree-renderer.test.tsx
@@ -0,0 +1,103 @@
+import { describe, expect, it, vi } from 'vitest';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { createElement } from 'react';
+import type { Node, PressTree } from '@ogs-tech/press-shared';
+import { TreeRenderer } from './tree-renderer';
+
+const site = (overrides: Partial<{ header: Node[]; footer: Node[] }> = {}) =>
+ ({
+ brand: { name: 'Press', favicon: '' },
+ routes: { home: 'home' },
+ pageDefaults: { header: overrides.header ?? [], footer: overrides.footer ?? [] },
+ }) as any;
+
+const tree = (children: Node[], extra: Partial = {}): PressTree => ({
+ version: 1,
+ root: { type: 'layout', header: { mode: 'none' }, footer: { mode: 'none' }, children, ...extra },
+});
+
+const paragraph = (id: string, content: string): Node => ({
+ id, type: 'block', component: 'preset-atom.paragraph', data: { content },
+});
+
+describe('TreeRenderer', () => {
+ it('renders header/main/footer with top-level blocks as DIRECT main children (prose rail)', () => {
+ const html = renderToStaticMarkup(
+ createElement(TreeRenderer, { body: tree([paragraph('p1', 'Hello')]), site: site() }),
+ );
+ expect(html).toContain('');
+ expect(html).toMatch(/]*>/);
+ expect(html).toContain('
');
+ });
+
+ it('renders a top-level row as Container>Grid>Column and a NESTED row as bare Grid (recursion)', () => {
+ const body = tree([{
+ id: 'r1', type: 'row', ratio: '33-67', container: { width: 'full', gap: 'compact', verticalAlign: 'center' },
+ children: [
+ { id: 'c1', type: 'column', children: [paragraph('p2', 'left')] },
+ { id: 'c2', type: 'column', container: { verticalAlign: 'bottom', gap: 'spacious' }, children: [{
+ id: 'r2', type: 'row', ratio: '50-50', children: [
+ { id: 'c3', type: 'column', children: [paragraph('p3', 'deep')] },
+ { id: 'c4', type: 'column', children: [] },
+ ],
+ }] },
+ ],
+ }]);
+ const html = renderToStaticMarkup(createElement(TreeRenderer, { body, site: site() }));
+ expect(html).toContain('data-max-width="full"'); // width applied top-level
+ expect((html.match(/data-press-layout="grid"/g) ?? []).length).toBe(2); // outer + nested grid
+ expect((html.match(/data-press-layout="container"/g) ?? []).length).toBe(1); // nested row gets NO Container
+ expect(html).toContain('data-align-items="center"');
+ expect(html).toContain('data-cell-align="end"');
+ expect(html).toContain('--press-cell-gap:var(--press-space-7)');
+ expect(html).toContain('deep');
+ });
+
+ it('resolves inherit slots against pageDefaults and hydrates the navbar there', () => {
+ const navbar: Node = { id: 'n', type: 'block', component: 'preset-organism.navbar', data: { items: [{ label: 'Home', url: '/' }] } };
+ const html = renderToStaticMarkup(
+ createElement(TreeRenderer, {
+ body: tree([], { header: { mode: 'inherit' } }),
+ site: site({ header: [navbar] }),
+ }),
+ );
+ expect(html).toContain('data-block="preset-organism.navbar"');
+ expect(html).toContain('Press'); // hydrated brand
+ });
+
+ it('applies the root gap as a main stack and omits it when undeclared', () => {
+ const withGap = tree([paragraph('p', 'x')], { container: { gap: 'compact' } });
+ expect(renderToStaticMarkup(createElement(TreeRenderer, { body: withGap, site: site() })))
+ .toMatch(/
]*data-press-stack[^>]*style="--press-tree-gap:var\(--press-space-3\)"/);
+ expect(renderToStaticMarkup(createElement(TreeRenderer, { body: tree([]), site: site() })))
+ .not.toContain('data-press-stack');
+ });
+
+ it('fails an invalid body to empty but KEEPS the inherited chrome (Spec §7)', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const navbar: Node = { id: 'n', type: 'block', component: 'preset-organism.navbar', data: {} };
+ const html = renderToStaticMarkup(
+ createElement(TreeRenderer, { body: { version: 99 }, site: site({ header: [navbar] }) }),
+ );
+ expect(html).toContain('data-block="preset-organism.navbar"');
+ expect(html).toMatch(/]*><\/main>/);
+ expect(warn).toHaveBeenCalled();
+ warn.mockRestore();
+ });
+
+ it('skips unknown components with a dev warning and honors adopter overrides', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const body = tree([
+ { id: 'u', type: 'block', component: 'custom-organism.mystery', data: {} },
+ { id: 'h', type: 'block', component: 'preset-organism.hero', data: { title: 'T' } },
+ ]);
+ const MyHero = () => createElement('div', { 'data-my-hero': '' }, 'override');
+ const html = renderToStaticMarkup(
+ createElement(TreeRenderer, { body, site: site(), components: { 'preset-organism.hero': MyHero } }),
+ );
+ expect(html).toContain('data-my-hero');
+ expect(html).not.toContain('mystery');
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('custom-organism.mystery'));
+ warn.mockRestore();
+ });
+});
diff --git a/packages/web/src/tree/tree-renderer.tsx b/packages/web/src/tree/tree-renderer.tsx
new file mode 100644
index 0000000..09c3b15
--- /dev/null
+++ b/packages/web/src/tree/tree-renderer.tsx
@@ -0,0 +1,119 @@
+/**
+ * TreeRenderer — renders a PressTree as // (Spec §5).
+ * Layout-by-components: the App Router `children` slot is the outlet; the tree
+ * owns the page shell. Read-side tolerance (Spec §7): a malformed body renders
+ * EMPTY (dev warning) but chrome still resolves from pageDefaults; unknown
+ * block components are skipped (BlockRenderer precedent); container attrs were
+ * already sanitized by the shared validator.
+ */
+import type { ComponentType, CSSProperties } from 'react';
+import type { ColumnNode, Node, Ratio, RowNode } from '@ogs-tech/press-shared';
+import { validatePressTree } from '@ogs-tech/press-shared';
+import { atomBlocks } from '../atom-blocks';
+import { organismBlocks } from '../organism-blocks';
+import { componentUrn } from '../urn';
+import type { ResolvedPressConfig } from '../config/types';
+import { Column } from '../layout/column';
+import { Container } from '../layout/container';
+import { Grid } from '../layout/grid';
+import { cellAlign, rowAlign, rowGap, rowWidth, spanFor, stackGap } from './container-attrs';
+import { hydrateEngineBlocks, resolveTree, type ResolvedTree } from './resolve-slots';
+
+type Registry = Record>;
+
+interface TreeRendererProps {
+ /** The raw page body (PressTree on the wire) — validated here, never trusted. */
+ body: unknown;
+ site: ResolvedPressConfig;
+ /** Adopter custom blocks, passed EXPLICITLY (no global registry — BlockRenderer contract kept). */
+ components?: Registry;
+}
+
+function BlockView({ node, registry }: { node: Node & { type: 'block' }; registry: Registry }) {
+ const Component = registry[node.component];
+ if (!Component) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.warn(`[press/web] no component registered for ${componentUrn(node.component)} — skipping`);
+ }
+ return null;
+ }
+ return ;
+}
+
+function ColumnView({ column, ratio, index, registry }: { column: ColumnNode; ratio: Ratio; index: number; registry: Registry }) {
+ const gap = stackGap(column.container);
+ const align = cellAlign(column.container);
+ const style = gap ? ({ ['--press-cell-gap' as string]: gap } as CSSProperties) : undefined;
+ return (
+
+
+
+
+
+ );
+}
+
+function RowView({ row, registry, top }: { row: RowNode; registry: Registry; top: boolean }) {
+ const grid = (
+
+ {row.children.map((column, i) => (
+
+ ))}
+
+ );
+ // width applies to top-level rows only (Spec §3); nested rows fill their cell.
+ if (!top) return grid;
+ return (
+
+ {grid}
+
+ );
+}
+
+function NodeList({ nodes, registry, top }: { nodes: Node[]; registry: Registry; top: boolean }) {
+ return (
+ <>
+ {nodes.map((node) => {
+ if (node.type === 'block') return ;
+ if (node.type === 'row') return ;
+ // A stray column never survives the validator; belt-and-braces skip.
+ return null;
+ })}
+ >
+ );
+}
+
+export function TreeRenderer({ body, site, components = {} }: TreeRendererProps) {
+ const registry: Registry = { ...atomBlocks, ...organismBlocks, ...components };
+ const { value: tree, errors } = validatePressTree(body);
+ if (!tree && process.env.NODE_ENV !== 'production') {
+ console.warn('[press/web] malformed composition tree — rendering empty body', errors);
+ }
+ const brand = { name: site.brand.name, logo: site.brand.logo };
+ const resolved: ResolvedTree = tree
+ ? resolveTree(tree, site)
+ : {
+ // Malformed body (Spec §7): body fails to empty, chrome still inherits.
+ header: hydrateEngineBlocks(site.pageDefaults.header, brand, site.routes.home),
+ children: [],
+ footer: hydrateEngineBlocks(site.pageDefaults.footer, brand, site.routes.home),
+ rootContainer: undefined,
+ };
+ const gap = stackGap(resolved.rootContainer);
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/packages/web/src/urn.test.ts b/packages/web/src/urn.test.ts
index 9823f26..a495a59 100644
--- a/packages/web/src/urn.test.ts
+++ b/packages/web/src/urn.test.ts
@@ -26,7 +26,7 @@ describe('componentUrn', () => {
expect(componentUrn('custom-organism.callout')).toBe('urn:component:custom-organism.callout');
});
- it('is a distinct axis from blockKey: the uid is the id segment here, the entity segment there', () => {
+ it('is a distinct axis from a computed per-instance identity: the uid is the id segment here, the entity segment there', () => {
// component TYPE identity vs computed per-instance identity
expect(componentUrn('preset-atom.image')).not.toBe(buildUrn('preset-atom.image', 5));
expect(componentUrn('preset-atom.image')).toBe('urn:component:preset-atom.image');
diff --git a/packages/web/src/urn.ts b/packages/web/src/urn.ts
index d763782..04362eb 100644
--- a/packages/web/src/urn.ts
+++ b/packages/web/src/urn.ts
@@ -22,9 +22,11 @@
* ARE the canonical base; any uid in them has this identity for free via
* `componentUrn`.
* 3. COMPUTED (`Urn`, NOT an `Entity`): formatted ad hoc for a value
- * with no durable identity of its own — `blockKey` qualifies a DZ row's
- * ephemeral numeric id with its `__component` (`urn:preset-atom.image:5`).
- * Deliberately never promoted into this union: a DZ row id isn't durable.
+ * with no durable identity of its own. Retired with the DZ-era block-key
+ * helper — `PressTree` nodes now carry a builder-minted `id: string`
+ * (Spec §3), so a node IS its own React key and no computed identity is
+ * needed. Kept as a class here for the historical shape of this union;
+ * nothing in the codebase formats one today.
*
* Extend this union — never widen a call site to plain `string` — when a new
* entity earns a stored/type-level urn (e.g. media). Mirrors ThemeName's
@@ -34,10 +36,10 @@ export type Entity = 'page' | 'site-setting' | 'plugin' | 'component';
/**
* A `urn:{entity}:{id}` identity string. Generic over any string — NOT bounded
- * to Entity — so the same primitive also formats COMPUTED identities (block-key
- * uses the block's `__component` as the entity segment) without admitting them
- * into the closed Entity union. The template literal already rejects an
- * arbitrary string at compile time; no nominal brand (and its `as Urn` noise).
+ * to Entity — so the same primitive can also format a COMPUTED identity
+ * (identity class 3 above) without admitting it into the closed Entity union.
+ * The template literal already rejects an arbitrary string at compile time;
+ * no nominal brand (and its `as Urn` noise).
*/
export type Urn = `urn:${E}:${string}`;
@@ -63,10 +65,9 @@ export function buildUrn(entity: E, id: string | number): Urn<
* Canonical identity of a component TYPE (identity class 2 above): the palette
* registration keyed by its uid — `preset-atom.image`, `preset-organism.hero`,
* `preset-organism.navbar`, an adopter's `custom-*`. Thin wrapper over `buildUrn`
- * so there is exactly one
- * formatting implementation. Contrast `blockKey`, which formats the COMPUTED
- * per-instance identity (`urn:{uid}:{id}`) — same primitive, different axis:
- * here the uid IS the id segment, there it is the entity segment.
+ * so there is exactly one formatting implementation — the uid IS the id
+ * segment here, distinct from a COMPUTED per-instance identity (class 3
+ * above), where it would be the entity segment instead.
*/
export function componentUrn(uid: string): Urn<'component'> {
return buildUrn('component', uid);
From 66282c865500ade8e00782f03aa10d9cdbb6c868 Mon Sep 17 00:00:00 2001
From: Odenir Gomes
Date: Tue, 21 Jul 2026 20:38:04 -0300
Subject: [PATCH 23/31] feat(web)!: host renders the tree shell; prose rail
rescoped to direct main children; cell/stack CSS
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../templates/host/app/[[...slug]]/page.tsx | 9 +-
packages/web/templates/host/app/layout.tsx | 20 +---
packages/web/templates/host/next.config.ts | 5 +-
packages/web/theme.css | 100 +++++++-----------
4 files changed, 55 insertions(+), 79 deletions(-)
diff --git a/packages/web/templates/host/app/[[...slug]]/page.tsx b/packages/web/templates/host/app/[[...slug]]/page.tsx
index 5008631..e73d856 100644
--- a/packages/web/templates/host/app/[[...slug]]/page.tsx
+++ b/packages/web/templates/host/app/[[...slug]]/page.tsx
@@ -1,10 +1,10 @@
import { notFound, permanentRedirect } from 'next/navigation';
import {
- BlockRenderer,
buildMetadata,
getPage,
getSiteConfig,
getStaticPageParams,
+ TreeRenderer,
} from '@ogs-tech/press-web';
import { customBlocks } from '../../press.blocks';
import { buildTime } from '../../press-config';
@@ -52,7 +52,10 @@ export default async function CatchAllPage({ params }: PageProps) {
// (e.g. /home) 308-redirects to '/', so home has no public slug URL.
if (path && path === buildTime.routes.home) permanentRedirect('/');
- const page = await getPage(path || buildTime.routes.home);
+ const [site, page] = await Promise.all([
+ getSiteConfig(buildTime),
+ getPage(path || buildTime.routes.home),
+ ]);
if (!page) notFound();
- return ;
+ return ;
}
diff --git a/packages/web/templates/host/app/layout.tsx b/packages/web/templates/host/app/layout.tsx
index 393601b..39d3392 100644
--- a/packages/web/templates/host/app/layout.tsx
+++ b/packages/web/templates/host/app/layout.tsx
@@ -1,6 +1,5 @@
import { Archivo, Bricolage_Grotesque, IBM_Plex_Mono } from 'next/font/google';
import {
- BlockRenderer,
buildConsentBootstrapScript,
buildMetadata,
buildThemeStyle,
@@ -8,7 +7,6 @@ import {
getSiteConfig,
} from '@ogs-tech/press-web';
import '@ogs-tech/press-web/theme.css';
-import { customBlocks } from '../press.blocks';
import { buildTime } from '../press-config';
// Default-theme fonts, loaded + optimized by next/font at build time. Each exposes
@@ -46,19 +44,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
- {/* Block-composed chrome (Spec §3): the same pipeline as the page body,
- hydrated by mapSiteSettings. An unreachable CMS → empty zones →
- header/footer render nothing (unbranded over synthetic, Spec §4). */}
-
- {children}
-
- {/* Cookie-consent plugin mount (cookie-consent Spec §1/§5): config is
- CMS-sourced and fails OPEN (banner shows with engine defaults when
- the CMS is unreachable — a consent gate must not vanish on a hiccup). */}
+ {/* The page shell (header/main/footer) is rendered by TreeRenderer inside the
+ route — the layout cannot see the slug, so it cannot resolve per-page
+ slots (Spec §5). It keeps html/head, theme injection, consent bootstrap
+ and the cookie banner. */}
+ {children}