react-static-webpack-plugin
Version:
Build full static sites using React, React Router and Webpack
437 lines (357 loc) • 15.3 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.addHash = exports.log = exports.renderSingleComponent = exports.isRoute = exports.getAssetKey = exports.getAllPaths = exports.getNestedPaths = exports.compileAsset = exports.prefix = exports.getExtraneousAssets = undefined;
var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
var _path = require('path');
var _path2 = _interopRequireDefault(_path);
var _fs = require('fs');
var _fs2 = _interopRequireDefault(_fs);
var _vm = require('vm');
var _vm2 = _interopRequireDefault(_vm);
var _flattenDeep = require('lodash/flattenDeep');
var _flattenDeep2 = _interopRequireDefault(_flattenDeep);
var _isString = require('lodash/isString');
var _isString2 = _interopRequireDefault(_isString);
var _react = require('react');
var _react2 = _interopRequireDefault(_react);
var _server = require('react-dom/server');
var _bluebird = require('bluebird');
var _bluebird2 = _interopRequireDefault(_bluebird);
var _SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin');
var _SingleEntryPlugin2 = _interopRequireDefault(_SingleEntryPlugin);
var _jsdom = require('jsdom');
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } }
var debug = require('debug')('react-static-webpack-plugin:utils');
var extraneousAssets = [];
// Allow other files to get access to the extraneous assets
var getExtraneousAssets = exports.getExtraneousAssets = function getExtraneousAssets() {
return extraneousAssets;
};
/**
* Adde a namespace/prefix to a filename so as to avoid naming conflicts with
* things the user has created.
*/
var prefix = exports.prefix = function prefix(name) {
return '__react-static-webpack-plugin__' + name;
};
/**
* Get all candidate absolute paths for the extract text plugin. If there are
* none that's fine. We use these paths to patch extract-text-wepback-plugin
* during compilation so that it doesn't throw a fit about loader-plugin
* interop.
*/
var getExtractTextPluginPaths = function getExtractTextPluginPaths(compilation) {
var _compilation$compiler = compilation.compiler,
options = _compilation$compiler.options,
context = _compilation$compiler.context;
var paths = new Set(); // Ensure unique paths
if (context) {
paths.add(_path2.default.resolve(context, './node_modules/extract-text-webpack-plugin'));
}
if (options && options.context) {
paths.add(_path2.default.resolve(options.context, './node_modules/extract-text-webpack-plugin'));
}
try {
if (options.resolve.modules && options.resolve.modules.length) {
options.resolve.modules.forEach(function (x) {
paths.add(_path2.default.resolve(x, './extract-text-webpack-plugin'));
});
}
} catch (err) {
debug('Error resolving options.resolve.modules');
}
try {
if (options.resolveLoader.modules && options.resolveLoader.modules.length) {
options.resolveLoader.modules.forEach(function (x) {
paths.add(_path2.default.resolve(x, './extract-text-webpack-plugin'));
});
}
} catch (err) {
debug('Error resolving options.resolveLoader.modules');
}
return Array.from(paths).filter(_fs2.default.existsSync);
};
/**
* Given the filepath of an asset (say js file) compile it and return the source
*/
var compileAsset = exports.compileAsset = function compileAsset(opts) {
var filepath = opts.filepath,
outputFilename = opts.outputFilename,
compilation = opts.compilation,
context = opts.context;
var compilerName = 'react-static-webpack compiling "' + filepath + '"';
var outputOptions = {
filename: outputFilename,
publicPath: compilation.outputOptions.publicPath
};
var rawAssets = {};
debug('Compilation context "' + context + '"');
debug('Compiling "' + _path2.default.resolve(context, filepath) + '"');
var childCompiler = compilation.createChildCompiler(compilerName, outputOptions);
childCompiler.apply(new _SingleEntryPlugin2.default(context, filepath));
// Patch extract text plugin
childCompiler.plugin('this-compilation', function (compilation) {
var extractTextPluginPaths = getExtractTextPluginPaths(compilation);
debug('this-compilation patching extractTextPluginPaths %O', extractTextPluginPaths);
// NOTE: This is taken directly from extract-text-webpack-plugin
// https://github.com/webpack/extract-text-webpack-plugin/blob/v1.0.1/loader.js#L62
//
// It seems that returning true allows the use of css modules while
// setting this equal to false makes the import of css modules fail, which
// means rendered pages do not have the correct classnames.
// loaderContext[x] = false;
compilation.plugin('normal-module-loader', function (loaderContext) {
extractTextPluginPaths.forEach(function (x) {
loaderContext[x] = function (content, opt) {
// See NOTE
return true;
};
});
});
/**
* In order to evaluate the raw compiled source files of assets instead of
* the minified or otherwise optimized version we hook into here and hold on
* to any chunk assets before they are compiled.
*
* NOTE: It is uncertain so far whether this source is actually the same as
* the unoptimized source of the file in question. I.e. it may not be the
* fullly compiled / bundled module code we want since this is not the emit
* callback.
*/
compilation.plugin('optimize-chunk-assets', function (chunks, cb) {
var files = [];
// Collect all asset names
chunks.forEach(function (chunk) {
chunk.files.forEach(function (file) {
return files.push(file);
});
});
compilation.additionalChunkAssets.forEach(function (file) {
return files.push(file);
});
rawAssets = files.reduce(function (agg, file) {
agg[file] = compilation.assets[file];
return agg;
}, {});
// Update the extraneous assets to remove
// TODO: This does not actually collect all the apropriate assets. What we
// want is EVERY file that was compiled during this compilation, since we
// don't want to output any of them. So far this only gets the associated
// js files, like routes.js (with prefix)
extraneousAssets = [].concat(_toConsumableArray(extraneousAssets), files);
cb();
});
});
// Run the compilation async and return a promise
// NOTE: For some reason, require simply doesn't work as expected in the
// evaluated string src code. This was meant to be a temporary fix to fix the
// issue of requiring node-uuid. It would be better to find a way to fully
// support any module code. Specifically, code like this failse because the
// require function simply does not return the built in crypto module:
// https://github.com/crypto-browserify/crypto-browserify/blob/v3.2.6/rng.js
return new _bluebird2.default(function (resolve, reject) {
childCompiler.runAsChild(function (err, entries, childCompilation) {
// Resolve / reject the promise
if (childCompilation.errors && childCompilation.errors.length) {
var errorDetails = childCompilation.errors.map(function (err) {
return err.message + (err.error ? ':\n' + err.error : '');
}).join('\n');
reject(new Error('Child compilation failed:\n' + errorDetails));
} else {
var asset = compilation.assets[outputFilename];
// See 'optimize-chunk-assets' above
if (rawAssets[outputFilename]) {
debug('Using raw source for ' + filepath);
asset = rawAssets[outputFilename];
}
resolve(asset);
}
});
}).then(function (asset) {
if (asset instanceof Error) {
debug(filepath + ' failed to copmile. Rejecting...');
return _bluebird2.default.reject(asset);
}
debug(filepath + ' compiled. Processing source...');
// Instantiate browser sandbox
var doc = (0, _jsdom.jsdom)('<html><body></body></html>');
var win = doc.defaultView;
// Pre-compile asset source
var script = new _vm2.default.Script(asset.source(), {
filename: filepath,
displayErrors: true
});
// Run it in the JSDOM context
return (0, _jsdom.evalVMScript)(win, script);
}).catch(function (err) {
debug(filepath + ' failed to process. Rejecting...');
return _bluebird2.default.reject(err);
});
};
// can be an React Element or a POJO
/**
* NOTE: We could likely use createRoutes to our advantage here. It may simplify
* the code we currently use to recurse over the virtual dom tree:
*
* import { createRoutes } from 'react-router';
* console.log(createRoutes(routes)); =>
* [ { path: '/',
* component: [Function: Layout],
* childRoutes: [ [Object], [Object] ] } ]
*
* Ex:
* const routes = (
* <Route component={App} path='/'>
* <Route component={About} path='/about' />
* </Route>
* );
*
* getAllPaths(routes); => ['/', '/about]
*/
var getNestedPaths = exports.getNestedPaths = function getNestedPaths(route) {
var prefix = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : '';
if (!route) return [];
if (Array.isArray(route)) return route.map(function (x) {
return getNestedPaths(x, prefix);
});
var path = route.props && route.props.path || route.path;
// Some routes such as redirects or index routes do not have a path. Skip
// them.
if (!path) return [];
path = prefix + path;
var nextPrefix = path === '/' ? path : path + '/';
var childRoutes = route.props && route.props.children || route.childRoutes;
return [path].concat(_toConsumableArray(getNestedPaths(childRoutes, nextPrefix)));
};
var getAllPaths = exports.getAllPaths = function getAllPaths(routes) {
return (0, _flattenDeep2.default)(getNestedPaths(routes));
};
/**
* 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
*/
var getAssetKey = exports.getAssetKey = function getAssetKey(location) {
var basename = _path2.default.basename(location);
var dirname = _path2.default.dirname(location).slice(1); // See NOTE above
var filename = void 0;
if (location.slice(-1) === '/') {
filename = !basename ? 'index.html' : basename + '/index.html';
} else if (basename === '*') {
filename = '404.html';
} else {
filename = basename + '.html';
}
var result = dirname ? dirname + _path2.default.sep + filename : filename;
debug('Getting asset key: "' + location + '" -> "' + result + '"');
return result;
};
/**
* 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 = exports.isRoute = function isRoute(_ref) {
var component = _ref.type;
return component && component.propTypes.path && component.propTypes.component;
};
/**
* 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 = exports.renderSingleComponent = function renderSingleComponent(imported, options, render, store) {
var Component = imported.default || imported;
var body = void 0;
var component = _react2.default.createElement(Component, null);
// Wrap the component in a Provider if the user passed us a redux store
if (store) {
debug('Store provider. Rendering single component within Provider.');
try {
var _require = require('react-redux'),
Provider = _require.Provider;
component = _react2.default.createElement(
Provider,
{ store: store },
_react2.default.createElement(Component, null)
);
// Make sure initialState will be provided to the template. Don't mutate
// options directly
options = _extends({}, options, { initialState: store.getState() }); // eslint-disable-line no-param-reassign
} catch (err) {
err.message = 'Could not require react-redux. Did you forget to install it?\n' + err.message;
throw err;
}
}
try {
debug('Rendering single component.');
body = (0, _server.renderToString)(component);
} catch (err) {
throw new Error('Invalid single component. Make sure you added your component as the default export from ' + options.routes);
}
var doc = render(_extends({}, options, {
title: Component.title, // See NOTE
body: body
}));
return {
source: function source() {
return doc;
},
size: function size() {
return doc.length;
}
};
};
/**
* Only log in prod and dev. I.e. do not log on CI. The reason for logging
* during prod is that we usually only build our bundles with
* NODE_ENV=production prefixing the build command.
*/
var log = exports.log = function log() {
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'production') {
var _console;
(_console = console).log.apply(_console, arguments);
}
};
/**
* Add hash to all options that includes '[hash]' ex: bundle.[hash].js
* NOTE: Only one hash for all files. So even if the css did not change it will get a new hash if the js changed.
*
* @param {Options} options
* @param {string} hash
*/
var addHash = exports.addHash = function addHash(options, hash) {
return Object.keys(options).reduce(function (previous, current) {
if (!(0, _isString2.default)(options[current])) {
previous[current] = options[current];
return previous;
}
previous[current] = options[current] && hash ? options[current].replace('[hash]', hash) : options[current];
return previous;
}, {});
};