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 new file mode 100644 index 000000000..d8dec91df --- /dev/null +++ b/adr/0012-schema-derived-type-generation.md @@ -0,0 +1,66 @@ +# ADR-0012: Schema-Derived Type Generation for the CLI + +**Status:** Accepted **Date:** 2026-07-28 + +## Context + +`storyblok types generate` built types with `json-schema-to-typescript` from pulled component JSON. +It ignored field `required` flags, so every field came out looser than it actually was. It ignored +`bloks` field `component_group_whitelist`s, so nested block types were never narrowed to the blocks +a field actually allows. It ignored the nestable versus root distinction, so root-only and +block-only components typed the same way. It also required a prior `components pull` with matching +flags, an extra step that could silently drift from what was actually in the space. + +`@storyblok/schema` already models all of this correctly at the type level, but only for users who +define their schema in code with `defineBlock`, `defineField`, and friends. Users managing +components in the Storyblok UI had no way to get those types without hand-writing them. + +## Decision + +`types generate --future-schema` fetches the space's components and component groups directly from +the Management API and emits block definition type literals, plus the public surface a hand-written +`schema.ts` would export: `Blocks`, `Schema`, `FieldPlugins`, `Block`, `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/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/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/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..59a1fa5c6 --- /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/folders.test.ts b/packages/cli/src/commands/schema/folders.test.ts index 2f30496f1..de84675f4 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,43 @@ describe("expandFolderPath", () => { ]); }); }); + +describe("buildGroupDisplayPathByUuid", () => { + it("joins parent display names with slashes, preserving original casing", () => { + const folders = [ + folder({ uuid: "a", name: "My Layout" }), + folder({ uuid: "b", name: "Heros", parent_uuid: "a" }), + ]; + + const result = buildGroupDisplayPathByUuid(folders); + + 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 = [ + folder({ uuid: "a", name: "A", parent_uuid: "b" }), + folder({ uuid: "b", name: "B", parent_uuid: "a" }), + ]; + + 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 = [folder({ uuid: "a", name: "Orphan", parent_uuid: "missing" })]; + + 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 92dbe570d..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'`). 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 segmentsByUuid = buildGroupSegmentsByUuid(folders, (name) => name); + 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 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/generate-code.ts b/packages/cli/src/commands/schema/init/generate-code.ts index 0e8de2933..0531a1fea 100644 --- a/packages/cli/src/commands/schema/init/generate-code.ts +++ b/packages/cli/src/commands/schema/init/generate-code.ts @@ -1,49 +1,40 @@ 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, + toSafeIdentifier, } 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"]); /** * 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; -} - -/** - * 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, ""); + return toSafeIdentifier(camel); } /** Returns the variable name for a component. e.g. `'teaser_list'` -> `'teaserListBlock'` */ @@ -61,66 +52,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 +152,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 +196,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,17 +210,18 @@ 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)) { 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; } - 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 +229,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])); 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/schema/to-dsl-field.test.ts b/packages/cli/src/commands/schema/to-dsl-field.test.ts new file mode 100644 index 000000000..e3cc106a6 --- /dev/null +++ b/packages/cli/src/commands/schema/to-dsl-field.test.ts @@ -0,0 +1,97 @@ +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("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"] }, + () => ({ 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..2f901cde2 --- /dev/null +++ b/packages/cli/src/commands/schema/to-dsl-field.ts @@ -0,0 +1,95 @@ +/** + * 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. + */ +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) { + // 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; + } + } 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.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 a5d1d28fb..5deffb61f 100644 --- a/packages/cli/src/commands/schema/utils.ts +++ b/packages/cli/src/commands/schema/utils.ts @@ -177,3 +177,108 @@ 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, ""); +} + +/** + * 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 + * 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; + }); +} diff --git a/packages/cli/src/commands/types/generate/README.md b/packages/cli/src/commands/types/generate/README.md index 671f1299c..3fe3e1534 100644 --- a/packages/cli/src/commands/types/generate/README.md +++ b/packages/cli/src/commands/types/generate/README.md @@ -4,9 +4,14 @@ The `types generate` command generates TypeScript type definitions (`.d.ts` file 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, +> `bloks` field whitelists, and the nestable versus root distinction. Use `--future-schema` instead, +> which derives types from the space schema via `@storyblok/schema`. + +> [!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. ## Basic Usage @@ -16,17 +21,20 @@ storyblok types generate --space ## 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 ` | 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 @@ -56,15 +64,16 @@ 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`. + +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 @@ -81,9 +90,6 @@ 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}/`. - ## Notes - The command requires you to be logged in to Storyblok @@ -92,3 +98,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 diff --git a/packages/cli/src/commands/types/generate/actions.ts b/packages/cli/src/commands/types/generate/actions.ts index 20260274c..7956b5e00 100644 --- a/packages/cli/src/commands/types/generate/actions.ts +++ b/packages/cli/src/commands/types/generate/actions.ts @@ -8,7 +8,8 @@ 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"; import { getLogger } from "../../../lib/logger/logger"; @@ -51,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.", @@ -575,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) @@ -589,7 +589,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 caebf03eb..942bcc319 100644 --- a/packages/cli/src/commands/types/generate/constants.ts +++ b/packages/cli/src/commands/types/generate/constants.ts @@ -1,3 +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; @@ -8,4 +19,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/filename.test.ts b/packages/cli/src/commands/types/generate/filename.test.ts new file mode 100644 index 000000000..ca2125184 --- /dev/null +++ b/packages/cli/src/commands/types/generate/filename.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +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"); + }); + + 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"); + }); +}); + +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 new file mode 100644 index 000000000..9032648e1 --- /dev/null +++ b/packages/cli/src/commands/types/generate/filename.ts @@ -0,0 +1,34 @@ +/** + * 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`; +} + +/** + * 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 new file mode 100644 index 000000000..edb828813 --- /dev/null +++ b/packages/cli/src/commands/types/generate/future-schema.ts @@ -0,0 +1,146 @@ +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"; + +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; + }; + /** + * 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; +} + +/** + * 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. + * + * 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, + 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 { + 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."); + } + // 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 " + + `other. Leave it unset to write to ${toDeclarationFileName(DEFAULT_SCHEMA_TYPES_FILENAME)} instead.`, + ); + } + + spinner = ui.createSpinner("Generating types..."); + const result = await generateSchemaTypes({ + space, + cwd: process.cwd(), + path, + 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"); + + 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) { + ui.warn( + `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.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.test.ts b/packages/cli/src/commands/types/generate/index.test.ts index 1461a0214..ecc438cd9 100644 --- a/packages/cli/src/commands/types/generate/index.test.ts +++ b/packages/cli/src/commands/types/generate/index.test.ts @@ -4,6 +4,44 @@ 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()); +const uiErrorMock = 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, + error: uiErrorMock, + 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 = [ { @@ -67,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, ); }); @@ -280,5 +319,263 @@ 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(uiWarnMock).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(uiErrorMock).toHaveBeenCalledWith(expect.stringContaining("--strict"), undefined, { + header: true, + }); + }); + + 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, + reason: "missing", + path: "/project/.storyblok/schema/schema.ts", + }, + }); + + 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(); + }); + + 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" }, + }); + + await typesCommand.parseAsync([ + "node", + "test", + "generate", + "--space", + "295018", + "--future-schema", + ]); + + expect(uiWarnMock).toHaveBeenCalledWith( + expect.stringContaining("src/storyblok/field-plugins.ts"), + ); + expect(uiWarnMock).not.toHaveBeenCalledWith( + expect.stringContaining("--field-plugins at the module"), + ); + }); + + // `--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"], + prunedFiles: [], + unmappedFieldTypes: ["storyblok-colorpicker"], + fieldPlugins: { + resolved: false, + reason: "missing", + path: "/project/config/schema/schema.ts", + }, + }); + + await typesCommand.parseAsync([ + "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"), + ); + }); + + // `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, + reason: "missing", + 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.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, + reason: "missing", + 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: [], + unmappedFieldTypes: [], + fieldPlugins: { + resolved: false, + reason: "missing", + path: "/project/.storyblok/schema/schema.ts", + }, + }); + + await typesCommand.parseAsync([ + "node", + "test", + "generate", + "--space", + "295018", + "--future-schema", + "--separate-files", + "--filename", + "shared", + ]); + + expect(uiWarnMock).not.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 1f97da407..19c664bc5 100644 --- a/packages/cli/src/commands/types/generate/index.ts +++ b/packages/cli/src/commands/types/generate/index.ts @@ -9,6 +9,8 @@ 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 .command("generate") @@ -30,14 +32,35 @@ 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") + .option( + "--field-plugins ", + `Path to a module exporting your defineFieldPlugin declarations (default: ${DEFAULT_SCHEMA_ENTRY_PATH})`, + ); 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) { + await runFutureSchemaTypes({ + options, + globals: { space, path, filename, separateFiles, verbose }, + getOptionValueSource: (attributeName) => command.getOptionValueSource(attributeName), + }); + 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.", + ); + if (options.fieldPlugins !== undefined) { + ui.warn("--field-plugins is ignored without --future-schema."); + } + 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/__fixtures__/components.ts b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/components.ts new file mode 100644 index 000000000..0a7c74410 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/components.ts @@ -0,0 +1,45 @@ +/** + * 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, 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 = [ + { + 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 }, + 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..35171d2bb --- /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.js'; + +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 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 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 = GridBlockDefinition | HeroBlockDefinition | 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 new file mode 100644 index 000000000..a5ee4be35 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/expected-types.d.ts @@ -0,0 +1,57 @@ +// 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 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 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 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 = GridBlockDefinition | 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/__fixtures__/plugins.ts b/packages/cli/src/commands/types/generate/schema-types/__fixtures__/plugins.ts new file mode 100644 index 000000000..89e817a15 --- /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 new file mode 100644 index 000000000..5f343edd5 --- /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/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..9d0dc8060 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/emitted-types.test-d.ts @@ -0,0 +1,94 @@ +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 + * 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(); + }); + + // 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", () => { + 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` 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">(); + }); + + it("exposes a Schema shaped for withTypes()", () => { + 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 + // one block (a plain `toHaveProperty('component')` would not catch that). + 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/field-plugins.test.ts b/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts new file mode 100644 index 000000000..02cde99b3 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/field-plugins.test.ts @@ -0,0 +1,169 @@ +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"; + +// 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", + reason: "missing", + searchedPath: join(cwd, DEFAULT_SCHEMA_ENTRY_PATH), + }); + }); + + 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"); + + 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("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", + 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 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 () => { + 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 new file mode 100644 index 000000000..02e8e9fc9 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/field-plugins.ts @@ -0,0 +1,154 @@ +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 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`. + * + * `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"; 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) + ) { + fieldTypes.push(plugin.fieldType); + } + } + 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 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 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; + 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); + + if (!existsSync(modulePath)) { + if (isExplicit) { + throw new CommandError(`Field plugins module not found: ${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}`, + ); + } + + 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) }; + } + + const nearMiss = findNearMissExport(module); + if (isExplicit) { + 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\`.` + }`, + ); + } + // 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/fixture-drift.test.ts b/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts new file mode 100644 index 000000000..a3f261978 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/fixture-drift.test.ts @@ -0,0 +1,57 @@ +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"; + +/** + * 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`), + * 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 rendered = renderSchemaTypes({ + blocks: serializeFixtureBlocks(), + fieldPlugins: { kind: "none" }, + space: "295018", + }); + + 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 rendered = renderSchemaTypes({ + blocks: serializeFixtureBlocks(), + 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", + }); + + 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 new file mode 100644 index 000000000..9512c6f04 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/index.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, it, vi } from "vitest"; +import { vol } from "memfs"; + +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/filesystem", 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("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); + }, + ); + + 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("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", () => { + 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("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.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(); + + 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({ + 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..bad0f7ada --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/index.ts @@ -0,0 +1,235 @@ +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, + * 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`, 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 + * unable to use `--future-schema` at all without editing its config, having + * typed nothing wrong. Those are returned instead, for the caller to report as + * ignored. `getOptionValueSource` comes from Commander, which records `'config'` + * for values hydrated by `applyConfigToCommander`; without it every set flag is + * treated as user-supplied. + * + * @returns the legacy-only flags that came from config and are being ignored. + */ +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)); + + if (used.length === 1) { + const [, flag, reason] = used[0]!; + throw new CommandError(`${flag} is not supported with --future-schema: ${reason}.`); + } + + 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(". ")}.`, + ); + } + + 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. */ + 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. */ + filename: string; + separateFiles?: boolean; + typePrefix?: string; + typeSuffix?: string; + fieldPluginsPath?: string; +} + +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[]; + /** + * 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, and tell "write a module here" apart from + * "the module here exports the wrong name". + */ + fieldPlugins: + | { resolved: true; path: string } + | { resolved: false; reason: "missing" | "unusable"; path: string; nearMissExport?: 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 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, + path: options.path, + 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([[toDeclarationFileName(options.filename), 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 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, + 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 new file mode 100644 index 000000000..721969d8e --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/integration.test.ts @@ -0,0 +1,101 @@ +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(); + }); +}); 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..8eb35b00c --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/render.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from "vitest"; + +import { buildNames, renderSchemaTypes, renderSeparateFiles, 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"); + }); + + 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", () => { + 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, 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", () => { + 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("MapiStory as InferStoryMapi"); + }); + + 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 { 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", () => { + const output = renderSchemaTypes({ + blocks: [heroBlock], + 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'];", + ); + }); + + 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", + }); + + 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", + ); + }); + + 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("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", () => { + /** + * 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], + 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.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", () => { + 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.js';", + ); + expect(files.get("storyblok-schema.d.ts")).not.toMatch(/\b2ColBlockDefinition/); + }); + + 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 new file mode 100644 index 000000000..6081e5049 --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/render.ts @@ -0,0 +1,284 @@ +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"; + +/** 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. + * + * `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) => + 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), + // 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)]), + ), + }; +} + +/** + * 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 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}`; +} + +/** 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}`, + "", + ]; +} + +/** + * 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 + * when {@link renderFieldPlugins} imports a user module. Names stay + * alphabetically ordered either way. + */ +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';`; +} + +/** + * 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 (!usesUserFieldPlugins(options)) { + 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}, ${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 names = buildNames(componentNames, options); + const fieldPlugins = renderFieldPlugins(options, names); + + const lines = [ + ...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(""); + } + + const definitionNames = componentNames.map((name) => names.definitionByComponent.get(name)!); + lines.push(...renderSurface(names, definitionNames, fieldPlugins.declaration)); + + 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), + renderSchemaImport(options), + ...fieldPlugins.imports, + ...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")); + + 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 new file mode 100644 index 000000000..225c46c6e --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/serialize.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from "vitest"; + +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", + is_root: false, + is_nestable: true, + schema: {}, + ...overrides, + }; +} + +/** 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", () => { + 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"] } }, + }), + context({ knownBlockNames: new Set(["hero", "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"]]) }), + ); + + 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" }), + context({ 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("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" } }, + }), + 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..a85e5f8bb --- /dev/null +++ b/packages/cli/src/commands/types/generate/schema-types/serialize.ts @@ -0,0 +1,205 @@ +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; + /** + * 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. */ +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[]; +} + +/** + * Narrows a component's wire `schema` to the field-record shape that + * `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) && 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 + * 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 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`), + * `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 (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" ? 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 = isFieldRecordMap(component.schema) ? component.schema : {}; + 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 }; +} diff --git a/packages/cli/src/utils/import-module.ts b/packages/cli/src/utils/import-module.ts new file mode 100644 index 000000000..859a8013a --- /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); } 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"], }, 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 29182b09b..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. @@ -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/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> >; 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', 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..f76c751bf --- /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; } >;