mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 05:35:13 +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
|
||||
_templates
|
||||
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": {
|
||||
"@nuxt/app": "^0.4.3",
|
||||
"@nuxt/component-discovery": "^0.1.0",
|
||||
"@nuxt/kit": "^0.6.3",
|
||||
"@nuxt/nitro": "^0.9.0",
|
||||
"@nuxt/pages": "^0.2.3",
|
||||
|
@ -50,6 +50,7 @@ export async function loadNuxt (opts: LoadNuxtOptions): Promise<Nuxt> {
|
||||
options._majorVersion = 3
|
||||
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/component-discovery/module'))
|
||||
|
||||
const nuxt = createNuxt(options)
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
Hello
|
||||
<HelloWorld />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
24
yarn.lock
24
yarn.lock
@ -1648,6 +1648,21 @@ __metadata:
|
||||
languageName: unknown
|
||||
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":
|
||||
version: 1.2.5
|
||||
resolution: "@nuxt/devalue@npm:1.2.5"
|
||||
@ -6000,6 +6015,14 @@ __metadata:
|
||||
languageName: unknown
|
||||
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":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "example-with-vite@workspace:examples/with-vite"
|
||||
@ -9796,6 +9819,7 @@ __metadata:
|
||||
resolution: "nuxt3@workspace:packages/nuxt3"
|
||||
dependencies:
|
||||
"@nuxt/app": ^0.4.3
|
||||
"@nuxt/component-discovery": ^0.1.0
|
||||
"@nuxt/kit": ^0.6.3
|
||||
"@nuxt/nitro": ^0.9.0
|
||||
"@nuxt/pages": ^0.2.3
|
||||
|
Loading…
Reference in New Issue
Block a user