mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-25 23:22:02 +00:00
feat: components discovery (#243)
Co-authored-by: Daniel Roe <daniel@roe.dev>
This commit is contained in:
parent
1ed3387243
commit
a0f81cd1fb
@ -2,3 +2,4 @@ dist
|
|||||||
node_modules
|
node_modules
|
||||||
_templates
|
_templates
|
||||||
schema
|
schema
|
||||||
|
**/*.tmpl.*
|
||||||
|
5
examples/with-components/app.vue
Normal file
5
examples/with-components/app.vue
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<hello-world />
|
||||||
|
</div>
|
||||||
|
</template>
|
5
examples/with-components/components/HelloWorld.vue
Normal file
5
examples/with-components/components/HelloWorld.vue
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
This is HelloWorld component!
|
||||||
|
</div>
|
||||||
|
</template>
|
4
examples/with-components/nuxt.config.ts
Normal file
4
examples/with-components/nuxt.config.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { defineNuxtConfig } from '@nuxt/kit'
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
})
|
12
examples/with-components/package.json
Normal file
12
examples/with-components/package.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "example-with-components",
|
||||||
|
"private": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"nuxt3": "latest"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nu dev",
|
||||||
|
"build": "nu build",
|
||||||
|
"start": "node .output/server"
|
||||||
|
}
|
||||||
|
}
|
9
packages/components/build.config.ts
Normal file
9
packages/components/build.config.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { defineBuildConfig } from 'unbuild'
|
||||||
|
|
||||||
|
export default defineBuildConfig({
|
||||||
|
declaration: true,
|
||||||
|
entries: [
|
||||||
|
'src/module',
|
||||||
|
{ input: 'src/runtime/', outDir: 'dist/runtime', format: 'esm' }
|
||||||
|
]
|
||||||
|
})
|
1
packages/components/module.js
Normal file
1
packages/components/module.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = require('./dist/module')
|
27
packages/components/package.json
Normal file
27
packages/components/package.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@nuxt/component-discovery",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"repository": "nuxt/framework",
|
||||||
|
"license": "MIT",
|
||||||
|
"types": "./dist/module.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"module.js"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"prepack": "unbuild"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxt/kit": "^0.6.3",
|
||||||
|
"globby": "^11.0.3",
|
||||||
|
"scule": "^0.2.1",
|
||||||
|
"ufo": "^0.7.5",
|
||||||
|
"upath": "^2.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"unbuild": "^0.3.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "3.1.1"
|
||||||
|
}
|
||||||
|
}
|
88
packages/components/src/module.ts
Normal file
88
packages/components/src/module.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import { defineNuxtModule, resolveAlias } from '@nuxt/kit'
|
||||||
|
import { resolve, dirname } from 'upath'
|
||||||
|
import { scanComponents } from './scan'
|
||||||
|
import type { ComponentsDir } from './types'
|
||||||
|
|
||||||
|
const isPureObjectOrString = (val: any) => (!Array.isArray(val) && typeof val === 'object') || typeof val === 'string'
|
||||||
|
const getDir = (p: string) => fs.statSync(p).isDirectory() ? p : dirname(p)
|
||||||
|
|
||||||
|
export default defineNuxtModule({
|
||||||
|
name: 'components',
|
||||||
|
defaults: {
|
||||||
|
dirs: ['~/components']
|
||||||
|
},
|
||||||
|
setup (options, nuxt) {
|
||||||
|
let componentDirs = []
|
||||||
|
|
||||||
|
// Resolve dirs
|
||||||
|
nuxt.hook('app:resolve', async () => {
|
||||||
|
await nuxt.callHook('components:dirs', options.dirs)
|
||||||
|
|
||||||
|
componentDirs = options.dirs.filter(isPureObjectOrString).map((dir) => {
|
||||||
|
const dirOptions: ComponentsDir = typeof dir === 'object' ? dir : { path: dir }
|
||||||
|
const dirPath = getDir(resolveAlias(dirOptions.path, nuxt.options.alias))
|
||||||
|
const transpile = typeof dirOptions.transpile === 'boolean' ? dirOptions.transpile : 'auto'
|
||||||
|
const extensions = dirOptions.extensions || ['vue'] // TODO: nuxt extensions and strip leading dot
|
||||||
|
|
||||||
|
dirOptions.level = Number(dirOptions.level || 0)
|
||||||
|
|
||||||
|
const enabled = fs.existsSync(dirPath)
|
||||||
|
if (!enabled && dirOptions.path !== '~/components') {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('Components directory not found: `' + dirPath + '`')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...dirOptions,
|
||||||
|
enabled,
|
||||||
|
path: dirPath,
|
||||||
|
extensions,
|
||||||
|
pattern: dirOptions.pattern || `**/*.{${extensions.join(',')},}`,
|
||||||
|
ignore: [
|
||||||
|
'**/*.stories.{js,ts,jsx,tsx}', // ignore storybook files
|
||||||
|
'**/*{M,.m,-m}ixin.{js,ts,jsx,tsx}', // ignore mixins
|
||||||
|
'**/*.d.ts', // .d.ts files
|
||||||
|
// TODO: support nuxt ignore patterns
|
||||||
|
...(dirOptions.ignore || [])
|
||||||
|
],
|
||||||
|
transpile: (transpile === 'auto' ? dirPath.includes('node_modules') : transpile)
|
||||||
|
}
|
||||||
|
}).filter(d => d.enabled)
|
||||||
|
|
||||||
|
nuxt.options.build!.transpile!.push(...componentDirs.filter(dir => dir.transpile).map(dir => dir.path))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Scan components and add to plugin
|
||||||
|
nuxt.hook('app:templates', async (app) => {
|
||||||
|
const components = await scanComponents(componentDirs, nuxt.options.srcDir!)
|
||||||
|
await nuxt.callHook('components:extend', components)
|
||||||
|
if (!components.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
app.templates.push({
|
||||||
|
path: 'components.mjs',
|
||||||
|
src: resolve(__dirname, 'runtime/components.tmpl.mjs'),
|
||||||
|
data: { components }
|
||||||
|
})
|
||||||
|
app.templates.push({
|
||||||
|
path: 'components.d.ts',
|
||||||
|
src: resolve(__dirname, 'runtime/components.tmpl.d.ts'),
|
||||||
|
data: { components }
|
||||||
|
})
|
||||||
|
app.plugins.push({ src: '#build/components' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for changes
|
||||||
|
nuxt.hook('builder:watch', async (event, path) => {
|
||||||
|
if (!['add', 'unlink'].includes(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fPath = resolve(nuxt.options.rootDir, path)
|
||||||
|
if (componentDirs.find(dir => fPath.startsWith(dir.path))) {
|
||||||
|
await nuxt.callHook('builder:generateApp')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
7
packages/components/src/runtime/components.tmpl.d.ts
vendored
Normal file
7
packages/components/src/runtime/components.tmpl.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
declare module 'vue' {
|
||||||
|
export interface GlobalComponents {
|
||||||
|
<%= components.map(c => {
|
||||||
|
return ` ${c.pascalName}: typeof import('${c.filePath}')['${c.export}']`
|
||||||
|
}).join(',\n') %>
|
||||||
|
}
|
||||||
|
}
|
22
packages/components/src/runtime/components.tmpl.mjs
Normal file
22
packages/components/src/runtime/components.tmpl.mjs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
import { defineAsyncComponent } from 'vue'
|
||||||
|
|
||||||
|
const components = {
|
||||||
|
<%= components.map(c => {
|
||||||
|
const exp = c.export === 'default' ? `c.default || c` : `c['${c.export}']`
|
||||||
|
const magicComments = [
|
||||||
|
`webpackChunkName: "${c.chunkName}"`,
|
||||||
|
c.prefetch === true || typeof c.prefetch === 'number' ? `webpackPrefetch: ${c.prefetch}` : false,
|
||||||
|
c.preload === true || typeof c.preload === 'number' ? `webpackPreload: ${c.preload}` : false,
|
||||||
|
].filter(Boolean).join(', ')
|
||||||
|
|
||||||
|
return ` ${c.pascalName}: defineAsyncComponent(() => import('${c.filePath}' /* ${magicComments} */).then(c => ${exp}))`
|
||||||
|
}).join(',\n') %>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (nuxt) {
|
||||||
|
for (const name in components) {
|
||||||
|
nuxt.app.component(name, components[name])
|
||||||
|
nuxt.app.component('Lazy' + name, components[name])
|
||||||
|
}
|
||||||
|
}
|
100
packages/components/src/scan.ts
Normal file
100
packages/components/src/scan.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { basename, extname, join, dirname, relative } from 'upath'
|
||||||
|
import globby from 'globby'
|
||||||
|
import { pascalCase, splitByCase } from 'scule'
|
||||||
|
import type { ScanDir, Component } from './types'
|
||||||
|
|
||||||
|
export function sortDirsByPathLength ({ path: pathA }: ScanDir, { path: pathB }: ScanDir): number {
|
||||||
|
return pathB.split(/[\\/]/).filter(Boolean).length - pathA.split(/[\\/]/).filter(Boolean).length
|
||||||
|
}
|
||||||
|
|
||||||
|
// vue@2 src/shared/util.js
|
||||||
|
// TODO: update to vue3?
|
||||||
|
function hyphenate (str: string):string {
|
||||||
|
return str.replace(/\B([A-Z])/g, '-$1').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanComponents (dirs: ScanDir[], srcDir: string): Promise<Component[]> {
|
||||||
|
const components: Component[] = []
|
||||||
|
const filePaths = new Set<string>()
|
||||||
|
const scannedPaths: string[] = []
|
||||||
|
|
||||||
|
for (const { path, pattern, ignore = [], prefix, extendComponent, pathPrefix, level, prefetch = false, preload = false } of dirs.sort(sortDirsByPathLength)) {
|
||||||
|
const resolvedNames = new Map<string, string>()
|
||||||
|
|
||||||
|
for (const _file of await globby(pattern!, { cwd: path, ignore })) {
|
||||||
|
const filePath = join(path, _file)
|
||||||
|
|
||||||
|
if (scannedPaths.find(d => filePath.startsWith(d))) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filePaths.has(filePath)) { continue }
|
||||||
|
filePaths.add(filePath)
|
||||||
|
|
||||||
|
// Resolve componentName
|
||||||
|
const prefixParts = ([] as string[]).concat(
|
||||||
|
prefix ? splitByCase(prefix) : [],
|
||||||
|
(pathPrefix !== false) ? splitByCase(relative(path, dirname(filePath))) : []
|
||||||
|
)
|
||||||
|
let fileName = basename(filePath, extname(filePath))
|
||||||
|
if (fileName.toLowerCase() === 'index') {
|
||||||
|
fileName = pathPrefix === false ? basename(dirname(filePath)) : '' /* inherits from path */
|
||||||
|
}
|
||||||
|
const fileNameParts = splitByCase(fileName)
|
||||||
|
|
||||||
|
const componentNameParts: string[] = []
|
||||||
|
|
||||||
|
while (prefixParts.length &&
|
||||||
|
(prefixParts[0] || '').toLowerCase() !== (fileNameParts[0] || '').toLowerCase()
|
||||||
|
) {
|
||||||
|
componentNameParts.push(prefixParts.shift()!)
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentName = pascalCase(componentNameParts) + pascalCase(fileNameParts)
|
||||||
|
|
||||||
|
if (resolvedNames.has(componentName)) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(`Two component files resolving to the same name \`${componentName}\`:\n` +
|
||||||
|
`\n - ${filePath}` +
|
||||||
|
`\n - ${resolvedNames.get(componentName)}`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resolvedNames.set(componentName, filePath)
|
||||||
|
|
||||||
|
const pascalName = pascalCase(componentName)
|
||||||
|
const kebabName = hyphenate(componentName)
|
||||||
|
const shortPath = relative(srcDir, filePath)
|
||||||
|
const chunkName = 'components/' + kebabName
|
||||||
|
|
||||||
|
let component: Component = {
|
||||||
|
filePath,
|
||||||
|
pascalName,
|
||||||
|
kebabName,
|
||||||
|
chunkName,
|
||||||
|
shortPath,
|
||||||
|
export: 'default',
|
||||||
|
global: Boolean(global),
|
||||||
|
level: Number(level),
|
||||||
|
prefetch: Boolean(prefetch),
|
||||||
|
preload: Boolean(preload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof extendComponent === 'function') {
|
||||||
|
component = (await extendComponent(component)) || component
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if component is already defined, used to overwite if level is inferiour
|
||||||
|
const definedComponent = components.find(c => c.pascalName === component.pascalName)
|
||||||
|
if (definedComponent && component.level < definedComponent.level) {
|
||||||
|
Object.assign(definedComponent, component)
|
||||||
|
} else if (!definedComponent) {
|
||||||
|
components.push(component)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scannedPaths.push(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return components
|
||||||
|
}
|
62
packages/components/src/types.ts
Normal file
62
packages/components/src/types.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
export interface Component {
|
||||||
|
pascalName: string
|
||||||
|
kebabName: string
|
||||||
|
export: string
|
||||||
|
filePath: string
|
||||||
|
shortPath: string
|
||||||
|
chunkName: string
|
||||||
|
level: number
|
||||||
|
prefetch: boolean
|
||||||
|
preload: boolean
|
||||||
|
|
||||||
|
/** @deprecated */
|
||||||
|
import?: string
|
||||||
|
/** @deprecated */
|
||||||
|
asyncImport?: string
|
||||||
|
/** @deprecated */
|
||||||
|
global?: boolean
|
||||||
|
/** @deprecated */
|
||||||
|
async?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanDir {
|
||||||
|
path: string
|
||||||
|
pattern?: string | string[]
|
||||||
|
ignore?: string[]
|
||||||
|
prefix?: string
|
||||||
|
pathPrefix?: boolean
|
||||||
|
level?: number
|
||||||
|
prefetch?: boolean
|
||||||
|
preload?: boolean
|
||||||
|
extendComponent?: (component: Component) => Promise<Component | void> | (Component | void)
|
||||||
|
/** @deprecated */
|
||||||
|
global?: boolean | 'dev'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentsDir extends ScanDir {
|
||||||
|
watch?: boolean
|
||||||
|
extensions?: string[]
|
||||||
|
transpile?: 'auto' | boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type componentsDirHook = (dirs: ComponentsDir[]) => void | Promise<void>
|
||||||
|
type componentsExtendHook = (components: (ComponentsDir | ScanDir)[]) => void | Promise<void>
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
dirs: (string | ComponentsDir)[]
|
||||||
|
loader: Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@nuxt/kit' {
|
||||||
|
interface NuxtOptions {
|
||||||
|
components: boolean | Options | Options['dirs']
|
||||||
|
}
|
||||||
|
interface NuxtOptionsHooks {
|
||||||
|
'components:dirs'?: componentsDirHook
|
||||||
|
'components:extend'?: componentsExtendHook
|
||||||
|
components?: {
|
||||||
|
dirs?: componentsDirHook
|
||||||
|
extend?: componentsExtendHook
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/app": "^0.4.3",
|
"@nuxt/app": "^0.4.3",
|
||||||
|
"@nuxt/component-discovery": "^0.1.0",
|
||||||
"@nuxt/kit": "^0.6.3",
|
"@nuxt/kit": "^0.6.3",
|
||||||
"@nuxt/nitro": "^0.9.0",
|
"@nuxt/nitro": "^0.9.0",
|
||||||
"@nuxt/pages": "^0.2.3",
|
"@nuxt/pages": "^0.2.3",
|
||||||
|
@ -50,6 +50,7 @@ export async function loadNuxt (opts: LoadNuxtOptions): Promise<Nuxt> {
|
|||||||
options._majorVersion = 3
|
options._majorVersion = 3
|
||||||
options.alias.vue = require.resolve('vue/dist/vue.esm-bundler.js')
|
options.alias.vue = require.resolve('vue/dist/vue.esm-bundler.js')
|
||||||
options.buildModules.push(require.resolve('@nuxt/pages/module'))
|
options.buildModules.push(require.resolve('@nuxt/pages/module'))
|
||||||
|
options.buildModules.push(require.resolve('@nuxt/component-discovery/module'))
|
||||||
|
|
||||||
const nuxt = createNuxt(options)
|
const nuxt = createNuxt(options)
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
Hello
|
<HelloWorld />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
24
yarn.lock
24
yarn.lock
@ -1648,6 +1648,21 @@ __metadata:
|
|||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
|
"@nuxt/component-discovery@^0.1.0, @nuxt/component-discovery@workspace:packages/components":
|
||||||
|
version: 0.0.0-use.local
|
||||||
|
resolution: "@nuxt/component-discovery@workspace:packages/components"
|
||||||
|
dependencies:
|
||||||
|
"@nuxt/kit": ^0.6.3
|
||||||
|
globby: ^11.0.3
|
||||||
|
scule: ^0.2.1
|
||||||
|
ufo: ^0.7.5
|
||||||
|
unbuild: ^0.3.1
|
||||||
|
upath: ^2.0.1
|
||||||
|
peerDependencies:
|
||||||
|
vue: 3.1.1
|
||||||
|
languageName: unknown
|
||||||
|
linkType: soft
|
||||||
|
|
||||||
"@nuxt/devalue@npm:^1.2.5":
|
"@nuxt/devalue@npm:^1.2.5":
|
||||||
version: 1.2.5
|
version: 1.2.5
|
||||||
resolution: "@nuxt/devalue@npm:1.2.5"
|
resolution: "@nuxt/devalue@npm:1.2.5"
|
||||||
@ -6000,6 +6015,14 @@ __metadata:
|
|||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
|
"example-with-components@workspace:examples/with-components":
|
||||||
|
version: 0.0.0-use.local
|
||||||
|
resolution: "example-with-components@workspace:examples/with-components"
|
||||||
|
dependencies:
|
||||||
|
nuxt3: latest
|
||||||
|
languageName: unknown
|
||||||
|
linkType: soft
|
||||||
|
|
||||||
"example-with-vite@workspace:examples/with-vite":
|
"example-with-vite@workspace:examples/with-vite":
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "example-with-vite@workspace:examples/with-vite"
|
resolution: "example-with-vite@workspace:examples/with-vite"
|
||||||
@ -9796,6 +9819,7 @@ __metadata:
|
|||||||
resolution: "nuxt3@workspace:packages/nuxt3"
|
resolution: "nuxt3@workspace:packages/nuxt3"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@nuxt/app": ^0.4.3
|
"@nuxt/app": ^0.4.3
|
||||||
|
"@nuxt/component-discovery": ^0.1.0
|
||||||
"@nuxt/kit": ^0.6.3
|
"@nuxt/kit": ^0.6.3
|
||||||
"@nuxt/nitro": ^0.9.0
|
"@nuxt/nitro": ^0.9.0
|
||||||
"@nuxt/pages": ^0.2.3
|
"@nuxt/pages": ^0.2.3
|
||||||
|
Loading…
Reference in New Issue
Block a user