Skip to content

Commit 3165cb4

Browse files
committed
fix(propsToJsonSchema): drop function props & better enum support
1 parent 0d6a0a7 commit 3165cb4

File tree

5 files changed

+2162
-3
lines changed

5 files changed

+2162
-3
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<template>
2+
<div>
3+
<h3>Enum Test Component</h3>
4+
<p>Color: {{ color }}</p>
5+
<p>Size: {{ size }}</p>
6+
<p>Variant: {{ variant }}</p>
7+
</div>
8+
</template>
9+
10+
<script setup lang="ts">
11+
interface Props {
12+
/**
13+
* The color of the component
14+
* @defaultValue 'primary'
15+
*/
16+
color?: 'error' | 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'neutral'
17+
18+
/**
19+
* The size of the component
20+
* @defaultValue 'md'
21+
*/
22+
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
23+
24+
/**
25+
* The variant of the component
26+
* @defaultValue 'solid'
27+
*/
28+
variant?: 'solid' | 'outline' | 'soft' | 'subtle' | 'ghost' | 'link'
29+
30+
/**
31+
* Whether the component is disabled
32+
*/
33+
disabled?: boolean
34+
35+
/**
36+
* The label text
37+
*/
38+
label?: string
39+
}
40+
41+
defineProps<Props>()
42+
</script>

src/types/schema.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11

22
export interface JsonSchema {
3-
type?: string
3+
type?: string | string[]
44
properties?: Record<string, any>
55
required?: string[]
66
description?: string
77
default?: any
88
anyOf?: any[]
9+
allOf?: any[]
10+
enum?: any[]
11+
items?: any
12+
additionalProperties?: boolean
913
}

src/utils/schema.ts

