Skip to content

Commit eedf402

Browse files
authored
fix: third-party components may break index (#30)
* new package filter * fixes * fix test case * address linting issue * try fix tests * propagate errors * better error handling * better timeouts
1 parent 5dd06a9 commit eedf402

File tree

8 files changed

+319
-120
lines changed

8 files changed

+319
-120
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,11 @@ export default defineNuxtConfig({
232232
directories: [] // exclude by directory pattern
233233
},
234234

235+
// Package filtering for component index (default: false = exclude all packages)
236+
includePackages: false, // Exclude all package components from node_modules
237+
// includePackages: true, // Include all package components (not recommended)
238+
// includePackages: ['my-package'], // Include only components from specific packages
239+
235240
// Override metadata for specific components
236241
overrides: {
237242
TestButton: { category: 'Forms', status: 'experimental' }
@@ -241,6 +246,16 @@ export default defineNuxtConfig({
241246
})
242247
```
243248

249+
#### Package Filtering
250+
251+
The `includePackages` option controls which npm package components are included in the component index:
252+
253+
- `false` (default): No components from node_modules packages are processed
254+
- `true`: All package components are processed (may cause warnings for incompatible packages)
255+
- `['package-name', '@org/package']`: Only components from specified packages are processed
256+
257+
Only affects components registered globally by Nuxt from npm packages.
258+
244259
### Requirements for Component Index
245260

246261
- Components must be **global** (registered with `global: true` in Nuxt)

src/module.ts

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ export interface ModuleOptions {
88
enabled?: boolean
99
category?: string
1010
status?: 'experimental' | 'stable' | 'deprecated' | 'obsolete'
11+
includePackages?: boolean | string[] // false = exclude all (default), array = include only these
1112
exclude?: {
1213
components?: string[]
13-
directories?: string[]
14+
directories?: string[] // Path patterns only (not packages)
1415
}
1516
overrides?: Record<string, {
1617
category?: string
@@ -29,9 +30,10 @@ export default defineNuxtModule<ModuleOptions>({
2930
enabled: true,
3031
category: 'Nuxt Components',
3132
status: 'stable',
33+
includePackages: false, // By default, exclude all packages from node_modules
3234
exclude: {
3335
components: ['*--default'],
34-
directories: [],
36+
directories: [], // Path patterns only
3537
},
3638
overrides: {},
3739
},
@@ -113,24 +115,32 @@ export default defineNuxtModule<ModuleOptions>({
113115
let componentIndexData: import('./runtime/server/utils/generateComponentIndex').ComponentIndexData | null = null
114116

115117
nuxt.hook('app:templatesGenerated', async () => {
116-
const { generateComponentIndex } = await import('./runtime/server/utils/generateComponentIndex')
117-
const { resolve: resolvePath } = await import('node:path')
118-
119-
const globalComponents = nuxt.apps.default.components.filter(c => c.global)
120-
121-
if (globalComponents.length > 0) {
122-
const tsconfigPath = resolvePath(nuxt.options.rootDir, 'tsconfig.json')
123-
componentIndexData = generateComponentIndex(
124-
globalComponents,
125-
tsconfigPath,
126-
{
127-
category: options.componentIndex!.category,
128-
status: options.componentIndex!.status,
129-
excludeDirectories: options.componentIndex!.exclude!.directories,
130-
excludeComponents: options.componentIndex!.exclude!.components,
131-
overrides: options.componentIndex!.overrides,
132-
},
133-
)
118+
try {
119+
const { generateComponentIndex } = await import('./runtime/server/utils/generateComponentIndex')
120+
const { resolve: resolvePath } = await import('node:path')
121+
122+
const globalComponents = nuxt.apps.default.components.filter(c => c.global)
123+
124+
if (globalComponents.length > 0) {
125+
const tsconfigPath = resolvePath(nuxt.options.rootDir, 'tsconfig.json')
126+
componentIndexData = generateComponentIndex(
127+
globalComponents,
128+
tsconfigPath,
129+
{
130+
category: options.componentIndex!.category,
131+
status: options.componentIndex!.status,
132+
includePackages: options.componentIndex!.includePackages,
133+
excludeDirectories: options.componentIndex!.exclude!.directories,
134+
excludeComponents: options.componentIndex!.exclude!.components,
135+
overrides: options.componentIndex!.overrides,
136+
},
137+
)
138+
}
139+
}
140+
catch (error) {
141+
console.error('[nuxt-component-preview] Error generating component index:', error)
142+
// Set to null so HTTP endpoint returns error status code
143+
componentIndexData = null
134144
}
135145
})
136146

src/runtime/server/routes/nuxt-component-preview/component-index.json.get.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import componentIndexData from '#nuxt-component-preview-index-data'
55
export default defineEventHandler((event) => {
66
if (!componentIndexData) {
77
throw createError({
8-
statusCode: 404,
9-
message: 'Component index not generated. Ensure componentIndex is enabled.',
8+
statusCode: 500,
9+
message: 'Component index generation failed.',
1010
})
1111
}
1212

src/runtime/server/utils/generateComponentIndex.ts

Lines changed: 152 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
1+
import { existsSync, statSync } from 'node:fs'
12
import type { Component } from '@nuxt/schema'
23
import { createChecker } from 'vue-component-meta'
34
import { 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(/[/\\]node_modules[/\\](@[^/\\]+[/\\][^/\\]+|[^/\\]+)/)
12+
return match ? match[1].replace(/\\/g, '/') : null // Normalize to forward slashes
13+
}
14+
515
export 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

Comments
 (0)