From 480f96ed9eddfb35e71ef5d7257c3eb4ff455ede Mon Sep 17 00:00:00 2001 From: romhml Date: Thu, 27 Feb 2025 15:19:30 +0100 Subject: [PATCH 01/21] refactor: bring in vue-component-meta to extend checker --- meta/lib/base.cts | 932 +++++++++++++++++++++++++ meta/lib/index.cts | 27 + meta/package.json | 28 + meta/tsconfig.json | 12 + package.json | 3 +- pnpm-lock.yaml | 30 + pnpm-workspace.yaml | 1 + src/runtime/server/services/checker.ts | 2 +- 8 files changed, 1033 insertions(+), 2 deletions(-) create mode 100644 meta/lib/base.cts create mode 100644 meta/lib/index.cts create mode 100644 meta/package.json create mode 100644 meta/tsconfig.json diff --git a/meta/lib/base.cts b/meta/lib/base.cts new file mode 100644 index 00000000..ce88fc68 --- /dev/null +++ b/meta/lib/base.cts @@ -0,0 +1,932 @@ +import type { TypeScriptProjectHost } from '@volar/typescript' +import { createLanguageServiceHost, resolveFileLanguageId } from '@volar/typescript' +import * as vue from '@vue/language-core' +import path from 'node:path' +import type * as ts from 'typescript' +import { code as typeHelpersCode } from 'vue-component-type-helpers' +import { code as vue2TypeHelpersCode } from 'vue-component-type-helpers/vue2' + +import type { + ComponentMeta, + Declaration, + EventMeta, + ExposeMeta, + MetaCheckerOptions, + PropertyMeta, + PropertyMetaSchema, + SlotMeta +} from 'vue-component-meta' + +const windowsPathReg = /\\/g + +export function createCheckerByJsonConfigBase( + ts: typeof import('typescript'), + rootDir: string, + json: any, + checkerOptions: MetaCheckerOptions = {} +) { + rootDir = rootDir.replace(windowsPathReg, '/') + return baseCreate( + ts, + () => vue.createParsedCommandLineByJson(ts, ts.sys, rootDir, json, undefined, true), + checkerOptions, + rootDir, + path.join(rootDir, 'jsconfig.json.global.vue') + ) +} + +export function createCheckerBase( + ts: typeof import('typescript'), + tsconfig: string, + checkerOptions: MetaCheckerOptions = {} +) { + tsconfig = tsconfig.replace(windowsPathReg, '/') + return baseCreate( + ts, + () => vue.createParsedCommandLine(ts, ts.sys, tsconfig, true), + checkerOptions, + path.dirname(tsconfig), + tsconfig + '.global.vue' + ) +} + +export function baseCreate( + ts: typeof import('typescript'), + getCommandLine: () => vue.ParsedCommandLine, + checkerOptions: MetaCheckerOptions, + rootPath: string, + globalComponentName: string +) { + let commandLine = getCommandLine() + let fileNames = commandLine.fileNames.map(path => path.replace(windowsPathReg, '/')) + let projectVersion = 0 + + const projectHost: TypeScriptProjectHost = { + getCurrentDirectory: () => rootPath, + getProjectVersion: () => projectVersion.toString(), + getCompilationSettings: () => commandLine.options, + getScriptFileNames: () => fileNames, + getProjectReferences: () => commandLine.projectReferences + } + const globalComponentSnapshot = ts.ScriptSnapshot.fromString('') + const scriptSnapshots = new Map() + const metaSnapshots = new Map() + const getScriptFileNames = projectHost.getScriptFileNames + projectHost.getScriptFileNames = () => { + const names = getScriptFileNames() + return [ + ...names, + ...names.map(getMetaFileName), + globalComponentName, + getMetaFileName(globalComponentName) + ] + } + + const vueLanguagePlugin = vue.createVueLanguagePlugin( + ts, + projectHost.getCompilationSettings(), + commandLine.vueOptions, + id => id + ) + const language = vue.createLanguage( + [ + vueLanguagePlugin, + { + getLanguageId(fileName) { + return resolveFileLanguageId(fileName) + } + } + ], + new vue.FileMap(ts.sys.useCaseSensitiveFileNames), + (fileName) => { + let snapshot = scriptSnapshots.get(fileName) + + if (fileName === globalComponentName) { + snapshot = globalComponentSnapshot + } else if (isMetaFileName(fileName)) { + if (!metaSnapshots.has(fileName)) { + metaSnapshots.set(fileName, ts.ScriptSnapshot.fromString(getMetaScriptContent(fileName))) + } + snapshot = metaSnapshots.get(fileName) + } else { + if (!scriptSnapshots.has(fileName)) { + const fileText = ts.sys.readFile(fileName) + if (fileText !== undefined) { + scriptSnapshots.set(fileName, ts.ScriptSnapshot.fromString(fileText)) + } else { + scriptSnapshots.set(fileName, undefined) + } + } + snapshot = scriptSnapshots.get(fileName) + } + + if (snapshot) { + language.scripts.set(fileName, snapshot) + } else { + language.scripts.delete(fileName) + } + } + ) + const { languageServiceHost } = createLanguageServiceHost(ts, ts.sys, language, s => s, projectHost) + const tsLs = ts.createLanguageService(languageServiceHost) + + const directoryExists = languageServiceHost.directoryExists?.bind(languageServiceHost) + const fileExists = languageServiceHost.fileExists.bind(languageServiceHost) + const getScriptSnapshot = languageServiceHost.getScriptSnapshot.bind(languageServiceHost) + const globalTypesName = vue.getGlobalTypesFileName(commandLine.vueOptions) + const globalTypesContents = `// @ts-nocheck\nexport {};\n` + vue.generateGlobalTypes(commandLine.vueOptions) + const globalTypesSnapshot: ts.IScriptSnapshot = { + getText: (start, end) => globalTypesContents.slice(start, end), + getLength: () => globalTypesContents.length, + getChangeRange: () => undefined + } + if (directoryExists) { + languageServiceHost.directoryExists = (path) => { + if (path.endsWith('.vue-global-types')) { + return true + } + return directoryExists(path) + } + } + languageServiceHost.fileExists = (path) => { + if (path.endsWith(`.vue-global-types/${globalTypesName}`) || path.endsWith(`.vue-global-types\\${globalTypesName}`)) { + return true + } + return fileExists(path) + } + languageServiceHost.getScriptSnapshot = (path) => { + if (path.endsWith(`.vue-global-types/${globalTypesName}`) || path.endsWith(`.vue-global-types\\${globalTypesName}`)) { + return globalTypesSnapshot + } + return getScriptSnapshot(path) + } + + if (checkerOptions.forceUseTs) { + const getScriptKind = languageServiceHost.getScriptKind?.bind(languageServiceHost) + languageServiceHost.getScriptKind = (fileName) => { + const scriptKind = getScriptKind!(fileName) + if (commandLine.vueOptions.extensions.some(ext => fileName.endsWith(ext))) { + if (scriptKind === ts.ScriptKind.JS) { + return ts.ScriptKind.TS + } + if (scriptKind === ts.ScriptKind.JSX) { + return ts.ScriptKind.TSX + } + } + return scriptKind + } + } + + let globalPropNames: string[] | undefined + + return { + getExportNames, + getComponentMeta, + updateFile(fileName: string, text: string) { + fileName = fileName.replace(windowsPathReg, '/') + scriptSnapshots.set(fileName, ts.ScriptSnapshot.fromString(text)) + projectVersion++ + }, + deleteFile(fileName: string) { + fileName = fileName.replace(windowsPathReg, '/') + fileNames = fileNames.filter(f => f !== fileName) + projectVersion++ + }, + reload() { + commandLine = getCommandLine() + fileNames = commandLine.fileNames.map(path => path.replace(windowsPathReg, '/')) + this.clearCache() + }, + clearCache() { + scriptSnapshots.clear() + projectVersion++ + }, + __internal__: { + tsLs + } + } + + function isMetaFileName(fileName: string) { + return fileName.endsWith('.meta.ts') + } + + function getMetaFileName(fileName: string) { + return ( + commandLine.vueOptions.extensions.some(ext => fileName.endsWith(ext)) + ? fileName + : fileName.slice(0, fileName.lastIndexOf('.')) + ) + '.meta.ts' + } + + function getMetaScriptContent(fileName: string) { + const code = ` +import * as Components from '${fileName.slice(0, -'.meta.ts'.length)}'; +export default {} as { [K in keyof typeof Components]: ComponentMeta; }; + +interface ComponentMeta { + type: ComponentType; + props: ComponentProps; + emit: ComponentEmit; + slots: ComponentSlots; + exposed: ComponentExposed; +}; + +${commandLine.vueOptions.target < 3 ? vue2TypeHelpersCode : typeHelpersCode} +`.trim() + return code + } + + function getExportNames(componentPath: string) { + const program = tsLs.getProgram()! + const typeChecker = program.getTypeChecker() + return _getExports(program, typeChecker, componentPath).exports.map(e => e.getName()) + } + + function getComponentMeta(componentPath: string, exportName = 'default'): ComponentMeta { + const program = tsLs.getProgram()! + const typeChecker = program.getTypeChecker() + const { symbolNode, exports } = _getExports(program, typeChecker, componentPath) + const _export = exports.find(property => property.getName() === exportName) + + if (!_export) { + throw `Could not find export ${exportName}` + } + + const componentType = typeChecker.getTypeOfSymbolAtLocation(_export, symbolNode) + const symbolProperties = componentType.getProperties() ?? [] + + let _type: ReturnType | undefined + let _props: ReturnType | undefined + let _events: ReturnType | undefined + let _slots: ReturnType | undefined + let _exposed: ReturnType | undefined + + return { + get type() { + return _type ?? (_type = getType()) + }, + get props() { + return _props ?? (_props = getProps()) + }, + get events() { + return _events ?? (_events = getEvents()) + }, + get slots() { + return _slots ?? (_slots = getSlots()) + }, + get exposed() { + return _exposed ?? (_exposed = getExposed()) + } + } + + function getType() { + const $type = symbolProperties.find(prop => prop.escapedName === 'type') + + if ($type) { + const type = typeChecker.getTypeOfSymbolAtLocation($type, symbolNode) + return Number(typeChecker.typeToString(type)) + } + + return 0 + } + + function getProps() { + const $props = symbolProperties.find(prop => prop.escapedName === 'props') + const propEventRegex = /^(on[A-Z])/ + let result: PropertyMeta[] = [] + + if ($props) { + const type = typeChecker.getTypeOfSymbolAtLocation($props, symbolNode) + const properties = type.getProperties() + + result = properties + .map((prop) => { + const { + resolveNestedProperties + } = createSchemaResolvers(typeChecker, symbolNode, checkerOptions, ts, language) + + return resolveNestedProperties(prop) + }) + .filter(prop => !propEventRegex.test(prop.name)) + } + + // fill global + if (componentPath !== globalComponentName) { + globalPropNames ??= getComponentMeta(globalComponentName).props.map(prop => prop.name) + for (const prop of result) { + prop.global = globalPropNames.includes(prop.name) + } + } + + // fill defaults + const printer = ts.createPrinter(checkerOptions.printer) + const sourceScript = language.scripts.get(componentPath)! + const { snapshot } = sourceScript + + const vueFile = sourceScript.generated?.root + const vueDefaults = vueFile && exportName === 'default' + ? (vueFile instanceof vue.VueVirtualCode ? readVueComponentDefaultProps(vueFile, printer, ts) : {}) + : {} + const tsDefaults = !vueFile ? readTsComponentDefaultProps( + ts.createSourceFile( + '/tmp.' + componentPath.slice(componentPath.lastIndexOf('.') + 1), // ts | js | tsx | jsx + snapshot.getText(0, snapshot.getLength()), + ts.ScriptTarget.Latest + ), + exportName, + printer, + ts + ) : {} + + for (const [propName, defaultExp] of Object.entries({ + ...vueDefaults, + ...tsDefaults + })) { + const prop = result.find(p => p.name === propName) + if (prop) { + prop.default = defaultExp.default + + if (defaultExp.required !== undefined) { + prop.required = defaultExp.required + } + + if (prop.default !== undefined) { + prop.required = false // props with default are always optional + } + } + } + + return result + } + + function getEvents() { + const $emit = symbolProperties.find(prop => prop.escapedName === 'emit') + + if ($emit) { + const type = typeChecker.getTypeOfSymbolAtLocation($emit, symbolNode) + const calls = type.getCallSignatures() + + return calls.map((call) => { + const { + resolveEventSignature + } = createSchemaResolvers(typeChecker, symbolNode, checkerOptions, ts, language) + + return resolveEventSignature(call) + }).filter(event => event.name) + } + + return [] + } + + function getSlots() { + const $slots = symbolProperties.find(prop => prop.escapedName === 'slots') + + if ($slots) { + const type = typeChecker.getTypeOfSymbolAtLocation($slots, symbolNode) + const properties = type.getProperties() + + return properties.map((prop) => { + const { + resolveSlotProperties + } = createSchemaResolvers(typeChecker, symbolNode, checkerOptions, ts, language) + + return resolveSlotProperties(prop) + }) + } + + return [] + } + + function getExposed() { + const $exposed = symbolProperties.find(prop => prop.escapedName === 'exposed') + + if ($exposed) { + const type = typeChecker.getTypeOfSymbolAtLocation($exposed, symbolNode) + const properties = type.getProperties().filter(prop => + // only exposed props will not have a valueDeclaration + !prop.valueDeclaration + ) + + return properties.map((prop) => { + const { + resolveExposedProperties + } = createSchemaResolvers(typeChecker, symbolNode, checkerOptions, ts, language) + + return resolveExposedProperties(prop) + }) + } + + return [] + } + } + + function _getExports( + program: ts.Program, + typeChecker: ts.TypeChecker, + componentPath: string + ) { + const sourceFile = program?.getSourceFile(getMetaFileName(componentPath)) + if (!sourceFile) { + throw 'Could not find main source file' + } + + const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile) + if (!moduleSymbol) { + throw 'Could not find module symbol' + } + + const exportedSymbols = typeChecker.getExportsOfModule(moduleSymbol) + + let symbolNode: ts.Expression | undefined + + for (const symbol of exportedSymbols) { + const [declaration] = symbol.getDeclarations() ?? [] + + if (ts.isExportAssignment(declaration)) { + symbolNode = declaration.expression + } + } + + if (!symbolNode) { + throw 'Could not find symbol node' + } + + const exportDefaultType = typeChecker.getTypeAtLocation(symbolNode) + const exports = exportDefaultType.getProperties() + + return { + symbolNode, + exports + } + } +} + +function createSchemaResolvers( + typeChecker: ts.TypeChecker, + symbolNode: ts.Expression, + { rawType, schema: options, noDeclarations }: MetaCheckerOptions, + ts: typeof import('typescript'), + language: vue.Language +) { + const visited = new Set() + + function shouldIgnore(subtype: ts.Type) { + const name = typeChecker.typeToString(subtype) + if (name === 'any') { + return true + } + + if (visited.has(subtype)) { + return true + } + + if (typeof options === 'object') { + for (const item of options.ignore ?? []) { + if (typeof item === 'function') { + const result = item(name, subtype, typeChecker) + if (typeof result === 'boolean') { + return result + } + } else if (name === item) { + return true + } + } + } + + return false + } + + function reducer(acc: any, cur: any) { + acc[cur.name] = cur + return acc + } + + function resolveNestedProperties(prop: ts.Symbol): PropertyMeta { + const subtype = typeChecker.getTypeOfSymbolAtLocation(prop, symbolNode) + let schema: PropertyMetaSchema + let declarations: Declaration[] + + return { + name: prop.getEscapedName().toString(), + global: false, + description: ts.displayPartsToString(prop.getDocumentationComment(typeChecker)), + tags: prop.getJsDocTags(typeChecker).map(tag => ({ + name: tag.name, + text: tag.text !== undefined ? ts.displayPartsToString(tag.text) : undefined + })), + required: !(prop.flags & ts.SymbolFlags.Optional), + type: typeChecker.typeToString(subtype), + rawType: rawType ? subtype : undefined, + get declarations() { + return declarations ??= getDeclarations(prop.declarations ?? []) + }, + get schema() { + return schema ??= resolveSchema(subtype) + } + } + } + function resolveSlotProperties(prop: ts.Symbol): SlotMeta { + const propType = typeChecker.getNonNullableType(typeChecker.getTypeOfSymbolAtLocation(prop, symbolNode)) + const signatures = propType.getCallSignatures() + const paramType = signatures[0]?.parameters[0] + const subtype = paramType ? typeChecker.getTypeOfSymbolAtLocation(paramType, symbolNode) : typeChecker.getAnyType() + let schema: PropertyMetaSchema + let declarations: Declaration[] + + return { + name: prop.getName(), + type: typeChecker.typeToString(subtype), + rawType: rawType ? subtype : undefined, + description: ts.displayPartsToString(prop.getDocumentationComment(typeChecker)), + get declarations() { + return declarations ??= getDeclarations(prop.declarations ?? []) + }, + get schema() { + return schema ??= resolveSchema(subtype) + } + } + } + function resolveExposedProperties(expose: ts.Symbol): ExposeMeta { + const subtype = typeChecker.getTypeOfSymbolAtLocation(expose, symbolNode) + let schema: PropertyMetaSchema + let declarations: Declaration[] + + return { + name: expose.getName(), + type: typeChecker.typeToString(subtype), + rawType: rawType ? subtype : undefined, + description: ts.displayPartsToString(expose.getDocumentationComment(typeChecker)), + get declarations() { + return declarations ??= getDeclarations(expose.declarations ?? []) + }, + get schema() { + return schema ??= resolveSchema(subtype) + } + } + } + function resolveEventSignature(call: ts.Signature): EventMeta { + const subtype = typeChecker.getTypeOfSymbolAtLocation(call.parameters[1], symbolNode) + let schema: PropertyMetaSchema[] + let declarations: Declaration[] + + return { + name: (typeChecker.getTypeOfSymbolAtLocation(call.parameters[0], symbolNode) as ts.StringLiteralType).value, + description: ts.displayPartsToString(call.getDocumentationComment(typeChecker)), + tags: call.getJsDocTags().map(tag => ({ + name: tag.name, + text: tag.text !== undefined ? ts.displayPartsToString(tag.text) : undefined + })), + type: typeChecker.typeToString(subtype), + rawType: rawType ? subtype : undefined, + signature: typeChecker.signatureToString(call), + get declarations() { + return declarations ??= call.declaration ? getDeclarations([call.declaration]) : [] + }, + get schema() { + return schema ??= typeChecker.getTypeArguments(subtype as ts.TypeReference).map(resolveSchema) + } + } + } + function resolveCallbackSchema(signature: ts.Signature): PropertyMetaSchema { + let schema: PropertyMetaSchema[] | undefined + + return { + kind: 'event', + type: typeChecker.signatureToString(signature), + get schema() { + return schema ??= signature.parameters.length > 0 + ? typeChecker + .getTypeArguments(typeChecker.getTypeOfSymbolAtLocation(signature.parameters[0], symbolNode) as ts.TypeReference) + .map(resolveSchema) + : undefined + } + } + } + function resolveSchema(subtype: ts.Type): PropertyMetaSchema { + const type = typeChecker.typeToString(subtype) + + if (shouldIgnore(subtype)) { + return type + } + + visited.add(subtype) + + if (subtype.isUnion()) { + let schema: PropertyMetaSchema[] + return { + kind: 'enum', + type, + get schema() { + return schema ??= subtype.types.map(resolveSchema) + } + } + } + + // @ts-ignore - typescript internal, isArrayLikeType exists + else if (typeChecker.isArrayLikeType(subtype)) { + let schema: PropertyMetaSchema[] + return { + kind: 'array', + type, + get schema() { + return schema ??= typeChecker.getTypeArguments(subtype as ts.TypeReference).map(resolveSchema) + } + } + } else if ( + subtype.getCallSignatures().length === 0 + && (subtype.isClassOrInterface() || subtype.isIntersection() || (subtype as ts.ObjectType).objectFlags & ts.ObjectFlags.Anonymous) + ) { + let schema: Record + return { + kind: 'object', + type, + get schema() { + return schema ??= subtype.getProperties().map(resolveNestedProperties).reduce(reducer, {}) + } + } + } else if (subtype.getCallSignatures().length === 1) { + return resolveCallbackSchema(subtype.getCallSignatures()[0]) + } + + return type + } + function getDeclarations(declaration: ts.Declaration[]) { + if (noDeclarations) { + return [] + } + return declaration.map(getDeclaration).filter(d => !!d) + } + function getDeclaration(declaration: ts.Declaration): Declaration | undefined { + const fileName = declaration.getSourceFile().fileName + const sourceScript = language.scripts.get(fileName) + if (sourceScript?.generated) { + const script = sourceScript.generated.languagePlugin.typescript?.getServiceScript(sourceScript.generated.root) + if (script) { + for (const [sourceScript, map] of language.maps.forEach(script.code)) { + for (const [start] of map.toSourceLocation(declaration.getStart())) { + for (const [end] of map.toSourceLocation(declaration.getEnd())) { + return { + file: sourceScript.id, + range: [start, end] + } + } + } + } + } + return undefined + } + return { + file: declaration.getSourceFile().fileName, + range: [declaration.getStart(), declaration.getEnd()] + } + } + + return { + resolveNestedProperties, + resolveSlotProperties, + resolveEventSignature, + resolveExposedProperties, + resolveSchema + } +} + +function readVueComponentDefaultProps( + root: vue.VueVirtualCode, + printer: ts.Printer | undefined, + ts: typeof import('typescript') +) { + let result: Record = {} + const { sfc } = root + + scriptSetupWorker() + scriptWorker() + + return result + + function scriptSetupWorker() { + if (!sfc.scriptSetup) { + return + } + const { ast } = sfc.scriptSetup + + const codegen = vue.tsCodegen.get(sfc) + const scriptSetupRanges = codegen?.getScriptSetupRanges() + + if (scriptSetupRanges?.withDefaults?.argNode) { + const obj = findObjectLiteralExpression(scriptSetupRanges.withDefaults.argNode) + if (obj) { + for (const prop of obj.properties) { + if (ts.isPropertyAssignment(prop)) { + const name = prop.name.getText(ast) + const expNode = resolveDefaultOptionExpression(prop.initializer, ts) + const expText = printer?.printNode(ts.EmitHint.Expression, expNode, ast) ?? expNode.getText(ast) + + result[name] = { + default: expText + } + } + } + } + } else if (scriptSetupRanges?.defineProps?.argNode) { + const obj = findObjectLiteralExpression(scriptSetupRanges.defineProps.argNode) + if (obj) { + result = { + ...result, + ...resolvePropsOption(ast, obj, printer, ts) + } + } + } else if (scriptSetupRanges?.defineProps?.destructured) { + for (const [name, initializer] of scriptSetupRanges.defineProps.destructured) { + if (initializer) { + const expText = printer?.printNode(ts.EmitHint.Expression, initializer, ast) ?? initializer.getText(ast) + result[name] = { + default: expText + } + } + } + } + + if (scriptSetupRanges?.defineProp) { + for (const defineProp of scriptSetupRanges.defineProp) { + const argNode = (defineProp as any).argNode + const obj = argNode ? findObjectLiteralExpression(argNode) : undefined + if (obj) { + const name = defineProp.name ? sfc.scriptSetup.content.slice(defineProp.name.start, defineProp.name.end).slice(1, -1) : 'modelValue' + result[name] = resolveModelOption(ast, obj, printer, ts) + } + } + } + + function findObjectLiteralExpression(node: ts.Node) { + if (ts.isObjectLiteralExpression(node)) { + return node + } + let result: ts.ObjectLiteralExpression | undefined + node.forEachChild((child) => { + if (!result) { + result = findObjectLiteralExpression(child) + } + }) + return result + } + } + + function scriptWorker() { + if (!sfc.script) { + return + } + const { ast } = sfc.script + + const scriptResult = readTsComponentDefaultProps(ast, 'default', printer, ts) + for (const [key, value] of Object.entries(scriptResult)) { + result[key] = value + } + } +} + +function readTsComponentDefaultProps( + ast: ts.SourceFile, + exportName: string, + printer: ts.Printer | undefined, + ts: typeof import('typescript') +) { + const props = getPropsNode() + + if (props) { + return resolvePropsOption(ast, props, printer, ts) + } + + return {} + + function getComponentNode() { + let result: ts.Node | undefined + + if (exportName === 'default') { + ast.forEachChild((child) => { + if (ts.isExportAssignment(child)) { + result = child.expression + } + }) + } else { + ast.forEachChild((child) => { + if ( + ts.isVariableStatement(child) + && child.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword) + ) { + for (const dec of child.declarationList.declarations) { + if (dec.name.getText(ast) === exportName) { + result = dec.initializer + } + } + } + }) + } + + return result + } + + function getComponentOptionsNode() { + const component = getComponentNode() + + if (component) { + // export default { ... } + if (ts.isObjectLiteralExpression(component)) { + return component + } + // export default defineComponent({ ... }) + // export default Vue.extend({ ... }) + else if (ts.isCallExpression(component)) { + if (component.arguments.length) { + const arg = component.arguments[0] + if (ts.isObjectLiteralExpression(arg)) { + return arg + } + } + } + } + } + + function getPropsNode() { + const options = getComponentOptionsNode() + const props = options?.properties.find(prop => prop.name?.getText(ast) === 'props') + if (props && ts.isPropertyAssignment(props)) { + if (ts.isObjectLiteralExpression(props.initializer)) { + return props.initializer + } + } + } +} + +function resolvePropsOption( + ast: ts.SourceFile, + props: ts.ObjectLiteralExpression, + printer: ts.Printer | undefined, + ts: typeof import('typescript') +) { + const result: Record = {} + + for (const prop of props.properties) { + if (ts.isPropertyAssignment(prop)) { + const name = prop.name?.getText(ast) + if (ts.isObjectLiteralExpression(prop.initializer)) { + const defaultProp = prop.initializer.properties.find(p => ts.isPropertyAssignment(p) && p.name.getText(ast) === 'default') as ts.PropertyAssignment | undefined + const requiredProp = prop.initializer.properties.find(p => ts.isPropertyAssignment(p) && p.name.getText(ast) === 'required') as ts.PropertyAssignment | undefined + + result[name] = {} + + if (requiredProp) { + const exp = requiredProp.initializer.getText(ast) + result[name].required = exp === 'true' + } + if (defaultProp) { + const expNode = resolveDefaultOptionExpression(defaultProp.initializer, ts) + const expText = printer?.printNode(ts.EmitHint.Expression, expNode, ast) ?? expNode.getText(ast) + result[name].default = expText + } + } + } + } + + return result +} + +function resolveModelOption( + ast: ts.SourceFile, + options: ts.ObjectLiteralExpression, + printer: ts.Printer | undefined, + ts: typeof import('typescript') +) { + const result: { default?: string } = {} + + for (const prop of options.properties) { + if (ts.isPropertyAssignment(prop)) { + const name = prop.name.getText(ast) + if (name === 'default') { + const expNode = resolveDefaultOptionExpression(prop.initializer, ts) + const expText = printer?.printNode(ts.EmitHint.Expression, expNode, ast) ?? expNode.getText(ast) + result.default = expText + } + } + } + + return result +} + +function resolveDefaultOptionExpression( + _default: ts.Expression, + ts: typeof import('typescript') +) { + if (ts.isArrowFunction(_default)) { + if (ts.isBlock(_default.body)) { + return _default // TODO + } else if (ts.isParenthesizedExpression(_default.body)) { + return _default.body.expression + } else { + return _default.body + } + } + return _default +} diff --git a/meta/lib/index.cts b/meta/lib/index.cts new file mode 100644 index 00000000..fbd1631c --- /dev/null +++ b/meta/lib/index.cts @@ -0,0 +1,27 @@ +import * as ts from 'typescript' +import { createCheckerBase, createCheckerByJsonConfigBase } from './base.cjs' +import type { MetaCheckerOptions } from 'vue-component-meta' + +export function createCheckerByJson( + rootPath: string, + json: any, + checkerOptions: MetaCheckerOptions = {} +) { + return createCheckerByJsonConfigBase( + ts, + rootPath, + json, + checkerOptions + ) +} + +export function createChecker( + tsconfig: string, + checkerOptions: MetaCheckerOptions = {} +) { + return createCheckerBase( + ts, + tsconfig, + checkerOptions + ) +} diff --git a/meta/package.json b/meta/package.json new file mode 100644 index 00000000..96f87187 --- /dev/null +++ b/meta/package.json @@ -0,0 +1,28 @@ +{ + "name": "@compodium/meta", + "version": "0.1.0", + "license": "MIT", + "type": "commonjs", + "exports": { + ".": "./dist/index.cjs" + }, + "files": [ + "dist/**/*.cjs", + "dist/**/*.d.ts" + ], + "scripts": { + "build": "tsc --outDir dist" + }, + "dependencies": { + "@vue/language-core": "2.2.4", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } +} diff --git a/meta/tsconfig.json b/meta/tsconfig.json new file mode 100644 index 00000000..438204d3 --- /dev/null +++ b/meta/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "nodenext" + }, + "include": ["lib/**/*"] +} diff --git a/package.json b/package.json index 368c541a..4aed16b2 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "prepack": "nuxt-module-build build && nuxi generate devtools", "dev": "COMPODIUM_LOCAL=true nuxi dev playground", "dev:build": "nuxi build playground", - "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare devtools && nuxi prepare playground", + "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare devtools && nuxi prepare playground && pnpm --filter @compodium/meta build", "release": "pnpm run lint && pnpm run test && pnpm run prepack && changelogen --release && pnpm publish && git push --follow-tags", "lint": "eslint .", "test": "vitest run", @@ -31,6 +31,7 @@ "dependencies": { "@nuxt/devtools-kit": "^2.1.0", "@nuxt/kit": "^3.15.4", + "@vue/language-core": "^2.2.4", "@vueuse/core": "^12.7.0", "chokidar": "^4.0.3", "consola": "^3.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0aa9e4da..c9ca7ba7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ importers: '@nuxt/kit': specifier: ^3.15.4 version: 3.15.4(magicast@0.3.5) + '@vue/language-core': + specifier: ^2.2.4 + version: 2.2.4(typescript@5.6.3) '@vueuse/core': specifier: ^12.7.0 version: 12.7.0(typescript@5.6.3) @@ -145,6 +148,28 @@ importers: specifier: ^1.2.26 version: 1.2.26 + meta: + dependencies: + '@volar/typescript': + specifier: ~2.4.11 + version: 2.4.11 + '@vue/language-core': + specifier: 2.2.4 + version: 2.2.4(typescript@5.6.3) + typescript: + specifier: '*' + version: 5.6.3 + vue-component-meta: + specifier: 2.2.4 + version: 2.2.4(typescript@5.6.3) + devDependencies: + '@types/node': + specifier: ^22.10.4 + version: 22.13.5 + '@types/path-browserify': + specifier: ^1.0.1 + version: 1.0.3 + playground: dependencies: '@nuxt/ui': @@ -1609,6 +1634,9 @@ packages: '@types/parse-path@7.0.3': resolution: {integrity: sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==} + '@types/path-browserify@1.0.3': + resolution: {integrity: sha512-ZmHivEbNCBtAfcrFeBCiTjdIc2dey0l7oCGNGpSuRTy8jP6UVND7oUowlvDujBy8r2Hoa8bfFUOCiPWfmtkfxw==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -6941,6 +6969,8 @@ snapshots: '@types/parse-path@7.0.3': {} + '@types/path-browserify@1.0.3': {} + '@types/resolve@1.20.2': {} '@types/semver@7.5.8': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a774569f..79c191b3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: - "./" + - "meta" - "devtools" - "playground" diff --git a/src/runtime/server/services/checker.ts b/src/runtime/server/services/checker.ts index 6c07520d..9eb73b65 100644 --- a/src/runtime/server/services/checker.ts +++ b/src/runtime/server/services/checker.ts @@ -1,4 +1,4 @@ -import { createCheckerByJson } from 'vue-component-meta' +import { createCheckerByJson } from '@compodium/meta' import type { ComponentMeta } from '../../../types' // @ts-expect-error virtual file From 617aff6764302dd653e784d978b0cec8bf0f21d7 Mon Sep 17 00:00:00 2001 From: Romain Hamel Date: Fri, 28 Feb 2025 17:54:33 +0100 Subject: [PATCH 02/21] chore: up --- meta/LICENSE | 21 +++++++++++++++++++++ meta/README.md | 3 +++ meta/lib/base.cts | 45 +++++++++++++++++++++------------------------ meta/lib/index.cts | 4 +++- meta/package.json | 2 +- meta/tsconfig.json | 4 ++++ tsconfig.json | 1 + 7 files changed, 54 insertions(+), 26 deletions(-) create mode 100644 meta/LICENSE create mode 100644 meta/README.md diff --git a/meta/LICENSE b/meta/LICENSE new file mode 100644 index 00000000..b55e47a7 --- /dev/null +++ b/meta/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-present Johnson Chu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/meta/README.md b/meta/README.md new file mode 100644 index 00000000..16bf65c6 --- /dev/null +++ b/meta/README.md @@ -0,0 +1,3 @@ +# Meta + +This package is a modified version of `vue-component-meta` from [vue-language-tools](https://github.com/vuejs/language-tools). It was imported and modified to define a macro to extend component meta (`extendCompodiumMeta`). This macro is used by Compodium to pass additional configuration for components. diff --git a/meta/lib/base.cts b/meta/lib/base.cts index ce88fc68..ac48f5cc 100644 --- a/meta/lib/base.cts +++ b/meta/lib/base.cts @@ -2,7 +2,7 @@ import type { TypeScriptProjectHost } from '@volar/typescript' import { createLanguageServiceHost, resolveFileLanguageId } from '@volar/typescript' import * as vue from '@vue/language-core' import path from 'node:path' -import type * as ts from 'typescript' +import type ts from 'typescript' import { code as typeHelpersCode } from 'vue-component-type-helpers' import { code as vue2TypeHelpersCode } from 'vue-component-type-helpers/vue2' @@ -224,11 +224,11 @@ import * as Components from '${fileName.slice(0, -'.meta.ts'.length)}'; export default {} as { [K in keyof typeof Components]: ComponentMeta; }; interface ComponentMeta { - type: ComponentType; - props: ComponentProps; - emit: ComponentEmit; - slots: ComponentSlots; - exposed: ComponentExposed; + type: ComponentType; + props: ComponentProps; + emit: ComponentEmit; + slots: ComponentSlots; + exposed: ComponentExposed; }; ${commandLine.vueOptions.target < 3 ? vue2TypeHelpersCode : typeHelpersCode} @@ -292,7 +292,7 @@ ${commandLine.vueOptions.target < 3 ? vue2TypeHelpersCode : typeHelpersCode} function getProps() { const $props = symbolProperties.find(prop => prop.escapedName === 'props') - const propEventRegex = /^(on[A-Z])/ + const propEventRegex = /^on[A-Z]/ let result: PropertyMeta[] = [] if ($props) { @@ -327,16 +327,18 @@ ${commandLine.vueOptions.target < 3 ? vue2TypeHelpersCode : typeHelpersCode} const vueDefaults = vueFile && exportName === 'default' ? (vueFile instanceof vue.VueVirtualCode ? readVueComponentDefaultProps(vueFile, printer, ts) : {}) : {} - const tsDefaults = !vueFile ? readTsComponentDefaultProps( - ts.createSourceFile( - '/tmp.' + componentPath.slice(componentPath.lastIndexOf('.') + 1), // ts | js | tsx | jsx - snapshot.getText(0, snapshot.getLength()), - ts.ScriptTarget.Latest - ), - exportName, - printer, - ts - ) : {} + const tsDefaults = !vueFile + ? readTsComponentDefaultProps( + ts.createSourceFile( + '/tmp.' + componentPath.slice(componentPath.lastIndexOf('.') + 1), // ts | js | tsx | jsx + snapshot.getText(0, snapshot.getLength()), + ts.ScriptTarget.Latest + ), + exportName, + printer, + ts + ) + : {} for (const [propName, defaultExp] of Object.entries({ ...vueDefaults, @@ -620,10 +622,7 @@ function createSchemaResolvers( return schema ??= subtype.types.map(resolveSchema) } } - } - - // @ts-ignore - typescript internal, isArrayLikeType exists - else if (typeChecker.isArrayLikeType(subtype)) { + } else if (typeChecker.isArrayLikeType(subtype)) { let schema: PropertyMetaSchema[] return { kind: 'array', @@ -833,9 +832,7 @@ function readTsComponentDefaultProps( if (component) { // export default { ... } - if (ts.isObjectLiteralExpression(component)) { - return component - } + if (ts.isObjectLiteralExpression(component)) return component // export default defineComponent({ ... }) // export default Vue.extend({ ... }) else if (ts.isCallExpression(component)) { diff --git a/meta/lib/index.cts b/meta/lib/index.cts index fbd1631c..9c265a91 100644 --- a/meta/lib/index.cts +++ b/meta/lib/index.cts @@ -1,7 +1,9 @@ -import * as ts from 'typescript' +import ts from 'typescript' import { createCheckerBase, createCheckerByJsonConfigBase } from './base.cjs' import type { MetaCheckerOptions } from 'vue-component-meta' +export type * from 'vue-component-meta' + export function createCheckerByJson( rootPath: string, json: any, diff --git a/meta/package.json b/meta/package.json index 96f87187..0c396325 100644 --- a/meta/package.json +++ b/meta/package.json @@ -11,7 +11,7 @@ "dist/**/*.d.ts" ], "scripts": { - "build": "tsc --outDir dist" + "build": "tsc -b" }, "dependencies": { "@vue/language-core": "2.2.4", diff --git a/meta/tsconfig.json b/meta/tsconfig.json index 438204d3..6c1bd0e0 100644 --- a/meta/tsconfig.json +++ b/meta/tsconfig.json @@ -1,5 +1,9 @@ { "compilerOptions": { + "outDir": "./dist", + "declaration": true, + "emitDeclarationOnly": false, + "declarationMap": true, "target": "esnext", "module": "NodeNext", "strict": true, diff --git a/tsconfig.json b/tsconfig.json index a11a5cac..0b08b5c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "node_modules", "playground", "devtools", + "meta", "test/fixtures/", "src/runtime/libs", ], From 8cd50fe6af5a0a76cc5f776e5ec8b49c0eea61ad Mon Sep 17 00:00:00 2001 From: Romain Hamel Date: Sat, 1 Mar 2025 10:48:13 +0100 Subject: [PATCH 03/21] refactor: define extendCompodiumMeta as a macro --- README.md | 2 + devtools/app/pages/components/[id].vue | 28 ++---- meta/lib/base.cts | 96 ++++++++++++++++++- meta/lib/index.cts | 1 + meta/tsconfig.json | 5 +- playground/components/CompodiumWelcome.vue | 6 ++ src/module.ts | 5 +- .../composables/extendCompodiumMeta.ts | 22 +---- src/runtime/libs/defaults.ts | 57 +++++++++++ .../examples/ui/CommandPaletteExample.vue | 29 ------ .../libs/examples/ui/ContextMenuExample.vue | 21 +--- .../libs/examples/ui/DropdownMenuExample.vue | 21 +--- src/runtime/renderer.vue | 28 +----- src/runtime/server/api/component-meta.get.ts | 15 ++- src/runtime/server/services/checker.ts | 3 +- src/types.d.ts | 8 +- test/basic.test.ts | 21 +++- .../basic/components/WithExtendMeta.vue | 19 ++++ test/with-nuxt-ui.test.ts | 4 +- 19 files changed, 241 insertions(+), 150 deletions(-) delete mode 100644 src/runtime/libs/examples/ui/CommandPaletteExample.vue create mode 100644 test/fixtures/basic/components/WithExtendMeta.vue diff --git a/README.md b/README.md index da94eb4d..0bbfdc84 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,8 @@ extendCompodiumMeta({ } }) ``` +> [!WARNING] +> `extendCompodiumMeta` is a macro that is evaluated at compile time. As such, you can only use literal values, and can't reference variables. Alternatively, you can specify default properties for your components in your `app.config.ts` file: diff --git a/devtools/app/pages/components/[id].vue b/devtools/app/pages/components/[id].vue index 31ece1f0..3a6b26db 100644 --- a/devtools/app/pages/components/[id].vue +++ b/devtools/app/pages/components/[id].vue @@ -28,13 +28,13 @@ const component = computed(() => { function getDefaultProps(component: ComponentMeta): Record { return component.meta?.props?.reduce((acc: Record, prop: any) => { - const value = getDefaultPropValue(prop) + const value = evalPropValue(prop) if (value !== undefined) acc[prop.name] = value return acc }, {}) } -function getDefaultPropValue(meta: Partial) { +function evalPropValue(meta: Partial) { if (!meta.default) return try { return new Function(`return ${meta.default}`)() @@ -44,14 +44,14 @@ function getDefaultPropValue(meta: Partial) { } const { data: componentMeta, refresh: refreshComponent } = useAsyncData('__compodium-fetch-meta', async () => { - const meta = await $fetch(`/api/component-meta/${componentId.value}`, { baseURL: '/__compodium__' }) - defaultProps.value = getDefaultProps(meta) - + const component = await $fetch(`/api/component-meta/${componentId.value}`, { baseURL: '/__compodium__' }) + defaultProps.value = { ...getDefaultProps(component), ...component.meta.compodium.defaultProps } if (!componentMeta.value || componentMeta.value?.componentId !== componentId.value) { + props.value = defaultProps.value updateComponent() + updateRenderer() } - - return meta + return component }, { watch: [componentId] }) watch(component, async (oldValue, newValue) => { @@ -74,17 +74,6 @@ async function updateRenderer() { const updateRendererDebounced = useDebounceFn(updateRenderer, 100, { maxWait: 300 }) -function onComponentLoaded() { - updateRenderer() -} - -async function updateDefaultProps(payload: { componentId: string, defaultProps: any }) { - if (componentId.value !== payload.componentId) return - props.value = { ...defaultProps.value, ...payload.defaultProps, ...props.value } - compodiumDefaultProps.value = { ...payload.defaultProps } - await hooks.callHook('renderer:update-props', { props: props.value }) -} - const hooks = createHooks() hooks.hook('renderer:mounted', () => hooks.callHook('renderer:update-component', { @@ -94,7 +83,7 @@ hooks.hook('renderer:mounted', () => hooks.callHook('renderer:update-component', path: component.value.filePath })) -hooks.hook('renderer:component-loaded', onComponentLoaded) +hooks.hook('renderer:component-loaded', updateRenderer) hooks.hook('component:added', useDebounceFn(async () => { await Promise.all([ @@ -105,7 +94,6 @@ hooks.hook('component:added', useDebounceFn(async () => { hooks.hook('component:removed', useDebounceFn(fetchCollections, 300)) hooks.hook('component:changed', useDebounceFn(() => refreshComponent(), 300)) -hooks.hook('devtools:update-default-props', updateDefaultProps) onMounted(() => window.__COMPODIUM_HOOKS__ = hooks) diff --git a/meta/lib/base.cts b/meta/lib/base.cts index ac48f5cc..4ac919bb 100644 --- a/meta/lib/base.cts +++ b/meta/lib/base.cts @@ -17,6 +17,12 @@ import type { SlotMeta } from 'vue-component-meta' +export type CompodiumMeta> = ComponentMeta & { + compodium?: { + defaultProps: Partial + } +} + const windowsPathReg = /\\/g export function createCheckerByJsonConfigBase( @@ -242,7 +248,7 @@ ${commandLine.vueOptions.target < 3 ? vue2TypeHelpersCode : typeHelpersCode} return _getExports(program, typeChecker, componentPath).exports.map(e => e.getName()) } - function getComponentMeta(componentPath: string, exportName = 'default'): ComponentMeta { + function getComponentMeta(componentPath: string, exportName = 'default'): CompodiumMeta { const program = tsLs.getProgram()! const typeChecker = program.getTypeChecker() const { symbolNode, exports } = _getExports(program, typeChecker, componentPath) @@ -260,6 +266,7 @@ ${commandLine.vueOptions.target < 3 ? vue2TypeHelpersCode : typeHelpersCode} let _events: ReturnType | undefined let _slots: ReturnType | undefined let _exposed: ReturnType | undefined + let _compodium: CompodiumMeta['compodium'] return { get type() { @@ -276,6 +283,9 @@ ${commandLine.vueOptions.target < 3 ? vue2TypeHelpersCode : typeHelpersCode} }, get exposed() { return _exposed ?? (_exposed = getExposed()) + }, + get compodium() { + return _compodium ?? (_compodium = getCompodiumMeta() as CompodiumMeta['compodium']) } } @@ -290,6 +300,90 @@ ${commandLine.vueOptions.target < 3 ? vue2TypeHelpersCode : typeHelpersCode} return 0 } + function getCompodiumMeta() { + const snapshot = language.scripts.get(componentPath)?.snapshot + const sourceScript = language.scripts.get(componentPath) + + if (!snapshot || !sourceScript) { + console.error('Snapshot or source script not found.') + return + } + + const vueFile = sourceScript!.generated?.root + const ast = vueFile && exportName === 'default' + ? (vueFile as vue.VueVirtualCode).sfc.scriptSetup?.ast + : ts.createSourceFile( + '/tmp.' + componentPath.slice(componentPath.lastIndexOf('.') + 1), + snapshot.getText(0, snapshot.getLength()), + ts.ScriptTarget.Latest + ) + + if (!ast) return + const identifier = 'extendCompodiumMeta' + function traverse(node: ts.Node, parent?: ts.Node): void { + if (!ast) return + + if ((node as any)?.text === identifier && ts.isIdentifier(node)) { + const argument = (parent as any)?.arguments?.[0] + if (argument && ts.isObjectLiteralExpression(argument)) { + return parseObjectLiteralExpression(argument, ast) + } + } + return ts.forEachChild(node, child => traverse(child, node)) + } + + return traverse(ast) + } + + function parseObjectLiteralExpression(node: ts.Node, ast: ts.SourceFile): any { + const printer = ts.createPrinter(checkerOptions.printer) + + // Function to traverse the AST and find the object literal expression + function traverse(node: ts.Node): any { + if (ts.isObjectLiteralExpression(node)) { + // Create a JavaScript object from the object literal expression + const obj: any = {} + node.properties.forEach((property) => { + if (ts.isPropertyAssignment(property) && ts.isIdentifier(property.name)) { + const key = property.name.text + const value = evaluateExpression(property.initializer) + obj[key] = value + } + }) + return obj + } + return ts.forEachChild(node, traverse) + } + + // Function to evaluate expressions (supports literals and arrays) + function evaluateExpression(node: ts.Node): any { + if (ts.isStringLiteral(node)) { + return node.text + } else if (ts.isNumericLiteral(node)) { + return Number.parseFloat(node.text) + } else if (ts.isStringLiteralOrJsxExpression(node)) { + return true + } else if (ts.isObjectLiteralExpression(node)) { + return traverse(node) + } else if (ts.isArrayLiteralExpression(node)) { + return node.elements.map(element => evaluateExpression(element)) + } else if (ts.isLiteralTypeLiteral(node)) { + if (node.kind === ts.SyntaxKind.TrueKeyword) { + return true + } else if (node.kind === ts.SyntaxKind.FalseKeyword) { + return false + } else if (node.kind === ts.SyntaxKind.NullKeyword) { + return null + } else if (node.kind === ts.SyntaxKind.UndefinedKeyword) { + return undefined + } + } + return printer?.printNode(ts.EmitHint.Expression, node, ast) + } + // Traverse the AST to find and transform the object literal + return traverse(node) + } + function getProps() { const $props = symbolProperties.find(prop => prop.escapedName === 'props') const propEventRegex = /^on[A-Z]/ diff --git a/meta/lib/index.cts b/meta/lib/index.cts index 9c265a91..23db434f 100644 --- a/meta/lib/index.cts +++ b/meta/lib/index.cts @@ -2,6 +2,7 @@ import ts from 'typescript' import { createCheckerBase, createCheckerByJsonConfigBase } from './base.cjs' import type { MetaCheckerOptions } from 'vue-component-meta' +export type { CompodiumMeta } from './base.cts' export type * from 'vue-component-meta' export function createCheckerByJson( diff --git a/meta/tsconfig.json b/meta/tsconfig.json index 6c1bd0e0..1ece5ae4 100644 --- a/meta/tsconfig.json +++ b/meta/tsconfig.json @@ -10,7 +10,10 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "moduleResolution": "nodenext" + "moduleResolution": "nodenext", + "paths": { + "#module/*": ["../src/*"] + } }, "include": ["lib/**/*"] } diff --git a/playground/components/CompodiumWelcome.vue b/playground/components/CompodiumWelcome.vue index f161a6f0..71467067 100644 --- a/playground/components/CompodiumWelcome.vue +++ b/playground/components/CompodiumWelcome.vue @@ -8,6 +8,12 @@ withDefaults(defineProps<{ spinIt?: boolean bounceIt?: boolean }>(), { title: 'Welcome!' }) + +extendCompodiumMeta({ + defaultProps: { + bounceIt: true + } +})