feat(cli): generate schema-derived types with types generate --future-schema - #719
Draft
maoberlehner wants to merge 35 commits into
Draft
feat(cli): generate schema-derived types with types generate --future-schema#719maoberlehner wants to merge 35 commits into
maoberlehner wants to merge 35 commits into
Conversation
maoberlehner
commented
Jul 28, 2026
maoberlehner
commented
Jul 28, 2026
maoberlehner
commented
Jul 28, 2026
@storyblok/angular
@storyblok/astro
@storyblok/api-client
storyblok
@storyblok/experiments
@storyblok/js
storyblok-js-client
@storyblok/lint-config
@storyblok/live-preview
@storyblok/management-api-client
@storyblok/migrations
@storyblok/nuxt
@storyblok/react
@storyblok/region-helper
@storyblok/richtext
@storyblok/schema
@storyblok/svelte
@storyblok/vue
commit: |
Contributor
|
maoberlehner
force-pushed
the
feat/DX-525-types-future-schema
branch
from
August 11, 2026 10:06
d3a591f to
0d0b513
Compare
Implements serializeBlockDefinition and serializeField to convert MAPI components into TypeScript type literals for block definitions. Widens id/created_at/updated_at (not type-relevant) and keeps literal types for name/is_root/is_nestable/folder/fields (read by type machinery). Collects custom field_type values for unmapped plugin warnings. Fixes DX-525
Replace 'as Record<string, Record<string, unknown>>' with a named type predicate function isFieldRecordMap to avoid type casts and clarify intent. Preserves runtime behavior and test coverage. Also narrow test title to match what is asserted (tab fields only). Fixes DX-525
Implement the single-file render module that assembles component definition type literals into a complete .d.ts file with shared surface types (Block, Blocks, Schema, FieldPlugins, etc.). All emitted names are resolved through buildNames to ensure prefix/suffix renaming is consistent everywhere.
Add renderSeparateFiles() to generate separate .d.ts files per block definition plus a main file holding the shared surface. Block definitions never reference each other, so no cross-imports between block files are needed. Import from componentFileName and resolveFileNames utilities to kebab-case names and dedupe collisions. Reuse renderFieldPlugins and renderSurface from renderSchemaTypes. Fixes DX-525
…ning for types generate Address review follow-ups on the --future-schema branch: migrate its user-facing output to getUI() per packages/cli/CLAUDE.md (raw konsola/Spinner calls are only allowed in unmigrated command code), compute outputDir with resolvePath() so it matches the "absolute directory" contract documented on generateSchemaTypes, warn when --field-plugins is passed without --future-schema instead of silently ignoring it, and import saveToFile directly from utils/filesystem instead of widening the utils barrel (avoids a second import route to the same function, which the barrel change made a mocking trap). Also adds a command-level test for the --future-schema success path (install hint, per-file output, unmapped-field-type warning), mocking getUI() and schema-types the way src/lib/config/helpers.test.ts does.
Adds a type-level test proving definition types plus @storyblok/schema's BlockContent reproduce hand-written schema types: name-keyed Block<T> resolution, required/optional fields, allow-list narrowing, recursive blocks, and is_nestable exclusion from bloks unions. A drift test (toMatchFileSnapshot) keeps the committed fixture in sync with what renderSchemaTypes actually emits, so the type-level assertions never run against stale output. Adds @storyblok/schema as a devDependency of packages/cli (the fixture imports it; the CLI itself never does) and scopes vitest's typecheck block to *.test-d.ts so it does not run tsc over the whole suite. The fixture is excluded from eslint --fix, which would otherwise reformat it out of sync with the renderer's literal output.
renderSchemaTypes/renderSeparateFiles unconditionally imported
Schema as InferSchema from @storyblok/schema even when no field-plugins
declaration used it, leaving a dead import in every --field-plugins-less
generation. Import it only when the field-plugins branch needs it.
Also fixes the AnyBlock type-level test: toHaveProperty('component')
passes for any single-block type too, so it didn't prove "accepts any
block". Assert the full component discriminant union instead. Drops a
structurally vacuous not.toEqualTypeOf('page') check superseded by the
exact-union assertion on the same line, and retitles/tightens the tab-field
test to assert the exact resulting type.
…ent it, and record the ADR Proves the group-to-folder and whitelist-to-allow resolution end to end against a mocked MAPI, since no unit test exercises the join between fetched component folders and fetched components. Documents --future-schema in the types generate README, moves the legacy generator's docs under a deprecated heading, and adds adr/0012-schema-derived-type-generation.md recording the decision and the rejected TypeScript-compiler alternative. Fixes DX-525
The convention path (.storyblok/schema/schema.ts) degrades silently to no custom field types both when the module is missing and when it exists but exports neither accepted shape, e.g. a typo'd export name. The README previously only described the missing-module case, which could send a user hunting for the wrong problem. Fixes DX-525
…suffix under --future-schema Final review pass on the types generate --future-schema feature: - render.ts emitted `Story = InferStory<Blocks>` and `StoryMapi = InferStoryMapi<Blocks>`, both missing the `TFieldPlugins` second argument that `Block<TName>` already threads. Registered custom fields silently fell back to an untyped value through Story/StoryMapi. - Add a field-plugins fixture (hand-rolled Standard Schema, no new dependency) and a type-level assertion that only passes when FieldPlugins reaches Story, verified empirically to fail when the fix is reverted. - Assert the real withTypes<Schema>() constraint (Blocks extends Block), not just its shape. - --suffix is legacy-only and meaningless under --future-schema; reject it like the other legacy flags instead of silently ignoring it. - README: document --path as a supported --future-schema option. - Move the legacy deprecation warning after the command banner. - Record the MAPI per-space name-uniqueness assumption behind definitionByComponent. - eslint.config.mjs: ignore the new plugins fixture, matching the existing byte-identical-fixture exemption. Fixes DX-525
Move the --future-schema action body into future-schema.ts so the command action stays a thin branch, shorten the option description, and keep the README to updates of what was already there. The full --future-schema documentation moves to the docs platform. Fixes DX-525
A field can carry a whitelist with restrict_components set to false. The app treats that as unrestricted, so narrowing the emitted type rejects content the editor accepts. Storyblok strips stale name lists when the flag is false but never strips component_group_whitelist, so the group case is a live, persistable state. Emit allow only when the restriction is active, and only for bloks and richtext, the two field types whose whitelist names blocks. On a multilink the same key holds content type names, so emitting it put a misleading list in a file users read. toDslField gets the same gate, which also fixes schema init: mapping an inert whitelist to allow made the next push re-derive restrict_components: true and switch a disabled restriction back on. Fixes DX-525
MAPI does not promise a stable component order, and the generated file is committed, so an upstream reordering showed up as a diff with no semantic change. Sort by name, comparing code units rather than using localeCompare, whose result depends on the machine's locale. An allow entry naming a component that no longer exists matches nothing through ApplyAllow's Extract, and a list of only such names resolves the field to never[], rejecting every value. Storyblok's cleanup job is eventual, skips non-nestable components, and never runs for schemas imported through the API, so stale names do arrive here. Fixes DX-525
…ename A project whose config sets `strict` for the legacy generator could not use --future-schema at all: assertNoLegacyFlags only checked whether a value was present, and applyConfigToCommander hydrates config values as if they had been typed. Commander records the source, so config-sourced flags are now reported as ignored instead of erroring, and a flag the user actually typed still fails. Both generators write <path>/types/<space>/<filename>.d.ts, so an explicit --filename makes them overwrite each other. Warn rather than refuse: the collision only matters if both are run. Fixes DX-525
Two call sites constructed jiti identically. Extract importModule, kept thin: the callers disagree on error wrapping and path resolution, so folding either in would impose one caller's behaviour on the other. The .storyblok/schema literal appeared in the schema init default, the field-plugins lookup, and two help strings. Centralize it, and derive the convention path from --path so field-plugin discovery uses the same base as the generated output. Its comment claimed it matched what schema init writes, which was not quite true: schema init has its own --out-dir that also ignores --path, so the two agreed only by coincidence. Also name a near-miss export in the --field-plugins error, since the failure mode is a misnamed export rather than a missing one. Fixes DX-525
…ditions The display-path walker was a copy of the slugified one; parameterize the segment transform instead. Loosen the cycle test, which pinned an iteration-order artifact rather than behaviour, and replace its `as never` fixtures with the typed helper already in the file. renderSchemaImport and renderFieldPlugins decided independently whether the file uses a user field-plugins module, so a disagreement would emit an unused InferSchema import. Derive both from one predicate. Fixes DX-525
Storyblok accepts component names like `2_col`, whose PascalCase form is not a valid identifier. An emitted `export type 2ColBlockDefinition` is a syntax error that takes the whole declaration file with it, not just its own line, so every type in the file dies. Extract toSafeIdentifier and apply it to the finished base name, after any fixed suffix: resolveVarNames' numeric disambiguation cannot reintroduce a leading digit, but toPascalCase can. Fixes DX-525
Adds --future-schema, --field-plugins, and --type-suffix to the options table, clarifies --filename and --suffix, and describes what each generator writes, including the --separate-files layout. Fixes DX-525
…adme The docs site page is the reference for both generators' output, so describing the --future-schema file layout here duplicates it and gives it a second place to drift from the CLI. Remove the section and the now-redundant "Legacy generator" heading, which only existed to separate the two. Fixes DX-525
…ename Three defects a manual QA pass against a real space surfaced. The emitted import of the field-plugins module had its extension stripped, which is TS2835 under `moduleResolution: node16`/`nodenext` in an ESM package. The file is generated code the user is told not to edit, so they had no way to repair it. Map the extension instead (`.ts` to `.js`, `.mts` to `.mjs`, `.cts` to `.cjs`): that form resolves under every mode, verified across all combinations of the four resolution modes and both package types, so it is strictly wider than the extension-less form. `--filename` is documented as taking a base name, but the documented default spells out `.d.ts`, so passing that value produced `my-types.d.ts.d.ts`. Extract toDeclarationFileName and share it with the legacy generator, so the same flag cannot mean two things depending on --future-schema. The unmapped-field-type warning hardcoded the default convention path. That is wrong under `--path`, which moves it, and redundant once a module has been loaded. Carry the searched path on the `none` source and name either the module in use or the path this run actually looked at. Fixes DX-525
…le blocks Two defects a second manual QA pass against a real space surfaced. 498d1e6 fixed the extension-less import specifier in the wrong place. It corrected `toRelativeImport`, which only ever serves the field-plugins module path, while the block imports under `--separate-files` built their specifier inline and never went through it. So the surface file still emitted `from './blocks/hero'`, which is TS2834 under `moduleResolution: node16`/`node18`/`nodenext` in an ESM package — every modern Node-ESM project. Nothing caught it: both fixtures render single-file output, so `emitted-types.test-d.ts` never typechecked a separate-files file, and two tests asserted the broken specifier outright. Add toDeclarationImportSpecifier beside toDeclarationFileName, so the written file name and the specifier that has to match it cannot drift apart. It cannot reuse toRelativeImport, which maps a trailing `.ts` and would turn `blocks/hero.d.ts` into `blocks/hero.d.js`. Guard the invariant over the emitted text rather than per call site: assert that no relative specifier the renderer emits lacks a JavaScript extension, in either mode, so a future inline call site fails too. `blocks/` was also never reconciled. A component deleted in the UI left its type file behind, still importable and describing a block that no longer exists, and switching back to single-file output orphaned the whole directory. Delete the block declarations a run did not write, and report the count rather than removing files silently. Scoped to `*.d.ts` directly inside `blocks/`: the output directory is shared with the legacy generator, so pruning it wholesale would take `storyblok-components.d.ts` with it. Fixes DX-525
The warning fired for any --filename under --future-schema while asserting the value "is also where the legacy generator writes". For --filename my-types or --separate-files --filename shared that statement is false, so the command told users their own path clashed with a file nothing writes. Compare the resolved name against the legacy default instead, and move that default out of actions.ts into constants.ts so both generators read one source rather than repeating the literal. Fixes DX-525
All four rejections shared one rationale covering field optionality, custom fields and the JSON-schema compiler. None of it applies to --suffix, so someone who passed that flag got an explanation of something they had not asked about, while the actual reason stayed in a code comment. Carry the reason alongside each flag and compose the message from it. Single- and multi-flag cases are phrased separately so neither reads awkwardly. Fixes DX-525
`FieldPluginsSource` collapsed "no file at the convention path" and "file there, wrong export name" into one `none` case, so the unmapped-field-type warning told users to place a module at a path where one already sat. That is the case `schema init` produces: it writes a `schema` export with no `fieldPlugins` key, so anyone following the docs hits it first and reads the advice as the command failing to see their file. Carry a `reason` on the `none` variant and run `findNearMissExport` for the convention path too, not only explicit ones. The warning now names the export to rename when a module is there, and only suggests creating one when nothing is. Fixes DX-525
`tab` and `section` group other fields in the editor UI and carry no value of their own, so no API response has a key for them. Their `FieldTypeValueMap` entries are already `never`, but the field mapping kept the key, surfacing them as `key?: null` — offering a property nothing can fill on every block that uses a tab. Skip fields whose resolved value is `never`, on the read and write mappings both. Fixed in the codegen template rather than the CLI's serializer so hand-written `defineSchema` blocks benefit too. The tuple wrapping in `HasNoValue` is required: a bare `extends never` distributes over the naked type parameter and never matches. A `custom` field with no registered plugin resolves to `PluginFieldValue`, and a `bloks` field with an empty registry to `never[]`, so neither is dropped; both are covered.
A hand-written validator passed as `value` satisfies the constraint and registers its `fieldType`, so nothing reports it as unmapped, while the field still resolves to the untyped fallback — the value type comes from `~standard.types`, not from what `validate` returns. Reads as the declaration having no effect, so say where the type is read from.
Also excludes the generated-types fixtures from formatting: the drift test compares them byte-for-byte against renderSchemaTypes' own output.
The branch changes tools/openapi-codegen/templates/field.ts (HasNoValue drops valueless layout fields from content types), so every consumer's copy of the templates has to be regenerated together, not just the one this branch works on. The diff is larger than that one change: main's repo-wide oxfmt pass reformatted the templates but never regenerated the consumers, whose committed output .prettierignore excludes from formatting. Regenerating therefore also brings packages/*/src/generated/types back in sync with the formatted templates. There is no semantic change in that part of the diff, only quoting and line wrapping. The spec-derived output (packages/cli and packages/richtext src/generated/overlay/types.gen.ts) stays at main's committed content on purpose. Regenerating it flips @hey-api's emitted interfaces to type aliases, because main's committed copy predates the current generator version, and packages/richtext/src/static/generate/richtext-element-types.ts only recognizes interface declarations. It would silently emit an empty StoryblokRichTextElementByType and break @storyblok/angular:build. That is a pre-existing landmine on main and needs its own fix.
Restores what the rebase onto main dropped and adapts the branch's tests to main's UI module: - generateSchemaFile emits `FieldPlugins`, `Block<Name>`, and `AnyBlock` again, and imports `BlockContent` only when the space has components. The branch extracted this file's helpers into ../to-dsl-field and ../utils; resolving that conflict in favour of the branch also reverted main's additions here. - The types generate tests assert on the mocked UI instead of `konsola`, which main replaced with `getUI()`, and the mock now exposes `error` so handleError can route CommandErrors through it.
maoberlehner
force-pushed
the
feat/DX-525-types-future-schema
branch
from
August 11, 2026 10:45
0d0b513 to
93d7812
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
storyblok types generate --future-schema, which generates accurate TypeScript types for a UI-managed Storyblok space, and deprecates the legacy generator.Why
The legacy generator builds types with
json-schema-to-typescriptfrom pulled component JSON, and gets them wrong in ways that matter: field optionality does not followrequired,bloksfields are not narrowed bycomponent_whitelist, the nestable and root distinction is ignored, and custom field types fall back to a loose parser hook. It also requires a priorcomponents pullwith matching flags, which is a persistent footgun.@storyblok/schemaalready models all of this correctly at the type level, but only for people who define their schema in code. This flag gives UI-managed spaces the same types without adopting a code-driven schema workflow.Approach
The command fetches the space's components and emits block definition type literals plus the public surface of a hand-written
schema.ts. TypeScript then resolves content shapes in the user's own project throughBlockContent, so the CLI duplicates none of the field-to-value rules and cannot drift from them.Block<'hero'>is the user-facing surface, matching the helper dogfooded in the Astro playground in 2852ad1. The definition types are plumbing forwithTypes<Schema>(),BlockContent, and the story aliases.Emitted exports:
Block<TName>,AnyBlock,Schema,Blocks,FieldPlugins,Story,StoryMapi, and one<Name>BlockDefinitionper block.--type-prefixand--type-suffixapply to every one of them.adr/0012-schema-derived-type-generation.mdrecords the decision and, importantly, the rejected alternative: resolving flattened content interfaces with the TypeScript compiler API. That was prototyped and rejected because self-referencing blocks collapse toanyunder everyNodeBuilderFlagscombination,PrettifydestroysaliasSymbolso named types inline as raw structure, the property walk would duplicate the field-to-value rules in JS, and it would addtypescriptas a CLI runtime dependency with a platform-specific native binary. The ADR exists so nobody retries it.Deprecation
Running
types generatewithout--future-schemanow prints a deprecation warning naming the replacement and the concrete defects. Nothing is removed and no timeline is committed. The legacy path is otherwise byte-identical, and by default the new output goes to a separate file (storyblok-schema.d.ts), so the two generators do not collide unless--filenameforces them to, which warns.--strict,--custom-fields-parser,--compiler-options, and--suffixare legacy-only and error when combined with--future-schema, rather than being silently ignored. A value that came from a config file rather than the command line is reported as ignored instead, so a project that configuresstrictfor the legacy generator can still use this flag.Custom field types
Custom fields need their
field_typebound to a validator withdefineFieldPlugin. The CLI looks forschema/schema.tsunder the CLI base path (--path, default.storyblok) by convention, or an explicit--field-plugins <path>, and accepts either aschemaexport (adefineSchemaresult) or a barefieldPluginsrecord. Unregisteredfield_types fall back to an untyped value and are reported as a warning.Documentation
The user-facing documentation lives in a docs PR, storyblok/storyblok-docs-platform#597, not in this repo. The package README only gains the deprecation callout and the new flags in its existing options table.
Notes for the reviewer
One unrelated lockfile line. Adding
@storyblok/schemaas apackages/clidevDependency requiredpnpm install, which also droppedasync-sema 3.1.1from the@storyblok/management-api-clientsnapshot. That is stale drift from ff3642d: mapi-client no longer declares that dependency, onlypackages/clidoes. It is a correction, not an accidental removal, and splitting it out would just mean the nextpnpm installregenerates it.Land fix(schema): close type gaps surfaced by the kitchen sink demo #718 first, and resolve one overlap by hand. The branches overlap in
packages/cli/src/commands/schema/init/generate-code.tsandpnpm-lock.yaml. Both fix the sameschema initdefect — an inert whitelist besiderestrict_components: falsewas mapped toallow, so the nextschema pushswitched a disabled restriction back on — but fix(schema): close type gaps surfaced by the kitchen sink demo #718 fixes it in the privatetoDslFieldinsidegenerate-code.tswhile this branch fixes it into-dsl-field.ts, the shared module it extracted that function into. Taking fix(schema): close type gaps surfaced by the kitchen sink demo #718's hunk would reintroduce the bug; dropping it is correct because the extracted module already carries the gate.to-dsl-field.test.tscovers both whitelist forms, so a wrong resolution fails the tests.Defects found and fixed during review.
allowemission, all three of which produce types that reject content the editor accepts:restrict_componentsisfalse. The group-whitelist form of that state is never normalized by the backend, so it is live rather than theoretical.multilink, where the same wire key holds content type names rather than block names. The gate isbloksplusrichtext— a richtext whitelist genuinely does restrict blocks.ApplyAllowis anExtract, so a list of only stale names resolved the field tonever[].Messages that told the user something untrue:
--filenamecollision warning fired for every--filename, asserting a clash with a file nothing writes. It now compares against the legacy default.field_typewarning told users to place a module where one already sat — the caseschema initproduces, since it writes aschemaexport with nofieldPluginskey. Missing and unusable modules are now distinguished, and the warning names the export to rename.--suffix. Each now carries its own reason.Also fixed:
tabandsectionfields surfaced askey?: nullon content types. They carry no value, so those keys are dropped — in the codegen template rather than this command's serializer, so hand-writtendefineSchemablocks benefit too.--path; the--field-pluginserror names a near-miss export, since the real failure mode is a misnamed export rather than a missing file; and the shared jiti bootstrap and the.storyblok/schemaliteral are each defined once.Fixes DX-525