Skip to content

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).

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 section

index.json is the site’s table of contents. It tracks:

  • Section orderingpages[].order is an array of section IDs in display order.
  • Section metadatasections[id] holds type, status (draft | live | archived; published is accepted as a legacy alias for live), and access (audience slug or null for public).
  • Site identitysiteId (used for Supabase tenant isolation) and optional lastModified timestamp.

The IndexSchema Zod schema in @drawnagency/primitives validates this file at load time.

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>): SiteContent

It:

  1. Reads index.pages[].order to get the display order of section IDs.
  2. For each ID, looks up the raw JSON from sectionFiles.
  3. Runs upgradeLegacySection() to transparently migrate any retired section types (idempotent — returns the same reference for current content).
  4. Validates the result against the discriminated-union getSectionSchema() Zod schema (requires at least 2 schemas registered).
  5. Enforces a maximum block tree depth (MAX_BLOCK_DEPTH).
  6. 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.

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.

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.

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.