/* global console, process */ import fs from 'node:fs' import path from 'node:path' import prettier from 'prettier' import ts from 'typescript' import { fileURLToPath } from 'node:url' const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)) const uiRoot = path.resolve(scriptDirectory, '..') const sourceRoot = path.join(uiRoot, 'src', 'components', 'ui') const outputFile = path.join(sourceRoot, '..', 'visualDesigner', 'generated', 'componentProps.json') const componentTypeOverrides = { Calendar: 'CalenderProps', ScrollBar: 'ScrollbarProps', } const namespaceOptions = { 'TypeAttributes.Size': ['lg', 'md', 'sm', 'xs'], 'TypeAttributes.ControlSize': ['lg', 'md', 'sm', 'xs'], 'TypeAttributes.Shape': ['round', 'circle', 'none'], 'TypeAttributes.Status': ['success', 'warning', 'danger', 'info'], 'TypeAttributes.FormLayout': ['horizontal', 'vertical', 'inline'], 'TypeAttributes.MenuVariant': ['light', 'dark', 'themed', 'transparent'], 'TypeAttributes.Direction': ['ltr', 'rtl'], } const walkFiles = (directory) => fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const target = path.join(directory, entry.name) if (entry.isDirectory()) return walkFiles(target) return /\.(ts|tsx)$/.test(entry.name) ? [target] : [] }) const sourceFiles = walkFiles(sourceRoot) const metadataInputs = [...sourceFiles, fileURLToPath(import.meta.url)] const outputIsCurrent = !process.argv.includes('--force') && fs.existsSync(outputFile) && fs.statSync(outputFile).mtimeMs >= Math.max(...metadataInputs.map((fileName) => fs.statSync(fileName).mtimeMs)) if (outputIsCurrent) { console.log('Designer component metadata is up to date.') process.exit(0) } const program = ts.createProgram(sourceFiles, { target: ts.ScriptTarget.Latest, jsx: ts.JsxEmit.ReactJSX, }) const interfaces = new Map() const defaultsByFile = new Map() const literalValue = (node, sourceFile) => { if (!node) return undefined if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text if (ts.isNumericLiteral(node)) return Number(node.text) if (node.kind === ts.SyntaxKind.TrueKeyword) return true if (node.kind === ts.SyntaxKind.FalseKeyword) return false if (node.kind === ts.SyntaxKind.NullKeyword) return null if (ts.isArrayLiteralExpression(node)) { const values = node.elements.map((element) => literalValue(element, sourceFile)) return values.some((value) => value === undefined) ? undefined : values } if (ts.isObjectLiteralExpression(node)) { const result = {} for (const property of node.properties) { if (!ts.isPropertyAssignment(property)) return undefined const name = property.name.getText(sourceFile).replace(/^['"]|['"]$/g, '') const value = literalValue(property.initializer, sourceFile) if (value === undefined) return undefined result[name] = value } return result } return undefined } for (const sourceFile of program.getSourceFiles()) { if (!path.resolve(sourceFile.fileName).startsWith(path.resolve(sourceRoot))) continue const fileDefaults = {} const visit = (node) => { if (ts.isInterfaceDeclaration(node) && node.name.text.endsWith('Props')) { interfaces.set(node.name.text, { node, sourceFile }) } if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name)) { for (const element of node.name.elements) { if (!ts.isIdentifier(element.name) || !element.initializer) continue const value = literalValue(element.initializer, sourceFile) if (value !== undefined) fileDefaults[element.name.text] = value } } ts.forEachChild(node, visit) } visit(sourceFile) defaultsByFile.set(sourceFile.fileName, fileDefaults) } const indexSource = program.getSourceFile(path.join(sourceRoot, 'index.ts')) if (!indexSource) throw new Error('UI component index could not be found.') const componentNames = [] for (const statement of indexSource.statements) { if (!ts.isExportDeclaration(statement) || statement.isTypeOnly || !statement.exportClause) continue if (!ts.isNamedExports(statement.exportClause)) continue for (const element of statement.exportClause.elements) componentNames.push(element.name.text) } const unionOptions = (typeNode, sourceFile) => { if (!typeNode) return [] const typeText = typeNode.getText(sourceFile) if (namespaceOptions[typeText]) return namespaceOptions[typeText] if (!ts.isUnionTypeNode(typeNode)) return [] return typeNode.types .filter((type) => ts.isLiteralTypeNode(type) && ts.isStringLiteral(type.literal)) .map((type) => type.literal.text) } const propertyType = (typeNode, sourceFile) => { const text = typeNode?.getText(sourceFile) || 'unknown' const options = unionOptions(typeNode, sourceFile) if (options.length) return { type: 'select', options } if (text.includes('=>') || text.startsWith('MouseEventHandler')) return { type: 'function' } if (/\bboolean\b/.test(text)) return { type: 'boolean' } if (/\bnumber\b/.test(text)) return { type: 'number' } if (/\[\]|Array<|ReadonlyArray clause.types.some((type) => type.expression.getText(sourceFile) === 'CommonProps'), ) const properties = [] for (const member of node.members) { if (!ts.isPropertySignature(member) || !member.name) continue const name = member.name.getText(sourceFile).replace(/^['"]|['"]$/g, '') const typeInfo = propertyType(member.type, sourceFile) properties.push({ name, tsType: member.type?.getText(sourceFile) || 'unknown', ...typeInfo, required: !member.questionToken, ...(defaults[name] !== undefined ? { defaultValue: defaults[name] } : {}), }) } if (extendsCommonProps) { for (const property of commonProperties) { if (!properties.some((item) => item.name === property.name)) properties.push(property) } } metadata[componentName] = { interfaceName, sourceFile: path.relative(uiRoot, sourceFile.fileName).replaceAll('\\', '/'), properties, } } fs.mkdirSync(path.dirname(outputFile), { recursive: true }) const formattedMetadata = await prettier.format(JSON.stringify(metadata), { parser: 'json' }) fs.writeFileSync(outputFile, formattedMetadata) console.log(`Generated designer metadata for ${Object.keys(metadata).length} UI components.`)