diff --git a/playground/components/TestComponent.vue b/playground/components/TestComponent.vue index 9d77895..75bdd76 100644 --- a/playground/components/TestComponent.vue +++ b/playground/components/TestComponent.vue @@ -11,6 +11,14 @@ const props = defineProps({ hello: { type: String, default: 'Hello' + }, + booleanProp: { + type: Boolean, + default: false + }, + numberProp: { + type: Number, + default: 1.3 } }) const emit = defineEmits(['change', 'delete']) diff --git a/playground/components/testTyped.vue b/playground/components/testTyped.vue new file mode 100644 index 0000000..e6a0a97 --- /dev/null +++ b/playground/components/testTyped.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/module.ts b/src/module.ts index 5bd21e1..692d307 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,7 +1,6 @@ import { readFile } from 'fs/promises' import { defineNuxtModule, resolveModule, createResolver, addServerHandler } from '@nuxt/kit' -import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc' -import type { SFCDescriptor } from '@vue/compiler-sfc' +import { parseComponent } from './utils/parseComponent' export interface ModuleOptions {} @@ -21,23 +20,7 @@ export default defineNuxtModule({ const path = resolveModule((component as any).filePath, { paths: nuxt.options.rootDir }) const source = await readFile(path, { encoding: 'utf-8' }) - // Parse component source - const { descriptor } = parse(source) - - // Parse script - const { props } = descriptor.scriptSetup - ? parseSetupScript(name, descriptor) - : { - props: [] - } - - const { slots } = parseTemplate(name, descriptor) - - return { - name, - props, - slots - } + return parseComponent(name, source) }) ) }) @@ -62,45 +45,3 @@ export default defineNuxtModule({ }) } }) - -function parseSetupScript (id: string, descriptor: SFCDescriptor) { - const script = compileScript(descriptor, { id }) - const props = Object.entries(script.bindings).filter(([_name, type]) => type === 'props').map(([name]) => ({ - name, - default: '?', - type: '?', - required: '?', - values: '?', - description: '?' - })) - return { - props - } -} - -function parseTemplate (id: string, descriptor: SFCDescriptor) { - if (!descriptor.template) { - return { - slots: [] - } - } - - const template = compileTemplate({ - source: descriptor.template.content, - id, - filename: id - }) - - const findSlots = (nodes: any[]) => { - if (!nodes.length) { return [] } - const slots = nodes.filter(n => n.tag === 'slot').map(s => JSON.parse(s.codegenNode.arguments[1])) - return [ - ...slots, - ...findSlots(nodes.flatMap(n => n.children || [])) - ] - } - - return { - slots: findSlots(template.ast?.children || []) - } -} diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..2752504 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,8 @@ +export interface ComponentProp { + name: string + type?: string, + default?: any + required?: boolean, + values?: any, + description?: string +} diff --git a/src/utils/ast.ts b/src/utils/ast.ts new file mode 100644 index 0000000..633960b --- /dev/null +++ b/src/utils/ast.ts @@ -0,0 +1,39 @@ +export function visit (node, test, visitNode) { + if (Array.isArray(node)) { + return node.forEach(n => visit(n, test, visitNode)) + } + + if (!node?.type) { return } + + if (test(node)) { + visitNode(node) + } + + switch (node.type) { + case 'VariableDeclaration': + visit(node.declarations, test, visitNode) + break + case 'VariableDeclarator': + visit(node.id, test, visitNode) + visit(node.init, test, visitNode) + break + case 'CallExpression': + visit(node.callee, test, visitNode) + visit(node.arguments, test, visitNode) + visit(node.typeParameters, test, visitNode) + break + case 'ObjectExpression': + visit(node.properties, test, visitNode) + break + case 'ObjectProperty': + visit(node.key, test, visitNode) + visit(node.value, test, visitNode) + break + case 'TSTypeParameterInstantiation': + visit(node.params, test, visitNode) + break + case 'TSTypeLiteral': + visit(node.members, test, visitNode) + break + } +} diff --git a/src/utils/parseComponent.ts b/src/utils/parseComponent.ts new file mode 100644 index 0000000..bb363e2 --- /dev/null +++ b/src/utils/parseComponent.ts @@ -0,0 +1,21 @@ +import { parse } from '@vue/compiler-sfc' +import { parseSetupScript } from './parseSetupScript' +import { parseTemplate } from './parseTemplate' + +export function parseComponent (name: string, source: string) { + // Parse component source + const { descriptor } = parse(source) + + // Parse script + const { props } = descriptor.scriptSetup + ? parseSetupScript(name, descriptor) + : { props: [] } + + const { slots } = parseTemplate(name, descriptor) + + return { + name, + props, + slots + } +} diff --git a/src/utils/parseSetupScript.ts b/src/utils/parseSetupScript.ts new file mode 100644 index 0000000..0979d7a --- /dev/null +++ b/src/utils/parseSetupScript.ts @@ -0,0 +1,62 @@ +import type { SFCDescriptor } from '@vue/compiler-sfc' +import { compileScript } from '@vue/compiler-sfc' +import { ComponentProp } from '../types' +import { visit } from './ast' + +export function parseSetupScript (id: string, descriptor: SFCDescriptor) { + const props: ComponentProp[] = [] + const script = compileScript(descriptor, { id }) + + function getValue (prop) { + if (prop.type.endsWith('Literal')) { + return prop.value + } + + if (prop.type === 'Identifier') { + return prop.name + } + + if (prop.type === 'ObjectExpression') { + return prop.properties.reduce((acc, prop) => { + acc[prop.key.name] = getValue(prop.value) + return acc + }, {}) + } + } + function getType (tsProperty) { + const { type } = tsProperty.typeAnnotation.typeAnnotation + switch (type) { + case 'TSStringKeyword': + return 'String' + case 'TSNumberKeyword': + return 'Number' + case 'TSBooleanKeyword': + return 'Boolean' + case 'TSObjectKeyword': + return 'Object' + } + } + + visit(script.scriptSetupAst, node => node.type === 'CallExpression' && node.callee?.name === 'defineProps', (node) => { + const properties = node.arguments[0]?.properties || [] + properties.reduce((props, p) => { + props.push({ + name: p.key.name, + ...getValue(p.value) + }) + return props + }, props) + visit(node, n => n.type === 'TSPropertySignature', (property) => { + const name = property.key.name + props.push({ + name, + required: !property.optional, + type: getType(property) + }) + }) + }) + + return { + props + } +} diff --git a/src/utils/parseTemplate.ts b/src/utils/parseTemplate.ts new file mode 100644 index 0000000..2cfda45 --- /dev/null +++ b/src/utils/parseTemplate.ts @@ -0,0 +1,31 @@ +import type { SFCDescriptor } from '@vue/compiler-sfc' +import { compileTemplate } from '@vue/compiler-sfc' + +export function parseTemplate (id: string, descriptor: SFCDescriptor) { + if (!descriptor.template) { + return { + slots: [] + } + } + + const template = compileTemplate({ + source: descriptor.template.content, + id, + filename: id + }) + + const findSlots = (nodes: any[]) => { + if (!nodes.length) { return [] } + const slots = nodes.filter(n => n.tag === 'slot').map(s => ({ + name: JSON.parse(s.codegenNode.arguments[1]) + })) + return [ + ...slots, + ...findSlots(nodes.flatMap(n => n.children || [])) + ] + } + + return { + slots: findSlots(template.ast?.children || []) + } +} diff --git a/test/basic-component.test.ts b/test/basic-component.test.ts new file mode 100644 index 0000000..c032c04 --- /dev/null +++ b/test/basic-component.test.ts @@ -0,0 +1,61 @@ +import fsp from 'fs/promises' +import { fileURLToPath } from 'url' +import { test, describe, expect } from 'vitest' +import { parseComponent } from '../src/utils/parseComponent' + +describe('Basic Component', async () => { + const path = fileURLToPath(new URL('./fixtures/basic/components/BasicComponent.vue', import.meta.url)) + const source = await fsp.readFile(path, { encoding: 'utf-8' }) + // Parse component source + const { props, slots } = parseComponent('BasicComponent', source) + + test('Slots', () => { + expect(slots).toEqual([ + { name: 'default' }, + { name: 'nuxt' } + ]) + }) + + test('Props', () => { + expect(props).toBeDefined() + expect(props.length > 0) + }) + + test('String', () => { + const stringProps = props.filter(p => p.type === 'String') + + expect(stringProps.length).toBe(1) + expect(stringProps[0].name).toBe('stringProp') + expect(stringProps[0].default).toBe('Hello') + }) + + test('Boolean', () => { + const booleanProps = props.filter(p => p.type === 'Boolean') + + expect(booleanProps.length).toBe(1) + expect(booleanProps[0].name).toBe('booleanProp') + expect(booleanProps[0].default).toBe(false) + }) + + test('Number', () => { + const numberProps = props.filter(p => p.type === 'Number') + + expect(numberProps.length).toBe(1) + expect(numberProps[0].name).toBe('numberProp') + expect(numberProps[0].default).toBe(1.3) + }) + + test('Array', () => { + const arrayProps = props.filter(p => p.type === 'Array') + + expect(arrayProps.length).toBe(1) + expect(arrayProps[0].name).toBe('arrayProp') + }) + + test('Object', () => { + const objectProps = props.filter(p => p.type === 'Object') + + expect(objectProps.length).toBe(1) + expect(objectProps[0].name).toBe('objectProp') + }) +}) diff --git a/test/fixtures/basic/components/BasicComponent.vue b/test/fixtures/basic/components/BasicComponent.vue new file mode 100644 index 0000000..ab53010 --- /dev/null +++ b/test/fixtures/basic/components/BasicComponent.vue @@ -0,0 +1,33 @@ + + +