Lines changed: 272 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,25 @@ export function propsToJsonSchema(props: ComponentMeta['props']): JsonSchema {
2323

2424
// Convert Vue prop type to JSON Schema type
2525
const propType = convertVueTypeToJsonSchema(prop.type, prop.schema as any)
26+
// Ignore if the prop type is undefined
27+
if (!propType) {
28+
continue
29+
}
30+
2631
Object.assign(propSchema, propType)
2732

2833
// Add default value if available and not already present, only for primitive types or for object with '{}'
2934
if (prop.default !== undefined && propSchema.default === undefined) {
3035
propSchema.default = parseDefaultValue(prop.default)
3136
}
37+
38+
// Also check for default values in tags
39+
if (propSchema.default === undefined && prop.tags) {
40+
const defaultValueTag = prop.tags.find(tag => tag.name === 'defaultValue')
41+
if (defaultValueTag) {
42+
propSchema.default = parseDefaultValue((defaultValueTag as unknown as { text: string }).text)
43+
}
44+
}
3245

3346
// Add the property to the schema
3447
schema.properties![prop.name] = propSchema
@@ -48,6 +61,24 @@ export function propsToJsonSchema(props: ComponentMeta['props']): JsonSchema {
4861
}
4962

5063
function convertVueTypeToJsonSchema(vueType: string, vueSchema: PropertyMetaSchema): any {
64+
// Skip function/event props as they're not useful in JSON Schema
65+
if (isFunctionProp(vueType, vueSchema)) {
66+
return undefined
67+
}
68+
69+
// Check for intersection types first (but only for simple cases, not union types)
70+
if (!vueType.includes('|') && vueType.includes(' & ')) {
71+
const intersectionSchema = convertIntersectionType(vueType)
72+
if (intersectionSchema) {
73+
return intersectionSchema
74+
}
75+
}
76+
77+
// Check if this is an enum type
78+
if (isEnumType(vueType, vueSchema)) {
79+
return convertEnumToJsonSchema(vueType, vueSchema)
80+
}
81+
5182
// Unwrap enums for optionals/unions
5283
const { type: unwrappedType, schema: unwrappedSchema, enumValues } = unwrapEnumSchema(vueType, vueSchema)
5384
if (enumValues && unwrappedType === 'boolean') {
@@ -172,6 +203,12 @@ function convertNestedSchemaToJsonSchemaProperties(nestedSchema: any): Record<st
172203
} else if (typeof prop === 'string') {
173204
type = prop
174205
}
206+
const converted = convertVueTypeToJsonSchema(type, schema)
207+
// Ignore if the converted type is undefined
208+
if (!converted) {
209+
continue
210+
}
211+
175212
properties[key] = convertVueTypeToJsonSchema(type, schema)
176213
// Only add description if non-empty
177214
if (description) {
@@ -218,8 +255,9 @@ function convertSimpleType(type: string): any {
218255

219256
function parseDefaultValue(defaultValue: string): any {
220257
try {
221-
// Remove quotes if it's a string literal
222-
if (defaultValue.startsWith('"') && defaultValue.endsWith('"')) {
258+
// Remove quotes if it's a string literal (both single and double quotes)
259+
if ((defaultValue.startsWith('"') && defaultValue.endsWith('"')) ||
260+
(defaultValue.startsWith("'") && defaultValue.endsWith("'"))) {
223261
return defaultValue.slice(1, -1)
224262
}
225263

@@ -305,4 +343,236 @@ function unwrapEnumSchema(vueType: string, vueSchema: PropertyMetaSchema): { typ
305343
}
306344

307345
return { type: vueType, schema: vueSchema }
346+
}
347+
348+
/**
349+
* Check if a type is an enum (union of string literals or boolean values)
350+
*/
351+
function isEnumType(vueType: string, vueSchema: PropertyMetaSchema): boolean {
352+
// Check if it's a union type with string literals or boolean values
353+
if (typeof vueSchema === 'object' && vueSchema?.kind === 'enum') {
354+
const schema = vueSchema.schema
355+
if (schema && typeof schema === 'object') {
356+
const values = Object.values(schema)
357+
// Check if all non-undefined values are string literals
358+
const stringLiterals = values.filter(v =>
359+
v !== 'undefined' &&
360+
typeof v === 'string' &&
361+
v.startsWith('"') &&
362+
v.endsWith('"')
363+
)
364+
// Check if all non-undefined values are boolean literals
365+
const booleanLiterals = values.filter(v =>
366+
v !== 'undefined' &&
367+
(v === 'true' || v === 'false')
368+
)
369+
return stringLiterals.length > 0 || booleanLiterals.length > 0
370+
}
371+
}
372+
373+
// Check if the type string contains string literals
374+
if (vueType.includes('"') && vueType.includes('|')) {
375+
return true
376+
}
377+
378+
return false
379+
}
380+
381+
/**
382+
* Convert enum type to JSON Schema
383+
*/
384+
function convertEnumToJsonSchema(vueType: string, vueSchema: PropertyMetaSchema): any {
385+
if (typeof vueSchema === 'object' && vueSchema?.kind === 'enum') {
386+
const schema = vueSchema.schema
387+
if (schema && typeof schema === 'object') {
388+
const enumValues: any[] = []
389+
const types = new Set<string>()
390+
391+
// Extract enum values and types
392+
Object.values(schema).forEach(value => {
393+
if (value === 'undefined') {
394+
// Handle optional types
395+
return
396+
}
397+
398+
if (typeof value === 'string') {
399+
if (value === 'true' || value === 'false') {
400+
enumValues.push(value === 'true')
401+
types.add('boolean')
402+
} else if (value.startsWith('"') && value.endsWith('"')) {
403+
enumValues.push(value.slice(1, -1)) // Remove quotes
404+
types.add('string')
405+
} else if (value === 'string') {
406+
types.add('string')
407+
} else if (value === 'number') {
408+
types.add('number')
409+
} else if (value === 'boolean') {
410+
types.add('boolean')
411+
}
412+
} else if (typeof value === 'object' && value !== null) {
413+
// Complex type like (string & {}) - convert to allOf schema
414+
if (value.type) {
415+
const convertedType = convertIntersectionType(value.type)
416+
if (convertedType) {
417+
// For intersection types in enums, we need to handle them differently
418+
// We'll add a special marker to indicate this is an intersection type
419+
types.add('__intersection__')
420+
} else {
421+
types.add(value.type)
422+
}
423+
}
424+
}
425+
})
426+
427+
// If we have enum values, create an enum schema
428+
if (enumValues.length > 0) {
429+
const result: any = { enum: enumValues }
430+
431+
// Check if we have intersection types
432+
if (types.has('__intersection__')) {
433+
// For enums with intersection types, we need to create a more complex schema
434+
// Find the intersection type in the original schema
435+
const intersectionType = Object.values(schema).find(v =>
436+
typeof v === 'object' && v?.type && v.type.includes(' & ')
437+
)
438+
439+
if (intersectionType) {
440+
const convertedIntersection = convertIntersectionType((intersectionType as unknown as { type: string }).type)
441+
if (convertedIntersection) {
442+
// Create an anyOf schema that combines the enum with the intersection type
443+
return {
444+
anyOf: [
445+
{ enum: enumValues },
446+
convertedIntersection
447+
]
448+
}
449+
}
450+
}
451+
}
452+
453+
// Add type if it's consistent
454+
if (types.size === 1) {
455+
result.type = Array.from(types)[0]
456+
} else if (types.size > 1) {
457+
result.type = Array.from(types)
458+
}
459+
460+
// Special case: if it's a boolean enum with just true/false, treat as regular boolean
461+
if (types.size === 1 && types.has('boolean') && enumValues.length === 2 &&
462+
enumValues.includes(true) && enumValues.includes(false)) {
463+
return { type: 'boolean' }
464+
}
465+
466+
return result
467+
}
468+
469+
// If no enum values but we have types, create a union type
470+
if (types.size > 1) {
471+
return { type: Array.from(types) }
472+
} else if (types.size === 1) {
473+
return { type: Array.from(types)[0] }
474+
}
475+
}
476+
}
477+
478+
// Fallback: try to extract from type string
479+
if (vueType.includes('"') && vueType.includes('|')) {
480+
const enumValues: string[] = []
481+
const parts = vueType.split('|').map(p => p.trim())
482+
483+
parts.forEach(part => {
484+
if (part.startsWith('"') && part.endsWith('"')) {
485+
enumValues.push(part.slice(1, -1))
486+
} else if (part === 'undefined') {
487+
// Skip undefined
488+
}
489+
})
490+
491+
if (enumValues.length > 0) {
492+
return { type: 'string', enum: enumValues }
493+
}
494+
}
495+
496+
// Final fallback
497+
return { type: 'string' }
498+
}
499+
500+
/**
501+
* Check if a prop is a function/event prop that should be excluded from JSON Schema
502+
*/
503+
function isFunctionProp(type: string, schema: any): boolean {
504+
// Check if the type contains function signatures
505+
if (type && typeof type === 'string') {
506+
// Look for function patterns like (event: MouseEvent) => void
507+
if (type.includes('=>') || type.includes('(event:') || type.includes('void')) {
508+
return true
509+
}
510+
}
511+
512+
// Check if the schema contains event handlers
513+
if (schema && typeof schema === 'object') {
514+
// Check for event kind in enum schemas
515+
if (schema.kind === 'enum' && schema.schema) {
516+
const values = Object.values(schema.schema) as Record<string, unknown>[]
517+
for (const value of values) {
518+
if (typeof value === 'object' && value?.kind === 'event') {
519+
return true
520+
}
521+
// Check nested arrays for event handlers
522+
if (typeof value === 'object' && value?.kind === 'array' && value.schema) {
523+
for (const item of value.schema as Record<string, unknown>[]) {
524+
if (typeof item === 'object' && item?.kind === 'event') {
525+
return true
526+
}
527+
}
528+
}
529+
}
530+
}
531+
}
532+
533+
return false
534+
}
535+
536+
/**
537+
* Convert TypeScript intersection types to JSON Schema allOf
538+
*/
539+
function convertIntersectionType(typeString: string): any | null {
540+
// Handle string & {} pattern
541+
if (typeString === 'string & {}') {
542+
return {
543+
allOf: [
544+
{ type: 'string' },
545+
{ type: 'object', additionalProperties: false }
546+
]
547+
}
548+
}
549+
550+
// Handle other intersection patterns
551+
if (typeString.includes(' & ')) {
552+
const parts = typeString.split(' & ').map(p => p.trim())
553+
const allOfSchemas = parts.map(part => {
554+
if (part === 'string') {
555+
return { type: 'string' }
556+
} else if (part === 'number') {
557+
return { type: 'number' }
558+
} else if (part === 'boolean') {
559+
return { type: 'boolean' }
560+
} else if (part === 'object') {
561+
return { type: 'object' }
562+
} else if (part === '{}') {
563+
return { type: 'object', additionalProperties: false }
564+
} else if (part === 'null') {
565+
return { type: 'null' }
566+
} else {
567+
// For other types, return as-is
568+
return { type: part }
569+
}
570+
})
571+
572+
return {
573+
allOf: allOfSchemas
574+
}
575+
}
576+
577+
return null
308578
}

0 commit comments

Comments
 (0)