Every component in the design system describes itself with JSON Schema — props, types, descriptions, nesting. Those schemas are the source of truth; everything else is derived.
This package turns them into the formats each consumer needs: TypeScript types, Storyblok component configuration, GraphQL schemas and static-site-generator inputs. It is what keeps a schema change from turning into hand-edited drift in five places.
| Fact | Detail |
|---|---|
| Input | JSON Schema (dereferenced, layered) |
| Outputs | TypeScript, Storyblok CMS config, GraphQL, SSG |
| Stack | TypeScript |
| Started | May 2023 |
What's in it
CMS, schema and project layers merge in a defined order — the same mechanism this site uses for its page content type.
Content types, prop types and the CMS config on this site all come out of this tool.
In detail
What is actually inside it
The same JSON Schema becomes TypeScript types, Storyblok component configuration, GraphQL schemas and static-site-generator inputs — the CMS configuration this very site uses is generated, not written.
CMS layer, schema layer, project layer: each may extend the one below, and the merge order is explicit, which is what makes "project overrides CMS overrides schema" predictable.
allOf/$ref schemas are collapsed before generation, so consumers never have to reimplement composition.
| Detail | Value |
|---|---|
| Repository type | Rush monorepo (build cache, policies) |
| Outputs | TypeScript, Storyblok CMS config, GraphQL, SSG |
| Started | May 2023 |
Technical notes
The decisions behind it
Why Rush instead of pnpm here?
What does the Copilot instructions file contain?
Nine converters, one contract
What the packages are, and the one interface they all implement
Every package under tools/ is published under @kickstartds/, and seven of the nine are converters that implement the same contract. @kickstartds/jsonschema-utils owns that contract: a converter calls getSchemaReducer() and supplies eight process functions — processObject, processRef, processRefArray, processObjectArray, processArray, processEnum, processConst, processBasic. The reducer walks the schema, dispatches on each node, and accumulates a single IReducerResult<Component, Template, Global>. All a converter decides is what one field looks like in its own target format.
Everything the converters would otherwise copy lives centrally: field order (x-cms-order, sorted in reduce.ts before fields reach any process function), display names (x-cms-label → title → toPascalCase(key)), component de-duplication (componentsEqual), and the type discriminator. addTypeInterfaces() moves an existing type property to typeProp and writes type: { const: ... } filled with the schema's own name, so anyOf variants stay distinguishable while the discriminator itself can stay out of the editor. Project knowledge stays in the project: each example passes a schemaClassifier switch mapping component names to Component, Template, Global or Object, and the converter's configuration() concatenates the three buckets into one components array.
What feeds all of this is processSchemas(), and its five steps have to run in this order: pre-process (inject type interfaces, inline $refs, apply the additionalProperties policy) → register every schema in AJV → reduce allOf, merge anyOf enums, split schema-local anyOf into new schemas with their own $id → post-process those generated schemas, optionally deleting x-cms-hidden fields → return the list of $ids. The return value is strings, not schemas: converters read the real schemas back out of AJV with getSchemasForIds(). AJV is the interchange medium, which is why a schema without $id fails hard instead of warning.
This site's own monorepo consumes the toolchain directly — its website package depends on @kickstartds/jsonschema-utils, jsonschema2storyblok, jsonschema2types and @kickstartds/cambria.
| Package | Output |
|---|---|
| @kickstartds/jsonschema-utils | Foundation: AJV registry, five-step pipeline, layering, reducer, lens generator |
| @kickstartds/jsonschema2storyblok | Storyblok blok configuration — tabs, sections, nested bloks |
| @kickstartds/jsonschema2types | TypeScript prop interfaces, one per component |
| @kickstartds/jsonschema2stackbit | Stackbit Object / Data / Page models |
| @kickstartds/jsonschema2staticcms | Static CMS / Decap widget YAML |
| @kickstartds/jsonschema2hygraph | Hygraph management-SDK field types |
| @kickstartds/jsonschema2uniform | Uniform component parameters and slots |
| @kickstartds/jsonschema2cport | CPort internal normalised fields |
| @kickstartds/cambria | Cambria lens engine for shape migration — not a converter |
| import/toolkit (legacy) | GraphQL, Sanity, TinaCMS, Builder.io, Gatsby — Lerna + Yarn predecessor, outside Rush |
Layers are hosts, not folders
Merge order, what it buys, and where it stops
A layer is not a directory — it is a URL host. getLayerName() returns kickstartds for anything under schema.kickstartds.com and the first host segment otherwise, so cms.mydesignsystem.com/hero.schema.json, visibility.mydesignsystem.com/hero.schema.json and schema.mydesignsystem.com/hero.schema.json are three views of one component. shouldLayer() merges two schemas only when their file names match, their hosts differ, and the target sits later in layerOrder; earlier entries win. The library default is ['cms', 'schema', 'kickstartds'], while the production examples run five: ['language', 'visibility', 'cms', 'schema', 'kickstartds'].
A layered schema has to be a root allOf with exactly one http $ref pointing at the layer below. getTopLayerSchemas() throws on zero or multiple refs, on a layer name that is missing from layerOrder, and on a layer that tries to extend itself. layerRefs() then rewrites references inside other schemas so they resolve to the layered $id — that is how the base button-group picks up the project's button. The known gap is commented in the source: the rewrite cannot follow a $ref that sits inside an overriding allOf part, because composition has not been resolved at that point yet.
The visibility layer is the counter-intuitive one. x-cms-hidden fields are not flagged in the generated config — with hideCmsFields: true they are deleted from the schema before conversion, so hidden fields never reach the CMS at all. To convert only a project's own components, getCustomSchemaIds() drops every $id under schema.kickstartds.com/.
| Order | Layer | Usually carries |
|---|---|---|
| 1 (wins) | language | Per-locale overrides |
| 2 | visibility | Editor-visibility deletions, generated from content patterns |
| 3 | cms | x-cms-* annotations, previews, grouping |
| 4 | schema | Project components (schema.mydesignsystem.com) |
| 5 | kickstartds | Upstream base components (schema.kickstartds.com) |
Sharp edges
The load-bearing parts that are not obvious from the file tree
How is $ref dereferencing implemented — and why is there a fork?
Conversion does not need a dereferencer: inlineReferences() runs in the pipeline's pre-processing step, so by the time a converter reads a schema out of AJV it contains no $refs. dereference(schemaIds, ajv) is a separate public utility for consumers that need one self-contained document per component. It builds a $RefParser whose only resolver reads from the AJV registry — canRead matches ^http.*.(?:schema|definitions|interface).json$ with order: 1 — so nothing is read from disk and every reference resolves against schemas that already passed the pipeline.
The parser is not the official package: the repo depends on @bcherny/json-schema-ref-parser@10.0.5-fork and ships a pnpm patch that rewrites its declaration from export = $RefParser to export default $RefParser, which is what makes the ESM default import in deref.ts typecheck. The second patch under common/pnpm-patches/ is unrelated to refs: to-json-schema@0.2.5 gets instanceof Array replaced by Array.isArray. The dereference and layering examples use dereference() to write one standalone schema file per component.
Why does the Storyblok output have both tabs and sections?
Storyblok offers exactly two grouping primitives, and the project splits them by intent. tab carries explicit author grouping (x-cms-group-name); section carries incidental schema nesting — any object property that is not itself a component. Tab keys are deterministic (tab-{groupName}) so consecutive runs diff cleanly; section keys are section-{uuid} because anonymous nested objects can repeat at the same level. The split inverted an earlier behaviour where every nested object became a tab, and it was accepted as a breaking editor-UI change.
Positions are not decided where fields are built. createBlokSchema() flattens nested objectFields into a single record, and only then does assignPositions() write pos — which is why every process function can hardcode pos: 0, and why a field's position is only meaningful after flattening. Component icons come from a hand-maintained id map with a block-wallet fallback, and nested blok fields are emitted with restrict_components: true plus the owning component in component_whitelist, so a slot can only hold the blok it declares.
What does x-cms-group-inline do to the data?
It is the only x-cms-* annotation that mutates keys; everything else is editor-view metadata. An inline wrapper object is removed, its children are hoisted one level and prefixed with the wrapper name plus __ (the converter first builds a single-underscore prefix for the section and upgrades it to a double underscore during the hoist). The CMS payload then no longer matches the component's own schema shape, so generateLens() in jsonschema-utils — not in cambria — emits a Cambria lens: hoist + rename per child, then remove for the wrapper.
Renderers load that lens, call reverseLens() and apply it to incoming payloads to recover the nested shape; every op is invertible, so the round-trip is exact. A component without an inline wrapper yields an empty lens and no file at all, and the generator throws Inline child '…' of '…' collides with existing sibling '…' if the prefixed name would shadow an existing sibling. Lenses are emitted per component name rather than per layer-prefixed $id, so five active layers still produce one file per component.
What is actually switched on in the Rush setup?
Nineteen projects — nine tools and ten examples — on Rush 5.97.1 and pnpm 7.32.2, pinned to Node >=18.15.0 <19.0.0, with ensureConsistentVersions on and allowedProjectTags limited to tools and examples. Two things people assume are present are not: the build cache is configured but off (buildCacheEnabled: false, cacheProvider: "local-only"), so CI has no shared cache; and version-policies.json is entirely commented out, so packages version individually rather than in lockstep.
projectFolderMinDepth / MaxDepth: 2 is what keeps import/toolkit/* — the private Lerna + Yarn predecessor, never listed in rush.json — out of the workspace. A --production flag is declared for build and rebuild and forces a full rebuild; CI uses it: rush change --verify, then rush install, then rush rebuild --verbose --production. Version-bump commits are restricted to @kickstartds.com addresses by gitPolicy, and the pre-commit hook is a Rush global command running pretty-quick --staged from the rush-prettier autoinstaller.
What makes the generated configs churn?
created_at and updated_at are stamped with new Date().toISOString() inside the Storyblok converter, so a rebuild rewrites every blok in the output even when nothing changed. Otherwise the output is meant to be deterministic — tab keys are tab-{groupName} and positions come from a final assignPositions() pass. The committed snapshot in examples/storyblok/resources/config.json is a useful check on that: it holds 31 bloks, 13 tabs and no sections, and its tabs still use uuid keys, so it predates the tab/section reallocation and is not the shape today's code emits.
jsonschema2storyblok also carries its own TODO list where the mapping is still lossy — wrong ids (all 0), block types that end up unset after import, split-off blok fields that do not encode max/min, and no hidden attribute to keep the injected type discriminator out of the editor.
Links
Where to find this one
