Skip to content

Commit 7da4f1a

Browse files
committed
chore: support events on registry types
1 parent 2d772ca commit 7da4f1a

2 files changed

Lines changed: 1456 additions & 90 deletions

File tree

scripts/generate-registry-types.ts

Lines changed: 213 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -253,34 +253,196 @@ function extractDeclarations(source: string, fileName: string): ExtractedDeclara
253253
return declarations
254254
}
255255

256-
// --- Component props extraction ---
256+
// --- Component props & events extraction ---
257257

258258
const SCRIPT_SETUP_RE = /<script\s[^>]*\bsetup\b[^>]*>([\s\S]*?)<\/script>/
259259

260260
function extractScriptSetup(vueSource: string): string | null {
261-
// Match <script setup> or <script lang="ts" setup> — handles attribute order variations
262261
const match = vueSource.match(SCRIPT_SETUP_RE)
263262
return match?.[1] ?? null
264263
}
265264

266-
function extractComponentProps(scriptSource: string, fileName: string): ExtractedProps | null {
265+
interface ComponentMeta {
266+
code: string
267+
defaults: Record<string, string>
268+
fields: SchemaFieldMeta[]
269+
events: SchemaFieldMeta[]
270+
models: SchemaFieldMeta[]
271+
}
272+
273+
function resolveTSType(node: any, source: string): string {
274+
if (!node)
275+
return 'unknown'
276+
// Handle common TS type nodes
277+
if (node.type === 'TSStringKeyword')
278+
return 'string'
279+
if (node.type === 'TSNumberKeyword')
280+
return 'number'
281+
if (node.type === 'TSBooleanKeyword')
282+
return 'boolean'
283+
if (node.type === 'TSAnyKeyword')
284+
return 'any'
285+
if (node.type === 'TSVoidKeyword')
286+
return 'void'
287+
if (node.type === 'TSNullKeyword')
288+
return 'null'
289+
if (node.type === 'TSUndefinedKeyword')
290+
return 'undefined'
291+
if (node.type === 'TSNeverKeyword')
292+
return 'never'
293+
if (node.type === 'TSUnionType') {
294+
return node.types.map((t: any) => resolveTSType(t, source)).join(' | ')
295+
}
296+
if (node.type === 'TSIntersectionType') {
297+
return node.types.map((t: any) => resolveTSType(t, source)).join(' & ')
298+
}
299+
if (node.type === 'TSArrayType') {
300+
return `${resolveTSType(node.elementType, source)}[]`
301+
}
302+
if (node.type === 'TSLiteralType') {
303+
if (node.literal?.type === 'StringLiteral' || (node.literal?.type === 'Literal' && typeof node.literal.value === 'string'))
304+
return `'${node.literal.value}'`
305+
if (node.literal?.type === 'NumericLiteral' || (node.literal?.type === 'Literal' && typeof node.literal.value === 'number'))
306+
return String(node.literal.value)
307+
if (node.literal?.type === 'BooleanLiteral' || (node.literal?.type === 'Literal' && typeof node.literal.value === 'boolean'))
308+
return String(node.literal.value)
309+
}
310+
// For complex types (generics, qualified names), use the raw source
311+
return source.slice(node.start, node.end)
312+
}
313+
314+
function extractPropsFields(typeNode: any, source: string): SchemaFieldMeta[] {
315+
if (!typeNode || typeNode.type !== 'TSTypeLiteral')
316+
return []
317+
318+
const fields: SchemaFieldMeta[] = []
319+
for (const member of typeNode.members || []) {
320+
if (member.type !== 'TSPropertySignature')
321+
continue
322+
const name = member.key?.name || member.key?.value
323+
if (!name)
324+
continue
325+
326+
fields.push({
327+
name,
328+
type: resolveTSType(member.typeAnnotation?.typeAnnotation, source),
329+
required: !member.optional,
330+
})
331+
}
332+
return fields
333+
}
334+
335+
function extractComponentMeta(scriptSource: string, fileName: string): ComponentMeta | null {
267336
const { program } = parseSync(fileName, scriptSource)
268-
let result: ExtractedProps | null = null
337+
let propsResult: { code: string, defaults: Record<string, string>, fields: SchemaFieldMeta[] } | null = null
338+
const events: SchemaFieldMeta[] = []
339+
const models: SchemaFieldMeta[] = []
340+
const constArrays: Record<string, string[]> = {}
341+
342+
// First pass: collect `as const` arrays for event name resolution
343+
walk(program, {
344+
enter(node) {
345+
if (node.type !== 'VariableDeclaration')
346+
return
347+
for (const decl of node.declarations || []) {
348+
if (decl.id?.type !== 'Identifier')
349+
continue
350+
const init = decl.init
351+
// Match: const foo = [...] as const
352+
if (init?.type === 'TSAsExpression' && init.expression?.type === 'ArrayExpression') {
353+
const names: string[] = []
354+
for (const el of init.expression.elements || []) {
355+
// oxc-parser uses 'Literal' (not 'StringLiteral')
356+
if ((el.type === 'StringLiteral' || el.type === 'Literal') && typeof el.value === 'string')
357+
names.push(el.value)
358+
}
359+
if (names.length)
360+
constArrays[decl.id.name] = names
361+
}
362+
}
363+
},
364+
})
269365

366+
// Second pass: extract defineProps, defineEmits, defineModel
270367
walk(program, {
271368
enter(node) {
272369
if (node.type !== 'CallExpression')
273370
return
274371

372+
// defineModel<T>('name')
373+
if (node.callee?.name === 'defineModel') {
374+
const modelName = node.arguments?.[0]?.value || 'modelValue'
375+
const typeParam = node.typeArguments?.params?.[0]
376+
models.push({
377+
name: `v-model:${modelName}`,
378+
type: typeParam ? resolveTSType(typeParam, scriptSource) : 'any',
379+
required: false,
380+
})
381+
return
382+
}
383+
384+
// defineEmits<{...}>()
385+
if (node.callee?.name === 'defineEmits') {
386+
const typeArg = node.typeArguments?.params?.[0]
387+
if (!typeArg)
388+
return
389+
390+
// Parse call signatures: (event: typeof arr[number], payload?: T): void
391+
if (typeArg.type === 'TSTypeLiteral') {
392+
for (const member of typeArg.members || []) {
393+
if (member.type !== 'TSCallSignatureDeclaration')
394+
continue
395+
const params = member.params || []
396+
if (params.length === 0)
397+
continue
398+
399+
// First param is the event discriminator
400+
const eventParam = params[0]
401+
const eventType = eventParam?.typeAnnotation?.typeAnnotation
402+
403+
// typeof constArrayName[number] → resolve to array values
404+
if (eventType?.type === 'TSIndexedAccessType') {
405+
const objType = eventType.objectType
406+
if (objType?.type === 'TSTypeQuery') {
407+
const refName = objType.exprName?.name
408+
if (refName && constArrays[refName]) {
409+
const payloadType = params.length > 1
410+
? resolveTSType(params[1]?.typeAnnotation?.typeAnnotation, scriptSource)
411+
: undefined
412+
for (const eventName of constArrays[refName]) {
413+
events.push({
414+
name: eventName,
415+
type: payloadType || '-',
416+
required: false,
417+
})
418+
}
419+
}
420+
}
421+
}
422+
// Literal string event: (event: 'ready', ...): void
423+
else if (eventType?.type === 'TSLiteralType' && (eventType.literal?.type === 'StringLiteral' || eventType.literal?.type === 'Literal')) {
424+
const payloadType = params.length > 1
425+
? resolveTSType(params[1]?.typeAnnotation?.typeAnnotation, scriptSource)
426+
: undefined
427+
events.push({
428+
name: eventType.literal.value,
429+
type: payloadType || '-',
430+
required: false,
431+
})
432+
}
433+
}
434+
}
435+
return
436+
}
437+
438+
// defineProps / withDefaults(defineProps)
275439
let definePropsCall: any = null
276440
let defaultsObj: any = null
277441

278-
// withDefaults(defineProps<...>(), { ... })
279442
if (node.callee?.name === 'withDefaults' && node.arguments?.[0]?.callee?.name === 'defineProps') {
280443
definePropsCall = node.arguments[0]
281444
defaultsObj = node.arguments[1]
282445
}
283-
// defineProps<...>()
284446
else if (node.callee?.name === 'defineProps') {
285447
definePropsCall = node
286448
}
@@ -293,26 +455,39 @@ function extractComponentProps(scriptSource: string, fileName: string): Extracte
293455
return
294456

295457
const code = scriptSource.slice(typeArg.start, typeArg.end)
458+
const fields = extractPropsFields(typeArg, scriptSource)
296459

297-
// Extract defaults
298460
const defaults: Record<string, string> = {}
299461
if (defaultsObj?.type === 'ObjectExpression') {
300462
for (const prop of defaultsObj.properties || []) {
301463
if (prop.type === 'ObjectProperty' || prop.type === 'Property') {
302464
const key = prop.key?.name || prop.key?.value
303-
if (key) {
465+
if (key)
304466
defaults[key] = scriptSource.slice(prop.value.start, prop.value.end)
305-
}
306467
}
307468
}
308469
}
309470

310-
result = { code, defaults }
311-
this.skip()
471+
// Merge defaults into fields
472+
for (const field of fields) {
473+
if (defaults[field.name])
474+
field.defaultValue = defaults[field.name]
475+
}
476+
477+
propsResult = { code, defaults, fields }
312478
},
313479
})
314480

315-
return result
481+
if (!propsResult)
482+
return null
483+
484+
return {
485+
code: propsResult.code,
486+
defaults: propsResult.defaults,
487+
fields: [...propsResult.fields, ...models],
488+
events,
489+
models,
490+
}
316491
}
317492

318493
// --- Main ---
@@ -343,7 +518,7 @@ function findVueComponents(dir: string): string[] {
343518
}
344519

345520
const componentFiles = findVueComponents(componentsDir)
346-
const componentProps: Record<string, ExtractedProps> = {}
521+
const componentMetas: Record<string, ComponentMeta> = {}
347522

348523
for (const filePath of componentFiles) {
349524
const vueSource = readFileSync(filePath, 'utf-8')
@@ -352,9 +527,9 @@ for (const filePath of componentFiles) {
352527
continue
353528

354529
const fileName = filePath.split('/').pop()!
355-
const props = extractComponentProps(scriptSetup, fileName.replace('.vue', '.ts'))
356-
if (props) {
357-
componentProps[fileName.replace('.vue', '')] = props
530+
const meta = extractComponentMeta(scriptSetup, fileName.replace('.vue', '.ts'))
531+
if (meta) {
532+
componentMetas[fileName.replace('.vue', '')] = meta
358533
}
359534
}
360535

@@ -387,31 +562,46 @@ const componentToSlug: Record<string, string> = {
387562
ScriptPayPalMessages: 'paypal',
388563
}
389564

390-
// Add component props as declarations to the types JSON
391-
for (const [componentName, props] of Object.entries(componentProps)) {
392-
// Skip non-public components (loading indicator, aria indicator, etc.)
565+
// Add component props/events as declarations and schema fields
566+
for (const [componentName, meta] of Object.entries(componentMetas)) {
393567
const slug = componentToSlug[componentName]
394568
if (!slug)
395569
continue
396570

397571
if (!types[slug])
398572
types[slug] = []
399573

400-
const propsInterface = `interface ${componentName}Props ${props.code}`
574+
const propsInterface = `interface ${componentName}Props ${meta.code}`
401575
types[slug].push({
402576
name: `${componentName}Props`,
403577
kind: 'interface',
404578
code: propsInterface,
405579
})
406580

407-
if (Object.keys(props.defaults).length) {
408-
const defaultsCode = `const ${componentName}Defaults = ${JSON.stringify(props.defaults, null, 2)}`
581+
if (Object.keys(meta.defaults).length) {
582+
const defaultsCode = `const ${componentName}Defaults = ${JSON.stringify(meta.defaults, null, 2)}`
409583
types[slug].push({
410584
name: `${componentName}Defaults`,
411585
kind: 'const',
412586
code: defaultsCode,
413587
})
414588
}
589+
590+
// Store structured props fields as schema fields for table rendering
591+
if (meta.fields.length) {
592+
schemaFields[`${componentName}Props`] = meta.fields
593+
}
594+
595+
// Store events as schema fields under a separate key
596+
if (meta.events.length) {
597+
schemaFields[`${componentName}Events`] = meta.events
598+
// Also add an events declaration so ScriptTypes can display it
599+
types[slug].push({
600+
name: `${componentName}Events`,
601+
kind: 'interface',
602+
code: `interface ${componentName}Events {\n${meta.events.map(e => ` ${e.name}: ${e.type}`).join('\n')}\n}`,
603+
})
604+
}
415605
}
416606

417607
const output = {
@@ -420,4 +610,4 @@ const output = {
420610
}
421611

422612
writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`)
423-
console.log(`Generated registry types for ${Object.keys(types).length} scripts (${Object.keys(componentProps).length} with component props, ${Object.keys(schemaFields).length} schema fields)`)
613+
console.log(`Generated registry types for ${Object.keys(types).length} scripts (${Object.keys(componentMetas).length} with component meta, ${Object.keys(schemaFields).length} schema fields)`)

0 commit comments

Comments
 (0)