Ga naar inhoud

Blokken en huisstijl

Twee harde regels bepalen hoe content en vormgeving uit elkaar blijven: een pagina is een geordende reeks blokken en huisstijl komt uit data. Deze pagina laat zien hoe dat in code is uitgewerkt.

Een blocks-kolom (jsonb) bevat een array van blokobjecten:

[
{ "id": "", "type": "hero", "props": { "heading": "", "actions": [] } },
{ "id": "", "type": "text", "props": { "body": "" } },
{ "id": "", "type": "cardGrid", "props": { "items": [] } }
]
  • id is stabiel per blok-instantie, zodat een pagebuilder later kan herordenen zonder identiteit te verliezen.
  • type is de discriminator: hij bepaalt welk schema en welke renderer bij dit blok horen.
  • props is de inhoud en wordt gevalideerd tegen het Zod-schema van dat type.

Het TypeScript-type Block is een discriminated union in packages/contract/src/blocks.ts; blocksSchema valideert een hele array.

Type Label in CMS Inhoud
hero Hero Prominente pagina-intro met kop, optionele media en acties
text Text Markdown-tekst met optionele kop en breedte
image Image Eén afbeelding uit de mediabibliotheek met optioneel onderschrift
cta Call to action Gerichte oproep met één actie
cardGrid Card grid Raster van kaarten met titel, tekst, media en link
quote Quote Citaat met naam en optioneel portret

Blokken met een kop (hero, cta, en text of cardGrid als ze een heading hebben) krijgen bij het renderen automatisch een oplopend kopniveau (h1, h2 en verder), zodat de pagina een correcte kopstructuur houdt zonder dat de redacteur daarover nadenkt.

Een bloktype bestaat altijd uit drie dingen, en nooit één ervan los:

Onderdeel Waar Wat
Schema packages/contract/src/blocks.ts Zod-schema voor props, opgenomen in blockSchema
Renderer packages/blocks/src/blocks/<Naam>.astro Astro-component die het blok als HTML tekent
Registry-entry packages/blocks/src/registry-meta.ts en registry.ts Label en beschrijving voor het CMS, plus de koppeling van type naar component

registry-meta.ts bevat bewust geen Astro-import: het CMS gebruikt het om formulieren en keuzelijsten op te bouwen (src/lib/cms/block-form.ts, default-block.ts, block-summary.ts) zonder renderers mee te laden. registry.ts voegt daar de componenten aan toe; BlockRenderer.astro is de enige plek die type aan een component koppelt.

  1. Schema in blocks.ts: een z.object met type: z.literal("<naam>") en de props, toevoegen aan blockSchema en exporteren, inclusief het type.
  2. Renderer packages/blocks/src/blocks/<Naam>.astro. Gebruik uitsluitend theme-utilities (bg-primary, text-muted-foreground, rounded-md); geen kleuren, fonts of maten. Media via MediaImage.astro.
  3. Entry in registry-meta.ts (type, schema, label, beschrijving) en in registry.ts (component). TypeScript dwingt af dat elk type uit de union een entry heeft.
  4. Controleer het CMS: het nieuwe type verschijnt in de blok-editor met een formulier afgeleid van het schema. Voeg waar nodig een standaardwaarde toe in default-block.ts.

Draai daarna pnpm typecheck; de union en de satisfies-controle in de registry wijzen aan wat je vergeten bent.

  • Schrijven. De CMS-acties zetten formulierdata om in een blok en valideren met het schema uit het contract. Ongeldige blokken bereiken de database niet.
  • Lezen. parseBlocksForRender (packages/blocks/src/validate.ts) valideert bij elk render opnieuw, blok voor blok. Een onbekend type of een schemafout wordt een issue: in productie wordt zo’n blok overgeslagen en gelogd, in dev-modus toont UnknownBlock.astro wat er mis is. Eén kapot blok haalt dus nooit een hele pagina neer.

Waarom twee keer? Omdat schema’s evolueren. Een blok dat vorig jaar geldig was, kan na een schemawijziging ongeldig zijn; de leeskant vangt dat op.

Het text-blok accepteert Markdown. packages/blocks/src/markdown.ts rendert met marked en haalt daarna met sanitize-html alles weg buiten een korte lijst veilige tags (alinea’s, nadruk, links, lijsten, citaten, code). Koppen zijn bewust niet toegestaan: de documentstructuur komt uit de blokkoppen. Links in navigatie en blokken gaan door safeHref uit het contract, dat alleen interne paden en http(s)-, mailto- en tel-links doorlaat.

site.theme (jsonb)
│ themeSchema.parse() ← packages/contract/src/theme.ts, elk veld met default
Theme-object { colors, typography, radii, spacing, logo }
│ themeToCssVarsString() ← dezelfde file; de enige vertaallaag
<html style="--color-primary: …; --font-heading: …; --radius-md: …; --spacing-base: …">
│ @theme inline ← apps/site-template/src/styles/global.css
Tailwind-utilities: bg-primary, text-primary-foreground, font-heading, rounded-md, p-4
Componenten en blokken gebruiken alleen die utilities
  • Elk veld heeft een default, zodat een nieuwe klant zonder ingestelde huisstijl toch een nette, werkende site heeft.
  • Afgeleide *Foreground-kleuren (primaryForeground en zo verder) zijn verplicht, zodat een knop nooit een hardcoded text-white nodig heeft.
  • Waarden worden opgeschoond (sanitizeCssVarValue) voordat ze in een style-attribuut komen; een klant kan via de huisstijl geen CSS of scripts injecteren.
  • --font-scale stuurt de root-fontsize, --spacing-base de basis van Tailwinds spacingschaal (p-4 wordt vier keer de basis).
  • Lettertypes: een systeemstack, met optioneel een externe stylesheet-URL per font (fontHeadingUrl, fontBodyUrl). Self-hosted fonts zijn een open punt.
  • Logo: theme.logo.mediaId verwijst naar een media-rij; de header valt terug op de sitenaam.

De klant bewerkt dit alles onder Instellingen in het CMS (src/lib/cms/theme-form.ts). De toets uit de architectuur blijft de beste controle: verander de primaire kleur in de database, herlaad, en de site verandert mee.

  • Geen bg-blue-600, geen hexcode, geen fontnaam in een component of blok van de klantsite.
  • Geen tweede vertaallaag naast themeToCssVars().
  • Geen paginatype met vaste contentvelden buiten het blokmodel.

Het master dashboard heeft geen tenant-theme; daar zijn gewone Tailwind-utilities juist correct.