Skip to content
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ export default {
}
```

### Using top level options

```js
export default {
buildModules: [
'@nuxt/components',
],
components: {
/* module options */
}
}
```

## Options

### `pattern`
Expand Down
53 changes: 39 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,67 @@ import { Configuration as WebpackConfig, Entry as WebpackEntry } from 'webpack'
import RuleSet from 'webpack/lib/RuleSet'
import { Module } from '@nuxt/types'

import { scanComponents, ScanOptions } from './scan'
import { scanComponents } from './scan'

const componentsModule: Module<ScanOptions> = function (moduleOptions) {
const scanOptions: ScanOptions = {
cwd: this.options.srcDir!,
pattern: 'components/**/*.{vue,ts,tsx,js,jsx}',
...moduleOptions
export interface Options {
dirs: Array<string | {
path: string
pattern?: string
ignore?: string[]
prefix?: string
watch?: boolean
transpile?: boolean
}>
}

const isPureObjectOrString = (val: any) => (!Array.isArray(val) && typeof val === 'object') || typeof val === 'string'

export default <Module<Options>> function (moduleOptions) {
const options: Options = {
dirs: ['~/components'],
...moduleOptions,
...this.options.components
}

this.nuxt.hook('build:before', async (builder: any) => {
let components = await scanComponents(scanOptions)
const nuxtIgnorePatterns: string[] = builder.ignore.ignore ? builder.ignore.ignore._rules.map((rule: any) => rule.pattern) : /* istanbul ignore next */ []
const componentDirs = options.dirs.filter(isPureObjectOrString).map((dir) => {
const dirOptions = typeof dir === 'object' ? dir : { path: dir }
return {
...dirOptions,
path: this.nuxt.resolver.resolvePath(dirOptions.path),
pattern: dirOptions.pattern || `**/*.{${builder.supportedExtensions.join(',')}}`,
ignore: nuxtIgnorePatterns.concat(dirOptions.ignore || [])
}
})

this.options.build!.transpile!.push(...componentDirs.filter(dir => dir.transpile).map(dir => dir.path))

let components = await scanComponents(componentDirs)

this.extendBuild((config) => {
const { rules }: any = new RuleSet(config.module!.rules)
const vueRule = rules.find((rule: any) => rule.use && rule.use.find((use: any) => use.loader === 'vue-loader'))
vueRule.use.unshift({
loader: require.resolve('./loader'),
options: {
componentsDir: this.options.dev ? path.join(this.options.srcDir!, 'components') : /* istanbul ignore next */ undefined,
dependencies: this.options.dev ? componentDirs.map(dir => dir.path) : /* istanbul ignore next */ [],
getComponents: () => components
}
})
config.module!.rules = rules
})

// Watch components directory for dev mode
// Watch
// istanbul ignore else
if (this.options.dev) {
const watcher = chokidar.watch(path.join(this.options!.srcDir!, 'components'), this.options.watchers!.chokidar)
if (this.options.dev && componentDirs.some(dir => dir.watch !== false)) {
const watcher = chokidar.watch(componentDirs.filter(dir => dir.watch !== false).map(dir => dir.path), this.options.watchers!.chokidar)
watcher.on('all', async (eventName) => {
if (!['add', 'unlink'].includes(eventName)) {
return
}

components = await scanComponents(scanOptions)
components = await scanComponents(componentDirs)
await builder.generateRoutesAndFiles()
})

Expand All @@ -50,11 +76,10 @@ const componentsModule: Module<ScanOptions> = function (moduleOptions) {
}
})

// Add Webpack entry for runtime installComponents function
this.nuxt.hook('webpack:config', (configs: WebpackConfig[]) => {
for (const config of configs.filter(c => ['client', 'modern', 'server'].includes(c.name!))) {
((config.entry as WebpackEntry).app as string[]).unshift(path.resolve(__dirname, 'installComponents.js'))
}
})
}

export default componentsModule
10 changes: 5 additions & 5 deletions src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { extractTags } from './tagExtractor'
import { Component, matcher } from './scan'

interface LoaderOptions {
componentsDir?: string
dependencies: string[]
getComponents(): Component[]
}

