diff --git a/package.json b/package.json index e9b9703f6c..376e3695d8 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "build": "loom build", "build-consumer": "loom build && ./scripts/build-consumer.sh", "build-consumer-spin": "loom build && ./scripts/build-consumer-spin.sh", - "generate-definition": "node ./scripts/generator.js", + "generate-definitions": "node ./scripts/generator.js", + "generate-definitions:admin": "yarn generate-definitions packages/ui-extensions/src/surfaces/admin/components.d.ts ../web/areas/clients/admin-web/app/shared/domains/extensibility/ui-extensions/components ../web/areas/clients/admin-web/app/shared/domains/extensibility/ui-extensions/definitionTemplate.txt", "clean": "git clean -xdf ./packages; rm -rf ./build", "predeploy": "yarn build", "deploy": "changeset publish", diff --git a/packages/ui-extensions/src/surfaces/admin/components.d.ts b/packages/ui-extensions/src/surfaces/admin/components.d.ts index 3e435a9fd8..dfe97895c7 100644 --- a/packages/ui-extensions/src/surfaces/admin/components.d.ts +++ b/packages/ui-extensions/src/surfaces/admin/components.d.ts @@ -30,7 +30,7 @@ interface BackgroundProps { /** * Adjust the background of the element. * - * @default: 'transparent' + * @default 'transparent' */ background?: BackgroundColorKeyword; } @@ -1779,7 +1779,7 @@ interface SectionProps$1 extends GlobalProps { * to the edge of the Section. For example, a full-width image. In this case, rely on `Box` with a padding of 'base' * to bring back the desired padding for the rest of the content. * - * @default: "auto" + * @default "auto" */ padding?: 'auto' | 'none'; } @@ -2794,6 +2794,9 @@ export type ButtonBaseProps = Required< | 'target' | 'href' | 'download' + | 'onBlur' + | 'onClick' + | 'onFocus' > >; export interface ButtonProps extends ButtonBaseProps { diff --git a/scripts/generator.js b/scripts/generator.js index a82f27881c..ab6a0620ce 100644 --- a/scripts/generator.js +++ b/scripts/generator.js @@ -4,173 +4,322 @@ const ts = require('typescript'); const fs = require('fs'); const path = require('path'); +const allSymbolNodes = new Map(); + /** * Generate definition objects to be used with `createRemoteComponent` from remote-dom * @param inputPath - path to your components definition * @param outputPath - path to save the generated definitions * @param templatePath - path to your definition template * @param components - space delimited component names - * @example: yarn generate-definition packages/ui-extensions/src/surfaces/admin/components ../web/app/shared/domains/extensibility/ui-extensions/components ../web/app/shared/domains/extensibility/ui-extensions/definitionTemplate.txt + * @example: yarn generate-definition packages/ui-extensions/src/surfaces/admin/components.d.ts ../web/areas/clients/admin-web/app/shared/domains/extensibility/ui-extensions/components ../web/areas/clients/admin-web/app/shared/domains/extensibility/ui-extensions/definitionTemplate.txt * NOTE: You will need to run prettier on the generated definitions. */ +const REQUIRED_TYPE = 'Required'; +const EXTRACT_TYPE = 'Extract'; +const PICK_TYPE = 'Pick'; +const FUNCTION_REGEX = /^on([A-Z][a-zA-Z]+$)/; + +function generate({ componentName, checker, outputRootFolder, templatePath }) { + const definition = constructFullDefinitionFromSymbol({ + symbolName: `${componentName}Props`, + checker, + }); -function generate({file, outputRootFolder, templatePath, components}) { - const componentName = file.match(/\/([^/.]+)\.ts/)?.[1]; + const outputPath = outputRootFolder + ? path.join(path.resolve(outputRootFolder), componentName) + : path.join(componentName); - if ( - !componentName || - (components.length && !components.includes(componentName)) - ) { + if (!definition) { + console.warn( + `Cannot generate definition for ${componentName}. This might not be a real component`, + ); return; } - const rootFolder = file.replace(/\/([^/.]+)\.ts/, ''); - - const program = ts.createProgram([file], {allowJs: true}); - const sourceFile = program.getSourceFile(file); - const checker = program.getTypeChecker(); - - if (!sourceFile) { - return; + // TEMPORARY - Fix for missing id until we add it to the interface for components + definition.properties.id = { type: "'string'" }; + // Strip out empty events + if (!Object.keys(definition.events).length) { + delete definition.events; } - const fileSymbol = checker.getSymbolAtLocation(sourceFile); - if (!fileSymbol || !fileSymbol.exports) { - return; - } + fs.mkdir(outputPath, { recursive: true }, (err) => { + if (err) throw err; + }); - const componentPropsSymbol = fileSymbol.exports.get(`${componentName}Props`); + fs.writeFile( + path.join(outputPath, 'definition.ts'), + construct( + { + componentName, + definition, + propType: definition.generic + ? `${componentName}Props` + : `${componentName}Props`, + }, + templatePath, + ), + function (err) { + if (err) { + return console.log('Failed to write definition: ', err); + } + console.log(`Definition generated for ${componentName}`); + }, + ); +} - if (!componentPropsSymbol) { - return; +function parseComplexType({ type, checker }) { + if (type.kind === ts.SyntaxKind.ExpressionWithTypeArguments) { + if (type.expression.escapedText === REQUIRED_TYPE) { + const parsedExpression = getParsedExpression({ + type: type.typeArguments[0], + checker, + }); + + return parsedExpression; + } else { + const fullDefininition = constructFullDefinitionFromSymbol({ + symbolName: type.expression.escapedText, + checker, + }); + return fullDefininition; + } + } else if (type.kind === ts.SyntaxKind.TypeReference) { + if (type.typeName.escapedText === REQUIRED_TYPE) { + const referenceType = type.typeArguments[0]; + // This has type arguments so we need to parse, for example Require> + if (referenceType.typeArguments) { + const parsedExpression = getParsedExpression({ + type: referenceType, + checker, + }); + return parsedExpression; + } else { + const definition = constructFullDefinitionFromSymbol({ + symbolName: referenceType.typeName.escapedText, + checker, + }); + return definition; + } + } else if (type.typeName.escapedText === EXTRACT_TYPE) { + const parsedExpression = getParsedExpression({ + type, + checker, + }); + return parsedExpression; + } } +} - // console.log(`${componentName} ->>>`); +function parseDeclarations({ declarations, checker }) { + return declarations.reduce((acc, declaration) => { + if (!declaration) { + return acc; + } - const localSymbols = fileSymbol.valueDeclaration.locals; + let combinedDeclarations = acc; - const nodeType = checker.getDeclaredTypeOfSymbol(componentPropsSymbol); - let all = getChildDefinition({ - symbol: componentPropsSymbol, - nodeType, - checker, - }); + if (declaration.heritageClauses) { + const heritageDeclations = parseDeclarations({ + declarations: declaration.heritageClauses, + checker, + }); + + combinedDeclarations = deepMergeDefinition( + combinedDeclarations, + heritageDeclations, + ); + } - const declarations = componentPropsSymbol.getDeclarations(); + const hasComplexExtend = + declaration.types && declaration.token === ts.SyntaxKind.ExtendsKeyword; - if (declarations) { - const types = declarations - .flatMap((node) => node.heritageClauses) - .flatMap((clause) => clause?.types) - .filter(Boolean); + if (hasComplexExtend) { + const parsedTypes = declaration.types.reduce((acc2, type) => { + // Parse expression + const parsedDefinition = parseComplexType({ + type, + checker, + }); - all = { - ...all, - ...types.reduce((acc, type) => { - if (!type) { - return acc; - } + return deepMergeDefinition(acc2, parsedDefinition); + }, {}); + return deepMergeDefinition(combinedDeclarations, parsedTypes); + } - const localSymbol = localSymbols.get(type.expression.escapedText); + // Handle other complex type + if ( + declaration.type && + declaration.type.kind === ts.SyntaxKind.TypeReference + ) { + const parsedExpression = parseComplexType({ + type: declaration.type, + checker, + }); + return deepMergeDefinition(combinedDeclarations, parsedExpression); + } - if (!localSymbol) { - return acc; - } + return combinedDeclarations; + }, {}); +} - const localNodeType = checker.getDeclaredTypeOfSymbol(localSymbol); - const inheritedDefinition = getChildDefinition({ - symbol: localNodeType.symbol, - nodeType: localNodeType, - checker, - fileSymbol, - skipGeneric: true, - }); +function constructFullDefinitionFromSymbol({ symbolName, checker }) { + const cache = allSymbolNodes.get(symbolName); + if (cache?.definition) { + return cache.definition; + } - // Skip generic definitions for inherited types as we only need to worry about the root - if (inheritedDefinition.generic) { - return acc; - } + // console.log('START constructFullDefinitionFromSymbol -->', symbolName); + const node = cache?.node; + if (!node) { + return; + } - return { - ...acc, - ...inheritedDefinition, - }; - }, {}), - }; + const symbol = node.symbol; + if (!symbol) { + return; } - const definition = Object.keys(all).reduce((acc, key) => { - if (!all[key]) { - return acc; - } + const nodeType = checker.getDeclaredTypeOfSymbol(symbol); + let events = {}; + const symbolProperties = + getChildDefinition({ + symbol, + nodeType, + checker, + }) || {}; - if (all[key].generic) { - acc.generic = true; - return acc; - } + let all = symbolProperties; + const declarations = symbol.getDeclarations(); - if (all[key].slot) { - if (!Object.prototype.hasOwnProperty.call(acc, 'slots')) { - acc.slots = []; - } + if (declarations) { + const declarationsDefinitions = parseDeclarations({ + declarations, + checker, + }); - acc.slots.push(`'${key}'`); - return acc; - } + all = { + ...all, + ...(declarationsDefinitions.properties || {}), + }; + events = declarationsDefinitions.events || {}; + } - // If this is an event, push to the events array and skip adding it to prop - if (typeof all[key].event === 'string') { - if (!Object.prototype.hasOwnProperty.call(acc, 'events')) { - acc.events = []; + const definition = Object.keys(all).reduce( + (acc, key) => { + if (!all[key]) { + return acc; } - acc.events.push(`'${all[key].event}'`); - return acc; - } - - if (!Object.prototype.hasOwnProperty.call(acc, 'properties')) { - acc.properties = {}; - } - acc.properties[key] = all[key]; + if (all[key].generic) { + acc.generic = true; + return acc; + } - return acc; - }, {}); + if (all[key].slot) { + if (!Object.prototype.hasOwnProperty.call(acc, 'slots')) { + acc.slots = []; + } - const outputFolder = outputRootFolder - ? path.join(path.resolve(outputRootFolder), componentName, 'definition.ts') - : path.join(rootFolder, 'definition.ts'); + acc.slots.push(`'${key}'`); + return acc; + } - fs.writeFile( - outputFolder, - construct( - { - componentName, - definition, - propType: definition.generic - ? `${componentName}Props` - : `${componentName}Props`, - }, - templatePath, - ), - function (err) { - if (err) { - return console.log('Failed to write definition: ', err); + // If this is an event, push to the events array and skip adding it to prop + if (typeof all[key].event === 'string') { + // This is just set to an empty placeholder because by defaults most events handlers don't need special logic + acc.events[key] = {}; + return acc; } - console.log(`Definition generated for ${componentName}`); + + acc.properties[key] = all[key]; + + return acc; }, + { properties: {}, events }, ); + + // Save definition for reuse + allSymbolNodes.set(symbolName, { node, definition }); + // console.log('JSON -->', JSON.stringify(definition)); + // console.log('END constructFullDefinitionFromSymbol -->', symbolName); + return definition; } -function getChildDefinition({symbol, nodeType, checker, skipGeneric = false}) { +function getChildDefinition({ symbol, checker, skipGeneric = false }) { + const stringEnumMap = new Map(); + const node = allSymbolNodes.get(symbol.name)?.node; + if (!node) { + return; + } + const nodeType = checker.getTypeAtLocation(node); + + allSymbolNodes.get(symbol.name)?.node.forEachChild((child) => { + const defaultTags = ts.getAllJSDocTags(child, (tag) => { + return tag.tagName.escapedText === 'default'; + }); + + if (defaultTags.length && defaultTags[0].comment) { + const childType = checker.getTypeAtLocation(child); + const values = []; + const interpolationRules = []; + if (childType.isUnion()) { + for (const type of childType.types) { + const childValue = checker.typeToString(type, child); + if (/`\${.*}.*`$/.test(childValue)) { + interpolationRules.push(childValue.replace(/`/g, "'")); + } else { + values.push(checker.typeToString(type, child).replace(/"/g, "'")); + } + } + } + // NOTE: This only works for string default values for now + // Trim non-value comments + const trimmedDefault = defaultTags[0].comment + .trim() + .replace(/^(?:'|")([a-zA-Z]*)(?:'|")(?:(?:\n|.)*)$/, "'$1'"); + stringEnumMap.set(child.symbol.escapedName, { + default: trimmedDefault, + values, + interpolationRules, + }); + } + }); + if (symbol.members) { return Array.from(symbol.members.entries()).reduce( (acc, [name, subSymbols]) => { - // console.log('name --->', name); + const subSymbolDeclaredType = subSymbols.valueDeclaration?.type; + const isIndexedAccessType = + subSymbolDeclaredType?.kind === ts.SyntaxKind.IndexedAccessType; + + if (isIndexedAccessType) { + const referenceSymbolName = + subSymbolDeclaredType.objectType.typeName.escapedText; + const propName = subSymbolDeclaredType.indexType.literal.text; + + const referenceDefinition = constructFullDefinitionFromSymbol({ + symbolName: referenceSymbolName, + checker, + }); + if (!referenceDefinition.properties) { + console.warn('missing --->', referenceSymbolName); + } + if (propName && referenceDefinition.properties?.[propName]) { + return { + ...acc, + [name]: referenceDefinition.properties[propName], + }; + } + } + const definition = getDefinition({ symbol: subSymbols, checker, skipGeneric, name, + stringEnumDefinition: stringEnumMap.get(name), }); if (!definition || name === 'children') { @@ -191,12 +340,12 @@ function getChildDefinition({symbol, nodeType, checker, skipGeneric = false}) { ...acc, ...symbols.reduce((subTypes, subSymbol) => { const name = checker.symbolToString(subSymbol); - // console.log('name --->', name); const definition = getDefinition({ symbol: subSymbol, checker, skipGeneric, name, + stringEnumDefinition: stringEnumMap.get(name), }); if (!definition || name === 'children') { @@ -213,18 +362,34 @@ function getChildDefinition({symbol, nodeType, checker, skipGeneric = false}) { } } -function getDefinition({symbol, checker, skipGeneric, name}) { +function getDefinition({ + symbol, + checker, + skipGeneric, + name, + stringEnumDefinition, +}) { const symbolType = checker.getTypeOfSymbolAtLocation( symbol, symbol.valueDeclaration, ); - - const kind = checker.typeToTypeNode(symbolType).kind; + const node = checker.typeToTypeNode(symbolType); + const kind = node.kind; const propType = checker.typeToString(symbolType); const baseLiteralType = checker.typeToString( checker.getBaseTypeOfLiteralType(symbol), ); + if (symbol.valueDeclaration?.type) { + const parsedExpression = getParsedExpression({ + type: symbol.valueDeclaration.type, + checker, + }); + if (parsedExpression) { + return parsedExpression.properties[name] || parsedExpression.events[name]; + } + } + // console.log('kind -->', kind); // console.log('propType -->', propType); // console.log('baseLiteralType -->', baseLiteralType); @@ -235,7 +400,7 @@ function getDefinition({symbol, checker, skipGeneric, name}) { } if (kind === ts.SyntaxKind.AnyKeyword) { - return skipGeneric ? undefined : {generic: true}; + return skipGeneric ? undefined : { generic: true }; } const isSlot = @@ -245,30 +410,33 @@ function getDefinition({symbol, checker, skipGeneric, name}) { const isFunction = kind === ts.SyntaxKind.FunctionType; if (isSlot) { - return {slot: true}; + return { slot: true }; } if (isFunction) { - const matchHandler = name.match(/^on([A-Z][a-zA-Z]+$)/); + const matchHandler = name.match(FUNCTION_REGEX); if (matchHandler && typeof matchHandler[1] === 'string') { - return {event: matchHandler[1].toLowerCase()}; + return { event: name }; } } if (kind === ts.SyntaxKind.ArrayType) { - return {type: "'array'"}; + return { type: "'array'" }; } if (kind === ts.SyntaxKind.TypeLiteral) { - return {type: "'object'"}; + return { type: "'object'" }; } - if ( - kind === ts.SyntaxKind.TypeReference || - kind === ts.SyntaxKind.Identifier || - kind === ts.SyntaxKind.LiteralType - ) { - return {type: `'${baseLiteralType}'`}; + if (kind === ts.SyntaxKind.TypeReference) { + if (stringEnumDefinition && baseLiteralType === 'string') { + return { type: "'stringEnum'", ...stringEnumDefinition }; + } + return { type: `'${baseLiteralType}'` }; + } + + if (kind === ts.SyntaxKind.Identifier || kind === ts.SyntaxKind.LiteralType) { + return { type: `'${baseLiteralType}'` }; } if (kind === ts.SyntaxKind.UnionType) { @@ -278,7 +446,7 @@ function getDefinition({symbol, checker, skipGeneric, name}) { // This is a simple union type, likely a union string if (!types.length) { - return {type: `'${baseLiteralType}'`}; + return { type: `'${baseLiteralType}'` }; } let unionTypes = []; @@ -320,14 +488,18 @@ function getDefinition({symbol, checker, skipGeneric, name}) { if (uniqueTypes.length > 1) { return { type: "'union'", - options: uniqueTypes.map((type) => ({type: `'${type}'`})), + options: uniqueTypes.map((type) => ({ type: `'${type}'` })), }; } - return {type: `'${uniqueTypes[0]}'`}; + if (unionTypes[0] === 'string' && stringEnumDefinition) { + return { type: "'stringEnum'", ...stringEnumDefinition }; + } + + return { type: `'${uniqueTypes[0]}'` }; } - return {type: `'${propType}'`}; + return { type: `'${propType}'` }; } function construct(parts, templatePath) { @@ -348,7 +520,7 @@ function construct(parts, templatePath) { }); } -function getUnionTypesFromTypeReference({type, checker}) { +function getUnionTypesFromTypeReference({ type, checker }) { if ( !type || !type.typeName || @@ -394,20 +566,136 @@ function getUnionTypesFromTypeReference({type, checker}) { }); } -const rootFolder = process.argv[2]; +function getParsedExpression({ type, checker }) { + const typeReference = type.typeArguments?.[0]; + const expressionType = type.typeArguments?.[1]; + if (!typeReference || !expressionType) { + return; + } + + switch (type.typeName.escapedText) { + case PICK_TYPE: { + const properties = {}; + const events = {}; + const referenceDefinition = constructFullDefinitionFromSymbol({ + symbolName: typeReference.typeName.escapedText, + checker, + }); + + if ( + !referenceDefinition.properties || + expressionType.kind !== ts.SyntaxKind.UnionType + ) { + return; + } + + expressionType.types.forEach((t) => { + if (referenceDefinition.properties[t.literal.text]) { + properties[t.literal.text] = + referenceDefinition.properties[t.literal.text]; + } else if (referenceDefinition.events[t.literal.text]) { + events[t.literal.text] = referenceDefinition.events[t.literal.text]; + } + }); + + return { properties, events }; + } + case EXTRACT_TYPE: { + if ( + typeReference.indexType && + typeReference.objectType && + expressionType.kind === ts.SyntaxKind.UnionType + ) { + // Handles union strings like `Extract` + const referenceDefinition = constructFullDefinitionFromSymbol({ + symbolName: typeReference.objectType.typeName.escapedText, + checker, + }); + const propIndex = typeReference.indexType.literal.text; + + if (!referenceDefinition.properties) { + return; + } + + const properties = { + [propIndex]: { + type: referenceDefinition.properties[propIndex].type, + default: referenceDefinition.properties[propIndex].default, + values: expressionType.types.map((t) => `'${t.literal.text}'`), + }, + }; + return { properties, events: {} }; + } else if (expressionType.kind === ts.SyntaxKind.TypeLiteral) { + // Handles inline declarations like `Extract` + // In this case the typeReference we passed in already has the correct interface + const referenceDefinition = constructFullDefinitionFromSymbol({ + symbolName: typeReference.typeName.escapedText, + checker, + }); + return referenceDefinition; + } + } + } +} + +function deepMergeDefinition(org = {}, addition = {}) { + if (!addition) { + return org; + } + const { properties = {}, events = {} } = org; + const merged = { + properties: { ...properties, ...(addition.properties || {}) }, + events: { ...events, ...(addition.events || {}) }, + }; + return merged; +} + +const filePath = process.argv[2]; const outputRootFolder = process.argv[3]; const templatePath = process.argv[4]; const components = process.argv.slice(5); -fs.readdir(rootFolder, (_error, files) => { - files - .filter((file) => file !== 'shared') - .forEach((file) => +fs.readFile(filePath, () => { + const program = ts.createProgram([filePath], { allowJs: true }); + const sourceFile = program.getSourceFile(filePath); + const checker = program.getTypeChecker(); + + if (!sourceFile) { + return; + } + + const allSymbols = checker.getSymbolAtLocation(sourceFile); + if (!allSymbols || !allSymbols.exports) { + return; + } + + ts.forEachChild(sourceFile, (node) => { + const symbol = checker.getSymbolAtLocation(node.name); + if (symbol) { + allSymbolNodes.set(symbol.name, { node }); + } + }); + + const componentClassRegex = new RegExp( + /declare class ([a-zA-Z]*)(?:(?:\n| )*)extends (?:[a-zA-Z]*)(?:(?:\n| )*)implements/g, + ); + const fileContent = sourceFile.getText(); + const componentsList = []; + let match; + while ((match = componentClassRegex.exec(fileContent)) !== null) { + componentsList.push(match[1]); + } + Array.from(componentsList) + .filter((componentName) => { + return !components.length || components.includes(componentName); + }) + .forEach((componentName) => { + // console.log(`${componentName} ->>>`); generate({ - file: path.join(rootFolder, file, `${file}.ts`), + checker, + componentName, outputRootFolder, templatePath, - components, - }), - ); + }); + }); });