Skip to content

Commit

Permalink
feat: extract props info from defineProps (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
farnabaz authored Jul 13, 2022
1 parent d3ded69 commit 8f89275
Show file tree
Hide file tree
Showing 10 changed files with 282 additions and 61 deletions.
8 changes: 8 additions & 0 deletions playground/components/TestComponent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
17 changes: 17 additions & 0 deletions playground/components/testTyped.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<template>
<div>
<slot />
<hr>
<slot name="nuxt" />
</div>
</template>

<script setup lang="ts">
const props = defineProps<{
hello: string,
booleanProp?: boolean,
numberProp?: number
}>()
const emit = defineEmits(['change', 'delete'])
</script>
63 changes: 2 additions & 61 deletions src/module.ts
Original file line number Diff line number Diff line change
@@ -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 {}

Expand All @@ -21,23 +20,7 @@ export default defineNuxtModule<ModuleOptions>({
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)
})
)
})
Expand All @@ -62,45 +45,3 @@ export default defineNuxtModule<ModuleOptions>({
})
}
})

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 || [])
}
}
8 changes: 8 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface ComponentProp {
name: string
type?: string,
default?: any
required?: boolean,
values?: any,
description?: string
}
39 changes: 39 additions & 0 deletions src/utils/ast.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
21 changes: 21 additions & 0 deletions src/utils/parseComponent.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
62 changes: 62 additions & 0 deletions src/utils/parseSetupScript.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
31 changes: 31 additions & 0 deletions src/utils/parseTemplate.ts
Original file line number Diff line number Diff line change
@@ -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 || [])
}
}
61 changes: 61 additions & 0 deletions test/basic-component.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
33 changes: 33 additions & 0 deletions test/fixtures/basic/components/BasicComponent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<template>
<div>
<slot />
<hr>
<slot name="nuxt" />
</div>
</template>

<script setup>
const props = defineProps({
stringProp: {
type: String,
default: 'Hello'
},
booleanProp: {
type: Boolean,
default: false
},
numberProp: {
type: Number,
default: 1.3
},
arrayProp: {
type: Array,
default: () => []
},
objectProp: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['change', 'delete'])
</script>

0 comments on commit 8f89275

Please sign in to comment.