Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
18174ba
feat(cli): add display-name folder path map for generated types
maoberlehner Jul 28, 2026
1563949
fix(cli): strengthen cyclic parent chain test with concrete assertions
maoberlehner Jul 28, 2026
9781619
refactor(cli): extract shared wire-to-DSL field mapping and generic h…
maoberlehner Jul 28, 2026
f8c3281
feat(cli): serialize components into block definition type literals
maoberlehner Jul 28, 2026
20a53f3
fix(cli): remove as cast from serialize, use type predicate instead
maoberlehner Jul 28, 2026
64cf3fa
feat(cli): resolve field-plugin declaration module for generated types
maoberlehner Jul 28, 2026
3c955b7
feat(cli): render schema-derived type file
maoberlehner Jul 28, 2026
c39e2fd
feat(cli): support --separate-files for schema-derived types
maoberlehner Jul 28, 2026
032395d
feat(cli): add types generate --future-schema flag
maoberlehner Jul 28, 2026
df01bc5
fix(cli): use getUI, absolute outputDir, and legacy field-plugins war…
maoberlehner Jul 28, 2026
791d2d0
test(cli): assert generated schema types behave like hand-written ones
maoberlehner Jul 28, 2026
faaaade
fix(cli): drop dead InferSchema import, sharpen AnyBlock assertion
maoberlehner Jul 28, 2026
a96e3cd
test(cli): add end-to-end integration test for --future-schema, docum…
maoberlehner Jul 28, 2026
624db87
docs(cli): document both silent field-plugins degradation cases
maoberlehner Jul 28, 2026
76ee60a
fix(cli): thread FieldPlugins into emitted Story/StoryMapi, reject --…
maoberlehner Jul 28, 2026
2aafe0d
refactor(cli): extract the --future-schema branch and trim its docs
maoberlehner Jul 28, 2026
147b286
fix(cli): stop narrowing block fields whose restriction is disabled
maoberlehner Jul 29, 2026
d035b2e
fix(cli): sort emitted blocks by name and drop unknown allow entries
maoberlehner Jul 29, 2026
7016672
fix(cli): do not reject legacy flags a config file set, warn on --fil…
maoberlehner Jul 29, 2026
7737b82
refactor(cli): share the jiti bootstrap and the schema path constants
maoberlehner Jul 29, 2026
9fa5063
refactor(cli): dedupe the folder path walker and unify the render con…
maoberlehner Jul 29, 2026
8144463
fix(cli): guard generated identifiers against a leading digit
maoberlehner Jul 29, 2026
b0608d4
docs(cli): document the --future-schema flags and output layout
maoberlehner Jul 29, 2026
03e6444
docs(cli): drop the --future-schema output layout from the command re…
maoberlehner Jul 29, 2026
d2f549a
fix(cli): make generated types resolve under nodenext and unify --fil…
maoberlehner Jul 29, 2026
de79fce
fix(cli): resolve --separate-files types under nodenext and prune sta…
maoberlehner Jul 29, 2026
1c23cea
fix(cli): scope the --filename caveat to the legacy generator's own file
maoberlehner Jul 29, 2026
78e2c70
fix(cli): give each legacy-only flag its own rejection reason
maoberlehner Jul 29, 2026
5676fbf
fix(cli): tell a missing field-plugins module from an unusable one
maoberlehner Jul 29, 2026
c4d4c78
fix(schema): drop valueless layout fields from content types
maoberlehner Jul 29, 2026
c619d83
docs(schema): note that defineFieldPlugin reads types from the validator
maoberlehner Jul 29, 2026
9c14e41
chore(cli): format branch sources with oxfmt
maoberlehner Aug 11, 2026
151c405
chore(openapi): regenerate the template-derived consumer types
maoberlehner Aug 11, 2026
7b334ce
fix(cli): repair rebase fallout in schema init and types generate tests
maoberlehner Aug 11, 2026
93d7812
fix(cli): assert the legacy-flag error through main's ui.error signature
maoberlehner Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,8 @@ tools/openapi-codegen/specs/
**/.next/
**/.astro/
**/.cache/

# The generated-types fixtures must stay byte-identical to what renderSchemaTypes
# emits; the drift test compares them. Formatting them would break it.
packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts
packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types-with-plugins.d.ts
66 changes: 66 additions & 0 deletions adr/0012-schema-derived-type-generation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# ADR-0012: Schema-Derived Type Generation for the CLI

**Status:** Accepted **Date:** 2026-07-28

## Context

`storyblok types generate` built types with `json-schema-to-typescript` from pulled component JSON.
It ignored field `required` flags, so every field came out looser than it actually was. It ignored
`bloks` field `component_group_whitelist`s, so nested block types were never narrowed to the blocks
a field actually allows. It ignored the nestable versus root distinction, so root-only and
block-only components typed the same way. It also required a prior `components pull` with matching
flags, an extra step that could silently drift from what was actually in the space.

`@storyblok/schema` already models all of this correctly at the type level, but only for users who
define their schema in code with `defineBlock`, `defineField`, and friends. Users managing
components in the Storyblok UI had no way to get those types without hand-writing them.

## Decision

`types generate --future-schema` fetches the space's components and component groups directly from
the Management API and emits block definition type literals, plus the public surface a hand-written
`schema.ts` would export: `Blocks`, `Schema`, `FieldPlugins`, `Block<TName>`, `AnyBlock`, `Story`,
and `StoryMapi`. Content shapes are resolved by TypeScript in the user's own project through
`@storyblok/schema`'s `BlockContent`, the same type that resolves them for code-defined schemas.

