Files
payloadcms/src/bin/generateTypes.ts
2022-08-13 13:04:24 -04:00

415 lines
12 KiB
TypeScript

/* eslint-disable no-nested-ternary */
import fs from 'fs';
import type { JSONSchema4 } from 'json-schema';
import { compile } from 'json-schema-to-typescript';
import Logger from '../utilities/logger';
import { fieldAffectsData, Field, Option, FieldAffectingData } from '../fields/config/types';
import { SanitizedCollectionConfig } from '../collections/config/types';
import { SanitizedConfig } from '../config/types';
import loadConfig from '../config/load';
import { SanitizedGlobalConfig } from '../globals/config/types';
import deepCopyObject from '../utilities/deepCopyObject';
const nonOptionalFieldTypes = ['group', 'array', 'blocks'];
const propertyIsOptional = (field: Field) => {
return fieldAffectsData(field) && (field.required === true || nonOptionalFieldTypes.includes(field.type));
};
function getCollectionIDType(collections: SanitizedCollectionConfig[], slug: string): 'string' | 'number' {
const matchedCollection = collections.find((collection) => collection.slug === slug);
const customIdField = matchedCollection.fields.find((field) => 'name' in field && field.name === 'id');
if (customIdField && customIdField.type === 'number') {
return 'number';
}
return 'string';
}
function returnOptionEnums(options: Option[]): string[] {
return options.map((option) => {
if (typeof option === 'object' && 'value' in option) {
return option.value;
}
return option;
});
}
function generateFieldTypes(config: SanitizedConfig, fields: Field[]): {
properties: {
[k: string]: JSONSchema4;
}
required: string[]
} {
let topLevelProps = [];
let requiredTopLevelProps = [];
return {
properties: Object.fromEntries(
fields.reduce((properties, field) => {
let fieldSchema: JSONSchema4;
switch (field.type) {
case 'text':
case 'textarea':
case 'code':
case 'email':
case 'date': {
fieldSchema = { type: 'string' };
break;
}
case 'number': {
fieldSchema = { type: 'number' };
break;
}
case 'checkbox': {
fieldSchema = { type: 'boolean' };
break;
}
case 'richText': {
fieldSchema = {
type: 'array',
items: {
type: 'object',
},
};
break;
}
case 'radio': {
fieldSchema = {
type: 'string',
enum: returnOptionEnums(field.options),
};
break;
}
case 'select': {
const selectType: JSONSchema4 = {
type: 'string',
enum: returnOptionEnums(field.options),
};
if (field.hasMany) {
fieldSchema = {
type: 'array',
items: selectType,
};
} else {
fieldSchema = selectType;
}
break;
}
case 'point': {
fieldSchema = {
type: 'array',
minItems: 2,
maxItems: 2,
items: [
{
type: 'number',
},
{
type: 'number',
},
],
};
break;
}
case 'relationship': {
if (Array.isArray(field.relationTo)) {
if (field.hasMany) {
fieldSchema = {
type: 'array',
items: {
oneOf: field.relationTo.map((relation) => {
const idFieldType = getCollectionIDType(config.collections, relation);
return {
type: 'object',
additionalProperties: false,
properties: {
value: {
oneOf: [
{
type: idFieldType,
},
{
$ref: `#/definitions/${relation}`,
},
],
},
relationTo: {
const: relation,
},
},
required: ['value', 'relationTo'],
};
}),
},
};
} else {
fieldSchema = {
oneOf: field.relationTo.map((relation) => {
const idFieldType = getCollectionIDType(config.collections, relation);
return {
type: 'object',
additionalProperties: false,
properties: {
value: {
oneOf: [
{
type: idFieldType,
},
{
$ref: `#/definitions/${relation}`,
},
],
},
relationTo: {
const: relation,
},
},
required: ['value', 'relationTo'],
};
}),
};
}
} else {
const idFieldType = getCollectionIDType(config.collections, field.relationTo);
if (field.hasMany) {
fieldSchema = {
type: 'array',
items: {
oneOf: [
{
type: idFieldType,
},
{
$ref: `#/definitions/${field.relationTo}`,
},
],
},
};
} else {
fieldSchema = {
oneOf: [
{
type: idFieldType,
},
{
$ref: `#/definitions/${field.relationTo}`,
},
],
};
}
}
break;
}
case 'upload': {
const idFieldType = getCollectionIDType(config.collections, field.relationTo);
fieldSchema = {
oneOf: [
{
type: idFieldType,
},
{
$ref: `#/definitions/${field.relationTo}`,
},
],
};
break;
}
case 'blocks': {
fieldSchema = {
type: 'array',
items: {
oneOf: field.blocks.map((block) => {
const blockSchema = generateFieldTypes(config, block.fields);
return {
type: 'object',
additionalProperties: false,
properties: {
...blockSchema.properties,
blockType: {
const: block.slug,
},
},
required: [
'blockType',
...blockSchema.required,
],
};
}),
},
};
break;
}
case 'array': {
fieldSchema = {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
...generateFieldTypes(config, field.fields),
},
};
break;
}
case 'row':
case 'collapsible': {
const topLevelFields = generateFieldTypes(config, field.fields);
requiredTopLevelProps = requiredTopLevelProps.concat(topLevelFields.required);
topLevelProps = topLevelProps.concat(Object.entries(topLevelFields.properties).map((prop) => prop));
break;
}
case 'tabs': {
field.tabs.forEach((tab) => {
const topLevelFields = generateFieldTypes(config, tab.fields);
requiredTopLevelProps = requiredTopLevelProps.concat(topLevelFields.required);
topLevelProps = topLevelProps.concat(Object.entries(topLevelFields.properties).map((prop) => prop));
});
break;
}
case 'group': {
fieldSchema = {
type: 'object',
additionalProperties: false,
...generateFieldTypes(config, field.fields),
};
break;
}
default: {
break;
}
}
if (fieldSchema && fieldAffectsData(field)) {
return [
...properties,
[
field.name,
{
...fieldSchema,
},
],
];
}
return [
...properties,
...topLevelProps,
];
}, []),
),
required: [
...fields
.filter(propertyIsOptional)
.map((field) => (fieldAffectsData(field) ? field.name : '')),
...requiredTopLevelProps,
],
};
}
function entityToJsonSchema(config: SanitizedConfig, incomingEntity: SanitizedCollectionConfig | SanitizedGlobalConfig): JSONSchema4 {
const entity = deepCopyObject(incomingEntity);
const title = 'label' in entity ? entity.label : entity.labels.singular;
const idField: FieldAffectingData = { type: 'text', name: 'id', required: true };
const customIdField = entity.fields.find((field) => fieldAffectsData(field) && field.name === 'id') as FieldAffectingData;
if (customIdField) {
customIdField.required = true;
} else {
entity.fields.unshift(idField);
}
if ('timestamps' in entity && entity.timestamps !== false) {
entity.fields.push(
{
type: 'text',
name: 'createdAt',
required: true,
},
{
type: 'text',
name: 'updatedAt',
required: true,
},
);
}
return {
title,
type: 'object',
additionalProperties: false,
...generateFieldTypes(config, entity.fields),
};
}
function configToJsonSchema(config: SanitizedConfig): JSONSchema4 {
return {
definitions: Object.fromEntries(
[
...config.globals.map((global) => [
global.slug,
entityToJsonSchema(config, global),
]),
...config.collections.map((collection) => [
collection.slug,
entityToJsonSchema(config, collection),
]),
],
),
additionalProperties: false,
};
}
export function generateTypes(): void {
const logger = Logger();
const config = loadConfig();
const outputFile = process.env.PAYLOAD_TS_OUTPUT_PATH || config.typescript.outputFile;
logger.info('Compiling TS types for Collections and Globals...');
const jsonSchema = configToJsonSchema(config);
compile(jsonSchema, 'Config', {
unreachableDefinitions: true,
bannerComment: '/* tslint:disable */\n/**\n* This file was automatically generated by Payload CMS.\n* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,\n* and re-run `payload generate:types` to regenerate this file.\n*/',
style: {
singleQuote: true,
},
}).then((compiled) => {
fs.writeFileSync(outputFile, compiled);
logger.info(`Types written to ${outputFile}`);
});
}
// when generateTypes.js is launched directly
if (module.id === require.main.id) {
generateTypes();
}