diff --git a/jest.setup.ts b/jest.setup.ts index 45aaf8b..07b1956 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -4,16 +4,16 @@ import z from 'zod'; import tsConfig from './tsconfig.json'; declare global { - function parseShape(shape: string): z.ZodType; + function parseShape(shape: string, deps?: Record): z.ZodType; function serializeShape(type: z.ZodType): string; } -globalThis.parseShape = global.parseShape = function (shape) { +globalThis.parseShape = global.parseShape = function (shape, deps = {}) { // remove strict mode for inline transpilation, as it would add `use strict` to the output const options = { ...tsConfig, compilerOptions: { ...tsConfig.compilerOptions, strict: false } }; const { outputText } = transpileModule(shape, options as unknown as TranspileOptions); // evaluate adding zod to the scope - return new Function('z', `return ${outputText};`)(z); + return new Function(...Object.keys(deps), `return ${outputText};`)(...Object.values(deps)); }; globalThis.serializeShape = global.serializeShape = function (type) { diff --git a/src/cli.ts b/src/cli.ts index 002a9d7..8576a13 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -50,13 +50,14 @@ export async function loadAndTransformCollections( await Promise.all( collections.map(async collection => { // transform collection - const { compiled: cptime } = transformCollection(collection); + const { compiled, dependencies } = transformCollection(collection); const keys = { name: collection.name }; const name = naming.replace(/%%(\w+)%%/, (_, k: keyof typeof keys) => keys[k] ?? _); const path = resolve(to, name); // build content and prettify if possible - const raw = `import { z } from 'astro:content';\n\nexport const schema = ${cptime};\n`; + const imports = [...new Set(dependencies)].toSorted(); + const raw = `import { ${imports.join(', ')} } from 'astro:content';\n\nexport const schema = ${compiled};\n`; const pretty = await tryOrFail(() => formatCode(raw, 'typescript'), ERROR.FORMATTING_FAILED); // prepare folder if non-existent, remove existing and write file diff --git a/src/transformers/field-boolean.transform.ts b/src/transformers/field-boolean.transform.ts index 91fc06c..b5fc0fe 100644 --- a/src/transformers/field-boolean.transform.ts +++ b/src/transformers/field-boolean.transform.ts @@ -5,4 +5,5 @@ import type { Transformer } from '../utils/transform.utils.js'; // https://decapcms.org/docs/widgets/#boolean export const transformBooleanField: Transformer = () => ({ compiled: 'z.boolean()', + dependencies: ['z'], }); diff --git a/src/transformers/field-code.transform.spec.ts b/src/transformers/field-code.transform.spec.ts index 513a07e..f8298c9 100644 --- a/src/transformers/field-code.transform.spec.ts +++ b/src/transformers/field-code.transform.spec.ts @@ -1,5 +1,5 @@ import type { CmsFieldBase, CmsFieldCode } from 'decap-cms-core'; -import z, { ZodObject } from 'zod'; +import * as z from 'zod'; import { transformCodeField } from './field-code.transform.js'; @@ -7,8 +7,8 @@ describe('field-string.transform', () => { it('always sets the code along with the language', () => { const field = { name: 'foo', widget: 'code' } as CmsFieldBase & CmsFieldCode; const { compiled } = transformCodeField(field); - const runtime = parseShape(compiled); - const { shape } = runtime as ZodObject; + const runtime = parseShape(compiled, { z }); + const { shape } = runtime as z.ZodObject; expect(shape).toHaveProperty('code'); expect(shape).toHaveProperty('language'); expect(shape.code).toBeInstanceOf(z.ZodString); diff --git a/src/transformers/field-code.transform.ts b/src/transformers/field-code.transform.ts index 6ac6151..1fbfeef 100644 --- a/src/transformers/field-code.transform.ts +++ b/src/transformers/field-code.transform.ts @@ -7,4 +7,5 @@ export const transformCodeField: Transformer = ({ output_code_only: flat, }) => ({ compiled: flat ? 'z.string()' : 'z.object({ code: z.string(), language: z.string() })', + dependencies: ['z'], }); diff --git a/src/transformers/field-date-time.transform.ts b/src/transformers/field-date-time.transform.ts index 603dd17..c6ebe56 100644 --- a/src/transformers/field-date-time.transform.ts +++ b/src/transformers/field-date-time.transform.ts @@ -7,4 +7,5 @@ export const transformDateTimeField: Transformer = () => ({ compiled: 'z.string()', + dependencies: ['z'], }); diff --git a/src/transformers/field-hidden.transform.ts b/src/transformers/field-hidden.transform.ts index 9d84e92..7db0b23 100644 --- a/src/transformers/field-hidden.transform.ts +++ b/src/transformers/field-hidden.transform.ts @@ -11,6 +11,7 @@ export const transformHiddenField: Transformer = const json: Zod.ZodType = z.lazy(() => z.union([literal, z.array(json), z.record(json)])); return json; })`, + dependencies: ['z'], // This @jsdoc version was meant to make tests working, as the cptime string has simply been eval'd. // That might be helpful (or not) later. For now, we compile the cptime string first and then eval it. // cptime: `z.lazy(() => { diff --git a/src/transformers/field-list.transform.spec.ts b/src/transformers/field-list.transform.spec.ts index b652cce..1f13531 100644 --- a/src/transformers/field-list.transform.spec.ts +++ b/src/transformers/field-list.transform.spec.ts @@ -1,4 +1,5 @@ import type { CmsField, CmsFieldBase, CmsFieldList } from 'decap-cms-core'; +import * as z from 'zod'; import { transformListField } from './field-list.transform.js'; @@ -10,7 +11,7 @@ describe('field-list.transform', () => { field: { name: 'foo', widget: 'text' } as CmsField, } as CmsFieldBase & CmsFieldList; const { compiled } = transformListField(field); - const runtime = parseShape(compiled); + const runtime = parseShape(compiled, { z }); const result = ['foo', 'bar', 'baz']; expect(() => runtime.parse(result)).not.toThrow(); }); @@ -25,7 +26,7 @@ describe('field-list.transform', () => { ], } as CmsFieldBase & CmsFieldList; const { compiled } = transformListField(field); - const runtime = parseShape(compiled); + const runtime = parseShape(compiled, { z }); const result = [{ foo: 'foo', bar: 123 }]; expect(() => runtime.parse(result)).not.toThrow(); }); @@ -45,7 +46,7 @@ describe('field-list.transform', () => { ], } as CmsFieldBase & CmsFieldList; const { compiled } = transformListField(field); - const runtime = parseShape(compiled); + const runtime = parseShape(compiled, { z }); const result = [{ type: 'foo', foo: 'foo', bar: 123 }]; expect(() => runtime.parse(result)).not.toThrow(); }); diff --git a/src/transformers/field-list.transform.ts b/src/transformers/field-list.transform.ts index f41f86b..3b3228d 100644 --- a/src/transformers/field-list.transform.ts +++ b/src/transformers/field-list.transform.ts @@ -19,20 +19,30 @@ export const transformListField: Transformer = ({ // transform first const item = transformObjectField(type); // extend with type discriminator - return { compiled: `${item.compiled}.extend({type: z.literal('${type.name}')})` }; + return { + compiled: `${item.compiled}.extend({type: z.literal('${type.name}')})`, + dependencies: ['z', ...item.dependencies], + }; }); return { compiled: `z.array(z.discriminatedUnion('type', [${items.map(t => t.compiled).join(',')}]))`, + dependencies: ['z', ...items.flatMap(({ dependencies }) => dependencies)], }; } // handle fields list if (Array.isArray(fields)) { const items = transformObjectField({ fields } as any); - return { compiled: `z.array(${items.compiled})` }; + return { + compiled: `z.array(${items.compiled})`, + dependencies: ['z', ...items.dependencies], + }; } // handle single field (or never) list const item = transformField(field); - return { compiled: `z.array(${item.compiled})` }; + return { + compiled: `z.array(${item.compiled})`, + dependencies: ['z', ...item.dependencies], + }; }; diff --git a/src/transformers/field-map.transform.ts b/src/transformers/field-map.transform.ts index 7eb2cbe..39589f8 100644 --- a/src/transformers/field-map.transform.ts +++ b/src/transformers/field-map.transform.ts @@ -12,4 +12,5 @@ import type { Transformer } from '../utils/transform.utils.js'; // https://decapcms.org/docs/widgets/#map export const transformMapField: Transformer = () => ({ compiled: 'z.string()', + dependencies: ['z'], }); diff --git a/src/transformers/field-never.transform.ts b/src/transformers/field-never.transform.ts index 7c045d0..2e7601c 100644 --- a/src/transformers/field-never.transform.ts +++ b/src/transformers/field-never.transform.ts @@ -4,4 +4,5 @@ import type { Transformer } from '../utils/transform.utils.js'; export const transformNeverField: Transformer = () => ({ compiled: 'z.never()', + dependencies: ['z'], }); diff --git a/src/transformers/field-number.transform.spec.ts b/src/transformers/field-number.transform.spec.ts index b00c162..0b446b6 100644 --- a/src/transformers/field-number.transform.spec.ts +++ b/src/transformers/field-number.transform.spec.ts @@ -1,4 +1,5 @@ import type { CmsFieldBase, CmsFieldNumber } from 'decap-cms-core'; +import * as z from 'zod'; import { transformNumberField } from './field-number.transform.js'; @@ -12,7 +13,7 @@ describe('field-number.transform', () => { it('can be integer', () => { const intField = { ...field, value_type: 'int' }; const { compiled } = transformNumberField(intField); - const runtime = parseShape(compiled); + const runtime = parseShape(compiled, { z }); const { checks } = runtime._def; expect(checks).toHaveLength(2); expect(checks).toEqual(expect.arrayContaining([{ kind: 'finite' }, { kind: 'int' }])); @@ -21,7 +22,7 @@ describe('field-number.transform', () => { it('can have a min value', () => { const minField = { ...field, min: 1 }; const { compiled } = transformNumberField(minField); - const runtime = parseShape(compiled); + const runtime = parseShape(compiled, { z }); const { checks } = runtime._def; expect(checks).toHaveLength(3); expect(checks).toEqual(expect.arrayContaining([{ kind: 'min', inclusive: true, value: 1 }])); @@ -30,7 +31,7 @@ describe('field-number.transform', () => { it('can have a max value', () => { const maxField = { ...field, max: 5 }; const { compiled } = transformNumberField(maxField); - const runtime = parseShape(compiled); + const runtime = parseShape(compiled, { z }); const { checks } = runtime._def; expect(checks).toHaveLength(3); expect(checks).toEqual(expect.arrayContaining([{ kind: 'max', inclusive: true, value: 5 }])); diff --git a/src/transformers/field-number.transform.ts b/src/transformers/field-number.transform.ts index 09b0064..b636cc1 100644 --- a/src/transformers/field-number.transform.ts +++ b/src/transformers/field-number.transform.ts @@ -8,7 +8,7 @@ export const transformNumberField: Transformer = min, value_type = 'int', }) => { - const transformed = { compiled: 'z.number().finite()' }; + const transformed = { compiled: 'z.number().finite()', dependencies: ['z'] }; // numbers can be float or int if (value_type === 'int') { diff --git a/src/transformers/field-object.transform.spec.ts b/src/transformers/field-object.transform.spec.ts index 76e2e15..b2df903 100644 --- a/src/transformers/field-object.transform.spec.ts +++ b/src/transformers/field-object.transform.spec.ts @@ -1,4 +1,5 @@ import type { CmsField, CmsFieldBase, CmsFieldObject } from 'decap-cms-core'; +import * as z from 'zod'; import { transformObjectField } from './field-object.transform.js'; @@ -13,8 +14,22 @@ describe('field-object.transform', () => { ], } as CmsFieldBase & CmsFieldObject; const { compiled } = transformObjectField(field); - const runtime = parseShape(compiled); + const runtime = parseShape(compiled, { z }); const result = { foo: 'foo', bar: 123 }; expect(() => runtime.parse(result)).not.toThrow(); }); + + it('flattens all nested dependencies', () => { + const field = { + name: 'foo', + widget: 'object', + fields: [ + { name: 'foo', widget: 'text' } as CmsField, + { name: 'bar', widget: 'number' } as CmsField, + ], + } as CmsFieldBase & CmsFieldObject; + const { dependencies } = transformObjectField(field); + // it's five instead of three, as `z.describe` is added for each field + expect(dependencies).toEqual(['z', 'z', 'z', 'z', 'z']); + }); }); diff --git a/src/transformers/field-object.transform.ts b/src/transformers/field-object.transform.ts index 7219190..83e631d 100644 --- a/src/transformers/field-object.transform.ts +++ b/src/transformers/field-object.transform.ts @@ -9,5 +9,6 @@ export const transformObjectField: Transformer = }) => { const results = fields.map(field => [field.name, transformField(field)] as const); const compiled = `z.object({${results.map(([name, r]) => `${name}: ${r.compiled}`).join(',')}})`; - return { compiled }; + const dependencies = ['z', ...results.flatMap(([, { dependencies }]) => dependencies)]; + return { compiled, dependencies }; }; diff --git a/src/transformers/field-relation.transform.ts b/src/transformers/field-relation.transform.ts index 6151f4b..cb00601 100644 --- a/src/transformers/field-relation.transform.ts +++ b/src/transformers/field-relation.transform.ts @@ -6,4 +6,5 @@ import type { Transformer } from '../utils/transform.utils.js'; // https://decapcms.org/docs/widgets/#relation export const transformRelationField: Transformer = () => ({ compiled: 'z.string()', + dependencies: ['z'], }); diff --git a/src/transformers/field-select.transform.ts b/src/transformers/field-select.transform.ts index 6704b4b..ee8e084 100644 --- a/src/transformers/field-select.transform.ts +++ b/src/transformers/field-select.transform.ts @@ -5,5 +5,8 @@ import type { Transformer } from '../utils/transform.utils.js'; // https://decapcms.org/docs/widgets/#select export const transformSelectField: Transformer = ({ options }) => { const items = options.map(option => (typeof option === 'string' ? option : option.value)); - return { compiled: `z.enum([${items.map(i => `'${i}'`).join(',')}])` }; + return { + compiled: `z.enum([${items.map(i => `'${i}'`).join(',')}])`, + dependencies: ['z'], + }; }; diff --git a/src/transformers/field-string.transform.ts b/src/transformers/field-string.transform.ts index ea67b79..fc312c8 100644 --- a/src/transformers/field-string.transform.ts +++ b/src/transformers/field-string.transform.ts @@ -8,4 +8,4 @@ import type { Transformer } from '../utils/transform.utils.js'; // https://decapcms.org/docs/widgets/#text export const transformStringField: Transformer< CmsFieldBase & (CmsFieldColor | CmsFieldStringOrText) -> = () => ({ compiled: 'z.string()' }); +> = () => ({ compiled: 'z.string()', dependencies: ['z'] }); diff --git a/src/transformers/field.transform.spec.ts b/src/transformers/field.transform.spec.ts index b2dfdd9..6f2038c 100644 --- a/src/transformers/field.transform.spec.ts +++ b/src/transformers/field.transform.spec.ts @@ -33,7 +33,7 @@ describe('field.transform', () => { }); it('exposes a Zod object at runtime', () => { - const runtime = parseShape(transformField(field).compiled); + const runtime = parseShape(transformField(field).compiled, { z }); expect(runtime).toBeInstanceOf(z.ZodString); }); @@ -87,7 +87,7 @@ describe('field.transform', () => { }); it(`exposes the correct runtime Zod object ${desc}`, () => { - const runtime = parseShape(transform(field).compiled); + const runtime = parseShape(transform(field).compiled, { z }); expect(runtime).toBeInstanceOf(runtype); }); }); diff --git a/src/transformers/field.transform.ts b/src/transformers/field.transform.ts index e6218e5..b547cda 100644 --- a/src/transformers/field.transform.ts +++ b/src/transformers/field.transform.ts @@ -26,72 +26,72 @@ export const transformField: Transformer = field => { const knownWidgets = field.widget as DecapWidgetType; const applyTransform = (field: Decap.CmsField, transformer: Transformer): TransformResult => transformer(field); - let compiled: string; + let result: TransformResult; switch (knownWidgets) { case 'color': // https://decapcms.org/docs/widgets/#color case 'markdown': // https://decapcms.org/docs/widgets/#markdown case 'string': // https://decapcms.org/docs/widgets/#string case 'text': // https://decapcms.org/docs/widgets/#text - ({ compiled } = applyTransform(field, transformStringField)); + result = applyTransform(field, transformStringField); break; case 'file': // https://decapcms.org/docs/widgets/#file case 'image': // https://decapcms.org/docs/widgets/#image - ({ compiled } = applyTransform(field, transformFileField)); + result = applyTransform(field, transformFileField); break; case 'datetime': // https://decapcms.org/docs/widgets/#datetime - ({ compiled } = applyTransform(field, transformDateTimeField)); + result = applyTransform(field, transformDateTimeField); break; case 'code': // https://decapcms.org/docs/widgets/#code - ({ compiled } = applyTransform(field, transformCodeField)); + result = applyTransform(field, transformCodeField); break; case 'hidden': // https://decapcms.org/docs/widgets/#hidden - ({ compiled } = applyTransform(field, transformHiddenField)); + result = applyTransform(field, transformHiddenField); break; case 'map': // https://decapcms.org/docs/widgets/#map - ({ compiled } = applyTransform(field, transformMapField)); + result = applyTransform(field, transformMapField); break; case 'relation': // https://decapcms.org/docs/widgets/#relation - ({ compiled } = applyTransform(field, transformRelationField)); + result = applyTransform(field, transformRelationField); break; case 'number': // https://decapcms.org/docs/widgets/#number - ({ compiled } = applyTransform(field, transformNumberField)); + result = applyTransform(field, transformNumberField); break; case 'boolean': // https://decapcms.org/docs/widgets/#boolean - ({ compiled } = applyTransform(field, transformBooleanField)); + result = applyTransform(field, transformBooleanField); break; case 'select': // https://decapcms.org/docs/widgets/#select - ({ compiled } = applyTransform(field, transformSelectField)); + result = applyTransform(field, transformSelectField); break; case 'object': // https://decapcms.org/docs/widgets/#object - ({ compiled } = applyTransform(field, transformObjectField)); + result = applyTransform(field, transformObjectField); break; case 'list': // https://decapcms.org/docs/widgets/#list - ({ compiled } = applyTransform(field, transformListField)); + result = applyTransform(field, transformListField); break; default: // eslint-disable-next-line @typescript-eslint/no-unused-expressions (_exhaustiveCheck: never = knownWidgets) => null; - ({ compiled } = transformNeverField(field)); + result = transformNeverField(field); break; } // flag field as optional, set a default value and add a description if available - ({ compiled } = applyOptional(field, { compiled })); - ({ compiled } = applyDefaultValue(field, { compiled })); - ({ compiled } = applyDescription(field, { compiled })); + result = applyOptional(field, result); + result = applyDefaultValue(field, result); + result = applyDescription(field, result); - return { compiled }; + return result; }; diff --git a/src/utils/transform.utils.ts b/src/utils/transform.utils.ts index 892f342..2b74fe7 100644 --- a/src/utils/transform.utils.ts +++ b/src/utils/transform.utils.ts @@ -9,11 +9,16 @@ export type Transformer = (field: F) => TransformResult; /** * Utility type defining the result of a transformation. - * It consists of a Zod runtime type and a string representation of the Zod schema. - * The latter is used to generate the content of a TypeScript file. + * It consists of a Zod schema string representation and an optional list of + * dependencies to the generated astro content module. The latter usually + * contains the `z` (zod) re-export of the Astro runtime. */ export type TransformResult = { + // the compiled result as string compiled: string; + // the optional list of dependencies - as we want this to be explicitly optional, + // one must return at least an empty array if really no dependencies are required + dependencies: string[]; }; /** @@ -26,6 +31,7 @@ export function applyOptional(field: Decap.CmsField, result: TransformResult): T if (field.required === false) { return { compiled: `${result.compiled}.nullish()`, + dependencies: ['z', ...result.dependencies], }; } return result; @@ -44,6 +50,7 @@ export function applyDefaultValue(field: Decap.CmsField, result: TransformResult // set default value return { compiled: `${result.compiled}.default(${JSON.stringify(def)})`, + dependencies: ['z', ...result.dependencies], }; } @@ -58,6 +65,7 @@ export function applyDescription(field: Decap.CmsField, result: TransformResult) // set a description return { compiled: `${result.compiled}.describe('${description}')`, + dependencies: ['z', ...result.dependencies], }; } @@ -99,9 +107,10 @@ export function transformCollection(collection: Decap.CmsCollection): TransformR // multiple file collection is a union of all results return { compiled: `z.union([${results.map(({ compiled: cptime }) => cptime).join(', ')}])`, + dependencies: ['z', ...results.flatMap(({ dependencies }) => dependencies)], }; } // a collection without a folder OR a file list is invalid, thus we define `never` - return { compiled: 'z.never()' }; + return { compiled: 'z.never()', dependencies: ['z'] }; }