Skip to Content
DocumentationFeaturesPlatformContent-types package

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 PageType union — the closed set of page types, derived from config.page_types;
  • the graph-native reader — loadGraph() (the indexed GraphView over graph/{nodes.jsonl,edges.tsv}, with derived roleOf/byRole/entityOf/edgesOf/membersOf accessors — edgesOf(id, type?) is a node’s whole relationship set, both layers, mirroring Page.edges in the engine) and loadManifest() (the projected site.manifest.json link tree). This is the reader — the old row-shaped loadRegistry()/RegistryEntry reader is removed (§6.2); see the crosswalk if your SSG still uses it;
  • page-bundle loaders — to load a page’s content/<id>/index.yaml bundle;
  • a redirect reader (loadRedirects()) — reads the url/ALIAS_OF graph layer, returns { from, to } to emit 301s. See Redirects;
  • an entities/facet reader + mesh-feed helpers — loadEntities / entitiesByFacet, and breadcrumbs / hubMembers / relatedLinks + the pageSummaries card index, so navigation is a call, not a re-implemented lib/;
  • named projections — getProjection(name) returns a typed, cached list of schema-shaped cards (label, thumbnail, tags) from a config mapping;
  • co-located asset helpers — assetUrl resolves a bare asset filename to a served URL; copyMeshAssets mirrors 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.

Built or source entry points

codegen.exports in gtmesh.config.yaml sets what the package’s entry points name:

ValueEntry pointsWhat the consumer does
dist (default)the compiled dist/ (JS + .d.ts, gitignored)build the package before dev, build and tests, e.g. a predev/prebuild step running pnpm --filter <name> build
sourcethe .ts filesnothing extra: each consumer compiles the package inside its own build

source fits a monorepo whose consumers are all bundlers or TypeScript-aware tools. Next needs the package in transpilePackages; Vite, tsx and vitest read it as is. Every consumer’s tsconfig must use "moduleResolution": "bundler", which is what the package’s own tsconfig switches to. Use dist when something runs the package under plain Node or resolves with NodeNext.

codegen: exports: source
// next.config.ts export default { transpilePackages: ["@acme/content-types"] };

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

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

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

ExportWhat it is
getProjection(name)Returns a runnable (opts?) => Card[] that projects the selected pages, memoised after first run. Typed per projection name.
PROJECTIONSThe 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.

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

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

MethodWhat 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 ids

A 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 passing

