|
| 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. |
0 commit comments