Content model
Site content lives entirely in JSON files inside the client repo’s src/content/ directory. The framework reads, validates, and assembles these files at request time (editor path) or at build time (viewer path via import.meta.glob).
Directory layout
Section titled “Directory layout”src/content/├── index.json # section ordering, status flags, access control├── site-config.json # site name, theme, dark mode toggle├── image-manifest.json # maps 16-char hash IDs → image folders + metadata└── sections/ ├── hero.json ├── colors.json └── ... # one JSON file per sectionindex.json
Section titled “index.json”index.json is the site’s table of contents. It tracks:
- Section ordering —
pages[].orderis an array of section IDs in display order. - Section metadata —
sections[id]holdstype,status(draft|live|archived;publishedis accepted as a legacy alias forlive), andaccess(audience slug ornullfor public). - Site identity —
siteId(used for Supabase tenant isolation) and optionallastModifiedtimestamp.
The IndexSchema Zod schema in @drawnagency/primitives validates this file at load time.
Per-section JSON files
Section titled “Per-section JSON files”Each section in src/content/sections/ is a self-contained JSON file named by its ID (e.g., hero.json). The file must have a type field matching a registered section schema. All other fields are defined by that section’s Zod schema.
Example:
{ "id": "hero", "type": "prose", "heading": "Brand Voice", "body": [{ "type": "paragraph", "content": [{ "type": "text", "text": "..." }] }]}How content is assembled — mergeSiteContent()
Section titled “How content is assembled — mergeSiteContent()”mergeSiteContent() is defined in packages/primitives/src/lib/loader.ts and re-exported by packages/core/src/lib/loader.ts (which adds GitHub-backed loaders on top).
mergeSiteContent(index: SiteIndex, sectionFiles: Record<string, unknown>): SiteContentIt:
- Reads
index.pages[].orderto get the display order of section IDs. - For each ID, looks up the raw JSON from
sectionFiles. - Runs
upgradeLegacySection()to transparently migrate any retired section types (idempotent — returns the same reference for current content). - Validates the result against the discriminated-union
getSectionSchema()Zod schema (requires at least 2 schemas registered). - Enforces a maximum block tree depth (
MAX_BLOCK_DEPTH). - Returns
{ sections: LoadedSection[], index: SiteIndex }.
Sections that fail validation or exceed depth limits are skipped with a console.warn — the page renders without them rather than crashing.
Static loading via import.meta.glob
Section titled “Static loading via import.meta.glob”For production viewer builds, loadStaticSiteContent() wraps mergeSiteContent() for a Vite import.meta.glob result:
const sectionGlob = import.meta.glob("@/content/sections/*.json", { eager: true, import: "default",});const { sections } = loadStaticSiteContent(staticIndex, sectionGlob);Vite bundles all matched JSON files at build time — there are no runtime filesystem reads in the viewer path.
GitHub-backed loading
Section titled “GitHub-backed loading”packages/core/src/lib/loader.ts adds loadContentFromGitHub(targetBranch?), which fetches index.json, site-config.json, and each section file from GitHub via the GitHub App installation token. The editor uses this to load the saved draft branch for in-progress edits.
Root src/ re-export stub
Section titled “Root src/ re-export stub”The monorepo’s root src/ tree is a one-line-re-export stub layer. It exists solely so Vitest tests can intercept imports with vi.mock("@/lib/loader"). It is not a working site — client repos have their own src/ content trees.