@viingx/content-hub-codegen
v0.1.0-preview.11
Published
Generates a typed Content Hub client from type definitions
Readme
@viingx/content-hub-codegen
Official code generator for the viingx Content Hub: generates a fully-typed, app-local client from your type definitions, on top of @viingx/content-hub-sdk. You get one typed accessor per content type — hub.blogPost.create({ … }) — with autocomplete and compile-time checking of every field, option, and reference. Queries are checked on both sides: the path (only indexed paths compile) and the value (q.eq("content.payload.status", "publish") is a compile error when the options are "draft" | "published" | "archived"). A dynamically-typed value (a plain string variable) against an option-list path needs the literal union — grab it from the generated XxxPathValue map, e.g. XxxPathValue["content.payload.status"].
The whole point: make writing correct Content Hub code fast. Generate once, then let tsc catch mistakes as you (or an AI agent) type. generate also maintains agent docs in your working tree (see below), so AI coding agents learn the non-guessable Hub rules automatically.
The @viingx family: @viingx/content-hub-sdk (the runtime this generates on top of) · this package · @viingx/extension-sdk (build UI extensions for the web client) · @viingx/create-extension (scaffold an extension project).
Preview release (
0.1.0-preview.x). The API may change before1.0.
Install
npm install --save-dev @viingx/content-hub-codegen
npm install @viingx/content-hub-sdk # runtime dep — the generated client imports itcontent-hub-sdk is a peer dependency: the tool itself doesn't need it, but the client it generates does. Run the CLI with npx @viingx/content-hub-codegen … or from an npm script.
The HUB_KEY that pull uses is a build-time credential — you run pull once (and when the schema changes) to snapshot your tenant's types into committed files. The generated client is import type only (erased at build) over the SDK you already use, so it adds no runtime dependency or auth: your app authenticates however it likes — API key, session token, or username/password login — and passes that connection to createHub. Prefer codegen when building against one known tenant: static payload types, autocomplete, and tsc checking are the biggest correctness lever — most of all for AI-written code, where tsc catches a wrong field/option/path as it's written. Fall back to the plain SDK (reading hub.types.list()/info() at runtime, no key) only for a generic multi-tenant client whose schema isn't known at build, when you can't get a type/* read key, or for a throwaway spike — and note codegen is additive, so you can layer it in later either way. Tenant-configured labels always come from runtime info()/localize(), with or without codegen.
Generate
The package ships a CLI (content-hub-codegen):
content-hub-codegen generate # read type definitions → write the client
content-hub-codegen check # exit 1 if the generated client is out of date (for CI)
content-hub-codegen pull # mirror a live Hub's type definitions into typesDir
content-hub-codegen schema pull # snapshot the tenant's GraphQL schema as SDLConfigure it with a content-hub.config.{js,mjs,ts} in your project root (a .ts config needs Node ≥ 22.18, which strips types natively; on older Node use .mjs/.js — it can still import { defineConfig }):
// content-hub.config.mjs
import { defineConfig } from "@viingx/content-hub-codegen";
export default defineConfig({
typesDir: "content-hub/types", // directory of committed TypeDefinition JSON files
output: "src/content-hub.generated.ts",
});…or skip the config and pass flags (they override the config when both are present):
content-hub-codegen generate --types <typesDir> --out <outFile>Generation reads the committed type-definition files and writes the client. Commit the generated file — it's deterministic (same input → identical output), reviewable in PRs, and check keeps it honest in CI.
Wire both into your project so a stale client can't slip through — an npm script pair and a CI step:
// package.json
"scripts": {
"codegen": "content-hub-codegen generate",
"codegen:check": "content-hub-codegen check" // exits 1 if the committed client is out of date
}# CI: fail the build if someone changed the schema but forgot to regenerate
- run: npm run codegen:checkPull from a live Hub
pull bootstraps (or refreshes) the committed type-definition files from a tenant — after that, generation stays offline and deterministic:
HUB_URL=https://<instance>.viingx.io HUB_KEY=<api key> npx @viingx/content-hub-codegen pullIt writes <typesDir>/<name>.json for every type (faithful, pretty-printed), plus _derivativeDefinitions.json with the tenant's derivative definitions (underscore-prefixed files aren't read as type definitions — they feed the DerivativeName union). Existing files are overwritten; files whose type no longer exists on the Hub are warned about, never deleted. Credentials come from --url/--key flags, the HUB_URL/HUB_KEY environment variables, or a gitignored .env in the working directory (HUB_URL=…/HUB_KEY=… lines — the same file the extension templates' deploy uses) — prefer the env variables or .env so keys stay out of shell history and npm scripts.
GraphQL schema snapshot
The Hub's GraphQL schema is generated per tenant, so typed GraphQL starts with a committed snapshot:
npm install -D graphql # optional peer — needed only for `schema pull`
HUB_URL=… HUB_KEY=… npx @viingx/content-hub-codegen schema pull --out schema.graphqlPoint your preferred GraphQL toolchain (@graphql-codegen, gql.tada) at the snapshot for typed queries; this package deliberately doesn't bundle one.
Agent docs (CLAUDE.md / AGENTS.md)
When run with a config file, generate also maintains a marked, managed section in your working-tree CLAUDE.md and AGENTS.md: a pointer to the generated client plus the non-guessable Content Hub rules (origin-only baseUrl, per-write version, source locale on create, server-assigned ids → upsert-by-query, index-commit write latency, workflow-document versions, …). Working-tree agent docs are auto-loaded by AI coding agents — unlike anything inside node_modules — so the rules are read every session, not just when someone thinks to look. Content outside the markers is never touched; updates are idempotent. Opt out with agentDocs: false in the config or --no-agent-docs (flag-only runs default to off).
Use
import { createHub } from "./src/content-hub.generated"; // the `output` path from your config
import { apiKeyConnection } from "@viingx/content-hub-sdk";
// origin (a full /api/v1.0 base works too); the SDK appends /api/v1.0
const hub = createHub(apiKeyConnection("https://hub.example.com", process.env.HUB_API_KEY!), { defaultLocale: "en" });
// `hub.<type>` is a typed collection. Payload, options, and references are all checked:
const id = await hub.blogPost.create({
title: "Hello world",
body: "<p>…</p>",
status: "published", // only the schema's options ("draft" | "published" | "archived") are allowed
author: { type: "person", id: "p1" }, // reference narrowed to its target type
});
import { q } from "@viingx/content-hub-sdk";
const recent = await hub.blogPost.query(
// Property paths are checked against this type's queryable (indexed) paths — a typo is a compile error.
{ where: q.eq("content.payload.status", "published"), sort: [q.desc("content.payload.publishedAt")] },
{ paging: { type: "offset", offset: 0, limit: 20 } },
);
console.log(recent.total);createHub returns everything the base SDK's createContentHub does, plus the typed accessors. Runtime semantics (auth, optimistic-concurrency version, errors, paging) are unchanged — see the SDK README.
What gets generated
For each type, from its definition:
XxxPayload— atypealias of the payload, withlistStringoptions as literal unions,referencenarrowed to{ type: <target>; id: string },group/choiceas nested/discriminated types,multiValue → Array<…>, andoptional → ?. Each field carries JSDoc built from the property'sdescriptionplus a type-specific hint (RFC 3339 format for dates/times, "whole numbers only" for integers, hex format for colors, …) and any validation constraints (value range, length, count) — so the format and limits show up on hover, not just the field's purpose.XxxLocalePayload— the localizable subset (orRecord<string, never>), documented the same way.XxxPath— the valid query property paths for that type, each union member annotated with the field's description and format hint.XxxState/XxxGate— literal unions of the workflow state names / quality-gate names (only when the type defines them). They're bound as the accessor'sS/QGgenerics, soworkflow.updateonly accepts defined states,suggestStatesreturns them, and quality-gate results are keyed by name.DerivativeName— the tenant-wide union of derivative (rendition) names: the system previews (systemPreview,systemHighPreview,systemPdfPreview) plus the tenant's derivative definitions (mirrored bypullinto<typesDir>/_derivativeDefinitions.json). Bound as the file APIs' derivative-name parameter, so a misspelled rendition is a compile error.- a typed accessor on
createHub's result.
Why this is fast (and AI-friendly)
tscis the oracle. Wrong field, bad option, or wrong-typed value is a compile error — the tightest possible write → fix loop, for humans and for Claude Code.- Thin over the SDK. It adds types, not a new API. Anything you know about
@viingx/content-hub-sdkapplies; there's nothing new to learn. - Offline & deterministic. Generation reads committed files — no creds, no network, identical output every run. The generated file is just code you read and diff.
- Don't hand-edit the generated file; re-run
npm run generatewhen the schema changes (its header says as much).
Status
Feature-complete for the preview: the generate/check/pull/schema pull CLI (config file — .js/.mjs/.cjs/.ts — or flags), all the type-narrowing artifacts (…Payload, …Path, the …PathValue query-value map, …State/…Gate — never when the type has no workflow/gates, so a wrong state won't compile — and DerivativeName) bound into the accessors, and the maintained agent docs.
License
Apache-2.0 — © viingx AG
