Skip to content
This repository has been archived by the owner on Apr 6, 2023. It is now read-only.

feat(nuxt)!: add support for components/global #6070

Merged
merged 8 commits into from
Jul 27, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/content/2.guide/3.directory-structure/4.components.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ Alternatively, though not recommended, you can register all your components glob
})
```

::StabilityEdge{title="Automatic global components"}
In the current version, components in `~/components/global` are not yet auto-registered.
::

You can also selectively register some components globally by placing them in a `~/components/global` directory.

::alert{type=info}
The `global` option can also be set per component directory.
::
Expand Down
69 changes: 46 additions & 23 deletions packages/nuxt/src/components/module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { statSync } from 'node:fs'
import { resolve, basename } from 'pathe'
import { resolve } from 'pathe'
import { defineNuxtModule, resolveAlias, addTemplate, addPluginTemplate } from '@nuxt/kit'
import type { Component, ComponentsDir, ComponentsOptions } from '@nuxt/schema'
import { componentsPluginTemplate, componentsTemplate, componentsTypeTemplate } from './templates'
Expand All @@ -13,6 +13,10 @@ function compareDirByPathLength ({ path: pathA }, { path: pathB }) {
return pathB.split(/[\\/]/).filter(Boolean).length - pathA.split(/[\\/]/).filter(Boolean).length
}

const DEFAULT_COMPONENTS_DIRS_RE = /\/components$|\/components\/global$/

type getComponentsT = (mode?: 'client' | 'server' | 'all') => Component[]

export default defineNuxtModule<ComponentsOptions>({
meta: {
name: 'components',
Expand All @@ -23,14 +27,25 @@ export default defineNuxtModule<ComponentsOptions>({
},
setup (componentOptions, nuxt) {
let componentDirs = []
const components: Component[] = []
const context = {
components: [] as Component[]
}

const getComponents: getComponentsT = (mode) => {
return (mode && mode !== 'all')
? context.components.filter(c => c.mode === mode || c.mode === 'all')
: context.components
}

const normalizeDirs = (dir: any, cwd: string) => {
if (Array.isArray(dir)) {
return dir.map(dir => normalizeDirs(dir, cwd)).flat().sort(compareDirByPathLength)
}
if (dir === true || dir === undefined) {
return [{ path: resolve(cwd, 'components') }]
return [
{ path: resolve(cwd, 'components/global'), global: true },
{ path: resolve(cwd, 'components') }
]
}
if (typeof dir === 'string') {
return {
Expand Down Expand Up @@ -65,7 +80,7 @@ export default defineNuxtModule<ComponentsOptions>({
dirOptions.level = Number(dirOptions.level || 0)

const present = isDirectory(dirPath)
if (!present && basename(dirOptions.path) !== 'components') {
if (!present && !DEFAULT_COMPONENTS_DIRS_RE.test(dirOptions.path)) {
// eslint-disable-next-line no-console
console.warn('Components directory not found: `' + dirPath + '`')
}
Expand All @@ -90,28 +105,31 @@ export default defineNuxtModule<ComponentsOptions>({
nuxt.options.build!.transpile!.push(...componentDirs.filter(dir => dir.transpile).map(dir => dir.path))
})

const options = { components, buildDir: nuxt.options.buildDir }

addTemplate({
...componentsTypeTemplate,
options
})
// components.d.ts
addTemplate({ ...componentsTypeTemplate, options: { getComponents } })
// components.plugin.mjs
addPluginTemplate({ ...componentsPluginTemplate, options: { getComponents } })
// components.server.mjs
addTemplate({ ...componentsTemplate, filename: 'components.server.mjs', options: { getComponents, mode: 'server' } })
// components.client.mjs
addTemplate({ ...componentsTemplate, filename: 'components.client.mjs', options: { getComponents, mode: 'client' } })

addPluginTemplate({
...componentsPluginTemplate,
options
nuxt.hook('vite:extendConfig', (config, { isClient }) => {
const mode = isClient ? 'client' : 'server'
config.resolve.alias['#components'] = resolve(nuxt.options.buildDir, `components.${mode}.mjs`)
})

nuxt.options.alias['#components'] = resolve(nuxt.options.buildDir, componentsTemplate.filename)
addTemplate({
...componentsTemplate,
options
nuxt.hook('webpack:config', (configs) => {
for (const config of configs) {
const mode = config.name === 'server' ? 'server' : 'client'
config.resolve.alias['#components'] = resolve(nuxt.options.buildDir, `components.${mode}.mjs`)
}
})

// Scan components and add to plugin
nuxt.hook('app:templates', async () => {
options.components = await scanComponents(componentDirs, nuxt.options.srcDir!)
await nuxt.callHook('components:extend', options.components)
const newComponents = await scanComponents(componentDirs, nuxt.options.srcDir!)
await nuxt.callHook('components:extend', newComponents)
context.components = newComponents
})

nuxt.hook('prepare:types', ({ references }) => {
Expand All @@ -129,7 +147,6 @@ export default defineNuxtModule<ComponentsOptions>({
}
})

const getComponents = () => options.components
nuxt.hook('vite:extendConfig', (config, { isClient }) => {
config.plugins = config.plugins || []
config.plugins.push(loaderPlugin.vite({
Expand All @@ -138,7 +155,10 @@ export default defineNuxtModule<ComponentsOptions>({
mode: isClient ? 'client' : 'server'
}))
if (nuxt.options.experimental.treeshakeClientOnly) {
config.plugins.push(TreeShakeTemplatePlugin.vite({ sourcemap: nuxt.options.sourcemap, getComponents }))
config.plugins.push(TreeShakeTemplatePlugin.vite({
sourcemap: nuxt.options.sourcemap,
getComponents
}))
}
})
nuxt.hook('webpack:config', (configs) => {
Expand All @@ -150,7 +170,10 @@ export default defineNuxtModule<ComponentsOptions>({
mode: config.name === 'client' ? 'client' : 'server'
}))
if (nuxt.options.experimental.treeshakeClientOnly) {
config.plugins.push(TreeShakeTemplatePlugin.webpack({ sourcemap: nuxt.options.sourcemap, getComponents }))
config.plugins.push(TreeShakeTemplatePlugin.webpack({
sourcemap: nuxt.options.sourcemap,
getComponents
}))
}
})
})
Expand Down
17 changes: 10 additions & 7 deletions packages/nuxt/src/components/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr
*/
let fileName = basename(filePath, extname(filePath))

const mode = fileName.match(/(?<=\.)(client|server)$/)?.[0] as 'client' | 'server' || 'all'
fileName = fileName.replace(/\.(client|server)$/, '')
const global = /\.(global)$/.test(fileName) || dir.global
const mode = fileName.match(/(?<=\.)(client|server)(\.global)?$/)?.[1] as 'client' | 'server' || 'all'
fileName = fileName.replace(/(\.(client|server))?(\.global)?$/, '')

if (fileName.toLowerCase() === 'index') {
fileName = dir.pathPrefix === false ? basename(dirname(filePath)) : '' /* inherits from path */
Expand Down Expand Up @@ -103,16 +104,18 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr
const chunkName = 'components/' + kebabName + suffix

let component: Component = {
// inheritable from directory configuration
mode,
global,
prefetch: Boolean(dir.prefetch),
preload: Boolean(dir.preload),
// specific to the file
filePath,
pascalName,
kebabName,
chunkName,
shortPath,
export: 'default',
global: dir.global,
prefetch: Boolean(dir.prefetch),
preload: Boolean(dir.preload),
mode
export: 'default'
}

if (typeof dir.extendComponent === 'function') {
Expand Down
51 changes: 32 additions & 19 deletions packages/nuxt/src/components/templates.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { isAbsolute, relative } from 'pathe'
import type { Component } from '@nuxt/schema'
import type { Component, Nuxt } from '@nuxt/schema'
import { genDynamicImport, genExport, genObjectFromRawEntries } from 'knitwork'
import { upperFirst } from 'scule'

export type ComponentsTemplateOptions = {
buildDir?: string
components: Component[]
export interface ComponentsTemplateContext {
nuxt: Nuxt
options: {
getComponents: (mode?: 'client' | 'server' | 'all') => Component[]
mode?: 'client' | 'server'
}
}

export type ImportMagicCommentsOptions = {
Expand All @@ -25,11 +27,13 @@ const createImportMagicComments = (options: ImportMagicCommentsOptions) => {

export const componentsPluginTemplate = {
filename: 'components.plugin.mjs',
getContents ({ options }: { options: ComponentsTemplateOptions }) {
getContents ({ options }: ComponentsTemplateContext) {
const globalComponents = options.getComponents().filter(c => c.global === true)

return `import { defineAsyncComponent } from 'vue'
import { defineNuxtPlugin } from '#app'

