Skip to content

Commit 80780f6

Browse files
feat(module): support multiple component directories (#1597)
Co-authored-by: Sadegh Barati <sadeghbaratiwork@gmail.com>
1 parent 9d37f88 commit 80780f6

File tree

11 files changed

+177
-54
lines changed

11 files changed

+177
-54
lines changed

packages/module/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,28 @@ export default defineNuxtConfig({
6464

6565
That's it! You can now use Shadcn Nuxt in your Nuxt app ✨
6666

67+
### Multiple component directories
68+
69+
Projects that split their base UI components from custom extensions can configure multiple directories, each with its own optional prefix:
70+
71+
```ts
72+
export default defineNuxtConfig({
73+
modules: ['shadcn-nuxt'],
74+
shadcn: {
75+
prefix: 'Ui',
76+
componentDir: [
77+
'@/components/ui',
78+
{
79+
path: '@/components/ai',
80+
prefix: 'Ai',
81+
},
82+
],
83+
},
84+
})
85+
```
86+
87+
Each directory is ignored by Nuxt's default auto-import scanning and re-registered through the module, ensuring clean separation without console warnings. See [issue #1593](https://github.com/unovue/shadcn-vue/issues/1593) for the background.
88+
6789
## Development
6890

6991
```bash

packages/module/src/module.ts

Lines changed: 91 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,40 @@ import { join } from 'node:path'
33
import { addComponent, addComponentsDir, createResolver, defineNuxtModule } from '@nuxt/kit'
44
import { parseSync } from 'oxc-parser'
55

6+
export interface ComponentDirConfig {
7+
path: string
8+
prefix?: string
9+
}
10+
11+
export type ComponentDirInput = string | ComponentDirConfig
12+
13+
export type ComponentDirOption = ComponentDirInput | ComponentDirInput[]
14+
15+
interface NormalizedComponentDir {
16+
path: string
17+
prefix: string
18+
}
19+
20+
function isComponentDirConfig(value: unknown): value is ComponentDirConfig {
21+
return typeof value === 'object' && value !== null && 'path' in value
22+
}
23+
24+
function normalizeComponentDirs(componentDir: ComponentDirOption, fallbackPrefix: string): NormalizedComponentDir[] {
25+
const dirs = Array.isArray(componentDir) ? componentDir : [componentDir]
26+
27+
return dirs
28+
.filter((dir): dir is ComponentDirInput => Boolean(dir))
29+
.map((dir) => {
30+
if (typeof dir === 'string')
31+
return { path: dir, prefix: fallbackPrefix }
32+
33+
if (isComponentDirConfig(dir))
34+
return { path: dir.path, prefix: dir.prefix ?? fallbackPrefix }
35+
36+
throw new Error('Invalid componentDir entry provided to shadcn module.')
37+
})
38+
}
39+
640
// TODO: add test to make sure all registry is being parse correctly
741
// Module options TypeScript interface definition
842
export interface ModuleOptions {
@@ -17,7 +51,7 @@ export interface ModuleOptions {
1751
* @link https://nuxt.com/docs/api/nuxt-config#alias
1852
* @default "@/components/ui"
1953
*/
20-
componentDir?: string
54+
componentDir?: ComponentDirOption
2155
}
2256

2357
export default defineNuxtModule<ModuleOptions>({
@@ -30,63 +64,66 @@ export default defineNuxtModule<ModuleOptions>({
3064
componentDir: '@/components/ui',
3165
},
3266
async setup({ prefix, componentDir }, nuxt) {
33-
const COMPONENT_DIR_PATH = componentDir!
3467
const ROOT_DIR_PATH = nuxt.options.rootDir
3568
const { resolve, resolvePath } = createResolver(ROOT_DIR_PATH)
3669

37-
// Components Auto Imports
38-
const componentsPath = await resolvePath(COMPONENT_DIR_PATH)
39-
40-
// Early return if directory doesn't exist
41-
if (!existsSync(componentsPath)) {
42-
console.warn(`Component directory does not exist: ${componentsPath}`)
43-
return
44-
}
45-
46-
// Tell Nuxt to not scan `componentsDir` for auto imports as we will do it manually
47-
// See https://github.com/unovue/shadcn-vue/pull/528#discussion_r1590206268
48-
addComponentsDir({
49-
path: componentsPath,
50-
extensions: [],
51-
ignore: ['**/*'],
52-
}, {
53-
prepend: true,
54-
})
70+
const normalizedDirs = normalizeComponentDirs(componentDir ?? '@/components/ui', prefix ?? 'Ui')
71+
72+
await Promise.all(normalizedDirs.map(async ({ path, prefix: dirPrefix }) => {
73+
// Components Auto Imports
74+
const componentsPath = await resolvePath(path)
75+
76+
// Early return if directory doesn't exist
77+
if (!existsSync(componentsPath)) {
78+
console.warn(`Component directory does not exist: ${componentsPath}`)
79+
return
80+
}
81+
82+
// Tell Nuxt to not scan `componentsDir` for auto imports as we will do it manually
83+
// See https://github.com/unovue/shadcn-vue/pull/528#discussion_r1590206268
84+
addComponentsDir({
85+
path: componentsPath,
86+
extensions: [],
87+
ignore: ['**/*'],
88+
}, {
89+
prepend: true,
90+
})
91+
92+
// Manually scan `componentsDir` for components and register them for auto imports
93+
try {
94+
await Promise.all(readdirSync(componentsPath).map(async (dir) => {
95+
try {
96+
const filePath = await resolvePath(join(path, dir, 'index'), { extensions: ['.ts', '.js'] })
97+
const content = readFileSync(filePath, { encoding: 'utf8' })
98+
const ast = parseSync(filePath, content, {
99+
sourceType: 'module',
100+
})
101+
102+
const exportedKeys: string[] = ast.program.body
103+
.filter(node => node.type === 'ExportNamedDeclaration')
104+
// @ts-expect-error parse return any
105+
.flatMap(node => node.specifiers?.map(specifier => specifier.exported?.name) || [])
106+
.filter((key: string) => /^[A-Z]/.test(key))
55107

56-
// Manually scan `componentsDir` for components and register them for auto imports
57-
try {
58-
await Promise.all(readdirSync(componentsPath).map(async (dir) => {
59-
try {
60-
const filePath = await resolvePath(join(COMPONENT_DIR_PATH, dir, 'index'), { extensions: ['.ts', '.js'] })
61-
const content = readFileSync(filePath, { encoding: 'utf8' })
62-
const ast = parseSync(filePath, content, {
63-
sourceType: 'module',
64-
})
65-
66-
const exportedKeys: string[] = ast.program.body
67-
.filter(node => node.type === 'ExportNamedDeclaration')
68-
// @ts-expect-error parse return any
69-
.flatMap(node => node.specifiers?.map(specifier => specifier.exported?.name) || [])
70-
.filter((key: string) => /^[A-Z]/.test(key))
71-
72-
exportedKeys.forEach((key) => {
73-
addComponent({
74-
name: `${prefix}${key}`, // name of the component to be used in vue templates
75-
export: key, // (optional) if the component is a named (rather than default) export
76-
filePath: resolve(filePath),
77-
priority: 1,
108+
exportedKeys.forEach((key) => {
109+
addComponent({
110+
name: `${dirPrefix}${key}`, // name of the component to be used in vue templates
111+
export: key, // (optional) if the component is a named (rather than default) export
112+
filePath: resolve(filePath),
113+
priority: 1,
114+
})
78115
})
79-
})
80-
}
81-
catch (err) {
82-
if (err instanceof Error)
83-
console.warn('Module error: ', err.message)
84-
}
85-
}))
86-
}
87-
catch (err) {
88-
if (err instanceof Error)
89-
console.warn(err.message)
90-
}
116+
}
117+
catch (err) {
118+
if (err instanceof Error)
119+
console.warn('Module error: ', err.message)
120+
}
121+
}))
122+
}
123+
catch (err) {
124+
if (err instanceof Error)
125+
console.warn(err.message)
126+
}
127+
}))
91128
},
92129
})
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script setup>
2+
</script>
3+
4+
<template>
5+
<div>
6+
<UiButton />
7+
<AiChatPanel />
8+
</div>
9+
</template>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<template>
2+
<section>
3+
AI Chat Panel
4+
</section>
5+
</template>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as ChatPanel } from './ChatPanel.vue'
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<template>
2+
<button>
3+
Base Button
4+
</button>
5+
</template>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as Button } from './Button.vue'
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import MyModule from '../../../src/module'
2+
3+
export default defineNuxtConfig({
4+
modules: [
5+
MyModule,
6+
],
7+
shadcn: {
8+
prefix: 'Ui',
9+
componentDir: [
10+
'@/components/ui',
11+
{
12+
path: '@/components/ai',
13+
prefix: 'Ai',
14+
},
15+
],
16+
},
17+
})
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "multi-dir",
3+
"type": "module",
4+
"private": true
5+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
declare module '*.vue' {
2+
import type { DefineComponent } from 'vue'
3+
4+
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>
5+
export default component
6+
}

0 commit comments

Comments
 (0)