Skip to content

Commit 490bdc7

Browse files
odenirdevclaude
andauthored
feat: composite section blocks (section.*) (#6)
* docs: add design spec for composite section blocks (section.*) Approved brainstorming design for an engine-owned section palette under a new Strapi category `section.*`, mirroring the existing press.* injection + type-sync mechanism. v1 ships two flat sections (section.hero, section.cta); sections with nested repeatable components (FeatureGrid, Testimonials) are deferred to a second spec. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(cms): inject section.hero and section.cta components * feat(cms): admit section.* into page DZ and serialize them Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(web): pin zero-change section generation in the type generator Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): add SectionHero/SectionCta types and the Hero renderer * feat(web): add the Cta section renderer * feat(web): merge sectionBlocks into BlockRenderer and export the palette Widen packages/web/vitest.config.ts test.include to src/**/*.test.{ts,tsx} so the new block-renderer.test.tsx (authored with JSX) is discovered by vitest; prior config only matched *.test.ts. * feat(web): theme section.hero and section.cta from press-* tokens * docs: document section.* palette and add changeset * docs(web): cite Spec §5.2 in the section.cta theme comment Final-review Minor #7: match the section.hero sibling's citation and the "comments cite Spec §…" convention. Comment-only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a9a719d commit 490bdc7

21 files changed

Lines changed: 725 additions & 6 deletions

.changeset/section-blocks.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@ogs-tech/press-cms': minor
3+
'@ogs-tech/press-web': minor
4+
---
5+
6+
feat: composite section blocks (section.*)
7+
8+
Adds an engine-owned palette of composite sections under a new `section.*`
9+
category: `section.hero` and `section.cta`. CMS injects both components and lists
10+
them statically in the page `body` Dynamic Zone; they flow through the unchanged
11+
type-sync pipeline (all fields are flat scalar/media/enum), so `serialize-schema`
12+
and the generator are untouched. Web ships `Hero`/`Cta` renderers behind a separate
13+
`sectionBlocks` registry that `BlockRenderer` merges between `referenceBlocks` and
14+
the adopter's `components` — each section is born branded by the Site Settings theme
15+
(theme.css consumes `var(--press-*)` tokens) and overridable via
16+
`components={{ 'section.hero': MyHero }}`. Additive and non-breaking: `press.*` and
17+
`custom.*` are unchanged; adopters gain `section.*` on `press upgrade`.

CLAUDE.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,18 @@ materialized `press-config.ts` / `press.blocks.ts` inside it).
8585
`src/components/custom/` is auto-admitted into the page `body` Dynamic Zone
8686
(`admitCustomBlocks`). The engine never names individual adopter blocks — only the
8787
`custom` category is the stable contract.
88-
- On the web side, `BlockRenderer` merges `referenceBlocks` (`press.*`) with the
89-
adopter's **explicit** `customBlocks` map (no global registry). It picks by
90-
`__component`; an unknown component is skipped with a dev-only warning, never a crash.
88+
- **Engine sections (`section.*`):** a second engine-owned palette of *composite*
89+
sections (`section.hero`, `section.cta`) — flat (scalar/media/enum) blocks
90+
injected under the `section` category and admitted into the page `body` Dynamic
91+
Zone **statically** (listed in `content-types/page/schema.json`), not via the
92+
dynamic `custom.*` push. They keep the `press.*` atoms intact and flow through the
93+
unchanged type-sync pipeline. `press.hero` stays removed — sections are never `press.*`.
94+
- On the web side, `BlockRenderer` merges three maps by `__component`:
95+
`{ ...referenceBlocks, ...sectionBlocks, ...components }` — engine `press.*` atoms,
96+
engine `section.*` sections (`src/section-blocks.ts`), then the adopter's
97+
**explicit** `customBlocks` map (no global registry). Adopter blocks win last, so
98+
any `section.*` is overridable via `components={{ 'section.hero': MyHero }}`. An
99+
unknown component is skipped with a dev-only warning, never a crash.
91100

