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:
- A floating inline click-to-edit button on the live page.
- A full drag-and-drop builder canvas (
/admin/editor/build/<page>), built on GrapesJS. - 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:
src/content/blocks/<type>.json— the schema:type,label,description, and afieldsarray, each{ key, type, label, default }. Alistfield also needs anitemarray of the same shape, describing one row.src/components/blocks/<Type>.astro— the Astro render component. Read every field via theblockField(section, key)helper fromsrc/content/content.ts(it falls back to the schema default automatically). Reuse existing theme classes — every theme’smain.css(public/themes/gravity/main.css,public/themes/clarity/main.css) shares the same class vocabulary — see Hard rules below.- Register it in
src/components/blocks/Sections.astro’sBLOCK_COMPONENTSmap (one line). src/builder/render.ts— add arender<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 theBLOCK_WRAPPERmap 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 matchingrender<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’smain.css(.card,.grid-3,var(--space-*), etc. — seepublic/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 inmain.cssinstead — 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/*.jsonin a shape that doesn’t matchSection/BlockSchemafromsrc/content/content.ts. Go through that module’sload/save/publish, or thenpm run content/blog/events/etc. CLI scripts — they preserve draft/publish semantics andGRAVITY_SITE_PREFIXscoping that hand-edited JSON can silently violate. - Don’t build a second, competing content-storage mechanism. Every content type already
goes through the pluggable
ContentStoreinsrc/stores/— see Storage. A new content type gets a newsrc/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
ContentStoreabstraction 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.