The Content Model

How Section/fields, block schemas, and the three interchangeable editors fit together — and the one rule that keeps them all working.

The rule that matters most

Anything a site owner should be able to edit after launch — without a developer and without touching code — must be a content block, not hand-written HTML on a page.

This site has three interchangeable editing surfaces that all operate on the exact same data:

  1. A floating inline click-to-edit button on the live page.
  2. A full drag-and-drop builder canvas (/admin/editor/build/<page>), built on GrapesJS.
  3. A JSON/CLI path (npm run content).

All three work by reading and writing Section[] — { id, type, fields } objects — through one module, src/content/content.ts. A block type only exists to those editors if it has a schema under src/content/blocks/*.json.

If you write a new marketing section directly into index.astro as literal markup instead of as a block, it renders correctly on the live site and is then completely invisible to both editors — not broken-looking, just silently unreachable. That’s the failure mode this page exists to prevent.

Heuristic: if a human product owner would plausibly want to change this text, image, or list themselves later without asking a developer, it belongs in a block’s fields. If it’s structural (page layout, nav, footer, deploy config, an entire new route) or truly one-off, it’s just code — edit it directly, no block needed. See hand-written pages below for the list of pages that are deliberately exempt.

Adding a new block type

This is a fixed, mechanical checklist — the builder canvas is schema-driven, so you do not need to write any GrapesJS-specific registration code:

  1. src/content/blocks/<type>.json — the schema: type, label, description, and a fields array, each { key, type, label, default }. A list field also needs an item array of the same shape, describing one row.
  2. src/components/blocks/<Type>.astro — the Astro render component. Read every field via the blockField(section, key) helper from src/content/content.ts (it falls back to the schema default automatically). Reuse existing theme classes — every theme’s main.css (public/themes/gravity/main.css, public/themes/clarity/main.css) shares the same class vocabulary — see Hard rules below.
  3. Register it in src/components/blocks/Sections.astro’s BLOCK_COMPONENTS map (one line).
  4. src/builder/render.ts — add a render<Type>(fields) function that mirrors step 2’s markup as a plain HTML string. This is the builder canvas’s live-preview renderer — a deliberate small duplication of the Astro component, not a shared abstraction, because GrapesJS renders HTML strings, not Astro components. Add the block’s outer <section> tag/class to the BLOCK_WRAPPER map in the same file.

That’s the whole checklist. src/builder/componentTypes.ts fetches every schema from /api/admin/editor/block-schemas at runtime and generates the GrapesJS block, component type, and trait panel from it automatically — don’t hand-write a new editor.Components.addType(...) call for a new block type; if you find yourself doing that, the schema-driven factory is missing something, and that’s the bug to fix.

Modifying an existing block type

A block type’s field list exists in three places that must all agree, or the builder canvas’s preview will silently diverge from the real site (and saved data may not round-trip):

  • src/content/blocks/<type>.json — the schema, source of truth for field keys/types/defaults.
  • src/components/blocks/<Type>.astro — the live-site renderer.
  • src/builder/render.ts’s matching render<Type>() — the canvas-preview renderer.

Add, rename, or remove a field in the schema and update the other two in the same change. Trait UI (the settings panel) needs no separate update — it’s generated from the schema.

Field types

Only six exist, and each maps to exactly one editor UI (see src/builder/traits.ts):

Type Editor UI Notes
text Plain input Single-line string
textarea Multi-line input Plain string, no formatting
richtext RTE (inline editor) / textarea (builder) Stored as raw HTML — no markdown
link URL input A URL string
image Upload button Wired to /api/admin/editor/upload, goes through the pluggable ContentStore — see Storage
list Repeater Needs an item sub-schema describing one row

If you think you need a seventh type, you also need to: add a case to traitTypeFor() in src/builder/componentTypes.ts, and a matching custom trait type in src/builder/traits.ts (follow the pattern of gv-textarea/gv-image/gv-list — read that file’s own docstring first, it explains why every custom trait manages its own DOM instead of GrapesJS’s default input-value wiring). Don’t invent a new field type string without doing both, or it’ll silently fall through to a plain text input.

Hand-written pages, not part of the block system

These pages are ordinary code, not editor-managed content — neither visual editor touches them: theme CSS, site chrome (nav/footer in BaseLayout.astro), and standalone pages — /get-started, /install-your-tools, /forms, /blog, /events, /scheduling, /schedule/*, /links, /podcast, /shop/*, /gallery, /contact, /admin/*, /portal/*, and this documentation site itself. When in doubt, ask: “does this text/image currently come from a Section’s fields?” If not, it’s just code — edit it directly.

Three pages are dual-mode: index.astro, about.astro, and features.astro render <Sections sections={content.sections} /> when block content has been published for that page, and fall back to a hardcoded default layout only when it hasn’t. Once a page has published blocks, add a new block/section instead of editing the fallback markup — the fallback branch is dead once real content exists.

Hard rules

  • Don’t hand-write new sections into index.astro/about.astro/features.astro’s markup once real content exists there — see dual-mode pages above.
  • Don’t add inline style= attributes or one-off CSS classes inside block markup. Use only the existing utility classes and CSS custom properties already defined in every theme’s main.css (.card, .grid-3, var(--space-*), etc. — see public/themes/gravity/main.css). The builder canvas deliberately has no style manager — block markup is the only thing that determines how a block looks, precisely so a site owner can never drag it off-theme.
  • A new shared class goes into every theme’s main.css, not just one. Each theme is a self-contained design system with the full class vocabulary, not a shared base plus per-theme diffs — a class added to only one theme breaks under the other.
  • Before adding a class to a hand-written page’s scoped <style>, check whether it (or something close to it) already exists on another page. A class reused across two or more pages belongs in main.css instead — a Python-script-based dedup audit (grep every <style> block, flag cross-file duplicates) is a standard verification step after any page-heavy change. See Theming for the full design-token system.
  • Don’t write directly to data/content/*.json in a shape that doesn’t match Section/BlockSchema from src/content/content.ts. Go through that module’s load/save/publish, or the npm run content/blog/events/etc. CLI scripts — they preserve draft/publish semantics and GRAVITY_SITE_PREFIX scoping that hand-edited JSON can silently violate.
  • Don’t build a second, competing content-storage mechanism. Every content type already goes through the pluggable ContentStore in src/stores/ — see Storage. A new content type gets a new src/services/*Store.ts, not a bespoke file-read/write path.
  • Don’t vendor the Adhara SDK into this repo. See Adhara Integration.

See also

  • Project Structure — the directory tour and request flow.
  • Storage — the pluggable ContentStore abstraction every feature’s data goes through.
  • Adhara Integration — how individual features switch from local JSON to a real CMS backend.
  • SEO & Metadata — how every page’s title/description/OG image and sitemap.xml/robots.txt are generated.