From 18174babf2c7de45be0ccbb6cfdaae64a2795a2f Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 28 Jul 2026 15:54:48 +0200 Subject: [PATCH 01/35] feat(cli): add display-name folder path map for generated types Fixes DX-525 --- .../cli/src/commands/schema/folders.test.ts | 39 ++++++++++++++++++- packages/cli/src/commands/schema/folders.ts | 33 ++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/schema/folders.test.ts b/packages/cli/src/commands/schema/folders.test.ts index 2f30496f1..88209974b 100644 --- a/packages/cli/src/commands/schema/folders.test.ts +++ b/packages/cli/src/commands/schema/folders.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import type { ComponentFolder } from "../../types"; -import { buildGroupPathByUuid, expandFolderPath, slugifyPath } from "./folders"; +import { + buildGroupDisplayPathByUuid, + buildGroupPathByUuid, + expandFolderPath, + slugifyPath, +} from "./folders"; function folder( partial: Partial & { name: string; uuid: string }, @@ -96,3 +101,35 @@ describe("expandFolderPath", () => { ]); }); }); + +describe("buildGroupDisplayPathByUuid", () => { + it("joins parent display names with slashes, preserving original casing", () => { + const folders = [ + { uuid: "a", name: "My Layout", parent_uuid: null }, + { uuid: "b", name: "Heros", parent_uuid: "a" }, + ]; + + const result = buildGroupDisplayPathByUuid(folders as never); + + expect(result.get("a")).toBe("My Layout"); + expect(result.get("b")).toBe("My Layout/Heros"); + }); + + it("treats a cyclic parent chain as a root instead of recursing forever", () => { + const folders = [ + { uuid: "a", name: "A", parent_uuid: "b" }, + { uuid: "b", name: "B", parent_uuid: "a" }, + ]; + + const result = buildGroupDisplayPathByUuid(folders as never); + + expect(result.get("a")).toBeDefined(); + expect(result.get("b")).toBeDefined(); + }); + + it("ignores a parent uuid that is not in the folder list", () => { + const folders = [{ uuid: "a", name: "Orphan", parent_uuid: "missing" }]; + + expect(buildGroupDisplayPathByUuid(folders as never).get("a")).toBe("Orphan"); + }); +}); diff --git a/packages/cli/src/commands/schema/folders.ts b/packages/cli/src/commands/schema/folders.ts index 92dbe570d..5b058479f 100644 --- a/packages/cli/src/commands/schema/folders.ts +++ b/packages/cli/src/commands/schema/folders.ts @@ -52,6 +52,39 @@ export function buildGroupPathByUuid(folders: ComponentFolder[]): Map { + const byUuid = new Map(folders.map(folder => [folder.uuid, folder])); + const segmentsByUuid = new Map(); + + function pathFor(uuid: string | null, visited: Set): string[] { + if (!uuid) { return []; } + const cached = segmentsByUuid.get(uuid); + if (cached) { return cached; } + const folder = byUuid.get(uuid); + if (!folder) { return []; } + if (visited.has(uuid)) { return []; } + visited.add(uuid); + const path = [...pathFor(folder.parent_uuid, visited), folder.name]; + segmentsByUuid.set(uuid, path); + return path; + } + + for (const folder of folders) { pathFor(folder.uuid, new Set()); } + return new Map([...segmentsByUuid].map(([uuid, segments]) => [uuid, segments.join('/')])); +} + /** * Slugifies each `/` segment of a display path: `'My Layout/Heros'` → * `'my-layout/heros'`. Segments are dropped when they slugify to empty, so From 15639496fbdc06bda806e0b1d3493e42ff06d574 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 28 Jul 2026 16:00:49 +0200 Subject: [PATCH 02/35] fix(cli): strengthen cyclic parent chain test with concrete assertions --- packages/cli/src/commands/schema/folders.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/schema/folders.test.ts b/packages/cli/src/commands/schema/folders.test.ts index 88209974b..e996fe158 100644 --- a/packages/cli/src/commands/schema/folders.test.ts +++ b/packages/cli/src/commands/schema/folders.test.ts @@ -123,8 +123,11 @@ describe("buildGroupDisplayPathByUuid", () => { const result = buildGroupDisplayPathByUuid(folders as never); - expect(result.get("a")).toBeDefined(); - expect(result.get("b")).toBeDefined(); + // When pathFor('a') recurses into pathFor('b'), which recurses back into + // pathFor('a'), the cycle is detected and cut (visited.has('a') is true), + // returning []. This bubbles up to set b=['B'], then a=['B', 'A']. + expect(result.get("a")).toBe("B/A"); + expect(result.get("b")).toBe("B"); }); it("ignores a parent uuid that is not in the folder list", () => { From 978161977c7428860b4328f5a54ee20a0a8b6971 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 28 Jul 2026 16:07:42 +0200 Subject: [PATCH 03/35] refactor(cli): extract shared wire-to-DSL field mapping and generic helpers --- .../src/commands/schema/init/generate-code.ts | 231 ++---------------- .../src/commands/schema/to-dsl-field.test.ts | 68 ++++++ .../cli/src/commands/schema/to-dsl-field.ts | 86 +++++++ packages/cli/src/commands/schema/utils.ts | 76 ++++++ 4 files changed, 255 insertions(+), 206 deletions(-) create mode 100644 packages/cli/src/commands/schema/to-dsl-field.test.ts create mode 100644 packages/cli/src/commands/schema/to-dsl-field.ts diff --git a/packages/cli/src/commands/schema/init/generate-code.ts b/packages/cli/src/commands/schema/init/generate-code.ts index 0e8de2933..27dcbc43e 100644 --- a/packages/cli/src/commands/schema/init/generate-code.ts +++ b/packages/cli/src/commands/schema/init/generate-code.ts @@ -1,17 +1,25 @@ import type { Component, ComponentFolder, Datasource } from "../../../types"; import { slugify } from "../../../utils/format"; import { buildGroupPathByUuid } from "../folders"; +import { resolveGroupWhitelistEntries, toDslField } from "../to-dsl-field"; import { COMPONENT_STRIP_KEYS, + componentFileName, DATASOURCE_STRIP_KEYS, formatValue, INDENT, isRecord, quoteString, RawCode, + resolveFileNames, + resolveVarNames, + sortSchemaByPos, stripKeys, + toKebabCase, } from "../utils"; +export { componentFileName, resolveFileNames, resolveVarNames } from "../utils"; + /** Fields to strip from individual schema field entries (`pos` is implicit in array order). */ const FIELD_STRIP_KEYS = new Set(["id", "pos"]); @@ -31,21 +39,6 @@ function toCamelCaseIdentifier(str: string): string { return /^\d/.test(camel) ? `_${camel}` : camel; } -/** - * Converts a string to kebab-case, keeping only filesystem/shell-safe - * characters. Handles snake_case, camelCase, PascalCase, and space-separated - * words; any remaining non-`[a-z0-9-]` characters collapse to a single `-`. - */ -function toKebabCase(str: string): string { - return str - .replace(/[\s_]+/g, "-") - .replace(/([a-z])([A-Z])/g, "$1-$2") - .toLowerCase() - .replace(/[^a-z0-9-]+/g, "-") - .replace(/-{2,}/g, "-") - .replace(/^-+|-+$/g, ""); -} - /** Returns the variable name for a component. e.g. `'teaser_list'` -> `'teaserListBlock'` */ export function componentVarName(name: string): string { return `${toCamelCaseIdentifier(name)}Block`; @@ -61,66 +54,6 @@ export function folderVarName(name: string): string { return `${toCamelCaseIdentifier(name)}Folder`; } -/** - * Resolves an ordered list of raw names to unique variable names. Names that - * sanitize to the same identifier get a numeric suffix (`…2`, `…3`), so the - * generated `export const`s and schema-object keys never collide. Index-aligned - * to `rawNames`. - */ -export function resolveVarNames( - rawNames: string[], - baseVarName: (name: string) => string, -): string[] { - const used = new Set(); - return rawNames.map((raw) => { - const base = baseVarName(raw); - let candidate = base; - let n = 2; - while (used.has(candidate)) { - candidate = `${base}${n++}`; - } - used.add(candidate); - return candidate; - }); -} - -/** - * Resolves an ordered list of already-sanitized base file names to unique ones. - * `toKebabCase` is lossy (it collapses `_`/`-` runs and strips symbols), so two - * distinct source names can produce the same file name even though the raw names - * are unique. Collisions get a `-2`, `-3`, … suffix so generated files never - * overwrite each other and each `schema.ts` import resolves unambiguously. - * - * `dirKeys` scopes uniqueness per directory: blocks live in their group - * subdirectory, so two blocks with the same file name in *different* group - * directories don't collide on disk and must keep their shared name. Pass the - * containing directory (e.g. the joined group path) per index; omit for a flat - * layout (datasources). Index-aligned to `baseNames`. - */ -export function resolveFileNames(baseNames: string[], dirKeys?: string[]): string[] { - const usedByDir = new Map>(); - return baseNames.map((base, i) => { - const dir = dirKeys?.[i] ?? ""; - let used = usedByDir.get(dir); - if (!used) { - used = new Set(); - usedByDir.set(dir, used); - } - let candidate = base; - let n = 2; - while (used.has(candidate)) { - candidate = `${base}-${n++}`; - } - used.add(candidate); - return candidate; - }); -} - -/** Returns the file name (without extension) for a component. e.g. `'teaser_list'` -> `'teaser-list'` */ -export function componentFileName(name: string): string { - return toKebabCase(name); -} - /** Returns the file name (without extension) for a datasource, using slug if available. */ export function datasourceFileName( datasource: Pick & { slug?: string }, @@ -221,98 +154,20 @@ export function resolveFolders(folders: ComponentFolder[]): ResolvedFolder[] { } /** - * Resolves a field's `component_group_whitelist` uuids to `defineFolder` ref - * identifiers when every uuid maps to a known folder var, returning the ordered - * {@link RawCode} refs. Returns `undefined` when there is nothing to resolve or - * any uuid is unknown, so the caller keeps the raw wire form (still round-trips - * via the diff's uuid↔path translation) rather than emitting a broken ref. + * Builds the `schema init` group-whitelist resolver: a group uuid becomes the + * bare `defineFolder` variable name its `folders.ts` declares, emitted verbatim + * via {@link RawCode}. */ -function resolveGroupWhitelistRefs( - whitelist: unknown, +function rawCodeGroupResolver( folderVarByUuid?: Map, -): RawCode[] | undefined { - if (!folderVarByUuid || !Array.isArray(whitelist) || whitelist.length === 0) { - return undefined; - } - const vars = whitelist.map((uuid) => - typeof uuid === "string" ? folderVarByUuid.get(uuid) : undefined, - ); - if (!vars.every((v): v is string => typeof v === "string")) { +): ((uuid: string) => RawCode | undefined) | undefined { + if (!folderVarByUuid) { return undefined; } - return vars.map((v) => new RawCode(v)); -} - -/** - * Reverse of the push-time DSL→wire field mapping: renames the wire reference - * keys back to their DSL form (`component_whitelist`→`allow`, - * `component_group_whitelist`→`allow` with folder refs, `datasource_slug`→`datasource`). - * The `source` selector is left untouched. - * - * `restrict_components: true` and `restrict_type` are dropped alongside a - * resolved `allow` — they're the wire byproduct `defineField`'s `allow` - * re-derives on push, not independent DSL state. A group whitelist that cannot - * be fully resolved to folder refs keeps its raw wire form. - * - * A field restricted to a component *group* carries both a `component_group_whitelist` - * and an empty `component_whitelist: []` on the wire; the group whitelist takes - * precedence, so `allow` is only sourced from `component_whitelist` when it holds - * actual block names — otherwise the resolved folder refs win. - * - * `restrict_components: false` disables the restriction while the space may still - * store a stale whitelist. Emitting that inactive list as `allow` would make - * `schema push` re-derive `restrict_components: true` and silently switch the - * restriction back on, changing what editors may insert. So a disabled - * restriction keeps its flag and drops the whitelist: the flag round-trips - * losslessly, at the cost of discarding a list that is not in force anyway. An - * absent `restrict_components` counts as active, matching backend enforcement. - */ -function toDslField( - field: Record, - folderVarByUuid?: Map, -): Record { - const { - component_whitelist, - component_group_whitelist, - datasource_slug, - restrict_components, - restrict_type, - ...rest - } = field; - const out: Record = { ...rest }; - const restrictionDisabled = restrict_components === false; - const groupRefs = restrictionDisabled - ? undefined - : resolveGroupWhitelistRefs(component_group_whitelist, folderVarByUuid); - const hasBlockNames = - !restrictionDisabled && Array.isArray(component_whitelist) && component_whitelist.length > 0; - if (restrictionDisabled) { - out.restrict_components = false; - if (restrict_type !== undefined) { - out.restrict_type = restrict_type; - } - } else if (hasBlockNames) { - out.allow = component_whitelist; - } else if (groupRefs) { - out.allow = groupRefs; - } else if (component_group_whitelist !== undefined) { - // A group whitelist we could not resolve to folder refs: keep the raw wire - // form (whitelist + restrict flags) so it still round-trips on push. - out.component_group_whitelist = component_group_whitelist; - if (restrict_components !== undefined) { - out.restrict_components = restrict_components; - } - if (restrict_type !== undefined) { - out.restrict_type = restrict_type; - } - } - // Otherwise there is no allow list (name whitelist absent or empty, no groups); - // `restrict_components`/`restrict_type` are byproducts `allow` re-derives on - // push, so they are dropped rather than emitted as orphaned DSL state. - if (datasource_slug !== undefined) { - out.datasource = datasource_slug; - } - return out; + return (uuid: string) => { + const varName = folderVarByUuid.get(uuid); + return varName === undefined ? undefined : new RawCode(varName); + }; } /** @@ -343,7 +198,7 @@ function generateFieldCode( folderVarByUuid?: Map, ): string { const clean = omitEmptyArrays( - toDslField(stripKeys(fieldData, FIELD_STRIP_KEYS), folderVarByUuid), + toDslField(stripKeys(fieldData, FIELD_STRIP_KEYS), rawCodeGroupResolver(folderVarByUuid)), ); return `defineField(${quoteString(fieldName)}, ${formatValue(clean, depth)})`; } @@ -357,6 +212,7 @@ function collectWhitelistFolderVars( schema: Record>, folderVarByUuid?: Map, ): string[] { + const resolver = rawCodeGroupResolver(folderVarByUuid); const vars = new Set(); for (const field of Object.values(schema)) { if (!isRecord(field)) { @@ -367,7 +223,7 @@ function collectWhitelistFolderVars( if (field.restrict_components === false) { continue; } - const refs = resolveGroupWhitelistRefs(field.component_group_whitelist, folderVarByUuid); + const refs = resolveGroupWhitelistEntries(field.component_group_whitelist, resolver); if (refs) { refs.forEach((ref) => vars.add(ref.code)); } @@ -375,19 +231,6 @@ function collectWhitelistFolderVars( return [...vars].sort(); } -/** Sorts schema fields by `pos` for stable ordering. */ -function sortSchemaByPos( - schema: Record>, -): [string, Record][] { - return Object.entries(schema) - .filter(([key]) => key !== "_uid" && key !== "component") - .sort(([, a], [, b]) => { - const posA = typeof a.pos === "number" ? a.pos : Infinity; - const posB = typeof b.pos === "number" ? b.pos : Infinity; - return posA - posB; - }); -} - /** Generates `folders.ts`: one `defineFolder` const per remote group, parents first. */ export function generateFoldersFile(resolved: ResolvedFolder[]): string { const varByUuid = new Map(resolved.map((r) => [r.folder.uuid, r.varName])); @@ -580,13 +423,7 @@ export function generateSchemaFile( lines.push( "import type { Schema as InferSchema, Story as InferStory } from '@storyblok/schema';", ); - // `BlockContent` only backs the block helpers below, which a space with no - // components does not get. Importing it regardless trips `noUnusedLocals`. - lines.push( - components.length > 0 - ? "import type { BlockContent, MapiStory as InferStoryMapi } from '@storyblok/schema';" - : "import type { MapiStory as InferStoryMapi } from '@storyblok/schema';", - ); + lines.push("import type { MapiStory as InferStoryMapi } from '@storyblok/schema';"); lines.push(""); // Import blocks from their (slugified) group subdirectory — local @@ -638,29 +475,11 @@ export function generateSchemaFile( lines.push("});"); lines.push(""); - // Schema and Blocks types derived via Schema helper. `FieldPlugins` is - // threaded through the story types so registering a field plugin later is a - // one-line change; with none registered it resolves to an empty map and costs - // nothing. + // Schema and Blocks types derived via Schema helper lines.push("export type Schema = InferSchema;"); lines.push("export type Blocks = Schema['blocks'];"); - lines.push("export type FieldPlugins = Schema['fieldPlugins'];"); - lines.push("export type Story = InferStory;"); - lines.push("export type StoryMapi = InferStoryMapi;"); - - if (components.length > 0) { - lines.push(""); - lines.push('// Type a component\'s props by block name: `Block<"hero">`.'); - lines.push("export type Block = BlockContent<"); - lines.push(" Extract,"); - lines.push(" Blocks,"); - lines.push(" FieldPlugins"); - lines.push(">;"); - lines.push(""); - lines.push("// Loose union of every block's content, for a dynamic component dispatcher."); - lines.push("export type AnyBlock = BlockContent;"); - } - + lines.push("export type Story = InferStory;"); + lines.push("export type StoryMapi = InferStoryMapi;"); lines.push(""); return lines.join("\n"); diff --git a/packages/cli/src/commands/schema/to-dsl-field.test.ts b/packages/cli/src/commands/schema/to-dsl-field.test.ts new file mode 100644 index 000000000..d6cb6d716 --- /dev/null +++ b/packages/cli/src/commands/schema/to-dsl-field.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveGroupWhitelistEntries, toDslField } from './to-dsl-field'; + +describe('toDslField', () => { + it('maps component_whitelist to allow', () => { + expect(toDslField({ type: 'bloks', component_whitelist: ['hero'] })).toEqual({ + type: 'bloks', + allow: ['hero'], + }); + }); + + it('maps a group whitelist through the caller-supplied resolver', () => { + const result = toDslField( + { type: 'bloks', component_group_whitelist: ['uuid-1'], restrict_components: true, restrict_type: 'groups' }, + uuid => (uuid === 'uuid-1' ? { folder: 'Layout' } : undefined), + ); + + expect(result).toEqual({ type: 'bloks', allow: [{ folder: 'Layout' }] }); + }); + + it('prefers block names over a group whitelist when both are present', () => { + const result = toDslField( + { type: 'bloks', component_whitelist: ['hero'], component_group_whitelist: ['uuid-1'] }, + () => ({ folder: 'Layout' }), + ); + + expect(result.allow).toEqual(['hero']); + }); + + it('keeps the raw wire form when a group uuid cannot be resolved', () => { + const result = toDslField( + { type: 'bloks', component_group_whitelist: ['unknown'], restrict_type: 'groups' }, + () => undefined, + ); + + expect(result).toEqual({ + type: 'bloks', + component_group_whitelist: ['unknown'], + restrict_type: 'groups', + }); + }); + + it('maps datasource_slug to datasource', () => { + expect(toDslField({ type: 'option', datasource_slug: 'colors' })).toEqual({ + type: 'option', + datasource: 'colors', + }); + }); +}); + +describe('resolveGroupWhitelistEntries', () => { + it('returns undefined when any uuid is unresolvable', () => { + expect(resolveGroupWhitelistEntries(['a', 'b'], u => (u === 'a' ? 'A' : undefined))).toBeUndefined(); + }); + + it('returns undefined for an empty whitelist', () => { + expect(resolveGroupWhitelistEntries([], () => 'x')).toBeUndefined(); + }); + + it('returns undefined when no resolver is supplied', () => { + expect(resolveGroupWhitelistEntries(['a'])).toBeUndefined(); + }); + + it('maps every uuid through the resolver', () => { + expect(resolveGroupWhitelistEntries(['a', 'b'], u => u.toUpperCase())).toEqual(['A', 'B']); + }); +}); diff --git a/packages/cli/src/commands/schema/to-dsl-field.ts b/packages/cli/src/commands/schema/to-dsl-field.ts new file mode 100644 index 000000000..ca08cf436 --- /dev/null +++ b/packages/cli/src/commands/schema/to-dsl-field.ts @@ -0,0 +1,86 @@ +/** + * Reverse of the push-time DSL→wire field mapping, shared by `schema init` + * (which emits `defineField` code) and `types generate --future-schema` (which + * emits type literals). The two need the same *semantics* but different `allow` + * entry shapes for a group whitelist, a `defineFolder` variable ref versus a + * folder display path, so the group resolution is supplied by the caller. + */ + +/** + * Resolves a field's `component_group_whitelist` uuids through `resolveEntry`. + * All-or-nothing: returns `undefined` when there is nothing to resolve or any + * uuid is unknown, so the caller keeps the raw wire form (which still + * round-trips via the diff's uuid↔path translation) rather than emitting a + * broken reference. + */ +export function resolveGroupWhitelistEntries( + whitelist: unknown, + resolveEntry?: (uuid: string) => T | undefined, +): T[] | undefined { + if (!resolveEntry || !Array.isArray(whitelist) || whitelist.length === 0) { return undefined; } + const entries = whitelist.map(uuid => (typeof uuid === 'string' ? resolveEntry(uuid) : undefined)); + if (!entries.every((entry): entry is T => entry !== undefined)) { return undefined; } + return entries; +} + +/** + * Renames the wire reference keys back to their DSL form + * (`component_whitelist`→`allow`, `component_group_whitelist`→`allow` with + * caller-resolved entries, `datasource_slug`→`datasource`). The `source` + * selector is left untouched. + * + * `restrict_components: true` and `restrict_type` are dropped alongside a + * resolved `allow`, they're the wire byproduct `defineField`'s `allow` + * re-derives on push, not independent DSL state. A group whitelist that cannot + * be fully resolved keeps its raw wire form. + * + * A field restricted to a component *group* carries both a + * `component_group_whitelist` and an empty `component_whitelist: []` on the + * wire; the group whitelist takes precedence, so `allow` is only sourced from + * `component_whitelist` when it holds actual block names. + * + * `restrict_components: false` disables the restriction while the space may still + * store a stale whitelist. Emitting that inactive list as `allow` would make + * `schema push` re-derive `restrict_components: true` and silently switch the + * restriction back on, changing what editors may insert. So a disabled + * restriction keeps its flag and drops the whitelist: the flag round-trips + * losslessly, at the cost of discarding a list that is not in force anyway. An + * absent `restrict_components` counts as active, matching backend enforcement. + */ +export function toDslField( + field: Record, + resolveGroupEntry?: (uuid: string) => T | undefined, +): Record { + const { + component_whitelist, + component_group_whitelist, + datasource_slug, + restrict_components, + restrict_type, + ...rest + } = field; + const out: Record = { ...rest }; + const restrictionDisabled = restrict_components === false; + const groupEntries = restrictionDisabled + ? undefined + : resolveGroupWhitelistEntries(component_group_whitelist, resolveGroupEntry); + const hasBlockNames = !restrictionDisabled + && Array.isArray(component_whitelist) && component_whitelist.length > 0; + if (restrictionDisabled) { + out.restrict_components = false; + if (restrict_type !== undefined) { out.restrict_type = restrict_type; } + } + else if (hasBlockNames) { + out.allow = component_whitelist; + } + else if (groupEntries) { + out.allow = groupEntries; + } + else if (component_group_whitelist !== undefined) { + out.component_group_whitelist = component_group_whitelist; + if (restrict_components !== undefined) { out.restrict_components = restrict_components; } + if (restrict_type !== undefined) { out.restrict_type = restrict_type; } + } + if (datasource_slug !== undefined) { out.datasource = datasource_slug; } + return out; +} diff --git a/packages/cli/src/commands/schema/utils.ts b/packages/cli/src/commands/schema/utils.ts index a5d1d28fb..ba58ae90c 100644 --- a/packages/cli/src/commands/schema/utils.ts +++ b/packages/cli/src/commands/schema/utils.ts @@ -177,3 +177,79 @@ export function stripKeys( } return result; } + +/** + * Converts a string to kebab-case, keeping only filesystem/shell-safe + * characters. Handles snake_case, camelCase, PascalCase, and space-separated + * words; any remaining non-`[a-z0-9-]` characters collapse to a single `-`. + */ +export function toKebabCase(str: string): string { + return str + .replace(/[\s_]+/g, '-') + .replace(/([a-z])([A-Z])/g, '$1-$2') + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * Resolves an ordered list of raw names to unique variable names. Names that + * sanitize to the same identifier get a numeric suffix (`…2`, `…3`), so the + * generated `export const`s and schema-object keys never collide. Index-aligned + * to `rawNames`. + */ +export function resolveVarNames(rawNames: string[], baseVarName: (name: string) => string): string[] { + const used = new Set(); + return rawNames.map((raw) => { + const base = baseVarName(raw); + let candidate = base; + let n = 2; + while (used.has(candidate)) { candidate = `${base}${n++}`; } + used.add(candidate); + return candidate; + }); +} + +/** + * Resolves an ordered list of already-sanitized base file names to unique ones. + * `toKebabCase` is lossy (it collapses `_`/`-` runs and strips symbols), so two + * distinct source names can produce the same file name even though the raw names + * are unique. Collisions get a `-2`, `-3`, … suffix so generated files never + * overwrite each other and each `schema.ts` import resolves unambiguously. + * + * `dirKeys` scopes uniqueness per directory: blocks live in their group + * subdirectory, so two blocks with the same file name in *different* group + * directories don't collide on disk and must keep their shared name. Pass the + * containing directory (e.g. the joined group path) per index; omit for a flat + * layout (datasources). Index-aligned to `baseNames`. + */ +export function resolveFileNames(baseNames: string[], dirKeys?: string[]): string[] { + const usedByDir = new Map>(); + return baseNames.map((base, i) => { + const dir = dirKeys?.[i] ?? ''; + let used = usedByDir.get(dir); + if (!used) { used = new Set(); usedByDir.set(dir, used); } + let candidate = base; + let n = 2; + while (used.has(candidate)) { candidate = `${base}-${n++}`; } + used.add(candidate); + return candidate; + }); +} + +/** Returns the file name (without extension) for a component. e.g. `'teaser_list'` -> `'teaser-list'` */ +export function componentFileName(name: string): string { + return toKebabCase(name); +} + +/** Sorts schema fields by `pos` for stable ordering. */ +export function sortSchemaByPos(schema: Record>): [string, Record][] { + return Object.entries(schema) + .filter(([key]) => key !== '_uid' && key !== 'component') + .sort(([, a], [, b]) => { + const posA = typeof a.pos === 'number' ? a.pos : Infinity; + const posB = typeof b.pos === 'number' ? b.pos : Infinity; + return posA - posB; + }); +} From f8c328177357b6ad8a8fe9af3f00833e7ca3a3b3 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 28 Jul 2026 16:15:01 +0200 Subject: [PATCH 04/35] feat(cli): serialize components into block definition type literals 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 --- .../generate/schema-types/serialize.test.ts | 127 ++++++++++++++++++ .../types/generate/schema-types/serialize.ts | 116 ++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 packages/cli/src/commands/types/generate/schema-types/serialize.test.ts create mode 100644 packages/cli/src/commands/types/generate/schema-types/serialize.ts diff --git a/packages/cli/src/commands/types/generate/schema-types/serialize.test.ts b/packages/cli/src/commands/types/generate/schema-types/serialize.test.ts new file mode 100644 index 000000000..54a020d7a --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/serialize.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; + +import { serializeBlockDefinition } from './serialize'; + +function component(overrides: Record = {}) { + return { + id: 1, + name: 'hero', + created_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-02T00:00:00.000Z', + is_root: false, + is_nestable: true, + schema: {}, + ...overrides, + } as never; +} + +const emptyContext = { displayPathByUuid: new Map() }; + +describe('serializeBlockDefinition', () => { + it('widens id/created_at/updated_at and keeps name/is_root/is_nestable literal', () => { + const result = serializeBlockDefinition(component(), emptyContext); + + expect(result.componentName).toBe('hero'); + expect(result.definitionBody).toBe([ + '{', + ' readonly id: number;', + ' created_at: string;', + ' updated_at: string;', + ' name: \'hero\';', + ' is_root: false;', + ' is_nestable: true;', + ' fields: [];', + '}', + ].join('\n')); + }); + + it('emits fields ordered by pos, keeping only type-relevant keys', () => { + const result = serializeBlockDefinition(component({ + schema: { + body: { type: 'bloks', pos: 1, description: 'ignored', translatable: true }, + headline: { type: 'text', pos: 0, required: true, default_value: 'ignored' }, + }, + }), emptyContext); + + expect(result.definitionBody).toContain([ + ' fields: [', + ' { name: \'headline\'; type: \'text\'; required: true },', + ' { name: \'body\'; type: \'bloks\' },', + ' ];', + ].join('\n')); + }); + + it('omits required when it is not true', () => { + const result = serializeBlockDefinition(component({ + schema: { headline: { type: 'text', required: false } }, + }), emptyContext); + + expect(result.definitionBody).toContain('{ name: \'headline\'; type: \'text\' }'); + }); + + it('maps component_whitelist to an allow tuple', () => { + const result = serializeBlockDefinition(component({ + schema: { body: { type: 'bloks', component_whitelist: ['grid', 'teaser'] } }, + }), emptyContext); + + expect(result.definitionBody).toContain('{ name: \'body\'; type: \'bloks\'; allow: [\'grid\', \'teaser\'] }'); + }); + + it('maps a group whitelist to allow folder entries using display paths', () => { + const result = serializeBlockDefinition(component({ + schema: { body: { type: 'bloks', component_group_whitelist: ['uuid-1'] } }, + }), { displayPathByUuid: new Map([['uuid-1', 'My Layout/Heros']]) }); + + expect(result.definitionBody).toContain('allow: [{ folder: \'My Layout/Heros\' }]'); + }); + + it('omits allow when a group whitelist cannot be resolved', () => { + const result = serializeBlockDefinition(component({ + schema: { body: { type: 'bloks', component_group_whitelist: ['unknown'] } }, + }), emptyContext); + + expect(result.definitionBody).toContain('{ name: \'body\'; type: \'bloks\' }'); + expect(result.definitionBody).not.toContain('allow'); + }); + + it('emits the block folder literal from its component group', () => { + const result = serializeBlockDefinition( + component({ component_group_uuid: 'uuid-1' }), + { displayPathByUuid: new Map([['uuid-1', 'My Layout']]) }, + ); + + expect(result.definitionBody).toContain(' folder: \'My Layout\';'); + }); + + it('keeps field_type on custom fields and reports it', () => { + const result = serializeBlockDefinition(component({ + schema: { accent: { type: 'custom', field_type: 'storyblok-colorpicker' } }, + }), emptyContext); + + expect(result.definitionBody).toContain('{ name: \'accent\'; type: \'custom\'; field_type: \'storyblok-colorpicker\' }'); + expect(result.customFieldTypes).toEqual(['storyblok-colorpicker']); + }); + + it('escapes quotes in names and values', () => { + const result = serializeBlockDefinition(component({ + schema: { 'it\'s': { type: 'text' } }, + }), emptyContext); + + expect(result.definitionBody).toContain('name: \'it\\\'s\''); + }); + + it('emits is_nestable true when the wire omits it, and false when explicit', () => { + expect(serializeBlockDefinition(component({ is_nestable: undefined }), emptyContext).definitionBody) + .toContain('is_nestable: true;'); + expect(serializeBlockDefinition(component({ is_nestable: false }), emptyContext).definitionBody) + .toContain('is_nestable: false;'); + }); + + it('keeps section and tab fields, which resolve to never downstream', () => { + const result = serializeBlockDefinition(component({ + schema: { general: { type: 'tab' } }, + }), emptyContext); + + expect(result.definitionBody).toContain('{ name: \'general\'; type: \'tab\' }'); + }); +}); diff --git a/packages/cli/src/commands/types/generate/schema-types/serialize.ts b/packages/cli/src/commands/types/generate/schema-types/serialize.ts new file mode 100644 index 000000000..05a0b7dc3 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/serialize.ts @@ -0,0 +1,116 @@ +import type { Component } from '../../../../types'; +import { toDslField } from '../../../schema/to-dsl-field'; +import { INDENT, isRecord, quoteString, sortSchemaByPos } from '../../../schema/utils'; + +/** Resolution context shared by every block in one generation run. */ +export interface SerializeContext { + /** `component_group_uuid` → display-name path, e.g. `'My Layout/Heros'`. */ + displayPathByUuid: Map; +} + +/** One component serialized to the type-literal body of its definition type. */ +export interface SerializedBlock { + /** Technical component name, e.g. `'hero'`. */ + componentName: string; + /** The emitted type literal, from `{` to `}`, without a trailing semicolon. */ + definitionBody: string; + /** `field_type` values seen on `custom` fields, for unmapped-plugin warnings. */ + customFieldTypes: string[]; +} + +/** + * Serializes one `allow` entry: a bare block name, or a folder reference. Any + * other shape yields `undefined`, which drops the whole `allow` (no narrowing + * is safer than wrong narrowing). + */ +function serializeAllowEntry(entry: unknown): string | undefined { + if (typeof entry === 'string') { return quoteString(entry); } + if (isRecord(entry) && typeof entry.folder === 'string') { return `{ folder: ${quoteString(entry.folder)} }`; } + return undefined; +} + +/** + * Serializes one field to a type literal. Only the keys the type-level + * machinery reads are emitted, `name`, `type`, `required` (when `true`), + * `allow`, `field_type`. Everything else on the wire (`description`, + * `translatable`, `default_value`, option lists, …) cannot affect the resulting + * content type, so including it would be pure diff churn. + */ +function serializeField( + fieldName: string, + fieldData: Record, + context: SerializeContext, +): { code: string; customFieldType?: string } { + const dsl = toDslField(fieldData, (uuid) => { + const path = context.displayPathByUuid.get(uuid); + return path === undefined ? undefined : { folder: path }; + }); + + const members = [`name: ${quoteString(fieldName)}`]; + if (typeof dsl.type === 'string') { members.push(`type: ${quoteString(dsl.type)}`); } + if (dsl.required === true) { members.push('required: true'); } + + if (Array.isArray(dsl.allow) && dsl.allow.length > 0) { + const entries = dsl.allow.map(serializeAllowEntry); + if (entries.every((entry): entry is string => entry !== undefined)) { + members.push(`allow: [${entries.join(', ')}]`); + } + } + + const customFieldType = dsl.type === 'custom' && typeof dsl.field_type === 'string' + ? dsl.field_type + : undefined; + if (customFieldType !== undefined) { members.push(`field_type: ${quoteString(customFieldType)}`); } + + return { + code: `{ ${members.join('; ')} }`, + ...(customFieldType === undefined ? {} : { customFieldType }), + }; +} + +/** + * Serializes a component to its block definition type literal. + * + * `id`, `created_at`, and `updated_at` are required by the MAPI `Component` + * that `Block` extends, but nothing at the type level reads their values, so + * they are emitted widened (`id: number`) rather than as the fetched + * literals, which would churn the diff on every regeneration. `name`, + * `is_root`, `is_nestable`, `folder`, and `fields` are read by + * `BlockContent`/`ApplyAllow`/`RootBlock`, so those stay literal. + */ +export function serializeBlockDefinition(component: Component, context: SerializeContext): SerializedBlock { + const lines = ['{']; + lines.push(`${INDENT}readonly id: number;`); + lines.push(`${INDENT}created_at: string;`); + lines.push(`${INDENT}updated_at: string;`); + lines.push(`${INDENT}name: ${quoteString(component.name)};`); + lines.push(`${INDENT}is_root: ${component.is_root === true ? 'true' : 'false'};`); + lines.push(`${INDENT}is_nestable: ${component.is_nestable === false ? 'false' : 'true'};`); + + const groupUuid = component.component_group_uuid; + const folderPath = typeof groupUuid === 'string' ? context.displayPathByUuid.get(groupUuid) : undefined; + if (folderPath !== undefined) { lines.push(`${INDENT}folder: ${quoteString(folderPath)};`); } + + const schema = isRecord(component.schema) ? component.schema as Record> : {}; + const fields = sortSchemaByPos(schema).filter(([, data]) => isRecord(data)); + + const customFieldTypes: string[] = []; + if (fields.length === 0) { + lines.push(`${INDENT}fields: [];`); + } + else { + lines.push(`${INDENT}fields: [`); + for (const [fieldName, fieldData] of fields) { + const { code, customFieldType } = serializeField(fieldName, fieldData, context); + if (customFieldType !== undefined && !customFieldTypes.includes(customFieldType)) { + customFieldTypes.push(customFieldType); + } + lines.push(`${INDENT}${INDENT}${code},`); + } + lines.push(`${INDENT}];`); + } + + lines.push('}'); + + return { componentName: component.name, definitionBody: lines.join('\n'), customFieldTypes }; +} From 20a53f3cf2713465b526666dd6ca7a475c6f9ca8 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 28 Jul 2026 16:26:33 +0200 Subject: [PATCH 05/35] fix(cli): remove as cast from serialize, use type predicate instead Replace 'as Record>' 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 --- .../types/generate/schema-types/serialize.test.ts | 2 +- .../commands/types/generate/schema-types/serialize.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/types/generate/schema-types/serialize.test.ts b/packages/cli/src/commands/types/generate/schema-types/serialize.test.ts index 54a020d7a..7d488c020 100644 --- a/packages/cli/src/commands/types/generate/schema-types/serialize.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/serialize.test.ts @@ -117,7 +117,7 @@ describe('serializeBlockDefinition', () => { .toContain('is_nestable: false;'); }); - it('keeps section and tab fields, which resolve to never downstream', () => { + it('keeps tab fields, which resolve to never downstream', () => { const result = serializeBlockDefinition(component({ schema: { general: { type: 'tab' } }, }), emptyContext); diff --git a/packages/cli/src/commands/types/generate/schema-types/serialize.ts b/packages/cli/src/commands/types/generate/schema-types/serialize.ts index 05a0b7dc3..e64723131 100644 --- a/packages/cli/src/commands/types/generate/schema-types/serialize.ts +++ b/packages/cli/src/commands/types/generate/schema-types/serialize.ts @@ -18,6 +18,14 @@ export interface SerializedBlock { customFieldTypes: string[]; } +/** + * Narrows a component's wire `schema` to the field-record shape that + * `sortSchemaByPos` accepts. Allows graceful handling of malformed schema. + */ +function isFieldRecordMap(value: unknown): value is Record> { + return isRecord(value); +} + /** * Serializes one `allow` entry: a bare block name, or a folder reference. Any * other shape yields `undefined`, which drops the whole `allow` (no narrowing @@ -91,7 +99,7 @@ export function serializeBlockDefinition(component: Component, context: Serializ const folderPath = typeof groupUuid === 'string' ? context.displayPathByUuid.get(groupUuid) : undefined; if (folderPath !== undefined) { lines.push(`${INDENT}folder: ${quoteString(folderPath)};`); } - const schema = isRecord(component.schema) ? component.schema as Record> : {}; + const schema = isFieldRecordMap(component.schema) ? component.schema : {}; const fields = sortSchemaByPos(schema).filter(([, data]) => isRecord(data)); const customFieldTypes: string[] = []; From 64cf3fa2b5126d10904f39f69c4b83019b04a39e Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 28 Jul 2026 16:33:53 +0200 Subject: [PATCH 06/35] feat(cli): resolve field-plugin declaration module for generated types --- .../schema-types/field-plugins.test.ts | 80 ++++++++++++++++++ .../generate/schema-types/field-plugins.ts | 83 +++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts create mode 100644 packages/cli/src/commands/types/generate/schema-types/field-plugins.ts diff --git a/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts b/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts new file mode 100644 index 000000000..dc0713782 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts @@ -0,0 +1,80 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FIELD_PLUGINS_CONVENTION_PATH, resolveFieldPluginsSource } from './field-plugins'; + +// This module resolves a real TypeScript file from disk via jiti, so it needs the +// real filesystem rather than the memfs mock the global test setup installs. +vi.unmock('node:fs'); +vi.unmock('node:fs/promises'); + +let cwd: string; + +const SCHEMA_EXPORT = ` +export const schema = { + blocks: {}, + fieldPlugins: { colorPicker: { fieldType: 'storyblok-colorpicker', value: {} } }, +}; +`; + +const RECORD_EXPORT = ` +export const fieldPlugins = { colorPicker: { fieldType: 'storyblok-colorpicker', value: {} } }; +`; + +beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), 'sb-field-plugins-')); +}); + +afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); +}); + +describe('resolveFieldPluginsSource', () => { + it('returns none when neither an override nor the convention file exists', async () => { + expect(await resolveFieldPluginsSource({ cwd })).toEqual({ kind: 'none' }); + }); + + it('detects a defineSchema result at the convention path', async () => { + const target = join(cwd, FIELD_PLUGINS_CONVENTION_PATH); + await mkdir(join(target, '..'), { recursive: true }); + await writeFile(target, SCHEMA_EXPORT, 'utf8'); + + const result = await resolveFieldPluginsSource({ cwd }); + + expect(result).toEqual({ kind: 'schema', modulePath: target, fieldTypes: ['storyblok-colorpicker'] }); + }); + + it('detects a bare fieldPlugins record via an explicit override', async () => { + const target = join(cwd, 'plugins.ts'); + await writeFile(target, RECORD_EXPORT, 'utf8'); + + const result = await resolveFieldPluginsSource({ cwd, override: 'plugins.ts' }); + + expect(result).toEqual({ kind: 'record', modulePath: target, fieldTypes: ['storyblok-colorpicker'] }); + }); + + it('throws when an explicit override does not exist', async () => { + await expect(resolveFieldPluginsSource({ cwd, override: 'missing.ts' })) + .rejects + .toThrow(/not found/); + }); + + it('throws when an explicit override exports neither supported shape', async () => { + const target = join(cwd, 'plugins.ts'); + await writeFile(target, 'export const nope = 1;', 'utf8'); + + await expect(resolveFieldPluginsSource({ cwd, override: 'plugins.ts' })) + .rejects + .toThrow(/fieldPlugins/); + }); + + it('returns none when the convention file exists but exports neither shape', async () => { + const target = join(cwd, FIELD_PLUGINS_CONVENTION_PATH); + await mkdir(join(target, '..'), { recursive: true }); + await writeFile(target, 'export const schema = { blocks: {} };', 'utf8'); + + expect(await resolveFieldPluginsSource({ cwd })).toEqual({ kind: 'none' }); + }); +}); diff --git a/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts b/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts new file mode 100644 index 000000000..cb1389278 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts @@ -0,0 +1,83 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'pathe'; + +import { CommandError, toError } from '../../../../utils'; +import { isRecord } from '../../../schema/utils'; + +/** Where a field-plugin declaration module is looked for when no override is given. */ +export const FIELD_PLUGINS_CONVENTION_PATH = '.storyblok/schema/schema.ts'; + +/** + * Where the generated `FieldPlugins` type comes from. + * + * - `schema`, the module exports a `defineSchema` result named `schema` (what + * `schema init` writes). + * - `record`, the module exports a bare `fieldPlugins` record. + * - `none`: nothing to import; `FieldPlugins` becomes `Record`. + */ +export type FieldPluginsSource = + | { kind: 'none' } + | { kind: 'schema'; modulePath: string; fieldTypes: string[] } + | { kind: 'record'; modulePath: string; fieldTypes: string[] }; + +/** Collects the `fieldType` of every entry in a `fieldPlugins` record. */ +function collectFieldTypes(fieldPlugins: Record): string[] { + const fieldTypes: string[] = []; + for (const plugin of Object.values(fieldPlugins)) { + if (isRecord(plugin) && typeof plugin.fieldType === 'string' && !fieldTypes.includes(plugin.fieldType)) { + fieldTypes.push(plugin.fieldType); + } + } + return fieldTypes; +} + +/** + * Resolves the module whose `defineFieldPlugin` declarations type `custom` + * fields. The module is loaded with `jiti` (TypeScript-aware) purely to detect + * which export shape it has and which `fieldType`s it registers, the generated + * file imports it by path and lets TypeScript do the real work. + * + * An explicit `--field-plugins` path that is missing or unusable is an error; + * the convention path silently degrades to `none`, since most spaces have no + * custom fields. + */ +export async function resolveFieldPluginsSource( + options: { cwd: string; override?: string }, +): Promise { + const isExplicit = options.override !== undefined; + const modulePath = resolve(options.cwd, options.override ?? FIELD_PLUGINS_CONVENTION_PATH); + + if (!existsSync(modulePath)) { + if (isExplicit) { + throw new CommandError(`Field plugins module not found: ${modulePath}`); + } + return { kind: 'none' }; + } + + const { createJiti } = await import('jiti'); + const jiti = createJiti(import.meta.url, { interopDefault: true }); + + let module: Record; + try { + module = await jiti.import(modulePath) as Record; + } + catch (maybeError) { + throw new CommandError(`Failed to load field plugins from ${modulePath}: ${toError(maybeError).message}`); + } + + const schemaExport = module.schema; + if (isRecord(schemaExport) && isRecord(schemaExport.fieldPlugins)) { + return { kind: 'schema', modulePath, fieldTypes: collectFieldTypes(schemaExport.fieldPlugins) }; + } + + if (isRecord(module.fieldPlugins)) { + return { kind: 'record', modulePath, fieldTypes: collectFieldTypes(module.fieldPlugins) }; + } + + if (isExplicit) { + throw new CommandError( + `${modulePath} exports neither a \`schema\` (a defineSchema result with fieldPlugins) nor a \`fieldPlugins\` record.`, + ); + } + return { kind: 'none' }; +} From 3c955b72678afc085d437d6a80e96775d3bcfde0 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 28 Jul 2026 16:41:11 +0200 Subject: [PATCH 07/35] feat(cli): render schema-derived type file 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. --- .../generate/schema-types/render.test.ts | 124 ++++++++++++++ .../types/generate/schema-types/render.ts | 154 ++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 packages/cli/src/commands/types/generate/schema-types/render.test.ts create mode 100644 packages/cli/src/commands/types/generate/schema-types/render.ts diff --git a/packages/cli/src/commands/types/generate/schema-types/render.test.ts b/packages/cli/src/commands/types/generate/schema-types/render.test.ts new file mode 100644 index 000000000..5dbff3517 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/render.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; + +import { buildNames, renderSchemaTypes, toRelativeImport } from './render'; + +const heroBlock = { + componentName: 'hero', + definitionBody: '{\n name: \'hero\';\n fields: [];\n}', + customFieldTypes: [], +}; +const teaserListBlock = { + componentName: 'teaser_list', + definitionBody: '{\n name: \'teaser_list\';\n fields: [];\n}', + customFieldTypes: [], +}; + +describe('buildNames', () => { + it('derives PascalCase definition names and the shared surface names', () => { + const names = buildNames(['hero', 'teaser_list'], {}); + + expect(names.definitionByComponent.get('hero')).toBe('HeroBlockDefinition'); + expect(names.definitionByComponent.get('teaser_list')).toBe('TeaserListBlockDefinition'); + expect(names.blocks).toBe('Blocks'); + expect(names.block).toBe('Block'); + expect(names.schema).toBe('Schema'); + }); + + it('applies prefix and suffix to every emitted name', () => { + const names = buildNames(['hero'], { typePrefix: 'Sb', typeSuffix: 'Type' }); + + expect(names.definitionByComponent.get('hero')).toBe('SbHeroBlockDefinitionType'); + expect(names.blocks).toBe('SbBlocksType'); + expect(names.block).toBe('SbBlockType'); + expect(names.schema).toBe('SbSchemaType'); + expect(names.fieldPlugins).toBe('SbFieldPluginsType'); + expect(names.anyBlock).toBe('SbAnyBlockType'); + expect(names.story).toBe('SbStoryType'); + expect(names.storyMapi).toBe('SbStoryMapiType'); + }); + + it('disambiguates components that collapse to the same PascalCase name', () => { + const names = buildNames(['teaser-list', 'teaser_list'], {}); + + const first = names.definitionByComponent.get('teaser-list'); + const second = names.definitionByComponent.get('teaser_list'); + expect(first).not.toBe(second); + expect([first, second]).toContain('TeaserListBlockDefinition'); + }); +}); + +describe('renderSchemaTypes', () => { + it('emits the definition types and the shared surface', () => { + const output = renderSchemaTypes({ + blocks: [heroBlock, teaserListBlock], + fieldPlugins: { kind: 'none' }, + space: '295018', + }); + + expect(output).toContain('import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from \'@storyblok/schema\';'); + expect(output).toContain('export type HeroBlockDefinition = {'); + expect(output).toContain('export type Blocks = HeroBlockDefinition | TeaserListBlockDefinition;'); + expect(output).toContain('export type FieldPlugins = Record;'); + expect(output).toContain('export type Schema = { blocks: Blocks; fieldPlugins: FieldPlugins };'); + expect(output).toContain('export type Block = BlockContent, Blocks, FieldPlugins>;'); + expect(output).toContain('export type AnyBlock = BlockContent;'); + expect(output).toContain('export type Story = InferStory;'); + expect(output).toContain('export type StoryMapi = InferStoryMapi;'); + }); + + it('renames internal references consistently with prefixed declarations', () => { + const output = renderSchemaTypes({ + blocks: [heroBlock], + fieldPlugins: { kind: 'none' }, + space: '295018', + typePrefix: 'Storyblok', + }); + + expect(output).toContain('export type StoryblokBlocks = StoryblokHeroBlockDefinition;'); + expect(output).toContain('export type StoryblokSchema = { blocks: StoryblokBlocks; fieldPlugins: StoryblokFieldPlugins };'); + expect(output).toContain('export type StoryblokBlock = BlockContent, StoryblokBlocks, StoryblokFieldPlugins>;'); + // the @storyblok/schema import aliases are file-internal and must not be renamed + expect(output).toContain('Schema as InferSchema'); + }); + + it('derives FieldPlugins from a defineSchema result', () => { + const output = renderSchemaTypes({ + blocks: [heroBlock], + fieldPlugins: { kind: 'schema', modulePath: '/abs/schema.ts', fieldTypes: ['x'] }, + fieldPluginsImportPath: '../../schema/schema', + space: '295018', + }); + + expect(output).toContain('import type { schema as userSchema } from \'../../schema/schema\';'); + expect(output).toContain('export type FieldPlugins = InferSchema[\'fieldPlugins\'];'); + }); + + it('derives FieldPlugins from a bare fieldPlugins record', () => { + const output = renderSchemaTypes({ + blocks: [heroBlock], + fieldPlugins: { kind: 'record', modulePath: '/abs/plugins.ts', fieldTypes: ['x'] }, + fieldPluginsImportPath: './plugins', + space: '295018', + }); + + expect(output).toContain('import type { fieldPlugins as userFieldPlugins } from \'./plugins\';'); + expect(output).toContain('export type FieldPlugins = InferSchema<{ blocks: Record; fieldPlugins: typeof userFieldPlugins }>[\'fieldPlugins\'];'); + }); + + it('records the space in the generated header', () => { + const output = renderSchemaTypes({ blocks: [heroBlock], fieldPlugins: { kind: 'none' }, space: '295018' }); + + expect(output.startsWith('// This file was generated by the Storyblok CLI. Do not edit by hand.')).toBe(true); + expect(output).toContain('// Space: 295018'); + }); +}); + +describe('toRelativeImport', () => { + it('builds a posix relative specifier without the extension', () => { + expect(toRelativeImport('/p/.storyblok/types/1', '/p/.storyblok/schema/schema.ts')).toBe('../../schema/schema'); + }); + + it('prefixes a sibling path with ./', () => { + expect(toRelativeImport('/p/types', '/p/types/plugins.ts')).toBe('./plugins'); + }); +}); diff --git a/packages/cli/src/commands/types/generate/schema-types/render.ts b/packages/cli/src/commands/types/generate/schema-types/render.ts new file mode 100644 index 000000000..bef7a77f8 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/render.ts @@ -0,0 +1,154 @@ +import { relative } from 'pathe'; + +import { toPascalCase } from '../../../../utils/format'; +import { resolveVarNames } from '../../../schema/utils'; +import type { FieldPluginsSource } from './field-plugins'; +import type { SerializedBlock } from './serialize'; + +/** Prefix/suffix applied to every emitted type name. */ +export interface NameOptions { + typePrefix?: string; + typeSuffix?: string; +} + +/** + * Every name the generated file declares. Resolved once so declarations and + * internal references can never drift apart under `--type-prefix`/`--type-suffix`. + */ +export interface EmittedNames { + blocks: string; + schema: string; + fieldPlugins: string; + block: string; + anyBlock: string; + story: string; + storyMapi: string; + /** Technical component name → its definition type name. */ + definitionByComponent: Map; +} + +export interface RenderOptions extends NameOptions { + blocks: SerializedBlock[]; + fieldPlugins: FieldPluginsSource; + /** Posix specifier from the output file to the field-plugins module. */ + fieldPluginsImportPath?: string; + space: string; +} + +function decorate(base: string, options: NameOptions): string { + return `${options.typePrefix ?? ''}${base}${options.typeSuffix ?? ''}`; +} + +/** + * Resolves the emitted names for a set of components. Definition names are + * `BlockDefinition`; components whose names collapse to the same + * PascalCase identifier (e.g. `teaser-list` and `teaser_list`) get a numeric + * suffix via {@link resolveVarNames} so the file never declares a duplicate. + */ +export function buildNames(componentNames: string[], options: NameOptions): EmittedNames { + const bases = resolveVarNames(componentNames, name => `${toPascalCase(name)}BlockDefinition`); + return { + blocks: decorate('Blocks', options), + schema: decorate('Schema', options), + fieldPlugins: decorate('FieldPlugins', options), + block: decorate('Block', options), + anyBlock: decorate('AnyBlock', options), + story: decorate('Story', options), + storyMapi: decorate('StoryMapi', options), + definitionByComponent: new Map(componentNames.map((name, i) => [name, decorate(bases[i], options)])), + }; +} + +/** Builds a posix, extension-less relative import specifier. */ +export function toRelativeImport(fromDir: string, toFile: string): string { + const specifier = relative(fromDir, toFile).replace(/\.(?:ts|tsx|mts|cts)$/, ''); + return specifier.startsWith('.') ? specifier : `./${specifier}`; +} + +/** The file header, identical across single-file and separate-file output. */ +export function renderHeader(space: string): string[] { + return [ + '// This file was generated by the Storyblok CLI. Do not edit by hand.', + `// Space: ${space}`, + '', + ]; +} + +/** + * Renders the `FieldPlugins` declaration plus the import it needs. + * + * Both accepted module shapes resolve through `@storyblok/schema`'s `Schema` + * helper, so the `fieldType → value` mapping is derived by the library rather + * than reconstructed here. A bare `fieldPlugins` record is wrapped in a + * synthetic schema whose `blocks` is `Record`, `never` satisfies + * the `Record` constraint, and the wrapper's `blocks` is never read. + */ +function renderFieldPlugins(options: RenderOptions, names: EmittedNames): { imports: string[]; declaration: string } { + const { fieldPlugins, fieldPluginsImportPath } = options; + + if (fieldPlugins.kind === 'none' || fieldPluginsImportPath === undefined) { + return { imports: [], declaration: `export type ${names.fieldPlugins} = Record;` }; + } + + if (fieldPlugins.kind === 'schema') { + return { + imports: [`import type { schema as userSchema } from '${fieldPluginsImportPath}';`], + declaration: `export type ${names.fieldPlugins} = InferSchema['fieldPlugins'];`, + }; + } + + return { + imports: [`import type { fieldPlugins as userFieldPlugins } from '${fieldPluginsImportPath}';`], + declaration: `export type ${names.fieldPlugins} = InferSchema<{ blocks: Record; fieldPlugins: typeof userFieldPlugins }>['fieldPlugins'];`, + }; +} + +/** + * Renders the shared surface: `Blocks`, `FieldPlugins`, `Schema`, the + * name-keyed `Block` helper, `AnyBlock`, and the story aliases. + * + * `Block` is the user-facing type (`Block<'hero'>`); the definition types + * it indexes into are plumbing for the CAPI/MAPI helpers, `withTypes()`, + * `BlockContent`, `Story`. + */ +function renderSurface(names: EmittedNames, definitionNames: string[], fieldPluginsDeclaration: string): string[] { + return [ + `export type ${names.blocks} = ${definitionNames.join(' | ')};`, + '', + fieldPluginsDeclaration, + '', + `export type ${names.schema} = { blocks: ${names.blocks}; fieldPlugins: ${names.fieldPlugins} };`, + '', + `export type ${names.block} = BlockContent, ${names.blocks}, ${names.fieldPlugins}>;`, + '', + `export type ${names.anyBlock} = BlockContent<${names.blocks}, ${names.blocks}, ${names.fieldPlugins}>;`, + '', + `export type ${names.story} = InferStory<${names.blocks}>;`, + `export type ${names.storyMapi} = InferStoryMapi<${names.blocks}>;`, + '', + ]; +} + +/** Renders the whole surface plus every definition type into one file. */ +export function renderSchemaTypes(options: RenderOptions): string { + const componentNames = options.blocks.map(block => block.componentName); + const names = buildNames(componentNames, options); + const fieldPlugins = renderFieldPlugins(options, names); + + const lines = [ + ...renderHeader(options.space), + 'import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from \'@storyblok/schema\';', + ...fieldPlugins.imports, + '', + ]; + + for (const block of options.blocks) { + lines.push(`export type ${names.definitionByComponent.get(block.componentName)!} = ${block.definitionBody};`); + lines.push(''); + } + + const definitionNames = componentNames.map(name => names.definitionByComponent.get(name)!); + lines.push(...renderSurface(names, definitionNames, fieldPlugins.declaration)); + + return lines.join('\n'); +} From c39e2fda0cca2c2df9e2e19bff06790dbb93ef0d Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 28 Jul 2026 16:47:29 +0200 Subject: [PATCH 08/35] feat(cli): support --separate-files for schema-derived types 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 --- .../generate/schema-types/render.test.ts | 56 ++++++++++++++++++- .../types/generate/schema-types/render.ts | 41 +++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/types/generate/schema-types/render.test.ts b/packages/cli/src/commands/types/generate/schema-types/render.test.ts index 5dbff3517..89ce78572 100644 --- a/packages/cli/src/commands/types/generate/schema-types/render.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/render.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { buildNames, renderSchemaTypes, toRelativeImport } from './render'; +import { buildNames, renderSchemaTypes, renderSeparateFiles, toRelativeImport } from './render'; const heroBlock = { componentName: 'hero', @@ -122,3 +122,57 @@ describe('toRelativeImport', () => { expect(toRelativeImport('/p/types', '/p/types/plugins.ts')).toBe('./plugins'); }); }); + +describe('renderSeparateFiles', () => { + it('writes one definition per block file and imports them in the main file', () => { + const files = renderSeparateFiles({ + blocks: [heroBlock, teaserListBlock], + fieldPlugins: { kind: 'none' }, + space: '295018', + filename: 'storyblok-schema', + }); + + expect([...files.keys()].sort()).toEqual([ + 'blocks/hero.d.ts', + 'blocks/teaser-list.d.ts', + 'storyblok-schema.d.ts', + ]); + + expect(files.get('blocks/hero.d.ts')).toContain('export type HeroBlockDefinition = {'); + expect(files.get('storyblok-schema.d.ts')).toContain('import type { HeroBlockDefinition } from \'./blocks/hero\';'); + expect(files.get('storyblok-schema.d.ts')).toContain('import type { TeaserListBlockDefinition } from \'./blocks/teaser-list\';'); + expect(files.get('storyblok-schema.d.ts')).toContain('export type Blocks = HeroBlockDefinition | TeaserListBlockDefinition;'); + expect(files.get('storyblok-schema.d.ts')).not.toContain('export type HeroBlockDefinition = {'); + }); + + it('disambiguates block file names that collide after kebab-casing', () => { + const files = renderSeparateFiles({ + blocks: [ + { componentName: 'teaser-list', definitionBody: '{}', customFieldTypes: [] }, + { componentName: 'teaser_list', definitionBody: '{}', customFieldTypes: [] }, + ], + fieldPlugins: { kind: 'none' }, + space: '295018', + filename: 'storyblok-schema', + }); + + expect([...files.keys()].sort()).toEqual([ + 'blocks/teaser-list-2.d.ts', + 'blocks/teaser-list.d.ts', + 'storyblok-schema.d.ts', + ]); + }); + + it('carries the field-plugins import into the main file only', () => { + const files = renderSeparateFiles({ + blocks: [heroBlock], + fieldPlugins: { kind: 'record', modulePath: '/abs/plugins.ts', fieldTypes: ['x'] }, + fieldPluginsImportPath: './plugins', + space: '295018', + filename: 'storyblok-schema', + }); + + expect(files.get('storyblok-schema.d.ts')).toContain('import type { fieldPlugins as userFieldPlugins } from \'./plugins\';'); + expect(files.get('blocks/hero.d.ts')).not.toContain('userFieldPlugins'); + }); +}); diff --git a/packages/cli/src/commands/types/generate/schema-types/render.ts b/packages/cli/src/commands/types/generate/schema-types/render.ts index bef7a77f8..19542ac6f 100644 --- a/packages/cli/src/commands/types/generate/schema-types/render.ts +++ b/packages/cli/src/commands/types/generate/schema-types/render.ts @@ -1,7 +1,7 @@ import { relative } from 'pathe'; import { toPascalCase } from '../../../../utils/format'; -import { resolveVarNames } from '../../../schema/utils'; +import { componentFileName, resolveFileNames, resolveVarNames } from '../../../schema/utils'; import type { FieldPluginsSource } from './field-plugins'; import type { SerializedBlock } from './serialize'; @@ -152,3 +152,42 @@ export function renderSchemaTypes(options: RenderOptions): string { return lines.join('\n'); } + +/** + * Renders one file per block definition plus the main file holding the shared + * surface. Definition literals never reference each other, so the split needs + * no cross-imports between block files, only the main file imports them. + * + * Returns posix relative paths so the caller can join them onto the output + * directory; keys are stable across platforms. + */ +export function renderSeparateFiles(options: RenderOptions & { filename: string }): Map { + const componentNames = options.blocks.map(block => block.componentName); + const names = buildNames(componentNames, options); + const fieldPlugins = renderFieldPlugins(options, names); + const fileNames = resolveFileNames(componentNames.map(name => componentFileName(name))); + + const files = new Map(); + + options.blocks.forEach((block, index) => { + const typeName = names.definitionByComponent.get(block.componentName)!; + files.set(`blocks/${fileNames[index]}.d.ts`, [ + ...renderHeader(options.space), + `export type ${typeName} = ${block.definitionBody};`, + '', + ].join('\n')); + }); + + const definitionNames = componentNames.map(name => names.definitionByComponent.get(name)!); + const mainLines = [ + ...renderHeader(options.space), + 'import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from \'@storyblok/schema\';', + ...fieldPlugins.imports, + ...definitionNames.map((typeName, index) => `import type { ${typeName} } from './blocks/${fileNames[index]}';`), + '', + ...renderSurface(names, definitionNames, fieldPlugins.declaration), + ]; + files.set(`${options.filename}.d.ts`, mainLines.join('\n')); + + return files; +} From 032395d4688b72188813457b84aa9ad869ffd87d Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 28 Jul 2026 16:58:11 +0200 Subject: [PATCH 09/35] feat(cli): add types generate --future-schema flag Wires the schema-derived type generator into `storyblok types generate` behind a new `--future-schema` flag, alongside `--field-plugins`. The legacy json-schema-to-typescript path now warns that it is deprecated, and legacy-only flags (--strict, --custom-fields-parser, --compiler-options) are rejected when combined with --future-schema. Also re-exports `./filesystem` from `src/utils/index.ts`, it held `saveToFile`/`saveToFileSync`/etc. but was not part of the barrel, so the new orchestration module could not import it as intended. --- .../src/commands/types/generate/constants.ts | 4 + .../src/commands/types/generate/index.test.ts | 21 +++ .../cli/src/commands/types/generate/index.ts | 64 +++++++++- .../types/generate/schema-types/index.test.ts | 120 ++++++++++++++++++ .../types/generate/schema-types/index.ts | 109 ++++++++++++++++ packages/cli/src/utils/index.ts | 1 + 6 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/commands/types/generate/schema-types/index.test.ts create mode 100644 packages/cli/src/commands/types/generate/schema-types/index.ts diff --git a/packages/cli/src/commands/types/generate/constants.ts b/packages/cli/src/commands/types/generate/constants.ts index caebf03eb..6b37b2c09 100644 --- a/packages/cli/src/commands/types/generate/constants.ts +++ b/packages/cli/src/commands/types/generate/constants.ts @@ -8,4 +8,8 @@ export interface GenerateTypesOptions { suffix?: string; customFieldsParser?: string; compilerOptions?: string; + /** Generate types from the space schema instead of the legacy JSON-schema generator. */ + futureSchema?: boolean; + /** Path to a module exporting `defineFieldPlugin` declarations. */ + fieldPlugins?: string; } diff --git a/packages/cli/src/commands/types/generate/index.test.ts b/packages/cli/src/commands/types/generate/index.test.ts index 1461a0214..75bd2d914 100644 --- a/packages/cli/src/commands/types/generate/index.test.ts +++ b/packages/cli/src/commands/types/generate/index.test.ts @@ -280,5 +280,26 @@ describe("types generate", () => { }), ); }); + + it('warns that the legacy generator is deprecated when --future-schema is absent', async () => { + vi.mocked(readComponentsFiles).mockResolvedValue(mockSpaceData); + vi.mocked(generateStoryblokTypes).mockResolvedValue(true); + vi.mocked(generateTypes).mockResolvedValue('// Generated types'); + + await typesCommand.parseAsync(['node', 'test', 'generate', '--space', '12345']); + + expect(konsola.warn).toHaveBeenCalledWith(expect.stringContaining('--future-schema')); + }); + }); + + describe('future-schema mode', () => { + it('rejects legacy-only flags when --future-schema is used', async () => { + await typesCommand.parseAsync(['node', 'test', 'generate', '--space', '295018', '--future-schema', '--strict']); + + expect(konsola.error).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('--strict') }), + false, + ); + }); }); }); diff --git a/packages/cli/src/commands/types/generate/index.ts b/packages/cli/src/commands/types/generate/index.ts index 1f97da407..094a53067 100644 --- a/packages/cli/src/commands/types/generate/index.ts +++ b/packages/cli/src/commands/types/generate/index.ts @@ -1,4 +1,5 @@ import type { Command } from "commander"; +import { join } from "pathe"; import { colorPalette, commands } from "../../../constants"; import { CommandError, FileSystemError, handleError } from "../../../utils"; import { type ComponentsData, readComponentsFiles } from "../../components/push/actions"; @@ -7,8 +8,10 @@ import { typesCommand } from "../command"; import { generateStoryblokTypes, generateTypes, saveTypesToComponentsFile } from "./actions"; import { readDatasourcesFiles } from "../../datasources/push/actions"; import type { SpaceDatasourcesData } from "../../../commands/datasources/constants"; +import type { CLISpinner } from "../../../lib/ui"; import { getUI } from "../../../lib/ui"; import { getLogger } from "../../../lib/logger/logger"; +import { assertNoLegacyFlags, generateSchemaTypes } from "./schema-types"; const generateCmd = typesCommand .command("generate") @@ -30,14 +33,69 @@ const generateCmd = typesCommand "--compiler-options ", "path to the compiler options from json-schema-to-typescript", ) - .option("-s, --space ", "space ID"); + .option("-s, --space ", "space ID") + .option( + "--future-schema", + "Generate types from the space schema (accurate optionality, block narrowing, and custom field types)", + ) + .option( + "--field-plugins ", + "Path to a module exporting your defineFieldPlugin declarations (default: .storyblok/schema/schema.ts)", + ); generateCmd.action(async (options: GenerateTypesOptions, command: Command) => { const ui = getUI(); - ui.title(`${commands.TYPES}`, colorPalette.TYPES, "Generating types..."); - const { space, path, verbose, suffix, filename, separateFiles } = command.optsWithGlobals(); + if (options.futureSchema) { + ui.title(`${commands.TYPES}`, colorPalette.TYPES, "Generating types from schema..."); + let spinner: CLISpinner | undefined; + try { + assertNoLegacyFlags(options); + if (!space) { + throw new CommandError("Please provide the space as argument --space SPACE_ID."); + } + + spinner = ui.createSpinner("Generating types..."); + const outputDir = join(path ?? ".storyblok", "types", space); + const result = await generateSchemaTypes({ + space, + cwd: process.cwd(), + outputDir, + filename: filename ?? "storyblok-schema", + separateFiles, + typePrefix: options.typePrefix, + typeSuffix: options.typeSuffix, + fieldPluginsPath: options.fieldPlugins, + }); + spinner.succeed(); + + result.files.forEach((file) => ui.ok(file)); + if (result.unmappedFieldTypes.length > 0) { + ui.warn( + `No field plugin registered for: ${result.unmappedFieldTypes.join(", ")}. ` + + "These custom fields fall back to an untyped value. Declare them with defineFieldPlugin " + + "and point --field-plugins at the module (or place it at .storyblok/schema/schema.ts).", + ); + } + ui.info( + "The generated types import from `@storyblok/schema`. Install it as a dev dependency: `npm i -D @storyblok/schema`.", + ); + ui.br(); + } catch (error) { + spinner?.failed(`Failed to generate types for space ${space}`); + ui.br(); + handleError(error as Error, verbose); + } + return; + } + + ui.warn( + "`types generate` without --future-schema is deprecated. The legacy generator does not follow " + + "field `required` flags, block whitelists, or nestable/root distinctions. Re-run with --future-schema.", + ); + ui.title(`${commands.TYPES}`, colorPalette.TYPES, "Generating types..."); + if (!space) { handleError( new CommandError("Please provide the space as argument --space SPACE_ID."), diff --git a/packages/cli/src/commands/types/generate/schema-types/index.test.ts b/packages/cli/src/commands/types/generate/schema-types/index.test.ts new file mode 100644 index 000000000..ad93e2ee3 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/index.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { assertNoLegacyFlags, generateSchemaTypes } from './index'; + +vi.mock('../../../schema/actions', () => ({ + fetchRemoteSchema: vi.fn(async () => ({ + remote: { components: new Map(), componentFolders: new Map(), datasources: new Map() }, + rawComponents: [ + { + id: 1, + name: 'hero', + created_at: '', + updated_at: '', + is_root: false, + is_nestable: true, + schema: { headline: { type: 'text', required: true, pos: 0 } }, + }, + { + id: 2, + name: 'page', + created_at: '', + updated_at: '', + is_root: true, + is_nestable: false, + schema: { body: { type: 'bloks', pos: 0 } }, + }, + ], + rawComponentFolders: [], + rawDatasources: [], + })), +})); + +const written = new Map(); +vi.mock('../../../../utils', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + saveToFile: vi.fn(async (path: string, content: string) => { written.set(path, content); }), + }; +}); + +describe('assertNoLegacyFlags', () => { + it('accepts options that use no legacy-only flag', () => { + expect(() => assertNoLegacyFlags({ typePrefix: 'Sb', separateFiles: true })).not.toThrow(); + }); + + it('rejects --strict', () => { + expect(() => assertNoLegacyFlags({ strict: true })).toThrow(/--strict/); + }); + + it('names every offending flag at once', () => { + expect(() => assertNoLegacyFlags({ strict: true, customFieldsParser: './p.ts', compilerOptions: './c.json' })) + .toThrow(/--strict.*--custom-fields-parser.*--compiler-options/s); + }); +}); + +describe('generateSchemaTypes', () => { + it('writes a single file containing the shared surface', async () => { + written.clear(); + + const result = await generateSchemaTypes({ + space: '295018', + cwd: '/project', + outputDir: '/project/.storyblok/types/295018', + filename: 'storyblok-schema', + }); + + expect(result.files).toEqual(['/project/.storyblok/types/295018/storyblok-schema.d.ts']); + const content = written.get('/project/.storyblok/types/295018/storyblok-schema.d.ts')!; + expect(content).toContain('export type HeroBlockDefinition = {'); + expect(content).toContain('export type PageBlockDefinition = {'); + expect(content).toContain('export type Blocks = HeroBlockDefinition | PageBlockDefinition;'); + expect(content).toContain('export type Block'); + }); + + it('reports custom field types that have no registered plugin', async () => { + written.clear(); + const { fetchRemoteSchema } = await import('../../../schema/actions'); + vi.mocked(fetchRemoteSchema).mockResolvedValueOnce({ + remote: { components: new Map(), componentFolders: new Map(), datasources: new Map() }, + rawComponents: [{ + id: 1, + name: 'hero', + created_at: '', + updated_at: '', + is_root: false, + is_nestable: true, + schema: { accent: { type: 'custom', field_type: 'storyblok-colorpicker', pos: 0 } }, + }], + rawComponentFolders: [], + rawDatasources: [], + } as never); + + const result = await generateSchemaTypes({ + space: '295018', + cwd: '/project', + outputDir: '/project/.storyblok/types/295018', + filename: 'storyblok-schema', + }); + + expect(result.unmappedFieldTypes).toEqual(['storyblok-colorpicker']); + }); + + it('throws when the space has no components', async () => { + const { fetchRemoteSchema } = await import('../../../schema/actions'); + vi.mocked(fetchRemoteSchema).mockResolvedValueOnce({ + remote: { components: new Map(), componentFolders: new Map(), datasources: new Map() }, + rawComponents: [], + rawComponentFolders: [], + rawDatasources: [], + } as never); + + await expect(generateSchemaTypes({ + space: '295018', + cwd: '/project', + outputDir: '/out', + filename: 'storyblok-schema', + })).rejects.toThrow(/no components/i); + }); +}); diff --git a/packages/cli/src/commands/types/generate/schema-types/index.ts b/packages/cli/src/commands/types/generate/schema-types/index.ts new file mode 100644 index 000000000..3ec53edec --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/index.ts @@ -0,0 +1,109 @@ +import { join } from 'pathe'; + +import { CommandError, saveToFile } from '../../../../utils'; +import { buildGroupDisplayPathByUuid } from '../../../schema/folders'; +import { fetchRemoteSchema } from '../../../schema/actions'; +import type { GenerateTypesOptions } from '../constants'; +import { resolveFieldPluginsSource } from './field-plugins'; +import { renderSchemaTypes, renderSeparateFiles, toRelativeImport } from './render'; +import { serializeBlockDefinition } from './serialize'; + +/** Options that only the legacy `json-schema-to-typescript` generator supports. */ +const LEGACY_ONLY_FLAGS: ReadonlyArray = [ + ['strict', '--strict'], + ['customFieldsParser', '--custom-fields-parser'], + ['compilerOptions', '--compiler-options'], +]; + +/** + * Rejects flags that cannot mean anything under `--future-schema`: optionality + * now comes from each field's `required`, custom fields resolve through + * `defineFieldPlugin`, and there is no `json-schema-to-typescript` to configure. + * Failing loudly beats silently ignoring a flag the user believes is applied. + */ +export function assertNoLegacyFlags(options: GenerateTypesOptions): void { + const used = LEGACY_ONLY_FLAGS + .filter(([key]) => options[key] !== undefined) + .map(([, flag]) => flag); + + if (used.length > 0) { + throw new CommandError( + `${used.join(', ')} ${used.length === 1 ? 'is' : 'are'} not supported with --future-schema. ` + + 'Field optionality comes from the schema, custom fields are typed with defineFieldPlugin ' + + '(see --field-plugins), and no JSON-schema compiler is involved.', + ); + } +} + +export interface GenerateSchemaTypesOptions { + space: string; + /** Project root, used to resolve the field-plugins module. */ + cwd: string; + /** Absolute directory the files are written into. */ + outputDir: string; + /** Base file name without extension. */ + filename: string; + separateFiles?: boolean; + typePrefix?: string; + typeSuffix?: string; + fieldPluginsPath?: string; +} + +export interface GenerateSchemaTypesResult { + /** Absolute paths written, in write order. */ + files: string[]; + /** `custom` field types with no registered plugin, typed loosely, warned about. */ + unmappedFieldTypes: string[]; +} + +/** + * Fetches a space's components and writes schema-derived types. + * + * The emitted file declares block *definition* types and derives content types + * from them through `@storyblok/schema`, so every field to value rule stays in + * the library. Datasources are not emitted: `option`/`options` fields resolve + * to `string`/`string[]` regardless, so they cannot affect the types. + */ +export async function generateSchemaTypes( + options: GenerateSchemaTypesOptions, +): Promise { + const { rawComponents, rawComponentFolders } = await fetchRemoteSchema(options.space); + + if (rawComponents.length === 0) { + throw new CommandError(`Space ${options.space} has no components, so there are no types to generate.`); + } + + const displayPathByUuid = buildGroupDisplayPathByUuid(rawComponentFolders); + const blocks = rawComponents.map(component => serializeBlockDefinition(component, { displayPathByUuid })); + + const fieldPlugins = await resolveFieldPluginsSource({ cwd: options.cwd, override: options.fieldPluginsPath }); + const fieldPluginsImportPath = fieldPlugins.kind === 'none' + ? undefined + : toRelativeImport(options.outputDir, fieldPlugins.modulePath); + + const renderOptions = { + blocks, + fieldPlugins, + fieldPluginsImportPath, + space: options.space, + typePrefix: options.typePrefix, + typeSuffix: options.typeSuffix, + }; + + const outputs = options.separateFiles + ? renderSeparateFiles({ ...renderOptions, filename: options.filename }) + : new Map([[`${options.filename}.d.ts`, renderSchemaTypes(renderOptions)]]); + + const files: string[] = []; + for (const [relativePath, content] of outputs) { + const absolutePath = join(options.outputDir, relativePath); + await saveToFile(absolutePath, content); + files.push(absolutePath); + } + + const registered = new Set(fieldPlugins.kind === 'none' ? [] : fieldPlugins.fieldTypes); + const unmappedFieldTypes = [...new Set(blocks.flatMap(block => block.customFieldTypes))] + .filter(fieldType => !registered.has(fieldType)); + + return { files, unmappedFieldTypes }; +} diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts index 5b32d2ac2..1bdb4bc32 100644 --- a/packages/cli/src/utils/index.ts +++ b/packages/cli/src/utils/index.ts @@ -7,6 +7,7 @@ export * from "./array"; export * from "./auth"; export * from "./error/"; export * from "./failure-reason-group"; +export * from "./filesystem"; export * from "./format"; export * from "./object"; export * from "./package"; From df01bc532adcb81fc9f24951987fe742cd177c64 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 28 Jul 2026 17:27:28 +0200 Subject: [PATCH 10/35] fix(cli): use getUI, absolute outputDir, and legacy field-plugins warning 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. --- .../src/commands/types/generate/index.test.ts | 84 +++++++++++++++++-- .../cli/src/commands/types/generate/index.ts | 12 ++- .../types/generate/schema-types/index.test.ts | 2 +- .../types/generate/schema-types/index.ts | 3 +- packages/cli/src/utils/index.ts | 1 - 5 files changed, 87 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/commands/types/generate/index.test.ts b/packages/cli/src/commands/types/generate/index.test.ts index 75bd2d914..5c6a1a113 100644 --- a/packages/cli/src/commands/types/generate/index.test.ts +++ b/packages/cli/src/commands/types/generate/index.test.ts @@ -4,6 +4,42 @@ import { generateStoryblokTypes, generateTypes } from "./actions"; import "../index"; import { typesCommand } from "../command"; import { readComponentsFiles } from "../../components/push/actions"; +import { generateSchemaTypes } from "./schema-types"; + +const uiTitleMock = vi.hoisted(() => vi.fn()); +const uiWarnMock = vi.hoisted(() => vi.fn()); +const uiInfoMock = vi.hoisted(() => vi.fn()); +const uiOkMock = vi.hoisted(() => vi.fn()); +const uiBrMock = vi.hoisted(() => vi.fn()); +const uiSpinnerSucceedMock = vi.hoisted(() => vi.fn()); +const uiSpinnerFailedMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../../lib/ui", async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + getUI: () => ({ + title: uiTitleMock, + warn: uiWarnMock, + info: uiInfoMock, + ok: uiOkMock, + br: uiBrMock, + createSpinner: () => ({ + start: vi.fn(), + succeed: uiSpinnerSucceedMock, + failed: uiSpinnerFailedMock, + }), + }), + }; +}); + +vi.mock("./schema-types", async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + generateSchemaTypes: vi.fn(), + }; +}); const mockResponse = [ { @@ -281,25 +317,57 @@ describe("types generate", () => { ); }); - it('warns that the legacy generator is deprecated when --future-schema is absent', async () => { + it("warns that the legacy generator is deprecated when --future-schema is absent", async () => { vi.mocked(readComponentsFiles).mockResolvedValue(mockSpaceData); vi.mocked(generateStoryblokTypes).mockResolvedValue(true); - vi.mocked(generateTypes).mockResolvedValue('// Generated types'); + vi.mocked(generateTypes).mockResolvedValue("// Generated types"); - await typesCommand.parseAsync(['node', 'test', 'generate', '--space', '12345']); + await typesCommand.parseAsync(["node", "test", "generate", "--space", "12345"]); - expect(konsola.warn).toHaveBeenCalledWith(expect.stringContaining('--future-schema')); + expect(konsola.warn).toHaveBeenCalledWith(expect.stringContaining("--future-schema")); }); }); - describe('future-schema mode', () => { - it('rejects legacy-only flags when --future-schema is used', async () => { - await typesCommand.parseAsync(['node', 'test', 'generate', '--space', '295018', '--future-schema', '--strict']); + describe("future-schema mode", () => { + it("rejects legacy-only flags when --future-schema is used", async () => { + await typesCommand.parseAsync([ + "node", + "test", + "generate", + "--space", + "295018", + "--future-schema", + "--strict", + ]); expect(konsola.error).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('--strict') }), + expect.objectContaining({ message: expect.stringContaining("--strict") }), false, ); }); + + it("generates schema types and reports success, per-file output, and unmapped field types", async () => { + vi.mocked(generateSchemaTypes).mockResolvedValue({ + files: ["/project/.storyblok/types/295018/storyblok-schema.d.ts"], + unmappedFieldTypes: ["storyblok-colorpicker"], + }); + + await typesCommand.parseAsync([ + "node", + "test", + "generate", + "--space", + "295018", + "--future-schema", + ]); + + expect(uiSpinnerSucceedMock).toHaveBeenCalled(); + expect(uiOkMock).toHaveBeenCalledWith( + "/project/.storyblok/types/295018/storyblok-schema.d.ts", + ); + expect(uiWarnMock).toHaveBeenCalledWith(expect.stringContaining("storyblok-colorpicker")); + expect(uiInfoMock).toHaveBeenCalledWith(expect.stringContaining("@storyblok/schema")); + expect(uiSpinnerFailedMock).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/cli/src/commands/types/generate/index.ts b/packages/cli/src/commands/types/generate/index.ts index 094a53067..1879774cb 100644 --- a/packages/cli/src/commands/types/generate/index.ts +++ b/packages/cli/src/commands/types/generate/index.ts @@ -1,7 +1,8 @@ import type { Command } from "commander"; import { join } from "pathe"; import { colorPalette, commands } from "../../../constants"; -import { CommandError, FileSystemError, handleError } from "../../../utils"; +import { CommandError, FileSystemError, handleError, toError } from "../../../utils"; +import { resolvePath } from "../../../utils/filesystem"; import { type ComponentsData, readComponentsFiles } from "../../components/push/actions"; import type { GenerateTypesOptions } from "./constants"; import { typesCommand } from "../command"; @@ -57,7 +58,7 @@ generateCmd.action(async (options: GenerateTypesOptions, command: Command) => { } spinner = ui.createSpinner("Generating types..."); - const outputDir = join(path ?? ".storyblok", "types", space); + const outputDir = resolvePath(path, join("types", space)); const result = await generateSchemaTypes({ space, cwd: process.cwd(), @@ -68,7 +69,7 @@ generateCmd.action(async (options: GenerateTypesOptions, command: Command) => { typeSuffix: options.typeSuffix, fieldPluginsPath: options.fieldPlugins, }); - spinner.succeed(); + spinner.succeed("Generated types"); result.files.forEach((file) => ui.ok(file)); if (result.unmappedFieldTypes.length > 0) { @@ -85,7 +86,7 @@ generateCmd.action(async (options: GenerateTypesOptions, command: Command) => { } catch (error) { spinner?.failed(`Failed to generate types for space ${space}`); ui.br(); - handleError(error as Error, verbose); + handleError(toError(error), verbose); } return; } @@ -94,6 +95,9 @@ generateCmd.action(async (options: GenerateTypesOptions, command: Command) => { "`types generate` without --future-schema is deprecated. The legacy generator does not follow " + "field `required` flags, block whitelists, or nestable/root distinctions. Re-run with --future-schema.", ); + if (options.fieldPlugins !== undefined) { + ui.warn("--field-plugins is ignored without --future-schema."); + } ui.title(`${commands.TYPES}`, colorPalette.TYPES, "Generating types..."); if (!space) { diff --git a/packages/cli/src/commands/types/generate/schema-types/index.test.ts b/packages/cli/src/commands/types/generate/schema-types/index.test.ts index ad93e2ee3..a3c92d427 100644 --- a/packages/cli/src/commands/types/generate/schema-types/index.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/index.test.ts @@ -31,7 +31,7 @@ vi.mock('../../../schema/actions', () => ({ })); const written = new Map(); -vi.mock('../../../../utils', async (importOriginal) => { +vi.mock('../../../../utils/filesystem', async (importOriginal) => { const actual = await importOriginal>(); return { ...actual, diff --git a/packages/cli/src/commands/types/generate/schema-types/index.ts b/packages/cli/src/commands/types/generate/schema-types/index.ts index 3ec53edec..6a2c197ff 100644 --- a/packages/cli/src/commands/types/generate/schema-types/index.ts +++ b/packages/cli/src/commands/types/generate/schema-types/index.ts @@ -1,6 +1,7 @@ import { join } from 'pathe'; -import { CommandError, saveToFile } from '../../../../utils'; +import { CommandError } from '../../../../utils'; +import { saveToFile } from '../../../../utils/filesystem'; import { buildGroupDisplayPathByUuid } from '../../../schema/folders'; import { fetchRemoteSchema } from '../../../schema/actions'; import type { GenerateTypesOptions } from '../constants'; diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts index 1bdb4bc32..5b32d2ac2 100644 --- a/packages/cli/src/utils/index.ts +++ b/packages/cli/src/utils/index.ts @@ -7,7 +7,6 @@ export * from "./array"; export * from "./auth"; export * from "./error/"; export * from "./failure-reason-group"; -export * from "./filesystem"; export * from "./format"; export * from "./object"; export * from "./package"; From 791d2d0960c18d889778dc3fa52f552ce03aceb4 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 28 Jul 2026 17:41:47 +0200 Subject: [PATCH 11/35] test(cli): assert generated schema types behave like hand-written ones Adds a type-level test proving definition types plus @storyblok/schema's BlockContent reproduce hand-written schema types: name-keyed Block 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. --- packages/cli/oxlint.config.ts | 10 +++- .../schema-types/__fixtures__/components.ts | 40 +++++++++++++ .../__fixtures__/expected-types.d.ts | 56 +++++++++++++++++++ .../schema-types/emitted-types.test-d.ts | 52 +++++++++++++++++ .../schema-types/fixture-drift.test.ts | 22 ++++++++ packages/cli/vite.config.ts | 4 ++ 6 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/types/generate/schema-types/__fixtures__/components.ts create mode 100644 packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts create mode 100644 packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts create mode 100644 packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts diff --git a/packages/cli/oxlint.config.ts b/packages/cli/oxlint.config.ts index eb9fb2ac3..3137cd49b 100644 --- a/packages/cli/oxlint.config.ts +++ b/packages/cli/oxlint.config.ts @@ -3,5 +3,13 @@ import { base } from "@storyblok/lint-config"; export default defineConfig({ extends: [base], - ignorePatterns: ["dist/", "node_modules/", "coverage/"], + // The expected-types fixture must stay byte-identical to `renderSchemaTypes`'s + // actual output, which the drift test in `fixture-drift.test.ts` compares it + // against; linting or formatting it would push it out of sync with the renderer. + ignorePatterns: [ + "dist/", + "node_modules/", + "coverage/", + "src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts", + ], }); diff --git a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/components.ts b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/components.ts new file mode 100644 index 000000000..b88255e96 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/components.ts @@ -0,0 +1,40 @@ +/** + * Component payloads shared by the fixture-drift test and the type-level test. + * Chosen to cover every type-level rule the design depends on: a required + * field, a whitelisted `bloks` field, a self-referencing `bloks` field, a + * root/non-nestable block, and a `tab` field. + */ +export const FIXTURE_COMPONENTS = [ + { + id: 1, + name: 'hero', + created_at: '', + updated_at: '', + is_root: false, + is_nestable: true, + schema: { + headline: { type: 'text', required: true, pos: 0 }, + image: { type: 'asset', pos: 1 }, + nested: { type: 'bloks', component_whitelist: ['grid'], pos: 2 }, + general: { type: 'tab', pos: 3 }, + }, + }, + { + id: 2, + name: 'grid', + created_at: '', + updated_at: '', + is_root: false, + is_nestable: true, + schema: { columns: { type: 'bloks', pos: 0 } }, + }, + { + id: 3, + name: 'page', + created_at: '', + updated_at: '', + is_root: true, + is_nestable: false, + schema: { body: { type: 'bloks', pos: 0 } }, + }, +]; diff --git a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts new file mode 100644 index 000000000..c7b904c81 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts @@ -0,0 +1,56 @@ +// This file was generated by the Storyblok CLI. Do not edit by hand. +// Space: 295018 + +import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from '@storyblok/schema'; + +export type HeroBlockDefinition = { + readonly id: number; + created_at: string; + updated_at: string; + name: 'hero'; + is_root: false; + is_nestable: true; + fields: [ + { name: 'headline'; type: 'text'; required: true }, + { name: 'image'; type: 'asset' }, + { name: 'nested'; type: 'bloks'; allow: ['grid'] }, + { name: 'general'; type: 'tab' }, + ]; +}; + +export type GridBlockDefinition = { + readonly id: number; + created_at: string; + updated_at: string; + name: 'grid'; + is_root: false; + is_nestable: true; + fields: [ + { name: 'columns'; type: 'bloks' }, + ]; +}; + +export type PageBlockDefinition = { + readonly id: number; + created_at: string; + updated_at: string; + name: 'page'; + is_root: true; + is_nestable: false; + fields: [ + { name: 'body'; type: 'bloks' }, + ]; +}; + +export type Blocks = HeroBlockDefinition | GridBlockDefinition | PageBlockDefinition; + +export type FieldPlugins = Record; + +export type Schema = { blocks: Blocks; fieldPlugins: FieldPlugins }; + +export type Block = BlockContent, Blocks, FieldPlugins>; + +export type AnyBlock = BlockContent; + +export type Story = InferStory; +export type StoryMapi = InferStoryMapi; diff --git a/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts b/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts new file mode 100644 index 000000000..924827be7 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts @@ -0,0 +1,52 @@ +import { describe, expectTypeOf, it } from 'vitest'; + +import type { AnyBlock, Block, Blocks, Schema } from './__fixtures__/expected-types'; + +/** + * Asserts the *behaviour* of the generated types, not their text. This is the + * test that proves definition types plus `BlockContent` reproduce hand-written + * schema types, the central claim of the design. + */ +describe('generated types', () => { + it('resolves a block content type by name', () => { + expectTypeOf>().toMatchObjectType<{ component: 'hero' }>(); + }); + + it('makes required fields required and others optional', () => { + expectTypeOf>().toHaveProperty('headline').toEqualTypeOf(); + expectTypeOf>().toHaveProperty('image').toBeNullable(); + }); + + it('drops tab fields to a non-value type', () => { + expectTypeOf>().toHaveProperty('general').toBeNullable(); + }); + + it('narrows a whitelisted bloks field to the allowed block only', () => { + type Nested = NonNullable['nested']>; + // `Nested[number]` is a union that distributes over `component`, so the + // union's discriminant is checked directly rather than through + // `toMatchObjectType`, which does not support union-typed `Actual` values. + expectTypeOf().toEqualTypeOf<'grid'>(); + }); + + it('supports recursive blocks', () => { + type Columns = NonNullable['columns']>; + // `grid` nests itself: a `grid`-component member must exist in the union. + expectTypeOf>().not.toBeNever(); + }); + + it('excludes non-nestable blocks from bloks unions', () => { + type Columns = NonNullable['columns']>; + // `page` is is_nestable: false, so it must not appear + expectTypeOf().not.toEqualTypeOf<'page'>(); + expectTypeOf().toEqualTypeOf<'grid' | 'hero'>(); + }); + + it('exposes a Schema shaped for withTypes()', () => { + expectTypeOf().toMatchObjectType<{ blocks: Blocks }>(); + }); + + it('accepts any block through AnyBlock', () => { + expectTypeOf().toHaveProperty('component'); + }); +}); diff --git a/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts b/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts new file mode 100644 index 000000000..82bf09b31 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { FIXTURE_COMPONENTS } from './__fixtures__/components'; +import { renderSchemaTypes } from './render'; +import { serializeBlockDefinition } from './serialize'; + +/** + * The committed fixture is what `emitted-types.test-d.ts` typechecks. If the + * renderer changes, this test fails and the fixture must be regenerated (`-u`), + * which re-runs the type-level assertions against the new output. Without this + * test the fixture could silently rot into a file the CLI no longer produces. + */ +describe('emitted type fixture', () => { + it('matches what the renderer currently produces', async () => { + const blocks = FIXTURE_COMPONENTS.map(component => + serializeBlockDefinition(component as never, { displayPathByUuid: new Map() })); + + const rendered = renderSchemaTypes({ blocks, fieldPlugins: { kind: 'none' }, space: '295018' }); + + await expect(rendered).toMatchFileSnapshot('./__fixtures__/expected-types.d.ts'); + }); +}); diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index 2cc7fc7be..a7ec8c3c5 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -15,6 +15,10 @@ export default defineConfig({ test: { globals: true, setupFiles: ["./test/setup.ts"], + typecheck: { + enabled: true, + include: ["src/**/*.test-d.ts"], + }, coverage: { reporter: ["text", "json", "html"], }, From faaaade2e60debf6c42b697911828eaeb76dd734 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 28 Jul 2026 17:56:22 +0200 Subject: [PATCH 12/35] fix(cli): drop dead InferSchema import, sharpen AnyBlock assertion 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. --- .../__fixtures__/expected-types.d.ts | 2 +- .../schema-types/emitted-types.test-d.ts | 15 ++++++++++----- .../types/generate/schema-types/render.test.ts | 7 +++++-- .../types/generate/schema-types/render.ts | 18 ++++++++++++++++-- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts index c7b904c81..bcddfb5cd 100644 --- a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts +++ b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts @@ -1,7 +1,7 @@ // This file was generated by the Storyblok CLI. Do not edit by hand. // Space: 295018 -import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from '@storyblok/schema'; +import type { BlockContent, MapiStory as InferStoryMapi, Story as InferStory } from '@storyblok/schema'; export type HeroBlockDefinition = { readonly id: number; diff --git a/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts b/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts index 924827be7..521f171d6 100644 --- a/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts +++ b/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts @@ -17,8 +17,8 @@ describe('generated types', () => { expectTypeOf>().toHaveProperty('image').toBeNullable(); }); - it('drops tab fields to a non-value type', () => { - expectTypeOf>().toHaveProperty('general').toBeNullable(); + it('resolves tab fields to an absent, valueless property', () => { + expectTypeOf>().toHaveProperty('general').toEqualTypeOf(); }); it('narrows a whitelisted bloks field to the allowed block only', () => { @@ -37,8 +37,10 @@ describe('generated types', () => { it('excludes non-nestable blocks from bloks unions', () => { type Columns = NonNullable['columns']>; - // `page` is is_nestable: false, so it must not appear - expectTypeOf().not.toEqualTypeOf<'page'>(); + // `page` is is_nestable: false, so it must not appear; this is exact + // equality against the full expected union, so a stray `'page'` member + // fails it (a `not.toEqualTypeOf<'page'>()` check would not: it is + // structurally incapable of failing against a multi-member union). expectTypeOf().toEqualTypeOf<'grid' | 'hero'>(); }); @@ -47,6 +49,9 @@ describe('generated types', () => { }); it('accepts any block through AnyBlock', () => { - expectTypeOf().toHaveProperty('component'); + // The discriminant must be the full union of component names, not a + // single block's, so this fails if `AnyBlock` is ever wrongly narrowed to + // one block (a plain `toHaveProperty('component')` would not catch that). + expectTypeOf().toEqualTypeOf<'hero' | 'grid' | 'page'>(); }); }); diff --git a/packages/cli/src/commands/types/generate/schema-types/render.test.ts b/packages/cli/src/commands/types/generate/schema-types/render.test.ts index 89ce78572..65cd73e02 100644 --- a/packages/cli/src/commands/types/generate/schema-types/render.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/render.test.ts @@ -55,7 +55,8 @@ describe('renderSchemaTypes', () => { space: '295018', }); - expect(output).toContain('import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from \'@storyblok/schema\';'); + expect(output).toContain('import type { BlockContent, MapiStory as InferStoryMapi, Story as InferStory } from \'@storyblok/schema\';'); + expect(output).not.toContain('InferSchema'); expect(output).toContain('export type HeroBlockDefinition = {'); expect(output).toContain('export type Blocks = HeroBlockDefinition | TeaserListBlockDefinition;'); expect(output).toContain('export type FieldPlugins = Record;'); @@ -78,7 +79,7 @@ describe('renderSchemaTypes', () => { expect(output).toContain('export type StoryblokSchema = { blocks: StoryblokBlocks; fieldPlugins: StoryblokFieldPlugins };'); expect(output).toContain('export type StoryblokBlock = BlockContent, StoryblokBlocks, StoryblokFieldPlugins>;'); // the @storyblok/schema import aliases are file-internal and must not be renamed - expect(output).toContain('Schema as InferSchema'); + expect(output).toContain('MapiStory as InferStoryMapi'); }); it('derives FieldPlugins from a defineSchema result', () => { @@ -89,6 +90,7 @@ describe('renderSchemaTypes', () => { space: '295018', }); + expect(output).toContain('import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from \'@storyblok/schema\';'); expect(output).toContain('import type { schema as userSchema } from \'../../schema/schema\';'); expect(output).toContain('export type FieldPlugins = InferSchema[\'fieldPlugins\'];'); }); @@ -101,6 +103,7 @@ describe('renderSchemaTypes', () => { space: '295018', }); + expect(output).toContain('import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from \'@storyblok/schema\';'); expect(output).toContain('import type { fieldPlugins as userFieldPlugins } from \'./plugins\';'); expect(output).toContain('export type FieldPlugins = InferSchema<{ blocks: Record; fieldPlugins: typeof userFieldPlugins }>[\'fieldPlugins\'];'); }); diff --git a/packages/cli/src/commands/types/generate/schema-types/render.ts b/packages/cli/src/commands/types/generate/schema-types/render.ts index 19542ac6f..01ed7a71b 100644 --- a/packages/cli/src/commands/types/generate/schema-types/render.ts +++ b/packages/cli/src/commands/types/generate/schema-types/render.ts @@ -74,6 +74,20 @@ export function renderHeader(space: string): string[] { ]; } +/** + * The `@storyblok/schema` import line. `InferStory`/`InferStoryMapi` back the + * always-emitted `Story`/`StoryMapi` aliases; `InferSchema` is only consumed by + * the `schema`/`record` branches of {@link renderFieldPlugins}, so it is pulled + * in only when needed, a `{ kind: 'none' }` run would otherwise import it + * unused. Names stay alphabetically ordered either way. + */ +function renderSchemaImport(fieldPlugins: FieldPluginsSource): string { + const names = ['BlockContent', 'MapiStory as InferStoryMapi']; + if (fieldPlugins.kind !== 'none') { names.push('Schema as InferSchema'); } + names.push('Story as InferStory'); + return `import type { ${names.join(', ')} } from '@storyblok/schema';`; +} + /** * Renders the `FieldPlugins` declaration plus the import it needs. * @@ -137,7 +151,7 @@ export function renderSchemaTypes(options: RenderOptions): string { const lines = [ ...renderHeader(options.space), - 'import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from \'@storyblok/schema\';', + renderSchemaImport(options.fieldPlugins), ...fieldPlugins.imports, '', ]; @@ -181,7 +195,7 @@ export function renderSeparateFiles(options: RenderOptions & { filename: string const definitionNames = componentNames.map(name => names.definitionByComponent.get(name)!); const mainLines = [ ...renderHeader(options.space), - 'import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from \'@storyblok/schema\';', + renderSchemaImport(options.fieldPlugins), ...fieldPlugins.imports, ...definitionNames.map((typeName, index) => `import type { ${typeName} } from './blocks/${fileNames[index]}';`), '', From a96e3cdd259577e16a8531cac961a58ceceda092 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 28 Jul 2026 18:06:21 +0200 Subject: [PATCH 13/35] test(cli): add end-to-end integration test for --future-schema, document 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 --- adr/0012-schema-derived-type-generation.md | 28 +++++ .../cli/src/commands/types/generate/README.md | 114 ++++++++++++++---- .../__snapshots__/integration.test.ts.snap | 47 ++++++++ .../generate/schema-types/integration.test.ts | 98 +++++++++++++++ 4 files changed, 264 insertions(+), 23 deletions(-) create mode 100644 adr/0012-schema-derived-type-generation.md create mode 100644 packages/cli/src/commands/types/generate/schema-types/__snapshots__/integration.test.ts.snap create mode 100644 packages/cli/src/commands/types/generate/schema-types/integration.test.ts diff --git a/adr/0012-schema-derived-type-generation.md b/adr/0012-schema-derived-type-generation.md new file mode 100644 index 000000000..95d3f6054 --- /dev/null +++ b/adr/0012-schema-derived-type-generation.md @@ -0,0 +1,28 @@ +# 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`, `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()` 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()` forces the definition types into the file regardless, and those same types make `Block` 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`. diff --git a/packages/cli/src/commands/types/generate/README.md b/packages/cli/src/commands/types/generate/README.md index 671f1299c..65e9fd8f6 100644 --- a/packages/cli/src/commands/types/generate/README.md +++ b/packages/cli/src/commands/types/generate/README.md @@ -1,20 +1,91 @@ # Types Generate Command -The `types generate` command generates TypeScript type definitions (`.d.ts` files) for your -Storyblok component schemas. This helps you maintain type safety when working with your Storyblok -content. +The `types generate` command generates TypeScript type definitions for your Storyblok component +schemas. This helps you maintain type safety when working with your Storyblok content. -> [!WARNING] Before generating types, first pull your components using the `components pull` -> command. Make sure to use the same flags (`--separate-files`, `--suffix`) that you used when -> pulling components to ensure the types are generated correctly. +> [!WARNING] **The default (legacy) generator is deprecated.** It ignores field `required` flags, so +> every field type comes out looser than it actually is. It ignores `bloks` field +> `component_group_whitelist`s, so nested block types are not narrowed to the blocks a field +> actually allows. It ignores the nestable versus root distinction, so root-only and block-only +> components type the same way. It also requires a prior `components pull` with matching flags +> (`--separate-files`, `--suffix`), an extra step that can silently drift from what is actually in +> the space. Use `--future-schema` instead. -## Basic Usage +## `--future-schema` (recommended) + +```bash +storyblok types generate --space --future-schema +``` + +Fetches the space's components directly from the Management API, no `components pull` needed, and +generates types derived from the same model `@storyblok/schema` uses, so optionality, block +narrowing, and custom field types are all correct. + +Writes `.storyblok/types//storyblok-schema.d.ts`, exporting: + +| Export | Purpose | +| ----------------------- | ---------------------------------------------------------------- | +| `Block<'hero'>` | The content type for one block, what you type components with | +| `AnyBlock` | Any block, for dispatcher components | +| `Schema` | For `createApiClient(…).withTypes()` | +| `Blocks` | Union of block definition types (plumbing for the helpers above) | +| `Story`, `StoryMapi` | Story types narrowed to your root blocks | +| `BlockDefinition` | One definition type per block | + +```ts +import type { Block, Schema } from "./.storyblok/types/295018/storyblok-schema"; + +interface Props { + block: Block<"hero">; +} + +const client = createApiClient({ accessToken }).withTypes(); +``` + +The generated file imports from `@storyblok/schema`, so install it as a dev dependency: +`npm i -D @storyblok/schema`. It is a types-only import, so it never ships in your application +bundle. + +The generated file is generated code. Exclude it from your linter and formatter the same way you +would exclude any other codegen output, for example by adding `.storyblok/types/` to your +`.eslintignore` or lint tool's ignore patterns, rather than editing the file by hand. + +### Custom field types + +Custom fields need their `field_type` bound to a validator with `defineFieldPlugin` so the generator +knows what value type to emit for them. The CLI looks for a field-plugins module at +`.storyblok/schema/schema.ts` by convention (the path `schema init` writes to), or at an explicit +`--field-plugins ` override. The module must export one of two shapes: + +- a `schema` export, the result of `defineSchema`, whose `fieldPlugins` record is used, or +- a bare `fieldPlugins` export, a record of `defineFieldPlugin` results keyed by name. + +An explicit `--field-plugins` path that does not exist or exports neither shape is an error. If no +module is found at the convention path, generation continues without custom field types, since most +spaces have none. `custom` fields whose `field_type` has no matching plugin fall back to an untyped +value and are reported as a warning after generation, listing every unmapped `field_type`. + +### Supported options + +`--future-schema` honours `--space`, `--filename`, `--separate-files`, `--type-prefix`, +`--type-suffix`, and `--field-plugins`. Prefix and suffix apply to every exported type name, not +just block names, so `--type-prefix Sb` turns `Block` into `SbBlock`, `Schema` into `SbSchema`, and +so on, along with every reference to those names inside the file. + +`--strict`, `--custom-fields-parser`, and `--compiler-options` are legacy-only and error when +combined with `--future-schema`: there is no `json-schema-to-typescript` compiler involved, +optionality comes from the schema's own `required` flags, and custom fields are typed with +`defineFieldPlugin` instead of a parser file. + +## Legacy generator (deprecated) + +### Basic usage ```bash storyblok types generate --space ``` -## Options +### Options | Option | Description | Default | | ------------------------------- | ------------------------------------------------------------ | ----------------------- | @@ -28,7 +99,7 @@ storyblok types generate --space | `--space ` | (Required) The ID of your Storyblok space | - | | `--path ` | Path to the directory containing your component files | `.storyblok/components` | -## Examples +### Examples Generate types for all components: @@ -54,16 +125,13 @@ Generate separate type files for each component: storyblok types generate --space 12345 --separate-files ``` -## File Structure - -The command will generate two files: +### File structure -1. A `storyblok.d.ts` file with base Storyblok types (like `StoryblokAsset`, `StoryblokRichTextDoc`, - etc.) -2. A `storyblok-components.d.ts` file for each space inside the `.storyblok/types/{spaceId}/` - directory with your component types +The command will generate two files: a `storyblok.d.ts` file with base Storyblok types (like +`StoryblokAsset`, `StoryblokRichTextDoc`, etc.) and a `storyblok-components.d.ts` file for each +space inside the `.storyblok/types/{spaceId}/` directory with your component types. -### Example Structure +#### Example structure When running: @@ -84,11 +152,11 @@ The following structure will be created: > **Note:** The `{spaceId}` folder corresponds to the ID of your Storyblok space. The generated > files are always placed under `.storyblok/types/` and `.storyblok/types/{spaceId}/`. -## Notes +### Notes -- The command requires you to be logged in to Storyblok -- The space ID is required -- The generated types are based on your component schemas in Storyblok +- The command requires you to be logged in to Storyblok. +- The space ID is required. +- The generated types are based on your component schemas in Storyblok. - When using `--strict`, the generated types will be more precise but may require more explicit type - handling in your code -- Custom field types can be handled by providing a parser file with `--custom-fields-parser` + handling in your code. +- Custom field types can be handled by providing a parser file with `--custom-fields-parser`. diff --git a/packages/cli/src/commands/types/generate/schema-types/__snapshots__/integration.test.ts.snap b/packages/cli/src/commands/types/generate/schema-types/__snapshots__/integration.test.ts.snap new file mode 100644 index 000000000..9549eac02 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/__snapshots__/integration.test.ts.snap @@ -0,0 +1,47 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`generateSchemaTypes (integration) > should resolve a component group into a folder literal and a group whitelist into an allow list 1`] = ` +"// This file was generated by the Storyblok CLI. Do not edit by hand. +// Space: 295018 + +import type { BlockContent, MapiStory as InferStoryMapi, Story as InferStory } from '@storyblok/schema'; + +export type HeroBlockDefinition = { + readonly id: number; + created_at: string; + updated_at: string; + name: 'hero'; + is_root: false; + is_nestable: true; + folder: 'My Layout'; + fields: [ + { name: 'headline'; type: 'text'; required: true }, + ]; +}; + +export type PageBlockDefinition = { + readonly id: number; + created_at: string; + updated_at: string; + name: 'page'; + is_root: true; + is_nestable: false; + fields: [ + { name: 'body'; type: 'bloks'; allow: [{ folder: 'My Layout' }] }, + ]; +}; + +export type Blocks = HeroBlockDefinition | PageBlockDefinition; + +export type FieldPlugins = Record; + +export type Schema = { blocks: Blocks; fieldPlugins: FieldPlugins }; + +export type Block = BlockContent, Blocks, FieldPlugins>; + +export type AnyBlock = BlockContent; + +export type Story = InferStory; +export type StoryMapi = InferStoryMapi; +" +`; diff --git a/packages/cli/src/commands/types/generate/schema-types/integration.test.ts b/packages/cli/src/commands/types/generate/schema-types/integration.test.ts new file mode 100644 index 000000000..ad38b7730 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/integration.test.ts @@ -0,0 +1,98 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { vol } from 'memfs'; + +import { getMapiClient } from '../../../../api'; +import { generateSchemaTypes } from './index'; + +const SPACE = '295018'; + +const server = setupServer(); + +// `generateSchemaTypes` calls `fetchRemoteSchema`, which reads the MAPI +// singleton set up by the program's preAction hook. Calling the function +// directly (rather than through `command.parseAsync`) bypasses that hook, so +// initialize it here the same way: with a token and region. +getMapiClient({ personalAccessToken: 'test-token', region: 'eu' }); + +const preconditions = { + /** + * A `bloks` field whose `component_group_whitelist` names a group, and a + * component that belongs to that same group. This is the one path no unit + * test covers end to end: it needs the fetched folders and the fetched + * components to line up through `generateSchemaTypes`. + */ + hasComponentsWithAGroupWhitelist() { + server.use( + http.get(`https://mapi.storyblok.com/v1/spaces/${SPACE}/components`, () => + HttpResponse.json({ + components: [ + { + id: 1, + name: 'hero', + created_at: '', + updated_at: '', + is_root: false, + is_nestable: true, + component_group_uuid: 'group-1', + schema: { headline: { type: 'text', required: true, pos: 0 } }, + }, + { + id: 2, + name: 'page', + created_at: '', + updated_at: '', + is_root: true, + is_nestable: false, + schema: { body: { type: 'bloks', component_group_whitelist: ['group-1'], pos: 0 } }, + }, + ], + })), + http.get(`https://mapi.storyblok.com/v1/spaces/${SPACE}/component_groups`, () => + HttpResponse.json({ + component_groups: [{ id: 1, uuid: 'group-1', name: 'My Layout', parent_uuid: null }], + })), + http.get(`https://mapi.storyblok.com/v1/spaces/${SPACE}/datasources`, () => + HttpResponse.json({ datasources: [] })), + ); + }, +}; + +describe('generateSchemaTypes (integration)', () => { + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + + afterEach(() => { + server.resetHandlers(); + vol.reset(); + }); + + afterAll(() => server.close()); + + it('should resolve a component group into a folder literal and a group whitelist into an allow list', async () => { + preconditions.hasComponentsWithAGroupWhitelist(); + vol.fromJSON({ '/project/package.json': '{}' }); + + const result = await generateSchemaTypes({ + space: SPACE, + cwd: '/project', + outputDir: '/project/.storyblok/types/295018', + filename: 'storyblok-schema', + }); + + expect(result.files).toHaveLength(1); + const content = vol.readFileSync(result.files[0], 'utf8') as string; + + // The component group resolves into a `folder` literal on the block that + // belongs to it. + expect(content).toContain('folder: \'My Layout\';'); + // The field's `component_group_whitelist` resolves into an `allow` list + // naming the same folder, proving the fetched folders and the fetched + // components were joined correctly. + expect(content).toContain('allow: [{ folder: \'My Layout\' }]'); + // The shared surface is present: `Blocks` unions both definition types. + expect(content).toContain('export type Blocks = HeroBlockDefinition | PageBlockDefinition;'); + + expect(content).toMatchSnapshot(); + }); +}); From 624db8725a70f647d7cffa2fbcd3db7c8af0aec1 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 28 Jul 2026 18:12:05 +0200 Subject: [PATCH 14/35] docs(cli): document both silent field-plugins degradation cases 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 --- packages/cli/src/commands/types/generate/README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/types/generate/README.md b/packages/cli/src/commands/types/generate/README.md index 65e9fd8f6..fd1ce511d 100644 --- a/packages/cli/src/commands/types/generate/README.md +++ b/packages/cli/src/commands/types/generate/README.md @@ -60,10 +60,14 @@ knows what value type to emit for them. The CLI looks for a field-plugins module - a `schema` export, the result of `defineSchema`, whose `fieldPlugins` record is used, or - a bare `fieldPlugins` export, a record of `defineFieldPlugin` results keyed by name. -An explicit `--field-plugins` path that does not exist or exports neither shape is an error. If no -module is found at the convention path, generation continues without custom field types, since most -spaces have none. `custom` fields whose `field_type` has no matching plugin fall back to an untyped -value and are reported as a warning after generation, listing every unmapped `field_type`. +An explicit `--field-plugins` path that does not exist, or that exists but exports neither a +`schema` nor a `fieldPlugins` shape, is an error. The convention path degrades silently instead: if +`.storyblok/schema/schema.ts` does not exist, or if it exists but exports neither shape, for example +because of a typo in the export name, generation continues without custom field types and prints no +warning about the module itself, since most spaces have none. Check the export name if you placed a +field-plugins module at the convention path and its types are not showing up. `custom` fields whose +`field_type` has no matching plugin fall back to an untyped value and are reported as a warning +after generation, listing every unmapped `field_type`. ### Supported options From 76ee60a584fb2bc4edbf0fc7107f928b10b59564 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 28 Jul 2026 18:35:00 +0200 Subject: [PATCH 15/35] fix(cli): thread FieldPlugins into emitted Story/StoryMapi, reject --suffix under --future-schema Final review pass on the types generate --future-schema feature: - render.ts emitted `Story = InferStory` and `StoryMapi = InferStoryMapi`, both missing the `TFieldPlugins` second argument that `Block` 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() 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 --- .../cli/src/commands/types/generate/README.md | 2 +- .../cli/src/commands/types/generate/index.ts | 2 +- .../schema-types/__fixtures__/components.ts | 9 ++- .../expected-types-with-plugins.d.ts | 58 +++++++++++++++++++ .../__fixtures__/expected-types.d.ts | 5 +- .../schema-types/__fixtures__/plugins.ts | 42 ++++++++++++++ .../__snapshots__/integration.test.ts.snap | 4 +- .../schema-types/emitted-types.test-d.ts | 27 +++++++++ .../schema-types/fixture-drift.test.ts | 20 +++++++ .../types/generate/schema-types/index.test.ts | 13 ++++- .../types/generate/schema-types/index.ts | 7 ++- .../generate/schema-types/render.test.ts | 4 +- .../types/generate/schema-types/render.ts | 6 +- 13 files changed, 183 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types-with-plugins.d.ts create mode 100644 packages/cli/src/commands/types/generate/schema-types/__fixtures__/plugins.ts diff --git a/packages/cli/src/commands/types/generate/README.md b/packages/cli/src/commands/types/generate/README.md index fd1ce511d..234607eff 100644 --- a/packages/cli/src/commands/types/generate/README.md +++ b/packages/cli/src/commands/types/generate/README.md @@ -71,7 +71,7 @@ after generation, listing every unmapped `field_type`. ### Supported options -`--future-schema` honours `--space`, `--filename`, `--separate-files`, `--type-prefix`, +`--future-schema` honours `--space`, `--path`, `--filename`, `--separate-files`, `--type-prefix`, `--type-suffix`, and `--field-plugins`. Prefix and suffix apply to every exported type name, not just block names, so `--type-prefix Sb` turns `Block` into `SbBlock`, `Schema` into `SbSchema`, and so on, along with every reference to those names inside the file. diff --git a/packages/cli/src/commands/types/generate/index.ts b/packages/cli/src/commands/types/generate/index.ts index 1879774cb..c0337a272 100644 --- a/packages/cli/src/commands/types/generate/index.ts +++ b/packages/cli/src/commands/types/generate/index.ts @@ -91,6 +91,7 @@ generateCmd.action(async (options: GenerateTypesOptions, command: Command) => { return; } + ui.title(`${commands.TYPES}`, colorPalette.TYPES, "Generating types..."); ui.warn( "`types generate` without --future-schema is deprecated. The legacy generator does not follow " + "field `required` flags, block whitelists, or nestable/root distinctions. Re-run with --future-schema.", @@ -98,7 +99,6 @@ generateCmd.action(async (options: GenerateTypesOptions, command: Command) => { if (options.fieldPlugins !== undefined) { ui.warn("--field-plugins is ignored without --future-schema."); } - ui.title(`${commands.TYPES}`, colorPalette.TYPES, "Generating types..."); if (!space) { handleError( diff --git a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/components.ts b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/components.ts index b88255e96..341452d13 100644 --- a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/components.ts +++ b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/components.ts @@ -2,7 +2,9 @@ * Component payloads shared by the fixture-drift test and the type-level test. * Chosen to cover every type-level rule the design depends on: a required * field, a whitelisted `bloks` field, a self-referencing `bloks` field, a - * root/non-nestable block, and a `tab` field. + * root/non-nestable block, a `tab` field, and a `custom` field (`page.accent`) + * whose `field_type` matches the plugin registered in `__fixtures__/plugins.ts`, + * so both the plugins-off (fallback) and plugins-on (typed) renders exercise it. */ export const FIXTURE_COMPONENTS = [ { @@ -35,6 +37,9 @@ export const FIXTURE_COMPONENTS = [ updated_at: '', is_root: true, is_nestable: false, - schema: { body: { type: 'bloks', pos: 0 } }, + schema: { + body: { type: 'bloks', pos: 0 }, + accent: { type: 'custom', field_type: 'colorpicker', pos: 1 }, + }, }, ]; diff --git a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types-with-plugins.d.ts b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types-with-plugins.d.ts new file mode 100644 index 000000000..28f072c72 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types-with-plugins.d.ts @@ -0,0 +1,58 @@ +// This file was generated by the Storyblok CLI. Do not edit by hand. +// Space: 295018 + +import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from '@storyblok/schema'; +import type { fieldPlugins as userFieldPlugins } from './plugins'; + +export type HeroBlockDefinition = { + readonly id: number; + created_at: string; + updated_at: string; + name: 'hero'; + is_root: false; + is_nestable: true; + fields: [ + { name: 'headline'; type: 'text'; required: true }, + { name: 'image'; type: 'asset' }, + { name: 'nested'; type: 'bloks'; allow: ['grid'] }, + { name: 'general'; type: 'tab' }, + ]; +}; + +export type GridBlockDefinition = { + readonly id: number; + created_at: string; + updated_at: string; + name: 'grid'; + is_root: false; + is_nestable: true; + fields: [ + { name: 'columns'; type: 'bloks' }, + ]; +}; + +export type PageBlockDefinition = { + readonly id: number; + created_at: string; + updated_at: string; + name: 'page'; + is_root: true; + is_nestable: false; + fields: [ + { name: 'body'; type: 'bloks' }, + { name: 'accent'; type: 'custom'; field_type: 'colorpicker' }, + ]; +}; + +export type Blocks = HeroBlockDefinition | GridBlockDefinition | PageBlockDefinition; + +export type FieldPlugins = InferSchema<{ blocks: Record; fieldPlugins: typeof userFieldPlugins }>['fieldPlugins']; + +export type Schema = { blocks: Blocks; fieldPlugins: FieldPlugins }; + +export type Block = BlockContent, Blocks, FieldPlugins>; + +export type AnyBlock = BlockContent; + +export type Story = InferStory; +export type StoryMapi = InferStoryMapi; diff --git a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts index bcddfb5cd..a3a77e265 100644 --- a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts +++ b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts @@ -39,6 +39,7 @@ export type PageBlockDefinition = { is_nestable: false; fields: [ { name: 'body'; type: 'bloks' }, + { name: 'accent'; type: 'custom'; field_type: 'colorpicker' }, ]; }; @@ -52,5 +53,5 @@ export type Block = BlockContent; -export type Story = InferStory; -export type StoryMapi = InferStoryMapi; +export type Story = InferStory; +export type StoryMapi = InferStoryMapi; diff --git a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/plugins.ts b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/plugins.ts new file mode 100644 index 000000000..e239169c6 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/plugins.ts @@ -0,0 +1,42 @@ +import { defineFieldPlugin } from '@storyblok/schema'; + +/** The value type a `colorpicker` custom field should resolve to. */ +export interface ColorPickerValue { + hex: string; +} + +/** + * Minimal shape of a [Standard Schema](https://standardschema.dev) validator, + * hand-rolled so this fixture needs no validator dependency (no zod, valibot, + * …). `defineFieldPlugin` accepts any Standard Schema, and nothing here + * validates at runtime, only the output type is used. + */ +interface MinimalStandardSchema { + readonly '~standard': { + readonly version: 1; + readonly vendor: string; + readonly validate: (value: unknown) => { readonly value: Output }; + readonly types?: { readonly input: unknown; readonly output: Output }; + }; +} + +const colorPickerSchema: MinimalStandardSchema = { + '~standard': { + version: 1, + vendor: 'fixture', + validate: () => ({ value: { hex: '#000000' } }), + // Standard Schema's own convention: `types` exists only for static + // inference (`StandardSchemaV1.InferOutput`) and is never read at + // runtime, so real implementations (zod, valibot, …) assign it the same + // way, a phantom cast rather than a constructed value. + types: undefined as unknown as { input: unknown; output: ColorPickerValue }, + }, +}; + +/** Registers `colorpicker` as the custom field used by `page.accent` in `components.ts`. */ +export const fieldPlugins = { + colorPicker: defineFieldPlugin({ + fieldType: 'colorpicker', + value: colorPickerSchema, + }), +}; diff --git a/packages/cli/src/commands/types/generate/schema-types/__snapshots__/integration.test.ts.snap b/packages/cli/src/commands/types/generate/schema-types/__snapshots__/integration.test.ts.snap index 9549eac02..5f343edd5 100644 --- a/packages/cli/src/commands/types/generate/schema-types/__snapshots__/integration.test.ts.snap +++ b/packages/cli/src/commands/types/generate/schema-types/__snapshots__/integration.test.ts.snap @@ -41,7 +41,7 @@ export type Block = BlockContent; -export type Story = InferStory; -export type StoryMapi = InferStoryMapi; +export type Story = InferStory; +export type StoryMapi = InferStoryMapi; " `; diff --git a/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts b/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts index 521f171d6..3cd9020a4 100644 --- a/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts +++ b/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts @@ -1,6 +1,8 @@ +import type { Block as SchemaBlock } from '@storyblok/schema'; import { describe, expectTypeOf, it } from 'vitest'; import type { AnyBlock, Block, Blocks, Schema } from './__fixtures__/expected-types'; +import type { Block as PluginBlock, Story as PluginStory } from './__fixtures__/expected-types-with-plugins'; /** * Asserts the *behaviour* of the generated types, not their text. This is the @@ -48,6 +50,12 @@ describe('generated types', () => { expectTypeOf().toMatchObjectType<{ blocks: Blocks }>(); }); + it('emits Blocks that satisfy withTypes\'s StoryblokTypesConfig constraint', () => { + // `createApiClient(...).withTypes()` accepts `{ components: Block } | { blocks: Block }` + // (`packages/capi-client/src/client.ts`), so the emitted union must extend `Block`. + expectTypeOf().toExtend(); + }); + it('accepts any block through AnyBlock', () => { // The discriminant must be the full union of component names, not a // single block's, so this fails if `AnyBlock` is ever wrongly narrowed to @@ -55,3 +63,22 @@ describe('generated types', () => { expectTypeOf().toEqualTypeOf<'hero' | 'grid' | 'page'>(); }); }); + +/** + * Renders `__fixtures__/components.ts` again with `__fixtures__/plugins.ts` + * registered (see `fixture-drift.test.ts`, "matches what the renderer + * produces with field plugins registered"). Every fixture above this point + * uses `fieldPlugins: { kind: 'none' }`, under which `FieldPlugins` is + * `Record` and `InferStory` and + * `InferStory` are indistinguishable. This block is the + * only one that can catch `render.ts` regressing to the single-argument form. + */ +describe('emitted Story/StoryMapi thread FieldPlugins', () => { + it('resolves a registered custom field through Block', () => { + expectTypeOf['accent']>>().toHaveProperty('hex').toEqualTypeOf(); + }); + + it('resolves a registered custom field through Story, not just through Block', () => { + expectTypeOf>().toHaveProperty('hex').toEqualTypeOf(); + }); +}); diff --git a/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts b/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts index 82bf09b31..ce220f2c0 100644 --- a/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts @@ -19,4 +19,24 @@ describe('emitted type fixture', () => { await expect(rendered).toMatchFileSnapshot('./__fixtures__/expected-types.d.ts'); }); + + /** + * Same components, but with `__fixtures__/plugins.ts` registered as the + * `colorpicker` field plugin. `emitted-types.test-d.ts` typechecks this + * fixture to prove `custom` fields resolve through `Story`/`StoryMapi`, not + * just through `Block`, see the "emitted `Story`" describe block. + */ + it('matches what the renderer produces with field plugins registered', async () => { + const blocks = FIXTURE_COMPONENTS.map(component => + serializeBlockDefinition(component as never, { displayPathByUuid: new Map() })); + + const rendered = renderSchemaTypes({ + blocks, + fieldPlugins: { kind: 'record', modulePath: '/abs/plugins.ts', fieldTypes: ['colorpicker'] }, + fieldPluginsImportPath: './plugins', + space: '295018', + }); + + await expect(rendered).toMatchFileSnapshot('./__fixtures__/expected-types-with-plugins.d.ts'); + }); }); diff --git a/packages/cli/src/commands/types/generate/schema-types/index.test.ts b/packages/cli/src/commands/types/generate/schema-types/index.test.ts index a3c92d427..eeba30245 100644 --- a/packages/cli/src/commands/types/generate/schema-types/index.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/index.test.ts @@ -48,9 +48,18 @@ describe('assertNoLegacyFlags', () => { expect(() => assertNoLegacyFlags({ strict: true })).toThrow(/--strict/); }); + it('rejects --suffix', () => { + expect(() => assertNoLegacyFlags({ suffix: 'v1' })).toThrow(/--suffix/); + }); + it('names every offending flag at once', () => { - expect(() => assertNoLegacyFlags({ strict: true, customFieldsParser: './p.ts', compilerOptions: './c.json' })) - .toThrow(/--strict.*--custom-fields-parser.*--compiler-options/s); + expect(() => assertNoLegacyFlags({ + strict: true, + customFieldsParser: './p.ts', + compilerOptions: './c.json', + suffix: 'v1', + })) + .toThrow(/--strict.*--custom-fields-parser.*--compiler-options.*--suffix/s); }); }); diff --git a/packages/cli/src/commands/types/generate/schema-types/index.ts b/packages/cli/src/commands/types/generate/schema-types/index.ts index 6a2c197ff..fb40598a9 100644 --- a/packages/cli/src/commands/types/generate/schema-types/index.ts +++ b/packages/cli/src/commands/types/generate/schema-types/index.ts @@ -14,13 +14,16 @@ const LEGACY_ONLY_FLAGS: ReadonlyArray { expect(output).toContain('export type Schema = { blocks: Blocks; fieldPlugins: FieldPlugins };'); expect(output).toContain('export type Block = BlockContent, Blocks, FieldPlugins>;'); expect(output).toContain('export type AnyBlock = BlockContent;'); - expect(output).toContain('export type Story = InferStory;'); - expect(output).toContain('export type StoryMapi = InferStoryMapi;'); + expect(output).toContain('export type Story = InferStory;'); + expect(output).toContain('export type StoryMapi = InferStoryMapi;'); }); it('renames internal references consistently with prefixed declarations', () => { diff --git a/packages/cli/src/commands/types/generate/schema-types/render.ts b/packages/cli/src/commands/types/generate/schema-types/render.ts index 01ed7a71b..50392b9a4 100644 --- a/packages/cli/src/commands/types/generate/schema-types/render.ts +++ b/packages/cli/src/commands/types/generate/schema-types/render.ts @@ -55,6 +55,8 @@ export function buildNames(componentNames: string[], options: NameOptions): Emit anyBlock: decorate('AnyBlock', options), story: decorate('Story', options), storyMapi: decorate('StoryMapi', options), + // Assumes component names are unique, which MAPI enforces per space, so two + // components can never collapse into the same map entry here. definitionByComponent: new Map(componentNames.map((name, i) => [name, decorate(bases[i], options)])), }; } @@ -137,8 +139,8 @@ function renderSurface(names: EmittedNames, definitionNames: string[], fieldPlug '', `export type ${names.anyBlock} = BlockContent<${names.blocks}, ${names.blocks}, ${names.fieldPlugins}>;`, '', - `export type ${names.story} = InferStory<${names.blocks}>;`, - `export type ${names.storyMapi} = InferStoryMapi<${names.blocks}>;`, + `export type ${names.story} = InferStory<${names.blocks}, ${names.fieldPlugins}>;`, + `export type ${names.storyMapi} = InferStoryMapi<${names.blocks}, ${names.fieldPlugins}>;`, '', ]; } From 2aafe0de65ba841c8d5b9487fe5a35b0ebb7f6f9 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 28 Jul 2026 19:02:57 +0200 Subject: [PATCH 16/35] refactor(cli): extract the --future-schema branch and trim its docs 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 --- .../cli/src/commands/types/generate/README.md | 145 +++++------------- .../commands/types/generate/future-schema.ts | 73 +++++++++ .../cli/src/commands/types/generate/index.ts | 55 +------ 3 files changed, 120 insertions(+), 153 deletions(-) create mode 100644 packages/cli/src/commands/types/generate/future-schema.ts diff --git a/packages/cli/src/commands/types/generate/README.md b/packages/cli/src/commands/types/generate/README.md index 234607eff..fe232096f 100644 --- a/packages/cli/src/commands/types/generate/README.md +++ b/packages/cli/src/commands/types/generate/README.md @@ -1,109 +1,41 @@ # Types Generate Command -The `types generate` command generates TypeScript type definitions for your Storyblok component -schemas. This helps you maintain type safety when working with your Storyblok content. +The `types generate` command generates TypeScript type definitions (`.d.ts` files) for your +Storyblok component schemas. This helps you maintain type safety when working with your Storyblok +content. -> [!WARNING] **The default (legacy) generator is deprecated.** It ignores field `required` flags, so -> every field type comes out looser than it actually is. It ignores `bloks` field -> `component_group_whitelist`s, so nested block types are not narrowed to the blocks a field -> actually allows. It ignores the nestable versus root distinction, so root-only and block-only -> components type the same way. It also requires a prior `components pull` with matching flags -> (`--separate-files`, `--suffix`), an extra step that can silently drift from what is actually in -> the space. Use `--future-schema` instead. +> [!WARNING] The default (legacy) generator is deprecated: it ignores field `required` flags, +> `bloks` field whitelists, and the nestable versus root distinction. Use `--future-schema` instead, +> which derives types from the space schema via `@storyblok/schema`. -## `--future-schema` (recommended) +> [!WARNING] Before generating types with the legacy generator, first pull your components using the +> `components pull` command. Make sure to use the same flags (`--separate-files`, `--suffix`) that +> you used when pulling components to ensure the types are generated correctly. `--future-schema` +> fetches components itself and needs no prior pull. -```bash -storyblok types generate --space --future-schema -``` - -Fetches the space's components directly from the Management API, no `components pull` needed, and -generates types derived from the same model `@storyblok/schema` uses, so optionality, block -narrowing, and custom field types are all correct. - -Writes `.storyblok/types//storyblok-schema.d.ts`, exporting: - -| Export | Purpose | -| ----------------------- | ---------------------------------------------------------------- | -| `Block<'hero'>` | The content type for one block, what you type components with | -| `AnyBlock` | Any block, for dispatcher components | -| `Schema` | For `createApiClient(…).withTypes()` | -| `Blocks` | Union of block definition types (plumbing for the helpers above) | -| `Story`, `StoryMapi` | Story types narrowed to your root blocks | -| `BlockDefinition` | One definition type per block | - -```ts -import type { Block, Schema } from "./.storyblok/types/295018/storyblok-schema"; - -interface Props { - block: Block<"hero">; -} - -const client = createApiClient({ accessToken }).withTypes(); -``` - -The generated file imports from `@storyblok/schema`, so install it as a dev dependency: -`npm i -D @storyblok/schema`. It is a types-only import, so it never ships in your application -bundle. - -The generated file is generated code. Exclude it from your linter and formatter the same way you -would exclude any other codegen output, for example by adding `.storyblok/types/` to your -`.eslintignore` or lint tool's ignore patterns, rather than editing the file by hand. - -### Custom field types - -Custom fields need their `field_type` bound to a validator with `defineFieldPlugin` so the generator -knows what value type to emit for them. The CLI looks for a field-plugins module at -`.storyblok/schema/schema.ts` by convention (the path `schema init` writes to), or at an explicit -`--field-plugins ` override. The module must export one of two shapes: - -- a `schema` export, the result of `defineSchema`, whose `fieldPlugins` record is used, or -- a bare `fieldPlugins` export, a record of `defineFieldPlugin` results keyed by name. - -An explicit `--field-plugins` path that does not exist, or that exists but exports neither a -`schema` nor a `fieldPlugins` shape, is an error. The convention path degrades silently instead: if -`.storyblok/schema/schema.ts` does not exist, or if it exists but exports neither shape, for example -because of a typo in the export name, generation continues without custom field types and prints no -warning about the module itself, since most spaces have none. Check the export name if you placed a -field-plugins module at the convention path and its types are not showing up. `custom` fields whose -`field_type` has no matching plugin fall back to an untyped value and are reported as a warning -after generation, listing every unmapped `field_type`. - -### Supported options - -`--future-schema` honours `--space`, `--path`, `--filename`, `--separate-files`, `--type-prefix`, -`--type-suffix`, and `--field-plugins`. Prefix and suffix apply to every exported type name, not -just block names, so `--type-prefix Sb` turns `Block` into `SbBlock`, `Schema` into `SbSchema`, and -so on, along with every reference to those names inside the file. - -`--strict`, `--custom-fields-parser`, and `--compiler-options` are legacy-only and error when -combined with `--future-schema`: there is no `json-schema-to-typescript` compiler involved, -optionality comes from the schema's own `required` flags, and custom fields are typed with -`defineFieldPlugin` instead of a parser file. - -## Legacy generator (deprecated) - -### Basic usage +## Basic Usage ```bash storyblok types generate --space ``` -### Options +## Options -| Option | Description | Default | -| ------------------------------- | ------------------------------------------------------------ | ----------------------- | -| `--sf, --separate-files` | Generate separate type definition files for each component | `false` | -| `--strict` | Enable strict mode with no loose typing | `false` | -| `--filename ` | File name for the generated type files | `storyblok` | -| `--type-prefix ` | Prefix to be prepended to all generated component type names | - | -| `--suffix ` | Suffix for component names | - | -| `--custom-fields-parser ` | Path to the parser file for Custom Field Types | - | -| `--compiler-options ` | Path to the compiler options from json-schema-to-typescript | - | -| `--space ` | (Required) The ID of your Storyblok space | - | -| `--path ` | Path to the directory containing your component files | `.storyblok/components` | +| Option | Description | Default | +| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| `--future-schema` | Generate types derived from the space schema instead of the deprecated legacy generator. Not compatible with `--strict`, `--suffix`, `--custom-fields-parser`, or `--compiler-options` | `false` | +| `--field-plugins ` | Path to a module exporting your `defineFieldPlugin` declarations, used to type `custom` fields. Requires `--future-schema` | `.storyblok/schema/schema.ts` | +| `--sf, --separate-files` | Generate separate type definition files for each component | `false` | +| `--strict` | Enable strict mode with no loose typing | `false` | +| `--filename ` | File name for the generated type files | `storyblok` | +| `--type-prefix ` | Prefix to be prepended to all generated component type names | - | +| `--suffix ` | Suffix for component names | - | +| `--custom-fields-parser ` | Path to the parser file for Custom Field Types | - | +| `--compiler-options ` | Path to the compiler options from json-schema-to-typescript | - | +| `--space ` | (Required) The ID of your Storyblok space | - | +| `--path ` | Path to the directory containing your component files | `.storyblok/components` | -### Examples +## Examples Generate types for all components: @@ -129,13 +61,16 @@ Generate separate type files for each component: storyblok types generate --space 12345 --separate-files ``` -### File structure +## File Structure + +The command will generate two files: -The command will generate two files: a `storyblok.d.ts` file with base Storyblok types (like -`StoryblokAsset`, `StoryblokRichTextDoc`, etc.) and a `storyblok-components.d.ts` file for each -space inside the `.storyblok/types/{spaceId}/` directory with your component types. +1. A `storyblok.d.ts` file with base Storyblok types (like `StoryblokAsset`, `StoryblokRichTextDoc`, + etc.) +2. A `storyblok-components.d.ts` file for each space inside the `.storyblok/types/{spaceId}/` + directory with your component types -#### Example structure +### Example Structure When running: @@ -156,11 +91,11 @@ The following structure will be created: > **Note:** The `{spaceId}` folder corresponds to the ID of your Storyblok space. The generated > files are always placed under `.storyblok/types/` and `.storyblok/types/{spaceId}/`. -### Notes +## Notes -- The command requires you to be logged in to Storyblok. -- The space ID is required. -- The generated types are based on your component schemas in Storyblok. +- The command requires you to be logged in to Storyblok +- The space ID is required +- The generated types are based on your component schemas in Storyblok - When using `--strict`, the generated types will be more precise but may require more explicit type - handling in your code. -- Custom field types can be handled by providing a parser file with `--custom-fields-parser`. + handling in your code +- Custom field types can be handled by providing a parser file with `--custom-fields-parser` diff --git a/packages/cli/src/commands/types/generate/future-schema.ts b/packages/cli/src/commands/types/generate/future-schema.ts new file mode 100644 index 000000000..7a5367ceb --- /dev/null +++ b/packages/cli/src/commands/types/generate/future-schema.ts @@ -0,0 +1,73 @@ +import { join } from 'pathe'; + +import { colorPalette, commands } from '../../../constants'; +import { CommandError, handleError, toError } from '../../../utils'; +import { resolvePath } from '../../../utils/filesystem'; +import type { CLISpinner } from '../../../lib/ui'; +import { getUI } from '../../../lib/ui'; +import type { GenerateTypesOptions } from './constants'; +import { assertNoLegacyFlags, generateSchemaTypes } from './schema-types'; + +export interface FutureSchemaCommandOptions { + /** Command options, including the legacy-only flags this mode rejects. */ + options: GenerateTypesOptions; + /** Global options resolved from Commander and the config file. */ + globals: { + space?: string; + path?: string; + filename?: string; + separateFiles?: boolean; + verbose?: boolean; + }; +} + +/** + * Runs `types generate --future-schema`: fetches the space's components and + * writes schema-derived types. + * + * Owns the user-facing output for this mode so the command action stays a thin + * branch. Errors are handled here rather than rethrown, matching the legacy + * path's behaviour. + */ +export async function runFutureSchemaTypes({ options, globals }: FutureSchemaCommandOptions): Promise { + const { space, path, filename, separateFiles, verbose } = globals; + const ui = getUI(); + ui.title(`${commands.TYPES}`, colorPalette.TYPES, 'Generating types from schema...'); + + let spinner: CLISpinner | undefined; + try { + assertNoLegacyFlags(options); + if (!space) { + throw new CommandError('Please provide the space as argument --space SPACE_ID.'); + } + + spinner = ui.createSpinner('Generating types...'); + const result = await generateSchemaTypes({ + space, + cwd: process.cwd(), + outputDir: resolvePath(path, join('types', space)), + filename: filename ?? 'storyblok-schema', + separateFiles, + typePrefix: options.typePrefix, + typeSuffix: options.typeSuffix, + fieldPluginsPath: options.fieldPlugins, + }); + spinner.succeed('Generated types'); + + result.files.forEach(file => ui.ok(file)); + if (result.unmappedFieldTypes.length > 0) { + ui.warn( + `No field plugin registered for: ${result.unmappedFieldTypes.join(', ')}. ` + + 'These custom fields fall back to an untyped value. Declare them with defineFieldPlugin ' + + 'and point --field-plugins at the module (or place it at .storyblok/schema/schema.ts).', + ); + } + ui.info('The generated types import from `@storyblok/schema`. Install it as a dev dependency: `npm i -D @storyblok/schema`.'); + ui.br(); + } + catch (error) { + spinner?.failed(`Failed to generate types for space ${space}`); + ui.br(); + handleError(toError(error), verbose); + } +} diff --git a/packages/cli/src/commands/types/generate/index.ts b/packages/cli/src/commands/types/generate/index.ts index c0337a272..c2f516ffc 100644 --- a/packages/cli/src/commands/types/generate/index.ts +++ b/packages/cli/src/commands/types/generate/index.ts @@ -1,18 +1,15 @@ import type { Command } from "commander"; -import { join } from "pathe"; import { colorPalette, commands } from "../../../constants"; -import { CommandError, FileSystemError, handleError, toError } from "../../../utils"; -import { resolvePath } from "../../../utils/filesystem"; +import { CommandError, FileSystemError, handleError } from "../../../utils"; import { type ComponentsData, readComponentsFiles } from "../../components/push/actions"; import type { GenerateTypesOptions } from "./constants"; import { typesCommand } from "../command"; import { generateStoryblokTypes, generateTypes, saveTypesToComponentsFile } from "./actions"; import { readDatasourcesFiles } from "../../datasources/push/actions"; import type { SpaceDatasourcesData } from "../../../commands/datasources/constants"; -import type { CLISpinner } from "../../../lib/ui"; import { getUI } from "../../../lib/ui"; import { getLogger } from "../../../lib/logger/logger"; -import { assertNoLegacyFlags, generateSchemaTypes } from "./schema-types"; +import { runFutureSchemaTypes } from "./future-schema"; const generateCmd = typesCommand .command("generate") @@ -35,10 +32,7 @@ const generateCmd = typesCommand "path to the compiler options from json-schema-to-typescript", ) .option("-s, --space ", "space ID") - .option( - "--future-schema", - "Generate types from the space schema (accurate optionality, block narrowing, and custom field types)", - ) + .option("--future-schema", "Generate types from the space schema") .option( "--field-plugins ", "Path to a module exporting your defineFieldPlugin declarations (default: .storyblok/schema/schema.ts)", @@ -49,45 +43,10 @@ generateCmd.action(async (options: GenerateTypesOptions, command: Command) => { const { space, path, verbose, suffix, filename, separateFiles } = command.optsWithGlobals(); if (options.futureSchema) { - ui.title(`${commands.TYPES}`, colorPalette.TYPES, "Generating types from schema..."); - let spinner: CLISpinner | undefined; - try { - assertNoLegacyFlags(options); - if (!space) { - throw new CommandError("Please provide the space as argument --space SPACE_ID."); - } - - spinner = ui.createSpinner("Generating types..."); - const outputDir = resolvePath(path, join("types", space)); - const result = await generateSchemaTypes({ - space, - cwd: process.cwd(), - outputDir, - filename: filename ?? "storyblok-schema", - separateFiles, - typePrefix: options.typePrefix, - typeSuffix: options.typeSuffix, - fieldPluginsPath: options.fieldPlugins, - }); - spinner.succeed("Generated types"); - - result.files.forEach((file) => ui.ok(file)); - if (result.unmappedFieldTypes.length > 0) { - ui.warn( - `No field plugin registered for: ${result.unmappedFieldTypes.join(", ")}. ` + - "These custom fields fall back to an untyped value. Declare them with defineFieldPlugin " + - "and point --field-plugins at the module (or place it at .storyblok/schema/schema.ts).", - ); - } - ui.info( - "The generated types import from `@storyblok/schema`. Install it as a dev dependency: `npm i -D @storyblok/schema`.", - ); - ui.br(); - } catch (error) { - spinner?.failed(`Failed to generate types for space ${space}`); - ui.br(); - handleError(toError(error), verbose); - } + await runFutureSchemaTypes({ + options, + globals: { space, path, filename, separateFiles, verbose }, + }); return; } From 147b286dbab0c60b3db207e1bf4903cddb30d9b8 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Wed, 29 Jul 2026 08:55:22 +0200 Subject: [PATCH 17/35] fix(cli): stop narrowing block fields whose restriction is disabled 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 --- .../src/commands/schema/init/generate-code.ts | 14 ++- .../src/commands/schema/to-dsl-field.test.ts | 18 ++++ .../cli/src/commands/schema/to-dsl-field.ts | 17 ++-- .../generate/schema-types/serialize.test.ts | 90 +++++++++++++++++-- .../types/generate/schema-types/serialize.ts | 70 +++++++++++++-- 5 files changed, 179 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/commands/schema/init/generate-code.ts b/packages/cli/src/commands/schema/init/generate-code.ts index 27dcbc43e..3d5343126 100644 --- a/packages/cli/src/commands/schema/init/generate-code.ts +++ b/packages/cli/src/commands/schema/init/generate-code.ts @@ -16,6 +16,7 @@ import { sortSchemaByPos, stripKeys, toKebabCase, + toSafeIdentifier, } from "../utils"; export { componentFileName, resolveFileNames, resolveVarNames } from "../utils"; @@ -26,17 +27,14 @@ const FIELD_STRIP_KEYS = new Set(["id", "pos"]); /** * Converts an arbitrary name into a valid camelCase JS identifier. * `slugify` reduces the input to `[a-z0-9_-]` (symbols stripped, spaces → `-`); - * we then camelCase across `_`/`-` runs and guard against an empty or - * leading-digit result so the output is always usable as an identifier. + * we then camelCase across `_`/`-` runs and hand the result to + * {@link toSafeIdentifier}, which guards the empty and leading-digit cases. */ function toCamelCaseIdentifier(str: string): string { const camel = slugify(str) .replace(/^[_-]+/, "") .replace(/[_-]+(.)/g, (_, char: string) => char.toUpperCase()); - if (!camel) { - return "_"; - } - return /^\d/.test(camel) ? `_${camel}` : camel; + return toSafeIdentifier(camel); } /** Returns the variable name for a component. e.g. `'teaser_list'` -> `'teaserListBlock'` */ @@ -218,8 +216,8 @@ function collectWhitelistFolderVars( if (!isRecord(field)) { continue; } - // Mirrors `toDslField`: a disabled restriction emits no `allow`, so its - // folders must not be imported either. + // A disabled restriction emits no `allow`, so importing its folder ref would + // leave an unused import in the user's generated project. if (field.restrict_components === false) { continue; } diff --git a/packages/cli/src/commands/schema/to-dsl-field.test.ts b/packages/cli/src/commands/schema/to-dsl-field.test.ts index d6cb6d716..3fca5858f 100644 --- a/packages/cli/src/commands/schema/to-dsl-field.test.ts +++ b/packages/cli/src/commands/schema/to-dsl-field.test.ts @@ -19,6 +19,24 @@ describe('toDslField', () => { expect(result).toEqual({ type: 'bloks', allow: [{ folder: 'Layout' }] }); }); + it('drops an inert whitelist and keeps the flag when the restriction is off', () => { + // Mapping the whitelist to `allow` would make the next push re-derive + // `restrict_components: true`, switching a disabled restriction back on. + const names = toDslField({ type: 'bloks', restrict_components: false, component_whitelist: ['hero'] }); + const groups = toDslField( + { + type: 'bloks', + restrict_components: false, + restrict_type: 'groups', + component_group_whitelist: ['uuid-1'], + }, + () => ({ folder: 'Layout' }), + ); + + expect(names).toEqual({ type: 'bloks', restrict_components: false }); + expect(groups).toEqual({ type: 'bloks', restrict_components: false, restrict_type: 'groups' }); + }); + it('prefers block names over a group whitelist when both are present', () => { const result = toDslField( { type: 'bloks', component_whitelist: ['hero'], component_group_whitelist: ['uuid-1'] }, diff --git a/packages/cli/src/commands/schema/to-dsl-field.ts b/packages/cli/src/commands/schema/to-dsl-field.ts index ca08cf436..5abc27f84 100644 --- a/packages/cli/src/commands/schema/to-dsl-field.ts +++ b/packages/cli/src/commands/schema/to-dsl-field.ts @@ -38,14 +38,6 @@ export function resolveGroupWhitelistEntries( * `component_group_whitelist` and an empty `component_whitelist: []` on the * wire; the group whitelist takes precedence, so `allow` is only sourced from * `component_whitelist` when it holds actual block names. - * - * `restrict_components: false` disables the restriction while the space may still - * store a stale whitelist. Emitting that inactive list as `allow` would make - * `schema push` re-derive `restrict_components: true` and silently switch the - * restriction back on, changing what editors may insert. So a disabled - * restriction keeps its flag and drops the whitelist: the flag round-trips - * losslessly, at the cost of discarding a list that is not in force anyway. An - * absent `restrict_components` counts as active, matching backend enforcement. */ export function toDslField( field: Record, @@ -65,8 +57,15 @@ export function toDslField( ? undefined : resolveGroupWhitelistEntries(component_group_whitelist, resolveGroupEntry); const hasBlockNames = !restrictionDisabled - && Array.isArray(component_whitelist) && component_whitelist.length > 0; + && Array.isArray(component_whitelist) + && component_whitelist.length > 0; if (restrictionDisabled) { + // The restriction is switched off, so the whitelist beside it is inert. + // Mapping it to `allow` would make `defineField` re-derive + // `restrict_components: true` on the next push and silently switch a + // disabled restriction back on. The flag is preserved instead, and the + // inactive whitelist is dropped: it is not in force, and keeping it is what + // causes the flip. out.restrict_components = false; if (restrict_type !== undefined) { out.restrict_type = restrict_type; } } diff --git a/packages/cli/src/commands/types/generate/schema-types/serialize.test.ts b/packages/cli/src/commands/types/generate/schema-types/serialize.test.ts index 7d488c020..183f783fe 100644 --- a/packages/cli/src/commands/types/generate/schema-types/serialize.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/serialize.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest'; +import type { Component } from '../../../../types'; +import type { SerializeContext } from './serialize'; import { serializeBlockDefinition } from './serialize'; -function component(overrides: Record = {}) { +function component(overrides: Partial = {}): Component { return { id: 1, name: 'hero', @@ -12,10 +14,19 @@ function component(overrides: Record = {}) { is_nestable: true, schema: {}, ...overrides, - } as never; + }; } -const emptyContext = { displayPathByUuid: new Map() }; +/** Context for a space whose only component is the one under test. */ +function context(overrides: Partial = {}): SerializeContext { + return { + displayPathByUuid: new Map(), + knownBlockNames: new Set(['hero']), + ...overrides, + }; +} + +const emptyContext = context(); describe('serializeBlockDefinition', () => { it('widens id/created_at/updated_at and keeps name/is_root/is_nestable literal', () => { @@ -62,7 +73,7 @@ describe('serializeBlockDefinition', () => { it('maps component_whitelist to an allow tuple', () => { const result = serializeBlockDefinition(component({ schema: { body: { type: 'bloks', component_whitelist: ['grid', 'teaser'] } }, - }), emptyContext); + }), context({ knownBlockNames: new Set(['hero', 'grid', 'teaser']) })); expect(result.definitionBody).toContain('{ name: \'body\'; type: \'bloks\'; allow: [\'grid\', \'teaser\'] }'); }); @@ -70,7 +81,7 @@ describe('serializeBlockDefinition', () => { it('maps a group whitelist to allow folder entries using display paths', () => { const result = serializeBlockDefinition(component({ schema: { body: { type: 'bloks', component_group_whitelist: ['uuid-1'] } }, - }), { displayPathByUuid: new Map([['uuid-1', 'My Layout/Heros']]) }); + }), context({ displayPathByUuid: new Map([['uuid-1', 'My Layout/Heros']]) })); expect(result.definitionBody).toContain('allow: [{ folder: \'My Layout/Heros\' }]'); }); @@ -87,7 +98,7 @@ describe('serializeBlockDefinition', () => { it('emits the block folder literal from its component group', () => { const result = serializeBlockDefinition( component({ component_group_uuid: 'uuid-1' }), - { displayPathByUuid: new Map([['uuid-1', 'My Layout']]) }, + context({ displayPathByUuid: new Map([['uuid-1', 'My Layout']]) }), ); expect(result.definitionBody).toContain(' folder: \'My Layout\';'); @@ -117,6 +128,73 @@ describe('serializeBlockDefinition', () => { .toContain('is_nestable: false;'); }); + it('omits allow when the restriction is switched off, for names and for groups', () => { + const names = serializeBlockDefinition(component({ + schema: { + body: { type: 'bloks', restrict_components: false, component_whitelist: ['grid'] }, + }, + }), context({ knownBlockNames: new Set(['hero', 'grid']) })); + + // The live case: Storyblok strips a stale name whitelist when the flag is + // false, but never strips a group whitelist, so this state does reach us. + const groups = serializeBlockDefinition(component({ + schema: { + body: { + type: 'bloks', + restrict_components: false, + restrict_type: 'groups', + component_group_whitelist: ['uuid-1'], + }, + }, + }), context({ displayPathByUuid: new Map([['uuid-1', 'My Layout']]) })); + + expect(names.definitionBody).not.toContain('allow'); + expect(groups.definitionBody).not.toContain('allow'); + }); + + it('keeps allow when restrict_components is absent, which the backend enforces', () => { + const result = serializeBlockDefinition(component({ + schema: { body: { type: 'bloks', component_whitelist: ['grid'] } }, + }), context({ knownBlockNames: new Set(['hero', 'grid']) })); + + expect(result.definitionBody).toContain('allow: [\'grid\']'); + }); + + it('emits allow on richtext but not on other whitelisted field types', () => { + const richtext = serializeBlockDefinition(component({ + schema: { body: { type: 'richtext', component_whitelist: ['grid'] } }, + }), context({ knownBlockNames: new Set(['hero', 'grid']) })); + + // A multilink's component_whitelist holds content type names, not block + // names, so emitting it would put a misleading list in the output. + const multilink = serializeBlockDefinition(component({ + schema: { link: { type: 'multilink', component_whitelist: ['page'] } }, + }), context({ knownBlockNames: new Set(['hero', 'page']) })); + + expect(richtext.definitionBody).toContain('allow: [\'grid\']'); + expect(multilink.definitionBody).toContain('{ name: \'link\'; type: \'multilink\' }'); + expect(multilink.definitionBody).not.toContain('allow'); + }); + + it('drops allow entries naming a block the space does not have', () => { + const result = serializeBlockDefinition(component({ + schema: { body: { type: 'bloks', component_whitelist: ['grid', 'deleted'] } }, + }), context({ knownBlockNames: new Set(['hero', 'grid']) })); + + expect(result.definitionBody).toContain('allow: [\'grid\']'); + }); + + it('omits allow entirely when no whitelisted block still exists', () => { + // An `allow` of only unknown names would resolve the field to `never[]` + // through `ApplyAllow`, rejecting every possible value. + const result = serializeBlockDefinition(component({ + schema: { body: { type: 'bloks', component_whitelist: ['deleted', 'gone'] } }, + }), emptyContext); + + expect(result.definitionBody).toContain('{ name: \'body\'; type: \'bloks\' }'); + expect(result.definitionBody).not.toContain('allow'); + }); + it('keeps tab fields, which resolve to never downstream', () => { const result = serializeBlockDefinition(component({ schema: { general: { type: 'tab' } }, diff --git a/packages/cli/src/commands/types/generate/schema-types/serialize.ts b/packages/cli/src/commands/types/generate/schema-types/serialize.ts index e64723131..085301d0d 100644 --- a/packages/cli/src/commands/types/generate/schema-types/serialize.ts +++ b/packages/cli/src/commands/types/generate/schema-types/serialize.ts @@ -6,6 +6,11 @@ import { INDENT, isRecord, quoteString, sortSchemaByPos } from '../../../schema/ export interface SerializeContext { /** `component_group_uuid` → display-name path, e.g. `'My Layout/Heros'`. */ displayPathByUuid: Map; + /** + * Every component name the space returned. Used to drop `allow` entries that + * name a block which no longer exists, see {@link serializeAllowEntries}. + */ + knownBlockNames: Set; } /** One component serialized to the type-literal body of its definition type. */ @@ -20,12 +25,16 @@ export interface SerializedBlock { /** * Narrows a component's wire `schema` to the field-record shape that - * `sortSchemaByPos` accepts. Allows graceful handling of malformed schema. + * `sortSchemaByPos` accepts. Allows graceful handling of malformed schema: a + * field whose value is not a record is dropped by the caller's own filter. */ function isFieldRecordMap(value: unknown): value is Record> { - return isRecord(value); + return isRecord(value) && Object.values(value).every(field => isRecord(field)); } +/** Field types whose `allow` list the wire actually uses to restrict blocks. */ +const BLOCK_RESTRICTED_FIELD_TYPES = new Set(['bloks', 'richtext']); + /** * Serializes one `allow` entry: a bare block name, or a folder reference. Any * other shape yields `undefined`, which drops the whole `allow` (no narrowing @@ -37,6 +46,55 @@ function serializeAllowEntry(entry: unknown): string | undefined { return undefined; } +/** + * Serializes a field's `allow` list, or returns `undefined` to emit no `allow` + * at all. + * + * Names that no component in the space answers to are dropped. `ApplyAllow` is + * an `Extract`, so an unmatched name contributes nothing to the union and a list + * of only unmatched names would resolve the field to `never[]`, rejecting every + * possible value. Storyblok does clean whitelists when a component is deleted + * (`CleanComponentSchemaJob`), but that job is eventual, is skipped for + * non-nestable components, and never runs at all for schemas imported or + * hand-written through the API, so stale names do reach this code. + * + * Folder entries are resolved all-or-nothing upstream in `toDslField`, so they + * are already known-good by the time they arrive here. + */ +function serializeAllowEntries(allow: unknown[], knownBlockNames: Set): string | undefined { + const known = allow.filter(entry => typeof entry !== 'string' || knownBlockNames.has(entry)); + if (known.length === 0) { return undefined; } + + const entries = known.map(serializeAllowEntry); + if (!entries.every((entry): entry is string => entry !== undefined)) { return undefined; } + return `allow: [${entries.join(', ')}]`; +} + +/** + * Decides whether a field's whitelist actually restricts which blocks it + * accepts, and so whether `allow` belongs in the emitted type. + * + * Two independent reasons to emit nothing: + * + * `restrict_components: false` means the restriction is switched off, and the + * app treats it that way, so the whitelist beside it is inert. Storyblok strips + * name whitelists when the flag is false, but never strips + * `component_group_whitelist`, so a field restricted to a folder with the + * restriction disabled is a persistable state that would otherwise narrow the + * type against blocks the editor happily accepts. A *missing* flag is not the + * same as `false`: the backend enforces the whitelist in that case, so + * narrowing stays correct. + * + * Only `bloks` and `richtext` fields restrict *blocks*. On a `multilink`, + * `component_whitelist` holds content type names and on other field types the + * key is meaningless, so emitting `allow` there would put a misleading list in + * a file the user reads. The type level ignores it either way. + */ +function isRestrictedByBlocks(fieldData: Record, dsl: Record): boolean { + if (fieldData.restrict_components === false) { return false; } + return typeof dsl.type === 'string' && BLOCK_RESTRICTED_FIELD_TYPES.has(dsl.type); +} + /** * Serializes one field to a type literal. Only the keys the type-level * machinery reads are emitted, `name`, `type`, `required` (when `true`), @@ -58,11 +116,9 @@ function serializeField( if (typeof dsl.type === 'string') { members.push(`type: ${quoteString(dsl.type)}`); } if (dsl.required === true) { members.push('required: true'); } - if (Array.isArray(dsl.allow) && dsl.allow.length > 0) { - const entries = dsl.allow.map(serializeAllowEntry); - if (entries.every((entry): entry is string => entry !== undefined)) { - members.push(`allow: [${entries.join(', ')}]`); - } + if (isRestrictedByBlocks(fieldData, dsl) && Array.isArray(dsl.allow) && dsl.allow.length > 0) { + const allow = serializeAllowEntries(dsl.allow, context.knownBlockNames); + if (allow !== undefined) { members.push(allow); } } const customFieldType = dsl.type === 'custom' && typeof dsl.field_type === 'string' From d035b2e269d861cddee634ba8223a9738875e830 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Wed, 29 Jul 2026 08:55:40 +0200 Subject: [PATCH 18/35] fix(cli): sort emitted blocks by name and drop unknown allow entries 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 --- .../expected-types-with-plugins.d.ts | 20 ++--- .../__fixtures__/expected-types.d.ts | 20 ++--- .../schema-types/fixture-drift.test.ts | 28 +++++-- .../types/generate/schema-types/index.test.ts | 79 +++++++++++++++++++ .../types/generate/schema-types/index.ts | 41 ++++++++-- 5 files changed, 154 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types-with-plugins.d.ts b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types-with-plugins.d.ts index 28f072c72..573eaa935 100644 --- a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types-with-plugins.d.ts +++ b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types-with-plugins.d.ts @@ -4,30 +4,30 @@ import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from '@storyblok/schema'; import type { fieldPlugins as userFieldPlugins } from './plugins'; -export type HeroBlockDefinition = { +export type GridBlockDefinition = { readonly id: number; created_at: string; updated_at: string; - name: 'hero'; + name: 'grid'; is_root: false; is_nestable: true; fields: [ - { name: 'headline'; type: 'text'; required: true }, - { name: 'image'; type: 'asset' }, - { name: 'nested'; type: 'bloks'; allow: ['grid'] }, - { name: 'general'; type: 'tab' }, + { name: 'columns'; type: 'bloks' }, ]; }; -export type GridBlockDefinition = { +export type HeroBlockDefinition = { readonly id: number; created_at: string; updated_at: string; - name: 'grid'; + name: 'hero'; is_root: false; is_nestable: true; fields: [ - { name: 'columns'; type: 'bloks' }, + { name: 'headline'; type: 'text'; required: true }, + { name: 'image'; type: 'asset' }, + { name: 'nested'; type: 'bloks'; allow: ['grid'] }, + { name: 'general'; type: 'tab' }, ]; }; @@ -44,7 +44,7 @@ export type PageBlockDefinition = { ]; }; -export type Blocks = HeroBlockDefinition | GridBlockDefinition | PageBlockDefinition; +export type Blocks = GridBlockDefinition | HeroBlockDefinition | PageBlockDefinition; export type FieldPlugins = InferSchema<{ blocks: Record; fieldPlugins: typeof userFieldPlugins }>['fieldPlugins']; diff --git a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts index a3a77e265..a5ee4be35 100644 --- a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts +++ b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts @@ -3,30 +3,30 @@ import type { BlockContent, MapiStory as InferStoryMapi, Story as InferStory } from '@storyblok/schema'; -export type HeroBlockDefinition = { +export type GridBlockDefinition = { readonly id: number; created_at: string; updated_at: string; - name: 'hero'; + name: 'grid'; is_root: false; is_nestable: true; fields: [ - { name: 'headline'; type: 'text'; required: true }, - { name: 'image'; type: 'asset' }, - { name: 'nested'; type: 'bloks'; allow: ['grid'] }, - { name: 'general'; type: 'tab' }, + { name: 'columns'; type: 'bloks' }, ]; }; -export type GridBlockDefinition = { +export type HeroBlockDefinition = { readonly id: number; created_at: string; updated_at: string; - name: 'grid'; + name: 'hero'; is_root: false; is_nestable: true; fields: [ - { name: 'columns'; type: 'bloks' }, + { name: 'headline'; type: 'text'; required: true }, + { name: 'image'; type: 'asset' }, + { name: 'nested'; type: 'bloks'; allow: ['grid'] }, + { name: 'general'; type: 'tab' }, ]; }; @@ -43,7 +43,7 @@ export type PageBlockDefinition = { ]; }; -export type Blocks = HeroBlockDefinition | GridBlockDefinition | PageBlockDefinition; +export type Blocks = GridBlockDefinition | HeroBlockDefinition | PageBlockDefinition; export type FieldPlugins = Record; diff --git a/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts b/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts index ce220f2c0..54216ae83 100644 --- a/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts @@ -1,9 +1,23 @@ import { describe, expect, it } from 'vitest'; +import type { Component } from '../../../../types'; import { FIXTURE_COMPONENTS } from './__fixtures__/components'; import { renderSchemaTypes } from './render'; import { serializeBlockDefinition } from './serialize'; +/** + * Serializes the fixture components the way `generateSchemaTypes` does, so the + * committed fixture stays a faithful sample of real output: sorted by name, with + * every fixture component registered as a known block so `allow` entries survive. + */ +function serializeFixtureBlocks() { + const components: Component[] = FIXTURE_COMPONENTS; + const knownBlockNames = new Set(components.map(component => component.name)); + return [...components] + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) + .map(component => serializeBlockDefinition(component, { displayPathByUuid: new Map(), knownBlockNames })); +} + /** * The committed fixture is what `emitted-types.test-d.ts` typechecks. If the * renderer changes, this test fails and the fixture must be regenerated (`-u`), @@ -12,10 +26,11 @@ import { serializeBlockDefinition } from './serialize'; */ describe('emitted type fixture', () => { it('matches what the renderer currently produces', async () => { - const blocks = FIXTURE_COMPONENTS.map(component => - serializeBlockDefinition(component as never, { displayPathByUuid: new Map() })); - - const rendered = renderSchemaTypes({ blocks, fieldPlugins: { kind: 'none' }, space: '295018' }); + const rendered = renderSchemaTypes({ + blocks: serializeFixtureBlocks(), + fieldPlugins: { kind: 'none' }, + space: '295018', + }); await expect(rendered).toMatchFileSnapshot('./__fixtures__/expected-types.d.ts'); }); @@ -27,11 +42,8 @@ describe('emitted type fixture', () => { * just through `Block`, see the "emitted `Story`" describe block. */ it('matches what the renderer produces with field plugins registered', async () => { - const blocks = FIXTURE_COMPONENTS.map(component => - serializeBlockDefinition(component as never, { displayPathByUuid: new Map() })); - const rendered = renderSchemaTypes({ - blocks, + blocks: serializeFixtureBlocks(), fieldPlugins: { kind: 'record', modulePath: '/abs/plugins.ts', fieldTypes: ['colorpicker'] }, fieldPluginsImportPath: './plugins', space: '295018', diff --git a/packages/cli/src/commands/types/generate/schema-types/index.test.ts b/packages/cli/src/commands/types/generate/schema-types/index.test.ts index eeba30245..0d47509fe 100644 --- a/packages/cli/src/commands/types/generate/schema-types/index.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/index.test.ts @@ -61,6 +61,18 @@ describe('assertNoLegacyFlags', () => { })) .toThrow(/--strict.*--custom-fields-parser.*--compiler-options.*--suffix/s); }); + + it('reports rather than rejects a legacy flag the config file set', () => { + // Erroring here would lock a project whose config sets `strict` out of + // --future-schema entirely, without the user having typed anything. + const ignored = assertNoLegacyFlags({ strict: true }, () => 'config'); + + expect(ignored).toEqual(['--strict']); + }); + + it('still rejects a legacy flag typed on the command line', () => { + expect(() => assertNoLegacyFlags({ strict: true }, () => 'cli')).toThrow(/--strict/); + }); }); describe('generateSchemaTypes', () => { @@ -110,6 +122,73 @@ describe('generateSchemaTypes', () => { expect(result.unmappedFieldTypes).toEqual(['storyblok-colorpicker']); }); + it('sorts blocks by name so regeneration is byte-stable', async () => { + written.clear(); + const { fetchRemoteSchema } = await import('../../../schema/actions'); + // Returned in an order MAPI does not promise to keep. + vi.mocked(fetchRemoteSchema).mockResolvedValueOnce({ + remote: { components: new Map(), componentFolders: new Map(), datasources: new Map() }, + rawComponents: [ + { id: 1, name: 'teaser', created_at: '', updated_at: '', is_root: false, is_nestable: true, schema: {} }, + { id: 2, name: 'hero', created_at: '', updated_at: '', is_root: false, is_nestable: true, schema: {} }, + ], + rawComponentFolders: [], + rawDatasources: [], + } as never); + + await generateSchemaTypes({ + space: '295018', + cwd: '/project', + outputDir: '/out', + filename: 'storyblok-schema', + }); + + const content = written.get('/out/storyblok-schema.d.ts')!; + expect(content).toContain('export type Blocks = HeroBlockDefinition | TeaserBlockDefinition;'); + expect(content.indexOf('HeroBlockDefinition = {')).toBeLessThan(content.indexOf('TeaserBlockDefinition = {')); + }); + + it('writes one file per block plus the surface file under --separate-files', async () => { + written.clear(); + + const result = await generateSchemaTypes({ + space: '295018', + cwd: '/project', + outputDir: '/out', + filename: 'storyblok-schema', + separateFiles: true, + }); + + expect(result.files.sort()).toEqual([ + '/out/blocks/hero.d.ts', + '/out/blocks/page.d.ts', + '/out/storyblok-schema.d.ts', + ]); + expect(written.get('/out/blocks/hero.d.ts')).toContain('export type HeroBlockDefinition = {'); + // The surface file imports the block files rather than redeclaring them. + const surface = written.get('/out/storyblok-schema.d.ts')!; + expect(surface).toContain('import type { HeroBlockDefinition } from \'./blocks/hero\';'); + expect(surface).toContain('export type Blocks = HeroBlockDefinition | PageBlockDefinition;'); + }); + + it('applies --type-prefix and --type-suffix to every exported name', async () => { + written.clear(); + + await generateSchemaTypes({ + space: '295018', + cwd: '/project', + outputDir: '/out', + filename: 'storyblok-schema', + typePrefix: 'Sb', + typeSuffix: 'Type', + }); + + const content = written.get('/out/storyblok-schema.d.ts')!; + expect(content).toContain('export type SbBlocksType = SbHeroBlockDefinitionType | SbPageBlockDefinitionType;'); + expect(content).toContain('export type SbBlockType'); + expect(content).toContain('export type SbSchemaType = {'); + }); + it('throws when the space has no components', async () => { const { fetchRemoteSchema } = await import('../../../schema/actions'); vi.mocked(fetchRemoteSchema).mockResolvedValueOnce({ diff --git a/packages/cli/src/commands/types/generate/schema-types/index.ts b/packages/cli/src/commands/types/generate/schema-types/index.ts index fb40598a9..dd4c7702c 100644 --- a/packages/cli/src/commands/types/generate/schema-types/index.ts +++ b/packages/cli/src/commands/types/generate/schema-types/index.ts @@ -24,11 +24,24 @@ const LEGACY_ONLY_FLAGS: ReadonlyArray options[key] !== undefined) - .map(([, flag]) => flag); +export function assertNoLegacyFlags( + options: GenerateTypesOptions, + getOptionValueSource?: (attributeName: string) => string | undefined, +): string[] { + const set = LEGACY_ONLY_FLAGS.filter(([key]) => options[key] !== undefined); + const fromConfig = set.filter(([key]) => getOptionValueSource?.(key) === 'config'); + const used = set.filter(entry => !fromConfig.includes(entry)).map(([, flag]) => flag); if (used.length > 0) { throw new CommandError( @@ -37,12 +50,16 @@ export function assertNoLegacyFlags(options: GenerateTypesOptions): void { + '(see --field-plugins), and no JSON-schema compiler is involved.', ); } + + return fromConfig.map(([, flag]) => flag); } export interface GenerateSchemaTypesOptions { space: string; /** Project root, used to resolve the field-plugins module. */ cwd: string; + /** The CLI base path (`--path`), which the field-plugins convention path honours. */ + path?: string; /** Absolute directory the files are written into. */ outputDir: string; /** Base file name without extension. */ @@ -78,9 +95,21 @@ export async function generateSchemaTypes( } const displayPathByUuid = buildGroupDisplayPathByUuid(rawComponentFolders); - const blocks = rawComponents.map(component => serializeBlockDefinition(component, { displayPathByUuid })); + const knownBlockNames = new Set(rawComponents.map(component => component.name)); + // Sorted by name so regeneration is byte-stable: MAPI does not promise a + // stable component order, and this file is committed, so an upstream + // reordering would otherwise show up as a diff with no semantic change. + // Compared by code unit rather than `localeCompare`, which would order + // differently depending on the machine's locale. + const blocks = [...rawComponents] + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) + .map(component => serializeBlockDefinition(component, { displayPathByUuid, knownBlockNames })); - const fieldPlugins = await resolveFieldPluginsSource({ cwd: options.cwd, override: options.fieldPluginsPath }); + const fieldPlugins = await resolveFieldPluginsSource({ + cwd: options.cwd, + path: options.path, + override: options.fieldPluginsPath, + }); const fieldPluginsImportPath = fieldPlugins.kind === 'none' ? undefined : toRelativeImport(options.outputDir, fieldPlugins.modulePath); From 7016672bc989991f1b31c395230310cc0fb7671f Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Wed, 29 Jul 2026 08:55:59 +0200 Subject: [PATCH 19/35] fix(cli): do not reject legacy flags a config file set, warn on --filename 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 /types//.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 --- .../commands/types/generate/future-schema.ts | 28 ++++++++++-- .../src/commands/types/generate/index.test.ts | 43 +++++++++++++++++++ .../cli/src/commands/types/generate/index.ts | 4 +- 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/types/generate/future-schema.ts b/packages/cli/src/commands/types/generate/future-schema.ts index 7a5367ceb..ca9399d5f 100644 --- a/packages/cli/src/commands/types/generate/future-schema.ts +++ b/packages/cli/src/commands/types/generate/future-schema.ts @@ -5,6 +5,7 @@ import { CommandError, handleError, toError } from '../../../utils'; import { resolvePath } from '../../../utils/filesystem'; import type { CLISpinner } from '../../../lib/ui'; import { getUI } from '../../../lib/ui'; +import { DEFAULT_SCHEMA_ENTRY_PATH } from '../../schema/constants'; import type { GenerateTypesOptions } from './constants'; import { assertNoLegacyFlags, generateSchemaTypes } from './schema-types'; @@ -19,6 +20,11 @@ export interface FutureSchemaCommandOptions { separateFiles?: boolean; verbose?: boolean; }; + /** + * Commander's per-option source lookup, used to tell a flag the user typed + * from one their config file set. See {@link assertNoLegacyFlags}. + */ + getOptionValueSource?: (attributeName: string) => string | undefined; } /** @@ -29,22 +35,38 @@ export interface FutureSchemaCommandOptions { * branch. Errors are handled here rather than rethrown, matching the legacy * path's behaviour. */ -export async function runFutureSchemaTypes({ options, globals }: FutureSchemaCommandOptions): Promise { +export async function runFutureSchemaTypes( + { options, globals, getOptionValueSource }: FutureSchemaCommandOptions, +): Promise { const { space, path, filename, separateFiles, verbose } = globals; const ui = getUI(); ui.title(`${commands.TYPES}`, colorPalette.TYPES, 'Generating types from schema...'); let spinner: CLISpinner | undefined; try { - assertNoLegacyFlags(options); + const ignoredFromConfig = assertNoLegacyFlags(options, getOptionValueSource); + if (ignoredFromConfig.length > 0) { + ui.warn( + `Ignoring ${ignoredFromConfig.join(', ')} from your config file: ` + + 'not supported with --future-schema.', + ); + } if (!space) { throw new CommandError('Please provide the space as argument --space SPACE_ID.'); } + if (filename !== undefined) { + ui.warn( + `--filename is set to \`${filename}\`, which is also where the legacy generator writes. ` + + 'Regenerating with and without --future-schema will overwrite one with the other. ' + + 'Leave it unset to keep them in separate files.', + ); + } spinner = ui.createSpinner('Generating types...'); const result = await generateSchemaTypes({ space, cwd: process.cwd(), + path, outputDir: resolvePath(path, join('types', space)), filename: filename ?? 'storyblok-schema', separateFiles, @@ -59,7 +81,7 @@ export async function runFutureSchemaTypes({ options, globals }: FutureSchemaCom ui.warn( `No field plugin registered for: ${result.unmappedFieldTypes.join(', ')}. ` + 'These custom fields fall back to an untyped value. Declare them with defineFieldPlugin ' - + 'and point --field-plugins at the module (or place it at .storyblok/schema/schema.ts).', + + `and point --field-plugins at the module (or place it at ${DEFAULT_SCHEMA_ENTRY_PATH}).`, ); } ui.info('The generated types import from `@storyblok/schema`. Install it as a dev dependency: `npm i -D @storyblok/schema`.'); diff --git a/packages/cli/src/commands/types/generate/index.test.ts b/packages/cli/src/commands/types/generate/index.test.ts index 5c6a1a113..ae0b39a6d 100644 --- a/packages/cli/src/commands/types/generate/index.test.ts +++ b/packages/cli/src/commands/types/generate/index.test.ts @@ -369,5 +369,48 @@ describe("types generate", () => { expect(uiInfoMock).toHaveBeenCalledWith(expect.stringContaining("@storyblok/schema")); expect(uiSpinnerFailedMock).not.toHaveBeenCalled(); }); + + it('forwards --field-plugins, --type-prefix, --type-suffix, and --path to the generator', async () => { + vi.mocked(generateSchemaTypes).mockResolvedValue({ files: [], unmappedFieldTypes: [] }); + + await typesCommand.parseAsync([ + 'node', + 'test', + 'generate', + '--space', + '295018', + '--future-schema', + '--field-plugins', + './src/storyblok/plugins.ts', + '--type-prefix', + 'Sb', + '--type-suffix', + 'Type', + ]); + + expect(generateSchemaTypes).toHaveBeenCalledWith(expect.objectContaining({ + space: '295018', + fieldPluginsPath: './src/storyblok/plugins.ts', + typePrefix: 'Sb', + typeSuffix: 'Type', + })); + }); + + it('warns that --filename collides with the legacy generator output', async () => { + vi.mocked(generateSchemaTypes).mockResolvedValue({ files: [], unmappedFieldTypes: [] }); + + await typesCommand.parseAsync([ + 'node', + 'test', + 'generate', + '--space', + '295018', + '--future-schema', + '--filename', + 'shared', + ]); + + expect(uiWarnMock).toHaveBeenCalledWith(expect.stringContaining('--filename')); + }); }); }); diff --git a/packages/cli/src/commands/types/generate/index.ts b/packages/cli/src/commands/types/generate/index.ts index c2f516ffc..19c664bc5 100644 --- a/packages/cli/src/commands/types/generate/index.ts +++ b/packages/cli/src/commands/types/generate/index.ts @@ -9,6 +9,7 @@ import { readDatasourcesFiles } from "../../datasources/push/actions"; import type { SpaceDatasourcesData } from "../../../commands/datasources/constants"; import { getUI } from "../../../lib/ui"; import { getLogger } from "../../../lib/logger/logger"; +import { DEFAULT_SCHEMA_ENTRY_PATH } from "../../schema/constants"; import { runFutureSchemaTypes } from "./future-schema"; const generateCmd = typesCommand @@ -35,7 +36,7 @@ const generateCmd = typesCommand .option("--future-schema", "Generate types from the space schema") .option( "--field-plugins ", - "Path to a module exporting your defineFieldPlugin declarations (default: .storyblok/schema/schema.ts)", + `Path to a module exporting your defineFieldPlugin declarations (default: ${DEFAULT_SCHEMA_ENTRY_PATH})`, ); generateCmd.action(async (options: GenerateTypesOptions, command: Command) => { @@ -46,6 +47,7 @@ generateCmd.action(async (options: GenerateTypesOptions, command: Command) => { await runFutureSchemaTypes({ options, globals: { space, path, filename, separateFiles, verbose }, + getOptionValueSource: (attributeName) => command.getOptionValueSource(attributeName), }); return; } From 7737b8250ddbe9253017d2574d6f3a1a69934957 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Wed, 29 Jul 2026 08:56:11 +0200 Subject: [PATCH 20/35] refactor(cli): share the jiti bootstrap and the schema path constants 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 --- packages/cli/src/commands/schema/changeset.ts | 3 +- packages/cli/src/commands/schema/constants.ts | 26 ++++++++ .../cli/src/commands/schema/init/actions.ts | 3 +- .../cli/src/commands/schema/init/index.ts | 3 +- .../schema-types/field-plugins.test.ts | 43 ++++++++++++- .../generate/schema-types/field-plugins.ts | 64 ++++++++++++++----- packages/cli/src/utils/import-module.ts | 17 +++++ .../cli/src/utils/schema/classify-exports.ts | 5 +- 8 files changed, 140 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/commands/schema/constants.ts create mode 100644 packages/cli/src/utils/import-module.ts diff --git a/packages/cli/src/commands/schema/changeset.ts b/packages/cli/src/commands/schema/changeset.ts index e0d1a934b..d015ce063 100644 --- a/packages/cli/src/commands/schema/changeset.ts +++ b/packages/cli/src/commands/schema/changeset.ts @@ -1,6 +1,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "pathe"; +import { SCHEMA_DIR_NAME } from "./constants"; import type { ChangesetData } from "./types"; import { fileTimestamp } from "./utils"; @@ -11,7 +12,7 @@ async function ensureDir(dir: string): Promise { /** Saves a changeset recording what was pushed and the full pre-push remote state for rollback. Returns the written file path. */ export async function saveChangeset(basePath: string, data: ChangesetData): Promise { - const dir = join(basePath, "schema", "changesets"); + const dir = join(basePath, SCHEMA_DIR_NAME, "changesets"); await ensureDir(dir); const fileName = `${fileTimestamp(data.timestamp)}.json`; const filePath = join(dir, fileName); diff --git a/packages/cli/src/commands/schema/constants.ts b/packages/cli/src/commands/schema/constants.ts new file mode 100644 index 000000000..6fe959915 --- /dev/null +++ b/packages/cli/src/commands/schema/constants.ts @@ -0,0 +1,26 @@ +import { join } from 'pathe'; + +import { DEFAULT_STORAGE_DIR } from '../../utils/filesystem'; + +/** + * Directory holding the code-defined schema, relative to the CLI's base path + * (`--path`, default `.storyblok`). `schema push` writes its changesets to a + * `changesets/` directory beneath it. + */ +export const SCHEMA_DIR_NAME = 'schema'; + +/** Entry file `schema init` writes and `schema push` expects. */ +export const SCHEMA_ENTRY_FILENAME = 'schema.ts'; + +/** Entry file path relative to the CLI's base path, e.g. `schema/schema.ts`. */ +export const SCHEMA_ENTRY_RELATIVE_PATH = join(SCHEMA_DIR_NAME, SCHEMA_ENTRY_FILENAME); + +/** + * The entry file path under the default base path, for help text and messages. + * Prefer resolving against the user's `--path` in code, this constant exists so + * user-facing copy cannot drift from the default behaviour. + */ +export const DEFAULT_SCHEMA_ENTRY_PATH = join(DEFAULT_STORAGE_DIR, SCHEMA_ENTRY_RELATIVE_PATH); + +/** Default `--out-dir` for `schema init`. */ +export const DEFAULT_SCHEMA_DIR = join(DEFAULT_STORAGE_DIR, SCHEMA_DIR_NAME); diff --git a/packages/cli/src/commands/schema/init/actions.ts b/packages/cli/src/commands/schema/init/actions.ts index 9a390e802..a76a962a1 100644 --- a/packages/cli/src/commands/schema/init/actions.ts +++ b/packages/cli/src/commands/schema/init/actions.ts @@ -3,6 +3,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join } from "pathe"; import type { Component, ComponentFolder, Datasource } from "../../../types"; +import { SCHEMA_ENTRY_FILENAME } from "../constants"; import { buildGroupPathByUuid } from "../folders"; import { generateComponentFile, @@ -87,7 +88,7 @@ export async function writeSchemaFiles( } // Write schema.ts (entry point with schema object, types, and Story alias) - const schemaPath = join(targetPath, "schema.ts"); + const schemaPath = join(targetPath, SCHEMA_ENTRY_FILENAME); await writeFileWithDirs( schemaPath, generateSchemaFile(resolvedComponents, resolvedDatasources, resolvedFolders), diff --git a/packages/cli/src/commands/schema/init/index.ts b/packages/cli/src/commands/schema/init/index.ts index 599cf8b68..afef1b28e 100644 --- a/packages/cli/src/commands/schema/init/index.ts +++ b/packages/cli/src/commands/schema/init/index.ts @@ -8,6 +8,7 @@ import { getReporter } from "../../../lib/reporter/reporter"; import { getUI } from "../../../lib/ui"; import { session } from "../../../session"; import { schemaCommand } from "../command"; +import { DEFAULT_SCHEMA_DIR } from "../constants"; import { displayPath } from "../utils"; import type { SchemaInitOptions } from "./constants"; import { fetchRemoteSchema } from "../actions"; @@ -32,7 +33,7 @@ schemaCommand "Initialize a local code-driven schema workspace from an existing Storyblok space (one-time bootstrap)", ) .option("-s, --space ", "space ID") - .option("--out-dir ", "Output directory for generated bootstrap files", ".storyblok/schema") + .option("--out-dir ", "Output directory for generated bootstrap files", DEFAULT_SCHEMA_DIR) .action(async (options: SchemaInitOptions, command) => { const ui = getUI(); const logger = getLogger(); diff --git a/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts b/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts index dc0713782..3816ef630 100644 --- a/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts @@ -3,7 +3,8 @@ import { tmpdir } from 'node:os'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { FIELD_PLUGINS_CONVENTION_PATH, resolveFieldPluginsSource } from './field-plugins'; +import { DEFAULT_SCHEMA_ENTRY_PATH, SCHEMA_ENTRY_RELATIVE_PATH } from '../../../schema/constants'; +import { resolveFieldPluginsSource } from './field-plugins'; // This module resolves a real TypeScript file from disk via jiti, so it needs the // real filesystem rather than the memfs mock the global test setup installs. @@ -37,7 +38,7 @@ describe('resolveFieldPluginsSource', () => { }); it('detects a defineSchema result at the convention path', async () => { - const target = join(cwd, FIELD_PLUGINS_CONVENTION_PATH); + const target = join(cwd, DEFAULT_SCHEMA_ENTRY_PATH); await mkdir(join(target, '..'), { recursive: true }); await writeFile(target, SCHEMA_EXPORT, 'utf8'); @@ -70,8 +71,44 @@ describe('resolveFieldPluginsSource', () => { .toThrow(/fieldPlugins/); }); + it('resolves the convention path under a custom --path', async () => { + const target = join(cwd, 'config', SCHEMA_ENTRY_RELATIVE_PATH); + await mkdir(join(target, '..'), { recursive: true }); + await writeFile(target, SCHEMA_EXPORT, 'utf8'); + + const result = await resolveFieldPluginsSource({ cwd, path: 'config' }); + + expect(result).toEqual({ kind: 'schema', modulePath: target, fieldTypes: ['storyblok-colorpicker'] }); + }); + + it('does not look under the default base path when --path is set', async () => { + const defaultTarget = join(cwd, DEFAULT_SCHEMA_ENTRY_PATH); + await mkdir(join(defaultTarget, '..'), { recursive: true }); + await writeFile(defaultTarget, SCHEMA_EXPORT, 'utf8'); + + expect(await resolveFieldPluginsSource({ cwd, path: 'config' })).toEqual({ kind: 'none' }); + }); + + it('names a near-miss export in the error for an explicit override', async () => { + const target = join(cwd, 'plugins.ts'); + await writeFile(target, SCHEMA_EXPORT.replace('export const schema', 'export const mySchema'), 'utf8'); + + await expect(resolveFieldPluginsSource({ cwd, override: 'plugins.ts' })) + .rejects + .toThrow(/`mySchema`/); + }); + + it('names a near-miss bare record too', async () => { + const target = join(cwd, 'plugins.ts'); + await writeFile(target, RECORD_EXPORT.replace('export const fieldPlugins', 'export const myPlugins'), 'utf8'); + + await expect(resolveFieldPluginsSource({ cwd, override: 'plugins.ts' })) + .rejects + .toThrow(/`myPlugins`/); + }); + it('returns none when the convention file exists but exports neither shape', async () => { - const target = join(cwd, FIELD_PLUGINS_CONVENTION_PATH); + const target = join(cwd, DEFAULT_SCHEMA_ENTRY_PATH); await mkdir(join(target, '..'), { recursive: true }); await writeFile(target, 'export const schema = { blocks: {} };', 'utf8'); diff --git a/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts b/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts index cb1389278..ee0e5cb8a 100644 --- a/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts +++ b/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts @@ -2,11 +2,11 @@ import { existsSync } from 'node:fs'; import { resolve } from 'pathe'; import { CommandError, toError } from '../../../../utils'; +import { DEFAULT_STORAGE_DIR } from '../../../../utils/filesystem'; +import { importModule } from '../../../../utils/import-module'; +import { SCHEMA_ENTRY_RELATIVE_PATH } from '../../../schema/constants'; import { isRecord } from '../../../schema/utils'; -/** Where a field-plugin declaration module is looked for when no override is given. */ -export const FIELD_PLUGINS_CONVENTION_PATH = '.storyblok/schema/schema.ts'; - /** * Where the generated `FieldPlugins` type comes from. * @@ -31,21 +31,54 @@ function collectFieldTypes(fieldPlugins: Record): string[] { return fieldTypes; } +/** + * Names an export that looks like it was meant to be picked up but is not named + * `schema` or `fieldPlugins`, so the error can point at the near miss instead of + * only restating the contract. Returns the first such export name. + * + * Both accepted shapes are recognised, a `defineSchema` result carrying + * `fieldPlugins` and a bare record of `defineFieldPlugin` results. Only the + * error message is affected: the emitted file imports the export by name, so + * accepting an arbitrary name would mean threading it through rendering too. + */ +function findNearMissExport(module: Record): string | undefined { + for (const [name, value] of Object.entries(module)) { + if (name === 'schema' || name === 'fieldPlugins' || name === 'default') { continue; } + if (!isRecord(value)) { continue; } + if (isRecord(value.fieldPlugins)) { return name; } + const entries = Object.values(value); + if (entries.length > 0 && entries.every(entry => isRecord(entry) && typeof entry.fieldType === 'string')) { + return name; + } + } + return undefined; +} + /** * Resolves the module whose `defineFieldPlugin` declarations type `custom` - * fields. The module is loaded with `jiti` (TypeScript-aware) purely to detect - * which export shape it has and which `fieldType`s it registers, the generated - * file imports it by path and lets TypeScript do the real work. + * fields. The generated file imports the module by path and lets TypeScript do + * the real work; this only needs to know which export shape it has and which + * `fieldType`s it registers. + * + * Detecting that means *executing* the module, since the shape is a runtime + * value. So any top-level side effects in the user's schema file run on every + * `types generate`. `schema push` loads the same file the same way, but there + * execution is the point rather than a means of inspection. * * An explicit `--field-plugins` path that is missing or unusable is an error; - * the convention path silently degrades to `none`, since most spaces have no - * custom fields. + * the convention path degrades to `none`, since most spaces have no custom + * fields. That degradation is not silent in the case that matters: a `custom` + * field with no registered plugin is reported afterwards as an unmapped + * `field_type`. */ export async function resolveFieldPluginsSource( - options: { cwd: string; override?: string }, + options: { cwd: string; path?: string; override?: string }, ): Promise { const isExplicit = options.override !== undefined; - const modulePath = resolve(options.cwd, options.override ?? FIELD_PLUGINS_CONVENTION_PATH); + const modulePath = options.override === undefined + // Honours `--path`, the same base the generated types are written under. + ? resolve(options.cwd, options.path ?? DEFAULT_STORAGE_DIR, SCHEMA_ENTRY_RELATIVE_PATH) + : resolve(options.cwd, options.override); if (!existsSync(modulePath)) { if (isExplicit) { @@ -54,12 +87,9 @@ export async function resolveFieldPluginsSource( return { kind: 'none' }; } - const { createJiti } = await import('jiti'); - const jiti = createJiti(import.meta.url, { interopDefault: true }); - let module: Record; try { - module = await jiti.import(modulePath) as Record; + module = await importModule(modulePath); } catch (maybeError) { throw new CommandError(`Failed to load field plugins from ${modulePath}: ${toError(maybeError).message}`); @@ -75,8 +105,12 @@ export async function resolveFieldPluginsSource( } if (isExplicit) { + const nearMiss = findNearMissExport(module); throw new CommandError( - `${modulePath} exports neither a \`schema\` (a defineSchema result with fieldPlugins) nor a \`fieldPlugins\` record.`, + `${modulePath} exports neither a \`schema\` (a defineSchema result with fieldPlugins) nor a \`fieldPlugins\` record.${ + nearMiss === undefined + ? '' + : ` Found \`${nearMiss}\`, which looks like one: rename it to \`schema\` or \`fieldPlugins\`.`}`, ); } return { kind: 'none' }; diff --git a/packages/cli/src/utils/import-module.ts b/packages/cli/src/utils/import-module.ts new file mode 100644 index 000000000..1857c17be --- /dev/null +++ b/packages/cli/src/utils/import-module.ts @@ -0,0 +1,17 @@ +/** + * Imports a user-authored module with `jiti`, so TypeScript entry files work + * without a build step. + * + * Deliberately thin: no error wrapping and no path resolution, because the + * callers disagree on both. Pass an **absolute** path. jiti resolves a relative + * specifier against this module's own location, which is not the user's project, + * and nested imports inside the loaded module resolve relative to that module + * regardless. + * + * Importing runs the module, so any top-level side effects it has will happen. + */ +export async function importModule(absolutePath: string): Promise> { + const { createJiti } = await import('jiti'); + const jiti = createJiti(import.meta.url, { interopDefault: true }); + return await jiti.import(absolutePath) as Record; +} diff --git a/packages/cli/src/utils/schema/classify-exports.ts b/packages/cli/src/utils/schema/classify-exports.ts index ae12c1a88..a1ebc8a70 100644 --- a/packages/cli/src/utils/schema/classify-exports.ts +++ b/packages/cli/src/utils/schema/classify-exports.ts @@ -13,6 +13,7 @@ import { stat } from "node:fs/promises"; import { resolve } from "pathe"; import { CommandError } from "../error/command-error"; +import { importModule } from "../import-module"; import { isRecord } from "../object"; /** Returns true if the value looks like a `defineBlock()` result (content-shape DSL). */ @@ -157,7 +158,5 @@ export async function loadSchemaModule(entryPath: string): Promise; + return importModule(entryAbs); } From 9fa5063e3ff4a2178be7c917041b903d3e6e9fbf Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Wed, 29 Jul 2026 08:56:23 +0200 Subject: [PATCH 21/35] refactor(cli): dedupe the folder path walker and unify the render conditions 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 --- .../cli/src/commands/schema/folders.test.ts | 33 ++++++----- packages/cli/src/commands/schema/folders.ts | 56 ++++++++----------- .../schema-types/emitted-types.test-d.ts | 2 +- .../types/generate/schema-types/render.ts | 38 +++++++++---- 4 files changed, 71 insertions(+), 58 deletions(-) diff --git a/packages/cli/src/commands/schema/folders.test.ts b/packages/cli/src/commands/schema/folders.test.ts index e996fe158..de84675f4 100644 --- a/packages/cli/src/commands/schema/folders.test.ts +++ b/packages/cli/src/commands/schema/folders.test.ts @@ -105,11 +105,11 @@ describe("expandFolderPath", () => { describe("buildGroupDisplayPathByUuid", () => { it("joins parent display names with slashes, preserving original casing", () => { const folders = [ - { uuid: "a", name: "My Layout", parent_uuid: null }, - { uuid: "b", name: "Heros", parent_uuid: "a" }, + folder({ uuid: "a", name: "My Layout" }), + folder({ uuid: "b", name: "Heros", parent_uuid: "a" }), ]; - const result = buildGroupDisplayPathByUuid(folders as never); + const result = buildGroupDisplayPathByUuid(folders); expect(result.get("a")).toBe("My Layout"); expect(result.get("b")).toBe("My Layout/Heros"); @@ -117,22 +117,27 @@ describe("buildGroupDisplayPathByUuid", () => { it("treats a cyclic parent chain as a root instead of recursing forever", () => { const folders = [ - { uuid: "a", name: "A", parent_uuid: "b" }, - { uuid: "b", name: "B", parent_uuid: "a" }, + folder({ uuid: "a", name: "A", parent_uuid: "b" }), + folder({ uuid: "b", name: "B", parent_uuid: "a" }), ]; - const result = buildGroupDisplayPathByUuid(folders as never); - - // When pathFor('a') recurses into pathFor('b'), which recurses back into - // pathFor('a'), the cycle is detected and cut (visited.has('a') is true), - // returning []. This bubbles up to set b=['B'], then a=['B', 'A']. - expect(result.get("a")).toBe("B/A"); - expect(result.get("b")).toBe("B"); + const result = buildGroupDisplayPathByUuid(folders); + + // The cycle is cut wherever the walk enters it, so which of the two ends up + // the root depends on iteration order, not on behaviour. What must hold is + // that both groups terminate with a path built only from real segments, and + // that neither repeats a segment (which is what a missed cycle would do). + for (const uuid of ["a", "b"]) { + const segments = result.get(uuid)?.split("/") ?? []; + expect(segments.length).toBeGreaterThan(0); + expect(segments.every((segment) => ["A", "B"].includes(segment))).toBe(true); + expect(new Set(segments).size).toBe(segments.length); + } }); it("ignores a parent uuid that is not in the folder list", () => { - const folders = [{ uuid: "a", name: "Orphan", parent_uuid: "missing" }]; + const folders = [folder({ uuid: "a", name: "Orphan", parent_uuid: "missing" })]; - expect(buildGroupDisplayPathByUuid(folders as never).get("a")).toBe("Orphan"); + expect(buildGroupDisplayPathByUuid(folders).get("a")).toBe("Orphan"); }); }); diff --git a/packages/cli/src/commands/schema/folders.ts b/packages/cli/src/commands/schema/folders.ts index 5b058479f..06d0676bb 100644 --- a/packages/cli/src/commands/schema/folders.ts +++ b/packages/cli/src/commands/schema/folders.ts @@ -12,11 +12,14 @@ import type { LocalFolder } from "./types"; */ /** - * Builds a `component_group_uuid → slugified path segments` map from the remote - * component groups, walking each group's `parent_uuid` chain. Used by - * `schema init` to lay blocks out in nested group directories. + * Builds a `component_group_uuid → path segments` map from the remote component + * groups, walking each group's `parent_uuid` chain and passing every group name + * through `toSegment`. */ -export function buildGroupPathByUuid(folders: ComponentFolder[]): Map { +function buildGroupSegmentsByUuid( + folders: ComponentFolder[], + toSegment: (name: string) => string, +): Map { const byUuid = new Map(folders.map((folder) => [folder.uuid, folder])); const cache = new Map(); @@ -41,7 +44,7 @@ export function buildGroupPathByUuid(folders: ComponentFolder[]): Map { + return buildGroupSegmentsByUuid(folders, slugify); +} + /** * Builds a `component_group_uuid → display-name path` map (e.g. - * `'My Layout/Heros'`), walking each group's `parent_uuid` chain. Unlike - * {@link buildGroupPathByUuid}, segments keep their original names: this map - * feeds the `folder` literals in generated *types*, where `Block['folder']` is - * documented as a display-name path. Both sides of a `MatchesFolder` comparison - * (a block's `folder` and a field's `allow: [{ folder }]`) come from this same - * map, so they always narrow consistently. - * - * Cycle handling mirrors {@link buildGroupPathByUuid}: a self-referential or - * cyclic `parent_uuid` chain is cut and the group is treated as a path root. + * `'My Layout/Heros'`). Unlike {@link buildGroupPathByUuid}, segments keep their + * original names: this map feeds the `folder` literals in generated *types*, + * where `Block['folder']` is documented as a display-name path. Both sides of a + * `MatchesFolder` comparison (a block's `folder` and a field's + * `allow: [{ folder }]`) come from this same map, so they always narrow + * consistently. */ export function buildGroupDisplayPathByUuid(folders: ComponentFolder[]): Map { - const byUuid = new Map(folders.map(folder => [folder.uuid, folder])); - const segmentsByUuid = new Map(); - - function pathFor(uuid: string | null, visited: Set): string[] { - if (!uuid) { return []; } - const cached = segmentsByUuid.get(uuid); - if (cached) { return cached; } - const folder = byUuid.get(uuid); - if (!folder) { return []; } - if (visited.has(uuid)) { return []; } - visited.add(uuid); - const path = [...pathFor(folder.parent_uuid, visited), folder.name]; - segmentsByUuid.set(uuid, path); - return path; - } - - for (const folder of folders) { pathFor(folder.uuid, new Set()); } - return new Map([...segmentsByUuid].map(([uuid, segments]) => [uuid, segments.join('/')])); + const segmentsByUuid = buildGroupSegmentsByUuid(folders, (name) => name); + return new Map([...segmentsByUuid].map(([uuid, segments]) => [uuid, segments.join("/")])); } /** diff --git a/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts b/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts index 3cd9020a4..f34d09ae1 100644 --- a/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts +++ b/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts @@ -39,7 +39,7 @@ describe('generated types', () => { it('excludes non-nestable blocks from bloks unions', () => { type Columns = NonNullable['columns']>; - // `page` is is_nestable: false, so it must not appear; this is exact + // `page` has is_nestable: false, so it must not appear; this is exact // equality against the full expected union, so a stray `'page'` member // fails it (a `not.toEqualTypeOf<'page'>()` check would not: it is // structurally incapable of failing against a multi-member union). diff --git a/packages/cli/src/commands/types/generate/schema-types/render.ts b/packages/cli/src/commands/types/generate/schema-types/render.ts index 50392b9a4..6a76d6b95 100644 --- a/packages/cli/src/commands/types/generate/schema-types/render.ts +++ b/packages/cli/src/commands/types/generate/schema-types/render.ts @@ -1,7 +1,7 @@ import { relative } from 'pathe'; import { toPascalCase } from '../../../../utils/format'; -import { componentFileName, resolveFileNames, resolveVarNames } from '../../../schema/utils'; +import { componentFileName, resolveFileNames, resolveVarNames, toSafeIdentifier } from '../../../schema/utils'; import type { FieldPluginsSource } from './field-plugins'; import type { SerializedBlock } from './serialize'; @@ -44,9 +44,13 @@ function decorate(base: string, options: NameOptions): string { * `BlockDefinition`; components whose names collapse to the same * PascalCase identifier (e.g. `teaser-list` and `teaser_list`) get a numeric * suffix via {@link resolveVarNames} so the file never declares a duplicate. + * + * `toSafeIdentifier` runs on the finished base name, because Storyblok accepts + * component names starting with a digit (`2_col`) and an unguarded `2Col…` would + * make the entire emitted file unparseable, not just its own declaration. */ export function buildNames(componentNames: string[], options: NameOptions): EmittedNames { - const bases = resolveVarNames(componentNames, name => `${toPascalCase(name)}BlockDefinition`); + const bases = resolveVarNames(componentNames, name => toSafeIdentifier(`${toPascalCase(name)}BlockDefinition`)); return { blocks: decorate('Blocks', options), schema: decorate('Schema', options), @@ -76,16 +80,28 @@ export function renderHeader(space: string): string[] { ]; } +/** + * Whether the emitted file derives `FieldPlugins` from a user module, as opposed + * to declaring it empty. + * + * Both the `@storyblok/schema` import line and the `FieldPlugins` declaration + * must agree on this: `InferSchema` is used by exactly the branches that import + * the user module, so deciding it once is what keeps the file from importing a + * name it never references. + */ +function usesUserFieldPlugins(options: Pick): boolean { + return options.fieldPlugins.kind !== 'none' && options.fieldPluginsImportPath !== undefined; +} + /** * The `@storyblok/schema` import line. `InferStory`/`InferStoryMapi` back the - * always-emitted `Story`/`StoryMapi` aliases; `InferSchema` is only consumed by - * the `schema`/`record` branches of {@link renderFieldPlugins}, so it is pulled - * in only when needed, a `{ kind: 'none' }` run would otherwise import it - * unused. Names stay alphabetically ordered either way. + * always-emitted `Story`/`StoryMapi` aliases; `InferSchema` is only consumed + * when {@link renderFieldPlugins} imports a user module. Names stay + * alphabetically ordered either way. */ -function renderSchemaImport(fieldPlugins: FieldPluginsSource): string { +function renderSchemaImport(options: Pick): string { const names = ['BlockContent', 'MapiStory as InferStoryMapi']; - if (fieldPlugins.kind !== 'none') { names.push('Schema as InferSchema'); } + if (usesUserFieldPlugins(options)) { names.push('Schema as InferSchema'); } names.push('Story as InferStory'); return `import type { ${names.join(', ')} } from '@storyblok/schema';`; } @@ -102,7 +118,7 @@ function renderSchemaImport(fieldPlugins: FieldPluginsSource): string { function renderFieldPlugins(options: RenderOptions, names: EmittedNames): { imports: string[]; declaration: string } { const { fieldPlugins, fieldPluginsImportPath } = options; - if (fieldPlugins.kind === 'none' || fieldPluginsImportPath === undefined) { + if (!usesUserFieldPlugins(options)) { return { imports: [], declaration: `export type ${names.fieldPlugins} = Record;` }; } @@ -153,7 +169,7 @@ export function renderSchemaTypes(options: RenderOptions): string { const lines = [ ...renderHeader(options.space), - renderSchemaImport(options.fieldPlugins), + renderSchemaImport(options), ...fieldPlugins.imports, '', ]; @@ -197,7 +213,7 @@ export function renderSeparateFiles(options: RenderOptions & { filename: string const definitionNames = componentNames.map(name => names.definitionByComponent.get(name)!); const mainLines = [ ...renderHeader(options.space), - renderSchemaImport(options.fieldPlugins), + renderSchemaImport(options), ...fieldPlugins.imports, ...definitionNames.map((typeName, index) => `import type { ${typeName} } from './blocks/${fileNames[index]}';`), '', From 8144463f8c3840f7a4b6ece32a76975aa9bef632 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Wed, 29 Jul 2026 09:09:41 +0200 Subject: [PATCH 22/35] fix(cli): guard generated identifiers against a leading digit 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 --- .../cli/src/commands/schema/utils.test.ts | 18 +++++++++ packages/cli/src/commands/schema/utils.ts | 15 +++++++ .../generate/schema-types/render.test.ts | 39 +++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/packages/cli/src/commands/schema/utils.test.ts b/packages/cli/src/commands/schema/utils.test.ts index 2dc4625da..7a38d2c3a 100644 --- a/packages/cli/src/commands/schema/utils.test.ts +++ b/packages/cli/src/commands/schema/utils.test.ts @@ -8,6 +8,7 @@ import { formatValue, quoteString, stripKeys, + toSafeIdentifier, } from "./utils"; describe("fileTimestamp", () => { @@ -194,3 +195,20 @@ describe("applyDefaults", () => { expect(entity).toEqual({ name: "page" }); }); }); + +describe("toSafeIdentifier", () => { + it("should leave a valid identifier untouched", () => { + expect(toSafeIdentifier("TeaserListBlockDefinition")).toBe("TeaserListBlockDefinition"); + expect(toSafeIdentifier("_leadingUnderscore")).toBe("_leadingUnderscore"); + expect(toSafeIdentifier("col2")).toBe("col2"); + }); + + it("should prefix an underscore when the name starts with a digit", () => { + expect(toSafeIdentifier("2Col")).toBe("_2Col"); + expect(toSafeIdentifier("2ColBlockDefinition")).toBe("_2ColBlockDefinition"); + }); + + it("should return a bare underscore for an empty name", () => { + expect(toSafeIdentifier("")).toBe("_"); + }); +}); diff --git a/packages/cli/src/commands/schema/utils.ts b/packages/cli/src/commands/schema/utils.ts index ba58ae90c..ad494bf22 100644 --- a/packages/cli/src/commands/schema/utils.ts +++ b/packages/cli/src/commands/schema/utils.ts @@ -193,6 +193,21 @@ export function toKebabCase(str: string): string { .replace(/^-+|-+$/g, ''); } +/** + * Guards a generated name against the two shapes that are not valid JS/TS + * identifiers: empty, and starting with a digit. Storyblok accepts component + * names like `2_col`, whose camelCase/PascalCase form would otherwise be emitted + * as a bare `2Col` and make the whole generated file a syntax error. Prefixing + * `_` keeps the name readable and collision-free (`_2Col`). + * + * Apply this to the *finished* identifier, after any fixed suffix is appended: + * `Foo` + `BlockDefinition` needs no guard, but a leading digit still does. + */ +export function toSafeIdentifier(identifier: string): string { + if (!identifier) { return '_'; } + return /^\d/.test(identifier) ? `_${identifier}` : identifier; +} + /** * Resolves an ordered list of raw names to unique variable names. Names that * sanitize to the same identifier get a numeric suffix (`…2`, `…3`), so the diff --git a/packages/cli/src/commands/types/generate/schema-types/render.test.ts b/packages/cli/src/commands/types/generate/schema-types/render.test.ts index 3f3a550bc..1bc5006c7 100644 --- a/packages/cli/src/commands/types/generate/schema-types/render.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/render.test.ts @@ -45,6 +45,18 @@ describe('buildNames', () => { expect(first).not.toBe(second); expect([first, second]).toContain('TeaserListBlockDefinition'); }); + + it('keeps a component name starting with a digit a valid identifier', () => { + const names = buildNames(['2_col'], {}); + + expect(names.definitionByComponent.get('2_col')).toBe('_2ColBlockDefinition'); + }); + + it('keeps a digit-leading name valid under a prefix and suffix', () => { + const names = buildNames(['2_col'], { typePrefix: 'Sb', typeSuffix: 'Type' }); + + expect(names.definitionByComponent.get('2_col')).toBe('Sb_2ColBlockDefinitionType'); + }); }); describe('renderSchemaTypes', () => { @@ -108,6 +120,20 @@ describe('renderSchemaTypes', () => { expect(output).toContain('export type FieldPlugins = InferSchema<{ blocks: Record; fieldPlugins: typeof userFieldPlugins }>[\'fieldPlugins\'];'); }); + it('declares and references a digit-leading component under its safe name', () => { + const output = renderSchemaTypes({ + blocks: [{ componentName: '2_col', definitionBody: '{}', customFieldTypes: [] }, heroBlock], + fieldPlugins: { kind: 'none' }, + space: '295018', + }); + + // A bare `2ColBlockDefinition` is a syntax error that takes the whole file + // with it, so the declaration and the union must both use the guarded name. + expect(output).toContain('export type _2ColBlockDefinition = {}'); + expect(output).toContain('export type Blocks = _2ColBlockDefinition | HeroBlockDefinition;'); + expect(output).not.toMatch(/\b2ColBlockDefinition/); + }); + it('records the space in the generated header', () => { const output = renderSchemaTypes({ blocks: [heroBlock], fieldPlugins: { kind: 'none' }, space: '295018' }); @@ -148,6 +174,19 @@ describe('renderSeparateFiles', () => { expect(files.get('storyblok-schema.d.ts')).not.toContain('export type HeroBlockDefinition = {'); }); + it('uses the safe name in both the block file and the main file import', () => { + const files = renderSeparateFiles({ + blocks: [{ componentName: '2_col', definitionBody: '{}', customFieldTypes: [] }], + fieldPlugins: { kind: 'none' }, + space: '295018', + filename: 'storyblok-schema', + }); + + expect(files.get('blocks/2-col.d.ts')).toContain('export type _2ColBlockDefinition = {}'); + expect(files.get('storyblok-schema.d.ts')).toContain('import type { _2ColBlockDefinition } from \'./blocks/2-col\';'); + expect(files.get('storyblok-schema.d.ts')).not.toMatch(/\b2ColBlockDefinition/); + }); + it('disambiguates block file names that collide after kebab-casing', () => { const files = renderSeparateFiles({ blocks: [ From b0608d4370b1574be302b3575bc09733366c2e79 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Wed, 29 Jul 2026 09:09:53 +0200 Subject: [PATCH 23/35] docs(cli): document the --future-schema flags and output layout 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 --- .../cli/src/commands/types/generate/README.md | 77 ++++++++++++++----- 1 file changed, 59 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/commands/types/generate/README.md b/packages/cli/src/commands/types/generate/README.md index fe232096f..75c663f7e 100644 --- a/packages/cli/src/commands/types/generate/README.md +++ b/packages/cli/src/commands/types/generate/README.md @@ -21,19 +21,20 @@ storyblok types generate --space ## Options -| Option | Description | Default | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | -| `--future-schema` | Generate types derived from the space schema instead of the deprecated legacy generator. Not compatible with `--strict`, `--suffix`, `--custom-fields-parser`, or `--compiler-options` | `false` | -| `--field-plugins ` | Path to a module exporting your `defineFieldPlugin` declarations, used to type `custom` fields. Requires `--future-schema` | `.storyblok/schema/schema.ts` | -| `--sf, --separate-files` | Generate separate type definition files for each component | `false` | -| `--strict` | Enable strict mode with no loose typing | `false` | -| `--filename ` | File name for the generated type files | `storyblok` | -| `--type-prefix ` | Prefix to be prepended to all generated component type names | - | -| `--suffix ` | Suffix for component names | - | -| `--custom-fields-parser ` | Path to the parser file for Custom Field Types | - | -| `--compiler-options ` | Path to the compiler options from json-schema-to-typescript | - | -| `--space ` | (Required) The ID of your Storyblok space | - | -| `--path ` | Path to the directory containing your component files | `.storyblok/components` | +| Option | Description | Default | +| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| `--future-schema` | Generate types derived from the space schema instead of the deprecated legacy generator. Not compatible with `--strict`, `--suffix`, `--custom-fields-parser`, or `--compiler-options` | `false` | +| `--field-plugins ` | Path to a module exporting your `defineFieldPlugin` declarations, used to type `custom` fields. Requires `--future-schema` | `.storyblok/schema/schema.ts` | +| `--sf, --separate-files` | Generate separate type definition files for each component | `false` | +| `--strict` | Enable strict mode with no loose typing | `false` | +| `--filename ` | Base file name for the generated type files. The legacy generator ignores it under `--separate-files`; `--future-schema` uses it to name the main file | `storyblok`, or `storyblok-schema` under `--future-schema` | +| `--type-prefix ` | Prefix to be prepended to all generated component type names | - | +| `--type-suffix ` | Suffix to be appended to all generated component type names | - | +| `--suffix ` | Suffix for component names, used to select the pulled component files the legacy generator reads | - | +| `--custom-fields-parser ` | Path to the parser file for Custom Field Types | - | +| `--compiler-options ` | Path to the compiler options from json-schema-to-typescript | - | +| `--space ` | (Required) The ID of your Storyblok space | - | +| `--path ` | Path to the directory containing your component files | `.storyblok/components` | ## Examples @@ -63,15 +64,18 @@ storyblok types generate --space 12345 --separate-files ## File Structure -The command will generate two files: +Both generators write under `.storyblok/types/`, where the `{spaceId}` folder corresponds to the ID +of your Storyblok space. Use `--path` to write somewhere other than `.storyblok`. + +### Legacy generator + +The legacy generator generates two files: 1. A `storyblok.d.ts` file with base Storyblok types (like `StoryblokAsset`, `StoryblokRichTextDoc`, etc.) 2. A `storyblok-components.d.ts` file for each space inside the `.storyblok/types/{spaceId}/` directory with your component types -### Example Structure - When running: ```bash @@ -88,8 +92,39 @@ The following structure will be created: └── storyblok-components.d.ts # Your component types ``` -> **Note:** The `{spaceId}` folder corresponds to the ID of your Storyblok space. The generated -> files are always placed under `.storyblok/types/` and `.storyblok/types/{spaceId}/`. +### `--future-schema` + +`--future-schema` generates a single `storyblok-schema.d.ts` file per space, which exports `Blocks`, +`Schema`, `FieldPlugins`, `Block`, `AnyBlock`, `Story`, and `StoryMapi`. It writes no +base-types file, because the base types come from `@storyblok/schema` at compile time. + +When running: + +```bash +storyblok types generate --space 295018 --future-schema +``` + +The following structure will be created: + +``` +.storyblok/ +└── types/ + └── 295018/ + └── storyblok-schema.d.ts # Your block definitions and the derived surface +``` + +With `--separate-files`, each block definition moves into its own file under `blocks/`, and the main +file imports them: + +``` +.storyblok/ +└── types/ + └── 295018/ + ├── blocks/ + │ ├── hero.d.ts + │ └── teaser-list.d.ts + └── storyblok-schema.d.ts # Imports the block files, exports the surface +``` ## Notes @@ -99,3 +134,9 @@ The following structure will be created: - When using `--strict`, the generated types will be more precise but may require more explicit type handling in your code - Custom field types can be handled by providing a parser file with `--custom-fields-parser` +- Files generated with `--future-schema` import from `@storyblok/schema`, so install it as a dev + dependency: `npm i -D @storyblok/schema`. It is a types-only import and never reaches your bundle +- Under `--future-schema`, custom fields resolve through `defineFieldPlugin` declarations. Point + `--field-plugins` at the module that exports them, or place it at the default + `.storyblok/schema/schema.ts`. Custom fields with no matching declaration fall back to an untyped + value and the command warns which `field_type`s were unmapped From 03e6444997360db57832d671e27d35eff1ccb7a0 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Wed, 29 Jul 2026 09:26:31 +0200 Subject: [PATCH 24/35] docs(cli): drop the --future-schema output layout from the command readme 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 --- .../cli/src/commands/types/generate/README.md | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/packages/cli/src/commands/types/generate/README.md b/packages/cli/src/commands/types/generate/README.md index 75c663f7e..3fe3e1534 100644 --- a/packages/cli/src/commands/types/generate/README.md +++ b/packages/cli/src/commands/types/generate/README.md @@ -67,8 +67,6 @@ storyblok types generate --space 12345 --separate-files Both generators write under `.storyblok/types/`, where the `{spaceId}` folder corresponds to the ID of your Storyblok space. Use `--path` to write somewhere other than `.storyblok`. -### Legacy generator - The legacy generator generates two files: 1. A `storyblok.d.ts` file with base Storyblok types (like `StoryblokAsset`, `StoryblokRichTextDoc`, @@ -92,40 +90,6 @@ The following structure will be created: └── storyblok-components.d.ts # Your component types ``` -### `--future-schema` - -`--future-schema` generates a single `storyblok-schema.d.ts` file per space, which exports `Blocks`, -`Schema`, `FieldPlugins`, `Block`, `AnyBlock`, `Story`, and `StoryMapi`. It writes no -base-types file, because the base types come from `@storyblok/schema` at compile time. - -When running: - -```bash -storyblok types generate --space 295018 --future-schema -``` - -The following structure will be created: - -``` -.storyblok/ -└── types/ - └── 295018/ - └── storyblok-schema.d.ts # Your block definitions and the derived surface -``` - -With `--separate-files`, each block definition moves into its own file under `blocks/`, and the main -file imports them: - -``` -.storyblok/ -└── types/ - └── 295018/ - ├── blocks/ - │ ├── hero.d.ts - │ └── teaser-list.d.ts - └── storyblok-schema.d.ts # Imports the block files, exports the surface -``` - ## Notes - The command requires you to be logged in to Storyblok From d2f549a5e66821e693e78c0076b392ee03784ca4 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Wed, 29 Jul 2026 09:55:21 +0200 Subject: [PATCH 25/35] fix(cli): make generated types resolve under nodenext and unify --filename 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 --- .../src/commands/types/generate/actions.ts | 3 +- .../src/commands/types/generate/constants.ts | 3 + .../commands/types/generate/filename.test.ts | 17 +++ .../src/commands/types/generate/filename.ts | 15 +++ .../commands/types/generate/future-schema.ts | 23 ++-- .../src/commands/types/generate/index.test.ts | 121 +++++++++++++----- .../expected-types-with-plugins.d.ts | 2 +- .../schema-types/field-plugins.test.ts | 15 ++- .../generate/schema-types/field-plugins.ts | 10 +- .../schema-types/fixture-drift.test.ts | 5 +- .../types/generate/schema-types/index.ts | 17 ++- .../generate/schema-types/render.test.ts | 19 ++- .../types/generate/schema-types/render.ts | 30 ++++- 13 files changed, 222 insertions(+), 58 deletions(-) create mode 100644 packages/cli/src/commands/types/generate/filename.test.ts create mode 100644 packages/cli/src/commands/types/generate/filename.ts diff --git a/packages/cli/src/commands/types/generate/actions.ts b/packages/cli/src/commands/types/generate/actions.ts index 20260274c..90b04f0c9 100644 --- a/packages/cli/src/commands/types/generate/actions.ts +++ b/packages/cli/src/commands/types/generate/actions.ts @@ -9,6 +9,7 @@ import { toPascalCase, } from "../../../utils"; import type { GenerateTypesOptions } from "./constants"; +import { toDeclarationFileName } from "./filename"; import type { StoryblokPropertyType } from "../../../types/storyblok"; import { storyblokSchemas } from "../../../utils/storyblok-schemas"; import { getLogger } from "../../../lib/logger/logger"; @@ -589,7 +590,7 @@ export const saveTypesToComponentsFile = async ( } } else if (typeof typedefData === "string") { // Save all types to a single file - await saveToFile(join(resolvedPath, `${filename}.d.ts`), typedefData); + await saveToFile(join(resolvedPath, toDeclarationFileName(filename)), typedefData); } } catch (error) { handleFileSystemError("write", error as Error); diff --git a/packages/cli/src/commands/types/generate/constants.ts b/packages/cli/src/commands/types/generate/constants.ts index 6b37b2c09..721217a1d 100644 --- a/packages/cli/src/commands/types/generate/constants.ts +++ b/packages/cli/src/commands/types/generate/constants.ts @@ -1,3 +1,6 @@ +/** Base file name `--future-schema` writes when `--filename` is unset. */ +export const DEFAULT_SCHEMA_TYPES_FILENAME = 'storyblok-schema'; + export interface GenerateTypesOptions { separateFiles?: boolean; strict?: boolean; diff --git a/packages/cli/src/commands/types/generate/filename.test.ts b/packages/cli/src/commands/types/generate/filename.test.ts new file mode 100644 index 000000000..e2da3c15f --- /dev/null +++ b/packages/cli/src/commands/types/generate/filename.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { toDeclarationFileName } from './filename'; + +describe('toDeclarationFileName', () => { + it('appends the declaration extension to a base name', () => { + expect(toDeclarationFileName('storyblok-components')).toBe('storyblok-components.d.ts'); + }); + + it('does not double an extension the user already spelled out', () => { + expect(toDeclarationFileName('my-types.d.ts')).toBe('my-types.d.ts'); + }); + + it('leaves an unrelated extension alone, since it is part of the name', () => { + expect(toDeclarationFileName('my-types.generated')).toBe('my-types.generated.d.ts'); + }); +}); diff --git a/packages/cli/src/commands/types/generate/filename.ts b/packages/cli/src/commands/types/generate/filename.ts new file mode 100644 index 000000000..80c7720b0 --- /dev/null +++ b/packages/cli/src/commands/types/generate/filename.ts @@ -0,0 +1,15 @@ +/** + * Builds the declaration file name from a `--filename` value. + * + * `--filename` is documented as taking a base name, and both generators append + * `.d.ts` to it. Users reasonably read the documented default + * (`storyblok-components.d.ts`) as the value to pass, which used to produce + * `storyblok-components.d.ts.d.ts`. Tolerate an extension the user already + * spelled out rather than doubling it. + * + * Shared by both generators so the same flag cannot mean two things depending on + * `--future-schema`. + */ +export function toDeclarationFileName(filename: string): string { + return `${filename.replace(/\.d\.ts$/, '')}.d.ts`; +} diff --git a/packages/cli/src/commands/types/generate/future-schema.ts b/packages/cli/src/commands/types/generate/future-schema.ts index ca9399d5f..ef693768f 100644 --- a/packages/cli/src/commands/types/generate/future-schema.ts +++ b/packages/cli/src/commands/types/generate/future-schema.ts @@ -1,12 +1,12 @@ -import { join } from 'pathe'; +import { join, relative } from 'pathe'; import { colorPalette, commands } from '../../../constants'; import { CommandError, handleError, toError } from '../../../utils'; import { resolvePath } from '../../../utils/filesystem'; import type { CLISpinner } from '../../../lib/ui'; import { getUI } from '../../../lib/ui'; -import { DEFAULT_SCHEMA_ENTRY_PATH } from '../../schema/constants'; -import type { GenerateTypesOptions } from './constants'; +import { DEFAULT_SCHEMA_TYPES_FILENAME, type GenerateTypesOptions } from './constants'; +import { toDeclarationFileName } from './filename'; import { assertNoLegacyFlags, generateSchemaTypes } from './schema-types'; export interface FutureSchemaCommandOptions { @@ -56,9 +56,9 @@ export async function runFutureSchemaTypes( } if (filename !== undefined) { ui.warn( - `--filename is set to \`${filename}\`, which is also where the legacy generator writes. ` - + 'Regenerating with and without --future-schema will overwrite one with the other. ' - + 'Leave it unset to keep them in separate files.', + `--filename is set to \`${toDeclarationFileName(filename)}\`, which is also where the legacy ` + + 'generator writes. Regenerating with and without --future-schema will overwrite one with the ' + + `other. Leave it unset to write to ${toDeclarationFileName(DEFAULT_SCHEMA_TYPES_FILENAME)} instead.`, ); } @@ -68,7 +68,7 @@ export async function runFutureSchemaTypes( cwd: process.cwd(), path, outputDir: resolvePath(path, join('types', space)), - filename: filename ?? 'storyblok-schema', + filename: filename ?? DEFAULT_SCHEMA_TYPES_FILENAME, separateFiles, typePrefix: options.typePrefix, typeSuffix: options.typeSuffix, @@ -78,10 +78,15 @@ export async function runFutureSchemaTypes( result.files.forEach(file => ui.ok(file)); if (result.unmappedFieldTypes.length > 0) { + // Names the module actually in use, or the path this run searched. `--path` + // moves the convention path, so the default would point at the wrong file. + const where = relative(process.cwd(), result.fieldPlugins.path); + const remedy = result.fieldPlugins.resolved + ? `in ${where}.` + : `and point --field-plugins at the module (or place it at ${where}).`; ui.warn( `No field plugin registered for: ${result.unmappedFieldTypes.join(', ')}. ` - + 'These custom fields fall back to an untyped value. Declare them with defineFieldPlugin ' - + `and point --field-plugins at the module (or place it at ${DEFAULT_SCHEMA_ENTRY_PATH}).`, + + `These custom fields fall back to an untyped value. Declare them with defineFieldPlugin ${remedy}`, ); } ui.info('The generated types import from `@storyblok/schema`. Install it as a dev dependency: `npm i -D @storyblok/schema`.'); diff --git a/packages/cli/src/commands/types/generate/index.test.ts b/packages/cli/src/commands/types/generate/index.test.ts index ae0b39a6d..0e38cb420 100644 --- a/packages/cli/src/commands/types/generate/index.test.ts +++ b/packages/cli/src/commands/types/generate/index.test.ts @@ -350,6 +350,7 @@ describe("types generate", () => { vi.mocked(generateSchemaTypes).mockResolvedValue({ files: ["/project/.storyblok/types/295018/storyblok-schema.d.ts"], unmappedFieldTypes: ["storyblok-colorpicker"], + fieldPlugins: { resolved: false, path: "/project/.storyblok/schema/schema.ts" }, }); await typesCommand.parseAsync([ @@ -370,47 +371,105 @@ describe("types generate", () => { expect(uiSpinnerFailedMock).not.toHaveBeenCalled(); }); - it('forwards --field-plugins, --type-prefix, --type-suffix, and --path to the generator', async () => { - vi.mocked(generateSchemaTypes).mockResolvedValue({ files: [], unmappedFieldTypes: [] }); + it("points the unmapped-field-type warning at the module it read instead of the default path", async () => { + vi.mocked(generateSchemaTypes).mockResolvedValue({ + files: ["/project/.storyblok/types/295018/storyblok-schema.d.ts"], + unmappedFieldTypes: ["storyblok-colorpicker"], + fieldPlugins: { resolved: true, path: "/project/src/storyblok/field-plugins.ts" }, + }); await typesCommand.parseAsync([ - 'node', - 'test', - 'generate', - '--space', - '295018', - '--future-schema', - '--field-plugins', - './src/storyblok/plugins.ts', - '--type-prefix', - 'Sb', - '--type-suffix', - 'Type', + "node", + "test", + "generate", + "--space", + "295018", + "--future-schema", ]); - expect(generateSchemaTypes).toHaveBeenCalledWith(expect.objectContaining({ - space: '295018', - fieldPluginsPath: './src/storyblok/plugins.ts', - typePrefix: 'Sb', - typeSuffix: 'Type', - })); + expect(uiWarnMock).toHaveBeenCalledWith( + expect.stringContaining("src/storyblok/field-plugins.ts"), + ); + expect(uiWarnMock).not.toHaveBeenCalledWith( + expect.stringContaining("--field-plugins at the module"), + ); }); - it('warns that --filename collides with the legacy generator output', async () => { - vi.mocked(generateSchemaTypes).mockResolvedValue({ files: [], unmappedFieldTypes: [] }); + // `--path` moves the convention path, so naming the default would point the + // user at a file they may already have. + it("names the searched path, not the default, when no field-plugins module resolved", async () => { + vi.mocked(generateSchemaTypes).mockResolvedValue({ + files: ["/project/config/types/295018/storyblok-schema.d.ts"], + unmappedFieldTypes: ["storyblok-colorpicker"], + fieldPlugins: { resolved: false, path: "/project/config/schema/schema.ts" }, + }); await typesCommand.parseAsync([ - 'node', - 'test', - 'generate', - '--space', - '295018', - '--future-schema', - '--filename', - 'shared', + "node", + "test", + "generate", + "--space", + "295018", + "--future-schema", + ]); + + expect(uiWarnMock).toHaveBeenCalledWith(expect.stringContaining("config/schema/schema.ts")); + expect(uiWarnMock).not.toHaveBeenCalledWith( + expect.stringContaining(".storyblok/schema/schema.ts"), + ); + }); + + it("forwards --field-plugins, --type-prefix, --type-suffix, and --path to the generator", async () => { + vi.mocked(generateSchemaTypes).mockResolvedValue({ + files: [], + unmappedFieldTypes: [], + fieldPlugins: { resolved: false, path: "/project/.storyblok/schema/schema.ts" }, + }); + + await typesCommand.parseAsync([ + "node", + "test", + "generate", + "--space", + "295018", + "--future-schema", + "--field-plugins", + "./src/storyblok/plugins.ts", + "--type-prefix", + "Sb", + "--type-suffix", + "Type", + ]); + + expect(generateSchemaTypes).toHaveBeenCalledWith( + expect.objectContaining({ + space: "295018", + fieldPluginsPath: "./src/storyblok/plugins.ts", + typePrefix: "Sb", + typeSuffix: "Type", + }), + ); + }); + + it("warns that --filename collides with the legacy generator output", async () => { + vi.mocked(generateSchemaTypes).mockResolvedValue({ + files: [], + unmappedFieldTypes: [], + fieldPlugins: { resolved: false, path: "/project/.storyblok/schema/schema.ts" }, + }); + + await typesCommand.parseAsync([ + "node", + "test", + "generate", + "--space", + "295018", + "--future-schema", + "--filename", + "shared", ]); - expect(uiWarnMock).toHaveBeenCalledWith(expect.stringContaining('--filename')); + expect(uiWarnMock).toHaveBeenCalledWith(expect.stringContaining("--filename")); }); }); }); diff --git a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types-with-plugins.d.ts b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types-with-plugins.d.ts index 573eaa935..35171d2bb 100644 --- a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types-with-plugins.d.ts +++ b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types-with-plugins.d.ts @@ -2,7 +2,7 @@ // Space: 295018 import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from '@storyblok/schema'; -import type { fieldPlugins as userFieldPlugins } from './plugins'; +import type { fieldPlugins as userFieldPlugins } from './plugins.js'; export type GridBlockDefinition = { readonly id: number; diff --git a/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts b/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts index 3816ef630..290f303c6 100644 --- a/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts @@ -34,7 +34,10 @@ afterEach(async () => { describe('resolveFieldPluginsSource', () => { it('returns none when neither an override nor the convention file exists', async () => { - expect(await resolveFieldPluginsSource({ cwd })).toEqual({ kind: 'none' }); + expect(await resolveFieldPluginsSource({ cwd })).toEqual({ + kind: 'none', + searchedPath: join(cwd, DEFAULT_SCHEMA_ENTRY_PATH), + }); }); it('detects a defineSchema result at the convention path', async () => { @@ -86,7 +89,10 @@ describe('resolveFieldPluginsSource', () => { await mkdir(join(defaultTarget, '..'), { recursive: true }); await writeFile(defaultTarget, SCHEMA_EXPORT, 'utf8'); - expect(await resolveFieldPluginsSource({ cwd, path: 'config' })).toEqual({ kind: 'none' }); + expect(await resolveFieldPluginsSource({ cwd, path: 'config' })).toEqual({ + kind: 'none', + searchedPath: join(cwd, 'config', SCHEMA_ENTRY_RELATIVE_PATH), + }); }); it('names a near-miss export in the error for an explicit override', async () => { @@ -112,6 +118,9 @@ describe('resolveFieldPluginsSource', () => { await mkdir(join(target, '..'), { recursive: true }); await writeFile(target, 'export const schema = { blocks: {} };', 'utf8'); - expect(await resolveFieldPluginsSource({ cwd })).toEqual({ kind: 'none' }); + expect(await resolveFieldPluginsSource({ cwd })).toEqual({ + kind: 'none', + searchedPath: join(cwd, DEFAULT_SCHEMA_ENTRY_PATH), + }); }); }); diff --git a/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts b/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts index ee0e5cb8a..35138b487 100644 --- a/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts +++ b/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts @@ -14,9 +14,13 @@ import { isRecord } from '../../../schema/utils'; * `schema init` writes). * - `record`, the module exports a bare `fieldPlugins` record. * - `none`: nothing to import; `FieldPlugins` becomes `Record`. + * + * `none` carries the path that was searched, so a message telling the user where + * to put a module names the path this run actually looked at. `--path` moves it, + * and naming the default there would point at a file the user may already have. */ export type FieldPluginsSource = - | { kind: 'none' } + | { kind: 'none'; searchedPath: string } | { kind: 'schema'; modulePath: string; fieldTypes: string[] } | { kind: 'record'; modulePath: string; fieldTypes: string[] }; @@ -84,7 +88,7 @@ export async function resolveFieldPluginsSource( if (isExplicit) { throw new CommandError(`Field plugins module not found: ${modulePath}`); } - return { kind: 'none' }; + return { kind: 'none', searchedPath: modulePath }; } let module: Record; @@ -113,5 +117,5 @@ export async function resolveFieldPluginsSource( : ` Found \`${nearMiss}\`, which looks like one: rename it to \`schema\` or \`fieldPlugins\`.`}`, ); } - return { kind: 'none' }; + return { kind: 'none', searchedPath: modulePath }; } diff --git a/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts b/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts index 54216ae83..588ebed03 100644 --- a/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { Component } from '../../../../types'; import { FIXTURE_COMPONENTS } from './__fixtures__/components'; -import { renderSchemaTypes } from './render'; +import { renderSchemaTypes, toRelativeImport } from './render'; import { serializeBlockDefinition } from './serialize'; /** @@ -45,7 +45,8 @@ describe('emitted type fixture', () => { const rendered = renderSchemaTypes({ blocks: serializeFixtureBlocks(), fieldPlugins: { kind: 'record', modulePath: '/abs/plugins.ts', fieldTypes: ['colorpicker'] }, - fieldPluginsImportPath: './plugins', + // Derived rather than hardcoded, so the fixture tracks the real specifier. + fieldPluginsImportPath: toRelativeImport('/abs', '/abs/plugins.ts'), space: '295018', }); diff --git a/packages/cli/src/commands/types/generate/schema-types/index.ts b/packages/cli/src/commands/types/generate/schema-types/index.ts index dd4c7702c..3edcdd60c 100644 --- a/packages/cli/src/commands/types/generate/schema-types/index.ts +++ b/packages/cli/src/commands/types/generate/schema-types/index.ts @@ -5,6 +5,7 @@ import { saveToFile } from '../../../../utils/filesystem'; import { buildGroupDisplayPathByUuid } from '../../../schema/folders'; import { fetchRemoteSchema } from '../../../schema/actions'; import type { GenerateTypesOptions } from '../constants'; +import { toDeclarationFileName } from '../filename'; import { resolveFieldPluginsSource } from './field-plugins'; import { renderSchemaTypes, renderSeparateFiles, toRelativeImport } from './render'; import { serializeBlockDefinition } from './serialize'; @@ -75,6 +76,12 @@ export interface GenerateSchemaTypesResult { files: string[]; /** `custom` field types with no registered plugin, typed loosely, warned about. */ unmappedFieldTypes: string[]; + /** + * The field-plugins module this run used, or the path it searched when none + * resolved. Lets the unmapped-field-type warning name a real path instead of + * the default, which `--path` moves. + */ + fieldPlugins: { resolved: boolean; path: string }; } /** @@ -125,7 +132,7 @@ export async function generateSchemaTypes( const outputs = options.separateFiles ? renderSeparateFiles({ ...renderOptions, filename: options.filename }) - : new Map([[`${options.filename}.d.ts`, renderSchemaTypes(renderOptions)]]); + : new Map([[toDeclarationFileName(options.filename), renderSchemaTypes(renderOptions)]]); const files: string[] = []; for (const [relativePath, content] of outputs) { @@ -138,5 +145,11 @@ export async function generateSchemaTypes( const unmappedFieldTypes = [...new Set(blocks.flatMap(block => block.customFieldTypes))] .filter(fieldType => !registered.has(fieldType)); - return { files, unmappedFieldTypes }; + return { + files, + unmappedFieldTypes, + fieldPlugins: fieldPlugins.kind === 'none' + ? { resolved: false, path: fieldPlugins.searchedPath } + : { resolved: true, path: fieldPlugins.modulePath }, + }; } diff --git a/packages/cli/src/commands/types/generate/schema-types/render.test.ts b/packages/cli/src/commands/types/generate/schema-types/render.test.ts index 1bc5006c7..a6fe8bfcd 100644 --- a/packages/cli/src/commands/types/generate/schema-types/render.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/render.test.ts @@ -143,12 +143,25 @@ describe('renderSchemaTypes', () => { }); describe('toRelativeImport', () => { - it('builds a posix relative specifier without the extension', () => { - expect(toRelativeImport('/p/.storyblok/types/1', '/p/.storyblok/schema/schema.ts')).toBe('../../schema/schema'); + it('builds a posix relative specifier with a javascript extension', () => { + expect(toRelativeImport('/p/.storyblok/types/1', '/p/.storyblok/schema/schema.ts')).toBe('../../schema/schema.js'); }); it('prefixes a sibling path with ./', () => { - expect(toRelativeImport('/p/types', '/p/types/plugins.ts')).toBe('./plugins'); + expect(toRelativeImport('/p/types', '/p/types/plugins.ts')).toBe('./plugins.js'); + }); + + // An extension-less specifier is TS2835 under node16/nodenext in an ESM + // package, and the emitted file is generated code the user cannot repair. + it('keeps the specifier resolvable under node16 by never emitting a bare path', () => { + expect(toRelativeImport('/p/types', '/p/plugins.tsx')).toBe('../plugins.js'); + expect(toRelativeImport('/p/types', '/p/plugins.mts')).toBe('../plugins.mjs'); + expect(toRelativeImport('/p/types', '/p/plugins.cts')).toBe('../plugins.cjs'); + }); + + it('leaves a module that already has a javascript extension alone', () => { + expect(toRelativeImport('/p/types', '/p/plugins.js')).toBe('../plugins.js'); + expect(toRelativeImport('/p/types', '/p/plugins.mjs')).toBe('../plugins.mjs'); }); }); diff --git a/packages/cli/src/commands/types/generate/schema-types/render.ts b/packages/cli/src/commands/types/generate/schema-types/render.ts index 6a76d6b95..62dacd3e1 100644 --- a/packages/cli/src/commands/types/generate/schema-types/render.ts +++ b/packages/cli/src/commands/types/generate/schema-types/render.ts @@ -2,6 +2,7 @@ import { relative } from 'pathe'; import { toPascalCase } from '../../../../utils/format'; import { componentFileName, resolveFileNames, resolveVarNames, toSafeIdentifier } from '../../../schema/utils'; +import { toDeclarationFileName } from '../filename'; import type { FieldPluginsSource } from './field-plugins'; import type { SerializedBlock } from './serialize'; @@ -65,9 +66,32 @@ export function buildNames(componentNames: string[], options: NameOptions): Emit }; } -/** Builds a posix, extension-less relative import specifier. */ +/** + * TypeScript source extensions mapped to the module extension an importer must + * write. TypeScript resolves `./x.js` to `x.ts`, so the emitted specifier names + * the *output* file even though the module on disk is TypeScript. + */ +const IMPORT_EXTENSION_BY_SOURCE_EXTENSION: ReadonlyArray = [ + [/\.tsx?$/, '.js'], + [/\.mts$/, '.mjs'], + [/\.cts$/, '.cjs'], +]; + +/** + * Builds a posix relative import specifier. + * + * The extension is rewritten rather than stripped: an extension-less relative + * specifier is an error under `moduleResolution: node16`/`nodenext` in an ESM + * package (TS2835), and this file is generated code the user is told not to + * edit, so they have no way to repair it. A `.js`-style specifier resolves under + * every mode, including `bundler` and legacy `node10`, so this is strictly wider + * than the extension-less form. A module that already has a JavaScript + * extension keeps it. + */ export function toRelativeImport(fromDir: string, toFile: string): string { - const specifier = relative(fromDir, toFile).replace(/\.(?:ts|tsx|mts|cts)$/, ''); + const path = relative(fromDir, toFile); + const mapping = IMPORT_EXTENSION_BY_SOURCE_EXTENSION.find(([pattern]) => pattern.test(path)); + const specifier = mapping === undefined ? path : path.replace(mapping[0], mapping[1]); return specifier.startsWith('.') ? specifier : `./${specifier}`; } @@ -219,7 +243,7 @@ export function renderSeparateFiles(options: RenderOptions & { filename: string '', ...renderSurface(names, definitionNames, fieldPlugins.declaration), ]; - files.set(`${options.filename}.d.ts`, mainLines.join('\n')); + files.set(toDeclarationFileName(options.filename), mainLines.join('\n')); return files; } From de79fce4f6a29446ae721540f3425f0e4382a8f1 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Wed, 29 Jul 2026 10:49:45 +0200 Subject: [PATCH 26/35] fix(cli): resolve --separate-files types under nodenext and prune stale blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects a second manual QA pass against a real space surfaced. 498d1e6c3 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 --- .../commands/types/generate/filename.test.ts | 25 +++++- .../src/commands/types/generate/filename.ts | 19 ++++ .../commands/types/generate/future-schema.ts | 8 ++ .../src/commands/types/generate/index.test.ts | 5 ++ .../types/generate/schema-types/index.test.ts | 89 ++++++++++++++++++- .../types/generate/schema-types/index.ts | 48 +++++++++- .../generate/schema-types/render.test.ts | 34 ++++++- .../types/generate/schema-types/render.ts | 4 +- 8 files changed, 224 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/types/generate/filename.test.ts b/packages/cli/src/commands/types/generate/filename.test.ts index e2da3c15f..c90bdcdcf 100644 --- a/packages/cli/src/commands/types/generate/filename.test.ts +++ b/packages/cli/src/commands/types/generate/filename.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { toDeclarationFileName } from './filename'; +import { toDeclarationFileName, toDeclarationImportSpecifier } from './filename'; describe('toDeclarationFileName', () => { it('appends the declaration extension to a base name', () => { @@ -15,3 +15,26 @@ describe('toDeclarationFileName', () => { expect(toDeclarationFileName('my-types.generated')).toBe('my-types.generated.d.ts'); }); }); + +describe('toDeclarationImportSpecifier', () => { + it('names the output module of the declaration file written for a base name', () => { + expect(toDeclarationImportSpecifier('hero')).toBe('hero.js'); + }); + + // An extension-less specifier is TS2834 under node16/node18/nodenext in an ESM + // package, and this is generated code the user cannot repair by hand. + it('never returns an extension-less specifier', () => { + for (const baseName of ['hero', 'teaser-list', '2-col', 'my-types.d.ts']) { + expect(toDeclarationImportSpecifier(baseName)).toMatch(/\.js$/); + } + }); + + it('pairs with toDeclarationFileName rather than appending to its output', () => { + expect(toDeclarationImportSpecifier('hero.d.ts')).toBe('hero.js'); + expect(toDeclarationImportSpecifier(toDeclarationFileName('hero'))).toBe('hero.js'); + }); + + it('leaves an unrelated extension alone, since it is part of the name', () => { + expect(toDeclarationImportSpecifier('my-types.generated')).toBe('my-types.generated.js'); + }); +}); diff --git a/packages/cli/src/commands/types/generate/filename.ts b/packages/cli/src/commands/types/generate/filename.ts index 80c7720b0..be25de606 100644 --- a/packages/cli/src/commands/types/generate/filename.ts +++ b/packages/cli/src/commands/types/generate/filename.ts @@ -13,3 +13,22 @@ export function toDeclarationFileName(filename: string): string { return `${filename.replace(/\.d\.ts$/, '')}.d.ts`; } + +/** + * Builds the import specifier naming a declaration file written from `baseName`. + * + * `toDeclarationFileName` writes `.d.ts`, but an importer must name the + * emitted module rather than the declaration, so the specifier is + * `.js`. An extension-less + * specifier is TS2834 under `moduleResolution: node16`/`node18`/`nodenext` in an + * ESM package — every modern Node-ESM setup — and this is generated code the user + * is told not to edit, so they have no way to repair it. `.js` resolves under + * every mode, including `bundler` and legacy `node10`, so it is strictly wider + * than the extension-less form. + * + * Kept beside `toDeclarationFileName` so the written name and the specifier that + * has to match it cannot drift apart. + */ +export function toDeclarationImportSpecifier(baseName: string): string { + return `${baseName.replace(/\.d\.ts$/, '')}.js`; +} diff --git a/packages/cli/src/commands/types/generate/future-schema.ts b/packages/cli/src/commands/types/generate/future-schema.ts index ef693768f..fd4132ffe 100644 --- a/packages/cli/src/commands/types/generate/future-schema.ts +++ b/packages/cli/src/commands/types/generate/future-schema.ts @@ -77,6 +77,14 @@ export async function runFutureSchemaTypes( spinner.succeed('Generated types'); result.files.forEach(file => ui.ok(file)); + if (result.prunedFiles.length > 0) { + // Says so rather than deleting silently: these are files a previous run + // wrote, so their disappearance would otherwise look like data loss. + ui.info( + `Removed ${result.prunedFiles.length} stale block type ` + + `${result.prunedFiles.length === 1 ? 'file' : 'files'} for components that no longer exist.`, + ); + } if (result.unmappedFieldTypes.length > 0) { // Names the module actually in use, or the path this run searched. `--path` // moves the convention path, so the default would point at the wrong file. diff --git a/packages/cli/src/commands/types/generate/index.test.ts b/packages/cli/src/commands/types/generate/index.test.ts index 0e38cb420..d29973cb6 100644 --- a/packages/cli/src/commands/types/generate/index.test.ts +++ b/packages/cli/src/commands/types/generate/index.test.ts @@ -349,6 +349,7 @@ describe("types generate", () => { it("generates schema types and reports success, per-file output, and unmapped field types", async () => { vi.mocked(generateSchemaTypes).mockResolvedValue({ files: ["/project/.storyblok/types/295018/storyblok-schema.d.ts"], + prunedFiles: [], unmappedFieldTypes: ["storyblok-colorpicker"], fieldPlugins: { resolved: false, path: "/project/.storyblok/schema/schema.ts" }, }); @@ -374,6 +375,7 @@ describe("types generate", () => { it("points the unmapped-field-type warning at the module it read instead of the default path", async () => { vi.mocked(generateSchemaTypes).mockResolvedValue({ files: ["/project/.storyblok/types/295018/storyblok-schema.d.ts"], + prunedFiles: [], unmappedFieldTypes: ["storyblok-colorpicker"], fieldPlugins: { resolved: true, path: "/project/src/storyblok/field-plugins.ts" }, }); @@ -400,6 +402,7 @@ describe("types generate", () => { it("names the searched path, not the default, when no field-plugins module resolved", async () => { vi.mocked(generateSchemaTypes).mockResolvedValue({ files: ["/project/config/types/295018/storyblok-schema.d.ts"], + prunedFiles: [], unmappedFieldTypes: ["storyblok-colorpicker"], fieldPlugins: { resolved: false, path: "/project/config/schema/schema.ts" }, }); @@ -422,6 +425,7 @@ describe("types generate", () => { it("forwards --field-plugins, --type-prefix, --type-suffix, and --path to the generator", async () => { vi.mocked(generateSchemaTypes).mockResolvedValue({ files: [], + prunedFiles: [], unmappedFieldTypes: [], fieldPlugins: { resolved: false, path: "/project/.storyblok/schema/schema.ts" }, }); @@ -454,6 +458,7 @@ describe("types generate", () => { it("warns that --filename collides with the legacy generator output", async () => { vi.mocked(generateSchemaTypes).mockResolvedValue({ files: [], + prunedFiles: [], unmappedFieldTypes: [], fieldPlugins: { resolved: false, path: "/project/.storyblok/schema/schema.ts" }, }); diff --git a/packages/cli/src/commands/types/generate/schema-types/index.test.ts b/packages/cli/src/commands/types/generate/schema-types/index.test.ts index 0d47509fe..e9d0fc933 100644 --- a/packages/cli/src/commands/types/generate/schema-types/index.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/index.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { vol } from 'memfs'; import { assertNoLegacyFlags, generateSchemaTypes } from './index'; @@ -167,10 +168,96 @@ describe('generateSchemaTypes', () => { expect(written.get('/out/blocks/hero.d.ts')).toContain('export type HeroBlockDefinition = {'); // The surface file imports the block files rather than redeclaring them. const surface = written.get('/out/storyblok-schema.d.ts')!; - expect(surface).toContain('import type { HeroBlockDefinition } from \'./blocks/hero\';'); + expect(surface).toContain('import type { HeroBlockDefinition } from \'./blocks/hero.js\';'); expect(surface).toContain('export type Blocks = HeroBlockDefinition | PageBlockDefinition;'); }); + /** + * `blocks/` holds one file per component, so a component deleted in the UI + * would otherwise leave an importable type describing a block that no longer + * exists. `saveToFile` is mocked here, so only the pre-seeded stale files are + * on the fake filesystem — enough to assert what pruning removes. + */ + it('deletes block files for components that no longer exist', async () => { + written.clear(); + vol.fromJSON({ + '/out/blocks/hero.d.ts': 'stale but still a real component', + '/out/blocks/page.d.ts': 'stale but still a real component', + '/out/blocks/removed-component.d.ts': 'orphan', + }); + + const result = await generateSchemaTypes({ + space: '295018', + cwd: '/project', + outputDir: '/out', + filename: 'storyblok-schema', + separateFiles: true, + }); + + expect(result.prunedFiles).toEqual(['/out/blocks/removed-component.d.ts']); + expect(Object.keys(vol.toJSON())).not.toContain('/out/blocks/removed-component.d.ts'); + // Files for components that still exist are rewritten, not pruned. + expect(Object.keys(vol.toJSON())).toEqual( + expect.arrayContaining(['/out/blocks/hero.d.ts', '/out/blocks/page.d.ts']), + ); + }); + + it('orphans the whole blocks directory when switching back to single-file output', async () => { + written.clear(); + vol.fromJSON({ + '/out/blocks/hero.d.ts': 'from a previous --separate-files run', + '/out/blocks/page.d.ts': 'from a previous --separate-files run', + }); + + const result = await generateSchemaTypes({ + space: '295018', + cwd: '/project', + outputDir: '/out', + filename: 'storyblok-schema', + }); + + expect(result.prunedFiles.sort()).toEqual(['/out/blocks/hero.d.ts', '/out/blocks/page.d.ts']); + }); + + // The output directory is shared with the legacy generator and may hold files + // this command knows nothing about, so pruning stops at `blocks/`. + it('never touches files outside the blocks directory', async () => { + written.clear(); + vol.fromJSON({ + '/out/storyblok-components.d.ts': 'legacy generator output', + '/out/datasource-types.d.ts': 'legacy generator output', + '/out/blocks/nested/keep.d.ts': 'not a file this renderer writes', + }); + + const result = await generateSchemaTypes({ + space: '295018', + cwd: '/project', + outputDir: '/out', + filename: 'storyblok-schema', + separateFiles: true, + }); + + expect(result.prunedFiles).toEqual([]); + expect(Object.keys(vol.toJSON())).toEqual(expect.arrayContaining([ + '/out/storyblok-components.d.ts', + '/out/datasource-types.d.ts', + '/out/blocks/nested/keep.d.ts', + ])); + }); + + it('reports nothing pruned when there is no blocks directory', async () => { + written.clear(); + + const result = await generateSchemaTypes({ + space: '295018', + cwd: '/project', + outputDir: '/out', + filename: 'storyblok-schema', + }); + + expect(result.prunedFiles).toEqual([]); + }); + it('applies --type-prefix and --type-suffix to every exported name', async () => { written.clear(); diff --git a/packages/cli/src/commands/types/generate/schema-types/index.ts b/packages/cli/src/commands/types/generate/schema-types/index.ts index 3edcdd60c..973e0578b 100644 --- a/packages/cli/src/commands/types/generate/schema-types/index.ts +++ b/packages/cli/src/commands/types/generate/schema-types/index.ts @@ -1,7 +1,8 @@ +import { rm } from 'node:fs/promises'; import { join } from 'pathe'; import { CommandError } from '../../../../utils'; -import { saveToFile } from '../../../../utils/filesystem'; +import { fileExists, readDirectory, saveToFile } from '../../../../utils/filesystem'; import { buildGroupDisplayPathByUuid } from '../../../schema/folders'; import { fetchRemoteSchema } from '../../../schema/actions'; import type { GenerateTypesOptions } from '../constants'; @@ -55,6 +56,46 @@ export function assertNoLegacyFlags( return fromConfig.map(([, flag]) => flag); } +/** The subdirectory `--separate-files` owns, one declaration file per block. */ +const BLOCKS_DIR = 'blocks'; + +/** + * Deletes declaration files in `blocks/` that this run did not write. + * + * The directory is created only by `--future-schema --separate-files` and holds + * one file per component, so its correct contents are exactly this run's output. + * Without reconciling it, a component deleted in the UI leaves its type file + * behind — still importable, describing a block that no longer exists — and + * switching back to single-file output orphans the whole directory. Users are + * told not to hand-edit generated types, so they would not think to clean it. + * + * Scoped deliberately: only `*.d.ts` directly inside `blocks/`, never nested + * paths and never the output directory itself, which the legacy generator also + * writes into and which may hold files this command knows nothing about. + * + * @returns absolute paths deleted. + */ +async function pruneStaleBlockFiles(outputDir: string, outputs: Map): Promise { + const blocksDir = join(outputDir, BLOCKS_DIR); + + if (!await fileExists(blocksDir)) { + return []; + } + + const written = new Set([...outputs.keys()]); + const entries = await readDirectory(blocksDir); + const stale = entries.filter(entry => entry.endsWith('.d.ts') && !written.has(`${BLOCKS_DIR}/${entry}`)); + + const deleted: string[] = []; + for (const entry of stale) { + const absolutePath = join(blocksDir, entry); + await rm(absolutePath, { force: true }); + deleted.push(absolutePath); + } + + return deleted; +} + export interface GenerateSchemaTypesOptions { space: string; /** Project root, used to resolve the field-plugins module. */ @@ -74,6 +115,8 @@ export interface GenerateSchemaTypesOptions { export interface GenerateSchemaTypesResult { /** Absolute paths written, in write order. */ files: string[]; + /** Absolute paths of stale `blocks/` declarations this run deleted. */ + prunedFiles: string[]; /** `custom` field types with no registered plugin, typed loosely, warned about. */ unmappedFieldTypes: string[]; /** @@ -141,12 +184,15 @@ export async function generateSchemaTypes( files.push(absolutePath); } + const prunedFiles = await pruneStaleBlockFiles(options.outputDir, outputs); + const registered = new Set(fieldPlugins.kind === 'none' ? [] : fieldPlugins.fieldTypes); const unmappedFieldTypes = [...new Set(blocks.flatMap(block => block.customFieldTypes))] .filter(fieldType => !registered.has(fieldType)); return { files, + prunedFiles, unmappedFieldTypes, fieldPlugins: fieldPlugins.kind === 'none' ? { resolved: false, path: fieldPlugins.searchedPath } diff --git a/packages/cli/src/commands/types/generate/schema-types/render.test.ts b/packages/cli/src/commands/types/generate/schema-types/render.test.ts index a6fe8bfcd..2ee04de56 100644 --- a/packages/cli/src/commands/types/generate/schema-types/render.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/render.test.ts @@ -166,6 +166,34 @@ describe('toRelativeImport', () => { }); describe('renderSeparateFiles', () => { + /** + * Guards the invariant at the level that matters, rather than per call site: + * every relative specifier the renderer emits must carry a JavaScript + * extension. An extension-less one is TS2834 under node16/node18/nodenext in + * an ESM package, which is every modern Node-ESM project, and the user is told + * not to edit generated types so they cannot repair it. This previously + * regressed because the block imports were built inline instead of going + * through a helper, so assert over the emitted text. + */ + it('emits no extension-less relative import in any file, in either mode', () => { + const options = { + blocks: [heroBlock, teaserListBlock], + fieldPlugins: { kind: 'record' as const, modulePath: '/abs/plugins.ts', fieldTypes: ['colorpicker'] }, + fieldPluginsImportPath: '../../schema/plugins.js', + space: '295018', + }; + + const emitted = [ + ...renderSeparateFiles({ ...options, filename: 'storyblok-schema' }).values(), + renderSchemaTypes(options), + ]; + + const specifiers = emitted.flatMap(content => [...content.matchAll(/from '(\.[^']*)'/g)].map(match => match[1])); + + expect(specifiers.length).toBeGreaterThan(0); + expect(specifiers.filter(specifier => !/\.(?:js|mjs|cjs)$/.test(specifier))).toEqual([]); + }); + it('writes one definition per block file and imports them in the main file', () => { const files = renderSeparateFiles({ blocks: [heroBlock, teaserListBlock], @@ -181,8 +209,8 @@ describe('renderSeparateFiles', () => { ]); expect(files.get('blocks/hero.d.ts')).toContain('export type HeroBlockDefinition = {'); - expect(files.get('storyblok-schema.d.ts')).toContain('import type { HeroBlockDefinition } from \'./blocks/hero\';'); - expect(files.get('storyblok-schema.d.ts')).toContain('import type { TeaserListBlockDefinition } from \'./blocks/teaser-list\';'); + expect(files.get('storyblok-schema.d.ts')).toContain('import type { HeroBlockDefinition } from \'./blocks/hero.js\';'); + expect(files.get('storyblok-schema.d.ts')).toContain('import type { TeaserListBlockDefinition } from \'./blocks/teaser-list.js\';'); expect(files.get('storyblok-schema.d.ts')).toContain('export type Blocks = HeroBlockDefinition | TeaserListBlockDefinition;'); expect(files.get('storyblok-schema.d.ts')).not.toContain('export type HeroBlockDefinition = {'); }); @@ -196,7 +224,7 @@ describe('renderSeparateFiles', () => { }); expect(files.get('blocks/2-col.d.ts')).toContain('export type _2ColBlockDefinition = {}'); - expect(files.get('storyblok-schema.d.ts')).toContain('import type { _2ColBlockDefinition } from \'./blocks/2-col\';'); + expect(files.get('storyblok-schema.d.ts')).toContain('import type { _2ColBlockDefinition } from \'./blocks/2-col.js\';'); expect(files.get('storyblok-schema.d.ts')).not.toMatch(/\b2ColBlockDefinition/); }); diff --git a/packages/cli/src/commands/types/generate/schema-types/render.ts b/packages/cli/src/commands/types/generate/schema-types/render.ts index 62dacd3e1..55551bab2 100644 --- a/packages/cli/src/commands/types/generate/schema-types/render.ts +++ b/packages/cli/src/commands/types/generate/schema-types/render.ts @@ -2,7 +2,7 @@ import { relative } from 'pathe'; import { toPascalCase } from '../../../../utils/format'; import { componentFileName, resolveFileNames, resolveVarNames, toSafeIdentifier } from '../../../schema/utils'; -import { toDeclarationFileName } from '../filename'; +import { toDeclarationFileName, toDeclarationImportSpecifier } from '../filename'; import type { FieldPluginsSource } from './field-plugins'; import type { SerializedBlock } from './serialize'; @@ -239,7 +239,7 @@ export function renderSeparateFiles(options: RenderOptions & { filename: string ...renderHeader(options.space), renderSchemaImport(options), ...fieldPlugins.imports, - ...definitionNames.map((typeName, index) => `import type { ${typeName} } from './blocks/${fileNames[index]}';`), + ...definitionNames.map((typeName, index) => `import type { ${typeName} } from './blocks/${toDeclarationImportSpecifier(fileNames[index])}';`), '', ...renderSurface(names, definitionNames, fieldPlugins.declaration), ]; From 1c23cea89e2c764776de90fa1cc77020d683bfa7 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Wed, 29 Jul 2026 14:50:01 +0200 Subject: [PATCH 27/35] fix(cli): scope the --filename caveat to the legacy generator's own file 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 --- .../src/commands/types/generate/actions.ts | 5 ++-- .../src/commands/types/generate/constants.ts | 8 +++++ .../commands/types/generate/future-schema.ts | 7 +++-- .../src/commands/types/generate/index.test.ts | 30 +++++++++++++++++-- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/types/generate/actions.ts b/packages/cli/src/commands/types/generate/actions.ts index 90b04f0c9..7956b5e00 100644 --- a/packages/cli/src/commands/types/generate/actions.ts +++ b/packages/cli/src/commands/types/generate/actions.ts @@ -8,7 +8,7 @@ import { toCamelCase, toPascalCase, } from "../../../utils"; -import type { GenerateTypesOptions } from "./constants"; +import { DEFAULT_COMPONENT_TYPES_FILENAME, type GenerateTypesOptions } from "./constants"; import { toDeclarationFileName } from "./filename"; import type { StoryblokPropertyType } from "../../../types/storyblok"; import { storyblokSchemas } from "../../../utils/storyblok-schemas"; @@ -52,7 +52,6 @@ export interface ComponentGroupsAndNamesObject { // Constants const STORY_TYPE = "ISbStoryData"; -const DEFAULT_COMPONENT_FILENAME = "storyblok-components"; const DEFAULT_TYPEDEFS_HEADER = [ "// This file was generated by the storyblok CLI.", "// DO NOT MODIFY THIS FILE BY HAND.", @@ -576,7 +575,7 @@ export const saveTypesToComponentsFile = async ( typedefData: string | Array<{ name: string; content: string }>, options: Pick, ) => { - const { filename = DEFAULT_COMPONENT_FILENAME, path, separateFiles } = options; + const { filename = DEFAULT_COMPONENT_TYPES_FILENAME, path, separateFiles } = options; // Ensure we always include the components/space folder structure regardless of custom path const resolvedPath = path ? resolve(process.cwd(), path, "types", space) diff --git a/packages/cli/src/commands/types/generate/constants.ts b/packages/cli/src/commands/types/generate/constants.ts index 721217a1d..e7522c79b 100644 --- a/packages/cli/src/commands/types/generate/constants.ts +++ b/packages/cli/src/commands/types/generate/constants.ts @@ -1,6 +1,14 @@ /** Base file name `--future-schema` writes when `--filename` is unset. */ export const DEFAULT_SCHEMA_TYPES_FILENAME = 'storyblok-schema'; +/** + * Base file name the legacy generator writes when `--filename` is unset. + * + * Shared with `--future-schema`, which warns when `--filename` aims it at this + * name, so the two generators cannot overwrite each other unnoticed. + */ +export const DEFAULT_COMPONENT_TYPES_FILENAME = 'storyblok-components'; + export interface GenerateTypesOptions { separateFiles?: boolean; strict?: boolean; diff --git a/packages/cli/src/commands/types/generate/future-schema.ts b/packages/cli/src/commands/types/generate/future-schema.ts index fd4132ffe..4eefb45fa 100644 --- a/packages/cli/src/commands/types/generate/future-schema.ts +++ b/packages/cli/src/commands/types/generate/future-schema.ts @@ -5,7 +5,7 @@ import { CommandError, handleError, toError } from '../../../utils'; import { resolvePath } from '../../../utils/filesystem'; import type { CLISpinner } from '../../../lib/ui'; import { getUI } from '../../../lib/ui'; -import { DEFAULT_SCHEMA_TYPES_FILENAME, type GenerateTypesOptions } from './constants'; +import { DEFAULT_COMPONENT_TYPES_FILENAME, DEFAULT_SCHEMA_TYPES_FILENAME, type GenerateTypesOptions } from './constants'; import { toDeclarationFileName } from './filename'; import { assertNoLegacyFlags, generateSchemaTypes } from './schema-types'; @@ -54,7 +54,10 @@ export async function runFutureSchemaTypes( if (!space) { throw new CommandError('Please provide the space as argument --space SPACE_ID.'); } - if (filename !== undefined) { + // Only the legacy generator's own file name is a collision. Warning on every + // --filename told users their own path clashed with a file nothing writes. + if (filename !== undefined + && toDeclarationFileName(filename) === toDeclarationFileName(DEFAULT_COMPONENT_TYPES_FILENAME)) { ui.warn( `--filename is set to \`${toDeclarationFileName(filename)}\`, which is also where the legacy ` + 'generator writes. Regenerating with and without --future-schema will overwrite one with the ' diff --git a/packages/cli/src/commands/types/generate/index.test.ts b/packages/cli/src/commands/types/generate/index.test.ts index d29973cb6..79ffbb679 100644 --- a/packages/cli/src/commands/types/generate/index.test.ts +++ b/packages/cli/src/commands/types/generate/index.test.ts @@ -455,7 +455,32 @@ describe("types generate", () => { ); }); - it("warns that --filename collides with the legacy generator output", async () => { + it.each(["storyblok-components", "storyblok-components.d.ts"])( + "warns that --filename %s collides with the legacy generator output", + async (filename) => { + vi.mocked(generateSchemaTypes).mockResolvedValue({ + files: [], + prunedFiles: [], + unmappedFieldTypes: [], + fieldPlugins: { resolved: false, path: "/project/.storyblok/schema/schema.ts" }, + }); + + await typesCommand.parseAsync([ + "node", + "test", + "generate", + "--space", + "295018", + "--future-schema", + "--filename", + filename, + ]); + + expect(uiWarnMock).toHaveBeenCalledWith(expect.stringContaining("--filename")); + }, + ); + + it("does not warn about a --filename the legacy generator never writes", async () => { vi.mocked(generateSchemaTypes).mockResolvedValue({ files: [], prunedFiles: [], @@ -470,11 +495,12 @@ describe("types generate", () => { "--space", "295018", "--future-schema", + "--separate-files", "--filename", "shared", ]); - expect(uiWarnMock).toHaveBeenCalledWith(expect.stringContaining("--filename")); + expect(uiWarnMock).not.toHaveBeenCalledWith(expect.stringContaining("--filename")); }); }); }); From 78e2c706f4d819404a9181131cb6fa084a0fba91 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Wed, 29 Jul 2026 14:51:08 +0200 Subject: [PATCH 28/35] fix(cli): give each legacy-only flag its own rejection reason 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 --- .../types/generate/schema-types/index.test.ts | 13 ++++++ .../types/generate/schema-types/index.ts | 42 +++++++++++-------- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/commands/types/generate/schema-types/index.test.ts b/packages/cli/src/commands/types/generate/schema-types/index.test.ts index e9d0fc933..7d9f4b6ea 100644 --- a/packages/cli/src/commands/types/generate/schema-types/index.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/index.test.ts @@ -53,6 +53,19 @@ describe('assertNoLegacyFlags', () => { expect(() => assertNoLegacyFlags({ suffix: 'v1' })).toThrow(/--suffix/); }); + it.each([ + ['strict', { strict: true }, /required/], + ['customFieldsParser', { customFieldsParser: './p.ts' }, /defineFieldPlugin/], + ['compilerOptions', { compilerOptions: './c.json' }, /JSON-schema compiler/], + ['suffix', { suffix: 'v1' }, /pulled component files/], + ] as const)('explains why %s cannot apply, rather than giving a shared rationale', (_name, options, reason) => { + expect(() => assertNoLegacyFlags(options)).toThrow(reason); + }); + + it('does not explain field optionality to someone who passed only --suffix', () => { + expect(() => assertNoLegacyFlags({ suffix: 'v1' })).not.toThrow(/optionality|required/); + }); + it('names every offending flag at once', () => { expect(() => assertNoLegacyFlags({ strict: true, diff --git a/packages/cli/src/commands/types/generate/schema-types/index.ts b/packages/cli/src/commands/types/generate/schema-types/index.ts index 973e0578b..30ca252a8 100644 --- a/packages/cli/src/commands/types/generate/schema-types/index.ts +++ b/packages/cli/src/commands/types/generate/schema-types/index.ts @@ -11,21 +11,25 @@ import { resolveFieldPluginsSource } from './field-plugins'; import { renderSchemaTypes, renderSeparateFiles, toRelativeImport } from './render'; import { serializeBlockDefinition } from './serialize'; -/** Options that only the legacy `json-schema-to-typescript` generator supports. */ -const LEGACY_ONLY_FLAGS: ReadonlyArray = [ - ['strict', '--strict'], - ['customFieldsParser', '--custom-fields-parser'], - ['compilerOptions', '--compiler-options'], - ['suffix', '--suffix'], +/** + * Options that only the legacy `json-schema-to-typescript` generator supports, + * each with why it cannot mean anything here. + * + * The reason is per flag rather than shared: a single combined rationale ended up + * explaining field optionality to someone who passed `--suffix`, which is about + * file selection and has nothing to do with it. + */ +const LEGACY_ONLY_FLAGS: ReadonlyArray = [ + ['strict', '--strict', 'field optionality comes from each field\'s `required` flag'], + ['customFieldsParser', '--custom-fields-parser', 'custom fields are typed with defineFieldPlugin, see --field-plugins'], + ['compilerOptions', '--compiler-options', 'there is no JSON-schema compiler to configure'], + ['suffix', '--suffix', 'it selects pulled component files, which this generator never reads'], ]; /** - * Rejects flags that cannot mean anything under `--future-schema`: optionality - * now comes from each field's `required`, custom fields resolve through - * `defineFieldPlugin`, there is no `json-schema-to-typescript` to configure, - * and `--suffix` only selects pulled component files, which this generator - * never reads. Failing loudly beats silently ignoring a flag the user - * believes is applied. + * Rejects flags that cannot mean anything under `--future-schema`, quoting the + * per-flag reason from {@link LEGACY_ONLY_FLAGS}. Failing loudly beats silently + * ignoring a flag the user believes is applied. * * A flag the *config file* set is a different case, and must not be an error: a * project that configures `strict` for the legacy generator would otherwise be @@ -43,13 +47,17 @@ export function assertNoLegacyFlags( ): string[] { const set = LEGACY_ONLY_FLAGS.filter(([key]) => options[key] !== undefined); const fromConfig = set.filter(([key]) => getOptionValueSource?.(key) === 'config'); - const used = set.filter(entry => !fromConfig.includes(entry)).map(([, flag]) => flag); + const used = set.filter(entry => !fromConfig.includes(entry)); + + if (used.length === 1) { + const [, flag, reason] = used[0]!; + throw new CommandError(`${flag} is not supported with --future-schema: ${reason}.`); + } - if (used.length > 0) { + if (used.length > 1) { throw new CommandError( - `${used.join(', ')} ${used.length === 1 ? 'is' : 'are'} not supported with --future-schema. ` - + 'Field optionality comes from the schema, custom fields are typed with defineFieldPlugin ' - + '(see --field-plugins), and no JSON-schema compiler is involved.', + `${used.map(([, flag]) => flag).join(', ')} are not supported with --future-schema. ` + + `${used.map(([, flag, reason]) => `${flag}: ${reason}`).join('. ')}.`, ); } From 5676fbf3cd4159f2b72f96e2c8a7c5b340261f7a Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Wed, 29 Jul 2026 14:51:46 +0200 Subject: [PATCH 29/35] fix(cli): tell a missing field-plugins module from an unusable one `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 --- .../commands/types/generate/future-schema.ts | 37 +++++++-- .../src/commands/types/generate/index.test.ts | 83 +++++++++++++++++-- .../schema-types/field-plugins.test.ts | 21 ++++- .../generate/schema-types/field-plugins.ts | 18 +++- .../types/generate/schema-types/index.ts | 14 +++- 5 files changed, 152 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/commands/types/generate/future-schema.ts b/packages/cli/src/commands/types/generate/future-schema.ts index 4eefb45fa..d6ea38e14 100644 --- a/packages/cli/src/commands/types/generate/future-schema.ts +++ b/packages/cli/src/commands/types/generate/future-schema.ts @@ -7,7 +7,7 @@ import type { CLISpinner } from '../../../lib/ui'; import { getUI } from '../../../lib/ui'; import { DEFAULT_COMPONENT_TYPES_FILENAME, DEFAULT_SCHEMA_TYPES_FILENAME, type GenerateTypesOptions } from './constants'; import { toDeclarationFileName } from './filename'; -import { assertNoLegacyFlags, generateSchemaTypes } from './schema-types'; +import { assertNoLegacyFlags, generateSchemaTypes, type GenerateSchemaTypesResult } from './schema-types'; export interface FutureSchemaCommandOptions { /** Command options, including the legacy-only flags this mode rejects. */ @@ -27,6 +27,33 @@ export interface FutureSchemaCommandOptions { getOptionValueSource?: (attributeName: string) => string | undefined; } +/** + * Advises how to fix unmapped `custom` field types, given where this run looked + * for field plugins. + * + * Names the module actually in use, or the path this run searched. `--path` moves + * the convention path, so the default would point at the wrong file. The three + * cases need genuinely different advice: add a declaration to a module already + * wired up, rename an export in a module that is already at the right path, or + * create one. + */ +function unmappedRemedy(fieldPlugins: GenerateSchemaTypesResult['fieldPlugins']): string { + const where = relative(process.cwd(), fieldPlugins.path); + + if (fieldPlugins.resolved) { + return `Declare them with defineFieldPlugin in ${where}.`; + } + + if (fieldPlugins.reason === 'unusable') { + return `${where} exports neither a \`schema\` (a defineSchema result with fieldPlugins) nor a \`fieldPlugins\` record, so its declarations were not read.${ + fieldPlugins.nearMissExport === undefined + ? '' + : ` Found \`${fieldPlugins.nearMissExport}\`, which looks like one: rename it to \`schema\` or \`fieldPlugins\`.`}`; + } + + return `Declare them with defineFieldPlugin and point --field-plugins at the module (or place it at ${where}).`; +} + /** * Runs `types generate --future-schema`: fetches the space's components and * writes schema-derived types. @@ -89,15 +116,9 @@ export async function runFutureSchemaTypes( ); } if (result.unmappedFieldTypes.length > 0) { - // Names the module actually in use, or the path this run searched. `--path` - // moves the convention path, so the default would point at the wrong file. - const where = relative(process.cwd(), result.fieldPlugins.path); - const remedy = result.fieldPlugins.resolved - ? `in ${where}.` - : `and point --field-plugins at the module (or place it at ${where}).`; ui.warn( `No field plugin registered for: ${result.unmappedFieldTypes.join(', ')}. ` - + `These custom fields fall back to an untyped value. Declare them with defineFieldPlugin ${remedy}`, + + `These custom fields fall back to an untyped value. ${unmappedRemedy(result.fieldPlugins)}`, ); } ui.info('The generated types import from `@storyblok/schema`. Install it as a dev dependency: `npm i -D @storyblok/schema`.'); diff --git a/packages/cli/src/commands/types/generate/index.test.ts b/packages/cli/src/commands/types/generate/index.test.ts index 79ffbb679..a1ff1de2f 100644 --- a/packages/cli/src/commands/types/generate/index.test.ts +++ b/packages/cli/src/commands/types/generate/index.test.ts @@ -351,7 +351,11 @@ describe("types generate", () => { files: ["/project/.storyblok/types/295018/storyblok-schema.d.ts"], prunedFiles: [], unmappedFieldTypes: ["storyblok-colorpicker"], - fieldPlugins: { resolved: false, path: "/project/.storyblok/schema/schema.ts" }, + fieldPlugins: { + resolved: false, + reason: "missing", + path: "/project/.storyblok/schema/schema.ts", + }, }); await typesCommand.parseAsync([ @@ -404,7 +408,11 @@ describe("types generate", () => { files: ["/project/config/types/295018/storyblok-schema.d.ts"], prunedFiles: [], unmappedFieldTypes: ["storyblok-colorpicker"], - fieldPlugins: { resolved: false, path: "/project/config/schema/schema.ts" }, + fieldPlugins: { + resolved: false, + reason: "missing", + path: "/project/config/schema/schema.ts", + }, }); await typesCommand.parseAsync([ @@ -422,12 +430,69 @@ describe("types generate", () => { ); }); + // `schema init` writes a `schema` export with no `fieldPlugins` key, so this + // is the case a user following the docs hits first. Telling them to place a + // module where one already sits reads as the command not seeing it. + it("says the module at the convention path exports the wrong name, rather than telling the user to create it", async () => { + vi.mocked(generateSchemaTypes).mockResolvedValue({ + files: ["/project/.storyblok/types/295018/storyblok-schema.d.ts"], + prunedFiles: [], + unmappedFieldTypes: ["storyblok-colorpicker"], + fieldPlugins: { + resolved: false, + reason: "unusable", + path: "/project/.storyblok/schema/schema.ts", + }, + }); + + await typesCommand.parseAsync([ + "node", + "test", + "generate", + "--space", + "295018", + "--future-schema", + ]); + + expect(uiWarnMock).toHaveBeenCalledWith(expect.stringContaining("exports neither")); + expect(uiWarnMock).not.toHaveBeenCalledWith(expect.stringContaining("place it at")); + }); + + it("names a near-miss export the convention-path module should be renamed from", async () => { + vi.mocked(generateSchemaTypes).mockResolvedValue({ + files: ["/project/.storyblok/types/295018/storyblok-schema.d.ts"], + prunedFiles: [], + unmappedFieldTypes: ["storyblok-colorpicker"], + fieldPlugins: { + resolved: false, + reason: "unusable", + path: "/project/.storyblok/schema/schema.ts", + nearMissExport: "myPlugins", + }, + }); + + await typesCommand.parseAsync([ + "node", + "test", + "generate", + "--space", + "295018", + "--future-schema", + ]); + + expect(uiWarnMock).toHaveBeenCalledWith(expect.stringContaining("myPlugins")); + }); + it("forwards --field-plugins, --type-prefix, --type-suffix, and --path to the generator", async () => { vi.mocked(generateSchemaTypes).mockResolvedValue({ files: [], prunedFiles: [], unmappedFieldTypes: [], - fieldPlugins: { resolved: false, path: "/project/.storyblok/schema/schema.ts" }, + fieldPlugins: { + resolved: false, + reason: "missing", + path: "/project/.storyblok/schema/schema.ts", + }, }); await typesCommand.parseAsync([ @@ -462,7 +527,11 @@ describe("types generate", () => { files: [], prunedFiles: [], unmappedFieldTypes: [], - fieldPlugins: { resolved: false, path: "/project/.storyblok/schema/schema.ts" }, + fieldPlugins: { + resolved: false, + reason: "missing", + path: "/project/.storyblok/schema/schema.ts", + }, }); await typesCommand.parseAsync([ @@ -485,7 +554,11 @@ describe("types generate", () => { files: [], prunedFiles: [], unmappedFieldTypes: [], - fieldPlugins: { resolved: false, path: "/project/.storyblok/schema/schema.ts" }, + fieldPlugins: { + resolved: false, + reason: "missing", + path: "/project/.storyblok/schema/schema.ts", + }, }); await typesCommand.parseAsync([ diff --git a/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts b/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts index 290f303c6..c61ca5de5 100644 --- a/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts @@ -36,6 +36,7 @@ describe('resolveFieldPluginsSource', () => { it('returns none when neither an override nor the convention file exists', async () => { expect(await resolveFieldPluginsSource({ cwd })).toEqual({ kind: 'none', + reason: 'missing', searchedPath: join(cwd, DEFAULT_SCHEMA_ENTRY_PATH), }); }); @@ -91,6 +92,7 @@ describe('resolveFieldPluginsSource', () => { expect(await resolveFieldPluginsSource({ cwd, path: 'config' })).toEqual({ kind: 'none', + reason: 'missing', searchedPath: join(cwd, 'config', SCHEMA_ENTRY_RELATIVE_PATH), }); }); @@ -113,14 +115,31 @@ describe('resolveFieldPluginsSource', () => { .toThrow(/`myPlugins`/); }); - it('returns none when the convention file exists but exports neither shape', async () => { + // The distinction drives the advice: `missing` means write a module here, + // `unusable` means rename an export in the module already here. `schema init` + // writes exactly this shape, so it is the case users hit first. + it('returns none with reason unusable when the convention file exists but exports neither shape', async () => { const target = join(cwd, DEFAULT_SCHEMA_ENTRY_PATH); await mkdir(join(target, '..'), { recursive: true }); await writeFile(target, 'export const schema = { blocks: {} };', 'utf8'); expect(await resolveFieldPluginsSource({ cwd })).toEqual({ kind: 'none', + reason: 'unusable', searchedPath: join(cwd, DEFAULT_SCHEMA_ENTRY_PATH), }); }); + + it('carries a near-miss export name from the convention path', async () => { + const target = join(cwd, DEFAULT_SCHEMA_ENTRY_PATH); + await mkdir(join(target, '..'), { recursive: true }); + await writeFile(target, RECORD_EXPORT.replace('export const fieldPlugins', 'export const myPlugins'), 'utf8'); + + expect(await resolveFieldPluginsSource({ cwd })).toEqual({ + kind: 'none', + reason: 'unusable', + searchedPath: target, + nearMissExport: 'myPlugins', + }); + }); }); diff --git a/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts b/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts index 35138b487..ce19e4a5a 100644 --- a/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts +++ b/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts @@ -18,9 +18,16 @@ import { isRecord } from '../../../schema/utils'; * `none` carries the path that was searched, so a message telling the user where * to put a module names the path this run actually looked at. `--path` moves it, * and naming the default there would point at a file the user may already have. + * + * It also carries *why* nothing resolved, because the two cases need opposite + * advice: `missing` means write a module at that path, `unusable` means the + * module is already there and its export needs renaming. `schema init` writes a + * `schema` export with no `fieldPlugins` key, so `unusable` is the case a user + * following the docs hits first, and telling them to create a file they are + * looking at reads as the command failing to see it. */ export type FieldPluginsSource = - | { kind: 'none'; searchedPath: string } + | { kind: 'none'; reason: 'missing' | 'unusable'; searchedPath: string; nearMissExport?: string } | { kind: 'schema'; modulePath: string; fieldTypes: string[] } | { kind: 'record'; modulePath: string; fieldTypes: string[] }; @@ -88,7 +95,7 @@ export async function resolveFieldPluginsSource( if (isExplicit) { throw new CommandError(`Field plugins module not found: ${modulePath}`); } - return { kind: 'none', searchedPath: modulePath }; + return { kind: 'none', reason: 'missing', searchedPath: modulePath }; } let module: Record; @@ -108,8 +115,8 @@ export async function resolveFieldPluginsSource( return { kind: 'record', modulePath, fieldTypes: collectFieldTypes(module.fieldPlugins) }; } + const nearMiss = findNearMissExport(module); if (isExplicit) { - const nearMiss = findNearMissExport(module); throw new CommandError( `${modulePath} exports neither a \`schema\` (a defineSchema result with fieldPlugins) nor a \`fieldPlugins\` record.${ nearMiss === undefined @@ -117,5 +124,8 @@ export async function resolveFieldPluginsSource( : ` Found \`${nearMiss}\`, which looks like one: rename it to \`schema\` or \`fieldPlugins\`.`}`, ); } - return { kind: 'none', searchedPath: modulePath }; + // The convention path degrades rather than failing, but the near miss is still + // worth carrying: the unmapped-field-type warning can then name the export to + // rename instead of restating the contract. + return { kind: 'none', reason: 'unusable', searchedPath: modulePath, ...(nearMiss === undefined ? {} : { nearMissExport: nearMiss }) }; } diff --git a/packages/cli/src/commands/types/generate/schema-types/index.ts b/packages/cli/src/commands/types/generate/schema-types/index.ts index 30ca252a8..8c5aee435 100644 --- a/packages/cli/src/commands/types/generate/schema-types/index.ts +++ b/packages/cli/src/commands/types/generate/schema-types/index.ts @@ -130,9 +130,12 @@ export interface GenerateSchemaTypesResult { /** * The field-plugins module this run used, or the path it searched when none * resolved. Lets the unmapped-field-type warning name a real path instead of - * the default, which `--path` moves. + * the default, which `--path` moves, and tell "write a module here" apart from + * "the module here exports the wrong name". */ - fieldPlugins: { resolved: boolean; path: string }; + fieldPlugins: + | { resolved: true; path: string } + | { resolved: false; reason: 'missing' | 'unusable'; path: string; nearMissExport?: string }; } /** @@ -203,7 +206,12 @@ export async function generateSchemaTypes( prunedFiles, unmappedFieldTypes, fieldPlugins: fieldPlugins.kind === 'none' - ? { resolved: false, path: fieldPlugins.searchedPath } + ? { + resolved: false, + reason: fieldPlugins.reason, + path: fieldPlugins.searchedPath, + ...(fieldPlugins.nearMissExport === undefined ? {} : { nearMissExport: fieldPlugins.nearMissExport }), + } : { resolved: true, path: fieldPlugins.modulePath }, }; } From c4d4c7812956e61bb99eb70d0baa4ff0019dbe90 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Wed, 29 Jul 2026 14:52:06 +0200 Subject: [PATCH 30/35] fix(schema): drop valueless layout fields from content types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../schema-types/emitted-types.test-d.ts | 7 +- packages/schema/src/generated/types/field.ts | 47 ++++++++++-- .../src/helpers/layout-fields.test-d.ts | 74 +++++++++++++++++++ tools/openapi-codegen/templates/field.ts | 59 +++++++++------ 4 files changed, 157 insertions(+), 30 deletions(-) create mode 100644 packages/schema/src/helpers/layout-fields.test-d.ts diff --git a/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts b/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts index f34d09ae1..3b44612d6 100644 --- a/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts +++ b/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts @@ -19,8 +19,11 @@ describe('generated types', () => { expectTypeOf>().toHaveProperty('image').toBeNullable(); }); - it('resolves tab fields to an absent, valueless property', () => { - expectTypeOf>().toHaveProperty('general').toEqualTypeOf(); + // A tab groups other fields in the editor UI and is never a key in story + // content, so it must not appear at all. It used to surface as `general?: null`, + // which offered a key no API response has. + it('omits tab fields from the content type entirely', () => { + expectTypeOf>().not.toHaveProperty('general'); }); it('narrows a whitelisted bloks field to the allowed block only', () => { diff --git a/packages/schema/src/generated/types/field.ts b/packages/schema/src/generated/types/field.ts index 29182b09b..03455761d 100644 --- a/packages/schema/src/generated/types/field.ts +++ b/packages/schema/src/generated/types/field.ts @@ -32,20 +32,55 @@ type NoBlocks = false; /** True when `T` is the un-narrowed base `Block` (i.e. no specific block was supplied). */ type IsBaseBlock = [Block] extends [T] ? true : false; +/** + * True when a field carries no value at all, i.e. its `FieldValue` is `never`. + * + * `tab` and `section` are layout containers the editor UI draws; they group other + * fields and never appear in story content. Their `FieldTypeValueMap` entries are + * `never`, which without this check surfaces them as `key?: null` properties — + * keys no API response ever has, cluttering autocomplete on every block that uses + * a tab. + * + * The tuple wrapping is required: a bare `V extends never` distributes over the + * naked type parameter and never matches. + */ +type HasNoValue = [V] extends [never] ? true : false; + /** * Maps a block's ordered `fields` array to its read content object, splitting - * required (`required: true`) from optional fields. Each `F` is a member of the - * field union, so it provably satisfies `FieldValue`'s `Field` constraint. + * required (`required: true`) from optional fields, and dropping fields that + * carry no value (see {@link HasNoValue}). Each `F` is a member of the field + * union, so it provably satisfies `FieldValue`'s `Field` constraint. */ type ContentFields> = Prettify< - { [F in TFields[number] as F extends { required: true } ? F['name'] : never]: FieldValue } - & { [F in TFields[number] as F extends { required: true } ? never : F['name']]?: FieldValue | null } + { + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } ? F['name'] : never + ]: FieldValue + } + & { + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } ? never : F['name'] + ]?: FieldValue | null + } >; /** Input (write) variant of {@link ContentFields}, resolving each field via {@link FieldValueInput}. */ type ContentFieldsInput> = Prettify< - { [F in TFields[number] as F extends { required: true } ? F['name'] : never]: FieldValueInput } - & { [F in TFields[number] as F extends { required: true } ? never : F['name']]?: FieldValueInput | null } + { + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } ? F['name'] : never + ]: FieldValueInput + } + & { + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } ? never : F['name'] + ]?: FieldValueInput | null + } >; /** diff --git a/packages/schema/src/helpers/layout-fields.test-d.ts b/packages/schema/src/helpers/layout-fields.test-d.ts new file mode 100644 index 000000000..8498ca275 --- /dev/null +++ b/packages/schema/src/helpers/layout-fields.test-d.ts @@ -0,0 +1,74 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { BlockContent, BlockContentInput, PluginFieldValue } from '../generated/types/field'; +import { defineBlock } from './define-block'; +import { defineField } from './define-field'; + +/** + * `tab` and `section` group other fields in the editor UI and carry no value of + * their own, so no API response ever has a key for them. They used to surface as + * `key?: null` properties, which put phantom keys in autocomplete on every block + * using a tab. + */ +describe('layout-only fields', () => { + const _heroBlock = defineBlock({ + name: 'hero', + fields: [ + defineField('general', { type: 'tab' }), + defineField('divider', { type: 'section' }), + defineField('title', { type: 'text' }), + defineField('headline', { type: 'text', required: true }), + ], + }); + + type Content = BlockContent; + type ContentInput = BlockContentInput; + + it('omits a tab field from the read content type', () => { + expectTypeOf().not.toHaveProperty('general'); + }); + + it('omits a section field from the read content type', () => { + expectTypeOf().not.toHaveProperty('divider'); + }); + + it('omits layout fields from the write content type too', () => { + expectTypeOf().not.toHaveProperty('general'); + expectTypeOf().not.toHaveProperty('divider'); + }); + + it('keeps the value-carrying fields around them', () => { + expectTypeOf().toHaveProperty('title'); + expectTypeOf().toEqualTypeOf(); + }); + + it('accepts content that omits the layout keys entirely', () => { + const content: Content = { _uid: 'a', component: 'hero', headline: 'Hi' }; + expectTypeOf(content).toExtend(); + }); +}); + +/** + * The guard drops fields whose value is `never`. A `custom` field with no + * registered plugin resolves to `PluginFieldValue`, not `never`, so it must + * survive — dropping it would silently hide the field instead of typing it + * loosely. + */ +describe('fields that must not be mistaken for layout fields', () => { + const _block = defineBlock({ + name: 'widget', + fields: [ + defineField('legacy', { type: 'custom', field_type: 'unregistered-plugin' }), + defineField('items', { type: 'bloks' }), + ], + }); + + type Content = BlockContent; + + it('keeps an unregistered custom field, typed loosely', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('keeps a bloks field whose registry resolves to no blocks', () => { + expectTypeOf().toHaveProperty('items'); + }); +}); diff --git a/tools/openapi-codegen/templates/field.ts b/tools/openapi-codegen/templates/field.ts index 63477c7bd..bfee94fea 100644 --- a/tools/openapi-codegen/templates/field.ts +++ b/tools/openapi-codegen/templates/field.ts @@ -35,10 +35,25 @@ type NoBlocks = false; /** True when `T` is the un-narrowed base `Block` (i.e. no specific block was supplied). */ type IsBaseBlock = [Block] extends [T] ? true : false; +/** + * True when a field carries no value at all, i.e. its `FieldValue` is `never`. + * + * `tab` and `section` are layout containers the editor UI draws; they group other + * fields and never appear in story content. Their `FieldTypeValueMap` entries are + * `never`, which without this check surfaces them as `key?: null` properties — + * keys no API response ever has, cluttering autocomplete on every block that uses + * a tab. + * + * The tuple wrapping is required: a bare `V extends never` distributes over the + * naked type parameter and never matches. + */ +type HasNoValue = [V] extends [never] ? true : false; + /** * Maps a block's ordered `fields` array to its read content object, splitting - * required (`required: true`) from optional fields. Each `F` is a member of the - * field union, so it provably satisfies `FieldValue`'s `Field` constraint. + * required (`required: true`) from optional fields, and dropping fields that + * carry no value (see {@link HasNoValue}). Each `F` is a member of the field + * union, so it provably satisfies `FieldValue`'s `Field` constraint. */ type ContentFields< TFields extends BlockFields, @@ -46,17 +61,17 @@ type ContentFields< TFieldPlugins = Record, > = Prettify< { - [F in TFields[number] as F extends { required: true } ? F["name"] : never]: FieldValue< - F, - TBlocks, - TFieldPlugins - >; + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } + ? F["name"] + : never]: FieldValue; } & { - [F in TFields[number] as F extends { required: true } ? never : F["name"]]?: FieldValue< - F, - TBlocks, - TFieldPlugins - > | null; + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } + ? never + : F["name"]]?: FieldValue | null; } >; @@ -67,17 +82,17 @@ type ContentFieldsInput< TFieldPlugins = Record, > = Prettify< { - [F in TFields[number] as F extends { required: true } ? F["name"] : never]: FieldValueInput< - F, - TBlocks, - TFieldPlugins - >; + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } + ? F["name"] + : never]: FieldValueInput; } & { - [F in TFields[number] as F extends { required: true } ? never : F["name"]]?: FieldValueInput< - F, - TBlocks, - TFieldPlugins - > | null; + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } + ? never + : F["name"]]?: FieldValueInput | null; } >; From c619d83567502c5a9f07953ce0c3af3581806494 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Wed, 29 Jul 2026 14:52:22 +0200 Subject: [PATCH 31/35] docs(schema): note that defineFieldPlugin reads types from the validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/schema/src/helpers/define-field-plugin.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/schema/src/helpers/define-field-plugin.ts b/packages/schema/src/helpers/define-field-plugin.ts index e2c1061c1..e5c8e43db 100644 --- a/packages/schema/src/helpers/define-field-plugin.ts +++ b/packages/schema/src/helpers/define-field-plugin.ts @@ -21,6 +21,13 @@ export interface FieldPlugin< * validator (Zod, Valibot, ArkType, or hand-written) and is retained for * runtime use. A thin, strongly-typed identity helper — it does not validate. * + * The value type is read from the validator's `~standard.types`, not from what + * its `validate` returns. A hand-written validator must therefore be annotated + * (`const v: StandardSchemaV1 = { … }`) or declare `types` + * explicitly. A bare object literal satisfies the constraint and registers the + * `fieldType` — so nothing warns — while the field still resolves to the untyped + * `PluginFieldValue` fallback, which reads as the declaration having no effect. + * * @example * const colorPicker = defineFieldPlugin({ * fieldType: 'my-custom-color-picker', From 9c14e41b0b631d40efccdeff4d9c34b460b3d094 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 11 Aug 2026 11:57:39 +0200 Subject: [PATCH 32/35] chore(cli): format branch sources with oxfmt Also excludes the generated-types fixtures from formatting: the drift test compares them byte-for-byte against renderSchemaTypes' own output. --- .prettierignore | 5 + adr/0012-schema-derived-type-generation.md | 62 +++- packages/cli/src/commands/schema/constants.ts | 8 +- .../src/commands/schema/to-dsl-field.test.ts | 93 ++--- .../cli/src/commands/schema/to-dsl-field.ts | 42 ++- packages/cli/src/commands/schema/utils.ts | 44 ++- .../src/commands/types/generate/constants.ts | 4 +- .../commands/types/generate/filename.test.ts | 38 +- .../src/commands/types/generate/filename.ts | 4 +- .../commands/types/generate/future-schema.ts | 86 +++-- .../schema-types/__fixtures__/components.ts | 32 +- .../schema-types/__fixtures__/plugins.ts | 12 +- .../schema-types/emitted-types.test-d.ts | 67 ++-- .../schema-types/field-plugins.test.ts | 164 +++++---- .../generate/schema-types/field-plugins.ts | 85 +++-- .../schema-types/fixture-drift.test.ts | 36 +- .../types/generate/schema-types/index.test.ts | 332 ++++++++++-------- .../types/generate/schema-types/index.ts | 102 +++--- .../generate/schema-types/integration.test.ts | 67 ++-- .../generate/schema-types/render.test.ts | 320 +++++++++-------- .../types/generate/schema-types/render.ts | 151 +++++--- .../generate/schema-types/serialize.test.ts | 303 +++++++++------- .../types/generate/schema-types/serialize.ts | 89 +++-- packages/cli/src/utils/import-module.ts | 4 +- .../src/helpers/layout-fields.test-d.ts | 60 ++-- 25 files changed, 1285 insertions(+), 925 deletions(-) diff --git a/.prettierignore b/.prettierignore index ed5a4c901..4d267efd4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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 diff --git a/adr/0012-schema-derived-type-generation.md b/adr/0012-schema-derived-type-generation.md index 95d3f6054..d8dec91df 100644 --- a/adr/0012-schema-derived-type-generation.md +++ b/adr/0012-schema-derived-type-generation.md @@ -1,28 +1,66 @@ # ADR-0012: Schema-Derived Type Generation for the CLI -**Status:** Accepted -**Date:** 2026-07-28 +**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 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. +`@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`, `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. +`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`, `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()` 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. +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()` 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()` forces the definition types into the file regardless, and those same types make `Block` 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`. +- **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()` forces + the definition types into the file regardless, and those same types make `Block` 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`. +- 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`. diff --git a/packages/cli/src/commands/schema/constants.ts b/packages/cli/src/commands/schema/constants.ts index 6fe959915..59a1fa5c6 100644 --- a/packages/cli/src/commands/schema/constants.ts +++ b/packages/cli/src/commands/schema/constants.ts @@ -1,16 +1,16 @@ -import { join } from 'pathe'; +import { join } from "pathe"; -import { DEFAULT_STORAGE_DIR } from '../../utils/filesystem'; +import { DEFAULT_STORAGE_DIR } from "../../utils/filesystem"; /** * Directory holding the code-defined schema, relative to the CLI's base path * (`--path`, default `.storyblok`). `schema push` writes its changesets to a * `changesets/` directory beneath it. */ -export const SCHEMA_DIR_NAME = 'schema'; +export const SCHEMA_DIR_NAME = "schema"; /** Entry file `schema init` writes and `schema push` expects. */ -export const SCHEMA_ENTRY_FILENAME = 'schema.ts'; +export const SCHEMA_ENTRY_FILENAME = "schema.ts"; /** Entry file path relative to the CLI's base path, e.g. `schema/schema.ts`. */ export const SCHEMA_ENTRY_RELATIVE_PATH = join(SCHEMA_DIR_NAME, SCHEMA_ENTRY_FILENAME); diff --git a/packages/cli/src/commands/schema/to-dsl-field.test.ts b/packages/cli/src/commands/schema/to-dsl-field.test.ts index 3fca5858f..e3cc106a6 100644 --- a/packages/cli/src/commands/schema/to-dsl-field.test.ts +++ b/packages/cli/src/commands/schema/to-dsl-field.test.ts @@ -1,86 +1,97 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from "vitest"; -import { resolveGroupWhitelistEntries, toDslField } from './to-dsl-field'; +import { resolveGroupWhitelistEntries, toDslField } from "./to-dsl-field"; -describe('toDslField', () => { - it('maps component_whitelist to allow', () => { - expect(toDslField({ type: 'bloks', component_whitelist: ['hero'] })).toEqual({ - type: 'bloks', - allow: ['hero'], +describe("toDslField", () => { + it("maps component_whitelist to allow", () => { + expect(toDslField({ type: "bloks", component_whitelist: ["hero"] })).toEqual({ + type: "bloks", + allow: ["hero"], }); }); - it('maps a group whitelist through the caller-supplied resolver', () => { + it("maps a group whitelist through the caller-supplied resolver", () => { const result = toDslField( - { type: 'bloks', component_group_whitelist: ['uuid-1'], restrict_components: true, restrict_type: 'groups' }, - uuid => (uuid === 'uuid-1' ? { folder: 'Layout' } : undefined), + { + type: "bloks", + component_group_whitelist: ["uuid-1"], + restrict_components: true, + restrict_type: "groups", + }, + (uuid) => (uuid === "uuid-1" ? { folder: "Layout" } : undefined), ); - expect(result).toEqual({ type: 'bloks', allow: [{ folder: 'Layout' }] }); + expect(result).toEqual({ type: "bloks", allow: [{ folder: "Layout" }] }); }); - it('drops an inert whitelist and keeps the flag when the restriction is off', () => { + it("drops an inert whitelist and keeps the flag when the restriction is off", () => { // Mapping the whitelist to `allow` would make the next push re-derive // `restrict_components: true`, switching a disabled restriction back on. - const names = toDslField({ type: 'bloks', restrict_components: false, component_whitelist: ['hero'] }); + const names = toDslField({ + type: "bloks", + restrict_components: false, + component_whitelist: ["hero"], + }); const groups = toDslField( { - type: 'bloks', + type: "bloks", restrict_components: false, - restrict_type: 'groups', - component_group_whitelist: ['uuid-1'], + restrict_type: "groups", + component_group_whitelist: ["uuid-1"], }, - () => ({ folder: 'Layout' }), + () => ({ folder: "Layout" }), ); - expect(names).toEqual({ type: 'bloks', restrict_components: false }); - expect(groups).toEqual({ type: 'bloks', restrict_components: false, restrict_type: 'groups' }); + expect(names).toEqual({ type: "bloks", restrict_components: false }); + expect(groups).toEqual({ type: "bloks", restrict_components: false, restrict_type: "groups" }); }); - it('prefers block names over a group whitelist when both are present', () => { + it("prefers block names over a group whitelist when both are present", () => { const result = toDslField( - { type: 'bloks', component_whitelist: ['hero'], component_group_whitelist: ['uuid-1'] }, - () => ({ folder: 'Layout' }), + { type: "bloks", component_whitelist: ["hero"], component_group_whitelist: ["uuid-1"] }, + () => ({ folder: "Layout" }), ); - expect(result.allow).toEqual(['hero']); + expect(result.allow).toEqual(["hero"]); }); - it('keeps the raw wire form when a group uuid cannot be resolved', () => { + it("keeps the raw wire form when a group uuid cannot be resolved", () => { const result = toDslField( - { type: 'bloks', component_group_whitelist: ['unknown'], restrict_type: 'groups' }, + { type: "bloks", component_group_whitelist: ["unknown"], restrict_type: "groups" }, () => undefined, ); expect(result).toEqual({ - type: 'bloks', - component_group_whitelist: ['unknown'], - restrict_type: 'groups', + type: "bloks", + component_group_whitelist: ["unknown"], + restrict_type: "groups", }); }); - it('maps datasource_slug to datasource', () => { - expect(toDslField({ type: 'option', datasource_slug: 'colors' })).toEqual({ - type: 'option', - datasource: 'colors', + it("maps datasource_slug to datasource", () => { + expect(toDslField({ type: "option", datasource_slug: "colors" })).toEqual({ + type: "option", + datasource: "colors", }); }); }); -describe('resolveGroupWhitelistEntries', () => { - it('returns undefined when any uuid is unresolvable', () => { - expect(resolveGroupWhitelistEntries(['a', 'b'], u => (u === 'a' ? 'A' : undefined))).toBeUndefined(); +describe("resolveGroupWhitelistEntries", () => { + it("returns undefined when any uuid is unresolvable", () => { + expect( + resolveGroupWhitelistEntries(["a", "b"], (u) => (u === "a" ? "A" : undefined)), + ).toBeUndefined(); }); - it('returns undefined for an empty whitelist', () => { - expect(resolveGroupWhitelistEntries([], () => 'x')).toBeUndefined(); + it("returns undefined for an empty whitelist", () => { + expect(resolveGroupWhitelistEntries([], () => "x")).toBeUndefined(); }); - it('returns undefined when no resolver is supplied', () => { - expect(resolveGroupWhitelistEntries(['a'])).toBeUndefined(); + it("returns undefined when no resolver is supplied", () => { + expect(resolveGroupWhitelistEntries(["a"])).toBeUndefined(); }); - it('maps every uuid through the resolver', () => { - expect(resolveGroupWhitelistEntries(['a', 'b'], u => u.toUpperCase())).toEqual(['A', 'B']); + it("maps every uuid through the resolver", () => { + expect(resolveGroupWhitelistEntries(["a", "b"], (u) => u.toUpperCase())).toEqual(["A", "B"]); }); }); diff --git a/packages/cli/src/commands/schema/to-dsl-field.ts b/packages/cli/src/commands/schema/to-dsl-field.ts index 5abc27f84..2f901cde2 100644 --- a/packages/cli/src/commands/schema/to-dsl-field.ts +++ b/packages/cli/src/commands/schema/to-dsl-field.ts @@ -17,9 +17,15 @@ export function resolveGroupWhitelistEntries( whitelist: unknown, resolveEntry?: (uuid: string) => T | undefined, ): T[] | undefined { - if (!resolveEntry || !Array.isArray(whitelist) || whitelist.length === 0) { return undefined; } - const entries = whitelist.map(uuid => (typeof uuid === 'string' ? resolveEntry(uuid) : undefined)); - if (!entries.every((entry): entry is T => entry !== undefined)) { return undefined; } + if (!resolveEntry || !Array.isArray(whitelist) || whitelist.length === 0) { + return undefined; + } + const entries = whitelist.map((uuid) => + typeof uuid === "string" ? resolveEntry(uuid) : undefined, + ); + if (!entries.every((entry): entry is T => entry !== undefined)) { + return undefined; + } return entries; } @@ -56,9 +62,8 @@ export function toDslField( const groupEntries = restrictionDisabled ? undefined : resolveGroupWhitelistEntries(component_group_whitelist, resolveGroupEntry); - const hasBlockNames = !restrictionDisabled - && Array.isArray(component_whitelist) - && component_whitelist.length > 0; + const hasBlockNames = + !restrictionDisabled && Array.isArray(component_whitelist) && component_whitelist.length > 0; if (restrictionDisabled) { // The restriction is switched off, so the whitelist beside it is inert. // Mapping it to `allow` would make `defineField` re-derive @@ -67,19 +72,24 @@ export function toDslField( // inactive whitelist is dropped: it is not in force, and keeping it is what // causes the flip. out.restrict_components = false; - if (restrict_type !== undefined) { out.restrict_type = restrict_type; } - } - else if (hasBlockNames) { + if (restrict_type !== undefined) { + out.restrict_type = restrict_type; + } + } else if (hasBlockNames) { out.allow = component_whitelist; - } - else if (groupEntries) { + } else if (groupEntries) { out.allow = groupEntries; - } - else if (component_group_whitelist !== undefined) { + } else if (component_group_whitelist !== undefined) { out.component_group_whitelist = component_group_whitelist; - if (restrict_components !== undefined) { out.restrict_components = restrict_components; } - if (restrict_type !== undefined) { out.restrict_type = restrict_type; } + if (restrict_components !== undefined) { + out.restrict_components = restrict_components; + } + if (restrict_type !== undefined) { + out.restrict_type = restrict_type; + } + } + if (datasource_slug !== undefined) { + out.datasource = datasource_slug; } - if (datasource_slug !== undefined) { out.datasource = datasource_slug; } return out; } diff --git a/packages/cli/src/commands/schema/utils.ts b/packages/cli/src/commands/schema/utils.ts index ad494bf22..5deffb61f 100644 --- a/packages/cli/src/commands/schema/utils.ts +++ b/packages/cli/src/commands/schema/utils.ts @@ -185,12 +185,12 @@ export function stripKeys( */ export function toKebabCase(str: string): string { return str - .replace(/[\s_]+/g, '-') - .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/[\s_]+/g, "-") + .replace(/([a-z])([A-Z])/g, "$1-$2") .toLowerCase() - .replace(/[^a-z0-9-]+/g, '-') - .replace(/-{2,}/g, '-') - .replace(/^-+|-+$/g, ''); + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-+|-+$/g, ""); } /** @@ -204,7 +204,9 @@ export function toKebabCase(str: string): string { * `Foo` + `BlockDefinition` needs no guard, but a leading digit still does. */ export function toSafeIdentifier(identifier: string): string { - if (!identifier) { return '_'; } + if (!identifier) { + return "_"; + } return /^\d/.test(identifier) ? `_${identifier}` : identifier; } @@ -214,13 +216,18 @@ export function toSafeIdentifier(identifier: string): string { * generated `export const`s and schema-object keys never collide. Index-aligned * to `rawNames`. */ -export function resolveVarNames(rawNames: string[], baseVarName: (name: string) => string): string[] { +export function resolveVarNames( + rawNames: string[], + baseVarName: (name: string) => string, +): string[] { const used = new Set(); return rawNames.map((raw) => { const base = baseVarName(raw); let candidate = base; let n = 2; - while (used.has(candidate)) { candidate = `${base}${n++}`; } + while (used.has(candidate)) { + candidate = `${base}${n++}`; + } used.add(candidate); return candidate; }); @@ -242,12 +249,17 @@ export function resolveVarNames(rawNames: string[], baseVarName: (name: string) export function resolveFileNames(baseNames: string[], dirKeys?: string[]): string[] { const usedByDir = new Map>(); return baseNames.map((base, i) => { - const dir = dirKeys?.[i] ?? ''; + const dir = dirKeys?.[i] ?? ""; let used = usedByDir.get(dir); - if (!used) { used = new Set(); usedByDir.set(dir, used); } + if (!used) { + used = new Set(); + usedByDir.set(dir, used); + } let candidate = base; let n = 2; - while (used.has(candidate)) { candidate = `${base}-${n++}`; } + while (used.has(candidate)) { + candidate = `${base}-${n++}`; + } used.add(candidate); return candidate; }); @@ -259,12 +271,14 @@ export function componentFileName(name: string): string { } /** Sorts schema fields by `pos` for stable ordering. */ -export function sortSchemaByPos(schema: Record>): [string, Record][] { +export function sortSchemaByPos( + schema: Record>, +): [string, Record][] { return Object.entries(schema) - .filter(([key]) => key !== '_uid' && key !== 'component') + .filter(([key]) => key !== "_uid" && key !== "component") .sort(([, a], [, b]) => { - const posA = typeof a.pos === 'number' ? a.pos : Infinity; - const posB = typeof b.pos === 'number' ? b.pos : Infinity; + const posA = typeof a.pos === "number" ? a.pos : Infinity; + const posB = typeof b.pos === "number" ? b.pos : Infinity; return posA - posB; }); } diff --git a/packages/cli/src/commands/types/generate/constants.ts b/packages/cli/src/commands/types/generate/constants.ts index e7522c79b..942bcc319 100644 --- a/packages/cli/src/commands/types/generate/constants.ts +++ b/packages/cli/src/commands/types/generate/constants.ts @@ -1,5 +1,5 @@ /** Base file name `--future-schema` writes when `--filename` is unset. */ -export const DEFAULT_SCHEMA_TYPES_FILENAME = 'storyblok-schema'; +export const DEFAULT_SCHEMA_TYPES_FILENAME = "storyblok-schema"; /** * Base file name the legacy generator writes when `--filename` is unset. @@ -7,7 +7,7 @@ export const DEFAULT_SCHEMA_TYPES_FILENAME = 'storyblok-schema'; * Shared with `--future-schema`, which warns when `--filename` aims it at this * name, so the two generators cannot overwrite each other unnoticed. */ -export const DEFAULT_COMPONENT_TYPES_FILENAME = 'storyblok-components'; +export const DEFAULT_COMPONENT_TYPES_FILENAME = "storyblok-components"; export interface GenerateTypesOptions { separateFiles?: boolean; diff --git a/packages/cli/src/commands/types/generate/filename.test.ts b/packages/cli/src/commands/types/generate/filename.test.ts index c90bdcdcf..ca2125184 100644 --- a/packages/cli/src/commands/types/generate/filename.test.ts +++ b/packages/cli/src/commands/types/generate/filename.test.ts @@ -1,40 +1,40 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from "vitest"; -import { toDeclarationFileName, toDeclarationImportSpecifier } from './filename'; +import { toDeclarationFileName, toDeclarationImportSpecifier } from "./filename"; -describe('toDeclarationFileName', () => { - it('appends the declaration extension to a base name', () => { - expect(toDeclarationFileName('storyblok-components')).toBe('storyblok-components.d.ts'); +describe("toDeclarationFileName", () => { + it("appends the declaration extension to a base name", () => { + expect(toDeclarationFileName("storyblok-components")).toBe("storyblok-components.d.ts"); }); - it('does not double an extension the user already spelled out', () => { - expect(toDeclarationFileName('my-types.d.ts')).toBe('my-types.d.ts'); + it("does not double an extension the user already spelled out", () => { + expect(toDeclarationFileName("my-types.d.ts")).toBe("my-types.d.ts"); }); - it('leaves an unrelated extension alone, since it is part of the name', () => { - expect(toDeclarationFileName('my-types.generated')).toBe('my-types.generated.d.ts'); + it("leaves an unrelated extension alone, since it is part of the name", () => { + expect(toDeclarationFileName("my-types.generated")).toBe("my-types.generated.d.ts"); }); }); -describe('toDeclarationImportSpecifier', () => { - it('names the output module of the declaration file written for a base name', () => { - expect(toDeclarationImportSpecifier('hero')).toBe('hero.js'); +describe("toDeclarationImportSpecifier", () => { + it("names the output module of the declaration file written for a base name", () => { + expect(toDeclarationImportSpecifier("hero")).toBe("hero.js"); }); // An extension-less specifier is TS2834 under node16/node18/nodenext in an ESM // package, and this is generated code the user cannot repair by hand. - it('never returns an extension-less specifier', () => { - for (const baseName of ['hero', 'teaser-list', '2-col', 'my-types.d.ts']) { + it("never returns an extension-less specifier", () => { + for (const baseName of ["hero", "teaser-list", "2-col", "my-types.d.ts"]) { expect(toDeclarationImportSpecifier(baseName)).toMatch(/\.js$/); } }); - it('pairs with toDeclarationFileName rather than appending to its output', () => { - expect(toDeclarationImportSpecifier('hero.d.ts')).toBe('hero.js'); - expect(toDeclarationImportSpecifier(toDeclarationFileName('hero'))).toBe('hero.js'); + it("pairs with toDeclarationFileName rather than appending to its output", () => { + expect(toDeclarationImportSpecifier("hero.d.ts")).toBe("hero.js"); + expect(toDeclarationImportSpecifier(toDeclarationFileName("hero"))).toBe("hero.js"); }); - it('leaves an unrelated extension alone, since it is part of the name', () => { - expect(toDeclarationImportSpecifier('my-types.generated')).toBe('my-types.generated.js'); + it("leaves an unrelated extension alone, since it is part of the name", () => { + expect(toDeclarationImportSpecifier("my-types.generated")).toBe("my-types.generated.js"); }); }); diff --git a/packages/cli/src/commands/types/generate/filename.ts b/packages/cli/src/commands/types/generate/filename.ts index be25de606..9032648e1 100644 --- a/packages/cli/src/commands/types/generate/filename.ts +++ b/packages/cli/src/commands/types/generate/filename.ts @@ -11,7 +11,7 @@ * `--future-schema`. */ export function toDeclarationFileName(filename: string): string { - return `${filename.replace(/\.d\.ts$/, '')}.d.ts`; + return `${filename.replace(/\.d\.ts$/, "")}.d.ts`; } /** @@ -30,5 +30,5 @@ export function toDeclarationFileName(filename: string): string { * has to match it cannot drift apart. */ export function toDeclarationImportSpecifier(baseName: string): string { - return `${baseName.replace(/\.d\.ts$/, '')}.js`; + return `${baseName.replace(/\.d\.ts$/, "")}.js`; } diff --git a/packages/cli/src/commands/types/generate/future-schema.ts b/packages/cli/src/commands/types/generate/future-schema.ts index d6ea38e14..edb828813 100644 --- a/packages/cli/src/commands/types/generate/future-schema.ts +++ b/packages/cli/src/commands/types/generate/future-schema.ts @@ -1,13 +1,21 @@ -import { join, relative } from 'pathe'; +import { join, relative } from "pathe"; -import { colorPalette, commands } from '../../../constants'; -import { CommandError, handleError, toError } from '../../../utils'; -import { resolvePath } from '../../../utils/filesystem'; -import type { CLISpinner } from '../../../lib/ui'; -import { getUI } from '../../../lib/ui'; -import { DEFAULT_COMPONENT_TYPES_FILENAME, DEFAULT_SCHEMA_TYPES_FILENAME, type GenerateTypesOptions } from './constants'; -import { toDeclarationFileName } from './filename'; -import { assertNoLegacyFlags, generateSchemaTypes, type GenerateSchemaTypesResult } from './schema-types'; +import { colorPalette, commands } from "../../../constants"; +import { CommandError, handleError, toError } from "../../../utils"; +import { resolvePath } from "../../../utils/filesystem"; +import type { CLISpinner } from "../../../lib/ui"; +import { getUI } from "../../../lib/ui"; +import { + DEFAULT_COMPONENT_TYPES_FILENAME, + DEFAULT_SCHEMA_TYPES_FILENAME, + type GenerateTypesOptions, +} from "./constants"; +import { toDeclarationFileName } from "./filename"; +import { + assertNoLegacyFlags, + generateSchemaTypes, + type GenerateSchemaTypesResult, +} from "./schema-types"; export interface FutureSchemaCommandOptions { /** Command options, including the legacy-only flags this mode rejects. */ @@ -37,18 +45,19 @@ export interface FutureSchemaCommandOptions { * wired up, rename an export in a module that is already at the right path, or * create one. */ -function unmappedRemedy(fieldPlugins: GenerateSchemaTypesResult['fieldPlugins']): string { +function unmappedRemedy(fieldPlugins: GenerateSchemaTypesResult["fieldPlugins"]): string { const where = relative(process.cwd(), fieldPlugins.path); if (fieldPlugins.resolved) { return `Declare them with defineFieldPlugin in ${where}.`; } - if (fieldPlugins.reason === 'unusable') { + if (fieldPlugins.reason === "unusable") { return `${where} exports neither a \`schema\` (a defineSchema result with fieldPlugins) nor a \`fieldPlugins\` record, so its declarations were not read.${ fieldPlugins.nearMissExport === undefined - ? '' - : ` Found \`${fieldPlugins.nearMissExport}\`, which looks like one: rename it to \`schema\` or \`fieldPlugins\`.`}`; + ? "" + : ` Found \`${fieldPlugins.nearMissExport}\`, which looks like one: rename it to \`schema\` or \`fieldPlugins\`.` + }`; } return `Declare them with defineFieldPlugin and point --field-plugins at the module (or place it at ${where}).`; @@ -62,69 +71,74 @@ function unmappedRemedy(fieldPlugins: GenerateSchemaTypesResult['fieldPlugins']) * branch. Errors are handled here rather than rethrown, matching the legacy * path's behaviour. */ -export async function runFutureSchemaTypes( - { options, globals, getOptionValueSource }: FutureSchemaCommandOptions, -): Promise { +export async function runFutureSchemaTypes({ + options, + globals, + getOptionValueSource, +}: FutureSchemaCommandOptions): Promise { const { space, path, filename, separateFiles, verbose } = globals; const ui = getUI(); - ui.title(`${commands.TYPES}`, colorPalette.TYPES, 'Generating types from schema...'); + ui.title(`${commands.TYPES}`, colorPalette.TYPES, "Generating types from schema..."); let spinner: CLISpinner | undefined; try { const ignoredFromConfig = assertNoLegacyFlags(options, getOptionValueSource); if (ignoredFromConfig.length > 0) { ui.warn( - `Ignoring ${ignoredFromConfig.join(', ')} from your config file: ` - + 'not supported with --future-schema.', + `Ignoring ${ignoredFromConfig.join(", ")} from your config file: ` + + "not supported with --future-schema.", ); } if (!space) { - throw new CommandError('Please provide the space as argument --space SPACE_ID.'); + throw new CommandError("Please provide the space as argument --space SPACE_ID."); } // Only the legacy generator's own file name is a collision. Warning on every // --filename told users their own path clashed with a file nothing writes. - if (filename !== undefined - && toDeclarationFileName(filename) === toDeclarationFileName(DEFAULT_COMPONENT_TYPES_FILENAME)) { + if ( + filename !== undefined && + toDeclarationFileName(filename) === toDeclarationFileName(DEFAULT_COMPONENT_TYPES_FILENAME) + ) { ui.warn( - `--filename is set to \`${toDeclarationFileName(filename)}\`, which is also where the legacy ` - + 'generator writes. Regenerating with and without --future-schema will overwrite one with the ' - + `other. Leave it unset to write to ${toDeclarationFileName(DEFAULT_SCHEMA_TYPES_FILENAME)} instead.`, + `--filename is set to \`${toDeclarationFileName(filename)}\`, which is also where the legacy ` + + "generator writes. Regenerating with and without --future-schema will overwrite one with the " + + `other. Leave it unset to write to ${toDeclarationFileName(DEFAULT_SCHEMA_TYPES_FILENAME)} instead.`, ); } - spinner = ui.createSpinner('Generating types...'); + spinner = ui.createSpinner("Generating types..."); const result = await generateSchemaTypes({ space, cwd: process.cwd(), path, - outputDir: resolvePath(path, join('types', space)), + outputDir: resolvePath(path, join("types", space)), filename: filename ?? DEFAULT_SCHEMA_TYPES_FILENAME, separateFiles, typePrefix: options.typePrefix, typeSuffix: options.typeSuffix, fieldPluginsPath: options.fieldPlugins, }); - spinner.succeed('Generated types'); + spinner.succeed("Generated types"); - result.files.forEach(file => ui.ok(file)); + result.files.forEach((file) => ui.ok(file)); if (result.prunedFiles.length > 0) { // Says so rather than deleting silently: these are files a previous run // wrote, so their disappearance would otherwise look like data loss. ui.info( - `Removed ${result.prunedFiles.length} stale block type ` - + `${result.prunedFiles.length === 1 ? 'file' : 'files'} for components that no longer exist.`, + `Removed ${result.prunedFiles.length} stale block type ` + + `${result.prunedFiles.length === 1 ? "file" : "files"} for components that no longer exist.`, ); } if (result.unmappedFieldTypes.length > 0) { ui.warn( - `No field plugin registered for: ${result.unmappedFieldTypes.join(', ')}. ` - + `These custom fields fall back to an untyped value. ${unmappedRemedy(result.fieldPlugins)}`, + `No field plugin registered for: ${result.unmappedFieldTypes.join(", ")}. ` + + `These custom fields fall back to an untyped value. ${unmappedRemedy(result.fieldPlugins)}`, ); } - ui.info('The generated types import from `@storyblok/schema`. Install it as a dev dependency: `npm i -D @storyblok/schema`.'); + ui.info( + "The generated types import from `@storyblok/schema`. Install it as a dev dependency: `npm i -D @storyblok/schema`.", + ); ui.br(); - } - catch (error) { + } catch (error) { spinner?.failed(`Failed to generate types for space ${space}`); ui.br(); handleError(toError(error), verbose); diff --git a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/components.ts b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/components.ts index 341452d13..0a7c74410 100644 --- a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/components.ts +++ b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/components.ts @@ -9,37 +9,37 @@ export const FIXTURE_COMPONENTS = [ { id: 1, - name: 'hero', - created_at: '', - updated_at: '', + name: "hero", + created_at: "", + updated_at: "", is_root: false, is_nestable: true, schema: { - headline: { type: 'text', required: true, pos: 0 }, - image: { type: 'asset', pos: 1 }, - nested: { type: 'bloks', component_whitelist: ['grid'], pos: 2 }, - general: { type: 'tab', pos: 3 }, + headline: { type: "text", required: true, pos: 0 }, + image: { type: "asset", pos: 1 }, + nested: { type: "bloks", component_whitelist: ["grid"], pos: 2 }, + general: { type: "tab", pos: 3 }, }, }, { id: 2, - name: 'grid', - created_at: '', - updated_at: '', + name: "grid", + created_at: "", + updated_at: "", is_root: false, is_nestable: true, - schema: { columns: { type: 'bloks', pos: 0 } }, + schema: { columns: { type: "bloks", pos: 0 } }, }, { id: 3, - name: 'page', - created_at: '', - updated_at: '', + name: "page", + created_at: "", + updated_at: "", is_root: true, is_nestable: false, schema: { - body: { type: 'bloks', pos: 0 }, - accent: { type: 'custom', field_type: 'colorpicker', pos: 1 }, + body: { type: "bloks", pos: 0 }, + accent: { type: "custom", field_type: "colorpicker", pos: 1 }, }, }, ]; diff --git a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/plugins.ts b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/plugins.ts index e239169c6..89e817a15 100644 --- a/packages/cli/src/commands/types/generate/schema-types/__fixtures__/plugins.ts +++ b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/plugins.ts @@ -1,4 +1,4 @@ -import { defineFieldPlugin } from '@storyblok/schema'; +import { defineFieldPlugin } from "@storyblok/schema"; /** The value type a `colorpicker` custom field should resolve to. */ export interface ColorPickerValue { @@ -12,7 +12,7 @@ export interface ColorPickerValue { * validates at runtime, only the output type is used. */ interface MinimalStandardSchema { - readonly '~standard': { + readonly "~standard": { readonly version: 1; readonly vendor: string; readonly validate: (value: unknown) => { readonly value: Output }; @@ -21,10 +21,10 @@ interface MinimalStandardSchema { } const colorPickerSchema: MinimalStandardSchema = { - '~standard': { + "~standard": { version: 1, - vendor: 'fixture', - validate: () => ({ value: { hex: '#000000' } }), + vendor: "fixture", + validate: () => ({ value: { hex: "#000000" } }), // Standard Schema's own convention: `types` exists only for static // inference (`StandardSchemaV1.InferOutput`) and is never read at // runtime, so real implementations (zod, valibot, …) assign it the same @@ -36,7 +36,7 @@ const colorPickerSchema: MinimalStandardSchema = { /** Registers `colorpicker` as the custom field used by `page.accent` in `components.ts`. */ export const fieldPlugins = { colorPicker: defineFieldPlugin({ - fieldType: 'colorpicker', + fieldType: "colorpicker", value: colorPickerSchema, }), }; diff --git a/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts b/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts index 3b44612d6..9d0dc8060 100644 --- a/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts +++ b/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts @@ -1,69 +1,72 @@ -import type { Block as SchemaBlock } from '@storyblok/schema'; -import { describe, expectTypeOf, it } from 'vitest'; +import type { Block as SchemaBlock } from "@storyblok/schema"; +import { describe, expectTypeOf, it } from "vitest"; -import type { AnyBlock, Block, Blocks, Schema } from './__fixtures__/expected-types'; -import type { Block as PluginBlock, Story as PluginStory } from './__fixtures__/expected-types-with-plugins'; +import type { AnyBlock, Block, Blocks, Schema } from "./__fixtures__/expected-types"; +import type { + Block as PluginBlock, + Story as PluginStory, +} from "./__fixtures__/expected-types-with-plugins"; /** * Asserts the *behaviour* of the generated types, not their text. This is the * test that proves definition types plus `BlockContent` reproduce hand-written * schema types, the central claim of the design. */ -describe('generated types', () => { - it('resolves a block content type by name', () => { - expectTypeOf>().toMatchObjectType<{ component: 'hero' }>(); +describe("generated types", () => { + it("resolves a block content type by name", () => { + expectTypeOf>().toMatchObjectType<{ component: "hero" }>(); }); - it('makes required fields required and others optional', () => { - expectTypeOf>().toHaveProperty('headline').toEqualTypeOf(); - expectTypeOf>().toHaveProperty('image').toBeNullable(); + it("makes required fields required and others optional", () => { + expectTypeOf>().toHaveProperty("headline").toEqualTypeOf(); + expectTypeOf>().toHaveProperty("image").toBeNullable(); }); // A tab groups other fields in the editor UI and is never a key in story // content, so it must not appear at all. It used to surface as `general?: null`, // which offered a key no API response has. - it('omits tab fields from the content type entirely', () => { - expectTypeOf>().not.toHaveProperty('general'); + it("omits tab fields from the content type entirely", () => { + expectTypeOf>().not.toHaveProperty("general"); }); - it('narrows a whitelisted bloks field to the allowed block only', () => { - type Nested = NonNullable['nested']>; + it("narrows a whitelisted bloks field to the allowed block only", () => { + type Nested = NonNullable["nested"]>; // `Nested[number]` is a union that distributes over `component`, so the // union's discriminant is checked directly rather than through // `toMatchObjectType`, which does not support union-typed `Actual` values. - expectTypeOf().toEqualTypeOf<'grid'>(); + expectTypeOf().toEqualTypeOf<"grid">(); }); - it('supports recursive blocks', () => { - type Columns = NonNullable['columns']>; + it("supports recursive blocks", () => { + type Columns = NonNullable["columns"]>; // `grid` nests itself: a `grid`-component member must exist in the union. - expectTypeOf>().not.toBeNever(); + expectTypeOf>().not.toBeNever(); }); - it('excludes non-nestable blocks from bloks unions', () => { - type Columns = NonNullable['columns']>; + it("excludes non-nestable blocks from bloks unions", () => { + type Columns = NonNullable["columns"]>; // `page` has is_nestable: false, so it must not appear; this is exact // equality against the full expected union, so a stray `'page'` member // fails it (a `not.toEqualTypeOf<'page'>()` check would not: it is // structurally incapable of failing against a multi-member union). - expectTypeOf().toEqualTypeOf<'grid' | 'hero'>(); + expectTypeOf().toEqualTypeOf<"grid" | "hero">(); }); - it('exposes a Schema shaped for withTypes()', () => { + it("exposes a Schema shaped for withTypes()", () => { expectTypeOf().toMatchObjectType<{ blocks: Blocks }>(); }); - it('emits Blocks that satisfy withTypes\'s StoryblokTypesConfig constraint', () => { + it("emits Blocks that satisfy withTypes's StoryblokTypesConfig constraint", () => { // `createApiClient(...).withTypes()` accepts `{ components: Block } | { blocks: Block }` // (`packages/capi-client/src/client.ts`), so the emitted union must extend `Block`. expectTypeOf().toExtend(); }); - it('accepts any block through AnyBlock', () => { + it("accepts any block through AnyBlock", () => { // The discriminant must be the full union of component names, not a // single block's, so this fails if `AnyBlock` is ever wrongly narrowed to // one block (a plain `toHaveProperty('component')` would not catch that). - expectTypeOf().toEqualTypeOf<'hero' | 'grid' | 'page'>(); + expectTypeOf().toEqualTypeOf<"hero" | "grid" | "page">(); }); }); @@ -76,12 +79,16 @@ describe('generated types', () => { * `InferStory` are indistinguishable. This block is the * only one that can catch `render.ts` regressing to the single-argument form. */ -describe('emitted Story/StoryMapi thread FieldPlugins', () => { - it('resolves a registered custom field through Block', () => { - expectTypeOf['accent']>>().toHaveProperty('hex').toEqualTypeOf(); +describe("emitted Story/StoryMapi thread FieldPlugins", () => { + it("resolves a registered custom field through Block", () => { + expectTypeOf["accent"]>>() + .toHaveProperty("hex") + .toEqualTypeOf(); }); - it('resolves a registered custom field through Story, not just through Block', () => { - expectTypeOf>().toHaveProperty('hex').toEqualTypeOf(); + it("resolves a registered custom field through Story, not just through Block", () => { + expectTypeOf>() + .toHaveProperty("hex") + .toEqualTypeOf(); }); }); diff --git a/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts b/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts index c61ca5de5..02cde99b3 100644 --- a/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts @@ -1,15 +1,15 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'pathe'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "pathe"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { DEFAULT_SCHEMA_ENTRY_PATH, SCHEMA_ENTRY_RELATIVE_PATH } from '../../../schema/constants'; -import { resolveFieldPluginsSource } from './field-plugins'; +import { DEFAULT_SCHEMA_ENTRY_PATH, SCHEMA_ENTRY_RELATIVE_PATH } from "../../../schema/constants"; +import { resolveFieldPluginsSource } from "./field-plugins"; // This module resolves a real TypeScript file from disk via jiti, so it needs the // real filesystem rather than the memfs mock the global test setup installs. -vi.unmock('node:fs'); -vi.unmock('node:fs/promises'); +vi.unmock("node:fs"); +vi.unmock("node:fs/promises"); let cwd: string; @@ -25,121 +25,145 @@ export const fieldPlugins = { colorPicker: { fieldType: 'storyblok-colorpicker', `; beforeEach(async () => { - cwd = await mkdtemp(join(tmpdir(), 'sb-field-plugins-')); + cwd = await mkdtemp(join(tmpdir(), "sb-field-plugins-")); }); afterEach(async () => { await rm(cwd, { recursive: true, force: true }); }); -describe('resolveFieldPluginsSource', () => { - it('returns none when neither an override nor the convention file exists', async () => { +describe("resolveFieldPluginsSource", () => { + it("returns none when neither an override nor the convention file exists", async () => { expect(await resolveFieldPluginsSource({ cwd })).toEqual({ - kind: 'none', - reason: 'missing', + kind: "none", + reason: "missing", searchedPath: join(cwd, DEFAULT_SCHEMA_ENTRY_PATH), }); }); - it('detects a defineSchema result at the convention path', async () => { + it("detects a defineSchema result at the convention path", async () => { const target = join(cwd, DEFAULT_SCHEMA_ENTRY_PATH); - await mkdir(join(target, '..'), { recursive: true }); - await writeFile(target, SCHEMA_EXPORT, 'utf8'); + await mkdir(join(target, ".."), { recursive: true }); + await writeFile(target, SCHEMA_EXPORT, "utf8"); const result = await resolveFieldPluginsSource({ cwd }); - expect(result).toEqual({ kind: 'schema', modulePath: target, fieldTypes: ['storyblok-colorpicker'] }); + expect(result).toEqual({ + kind: "schema", + modulePath: target, + fieldTypes: ["storyblok-colorpicker"], + }); }); - it('detects a bare fieldPlugins record via an explicit override', async () => { - const target = join(cwd, 'plugins.ts'); - await writeFile(target, RECORD_EXPORT, 'utf8'); + it("detects a bare fieldPlugins record via an explicit override", async () => { + const target = join(cwd, "plugins.ts"); + await writeFile(target, RECORD_EXPORT, "utf8"); - const result = await resolveFieldPluginsSource({ cwd, override: 'plugins.ts' }); + const result = await resolveFieldPluginsSource({ cwd, override: "plugins.ts" }); - expect(result).toEqual({ kind: 'record', modulePath: target, fieldTypes: ['storyblok-colorpicker'] }); + expect(result).toEqual({ + kind: "record", + modulePath: target, + fieldTypes: ["storyblok-colorpicker"], + }); }); - it('throws when an explicit override does not exist', async () => { - await expect(resolveFieldPluginsSource({ cwd, override: 'missing.ts' })) - .rejects - .toThrow(/not found/); + it("throws when an explicit override does not exist", async () => { + await expect(resolveFieldPluginsSource({ cwd, override: "missing.ts" })).rejects.toThrow( + /not found/, + ); }); - it('throws when an explicit override exports neither supported shape', async () => { - const target = join(cwd, 'plugins.ts'); - await writeFile(target, 'export const nope = 1;', 'utf8'); + it("throws when an explicit override exports neither supported shape", async () => { + const target = join(cwd, "plugins.ts"); + await writeFile(target, "export const nope = 1;", "utf8"); - await expect(resolveFieldPluginsSource({ cwd, override: 'plugins.ts' })) - .rejects - .toThrow(/fieldPlugins/); + await expect(resolveFieldPluginsSource({ cwd, override: "plugins.ts" })).rejects.toThrow( + /fieldPlugins/, + ); }); - it('resolves the convention path under a custom --path', async () => { - const target = join(cwd, 'config', SCHEMA_ENTRY_RELATIVE_PATH); - await mkdir(join(target, '..'), { recursive: true }); - await writeFile(target, SCHEMA_EXPORT, 'utf8'); + it("resolves the convention path under a custom --path", async () => { + const target = join(cwd, "config", SCHEMA_ENTRY_RELATIVE_PATH); + await mkdir(join(target, ".."), { recursive: true }); + await writeFile(target, SCHEMA_EXPORT, "utf8"); - const result = await resolveFieldPluginsSource({ cwd, path: 'config' }); + const result = await resolveFieldPluginsSource({ cwd, path: "config" }); - expect(result).toEqual({ kind: 'schema', modulePath: target, fieldTypes: ['storyblok-colorpicker'] }); + expect(result).toEqual({ + kind: "schema", + modulePath: target, + fieldTypes: ["storyblok-colorpicker"], + }); }); - it('does not look under the default base path when --path is set', async () => { + it("does not look under the default base path when --path is set", async () => { const defaultTarget = join(cwd, DEFAULT_SCHEMA_ENTRY_PATH); - await mkdir(join(defaultTarget, '..'), { recursive: true }); - await writeFile(defaultTarget, SCHEMA_EXPORT, 'utf8'); + await mkdir(join(defaultTarget, ".."), { recursive: true }); + await writeFile(defaultTarget, SCHEMA_EXPORT, "utf8"); - expect(await resolveFieldPluginsSource({ cwd, path: 'config' })).toEqual({ - kind: 'none', - reason: 'missing', - searchedPath: join(cwd, 'config', SCHEMA_ENTRY_RELATIVE_PATH), + expect(await resolveFieldPluginsSource({ cwd, path: "config" })).toEqual({ + kind: "none", + reason: "missing", + searchedPath: join(cwd, "config", SCHEMA_ENTRY_RELATIVE_PATH), }); }); - it('names a near-miss export in the error for an explicit override', async () => { - const target = join(cwd, 'plugins.ts'); - await writeFile(target, SCHEMA_EXPORT.replace('export const schema', 'export const mySchema'), 'utf8'); - - await expect(resolveFieldPluginsSource({ cwd, override: 'plugins.ts' })) - .rejects - .toThrow(/`mySchema`/); + it("names a near-miss export in the error for an explicit override", async () => { + const target = join(cwd, "plugins.ts"); + await writeFile( + target, + SCHEMA_EXPORT.replace("export const schema", "export const mySchema"), + "utf8", + ); + + await expect(resolveFieldPluginsSource({ cwd, override: "plugins.ts" })).rejects.toThrow( + /`mySchema`/, + ); }); - it('names a near-miss bare record too', async () => { - const target = join(cwd, 'plugins.ts'); - await writeFile(target, RECORD_EXPORT.replace('export const fieldPlugins', 'export const myPlugins'), 'utf8'); - - await expect(resolveFieldPluginsSource({ cwd, override: 'plugins.ts' })) - .rejects - .toThrow(/`myPlugins`/); + it("names a near-miss bare record too", async () => { + const target = join(cwd, "plugins.ts"); + await writeFile( + target, + RECORD_EXPORT.replace("export const fieldPlugins", "export const myPlugins"), + "utf8", + ); + + await expect(resolveFieldPluginsSource({ cwd, override: "plugins.ts" })).rejects.toThrow( + /`myPlugins`/, + ); }); // The distinction drives the advice: `missing` means write a module here, // `unusable` means rename an export in the module already here. `schema init` // writes exactly this shape, so it is the case users hit first. - it('returns none with reason unusable when the convention file exists but exports neither shape', async () => { + it("returns none with reason unusable when the convention file exists but exports neither shape", async () => { const target = join(cwd, DEFAULT_SCHEMA_ENTRY_PATH); - await mkdir(join(target, '..'), { recursive: true }); - await writeFile(target, 'export const schema = { blocks: {} };', 'utf8'); + await mkdir(join(target, ".."), { recursive: true }); + await writeFile(target, "export const schema = { blocks: {} };", "utf8"); expect(await resolveFieldPluginsSource({ cwd })).toEqual({ - kind: 'none', - reason: 'unusable', + kind: "none", + reason: "unusable", searchedPath: join(cwd, DEFAULT_SCHEMA_ENTRY_PATH), }); }); - it('carries a near-miss export name from the convention path', async () => { + it("carries a near-miss export name from the convention path", async () => { const target = join(cwd, DEFAULT_SCHEMA_ENTRY_PATH); - await mkdir(join(target, '..'), { recursive: true }); - await writeFile(target, RECORD_EXPORT.replace('export const fieldPlugins', 'export const myPlugins'), 'utf8'); + await mkdir(join(target, ".."), { recursive: true }); + await writeFile( + target, + RECORD_EXPORT.replace("export const fieldPlugins", "export const myPlugins"), + "utf8", + ); expect(await resolveFieldPluginsSource({ cwd })).toEqual({ - kind: 'none', - reason: 'unusable', + kind: "none", + reason: "unusable", searchedPath: target, - nearMissExport: 'myPlugins', + nearMissExport: "myPlugins", }); }); }); diff --git a/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts b/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts index ce19e4a5a..02e8e9fc9 100644 --- a/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts +++ b/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts @@ -1,11 +1,11 @@ -import { existsSync } from 'node:fs'; -import { resolve } from 'pathe'; +import { existsSync } from "node:fs"; +import { resolve } from "pathe"; -import { CommandError, toError } from '../../../../utils'; -import { DEFAULT_STORAGE_DIR } from '../../../../utils/filesystem'; -import { importModule } from '../../../../utils/import-module'; -import { SCHEMA_ENTRY_RELATIVE_PATH } from '../../../schema/constants'; -import { isRecord } from '../../../schema/utils'; +import { CommandError, toError } from "../../../../utils"; +import { DEFAULT_STORAGE_DIR } from "../../../../utils/filesystem"; +import { importModule } from "../../../../utils/import-module"; +import { SCHEMA_ENTRY_RELATIVE_PATH } from "../../../schema/constants"; +import { isRecord } from "../../../schema/utils"; /** * Where the generated `FieldPlugins` type comes from. @@ -27,15 +27,19 @@ import { isRecord } from '../../../schema/utils'; * looking at reads as the command failing to see it. */ export type FieldPluginsSource = - | { kind: 'none'; reason: 'missing' | 'unusable'; searchedPath: string; nearMissExport?: string } - | { kind: 'schema'; modulePath: string; fieldTypes: string[] } - | { kind: 'record'; modulePath: string; fieldTypes: string[] }; + | { kind: "none"; reason: "missing" | "unusable"; searchedPath: string; nearMissExport?: string } + | { kind: "schema"; modulePath: string; fieldTypes: string[] } + | { kind: "record"; modulePath: string; fieldTypes: string[] }; /** Collects the `fieldType` of every entry in a `fieldPlugins` record. */ function collectFieldTypes(fieldPlugins: Record): string[] { const fieldTypes: string[] = []; for (const plugin of Object.values(fieldPlugins)) { - if (isRecord(plugin) && typeof plugin.fieldType === 'string' && !fieldTypes.includes(plugin.fieldType)) { + if ( + isRecord(plugin) && + typeof plugin.fieldType === "string" && + !fieldTypes.includes(plugin.fieldType) + ) { fieldTypes.push(plugin.fieldType); } } @@ -54,11 +58,20 @@ function collectFieldTypes(fieldPlugins: Record): string[] { */ function findNearMissExport(module: Record): string | undefined { for (const [name, value] of Object.entries(module)) { - if (name === 'schema' || name === 'fieldPlugins' || name === 'default') { continue; } - if (!isRecord(value)) { continue; } - if (isRecord(value.fieldPlugins)) { return name; } + if (name === "schema" || name === "fieldPlugins" || name === "default") { + continue; + } + if (!isRecord(value)) { + continue; + } + if (isRecord(value.fieldPlugins)) { + return name; + } const entries = Object.values(value); - if (entries.length > 0 && entries.every(entry => isRecord(entry) && typeof entry.fieldType === 'string')) { + if ( + entries.length > 0 && + entries.every((entry) => isRecord(entry) && typeof entry.fieldType === "string") + ) { return name; } } @@ -82,37 +95,41 @@ function findNearMissExport(module: Record): string | undefined * field with no registered plugin is reported afterwards as an unmapped * `field_type`. */ -export async function resolveFieldPluginsSource( - options: { cwd: string; path?: string; override?: string }, -): Promise { +export async function resolveFieldPluginsSource(options: { + cwd: string; + path?: string; + override?: string; +}): Promise { const isExplicit = options.override !== undefined; - const modulePath = options.override === undefined - // Honours `--path`, the same base the generated types are written under. - ? resolve(options.cwd, options.path ?? DEFAULT_STORAGE_DIR, SCHEMA_ENTRY_RELATIVE_PATH) - : resolve(options.cwd, options.override); + const modulePath = + options.override === undefined + ? // Honours `--path`, the same base the generated types are written under. + resolve(options.cwd, options.path ?? DEFAULT_STORAGE_DIR, SCHEMA_ENTRY_RELATIVE_PATH) + : resolve(options.cwd, options.override); if (!existsSync(modulePath)) { if (isExplicit) { throw new CommandError(`Field plugins module not found: ${modulePath}`); } - return { kind: 'none', reason: 'missing', searchedPath: modulePath }; + return { kind: "none", reason: "missing", searchedPath: modulePath }; } let module: Record; try { module = await importModule(modulePath); - } - catch (maybeError) { - throw new CommandError(`Failed to load field plugins from ${modulePath}: ${toError(maybeError).message}`); + } catch (maybeError) { + throw new CommandError( + `Failed to load field plugins from ${modulePath}: ${toError(maybeError).message}`, + ); } const schemaExport = module.schema; if (isRecord(schemaExport) && isRecord(schemaExport.fieldPlugins)) { - return { kind: 'schema', modulePath, fieldTypes: collectFieldTypes(schemaExport.fieldPlugins) }; + return { kind: "schema", modulePath, fieldTypes: collectFieldTypes(schemaExport.fieldPlugins) }; } if (isRecord(module.fieldPlugins)) { - return { kind: 'record', modulePath, fieldTypes: collectFieldTypes(module.fieldPlugins) }; + return { kind: "record", modulePath, fieldTypes: collectFieldTypes(module.fieldPlugins) }; } const nearMiss = findNearMissExport(module); @@ -120,12 +137,18 @@ export async function resolveFieldPluginsSource( throw new CommandError( `${modulePath} exports neither a \`schema\` (a defineSchema result with fieldPlugins) nor a \`fieldPlugins\` record.${ nearMiss === undefined - ? '' - : ` Found \`${nearMiss}\`, which looks like one: rename it to \`schema\` or \`fieldPlugins\`.`}`, + ? "" + : ` Found \`${nearMiss}\`, which looks like one: rename it to \`schema\` or \`fieldPlugins\`.` + }`, ); } // The convention path degrades rather than failing, but the near miss is still // worth carrying: the unmapped-field-type warning can then name the export to // rename instead of restating the contract. - return { kind: 'none', reason: 'unusable', searchedPath: modulePath, ...(nearMiss === undefined ? {} : { nearMissExport: nearMiss }) }; + return { + kind: "none", + reason: "unusable", + searchedPath: modulePath, + ...(nearMiss === undefined ? {} : { nearMissExport: nearMiss }), + }; } diff --git a/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts b/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts index 588ebed03..a3f261978 100644 --- a/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from "vitest"; -import type { Component } from '../../../../types'; -import { FIXTURE_COMPONENTS } from './__fixtures__/components'; -import { renderSchemaTypes, toRelativeImport } from './render'; -import { serializeBlockDefinition } from './serialize'; +import type { Component } from "../../../../types"; +import { FIXTURE_COMPONENTS } from "./__fixtures__/components"; +import { renderSchemaTypes, toRelativeImport } from "./render"; +import { serializeBlockDefinition } from "./serialize"; /** * Serializes the fixture components the way `generateSchemaTypes` does, so the @@ -12,10 +12,12 @@ import { serializeBlockDefinition } from './serialize'; */ function serializeFixtureBlocks() { const components: Component[] = FIXTURE_COMPONENTS; - const knownBlockNames = new Set(components.map(component => component.name)); + const knownBlockNames = new Set(components.map((component) => component.name)); return [...components] .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) - .map(component => serializeBlockDefinition(component, { displayPathByUuid: new Map(), knownBlockNames })); + .map((component) => + serializeBlockDefinition(component, { displayPathByUuid: new Map(), knownBlockNames }), + ); } /** @@ -24,15 +26,15 @@ function serializeFixtureBlocks() { * which re-runs the type-level assertions against the new output. Without this * test the fixture could silently rot into a file the CLI no longer produces. */ -describe('emitted type fixture', () => { - it('matches what the renderer currently produces', async () => { +describe("emitted type fixture", () => { + it("matches what the renderer currently produces", async () => { const rendered = renderSchemaTypes({ blocks: serializeFixtureBlocks(), - fieldPlugins: { kind: 'none' }, - space: '295018', + fieldPlugins: { kind: "none" }, + space: "295018", }); - await expect(rendered).toMatchFileSnapshot('./__fixtures__/expected-types.d.ts'); + await expect(rendered).toMatchFileSnapshot("./__fixtures__/expected-types.d.ts"); }); /** @@ -41,15 +43,15 @@ describe('emitted type fixture', () => { * fixture to prove `custom` fields resolve through `Story`/`StoryMapi`, not * just through `Block`, see the "emitted `Story`" describe block. */ - it('matches what the renderer produces with field plugins registered', async () => { + it("matches what the renderer produces with field plugins registered", async () => { const rendered = renderSchemaTypes({ blocks: serializeFixtureBlocks(), - fieldPlugins: { kind: 'record', modulePath: '/abs/plugins.ts', fieldTypes: ['colorpicker'] }, + fieldPlugins: { kind: "record", modulePath: "/abs/plugins.ts", fieldTypes: ["colorpicker"] }, // Derived rather than hardcoded, so the fixture tracks the real specifier. - fieldPluginsImportPath: toRelativeImport('/abs', '/abs/plugins.ts'), - space: '295018', + fieldPluginsImportPath: toRelativeImport("/abs", "/abs/plugins.ts"), + space: "295018", }); - await expect(rendered).toMatchFileSnapshot('./__fixtures__/expected-types-with-plugins.d.ts'); + await expect(rendered).toMatchFileSnapshot("./__fixtures__/expected-types-with-plugins.d.ts"); }); }); diff --git a/packages/cli/src/commands/types/generate/schema-types/index.test.ts b/packages/cli/src/commands/types/generate/schema-types/index.test.ts index 7d9f4b6ea..9512c6f04 100644 --- a/packages/cli/src/commands/types/generate/schema-types/index.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/index.test.ts @@ -1,29 +1,29 @@ -import { describe, expect, it, vi } from 'vitest'; -import { vol } from 'memfs'; +import { describe, expect, it, vi } from "vitest"; +import { vol } from "memfs"; -import { assertNoLegacyFlags, generateSchemaTypes } from './index'; +import { assertNoLegacyFlags, generateSchemaTypes } from "./index"; -vi.mock('../../../schema/actions', () => ({ +vi.mock("../../../schema/actions", () => ({ fetchRemoteSchema: vi.fn(async () => ({ remote: { components: new Map(), componentFolders: new Map(), datasources: new Map() }, rawComponents: [ { id: 1, - name: 'hero', - created_at: '', - updated_at: '', + name: "hero", + created_at: "", + updated_at: "", is_root: false, is_nestable: true, - schema: { headline: { type: 'text', required: true, pos: 0 } }, + schema: { headline: { type: "text", required: true, pos: 0 } }, }, { id: 2, - name: 'page', - created_at: '', - updated_at: '', + name: "page", + created_at: "", + updated_at: "", is_root: true, is_nestable: false, - schema: { body: { type: 'bloks', pos: 0 } }, + schema: { body: { type: "bloks", pos: 0 } }, }, ], rawComponentFolders: [], @@ -32,157 +32,183 @@ vi.mock('../../../schema/actions', () => ({ })); const written = new Map(); -vi.mock('../../../../utils/filesystem', async (importOriginal) => { +vi.mock("../../../../utils/filesystem", async (importOriginal) => { const actual = await importOriginal>(); return { ...actual, - saveToFile: vi.fn(async (path: string, content: string) => { written.set(path, content); }), + saveToFile: vi.fn(async (path: string, content: string) => { + written.set(path, content); + }), }; }); -describe('assertNoLegacyFlags', () => { - it('accepts options that use no legacy-only flag', () => { - expect(() => assertNoLegacyFlags({ typePrefix: 'Sb', separateFiles: true })).not.toThrow(); +describe("assertNoLegacyFlags", () => { + it("accepts options that use no legacy-only flag", () => { + expect(() => assertNoLegacyFlags({ typePrefix: "Sb", separateFiles: true })).not.toThrow(); }); - it('rejects --strict', () => { + it("rejects --strict", () => { expect(() => assertNoLegacyFlags({ strict: true })).toThrow(/--strict/); }); - it('rejects --suffix', () => { - expect(() => assertNoLegacyFlags({ suffix: 'v1' })).toThrow(/--suffix/); + it("rejects --suffix", () => { + expect(() => assertNoLegacyFlags({ suffix: "v1" })).toThrow(/--suffix/); }); it.each([ - ['strict', { strict: true }, /required/], - ['customFieldsParser', { customFieldsParser: './p.ts' }, /defineFieldPlugin/], - ['compilerOptions', { compilerOptions: './c.json' }, /JSON-schema compiler/], - ['suffix', { suffix: 'v1' }, /pulled component files/], - ] as const)('explains why %s cannot apply, rather than giving a shared rationale', (_name, options, reason) => { - expect(() => assertNoLegacyFlags(options)).toThrow(reason); + ["strict", { strict: true }, /required/], + ["customFieldsParser", { customFieldsParser: "./p.ts" }, /defineFieldPlugin/], + ["compilerOptions", { compilerOptions: "./c.json" }, /JSON-schema compiler/], + ["suffix", { suffix: "v1" }, /pulled component files/], + ] as const)( + "explains why %s cannot apply, rather than giving a shared rationale", + (_name, options, reason) => { + expect(() => assertNoLegacyFlags(options)).toThrow(reason); + }, + ); + + it("does not explain field optionality to someone who passed only --suffix", () => { + expect(() => assertNoLegacyFlags({ suffix: "v1" })).not.toThrow(/optionality|required/); }); - it('does not explain field optionality to someone who passed only --suffix', () => { - expect(() => assertNoLegacyFlags({ suffix: 'v1' })).not.toThrow(/optionality|required/); + it("names every offending flag at once", () => { + expect(() => + assertNoLegacyFlags({ + strict: true, + customFieldsParser: "./p.ts", + compilerOptions: "./c.json", + suffix: "v1", + }), + ).toThrow(/--strict.*--custom-fields-parser.*--compiler-options.*--suffix/s); }); - it('names every offending flag at once', () => { - expect(() => assertNoLegacyFlags({ - strict: true, - customFieldsParser: './p.ts', - compilerOptions: './c.json', - suffix: 'v1', - })) - .toThrow(/--strict.*--custom-fields-parser.*--compiler-options.*--suffix/s); - }); - - it('reports rather than rejects a legacy flag the config file set', () => { + it("reports rather than rejects a legacy flag the config file set", () => { // Erroring here would lock a project whose config sets `strict` out of // --future-schema entirely, without the user having typed anything. - const ignored = assertNoLegacyFlags({ strict: true }, () => 'config'); + const ignored = assertNoLegacyFlags({ strict: true }, () => "config"); - expect(ignored).toEqual(['--strict']); + expect(ignored).toEqual(["--strict"]); }); - it('still rejects a legacy flag typed on the command line', () => { - expect(() => assertNoLegacyFlags({ strict: true }, () => 'cli')).toThrow(/--strict/); + it("still rejects a legacy flag typed on the command line", () => { + expect(() => assertNoLegacyFlags({ strict: true }, () => "cli")).toThrow(/--strict/); }); }); -describe('generateSchemaTypes', () => { - it('writes a single file containing the shared surface', async () => { +describe("generateSchemaTypes", () => { + it("writes a single file containing the shared surface", async () => { written.clear(); const result = await generateSchemaTypes({ - space: '295018', - cwd: '/project', - outputDir: '/project/.storyblok/types/295018', - filename: 'storyblok-schema', + space: "295018", + cwd: "/project", + outputDir: "/project/.storyblok/types/295018", + filename: "storyblok-schema", }); - expect(result.files).toEqual(['/project/.storyblok/types/295018/storyblok-schema.d.ts']); - const content = written.get('/project/.storyblok/types/295018/storyblok-schema.d.ts')!; - expect(content).toContain('export type HeroBlockDefinition = {'); - expect(content).toContain('export type PageBlockDefinition = {'); - expect(content).toContain('export type Blocks = HeroBlockDefinition | PageBlockDefinition;'); - expect(content).toContain('export type Block'); + expect(result.files).toEqual(["/project/.storyblok/types/295018/storyblok-schema.d.ts"]); + const content = written.get("/project/.storyblok/types/295018/storyblok-schema.d.ts")!; + expect(content).toContain("export type HeroBlockDefinition = {"); + expect(content).toContain("export type PageBlockDefinition = {"); + expect(content).toContain("export type Blocks = HeroBlockDefinition | PageBlockDefinition;"); + expect(content).toContain("export type Block"); }); - it('reports custom field types that have no registered plugin', async () => { + it("reports custom field types that have no registered plugin", async () => { written.clear(); - const { fetchRemoteSchema } = await import('../../../schema/actions'); + const { fetchRemoteSchema } = await import("../../../schema/actions"); vi.mocked(fetchRemoteSchema).mockResolvedValueOnce({ remote: { components: new Map(), componentFolders: new Map(), datasources: new Map() }, - rawComponents: [{ - id: 1, - name: 'hero', - created_at: '', - updated_at: '', - is_root: false, - is_nestable: true, - schema: { accent: { type: 'custom', field_type: 'storyblok-colorpicker', pos: 0 } }, - }], + rawComponents: [ + { + id: 1, + name: "hero", + created_at: "", + updated_at: "", + is_root: false, + is_nestable: true, + schema: { accent: { type: "custom", field_type: "storyblok-colorpicker", pos: 0 } }, + }, + ], rawComponentFolders: [], rawDatasources: [], } as never); const result = await generateSchemaTypes({ - space: '295018', - cwd: '/project', - outputDir: '/project/.storyblok/types/295018', - filename: 'storyblok-schema', + space: "295018", + cwd: "/project", + outputDir: "/project/.storyblok/types/295018", + filename: "storyblok-schema", }); - expect(result.unmappedFieldTypes).toEqual(['storyblok-colorpicker']); + expect(result.unmappedFieldTypes).toEqual(["storyblok-colorpicker"]); }); - it('sorts blocks by name so regeneration is byte-stable', async () => { + it("sorts blocks by name so regeneration is byte-stable", async () => { written.clear(); - const { fetchRemoteSchema } = await import('../../../schema/actions'); + const { fetchRemoteSchema } = await import("../../../schema/actions"); // Returned in an order MAPI does not promise to keep. vi.mocked(fetchRemoteSchema).mockResolvedValueOnce({ remote: { components: new Map(), componentFolders: new Map(), datasources: new Map() }, rawComponents: [ - { id: 1, name: 'teaser', created_at: '', updated_at: '', is_root: false, is_nestable: true, schema: {} }, - { id: 2, name: 'hero', created_at: '', updated_at: '', is_root: false, is_nestable: true, schema: {} }, + { + id: 1, + name: "teaser", + created_at: "", + updated_at: "", + is_root: false, + is_nestable: true, + schema: {}, + }, + { + id: 2, + name: "hero", + created_at: "", + updated_at: "", + is_root: false, + is_nestable: true, + schema: {}, + }, ], rawComponentFolders: [], rawDatasources: [], } as never); await generateSchemaTypes({ - space: '295018', - cwd: '/project', - outputDir: '/out', - filename: 'storyblok-schema', + space: "295018", + cwd: "/project", + outputDir: "/out", + filename: "storyblok-schema", }); - const content = written.get('/out/storyblok-schema.d.ts')!; - expect(content).toContain('export type Blocks = HeroBlockDefinition | TeaserBlockDefinition;'); - expect(content.indexOf('HeroBlockDefinition = {')).toBeLessThan(content.indexOf('TeaserBlockDefinition = {')); + const content = written.get("/out/storyblok-schema.d.ts")!; + expect(content).toContain("export type Blocks = HeroBlockDefinition | TeaserBlockDefinition;"); + expect(content.indexOf("HeroBlockDefinition = {")).toBeLessThan( + content.indexOf("TeaserBlockDefinition = {"), + ); }); - it('writes one file per block plus the surface file under --separate-files', async () => { + it("writes one file per block plus the surface file under --separate-files", async () => { written.clear(); const result = await generateSchemaTypes({ - space: '295018', - cwd: '/project', - outputDir: '/out', - filename: 'storyblok-schema', + space: "295018", + cwd: "/project", + outputDir: "/out", + filename: "storyblok-schema", separateFiles: true, }); expect(result.files.sort()).toEqual([ - '/out/blocks/hero.d.ts', - '/out/blocks/page.d.ts', - '/out/storyblok-schema.d.ts', + "/out/blocks/hero.d.ts", + "/out/blocks/page.d.ts", + "/out/storyblok-schema.d.ts", ]); - expect(written.get('/out/blocks/hero.d.ts')).toContain('export type HeroBlockDefinition = {'); + expect(written.get("/out/blocks/hero.d.ts")).toContain("export type HeroBlockDefinition = {"); // The surface file imports the block files rather than redeclaring them. - const surface = written.get('/out/storyblok-schema.d.ts')!; - expect(surface).toContain('import type { HeroBlockDefinition } from \'./blocks/hero.js\';'); - expect(surface).toContain('export type Blocks = HeroBlockDefinition | PageBlockDefinition;'); + const surface = written.get("/out/storyblok-schema.d.ts")!; + expect(surface).toContain("import type { HeroBlockDefinition } from './blocks/hero.js';"); + expect(surface).toContain("export type Blocks = HeroBlockDefinition | PageBlockDefinition;"); }); /** @@ -191,106 +217,110 @@ describe('generateSchemaTypes', () => { * exists. `saveToFile` is mocked here, so only the pre-seeded stale files are * on the fake filesystem — enough to assert what pruning removes. */ - it('deletes block files for components that no longer exist', async () => { + it("deletes block files for components that no longer exist", async () => { written.clear(); vol.fromJSON({ - '/out/blocks/hero.d.ts': 'stale but still a real component', - '/out/blocks/page.d.ts': 'stale but still a real component', - '/out/blocks/removed-component.d.ts': 'orphan', + "/out/blocks/hero.d.ts": "stale but still a real component", + "/out/blocks/page.d.ts": "stale but still a real component", + "/out/blocks/removed-component.d.ts": "orphan", }); const result = await generateSchemaTypes({ - space: '295018', - cwd: '/project', - outputDir: '/out', - filename: 'storyblok-schema', + space: "295018", + cwd: "/project", + outputDir: "/out", + filename: "storyblok-schema", separateFiles: true, }); - expect(result.prunedFiles).toEqual(['/out/blocks/removed-component.d.ts']); - expect(Object.keys(vol.toJSON())).not.toContain('/out/blocks/removed-component.d.ts'); + expect(result.prunedFiles).toEqual(["/out/blocks/removed-component.d.ts"]); + expect(Object.keys(vol.toJSON())).not.toContain("/out/blocks/removed-component.d.ts"); // Files for components that still exist are rewritten, not pruned. expect(Object.keys(vol.toJSON())).toEqual( - expect.arrayContaining(['/out/blocks/hero.d.ts', '/out/blocks/page.d.ts']), + expect.arrayContaining(["/out/blocks/hero.d.ts", "/out/blocks/page.d.ts"]), ); }); - it('orphans the whole blocks directory when switching back to single-file output', async () => { + it("orphans the whole blocks directory when switching back to single-file output", async () => { written.clear(); vol.fromJSON({ - '/out/blocks/hero.d.ts': 'from a previous --separate-files run', - '/out/blocks/page.d.ts': 'from a previous --separate-files run', + "/out/blocks/hero.d.ts": "from a previous --separate-files run", + "/out/blocks/page.d.ts": "from a previous --separate-files run", }); const result = await generateSchemaTypes({ - space: '295018', - cwd: '/project', - outputDir: '/out', - filename: 'storyblok-schema', + space: "295018", + cwd: "/project", + outputDir: "/out", + filename: "storyblok-schema", }); - expect(result.prunedFiles.sort()).toEqual(['/out/blocks/hero.d.ts', '/out/blocks/page.d.ts']); + expect(result.prunedFiles.sort()).toEqual(["/out/blocks/hero.d.ts", "/out/blocks/page.d.ts"]); }); // The output directory is shared with the legacy generator and may hold files // this command knows nothing about, so pruning stops at `blocks/`. - it('never touches files outside the blocks directory', async () => { + it("never touches files outside the blocks directory", async () => { written.clear(); vol.fromJSON({ - '/out/storyblok-components.d.ts': 'legacy generator output', - '/out/datasource-types.d.ts': 'legacy generator output', - '/out/blocks/nested/keep.d.ts': 'not a file this renderer writes', + "/out/storyblok-components.d.ts": "legacy generator output", + "/out/datasource-types.d.ts": "legacy generator output", + "/out/blocks/nested/keep.d.ts": "not a file this renderer writes", }); const result = await generateSchemaTypes({ - space: '295018', - cwd: '/project', - outputDir: '/out', - filename: 'storyblok-schema', + space: "295018", + cwd: "/project", + outputDir: "/out", + filename: "storyblok-schema", separateFiles: true, }); expect(result.prunedFiles).toEqual([]); - expect(Object.keys(vol.toJSON())).toEqual(expect.arrayContaining([ - '/out/storyblok-components.d.ts', - '/out/datasource-types.d.ts', - '/out/blocks/nested/keep.d.ts', - ])); + expect(Object.keys(vol.toJSON())).toEqual( + expect.arrayContaining([ + "/out/storyblok-components.d.ts", + "/out/datasource-types.d.ts", + "/out/blocks/nested/keep.d.ts", + ]), + ); }); - it('reports nothing pruned when there is no blocks directory', async () => { + it("reports nothing pruned when there is no blocks directory", async () => { written.clear(); const result = await generateSchemaTypes({ - space: '295018', - cwd: '/project', - outputDir: '/out', - filename: 'storyblok-schema', + space: "295018", + cwd: "/project", + outputDir: "/out", + filename: "storyblok-schema", }); expect(result.prunedFiles).toEqual([]); }); - it('applies --type-prefix and --type-suffix to every exported name', async () => { + it("applies --type-prefix and --type-suffix to every exported name", async () => { written.clear(); await generateSchemaTypes({ - space: '295018', - cwd: '/project', - outputDir: '/out', - filename: 'storyblok-schema', - typePrefix: 'Sb', - typeSuffix: 'Type', + space: "295018", + cwd: "/project", + outputDir: "/out", + filename: "storyblok-schema", + typePrefix: "Sb", + typeSuffix: "Type", }); - const content = written.get('/out/storyblok-schema.d.ts')!; - expect(content).toContain('export type SbBlocksType = SbHeroBlockDefinitionType | SbPageBlockDefinitionType;'); - expect(content).toContain('export type SbBlockType'); - expect(content).toContain('export type SbSchemaType = {'); + const content = written.get("/out/storyblok-schema.d.ts")!; + expect(content).toContain( + "export type SbBlocksType = SbHeroBlockDefinitionType | SbPageBlockDefinitionType;", + ); + expect(content).toContain("export type SbBlockType"); + expect(content).toContain("export type SbSchemaType = {"); }); - it('throws when the space has no components', async () => { - const { fetchRemoteSchema } = await import('../../../schema/actions'); + it("throws when the space has no components", async () => { + const { fetchRemoteSchema } = await import("../../../schema/actions"); vi.mocked(fetchRemoteSchema).mockResolvedValueOnce({ remote: { components: new Map(), componentFolders: new Map(), datasources: new Map() }, rawComponents: [], @@ -298,11 +328,13 @@ describe('generateSchemaTypes', () => { rawDatasources: [], } as never); - await expect(generateSchemaTypes({ - space: '295018', - cwd: '/project', - outputDir: '/out', - filename: 'storyblok-schema', - })).rejects.toThrow(/no components/i); + await expect( + generateSchemaTypes({ + space: "295018", + cwd: "/project", + outputDir: "/out", + filename: "storyblok-schema", + }), + ).rejects.toThrow(/no components/i); }); }); diff --git a/packages/cli/src/commands/types/generate/schema-types/index.ts b/packages/cli/src/commands/types/generate/schema-types/index.ts index 8c5aee435..bad0f7ada 100644 --- a/packages/cli/src/commands/types/generate/schema-types/index.ts +++ b/packages/cli/src/commands/types/generate/schema-types/index.ts @@ -1,15 +1,15 @@ -import { rm } from 'node:fs/promises'; -import { join } from 'pathe'; - -import { CommandError } from '../../../../utils'; -import { fileExists, readDirectory, saveToFile } from '../../../../utils/filesystem'; -import { buildGroupDisplayPathByUuid } from '../../../schema/folders'; -import { fetchRemoteSchema } from '../../../schema/actions'; -import type { GenerateTypesOptions } from '../constants'; -import { toDeclarationFileName } from '../filename'; -import { resolveFieldPluginsSource } from './field-plugins'; -import { renderSchemaTypes, renderSeparateFiles, toRelativeImport } from './render'; -import { serializeBlockDefinition } from './serialize'; +import { rm } from "node:fs/promises"; +import { join } from "pathe"; + +import { CommandError } from "../../../../utils"; +import { fileExists, readDirectory, saveToFile } from "../../../../utils/filesystem"; +import { buildGroupDisplayPathByUuid } from "../../../schema/folders"; +import { fetchRemoteSchema } from "../../../schema/actions"; +import type { GenerateTypesOptions } from "../constants"; +import { toDeclarationFileName } from "../filename"; +import { resolveFieldPluginsSource } from "./field-plugins"; +import { renderSchemaTypes, renderSeparateFiles, toRelativeImport } from "./render"; +import { serializeBlockDefinition } from "./serialize"; /** * Options that only the legacy `json-schema-to-typescript` generator supports, @@ -20,10 +20,14 @@ import { serializeBlockDefinition } from './serialize'; * file selection and has nothing to do with it. */ const LEGACY_ONLY_FLAGS: ReadonlyArray = [ - ['strict', '--strict', 'field optionality comes from each field\'s `required` flag'], - ['customFieldsParser', '--custom-fields-parser', 'custom fields are typed with defineFieldPlugin, see --field-plugins'], - ['compilerOptions', '--compiler-options', 'there is no JSON-schema compiler to configure'], - ['suffix', '--suffix', 'it selects pulled component files, which this generator never reads'], + ["strict", "--strict", "field optionality comes from each field's `required` flag"], + [ + "customFieldsParser", + "--custom-fields-parser", + "custom fields are typed with defineFieldPlugin, see --field-plugins", + ], + ["compilerOptions", "--compiler-options", "there is no JSON-schema compiler to configure"], + ["suffix", "--suffix", "it selects pulled component files, which this generator never reads"], ]; /** @@ -46,8 +50,8 @@ export function assertNoLegacyFlags( getOptionValueSource?: (attributeName: string) => string | undefined, ): string[] { const set = LEGACY_ONLY_FLAGS.filter(([key]) => options[key] !== undefined); - const fromConfig = set.filter(([key]) => getOptionValueSource?.(key) === 'config'); - const used = set.filter(entry => !fromConfig.includes(entry)); + const fromConfig = set.filter(([key]) => getOptionValueSource?.(key) === "config"); + const used = set.filter((entry) => !fromConfig.includes(entry)); if (used.length === 1) { const [, flag, reason] = used[0]!; @@ -56,8 +60,8 @@ export function assertNoLegacyFlags( if (used.length > 1) { throw new CommandError( - `${used.map(([, flag]) => flag).join(', ')} are not supported with --future-schema. ` - + `${used.map(([, flag, reason]) => `${flag}: ${reason}`).join('. ')}.`, + `${used.map(([, flag]) => flag).join(", ")} are not supported with --future-schema. ` + + `${used.map(([, flag, reason]) => `${flag}: ${reason}`).join(". ")}.`, ); } @@ -65,7 +69,7 @@ export function assertNoLegacyFlags( } /** The subdirectory `--separate-files` owns, one declaration file per block. */ -const BLOCKS_DIR = 'blocks'; +const BLOCKS_DIR = "blocks"; /** * Deletes declaration files in `blocks/` that this run did not write. @@ -83,16 +87,21 @@ const BLOCKS_DIR = 'blocks'; * * @returns absolute paths deleted. */ -async function pruneStaleBlockFiles(outputDir: string, outputs: Map): Promise { +async function pruneStaleBlockFiles( + outputDir: string, + outputs: Map, +): Promise { const blocksDir = join(outputDir, BLOCKS_DIR); - if (!await fileExists(blocksDir)) { + if (!(await fileExists(blocksDir))) { return []; } const written = new Set([...outputs.keys()]); const entries = await readDirectory(blocksDir); - const stale = entries.filter(entry => entry.endsWith('.d.ts') && !written.has(`${BLOCKS_DIR}/${entry}`)); + const stale = entries.filter( + (entry) => entry.endsWith(".d.ts") && !written.has(`${BLOCKS_DIR}/${entry}`), + ); const deleted: string[] = []; for (const entry of stale) { @@ -135,7 +144,7 @@ export interface GenerateSchemaTypesResult { */ fieldPlugins: | { resolved: true; path: string } - | { resolved: false; reason: 'missing' | 'unusable'; path: string; nearMissExport?: string }; + | { resolved: false; reason: "missing" | "unusable"; path: string; nearMissExport?: string }; } /** @@ -152,11 +161,13 @@ export async function generateSchemaTypes( const { rawComponents, rawComponentFolders } = await fetchRemoteSchema(options.space); if (rawComponents.length === 0) { - throw new CommandError(`Space ${options.space} has no components, so there are no types to generate.`); + throw new CommandError( + `Space ${options.space} has no components, so there are no types to generate.`, + ); } const displayPathByUuid = buildGroupDisplayPathByUuid(rawComponentFolders); - const knownBlockNames = new Set(rawComponents.map(component => component.name)); + const knownBlockNames = new Set(rawComponents.map((component) => component.name)); // Sorted by name so regeneration is byte-stable: MAPI does not promise a // stable component order, and this file is committed, so an upstream // reordering would otherwise show up as a diff with no semantic change. @@ -164,16 +175,19 @@ export async function generateSchemaTypes( // differently depending on the machine's locale. const blocks = [...rawComponents] .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) - .map(component => serializeBlockDefinition(component, { displayPathByUuid, knownBlockNames })); + .map((component) => + serializeBlockDefinition(component, { displayPathByUuid, knownBlockNames }), + ); const fieldPlugins = await resolveFieldPluginsSource({ cwd: options.cwd, path: options.path, override: options.fieldPluginsPath, }); - const fieldPluginsImportPath = fieldPlugins.kind === 'none' - ? undefined - : toRelativeImport(options.outputDir, fieldPlugins.modulePath); + const fieldPluginsImportPath = + fieldPlugins.kind === "none" + ? undefined + : toRelativeImport(options.outputDir, fieldPlugins.modulePath); const renderOptions = { blocks, @@ -197,21 +211,25 @@ export async function generateSchemaTypes( const prunedFiles = await pruneStaleBlockFiles(options.outputDir, outputs); - const registered = new Set(fieldPlugins.kind === 'none' ? [] : fieldPlugins.fieldTypes); - const unmappedFieldTypes = [...new Set(blocks.flatMap(block => block.customFieldTypes))] - .filter(fieldType => !registered.has(fieldType)); + const registered = new Set(fieldPlugins.kind === "none" ? [] : fieldPlugins.fieldTypes); + const unmappedFieldTypes = [...new Set(blocks.flatMap((block) => block.customFieldTypes))].filter( + (fieldType) => !registered.has(fieldType), + ); return { files, prunedFiles, unmappedFieldTypes, - fieldPlugins: fieldPlugins.kind === 'none' - ? { - resolved: false, - reason: fieldPlugins.reason, - path: fieldPlugins.searchedPath, - ...(fieldPlugins.nearMissExport === undefined ? {} : { nearMissExport: fieldPlugins.nearMissExport }), - } - : { resolved: true, path: fieldPlugins.modulePath }, + fieldPlugins: + fieldPlugins.kind === "none" + ? { + resolved: false, + reason: fieldPlugins.reason, + path: fieldPlugins.searchedPath, + ...(fieldPlugins.nearMissExport === undefined + ? {} + : { nearMissExport: fieldPlugins.nearMissExport }), + } + : { resolved: true, path: fieldPlugins.modulePath }, }; } diff --git a/packages/cli/src/commands/types/generate/schema-types/integration.test.ts b/packages/cli/src/commands/types/generate/schema-types/integration.test.ts index ad38b7730..721969d8e 100644 --- a/packages/cli/src/commands/types/generate/schema-types/integration.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/integration.test.ts @@ -1,12 +1,12 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import { vol } from 'memfs'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { vol } from "memfs"; -import { getMapiClient } from '../../../../api'; -import { generateSchemaTypes } from './index'; +import { getMapiClient } from "../../../../api"; +import { generateSchemaTypes } from "./index"; -const SPACE = '295018'; +const SPACE = "295018"; const server = setupServer(); @@ -14,7 +14,7 @@ const server = setupServer(); // singleton set up by the program's preAction hook. Calling the function // directly (rather than through `command.parseAsync`) bypasses that hook, so // initialize it here the same way: with a token and region. -getMapiClient({ personalAccessToken: 'test-token', region: 'eu' }); +getMapiClient({ personalAccessToken: "test-token", region: "eu" }); const preconditions = { /** @@ -30,37 +30,40 @@ const preconditions = { components: [ { id: 1, - name: 'hero', - created_at: '', - updated_at: '', + name: "hero", + created_at: "", + updated_at: "", is_root: false, is_nestable: true, - component_group_uuid: 'group-1', - schema: { headline: { type: 'text', required: true, pos: 0 } }, + component_group_uuid: "group-1", + schema: { headline: { type: "text", required: true, pos: 0 } }, }, { id: 2, - name: 'page', - created_at: '', - updated_at: '', + name: "page", + created_at: "", + updated_at: "", is_root: true, is_nestable: false, - schema: { body: { type: 'bloks', component_group_whitelist: ['group-1'], pos: 0 } }, + schema: { body: { type: "bloks", component_group_whitelist: ["group-1"], pos: 0 } }, }, ], - })), + }), + ), http.get(`https://mapi.storyblok.com/v1/spaces/${SPACE}/component_groups`, () => HttpResponse.json({ - component_groups: [{ id: 1, uuid: 'group-1', name: 'My Layout', parent_uuid: null }], - })), + component_groups: [{ id: 1, uuid: "group-1", name: "My Layout", parent_uuid: null }], + }), + ), http.get(`https://mapi.storyblok.com/v1/spaces/${SPACE}/datasources`, () => - HttpResponse.json({ datasources: [] })), + HttpResponse.json({ datasources: [] }), + ), ); }, }; -describe('generateSchemaTypes (integration)', () => { - beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +describe("generateSchemaTypes (integration)", () => { + beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => { server.resetHandlers(); @@ -69,29 +72,29 @@ describe('generateSchemaTypes (integration)', () => { afterAll(() => server.close()); - it('should resolve a component group into a folder literal and a group whitelist into an allow list', async () => { + it("should resolve a component group into a folder literal and a group whitelist into an allow list", async () => { preconditions.hasComponentsWithAGroupWhitelist(); - vol.fromJSON({ '/project/package.json': '{}' }); + vol.fromJSON({ "/project/package.json": "{}" }); const result = await generateSchemaTypes({ space: SPACE, - cwd: '/project', - outputDir: '/project/.storyblok/types/295018', - filename: 'storyblok-schema', + cwd: "/project", + outputDir: "/project/.storyblok/types/295018", + filename: "storyblok-schema", }); expect(result.files).toHaveLength(1); - const content = vol.readFileSync(result.files[0], 'utf8') as string; + const content = vol.readFileSync(result.files[0], "utf8") as string; // The component group resolves into a `folder` literal on the block that // belongs to it. - expect(content).toContain('folder: \'My Layout\';'); + expect(content).toContain("folder: 'My Layout';"); // The field's `component_group_whitelist` resolves into an `allow` list // naming the same folder, proving the fetched folders and the fetched // components were joined correctly. - expect(content).toContain('allow: [{ folder: \'My Layout\' }]'); + expect(content).toContain("allow: [{ folder: 'My Layout' }]"); // The shared surface is present: `Blocks` unions both definition types. - expect(content).toContain('export type Blocks = HeroBlockDefinition | PageBlockDefinition;'); + expect(content).toContain("export type Blocks = HeroBlockDefinition | PageBlockDefinition;"); expect(content).toMatchSnapshot(); }); diff --git a/packages/cli/src/commands/types/generate/schema-types/render.test.ts b/packages/cli/src/commands/types/generate/schema-types/render.test.ts index 2ee04de56..8eb35b00c 100644 --- a/packages/cli/src/commands/types/generate/schema-types/render.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/render.test.ts @@ -1,171 +1,199 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from "vitest"; -import { buildNames, renderSchemaTypes, renderSeparateFiles, toRelativeImport } from './render'; +import { buildNames, renderSchemaTypes, renderSeparateFiles, toRelativeImport } from "./render"; const heroBlock = { - componentName: 'hero', - definitionBody: '{\n name: \'hero\';\n fields: [];\n}', + componentName: "hero", + definitionBody: "{\n name: 'hero';\n fields: [];\n}", customFieldTypes: [], }; const teaserListBlock = { - componentName: 'teaser_list', - definitionBody: '{\n name: \'teaser_list\';\n fields: [];\n}', + componentName: "teaser_list", + definitionBody: "{\n name: 'teaser_list';\n fields: [];\n}", customFieldTypes: [], }; -describe('buildNames', () => { - it('derives PascalCase definition names and the shared surface names', () => { - const names = buildNames(['hero', 'teaser_list'], {}); +describe("buildNames", () => { + it("derives PascalCase definition names and the shared surface names", () => { + const names = buildNames(["hero", "teaser_list"], {}); - expect(names.definitionByComponent.get('hero')).toBe('HeroBlockDefinition'); - expect(names.definitionByComponent.get('teaser_list')).toBe('TeaserListBlockDefinition'); - expect(names.blocks).toBe('Blocks'); - expect(names.block).toBe('Block'); - expect(names.schema).toBe('Schema'); + expect(names.definitionByComponent.get("hero")).toBe("HeroBlockDefinition"); + expect(names.definitionByComponent.get("teaser_list")).toBe("TeaserListBlockDefinition"); + expect(names.blocks).toBe("Blocks"); + expect(names.block).toBe("Block"); + expect(names.schema).toBe("Schema"); }); - it('applies prefix and suffix to every emitted name', () => { - const names = buildNames(['hero'], { typePrefix: 'Sb', typeSuffix: 'Type' }); - - expect(names.definitionByComponent.get('hero')).toBe('SbHeroBlockDefinitionType'); - expect(names.blocks).toBe('SbBlocksType'); - expect(names.block).toBe('SbBlockType'); - expect(names.schema).toBe('SbSchemaType'); - expect(names.fieldPlugins).toBe('SbFieldPluginsType'); - expect(names.anyBlock).toBe('SbAnyBlockType'); - expect(names.story).toBe('SbStoryType'); - expect(names.storyMapi).toBe('SbStoryMapiType'); + it("applies prefix and suffix to every emitted name", () => { + const names = buildNames(["hero"], { typePrefix: "Sb", typeSuffix: "Type" }); + + expect(names.definitionByComponent.get("hero")).toBe("SbHeroBlockDefinitionType"); + expect(names.blocks).toBe("SbBlocksType"); + expect(names.block).toBe("SbBlockType"); + expect(names.schema).toBe("SbSchemaType"); + expect(names.fieldPlugins).toBe("SbFieldPluginsType"); + expect(names.anyBlock).toBe("SbAnyBlockType"); + expect(names.story).toBe("SbStoryType"); + expect(names.storyMapi).toBe("SbStoryMapiType"); }); - it('disambiguates components that collapse to the same PascalCase name', () => { - const names = buildNames(['teaser-list', 'teaser_list'], {}); + it("disambiguates components that collapse to the same PascalCase name", () => { + const names = buildNames(["teaser-list", "teaser_list"], {}); - const first = names.definitionByComponent.get('teaser-list'); - const second = names.definitionByComponent.get('teaser_list'); + const first = names.definitionByComponent.get("teaser-list"); + const second = names.definitionByComponent.get("teaser_list"); expect(first).not.toBe(second); - expect([first, second]).toContain('TeaserListBlockDefinition'); + expect([first, second]).toContain("TeaserListBlockDefinition"); }); - it('keeps a component name starting with a digit a valid identifier', () => { - const names = buildNames(['2_col'], {}); + it("keeps a component name starting with a digit a valid identifier", () => { + const names = buildNames(["2_col"], {}); - expect(names.definitionByComponent.get('2_col')).toBe('_2ColBlockDefinition'); + expect(names.definitionByComponent.get("2_col")).toBe("_2ColBlockDefinition"); }); - it('keeps a digit-leading name valid under a prefix and suffix', () => { - const names = buildNames(['2_col'], { typePrefix: 'Sb', typeSuffix: 'Type' }); + it("keeps a digit-leading name valid under a prefix and suffix", () => { + const names = buildNames(["2_col"], { typePrefix: "Sb", typeSuffix: "Type" }); - expect(names.definitionByComponent.get('2_col')).toBe('Sb_2ColBlockDefinitionType'); + expect(names.definitionByComponent.get("2_col")).toBe("Sb_2ColBlockDefinitionType"); }); }); -describe('renderSchemaTypes', () => { - it('emits the definition types and the shared surface', () => { +describe("renderSchemaTypes", () => { + it("emits the definition types and the shared surface", () => { const output = renderSchemaTypes({ blocks: [heroBlock, teaserListBlock], - fieldPlugins: { kind: 'none' }, - space: '295018', + fieldPlugins: { kind: "none" }, + space: "295018", }); - expect(output).toContain('import type { BlockContent, MapiStory as InferStoryMapi, Story as InferStory } from \'@storyblok/schema\';'); - expect(output).not.toContain('InferSchema'); - expect(output).toContain('export type HeroBlockDefinition = {'); - expect(output).toContain('export type Blocks = HeroBlockDefinition | TeaserListBlockDefinition;'); - expect(output).toContain('export type FieldPlugins = Record;'); - expect(output).toContain('export type Schema = { blocks: Blocks; fieldPlugins: FieldPlugins };'); - expect(output).toContain('export type Block = BlockContent, Blocks, FieldPlugins>;'); - expect(output).toContain('export type AnyBlock = BlockContent;'); - expect(output).toContain('export type Story = InferStory;'); - expect(output).toContain('export type StoryMapi = InferStoryMapi;'); + expect(output).toContain( + "import type { BlockContent, MapiStory as InferStoryMapi, Story as InferStory } from '@storyblok/schema';", + ); + expect(output).not.toContain("InferSchema"); + expect(output).toContain("export type HeroBlockDefinition = {"); + expect(output).toContain( + "export type Blocks = HeroBlockDefinition | TeaserListBlockDefinition;", + ); + expect(output).toContain("export type FieldPlugins = Record;"); + expect(output).toContain( + "export type Schema = { blocks: Blocks; fieldPlugins: FieldPlugins };", + ); + expect(output).toContain( + "export type Block = BlockContent, Blocks, FieldPlugins>;", + ); + expect(output).toContain("export type AnyBlock = BlockContent;"); + expect(output).toContain("export type Story = InferStory;"); + expect(output).toContain("export type StoryMapi = InferStoryMapi;"); }); - it('renames internal references consistently with prefixed declarations', () => { + it("renames internal references consistently with prefixed declarations", () => { const output = renderSchemaTypes({ blocks: [heroBlock], - fieldPlugins: { kind: 'none' }, - space: '295018', - typePrefix: 'Storyblok', + fieldPlugins: { kind: "none" }, + space: "295018", + typePrefix: "Storyblok", }); - expect(output).toContain('export type StoryblokBlocks = StoryblokHeroBlockDefinition;'); - expect(output).toContain('export type StoryblokSchema = { blocks: StoryblokBlocks; fieldPlugins: StoryblokFieldPlugins };'); - expect(output).toContain('export type StoryblokBlock = BlockContent, StoryblokBlocks, StoryblokFieldPlugins>;'); + expect(output).toContain("export type StoryblokBlocks = StoryblokHeroBlockDefinition;"); + expect(output).toContain( + "export type StoryblokSchema = { blocks: StoryblokBlocks; fieldPlugins: StoryblokFieldPlugins };", + ); + expect(output).toContain( + "export type StoryblokBlock = BlockContent, StoryblokBlocks, StoryblokFieldPlugins>;", + ); // the @storyblok/schema import aliases are file-internal and must not be renamed - expect(output).toContain('MapiStory as InferStoryMapi'); + expect(output).toContain("MapiStory as InferStoryMapi"); }); - it('derives FieldPlugins from a defineSchema result', () => { + it("derives FieldPlugins from a defineSchema result", () => { const output = renderSchemaTypes({ blocks: [heroBlock], - fieldPlugins: { kind: 'schema', modulePath: '/abs/schema.ts', fieldTypes: ['x'] }, - fieldPluginsImportPath: '../../schema/schema', - space: '295018', + fieldPlugins: { kind: "schema", modulePath: "/abs/schema.ts", fieldTypes: ["x"] }, + fieldPluginsImportPath: "../../schema/schema", + space: "295018", }); - expect(output).toContain('import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from \'@storyblok/schema\';'); - expect(output).toContain('import type { schema as userSchema } from \'../../schema/schema\';'); - expect(output).toContain('export type FieldPlugins = InferSchema[\'fieldPlugins\'];'); + expect(output).toContain( + "import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from '@storyblok/schema';", + ); + expect(output).toContain("import type { schema as userSchema } from '../../schema/schema';"); + expect(output).toContain( + "export type FieldPlugins = InferSchema['fieldPlugins'];", + ); }); - it('derives FieldPlugins from a bare fieldPlugins record', () => { + it("derives FieldPlugins from a bare fieldPlugins record", () => { const output = renderSchemaTypes({ blocks: [heroBlock], - fieldPlugins: { kind: 'record', modulePath: '/abs/plugins.ts', fieldTypes: ['x'] }, - fieldPluginsImportPath: './plugins', - space: '295018', + fieldPlugins: { kind: "record", modulePath: "/abs/plugins.ts", fieldTypes: ["x"] }, + fieldPluginsImportPath: "./plugins", + space: "295018", }); - expect(output).toContain('import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from \'@storyblok/schema\';'); - expect(output).toContain('import type { fieldPlugins as userFieldPlugins } from \'./plugins\';'); - expect(output).toContain('export type FieldPlugins = InferSchema<{ blocks: Record; fieldPlugins: typeof userFieldPlugins }>[\'fieldPlugins\'];'); + expect(output).toContain( + "import type { BlockContent, MapiStory as InferStoryMapi, Schema as InferSchema, Story as InferStory } from '@storyblok/schema';", + ); + expect(output).toContain("import type { fieldPlugins as userFieldPlugins } from './plugins';"); + expect(output).toContain( + "export type FieldPlugins = InferSchema<{ blocks: Record; fieldPlugins: typeof userFieldPlugins }>['fieldPlugins'];", + ); }); - it('declares and references a digit-leading component under its safe name', () => { + it("declares and references a digit-leading component under its safe name", () => { const output = renderSchemaTypes({ - blocks: [{ componentName: '2_col', definitionBody: '{}', customFieldTypes: [] }, heroBlock], - fieldPlugins: { kind: 'none' }, - space: '295018', + blocks: [{ componentName: "2_col", definitionBody: "{}", customFieldTypes: [] }, heroBlock], + fieldPlugins: { kind: "none" }, + space: "295018", }); // A bare `2ColBlockDefinition` is a syntax error that takes the whole file // with it, so the declaration and the union must both use the guarded name. - expect(output).toContain('export type _2ColBlockDefinition = {}'); - expect(output).toContain('export type Blocks = _2ColBlockDefinition | HeroBlockDefinition;'); + expect(output).toContain("export type _2ColBlockDefinition = {}"); + expect(output).toContain("export type Blocks = _2ColBlockDefinition | HeroBlockDefinition;"); expect(output).not.toMatch(/\b2ColBlockDefinition/); }); - it('records the space in the generated header', () => { - const output = renderSchemaTypes({ blocks: [heroBlock], fieldPlugins: { kind: 'none' }, space: '295018' }); + it("records the space in the generated header", () => { + const output = renderSchemaTypes({ + blocks: [heroBlock], + fieldPlugins: { kind: "none" }, + space: "295018", + }); - expect(output.startsWith('// This file was generated by the Storyblok CLI. Do not edit by hand.')).toBe(true); - expect(output).toContain('// Space: 295018'); + expect( + output.startsWith("// This file was generated by the Storyblok CLI. Do not edit by hand."), + ).toBe(true); + expect(output).toContain("// Space: 295018"); }); }); -describe('toRelativeImport', () => { - it('builds a posix relative specifier with a javascript extension', () => { - expect(toRelativeImport('/p/.storyblok/types/1', '/p/.storyblok/schema/schema.ts')).toBe('../../schema/schema.js'); +describe("toRelativeImport", () => { + it("builds a posix relative specifier with a javascript extension", () => { + expect(toRelativeImport("/p/.storyblok/types/1", "/p/.storyblok/schema/schema.ts")).toBe( + "../../schema/schema.js", + ); }); - it('prefixes a sibling path with ./', () => { - expect(toRelativeImport('/p/types', '/p/types/plugins.ts')).toBe('./plugins.js'); + it("prefixes a sibling path with ./", () => { + expect(toRelativeImport("/p/types", "/p/types/plugins.ts")).toBe("./plugins.js"); }); // An extension-less specifier is TS2835 under node16/nodenext in an ESM // package, and the emitted file is generated code the user cannot repair. - it('keeps the specifier resolvable under node16 by never emitting a bare path', () => { - expect(toRelativeImport('/p/types', '/p/plugins.tsx')).toBe('../plugins.js'); - expect(toRelativeImport('/p/types', '/p/plugins.mts')).toBe('../plugins.mjs'); - expect(toRelativeImport('/p/types', '/p/plugins.cts')).toBe('../plugins.cjs'); + it("keeps the specifier resolvable under node16 by never emitting a bare path", () => { + expect(toRelativeImport("/p/types", "/p/plugins.tsx")).toBe("../plugins.js"); + expect(toRelativeImport("/p/types", "/p/plugins.mts")).toBe("../plugins.mjs"); + expect(toRelativeImport("/p/types", "/p/plugins.cts")).toBe("../plugins.cjs"); }); - it('leaves a module that already has a javascript extension alone', () => { - expect(toRelativeImport('/p/types', '/p/plugins.js')).toBe('../plugins.js'); - expect(toRelativeImport('/p/types', '/p/plugins.mjs')).toBe('../plugins.mjs'); + it("leaves a module that already has a javascript extension alone", () => { + expect(toRelativeImport("/p/types", "/p/plugins.js")).toBe("../plugins.js"); + expect(toRelativeImport("/p/types", "/p/plugins.mjs")).toBe("../plugins.mjs"); }); }); -describe('renderSeparateFiles', () => { +describe("renderSeparateFiles", () => { /** * Guards the invariant at the level that matters, rather than per call site: * every relative specifier the renderer emits must carry a JavaScript @@ -175,87 +203,103 @@ describe('renderSeparateFiles', () => { * regressed because the block imports were built inline instead of going * through a helper, so assert over the emitted text. */ - it('emits no extension-less relative import in any file, in either mode', () => { + it("emits no extension-less relative import in any file, in either mode", () => { const options = { blocks: [heroBlock, teaserListBlock], - fieldPlugins: { kind: 'record' as const, modulePath: '/abs/plugins.ts', fieldTypes: ['colorpicker'] }, - fieldPluginsImportPath: '../../schema/plugins.js', - space: '295018', + fieldPlugins: { + kind: "record" as const, + modulePath: "/abs/plugins.ts", + fieldTypes: ["colorpicker"], + }, + fieldPluginsImportPath: "../../schema/plugins.js", + space: "295018", }; const emitted = [ - ...renderSeparateFiles({ ...options, filename: 'storyblok-schema' }).values(), + ...renderSeparateFiles({ ...options, filename: "storyblok-schema" }).values(), renderSchemaTypes(options), ]; - const specifiers = emitted.flatMap(content => [...content.matchAll(/from '(\.[^']*)'/g)].map(match => match[1])); + const specifiers = emitted.flatMap((content) => + [...content.matchAll(/from '(\.[^']*)'/g)].map((match) => match[1]), + ); expect(specifiers.length).toBeGreaterThan(0); - expect(specifiers.filter(specifier => !/\.(?:js|mjs|cjs)$/.test(specifier))).toEqual([]); + expect(specifiers.filter((specifier) => !/\.(?:js|mjs|cjs)$/.test(specifier))).toEqual([]); }); - it('writes one definition per block file and imports them in the main file', () => { + it("writes one definition per block file and imports them in the main file", () => { const files = renderSeparateFiles({ blocks: [heroBlock, teaserListBlock], - fieldPlugins: { kind: 'none' }, - space: '295018', - filename: 'storyblok-schema', + fieldPlugins: { kind: "none" }, + space: "295018", + filename: "storyblok-schema", }); expect([...files.keys()].sort()).toEqual([ - 'blocks/hero.d.ts', - 'blocks/teaser-list.d.ts', - 'storyblok-schema.d.ts', + "blocks/hero.d.ts", + "blocks/teaser-list.d.ts", + "storyblok-schema.d.ts", ]); - expect(files.get('blocks/hero.d.ts')).toContain('export type HeroBlockDefinition = {'); - expect(files.get('storyblok-schema.d.ts')).toContain('import type { HeroBlockDefinition } from \'./blocks/hero.js\';'); - expect(files.get('storyblok-schema.d.ts')).toContain('import type { TeaserListBlockDefinition } from \'./blocks/teaser-list.js\';'); - expect(files.get('storyblok-schema.d.ts')).toContain('export type Blocks = HeroBlockDefinition | TeaserListBlockDefinition;'); - expect(files.get('storyblok-schema.d.ts')).not.toContain('export type HeroBlockDefinition = {'); + expect(files.get("blocks/hero.d.ts")).toContain("export type HeroBlockDefinition = {"); + expect(files.get("storyblok-schema.d.ts")).toContain( + "import type { HeroBlockDefinition } from './blocks/hero.js';", + ); + expect(files.get("storyblok-schema.d.ts")).toContain( + "import type { TeaserListBlockDefinition } from './blocks/teaser-list.js';", + ); + expect(files.get("storyblok-schema.d.ts")).toContain( + "export type Blocks = HeroBlockDefinition | TeaserListBlockDefinition;", + ); + expect(files.get("storyblok-schema.d.ts")).not.toContain("export type HeroBlockDefinition = {"); }); - it('uses the safe name in both the block file and the main file import', () => { + it("uses the safe name in both the block file and the main file import", () => { const files = renderSeparateFiles({ - blocks: [{ componentName: '2_col', definitionBody: '{}', customFieldTypes: [] }], - fieldPlugins: { kind: 'none' }, - space: '295018', - filename: 'storyblok-schema', + blocks: [{ componentName: "2_col", definitionBody: "{}", customFieldTypes: [] }], + fieldPlugins: { kind: "none" }, + space: "295018", + filename: "storyblok-schema", }); - expect(files.get('blocks/2-col.d.ts')).toContain('export type _2ColBlockDefinition = {}'); - expect(files.get('storyblok-schema.d.ts')).toContain('import type { _2ColBlockDefinition } from \'./blocks/2-col.js\';'); - expect(files.get('storyblok-schema.d.ts')).not.toMatch(/\b2ColBlockDefinition/); + expect(files.get("blocks/2-col.d.ts")).toContain("export type _2ColBlockDefinition = {}"); + expect(files.get("storyblok-schema.d.ts")).toContain( + "import type { _2ColBlockDefinition } from './blocks/2-col.js';", + ); + expect(files.get("storyblok-schema.d.ts")).not.toMatch(/\b2ColBlockDefinition/); }); - it('disambiguates block file names that collide after kebab-casing', () => { + it("disambiguates block file names that collide after kebab-casing", () => { const files = renderSeparateFiles({ blocks: [ - { componentName: 'teaser-list', definitionBody: '{}', customFieldTypes: [] }, - { componentName: 'teaser_list', definitionBody: '{}', customFieldTypes: [] }, + { componentName: "teaser-list", definitionBody: "{}", customFieldTypes: [] }, + { componentName: "teaser_list", definitionBody: "{}", customFieldTypes: [] }, ], - fieldPlugins: { kind: 'none' }, - space: '295018', - filename: 'storyblok-schema', + fieldPlugins: { kind: "none" }, + space: "295018", + filename: "storyblok-schema", }); expect([...files.keys()].sort()).toEqual([ - 'blocks/teaser-list-2.d.ts', - 'blocks/teaser-list.d.ts', - 'storyblok-schema.d.ts', + "blocks/teaser-list-2.d.ts", + "blocks/teaser-list.d.ts", + "storyblok-schema.d.ts", ]); }); - it('carries the field-plugins import into the main file only', () => { + it("carries the field-plugins import into the main file only", () => { const files = renderSeparateFiles({ blocks: [heroBlock], - fieldPlugins: { kind: 'record', modulePath: '/abs/plugins.ts', fieldTypes: ['x'] }, - fieldPluginsImportPath: './plugins', - space: '295018', - filename: 'storyblok-schema', + fieldPlugins: { kind: "record", modulePath: "/abs/plugins.ts", fieldTypes: ["x"] }, + fieldPluginsImportPath: "./plugins", + space: "295018", + filename: "storyblok-schema", }); - expect(files.get('storyblok-schema.d.ts')).toContain('import type { fieldPlugins as userFieldPlugins } from \'./plugins\';'); - expect(files.get('blocks/hero.d.ts')).not.toContain('userFieldPlugins'); + expect(files.get("storyblok-schema.d.ts")).toContain( + "import type { fieldPlugins as userFieldPlugins } from './plugins';", + ); + expect(files.get("blocks/hero.d.ts")).not.toContain("userFieldPlugins"); }); }); diff --git a/packages/cli/src/commands/types/generate/schema-types/render.ts b/packages/cli/src/commands/types/generate/schema-types/render.ts index 55551bab2..6081e5049 100644 --- a/packages/cli/src/commands/types/generate/schema-types/render.ts +++ b/packages/cli/src/commands/types/generate/schema-types/render.ts @@ -1,10 +1,15 @@ -import { relative } from 'pathe'; +import { relative } from "pathe"; -import { toPascalCase } from '../../../../utils/format'; -import { componentFileName, resolveFileNames, resolveVarNames, toSafeIdentifier } from '../../../schema/utils'; -import { toDeclarationFileName, toDeclarationImportSpecifier } from '../filename'; -import type { FieldPluginsSource } from './field-plugins'; -import type { SerializedBlock } from './serialize'; +import { toPascalCase } from "../../../../utils/format"; +import { + componentFileName, + resolveFileNames, + resolveVarNames, + toSafeIdentifier, +} from "../../../schema/utils"; +import { toDeclarationFileName, toDeclarationImportSpecifier } from "../filename"; +import type { FieldPluginsSource } from "./field-plugins"; +import type { SerializedBlock } from "./serialize"; /** Prefix/suffix applied to every emitted type name. */ export interface NameOptions { @@ -37,7 +42,7 @@ export interface RenderOptions extends NameOptions { } function decorate(base: string, options: NameOptions): string { - return `${options.typePrefix ?? ''}${base}${options.typeSuffix ?? ''}`; + return `${options.typePrefix ?? ""}${base}${options.typeSuffix ?? ""}`; } /** @@ -51,18 +56,22 @@ function decorate(base: string, options: NameOptions): string { * make the entire emitted file unparseable, not just its own declaration. */ export function buildNames(componentNames: string[], options: NameOptions): EmittedNames { - const bases = resolveVarNames(componentNames, name => toSafeIdentifier(`${toPascalCase(name)}BlockDefinition`)); + const bases = resolveVarNames(componentNames, (name) => + toSafeIdentifier(`${toPascalCase(name)}BlockDefinition`), + ); return { - blocks: decorate('Blocks', options), - schema: decorate('Schema', options), - fieldPlugins: decorate('FieldPlugins', options), - block: decorate('Block', options), - anyBlock: decorate('AnyBlock', options), - story: decorate('Story', options), - storyMapi: decorate('StoryMapi', options), + blocks: decorate("Blocks", options), + schema: decorate("Schema", options), + fieldPlugins: decorate("FieldPlugins", options), + block: decorate("Block", options), + anyBlock: decorate("AnyBlock", options), + story: decorate("Story", options), + storyMapi: decorate("StoryMapi", options), // Assumes component names are unique, which MAPI enforces per space, so two // components can never collapse into the same map entry here. - definitionByComponent: new Map(componentNames.map((name, i) => [name, decorate(bases[i], options)])), + definitionByComponent: new Map( + componentNames.map((name, i) => [name, decorate(bases[i], options)]), + ), }; } @@ -72,9 +81,9 @@ export function buildNames(componentNames: string[], options: NameOptions): Emit * the *output* file even though the module on disk is TypeScript. */ const IMPORT_EXTENSION_BY_SOURCE_EXTENSION: ReadonlyArray = [ - [/\.tsx?$/, '.js'], - [/\.mts$/, '.mjs'], - [/\.cts$/, '.cjs'], + [/\.tsx?$/, ".js"], + [/\.mts$/, ".mjs"], + [/\.cts$/, ".cjs"], ]; /** @@ -92,15 +101,15 @@ export function toRelativeImport(fromDir: string, toFile: string): string { const path = relative(fromDir, toFile); const mapping = IMPORT_EXTENSION_BY_SOURCE_EXTENSION.find(([pattern]) => pattern.test(path)); const specifier = mapping === undefined ? path : path.replace(mapping[0], mapping[1]); - return specifier.startsWith('.') ? specifier : `./${specifier}`; + return specifier.startsWith(".") ? specifier : `./${specifier}`; } /** The file header, identical across single-file and separate-file output. */ export function renderHeader(space: string): string[] { return [ - '// This file was generated by the Storyblok CLI. Do not edit by hand.', + "// This file was generated by the Storyblok CLI. Do not edit by hand.", `// Space: ${space}`, - '', + "", ]; } @@ -113,8 +122,10 @@ export function renderHeader(space: string): string[] { * the user module, so deciding it once is what keeps the file from importing a * name it never references. */ -function usesUserFieldPlugins(options: Pick): boolean { - return options.fieldPlugins.kind !== 'none' && options.fieldPluginsImportPath !== undefined; +function usesUserFieldPlugins( + options: Pick, +): boolean { + return options.fieldPlugins.kind !== "none" && options.fieldPluginsImportPath !== undefined; } /** @@ -123,11 +134,15 @@ function usesUserFieldPlugins(options: Pick): string { - const names = ['BlockContent', 'MapiStory as InferStoryMapi']; - if (usesUserFieldPlugins(options)) { names.push('Schema as InferSchema'); } - names.push('Story as InferStory'); - return `import type { ${names.join(', ')} } from '@storyblok/schema';`; +function renderSchemaImport( + options: Pick, +): string { + const names = ["BlockContent", "MapiStory as InferStoryMapi"]; + if (usesUserFieldPlugins(options)) { + names.push("Schema as InferSchema"); + } + names.push("Story as InferStory"); + return `import type { ${names.join(", ")} } from '@storyblok/schema';`; } /** @@ -139,14 +154,20 @@ function renderSchemaImport(options: Pick`, `never` satisfies * the `Record` constraint, and the wrapper's `blocks` is never read. */ -function renderFieldPlugins(options: RenderOptions, names: EmittedNames): { imports: string[]; declaration: string } { +function renderFieldPlugins( + options: RenderOptions, + names: EmittedNames, +): { imports: string[]; declaration: string } { const { fieldPlugins, fieldPluginsImportPath } = options; if (!usesUserFieldPlugins(options)) { - return { imports: [], declaration: `export type ${names.fieldPlugins} = Record;` }; + return { + imports: [], + declaration: `export type ${names.fieldPlugins} = Record;`, + }; } - if (fieldPlugins.kind === 'schema') { + if (fieldPlugins.kind === "schema") { return { imports: [`import type { schema as userSchema } from '${fieldPluginsImportPath}';`], declaration: `export type ${names.fieldPlugins} = InferSchema['fieldPlugins'];`, @@ -167,27 +188,31 @@ function renderFieldPlugins(options: RenderOptions, names: EmittedNames): { impo * it indexes into are plumbing for the CAPI/MAPI helpers, `withTypes()`, * `BlockContent`, `Story`. */ -function renderSurface(names: EmittedNames, definitionNames: string[], fieldPluginsDeclaration: string): string[] { +function renderSurface( + names: EmittedNames, + definitionNames: string[], + fieldPluginsDeclaration: string, +): string[] { return [ - `export type ${names.blocks} = ${definitionNames.join(' | ')};`, - '', + `export type ${names.blocks} = ${definitionNames.join(" | ")};`, + "", fieldPluginsDeclaration, - '', + "", `export type ${names.schema} = { blocks: ${names.blocks}; fieldPlugins: ${names.fieldPlugins} };`, - '', + "", `export type ${names.block} = BlockContent, ${names.blocks}, ${names.fieldPlugins}>;`, - '', + "", `export type ${names.anyBlock} = BlockContent<${names.blocks}, ${names.blocks}, ${names.fieldPlugins}>;`, - '', + "", `export type ${names.story} = InferStory<${names.blocks}, ${names.fieldPlugins}>;`, `export type ${names.storyMapi} = InferStoryMapi<${names.blocks}, ${names.fieldPlugins}>;`, - '', + "", ]; } /** Renders the whole surface plus every definition type into one file. */ export function renderSchemaTypes(options: RenderOptions): string { - const componentNames = options.blocks.map(block => block.componentName); + const componentNames = options.blocks.map((block) => block.componentName); const names = buildNames(componentNames, options); const fieldPlugins = renderFieldPlugins(options, names); @@ -195,18 +220,20 @@ export function renderSchemaTypes(options: RenderOptions): string { ...renderHeader(options.space), renderSchemaImport(options), ...fieldPlugins.imports, - '', + "", ]; for (const block of options.blocks) { - lines.push(`export type ${names.definitionByComponent.get(block.componentName)!} = ${block.definitionBody};`); - lines.push(''); + lines.push( + `export type ${names.definitionByComponent.get(block.componentName)!} = ${block.definitionBody};`, + ); + lines.push(""); } - const definitionNames = componentNames.map(name => names.definitionByComponent.get(name)!); + const definitionNames = componentNames.map((name) => names.definitionByComponent.get(name)!); lines.push(...renderSurface(names, definitionNames, fieldPlugins.declaration)); - return lines.join('\n'); + return lines.join("\n"); } /** @@ -217,33 +244,41 @@ export function renderSchemaTypes(options: RenderOptions): string { * Returns posix relative paths so the caller can join them onto the output * directory; keys are stable across platforms. */ -export function renderSeparateFiles(options: RenderOptions & { filename: string }): Map { - const componentNames = options.blocks.map(block => block.componentName); +export function renderSeparateFiles( + options: RenderOptions & { filename: string }, +): Map { + const componentNames = options.blocks.map((block) => block.componentName); const names = buildNames(componentNames, options); const fieldPlugins = renderFieldPlugins(options, names); - const fileNames = resolveFileNames(componentNames.map(name => componentFileName(name))); + const fileNames = resolveFileNames(componentNames.map((name) => componentFileName(name))); const files = new Map(); options.blocks.forEach((block, index) => { const typeName = names.definitionByComponent.get(block.componentName)!; - files.set(`blocks/${fileNames[index]}.d.ts`, [ - ...renderHeader(options.space), - `export type ${typeName} = ${block.definitionBody};`, - '', - ].join('\n')); + files.set( + `blocks/${fileNames[index]}.d.ts`, + [ + ...renderHeader(options.space), + `export type ${typeName} = ${block.definitionBody};`, + "", + ].join("\n"), + ); }); - const definitionNames = componentNames.map(name => names.definitionByComponent.get(name)!); + const definitionNames = componentNames.map((name) => names.definitionByComponent.get(name)!); const mainLines = [ ...renderHeader(options.space), renderSchemaImport(options), ...fieldPlugins.imports, - ...definitionNames.map((typeName, index) => `import type { ${typeName} } from './blocks/${toDeclarationImportSpecifier(fileNames[index])}';`), - '', + ...definitionNames.map( + (typeName, index) => + `import type { ${typeName} } from './blocks/${toDeclarationImportSpecifier(fileNames[index])}';`, + ), + "", ...renderSurface(names, definitionNames, fieldPlugins.declaration), ]; - files.set(toDeclarationFileName(options.filename), mainLines.join('\n')); + files.set(toDeclarationFileName(options.filename), mainLines.join("\n")); return files; } diff --git a/packages/cli/src/commands/types/generate/schema-types/serialize.test.ts b/packages/cli/src/commands/types/generate/schema-types/serialize.test.ts index 183f783fe..225c46c6e 100644 --- a/packages/cli/src/commands/types/generate/schema-types/serialize.test.ts +++ b/packages/cli/src/commands/types/generate/schema-types/serialize.test.ts @@ -1,15 +1,15 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from "vitest"; -import type { Component } from '../../../../types'; -import type { SerializeContext } from './serialize'; -import { serializeBlockDefinition } from './serialize'; +import type { Component } from "../../../../types"; +import type { SerializeContext } from "./serialize"; +import { serializeBlockDefinition } from "./serialize"; function component(overrides: Partial = {}): Component { return { id: 1, - name: 'hero', - created_at: '2024-01-01T00:00:00.000Z', - updated_at: '2024-01-02T00:00:00.000Z', + name: "hero", + created_at: "2024-01-01T00:00:00.000Z", + updated_at: "2024-01-02T00:00:00.000Z", is_root: false, is_nestable: true, schema: {}, @@ -21,185 +21,240 @@ function component(overrides: Partial = {}): Component { function context(overrides: Partial = {}): SerializeContext { return { displayPathByUuid: new Map(), - knownBlockNames: new Set(['hero']), + knownBlockNames: new Set(["hero"]), ...overrides, }; } const emptyContext = context(); -describe('serializeBlockDefinition', () => { - it('widens id/created_at/updated_at and keeps name/is_root/is_nestable literal', () => { +describe("serializeBlockDefinition", () => { + it("widens id/created_at/updated_at and keeps name/is_root/is_nestable literal", () => { const result = serializeBlockDefinition(component(), emptyContext); - expect(result.componentName).toBe('hero'); - expect(result.definitionBody).toBe([ - '{', - ' readonly id: number;', - ' created_at: string;', - ' updated_at: string;', - ' name: \'hero\';', - ' is_root: false;', - ' is_nestable: true;', - ' fields: [];', - '}', - ].join('\n')); + expect(result.componentName).toBe("hero"); + expect(result.definitionBody).toBe( + [ + "{", + " readonly id: number;", + " created_at: string;", + " updated_at: string;", + " name: 'hero';", + " is_root: false;", + " is_nestable: true;", + " fields: [];", + "}", + ].join("\n"), + ); }); - it('emits fields ordered by pos, keeping only type-relevant keys', () => { - const result = serializeBlockDefinition(component({ - schema: { - body: { type: 'bloks', pos: 1, description: 'ignored', translatable: true }, - headline: { type: 'text', pos: 0, required: true, default_value: 'ignored' }, - }, - }), emptyContext); + it("emits fields ordered by pos, keeping only type-relevant keys", () => { + const result = serializeBlockDefinition( + component({ + schema: { + body: { type: "bloks", pos: 1, description: "ignored", translatable: true }, + headline: { type: "text", pos: 0, required: true, default_value: "ignored" }, + }, + }), + emptyContext, + ); - expect(result.definitionBody).toContain([ - ' fields: [', - ' { name: \'headline\'; type: \'text\'; required: true },', - ' { name: \'body\'; type: \'bloks\' },', - ' ];', - ].join('\n')); + expect(result.definitionBody).toContain( + [ + " fields: [", + " { name: 'headline'; type: 'text'; required: true },", + " { name: 'body'; type: 'bloks' },", + " ];", + ].join("\n"), + ); }); - it('omits required when it is not true', () => { - const result = serializeBlockDefinition(component({ - schema: { headline: { type: 'text', required: false } }, - }), emptyContext); + it("omits required when it is not true", () => { + const result = serializeBlockDefinition( + component({ + schema: { headline: { type: "text", required: false } }, + }), + emptyContext, + ); - expect(result.definitionBody).toContain('{ name: \'headline\'; type: \'text\' }'); + expect(result.definitionBody).toContain("{ name: 'headline'; type: 'text' }"); }); - it('maps component_whitelist to an allow tuple', () => { - const result = serializeBlockDefinition(component({ - schema: { body: { type: 'bloks', component_whitelist: ['grid', 'teaser'] } }, - }), context({ knownBlockNames: new Set(['hero', 'grid', 'teaser']) })); + it("maps component_whitelist to an allow tuple", () => { + const result = serializeBlockDefinition( + component({ + schema: { body: { type: "bloks", component_whitelist: ["grid", "teaser"] } }, + }), + context({ knownBlockNames: new Set(["hero", "grid", "teaser"]) }), + ); - expect(result.definitionBody).toContain('{ name: \'body\'; type: \'bloks\'; allow: [\'grid\', \'teaser\'] }'); + expect(result.definitionBody).toContain( + "{ name: 'body'; type: 'bloks'; allow: ['grid', 'teaser'] }", + ); }); - it('maps a group whitelist to allow folder entries using display paths', () => { - const result = serializeBlockDefinition(component({ - schema: { body: { type: 'bloks', component_group_whitelist: ['uuid-1'] } }, - }), context({ displayPathByUuid: new Map([['uuid-1', 'My Layout/Heros']]) })); + it("maps a group whitelist to allow folder entries using display paths", () => { + const result = serializeBlockDefinition( + component({ + schema: { body: { type: "bloks", component_group_whitelist: ["uuid-1"] } }, + }), + context({ displayPathByUuid: new Map([["uuid-1", "My Layout/Heros"]]) }), + ); - expect(result.definitionBody).toContain('allow: [{ folder: \'My Layout/Heros\' }]'); + expect(result.definitionBody).toContain("allow: [{ folder: 'My Layout/Heros' }]"); }); - it('omits allow when a group whitelist cannot be resolved', () => { - const result = serializeBlockDefinition(component({ - schema: { body: { type: 'bloks', component_group_whitelist: ['unknown'] } }, - }), emptyContext); + it("omits allow when a group whitelist cannot be resolved", () => { + const result = serializeBlockDefinition( + component({ + schema: { body: { type: "bloks", component_group_whitelist: ["unknown"] } }, + }), + emptyContext, + ); - expect(result.definitionBody).toContain('{ name: \'body\'; type: \'bloks\' }'); - expect(result.definitionBody).not.toContain('allow'); + expect(result.definitionBody).toContain("{ name: 'body'; type: 'bloks' }"); + expect(result.definitionBody).not.toContain("allow"); }); - it('emits the block folder literal from its component group', () => { + it("emits the block folder literal from its component group", () => { const result = serializeBlockDefinition( - component({ component_group_uuid: 'uuid-1' }), - context({ displayPathByUuid: new Map([['uuid-1', 'My Layout']]) }), + component({ component_group_uuid: "uuid-1" }), + context({ displayPathByUuid: new Map([["uuid-1", "My Layout"]]) }), ); - expect(result.definitionBody).toContain(' folder: \'My Layout\';'); + expect(result.definitionBody).toContain(" folder: 'My Layout';"); }); - it('keeps field_type on custom fields and reports it', () => { - const result = serializeBlockDefinition(component({ - schema: { accent: { type: 'custom', field_type: 'storyblok-colorpicker' } }, - }), emptyContext); + it("keeps field_type on custom fields and reports it", () => { + const result = serializeBlockDefinition( + component({ + schema: { accent: { type: "custom", field_type: "storyblok-colorpicker" } }, + }), + emptyContext, + ); - expect(result.definitionBody).toContain('{ name: \'accent\'; type: \'custom\'; field_type: \'storyblok-colorpicker\' }'); - expect(result.customFieldTypes).toEqual(['storyblok-colorpicker']); + expect(result.definitionBody).toContain( + "{ name: 'accent'; type: 'custom'; field_type: 'storyblok-colorpicker' }", + ); + expect(result.customFieldTypes).toEqual(["storyblok-colorpicker"]); }); - it('escapes quotes in names and values', () => { - const result = serializeBlockDefinition(component({ - schema: { 'it\'s': { type: 'text' } }, - }), emptyContext); + it("escapes quotes in names and values", () => { + const result = serializeBlockDefinition( + component({ + schema: { "it's": { type: "text" } }, + }), + emptyContext, + ); - expect(result.definitionBody).toContain('name: \'it\\\'s\''); + expect(result.definitionBody).toContain("name: 'it\\'s'"); }); - it('emits is_nestable true when the wire omits it, and false when explicit', () => { - expect(serializeBlockDefinition(component({ is_nestable: undefined }), emptyContext).definitionBody) - .toContain('is_nestable: true;'); - expect(serializeBlockDefinition(component({ is_nestable: false }), emptyContext).definitionBody) - .toContain('is_nestable: false;'); + it("emits is_nestable true when the wire omits it, and false when explicit", () => { + expect( + serializeBlockDefinition(component({ is_nestable: undefined }), emptyContext).definitionBody, + ).toContain("is_nestable: true;"); + expect( + serializeBlockDefinition(component({ is_nestable: false }), emptyContext).definitionBody, + ).toContain("is_nestable: false;"); }); - it('omits allow when the restriction is switched off, for names and for groups', () => { - const names = serializeBlockDefinition(component({ - schema: { - body: { type: 'bloks', restrict_components: false, component_whitelist: ['grid'] }, - }, - }), context({ knownBlockNames: new Set(['hero', 'grid']) })); + it("omits allow when the restriction is switched off, for names and for groups", () => { + const names = serializeBlockDefinition( + component({ + schema: { + body: { type: "bloks", restrict_components: false, component_whitelist: ["grid"] }, + }, + }), + context({ knownBlockNames: new Set(["hero", "grid"]) }), + ); // The live case: Storyblok strips a stale name whitelist when the flag is // false, but never strips a group whitelist, so this state does reach us. - const groups = serializeBlockDefinition(component({ - schema: { - body: { - type: 'bloks', - restrict_components: false, - restrict_type: 'groups', - component_group_whitelist: ['uuid-1'], + const groups = serializeBlockDefinition( + component({ + schema: { + body: { + type: "bloks", + restrict_components: false, + restrict_type: "groups", + component_group_whitelist: ["uuid-1"], + }, }, - }, - }), context({ displayPathByUuid: new Map([['uuid-1', 'My Layout']]) })); + }), + context({ displayPathByUuid: new Map([["uuid-1", "My Layout"]]) }), + ); - expect(names.definitionBody).not.toContain('allow'); - expect(groups.definitionBody).not.toContain('allow'); + expect(names.definitionBody).not.toContain("allow"); + expect(groups.definitionBody).not.toContain("allow"); }); - it('keeps allow when restrict_components is absent, which the backend enforces', () => { - const result = serializeBlockDefinition(component({ - schema: { body: { type: 'bloks', component_whitelist: ['grid'] } }, - }), context({ knownBlockNames: new Set(['hero', 'grid']) })); + it("keeps allow when restrict_components is absent, which the backend enforces", () => { + const result = serializeBlockDefinition( + component({ + schema: { body: { type: "bloks", component_whitelist: ["grid"] } }, + }), + context({ knownBlockNames: new Set(["hero", "grid"]) }), + ); - expect(result.definitionBody).toContain('allow: [\'grid\']'); + expect(result.definitionBody).toContain("allow: ['grid']"); }); - it('emits allow on richtext but not on other whitelisted field types', () => { - const richtext = serializeBlockDefinition(component({ - schema: { body: { type: 'richtext', component_whitelist: ['grid'] } }, - }), context({ knownBlockNames: new Set(['hero', 'grid']) })); + it("emits allow on richtext but not on other whitelisted field types", () => { + const richtext = serializeBlockDefinition( + component({ + schema: { body: { type: "richtext", component_whitelist: ["grid"] } }, + }), + context({ knownBlockNames: new Set(["hero", "grid"]) }), + ); // A multilink's component_whitelist holds content type names, not block // names, so emitting it would put a misleading list in the output. - const multilink = serializeBlockDefinition(component({ - schema: { link: { type: 'multilink', component_whitelist: ['page'] } }, - }), context({ knownBlockNames: new Set(['hero', 'page']) })); + const multilink = serializeBlockDefinition( + component({ + schema: { link: { type: "multilink", component_whitelist: ["page"] } }, + }), + context({ knownBlockNames: new Set(["hero", "page"]) }), + ); - expect(richtext.definitionBody).toContain('allow: [\'grid\']'); - expect(multilink.definitionBody).toContain('{ name: \'link\'; type: \'multilink\' }'); - expect(multilink.definitionBody).not.toContain('allow'); + expect(richtext.definitionBody).toContain("allow: ['grid']"); + expect(multilink.definitionBody).toContain("{ name: 'link'; type: 'multilink' }"); + expect(multilink.definitionBody).not.toContain("allow"); }); - it('drops allow entries naming a block the space does not have', () => { - const result = serializeBlockDefinition(component({ - schema: { body: { type: 'bloks', component_whitelist: ['grid', 'deleted'] } }, - }), context({ knownBlockNames: new Set(['hero', 'grid']) })); + it("drops allow entries naming a block the space does not have", () => { + const result = serializeBlockDefinition( + component({ + schema: { body: { type: "bloks", component_whitelist: ["grid", "deleted"] } }, + }), + context({ knownBlockNames: new Set(["hero", "grid"]) }), + ); - expect(result.definitionBody).toContain('allow: [\'grid\']'); + expect(result.definitionBody).toContain("allow: ['grid']"); }); - it('omits allow entirely when no whitelisted block still exists', () => { + it("omits allow entirely when no whitelisted block still exists", () => { // An `allow` of only unknown names would resolve the field to `never[]` // through `ApplyAllow`, rejecting every possible value. - const result = serializeBlockDefinition(component({ - schema: { body: { type: 'bloks', component_whitelist: ['deleted', 'gone'] } }, - }), emptyContext); + const result = serializeBlockDefinition( + component({ + schema: { body: { type: "bloks", component_whitelist: ["deleted", "gone"] } }, + }), + emptyContext, + ); - expect(result.definitionBody).toContain('{ name: \'body\'; type: \'bloks\' }'); - expect(result.definitionBody).not.toContain('allow'); + expect(result.definitionBody).toContain("{ name: 'body'; type: 'bloks' }"); + expect(result.definitionBody).not.toContain("allow"); }); - it('keeps tab fields, which resolve to never downstream', () => { - const result = serializeBlockDefinition(component({ - schema: { general: { type: 'tab' } }, - }), emptyContext); + it("keeps tab fields, which resolve to never downstream", () => { + const result = serializeBlockDefinition( + component({ + schema: { general: { type: "tab" } }, + }), + emptyContext, + ); - expect(result.definitionBody).toContain('{ name: \'general\'; type: \'tab\' }'); + expect(result.definitionBody).toContain("{ name: 'general'; type: 'tab' }"); }); }); diff --git a/packages/cli/src/commands/types/generate/schema-types/serialize.ts b/packages/cli/src/commands/types/generate/schema-types/serialize.ts index 085301d0d..a85e5f8bb 100644 --- a/packages/cli/src/commands/types/generate/schema-types/serialize.ts +++ b/packages/cli/src/commands/types/generate/schema-types/serialize.ts @@ -1,6 +1,6 @@ -import type { Component } from '../../../../types'; -import { toDslField } from '../../../schema/to-dsl-field'; -import { INDENT, isRecord, quoteString, sortSchemaByPos } from '../../../schema/utils'; +import type { Component } from "../../../../types"; +import { toDslField } from "../../../schema/to-dsl-field"; +import { INDENT, isRecord, quoteString, sortSchemaByPos } from "../../../schema/utils"; /** Resolution context shared by every block in one generation run. */ export interface SerializeContext { @@ -29,11 +29,11 @@ export interface SerializedBlock { * field whose value is not a record is dropped by the caller's own filter. */ function isFieldRecordMap(value: unknown): value is Record> { - return isRecord(value) && Object.values(value).every(field => isRecord(field)); + return isRecord(value) && Object.values(value).every((field) => isRecord(field)); } /** Field types whose `allow` list the wire actually uses to restrict blocks. */ -const BLOCK_RESTRICTED_FIELD_TYPES = new Set(['bloks', 'richtext']); +const BLOCK_RESTRICTED_FIELD_TYPES = new Set(["bloks", "richtext"]); /** * Serializes one `allow` entry: a bare block name, or a folder reference. Any @@ -41,8 +41,12 @@ const BLOCK_RESTRICTED_FIELD_TYPES = new Set(['bloks', 'richtext']); * is safer than wrong narrowing). */ function serializeAllowEntry(entry: unknown): string | undefined { - if (typeof entry === 'string') { return quoteString(entry); } - if (isRecord(entry) && typeof entry.folder === 'string') { return `{ folder: ${quoteString(entry.folder)} }`; } + if (typeof entry === "string") { + return quoteString(entry); + } + if (isRecord(entry) && typeof entry.folder === "string") { + return `{ folder: ${quoteString(entry.folder)} }`; + } return undefined; } @@ -62,12 +66,16 @@ function serializeAllowEntry(entry: unknown): string | undefined { * are already known-good by the time they arrive here. */ function serializeAllowEntries(allow: unknown[], knownBlockNames: Set): string | undefined { - const known = allow.filter(entry => typeof entry !== 'string' || knownBlockNames.has(entry)); - if (known.length === 0) { return undefined; } + const known = allow.filter((entry) => typeof entry !== "string" || knownBlockNames.has(entry)); + if (known.length === 0) { + return undefined; + } const entries = known.map(serializeAllowEntry); - if (!entries.every((entry): entry is string => entry !== undefined)) { return undefined; } - return `allow: [${entries.join(', ')}]`; + if (!entries.every((entry): entry is string => entry !== undefined)) { + return undefined; + } + return `allow: [${entries.join(", ")}]`; } /** @@ -90,9 +98,14 @@ function serializeAllowEntries(allow: unknown[], knownBlockNames: Set): * key is meaningless, so emitting `allow` there would put a misleading list in * a file the user reads. The type level ignores it either way. */ -function isRestrictedByBlocks(fieldData: Record, dsl: Record): boolean { - if (fieldData.restrict_components === false) { return false; } - return typeof dsl.type === 'string' && BLOCK_RESTRICTED_FIELD_TYPES.has(dsl.type); +function isRestrictedByBlocks( + fieldData: Record, + dsl: Record, +): boolean { + if (fieldData.restrict_components === false) { + return false; + } + return typeof dsl.type === "string" && BLOCK_RESTRICTED_FIELD_TYPES.has(dsl.type); } /** @@ -113,21 +126,28 @@ function serializeField( }); const members = [`name: ${quoteString(fieldName)}`]; - if (typeof dsl.type === 'string') { members.push(`type: ${quoteString(dsl.type)}`); } - if (dsl.required === true) { members.push('required: true'); } + if (typeof dsl.type === "string") { + members.push(`type: ${quoteString(dsl.type)}`); + } + if (dsl.required === true) { + members.push("required: true"); + } if (isRestrictedByBlocks(fieldData, dsl) && Array.isArray(dsl.allow) && dsl.allow.length > 0) { const allow = serializeAllowEntries(dsl.allow, context.knownBlockNames); - if (allow !== undefined) { members.push(allow); } + if (allow !== undefined) { + members.push(allow); + } } - const customFieldType = dsl.type === 'custom' && typeof dsl.field_type === 'string' - ? dsl.field_type - : undefined; - if (customFieldType !== undefined) { members.push(`field_type: ${quoteString(customFieldType)}`); } + const customFieldType = + dsl.type === "custom" && typeof dsl.field_type === "string" ? dsl.field_type : undefined; + if (customFieldType !== undefined) { + members.push(`field_type: ${quoteString(customFieldType)}`); + } return { - code: `{ ${members.join('; ')} }`, + code: `{ ${members.join("; ")} }`, ...(customFieldType === undefined ? {} : { customFieldType }), }; } @@ -142,18 +162,24 @@ function serializeField( * `is_root`, `is_nestable`, `folder`, and `fields` are read by * `BlockContent`/`ApplyAllow`/`RootBlock`, so those stay literal. */ -export function serializeBlockDefinition(component: Component, context: SerializeContext): SerializedBlock { - const lines = ['{']; +export function serializeBlockDefinition( + component: Component, + context: SerializeContext, +): SerializedBlock { + const lines = ["{"]; lines.push(`${INDENT}readonly id: number;`); lines.push(`${INDENT}created_at: string;`); lines.push(`${INDENT}updated_at: string;`); lines.push(`${INDENT}name: ${quoteString(component.name)};`); - lines.push(`${INDENT}is_root: ${component.is_root === true ? 'true' : 'false'};`); - lines.push(`${INDENT}is_nestable: ${component.is_nestable === false ? 'false' : 'true'};`); + lines.push(`${INDENT}is_root: ${component.is_root === true ? "true" : "false"};`); + lines.push(`${INDENT}is_nestable: ${component.is_nestable === false ? "false" : "true"};`); const groupUuid = component.component_group_uuid; - const folderPath = typeof groupUuid === 'string' ? context.displayPathByUuid.get(groupUuid) : undefined; - if (folderPath !== undefined) { lines.push(`${INDENT}folder: ${quoteString(folderPath)};`); } + const folderPath = + typeof groupUuid === "string" ? context.displayPathByUuid.get(groupUuid) : undefined; + if (folderPath !== undefined) { + lines.push(`${INDENT}folder: ${quoteString(folderPath)};`); + } const schema = isFieldRecordMap(component.schema) ? component.schema : {}; const fields = sortSchemaByPos(schema).filter(([, data]) => isRecord(data)); @@ -161,8 +187,7 @@ export function serializeBlockDefinition(component: Component, context: Serializ const customFieldTypes: string[] = []; if (fields.length === 0) { lines.push(`${INDENT}fields: [];`); - } - else { + } else { lines.push(`${INDENT}fields: [`); for (const [fieldName, fieldData] of fields) { const { code, customFieldType } = serializeField(fieldName, fieldData, context); @@ -174,7 +199,7 @@ export function serializeBlockDefinition(component: Component, context: Serializ lines.push(`${INDENT}];`); } - lines.push('}'); + lines.push("}"); - return { componentName: component.name, definitionBody: lines.join('\n'), customFieldTypes }; + return { componentName: component.name, definitionBody: lines.join("\n"), customFieldTypes }; } diff --git a/packages/cli/src/utils/import-module.ts b/packages/cli/src/utils/import-module.ts index 1857c17be..859a8013a 100644 --- a/packages/cli/src/utils/import-module.ts +++ b/packages/cli/src/utils/import-module.ts @@ -11,7 +11,7 @@ * Importing runs the module, so any top-level side effects it has will happen. */ export async function importModule(absolutePath: string): Promise> { - const { createJiti } = await import('jiti'); + const { createJiti } = await import("jiti"); const jiti = createJiti(import.meta.url, { interopDefault: true }); - return await jiti.import(absolutePath) as Record; + return (await jiti.import(absolutePath)) as Record; } diff --git a/packages/schema/src/helpers/layout-fields.test-d.ts b/packages/schema/src/helpers/layout-fields.test-d.ts index 8498ca275..f76c751bf 100644 --- a/packages/schema/src/helpers/layout-fields.test-d.ts +++ b/packages/schema/src/helpers/layout-fields.test-d.ts @@ -1,7 +1,7 @@ -import { describe, expectTypeOf, it } from 'vitest'; -import type { BlockContent, BlockContentInput, PluginFieldValue } from '../generated/types/field'; -import { defineBlock } from './define-block'; -import { defineField } from './define-field'; +import { describe, expectTypeOf, it } from "vitest"; +import type { BlockContent, BlockContentInput, PluginFieldValue } from "../generated/types/field"; +import { defineBlock } from "./define-block"; +import { defineField } from "./define-field"; /** * `tab` and `section` group other fields in the editor UI and carry no value of @@ -9,40 +9,40 @@ import { defineField } from './define-field'; * `key?: null` properties, which put phantom keys in autocomplete on every block * using a tab. */ -describe('layout-only fields', () => { +describe("layout-only fields", () => { const _heroBlock = defineBlock({ - name: 'hero', + name: "hero", fields: [ - defineField('general', { type: 'tab' }), - defineField('divider', { type: 'section' }), - defineField('title', { type: 'text' }), - defineField('headline', { type: 'text', required: true }), + defineField("general", { type: "tab" }), + defineField("divider", { type: "section" }), + defineField("title", { type: "text" }), + defineField("headline", { type: "text", required: true }), ], }); type Content = BlockContent; type ContentInput = BlockContentInput; - it('omits a tab field from the read content type', () => { - expectTypeOf().not.toHaveProperty('general'); + it("omits a tab field from the read content type", () => { + expectTypeOf().not.toHaveProperty("general"); }); - it('omits a section field from the read content type', () => { - expectTypeOf().not.toHaveProperty('divider'); + it("omits a section field from the read content type", () => { + expectTypeOf().not.toHaveProperty("divider"); }); - it('omits layout fields from the write content type too', () => { - expectTypeOf().not.toHaveProperty('general'); - expectTypeOf().not.toHaveProperty('divider'); + it("omits layout fields from the write content type too", () => { + expectTypeOf().not.toHaveProperty("general"); + expectTypeOf().not.toHaveProperty("divider"); }); - it('keeps the value-carrying fields around them', () => { - expectTypeOf().toHaveProperty('title'); - expectTypeOf().toEqualTypeOf(); + it("keeps the value-carrying fields around them", () => { + expectTypeOf().toHaveProperty("title"); + expectTypeOf().toEqualTypeOf(); }); - it('accepts content that omits the layout keys entirely', () => { - const content: Content = { _uid: 'a', component: 'hero', headline: 'Hi' }; + it("accepts content that omits the layout keys entirely", () => { + const content: Content = { _uid: "a", component: "hero", headline: "Hi" }; expectTypeOf(content).toExtend(); }); }); @@ -53,22 +53,22 @@ describe('layout-only fields', () => { * survive — dropping it would silently hide the field instead of typing it * loosely. */ -describe('fields that must not be mistaken for layout fields', () => { +describe("fields that must not be mistaken for layout fields", () => { const _block = defineBlock({ - name: 'widget', + name: "widget", fields: [ - defineField('legacy', { type: 'custom', field_type: 'unregistered-plugin' }), - defineField('items', { type: 'bloks' }), + defineField("legacy", { type: "custom", field_type: "unregistered-plugin" }), + defineField("items", { type: "bloks" }), ], }); type Content = BlockContent; - it('keeps an unregistered custom field, typed loosely', () => { - expectTypeOf>().toEqualTypeOf(); + it("keeps an unregistered custom field, typed loosely", () => { + expectTypeOf>().toEqualTypeOf(); }); - it('keeps a bloks field whose registry resolves to no blocks', () => { - expectTypeOf().toHaveProperty('items'); + it("keeps a bloks field whose registry resolves to no blocks", () => { + expectTypeOf().toHaveProperty("items"); }); }); From 151c405aad26c527b3a44bd3af93137297b3d25d Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 11 Aug 2026 12:00:58 +0200 Subject: [PATCH 33/35] chore(openapi): regenerate the template-derived consumer types 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. --- .../capi-client/src/generated/types/block.ts | 67 +++--- .../capi-client/src/generated/types/field.ts | 212 ++++++++++++------ .../capi-client/src/generated/types/story.ts | 22 +- .../live-preview/src/generated/types/block.ts | 67 +++--- .../live-preview/src/generated/types/field.ts | 212 ++++++++++++------ .../live-preview/src/generated/types/story.ts | 22 +- .../mapi-client/src/generated/types/block.ts | 67 +++--- .../mapi-client/src/generated/types/field.ts | 212 ++++++++++++------ .../src/generated/types/mapi-story.ts | 41 ++-- .../mapi-client/src/generated/types/story.ts | 22 +- packages/schema/src/generated/types/block.ts | 67 +++--- packages/schema/src/generated/types/field.ts | 189 ++++++++++------ .../schema/src/generated/types/mapi-story.ts | 41 ++-- packages/schema/src/generated/types/story.ts | 22 +- 14 files changed, 789 insertions(+), 474 deletions(-) diff --git a/packages/capi-client/src/generated/types/block.ts b/packages/capi-client/src/generated/types/block.ts index 698932b77..f361ba9cf 100644 --- a/packages/capi-client/src/generated/types/block.ts +++ b/packages/capi-client/src/generated/types/block.ts @@ -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` @@ -23,42 +20,46 @@ export type Block< TFields extends BlockFields = BlockFields, TIsRoot extends boolean = boolean, TIsNestable extends boolean = boolean, -> = Override, { - 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, + { + 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 = - Extract; +export type RootBlock = Extract; /** * 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 = - Extract; +export type NestableBlock = Extract< + T, + { is_nestable: true } +>; diff --git a/packages/capi-client/src/generated/types/field.ts b/packages/capi-client/src/generated/types/field.ts index 29182b09b..a6a80abea 100644 --- a/packages/capi-client/src/generated/types/field.ts +++ b/packages/capi-client/src/generated/types/field.ts @@ -10,12 +10,18 @@ import type { PluginFieldValue, RichTextFieldValue, TableFieldValue, -} from './_sources'; -import type { Prettify } from './_utils'; -import type { Block, BlockFields } from './block'; +} from "./_sources"; +import type { Prettify } from "./_utils"; +import type { Block, BlockFields } from "./block"; export type { Field }; -export type { AssetFieldValue, MultilinkFieldValue, PluginFieldValue, RichTextFieldValue, TableFieldValue }; +export type { + AssetFieldValue, + MultilinkFieldValue, + PluginFieldValue, + RichTextFieldValue, + TableFieldValue, +}; /** * @deprecated Use {@link RichTextFieldValue} instead. Will be removed in a future major version. @@ -32,20 +38,65 @@ type NoBlocks = false; /** True when `T` is the un-narrowed base `Block` (i.e. no specific block was supplied). */ type IsBaseBlock = [Block] extends [T] ? true : false; +/** + * True when a field carries no value at all, i.e. its `FieldValue` is `never`. + * + * `tab` and `section` are layout containers the editor UI draws; they group other + * fields and never appear in story content. Their `FieldTypeValueMap` entries are + * `never`, which without this check surfaces them as `key?: null` properties — + * keys no API response ever has, cluttering autocomplete on every block that uses + * a tab. + * + * The tuple wrapping is required: a bare `V extends never` distributes over the + * naked type parameter and never matches. + */ +type HasNoValue = [V] extends [never] ? true : false; + /** * Maps a block's ordered `fields` array to its read content object, splitting - * required (`required: true`) from optional fields. Each `F` is a member of the - * field union, so it provably satisfies `FieldValue`'s `Field` constraint. + * required (`required: true`) from optional fields, and dropping fields that + * carry no value (see {@link HasNoValue}). Each `F` is a member of the field + * union, so it provably satisfies `FieldValue`'s `Field` constraint. */ -type ContentFields> = Prettify< - { [F in TFields[number] as F extends { required: true } ? F['name'] : never]: FieldValue } - & { [F in TFields[number] as F extends { required: true } ? never : F['name']]?: FieldValue | null } +type ContentFields< + TFields extends BlockFields, + TBlocks extends Block | NoBlocks, + TFieldPlugins = Record, +> = Prettify< + { + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } + ? F["name"] + : never]: FieldValue; + } & { + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } + ? never + : F["name"]]?: FieldValue | null; + } >; /** Input (write) variant of {@link ContentFields}, resolving each field via {@link FieldValueInput}. */ -type ContentFieldsInput> = Prettify< - { [F in TFields[number] as F extends { required: true } ? F['name'] : never]: FieldValueInput } - & { [F in TFields[number] as F extends { required: true } ? never : F['name']]?: FieldValueInput | null } +type ContentFieldsInput< + TFields extends BlockFields, + TBlocks extends Block | NoBlocks, + TFieldPlugins = Record, +> = Prettify< + { + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } + ? F["name"] + : never]: FieldValueInput; + } & { + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } + ? never + : F["name"]]?: FieldValueInput | null; + } >; /** @@ -54,27 +105,41 @@ type ContentFieldsInput> = +export type BlockContent< + TBlock extends Block = Block, + TBlocks extends Block | NoBlocks = NoBlocks, + TFieldPlugins = Record, +> = IsBaseBlock extends true ? BlockContentBase - // distribute over each member of the `TBlock` union - : TBlock extends any + : // distribute over each member of the `TBlock` union + TBlock extends any ? Prettify< - { _uid: string; component: TBlock['name']; _editable?: string } - & ContentFields - > + { _uid: string; component: TBlock["name"]; _editable?: string } & ContentFields< + TBlock["fields"], + TBlocks, + TFieldPlugins + > + > : never; /** Input variant of {@link BlockContent} for write operations (creating/updating stories via the MAPI). `_uid` is optional. */ -export type BlockContentInput> = +export type BlockContentInput< + TBlock extends Block = Block, + TBlocks extends Block | NoBlocks = NoBlocks, + TFieldPlugins = Record, +> = IsBaseBlock extends true ? BlockContentInputBase - // distribute over each member of the `TBlock` union - : TBlock extends any + : // distribute over each member of the `TBlock` union + TBlock extends any ? Prettify< - { _uid?: string; component: TBlock['name']; _editable?: string } - & ContentFieldsInput - > + { _uid?: string; component: TBlock["name"]; _editable?: string } & ContentFieldsInput< + TBlock["fields"], + TBlocks, + TFieldPlugins + > + > : never; export type BlocksFieldValue< @@ -84,7 +149,7 @@ export type BlocksFieldValue< > = BlockContent[]; /** Union of all valid Storyblok field type discriminants (e.g., `text`, `bloks`). */ -export type FieldType = Field['type']; +export type FieldType = Field["type"]; interface FieldTypeValueMap { text: string; @@ -112,10 +177,11 @@ interface FieldTypeValueMap { custom: PluginFieldValue; } -type IsNestable = - T extends { is_nestable: false } ? false - : T extends { is_nestable: true } ? true - : true; +type IsNestable = T extends { is_nestable: false } + ? false + : T extends { is_nestable: true } + ? true + : true; type AllowEntry = string | { folder: string }; @@ -130,22 +196,31 @@ type AllowEntry = string | { folder: string }; * string path on both the block's `folder` and the field's `allow`: a shared ref * carries the exact path on both sides, so no drift is possible. */ -type MatchesFolder = - TBlock extends { folder: infer BF extends string } - ? Lowercase extends Lowercase | `${Lowercase}/${string}` ? TBlock : never - : never; +type MatchesFolder = TBlock extends { + folder: infer BF extends string; +} + ? Lowercase extends Lowercase | `${Lowercase}/${string}` + ? TBlock + : never + : never; -type ApplyAllow = TField extends { allow: ReadonlyArray } +type ApplyAllow = TField extends { + allow: ReadonlyArray; +} ? TAllowed extends string - // keep only the registry blocks named in `allow` - ? Extract + ? // keep only the registry blocks named in `allow` + Extract : TAllowed extends { folder: infer F extends string } - // keep registry blocks in the folder (or any nested folder) - ? TBlocks extends any ? MatchesFolder : never + ? // keep registry blocks in the folder (or any nested folder) + TBlocks extends any + ? MatchesFolder + : never + : never + : // no `allow`: distribute over the registry, keeping nestable blocks + TBlocks extends any + ? IsNestable extends true + ? TBlocks : never - // no `allow`: distribute over the registry, keeping nestable blocks - : TBlocks extends any - ? IsNestable extends true ? TBlocks : never : never; /** @@ -154,7 +229,9 @@ type ApplyAllow = TField extends { allow: ReadonlyArray = TField extends { deny: ReadonlyArray } +type ApplyDeny = TField extends { + deny: ReadonlyArray; +} ? Exclude : TBlocks; @@ -170,12 +247,11 @@ type ApplyRestrictions = ApplyDeny = - TField extends { field_type: infer F extends string } - ? F extends keyof TFieldPlugins - ? Prettify - : PluginFieldValue - : PluginFieldValue; +type ResolveCustom = TField extends { field_type: infer F extends string } + ? F extends keyof TFieldPlugins + ? Prettify + : PluginFieldValue + : PluginFieldValue; /** Resolves a field definition to its runtime content value type (read). */ export type FieldValue< @@ -183,17 +259,17 @@ export type FieldValue< TBlocks extends Block | NoBlocks = NoBlocks, TFieldPlugins = Record, > = Prettify< - TField extends { type: 'bloks' } - // guard `never` first: `[never] extends [Block]` is structurally true, so an - // empty registry would otherwise be mistaken for a populated one - ? [TBlocks] extends [never] - ? BlockContentBase[] - : [TBlocks] extends [Block] - ? BlockContent, TBlocks, TFieldPlugins>[] - : BlockContentBase[] - : TField extends { type: 'custom' } + TField extends { type: "bloks" } + ? // guard `never` first: `[never] extends [Block]` is structurally true, so an + // empty registry would otherwise be mistaken for a populated one + [TBlocks] extends [never] + ? BlockContentBase[] + : [TBlocks] extends [Block] + ? BlockContent, TBlocks, TFieldPlugins>[] + : BlockContentBase[] + : TField extends { type: "custom" } ? ResolveCustom - : FieldTypeValueMap[TField['type']] + : FieldTypeValueMap[TField["type"]] >; /** Resolves a field definition to its input value type (write). */ @@ -202,15 +278,15 @@ export type FieldValueInput< TBlocks extends Block | NoBlocks = NoBlocks, TFieldPlugins = Record, > = Prettify< - TField extends { type: 'bloks' } - // guard `never` first: `[never] extends [Block]` is structurally true, so an - // empty registry would otherwise be mistaken for a populated one - ? [TBlocks] extends [never] - ? BlockContentInputBase[] - : [TBlocks] extends [Block] - ? BlockContentInput, TBlocks, TFieldPlugins>[] - : BlockContentInputBase[] - : TField extends { type: 'custom' } + TField extends { type: "bloks" } + ? // guard `never` first: `[never] extends [Block]` is structurally true, so an + // empty registry would otherwise be mistaken for a populated one + [TBlocks] extends [never] + ? BlockContentInputBase[] + : [TBlocks] extends [Block] + ? BlockContentInput, TBlocks, TFieldPlugins>[] + : BlockContentInputBase[] + : TField extends { type: "custom" } ? ResolveCustom - : FieldTypeValueMap[TField['type']] + : FieldTypeValueMap[TField["type"]] >; diff --git a/packages/capi-client/src/generated/types/story.ts b/packages/capi-client/src/generated/types/story.ts index d32a8fa23..6021a2a00 100644 --- a/packages/capi-client/src/generated/types/story.ts +++ b/packages/capi-client/src/generated/types/story.ts @@ -1,10 +1,10 @@ // Generated by @storyblok/openapi-codegen. Do not edit by hand. // Source template lives in tools/openapi-codegen/templates/. -import type { CapiStory as CapiStoryGenerated } from './_sources'; -import type { Override, Prettify } from './_utils'; -import type { Block, RootBlock } from './block'; -import type { BlockContent } from './field'; +import type { CapiStory as CapiStoryGenerated } from "./_sources"; +import type { Override, Prettify } from "./_utils"; +import type { Block, RootBlock } from "./block"; +import type { BlockContent } from "./field"; /** * Registry of all blocks, threaded through to resolve nested `bloks` fields. @@ -27,9 +27,13 @@ export type Story< // caller passed root block(s) directly → use them as the content type [TBlockOrBlocks] extends [RootBlock] ? CapiStoryWithSchemaContent - // caller passed the full block union → derive root blocks, thread the union as the registry - : [TBlocks] extends [NoBlocks] - ? CapiStoryWithSchemaContent, TBlockOrBlocks, TFieldPlugins> - // caller passed both → honour the explicit registry - : CapiStoryWithSchemaContent, TBlocks, TFieldPlugins> + : // caller passed the full block union → derive root blocks, thread the union as the registry + [TBlocks] extends [NoBlocks] + ? CapiStoryWithSchemaContent< + Extract, + TBlockOrBlocks, + TFieldPlugins + > + : // caller passed both → honour the explicit registry + CapiStoryWithSchemaContent, TBlocks, TFieldPlugins> >; diff --git a/packages/live-preview/src/generated/types/block.ts b/packages/live-preview/src/generated/types/block.ts index 698932b77..f361ba9cf 100644 --- a/packages/live-preview/src/generated/types/block.ts +++ b/packages/live-preview/src/generated/types/block.ts @@ -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` @@ -23,42 +20,46 @@ export type Block< TFields extends BlockFields = BlockFields, TIsRoot extends boolean = boolean, TIsNestable extends boolean = boolean, -> = Override, { - 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, + { + 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 = - Extract; +export type RootBlock = Extract; /** * 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 = - Extract; +export type NestableBlock = Extract< + T, + { is_nestable: true } +>; diff --git a/packages/live-preview/src/generated/types/field.ts b/packages/live-preview/src/generated/types/field.ts index 29182b09b..a6a80abea 100644 --- a/packages/live-preview/src/generated/types/field.ts +++ b/packages/live-preview/src/generated/types/field.ts @@ -10,12 +10,18 @@ import type { PluginFieldValue, RichTextFieldValue, TableFieldValue, -} from './_sources'; -import type { Prettify } from './_utils'; -import type { Block, BlockFields } from './block'; +} from "./_sources"; +import type { Prettify } from "./_utils"; +import type { Block, BlockFields } from "./block"; export type { Field }; -export type { AssetFieldValue, MultilinkFieldValue, PluginFieldValue, RichTextFieldValue, TableFieldValue }; +export type { + AssetFieldValue, + MultilinkFieldValue, + PluginFieldValue, + RichTextFieldValue, + TableFieldValue, +}; /** * @deprecated Use {@link RichTextFieldValue} instead. Will be removed in a future major version. @@ -32,20 +38,65 @@ type NoBlocks = false; /** True when `T` is the un-narrowed base `Block` (i.e. no specific block was supplied). */ type IsBaseBlock = [Block] extends [T] ? true : false; +/** + * True when a field carries no value at all, i.e. its `FieldValue` is `never`. + * + * `tab` and `section` are layout containers the editor UI draws; they group other + * fields and never appear in story content. Their `FieldTypeValueMap` entries are + * `never`, which without this check surfaces them as `key?: null` properties — + * keys no API response ever has, cluttering autocomplete on every block that uses + * a tab. + * + * The tuple wrapping is required: a bare `V extends never` distributes over the + * naked type parameter and never matches. + */ +type HasNoValue = [V] extends [never] ? true : false; + /** * Maps a block's ordered `fields` array to its read content object, splitting - * required (`required: true`) from optional fields. Each `F` is a member of the - * field union, so it provably satisfies `FieldValue`'s `Field` constraint. + * required (`required: true`) from optional fields, and dropping fields that + * carry no value (see {@link HasNoValue}). Each `F` is a member of the field + * union, so it provably satisfies `FieldValue`'s `Field` constraint. */ -type ContentFields> = Prettify< - { [F in TFields[number] as F extends { required: true } ? F['name'] : never]: FieldValue } - & { [F in TFields[number] as F extends { required: true } ? never : F['name']]?: FieldValue | null } +type ContentFields< + TFields extends BlockFields, + TBlocks extends Block | NoBlocks, + TFieldPlugins = Record, +> = Prettify< + { + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } + ? F["name"] + : never]: FieldValue; + } & { + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } + ? never + : F["name"]]?: FieldValue | null; + } >; /** Input (write) variant of {@link ContentFields}, resolving each field via {@link FieldValueInput}. */ -type ContentFieldsInput> = Prettify< - { [F in TFields[number] as F extends { required: true } ? F['name'] : never]: FieldValueInput } - & { [F in TFields[number] as F extends { required: true } ? never : F['name']]?: FieldValueInput | null } +type ContentFieldsInput< + TFields extends BlockFields, + TBlocks extends Block | NoBlocks, + TFieldPlugins = Record, +> = Prettify< + { + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } + ? F["name"] + : never]: FieldValueInput; + } & { + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } + ? never + : F["name"]]?: FieldValueInput | null; + } >; /** @@ -54,27 +105,41 @@ type ContentFieldsInput> = +export type BlockContent< + TBlock extends Block = Block, + TBlocks extends Block | NoBlocks = NoBlocks, + TFieldPlugins = Record, +> = IsBaseBlock extends true ? BlockContentBase - // distribute over each member of the `TBlock` union - : TBlock extends any + : // distribute over each member of the `TBlock` union + TBlock extends any ? Prettify< - { _uid: string; component: TBlock['name']; _editable?: string } - & ContentFields - > + { _uid: string; component: TBlock["name"]; _editable?: string } & ContentFields< + TBlock["fields"], + TBlocks, + TFieldPlugins + > + > : never; /** Input variant of {@link BlockContent} for write operations (creating/updating stories via the MAPI). `_uid` is optional. */ -export type BlockContentInput> = +export type BlockContentInput< + TBlock extends Block = Block, + TBlocks extends Block | NoBlocks = NoBlocks, + TFieldPlugins = Record, +> = IsBaseBlock extends true ? BlockContentInputBase - // distribute over each member of the `TBlock` union - : TBlock extends any + : // distribute over each member of the `TBlock` union + TBlock extends any ? Prettify< - { _uid?: string; component: TBlock['name']; _editable?: string } - & ContentFieldsInput - > + { _uid?: string; component: TBlock["name"]; _editable?: string } & ContentFieldsInput< + TBlock["fields"], + TBlocks, + TFieldPlugins + > + > : never; export type BlocksFieldValue< @@ -84,7 +149,7 @@ export type BlocksFieldValue< > = BlockContent[]; /** Union of all valid Storyblok field type discriminants (e.g., `text`, `bloks`). */ -export type FieldType = Field['type']; +export type FieldType = Field["type"]; interface FieldTypeValueMap { text: string; @@ -112,10 +177,11 @@ interface FieldTypeValueMap { custom: PluginFieldValue; } -type IsNestable = - T extends { is_nestable: false } ? false - : T extends { is_nestable: true } ? true - : true; +type IsNestable = T extends { is_nestable: false } + ? false + : T extends { is_nestable: true } + ? true + : true; type AllowEntry = string | { folder: string }; @@ -130,22 +196,31 @@ type AllowEntry = string | { folder: string }; * string path on both the block's `folder` and the field's `allow`: a shared ref * carries the exact path on both sides, so no drift is possible. */ -type MatchesFolder = - TBlock extends { folder: infer BF extends string } - ? Lowercase extends Lowercase | `${Lowercase}/${string}` ? TBlock : never - : never; +type MatchesFolder = TBlock extends { + folder: infer BF extends string; +} + ? Lowercase extends Lowercase | `${Lowercase}/${string}` + ? TBlock + : never + : never; -type ApplyAllow = TField extends { allow: ReadonlyArray } +type ApplyAllow = TField extends { + allow: ReadonlyArray; +} ? TAllowed extends string - // keep only the registry blocks named in `allow` - ? Extract + ? // keep only the registry blocks named in `allow` + Extract : TAllowed extends { folder: infer F extends string } - // keep registry blocks in the folder (or any nested folder) - ? TBlocks extends any ? MatchesFolder : never + ? // keep registry blocks in the folder (or any nested folder) + TBlocks extends any + ? MatchesFolder + : never + : never + : // no `allow`: distribute over the registry, keeping nestable blocks + TBlocks extends any + ? IsNestable extends true + ? TBlocks : never - // no `allow`: distribute over the registry, keeping nestable blocks - : TBlocks extends any - ? IsNestable extends true ? TBlocks : never : never; /** @@ -154,7 +229,9 @@ type ApplyAllow = TField extends { allow: ReadonlyArray = TField extends { deny: ReadonlyArray } +type ApplyDeny = TField extends { + deny: ReadonlyArray; +} ? Exclude : TBlocks; @@ -170,12 +247,11 @@ type ApplyRestrictions = ApplyDeny = - TField extends { field_type: infer F extends string } - ? F extends keyof TFieldPlugins - ? Prettify - : PluginFieldValue - : PluginFieldValue; +type ResolveCustom = TField extends { field_type: infer F extends string } + ? F extends keyof TFieldPlugins + ? Prettify + : PluginFieldValue + : PluginFieldValue; /** Resolves a field definition to its runtime content value type (read). */ export type FieldValue< @@ -183,17 +259,17 @@ export type FieldValue< TBlocks extends Block | NoBlocks = NoBlocks, TFieldPlugins = Record, > = Prettify< - TField extends { type: 'bloks' } - // guard `never` first: `[never] extends [Block]` is structurally true, so an - // empty registry would otherwise be mistaken for a populated one - ? [TBlocks] extends [never] - ? BlockContentBase[] - : [TBlocks] extends [Block] - ? BlockContent, TBlocks, TFieldPlugins>[] - : BlockContentBase[] - : TField extends { type: 'custom' } + TField extends { type: "bloks" } + ? // guard `never` first: `[never] extends [Block]` is structurally true, so an + // empty registry would otherwise be mistaken for a populated one + [TBlocks] extends [never] + ? BlockContentBase[] + : [TBlocks] extends [Block] + ? BlockContent, TBlocks, TFieldPlugins>[] + : BlockContentBase[] + : TField extends { type: "custom" } ? ResolveCustom - : FieldTypeValueMap[TField['type']] + : FieldTypeValueMap[TField["type"]] >; /** Resolves a field definition to its input value type (write). */ @@ -202,15 +278,15 @@ export type FieldValueInput< TBlocks extends Block | NoBlocks = NoBlocks, TFieldPlugins = Record, > = Prettify< - TField extends { type: 'bloks' } - // guard `never` first: `[never] extends [Block]` is structurally true, so an - // empty registry would otherwise be mistaken for a populated one - ? [TBlocks] extends [never] - ? BlockContentInputBase[] - : [TBlocks] extends [Block] - ? BlockContentInput, TBlocks, TFieldPlugins>[] - : BlockContentInputBase[] - : TField extends { type: 'custom' } + TField extends { type: "bloks" } + ? // guard `never` first: `[never] extends [Block]` is structurally true, so an + // empty registry would otherwise be mistaken for a populated one + [TBlocks] extends [never] + ? BlockContentInputBase[] + : [TBlocks] extends [Block] + ? BlockContentInput, TBlocks, TFieldPlugins>[] + : BlockContentInputBase[] + : TField extends { type: "custom" } ? ResolveCustom - : FieldTypeValueMap[TField['type']] + : FieldTypeValueMap[TField["type"]] >; diff --git a/packages/live-preview/src/generated/types/story.ts b/packages/live-preview/src/generated/types/story.ts index d32a8fa23..6021a2a00 100644 --- a/packages/live-preview/src/generated/types/story.ts +++ b/packages/live-preview/src/generated/types/story.ts @@ -1,10 +1,10 @@ // Generated by @storyblok/openapi-codegen. Do not edit by hand. // Source template lives in tools/openapi-codegen/templates/. -import type { CapiStory as CapiStoryGenerated } from './_sources'; -import type { Override, Prettify } from './_utils'; -import type { Block, RootBlock } from './block'; -import type { BlockContent } from './field'; +import type { CapiStory as CapiStoryGenerated } from "./_sources"; +import type { Override, Prettify } from "./_utils"; +import type { Block, RootBlock } from "./block"; +import type { BlockContent } from "./field"; /** * Registry of all blocks, threaded through to resolve nested `bloks` fields. @@ -27,9 +27,13 @@ export type Story< // caller passed root block(s) directly → use them as the content type [TBlockOrBlocks] extends [RootBlock] ? CapiStoryWithSchemaContent - // caller passed the full block union → derive root blocks, thread the union as the registry - : [TBlocks] extends [NoBlocks] - ? CapiStoryWithSchemaContent, TBlockOrBlocks, TFieldPlugins> - // caller passed both → honour the explicit registry - : CapiStoryWithSchemaContent, TBlocks, TFieldPlugins> + : // caller passed the full block union → derive root blocks, thread the union as the registry + [TBlocks] extends [NoBlocks] + ? CapiStoryWithSchemaContent< + Extract, + TBlockOrBlocks, + TFieldPlugins + > + : // caller passed both → honour the explicit registry + CapiStoryWithSchemaContent, TBlocks, TFieldPlugins> >; diff --git a/packages/mapi-client/src/generated/types/block.ts b/packages/mapi-client/src/generated/types/block.ts index 698932b77..f361ba9cf 100644 --- a/packages/mapi-client/src/generated/types/block.ts +++ b/packages/mapi-client/src/generated/types/block.ts @@ -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` @@ -23,42 +20,46 @@ export type Block< TFields extends BlockFields = BlockFields, TIsRoot extends boolean = boolean, TIsNestable extends boolean = boolean, -> = Override, { - 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, + { + 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 = - Extract; +export type RootBlock = Extract; /** * 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 = - Extract; +export type NestableBlock = Extract< + T, + { is_nestable: true } +>; diff --git a/packages/mapi-client/src/generated/types/field.ts b/packages/mapi-client/src/generated/types/field.ts index 29182b09b..a6a80abea 100644 --- a/packages/mapi-client/src/generated/types/field.ts +++ b/packages/mapi-client/src/generated/types/field.ts @@ -10,12 +10,18 @@ import type { PluginFieldValue, RichTextFieldValue, TableFieldValue, -} from './_sources'; -import type { Prettify } from './_utils'; -import type { Block, BlockFields } from './block'; +} from "./_sources"; +import type { Prettify } from "./_utils"; +import type { Block, BlockFields } from "./block"; export type { Field }; -export type { AssetFieldValue, MultilinkFieldValue, PluginFieldValue, RichTextFieldValue, TableFieldValue }; +export type { + AssetFieldValue, + MultilinkFieldValue, + PluginFieldValue, + RichTextFieldValue, + TableFieldValue, +}; /** * @deprecated Use {@link RichTextFieldValue} instead. Will be removed in a future major version. @@ -32,20 +38,65 @@ type NoBlocks = false; /** True when `T` is the un-narrowed base `Block` (i.e. no specific block was supplied). */ type IsBaseBlock = [Block] extends [T] ? true : false; +/** + * True when a field carries no value at all, i.e. its `FieldValue` is `never`. + * + * `tab` and `section` are layout containers the editor UI draws; they group other + * fields and never appear in story content. Their `FieldTypeValueMap` entries are + * `never`, which without this check surfaces them as `key?: null` properties — + * keys no API response ever has, cluttering autocomplete on every block that uses + * a tab. + * + * The tuple wrapping is required: a bare `V extends never` distributes over the + * naked type parameter and never matches. + */ +type HasNoValue = [V] extends [never] ? true : false; + /** * Maps a block's ordered `fields` array to its read content object, splitting - * required (`required: true`) from optional fields. Each `F` is a member of the - * field union, so it provably satisfies `FieldValue`'s `Field` constraint. + * required (`required: true`) from optional fields, and dropping fields that + * carry no value (see {@link HasNoValue}). Each `F` is a member of the field + * union, so it provably satisfies `FieldValue`'s `Field` constraint. */ -type ContentFields> = Prettify< - { [F in TFields[number] as F extends { required: true } ? F['name'] : never]: FieldValue } - & { [F in TFields[number] as F extends { required: true } ? never : F['name']]?: FieldValue | null } +type ContentFields< + TFields extends BlockFields, + TBlocks extends Block | NoBlocks, + TFieldPlugins = Record, +> = Prettify< + { + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } + ? F["name"] + : never]: FieldValue; + } & { + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } + ? never + : F["name"]]?: FieldValue | null; + } >; /** Input (write) variant of {@link ContentFields}, resolving each field via {@link FieldValueInput}. */ -type ContentFieldsInput> = Prettify< - { [F in TFields[number] as F extends { required: true } ? F['name'] : never]: FieldValueInput } - & { [F in TFields[number] as F extends { required: true } ? never : F['name']]?: FieldValueInput | null } +type ContentFieldsInput< + TFields extends BlockFields, + TBlocks extends Block | NoBlocks, + TFieldPlugins = Record, +> = Prettify< + { + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } + ? F["name"] + : never]: FieldValueInput; + } & { + [F in TFields[number] as HasNoValue> extends true + ? never + : F extends { required: true } + ? never + : F["name"]]?: FieldValueInput | null; + } >; /** @@ -54,27 +105,41 @@ type ContentFieldsInput> = +export type BlockContent< + TBlock extends Block = Block, + TBlocks extends Block | NoBlocks = NoBlocks, + TFieldPlugins = Record, +> = IsBaseBlock extends true ? BlockContentBase - // distribute over each member of the `TBlock` union - : TBlock extends any + : // distribute over each member of the `TBlock` union + TBlock extends any ? Prettify< - { _uid: string; component: TBlock['name']; _editable?: string } - & ContentFields - > + { _uid: string; component: TBlock["name"]; _editable?: string } & ContentFields< + TBlock["fields"], + TBlocks, + TFieldPlugins + > + > : never; /** Input variant of {@link BlockContent} for write operations (creating/updating stories via the MAPI). `_uid` is optional. */ -export type BlockContentInput> = +export type BlockContentInput< + TBlock extends Block = Block, + TBlocks extends Block | NoBlocks = NoBlocks, + TFieldPlugins = Record, +> = IsBaseBlock extends true ? BlockContentInputBase - // distribute over each member of the `TBlock` union - : TBlock extends any + : // distribute over each member of the `TBlock` union + TBlock extends any ? Prettify< - { _uid?: string; component: TBlock['name']; _editable?: string } - & ContentFieldsInput - > + { _uid?: string; component: TBlock["name"]; _editable?: string } & ContentFieldsInput< + TBlock["fields"], + TBlocks, + TFieldPlugins + > + > : never; export type BlocksFieldValue< @@ -84,7 +149,7 @@ export type BlocksFieldValue< > = BlockContent[]; /** Union of all valid Storyblok field type discriminants (e.g., `text`, `bloks`). */ -export type FieldType = Field['type']; +export type FieldType = Field["type"]; interface FieldTypeValueMap { text: string; @@ -112,10 +177,11 @@ interface FieldTypeValueMap { custom: PluginFieldValue; } -type IsNestable = - T extends { is_nestable: false } ? false - : T extends { is_nestable: true } ? true - : true; +type IsNestable = T extends { is_nestable: false } + ? false + : T extends { is_nestable: true } + ? true + : true; type AllowEntry = string | { folder: string }; @@ -130,22 +196,31 @@ type AllowEntry = string | { folder: string }; * string path on both the block's `folder` and the field's `allow`: a shared ref * carries the exact path on both sides, so no drift is possible. */ -type MatchesFolder = - TBlock extends { folder: infer BF extends string } - ? Lowercase extends Lowercase | `${Lowercase}/${string}` ? TBlock : never - : never; +type MatchesFolder = TBlock extends { + folder: infer BF extends string; +} + ? Lowercase extends Lowercase | `${Lowercase}/${string}` + ? TBlock + : never + : never; -type ApplyAllow = TField extends { allow: ReadonlyArray } +type ApplyAllow = TField extends { + allow: ReadonlyArray; +} ? TAllowed extends string - // keep only the registry blocks named in `allow` - ? Extract + ? // keep only the registry blocks named in `allow` + Extract : TAllowed extends { folder: infer F extends string } - // keep registry blocks in the folder (or any nested folder) - ? TBlocks extends any ? MatchesFolder : never + ? // keep registry blocks in the folder (or any nested folder) + TBlocks extends any + ? MatchesFolder + : never + : never + : // no `allow`: distribute over the registry, keeping nestable blocks + TBlocks extends any + ? IsNestable extends true + ? TBlocks : never - // no `allow`: distribute over the registry, keeping nestable blocks - : TBlocks extends any - ? IsNestable extends true ? TBlocks : never : never; /** @@ -154,7 +229,9 @@ type ApplyAllow = TField extends { allow: ReadonlyArray = TField extends { deny: ReadonlyArray } +type ApplyDeny = TField extends { + deny: ReadonlyArray; +} ? Exclude : TBlocks; @@ -170,12 +247,11 @@ type ApplyRestrictions = ApplyDeny = - TField extends { field_type: infer F extends string } - ? F extends keyof TFieldPlugins - ? Prettify - : PluginFieldValue - : PluginFieldValue; +type ResolveCustom = TField extends { field_type: infer F extends string } + ? F extends keyof TFieldPlugins + ? Prettify + : PluginFieldValue + : PluginFieldValue; /** Resolves a field definition to its runtime content value type (read). */ export type FieldValue< @@ -183,17 +259,17 @@ export type FieldValue< TBlocks extends Block | NoBlocks = NoBlocks, TFieldPlugins = Record, > = Prettify< - TField extends { type: 'bloks' } - // guard `never` first: `[never] extends [Block]` is structurally true, so an - // empty registry would otherwise be mistaken for a populated one - ? [TBlocks] extends [never] - ? BlockContentBase[] - : [TBlocks] extends [Block] - ? BlockContent, TBlocks, TFieldPlugins>[] - : BlockContentBase[] - : TField extends { type: 'custom' } + TField extends { type: "bloks" } + ? // guard `never` first: `[never] extends [Block]` is structurally true, so an + // empty registry would otherwise be mistaken for a populated one + [TBlocks] extends [never] + ? BlockContentBase[] + : [TBlocks] extends [Block] + ? BlockContent, TBlocks, TFieldPlugins>[] + : BlockContentBase[] + : TField extends { type: "custom" } ? ResolveCustom - : FieldTypeValueMap[TField['type']] + : FieldTypeValueMap[TField["type"]] >; /** Resolves a field definition to its input value type (write). */ @@ -202,15 +278,15 @@ export type FieldValueInput< TBlocks extends Block | NoBlocks = NoBlocks, TFieldPlugins = Record, > = Prettify< - TField extends { type: 'bloks' } - // guard `never` first: `[never] extends [Block]` is structurally true, so an - // empty registry would otherwise be mistaken for a populated one - ? [TBlocks] extends [never] - ? BlockContentInputBase[] - : [TBlocks] extends [Block] - ? BlockContentInput, TBlocks, TFieldPlugins>[] - : BlockContentInputBase[] - : TField extends { type: 'custom' } + TField extends { type: "bloks" } + ? // guard `never` first: `[never] extends [Block]` is structurally true, so an + // empty registry would otherwise be mistaken for a populated one + [TBlocks] extends [never] + ? BlockContentInputBase[] + : [TBlocks] extends [Block] + ? BlockContentInput, TBlocks, TFieldPlugins>[] + : BlockContentInputBase[] + : TField extends { type: "custom" } ? ResolveCustom - : FieldTypeValueMap[TField['type']] + : FieldTypeValueMap[TField["type"]] >; diff --git a/packages/mapi-client/src/generated/types/mapi-story.ts b/packages/mapi-client/src/generated/types/mapi-story.ts index 9a3344ce8..6dd716463 100644 --- a/packages/mapi-client/src/generated/types/mapi-story.ts +++ b/packages/mapi-client/src/generated/types/mapi-story.ts @@ -5,10 +5,10 @@ import type { MapiStory as MapiStoryGenerated, StoryCreate as StoryCreateGenerated, StoryUpdate as StoryUpdateGenerated, -} from './_sources'; -import type { Override, Prettify } from './_utils'; -import type { Block, RootBlock } from './block'; -import type { BlockContent, BlockContentInput } from './field'; +} from "./_sources"; +import type { Override, Prettify } from "./_utils"; +import type { Block, RootBlock } from "./block"; +import type { BlockContent, BlockContentInput } from "./field"; /** * Registry of all blocks, threaded through to resolve nested `bloks` fields. @@ -28,11 +28,14 @@ type MapiStoryWithSchemaContent< TFieldPlugins = Record, > = RootBlock extends TBlock ? TStory - : Override - : BlockContent; - }>; + : Override< + TStory, + { + content: TStory extends StoryCreateGenerated | StoryUpdateGenerated + ? BlockContentInput + : BlockContent; + } + >; type MakeMapiStory< TStory extends MapiStoryGenerated | StoryCreateGenerated | StoryUpdateGenerated, @@ -43,11 +46,21 @@ type MakeMapiStory< // caller passed root block(s) directly → use them as the content type [TBlockOrBlocks] extends [RootBlock] ? MapiStoryWithSchemaContent - // caller passed the full block union → derive root blocks, thread the union as the registry - : [TBlocks] extends [NoBlocks] - ? MapiStoryWithSchemaContent, TBlockOrBlocks, TFieldPlugins> - // caller passed both → honour the explicit registry - : MapiStoryWithSchemaContent, TBlocks, TFieldPlugins> + : // caller passed the full block union → derive root blocks, thread the union as the registry + [TBlocks] extends [NoBlocks] + ? MapiStoryWithSchemaContent< + TStory, + Extract, + TBlockOrBlocks, + TFieldPlugins + > + : // caller passed both → honour the explicit registry + MapiStoryWithSchemaContent< + TStory, + Extract, + TBlocks, + TFieldPlugins + > >; /** A Storyblok MAPI story. */ diff --git a/packages/mapi-client/src/generated/types/story.ts b/packages/mapi-client/src/generated/types/story.ts index d32a8fa23..6021a2a00 100644 --- a/packages/mapi-client/src/generated/types/story.ts +++ b/packages/mapi-client/src/generated/types/story.ts @@ -1,10 +1,10 @@ // Generated by @storyblok/openapi-codegen. Do not edit by hand. // Source template lives in tools/openapi-codegen/templates/. -import type { CapiStory as CapiStoryGenerated } from './_sources'; -import type { Override, Prettify } from './_utils'; -import type { Block, RootBlock } from './block'; -import type { BlockContent } from './field'; +import type { CapiStory as CapiStoryGenerated } from "./_sources"; +import type { Override, Prettify } from "./_utils"; +import type { Block, RootBlock } from "./block"; +import type { BlockContent } from "./field"; /** * Registry of all blocks, threaded through to resolve nested `bloks` fields. @@ -27,9 +27,13 @@ export type Story< // caller passed root block(s) directly → use them as the content type [TBlockOrBlocks] extends [RootBlock] ? CapiStoryWithSchemaContent - // caller passed the full block union → derive root blocks, thread the union as the registry - : [TBlocks] extends [NoBlocks] - ? CapiStoryWithSchemaContent, TBlockOrBlocks, TFieldPlugins> - // caller passed both → honour the explicit registry - : CapiStoryWithSchemaContent, TBlocks, TFieldPlugins> + : // caller passed the full block union → derive root blocks, thread the union as the registry + [TBlocks] extends [NoBlocks] + ? CapiStoryWithSchemaContent< + Extract, + TBlockOrBlocks, + TFieldPlugins + > + : // caller passed both → honour the explicit registry + CapiStoryWithSchemaContent, TBlocks, TFieldPlugins> >; diff --git a/packages/schema/src/generated/types/block.ts b/packages/schema/src/generated/types/block.ts index 698932b77..f361ba9cf 100644 --- a/packages/schema/src/generated/types/block.ts +++ b/packages/schema/src/generated/types/block.ts @@ -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` @@ -23,42 +20,46 @@ export type Block< TFields extends BlockFields = BlockFields, TIsRoot extends boolean = boolean, TIsNestable extends boolean = boolean, -> = Override, { - 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, + { + 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 = - Extract; +export type RootBlock = Extract; /** * 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 = - Extract; +export type NestableBlock = Extract< + T, + { is_nestable: true } +>; diff --git a/packages/schema/src/generated/types/field.ts b/packages/schema/src/generated/types/field.ts index 03455761d..a6a80abea 100644 --- a/packages/schema/src/generated/types/field.ts +++ b/packages/schema/src/generated/types/field.ts @@ -10,12 +10,18 @@ import type { PluginFieldValue, RichTextFieldValue, TableFieldValue, -} from './_sources'; -import type { Prettify } from './_utils'; -import type { Block, BlockFields } from './block'; +} from "./_sources"; +import type { Prettify } from "./_utils"; +import type { Block, BlockFields } from "./block"; export type { Field }; -export type { AssetFieldValue, MultilinkFieldValue, PluginFieldValue, RichTextFieldValue, TableFieldValue }; +export type { + AssetFieldValue, + MultilinkFieldValue, + PluginFieldValue, + RichTextFieldValue, + TableFieldValue, +}; /** * @deprecated Use {@link RichTextFieldValue} instead. Will be removed in a future major version. @@ -52,34 +58,44 @@ type HasNoValue = [V] extends [never] ? true : false; * carry no value (see {@link HasNoValue}). Each `F` is a member of the field * union, so it provably satisfies `FieldValue`'s `Field` constraint. */ -type ContentFields> = Prettify< +type ContentFields< + TFields extends BlockFields, + TBlocks extends Block | NoBlocks, + TFieldPlugins = Record, +> = Prettify< { [F in TFields[number] as HasNoValue> extends true ? never - : F extends { required: true } ? F['name'] : never - ]: FieldValue - } - & { + : F extends { required: true } + ? F["name"] + : never]: FieldValue; + } & { [F in TFields[number] as HasNoValue> extends true ? never - : F extends { required: true } ? never : F['name'] - ]?: FieldValue | null + : F extends { required: true } + ? never + : F["name"]]?: FieldValue | null; } >; /** Input (write) variant of {@link ContentFields}, resolving each field via {@link FieldValueInput}. */ -type ContentFieldsInput> = Prettify< +type ContentFieldsInput< + TFields extends BlockFields, + TBlocks extends Block | NoBlocks, + TFieldPlugins = Record, +> = Prettify< { [F in TFields[number] as HasNoValue> extends true ? never - : F extends { required: true } ? F['name'] : never - ]: FieldValueInput - } - & { + : F extends { required: true } + ? F["name"] + : never]: FieldValueInput; + } & { [F in TFields[number] as HasNoValue> extends true ? never - : F extends { required: true } ? never : F['name'] - ]?: FieldValueInput | null + : F extends { required: true } + ? never + : F["name"]]?: FieldValueInput | null; } >; @@ -89,27 +105,41 @@ type ContentFieldsInput> = +export type BlockContent< + TBlock extends Block = Block, + TBlocks extends Block | NoBlocks = NoBlocks, + TFieldPlugins = Record, +> = IsBaseBlock extends true ? BlockContentBase - // distribute over each member of the `TBlock` union - : TBlock extends any + : // distribute over each member of the `TBlock` union + TBlock extends any ? Prettify< - { _uid: string; component: TBlock['name']; _editable?: string } - & ContentFields - > + { _uid: string; component: TBlock["name"]; _editable?: string } & ContentFields< + TBlock["fields"], + TBlocks, + TFieldPlugins + > + > : never; /** Input variant of {@link BlockContent} for write operations (creating/updating stories via the MAPI). `_uid` is optional. */ -export type BlockContentInput> = +export type BlockContentInput< + TBlock extends Block = Block, + TBlocks extends Block | NoBlocks = NoBlocks, + TFieldPlugins = Record, +> = IsBaseBlock extends true ? BlockContentInputBase - // distribute over each member of the `TBlock` union - : TBlock extends any + : // distribute over each member of the `TBlock` union + TBlock extends any ? Prettify< - { _uid?: string; component: TBlock['name']; _editable?: string } - & ContentFieldsInput - > + { _uid?: string; component: TBlock["name"]; _editable?: string } & ContentFieldsInput< + TBlock["fields"], + TBlocks, + TFieldPlugins + > + > : never; export type BlocksFieldValue< @@ -119,7 +149,7 @@ export type BlocksFieldValue< > = BlockContent[]; /** Union of all valid Storyblok field type discriminants (e.g., `text`, `bloks`). */ -export type FieldType = Field['type']; +export type FieldType = Field["type"]; interface FieldTypeValueMap { text: string; @@ -147,10 +177,11 @@ interface FieldTypeValueMap { custom: PluginFieldValue; } -type IsNestable = - T extends { is_nestable: false } ? false - : T extends { is_nestable: true } ? true - : true; +type IsNestable = T extends { is_nestable: false } + ? false + : T extends { is_nestable: true } + ? true + : true; type AllowEntry = string | { folder: string }; @@ -165,22 +196,31 @@ type AllowEntry = string | { folder: string }; * string path on both the block's `folder` and the field's `allow`: a shared ref * carries the exact path on both sides, so no drift is possible. */ -type MatchesFolder = - TBlock extends { folder: infer BF extends string } - ? Lowercase extends Lowercase | `${Lowercase}/${string}` ? TBlock : never - : never; +type MatchesFolder = TBlock extends { + folder: infer BF extends string; +} + ? Lowercase extends Lowercase | `${Lowercase}/${string}` + ? TBlock + : never + : never; -type ApplyAllow = TField extends { allow: ReadonlyArray } +type ApplyAllow = TField extends { + allow: ReadonlyArray; +} ? TAllowed extends string - // keep only the registry blocks named in `allow` - ? Extract + ? // keep only the registry blocks named in `allow` + Extract : TAllowed extends { folder: infer F extends string } - // keep registry blocks in the folder (or any nested folder) - ? TBlocks extends any ? MatchesFolder : never + ? // keep registry blocks in the folder (or any nested folder) + TBlocks extends any + ? MatchesFolder + : never + : never + : // no `allow`: distribute over the registry, keeping nestable blocks + TBlocks extends any + ? IsNestable extends true + ? TBlocks : never - // no `allow`: distribute over the registry, keeping nestable blocks - : TBlocks extends any - ? IsNestable extends true ? TBlocks : never : never; /** @@ -189,7 +229,9 @@ type ApplyAllow = TField extends { allow: ReadonlyArray = TField extends { deny: ReadonlyArray } +type ApplyDeny = TField extends { + deny: ReadonlyArray; +} ? Exclude : TBlocks; @@ -205,12 +247,11 @@ type ApplyRestrictions = ApplyDeny = - TField extends { field_type: infer F extends string } - ? F extends keyof TFieldPlugins - ? Prettify - : PluginFieldValue - : PluginFieldValue; +type ResolveCustom = TField extends { field_type: infer F extends string } + ? F extends keyof TFieldPlugins + ? Prettify + : PluginFieldValue + : PluginFieldValue; /** Resolves a field definition to its runtime content value type (read). */ export type FieldValue< @@ -218,17 +259,17 @@ export type FieldValue< TBlocks extends Block | NoBlocks = NoBlocks, TFieldPlugins = Record, > = Prettify< - TField extends { type: 'bloks' } - // guard `never` first: `[never] extends [Block]` is structurally true, so an - // empty registry would otherwise be mistaken for a populated one - ? [TBlocks] extends [never] - ? BlockContentBase[] - : [TBlocks] extends [Block] - ? BlockContent, TBlocks, TFieldPlugins>[] - : BlockContentBase[] - : TField extends { type: 'custom' } + TField extends { type: "bloks" } + ? // guard `never` first: `[never] extends [Block]` is structurally true, so an + // empty registry would otherwise be mistaken for a populated one + [TBlocks] extends [never] + ? BlockContentBase[] + : [TBlocks] extends [Block] + ? BlockContent, TBlocks, TFieldPlugins>[] + : BlockContentBase[] + : TField extends { type: "custom" } ? ResolveCustom - : FieldTypeValueMap[TField['type']] + : FieldTypeValueMap[TField["type"]] >; /** Resolves a field definition to its input value type (write). */ @@ -237,15 +278,15 @@ export type FieldValueInput< TBlocks extends Block | NoBlocks = NoBlocks, TFieldPlugins = Record, > = Prettify< - TField extends { type: 'bloks' } - // guard `never` first: `[never] extends [Block]` is structurally true, so an - // empty registry would otherwise be mistaken for a populated one - ? [TBlocks] extends [never] - ? BlockContentInputBase[] - : [TBlocks] extends [Block] - ? BlockContentInput, TBlocks, TFieldPlugins>[] - : BlockContentInputBase[] - : TField extends { type: 'custom' } + TField extends { type: "bloks" } + ? // guard `never` first: `[never] extends [Block]` is structurally true, so an + // empty registry would otherwise be mistaken for a populated one + [TBlocks] extends [never] + ? BlockContentInputBase[] + : [TBlocks] extends [Block] + ? BlockContentInput, TBlocks, TFieldPlugins>[] + : BlockContentInputBase[] + : TField extends { type: "custom" } ? ResolveCustom - : FieldTypeValueMap[TField['type']] + : FieldTypeValueMap[TField["type"]] >; diff --git a/packages/schema/src/generated/types/mapi-story.ts b/packages/schema/src/generated/types/mapi-story.ts index 9a3344ce8..6dd716463 100644 --- a/packages/schema/src/generated/types/mapi-story.ts +++ b/packages/schema/src/generated/types/mapi-story.ts @@ -5,10 +5,10 @@ import type { MapiStory as MapiStoryGenerated, StoryCreate as StoryCreateGenerated, StoryUpdate as StoryUpdateGenerated, -} from './_sources'; -import type { Override, Prettify } from './_utils'; -import type { Block, RootBlock } from './block'; -import type { BlockContent, BlockContentInput } from './field'; +} from "./_sources"; +import type { Override, Prettify } from "./_utils"; +import type { Block, RootBlock } from "./block"; +import type { BlockContent, BlockContentInput } from "./field"; /** * Registry of all blocks, threaded through to resolve nested `bloks` fields. @@ -28,11 +28,14 @@ type MapiStoryWithSchemaContent< TFieldPlugins = Record, > = RootBlock extends TBlock ? TStory - : Override - : BlockContent; - }>; + : Override< + TStory, + { + content: TStory extends StoryCreateGenerated | StoryUpdateGenerated + ? BlockContentInput + : BlockContent; + } + >; type MakeMapiStory< TStory extends MapiStoryGenerated | StoryCreateGenerated | StoryUpdateGenerated, @@ -43,11 +46,21 @@ type MakeMapiStory< // caller passed root block(s) directly → use them as the content type [TBlockOrBlocks] extends [RootBlock] ? MapiStoryWithSchemaContent - // caller passed the full block union → derive root blocks, thread the union as the registry - : [TBlocks] extends [NoBlocks] - ? MapiStoryWithSchemaContent, TBlockOrBlocks, TFieldPlugins> - // caller passed both → honour the explicit registry - : MapiStoryWithSchemaContent, TBlocks, TFieldPlugins> + : // caller passed the full block union → derive root blocks, thread the union as the registry + [TBlocks] extends [NoBlocks] + ? MapiStoryWithSchemaContent< + TStory, + Extract, + TBlockOrBlocks, + TFieldPlugins + > + : // caller passed both → honour the explicit registry + MapiStoryWithSchemaContent< + TStory, + Extract, + TBlocks, + TFieldPlugins + > >; /** A Storyblok MAPI story. */ diff --git a/packages/schema/src/generated/types/story.ts b/packages/schema/src/generated/types/story.ts index d32a8fa23..6021a2a00 100644 --- a/packages/schema/src/generated/types/story.ts +++ b/packages/schema/src/generated/types/story.ts @@ -1,10 +1,10 @@ // Generated by @storyblok/openapi-codegen. Do not edit by hand. // Source template lives in tools/openapi-codegen/templates/. -import type { CapiStory as CapiStoryGenerated } from './_sources'; -import type { Override, Prettify } from './_utils'; -import type { Block, RootBlock } from './block'; -import type { BlockContent } from './field'; +import type { CapiStory as CapiStoryGenerated } from "./_sources"; +import type { Override, Prettify } from "./_utils"; +import type { Block, RootBlock } from "./block"; +import type { BlockContent } from "./field"; /** * Registry of all blocks, threaded through to resolve nested `bloks` fields. @@ -27,9 +27,13 @@ export type Story< // caller passed root block(s) directly → use them as the content type [TBlockOrBlocks] extends [RootBlock] ? CapiStoryWithSchemaContent - // caller passed the full block union → derive root blocks, thread the union as the registry - : [TBlocks] extends [NoBlocks] - ? CapiStoryWithSchemaContent, TBlockOrBlocks, TFieldPlugins> - // caller passed both → honour the explicit registry - : CapiStoryWithSchemaContent, TBlocks, TFieldPlugins> + : // caller passed the full block union → derive root blocks, thread the union as the registry + [TBlocks] extends [NoBlocks] + ? CapiStoryWithSchemaContent< + Extract, + TBlockOrBlocks, + TFieldPlugins + > + : // caller passed both → honour the explicit registry + CapiStoryWithSchemaContent, TBlocks, TFieldPlugins> >; From 7b334ce8a7ed61b2d8e28aca003475b75c42935a Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 11 Aug 2026 12:04:33 +0200 Subject: [PATCH 34/35] fix(cli): repair rebase fallout in schema init and types generate tests Restores what the rebase onto main dropped and adapts the branch's tests to main's UI module: - generateSchemaFile emits `FieldPlugins`, `Block`, 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. --- .../src/commands/schema/init/generate-code.ts | 32 ++++++++++++++++--- .../src/commands/types/generate/index.test.ts | 9 ++++-- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/schema/init/generate-code.ts b/packages/cli/src/commands/schema/init/generate-code.ts index 3d5343126..0531a1fea 100644 --- a/packages/cli/src/commands/schema/init/generate-code.ts +++ b/packages/cli/src/commands/schema/init/generate-code.ts @@ -421,7 +421,13 @@ export function generateSchemaFile( lines.push( "import type { Schema as InferSchema, Story as InferStory } from '@storyblok/schema';", ); - lines.push("import type { MapiStory as InferStoryMapi } from '@storyblok/schema';"); + // `BlockContent` only backs the block helpers below, which a space with no + // components does not get. Importing it regardless trips `noUnusedLocals`. + lines.push( + components.length > 0 + ? "import type { BlockContent, MapiStory as InferStoryMapi } from '@storyblok/schema';" + : "import type { MapiStory as InferStoryMapi } from '@storyblok/schema';", + ); lines.push(""); // Import blocks from their (slugified) group subdirectory — local @@ -473,11 +479,29 @@ export function generateSchemaFile( lines.push("});"); lines.push(""); - // Schema and Blocks types derived via Schema helper + // Schema and Blocks types derived via Schema helper. `FieldPlugins` is + // threaded through the story types so registering a field plugin later is a + // one-line change; with none registered it resolves to an empty map and costs + // nothing. lines.push("export type Schema = InferSchema;"); lines.push("export type Blocks = Schema['blocks'];"); - lines.push("export type Story = InferStory;"); - lines.push("export type StoryMapi = InferStoryMapi;"); + lines.push("export type FieldPlugins = Schema['fieldPlugins'];"); + lines.push("export type Story = InferStory;"); + lines.push("export type StoryMapi = InferStoryMapi;"); + + if (components.length > 0) { + lines.push(""); + lines.push('// Type a component\'s props by block name: `Block<"hero">`.'); + lines.push("export type Block = BlockContent<"); + lines.push(" Extract,"); + lines.push(" Blocks,"); + lines.push(" FieldPlugins"); + lines.push(">;"); + lines.push(""); + lines.push("// Loose union of every block's content, for a dynamic component dispatcher."); + lines.push("export type AnyBlock = BlockContent;"); + } + lines.push(""); return lines.join("\n"); diff --git a/packages/cli/src/commands/types/generate/index.test.ts b/packages/cli/src/commands/types/generate/index.test.ts index a1ff1de2f..effde6cfd 100644 --- a/packages/cli/src/commands/types/generate/index.test.ts +++ b/packages/cli/src/commands/types/generate/index.test.ts @@ -13,6 +13,7 @@ const uiOkMock = vi.hoisted(() => vi.fn()); const uiBrMock = vi.hoisted(() => vi.fn()); const uiSpinnerSucceedMock = vi.hoisted(() => vi.fn()); const uiSpinnerFailedMock = vi.hoisted(() => vi.fn()); +const uiErrorMock = vi.hoisted(() => vi.fn()); vi.mock("../../../lib/ui", async (importOriginal) => { const actual = await importOriginal>(); @@ -24,6 +25,7 @@ vi.mock("../../../lib/ui", async (importOriginal) => { info: uiInfoMock, ok: uiOkMock, br: uiBrMock, + error: uiErrorMock, createSpinner: () => ({ start: vi.fn(), succeed: uiSpinnerSucceedMock, @@ -103,8 +105,9 @@ describe("types generate", () => { expect(generateTypes).toHaveBeenCalledWith(mockSpaceData, expect.objectContaining({})); - expect(console.error).toHaveBeenCalledWith( + expect(uiOkMock).toHaveBeenCalledWith( expect.stringContaining("Successfully generated types for space"), + true, ); }); @@ -324,7 +327,7 @@ describe("types generate", () => { await typesCommand.parseAsync(["node", "test", "generate", "--space", "12345"]); - expect(konsola.warn).toHaveBeenCalledWith(expect.stringContaining("--future-schema")); + expect(uiWarnMock).toHaveBeenCalledWith(expect.stringContaining("--future-schema")); }); }); @@ -340,7 +343,7 @@ describe("types generate", () => { "--strict", ]); - expect(konsola.error).toHaveBeenCalledWith( + expect(uiErrorMock).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining("--strict") }), false, ); From 93d781278831ae9c3348947d6210f4347c68f966 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Tue, 11 Aug 2026 12:05:15 +0200 Subject: [PATCH 35/35] fix(cli): assert the legacy-flag error through main's ui.error signature --- packages/cli/src/commands/types/generate/index.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/types/generate/index.test.ts b/packages/cli/src/commands/types/generate/index.test.ts index effde6cfd..ecc438cd9 100644 --- a/packages/cli/src/commands/types/generate/index.test.ts +++ b/packages/cli/src/commands/types/generate/index.test.ts @@ -343,10 +343,9 @@ describe("types generate", () => { "--strict", ]); - expect(uiErrorMock).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining("--strict") }), - false, - ); + expect(uiErrorMock).toHaveBeenCalledWith(expect.stringContaining("--strict"), undefined, { + header: true, + }); }); it("generates schema types and reports success, per-file output, and unmapped field types", async () => {