Skip to content

Commit

Permalink
feat: add transform dependencies
Browse files Browse the repository at this point in the history
to result payload and build imports from them
  • Loading branch information
davidenke committed Dec 14, 2024
1 parent 71bd46b commit 42052f4
Show file tree
Hide file tree
Showing 22 changed files with 94 additions and 45 deletions.
6 changes: 3 additions & 3 deletions jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ import z from 'zod';
import tsConfig from './tsconfig.json';

declare global {
function parseShape(shape: string): z.ZodType<any, any, any>;
function parseShape(shape: string, deps?: Record<string, unknown>): z.ZodType<any, any, any>;
function serializeShape(type: z.ZodType<any, any, any>): 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) {
Expand Down
5 changes: 3 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/transformers/field-boolean.transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ import type { Transformer } from '../utils/transform.utils.js';
// https://decapcms.org/docs/widgets/#boolean
export const transformBooleanField: Transformer<CmsFieldBase & CmsFieldBoolean> = () => ({
compiled: 'z.boolean()',
dependencies: ['z'],
});
6 changes: 3 additions & 3 deletions src/transformers/field-code.transform.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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';

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<any>;
const runtime = parseShape(compiled, { z });
const { shape } = runtime as z.ZodObject<any>;
expect(shape).toHaveProperty('code');
expect(shape).toHaveProperty('language');
expect(shape.code).toBeInstanceOf(z.ZodString);
Expand Down
1 change: 1 addition & 0 deletions src/transformers/field-code.transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export const transformCodeField: Transformer<CmsFieldBase & CmsFieldCode> = ({
output_code_only: flat,
}) => ({
compiled: flat ? 'z.string()' : 'z.object({ code: z.string(), language: z.string() })',
dependencies: ['z'],
});
1 change: 1 addition & 0 deletions src/transformers/field-date-time.transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export const transformDateTimeField: Transformer<CmsFieldBase & CmsFieldDateTime
// Decap does not store seconds, but we can just articulate 'no milliseconds'
// https://zod.dev/?id=dates-1
compiled: 'z.date()',
dependencies: ['z'],
});
1 change: 1 addition & 0 deletions src/transformers/field-file.transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ import type { Transformer } from '../utils/transform.utils.js';
// https://decapcms.org/docs/widgets/#image
export const transformFileField: Transformer<CmsFieldBase & CmsFieldStringOrText> = () => ({
compiled: 'z.string()',
dependencies: ['z'],
});
1 change: 1 addition & 0 deletions src/transformers/field-hidden.transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const transformHiddenField: Transformer<CmsFieldBase & CmsFieldHidden> =
const json: Zod.ZodType<Json> = 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(() => {
Expand Down
7 changes: 4 additions & 3 deletions src/transformers/field-list.transform.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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();
});
Expand All @@ -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();
});
Expand All @@ -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();
});
Expand Down
16 changes: 13 additions & 3 deletions src/transformers/field-list.transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,30 @@ export const transformListField: Transformer<CmsFieldBase & CmsFieldList> = ({
// 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],
};
};
1 change: 1 addition & 0 deletions src/transformers/field-map.transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ import type { Transformer } from '../utils/transform.utils.js';
// https://decapcms.org/docs/widgets/#map
export const transformMapField: Transformer<CmsFieldBase & CmsFieldMap> = () => ({
compiled: 'z.string()',
dependencies: ['z'],
});
1 change: 1 addition & 0 deletions src/transformers/field-never.transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ import type { Transformer } from '../utils/transform.utils.js';

export const transformNeverField: Transformer<CmsFieldBase> = () => ({
compiled: 'z.never()',
dependencies: ['z'],
});
7 changes: 4 additions & 3 deletions src/transformers/field-number.transform.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CmsFieldBase, CmsFieldNumber } from 'decap-cms-core';
import * as z from 'zod';

import { transformNumberField } from './field-number.transform.js';

Expand All @@ -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' }]));
Expand All @@ -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 }]));
Expand All @@ -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 }]));
Expand Down
2 changes: 1 addition & 1 deletion src/transformers/field-number.transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const transformNumberField: Transformer<CmsFieldBase & CmsFieldNumber> =
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') {
Expand Down
17 changes: 16 additions & 1 deletion src/transformers/field-object.transform.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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']);
});
});
3 changes: 2 additions & 1 deletion src/transformers/field-object.transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ export const transformObjectField: Transformer<CmsFieldBase & CmsFieldObject> =
}) => {
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 };
};
1 change: 1 addition & 0 deletions src/transformers/field-relation.transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ import type { Transformer } from '../utils/transform.utils.js';
// https://decapcms.org/docs/widgets/#relation
export const transformRelationField: Transformer<CmsFieldBase & CmsFieldRelation> = () => ({
compiled: 'z.string()',
dependencies: ['z'],
});
5 changes: 4 additions & 1 deletion src/transformers/field-select.transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@ import type { Transformer } from '../utils/transform.utils.js';
// https://decapcms.org/docs/widgets/#select
export const transformSelectField: Transformer<CmsFieldBase & CmsFieldSelect> = ({ 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'],
};
};
2 changes: 1 addition & 1 deletion src/transformers/field-string.transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] });
4 changes: 2 additions & 2 deletions src/transformers/field.transform.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down Expand Up @@ -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);
});
});
Expand Down
36 changes: 18 additions & 18 deletions src/transformers/field.transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,72 +26,72 @@ export const transformField: Transformer = field => {
const knownWidgets = field.widget as DecapWidgetType;
const applyTransform = (field: Decap.CmsField, transformer: Transformer<any>): 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;
};
Loading

0 comments on commit 42052f4

Please sign in to comment.