Skip to content

ADR-0004 — CMS adapter pattern and loud-fail discipline at every layer

Status: Accepted Date: 2026-05-09 / 2026-05-10 Deciders: Cathal Dempsey Related: ADR-0001 (fulldev as foundation), ADR-0003 (assembler-fulldev)

Context

ADR-0001 chose fulldev/ui as the canonical block library, consumed by the per-site assembler. ADR-0003 specified how the assembler emits fulldev components for each matched section. That covers the "1800 static sites" path.

The other path is the multi-tenant CMS worker (apps/cms/) — a single Cloudflare Worker + D1 + R2 + emdash that serves CMS-driven content for many sites. It needs to render the same block types the assembler does, but from Portable Text in D1 instead of from extracted JSON. Two implicit questions had to be answered before adding any block:

  1. Does the CMS get its own component library (Path B), build on top of fulldev (Path A), or write a thin wrapper around the canonical (Path C)?
  2. What happens when something goes wrong inside any of these layers — wrong block type in D1, missing image, unrecognised icon name, malformed Portable Text node?

The first question reaches into how much code we double; the second reaches into how invisible bugs become.

Decisions

1. Path A — adapters wrap canonical fulldev components

Adopted. Each CMS block type has a thin adapter at apps/cms/src/components/fcr-blocks/<Name>.astro (~20–50 lines) that maps a Portable Text node to a canonical block at packages/components-v3/src/components/blocks/<name>.astro.

The adapter does field-name translation, section-heading slot handling, and trivial defaults. It does not own design or layout — those live in the canonical block, which is also what the assembler emits.

Alternatives considered

Path B — fresh CMS-only component library. A second component library inside apps/cms/src/components/blocks/ that mirrors the canonical's blocks. Discussed and rejected.

Path C — wholesale rewrite to a third foundation. Drop fulldev entirely for the CMS path. Discussed and rejected immediately.

What settled it

The fulldev/ui upstream commit rate measured during evaluation: ~50 commits in 7 days. That number is not just upstream-velocity trivia — it determines what "two parallel libraries" actually costs to maintain.

  • Path B forks the design surface in two. Every fix to the canonical has to be re-applied in the CMS clone (or vice versa). At ~50 commits/week of upstream movement plus our own forks, drift becomes a tax we'd pay every session.
  • Path C compounds Path B's cost with re-evaluation expense (we already chose fulldev in ADR-0001 against AstroWind and bejamas; redoing that exercise for the CMS is wasted motion).
  • Path A keeps the canonical as the single source of truth. CMS-side variation lives in the thin adapter, where the cost is bounded (50 lines, one file per block type).

2. Loud-fail discipline at every layer

Silent failure is unacceptable. Bad/missing input renders a visible placeholder + emits an SSR warning. Applied at three layers:

Block-dispatcher layer

FcrBlocks.astro carries an explicit MAP from _type to component. Anything not in MAP renders MissingBlock.astro — a red dashed banner showing the unknown type and the block's data, plus a console.warn to the SSR log. There is no silent fall-through.

scripts/check-renderers.mjs (prebuild hook) refuses to ship the build if any block type declared in the FCR plugin schema has no dispatcher entry, or points at a missing file. Site-globals (fcr.header, fcr.topbar, etc.) are checked separately against Base.astro's globals lookup.

ALLOW_MISSING_RENDERERS=1 exists as an escape hatch but the script is intentionally loud about it being a smell:

⚠ ALLOW_MISSING_RENDERERS=1 — continuing despite errors. THIS IS A SMELL.

The discomfort is the point. It's there for local-dev "I'm working on the renderer right now" cases, not for production.

Primitive layer

A 22-family audit of UI primitives on 2026-05-09 surfaced 5 silent- failure bugs:

  • Icon — silently emitted nothing for unmatched names
  • Image / LogoImage / AvatarImage — silent on missing src
  • Rating — silent on 0, NaN, malformed numerics

Each was given the same treatment: when input is missing or unrecognised, render a ui-missing-{icon,image,logo,avatar,rating} red dashed inline placeholder + log a console warning. The placeholder is deliberately ugly — operator attention is the whole point.