92101
### Build-time anchors vs. runtime Site Settings
93102

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
# Press — Composite Section Blocks (`section.*`, v1)
2+
3+
**Status:** Approved design · **Date:** 2026-07-01 · **Scope:** flat sections only (`section.hero`, `section.cta`)
4+
5+
## 1. Context
6+
7+
Press ships a Gutenberg-style **atomic** reference palette under `press.*`
8+
(paragraph, heading, list, quote, image, button, separator, spacer) plus an
9+
adopter extension point under `custom.*`. There is no engine-owned concept of a
10+
higher-level **composite section** (Hero, CTA, FeatureGrid).
11+
12+
An adopter *can* already build a section today as a `custom.*` block — a Strapi
13+
component with fixed fields + a React renderer (see `apps/playground/.../custom/callout.json`
14+
+ `Callout.tsx`). What is missing is a **curated, ready-made section library the
15+
adopter gets for free** from the engine, branded automatically by the adopter's
16+
theme.
17+
18+
Note the deliberate history: commit `2fa33de` **removed `press.hero`** (BREAKING)
19+
to keep the `press.*` palette atomic/editorial. This spec does **not** reverse that
20+
decision — it introduces sections under a **separate category** so the atomic
21+
boundary in `press.*` stays intact.
22+
23+
### Where this lives in the architecture
24+
25+
Sections are **page content**, so they live in **Plane A** — the type-sync loop:
26+
27+
```
28+
inject section.* → page.body DZ → GET /api/press/schema → generator
29+
→ generated.ts (SectionHero/SectionCta + PageBody union) → BlockRenderer
30+
```
31+
32+
This is the same plane and the same mechanism the `press.*` atoms already use.
33+
The v1 sections are **flat** (only scalar/media/enum fields), which is what keeps
34+
the type-sync pipeline unchanged (see §7).
35+
36+
## 2. Goals
37+
38+
- Ship an engine-owned palette of composite sections under a new Strapi category
39+
`section.*`, admitted into the page `body` Dynamic Zone automatically.
40+
- v1 delivers two **flat** sections: `section.hero` and `section.cta`.
41+
- Each section renders as **semantic HTML + `data-block`**, styled by `theme.css`
42+
as a pure consumer of `var(--press-*)` tokens — so a section is **born branded**
43+
with the adopter's Site Settings theme, with no override required.
44+
- The adopter can override any section by passing its own renderer to
45+
`BlockRenderer` (`components={{ 'section.hero': MyHero }}`) — reusing the existing
46+
last-wins merge, no new mechanism.
47+
- Additive and non-breaking: `press.*` and `custom.*` are untouched; adopters gain
48+
`section.*` on `press upgrade`.
49+
50+
## 3. Non-goals (explicitly deferred)
51+
52+
- **Sections with lists / nested repeatable components** (FeatureGrid, Testimonials).
53+
These require extending `serialize-schema` (transitive component-ref walk), the
54+
generator (`type: 'component'` single + repeatable), and the `Attr` shared type.
55+
Deferred to a **second spec** ("nested component pipeline"). v1 stays flat on
56+
purpose so it ships without touching the type contract.
57+
- **Editor-arranged inner content** (a DZ inside a section) — blocked by Strapi 5
58+
(no Dynamic Zone nesting inside a component). Out of scope entirely.
59+
- **Reintroducing `press.hero`** — sections live under `section.*`, never `press.*`.
60+
61+
## 4. The two sections (v1)
62+
63+
All fields are scalar / media / enum → they pass through the current generator with
64+
zero changes.
65+
66+
### `section.hero`
67+
| field | type | required | notes |
68+
|---|---|---|---|
69+
| `eyebrow` | string | no | small kicker above the title |
70+
| `title` | string | **yes** | primary heading |
71+
| `subtitle` | text | no | supporting copy |
72+
| `image` | media (single) | no | hero visual |
73+
| `ctaLabel` | string | no | call-to-action label |
74+
| `ctaHref` | string | no | call-to-action target |
75+
| `align` | enumeration `left \| center` | no (default `left`) | layout alignment |
76+
77+
### `section.cta`
78+
| field | type | required | notes |
79+
|---|---|---|---|
80+
| `title` | string | **yes** | banner heading |
81+
| `subtitle` | text | no | supporting copy |
82+
| `buttonLabel` | string | **yes** | button label |
83+
| `buttonHref` | string | **yes** | button target |
84+
| `align` | enumeration `left \| center` | no (default `left`) | layout alignment |
85+
86+
## 5. Architecture — symmetry with `press.*`
87+
88+
The mechanism already exists; this spec **mirrors** it into a new category. No new
89+
contract is invented.
90+
91+
### 5.1 CMS (`packages/cms`)
92+
- Add two component schema JSONs under `server/src/components/section/` (`hero.json`,
93+
`cta.json`). Physical folder is organizational only — category is set explicitly in
94+
code (below), because Strapi does not scan `node_modules`.
95+
- Add two entries to `ENGINE_COMPONENTS` in `lib/inject-components.ts` with
96+
`category: 'section'`, `name: 'hero' | 'cta'`. `injectComponents` registers them
97+
with a deterministic `globalId` (`toGlobalId('component_section.hero')`), exactly
98+
like the `press.*` atoms.
99+
- List `"section.hero"` and `"section.cta"` **statically** in
100+
`content-types/page/schema.json` `body.components`, alongside the `press.*` atoms.
101+
They are engine-owned and deterministic, so static admission (not the dynamic
102+
`custom.*` push in `admitCustomBlocks`) is the correct, symmetric choice.
103+
- `serialize-schema` needs **no change**: the sections are in the page DZ, so they are
104+
serialized into `schema.components` automatically, and their flat attributes already
105+
serialize.
106+
107+
### 5.2 Web (`packages/web`)
108+
- Add two renderers: `src/sections/hero.tsx`, `src/sections/cta.tsx`. Each emits
109+
semantic HTML with a `data-block="section.hero"` / `data-block="section.cta"` anchor
110+
and reads its generated typed props (`SectionHero` / `SectionCta`).
111+
- Introduce a **separate registry map** `sectionBlocks` (`src/section-blocks.ts`):
112+
```ts
113+
export const sectionBlocks = {
114+
'section.hero': Hero,
115+
'section.cta': Cta,
116+
};
117+
```
118+
This preserves the documented invariant that `referenceBlocks` is `press.*`-only,
119+
and mirrors the three-palette split (`press.*` / `section.*` / `custom.*`) in code.
120+
- `BlockRenderer` merges the new map between reference and adopter blocks:
121+
```ts
122+
const registry = { ...referenceBlocks, ...sectionBlocks, ...components };
123+
```
124+
Adopter `components` still wins last → per-section override for free.
125+
- `theme.css` gains `[data-block="section.hero"]` and `[data-block="section.cta"]`
126+
rules that consume `var(--press-*)` tokens (colors, space, text, radius, fonts).
127+
`theme.css` stays a pure token consumer — no hardcoded brand values.
128+
- `src/index.ts` exports `Hero`, `Cta`, `sectionBlocks`, and the `SectionHero` /
129+
`SectionCta` types.
130+
131+
## 6. Data flow (pipeline unchanged, content new)
132+
133+
```
134+
CMS register: inject section.hero/section.cta (category 'section')
135+
→ page.body DZ lists section.* statically
136+
→ GET /api/press/schema serializes them (flat fields)
137+
→ generator emits `SectionHero` / `SectionCta` interfaces
138+
and adds both uids to the `PageBody` union
139+
→ adopter getPage() returns a typed body
140+
→ BlockRenderer picks 'section.hero' from sectionBlocks
141+
→ renders <section data-block="section.hero"> … </section>
142+
→ theme.css applies the adopter's theme tokens
143+
```
144+
145+
## 7. Type-sync impact — none
146+
147+
`generate.ts` `tsTypeForAttribute` already handles scalar, `enumeration`, and `media`.
148+
Every v1 field is one of those, so both sections generate correct interfaces with
149+
**zero generator change**. The generator's existing "DZ-inside-component out of scope"
150+
guard (`generate.ts:49-50`) is not triggered — v1 sections have no nested DZ and no
151+
nested component. (Nested components are the deferred §3 work.)
152+
153+
## 8. Error handling (reuses existing tolerant contracts)
154+
155+
- **Missing renderer**`BlockRenderer` already skips the block with a dev-only
156+
warning; never crashes.
157+
- **Incomplete draft** → each section renderer is tolerant, mirroring `press.image`
158+
(which renders nothing when `image.url` is absent):
159+
- `section.hero` with no `title` → render nothing.
160+
- `section.hero` with `ctaLabel` but no `ctaHref` (or vice-versa) → render the CTA
161+
only when both are present.
162+
- `section.cta` with a missing `buttonHref` → render the heading/subtitle without
163+
the button rather than a dead link.
164+
- **Injection collision** (uid already registered) → `injectComponents` already
165+
warns + skips.
166+
167+
## 9. Override
168+
169+
The adopter escapes the engine visual by passing a renderer for the section key:
170+
171+
```tsx
172+
<BlockRenderer blocks={page.body} components={{ 'section.hero': MyHero }} />
173+
```
174+
175+
`{ ...referenceBlocks, ...sectionBlocks, ...components }` makes the adopter's map win.
176+
This is the same override contract already documented for reference blocks — no new
177+
surface.
178+
179+
## 10. Testing (repo gate: vitest + tsc, no eslint)
180+
181+
- **CMS**
182+
- `inject-components.test.ts`: `section.hero` / `section.cta` are injected with
183+
category `section` and the derived `globalId`; both appear in the page `body` DZ
184+
`components`.
185+
- `serialize-schema.test.ts`: both sections appear in the serialized
186+
`schema.components` with their flat attributes.
187+
- **Web**
188+
- `sections/hero.test.ts` / `sections/cta.test.ts`: render with full props (semantic
189+
structure + `data-block` present); tolerant empty-state cases from §8.
190+
- `block-renderer.test.tsx`: a `section.*` block resolves from `sectionBlocks`; an
191+
adopter-provided `components['section.hero']` overrides it.
192+
- **Generator**
193+
- `generate.test.ts`: a schema containing `section.hero` produces a `SectionHero`
194+
interface and includes it in the `PageBody` union.
195+
- **Playground (dogfood)**
196+
- Add `section.hero` + `section.cta` to a demo page so `pnpm play` renders both.
197+
198+
## 11. Delivery
199+
200+
- **Changeset**: minor feature for `@ogs-tech/press-cms` and `@ogs-tech/press-web`
201+
(new `section.*` palette).
202+
- **Adoption note**: adopters gain `section.*` on `press upgrade`; every section is
203+
overridable via `BlockRenderer` `components`.
204+
- **Docs**: update the architecture reference in `CLAUDE.md` to describe the third
205+
palette (`press.*` atoms / `section.*` engine sections / `custom.*` adopter) and the
206+
`sectionBlocks` merge in `BlockRenderer`.
207+
208+
## 12. Open items (confirmed during design)
209+
210+
- Field sets in §4 are approved.
211+
- Separate `sectionBlocks` map (not folding into `referenceBlocks`) is approved.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"collectionName": "components_section_ctas",
3+
"info": { "displayName": "Call to Action", "description": "A call-to-action banner shipped by the press engine" },
4+
"options": {},
5+
"attributes": {
6+
"title": { "type": "string", "required": true },
7+
"subtitle": { "type": "text" },
8+
"buttonLabel": { "type": "string", "required": true },
9+
"buttonHref": { "type": "string", "required": true },
10+
"align": { "type": "enumeration", "enum": ["left", "center"], "default": "left" }
11+
}
12+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"collectionName": "components_section_heroes",
3+
"info": { "displayName": "Hero", "description": "A hero section shipped by the press engine" },
4+
"options": {},
5+
"attributes": {
6+
"eyebrow": { "type": "string" },
7+
"title": { "type": "string", "required": true },
8+
"subtitle": { "type": "text" },
9+
"image": { "type": "media", "multiple": false, "allowedTypes": ["images"] },
10+
"ctaLabel": { "type": "string" },
11+
"ctaHref": { "type": "string" },
12+
"align": { "type": "enumeration", "enum": ["left", "center"], "default": "left" }
13+
}
14+
}

