From 6c9b50058669db3813dfdbee0a05fef5f89dab5c Mon Sep 17 00:00:00 2001 From: Convly Date: Fri, 17 Jun 2022 19:00:27 +0200 Subject: [PATCH] Update generator. Use typescript compiler API to generate definitions --- packages/core/strapi/bin/strapi.js | 2 +- .../commands/content-types/generate-types.js | 20 ++ .../generate-types/attributes.js | 145 ----------- .../content-types/generate-types/global.js | 21 -- .../content-types/generate-types/imports.js | 26 -- .../content-types/generate-types/index.js | 167 ------------ .../content-types/generate-types/schemas.js | 45 ---- .../content-types/generate-types/utils.js | 79 ------ .../lib/types/core/attributes/common.d.ts | 2 +- .../lib/types/core/attributes/relation.d.ts | 2 +- .../utils/typescript/lib/generators/index.js | 7 + .../lib/generators/schemas/attributes.js | 241 ++++++++++++++++++ .../lib/generators/schemas/global.js | 68 +++++ .../lib/generators/schemas/imports.js | 33 +++ .../lib/generators/schemas/index.js | 173 +++++++++++++ .../lib/generators/schemas/schema.js | 87 +++++++ .../lib/generators/schemas/utils.js | 145 +++++++++++ packages/utils/typescript/lib/index.js | 2 + packages/utils/typescript/package.json | 8 +- yarn.lock | 7 +- 20 files changed, 791 insertions(+), 489 deletions(-) create mode 100644 packages/core/strapi/lib/commands/content-types/generate-types.js delete mode 100644 packages/core/strapi/lib/commands/content-types/generate-types/attributes.js delete mode 100644 packages/core/strapi/lib/commands/content-types/generate-types/global.js delete mode 100644 packages/core/strapi/lib/commands/content-types/generate-types/imports.js delete mode 100644 packages/core/strapi/lib/commands/content-types/generate-types/index.js delete mode 100644 packages/core/strapi/lib/commands/content-types/generate-types/schemas.js delete mode 100644 packages/core/strapi/lib/commands/content-types/generate-types/utils.js create mode 100644 packages/utils/typescript/lib/generators/index.js create mode 100644 packages/utils/typescript/lib/generators/schemas/attributes.js create mode 100644 packages/utils/typescript/lib/generators/schemas/global.js create mode 100644 packages/utils/typescript/lib/generators/schemas/imports.js create mode 100644 packages/utils/typescript/lib/generators/schemas/index.js create mode 100644 packages/utils/typescript/lib/generators/schemas/schema.js create mode 100644 packages/utils/typescript/lib/generators/schemas/utils.js diff --git a/packages/core/strapi/bin/strapi.js b/packages/core/strapi/bin/strapi.js index a996eeeb23..ced3c49d18 100755 --- a/packages/core/strapi/bin/strapi.js +++ b/packages/core/strapi/bin/strapi.js @@ -221,7 +221,7 @@ program 'Specify a relative directory in which the schemas definitions will be generated' ) .option('-f, --file ', 'Specify a filename to store the schemas definitions') - .option('-s, --silence', `Don't display debug information`, false) + .option('-d, --debug', `Display debug information`, false) .action(getLocalScript('content-types/generate-types')); program diff --git a/packages/core/strapi/lib/commands/content-types/generate-types.js b/packages/core/strapi/lib/commands/content-types/generate-types.js new file mode 100644 index 0000000000..7e64056b24 --- /dev/null +++ b/packages/core/strapi/lib/commands/content-types/generate-types.js @@ -0,0 +1,20 @@ +'use strict'; + +const tsUtils = require('@strapi/typescript-utils'); + +const strapi = require('../../index'); + +module.exports = async function({ outDir, file, debug }) { + const appContext = await strapi.compile(); + const app = await strapi(appContext).register(); + + await tsUtils.generators.generateSchemasDefinitions({ + strapi: app, + outDir: outDir || appContext.appDir, + file, + dirs: appContext, + debug, + }); + + app.destroy(); +}; diff --git a/packages/core/strapi/lib/commands/content-types/generate-types/attributes.js b/packages/core/strapi/lib/commands/content-types/generate-types/attributes.js deleted file mode 100644 index ad854d2206..0000000000 --- a/packages/core/strapi/lib/commands/content-types/generate-types/attributes.js +++ /dev/null @@ -1,145 +0,0 @@ -'use strict'; - -const fp = require('lodash/fp'); - -const { addImport } = require('./imports'); -const { logWarning, toType } = require('./utils'); - -const generateAttributesDefinition = (attributes, uid) => { - const attributesDefinitions = []; - - for (const [attributeName, attribute] of Object.entries(attributes)) { - const type = getAttributeType(attributeName, attribute, uid); - - attributesDefinitions.push([attributeName, type]); - } - - const formattedDefinitions = attributesDefinitions - .map(([name, attributeType]) => ` ${name}: ${attributeType};`) - .join('\n'); - - return ` attributes: { -${formattedDefinitions} - }`; -}; - -const getAttributeType = (attributeName, attribute, uid) => { - const mappers = { - string() { - return ['StringAttribute', null]; - }, - text() { - return ['TextAttribute', null]; - }, - richtext() { - return ['RichTextAttribute', null]; - }, - password() { - return ['PasswordAttribute', null]; - }, - email() { - return ['EmailAttribute', null]; - }, - date() { - return ['DateAttribute', null]; - }, - time() { - return ['TimeAttribute', null]; - }, - datetime() { - return ['DateTimeAttribute', null]; - }, - timestamp() { - return ['TimestampAttribute', null]; - }, - integer() { - return ['IntegerAttribute', null]; - }, - biginteger() { - return ['BigIntegerAttribute', null]; - }, - float() { - return ['FloatAttribute', null]; - }, - decimal() { - return ['DecimalAttribute', null]; - }, - uid() { - return ['UIDAttribute', null]; - }, - enumeration() { - return ['EnumerationAttribute', null]; - }, - boolean() { - return ['BooleanAttribute', null]; - }, - json() { - return ['JsonAttribute', null]; - }, - media() { - return ['MediaAttribute', null]; - }, - relation() { - const { relation, target } = attribute; - - if (relation.includes('morph') | relation.includes('Morph')) { - return ['PolymorphicRelationAttribute', [`'${uid}'`, `'${relation}'`]]; - } - - return ['RelationAttribute', [`'${uid}'`, `'${relation}'`, `'${target}'`]]; - }, - component() { - const target = attribute.component; - const params = [`'${target}'`]; - - if (attribute.repeatable) { - params.push(true); - } - - return ['ComponentAttribute', params]; - }, - dynamiczone() { - const components = JSON.stringify(attribute.components); - - return ['DynamicZoneAttribute', [components]]; - }, - }; - - if (!Object.keys(mappers).includes(attribute.type)) { - logWarning( - `"${attributeName}" attribute from "${uid}" has an invalid type: "${attribute.type}"` - ); - - return null; - } - - let [attributeType, typeParams] = mappers[attribute.type](); - - addImport(attributeType); - - let type = typeParams ? `${attributeType}<${typeParams.join(', ')}>` : attributeType; - - if (attribute.required) { - addImport('RequiredAttribute'); - - type = `${type} & RequiredAttribute`; - } - - if (attribute.pluginOptions && !fp.isEmpty(attribute.pluginOptions)) { - addImport('PluginOptionsAttribute'); - - const pluginOptionsType = toType(attribute.pluginOptions, { - inline: true, - indent: 0, - }); - - type = `${type} & PluginOptionsAttribute<${pluginOptionsType}>`; - } - - return type; -}; - -module.exports = { - generateAttributesDefinition, - getAttributeType, -}; diff --git a/packages/core/strapi/lib/commands/content-types/generate-types/global.js b/packages/core/strapi/lib/commands/content-types/generate-types/global.js deleted file mode 100644 index 1da1174f76..0000000000 --- a/packages/core/strapi/lib/commands/content-types/generate-types/global.js +++ /dev/null @@ -1,21 +0,0 @@ -'use strict'; - -const generateGlobalDefinition = definitions => { - const formattedSchemasDefinitions = definitions - .map(({ uid, type }) => ` '${uid}': ${type}`) - .join('\n'); - - return ` -declare global { - namespace Strapi { - interface Schemas { -${formattedSchemasDefinitions} - } - } -} -`; -}; - -module.exports = { - generateGlobalDefinition, -}; diff --git a/packages/core/strapi/lib/commands/content-types/generate-types/imports.js b/packages/core/strapi/lib/commands/content-types/generate-types/imports.js deleted file mode 100644 index 108f42c7aa..0000000000 --- a/packages/core/strapi/lib/commands/content-types/generate-types/imports.js +++ /dev/null @@ -1,26 +0,0 @@ -'use strict'; - -const imports = []; - -module.exports = { - getImports() { - return imports; - }, - - addImport(type) { - const hasType = imports.includes(type); - - if (!hasType) { - imports.push(type); - } - }, - - generateImports() { - const formattedImports = imports.map(p => ` ${p}`).join(',\n'); - - return `import { -${formattedImports} -} from '@strapi/strapi'; -`; - }, -}; diff --git a/packages/core/strapi/lib/commands/content-types/generate-types/index.js b/packages/core/strapi/lib/commands/content-types/generate-types/index.js deleted file mode 100644 index 2496e1fb2a..0000000000 --- a/packages/core/strapi/lib/commands/content-types/generate-types/index.js +++ /dev/null @@ -1,167 +0,0 @@ -'use strict'; - -const path = require('path'); - -const CLITable = require('cli-table3'); -const chalk = require('chalk'); -const fp = require('lodash/fp'); -const fse = require('fs-extra'); - -const tsUtils = require('@strapi/typescript-utils'); - -const createStrapiInstance = require('../../../index'); - -const { generateSchemaDefinition } = require('./schemas'); -const { generateGlobalDefinition } = require('./global'); -const { generateImports } = require('./imports'); -const { logWarning, getSchemaTypeName } = require('./utils'); - -module.exports = async function({ outDir, file, silence }) { - const [app, dirs] = await setup(); - - const schemas = getAllStrapiSchemas(app); - - const table = createInfoTable(); - - const definitions = generateTypesDefinitions(schemas); - const globalDefinition = generateGlobalDefinition(definitions); - const imports = generateImports(); - - const fullDefinition = [ - imports + '\n', - definitions.map(fp.get('definition')).join('\n'), - globalDefinition, - ].join(''); - - await generateSchemaFile(outDir || dirs.app, fullDefinition, file); - - for (const definition of definitions) { - const isValidDefinition = definition.definition !== null; - const validateAndTransform = isValidDefinition ? fp.identity : chalk.redBright; - - table.push([ - validateAndTransform(definition.kind), - validateAndTransform(definition.uid), - validateAndTransform(definition.type), - isValidDefinition ? chalk.greenBright('✓') : chalk.redBright('✗'), - ]); - } - - const successfullDefinition = fp.filter(d => !fp.isNil(d.definition), definitions); - const skippedDefinition = fp.filter(d => fp.isNil(d.definition), definitions); - - if (!silence) { - console.log(table.toString()); - console.log( - chalk.greenBright( - `Generated ${fp.size( - successfullDefinition - )} type definition for your Strapi application's schemas.` - ) - ); - - const skippedAmount = fp.size(skippedDefinition); - - if (skippedAmount > 0) { - console.log( - chalk.redBright( - `Skipped ${skippedAmount} (${skippedDefinition.map(d => d.uid).join(', ')})` - ) - ); - } - } - - app.destroy(); -}; - -/** - * Setup a Strapi application based on the current process directory - * @returns {Promise<[Strapi, { app: string, dist: string }]>} - */ -const setup = async () => { - const dirs = { app: process.cwd(), dist: process.cwd() }; - - const isTSProject = await tsUtils.isUsingTypeScript(dirs.app); - - if (isTSProject) { - await tsUtils.compile(dirs.app, { configOptions: { options: { incremental: true } } }); - - dirs.dist = await tsUtils.resolveOutDir(dirs.app); - } - - const app = await createStrapiInstance({ appDir: dirs.app, distDir: dirs.dist }).register(); - - return [app, dirs]; -}; - -/** - * Generate all the TypeScript definitions for the app schemas - * @param {Object} schemas - */ -const generateTypesDefinitions = schemas => { - const definitions = []; - - for (const [uid, schema] of Object.entries(schemas)) { - const { modelType, kind } = schema; - // Schema UID -> Interface Name - const type = getSchemaTypeName(uid); - - let definition; - - const isComponent = modelType === 'component'; - const isContentType = - modelType === 'contentType' && ['singleType', 'collectionType'].includes(kind); - - // Handle components and content types - if (isComponent || isContentType) { - definition = generateSchemaDefinition(uid, schema, type) + '\n'; - } - - // Other - else { - logWarning(`"${uid}" has an invalid model definition. Skipping...`); - definition = null; - } - - // Add the generated definition to the list - definitions.push({ - type, - uid, - schema, - definition, - kind: fp.upperFirst(modelType === 'component' ? 'component' : kind), - }); - } - - return definitions; -}; - -/** - * Generate the content-types definitions file based on the given arguments - * @param {string} dir - * @param {string} definition - * @param {string} [file] - */ -const generateSchemaFile = async (dir, definition, file) => { - const filePath = path.join(dir, file || 'schemas.d.ts'); - - await fse.writeFile(filePath, definition); -}; - -/** - * Get all content types and components loaded in the Strapi instance - * - * @param {Strapi} app - * @returns {Object} - */ -const getAllStrapiSchemas = app => ({ ...app.contentTypes, ...app.components }); - -/** - * Create a new info table for the content types definitions - */ -const createInfoTable = () => { - return new CLITable({ - head: [chalk.green('Model Type'), chalk.blue('UID'), chalk.blue('Type'), chalk.gray('Status')], - colAligns: ['center', 'left', 'left', 'center'], - }); -}; diff --git a/packages/core/strapi/lib/commands/content-types/generate-types/schemas.js b/packages/core/strapi/lib/commands/content-types/generate-types/schemas.js deleted file mode 100644 index ea4b5d9a4a..0000000000 --- a/packages/core/strapi/lib/commands/content-types/generate-types/schemas.js +++ /dev/null @@ -1,45 +0,0 @@ -'use strict'; - -const fp = require('lodash/fp'); - -const { generateAttributesDefinition } = require('./attributes'); -const { addImport } = require('./imports'); -const { toType } = require('./utils'); - -const getBaseSchema = schema => { - const { modelType, kind } = schema; - - // Component - if (modelType === 'component') { - return 'ComponentSchema'; - } - - // Content Type - else if (modelType === 'contentType') { - return `${fp.upperFirst(kind)}Schema`; - } - - return null; -}; - -const getFormatOptions = prefix => ({ indentStart: 2, suffix: ';', prefix }); - -const generateSchemaDefinition = (uid, schema, type) => { - const baseInterface = getBaseSchema(schema); - - addImport(baseInterface); - - const propertiesKeys = ['info', 'options', 'pluginOptions']; - - const definitionBody = propertiesKeys - .map(key => toType(fp.get(key, schema), getFormatOptions(key))) - .concat(generateAttributesDefinition(schema.attributes, uid)) - .filter(def => !fp.isNil(def)) - .join(''); - - return `interface ${type} extends ${baseInterface} {\n${definitionBody}\n}`; -}; - -module.exports = { - generateSchemaDefinition, -}; diff --git a/packages/core/strapi/lib/commands/content-types/generate-types/utils.js b/packages/core/strapi/lib/commands/content-types/generate-types/utils.js deleted file mode 100644 index 07adf4a373..0000000000 --- a/packages/core/strapi/lib/commands/content-types/generate-types/utils.js +++ /dev/null @@ -1,79 +0,0 @@ -'use strict'; - -const chalk = require('chalk'); -const fp = require('lodash/fp'); - -const logWarning = message => { - console.log(chalk.yellow(`[${new Date().toLocaleTimeString()}] (warning):\t${message}`)); -}; - -const getSchemaTypeName = fp.flow(fp.replace(/(:.)/, ' '), fp.camelCase, fp.upperFirst); - -const toType = (object, formatOptions = {}) => { - const { - prefix = null, - suffix = null, - inline = false, - indent = 2, - indentStart = 0, - } = formatOptions; - - if (fp.isNil(object) || fp.isEmpty(object)) { - return null; - } - - let definition = ''; - - const properties = Object.entries(object); - - const lineBreak = (s = '') => (inline ? s : '\n'); - const offset = (m = 0) => ' '.repeat(indentStart + indent * m); - const getPrefix = (s = '') => (prefix ? `${prefix}: ` : s); - const getSuffix = (s = '') => suffix || s; - - for (const [key, value] of properties) { - const validKey = key.includes('-') ? `'${key}'` : key; - - let row; - - // TODO: Handle arrays types - - // Handle recursive types (objects) - if (fp.isObject(value) && !fp.isEmpty(value)) { - row = toType(value, { - prefix: validKey, - indentStart: indentStart + indent, - suffix, - inline, - indent, - }); - } - - // All non-recursive types are handled there - else { - let type = value; - - if (fp.isString(value)) { - type = `'${value}'`; - } - - row = `${offset(1)}${validKey}: ${type};${lineBreak(' ')}`; - } - - definition += row; - } - - if (!inline) { - definition = definition.slice(0, definition.length - 1); - } - - return `${offset()}${getPrefix()}{${lineBreak(' ')}${definition}${lineBreak( - '' - )}${offset()}}${getSuffix()}${lineBreak()}`; -}; - -module.exports = { - logWarning, - getSchemaTypeName, - toType, -}; diff --git a/packages/core/strapi/lib/types/core/attributes/common.d.ts b/packages/core/strapi/lib/types/core/attributes/common.d.ts index 50cb9a759d..44dd6cf27f 100644 --- a/packages/core/strapi/lib/types/core/attributes/common.d.ts +++ b/packages/core/strapi/lib/types/core/attributes/common.d.ts @@ -36,7 +36,7 @@ export type SetMinMax, U = number> = T; export type SetMinMaxLength = T; // pluginOptions -export type SetAttributePluginOptions = { pluginOptions?: T }; +export type SetPluginOptions = { pluginOptions?: T }; // default export type DefaultTo = { default: T }; diff --git a/packages/core/strapi/lib/types/core/attributes/relation.d.ts b/packages/core/strapi/lib/types/core/attributes/relation.d.ts index 9f518bb66d..0003825a3f 100644 --- a/packages/core/strapi/lib/types/core/attributes/relation.d.ts +++ b/packages/core/strapi/lib/types/core/attributes/relation.d.ts @@ -26,7 +26,7 @@ export interface PolymorphicRelationAttributeProperties< export type RelationAttribute< S extends SchemaUID, R extends RelationsType, - T extends SchemaUID + T extends R extends PolymorphicRelationsType ? never: SchemaUID = never > = Attribute<'relation'> & // Properties (R extends BasicRelationsType diff --git a/packages/utils/typescript/lib/generators/index.js b/packages/utils/typescript/lib/generators/index.js new file mode 100644 index 0000000000..f7fb9e53a7 --- /dev/null +++ b/packages/utils/typescript/lib/generators/index.js @@ -0,0 +1,7 @@ +'use strict'; + +const generateSchemasDefinitions = require('./schemas'); + +module.exports = { + generateSchemasDefinitions, +}; diff --git a/packages/utils/typescript/lib/generators/schemas/attributes.js b/packages/utils/typescript/lib/generators/schemas/attributes.js new file mode 100644 index 0000000000..3830772b41 --- /dev/null +++ b/packages/utils/typescript/lib/generators/schemas/attributes.js @@ -0,0 +1,241 @@ +'use strict'; + +const { factory } = require('typescript'); +const fp = require('lodash/fp'); + +const { addImport } = require('./imports'); +const { getTypeNode, toTypeLitteral } = require('./utils'); + +/** + * Generate a property signature node for a given attribute + * + * @param {object} schema + * @param {string} attributeName + * @param {object} attribute + * @returns {object} + */ +const attributeToPropertySignature = (schema, attributeName, attribute) => { + const baseType = getAttributeType(attributeName, attribute, schema.uid); + const modifiers = getAttributeModifiers(attributeName, attribute); + + const nodes = [baseType, ...modifiers]; + + return factory.createPropertySignature( + undefined, + factory.createIdentifier(attributeName), + undefined, + factory.createIntersectionTypeNode(nodes) + ); +}; + +/** + * Create the base type node for a given attribute + * + * @param {string} attributeName + * @param {object} attribute + * @param {string} uid + * @returns {object} + */ +const getAttributeType = (attributeName, attribute, uid) => { + const mappers = { + string() { + return ['StringAttribute']; + }, + text() { + return ['TextAttribute']; + }, + richtext() { + return ['RichTextAttribute']; + }, + password() { + return ['PasswordAttribute']; + }, + email() { + return ['EmailAttribute']; + }, + date() { + return ['DateAttribute']; + }, + time() { + return ['TimeAttribute']; + }, + datetime() { + return ['DateTimeAttribute']; + }, + timestamp() { + return ['TimestampAttribute']; + }, + integer() { + return ['IntegerAttribute']; + }, + biginteger() { + return ['BigIntegerAttribute']; + }, + float() { + return ['FloatAttribute']; + }, + decimal() { + return ['DecimalAttribute']; + }, + uid() { + return ['UIDAttribute']; + }, + enumeration() { + return ['EnumerationAttribute']; + }, + boolean() { + return ['BooleanAttribute']; + }, + json() { + return ['JSONAttribute']; + }, + media() { + return ['MediaAttribute']; + }, + relation() { + const { relation, target } = attribute; + + if (relation.includes('morph') | relation.includes('Morph')) { + return [ + 'RelationAttribute', + [factory.createStringLiteral(uid, true), factory.createStringLiteral(relation, true)], + ]; + } + + return [ + 'RelationAttribute', + [ + factory.createStringLiteral(uid, true), + factory.createStringLiteral(relation, true), + factory.createStringLiteral(target, true), + ], + ]; + }, + component() { + const target = attribute.component; + const params = [factory.createStringLiteral(target, true)]; + + if (attribute.repeatable) { + params.push(factory.createTrue()); + } + + return ['ComponentAttribute', params]; + }, + dynamiczone() { + const componentsParam = factory.createTupleTypeNode( + attribute.components.map(component => factory.createStringLiteral(component)) + ); + + return ['DynamicZoneAttribute', [componentsParam]]; + }, + }; + + if (!Object.keys(mappers).includes(attribute.type)) { + console.warning( + `"${attributeName}" attribute from "${uid}" has an invalid type: "${attribute.type}"` + ); + + return null; + } + + const [attributeType, typeParams] = mappers[attribute.type](); + + addImport(attributeType); + + return getTypeNode(attributeType, typeParams); +}; + +/** + * Collect every modifier node from an attribute + * + * @param {string} _attributeName + * @param {object} attribute + * @returns {object[]} + */ +const getAttributeModifiers = (_attributeName, attribute) => { + const modifiers = []; + + // Required + if (attribute.required) { + addImport('RequiredAttribute'); + + modifiers.push(factory.createTypeReferenceNode(factory.createIdentifier('RequiredAttribute'))); + } + + // Private + if (attribute.private) { + addImport('PrivateAttribute'); + + modifiers.push(factory.createTypeReferenceNode(factory.createIdentifier('PrivateAttribute'))); + } + + // Unique + if (attribute.unique) { + addImport('UniqueAttribute'); + + modifiers.push(factory.createTypeReferenceNode(factory.createIdentifier('UniqueAttribute'))); + } + + // Configurable + if (attribute.configurable) { + addImport('ConfigurableAttribute'); + + modifiers.push( + factory.createTypeReferenceNode(factory.createIdentifier('ConfigurableAttribute')) + ); + } + + // Plugin Options + if (!fp.isEmpty(attribute.pluginOptions)) { + addImport('SetPluginOptions'); + + modifiers.push( + factory.createTypeReferenceNode( + factory.createIdentifier('SetPluginOptions'), + // Transform the pluginOptions object into an object litteral expression + [toTypeLitteral(attribute.pluginOptions)] + ) + ); + } + + // Min / Max + if (!fp.isNil(attribute.min) || !fp.isNil(attribute.max)) { + addImport('SetMinMax'); + + const minMaxProperties = fp.pick(['min', 'max'], attribute); + + modifiers.push( + factory.createTypeReferenceNode(factory.createIdentifier('SetMinMax'), [ + toTypeLitteral(minMaxProperties), + ]) + ); + } + + // Min length / Max length + if (!fp.isNil(attribute.minLength) || !fp.isNil(attribute.maxLength)) { + addImport('SetMinMaxLength'); + + const minMaxProperties = fp.pick(['minLength', 'maxLength'], attribute); + + modifiers.push( + factory.createTypeReferenceNode(factory.createIdentifier('SetMinMaxLength'), [ + toTypeLitteral(minMaxProperties), + ]) + ); + } + + // Default + if (!fp.isNil(attribute.default)) { + addImport('DefaultTo'); + + const defaultLitteral = toTypeLitteral(attribute.default); + + modifiers.push( + factory.createTypeReferenceNode(factory.createIdentifier('DefaultTo'), [defaultLitteral]) + ); + } + + return modifiers; +}; + +module.exports = attributeToPropertySignature; diff --git a/packages/utils/typescript/lib/generators/schemas/global.js b/packages/utils/typescript/lib/generators/schemas/global.js new file mode 100644 index 0000000000..dad8efa266 --- /dev/null +++ b/packages/utils/typescript/lib/generators/schemas/global.js @@ -0,0 +1,68 @@ +'use strict'; + +const ts = require('typescript'); +const { factory } = require('typescript'); + +const { getSchemaInterfaceName } = require('./utils'); + +/** + * Generate the global module augmentation block + * + * @param {Array<{ schema: object; definition: ts.TypeNode }>} schemasDefinitions + * @returns {ts.ModuleDeclaration} + */ +const generateGlobalDefinition = (schemasDefinitions = []) => { + const properties = schemasDefinitions.map(schemaDefinitionToPropertySignature); + + return factory.createModuleDeclaration( + undefined, + [factory.createModifier(ts.SyntaxKind.DeclareKeyword)], + factory.createIdentifier('global'), + factory.createModuleBlock([ + factory.createModuleDeclaration( + undefined, + undefined, + factory.createIdentifier('Strapi'), + factory.createModuleBlock([ + factory.createInterfaceDeclaration( + undefined, + undefined, + factory.createIdentifier('Schemas'), + undefined, + undefined, + properties + ), + ]), + ts.NodeFlags.Namespace | + ts.NodeFlags.ExportContext | + ts.NodeFlags.Ambient | + ts.NodeFlags.ContextFlags + ), + ]), + ts.NodeFlags.ExportContext | + ts.NodeFlags.GlobalAugmentation | + ts.NodeFlags.Ambient | + ts.NodeFlags.ContextFlags + ); +}; + +/** + * + * @param {object} schemaDefinition + * @param {ts.InterfaceDeclaration} schemaDefinition.definition + * @param {object} schemaDefinition.schema + */ +const schemaDefinitionToPropertySignature = ({ schema }) => { + const { uid } = schema; + + const interfaceTypeName = getSchemaInterfaceName(uid); + + return factory.createPropertySignature( + undefined, + factory.createStringLiteral(uid, true), + undefined, + factory.createTypeReferenceNode(factory.createIdentifier(interfaceTypeName)) + ); +}; + +module.exports = { generateGlobalDefinition }; diff --git a/packages/utils/typescript/lib/generators/schemas/imports.js b/packages/utils/typescript/lib/generators/schemas/imports.js new file mode 100644 index 0000000000..f842259bfa --- /dev/null +++ b/packages/utils/typescript/lib/generators/schemas/imports.js @@ -0,0 +1,33 @@ +'use strict'; + +const { factory } = require('typescript'); + +const imports = []; + +module.exports = { + getImports() { + return imports; + }, + + addImport(type) { + const hasType = imports.includes(type); + + if (!hasType) { + imports.push(type); + } + }, + + generateImportDefinition() { + const formattedImports = imports.map(key => + factory.createImportSpecifier(false, undefined, factory.createIdentifier(key)) + ); + + return factory.createImportDeclaration( + undefined, + undefined, + factory.createImportClause(false, undefined, factory.createNamedImports(formattedImports)), + factory.createStringLiteral('@strapi/strapi'), + undefined + ); + }, +}; diff --git a/packages/utils/typescript/lib/generators/schemas/index.js b/packages/utils/typescript/lib/generators/schemas/index.js new file mode 100644 index 0000000000..acb9c51e60 --- /dev/null +++ b/packages/utils/typescript/lib/generators/schemas/index.js @@ -0,0 +1,173 @@ +'use strict'; + +const path = require('path'); + +const ts = require('typescript'); +const { factory } = require('typescript'); + +const fp = require('lodash/fp'); +const fse = require('fs-extra'); +const prettier = require('prettier'); +const chalk = require('chalk'); +const CLITable = require('cli-table3'); + +const { generateImportDefinition } = require('./imports'); +const { generateSchemaDefinition } = require('./schema'); +const { generateGlobalDefinition } = require('./global'); +const { + getAllStrapiSchemas, + getSchemaInterfaceName, + getSchemaModelType, + getDefinitionAttributesCount, +} = require('./utils'); + +const DEFAULT_OUT_FILENAME = 'schemas.d.ts'; + +/** + * Generate type definitions for Strapi schemas + * + * @param {object} options + * @param {Strapi} options.strapi + * @param {{ distDir: string; appDir: string; }} options.dirs + * @param {string} [options.outDir] + * @param {string} [options.file] + * @param {boolean} [options.debug] + */ +const generateSchemasDefinitions = async (options = {}) => { + const { strapi, outDir = process.cwd(), file = DEFAULT_OUT_FILENAME, debug = true } = options; + + const schemas = getAllStrapiSchemas(strapi); + + const schemasDefinitions = Object.values(schemas).map(schema => ({ + schema, + definition: generateSchemaDefinition(schema), + })); + + const allDefinitions = [ + // Imports + generateImportDefinition(), + + // Add a newline after the import statement + factory.createIdentifier('\n'), + + // Schemas + ...schemasDefinitions.reduce( + (acc, def) => [ + ...acc, + def.definition, + // Add a newline between each interface declaration + factory.createIdentifier('\n'), + ], + [] + ), + + // Global + generateGlobalDefinition(schemasDefinitions), + ]; + + const output = emitDefinitions(allDefinitions); + const formattedOutput = await format(output); + + const definitionFilepath = await saveDefinitionToFileSystem(outDir, file, formattedOutput); + + if (debug) { + logDebugInformation(schemasDefinitions, { filepath: definitionFilepath }); + } +}; + +const emitDefinitions = definitions => { + const nodeArray = factory.createNodeArray(definitions); + + const sourceFile = ts.createSourceFile( + 'placeholder.ts', + '', + ts.ScriptTarget.ESNext, + true, + ts.ScriptKind.TS + ); + + const printer = ts.createPrinter({ newLine: true, omitTrailingSemicolon: true }); + + return printer.printList(ts.ListFormat.MultiLine, nodeArray, sourceFile); +}; + +const saveDefinitionToFileSystem = async (dir, file, content) => { + const filepath = path.join(dir, file); + + await fse.writeFile(filepath, content); + + return filepath; +}; + +/** + * Format the given definitions. + * Uses the existing config if one is defined in the project. + * + * @param {string} content + * @returns {string} + */ +const format = async content => { + const configFile = await prettier.resolveConfigFile(); + const config = configFile + ? await prettier.resolveConfig(configFile) + : // Default config + { + singleQuote: true, + useTabs: false, + tabWidth: 2, + }; + + Object.assign(config, { parser: 'typescript' }); + + return prettier.format(content, config); +}; + +const logDebugInformation = (definitions, options = {}) => { + const { filepath } = options; + + const table = new CLITable({ + head: [ + chalk.bold(chalk.green('Model Type')), + chalk.bold(chalk.blue('UID')), + chalk.bold(chalk.blue('Type')), + chalk.bold(chalk.gray('Attributes Count')), + ], + colAligns: ['center', 'left', 'left', 'center'], + }); + + const sortedDefinitions = definitions.map(def => ({ + ...def, + attributesCount: getDefinitionAttributesCount(def.definition), + })); + + for (const { schema, attributesCount } of sortedDefinitions) { + const modelType = fp.upperFirst(getSchemaModelType(schema)); + const interfaceType = getSchemaInterfaceName(schema.uid); + + table.push([ + chalk.greenBright(modelType), + chalk.blue(schema.uid), + chalk.blue(interfaceType), + chalk.grey(fp.isNil(attributesCount) ? 'N/A' : attributesCount), + ]); + } + + // Table + console.log(table.toString()); + + // Metrics + console.log( + chalk.greenBright( + `Generated ${definitions.length} type definition for your Strapi application's schemas.` + ) + ); + + // Filepath + const relativePath = path.relative(process.cwd(), filepath); + + console.log( + chalk.grey(`The definitions file has been generated here: ${chalk.bold(relativePath)}`) + ); +}; + +module.exports = generateSchemasDefinitions; diff --git a/packages/utils/typescript/lib/generators/schemas/schema.js b/packages/utils/typescript/lib/generators/schemas/schema.js new file mode 100644 index 0000000000..77d2c29e65 --- /dev/null +++ b/packages/utils/typescript/lib/generators/schemas/schema.js @@ -0,0 +1,87 @@ +'use strict'; + +const ts = require('typescript'); +const { factory } = require('typescript'); +const { isEmpty } = require('lodash/fp'); + +const { getSchemaExtendsTypeName, getSchemaInterfaceName, toTypeLitteral } = require('./utils'); +const attributeToPropertySignature = require('./attributes'); +const { addImport } = require('./imports'); + +/** + * Generate an interface declaration for a given schema + * + * @param {object} schema + * @returns {ts.InterfaceDeclaration} + */ +const generateSchemaDefinition = schema => { + const { uid } = schema; + + // Resolve the different interface names needed to declare the schema's interface + const interfaceName = getSchemaInterfaceName(uid); + const parentType = getSchemaExtendsTypeName(schema); + + // Make sure the extended interface are imported + addImport(parentType); + + // Properties whose values can be mapped to a litteral type expression + const litteralPropertiesDefinitions = ['info', 'options', 'pluginOptions'] + // Ignore non-existent or empty declarations + .filter(key => !isEmpty(schema[key])) + // Generate litteral definition for each property + .map(generatePropertyLitteralDefinitionFactory(schema)); + + // Generate the `attributes` litteral type definition + const attributesProp = generateAttributePropertySignature(schema); + + // Merge every schema's definition in a single list + const schemaProperties = [...litteralPropertiesDefinitions, attributesProp]; + + // Generate the schema's interface declaration + const schemaType = factory.createInterfaceDeclaration( + undefined, + [factory.createModifier(ts.SyntaxKind.ExportKeyword)], + factory.createIdentifier(interfaceName), + undefined, + [ + factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [ + factory.createIdentifier(parentType), + ]), + ], + schemaProperties + ); + + return schemaType; +}; + +/** + * Generate a property signature for the schema's `attributes` field + * + * @param {object} schema + * @returns {ts.PropertySignature} + */ +const generateAttributePropertySignature = schema => { + const { attributes } = schema; + + const properties = Object.entries(attributes).map(([attributeName, attribute]) => { + return attributeToPropertySignature(schema, attributeName, attribute); + }); + + return factory.createPropertySignature( + undefined, + factory.createIdentifier('attributes'), + undefined, + factory.createTypeLiteralNode(properties) + ); +}; + +const generatePropertyLitteralDefinitionFactory = schema => key => { + return factory.createPropertySignature( + undefined, + factory.createIdentifier(key), + undefined, + toTypeLitteral(schema[key]) + ); +}; + +module.exports = { generateSchemaDefinition }; diff --git a/packages/utils/typescript/lib/generators/schemas/utils.js b/packages/utils/typescript/lib/generators/schemas/utils.js new file mode 100644 index 0000000000..ded39ef033 --- /dev/null +++ b/packages/utils/typescript/lib/generators/schemas/utils.js @@ -0,0 +1,145 @@ +'use strict'; + +const { factory } = require('typescript'); +const { + flow, + replace, + camelCase, + upperFirst, + isObject, + isString, + isNumber, + isArray, + isBoolean, + propEq, +} = require('lodash/fp'); + +/** + * Get all components and content-types in a Strapi application + * + * @param {Strapi} strapi + * @returns {object} + */ +const getAllStrapiSchemas = strapi => ({ ...strapi.contentTypes, ...strapi.components }); + +/** + * Extract a valid interface name from a schema uid + * + * @param {string} uid + * @returns {string} + */ +const getSchemaInterfaceName = flow(replace(/(:.)/, ' '), camelCase, upperFirst); + +/** + * Get the parent type name to extend based on the schema's nature + * + * @param {object} schema + * @returns {string} + */ +const getSchemaExtendsTypeName = schema => { + const base = getSchemaModelType(schema); + + return upperFirst(base) + 'Schema'; +}; + +const getSchemaModelType = schema => { + const { modelType, kind } = schema; + + // Components + if (modelType === 'component') { + return 'component'; + } + + // Content-Types + else if (modelType === 'contentType') { + return kind; + } + + return null; +}; + +/** + * Get a type node based on a type and its params + * + * @param {string} typeName + * @param {ts.TypeNode[]} [params] + * @returns + */ +const getTypeNode = (typeName, params = []) => { + return factory.createTypeReferenceNode(factory.createIdentifier(typeName), params); +}; + +/** + * Transform a regular JavaScript object into an object litteral expression + * @param {object} data + * @returns {ts.ObjectLiteralExpression} + */ +const toTypeLitteral = data => { + if (isString(data)) { + return factory.createStringLiteral(data, true); + } + + if (isNumber(data)) { + return factory.createNumericLiteral(data); + } + + if (isBoolean(data)) { + return data ? factory.createTrue() : factory.createFalse(); + } + + if (isArray(data)) { + return factory.createTupleTypeNode(data.map(item => toTypeLitteral(item))); + } + + if (!isObject(data)) { + throw new Error('Cannot convert to object litteral. Unknown type', typeof data); + } + + const entries = Object.entries(data); + + const props = entries.reduce((acc, [key, value]) => { + // Handle keys such as content-type-builder & co. + const identifier = key.includes('-') + ? factory.createStringLiteral(key, true) + : factory.createIdentifier(key); + + return [ + ...acc, + factory.createPropertyDeclaration( + undefined, + undefined, + identifier, + undefined, + toTypeLitteral(value) + ), + ]; + }, []); + + return factory.createTypeLiteralNode(props); +}; + +/** + * Get the number of attributes generated for a given schema definition + * + * @param {ts.TypeNode} definition + * @returns {number | null} + */ +const getDefinitionAttributesCount = definition => { + const attributesNode = definition.members.find(propEq('name.escapedText', 'attributes')); + + if (!attributesNode) { + return null; + } + + return attributesNode.type.members.length; +}; + +module.exports = { + getAllStrapiSchemas, + getSchemaInterfaceName, + getSchemaExtendsTypeName, + getSchemaModelType, + getDefinitionAttributesCount, + getTypeNode, + toTypeLitteral, +}; diff --git a/packages/utils/typescript/lib/index.js b/packages/utils/typescript/lib/index.js index 2a551b7524..524db1adef 100644 --- a/packages/utils/typescript/lib/index.js +++ b/packages/utils/typescript/lib/index.js @@ -4,11 +4,13 @@ const compile = require('./compile'); const compilers = require('./compilers'); const admin = require('./admin'); const utils = require('./utils'); +const generators = require('./generators'); module.exports = { compile, compilers, admin, + generators, ...utils, }; diff --git a/packages/utils/typescript/package.json b/packages/utils/typescript/package.json index 5fd2bd18aa..a12e6dc9b9 100644 --- a/packages/utils/typescript/package.json +++ b/packages/utils/typescript/package.json @@ -24,9 +24,13 @@ "lib": "./lib" }, "dependencies": { - "lodash": "4.17.21", + "@strapi/strapi": "4.2.0", + "typescript": "4.6.2", + "chalk": "4.1.2", + "cli-table3": "0.6.2", "fs-extra": "10.0.1", - "typescript": "4.6.2" + "lodash": "4.17.21", + "prettier": "2.7.1" }, "engines": { "node": ">=12.22.0 <=16.x.x", diff --git a/yarn.lock b/yarn.lock index f28a839b87..b541a80d92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9150,7 +9150,7 @@ cli-table3@0.6.1: optionalDependencies: colors "1.4.0" -cli-table3@^0.6.1: +cli-table3@0.6.2, cli-table3@^0.6.1: version "0.6.2" resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.2.tgz#aaf5df9d8b5bf12634dc8b3040806a0c07120d2a" integrity sha512-QyavHCaIC80cMivimWu4aWHilIpiDpfm3hGmqAmXVL1UsnbLuBSMd21hTX6VY4ZSDSM73ESLeF8TOYId3rBTbw== @@ -19341,6 +19341,11 @@ prettier@1.19.1: resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== +prettier@2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.7.1.tgz#e235806850d057f97bb08368a4f7d899f7760c64" + integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g== + "prettier@>=2.2.1 <=2.3.0": version "2.3.0" resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.0.tgz#b6a5bf1284026ae640f17f7ff5658a7567fc0d18"