Skip to content

The registry & defineSection

The section registry is the runtime lookup table that maps section types (string keys like "prose" or "colors") to their Zod schemas and React components. It lives in packages/primitives/src/lib/registry.ts and is the single source of truth for both the viewer render path and the editor.

The registry is a module-level singleton stored on globalThis under a well-known Symbol.for key:

const REGISTRY_KEY = Symbol.for("@drawnagency/primitives/registry");

The Symbol.for key is the safety net against module duplication. If Rollup or Vite produces two copies of the registry module (a risk during SSR code-splitting — see SSR / Netlify gotchas), both copies share the same underlying globalThis[REGISTRY_KEY] instance. Without this, sections registered in one copy would be invisible to the other, producing the “At least 2 section schemas must be registered” runtime error.

The following functions operate on the default registry instance and are the normal way to interact with the registry:

import {
registerSection,
registerSchema,
registerRichText,
getRichTextFields,
getSection,
getSchema,
getAllSections,
getAllSchemas,
clearRegistry,
} from "@drawnagency/primitives/lib/registry";
FunctionSignaturePurpose
registerSection(def: SectionDefinition) => voidRegister a full section definition (schema + component)
registerSchema(type: string, schema: ZodType) => voidRegister a schema only (no component; used by API validation)
registerRichText(type: string, fields: readonly string[]) => voidDeclare rich-text field paths for the HTML sanitizer
getRichTextFields(type: string) => readonly string[]Read declared rich-text fields
getSection(type: string) => SectionDefinition | undefinedLook up a full section definition
getSchema(type: string) => ZodType | undefinedLook up a schema (checks schemas map first, then sections)
getAllSections() => SectionDefinition[]All registered section definitions
getAllSchemas() => ZodType[]All schemas (merged from both registerSchema and registerSection calls)
clearRegistry() => voidClear all registrations (used in tests)

createRegistry() is also exported for creating isolated registry instances (used in tests).

defineSection is the helper for declaring a section. It takes a typed definition object and returns it as a SectionDefinition — the main value of calling it is TypeScript inference: the content prop type of component is inferred from schema.

import { defineSection } from "@drawnagency/primitives";
import { z } from "zod";
import MyComponent from "./MyComponent";
export default defineSection({
type: "my_type",
label: "My Section",
schema: z.object({ text: z.string() }),
component: MyComponent,
defaults: () => ({ text: "" }),
// optional:
category: "core", // "core" | "brand-guide" (presentation only)
navRole: "h1", // "h1" | "h2" | "h3" — drives sidebar nav
richTextFields: ["text"], // field paths that contain sanitized HTML
settings: { ... }, // declarative settings panel fields
settingsTabs: [...], // group settings fields into tabs
inheritableSettings: [...], // keys a parent container can set as child defaults
getLabel: (content) => content.text,
getThumbnails: (content) => [...],
});

Some built-in sections use internal helpers that call defineSection under the hood (e.g. defineHeadingSection for heading-type sections), but defineSection is the public API exported from @drawnagency/primitives.

All built-in sections are declared in a single ordered array in packages/primitives/src/components/sections/all-sections.ts:

export const allSectionDefs = [...coreSectionDefs, ...brandGuideSectionDefs];

Both register.ts (which calls registerSection for each) and register-schemas.ts (which calls registerSchema for each) derive from this one array, so the two registration paths can never drift apart.

Generic, brand-agnostic sections. A non-brand site can use these and tree-shake the brand-guide group entirely.

TypeDescription
link_headingTop-level section heading; drives <h2> with navRole: "h1"
sub_headingSub-section heading; drives <h3> with navRole: "h2"
sub_sub_headingTertiary heading; drives <h4> with navRole: "h3"
proseRich-text body copy (TipTap)
mediaSingle image
buttonCTA button with configurable link
containerLayout container that wraps child sections
spacerVertical whitespace

Brand-guide sections (brandGuideSectionDefs)

Section titled “Brand-guide sections (brandGuideSectionDefs)”

Opinionated sections for brand guidelines. Importing @drawnagency/primitives/components/sections/brand-guide is a self-contained subtree — builds that omit this import drop the brand-guide sections entirely.

TypeDescription
colorsColor palette with CMYK/Pantone/hex swatches
icon_listList of icons with labels
dodont_mediaSide-by-side do/don’t image examples

Retired types (do not use): split_content, media_grid, do_dont_grid, do_dont — these were decomposed into container-based compositions by mergeSiteContent() in the loader.

Each section has two rendering contexts:

  • Viewer (shell): Zero JavaScript. React components render server-side to pure HTML. The viewer path in packages/core/src/pages/[...slug].astro does not hydrate any React.
  • Editor: The full <EditorShell client:load /> is hydrated. The same section component is used in both contexts — it receives an isEditMode prop and an onChange callback that is only populated in the editor.

The SectionProps<T> type reflects this:

interface SectionProps<T> {
content: T;
options?: Record<string, unknown>;
onChange?: (content: T) => void; // undefined in viewer
isEditMode: boolean;
openModal?: (title: string, content: ReactNode) => void;
}