packages/cms/server/src/content-types/page/schema.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
"press.image",
2525
"press.button",
2626
"press.separator",
27-
"press.spacer"
27+
"press.spacer",
28+
"section.hero",
29+
"section.cta"
2830
]
2931
}
3032
}

packages/cms/server/src/lib/inject-components.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from 'vitest';
22
import { admitCustomBlocks, injectComponents } from './inject-components';
3+
import pageSchema from '../content-types/page/schema.json';
34

45
const PAGE_UID = 'plugin::press-cms.page';
56

@@ -117,4 +118,35 @@ describe('injectComponents', () => {
117118
expect(page.attributes.body.components).toContain('custom.callout'); // custom admitted
118119
expect(page.attributes.body.components).not.toContain('press.nav-item'); // never admitted
119120
});
121+
122+
it('injects section.hero and section.cta under category "section" with a derived globalId', () => {
123+
// Sections mirror the press.* injection mechanism but under a SEPARATE category
124+
// so the atomic press.* boundary stays intact (Spec §5.1).
125+
const { strapi, components } = makeStrapi();
126+
injectComponents({ strapi });
127+
128+
expect(components.get('section.hero')?.modelType).toBe('component');
129+
expect(components.get('section.hero')?.category).toBe('section');
130+
expect(components.get('section.hero')?.globalId).toBe('ComponentSectionHero');
131+
132+
expect(components.get('section.cta')?.modelType).toBe('component');
133+
expect(components.get('section.cta')?.category).toBe('section');
134+
expect(components.get('section.cta')?.globalId).toBe('ComponentSectionCta');
135+
136+
// Sections are NOT press.hero — the removed atom stays removed (Spec §3).
137+
expect(components.get('press.hero')).toBeUndefined();
138+
});
139+
});
140+
141+
describe('page body dynamic zone (static section admission)', () => {
142+
it('lists section.hero and section.cta alongside the press.* atoms', () => {
143+
// Sections are engine-owned and deterministic, so they are admitted STATICALLY
144+
// in the page schema (not via the dynamic custom.* push) — Spec §5.1.
145+
const components = pageSchema.attributes.body.components as string[];
146+
expect(components).toContain('section.hero');
147+
expect(components).toContain('section.cta');
148+
// Additive: the press.* atoms remain admitted, unchanged (Spec §2).
149+
expect(components).toContain('press.paragraph');
150+
expect(components).toContain('press.image');
151+
});
120152
});

