Follow

kickstartDS schema

JSON Schema in, types and CMS configuration out — the toolchain behind storyblok configuration

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.


FactDetail
InputJSON Schema (dereferenced, layered)
OutputsTypeScript, Storyblok CMS config, GraphQL, SSG
StackTypeScript
StartedMay 2023

What's in it

Layer order matters

CMS, schema and project layers merge in a defined order — the same mechanism this site uses for its page content type.

Used by every downstream

Content types, prop types and the CMS config on this site all come out of this tool.

In detail

What is actually inside it

One schema, many outputs

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.

Layers with a defined order

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.

Dereference once, consume everywhere

allOf/$ref schemas are collapsed before generation, so consumers never have to reimplement composition.

DetailValue
Repository typeRush monorepo (build cache, policies)
OutputsTypeScript, Storyblok CMS config, GraphQL, SSG
StartedMay 2023

Technical notes

The decisions behind it

Why Rush instead of pnpm here?
The repository is a set of small tools with independent release cadences and a shared build cache. Rush's policies fit that; there is no ideological statement in it, just the cheapest way to keep a dozen packages consistent.
What does the Copilot instructions file contain?
The repository's conventions — where generated files live, how layers are ordered, what must not be hand-edited — which is the same document a new contributor needs.

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.

9
published packages
Seven converters plus the utils foundation and the cambria lens engine.
8
process functions
The entire surface a converter implements to target a new CMS.
5
pipeline steps
Layer, graph, pre-process, AJV registration, composition resolution — fixed order.
19
Rush projects
Nine tools and ten examples in one workspace, all published.
PackageOutput
@kickstartds/jsonschema-utilsFoundation: AJV registry, five-step pipeline, layering, reducer, lens generator
@kickstartds/jsonschema2storyblokStoryblok blok configuration — tabs, sections, nested bloks
@kickstartds/jsonschema2typesTypeScript prop interfaces, one per component
@kickstartds/jsonschema2stackbitStackbit Object / Data / Page models
@kickstartds/jsonschema2staticcmsStatic CMS / Decap widget YAML
@kickstartds/jsonschema2hygraphHygraph management-SDK field types
@kickstartds/jsonschema2uniformUniform component parameters and slots
@kickstartds/jsonschema2cportCPort internal normalised fields
@kickstartds/cambriaCambria 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/.

OrderLayerUsually carries
1 (wins)languagePer-locale overrides
2visibilityEditor-visibility deletions, generated from content patterns
3cmsx-cms-* annotations, previews, grouping
4schemaProject components (schema.mydesignsystem.com)
5kickstartdsUpstream 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.