diff --git a/.eslintignore b/.eslintignore index 5cc1873034..890258d49f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ generated __fixtures__ -dist \ No newline at end of file +dist +website \ No newline at end of file diff --git a/lib/commands/build.js b/lib/commands/build.js index 7f017ca84e..cbb6501d7c 100644 --- a/lib/commands/build.js +++ b/lib/commands/build.js @@ -4,7 +4,8 @@ const chalk = require('chalk'); const fs = require('fs-extra'); const globby = require('globby'); const load = require('../load'); -const createProdConfig = require('../webpack/prod'); +const createServerConfig = require('../webpack/server'); +const createClientConfig = require('../webpack/client'); function compile(config) { return new Promise((resolve, reject) => { @@ -35,11 +36,14 @@ module.exports = async function build(siteDir, cliOptions = {}) { const props = await load(siteDir); - // create compiler from generated webpack config - const config = createProdConfig(props).toConfig(); + const serverConfig = createServerConfig(props).toConfig(); + const clientConfig = createClientConfig(props).toConfig(); - // compile! - await compile(config); + // we build the client bundles first + await compile(clientConfig); + + // then we build the server bundles (render the static HTML and pick client bundle) + await compile(serverConfig); // copy static files const {outDir} = props; diff --git a/lib/commands/start.js b/lib/commands/start.js index a42804ad2b..a8ee2f4f5f 100644 --- a/lib/commands/start.js +++ b/lib/commands/start.js @@ -10,8 +10,9 @@ const serveStatic = require('koa-static'); const history = require('connect-history-api-fallback'); const portfinder = require('portfinder'); const serve = require('webpack-serve'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); const load = require('../load'); -const createDevConfig = require('../webpack/dev'); +const createClientConfig = require('../webpack/client'); async function getPort(reqPort) { portfinder.basePort = parseInt(reqPort, 10) || 3000; @@ -52,7 +53,18 @@ module.exports = async function start(siteDir, cliOptions = {}) { const {baseUrl} = props; // create compiler from generated webpack config - const config = createDevConfig(props).toConfig(); + let config = createClientConfig(props); + + const {siteConfig} = props; + config.plugin('html-webpack-plugin').use(HtmlWebpackPlugin, [ + { + hash: true, + template: path.resolve(__dirname, '../core/devTemplate.ejs'), + filename: 'index.html', + title: siteConfig.title + } + ]); + config = config.toConfig(); const compiler = webpack(config); // webpack-serve diff --git a/lib/core/clientEntry.js b/lib/core/clientEntry.js new file mode 100644 index 0000000000..38f475e627 --- /dev/null +++ b/lib/core/clientEntry.js @@ -0,0 +1,19 @@ +import React from 'react'; +import {BrowserRouter} from 'react-router-dom'; +import ReactDOM from 'react-dom'; + +import App from './App'; +import prerender from './prerender'; +import routes from '@generated/routes'; // eslint-disable-line + +// Client side render (e.g: running in browser) to become single-page application (SPA) +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + prerender(routes, window.location.pathname).then(() => { + ReactDOM.render( + + + , + document.getElementById('app') + ); + }); +} diff --git a/lib/core/devEntry.js b/lib/core/devEntry.js deleted file mode 100644 index 3cfc7386bc..0000000000 --- a/lib/core/devEntry.js +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import {BrowserRouter} from 'react-router-dom'; - -import App from './App'; - -ReactDOM.render( - - - , - document.getElementById('app') -); diff --git a/lib/core/prerender.js b/lib/core/prerender.js new file mode 100644 index 0000000000..7e41893617 --- /dev/null +++ b/lib/core/prerender.js @@ -0,0 +1,18 @@ +import {matchRoutes} from 'react-router-config'; + +/** + * This helps us to make sure all the async component for that particular route + * is loaded before rendering. This is to avoid loading screens on first page load + */ +export default function prerender(routeConfig, providedLocation) { + const matches = matchRoutes(routeConfig, providedLocation); + return Promise.all( + matches.map(match => { + const {component} = match.route; + if (component && component.preload) { + return component.preload(); + } + return undefined; + }) + ); +} diff --git a/lib/core/prodEntry.js b/lib/core/prodEntry.js deleted file mode 100644 index 24ff2ac532..0000000000 --- a/lib/core/prodEntry.js +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import {BrowserRouter, StaticRouter} from 'react-router-dom'; -import ReactDOM from 'react-dom'; -import ReactDOMServer from 'react-dom/server'; - -import App from './App'; - -// Client side render (e.g: running in browser) to become single-page application (SPA) -if (typeof document !== 'undefined') { - ReactDOM.render( - - - , - document.getElementById('app') - ); -} - -// Renderer for static-site-generator-webpack-plugin (async rendering via callbacks) -export default function render(locals, callback) { - const context = {}; - const body = ReactDOMServer.renderToString( - - - - ); - - // Build HTML template - const assets = Object.keys(locals.webpackStats.compilation.assets); - const css = assets.filter(value => value.match(/\.css$/)); - const js = assets.filter(value => value.match(/\.js$/)); - const {title, baseUrl, lang = 'en', template} = locals; - const html = template({body, baseUrl, css, js, title, lang}); - - callback(null, html); -} diff --git a/lib/core/prodTemplate.ejs b/lib/core/prodTemplate.ejs deleted file mode 100644 index 2c11428a73..0000000000 --- a/lib/core/prodTemplate.ejs +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - <%- title %> - <% css.forEach(function(file){ %> - - <% }); %> - - -
<%- body %>
- <% js.forEach(function(file){ %> - - <% }); %> - - \ No newline at end of file diff --git a/lib/core/serverEntry.js b/lib/core/serverEntry.js new file mode 100644 index 0000000000..1bd7dab180 --- /dev/null +++ b/lib/core/serverEntry.js @@ -0,0 +1,59 @@ +import React from 'react'; +import {StaticRouter} from 'react-router-dom'; +import ReactDOMServer from 'react-dom/server'; +import Helmet from 'react-helmet'; + +import App from './App'; +import prerender from './prerender'; +import routes from '@generated/routes'; // eslint-disable-line +import webpackClientStats from '@build/client.stats.json'; //eslint-disable-line + +// Renderer for static-site-generator-webpack-plugin (async rendering via promises) +export default function render(locals) { + return prerender(routes, locals.path).then(() => { + const context = {}; + const appHtml = ReactDOMServer.renderToString( + + + + ); + + const helmet = Helmet.renderStatic(); + const htmlAttributes = helmet.htmlAttributes.toString(); + const bodyAttributes = helmet.bodyAttributes.toString(); + const metaStrings = [ + helmet.title.toString(), + helmet.meta.toString(), + helmet.link.toString() + ]; + const metaHtml = metaStrings.filter(Boolean).join('\n '); + + const assets = webpackClientStats.assetsByChunkName.main; + const jsFiles = assets.filter(value => value.match(/\.js$/)); + const cssFiles = assets.filter(value => value.match(/\.css$/)); + const {baseUrl} = locals; + + const html = ` + + + + ${metaHtml} + + + ${cssFiles.map( + cssFile => + `` + )} + + +
${appHtml}
+ ${jsFiles.map( + jsFile => + `` + )} + + +`; + return html; + }); +} diff --git a/lib/load/routes.js b/lib/load/routes.js index 2b6f48fd60..286ca42b76 100644 --- a/lib/load/routes.js +++ b/lib/load/routes.js @@ -1,40 +1,32 @@ -const {fileToComponentName} = require('./utils'); - async function genRoutesConfig({docsData = [], pagesData = []}) { function genDocsRoute({path: docsPath, source}) { - const componentName = fileToComponentName(source); return ` { path: ${JSON.stringify(docsPath)}, exact: true, - component: (props) => ( - - <${componentName} /> - - ) + component: Loadable({ + loader: () => import('@docs/${source}'), + loading: Loading, + render(loaded, props) { + let Content = loaded.default; + return ; + } + }) }`; } - function genDocsImport({source}) { - const componentName = fileToComponentName(source); - return `import ${componentName} from '@docs/${source}';`; - } - function genPagesRoute({path: pagesPath, source}) { - const componentName = fileToComponentName(source); return ` { path: ${JSON.stringify(pagesPath)}, exact: true, - component: ${componentName} + component: Loadable({ + loader: () => import('@pages/${source}'), + loading: Loading + }) }`; } - function genPagesImport({source}) { - const componentName = fileToComponentName(source); - return `import ${componentName} from '@pages/${source}';`; - } - const notFoundRoute = `, { path: '*', @@ -43,10 +35,10 @@ async function genRoutesConfig({docsData = [], pagesData = []}) { return ( `import React from 'react';\n` + + `import Loading from '@theme/Loading';\n` + + `import Loadable from 'react-loadable';\n` + `import Docs from '@theme/Docs';\n` + `import NotFound from '@theme/NotFound';\n` + - `${pagesData.map(genPagesImport).join('\n')}\n` + - `${docsData.map(genDocsImport).join('\n')}\n` + `const routes = [${docsData.map(genDocsRoute).join(',')},${pagesData .map(genPagesRoute) .join(',')}${notFoundRoute}\n];\n` + diff --git a/lib/theme/Loading.js b/lib/theme/Loading.js new file mode 100644 index 0000000000..c4af8d5525 --- /dev/null +++ b/lib/theme/Loading.js @@ -0,0 +1,15 @@ +import React from 'react'; + +export default props => { + if (props.error) { + return ( +
+ Error!{' '} + +
+ ); + } + return
Loading...
; +}; diff --git a/lib/webpack/base.js b/lib/webpack/base.js index db3eec401e..2e3b496294 100644 --- a/lib/webpack/base.js +++ b/lib/webpack/base.js @@ -4,7 +4,7 @@ const path = require('path'); const mdLoader = require.resolve('./loader/markdown'); -module.exports = function createBaseConfig(props) { +module.exports = function createBaseConfig(props, isServer) { const { siteConfig, outDir, @@ -34,6 +34,7 @@ module.exports = function createBaseConfig(props) { .set('@site', siteDir) .set('@docs', docsDir) .set('@pages', pagesDir) + .set('@build', outDir) .set('@generated', path.resolve(__dirname, '../core/generated')) .set('@core', path.resolve(__dirname, '../core')) .end(); @@ -44,7 +45,8 @@ module.exports = function createBaseConfig(props) { .loader('babel-loader') .options({ babelrc: false, - presets: ['env', 'react'] + presets: ['env', 'react'], + plugins: [isServer ? 'dynamic-import-node' : 'syntax-dynamic-import'] }); } diff --git a/lib/webpack/client.js b/lib/webpack/client.js new file mode 100644 index 0000000000..b7f21758be --- /dev/null +++ b/lib/webpack/client.js @@ -0,0 +1,20 @@ +const path = require('path'); +const webpackNiceLog = require('webpack-nicelog'); +const {StatsWriterPlugin} = require('webpack-stats-plugin'); +const createBaseConfig = require('./base'); + +module.exports = function createClientConfig(props) { + const config = createBaseConfig(props); + + config.entry('main').add(path.resolve(__dirname, '../core/clientEntry.js')); + + // write webpack stats object to a file so we can + // programmatically refer to the correct bundle path in Node.js server. + config + .plugin('stats') + .use(StatsWriterPlugin, [{filename: 'client.stats.json'}]); + + config.plugin('niceLog').use(webpackNiceLog, [{name: 'Client'}]); + + return config; +}; diff --git a/lib/webpack/dev.js b/lib/webpack/dev.js deleted file mode 100644 index dcdc3a6e51..0000000000 --- a/lib/webpack/dev.js +++ /dev/null @@ -1,24 +0,0 @@ -const path = require('path'); -const HtmlWebpackPlugin = require('html-webpack-plugin'); -const webpackNiceLog = require('webpack-nicelog'); -const createBaseConfig = require('./base'); - -module.exports = function createDevConfig(props) { - const config = createBaseConfig(props); - - config.entry('main').add(path.resolve(__dirname, '../core/devEntry.js')); - - const {siteConfig} = props; - config.plugin('html-webpack-plugin').use(HtmlWebpackPlugin, [ - { - inject: false, - hash: true, - template: path.resolve(__dirname, '../core/devTemplate.ejs'), - filename: 'index.html', - title: siteConfig.title - } - ]); - config.plugin('niceLog').use(webpackNiceLog, [{name: 'Development'}]); - - return config; -}; diff --git a/lib/webpack/prod.js b/lib/webpack/server.js similarity index 59% rename from lib/webpack/prod.js rename to lib/webpack/server.js index 506be0d81f..a562199727 100644 --- a/lib/webpack/prod.js +++ b/lib/webpack/server.js @@ -1,15 +1,16 @@ const path = require('path'); -const fs = require('fs'); -const ejs = require('ejs'); const staticSiteGenerator = require('static-site-generator-webpack-plugin'); const webpackNiceLog = require('webpack-nicelog'); const createBaseConfig = require('./base'); module.exports = function createProdConfig(props) { - const config = createBaseConfig(props); + const config = createBaseConfig(props, true); - config.entry('main').add(path.resolve(__dirname, '../core/prodEntry.js')); - config.output.libraryTarget('umd'); + config.entry('main').add(path.resolve(__dirname, '../core/serverEntry.js')); + + config.target('node'); + + config.output.filename('server.bundle.js').libraryTarget('commonjs2'); // Workaround for Webpack 4 Bug (https://github.com/webpack/webpack/issues/6522) config.output.globalObject('this'); @@ -18,25 +19,19 @@ module.exports = function createProdConfig(props) { // static site generator webpack plugin const paths = [...docsData, ...pagesData].map(data => data.path); - const template = ejs.compile( - fs.readFileSync( - path.resolve(__dirname, '../core/prodTemplate.ejs'), - 'utf-8' - ) - ); config.plugin('siteGenerator').use(staticSiteGenerator, [ { entry: 'main', locals: { - title: siteConfig.title || 'Munseo', - baseUrl: siteConfig.baseUrl, - template + baseUrl: siteConfig.baseUrl }, paths } ]); // show compilation progress bar and build time - config.plugin('niceLog').use(webpackNiceLog, [{name: 'Production'}]); + config + .plugin('niceLog') + .use(webpackNiceLog, [{name: 'Server', color: 'yellow'}]); return config; }; diff --git a/package.json b/package.json index 879cbcf08c..6b2a272a39 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "dependencies": { "babel-core": "^6.26.3", "babel-loader": "^7.1.5", + "babel-plugin-dynamic-import-node": "^2.0.0", + "babel-plugin-syntax-dynamic-import": "^6.18.0", "babel-preset-env": "^1.7.0", "babel-preset-react": "^6.24.1", "chalk": "^2.4.1", @@ -49,7 +51,6 @@ "commander": "^2.16.0", "connect-history-api-fallback": "^1.5.0", "css-loader": "^1.0.0", - "ejs": "^2.6.1", "front-matter": "^2.3.0", "fs-extra": "^7.0.0", "globby": "^8.0.1", @@ -65,6 +66,8 @@ "prismjs": "^1.15.0", "react": "^16.4.1", "react-dom": "^16.4.1", + "react-helmet": "^5.2.0", + "react-loadable": "^5.5.0", "react-router-config": "^1.0.0-beta.4", "react-router-dom": "^4.3.1", "remarkable": "^1.7.1", @@ -74,7 +77,8 @@ "webpack": "^4.16.3", "webpack-chain": "^4.8.0", "webpack-nicelog": "^2.2.1", - "webpack-serve": "^2.0.2" + "webpack-serve": "^2.0.2", + "webpack-stats-plugin": "^0.2.1" }, "engines": { "node": ">=8" diff --git a/website/pages/index.js b/website/pages/index.js index 2d89400073..5dd3022176 100644 --- a/website/pages/index.js +++ b/website/pages/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import TicTacToe from './tictactoe'; +import Helmet from 'react-helmet'; import {Link} from 'react-router-dom'; export default class Home extends React.Component { @@ -12,10 +12,9 @@ export default class Home extends React.Component { )); return (
+

Available Urls

    {routeLinks}
-

Play some TicTacToe

-
); } diff --git a/website/pages/tictactoe.js b/website/pages/tictactoe.js index b102b7d1fe..e9c93dd8a2 100644 --- a/website/pages/tictactoe.js +++ b/website/pages/tictactoe.js @@ -1,4 +1,5 @@ import React from 'react'; +import Helmet from 'react-helmet'; import style from './tictactoe.css'; function Square(props) { @@ -125,6 +126,7 @@ class Game extends React.Component { return (
+
this.handleClick(i)} />
diff --git a/yarn.lock b/yarn.lock index 9d3f1bf739..5492a2cc91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -723,6 +723,13 @@ babel-plugin-check-es2015-constants@^6.22.0: dependencies: babel-runtime "^6.22.0" +babel-plugin-dynamic-import-node@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.0.0.tgz#d6fc3f6c5e3bdc34e49c15faca7ce069755c0a57" + dependencies: + babel-plugin-syntax-dynamic-import "^6.18.0" + object.assign "^4.1.0" + babel-plugin-istanbul@^4.1.6: version "4.1.6" resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz#36c59b2192efce81c5b378321b74175add1c9a45" @@ -740,6 +747,10 @@ babel-plugin-syntax-async-functions@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95" +babel-plugin-syntax-dynamic-import@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz#8d6a26229c83745a9982a441051572caa179b1da" + babel-plugin-syntax-exponentiation-operator@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz#9ee7e8337290da95288201a6a57f4170317830de" @@ -1900,7 +1911,7 @@ decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" -deep-equal@~1.0.1: +deep-equal@^1.0.1, deep-equal@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" @@ -2136,10 +2147,6 @@ ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" -ejs@^2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.6.1.tgz#498ec0d495655abc6f23cd61868d926464071aa0" - electron-to-chromium@^1.3.47: version "1.3.52" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.52.tgz#d2d9f1270ba4a3b967b831c40ef71fb4d9ab5ce0" @@ -2490,6 +2497,10 @@ execa@^0.8.0: signal-exit "^3.0.0" strip-eof "^1.0.0" +exenv@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d" + exit@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" @@ -5401,7 +5412,7 @@ prompts@^0.1.9: kleur "^2.0.1" sisteransi "^0.1.1" -prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2: +prop-types@^15.5.0, prop-types@^15.5.4, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2: version "15.6.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102" dependencies: @@ -5547,6 +5558,21 @@ react-error-overlay@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-4.0.0.tgz#d198408a85b4070937a98667f500c832f86bd5d4" +react-helmet@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/react-helmet/-/react-helmet-5.2.0.tgz#a81811df21313a6d55c5f058c4aeba5d6f3d97a7" + dependencies: + deep-equal "^1.0.1" + object-assign "^4.1.1" + prop-types "^15.5.4" + react-side-effect "^1.1.0" + +react-loadable@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/react-loadable/-/react-loadable-5.5.0.tgz#582251679d3da86c32aae2c8e689c59f1196d8c4" + dependencies: + prop-types "^15.5.0" + react-router-config@^1.0.0-beta.4: version "1.0.0-beta.4" resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-1.0.0-beta.4.tgz#d202496dd0eabdf06cf24eb0793031f6891eef01" @@ -5574,6 +5600,13 @@ react-router@^4.3.1: prop-types "^15.6.1" warning "^4.0.1" +react-side-effect@^1.1.0: + version "1.1.5" + resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-1.1.5.tgz#f26059e50ed9c626d91d661b9f3c8bb38cd0ff2d" + dependencies: + exenv "^1.2.1" + shallowequal "^1.0.1" + react@^16.4.1: version "16.4.1" resolved "https://registry.yarnpkg.com/react/-/react-16.4.1.tgz#de51ba5764b5dbcd1f9079037b862bd26b82fe32" @@ -6055,6 +6088,10 @@ sha.js@^2.4.0, sha.js@^2.4.8: inherits "^2.0.1" safe-buffer "^5.0.1" +shallowequal@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -6970,6 +7007,10 @@ webpack-sources@^1.0.1, webpack-sources@^1.1.0: source-list-map "^2.0.0" source-map "~0.6.1" +webpack-stats-plugin@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/webpack-stats-plugin/-/webpack-stats-plugin-0.2.1.tgz#1f5bac13fc25d62cbb5fd0ff646757dc802b8595" + webpack@^4.16.3: version "4.16.3" resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.16.3.tgz#861be3176d81e7e3d71c66c8acc9bba35588b525"