function install (this: WebpackLoader.LoaderContext, content: string, components: Component[]) {
const imports = '{' + components.map(c => `${c.name}: ${c.import}`).join(',') + '}'
const imports = '{' + components.map(c => `${c.pascalName}: ${c.import}`).join(',') + '}'

let newContent = '/* nuxt-component-imports */\n'
newContent += `installComponents(component, ${imports})\n`
Expand All @@ -32,10 +32,10 @@ export default async function loader (this: WebpackLoader.LoaderContext, content
if (!this.resourceQuery) {
this.addDependency(this.resourcePath)

const { componentsDir, getComponents } = loaderUtils.getOptions(this) as LoaderOptions
const { dependencies, getComponents } = loaderUtils.getOptions(this) as LoaderOptions

if (componentsDir) {
this.addDependency(componentsDir)
for (const dependency of dependencies) {
this.addDependency(dependency)
}

const tags = await extractTags(this.resourcePath)
Expand Down
69 changes: 47 additions & 22 deletions src/scan.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,66 @@
import path from 'path'
import { basename, extname, join } from 'path'
import glob from 'glob'
import { camelCase, kebabCase, upperFirst } from 'lodash'

export interface Component {
name: string
const LAZY_PREFIX = 'lazy'
const pascalCase = (str: string) => upperFirst(camelCase(str))

export interface ScanDir {
path: string
pattern: string
ignore?: string[]
prefix?: string
}

export interface Component {
pascalName: string
kebabName: string
import: string
kebabTag: string
pascalTag: string
}

export interface ScanOptions {
cwd: string
pattern: string
ignore?: string | string[]
function sortDirsByPathLength ({ path: pathA }: ScanDir, { path: pathB }: ScanDir): number {
return pathB.split('/').filter(Boolean).length - pathA.split('/').filter(Boolean).length
}

export async function scanComponents ({ cwd, pattern, ignore }: ScanOptions): Promise<Component[]> {
const files: string[] = await glob.sync(pattern, { cwd, ignore })
const components: Component[] = files.map((file) => {
const fileName = path.basename(file, path.extname(file))
const [pascalTag, kebabTag] = [upperFirst(camelCase(fileName)), kebabCase(fileName)]

return {
name: pascalTag,
pascalTag,
kebabTag,
import: `require('~/${file}').default`
function prefixComponent (prefix: string = '', { pascalName, kebabName, ...rest }: Component): Component {
return {
pascalName: pascalName.startsWith(prefix) ? pascalName : pascalCase(prefix) + pascalName,
kebabName: kebabName.startsWith(prefix) ? kebabName : kebabCase(prefix) + '-' + kebabName,
...rest
}
}

export async function scanComponents (dirs: ScanDir[]): Promise<Component[]> {
const components: Component[] = []
const processedPaths: string[] = []

for (const { path, pattern, ignore, prefix } of dirs.sort(sortDirsByPathLength)) {
for (const file of await glob.sync(pattern, { cwd: path, ignore })) {
const filePath = join(path, file)

if (processedPaths.includes(filePath)) {
continue
}

const fileName = basename(file, extname(file))
const pascalName = pascalCase(fileName)
const kebabName = kebabCase(fileName)

components.push(
prefixComponent(prefix, { pascalName, kebabName, import: `require('${filePath}').default` }),
prefixComponent(LAZY_PREFIX, prefixComponent(prefix, { pascalName, kebabName, import: `function () { return import('${filePath}') }` }))
)

processedPaths.push(filePath)
}
})
}

return components
}

export function matcher (tags: string[], components: Component[]) {
return tags.reduce((matches, tag) => {
const match = components.find(({ pascalTag, kebabTag }) => [pascalTag, kebabTag].includes(tag))
const match = components.find(({ pascalName, kebabName }) => [pascalName, kebabName].includes(tag))
match && matches.push(match)
return matches
}, [] as Component[])
Expand Down
File renamed without changes.
7 changes: 0 additions & 7 deletions test/fixture/components/ComponentBaz.js

This file was deleted.

5 changes: 5 additions & 0 deletions test/fixture/components/base/Button.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<div>
Base Button
</div>
</template>
5 changes: 5 additions & 0 deletions test/fixture/components/icons/Home.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<div>
Icon Home
</div>
</template>
13 changes: 9 additions & 4 deletions test/fixture/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@ const config: Configuration = {
buildDIr: path.resolve(__dirname, '.nuxt'),
srcDir: __dirname,

buildModules: [
'@nuxt/typescript-build',
componentsModule
]
buildModules: ['@nuxt/typescript-build', componentsModule],

components: {
dirs: [
'~/components',
{ path: '@/components/base', prefix: 'Base' },
{ path: '@/components/icons', prefix: 'Icon', transpile: true /* Only for coverage purpose */ }
]
}
}

export default config
7 changes: 4 additions & 3 deletions test/fixture/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<template>
<div>
<ComponentFoo />
<ComponentBar />
<component-baz />
<Foo />
<LazyBar />
<BaseButton />
<IconHome />
</div>
</template>
3 changes: 2 additions & 1 deletion test/module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ describe('module', () => {
const { html } = await nuxt.server.renderRoute('/')
expect(html).toContain('Foo')
expect(html).toContain('Bar')
expect(html).toContain('Baz')
expect(html).toContain('Base Button')
expect(html).toContain('Icon Home')
})

test('watch: rebuild on add/remove', async () => {
Expand Down
19 changes: 12 additions & 7 deletions test/unit/loader.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import path from 'path'
import { loader as WebpackLoader } from 'webpack'
import loader from '../../src/loader'
import { scanFixtureComponents } from './scanner.test'
import { scanFixtureComponents } from './utils'

let testLoader

Expand All @@ -16,6 +16,7 @@ beforeAll(async () => {
cacheable: (_bool) => {},
callback: (_, newContent) => { finalContent = newContent },
query: {
dependencies: [],
getComponents: () => fixtureComponents
},
...context
Expand All @@ -25,18 +26,22 @@ beforeAll(async () => {
}
})

function expectToContainImports (content: string) {
const fixturePath = path.resolve('test/fixture')
expect(content).toContain(`require('${fixturePath}/components/Foo.vue')`)
expect(content).toContain(`function () { return import('${fixturePath}/components/Bar.ts') }`)
expect(content).toContain(`require('${fixturePath}/components/base/Button.vue')`)
expect(content).toContain(`require('${fixturePath}/components/icons/Home.vue')`)
}

test('default', async () => {
const { content } = await testLoader({ resourcePath: path.resolve('test/fixture/pages/index.vue') }, 'test')
expect(content).toContain("require('~/components/ComponentFoo.vue')")
expect(content).toContain("require('~/components/ComponentBar.ts')")
expect(content).toContain("require('~/components/ComponentBaz.js')")
expectToContainImports(content)
})

test('hot reload', async () => {
const { content } = await testLoader({ resourcePath: path.resolve('test/fixture/pages/index.vue') }, '/* hot reload */')
expect(content).toContain("require('~/components/ComponentFoo.vue')")
expect(content).toContain("require('~/components/ComponentBar.ts')")
expect(content).toContain("require('~/components/ComponentBaz.js')")
expectToContainImports(content)
})

test('resourceQuery is truthy', async () => {
Expand Down
9 changes: 5 additions & 4 deletions test/unit/matcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { matcher } from '../../src/scan'
import { scanFixtureComponents } from './scanner.test'
import { scanFixtureComponents } from './utils'

test('matcher', async () => {
const components = await scanFixtureComponents()
const tags = ['ComponentFoo', 'ComponentBar', 'component-baz']
const tags = ['Foo', 'LazyBar', 'BaseButton', 'IconHome']

const matchedComponents = matcher(tags, components).sort((a, b) => a.name < b.name ? -1 : 1)
expect(matchedComponents).toEqual(components.sort((a, b) => a.name < b.name ? -1 : 1))
const matchedComponents = matcher(tags, components).sort((a, b) => a.pascalName < b.pascalName ? -1 : 1)

expect(matchedComponents).toHaveLength(4)
})
21 changes: 11 additions & 10 deletions test/unit/scanner.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import path from 'path'
import { scanComponents } from '../../src/scan'

export function scanFixtureComponents () {
return scanComponents({
cwd: path.resolve('test/fixture'),
pattern: 'components/**/*.{vue,ts,js}'
})
}
import { scanFixtureComponents } from './utils'

test('scanner', async () => {
const components = await scanFixtureComponents()

expect(components).toHaveLength(3)
expect(components.map(c => c.pascalName)).toEqual([
'BaseButton',
'LazyBaseButton',
'IconHome',
'LazyIconHome',
'Bar',
'LazyBar',
'Foo',
'LazyFoo'
])
})
4 changes: 2 additions & 2 deletions test/unit/tagExtractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { extractTags } from '../../src/tagExtractor'
test('with template', async () => {
const tags = await extractTags(path.resolve('test/fixture/pages/index.vue'))

expect(tags).toHaveLength(4)
expect(tags).toEqual(['ComponentFoo', 'ComponentBar', 'component-baz', 'div'])
expect(tags).toHaveLength(5)
expect(tags).toEqual(['Foo', 'LazyBar', 'BaseButton', 'IconHome', 'div'])
})

test('without template', async () => {
Expand Down
Loading