react-static-webpack-plugin
Version:
Build full static sites using React, React Router and Webpack
255 lines (205 loc) • 8.37 kB
JavaScript
'use strict';
var _path = require('path');
var _path2 = _interopRequireDefault(_path);
var _react = require('react');
var _react2 = _interopRequireDefault(_react);
var _server = require('react-dom/server');
var _server2 = _interopRequireDefault(_server);
var _eval = require('eval');
var _eval2 = _interopRequireDefault(_eval);
var _reactRouter = require('react-router');
var _async = require('async');
var _async2 = _interopRequireDefault(_async);
var _debug = require('debug');
var _debug2 = _interopRequireDefault(_debug);
var _package = require('../package.json');
var _utils = require('./utils.js');
var _Html = require('./Html.js');
var _Html2 = _interopRequireDefault(_Html);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function _typeof(obj) { return obj && typeof Symbol !== "undefined" && obj.constructor === Symbol ? "symbol" : typeof obj; } /* eslint-disable no-use-before-define, func-names */
var log = (0, _debug2.default)(_package.name);
/**
* We are running in this host system's node env. So all node goes but be aware
* of the version number if using ES6. Of coures this module itself could be
* compiled with babel.
*
* Usage:
*
* new StaticSitePlugin({ src: 'client/routes.js', ...options }),
*
*/
function StaticSitePlugin(options) {
this.options = options;
}
/**
* compiler seems to be an instance of the Compiler
* https://github.com/webpack/webpack/blob/master/lib/Compiler.js#L143
*
* NOTE: renderProps.routes is always passed as an array of route elements. For
* deeply nested routes the last element in the array is the route we want to
* get the props of, so we grab it out of the array and use it. This lets us do
* things like:
*
* <Route title='Blah blah blah...' {...moreProps} />
*
* Then set the document title to the title defined on that route
*
* TODO:
* - Allow defining a custom JSX template instead of the built-in Html.js
* - Allow passing a function for title?
*
*/
StaticSitePlugin.prototype.apply = function (compiler) {
var _this = this;
compiler.plugin('emit', function (compilation, cb) {
var asset = findAsset(_this.options.src, compilation);
if (!asset) throw new Error('Output file not found: ' + _this.options.src);
var source = (0, _eval2.default)(asset.source());
var Component = source.routes || source;
log('src evaluated to Component:', Component);
// NOTE: If Symbol(react.element) was removed this would no longer work
if (!isValidComponent(Component)) {
log('Component was invalid. Throwing error.');
throw new Error(_package.name + ' -- options.src entry point must export a valid React Component.');
}
if (!isRoute(Component)) {
log('Entrypoint or chunk name did not return a Route component. Rendering as individual component instead.');
compilation.assets['index.html'] = renderSingleComponent(Component, _this.options);
return cb();
}
var paths = (0, _utils.getAllPaths)(Component);
log('Parsed routes:', paths);
_async2.default.forEach(paths, function (location, callback) {
(0, _reactRouter.match)({ routes: Component, location: location }, function (error, redirectLocation, renderProps) {
var route = renderProps.routes[renderProps.routes.length - 1]; // See NOTE
var body = _server2.default.renderToString(_react2.default.createElement(_reactRouter.RoutingContext, renderProps));
var _options = _this.options;
var stylesheet = _options.stylesheet;
var favicon = _options.favicon;
var assetKey = getAssetKey(location);
var doc = _Html2.default.renderToDocumentString({
title: route.title,
body: body,
stylesheet: stylesheet,
favicon: favicon
});
compilation.assets[assetKey] = {
source: function source() {
return doc;
},
size: function size() {
return doc.length;
}
};
callback();
});
}, function (err) {
if (err) throw err;
cb();
});
});
};
/**
* @param {string} src
* @param {Compilation} compilation
*/
var findAsset = function findAsset(src, compilation) {
var asset = compilation.assets[src];
// Found it. It was a key within assets
if (asset) return asset;
// Didn't find it in assets, it must be a chunk
var webpackStatsJson = compilation.getStats().toJson();
var chunkValue = webpackStatsJson.assetsByChunkName[src];
// Uh oh, couldn't find it as a chunk value either. This indicates a failure
// to find the asset. The caller should handle a falsey value as it sees fit.
if (!chunkValue) return null;
// Webpack outputs an array for each chunk when using sourcemaps
if (chunkValue instanceof Array) chunkValue = chunkValue[0]; // Is the main bundle always the first element?
return compilation.assets[chunkValue];
};
/**
* Given a string location (i.e. path) return a relevant HTML filename.
* Ex: '/' -> 'index.html'
* Ex: '/about' -> 'about.html'
* Ex: '/about/' -> 'about/index.html'
* Ex: '/about/team' -> 'about/team.html'
*
* NOTE: Don't want leading slash
* i.e. 'path/to/file.html' instead of '/path/to/file.html'
*
* NOTE: There is a lone case where the users specifices a not found route that
* results in a '/*' location. In this case we output 404.html, since it's
* assumed that this is a 404 route. See the RR changelong for details:
* https://github.com/rackt/react-router/blob/1.0.x/CHANGES.md#notfound-route
*
* @param {string} location
* @return {string} relative path to output file
*/
var getAssetKey = function getAssetKey(location) {
var basename = _path2.default.basename(location);
var dirname = _path2.default.dirname(location).slice(1); // See NOTE above
var filename = undefined;
if (!basename || location.slice(-1) === '/') filename = 'index.html';else if (basename === '*') filename = '404.html';else filename = basename + '.html';
return dirname ? dirname + _path2.default.sep + filename : filename;
};
/**
* Test if a React Element is a React Router Route or not. Note that this tests
* the actual object (i.e. React Element), not a constructor. As such we
* immediately deconstruct out the type property as that is what we want to
* test.
*
* NOTE: Testing whether Component.type was an instanceof Route did not work.
*
* NOTE: This is a fragile test. The React Router API is notorious for
* introducing breaking changes, so of the RR team changed the manditory path
* and component props this would fail.
*/
var isRoute = function isRoute(_ref) {
var component = _ref.type;
return component && component.propTypes.path && component.propTypes.component;
};
/**
* Test if a component is a valid React component.
*
* NOTE: This is a pretty wonky test. React.createElement wasn't doing it for
* me. It seemed to be giving false positives.
*
* @param {any} component
* @return {boolean}
*/
var isValidComponent = function isValidComponent(Component) {
var _React$createElement = _react2.default.createElement(Component);
var type = _React$createElement.type;
return (typeof type === 'undefined' ? 'undefined' : _typeof(type)) === 'object' || typeof type === 'function';
};
/**
* If not provided with any React Router Routes we try to render whatever asset
* was passed to the plugin as a React component. The use case would be anyone
* who doesn't need/want to add RR as a dependency to their app and instead only
* wants a single HTML file output.
*
* NOTE: In the case of a single component we use the static prop 'title' to get
* the page title. If this is not provided then the title will default to
* whatever is provided in the template.
*/
var renderSingleComponent = function renderSingleComponent(Component, options) {
var body = _server2.default.renderToString(_react2.default.createElement(Component, null));
var stylesheet = options.stylesheet;
var favicon = options.favicon;
var doc = _Html2.default.renderToDocumentString({
title: Component.title, // See NOTE
body: body,
stylesheet: stylesheet,
favicon: favicon
});
return {
source: function source() {
return doc;
},
size: function size() {
return doc.length;
}
};
};
module.exports = StaticSitePlugin;