Page types & schemas
Every page in the mesh has a page type — a typed contract that says both how to write it and what shape it must take. A page type is always a pair: a template and a schema.
What it is
Each entry under page_types in gtmesh.config.yaml names a template and a schema:
- The template (
templates/<type>.md) is the prose theory — the editorial guidance the article-writer follows when filling the body. - The schema (
schemas/<type>.schema.yaml) is the structure — a JSON Schema thatapplyscaffolds an empty skeleton from, and thatvalidate/sealenforce with ajv. The SSG renders against the same contract.
Two schema systems, never conflated: zod validates the registry; JSON Schema / ajv validates page YAML structure. Page types live entirely in the second world.
Why it matters
Typed contracts are what keep programmatic content consistent at scale. The schema guarantees a review page always carries a rubric and a verdict block, a comparison always carries its entity pair, and so on — so a thousand generated pages share one shape the SSG can render and AI answer engines can parse. The template guarantees they share one voice. Because the schema is checked at seal, a page that doesn’t conform simply cannot ship.
How it works
Selection is deterministic. Each page type carries a when predicate (a section, and/or a role, and/or the page’s resolved entity_kind) and plan picks the type whose predicate matches — first match wins, so specific rules sit above catch-alls. A kind: commercial_* project selects the money-layer types (product, tool, directory, trust); a content site selects entity-hub, educational, guide, and friends.
Typing hubs by entity class. A demand-minted hub (entity-aware classification) carries no section, so when: { role: hub } would collapse every hub onto one type. Add when: { entity_kind: … } (the entity node’s class label) to give each its own type — manufacturer-hub, industry-hub — placed above the generic entity-hub catch-all. Beyond the template/schema, this aligns the minted hub’s identity with a hand-authored typed seed of the same class, so the demand folds into the seed rather than minting a duplicate page. See Tuning §5 for the full recipe and Folding demand into a hub for why the alignment matters.
The key is entity_kind, not class. page_types.when isn’t validated for unknown keys, so when: { class: … } is silently ignored — the hub falls through to the generic entity-hub, its type no longer matches the typed seed, and you get a stray page with no error. If a typed hub won’t fold, check this first.
Catalog types. A page type can carry catalog: true to declare that it embodies an entity — a product, a category/manufacturer hub. Only catalog types emit the HAS_<axis> domain edges that file a page into the money layer; content types (guide, educational, comparison, …) merely mention entities, so they stay uncatalogued and emit only the topical MEMBER_OF membership that routes them up. The flag is opt-in and additive — see the HAS_<axis> gate for why it matters. The scaffold ships catalog: true on product and entity-hub.
Every page validates as the {type, meta, content} envelope (additionalProperties: false, with type as a const discriminator). meta is the render projection — slug is apply-owned, the editorial fields (metaTitle/title/navTitle/metaDescription) are writer-authored. content is the typed body.
A type can also carry schema variants — schema_variants plus variant_when lets one type resolve to a different schema by context. The scaffold’s educational type, for example, renders thin when a sibling hub exists in the same product cluster and full otherwise; plan resolves which one applies.
Sharing types across schemas
schemas/common.schema.yaml carries the engine’s shared types and is refreshed by gtmesh upgrade, so keep your own shared types in a schema file you own. Define each one once under definitions: and reference it by file from any page schema:
# schemas/shared.schema.yaml — your project's shared types
definitions:
hero:
type: object
additionalProperties: false
required: [title, image]
properties:
title: { type: string, minLength: 1 }
standfirst: { type: string }
image: { $ref: "#/definitions/image" } # common's `image`, available in every schema file# schemas/guide.schema.yaml
properties:
content:
properties:
hero: { $ref: "shared.schema.yaml#/definitions/hero" }The engine folds every referenced definition into the page schema before it validates, scaffolds a skeleton, fingerprints the schema, or generates TS types, so a shared type behaves exactly like one written inline and emits one TS interface.
-
Paths are relative to the referring file, with no leading
/, and must stay insideschemas/. Subdirectories work:common/address.schema.yaml#/definitions/address, and from insidecommon/,../shared.schema.yaml#/definitions/hero. URLs and paths that leaveschemas/are errors. File names match case exactly (Shared.schema.yamlis notshared.schema.yaml), on every OS, so a ref that resolves on a Mac also resolves on Linux CI. -
Inside a shared file, a local
#/definitions/xmeans that file’sx, falling back to common’s. -
Names are global. Only one TS type can carry a name, so every copy of a name must describe the same type:
- A name shared by file
$refmust match exactly everywhere it appears, including a page schema’s local copy on a page that never refs the shared one. - Page-local copies with no shared one must match in structure. Copies that differ only in
title,description,$commentorexamplesare a warning: the type is right, but its doc comments come from whichever copy loads last.
gtmesh page validateandgtmesh typesfail on a mismatch and name every file involved.gtmesh doctorreports it as an error, andgtmesh planprints it as a warning. Keep one copy in a shared file and$refit. - A name shared by file
-
Moving copies into a shared file keeps a page type’s fingerprint only when every definition you remove from it is one it actually used. Only definitions a schema reaches are folded back in, so an unused copy that leaves the file changes its fingerprint, and
gtmesh applyrestamps its sealed pages. The same happens when the copies you replace weren’t identical to the shared one. -
Editing a shared type changes the fingerprint of exactly the page types that reach it.
gtmesh applyre-validates their sealed pages, restamps the ones that still fit, and moves the rest toneeds_update. Rungtmesh typesto regenerate the TS package.
Key files & flags
| Thing | What it is |
|---|---|
gtmesh.config.yaml page_types | The list of types: each id → template + schema + when |
page_types[].catalog | true marks a type that embodies an entity → emits HAS_<axis> domain edges |
templates/<type>.md | Prose theory for the writer |
schemas/<type>.schema.yaml | The structural contract (JSON Schema) |
schemas/common.schema.yaml | The engine’s shared types (meta, faqItem, citation, …), merged into every schema and $ref-ed locally |
schemas/shared.schema.yaml (any name) | Your own shared types, $ref-ed by file: shared.schema.yaml#/definitions/<name> |
schema_variants / variant_when | Resolve one type to a variant schema by context |
gtmesh page validate <slug> | Check a page against its schema (ajv) + editorial lints |
Related
- Writer & quality gate — who fills the body against these contracts
- Tuning — changing prose theory and structure per type