Content-types package
What it is
The bridge between the mesh and whatever consumes it.
gtmesh types emits a self-contained TypeScript
workspace package (default packages/content-types/) that your static-site
generator — or any TypeScript consumer — compiles against. It is the
content-types package.
Why it matters
The mesh’s contract is its page schemas and its committed graph. Hand-writing types to
match them drifts the moment a schema changes. Generating them keeps the
consumer honest: the SSG imports the same shapes the engine guarantees, and
gtmesh types --check fails CI the instant the committed package and the schemas
disagree.
How it works
gtmesh types reads schemas/ and the page types declared in
config.page_types, then writes a buildable package containing:
- per-type page interfaces — one TypeScript interface per page type, derived from its schema;
- a
PageTypeunion — the closed set of page types, derived fromconfig.page_types; - the graph-native reader —
loadGraph()(the indexedGraphViewovergraph/{nodes.jsonl,edges.tsv}, with derivedroleOf/byRole/entityOf/edgesOf/membersOfaccessors —edgesOf(id, type?)is a node’s whole relationship set, both layers, mirroringPage.edgesin the engine) andloadManifest()(the projectedsite.manifest.jsonlink tree). This is the reader — the old row-shapedloadRegistry()/RegistryEntryreader is removed (§6.2); see the crosswalk if your SSG still uses it; - page-bundle loaders — to load a page’s
content/<id>/index.yamlbundle; - a redirect reader (
loadRedirects()) — reads theurl/ALIAS_OFgraph layer, returns{ from, to }to emit 301s. See Redirects; - an entities/facet reader + mesh-feed helpers —
loadEntities/entitiesByFacet, andbreadcrumbs/hubMembers/relatedLinks+ thepageSummariescard index, so navigation is a call, not a re-implementedlib/; - named projections —
getProjection(name)returns a typed, cached list of schema-shaped cards (label, thumbnail, tags) from a config mapping; - co-located asset helpers —
assetUrlresolves a bare asset filename to a served URL;copyMeshAssetsmirrors the files to your public dir; - config-derived constants (
site.ts) — the section list, the per-environment render set, the site base URL, the facet axes, the thumbnail projection, and the asset base, so the SSG reads engine-owned policy instead of re-declaring it.
The package is self-contained, so the consumer depends on it like any workspace package — no reaching into the mesh internals.
Wire gtmesh types --check into CI. It writes nothing and exits non-zero on
drift, so the consumer’s types can never fall out of sync with the content
contract.
Library API
Everything the package exports, grouped by what it reads. For how these compose into a site build, see Build a website.
Pages — load a page’s content bundle
| Function | What it does |
|---|---|
loadPage<T>(slug, opts?) | Load + parse one bundle content/<slug>/index.yaml, typed as T (pass a generated <Type>Page). null if absent. |
loadPagesByType<T>(type, opts?) | Every page of a page_type, graph-driven — { slug, node, page }[] (each node is a GraphNode; its derived views come from the GraphView accessors). |
loadPagesBySection<T>(section, opts?) | Every member page in a section, same shape. Excludes the section’s own root (the index/home structural pages) so an index never self-lists — pass { excludeIndex: false } to include the landing. |
pageSlugs(opts?) | The slugs for a type/section/status — no bodies read. For generateStaticParams, sitemaps. |
authorOf<T>(page, opts?) | The author bundle a page references via meta.author (#179 E2) — content/people/<slug>/, typed via PersonPage. Null if unset. |
loadOrganization<T>(opts?) | The organization identity (foundation/organization.yaml), typed via Organization. Null if absent. |
loadPageBrief(slug, opts?) | The engine-owned assignment brief (apply’s _brief block — the writer’s instructions) as a typed camelCase RegistryBrief. Excluded from the generated <Type>Page types by design (it isn’t render content); this is the explicit accessor for tooling/debug UIs. Null when there’s no bundle or no _brief. |
All accept a PageQuery: { status?, graph?, root?, path? }. Pass
status: renderableStatuses(env) to list only the pages an environment renders,
and graph to reuse an already-loaded GraphView instead of re-reading it.
Feeds — navigation as cards
Traversal over the mesh link tree, returning ready-to-render PageSummary[]. Page identity + status
come from the GraphView (pass the loadGraph() handle as the first argument); the up-link/sibling
arrays are a site.manifest.json read. Each accepts opts.status (prune to the render set) and
opts.thumbnails (fill card images). See Navigation & relationships.
| Function | What it does |
|---|---|
breadcrumbs(g, slug, opts?) | Root → page trail (primary up-link chain), each hop a card. |
hubMembers(g, hubSlug, opts?) | The pages that link up to a hub (the derived down edge), sorted. |
relatedLinks(g, slug, opts?) | { siblings, across } — peer links as cards. |
Projections — schema-shaped cards (projections.ts)
Named projections: a page → a shaped
list-item whose shape is a schema (card.schema.yaml → the Card type).
| Export | What it is |
|---|---|
getProjection(name) | Returns a runnable (opts?) => Card[] that projects the selected pages, memoised after first run. Typed per projection name. |
PROJECTIONS | The baked projection specs (name → schema + select + field mapping). |
Card (+ CardThumbnail, CardTag) | The generated result type for the card schema. |
getProjection("productCard")({ status: renderableStatuses(env) }) // → Card[]
.map(c => ({ href: c.slug, label: c.label, img: c.thumbnail, tags: c.tags }));Summaries — the card index (summary.ts)
The build-once slug → PageSummary projection: a graph-node + bundle-meta join
(label, description, optional thumbnail), scanned once and memoised on the
GraphView, so labelling related slugs is O(1) instead of re-parsing bundles. Pass the
loadGraph() handle as the first argument.
| Function | What it does |
|---|---|
pageSummaries(g, opts?) | The full slug → PageSummary index (restrict with opts.status). |
pageSummary(g, slug, opts?) | One page’s card (reads just that bundle, cached). |
labelForSlug(g, slug, opts?) | The display name — navTitle ?? title ?? humanised slug. |
PageSummary = { slug, type, section, entity, title?, navTitle?, description?, label, thumbnail? }.
Pass { thumbnails: THUMBNAILS } to populate thumbnail ({ asset, alt?, caption?, ratio? });
asset is the bare co-located filename (resolve content/<slug>/<asset>).
Graph — the graph-native accessor (graph.ts)
loadGraph(opts?) returns an indexed, read-only GraphView over the committed
graph (graph/nodes.jsonl + graph/edges.tsv) — parsed and indexed once per
build (no per-call re-parse), source-hidden (you never pass arrays around). It’s a
generic accessor: it exposes the raw graph — nodes by id/property + the typed
edges — and your SSG builds its own domain lists from the edge relationships it
knows. The lib re-encodes no config vocabulary (no cluster/facet/topic groupers), so
nothing in it can drift from your gtmesh.config.yaml.
| Method | What it does |
|---|---|
getById(id) / node(id) | One node by its stable id (the frozen-first-slug handle — the same value written into each page’s _brief.id). |
findByProperty(prop, value) | Every node whose top-level prop equals value (indexed per prop) — e.g. findByProperty("status", "published"). |
bySlug(slug) | The page node serving slug (indexed). |
byLabel(label) | Enumerate every node carrying label — byLabel("product") (all products), byLabel("manufacturer") (pages on the manufacturer axis), byLabel("topic"), byLabel("url"), byLabel("page"). This is how you build index / listing pages (§A.3 “all manufacturer pages = a label query”). A hub page carries its axis label (its page_type minus -hub), so “all category pages” is byLabel("category"). |
nodes() / edges() | The full node / edge set (a fresh, stable-ordered copy) — for a sitemap or an all-pages sweep. |
out(id, type?) / in(id, type?) | The typed edges from/to id (optionally one edge type) — the raw relations, exposed directly. |
outNodes(id, type?) / inNodes(id, type?) | The resolved endpoint nodes of those edges — this is how you build a domain list: outNodes(id, "HAS_APPLICATION") for a product’s applications, outNodes(id, "HAS_COLLECTION") for its collections, inNodes(hubId, "HAS_CATEGORY") for a hub’s members. |
Derived accessors (sugar). The mesh’s authority derivations, computed once per view and reused — so you never hand-roll the apex-edge role scan or the pluralised-hub entity trap. Each wraps the same canonical engine derivation, so roles/entities stay in one place.
| Method | What it does |
|---|---|
roleOf(id) | The page’s DERIVED role — pillar (PILLAR_OF), hub (HUB_OF), sub-hub (a HUB_OF also MEMBER_OF a topic it doesn’t own), spoke (no apex), or a structural kind (home/index). A view of the apex edges, never a stored column. |
byRole(role) | Every page node whose roleOf equals role, id-ordered (indexed) — the role filter (byRole("hub")) without a per-call edge scan. |
entityOf(id) | The page’s DERIVED entity slug — the apex value for an owner (so a pluralised /categories/rotary-lobe-pumps → rotary-lobe-pump), else the id minus its section prefix. |
edgesOf(id, type?) | The node’s out-edges as { type, to }, sorted + deduped — both layers in one accessor. Filter to read one: edgesOf(id, "HAS_INDUSTRY") for a domain axis (every value on it), edgesOf(id, "MEMBER_OF") for topical membership. Targets are node ids — the stable token, and what you link with. Mirrors Page.edges in the engine. |
membersOf(id) | The members of a topic — pass a topic id (topics/<value>) or a hub/pillar page id (resolved via HUB_OF/PILLAR_OF), owner excluded. |
loadRedirects(opts?) | Standalone (not a GraphView method): the redirect list (old slug → current) from the url/ALIAS_OF graph layer, for emitting 301s. Auto-detects the store; not deprecated. |
Coming from clustersOf / topicsOf? Those per-layer accessors are gone — edgesOf replaces
both. clustersOf typed a page’s memberships as axis → ONE value, so a page on four industries
reported one and the rest were dropped; topicsOf returned bare values rather than node ids. Rewrite
each call, then run gtmesh types:
g.clustersOf(id).industry → g.edgesOf(id, "HAS_INDUSTRY").map(e => e.to) // every value, as node ids
g.topicsOf(id) → g.edgesOf(id, "MEMBER_OF").map(e => e.to) // the same memberships, as node idsA target id is the stable token: link with it, or resolve it to a node with getById(e.to) (its display
name is on the node) — or to the value it stands for with entityOf(e.to), which unwraps a pluralised
hub (categories/rotary-lobe-pumps → rotary-lobe-pump).
membershipsFromGraph is gone too, for the same reason: it pre-bucketed memberships as axis → one
value, so a many-valued axis silently lost everything after the first. Group edgesOf yourself if you
want a map, and each axis keeps its real cardinality. topicAxesFromGraph retires with it — once nothing
in the package consumed a topic’s axis, keeping a second copy of the engine’s derivation only invited
drift. Need a topic’s axis? Read its apex’s labels off edgesOf(topic, "HUB_OF").
Edge-type knowledge (HAS_APPLICATION, HAS_COLLECTION, …) is domain-specific and
lives in your SSG — which is exactly where it belongs. Each page bundle also carries its
stable node id at _brief.id — use it as the lookup key into getById (it survives
renames; a better cache key than the slug).
import { loadGraph, loadPageBrief } from "@acme/content-types";
const g = loadGraph();
// a listing / index page — enumerate by label
const allProducts = g.byLabel("product"); // every product page
// a detail page — build domain lists by the edge types you know
const id = loadPageBrief("/products/alfa-laval-sru")!.id; // the stable handle
const applications = g.outNodes(id, "HAS_APPLICATION"); // build your own list, by edge type
const collections = g.outNodes(id, "HAS_COLLECTION"); // …and another — no entities.csv, no array passingMigrating off the row reader (loadRegistry → loadGraph)
The row-reconstruction reader (RegistryEntry / loadRegistry* and the slug-keyed helpers) is
removed in the graph cleanup (§6.2) — running gtmesh types regenerates the package without
it. If your SSG still calls it, move each call to its loadGraph() equivalent. The graph is loaded
once and indexed, so these are lookups, not array scans:
| Removed row reader | Graph-native replacement | Before → after |
|---|---|---|
loadRegistry() (enumerate pages) | loadGraph().byLabel("page") / .nodes() | loadRegistry({ root }) → loadGraph({ root }).byLabel("page") |
| a status filter | .byLabel("page") + a node.props.status filter | loadRegistry().filter(r => r.status === "published") → g.byLabel("page").filter(n => n.props.status === "published") |
the role filter (r.role === "hub") | byRole(role) — role is derived from the apex edges, not a stored column | loadRegistry().filter(r => r.role === "hub") → g.byRole("hub") |
entryBySlug(entries, slug) | bySlug(slug) | entryBySlug(loadRegistry(), slug) → g.bySlug(slug) |
childrenBySlug(entries) (the down edge) | hubMembers(g, hubSlug), or membersOf for topical members | childrenBySlug(entries).get(hubSlug) → hubMembers(g, hubSlug) |
pagesByTopic(entries) / allTopics(entries) | membersOf(topicId) / byLabel("topic") | pagesByTopic(entries).get("rotary-lobe-pump") → g.membersOf("topics/rotary-lobe-pump") |
breadcrumbs / hubMembers / relatedLinks (feeds) | same functions, now (g, …) — pass the GraphView instead of the entries array | hubMembers(entries, hubSlug) → hubMembers(g, hubSlug) |
pageSummary / pageSummaries / labelForSlug (cards) | same functions, now (g, …) | pageSummary(entries, slug) → pageSummary(g, slug) |
The RegistryEntry fields that were derived at reconstruction move to the same edge reads — read
them off the graph rather than a row:
RegistryEntry field | Graph-native read |
|---|---|
.clusters (axis → value) | edgesOf(id, "HAS_<AXIS>") — the page’s relationships on that axis, every value, as target node ids. One axis per call, so a many-valued axis reads the same way a single-valued one does. |
.topics (MEMBER_OF targets) | edgesOf(id, "MEMBER_OF") — the same memberships, as topics/<value> node ids |
.entity | entityOf(id) — the apex value for an owner (a pluralised hub slug still resolves to the singular entity), else the node id minus its section prefix |
.role | roleOf(id) — derived from the apex edge (HUB_OF ⇒ hub, PILLAR_OF ⇒ pillar, a hub also MEMBER_OF a topic it doesn’t own ⇒ sub-hub, else spoke) — not a stored prop |
Redirects need no change: loadRedirects() returns the same { from, to } list from the
url/ALIAS_OF graph layer. It is not deprecated.
Entities — the entity table & facets (entities.ts) — deprecated
Deprecated (graph-native migration). These slug-keyed table readers
reconstruct a CSV-shaped array and ask you to scan it (entityBySlug(entries, …)).
They still work and are not removed in this release — but new code should read
the graph directly via loadGraph.
Migration path (every capability has a concrete port):
| Old (table) | New (loadGraph()) |
|---|---|
loadEntities() / loadEntitiesFromGraph() (enumerate) | byLabel("<label>") (e.g. byLabel("product")) or nodes(); read fields off node.props |
entityBySlug(entities, slug) | bySlug(slug) / getById(id) |
entityFacets(entity, axes) | per-axis outNodes(id, "HAS_<AXIS>") (e.g. HAS_INDUSTRY) |
entitiesByFacet(entities, axis, value) | inNodes("<axis>/<value>", "HAS_<AXIS>") (or the owner hub id) |
Entity type | GraphNode (fields on node.props) |
facetValues(cell) | obsolete — edges are discrete, nothing to split |
The helpers will be removed in a future release once meshes have migrated.
These deprecated readers reconstruct a slug-keyed table of entity relationships and their
config-declared facet columns (taxonomy.facets); the same data now lives on entity nodes in the
committed graph, read via loadGraph() above.
| Function | What it does |
|---|---|
loadEntities(opts?) | Read the committed graph into typed Entity[] — reconstructed from folded page nodes + HAS_<AXIS> facet edges, memoised per graph mtime (a thin alias for loadEntitiesFromGraph). There is no entities.csv. |
entityBySlug(entities, key, keyColumn?) | One entity by key (pass ENTITY_KEY if the key column isn’t slug). |
entitiesByFacet(entities, axis, value) | Facet-hub membership — every entity whose axis column contains value. |
entityFacets(entity, facetAxes) | An entity’s facet memberships (pass FACET_AXES). |
Assets — co-located images & documents (assets.ts + site.ts)
Resolve a bare asset filename to a URL, and ship the files. See Assets.
| Export | What it is |
|---|---|
assetUrl(slug, asset, base?) | The served URL for a bare asset — ${base}${slug}/${asset} (base defaults to ASSET_BASE). Used for card thumbnails and body images alike. |
ASSET_BASE | The URL prefix assets serve under, from site.assets.base (default /content). |
pageAssets(slug, opts?) | One page’s co-located assets — { slug, name, path }[]. |
meshAssets(opts?) | Every renderable page’s assets (status-filterable) — the copy inventory. |
copyMeshAssets(dest, opts?) | Mirror content/<slug>/<asset> → dest/<slug>/<asset>; returns the manifest. |
Site — config-derived constants (site.ts)
Projected from gtmesh.config.yaml by gtmesh types, so the SSG never
re-declares engine policy.
| Export | What it is |
|---|---|
SECTIONS | The top-level URL sections, from taxonomy.sections (a readonly tuple). |
sectionLabel(section) | Humanise a section slug for nav (best-for → Best For). |
RENDER_ENVS | The full env → renderable statuses map, from config.environments. |
renderableStatuses(env) | The statuses visible in env — the same policy render-manifest applies. Throws on an unconfigured env. |
siteBaseUrl(env?) | The canonical origin for env (its base_url override, else the committed site.base_url; null if none). |
SITE_BASE_URL / SITE_BASE_URL_BY_ENV | The committed default origin and the per-env overrides, if you need them directly. |
ENTITY_KEY / CLUSTER_AXES / FACET_AXES | The entities key column + cluster/facet axis names, from taxonomy — pass FACET_AXES to entityFacets. |
THUMBNAILS | The card-thumbnail projection config (paths + register ratios), from site.thumbnail + images — pass to the summary builders. |
Types
Generated per-schema interfaces (GuidePage, ProductPage, …), the PageType
union, GraphNode / GraphEdge / GraphView, Entity, RegistryBrief,
Redirect, Status, LoadOptions, PageBundle, Section, and RenderEnv — all
re-exported from the package entry. (RegistryEntry / RegistryRow are removed — §6.2.)
Key files & flags
| Where | What it controls |
|---|---|
gtmesh types | Generate the package |
gtmesh types --check | Verify the committed package matches the schemas (CI gate) |
gtmesh types --dir <path> | Override the package directory |
schemas/ + config.page_types | The source of the generated interfaces and union |