Skip to content

Commit

Permalink
feat: components discovery (nuxt#243)
Browse files Browse the repository at this point in the history
Co-authored-by: Daniel Roe <daniel@roe.dev>
  • Loading branch information
pi0 and danielroe authored Jun 18, 2021
1 parent 1ed3387 commit a0f81cd
Show file tree
Hide file tree
Showing 17 changed files with 370 additions and 1 deletion.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ dist
node_modules
_templates
schema
**/*.tmpl.*
5 changes: 5 additions & 0 deletions examples/with-components/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<div>
<hello-world />
</div>
</template>
5 changes: 5 additions & 0 deletions examples/with-components/components/HelloWorld.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<div>
This is HelloWorld component!
</div>
</template>
4 changes: 4 additions & 0 deletions examples/with-components/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { defineNuxtConfig } from '@nuxt/kit'

export default defineNuxtConfig({
})
12 changes: 12 additions & 0 deletions examples/with-components/package.json
Original file line number Diff line number Diff line change
@@ -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 changes: 9 additions & 0 deletions packages/components/build.config.ts
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions packages/components/module.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('./dist/module')
27 changes: 27 additions & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
@@ -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 changes: 88 additions & 0 deletions packages/components/src/module.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 7 additions & 0 deletions packages/components/src/runtime/components.tmpl.d.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 22 additions & 0 deletions packages/components/src/runtime/components.tmpl.mjs
Original file line number Diff line number Diff line change
@@ -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 changes: 100 additions & 0 deletions packages/components/src/scan.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 62 additions & 0 deletions packages/components/src/types.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
}
1 change: 1 addition & 0 deletions packages/nuxt3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/nuxt3/src/nuxt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion playground/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div>
Hello
<HelloWorld />
</div>
</template>

Expand Down
Loading

0 comments on commit a0f81cd

Please sign in to comment.