182 lines
7 KiB
JavaScript
182 lines
7 KiB
JavaScript
/* 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</.test(text)) return { type: 'array' }
|
|
if (/CSSProperties|Record<|object/.test(text)) return { type: 'object' }
|
|
return { type: 'string' }
|
|
}
|
|
|
|
const commonProperties = [
|
|
{ name: 'className', tsType: 'string', type: 'string', required: false },
|
|
{ name: 'children', tsType: 'ReactNode', type: 'string', required: false },
|
|
{ name: 'style', tsType: 'CSSProperties', type: 'object', required: false },
|
|
]
|
|
|
|
const metadata = {}
|
|
for (const componentName of [...new Set(componentNames)]) {
|
|
const interfaceName = componentTypeOverrides[componentName] || `${componentName}Props`
|
|
const entry = interfaces.get(interfaceName)
|
|
if (!entry) continue
|
|
const { node, sourceFile } = entry
|
|
const defaults = defaultsByFile.get(sourceFile.fileName) || {}
|
|
const extendsCommonProps = node.heritageClauses?.some((clause) =>
|
|
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.`)
|