diff --git a/packages/vue-component-meta/src/index.ts b/packages/vue-component-meta/src/index.ts index c133997f8..0b11c2dca 100644 --- a/packages/vue-component-meta/src/index.ts +++ b/packages/vue-component-meta/src/index.ts @@ -177,14 +177,23 @@ export function createComponentMetaChecker(tsconfigPath: string, checkerOptions: } // fill defaults - if (componentPath.endsWith('.vue') && exportName === 'default') { - const snapshot = host.getScriptSnapshot(componentPath)!; - const defaults = readCmponentDefaultProps(snapshot.getText(0, snapshot.getLength())); - for (const propName in defaults) { - const prop = result.find(p => p.name === propName); - if (prop) { - prop.default = defaults[propName]; - } + const printer = ts.createPrinter(checkerOptions.printer); + const snapshot = host.getScriptSnapshot(componentPath)!; + const vueDefaults = componentPath.endsWith('.vue') && exportName === 'default' ? readVueComponentDefaultProps(snapshot.getText(0, snapshot.getLength()), printer) : {}; + const tsDefaults = !componentPath.endsWith('.vue') ? readTsComponentDefaultProps( + componentPath.substring(componentPath.lastIndexOf('.') + 1), // ts | js | tsx | jsx + snapshot.getText(0, snapshot.getLength()), + exportName, + printer, + ) : {}; + + for (const [propName, defaultExp] of Object.entries({ + ...vueDefaults, + ...tsDefaults, + })) { + const prop = result.find(p => p.name === propName); + if (prop) { + prop.default = defaultExp; } } @@ -274,8 +283,8 @@ export function createComponentMetaChecker(tsconfigPath: string, checkerOptions: } function createSchemaResolvers(typeChecker: ts.TypeChecker, symbolNode: ts.Expression, options: MetaCheckerSchemaOptions = {}) { - const ignore = options.ignore ?? []; - const enabled = options.enabled ?? false; + const enabled = !!options; + const ignore = typeof options === 'object' ? options.ignore ?? [] : []; function shouldIgnore(subtype: ts.Type) { const type = typeChecker.typeToString(subtype); @@ -421,42 +430,166 @@ function createSchemaResolvers(typeChecker: ts.TypeChecker, symbolNode: ts.Expre }; } -function readCmponentDefaultProps(fileText: string) { +function readVueComponentDefaultProps(vueFileText: string, printer: ts.Printer) { - const vueSourceFile = vue.createSourceFile('/tmp.vue', fileText, {}, {}, ts); - const descriptor = vueSourceFile.getDescriptor(); - const scriptSetupRanges = vueSourceFile.getScriptSetupRanges(); const result: Record = {}; - if (descriptor.scriptSetup && scriptSetupRanges?.withDefaultsArg) { + scriptSetupWorker(); + sciptWorker(); + + return result; + + function scriptSetupWorker() { + + const vueSourceFile = vue.createSourceFile('/tmp.vue', vueFileText, {}, {}, ts); + const descriptor = vueSourceFile.getDescriptor(); + const scriptSetupRanges = vueSourceFile.getScriptSetupRanges(); + + if (descriptor.scriptSetup && scriptSetupRanges?.withDefaultsArg) { + + const defaultsText = descriptor.scriptSetup.content.substring(scriptSetupRanges.withDefaultsArg.start, scriptSetupRanges.withDefaultsArg.end); + const ast = ts.createSourceFile('/tmp.' + descriptor.scriptSetup.lang, '(' + defaultsText + ')', ts.ScriptTarget.Latest); + const obj = findObjectLiteralExpression(ast); - const defaultsText = descriptor.scriptSetup.content.substring(scriptSetupRanges.withDefaultsArg.start, scriptSetupRanges.withDefaultsArg.end); - const ast = ts.createSourceFile('/tmp.' + descriptor.scriptSetup.lang, '(' + defaultsText + ')', ts.ScriptTarget.Latest); - const obj = findObjectLiteralExpression(ast); + if (obj) { + for (const prop of obj.properties) { + if (ts.isPropertyAssignment(prop)) { + const name = prop.name.getText(ast); + const exp = printer.printNode(ts.EmitHint.Expression, resolveDefaultOptionExpression(prop.initializer), ast);; + result[name] = exp; + } + } + } - if (obj) { - for (const prop of obj.properties) { - if (ts.isPropertyAssignment(prop)) { - const name = prop.name.getText(ast); - const exp = prop.initializer.getText(ast); - result[name] = exp; + 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 sciptWorker() { + + const vueSourceFile = vue.createSourceFile('/tmp.vue', vueFileText, {}, {}, ts); + const descriptor = vueSourceFile.getDescriptor(); - function findObjectLiteralExpression(node: ts.Node) { - if (ts.isObjectLiteralExpression(node)) { - return node; + if (descriptor.script) { + const scriptResult = readTsComponentDefaultProps(descriptor.script.lang, descriptor.script.content, 'default', printer); + for (const [key, value] of Object.entries(scriptResult)) { + result[key] = value; } - let result: ts.ObjectLiteralExpression | undefined; - node.forEachChild(child => { - if (!result) { - result = findObjectLiteralExpression(child); + } + } +} + +function readTsComponentDefaultProps(lang: string, tsFileText: string, exportName: string, printer: ts.Printer) { + + const result: Record = {}; + const ast = ts.createSourceFile('/tmp.' + lang, tsFileText, ts.ScriptTarget.Latest); + const props = getPropsNode(); + + if (props) { + for (const prop of props.properties) { + if (ts.isPropertyAssignment(prop)) { + const name = prop.name?.getText(ast); + if (ts.isObjectLiteralExpression(prop.initializer)) { + for (const propOption of prop.initializer.properties) { + if (ts.isPropertyAssignment(propOption)) { + if (propOption.name?.getText(ast) === 'default') { + const _default = propOption.initializer; + result[name] = printer.printNode(ts.EmitHint.Expression, resolveDefaultOptionExpression(_default), ast); + } + } + } } - }); - return result; + } } } return result; + + 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 resolveDefaultOptionExpression(_default: ts.Expression) { + 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/packages/vue-component-meta/src/types.ts b/packages/vue-component-meta/src/types.ts index d37d0cea3..330d1ae8d 100644 --- a/packages/vue-component-meta/src/types.ts +++ b/packages/vue-component-meta/src/types.ts @@ -40,11 +40,11 @@ export type PropertyMetaSchema = string | { kind: 'event', type: string, schema?: PropertyMetaSchema[]; } | { kind: 'object', type: string, schema?: Record; }; -export interface MetaCheckerSchemaOptions { - enabled?: boolean; +export type MetaCheckerSchemaOptions = boolean | { ignore?: string[]; } export interface MetaCheckerOptions { schema?: MetaCheckerSchemaOptions; forceUseTs?: boolean; + printer?: import('typescript').PrinterOptions; } diff --git a/packages/vue-component-meta/tests/index.spec.ts b/packages/vue-component-meta/tests/index.spec.ts index 850847171..336ad4a51 100644 --- a/packages/vue-component-meta/tests/index.spec.ts +++ b/packages/vue-component-meta/tests/index.spec.ts @@ -7,12 +7,18 @@ describe(`vue-component-meta`, () => { const tsconfigPath = path.resolve(__dirname, '../../vue-test-workspace/vue-component-meta/tsconfig.json'); const checker = createComponentMetaChecker(tsconfigPath, { forceUseTs: true, + printer: { + newLine: 1, + }, }); const checker_schema = createComponentMetaChecker(tsconfigPath, { schema: { enabled: true, ignore: ['MyIgnoredNestedProps'], - } + }, + printer: { + newLine: 1, + }, }); test('global-props', () => { @@ -51,6 +57,7 @@ describe(`vue-component-meta`, () => { const foo = meta.props.find(prop => prop.name === 'foo'); const bar = meta.props.find(prop => prop.name === 'bar'); const baz = meta.props.find(prop => prop.name === 'baz'); + const bazWithDefault = meta.props.find(prop => prop.name === 'bazWithDefault'); const union = meta.props.find(prop => prop.name === 'union'); const unionOptional = meta.props.find(prop => prop.name === 'unionOptional'); const nested = meta.props.find(prop => prop.name === 'nested'); @@ -101,14 +108,19 @@ describe(`vue-component-meta`, () => { }); expect(baz).toBeDefined(); - expect(baz?.required).toBeTruthy(); - expect(baz?.type).toEqual('string[]'); + // When initializing an array, users have to do it in a function to avoid + // referencing always the same instance for every component + // if no params are given to the function and it is simply an Array, + // the array is the default value and should be given instead of the function + expect(baz?.default).toEqual(`["foo", "bar"]`); + expect(baz?.required).toBeFalsy(); + expect(baz?.type).toEqual('string[] | undefined'); expect(baz?.description).toEqual('string array baz'); - expect(baz?.schema).toEqual({ - kind: 'array', - type: 'string[]', - schema: ['string'] - }); + // expect(baz?.schema).toEqual({ + // kind: 'array', + // type: 'string[]', + // schema: ['string'] + // }); expect(union).toBeDefined(); expect(union?.required).toBeTruthy(); @@ -498,4 +510,57 @@ describe(`vue-component-meta`, () => { expect(a).toBeDefined(); expect(b).toBeDefined(); }); + + test('options-api', () => { + + const componentPath = path.resolve(__dirname, '../../vue-test-workspace/vue-component-meta/options-api/component.ts'); + const meta = checker.getComponentMeta(componentPath); + + // const submitEvent = meta.events.find(evt => evt.name === 'submit'); + + // expect(submitEvent).toBeDefined(); + // expect(submitEvent?.schema).toEqual(expect.arrayContaining([{ + // kind: 'object', + // schema: { + // email: { + // description: 'email of user', + // name: 'email', + // required: true, + // schema: 'string', + // tags: [], + // type: 'string' + // }, + // password: { + // description: 'password of same user', + // name: 'password', + // required: true, + // schema: 'string', + // tags: [], + // type: 'string' + // } + // }, + // type: 'SubmitPayload' + // }])); + + const propNumberDefault = meta.props.find(prop => prop.name === 'numberDefault'); + + // expect(propNumberDefault).toBeDefined(); + // expect(propNumberDefault?.type).toEqual('number | undefined'); + // expect(propNumberDefault?.schema).toEqual({ + // kind: 'enum', + // schema: ['undefined', 'number'], + // type: 'number | undefined' + // }); + expect(propNumberDefault?.default).toEqual(`42`); + + const propObjectDefault = meta.props.find(prop => prop.name === 'objectDefault'); + + expect(propObjectDefault).toBeDefined(); + expect(propObjectDefault?.default).toEqual(`{\n foo: "bar"\n}`); + + const propArrayDefault = meta.props.find(prop => prop.name === 'arrayDefault'); + + expect(propArrayDefault).toBeDefined(); + expect(propArrayDefault?.default).toEqual(`[1, 2, 3]`); + }); }); diff --git a/packages/vue-test-workspace/vue-component-meta/options-api/component.ts b/packages/vue-test-workspace/vue-component-meta/options-api/component.ts new file mode 100644 index 000000000..19a04f1e5 --- /dev/null +++ b/packages/vue-test-workspace/vue-component-meta/options-api/component.ts @@ -0,0 +1,64 @@ +import { defineComponent } from "vue"; + +interface SubmitPayload { + /** + * email of user + */ + email: string; + /** + * password of same user + */ + password: string; +} + +export default defineComponent({ + emits: { + // Validate submit event + submit: ({ email, password }: SubmitPayload) => { + if (email && password) { + return true; + } else { + console.warn('Invalid submit event payload!'); + return false; + } + } + }, + props: { + /** + * Default number + */ + numberDefault: { + type: Number, + default: 42 + }, + /** + * Default function Object + */ + objectDefault: { + type: Object, + default: () => ({ + foo: 'bar' + }) + }, + /** + * Default function Array + */ + arrayDefault: { + type: Array, + default: () => [1, 2, 3] + }, + /** + * Default function more complex + */ + complexDefault: { + type: Array, + default: (props: any) => { + if (props.arrayDefault.length > props.numberDefault) { + return []; + } else { + return undefined; + } + } + }, + }, +}); diff --git a/packages/vue-test-workspace/vue-component-meta/reference-type-props/component.vue b/packages/vue-test-workspace/vue-component-meta/reference-type-props/component.vue index 17c4e079f..368fd4993 100644 --- a/packages/vue-test-workspace/vue-component-meta/reference-type-props/component.vue +++ b/packages/vue-test-workspace/vue-component-meta/reference-type-props/component.vue @@ -3,5 +3,6 @@ import { MyProps } from './my-props'; withDefaults(defineProps(), { bar: 1, + baz: () => ['foo', 'bar'], }); diff --git a/packages/vue-test-workspace/vue-component-meta/reference-type-props/my-props.ts b/packages/vue-test-workspace/vue-component-meta/reference-type-props/my-props.ts index 53bf591c8..782f1ef6b 100644 --- a/packages/vue-test-workspace/vue-component-meta/reference-type-props/my-props.ts +++ b/packages/vue-test-workspace/vue-component-meta/reference-type-props/my-props.ts @@ -10,21 +10,21 @@ export interface MyIgnoredNestedProps { } enum MyEnum { - Small, - Medium, - Large, + Small, + Medium, + Large, } const categories = [ - 'Uncategorized', - 'Content', - 'Interaction', - 'Display', - 'Forms', - 'Addons', -] as const + 'Uncategorized', + 'Content', + 'Interaction', + 'Display', + 'Forms', + 'Addons', +] as const; -type MyCategories = typeof categories[number] +type MyCategories = typeof categories[number]; export interface MyProps { /** @@ -48,7 +48,7 @@ export interface MyProps { /** * string array baz */ - baz: string[], + baz?: string[], /** * required union type */ @@ -90,5 +90,5 @@ export interface MyProps { * literal type alias that require context */ literalFromContext: MyCategories, - inlined: { foo: string }, + inlined: { foo: string; }, }