const components = ${genObjectFromRawEntries(options.components.filter(c => c.global === true).map((c) => {
const components = ${genObjectFromRawEntries(globalComponents.map((c) => {
const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']`
const comment = createImportMagicComments(c)

Expand All @@ -47,36 +51,45 @@ export default defineNuxtPlugin(nuxtApp => {
}

export const componentsTemplate = {
filename: 'components.mjs',
getContents ({ options }: { options: ComponentsTemplateOptions }) {
// components.[server|client].mjs'
getContents ({ options }: ComponentsTemplateContext) {
return [
'import { defineAsyncComponent } from \'vue\'',
...options.components.flatMap((c) => {
...options.getComponents(options.mode).flatMap((c) => {
const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']`
const comment = createImportMagicComments(c)
const nameWithSuffix = `${c.pascalName}${c.mode !== 'all' ? upperFirst(c.mode) : ''}`

return [
genExport(c.filePath, [{ name: c.export, as: nameWithSuffix }]),
`export const Lazy${nameWithSuffix} = defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))`
genExport(c.filePath, [{ name: c.export, as: c.pascalName }]),
`export const Lazy${c.pascalName} = defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))`
]
}),
`export const componentNames = ${JSON.stringify(options.components.map(c => c.pascalName))}`
`export const componentNames = ${JSON.stringify(options.getComponents().map(c => c.pascalName))}`
].join('\n')
}
}

export const componentsTypeTemplate = {
filename: 'components.d.ts',
getContents: ({ options }: { options: ComponentsTemplateOptions }) => `// Generated by components discovery
getContents: ({ options, nuxt }: ComponentsTemplateContext) => {
const buildDir = nuxt.options.buildDir
const componentTypes = options.getComponents().map(c => [
c.pascalName,
`typeof ${genDynamicImport(isAbsolute(c.filePath) ? relative(buildDir, c.filePath) : c.filePath, { wrapper: false })}['${c.export}']`
])

return `// Generated by components discovery
declare module 'vue' {
export interface GlobalComponents {
${options.components.map(c => ` '${c.pascalName}': typeof ${genDynamicImport(isAbsolute(c.filePath) ? relative(options.buildDir, c.filePath) : c.filePath, { wrapper: false })}['${c.export}']`).join(',\n')}
${options.components.map(c => ` 'Lazy${c.pascalName}': typeof ${genDynamicImport(isAbsolute(c.filePath) ? relative(options.buildDir, c.filePath) : c.filePath, { wrapper: false })}['${c.export}']`).join(',\n')}
${componentTypes.map(([pascalName, type]) => ` '${pascalName}': ${type}`).join(',\n')}
${componentTypes.map(([pascalName, type]) => ` 'Lazy${pascalName}': ${type}`).join(',\n')}
}
}
${options.components.map(c => `export const ${c.pascalName}${c.mode !== 'all' ? upperFirst(c.mode) : ''}: typeof ${genDynamicImport(isAbsolute(c.filePath) ? relative(options.buildDir, c.filePath) : c.filePath, { wrapper: false })}['${c.export}']`).join('\n')}
${options.components.map(c => `export const Lazy${c.pascalName}${c.mode !== 'all' ? upperFirst(c.mode) : ''}: typeof ${genDynamicImport(isAbsolute(c.filePath) ? relative(options.buildDir, c.filePath) : c.filePath, { wrapper: false })}['${c.export}']`).join('\n')}

${componentTypes.map(([pascalName, type]) => `export const ${pascalName}: ${type}`).join(',\n')}
${componentTypes.map(([pascalName, type]) => `export const Lazy${pascalName}: ${type}`).join(',\n')}

export const componentNames: string[]
`
}
}
2 changes: 1 addition & 1 deletion packages/schema/src/config/_adhoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default {
return { dirs: val }
}
if (val === undefined || val === true) {
return { dirs: ['~/components'] }
return { dirs: [{ path: '~/components/global', global: true }, '~/components'] }
}
return val
}
Expand Down
2 changes: 2 additions & 0 deletions test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ describe('pages', () => {
expect(html).toContain('Composable | template: auto imported from ~/components/template.ts')
// should import components
expect(html).toContain('This is a custom component with a named export.')
expect(html).toContain('global component registered automatically')
expect(html).toContain('global component via suffix')

await expectNoClientErrors('/')
})
Expand Down
3 changes: 3 additions & 0 deletions test/fixtures/basic/components/WithSuffix.global.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
global component via suffix
</template>
3 changes: 3 additions & 0 deletions test/fixtures/basic/components/global/TestGlobal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
global component registered automatically
</template>
2 changes: 2 additions & 0 deletions test/fixtures/basic/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
</NuxtLink>
<SugarCounter :count="12" />
<CustomComponent />
<component :is="`test${'-'.toString()}global`" />
<component :is="`with${'-'.toString()}suffix`" />
</div>
</template>

Expand Down