Migrating 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 readerGraph-native replacementBefore → after
loadRegistry() (enumerate pages)loadGraph().byLabel("page") / .nodes()loadRegistry({ root }) → loadGraph({ root }).byLabel("page")
a status filter.byLabel("page") + a node.props.status filterloadRegistry().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 columnloadRegistry().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 memberschildrenBySlug(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 arrayhubMembers(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 fieldGraph-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
.entityentityOf(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
.roleroleOf(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 typeGraphNode (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.

FunctionWhat it does
loadEntities(opts?)Read the committed graph into typed Entity[] — reconstructed from folded page nodes + HAS_<AXIS> facet edges, memoised per store revision (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.

ExportWhat 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_BASEThe 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. Main entry only: it copies the mesh’s files, so pass { root }.

Store — where the loaders read from

Every loader reads the mesh through a mesh store (store.ts, file-store.ts, snapshot.ts). By default that’s the files at root (or $CONTENT_ROOT), exactly as before. Where a site renders without a filesystem, such as an edge worker, build a snapshot of the mesh at build time and serve it from memory. The loaders and queries then run unchanged, and status filtering, roles and links derive the same way.

// build step (Node): write the snapshot the app ships import { writeFileSync } from "node:fs"; import { snapshotMesh, renderableStatuses } from "@acme/content-types"; writeFileSync("src/generated/mesh.json", JSON.stringify(snapshotMesh({ root: meshDir, status: renderableStatuses(env) }))); // app startup (no filesystem): every loader now reads the snapshot import { setMeshStore, memoryMeshStore, type MeshSnapshot } from "@acme/content-types/memory"; import snapshot from "./generated/mesh.json"; setMeshStore(memoryMeshStore(snapshot as MeshSnapshot));
ExportWhat it is
@acme/content-types/memoryA second package entry with the same loaders, queries, types and Gtmesh namespace, minus the file helpers (fileMeshStore, pagePath, manifestPath, copyMeshAssets, contentRoot) and snapshotMesh. Nothing it imports touches node: modules, so a bundler has no filesystem calls to trace. Use it in runtime code that reads a snapshot.
MeshStoreThe interface the loaders read: revision(), nodes(), edges(), manifest(), page(slug), organization(), assets(slug). Synchronous. Implement it to read the mesh from your own storage.
fileMeshStore({ root? })The mesh on disk. It’s the default store, and its revision is the graph and manifest mtimes.
snapshotMesh({ root?, status? })The mesh as one deterministic, JSON-serializable MeshSnapshot: every graph node and edge, the manifest, the organization, page bundles and asset filenames. status keeps only those pages’ bundles and assets; the graph stays whole. revision is a sha256 of the content.
memoryMeshStore(snapshot)Serves a snapshot. Each page read is a fresh copy. Throws on a snapshot in another format; rebuild it with snapshotMesh.
setMeshStore(store)The store every loader reads when a call names neither store nor root. setMeshStore(undefined) returns to the file store.
{ store } (any loader option)Read one call from a specific store.

The store a call reads is opts.store, then the files at opts.root when the call names one (or at an explicit opts.path, for the manifest, organization and redirect readers), then the store set with setMeshStore, then the files at $CONTENT_ROOT (or the cwd). The /memory entry has no files to read, so a loader called there before setMeshStore, or with { root } or { path }, throws an error that says what to change. Call setMeshStore before any loader runs, and leave root and path out of runtime calls.

A snapshot built with status holds only those pages’ bundles, so query it with that same status (the renderable set you render anyway). A query without it still sees every graph node, but the pruned pages have no bundle to read. Each loader caches its result per store and refreshes when the store’s revision() changes. copyMeshAssets copies files, so run it in the build step with { root }; asset bytes ship with the site’s static files. For the Cloudflare recipe, see Deploying on Cloudflare Workers.

Site — config-derived constants (site.ts)

Projected from gtmesh.config.yaml by gtmesh types, so the SSG never re-declares engine policy.

ExportWhat it is
SECTIONSThe top-level URL sections, from taxonomy.sections (a readonly tuple).
SiteSectionOne of SECTIONS: the union of the section slugs ("products" | "guides" | …). Use it to type a variable that holds a section, so a typo fails to compile.
sectionLabel(section)Humanise a section slug for nav (best-for → Best For).
RENDER_ENVSThe 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_ENVThe committed default origin and the per-env overrides, if you need them directly.
ENTITY_KEY / CLUSTER_AXES / FACET_AXESThe entities key column + cluster/facet axis names, from taxonomy — pass FACET_AXES to entityFacets.
THUMBNAILSThe 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, MeshStore, MeshSnapshot, PageBundle, SiteSection, and RenderEnv — all re-exported from the package entry. (RegistryEntry / RegistryRow are removed — §6.2.)

Type names: yours take priority

Your schemas’ definitions become types in the same package entry as the engine’s own exports, so a definition can share a name with one of them: a shared section definition generates Section, and one named status generates Status. When that happens:

  • your type takes the plain name. import type { Status } from "@acme/content-types" gives the type from your schema;

  • the engine’s stays reachable under the Gtmesh namespace. Every engine export is also available as Gtmesh.<Name>, whatever your schemas define:

    import type { Status, Gtmesh } from "@acme/content-types"; const mine: Status = "open"; // from your schema const page: Gtmesh.Status = "published"; // the engine's page status
  • gtmesh types warns once per shared name, naming the import that now resolves to your type.

A schema enum takes the name as a value too, so Status.Red works. A definition’s name can’t break the package build. The one reserved name is Gtmesh itself: gtmesh types stops and asks you to rename a definition that would generate it.

Because a new definition can take a name your code already imports, import engine types through the namespace (Gtmesh.Status, Gtmesh.GraphNode) in code that should keep meaning the engine’s type whatever your schemas add later.

Section is the earlier name of SiteSection. It stays available as a deprecated alias of SiteSection until the next major release, or until your schemas define a Section of their own, whichever comes first. Import SiteSection in new code.

Key files & flags

WhereWhat it controls
gtmesh typesGenerate the package
gtmesh types --checkVerify the committed package matches the schemas (CI gate)
gtmesh types --dir <path>Override the package directory
codegen.dir (config)Where the package is written, relative to the mesh (default packages/content-types)
codegen.name (config)The package name (default @<project>/content-types)
codegen.exports (config)dist (default) or source: what the entry points name — see built or source entry points
schemas/ + config.page_typesThe source of the generated interfaces and union
  • types — the command in full
  • Lifecycle — how pages reach the committed graph the package reads
Last updated on