1+ import { existsSync , statSync } from 'node:fs'
12import type { Component } from '@nuxt/schema'
23import { createChecker } from 'vue-component-meta'
34import { minimatch } from 'minimatch'
45
6+ /**
7+ * Extract package name from a file path in node_modules
8+ * Handles scoped packages (@org/package) and regular packages
9+ */
10+ export function extractPackageName ( filePath : string ) : string | null {
11+ const match = filePath . match ( / [ / \\ ] n o d e _ m o d u l e s [ / \\ ] ( @ [ ^ / \\ ] + [ / \\ ] [ ^ / \\ ] + | [ ^ / \\ ] + ) / )
12+ return match ? match [ 1 ] . replace ( / \\ / g, '/' ) : null // Normalize to forward slashes
13+ }
14+
515export interface ComponentIndexOptions {
616 category : string
717 status : 'experimental' | 'stable' | 'deprecated' | 'obsolete'
18+ includePackages ?: boolean | string [ ] // false = exclude all packages, array = include only these
819 excludeDirectories ?: string [ ]
920 excludeComponents ?: string [ ]
1021 overrides ?: Record < string , {
@@ -52,7 +63,44 @@ export function generateComponentIndex(
5263) : ComponentIndexData {
5364 // Filter components
5465 const filtered = components . filter ( ( c ) => {
55- // Check directory exclusions
66+ // First check if the path exists
67+ if ( ! existsSync ( c . filePath ) ) {
68+ console . warn ( `[nuxt-component-preview] Component file not found: ${ c . filePath } ` )
69+ return false
70+ }
71+
72+ // Check if it's actually a file (not a directory)
73+ try {
74+ const stats = statSync ( c . filePath )
75+ if ( stats . isDirectory ( ) ) {
76+ console . log ( `[nuxt-component-preview] Skipping directory: ${ c . filePath } ` )
77+ return false
78+ }
79+ }
80+ catch ( error ) {
81+ console . warn ( `[nuxt-component-preview] Error checking file stats for ${ c . filePath } :` , error )
82+ return false
83+ }
84+
85+ // Handle package filtering
86+ const isInNodeModules = c . filePath . includes ( '/node_modules/' ) || c . filePath . includes ( '\\node_modules\\' )
87+ if ( isInNodeModules ) {
88+ // Default: exclude all packages (includePackages === false or undefined)
89+ if ( options . includePackages === false || options . includePackages === undefined ) {
90+ return false
91+ }
92+
93+ // If includePackages is an array, only include packages in that list
94+ if ( Array . isArray ( options . includePackages ) ) {
95+ const packageName = extractPackageName ( c . filePath )
96+ if ( ! packageName || ! options . includePackages . includes ( packageName ) ) {
97+ return false
98+ }
99+ }
100+ // If includePackages === true, include all packages (no filtering)
101+ }
102+
103+ // Check directory exclusions (path patterns only)
56104 if ( options . excludeDirectories ) {
57105 const excluded = options . excludeDirectories . some ( pattern =>
58106 minimatch ( c . shortPath , `**/${ pattern } /**` ) ,
@@ -74,108 +122,122 @@ export function generateComponentIndex(
74122 const checker = createChecker ( tsconfigPath , { printer : { newLine : 1 } } )
75123
76124 const componentData = filtered . map ( ( component ) => {
77- const meta = checker . getComponentMeta ( component . filePath )
78-
79- // Extract props, filtering out Vue internals
80- const vueInternalProps = [ 'key' , 'ref' , 'ref_for' , 'ref_key' , 'class' , 'style' ]
81- const props = meta . props
82- . filter ( p => ! vueInternalProps . includes ( p . name ) )
83- . reduce ( ( acc , prop ) => {
84- const propDef : Partial < PropDefinition > = {
85- type : mapVueTypeToJsonSchema ( prop . type ) ,
86- title : prop . name . charAt ( 0 ) . toUpperCase ( ) + prop . name . slice ( 1 ) . replace ( / ( [ A - Z ] ) / g, ' $1' ) ,
87- }
88-
89- if ( prop . description ) propDef . description = prop . description
90- if ( prop . default !== undefined ) propDef . default = parseDefaultValue ( prop . default )
91-
92- // Extract enum from TypeScript union types
93- const enumValues = extractEnumFromType ( prop . type )
94- if ( enumValues . length > 0 ) {
95- propDef . enum = enumValues
125+ try {
126+ const meta = checker . getComponentMeta ( component . filePath )
127+
128+ // Extract props, filtering out Vue internals
129+ const vueInternalProps = [ 'key' , 'ref' , 'ref_for' , 'ref_key' , 'class' , 'style' ]
130+ const props = meta . props
131+ . filter ( p => ! vueInternalProps . includes ( p . name ) )
132+ . reduce ( ( acc , prop ) => {
133+ const propDef : Partial < PropDefinition > = {
134+ type : mapVueTypeToJsonSchema ( prop . type ) ,
135+ title : prop . name . charAt ( 0 ) . toUpperCase ( ) + prop . name . slice ( 1 ) . replace ( / ( [ A - Z ] ) / g, ' $1' ) ,
136+ }
96137
97- // Check for custom @enumLabels JSDoc tag
98- let metaEnum : Record < string , string > | undefined
99- if ( prop . tags ) {
100- const enumLabelsTag = prop . tags . find ( ( t : { name : string , text ?: string } ) => t . name === 'enumLabels' )
101- if ( enumLabelsTag ?. text ) {
102- try {
103- metaEnum = JSON . parse ( enumLabelsTag . text )
104- }
105- catch {
106- console . warn ( `Invalid @enumLabels JSON for ${ prop . name } :` , enumLabelsTag . text )
138+ if ( prop . description ) propDef . description = prop . description
139+ if ( prop . default !== undefined ) propDef . default = parseDefaultValue ( prop . default )
140+
141+ // Extract enum from TypeScript union types
142+ const enumValues = extractEnumFromType ( prop . type )
143+ if ( enumValues . length > 0 ) {
144+ propDef . enum = enumValues
145+
146+ // Check for custom @enumLabels JSDoc tag
147+ let metaEnum : Record < string , string > | undefined
148+ if ( prop . tags ) {
149+ const enumLabelsTag = prop . tags . find ( ( t : { name : string , text ?: string } ) => t . name === 'enumLabels' )
150+ if ( enumLabelsTag ?. text ) {
151+ try {
152+ metaEnum = JSON . parse ( enumLabelsTag . text )
153+ }
154+ catch {
155+ console . warn ( `Invalid @enumLabels JSON for ${ prop . name } :` , enumLabelsTag . text )
156+ }
107157 }
108158 }
109- }
110159
111- // Generate meta:enum only if custom labels provided or auto-generation adds value
112- if ( ! metaEnum ) {
113- const isNumericEnum = enumValues . every ( v => typeof v === 'number' )
114- if ( ! isNumericEnum ) {
115- metaEnum = enumValues . reduce ( ( acc , val ) => {
116- const strVal = String ( val )
117- // Convert kebab-case and camelCase to Title Case
118- const label = strVal
119- . replace ( / [ - _ ] / g, ' ' )
120- . replace ( / ( [ A - Z ] ) / g, ' $1' )
121- . trim ( )
122- . split ( ' ' )
123- . map ( word => word . charAt ( 0 ) . toUpperCase ( ) + word . slice ( 1 ) )
124- . join ( ' ' )
125- acc [ val ] = label
126- return acc
127- } , { } as Record < string , string > )
128-
129- // Only include if it differs from raw values
130- const addsValue = Object . entries ( metaEnum ) . some ( ( [ key , val ] ) => key !== val )
131- if ( ! addsValue ) {
132- metaEnum = undefined
160+ // Generate meta:enum only if custom labels provided or auto-generation adds value
161+ if ( ! metaEnum ) {
162+ const isNumericEnum = enumValues . every ( v => typeof v === 'number' )
163+ if ( ! isNumericEnum ) {
164+ metaEnum = enumValues . reduce ( ( acc , val ) => {
165+ const strVal = String ( val )
166+ // Convert kebab-case and camelCase to Title Case
167+ const label = strVal
168+ . replace ( / [ - _ ] / g, ' ' )
169+ . replace ( / ( [ A - Z ] ) / g, ' $1' )
170+ . trim ( )
171+ . split ( ' ' )
172+ . map ( word => word . charAt ( 0 ) . toUpperCase ( ) + word . slice ( 1 ) )
173+ . join ( ' ' )
174+ acc [ val ] = label
175+ return acc
176+ } , { } as Record < string , string > )
177+
178+ // Only include if it differs from raw values
179+ const addsValue = Object . entries ( metaEnum ) . some ( ( [ key , val ] ) => key !== val )
180+ if ( ! addsValue ) {
181+ metaEnum = undefined
182+ }
133183 }
134184 }
135- }
136185
137- if ( metaEnum ) {
138- propDef [ 'meta:enum' ] = metaEnum
186+ if ( metaEnum ) {
187+ propDef [ 'meta:enum' ] = metaEnum
188+ }
139189 }
140- }
141190
142- // Add examples from @example JSDoc tags
143- if ( prop . tags ) {
144- const exampleTags = prop . tags . filter ( ( t : { name : string , text ?: string } ) => t . name === 'example' )
145- if ( exampleTags . length > 0 ) {
146- propDef . examples = exampleTags . map ( ( t : { text ?: string } ) => parseDefaultValue ( t . text || '' ) )
191+ // Add examples from @example JSDoc tags
192+ if ( prop . tags ) {
193+ const exampleTags = prop . tags . filter ( ( t : { name : string , text ?: string } ) => t . name === 'example' )
194+ if ( exampleTags . length > 0 ) {
195+ propDef . examples = exampleTags . map ( ( t : { text ?: string } ) => parseDefaultValue ( t . text || '' ) )
196+ }
147197 }
148- }
149198
150- acc [ prop . name ] = propDef as PropDefinition
151- return acc
152- } , { } as Record < string , PropDefinition > )
199+ acc [ prop . name ] = propDef as PropDefinition
200+ return acc
201+ } , { } as Record < string , PropDefinition > )
153202
154- // Extract slots
155- const slots = meta . slots
156- . reduce ( ( acc , slot ) => {
157- acc [ slot . name ] = {
158- title : slot . name . charAt ( 0 ) . toUpperCase ( ) + slot . name . slice ( 1 ) . replace ( / ( [ A - Z ] ) / g, ' $1' ) ,
159- description : slot . description || undefined ,
160- }
161- return acc
162- } , { } as Record < string , SlotDefinition > )
163-
164- // Apply overrides if present
165- const override = options . overrides ?. [ component . pascalName ]
166-
167- return {
168- id : component . pascalName ,
169- name : component . pascalName . replace ( / ( [ A - Z ] ) / g, ' $1' ) . trim ( ) ,
170- category : override ?. category || options . category ,
171- status : override ?. status || options . status ,
172- props : {
173- type : 'object' ,
174- properties : props ,
175- } ,
176- ...( Object . keys ( slots ) . length > 0 && { slots } ) ,
203+ // Extract slots
204+ const slots = meta . slots
205+ . reduce ( ( acc , slot ) => {
206+ acc [ slot . name ] = {
207+ title : slot . name . charAt ( 0 ) . toUpperCase ( ) + slot . name . slice ( 1 ) . replace ( / ( [ A - Z ] ) / g, ' $1' ) ,
208+ description : slot . description || undefined ,
209+ }
210+ return acc
211+ } , { } as Record < string , SlotDefinition > )
212+
213+ // Apply overrides if present
214+ const override = options . overrides ?. [ component . pascalName ]
215+
216+ return {
217+ id : component . pascalName ,
218+ name : component . pascalName . replace ( / ( [ A - Z ] ) / g, ' $1' ) . trim ( ) ,
219+ category : override ?. category || options . category ,
220+ status : override ?. status || options . status ,
221+ props : {
222+ type : 'object' ,
223+ properties : props ,
224+ } ,
225+ ...( Object . keys ( slots ) . length > 0 && { slots } ) ,
226+ }
177227 }
178- } )
228+ catch ( error ) {
229+ // Log different message based on file type to help debugging
230+ const fileExt = component . filePath . split ( '.' ) . pop ( )
231+ if ( fileExt !== 'vue' ) {
232+ console . warn ( `[nuxt-component-preview] Could not extract metadata from ${ fileExt } file: ${ component . filePath } ` )
233+ }
234+ else {
235+ console . error ( `[nuxt-component-preview] Error processing component ${ component . filePath } :` , error )
236+ }
237+ // Return null to filter out components that can't be processed
238+ return null
239+ }
240+ } ) . filter ( Boolean ) as ComponentDefinition [ ]
179241
180242 return {
181243 version : '1.0' ,
0 commit comments