Every field to value rule stays in `@storyblok/schema`. The CLI duplicates none of it, so the two
paths cannot drift apart. The emitted file imports `@storyblok/schema`, so it must be installed as a
types-only dev dependency. `Block<'hero'>` is the user-facing surface; the definition types and
`Blocks` union are plumbing for `withTypes<Schema>()` and for `Block` itself. No per-block content
aliases are emitted, matching the pattern already established for code-defined schemas. The legacy
generator is deprecated with a runtime warning, not removed, so existing pipelines keep working
until users migrate.

## Alternatives Considered

- **Generate flattened content interfaces with the TypeScript compiler API** (write a temporary
`schema init` style module, then resolve types with TypeScript's `unstable/sync` API and walk the
resolved properties). Prototyped and rejected for four reasons. Self-referencing blocks collapsed
to `any` under every `NodeBuilderFlags` combination tried, so correct output would still need a
hand-written property walk rather than a type-printer call. A `Prettify` step destroyed
`aliasSymbol`, so named types such as `AssetFieldValue` printed as their raw inlined structure
instead of by name, and recovering the name required structural assignability matching against
every known field-value type. The property walk itself re-implemented the field to value mapping
rules in JavaScript, which would then need to track `field.ts` forever as a second copy. And the
approach added `typescript` as a CLI runtime dependency, a platform-specific native binary, plus a
subprocess per run and a temporary workspace written into the user's project. Its only advantage
was an emitted file that does not import `@storyblok/schema`. Since `withTypes<Schema>()` forces
the definition types into the file regardless, and those same types make `Block<TName>` a one-line
alias, the compiler route would have produced a second, drift-prone representation of types the
file already expresses.
- **Reimplement the field to value mapping in the CLI** to emit fully self-contained interfaces
without a compiler or a `@storyblok/schema` import. Rejected for the same duplication reason as
above, with no compensating benefit: it trades one import for a second implementation of rules
that must stay in lockstep with `field.ts`.

## Consequences

- Types generated from a space's live schema are as accurate as types written by hand with
`defineBlock`, because both paths resolve through the same `BlockContent` logic in
`@storyblok/schema`.
- Consumers of `--future-schema` must add `@storyblok/schema` as a dev dependency. It is a
types-only import and is never included in application bundles.
- The generated file is generated code and should be excluded from the user's linter and formatter,
the same way any other codegen output is.
- The legacy generator remains available and unchanged, so no existing workflow breaks, but it now
prints a deprecation warning pointing at `--future-schema`.
67 changes: 34 additions & 33 deletions packages/capi-client/src/generated/types/block.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
// Generated by @storyblok/openapi-codegen. Do not edit by hand.
// Source template lives in tools/openapi-codegen/templates/.

import type {
Component as ComponentGenerated,
Field,
} from './_sources';
import type { Override } from './_utils';
import type { Component as ComponentGenerated, Field } from "./_sources";
import type { Override } from "./_utils";

/**
* Ordered array of named fields — the content-shape DSL form `defineBlock`
Expand All @@ -23,42 +20,46 @@ export type Block<
TFields extends BlockFields = BlockFields,
TIsRoot extends boolean = boolean,
TIsNestable extends boolean = boolean,
> = Override<Omit<ComponentGenerated, 'schema' | 'component_group_uuid'>, {
name: TName;
fields: TFields;
is_root?: TIsRoot;
is_nestable?: TIsNestable;
/**
* Escape hatch for pinning this block to a Storyblok UI-managed component
* group by UUID. Component groups are normally maintained in code via the
* schema directory layout; set this only if you intentionally manage groups
* in the Storyblok UI, and fill in the group UUID yourself. When set,
* `schema push` diffs it and sends it to the Management API; when omitted,
* the block's remote group is left untouched.
*
* @deprecated Prefer maintaining component groups in code through the
* directory layout.
*/
component_group_uuid?: string | null;
/**
* Folder membership as a display name path (e.g. `'Layout/Heros'`); `null` =
* explicitly ungrouped (push clears the group). Absent = unmanaged (push
* leaves the remote group untouched).
*/
folder?: string | null;
}>;
> = Override<
Omit<ComponentGenerated, "schema" | "component_group_uuid">,
{
name: TName;
fields: TFields;
is_root?: TIsRoot;
is_nestable?: TIsNestable;
/**
* Escape hatch for pinning this block to a Storyblok UI-managed component
* group by UUID. Component groups are normally maintained in code via the
* schema directory layout; set this only if you intentionally manage groups
* in the Storyblok UI, and fill in the group UUID yourself. When set,
* `schema push` diffs it and sends it to the Management API; when omitted,
* the block's remote group is left untouched.
*
* @deprecated Prefer maintaining component groups in code through the
* directory layout.
*/
component_group_uuid?: string | null;
/**
* Folder membership as a display name path (e.g. `'Layout/Heros'`); `null` =
* explicitly ungrouped (push clears the group). Absent = unmanaged (push
* leaves the remote group untouched).
*/
folder?: string | null;
}
>;

/**
* A root {@link Block} (`is_root: true`). Given a union of blocks, narrows to
* its root members; with no argument it is the generic root-block type.
*/
export type RootBlock<T extends Block = Block & { is_root: true }> =
Extract<T, { is_root: true }>;
export type RootBlock<T extends Block = Block & { is_root: true }> = Extract<T, { is_root: true }>;

/**
* A nestable {@link Block} (`is_nestable: true`). Given a union of blocks,
* narrows to its nestable members; with no argument it is the generic
* nestable-block type.
*/
export type NestableBlock<T extends Block = Block & { is_nestable: true }> =
Extract<T, { is_nestable: true }>;
export type NestableBlock<T extends Block = Block & { is_nestable: true }> = Extract<
T,
{ is_nestable: true }
>;
Loading
Loading