Two canonical block fixes were made along the way: - reviews-1.astro made the avatar conditional on item?.image?.src to avoid placeholder noise when CMS schema legitimately has no headshot field. (Loud-fail is correct for unintended absence; structurally optional fields shouldn't fire it.) - team-grid.astro removed a {image?.src && ...} short-circuit that was silently swallowing the missing-image case before the Image primitive's loud-fail could fire.

Two adapter de-filterings: LogoStrip and Gallery were dropping items with missing src via .filter(l => l?.src). Same class of silent-failure bug at the adapter layer.

Build layer

check-renderers.mjs (described above) is the build-time guard. The pattern: anything that "silently swallows bad input" is a bug even if we haven't hit it yet — apply the same audit shape upward (matchers, schema-↔-component validation) when a layer comes into focus.

3. Icon primitive — explicit static map, not import.meta.glob

Adopted. Icon imports live in packages/components-v3/src/components/ui/icon/icon.astro as explicit import statements per icon, mapped through LUCIDE and SIMPLE records.

Alternatives considered

import.meta.glob("node_modules/lucide-static/icons/*.svg") — the original implementation. Rejected.

What settled it

When the primitive is consumed via Vite alias from packages/components-v3/, the glob pattern resolves relative to the icon file — looking for packages/components-v3/src/components/ui/icon/node_modules/lucide-static/..., which doesn't exist. The glob silently returns an empty set with no build error. Every Icon name becomes "no match" → silent emit nothing (prior behaviour) or loud placeholder (after the audit).

We hit this in production with arrow-right failing across every ServiceGrid tile.

Static-map imports fail at build time when a name is misspelled or an import target is missing. The failure mode is loud.

LinkedIn isn't in simple-icons (licensing); the static map omits it and the loud-fail placeholder fires instead. Acceptable for now — fix is lucide-staticlucide swap, or a bundled custom SVG, when LinkedIn icon support becomes worth the 15-minute decision.

Consequences

Positive

  • Single source of truth for blocks. CMS edits to design have to land in the canonical, where the assembler will pick them up too.
  • Adapters are auditable in one place — small, cohesive, no design logic.
  • Every layer fails the same way. Operator pattern-recognises a red dashed placeholder regardless of whether it came from an unknown block type, an unresolved icon, or a missing image src.
  • The build refuses to ship silent gaps. The 5 silent-fail primitives were sitting there since fulldev/ui import; the audit shape catches whole classes at once, not bug-by-bug over weeks.

Negative / risks

  • Adapter drift. If a canonical's prop shape changes, every adapter that consumes it is a potential break. Mitigation: the adapter layer is small and the build-time guard catches missing renderers; type drift is the gap (see ADR-0004 follow-ups).
  • Static-icon map maintenance. Adding a new icon requires a code change. This is a feature, not a bug — the change is one-line, and it makes "what icons are usable" enumerable. Does mean a future session has to look at the map before assuming an arbitrary lucide name works.
  • Loud placeholder noise during real authoring. When an operator is mid-edit and a block is incomplete, the page renders dashed-red rectangles. This is by design but is uncomfortable in admin preview. May want to gate placeholder rendering on a "dev/preview" signal eventually.

When to revisit

  • Drift between adapter and canonical prop shapes. If type errors start showing up at adapter sites, the gap (schema-↔-component type validation) is a real problem and needs ADR-0006.
  • fulldev upstream gets unmaintained or pivots. Same trigger as ADR-0001 — fork upstream and keep going.
  • Loud-fail placeholders create noise problems in CMS preview. Add a preview mode that softens placeholders, but never disables them entirely.

Followups

Shared theme emission — CMS reuses the assembler's emitGlobalCss (2026-05-12)

The CMS deployed worker and the per-site assembled builds now share one theme-emission code path. Previously the CMS had an empty apps/cms/src/styles/theme.css (template with all overrides commented out) and a fixed tailwind.css that only imported Tailwind. Canonical fulldev blocks rendered with their hardcoded fallback colours — happened to be FCR-typical (cyan / yellow-green) so the output looked approximately right by coincidence, but missing fonts, the per-site service-accent tokens, and any deviation from garvanbay's exact palette.

The followup adds:

  • apps/cms/scripts/emit-theme-css.mjs — reads builds/<domain>/theme.json (where <domain> comes from seed.json#/meta/name) and writes the output of lib/assembler-fulldev/theme.js#emitGlobalCss to apps/cms/src/styles/global.css. Runs as part of prebuild alongside check-renderers.
  • Base.astro now imports global.css (generated) + theme.css (operator-override layer, still empty by default). The legacy --fcr-primary / --fcr-accent / --fcr-foreground inline-style block in Base.astro is removed — body now reads the canonical --foreground / --background / --font-sans tokens that global.css populates.
  • TeamGrid.astro switched from var(--fcr-primary, ...) to var(--primary, ...) to match the canonical naming.
  • lib/theme-extractor.js patched to honour BUILDS_DIR env var (consistent with seed-from-build.js); the hardcoded EC2-only output path is now a fallback.
  • tw-animate-css added as a CMS dep — emitGlobalCss imports it.

Same canonical theme, two consumers, both work after. Phase 3 regression-check intent: any tweak to theme.js ripples to both the assembler-emitted per-site builds AND the CMS-emitted global.css.

Multi-tenant deferral. Today's CMS deploy serves one domain (garvanbay). When multiple FCR sites share a single CMS worker, global.css must move from a build-time file to a runtime D1 lookup (or Worker-level per-host static asset). Captured here so the single-tenant scaffolding doesn't paint into a corner: the helper contract (theme.json → CSS string) stays stable; only the storage flips.

Path A enforcement — 9 hand-rolled adapters rewritten (2026-05-12)

When ADR-0004 was accepted on 2026-05-10, Path A (adapter-wraps-canonical) was the stated pattern but only 10 of the 19 CMS adapters under apps/cms/src/components/fcr-blocks/ followed it. The remaining 9 were hand-rolled with their own inline <style> blocks and bespoke .fcr-* class names — a parallel design surface that diverged visually from the per-site assembled build (which uses the canonical fulldev blocks directly via the assembler-fulldev pipeline).

The divergence was invisible until Slice 3 of ADR-0005 (image migration) landed — once images stopped 404'ing on the deployed CMS, the structural mismatch became the dominant visual signal. This followup slice rewrote all 9 hand-rolled adapters to delegate to canonical blocks:

Adapter Canonical target Reason for picker
Hero.astro hero-4 (with media) / hero-3 (text-only) Branch on imageUrl / videoUrl / slideshowUrls presence per ADR-0003 §"Hero" variant table.
About.astro content-1 ADR-0003 §"About / Content". imagePosition: 'left' not honoured (canonical doesn't expose the prop) — known polish gap.
CtaStrip.astro cta-wcp Custom variant — canonical accepts both scalar and object image, no further translation needed.
FAQ.astro faqs-1 {question, answer}{title, description}. showSearch: false is currently advisory — canonical always renders the input.
BlogPosts.astro posts-wcp Per known-patterns "Blog Feed Pipeline".
Checklist.astro checklist Multiline string items split on \n and mapped to { text } items.
Header.astro header-wcp Matches assembler-fulldev's choice (per lib/assembler-fulldev/translate.js). Submenus not supported by CMS schema (single-level only). Superseded 2026-07-08 pt6: items gained a multiline subItems field ("Label | /url" per line, the footer-lines encoding) — fromHeader emits it from menus[].links, the adapter parses it back, and the canonical dropdown renders it. The CMS header is no longer single-level.
Footer.astro footer-wcp First partnerLogos entry becomes the credit image; designCredit text drops if no logo present.
TopBar.astro topbar-wcp CMS schema flattens contact fields → canonical groups them under contact: { phone, email }. Platforms outside the canonical's typed enum fall back to the generic link icon.

The pattern after this slice: every CMS adapter is a thin (15–60 LOC) file that imports a canonical block, translates Portable Text fields to the canonical's prop shape, and delegates rendering. Inline <style> blocks are absent except for one heading-wrapper micro-style in TeamGrid.astro (canonical's slot context isn't heading-friendly).

Smoketest verified post-deploy. /, /contact, /smoketest, /adapter-test all return HTTP 200 with zero fcr-{hero,about,cta-strip, faq,checklist,blog-posts,header,footer,topbar} legacy class hits in the HTML and 16–31 canonical block markers each. Image refs route through Astro's /_image?href=...&f=webp pipeline (was raw <img src> before).

Known follow-up gaps (not regressions; pre-existing schema/data shapes that the canonical doesn't fully express): - About's imagePosition: 'left' renders with canonical's default direction. Adding direction support is a canonical-side prop change. - FAQ's showSearch: false is ignored. Same — needs a canonical prop. - Header doesn't carry submenu nesting (CMS schema is single-level). Closed 2026-07-08 pt6 — see the Header row note above (subItems).

D1 ↔ seed.json drift (orthogonal). The deployed CMS's home row in D1 was seeded from a pre-Slice-3 snapshot with empty imageUrl. The adapter correctly routes to hero-3 (text-only) given that data, which is correct adapter behavior but visually diverges from the live garvanbay homepage (which has a hero image). Re-seeding with the current seed.json would restore the rich hero data; that's a re-seed step, not an adapter concern.

Editor-surface followups (2026-07-04)

The WS-3 spike (editing through the deployed emdash admin) surfaced three gaps between "renders when seeded" and "an operator can author":

  1. Native Portable Text types need renderers. The emdash editor lets an operator insert native blocks (paragraph/heading/quote/lists = _type: "block"; _type: "code"; _type: "image"). The dispatcher only knew fcr.*, so any insert tripped the loud-fail "Missing renderer" banner on the live page. Added Prose.astro (block → PortableText, empty skipped), CodeBlock.astro, ImageBlock.astro (native image, loud-fail if the asset has no URL), registered under non-fcr.* keys (check-renderers ignores them). Commits 25112bc, 5394c81.

  2. About adapter routed everything to content-1 (stacked). A side image rendered full-width above/below the text and imagePosition was ignored. Fixed to mirror the assembler (lib/assembler-fulldev/translate.js): a block with a side image → content-2 (SectionSplit, side-by-side) with reverse = (imagePosition === "left"); text-only → content-1. imagePosition is now honored and operator-editable. Commit ffdf270.

  3. The CMS <main> was not an @container. content-2 / SectionSplit use a container query (@5xl:grid-cols-2) that only becomes two columns inside a sized @container. The per-site assembler provides this via its section-provider wrapper; the CMS rendered <main><slot/></main>, so every split stayed single-column. Added @container/section-provider to Base.astro's <main>. Commit 63beb5f.

Pattern: the canonical block library and the container primitives are shared with the assembler; the CMS must replicate the assembler's wrapper context (section-provider / @container), not just call the same blocks. See known-patterns "SectionSplit needs a container-query context".

Loud-fail scoping — structural absence is judged at the BLOCK, not the item (2026-07-13)

§2 says bad/missing input renders a visible placeholder, and the "Avatar-conditional" rule (known-patterns) already carves out structurally optional fields. This followup records the scope at which "structural" is decided, because getting it wrong is what produced 276 loud-fail placeholders on a site whose content was entirely correct.

Two distinct causes, both closed 2026-07-12/13, both of which looked like missing assets and were not:

  1. A shape-coercing emitter destroyed content (USPBar: fromLogoStrip — a text-item matcher pointed at an image-item emitter, so {icon,text} became {src:''}). The placeholder was the symptom; the data loss was the bug. Fix: split the emitter by what the section actually carries. See known-patterns "One emitter, two item shapes".
  2. A block rendered a media slot for cards that legitimately have none (services-1.astro rendered <TileMedia> unconditionally, so a live link grid — title + href, zero imagery — loud-failed on every card).

The rule that falls out, and that any new block must honour:

An item missing a field is loud when its siblings have it (a real gap an operator must see); it is structural when no item in the block has it (the design simply has no imagery there). So guard at the container: compute hasMedia = items.some(i => i.image?.src) and omit the media slot entirely when false — never blanket-guard per item, which would silence the real gaps (exactly the short-circuit deliberately removed from team-grid.astro in §2).

Retaining the signal must be proven, not assumed: force a mixed grid through the live path (blank one image of three, confirm the placeholder fires, revert).

Corollary for diagnosis: a wall of img?/logo? placeholders invites the story "the capture missed the images". Ask which matcher produced this block first — the build's section-matchers.json sidecar answers it in one lookup, and it was the correct answer three times running.

References

  • ADR-0001 (fulldev as canonical foundation)
  • ADR-0002 (primitive neutrality, the related fingerprint discussion)
  • ADR-0003 (assembler-fulldev — same canonical, different consumer)
  • apps/cms/src/components/FcrBlocks.astro (dispatcher)
  • apps/cms/src/components/MissingBlock.astro (block-layer placeholder)
  • apps/cms/scripts/check-renderers.mjs (build-time guard)
  • packages/components-v3/src/components/ui/icon/icon.astro (static-map Icon)
  • Memory: feedback_fulldev_chosen.md
  • Session: pipeline/session-2026-05-10-handover.md, pipeline/session-2026-05-12-handover.md (Path A enforcement)