packages/cms/server/src/lib/inject-components.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import seoSchema from '../components/seo.json';
1111
import themeColorsSchema from '../components/theme-colors.json';
1212
import themeRadiusSchema from '../components/theme-radius.json';
1313
import navItemSchema from '../components/nav-item.json';
14+
import heroSectionSchema from '../components/section/hero.json';
15+
import ctaSectionSchema from '../components/section/cta.json';
1416
import { toGlobalId } from './global-id';
1517

1618
/**
@@ -41,6 +43,10 @@ const ENGINE_COMPONENTS: Array<{ category: string; name: string; schema: Record<
4143
{ category: 'press', name: 'button', schema: buttonSchema as Record<string, unknown> },
4244
{ category: 'press', name: 'separator', schema: separatorSchema as Record<string, unknown> },
4345
{ category: 'press', name: 'spacer', schema: spacerSchema as Record<string, unknown> },
46+
// Composite sections: engine-owned, flat (scalar/media/enum) building blocks.
47+
// Separate category from press.* keeps the atomic palette intact (Spec §5.1).
48+
{ category: 'section', name: 'hero', schema: heroSectionSchema as Record<string, unknown> },
49+
{ category: 'section', name: 'cta', schema: ctaSectionSchema as Record<string, unknown> },
4450
// Configuration components used by the Site Settings single type (not page blocks).
4551
{ category: 'press', name: 'seo', schema: seoSchema as Record<string, unknown> },
4652
{ category: 'press', name: 'theme-colors', schema: themeColorsSchema as Record<string, unknown> },

0 commit comments

Comments
 (0)