From 8ab135af552012973e70b057ffc05152b315fdcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Mon, 7 Nov 2016 02:34:58 +0100 Subject: [PATCH] Prototype 0.1.0 working Alpha 0.1.0 --- .eslintrc.js | 27 ++ .gitignore | 3 - README.md | 1 + bin/nuxt | 29 ++ bin/nuxt-init | 81 ++++++ bin/nuxt-start | 70 +++++ examples/basic-css/pages/index.vue | 18 ++ examples/head-elements/pages/index.vue | 15 ++ examples/hello-world/pages/about.vue | 3 + examples/hello-world/pages/index.vue | 3 + .../components/paragraph.vue | 12 + .../nested-components/components/post.vue | 25 ++ examples/nested-components/pages/index.vue | 54 ++++ examples/with-ava/Readme.md | 25 ++ examples/with-ava/package.json | 11 + examples/with-ava/pages/index.vue | 19 ++ examples/with-ava/test/index.test.js | 77 ++++++ index.js | 8 + lib/app/App.vue | 40 +++ lib/app/client.js | 179 +++++++++++++ lib/app/components/Loading.vue | 31 +++ lib/app/index.js | 24 ++ lib/app/router.js | 38 +++ lib/app/server.js | 70 +++++ lib/app/utils.js | 38 +++ lib/build/index.js | 248 ++++++++++++++++++ lib/build/webpack/base.config.js | 52 ++++ lib/build/webpack/client.config.js | 55 ++++ lib/build/webpack/server.config.js | 23 ++ lib/build/webpack/vue-loader.config.js | 16 ++ lib/nuxt.js | 155 +++++++++++ lib/render.js | 19 ++ lib/renderRoute.js | 7 + lib/utils.js | 29 ++ lib/views/app.html | 19 ++ lib/views/error.html | 11 + package.json | 112 ++++---- pages/_error-debug.vue | 61 +++++ pages/_error.vue | 60 +++++ test/fixtures/basic/pages/async-props.vue | 13 + test/fixtures/basic/pages/css.vue | 9 + test/fixtures/basic/pages/head.vue | 8 + test/fixtures/basic/pages/stateful.vue | 16 ++ test/fixtures/basic/pages/stateless.vue | 3 + test/index.js | 39 +++ 45 files changed, 1788 insertions(+), 68 deletions(-) create mode 100644 .eslintrc.js create mode 100755 bin/nuxt create mode 100755 bin/nuxt-init create mode 100755 bin/nuxt-start create mode 100755 examples/basic-css/pages/index.vue create mode 100755 examples/head-elements/pages/index.vue create mode 100755 examples/hello-world/pages/about.vue create mode 100755 examples/hello-world/pages/index.vue create mode 100755 examples/nested-components/components/paragraph.vue create mode 100755 examples/nested-components/components/post.vue create mode 100755 examples/nested-components/pages/index.vue create mode 100755 examples/with-ava/Readme.md create mode 100755 examples/with-ava/package.json create mode 100755 examples/with-ava/pages/index.vue create mode 100755 examples/with-ava/test/index.test.js create mode 100644 index.js create mode 100644 lib/app/App.vue create mode 100644 lib/app/client.js create mode 100644 lib/app/components/Loading.vue create mode 100644 lib/app/index.js create mode 100644 lib/app/router.js create mode 100644 lib/app/server.js create mode 100644 lib/app/utils.js create mode 100644 lib/build/index.js create mode 100644 lib/build/webpack/base.config.js create mode 100644 lib/build/webpack/client.config.js create mode 100644 lib/build/webpack/server.config.js create mode 100644 lib/build/webpack/vue-loader.config.js create mode 100644 lib/nuxt.js create mode 100644 lib/render.js create mode 100644 lib/renderRoute.js create mode 100644 lib/utils.js create mode 100644 lib/views/app.html create mode 100644 lib/views/error.html create mode 100755 pages/_error-debug.vue create mode 100644 pages/_error.vue create mode 100755 test/fixtures/basic/pages/async-props.vue create mode 100755 test/fixtures/basic/pages/css.vue create mode 100755 test/fixtures/basic/pages/head.vue create mode 100755 test/fixtures/basic/pages/stateful.vue create mode 100755 test/fixtures/basic/pages/stateless.vue create mode 100755 test/index.js diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000000..53a5ff72a2 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,27 @@ +module.exports = { + root: true, + parser: 'babel-eslint', + parserOptions: { + sourceType: 'module' + }, + env: { + browser: true, + node: true, + mocha: true + }, + extends: 'standard', + // required to lint *.vue files + plugins: [ + 'html' + ], + // add your custom rules here + rules: { + // allow paren-less arrow functions + 'arrow-parens': 0, + // allow async-await + 'generator-star-spacing': 0, + // allow debugger during development + 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 + }, + globals: {} +} diff --git a/.gitignore b/.gitignore index a18a862098..1a70a5547f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -# build output -dist - # dependencies yarn.lock node_modules diff --git a/README.md b/README.md index 4d29311df0..06ff887b95 100644 --- a/README.md +++ b/README.md @@ -47,5 +47,6 @@ So far, we get: - Hot code reloading - Server rendering and indexing of `./pages` - Static file serving. `./static/` is mapped to `/static/` +- Config file nuxt.config.js To see how simple this is, check out the [sample app - nuxtgram](https://github.com/atinux/nuxtgram) diff --git a/bin/nuxt b/bin/nuxt new file mode 100755 index 0000000000..276b42ba89 --- /dev/null +++ b/bin/nuxt @@ -0,0 +1,29 @@ +#!/usr/bin/env node --harmony_proxies + +const { join } = require('path') +const { spawn } = require('cross-spawn') + +const defaultCommand = 'start' +const commands = new Set([ + defaultCommand, + 'init' +]) + +let cmd = process.argv[2] +let args + +if (commands.has(cmd)) { + args = process.argv.slice(3) +} else { + cmd = defaultCommand + args = process.argv.slice(2) +} + +const bin = join(__dirname, 'nuxt-' + cmd) + +const proc = spawn(bin, args, { stdio: 'inherit', customFds: [0, 1, 2] }) +proc.on('close', (code) => process.exit(code)) +proc.on('error', (err) => { + console.error(err) + process.exit(1) +}) diff --git a/bin/nuxt-init b/bin/nuxt-init new file mode 100755 index 0000000000..6331dcd080 --- /dev/null +++ b/bin/nuxt-init @@ -0,0 +1,81 @@ +#!/usr/bin/env node --harmony_proxies + +const co = require('co') +const mkdirp = require('mkdirp-then') +const pify = require('pify') +const { resolve, join, basename } = require('path') +const { existsSync, writeFile } = require('fs') + +const rootDir = resolve(process.argv.slice(2)[0] || '.') + +if (basename(rootDir) === 'pages') { + console.warn('Your root directory is named "pages". This looks suspicious. You probably want to go one directory up.') + process.exit(0) +} + +co(function * () { + yield new Promise((resolve) => setTimeout(resolve, 0)) // avoid undefined variables basePackage, etc. + if (!existsSync(rootDir)) { + yield mkdirp(rootDir) + } + if (!existsSync(join(rootDir, 'package.json'))) { + yield pify(writeFile)(join(rootDir, 'package.json'), basePackage.replace(/my-app/g, basename(rootDir))) + } + if (!existsSync(join(rootDir, 'nuxt.config.js'))) { + yield pify(writeFile)(join(rootDir, 'nuxt.config.js'), baseConfig) + } + if (!existsSync(join(rootDir, 'static'))) { + yield mkdirp(join(rootDir, 'static')) + } + if (!existsSync(join(rootDir, 'pages'))) { + yield mkdirp(join(rootDir, 'pages')) + yield pify(writeFile)(join(rootDir, 'pages', 'index.vue'), basePage) + } +}) +.then(() => { + console.log('Nuxt project [' + basename(rootDir) + '] created') +}) +.catch((err) => { + console.error(err) + process.exit(1) +}) + +const basePackage = `{ + "name": "my-app", + "description": "", + "dependencies": { + "nuxt": "latest" + }, + "scripts": { + "start": "nuxt" + } +} +` + +const baseConfig = `module.exports = { + // Nuxt.js configuration file + // Please look at https://nuxtjs.org/docs/config-file +} +` + +const basePage = ` + + + + + +` diff --git a/bin/nuxt-start b/bin/nuxt-start new file mode 100755 index 0000000000..595f61643f --- /dev/null +++ b/bin/nuxt-start @@ -0,0 +1,70 @@ +#!/usr/bin/env node --harmony_proxies + +const http = require('http') +const fs = require('fs') +const serveStatic = require('serve-static') +const Nuxt = require('../') +const { resolve } = require('path') + +const rootDir = resolve(process.argv.slice(2)[0] || '.') +const nuxtConfigFile = resolve(rootDir, 'nuxt.config.js') +let options = {} +if (fs.existsSync(nuxtConfigFile)) { + options = require(nuxtConfigFile) +} +if (typeof options.rootDir !== 'string') { + options.rootDir = rootDir +} + +new Nuxt(options) +.then((nuxt) => { + new Server(nuxt) + .listen(process.env.PORT, process.env.HOST) +}) +.catch((err) => { + console.error(err) + process.exit() +}) + +class Server { + + constructor (nuxt) { + this.server = http.createServer(this.handle.bind(this)) + this.staticServer = serveStatic('static', { fallthrough: false }) + this.nuxt = nuxt + return this + } + + handle (req, res) { + const method = req.method.toUpperCase() + + if (method !== 'GET' && method !== 'HEAD') { + return this.nuxt.render(req, res) + } + this._staticHandler(req, res) + .catch((e) => { + // File not found + this.nuxt.render(req, res) + }) + } + + listen (port, host) { + host = host || 'localhost' + port = port || 3000 + this.server.listen(port, host, () => { + console.log('Ready on http://%s:%s', host, port) + }) + } + + _staticHandler (req, res) { + return new Promise((resolve, reject) => { + this.staticServer(req, res, (error) => { + if (!error) { + return resolve() + } + error.message = `Route ${error.message} while resolving ${req.url}` + reject(error) + }) + }) + } +} diff --git a/examples/basic-css/pages/index.vue b/examples/basic-css/pages/index.vue new file mode 100755 index 0000000000..d66dbd94e7 --- /dev/null +++ b/examples/basic-css/pages/index.vue @@ -0,0 +1,18 @@ + + + diff --git a/examples/head-elements/pages/index.vue b/examples/head-elements/pages/index.vue new file mode 100755 index 0000000000..8896993d85 --- /dev/null +++ b/examples/head-elements/pages/index.vue @@ -0,0 +1,15 @@ + + + diff --git a/examples/hello-world/pages/about.vue b/examples/hello-world/pages/about.vue new file mode 100755 index 0000000000..36382aa404 --- /dev/null +++ b/examples/hello-world/pages/about.vue @@ -0,0 +1,3 @@ + diff --git a/examples/hello-world/pages/index.vue b/examples/hello-world/pages/index.vue new file mode 100755 index 0000000000..b6677d0e9c --- /dev/null +++ b/examples/hello-world/pages/index.vue @@ -0,0 +1,3 @@ + diff --git a/examples/nested-components/components/paragraph.vue b/examples/nested-components/components/paragraph.vue new file mode 100755 index 0000000000..bf7a6a7090 --- /dev/null +++ b/examples/nested-components/components/paragraph.vue @@ -0,0 +1,12 @@ + + + diff --git a/examples/nested-components/components/post.vue b/examples/nested-components/components/post.vue new file mode 100755 index 0000000000..fad939dbe4 --- /dev/null +++ b/examples/nested-components/components/post.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/examples/nested-components/pages/index.vue b/examples/nested-components/pages/index.vue new file mode 100755 index 0000000000..534bef5d85 --- /dev/null +++ b/examples/nested-components/pages/index.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/examples/with-ava/Readme.md b/examples/with-ava/Readme.md new file mode 100755 index 0000000000..cd449998ab --- /dev/null +++ b/examples/with-ava/Readme.md @@ -0,0 +1,25 @@ +## Add testing to your `nuxt` app using `ava` and `jsdom` + +[`ava`](https://github.com/avajs/ava) is a powerful JavaScript testing framework, mixed with [`jsdom`](https://github.com/tmpvar/jsdom), we can use them to do end-to-end testing easily for `nuxt` applications. + +```bash +npm install --save-dev ava jsdom +``` + +Add test script to the `package.json` + +__package.json__ + +```javascript +// ... +"scripts": { + "test": "ava", +} +// ... + +``` + +Launch the tests: +```bash +npm test +``` diff --git a/examples/with-ava/package.json b/examples/with-ava/package.json new file mode 100755 index 0000000000..9f53c3a5e8 --- /dev/null +++ b/examples/with-ava/package.json @@ -0,0 +1,11 @@ +{ + "name": "ava-tests", + "scripts": { + "start": "../../bin/nuxt .", + "test": "ava" + }, + "devDependencies": { + "ava": "^0.16.0", + "jsdom": "^9.8.3" + } +} diff --git a/examples/with-ava/pages/index.vue b/examples/with-ava/pages/index.vue new file mode 100755 index 0000000000..cac2fde6a3 --- /dev/null +++ b/examples/with-ava/pages/index.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/examples/with-ava/test/index.test.js b/examples/with-ava/test/index.test.js new file mode 100755 index 0000000000..95c11390c5 --- /dev/null +++ b/examples/with-ava/test/index.test.js @@ -0,0 +1,77 @@ +/* +** Test with Ava can be written in ES6 \o/ +*/ +import test from 'ava' +import jsdom from 'jsdom' +import { createServer } from 'http' +import { resolve } from 'path' + +let nuxt = null +let server = null + +// Init nuxt.js and create server listening on localhost:4000 +test.before('Init nuxt.js', (t) => { + process.env.NODE_ENV = 'test' + const Nuxt = require('../../../') + const options = { + rootDir: resolve(__dirname, '..') + } + return new Nuxt(options) + .then(function (_nuxt) { + nuxt = _nuxt + server = createServer((req, res) => nuxt.render(req, res)) + return new Promise((resolve, reject) => { + server.listen(4000, 'localhost', () => { + resolve() + }) + }) + }) +}) + +// Function used to do dom checking via jsdom +async function renderAndGetWindow (route) { + return new Promise((resolve, reject) => { + const url = 'http://localhost:4000' + route + jsdom.env({ + url: url, + features: { + FetchExternalResources: ['script', 'link'], + ProcessExternalResources: ['script'] + }, + done (err, window) { + if (err) return reject(err) + // Used by nuxt.js to say when the components are loaded and the app ready + window.onNuxtReady = function () { + resolve(window) + } + } + }) + }) +} + +/* +** Example of testing only the html +*/ +test('Route / exits and render HTML', async t => { + let context = {} + const html = await nuxt.renderRoute('/', context) + t.true(html.includes('

Hello world!

')) + t.is(context.nuxt.error, null) + t.is(context.nuxt.data[0].name, 'world') +}) + +/* +** Example of testing via dom checking +*/ +test('Route / exits and render HTML', async t => { + const window = await renderAndGetWindow('/') + t.is(window.document.querySelector('p').textContent, 'Hello world!') + t.is(window.document.querySelector('p').className, 'red-color') + t.true(window.document.querySelectorAll('style')[2].textContent.includes('.red-color {\n color: red;\n}')) +}) + +// Close server and ask nuxt to stop listening to file changes +test.after('Closing server and nuxt.js', t => { + server.close() + nuxt.stop() +}) diff --git a/index.js b/index.js new file mode 100644 index 0000000000..4f6b0c1b3c --- /dev/null +++ b/index.js @@ -0,0 +1,8 @@ +/*! + * nuxt.js + * MIT Licensed + */ + +'use strict' + +module.exports = require('./lib/nuxt') diff --git a/lib/app/App.vue b/lib/app/App.vue new file mode 100644 index 0000000000..c9649b0385 --- /dev/null +++ b/lib/app/App.vue @@ -0,0 +1,40 @@ + + + diff --git a/lib/app/client.js b/lib/app/client.js new file mode 100644 index 0000000000..d1b38697fe --- /dev/null +++ b/lib/app/client.js @@ -0,0 +1,179 @@ +require('es6-promise').polyfill() +require('es6-object-assign').polyfill() +import Vue from 'vue' +import { app, router<%= (store ? ', store' : '') %> } from './index' +import { getMatchedComponents, flatMapComponents, getContext, getLocation } from './utils' +const noopData = () => { return {} } +const noopFetch = () => {} + +function loadAsyncComponents (to, from, next) { + const resolveComponents = flatMapComponents(to, (Component, _, match, key) => { + if (typeof Component === 'function' && !Component.options) { + return new Promise(function (resolve, reject) { + const _resolve = (Component) => { + // console.log('Component loaded', Component, match.path, key) + match.components[key] = Component + resolve(Component) + } + Component().then(_resolve).catch(reject) + }) + } + // console.log('Return Component', match) + return Component + }) + <%= (loading ? 'this.$loading.start && this.$loading.start()' : '') %> + Promise.all(resolveComponents) + .then(() => next()) + .catch((err) => { + this.error({ statusCode: 500, message: err.message }) + next(false) + }) +} + +function render (to, from, next) { + let Components = getMatchedComponents(to) + if (!Components.length) { + this.error({ statusCode: 404, message: 'This page could not be found.', url: to.path }) + return next() + } + // console.log('Load components', Components, to.path) + // Update ._data and other properties if hot reloaded + Components.forEach(function (Component) { + if (!Component._data) { + Component._data = Component.data || noopData + } + if (Component._Ctor && Component._Ctor.options) { + Component.fetch = Component._Ctor.options.fetch + const originalDataFn = Component._data.toString().replace(/\s/g, '') + const dataFn = (Component.data || noopData).toString().replace(/\s/g, '') + const newDataFn = (Component._Ctor.options.data || noopData).toString().replace(/\s/g, '') + // If component data method changed + if (newDataFn !== originalDataFn && newDataFn !== dataFn) { + Component._data = Component._Ctor.options.data || noopData + } + } + }) + this.error() + Promise.all(Components.map((Component) => { + let promises = [] + const context = getContext({ to<%= (store ? ', store' : '') %>, isClient: true }) + if (Component._data && typeof Component._data === 'function') { + var promise = Component._data(context) + if (!(promise instanceof Promise)) promise = Promise.resolve(promise) + promise.then((data) => { + Component.data = () => data + if (Component._Ctor && Component._Ctor.options) { + Component._Ctor.options.data = Component.data + } + <%= (loading ? 'this.$loading.start && this.$loading.increase(30)' : '') %> + }) + promises.push(promise) + } + if (Component.fetch) { + var p = Component.fetch(context) + <%= (loading ? 'p.then(() => this.$loading.increase && this.$loading.increase(30))' : '') %> + promises.push(p) + } + return Promise.all(promises) + })) + .then(() => { + <%= (loading ? 'this.$loading.finish && this.$loading.finish()' : '') %> + next() + }) + .catch(function (error) { + this.error(error) + next(false) + }) +} + +// Special hot reload with data(context) +function hotReloadAPI (_app) { + var _forceUpdate = _app.$forceUpdate.bind(_app) + _app.$forceUpdate = function () { + let Component = getMatchedComponents(router.currentRoute)[0] + if (!Component) return _forceUpdate() + <%= (loading ? 'this.$loading.start && this.$loading.start()' : '') %> + let promises = [] + const context = getContext({ route: router.currentRoute<%= (store ? ', store' : '') %>, isClient: true }) + // Check if data has been updated + const originalDataFn = (Component._data || noopData).toString().replace(/\s/g, '') + const newDataFn = (Component._Ctor.options.data || noopData).toString().replace(/\s/g, '') + if (originalDataFn !== newDataFn) { + Component._data = Component._Ctor.options.data + let p = Component._data(context) + if (!(p instanceof Promise)) { p = Promise.resolve(p) } + p.then((data) => { + Component.data = () => data + Component._Ctor.options.data = Component.data + <%= (loading ? 'this.$loading.increase && this.$loading.increase(30)' : '') %> + }) + promises.push(p) + } + // Check if fetch has been updated + const originalFetchFn = (Component.fetch || noopFetch).toString().replace(/\s/g, '') + const newFetchFn = (Component._Ctor.options.fetch || noopFetch).toString().replace(/\s/g, '') + // Fetch has been updated, we call it to update the store + if (originalFetchFn !== newFetchFn) { + Component.fetch = Component._Ctor.options.fetch + let p = Component.fetch(context) + if (!(p instanceof Promise)) { p = Promise.resolve(p) } + <%= (loading ? 'p.then(() => this.$loading.increase && this.$loading.increase(30))' : '') %> + promises.push(p) + } + return Promise.all(promises).then(() => { + <%= (loading ? 'this.$loading.finish && this.$loading.finish(30)' : '') %> + _forceUpdate() + }) + } +} + +// Load vue app +const NUXT = window.__NUXT__ || {} +if (!NUXT) { + throw new Error('[nuxt.js] cannot find the global variable __NUXT__, make sure the server is working.') +} +<% if (store) { %> +// Replace store state +if (NUXT.state) { + store.replaceState(NUXT.state) +} +<% } %> +// Get matched components +const path = getLocation(router.options.base) +const resolveComponents = flatMapComponents(router.match(path), (Component, _, match, key, index) => { + if (typeof Component === 'function' && !Component.options) { + return new Promise(function (resolve, reject) { + const _resolve = (Component) => { + if (Component.data && typeof Component.data === 'function') { + Component._data = Component.data + Component.data = () => NUXT.data[index] + if (Component._Ctor && Component._Ctor.options) { + Component._Ctor.options.data = Component.data + } + } + match.components[key] = Component + resolve(Component) + } + Component().then(_resolve).catch(reject) + }) + } + return Component +}) + +Promise.all(resolveComponents) +.then((Components) => { + const _app = new Vue(app) + if (NUXT.error) _app.error(NUXT.error) + if (module.hot) hotReloadAPI(_app) + _app.$mount('#app') + // Add router hooks + router.beforeEach(loadAsyncComponents.bind(_app)) + router.beforeEach(render.bind(_app)) + // Call window.onModulesLoaded for jsdom testing (https://github.com/tmpvar/jsdom#dealing-with-asynchronous-script-loading) + if (typeof window.onNuxtReady === 'function') { + window.onNuxtReady() + } +}) +.catch((err) => { + console.error('[Nuxt.js] Cannot load components', err) +}) diff --git a/lib/app/components/Loading.vue b/lib/app/components/Loading.vue new file mode 100644 index 0000000000..ca79c1a71c --- /dev/null +++ b/lib/app/components/Loading.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/lib/app/index.js b/lib/app/index.js new file mode 100644 index 0000000000..a280faca6b --- /dev/null +++ b/lib/app/index.js @@ -0,0 +1,24 @@ +// The Vue build version to load with the `import` command +// (runtime-only or standalone) has been set in webpack.base.conf with an alias. +import Vue from 'vue' +import router from './router' +<% if (store && storePath) { %>import store from '<%= storePath %>'<% } %> + +// import VueProgressBar from './plugins/vue-progressbar' +// Vue.use(VueProgressBar, { +// color: '#efc14e', +// failedColor: 'red', +// height: '2px' +// }) + +import App from './App.vue' +// create the app instance. +// here we inject the router and store to all child components, +// making them available everywhere as `this.$router` and `this.$store`. +const app = { + router, + <%= (store ? 'store,' : '') %> + ...App +} + +export { app, router<%= (store ? ', store' : '') %> } diff --git a/lib/app/router.js b/lib/app/router.js new file mode 100644 index 0000000000..208df1fc98 --- /dev/null +++ b/lib/app/router.js @@ -0,0 +1,38 @@ +import Vue from 'vue' +import Router from 'vue-router' +import Meta from 'vue-meta' + +Vue.use(Router) +Vue.use(Meta) + +<% routes.forEach(function (route) { %> +const <%= route._name %> = process.BROWSER ? () => System.import('<%= route._component %>') : require('<%= route._component %>') +<% }) %> + +const scrollBehavior = (to, from, savedPosition) => { + if (savedPosition) { + // savedPosition is only available for popstate navigations. + return savedPosition + } else { + // Scroll to the top by default + let position = { x: 0, y: 0 } + // if link has anchor, scroll to anchor by returning the selector + if (to.hash) { + position = { selector: to.hash } + } + return position + } +} + +export default new Router({ + mode: 'history', + scrollBehavior, + routes: [ + <% routes.forEach((route, i) => { %> + { + path: '<%= route.path %>', + component: <%= route._name %> + }<%= (i + 1 === routes.length ? '' : ',') %> + <% }) %> + ] +}) diff --git a/lib/app/server.js b/lib/app/server.js new file mode 100644 index 0000000000..9b734e02cc --- /dev/null +++ b/lib/app/server.js @@ -0,0 +1,70 @@ +const debug = require('debug')('nuxt:render') +import Vue from 'vue' +import { pick } from 'lodash' +import { app, router<%= (store ? ', store' : '') %> } from './index' +import { getMatchedComponents, getContext } from './utils' + +const isDev = process.env.NODE_ENV !== 'production' +const _app = new Vue(app) + +// This exported function will be called by `bundleRenderer`. +// This is where we perform data-prefetching to determine the +// state of our application before actually rendering it. +// Since data fetching is async, this function is expected to +// return a Promise that resolves to the app instance. +export default context => { + // set router's location + router.push(context.url) + + // Add route to the context + context.route = router.currentRoute + // Add meta infos + context.meta = _app.$meta() + // Add store to the context + <%= (store ? 'context.store = store' : '') %> + + // Nuxt object + context.nuxt = { data: [], error: null<%= (store ? ', state: null' : '') %> } + + <%= (isDev ? 'const s = isDev && Date.now()' : '') %> + // Call data & fecth hooks on components matched by the route. + let Components = getMatchedComponents(context.route) + if (!Components.length) { + context.nuxt.error = _app.error({ statusCode: 404, message: 'This page could not be found.', url: context.route.path }) + <%= (store ? 'context.nuxt.state = store.state' : '') %> + return Promise.resolve(_app) + } + return Promise.all(Components.map((Component) => { + let promises = [] + if (Component.data && typeof Component.data === 'function') { + Component._data = Component.data + var promise = Component.data(getContext(context)) + if (!(promise instanceof Promise)) promise = Promise.resolve(promise) + promise.then((data) => { + Component.data = () => data + }) + promises.push(promise) + } else { + promises.push(null) + } + if (Component.fetch) { + promises.push(Component.fetch(getContext(context))) + } + return Promise.all(promises) + })) + .then((res) => { + <% if (isDev) { %> + debug('Data fetch ' + context.req.url + ': ' + (Date.now() - s) + 'ms') + <% } %> + // datas are the first row of each + context.nuxt.data = res.map((tab) => tab[0]) + <%= (store ? '// Add the state from the vuex store' : '') %> + <%= (store ? 'context.nuxt.state = store.state' : '') %> + return _app + }) + .catch(function (error) { + context.nuxt.error = _app.error(error) + <%= (store ? 'context.nuxt.state = store.state' : '') %> + return _app + }) +} diff --git a/lib/app/utils.js b/lib/app/utils.js new file mode 100644 index 0000000000..b7e7f1c8da --- /dev/null +++ b/lib/app/utils.js @@ -0,0 +1,38 @@ +'use strict' + +export function getMatchedComponents (route) { + return [].concat.apply([], route.matched.map(function (m) { + return Object.keys(m.components).map(function (key) { + return m.components[key] + }) + })) +} + +export function flatMapComponents (route, fn) { + return Array.prototype.concat.apply([], route.matched.map(function (m, index) { + return Object.keys(m.components).map(function (key) { + return fn(m.components[key], m.instances[key], m, key, index) + }) + })) +} + +export function getContext (context) { + let ctx = { + isServer: !!context.isServer, + isClient: !!context.isClient, + <%= (store ? 'store: context.store,' : '') %> + route: (context.to ? context.to : context.route) + } + if (context.req) ctx.req = context.req + if (context.res) ctx.req = context.res + return ctx +} + +// Imported from vue-router +export function getLocation (base) { + var path = window.location.pathname + if (base && path.indexOf(base) === 0) { + path = path.slice(base.length) + } + return (path || '/') + window.location.search + window.location.hash +} diff --git a/lib/build/index.js b/lib/build/index.js new file mode 100644 index 0000000000..dcb5a98a30 --- /dev/null +++ b/lib/build/index.js @@ -0,0 +1,248 @@ +'use strict' + +const debug = require('debug')('nuxt:build') +const _ = require('lodash') +const del = require('del') +const fs = require('fs') +const glob = require('glob-promise') +const hash = require('hash-sum') +const mkdirp = require('mkdirp-then') +const pify = require('pify') +const webpack = require('webpack') +const { createBundleRenderer } = require('vue-server-renderer') +const { join, resolve } = require('path') +const r = resolve + +module.exports = function * () { + /* + ** Check if pages dir exists and warn if not + */ + if (!fs.existsSync(join(this.dir, 'pages'))) { + if (fs.existsSync(join(this.dir, '..', 'pages'))) { + console.error('> No `pages` directory found. Did you mean to run `next` in the parent (`../`) directory?') + } else { + console.error('> Couldn\'t find a `pages` directory. Please create one under the project root') + } + process.exit() + } + if (this.options.store && !fs.existsSync(join(this.dir, 'store'))) { + console.error('> No `store` directory found (store option activated). Please create on under the project root') + process.exit() + } + if (this.options.store && !fs.existsSync(join(this.dir, 'store', 'index.js'))) { + console.error('> No `store/index.js` file found (store option activated). Please create the file.') + process.exit() + } + debug(`App root: ${this.dir}`) + debug('Generating .nuxt/ files...') + /* + ** Create .nuxt/, .nuxt/components and .nuxt/dist folders + */ + yield del(r(this.dir, '.nuxt'), { force: process.env.NODE_ENV === 'test' }) + yield mkdirp(r(this.dir, '.nuxt/components')) + if (this.isProd) { + yield mkdirp(r(this.dir, '.nuxt/dist')) + } + /* + ** Generate routes based on files + */ + const files = yield glob('pages/**/*.vue', { cwd: this.dir }) + let routes = [] + files.forEach((file) => { + let path = file.replace(/^pages/, '').replace(/index\.vue$/, '/').replace(/\.vue$/, '').replace(/\/{2,}/g, '/') + if (path[1] === '_') return + routes.push({ path: path, component: file }) + }) + this.options.routes.forEach((route) => { + route.component = r(this.dir, route.component) + }) + this.options.routes = routes.concat(this.options.routes) + // TODO: check .children + this.options.routes.forEach((route) => { + route._component = r(this.dir, route.component) + route._name = '_' + hash(route._component) + route.component = route._name + }) + /* + ** Interpret and move template files to .nuxt/ + */ + let templatesFiles = [ + 'App.vue', + 'client.js', + 'index.js', + 'router.js', + 'server.js', + 'utils.js', + 'components/Loading.vue' + ] + let templateVars = { + isDev: this.isDev, + store: this.options.store, + loading: (this.options.loading === 'string' ? r(this.dir, this.options.loading) : this.options.loading), + components: { + Loading: r(__dirname, '..', 'app', 'components', 'Loading.vue'), + ErrorPage: r(__dirname, '..', '..', 'pages', (this.isDev ? '_error-debug.vue' : '_error.vue')) + }, + routes: this.options.routes + } + if (this.options.store) { + templateVars.storePath = r(this.dir, 'store') + } + if (this.isDev && files.includes('pages/_error-debug.vue')) { + templateVars.components.ErrorPage = r(this.dir, 'pages/_error-debug.vue') + } + if (!this.isDev && files.includes('pages/_error.vue')) { + templateVars.components.ErrorPage = r(this.dir, 'pages/_error.vue') + } + const readFile = pify(fs.readFile) + const writeFile = pify(fs.writeFile) + let moveTemplates = templatesFiles.map((file) => { + return readFile(r(__dirname, '..', 'app', file), 'utf8') + .then((fileContent) => { + const template = _.template(fileContent) + const content = template(templateVars) + return writeFile(r(this.dir, '.nuxt', file), content, 'utf8') + }) + }) + yield moveTemplates + debug('Files moved!') + /* + ** Generate .nuxt/dist/ files + */ + if (this.isDev) { + debug('Adding webpack middlewares...') + createWebpackMiddlewares.call(this) + webpackWatchAndUpdate.call(this) + } else { + debug('Building files...') + yield [ + webpackRunClient.call(this), + webpackRunServer.call(this) + ] + } + return this +} + +function getWebpackClientConfig () { + var config = require(r(__dirname, 'webpack', 'client.config.js')) + // Entry + config.entry.app = r(this.dir, '.nuxt', 'client.js') + // Add vendors + if (this.options.store) config.entry.vendor.push('vuex') + config.entry.vendor = config.entry.vendor.concat(this.options.vendor) + // extract vendor chunks for better caching + config.plugins.push( + new webpack.optimize.CommonsChunkPlugin({ + name: 'vendor', + filename: this.options.filenames.vendor + }) + ) + // Output + config.output.path = r(this.dir, '.nuxt', 'dist') + config.output.filename = this.options.filenames.app + // Extract text plugin + if (this.isProd) { + const ExtractTextPlugin = require('extract-text-webpack-plugin') + let plugin = config.plugins.find((plugin) => plugin instanceof ExtractTextPlugin) + if (plugin) plugin.filename = this.options.filenames.css + } + return config +} + +function getWebpackServerConfig () { + var config = require(r(__dirname, 'webpack', 'server.config.js')) + // Entry + config.entry = r(this.dir, '.nuxt', 'server.js') + // Output + config.output.path = r(this.dir, '.nuxt', 'dist') + // Externals + config.externals = Object.keys(require(r(__dirname, '..', '..', 'package.json')).dependencies || {}) + const projectPackageJson = r(this.dir, 'package.json') + if (fs.existsSync(projectPackageJson)) { + config.externals = [].concat(Object.keys(require(r(this.dir, 'package.json')).dependencies || {})) + } + config.externals = _.uniq(config.externals) + return config +} + +function createWebpackMiddlewares () { + const clientConfig = getWebpackClientConfig.call(this) + // setup on the fly compilation + hot-reload + clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app] + clientConfig.plugins.push( + new webpack.HotModuleReplacementPlugin(), + new webpack.NoErrorsPlugin() + ) + const clientCompiler = webpack(clientConfig) + // Add the middlewares to the instance context + this.webpackDevMiddleware = pify(require('webpack-dev-middleware')(clientCompiler, { + publicPath: clientConfig.output.publicPath, + stats: { + colors: true, + chunks: false + }, + quiet: true, + noInfo: true + })) + this.webpackHotMiddleware = pify(require('webpack-hot-middleware')(clientCompiler)) +} + +function webpackWatchAndUpdate () { + const MFS = require('memory-fs') // <- dependencies of webpack + const mfs = new MFS() + const serverConfig = getWebpackServerConfig.call(this) + const serverCompiler = webpack(serverConfig) + const outputPath = join(serverConfig.output.path, serverConfig.output.filename) + serverCompiler.outputFileSystem = mfs + this.webpackServerWatcher = serverCompiler.watch({}, (err, stats) => { + if (err) throw err + stats = stats.toJson() + stats.errors.forEach(err => console.error(err)) + stats.warnings.forEach(err => console.warn(err)) + createRenderer.call(this, mfs.readFileSync(outputPath, 'utf-8')) + }) +} + +function webpackRunClient () { + return new Promise((resolve, reject) => { + const clientConfig = getWebpackClientConfig.call(this) + const serverCompiler = webpack(clientConfig) + serverCompiler.run((err, stats) => { + if (err) return reject(err) + debug('[webpack:build:client]\n', stats.toString({ chunks: false, colors: true })) + 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) + debug('[webpack:build:server]\n', stats.toString({ chunks: false, colors: true })) + const bundlePath = join(serverConfig.output.path, serverConfig.output.filename) + createRenderer.call(this, fs.readFileSync(bundlePath, 'utf8')) + resolve() + }) + }) +} + +function createRenderer (bundle) { + process.env.VUE_ENV = (process.env.VUE_ENV ? process.env.VUE_ENV : 'server') + // Create bundle renderer to give a fresh context for every request + let cacheConfig = false + if (this.options.cache) { + this.options.cache = (typeof this.options.cache !== 'object' ? {} : this.options.cache) + cacheConfig = require('lru-cache')(_.defaults(this.options.cache, { + max: 1000, + maxAge: 1000 * 60 * 15 + })) + } + this.renderer = createBundleRenderer(bundle, { + cache: cacheConfig + }) + this.renderToString = pify(this.renderer.renderToString) + this.renderToStream = this.renderer.renderToStream +} diff --git a/lib/build/webpack/base.config.js b/lib/build/webpack/base.config.js new file mode 100644 index 0000000000..2b325ef200 --- /dev/null +++ b/lib/build/webpack/base.config.js @@ -0,0 +1,52 @@ +const vueLoaderConfig = require('./vue-loader.config') + +/* +|-------------------------------------------------------------------------- +| Webpack Shared Config +| +| This is the config which is extented by the server and client +| webpack config files +|-------------------------------------------------------------------------- +*/ +module.exports = { + devtool: 'source-map', + entry: { + vendor: ['vue', 'vue-router', 'vue-meta', 'es6-promise', 'es6-object-assign'] + }, + output: { + publicPath: '/_nuxt/' + }, + module: { + rules: [ + { + test: /\.vue$/, + loader: 'vue', + options: vueLoaderConfig + }, + { + test: /\.js$/, + loader: 'babel', + exclude: /node_modules/, + options: { + presets: ['es2015', 'stage-2'] + } + }, + { + test: /\.(png|jpg|gif|svg)$/, + loader: 'url', + options: { + limit: 1000, // 1KO + name: 'img/[name].[ext]?[hash]' + } + }, + { + test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, + loader: 'url', + query: { + limit: 1000, // 1 KO + name: 'fonts/[name].[hash:7].[ext]' + } + } + ] + } +} diff --git a/lib/build/webpack/client.config.js b/lib/build/webpack/client.config.js new file mode 100644 index 0000000000..6d7c426a5d --- /dev/null +++ b/lib/build/webpack/client.config.js @@ -0,0 +1,55 @@ +const webpack = require('webpack') +const base = require('./base.config') +const vueConfig = require('./vue-loader.config') + +/* +|-------------------------------------------------------------------------- +| Webpack Client Config +| +| Generate public/dist/client-vendor-bundle.js +| Generate public/dist/client-bundle.js +| +| In production, will generate public/dist/style.css +|-------------------------------------------------------------------------- +*/ + +const config = Object.assign({}, base, { + plugins: (base.plugins || []).concat([ + // strip comments in Vue code + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), + 'process.BROWSER': true + }) + ]) +}) + +if (process.env.NODE_ENV === 'production') { + // Use ExtractTextPlugin to extract CSS into a single file + // so it's applied on initial render + const ExtractTextPlugin = require('extract-text-webpack-plugin') + + // vueConfig is already included in the config via LoaderOptionsPlugin + // here we overwrite the loader config for diff --git a/pages/_error.vue b/pages/_error.vue new file mode 100644 index 0000000000..889021e0db --- /dev/null +++ b/pages/_error.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/test/fixtures/basic/pages/async-props.vue b/test/fixtures/basic/pages/async-props.vue new file mode 100755 index 0000000000..0299b74965 --- /dev/null +++ b/test/fixtures/basic/pages/async-props.vue @@ -0,0 +1,13 @@ + + + diff --git a/test/fixtures/basic/pages/css.vue b/test/fixtures/basic/pages/css.vue new file mode 100755 index 0000000000..902c7a07f2 --- /dev/null +++ b/test/fixtures/basic/pages/css.vue @@ -0,0 +1,9 @@ + + + diff --git a/test/fixtures/basic/pages/head.vue b/test/fixtures/basic/pages/head.vue new file mode 100755 index 0000000000..7d86ac9d89 --- /dev/null +++ b/test/fixtures/basic/pages/head.vue @@ -0,0 +1,8 @@ + diff --git a/test/fixtures/basic/pages/stateful.vue b/test/fixtures/basic/pages/stateful.vue new file mode 100755 index 0000000000..1ef1e2ee31 --- /dev/null +++ b/test/fixtures/basic/pages/stateful.vue @@ -0,0 +1,16 @@ + + + diff --git a/test/fixtures/basic/pages/stateless.vue b/test/fixtures/basic/pages/stateless.vue new file mode 100755 index 0000000000..fc2eef5cf0 --- /dev/null +++ b/test/fixtures/basic/pages/stateless.vue @@ -0,0 +1,3 @@ + diff --git a/test/index.js b/test/index.js new file mode 100755 index 0000000000..82daec80ae --- /dev/null +++ b/test/index.js @@ -0,0 +1,39 @@ +import test from 'ava' +import { join } from 'path' +import build from '../server/build' +import { render as _render } from '../server/render' + +const dir = join(__dirname, 'fixtures', 'basic') + +test.before(() => build(dir)) + +test(async t => { + const html = await render('/stateless') + t.true(html.includes('

My component!

')) +}) + +test(async t => { + const html = await render('/css') + t.true(html.includes('.red{color:red;}')) + t.true(html.includes('
This is red
')) +}) + +test(async t => { + const html = await render('/stateful') + t.true(html.includes('

The answer is 42

')) +}) + +test(async t => { + const html = await (render('/head')) + t.true(html.includes('')) + t.true(html.includes('

I can haz meta tags

')) +}) + +test(async t => { + const html = await render('/async-props') + t.true(html.includes('

Kobe Bryant

')) +}) + +function render (url, ctx) { + return _render(url, ctx, { dir, staticMarkup: true }) +}