diff --git a/lib/build.js b/lib/build.js deleted file mode 100644 index 36b2cf5286..0000000000 --- a/lib/build.js +++ /dev/null @@ -1,569 +0,0 @@ -'use strict' - -import _ from 'lodash' -import chokidar from 'chokidar' -import fs from 'fs-extra' -import hash from 'hash-sum' -import pify from 'pify' -import webpack from 'webpack' -import PostCompilePlugin from 'post-compile-webpack-plugin' -import serialize from 'serialize-javascript' -import { createBundleRenderer } from 'vue-server-renderer' -import { join, resolve, basename, dirname } from 'path' -import { isUrl, r, wp } from './utils' -import clientWebpackConfig from './webpack/client.config.js' -import serverWebpackConfig from './webpack/server.config.js' -const debug = require('debug')('nuxt:build') -const remove = pify(fs.remove) -const readFile = pify(fs.readFile) -const utimes = pify(fs.utimes) -const writeFile = pify(fs.writeFile) -const mkdirp = pify(fs.mkdirp) -const glob = pify(require('glob')) - -let webpackStats = 'none' -debug.color = 2 // force green color - -const defaults = { - analyze: false, - extractCSS: false, - publicPath: '/_nuxt/', - filenames: { - css: 'common.[chunkhash].css', - manifest: 'manifest.[hash].js', - vendor: 'vendor.bundle.[chunkhash].js', - app: 'nuxt.bundle.[chunkhash].js' - }, - vendor: [], - loaders: [], - plugins: [], - babel: {}, - postcss: [], - templates: [], - watch: [] -} -const defaultsLoaders = [ - { - test: /\.(png|jpe?g|gif|svg)$/, - loader: 'url-loader', - query: { - limit: 1000, // 1KO - name: 'img/[name].[hash:7].[ext]' - } - }, - { - test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, - loader: 'url-loader', - query: { - limit: 1000, // 1 KO - name: 'fonts/[name].[hash:7].[ext]' - } - } -] -const defaultsPostcss = [ - require('autoprefixer')({ - browsers: ['last 3 versions'] - }) -] - -export function options () { - // Defaults build options - let extraDefaults = {} - if (this.options.build && !Array.isArray(this.options.build.loaders)) extraDefaults.loaders = defaultsLoaders - if (this.options.build && !Array.isArray(this.options.build.postcss)) extraDefaults.postcss = defaultsPostcss - this.options.build = _.defaultsDeep(this.options.build, defaults, extraDefaults) - /* istanbul ignore if */ - if (this.dev && isUrl(this.options.build.publicPath)) { - this.options.build.publicPath = defaults.publicPath - } -} - -export function production () { - // Production, create server-renderer - webpackStats = { - chunks: false, - children: false, - modules: false, - colors: true - } - const serverConfig = getWebpackServerConfig.call(this) - const bundlePath = join(serverConfig.output.path, 'server-bundle.json') - const manifestPath = join(serverConfig.output.path, 'client-manifest.json') - if (fs.existsSync(bundlePath) && fs.existsSync(manifestPath)) { - const bundle = fs.readFileSync(bundlePath, 'utf8') - const manifest = fs.readFileSync(manifestPath, 'utf8') - createRenderer.call(this, JSON.parse(bundle), JSON.parse(manifest)) - addAppTemplate.call(this) - } -} - -export async function build () { - // Avoid calling this method multiple times - if (this._buildDone) { - return this - } - // If building - if (this._building) { - return new Promise((resolve) => { - setTimeout(() => { - resolve(this.build()) - }, 300) - }) - } - this._building = true - // Wait for Nuxt.js to be ready - await this.ready() - // Check if pages dir exists and warn if not - this._nuxtPages = typeof this.createRoutes !== 'function' - if (this._nuxtPages) { - if (!fs.existsSync(join(this.srcDir, 'pages'))) { - if (fs.existsSync(join(this.srcDir, '..', 'pages'))) { - console.error('> No `pages` directory found. Did you mean to run `nuxt` in the parent (`../`) directory?') // eslint-disable-line no-console - } else { - console.error('> Couldn\'t find a `pages` directory. Please create one under the project root') // eslint-disable-line no-console - } - process.exit(1) - } - } - debug(`App root: ${this.srcDir}`) - debug(`Generating ${this.buildDir} files...`) - // Create .nuxt/, .nuxt/components and .nuxt/dist folders - await remove(r(this.buildDir)) - await mkdirp(r(this.buildDir, 'components')) - if (!this.dev) { - await mkdirp(r(this.buildDir, 'dist')) - } - // Generate routes and interpret the template files - await generateRoutesAndFiles.call(this) - // Generate .nuxt/dist/ files - await buildFiles.call(this) - // Flag to set that building is done - this._buildDone = true - return this -} - -async function buildFiles () { - if (this.dev) { - debug('Adding webpack middleware...') - createWebpackMiddleware.call(this) - webpackWatchAndUpdate.call(this) - watchFiles.call(this) - } else { - debug('Building files...') - await webpackRunClient.call(this) - await webpackRunServer.call(this) - addAppTemplate.call(this) - } -} - -function addAppTemplate () { - let templatePath = resolve(this.buildDir, 'dist', 'index.html') - if (fs.existsSync(templatePath)) { - this.appTemplate = _.template(fs.readFileSync(templatePath, 'utf8'), { - interpolate: /{{([\s\S]+?)}}/g - }) - } -} - -async function generateRoutesAndFiles () { - debug('Generating files...') - // -- Templates -- - let templatesFiles = [ - 'App.vue', - 'client.js', - 'index.js', - 'middleware.js', - 'router.js', - 'server.js', - 'utils.js', - 'components/nuxt-error.vue', - 'components/nuxt-loading.vue', - 'components/nuxt-child.js', - 'components/nuxt-link.js', - 'components/nuxt.vue' - ] - const templateVars = { - options: this.options, - uniqBy: _.uniqBy, - isDev: this.dev, - router: { - mode: this.options.router.mode, - base: this.options.router.base, - middleware: this.options.router.middleware, - linkActiveClass: this.options.router.linkActiveClass, - linkExactActiveClass: this.options.router.linkExactActiveClass, - scrollBehavior: this.options.router.scrollBehavior - }, - env: this.options.env, - head: this.options.head, - middleware: fs.existsSync(join(this.srcDir, 'middleware')), - store: this.options.store || fs.existsSync(join(this.srcDir, 'store')), - css: this.options.css, - plugins: this.options.plugins.map((p, i) => { - if (typeof p === 'string') p = { src: p } - p.src = r(this.srcDir, p.src) - return { src: p.src, ssr: (p.ssr !== false), name: `plugin${i}` } - }), - appPath: './App.vue', - layouts: Object.assign({}, this.options.layouts), - loading: (typeof this.options.loading === 'string' ? r(this.srcDir, this.options.loading) : this.options.loading), - transition: this.options.transition, - components: { - ErrorPage: this.options.ErrorPage ? r(this.options.ErrorPage) : null - } - } - - // -- Layouts -- - if (fs.existsSync(resolve(this.srcDir, 'layouts'))) { - const layoutsFiles = await glob('layouts/*.vue', {cwd: this.srcDir}) - layoutsFiles.forEach((file) => { - let name = file.split('/').slice(-1)[0].replace('.vue', '') - if (name === 'error') return - templateVars.layouts[name] = r(this.srcDir, file) - }) - if (layoutsFiles.includes('layouts/error.vue')) { - templateVars.components.ErrorPage = r(this.srcDir, 'layouts/error.vue') - } - } - // If no default layout, create its folder and add the default folder - if (!templateVars.layouts.default) { - await mkdirp(r(this.buildDir, 'layouts')) - templatesFiles.push('layouts/default.vue') - templateVars.layouts.default = r(__dirname, 'app', 'layouts', 'default.vue') - } - - // -- Routes -- - debug('Generating routes...') - // If user defined a custom method to create routes - if (this._nuxtPages) { - // Use nuxt.js createRoutes bases on pages/ - const files = await glob('pages/**/*.vue', {cwd: this.srcDir}) - templateVars.router.routes = createRoutes(files, this.srcDir) - } else { - templateVars.router.routes = this.createRoutes(this.srcDir) - } - // router.extendRoutes method - if (typeof this.options.router.extendRoutes === 'function') { - // let the user extend the routes - this.options.router.extendRoutes.call(this, templateVars.router.routes || [], r) - } - // Routes for generate command - this.routes = flatRoutes(templateVars.router.routes || []) - - // -- Store -- - // Add store if needed - if (this.options.store) { - templatesFiles.push('store.js') - } - - // Resolve template files - const customTemplateFiles = this.options.build.templates.map(t => t.dst || basename(t.src || t)) - templatesFiles = templatesFiles.map(file => { - // Skip if custom file was already provided in build.templates[] - if (customTemplateFiles.indexOf(file) !== -1) { - return - } - // Allow override templates using a file with same name in ${srcDir}/app - const customPath = r(this.srcDir, 'app', file) - const customFileExists = fs.existsSync(customPath) - return { - src: customFileExists ? customPath : r(__dirname, 'app', file), - dst: file, - custom: customFileExists - } - }).filter(i => !!i) - - // -- Custom templates -- - // Add custom template files - templatesFiles = templatesFiles.concat(this.options.build.templates.map(t => { - return Object.assign({ - src: r(this.dir, t.src || t), - dst: t.dst || basename(t.src || t), - custom: true - }, t) - })) - - // Interpret and move template files to .nuxt/ - return Promise.all(templatesFiles.map(async ({ src, dst, options, custom }) => { - // Add template to watchers - this.options.build.watch.push(src) - // Render template to dst - const fileContent = await readFile(src, 'utf8') - const template = _.template(fileContent, { - imports: { - serialize, - hash, - r, - wp - } - }) - const content = template(Object.assign({}, templateVars, { - options: options || {}, - custom, - src, - dst - })) - const path = r(this.buildDir, dst) - // Ensure parent dir exits - await mkdirp(dirname(path)) - // Write file - await writeFile(path, content, 'utf8') - // Fix webpack loop (https://github.com/webpack/watchpack/issues/25#issuecomment-287789288) - const dateFS = Date.now() / 1000 - 30 - return utimes(path, dateFS, dateFS) - })) -} - -function createRoutes (files, srcDir) { - let routes = [] - files.forEach((file) => { - let keys = file.replace(/^pages/, '').replace(/\.vue$/, '').replace(/\/{2,}/g, '/').split('/').slice(1) - let route = { name: '', path: '', component: r(srcDir, file) } - let parent = routes - keys.forEach((key, i) => { - route.name = route.name ? route.name + '-' + key.replace('_', '') : key.replace('_', '') - route.name += (key === '_') ? 'all' : '' - let child = _.find(parent, { name: route.name }) - if (child) { - if (!child.children) { - child.children = [] - } - parent = child.children - route.path = '' - } else { - if (key === 'index' && (i + 1) === keys.length) { - route.path += (i > 0 ? '' : '/') - } else { - route.path += '/' + (key === '_' ? '*' : key.replace('_', ':')) - if (key !== '_' && key.indexOf('_') !== -1) { - route.path += '?' - } - } - } - }) - // Order Routes path - parent.push(route) - parent.sort((a, b) => { - if (!a.path.length || a.path === '/') { return -1 } - if (!b.path.length || b.path === '/') { return 1 } - var res = 0 - var _a = a.path.split('/') - var _b = b.path.split('/') - for (var i = 0; i < _a.length; i++) { - if (res !== 0) { break } - var y = (_a[i].indexOf('*') > -1) ? 2 : (_a[i].indexOf(':') > -1 ? 1 : 0) - var z = (_b[i].indexOf('*') > -1) ? 2 : (_b[i].indexOf(':') > -1 ? 1 : 0) - res = y - z - if (i === _b.length - 1 && res === 0) { - res = 1 - } - } - return res === 0 ? -1 : res - }) - }) - return cleanChildrenRoutes(routes) -} - -function cleanChildrenRoutes (routes, isChild = false) { - let start = -1 - let routesIndex = [] - routes.forEach((route) => { - if (/-index$/.test(route.name) || route.name === 'index') { - // Save indexOf 'index' key in name - let res = route.name.split('-') - let s = res.indexOf('index') - start = (start === -1 || s < start) ? s : start - routesIndex.push(res) - } - }) - routes.forEach((route) => { - route.path = (isChild) ? route.path.replace('/', '') : route.path - if (route.path.indexOf('?') > -1) { - let names = route.name.split('-') - let paths = route.path.split('/') - if (!isChild) { paths.shift() } // clean first / for parents - routesIndex.forEach((r) => { - let i = r.indexOf('index') - start // children names - if (i < paths.length) { - for (var a = 0; a <= i; a++) { - if (a === i) { paths[a] = paths[a].replace('?', '') } - if (a < i && names[a] !== r[a]) { break } - } - } - }) - route.path = (isChild ? '' : '/') + paths.join('/') - } - route.name = route.name.replace(/-index$/, '') - if (route.children) { - if (route.children.find((child) => child.path === '')) { - delete route.name - } - route.children = cleanChildrenRoutes(route.children, true) - } - }) - return routes -} - -function flatRoutes (router, path = '', routes = []) { - router.forEach((r) => { - if (!r.path.includes(':') && !r.path.includes('*')) { - if (r.children) { - flatRoutes(r.children, path + r.path + '/', routes) - } else { - routes.push((r.path === '' && path[path.length - 1] === '/' ? path.slice(0, -1) : path) + r.path) - } - } - }) - return routes -} - -function getWebpackClientConfig () { - return clientWebpackConfig.call(this) -} - -function getWebpackServerConfig () { - return serverWebpackConfig.call(this) -} - -function createWebpackMiddleware () { - const clientConfig = getWebpackClientConfig.call(this) - const host = process.env.HOST || process.env.npm_package_config_nuxt_host || 'localhost' - const port = process.env.PORT || process.env.npm_package_config_nuxt_port || '3000' - - // setup on the fly compilation + hot-reload - clientConfig.entry.app = _.flatten(['webpack-hot-middleware/client?reload=true', clientConfig.entry.app]) - clientConfig.plugins.push( - new webpack.HotModuleReplacementPlugin(), - new webpack.NoEmitOnErrorsPlugin(), - new PostCompilePlugin(stats => { - if (!stats.hasErrors() && !stats.hasWarnings()) { - // We don't use os.host() here because browsers have special behaviour with localhost - // For example chrome allows Geolocation api only to https or localhost origins - let _host = host === '0.0.0.0' ? 'localhost' : host - console.log(`> Open http://${_host}:${port}\n`) // eslint-disable-line no-console - } - }) - ) - const clientCompiler = webpack(clientConfig) - this.clientCompiler = clientCompiler - // Add the middleware to the instance context - this.webpackDevMiddleware = pify(require('webpack-dev-middleware')(clientCompiler, { - publicPath: clientConfig.output.publicPath, - stats: webpackStats, - quiet: true, - noInfo: true, - watchOptions: this.options.watchers.webpack - })) - this.webpackHotMiddleware = pify(require('webpack-hot-middleware')(clientCompiler, { - log: () => {} - })) - clientCompiler.plugin('done', () => { - const fs = this.webpackDevMiddleware.fileSystem - const filePath = join(clientConfig.output.path, 'index.html') - if (fs.existsSync(filePath)) { - const template = fs.readFileSync(filePath, 'utf-8') - this.appTemplate = _.template(template, { - interpolate: /{{([\s\S]+?)}}/g - }) - } - this.watchHandler() - }) -} - -function webpackWatchAndUpdate () { - const MFS = require('memory-fs') // <- dependencies of webpack - const serverFS = new MFS() - const clientFS = this.clientCompiler.outputFileSystem - const serverConfig = getWebpackServerConfig.call(this) - const serverCompiler = webpack(serverConfig) - const bundlePath = join(serverConfig.output.path, 'server-bundle.json') - const manifestPath = join(serverConfig.output.path, 'client-manifest.json') - serverCompiler.outputFileSystem = serverFS - const watchHandler = (err) => { - if (err) throw err - const bundleExists = serverFS.existsSync(bundlePath) - const manifestExists = clientFS.existsSync(manifestPath) - if (bundleExists && manifestExists) { - const bundle = serverFS.readFileSync(bundlePath, 'utf8') - const manifest = clientFS.readFileSync(manifestPath, 'utf8') - createRenderer.call(this, JSON.parse(bundle), JSON.parse(manifest)) - } - } - this.watchHandler = watchHandler - this.webpackServerWatcher = serverCompiler.watch(this.options.watchers.webpack, watchHandler) -} - -function webpackRunClient () { - return new Promise((resolve, reject) => { - const clientConfig = getWebpackClientConfig.call(this) - const clientCompiler = webpack(clientConfig) - clientCompiler.run((err, stats) => { - if (err) return reject(err) - console.log('[nuxt:build:client]\n', stats.toString(webpackStats)) // eslint-disable-line no-console - if (stats.hasErrors()) return reject(new Error('Webpack build exited with errors')) - resolve() - }) - }) -} - -function webpackRunServer () { - return new Promise((resolve, reject) => { - const serverConfig = getWebpackServerConfig.call(this) - const serverCompiler = webpack(serverConfig) - serverCompiler.run((err, stats) => { - if (err) return reject(err) - console.log('[nuxt:build:server]\n', stats.toString(webpackStats)) // eslint-disable-line no-console - if (stats.hasErrors()) return reject(new Error('Webpack build exited with errors')) - const bundlePath = join(serverConfig.output.path, 'server-bundle.json') - const manifestPath = join(serverConfig.output.path, 'client-manifest.json') - readFile(bundlePath, 'utf8') - .then(bundle => { - readFile(manifestPath, 'utf8') - .then(manifest => { - createRenderer.call(this, JSON.parse(bundle), JSON.parse(manifest)) - resolve() - }) - }) - }) - }) -} - -function createRenderer (bundle, manifest) { - // Create bundle renderer to give a fresh context for every request - this.renderer = createBundleRenderer(bundle, Object.assign({ - clientManifest: manifest, - runInNewContext: false, - basedir: this.dir - }, this.options.build.ssr)) - this.renderToString = pify(this.renderer.renderToString) - this.renderToStream = this.renderer.renderToStream -} - -function watchFiles () { - const patterns = [ - r(this.srcDir, 'layouts'), - r(this.srcDir, 'store'), - r(this.srcDir, 'middleware'), - r(this.srcDir, 'layouts/*.vue'), - r(this.srcDir, 'layouts/**/*.vue') - ] - if (this._nuxtPages) { - patterns.push(r(this.srcDir, 'pages')) - patterns.push(r(this.srcDir, 'pages/*.vue')) - patterns.push(r(this.srcDir, 'pages/**/*.vue')) - } - const options = Object.assign({}, this.options.watchers.chokidar, { - ignoreInitial: true - }) - /* istanbul ignore next */ - const refreshFiles = _.debounce(async () => { - await generateRoutesAndFiles.call(this) - }, 200) - // Watch for internals - this.filesWatcher = chokidar.watch(patterns, options) - .on('add', refreshFiles) - .on('unlink', refreshFiles) - // Watch for custom provided files - this.customFilesWatcher = chokidar.watch(_.uniq(this.options.build.watch), options) - .on('change', refreshFiles) -} diff --git a/lib/builder.js b/lib/builder.js new file mode 100644 index 0000000000..a920ccf32c --- /dev/null +++ b/lib/builder.js @@ -0,0 +1,601 @@ +import _ from 'lodash' +import chokidar from 'chokidar' +import fs from 'fs-extra' +import hash from 'hash-sum' +import pify from 'pify' +import webpack from 'webpack' +import PostCompilePlugin from 'post-compile-webpack-plugin' +import serialize from 'serialize-javascript' +import { createBundleRenderer } from 'vue-server-renderer' +import { join, resolve, basename, dirname } from 'path' +import { isUrl, r, wp } from './utils' +import clientWebpackConfig from './webpack/client.config.js' +import serverWebpackConfig from './webpack/server.config.js' +import defaults from './defaults' +import Tapable from 'tapable' + +const debug = require('debug')('nuxt:build') +debug.color = 2 // Force green color + +const remove = pify(fs.remove) +const readFile = pify(fs.readFile) +const utimes = pify(fs.utimes) +const writeFile = pify(fs.writeFile) +const mkdirp = pify(fs.mkdirp) +const glob = pify(require('glob')) + +export default class Builder extends Tapable { + constructor (nuxt) { + super() + this.nuxt = nuxt + this.options = nuxt.options + + // Add extra loaders only if they are not already provided + let extraDefaults = {} + if (this.options.build && !Array.isArray(this.options.build.loaders)) { + extraDefaults.loaders = defaultsLoaders + } + if (this.options.build && !Array.isArray(this.options.build.postcss)) { + extraDefaults.postcss = defaultsPostcss + } + this.options.build = _.defaultsDeep(this.options.build, extraDefaults) + /* istanbul ignore if */ + if (this.options.dev && isUrl(this.options.build.publicPath)) { + this.options.build.publicPath = defaults.publicPath.publicPath + } + + // Stats + this.webpackStats = { + chunks: false, + children: false, + modules: false, + colors: true + } + + this._buildStatus = STATUS.INITIAL + } + + ready () { + if (this.options.dev) { + // Don't await for builder in dev (faster startup) + this.build().catch(err => { + console.error(err) + process.exit(1) + }) + return Promise.resolve(this) + } else { + return this.production() + } + } + + async build () { + // Avoid calling this method multiple times + if (this._buildStatus === STATUS.BUILD_DONE) { + return this + } + // If building + if (this._buildStatus === STATUS.BUILDING) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(this.build()) + }, 300) + }) + } + this._buildStatus = STATUS.BUILDING + // Check if pages dir exists and warn if not + this._nuxtPages = typeof this.options.build.createRoutes !== 'function' + + if (this._nuxtPages) { + if (!fs.existsSync(join(this.options.srcDir, 'pages'))) { + if (fs.existsSync(join(this.options.srcDir, '..', 'pages'))) { + console.error('> No `pages` directory found. Did you mean to run `nuxt` in the parent (`../`) directory?') // eslint-disable-line no-console + } else { + console.error('> Couldn\'t find a `pages` directory. Please create one under the project root') // eslint-disable-line no-console + } + process.exit(1) + } + } + debug(`App root: ${this.options.srcDir}`) + debug(`Generating ${this.options.buildDir} files...`) + + // Create .nuxt/, .nuxt/components and .nuxt/dist folders + await remove(r(this.options.buildDir)) + await mkdirp(r(this.options.buildDir, 'components')) + if (!this.options.dev) { + await mkdirp(r(this.options.buildDir, 'dist')) + } + // Generate routes and interpret the template files + await this.generateRoutesAndFiles() + // Generate .nuxt/dist/ files + await this.buildFiles() + // Flag to set that building is done + this._buildStatus = STATUS.BUILD_DONE + return this + } + + production () { + // Production, create server-renderer + const serverConfig = this.getWebpackServerConfig() + const bundlePath = join(serverConfig.output.path, 'server-bundle.json') + const manifestPath = join(serverConfig.output.path, 'client-manifest.json') + if (fs.existsSync(bundlePath) && fs.existsSync(manifestPath)) { + const bundle = fs.readFileSync(bundlePath, 'utf8') + const manifest = fs.readFileSync(manifestPath, 'utf8') + this.createRenderer(JSON.parse(bundle), JSON.parse(manifest)) + this.addAppTemplate() + } + return this + } + + addAppTemplate () { + let templatePath = resolve(this.options.buildDir, 'dist', 'index.html') + if (fs.existsSync(templatePath)) { + this.appTemplate = _.template(fs.readFileSync(templatePath, 'utf8'), { + interpolate: /{{([\s\S]+?)}}/g + }) + } + } + + async generateRoutesAndFiles () { + debug('Generating files...') + // -- Templates -- + let templatesFiles = [ + 'App.vue', + 'client.js', + 'index.js', + 'middleware.js', + 'router.js', + 'server.js', + 'utils.js', + 'components/nuxt-error.vue', + 'components/nuxt-loading.vue', + 'components/nuxt-child.js', + 'components/nuxt-link.js', + 'components/nuxt.vue' + ] + const templateVars = { + options: this.options, + uniqBy: _.uniqBy, + isDev: this.options.dev, + router: { + mode: this.options.router.mode, + base: this.options.router.base, + middleware: this.options.router.middleware, + linkActiveClass: this.options.router.linkActiveClass, + linkExactActiveClass: this.options.router.linkExactActiveClass, + scrollBehavior: this.options.router.scrollBehavior + }, + env: this.options.env, + head: this.options.head, + middleware: fs.existsSync(join(this.options.srcDir, 'middleware')), + store: this.options.store || fs.existsSync(join(this.options.srcDir, 'store')), + css: this.options.css, + plugins: this.options.plugins.map((p, i) => { + if (typeof p === 'string') p = { src: p } + p.src = r(this.options.srcDir, p.src) + return { src: p.src, ssr: (p.ssr !== false), name: `plugin${i}` } + }), + appPath: './App.vue', + layouts: Object.assign({}, this.options.layouts), + loading: (typeof this.options.loading === 'string' ? r(this.options.srcDir, this.options.loading) : this.options.loading), + transition: this.options.transition, + components: { + ErrorPage: this.options.ErrorPage ? r(this.options.ErrorPage) : null + } + } + + // -- Layouts -- + if (fs.existsSync(resolve(this.options.srcDir, 'layouts'))) { + const layoutsFiles = await glob('layouts/*.vue', { cwd: this.options.srcDir }) + layoutsFiles.forEach((file) => { + let name = file.split('/').slice(-1)[0].replace('.vue', '') + if (name === 'error') return + templateVars.layouts[name] = r(this.options.srcDir, file) + }) + if (layoutsFiles.includes('layouts/error.vue')) { + templateVars.components.ErrorPage = r(this.options.srcDir, 'layouts/error.vue') + } + } + // If no default layout, create its folder and add the default folder + if (!templateVars.layouts.default) { + await mkdirp(r(this.options.buildDir, 'layouts')) + templatesFiles.push('layouts/default.vue') + templateVars.layouts.default = r(__dirname, 'app', 'layouts', 'default.vue') + } + + // -- Routes -- + debug('Generating routes...') + // If user defined a custom method to create routes + if (this._nuxtPages) { + // Use nuxt.js createRoutes bases on pages/ + const files = await glob('pages/**/*.vue', { cwd: this.options.srcDir }) + templateVars.router.routes = this.createRoutes(files, this.options.srcDir) + } else { + templateVars.router.routes = this.options.build.createRoutes(this.options.srcDir) + } + // router.extendRoutes method + if (typeof this.options.router.extendRoutes === 'function') { + // let the user extend the routes + this.options.router.extendRoutes(this, templateVars.router.routes || [], r) + } + // Routes for generate command + this.routes = this.flatRoutes(templateVars.router.routes || []) + + // -- Store -- + // Add store if needed + if (this.options.store) { + templatesFiles.push('store.js') + } + + // Resolve template files + const customTemplateFiles = this.options.build.templates.map(t => t.dst || basename(t.src || t)) + + templatesFiles = templatesFiles.map(file => { + // Skip if custom file was already provided in build.templates[] + if (customTemplateFiles.indexOf(file) !== -1) { + return + } + // Allow override templates using a file with same name in ${srcDir}/app + const customPath = r(this.options.srcDir, 'app', file) + const customFileExists = fs.existsSync(customPath) + + return { + src: customFileExists ? customPath : r(__dirname, 'app', file), + dst: file, + custom: customFileExists + } + }).filter(i => !!i) + + // -- Custom templates -- + // Add custom template files + templatesFiles = templatesFiles.concat(this.options.build.templates.map(t => { + return Object.assign({ + src: r(this.options.srcDir, t.src || t), + dst: t.dst || basename(t.src || t), + custom: true + }, t) + })) + + // Interpret and move template files to .nuxt/ + return Promise.all(templatesFiles.map(async ({ src, dst, options, custom }) => { + // Add template to watchers + this.options.build.watch.push(src) + // Render template to dst + const fileContent = await readFile(src, 'utf8') + const template = _.template(fileContent, { + imports: { + serialize, + hash, + r, + wp + } + }) + const content = template(Object.assign({}, templateVars, { + options: options || {}, + custom, + src, + dst + })) + const path = r(this.options.buildDir, dst) + // Ensure parent dir exits + await mkdirp(dirname(path)) + // Write file + await writeFile(path, content, 'utf8') + // Fix webpack loop (https://github.com/webpack/watchpack/issues/25#issuecomment-287789288) + const dateFS = Date.now() / 1000 - 30 + return utimes(path, dateFS, dateFS) + })) + } + + async buildFiles () { + if (this.options.dev) { + debug('Adding webpack middleware...') + this.createWebpackMiddleware() + this.webpackWatchAndUpdate() + this.watchFiles() + } else { + debug('Building files...') + await this.webpackRunClient() + await this.webpackRunServer() + this.addAppTemplate() + } + } + + createRoutes (files, srcDir) { + let routes = [] + files.forEach((file) => { + let keys = file.replace(/^pages/, '').replace(/\.vue$/, '').replace(/\/{2,}/g, '/').split('/').slice(1) + let route = { name: '', path: '', component: r(srcDir, file) } + let parent = routes + keys.forEach((key, i) => { + route.name = route.name ? route.name + '-' + key.replace('_', '') : key.replace('_', '') + route.name += (key === '_') ? 'all' : '' + let child = _.find(parent, { name: route.name }) + if (child) { + if (!child.children) { + child.children = [] + } + parent = child.children + route.path = '' + } else { + if (key === 'index' && (i + 1) === keys.length) { + route.path += (i > 0 ? '' : '/') + } else { + route.path += '/' + (key === '_' ? '*' : key.replace('_', ':')) + if (key !== '_' && key.indexOf('_') !== -1) { + route.path += '?' + } + } + } + }) + // Order Routes path + parent.push(route) + parent.sort((a, b) => { + if (!a.path.length || a.path === '/') { + return -1 + } + if (!b.path.length || b.path === '/') { + return 1 + } + let res = 0 + let _a = a.path.split('/') + let _b = b.path.split('/') + for (let i = 0; i < _a.length; i++) { + if (res !== 0) { + break + } + let y = (_a[i].indexOf('*') > -1) ? 2 : (_a[i].indexOf(':') > -1 ? 1 : 0) + let z = (_b[i].indexOf('*') > -1) ? 2 : (_b[i].indexOf(':') > -1 ? 1 : 0) + res = y - z + if (i === _b.length - 1 && res === 0) { + res = 1 + } + } + return res === 0 ? -1 : res + }) + }) + return this.cleanChildrenRoutes(routes) + } + + cleanChildrenRoutes (routes, isChild = false) { + let start = -1 + let routesIndex = [] + routes.forEach((route) => { + if (/-index$/.test(route.name) || route.name === 'index') { + // Save indexOf 'index' key in name + let res = route.name.split('-') + let s = res.indexOf('index') + start = (start === -1 || s < start) ? s : start + routesIndex.push(res) + } + }) + routes.forEach((route) => { + route.path = (isChild) ? route.path.replace('/', '') : route.path + if (route.path.indexOf('?') > -1) { + let names = route.name.split('-') + let paths = route.path.split('/') + if (!isChild) { + paths.shift() + } // clean first / for parents + routesIndex.forEach((r) => { + let i = r.indexOf('index') - start // children names + if (i < paths.length) { + for (let a = 0; a <= i; a++) { + if (a === i) { + paths[a] = paths[a].replace('?', '') + } + if (a < i && names[a] !== r[a]) { + break + } + } + } + }) + route.path = (isChild ? '' : '/') + paths.join('/') + } + route.name = route.name.replace(/-index$/, '') + if (route.children) { + if (route.children.find((child) => child.path === '')) { + delete route.name + } + route.children = this.cleanChildrenRoutes(route.children, true) + } + }) + return routes + } + + flatRoutes (router, path = '', routes = []) { + router.forEach((r) => { + if (!r.path.includes(':') && !r.path.includes('*')) { + if (r.children) { + flatRoutes(r.children, path + r.path + '/', routes) + } else { + routes.push((r.path === '' && path[path.length - 1] === '/' ? path.slice(0, -1) : path) + r.path) + } + } + }) + return routes + } + + getWebpackClientConfig () { + return clientWebpackConfig.call(this) + } + + getWebpackServerConfig () { + return serverWebpackConfig.call(this) + } + + createWebpackMiddleware () { + const clientConfig = this.getWebpackClientConfig() + const host = process.env.HOST || process.env.npm_package_config_nuxt_host || 'localhost' + const port = process.env.PORT || process.env.npm_package_config_nuxt_port || '3000' + + // setup on the fly compilation + hot-reload + clientConfig.entry.app = _.flatten(['webpack-hot-middleware/client?reload=true', clientConfig.entry.app]) + clientConfig.plugins.push( + new webpack.HotModuleReplacementPlugin(), + new webpack.NoEmitOnErrorsPlugin(), + new PostCompilePlugin(stats => { + if (!stats.hasErrors() && !stats.hasWarnings()) { + // We don't use os.host() here because browsers have special behaviour with localhost + // For example chrome allows Geolocation api only to https or localhost origins + let _host = host === '0.0.0.0' ? 'localhost' : host + console.log(`> Open http://${_host}:${port}\n`) // eslint-disable-line no-console + } + }) + ) + const clientCompiler = webpack(clientConfig) + this.clientCompiler = clientCompiler + // Add the middleware to the instance context + this.webpackDevMiddleware = pify(require('webpack-dev-middleware')(clientCompiler, { + publicPath: clientConfig.output.publicPath, + stats: this.webpackStats, + quiet: true, + noInfo: true, + watchOptions: this.options.watchers.webpack + })) + this.webpackHotMiddleware = pify(require('webpack-hot-middleware')(clientCompiler, { + log: () => { + } + })) + clientCompiler.plugin('done', () => { + const fs = this.webpackDevMiddleware.fileSystem + const filePath = join(clientConfig.output.path, 'index.html') + if (fs.existsSync(filePath)) { + const template = fs.readFileSync(filePath, 'utf-8') + this.appTemplate = _.template(template, { + interpolate: /{{([\s\S]+?)}}/g + }) + } + this.watchHandler() + }) + } + + webpackWatchAndUpdate () { + const MFS = require('memory-fs') // <- dependencies of webpack + const serverFS = new MFS() + const clientFS = this.clientCompiler.outputFileSystem + const serverConfig = this.getWebpackServerConfig() + const serverCompiler = webpack(serverConfig) + const bundlePath = join(serverConfig.output.path, 'server-bundle.json') + const manifestPath = join(serverConfig.output.path, 'client-manifest.json') + serverCompiler.outputFileSystem = serverFS + const watchHandler = (err) => { + if (err) throw err + const bundleExists = serverFS.existsSync(bundlePath) + const manifestExists = clientFS.existsSync(manifestPath) + if (bundleExists && manifestExists) { + const bundle = serverFS.readFileSync(bundlePath, 'utf8') + const manifest = clientFS.readFileSync(manifestPath, 'utf8') + this.createRenderer(JSON.parse(bundle), JSON.parse(manifest)) + } + } + this.watchHandler = watchHandler + this.webpackServerWatcher = serverCompiler.watch(this.options.watchers.webpack, watchHandler) + } + + webpackRunClient () { + return new Promise((resolve, reject) => { + const clientConfig = this.getWebpackClientConfig() + const clientCompiler = webpack(clientConfig) + clientCompiler.run((err, stats) => { + if (err) return reject(err) + console.log('[nuxt:build:client]\n', stats.toString(this.webpackStats)) // eslint-disable-line no-console + if (stats.hasErrors()) { + return reject(new Error('Webpack build exited with errors')) + } + resolve() + }) + }) + } + + webpackRunServer () { + return new Promise((resolve, reject) => { + const serverConfig = this.getWebpackServerConfig() + const serverCompiler = webpack(serverConfig) + serverCompiler.run((err, stats) => { + if (err) return reject(err) + console.log('[nuxt:build:server]\n', stats.toString(this.webpackStats)) // eslint-disable-line no-console + if (stats.hasErrors()) return reject(new Error('Webpack build exited with errors')) + const bundlePath = join(serverConfig.output.path, 'server-bundle.json') + const manifestPath = join(serverConfig.output.path, 'client-manifest.json') + readFile(bundlePath, 'utf8') + .then(bundle => { + readFile(manifestPath, 'utf8') + .then(manifest => { + this.createRenderer(JSON.parse(bundle), JSON.parse(manifest)) + resolve() + }) + }) + }) + }) + } + + createRenderer (bundle, manifest) { + // Create bundle renderer to give a fresh context for every request + this.renderer = createBundleRenderer(bundle, Object.assign({ + clientManifest: manifest, + runInNewContext: false, + basedir: this.options.rootDir + }, this.options.build.ssr)) + this.renderToString = pify(this.renderer.renderToString) + this.renderToStream = this.renderer.renderToStream + } + + watchFiles () { + const patterns = [ + r(this.options.srcDir, 'layouts'), + r(this.options.srcDir, 'store'), + r(this.options.srcDir, 'middleware'), + r(this.options.srcDir, 'layouts/*.vue'), + r(this.options.srcDir, 'layouts/**/*.vue') + ] + if (this._nuxtPages) { + patterns.push(r(this.options.srcDir, 'pages')) + patterns.push(r(this.options.srcDir, 'pages/*.vue')) + patterns.push(r(this.options.srcDir, 'pages/**/*.vue')) + } + const options = Object.assign({}, this.options.watchers.chokidar, { + ignoreInitial: true + }) + /* istanbul ignore next */ + const refreshFiles = _.debounce(this.generateRoutesAndFiles, 200) + // Watch for internals + this.filesWatcher = chokidar.watch(patterns, options) + .on('add', refreshFiles) + .on('unlink', refreshFiles) + // Watch for custom provided files + this.customFilesWatcher = chokidar.watch(_.uniq(this.options.build.watch), options) + .on('change', refreshFiles) + } +} + +const defaultsLoaders = [ + { + test: /\.(png|jpe?g|gif|svg)$/, + loader: 'url-loader', + query: { + limit: 1000, // 1KO + name: 'img/[name].[hash:7].[ext]' + } + }, + { + test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, + loader: 'url-loader', + query: { + limit: 1000, // 1 KO + name: 'fonts/[name].[hash:7].[ext]' + } + } +] + +const defaultsPostcss = [ + require('autoprefixer')({ + browsers: ['last 3 versions'] + }) +] + +const STATUS = { + INITIAL: 1, + BUILD_DONE: 2, + BUILDING: 3 +} diff --git a/lib/defaults.js b/lib/defaults.js new file mode 100755 index 0000000000..d8d20df8a7 --- /dev/null +++ b/lib/defaults.js @@ -0,0 +1,95 @@ +export default { + dev: (process.env.NODE_ENV !== 'production'), + buildDir: '.nuxt', + build: { + analyze: false, + extractCSS: false, + publicPath: '/_nuxt/', + filenames: { + css: 'common.[chunkhash].css', + manifest: 'manifest.[hash].js', + vendor: 'vendor.bundle.[chunkhash].js', + app: 'nuxt.bundle.[chunkhash].js' + }, + vendor: [], + loaders: [], + plugins: [], + babel: {}, + postcss: [], + templates: [], + watch: [] + }, + generate: { + dir: 'dist', + routes: [], + interval: 0, + minify: { + collapseBooleanAttributes: true, + collapseWhitespace: true, + decodeEntities: true, + minifyCSS: true, + minifyJS: true, + processConditionalComments: true, + removeAttributeQuotes: false, + removeComments: false, + removeEmptyAttributes: true, + removeOptionalTags: true, + removeRedundantAttributes: true, + removeScriptTypeAttributes: false, + removeStyleLinkTypeAttributes: false, + removeTagWhitespace: false, + sortAttributes: true, + sortClassName: true, + trimCustomFragments: true, + useShortDoctype: true + } + }, + env: {}, + head: { + meta: [], + link: [], + style: [], + script: [] + }, + plugins: [], + css: [], + modules: [], + layouts: {}, + serverMiddleware: [], + ErrorPage: null, + loading: { + color: 'black', + failedColor: 'red', + height: '2px', + duration: 5000 + }, + transition: { + name: 'page', + mode: 'out-in' + }, + router: { + mode: 'history', + base: '/', + middleware: [], + linkActiveClass: 'nuxt-link-active', + linkExactActiveClass: 'nuxt-link-exact-active', + extendRoutes: null, + scrollBehavior: null + }, + render: { + http2: { + push: false + }, + static: {}, + gzip: { + threshold: 0 + }, + etag: { + weak: true // Faster for responses > 5KB + } + }, + watchers: { + webpack: {}, + chokidar: {} + } +} diff --git a/lib/generate.js b/lib/generate.js index 7525cb843d..ccc017cd56 100644 --- a/lib/generate.js +++ b/lib/generate.js @@ -1,11 +1,11 @@ -'use strict' - import fs from 'fs-extra' import pify from 'pify' import _ from 'lodash' import { resolve, join, dirname, sep } from 'path' import { isUrl, promisifyRoute, waitFor } from './utils' import { minify } from 'html-minifier' +import Tapable from 'tapable' + const debug = require('debug')('nuxt:generate') const copy = pify(fs.copy) const remove = pify(fs.remove) @@ -38,124 +38,136 @@ const defaults = { } } -export default async function () { - const s = Date.now() - let errors = [] - /* - ** Wait for modules to be initialized - */ - await this.ready() - /* - ** Set variables - */ - this.options.generate = _.defaultsDeep(this.options.generate, defaults) - var srcStaticPath = resolve(this.srcDir, 'static') - var srcBuiltPath = resolve(this.buildDir, 'dist') - var distPath = resolve(this.dir, this.options.generate.dir) - var distNuxtPath = join(distPath, (isUrl(this.options.build.publicPath) ? '' : this.options.build.publicPath)) - /* - ** Launch build process - */ - await this.build() - /* - ** Clean destination folder - */ - try { - await remove(distPath) - debug('Destination folder cleaned') - } catch (e) {} - /* - ** Copy static and built files - */ - if (fs.existsSync(srcStaticPath)) { - await copy(srcStaticPath, distPath) +export default class Generator extends Tapable { + constructor (nuxt) { + super() + this.nuxt = nuxt + this.options = nuxt.options } - await copy(srcBuiltPath, distNuxtPath) - debug('Static & build files copied') - if (this.options.router.mode !== 'hash') { - // Resolve config.generate.routes promises before generating the routes + + async generate () { + const s = Date.now() + let errors = [] + /* + ** Wait for modules to be initialized + */ + await this.ready() + /* + ** Set variables + */ + this.options.generate = _.defaultsDeep(this.options.generate, defaults) + let srcStaticPath = resolve(this.options.srcDir, 'static') + let srcBuiltPath = resolve(this.buildDir, 'dist') + let distPath = resolve(this.options.rootDir, this.options.generate.dir) + let distNuxtPath = join(distPath, (isUrl(this.options.build.publicPath) ? '' : this.options.build.publicPath)) + /* + ** Launch build process + */ + await this.build() + /* + ** Clean destination folder + */ try { - var generateRoutes = await promisifyRoute(this.options.generate.routes || []) + await remove(distPath) + debug('Destination folder cleaned') } catch (e) { - console.error('Could not resolve routes') // eslint-disable-line no-console - console.error(e) // eslint-disable-line no-console - process.exit(1) - throw e // eslint-disable-line no-unreachable + } + /* + ** Copy static and built files + */ + if (fs.existsSync(srcStaticPath)) { + await copy(srcStaticPath, distPath) + } + await copy(srcBuiltPath, distNuxtPath) + debug('Static & build files copied') + if (this.options.router.mode !== 'hash') { + // Resolve config.generate.routes promises before generating the routes + try { + let generateRoutes = await promisifyRoute(this.options.generate.routes || []) + } catch (e) { + console.error('Could not resolve routes') // eslint-disable-line no-console + console.error(e) // eslint-disable-line no-console + process.exit(1) + throw e // eslint-disable-line no-unreachable + } + } + function decorateWithPayloads (routes) { + let routeMap = {} + // Fill routeMap for known routes + routes.forEach((route) => { + routeMap[route] = { + route, + payload: null + } + }) + // Fill routeMap with given generate.routes + generateRoutes.forEach((route) => { + // route is either a string or like {route : "/my_route/1"} + const path = _.isString(route) ? route : route.route + routeMap[path] = { + route: path, + payload: route.payload || null + } + }) + return _.values(routeMap) + } + + /* + ** Generate only index.html for router.mode = 'hash' + */ + let routes = (this.options.router.mode === 'hash') ? ['/'] : this.routes + routes = decorateWithPayloads(routes) + + while (routes.length) { + let n = 0 + await Promise.all(routes.splice(0, 500).map(async ({ route, payload }) => { + await waitFor(n++ * this.options.generate.interval) + let html + try { + const res = await this.renderRoute(route, { _generate: true, payload }) + html = res.html + if (res.error) { + errors.push({ type: 'handled', route, error: res.error }) + } + } catch (err) { + /* istanbul ignore next */ + return errors.push({ type: 'unhandled', route, error: err }) + } + if (this.options.generate.minify) { + try { + html = minify(html, this.options.generate.minify) + } catch (err) /* istanbul ignore next */ { + const minifyErr = new Error(`HTML minification failed. Make sure the route generates valid HTML. Failed HTML:\n ${html}`) + errors.push({ type: 'unhandled', route, error: minifyErr }) + } + } + let path = join(route, sep, 'index.html') // /about -> /about/index.html + debug('Generate file: ' + path) + path = join(distPath, path) + // Make sure the sub folders are created + await mkdirp(dirname(path)) + await writeFile(path, html, 'utf8') + })) + } + // Add .nojekyll file to let Github Pages add the _nuxt/ folder + // https://help.github.com/articles/files-that-start-with-an-underscore-are-missing/ + const nojekyllPath = resolve(distPath, '.nojekyll') + writeFile(nojekyllPath, '') + const duration = Math.round((Date.now() - s) / 100) / 10 + debug(`HTML Files generated in ${duration}s`) + + if (errors.length) { + const report = errors.map(({ type, route, error }) => { + /* istanbul ignore if */ + if (type === 'unhandled') { + return `Route: '${route}'\n${error.stack}` + } else { + return `Route: '${route}' thrown an error: \n` + JSON.stringify(error) + } + }) + console.error('==== Error report ==== \n' + report.join('\n\n')) // eslint-disable-line no-console } } - function decorateWithPayloads (routes) { - let routeMap = {} - // Fill routeMap for known routes - routes.forEach((route) => { - routeMap[route] = { - route, - payload: null - } - }) - // Fill routeMap with given generate.routes - generateRoutes.forEach((route) => { - // route is either a string or like {route : "/my_route/1"} - const path = _.isString(route) ? route : route.route - routeMap[path] = { - route: path, - payload: route.payload || null - } - }) - return _.values(routeMap) - } - /* - ** Generate only index.html for router.mode = 'hash' - */ - let routes = (this.options.router.mode === 'hash') ? ['/'] : this.routes - routes = decorateWithPayloads(routes) - while (routes.length) { - let n = 0 - await Promise.all(routes.splice(0, 500).map(async ({route, payload}) => { - await waitFor(n++ * this.options.generate.interval) - let html - try { - const res = await this.renderRoute(route, { _generate: true, payload }) - html = res.html - if (res.error) { - errors.push({ type: 'handled', route, error: res.error }) - } - } catch (err) { - /* istanbul ignore next */ - return errors.push({ type: 'unhandled', route, error: err }) - } - if (this.options.generate.minify) { - try { - html = minify(html, this.options.generate.minify) - } catch (err) /* istanbul ignore next */ { - const minifyErr = new Error(`HTML minification failed. Make sure the route generates valid HTML. Failed HTML:\n ${html}`) - errors.push({ type: 'unhandled', route, error: minifyErr }) - } - } - let path = join(route, sep, 'index.html') // /about -> /about/index.html - debug('Generate file: ' + path) - path = join(distPath, path) - // Make sure the sub folders are created - await mkdirp(dirname(path)) - await writeFile(path, html, 'utf8') - })) - } - // Add .nojekyll file to let Github Pages add the _nuxt/ folder - // https://help.github.com/articles/files-that-start-with-an-underscore-are-missing/ - const nojekyllPath = resolve(distPath, '.nojekyll') - writeFile(nojekyllPath, '') - const duration = Math.round((Date.now() - s) / 100) / 10 - debug(`HTML Files generated in ${duration}s`) - - if (errors.length) { - const report = errors.map(({ type, route, error }) => { - /* istanbul ignore if */ - if (type === 'unhandled') { - return `Route: '${route}'\n${error.stack}` - } else { - return `Route: '${route}' thrown an error: \n` + JSON.stringify(error) - } - }) - console.error('==== Error report ==== \n' + report.join('\n\n')) // eslint-disable-line no-console - } } + diff --git a/lib/module.js b/lib/module-container.js similarity index 94% rename from lib/module.js rename to lib/module-container.js index eed166f5fa..7136af1466 100755 --- a/lib/module.js +++ b/lib/module-container.js @@ -1,25 +1,23 @@ -'use strict' - import path from 'path' import fs from 'fs' import { uniq } from 'lodash' import hash from 'hash-sum' import { chainFn, sequence } from './utils' +import Tapable from 'tapable' const debug = require('debug')('nuxt:module') -class Module { +export default class ModuleContainer extends Tapable { constructor (nuxt) { + super() this.nuxt = nuxt this.options = nuxt.options this.requiredModules = [] - this.initing = this.ready() } async ready () { - if (this.initing) { - await this.initing - return this + if (this._ready) { + return this._ready } // Install all modules in sequence await sequence(this.options.modules, this.addModule.bind(this)) @@ -61,10 +59,10 @@ class Module { } addPlugin (template) { - const {dst} = this.addTemplate(template) + const { dst } = this.addTemplate(template) // Add to nuxt plugins this.options.plugins.unshift({ - src: path.join(this.nuxt.buildDir, dst), + src: path.join(this.options.buildDir, dst), ssr: template.ssr }) } @@ -159,5 +157,3 @@ class Module { }) } } - -export default Module diff --git a/lib/nuxt.js b/lib/nuxt.js index a7373a8cc6..ed0d35398a 100644 --- a/lib/nuxt.js +++ b/lib/nuxt.js @@ -1,150 +1,104 @@ -'use strict' - import _ from 'lodash' import compression from 'compression' import fs from 'fs-extra' import pify from 'pify' import Server from './server' -import Module from './module' -import * as build from './build' -import * as render from './render' -import generate from './generate' +import ModuleContainer from './module-container' +import Builder from './builder' +import Renderer from './renderer' +import Generate from './generate' import serveStatic from 'serve-static' import { resolve, join } from 'path' -import * as utils from './utils' +import defaults from './defaults' +import Tapable from 'tapable' -class Nuxt { +export default class Nuxt extends Tapable { constructor (options = {}) { - const defaults = { - dev: (process.env.NODE_ENV !== 'production'), - buildDir: '.nuxt', - env: {}, - head: { - meta: [], - link: [], - style: [], - script: [] - }, - plugins: [], - css: [], - modules: [], - layouts: {}, - serverMiddleware: [], - ErrorPage: null, - loading: { - color: 'black', - failedColor: 'red', - height: '2px', - duration: 5000 - }, - transition: { - name: 'page', - mode: 'out-in' - }, - router: { - mode: 'history', - base: '/', - middleware: [], - linkActiveClass: 'nuxt-link-active', - linkExactActiveClass: 'nuxt-link-exact-active', - extendRoutes: null, - scrollBehavior: null - }, - render: { - http2: { - push: false - }, - static: {}, - gzip: { - threshold: 0 - }, - etag: { - weak: true // Faster for responses > 5KB - } - }, - watchers: { - webpack: {}, - chokidar: {} - } + super() + + // Normalize options + if (options.loading === true) { + delete options.loading + } + if (options.router && typeof options.router.middleware === 'string') { + options.router.middleware = [options.router.middleware] } - // Sanitization - if (options.loading === true) delete options.loading - if (options.router && typeof options.router.middleware === 'string') options.router.middleware = [options.router.middleware] if (options.router && typeof options.router.base === 'string') { this._routerBaseSpecified = true } - if (typeof options.transition === 'string') options.transition = {name: options.transition} + if (typeof options.transition === 'string') { + options.transition = { name: options.transition } + } + + // Apply defaults this.options = _.defaultsDeep(options, defaults) - // Ready variable - this._ready = false - // Env variables - this.dev = this.options.dev - // Explicit srcDir, rootDir and buildDir - this.dir = (typeof options.rootDir === 'string' && options.rootDir ? options.rootDir : process.cwd()) - this.srcDir = (typeof options.srcDir === 'string' && options.srcDir ? resolve(this.dir, options.srcDir) : this.dir) - this.buildDir = join(this.dir, options.buildDir) - options.rootDir = this.dir - options.srcDir = this.srcDir - options.buildDir = this.buildDir - // If store defined, update store options to true - if (fs.existsSync(join(this.srcDir, 'store'))) { - this.options.store = true - } - // If app.html is defined, set the template path to the user template - this.options.appTemplatePath = resolve(__dirname, 'views/app.template.html') - if (fs.existsSync(join(this.srcDir, 'app.html'))) { - this.options.appTemplatePath = join(this.srcDir, 'app.html') - } - // renderer used by Vue.js (via createBundleRenderer) - this.renderer = null - // For serving static/ files to / - this.serveStatic = pify(serveStatic(resolve(this.srcDir, 'static'), this.options.render.static)) - // For serving .nuxt/dist/ files (only when build.publicPath is not an URL) - this.serveStaticNuxt = pify(serveStatic(resolve(this.buildDir, 'dist'), { - maxAge: (this.dev ? 0 : '1y') // 1 year in production - })) - // gzip middleware for production - if (!this.dev && this.options.render.gzip) { - this.gzipMiddleware = pify(compression(this.options.render.gzip)) - } - // Add this.Server Class + + // Resolve dirs + this.options.rootDir = (typeof options.rootDir === 'string' && options.rootDir ? options.rootDir : process.cwd()) + this.options.srcDir = (typeof options.srcDir === 'string' && options.srcDir ? resolve(options.rootDir, options.srcDir) : this.options.rootDir) + this.options.buildDir = join(this.options.rootDir, options.buildDir) + this.Server = Server - // Add this.build - build.options.call(this) // Add build options - this.build = build.build.bind(this) + this.componentTasks() + + // Create instance of core components + this.builder = new Builder(this) + this.renderer = new Renderer(this) + this.generate = new Generate(this) + this.moduleContainer = new ModuleContainer(this) + + // Backward compatibility + this.render = this.renderer.render.bind(this.renderer) + this.renderRoute = this.renderer.renderRoute.bind(this.renderer) + this.renderAndGetWindow = this.renderer.renderAndGetWindow.bind(this.renderer) + this.build = this.ready.bind(this) + this.dir = options.rootDir + this.srcDir = options.srcDir + this.buildDir = options.buildDir + + // Wait for all core components be ready + this._ready = this.ready().catch(console.error) + } + + componentTasks () { + // TODO: This task should move into their own components instead + // Error template this.errorTemplate = _.template(fs.readFileSync(resolve(__dirname, 'views', 'error.html'), 'utf8'), { interpolate: /{{([\s\S]+?)}}/g }) - // Add this.render and this.renderRoute - this.render = render.render.bind(this) - this.renderRoute = render.renderRoute.bind(this) - this.renderAndGetWindow = render.renderAndGetWindow.bind(this) - // Add this.generate - this.generate = generate.bind(this) - // Add this.utils (tests purpose) - this.utils = utils - // Add module integration - this.module = new Module(this) - // Init nuxt.js - this._ready = this.ready() - // Return nuxt.js instance - return this + + // If store defined, update store options to true + if (fs.existsSync(join(this.options.srcDir, 'store'))) { + this.options.store = true + } + + // If app.html is defined, set the template path to the user template + this.options.appTemplatePath = resolve(__dirname, 'views/app.template.html') + if (fs.existsSync(join(this.options.srcDir, 'app.html'))) { + this.options.appTemplatePath = join(this.options.srcDir, 'app.html') + } + + // For serving static/ files to / + this.serveStatic = pify(serveStatic(resolve(this.options.srcDir, 'static'), this.options.render.static)) + // For serving .nuxt/dist/ files (only when build.publicPath is not an URL) + this.serveStaticNuxt = pify(serveStatic(resolve(this.options.buildDir, 'dist'), { + maxAge: (this.options.dev ? 0 : '1y') // 1 year in production + })) + + // gzip middleware for production + if (!this.options.dev && this.options.render.gzip) { + this.gzipMiddleware = pify(compression(this.options.render.gzip)) + } } async ready () { if (this._ready) { - await this._ready - return this - } - // Init modules - await this.module.ready() - // Launch build in development but don't wait for it to be finished - if (this.dev) { - this.build() - } else { - build.production.call(this) + return this._ready } + await this.moduleContainer.ready() + await this.builder.ready() + console.log('Nuxt Ready!') return this } @@ -178,4 +132,3 @@ class Nuxt { } } -export default Nuxt diff --git a/lib/render.js b/lib/render.js deleted file mode 100644 index 238c31a528..0000000000 --- a/lib/render.js +++ /dev/null @@ -1,196 +0,0 @@ -'use strict' - -import ansiHTML from 'ansi-html' -import serialize from 'serialize-javascript' -import generateETag from 'etag' -import fresh from 'fresh' -import { getContext, setAnsiColors, encodeHtml } from './utils' - -const debug = require('debug')('nuxt:render') -// force blue color -debug.color = 4 -setAnsiColors(ansiHTML) - -export async function render (req, res) { - // Wait for nuxt.js to be ready - await this.ready() - // Check if project is built for production - if (!this.renderer && !this.dev) { - console.error('> No build files found, please run `nuxt build` before launching `nuxt start`') // eslint-disable-line no-console - process.exit(1) - } - /* istanbul ignore if */ - if (!this.renderer || !this.appTemplate) { - return new Promise((resolve) => { - setTimeout(() => { - resolve(this.render(req, res)) - }, 1000) - }) - } - // Get context - const context = getContext(req, res) - res.statusCode = 200 - try { - if (this.dev) { - // Call webpack middleware only in development - await this.webpackDevMiddleware(req, res) - await this.webpackHotMiddleware(req, res) - } - if (!this.dev && this.options.render.gzip) { - await this.gzipMiddleware(req, res) - } - // If base in req.url, remove it for the middleware and vue-router - if (this.options.router.base !== '/' && req.url.indexOf(this.options.router.base) === 0) { - // Compatibility with base url for dev server - req.url = req.url.replace(this.options.router.base, '/') - } - // Serve static/ files - await this.serveStatic(req, res) - // Serve .nuxt/dist/ files (only for production) - if (!this.dev && req.url.indexOf(this.options.build.publicPath) === 0) { - const url = req.url - req.url = req.url.replace(this.options.build.publicPath, '/') - await this.serveStaticNuxt(req, res) - /* istanbul ignore next */ - req.url = url - } - if (this.dev && req.url.indexOf(this.options.build.publicPath) === 0 && req.url.includes('.hot-update.json')) { - res.statusCode = 404 - return res.end() - } - const {html, error, redirected, resourceHints} = await this.renderRoute(req.url, context) - if (redirected) { - return html - } - if (error) { - res.statusCode = context.nuxt.error.statusCode || 500 - } - // ETag header - if (!error && this.options.render.etag) { - const etag = generateETag(html, this.options.render.etag) - if (fresh(req.headers, {etag})) { - res.statusCode = 304 - res.end() - return - } - res.setHeader('ETag', etag) - } - // HTTP2 push headers - if (!error && this.options.render.http2.push) { - // Parse resourceHints to extract HTTP.2 prefetch/push headers - // https://w3c.github.io/preload/#server-push-http-2 - const regex = /link rel="([^"]*)" href="([^"]*)" as="([^"]*)"/g - const pushAssets = [] - let m - while (m = regex.exec(resourceHints)) { // eslint-disable-line no-cond-assign - const [_, rel, href, as] = m // eslint-disable-line no-unused-vars - if (rel === 'preload') { - pushAssets.push(`<${href}>; rel=${rel}; as=${as}`) - } - } - // Pass with single Link header - // https://blog.cloudflare.com/http-2-server-push-with-multiple-assets-per-link-header - res.setHeader('Link', pushAssets.join(',')) - } - res.setHeader('Content-Type', 'text/html; charset=utf-8') - res.setHeader('Content-Length', Buffer.byteLength(html)) - res.end(html, 'utf8') - return html - } catch (err) { - if (context.redirected) { - console.error(err) // eslint-disable-line no-console - return err - } - const html = this.errorTemplate({ - /* istanbul ignore if */ - error: err, - stack: ansiHTML(encodeHtml(err.stack)) - }) - res.statusCode = 500 - res.setHeader('Content-Type', 'text/html; charset=utf-8') - res.setHeader('Content-Length', Buffer.byteLength(html)) - res.end(html, 'utf8') - return err - } -} - -export async function renderRoute (url, context = {}) { - // Wait for modules to be initialized - await this.ready() - // Log rendered url - debug(`Rendering url ${url}`) - // Add url and isSever to the context - context.url = url - context.isServer = true - // Call renderToString from the bundleRenderer and generate the HTML (will update the context as well) - let APP = await this.renderToString(context) - if (!context.nuxt.serverRendered) { - APP = '
' - } - const m = context.meta.inject() - let HEAD = m.meta.text() + m.title.text() + m.link.text() + m.style.text() + m.script.text() + m.noscript.text() - if (this._routerBaseSpecified) { - HEAD += `