@enact/dev-utils
Version:
A collection of development utilities for Enact apps.
504 lines (468 loc) • 18.3 kB
JavaScript
const fs = require('fs');
const path = require('path');
const chalk = require('chalk');
const templates = require('./templates');
const vdomServer = require('./vdom-server-render');
function PrerenderPlugin(options = {}) {
this.options = options;
this.options.chunk = this.options.chunk || 'main.js';
if (!this.options.chunk.endsWith('.js')) this.options.chunk += '.js';
if (this.options.locales === undefined) this.options.locales = 'en-US';
if (this.options.mapfile === undefined || this.options.mapfile === true) this.options.mapfile = 'locale-map.json';
// eslint-disable-next-line
if (!this.options.server) this.options.server = require('react-dom/server');
}
PrerenderPlugin.prototype.apply = function(compiler) {
const opts = this.options;
const status = {prerender: [], attr: [], alias: []};
let locales;
compiler.plugin('compilation', compilation => {
const appInfoOptimize = {groups: {}, coverage: []};
let jsAssets = [];
// Do nothing when run in virtual or custom output FS
if (!isNodeOutputFS(compiler)) return;
// Determine the target locales and load up the startup scripts.
locales = parseLocales(compiler.options.context, opts.locales);
// Ensure that any async chunk-loading jsonp functions are isomorphically compatible.
compilation.mainTemplate.plugin('bootstrap', source => {
return source.replace(/window/g, '(function() { return this; }())');
});
// Prerender each locale desired and output an error on failure.
compilation.plugin('chunk-asset', (chunk, file) => {
if (file === opts.chunk) {
compilation.applyPlugins('prerender-chunk', {chunk: opts.chunk, locales: locales});
vdomServer.stage(compilation.assets[opts.chunk].source(), opts);
for (let i = 0; i < locales.length; i++) {
try {
// Prerender the locale.
const renderOpts = {
server: opts.server,
locale: locales[i],
externals: opts.externals,
fontGenerator: opts.fontGenerator
};
compilation.applyPlugins('prerender-locale', {
chunk: opts.chunk,
opts: renderOpts
});
let appHtml = vdomServer.render(renderOpts);
// Extract the root CSS classes and react checksum from the prerendered html code.
status.attr[i] = {};
appHtml = appHtml
.replace(
/(<div[^>]*class="((?!enact-locale-)[^"])*)(\senact-locale-[^"]*)"/i,
(match, before, s, classAttr) => {
status.attr[i].classes = classAttr;
return before + '"';
}
)
.replace(/(<div[^>]*data-react-checksum=")([^"]*)"/i, (match, before, checksum) => {
status.attr[i].checksum = checksum;
return before + '"';
});
// Dedupe the sanitized html code and alias as needed
const index = status.prerender.indexOf(appHtml);
if (index === -1) {
status.prerender[i] = appHtml;
} else {
status.alias[i] = locales[index];
}
} catch (e) {
status.err = {locale: locales[i], result: e};
break;
}
}
if (!status.err) {
vdomServer.unstage();
// Simplify out aliases and group together for minimal file output.
simplifyAliases(locales, status);
}
}
});
// For any target locales that don't already have appinfo files, dynamically generate new ones.
compilation.plugin('webos-meta-list-localized', locList => {
// No need to process localized appinfo files on error or a singular locale.
if (!status.err && locales.length > 1) {
for (let i = 0; i < locales.length; i++) {
if (locales[i].indexOf('multi') !== 0 && !/\.\d+$/.test(locales[i])) {
// Handle each locale that isn't a multi-language group item
const lang = language(locales[i]);
let appInfo = path.join('resources', locales[i].replace(/-/g, path.sep), 'appinfo.json');
if (status.alias[i] && status.alias[i].indexOf('multi') === 0) {
// Locale is part of a multi-language grouping.
if (
locales.indexOf(lang) >= 0 ||
(appInfoOptimize.groups[lang] && appInfoOptimize.groups[lang] !== status.alias[i])
) {
// Parent language entry already exists, or the appinfo optimization group for this language points
// to a different alias, so we can't simplify any further.
if (locList.indexOf(appInfo) === -1) {
// Add full locale appinfo entry if not already there.
locList.push({generate: appInfo});
}
} else if (!appInfoOptimize.groups[lang]) {
// No parent language and no existing appinfo optimization group for this language, so let's
// create one and simplify the output for the locale.
appInfoOptimize.groups[lang] = status.alias[i];
appInfoOptimize.coverage.push(locales[i]);
appInfo = path.join('resources', lang, 'appinfo.json');
if (locList.indexOf(appInfo) === -1) {
locList.push({generate: appInfo});
}
}
} else if (status.alias[i] !== lang && locList.indexOf(appInfo) === -1) {
// Not aliased, or not aliased to parent language so create appinfo if it does not exist.
locList.push({generate: appInfo});
}
}
}
}
return locList;
});
// Update any root appinfo to tag as using prerendering to avoid webOS splash screen.
// Temporary root value used until webOS parsing of localized appinfo.json boolean values is fixed.
compilation.plugin('webos-meta-root-appinfo', meta => {
if (typeof meta.usePrerendering === 'undefined' && locales.length > 0) {
meta.usePrerendering = true;
}
return meta;
});
// For each prerendered target locale's appinfo, update the 'main' value.
compilation.plugin('webos-meta-localized-appinfo', (meta, info) => {
let loc = info.locale;
// Exclude appinfo entries covered by appinfo optimization groups.
if (appInfoOptimize.coverage.indexOf(loc) === -1) {
const index = locales.indexOf(loc);
if (index === -1) {
// When not found in our target list, fallback to our appinfo optimization groups.
loc = appInfoOptimize.groups[loc];
} else if (index >= 0 && status.alias[index]) {
// Resolve any locale aliases.
loc = status.alias[index];
}
if (loc) {
meta.main = 'index.' + loc + '.html';
}
}
return meta;
});
// Force HtmlWebpackPlugin to use body inject format and set aside the js assets.
compilation.plugin('html-webpack-plugin-before-html-processing', (htmlPluginData, callback) => {
htmlPluginData.plugin.options.inject = 'body';
jsAssets = htmlPluginData.assets.js;
htmlPluginData.assets.js = [];
callback(null, htmlPluginData);
});
// Use the prerendered-startup.js to asynchronously add the js assets at load time and embed that
// script inline in the HTML head.
compilation.plugin('html-webpack-plugin-alter-asset-tags', (htmlPluginData, callback) => {
htmlPluginData.head.unshift({
tagName: 'script',
closeTag: true,
attributes: {
type: 'text/javascript'
},
innerHTML: templates.startup(opts.screenTypes, jsAssets)
});
callback(null, htmlPluginData);
});
// Inject prerendered static HTML
compilation.plugin('html-webpack-plugin-after-html-processing', (htmlPluginData, callback) => {
const applyToRoot = rootInjection(htmlPluginData.html);
Promise.all(
locales.map((loc, i) => {
const linked = Object.keys(status.alias).filter(key => status.alias[key] === loc);
const body = [];
let mapping;
if (status.err || !status.prerender[i] || status.alias[i]) return;
if (linked.length === 0) {
// Single locale, re-inject root classes and react checksum.
status.prerender[i] = status.prerender[i]
.replace(/(<div[^>]*class="[^"]*)"/i, '$1' + status.attr[i].classes + '"')
.replace(/(<div[^>]*data-react-checksum=")"/i, '$1' + status.attr[i].checksum + '"');
} else {
// Create a mapping of locales and classes
mapping = linked.reduce(
(m, c) => Object.assign(m, {[locales[c].toLowerCase()]: status.attr[c]}),
{}
);
}
// Handle updating of locales for multi-locale prerenders, along with deeplinking.
const appHtml = parsePrerender(status.prerender[i]);
const updater = templates.update(mapping, opts.deep, appHtml.prerender);
if (opts.deep) appHtml.prerender = '';
if (updater) {
body.push({
tagName: 'script',
closeTag: true,
attributes: {
type: 'text/javascript'
},
innerHTML: updater
});
}
// Inject app HTML then re-process in HtmlWebpackPlugin for potential minification.
htmlPluginData.plugin.options.inject = true;
return htmlPluginData.plugin
.postProcessHtml(applyToRoot(appHtml.prerender), {}, {head: appHtml.head, body: body})
.then(html => {
if (locales.length === 1) {
// Only 1 locale, so just output as the default root index.html
htmlPluginData.html = html;
} else {
// Multiple locales, so output as locale-specific html file.
emitAsset(compilation, 'index.' + loc + '.html', html);
}
});
})
)
.then(() => {
callback(null, htmlPluginData);
})
.catch(err => {
// Avoid misattribution of error to html plugin compiler by assigning error directly.
compilation.errors.push(err);
callback(null, htmlPluginData);
});
});
});
// Report any failed locale prerenders at the compiler level to fail the build,
// otherwise generate optional locale map asset.
compiler.plugin('after-compile', (compilation, callback) => {
if (status.err) {
// @TODO: pretty-print error details
let message =
chalk.red(chalk.bold('Unable to generate prerender of app state HTML for ' + status.err.locale + ':')) +
'\n';
message += status.err.result.stack || status.err.result.message || status.err.result;
callback(new Error(message));
} else {
// Generate a JSON file that maps the locales to their HTML files.
if (opts.mapfile && locales.length > 1 && isNodeOutputFS(compiler)) {
const mapper = (m, c, i) =>
status.alias.includes(c) ? m : Object.assign(m, {[c]: `index.${status.alias[i] || c}.html`});
const mapping = {fallback: 'index.html', locales: locales.reduce(mapper, {})};
let out = 'locale-map.json';
if (typeof opts.mapfile === 'string') {
out = opts.mapfile;
}
emitAsset(compilation, out, JSON.stringify(mapping, null, '\t'));
}
callback();
}
});
};
// Determine if it's a NodeJS output filesystem or if it's a foreign/virtual one.
function isNodeOutputFS(compiler) {
return (
compiler.outputFileSystem &&
compiler.outputFileSystem.constructor &&
compiler.outputFileSystem.constructor.name &&
compiler.outputFileSystem.constructor.name === 'NodeOutputFileSystem'
);
}
// Determine the desired target locales based of option content.
// Can be a preset like 'tv' or 'signage', 'used' for all used app-level locales, 'all' for
// all locales supported by ilib, a custom json file input, or a comma-separated lists
function parseLocales(context, target) {
if (!target || target === 'none') {
return [];
} else if (Array.isArray(target)) {
return target;
} else if (target === 'tv') {
return JSON.parse(fs.readFileSync(path.join(__dirname, 'locales-tv.json'), {encoding: 'utf8'})).locales;
} else if (target === 'signage') {
return JSON.parse(fs.readFileSync(path.join(__dirname, 'locales-signage.json'), {encoding: 'utf8'})).locales;
} else if (target === 'used') {
return detectLocales(path.join(context, 'resources', 'ilibmanifest.json'));
} else if (target === 'all') {
return detectLocales(path.join('node_modules', '@enact', 'i18n', 'ilib', 'locale', 'ilibmanifest.json'), true);
} else if (/\.json$/i.test(target)) {
return JSON.parse(fs.readFileSync(target, {encoding: 'utf8'})).locales;
} else {
return target.split(/\s*[\n,]\s*/).filter(Boolean);
}
}
// Scan an ilib manifest and detect all locales that it uses.
function detectLocales(manifest, deepestOnly) {
try {
const meta = JSON.parse(fs.readFileSync(manifest, {encoding: 'utf8'}));
const locales = [];
let curr, currLocale;
for (let i = 0; meta.files && i < meta.files.length; i++) {
curr = path.dirname(meta.files[i]);
currLocale = curr.replace(/[\\/]+/, '-');
if (locales.indexOf(curr) === -1 && /^([a-z]{2})\b/.test(currLocale)) {
if (deepestOnly) {
// Remove any matches of parent directories.
for (let x = curr; x.indexOf('/') !== -1 || x.indexOf('\\') !== -1; x = path.dirname(x)) {
const index = locales.indexOf(x.replace(/[\\/]+/, '-'));
if (index >= 0) {
locales.splice(index, 1);
}
}
// Only add the entry if children aren't already in the list.
let childFound = false;
for (let k = 0; k < locales.length && !childFound; k++) {
childFound = locales[k].indexOf(currLocale) === 0;
}
if (!childFound) {
locales.push(currLocale);
}
} else {
locales.push(currLocale);
}
}
}
locales.sort((a, b) => a.split('-').length > b.split('-').length);
return locales;
} catch (e) {
return [];
}
}
// Simplifies and groups the locales and aliases to ensure minimal output needed.
function simplifyAliases(locales, status) {
const links = {};
const sharedCSS = {};
const common = (a, b) => a.filter(b.includes.bind(b));
const remove = (targets, classes) =>
classes
.split(/\s+/)
.filter(c => !targets.includes(c))
.join(' ');
let multiCount = 1;
// First pass: simplify alias names to language designations or 'multi' for multi-language groupings.
// Additionally determines all shared root CSS classes for the groupings.
for (let i = 0; i < status.alias.length; i++) {
if (status.alias[i]) {
const lang = language(locales[i]);
if (!links[status.alias[i]]) {
const alias = language(status.alias[i]);
let regionCount = 0;
for (const x in links) {
if (links[x] === alias || links[x].indexOf(alias + '.') === 0) {
regionCount++;
}
}
links[status.alias[i]] = regionCount > 0 ? alias + '.' + (regionCount + 1) : alias;
}
if (links[status.alias[i]].indexOf(lang) !== 0 && links[status.alias[i]].indexOf('multi') !== 0) {
if (multiCount > 1) {
links[status.alias[i]] = 'multi.' + multiCount;
} else {
links[status.alias[i]] = 'multi';
}
multiCount++;
}
status.attr[i].classes = status.attr[i].classes || '';
if (!sharedCSS[status.alias[i]]) {
sharedCSS[status.alias[i]] = common(
status.attr[i].classes.split(/\s+/),
status.attr[locales.indexOf(status.alias[i])].classes.split(/\s+/)
);
} else {
sharedCSS[status.alias[i]] = common(sharedCSS[status.alias[i]], status.attr[i].classes.split(/\s+/));
}
}
}
// Second pass: with the shared root CSS classes determined, remove from the individual class strings
// and update the alias names to the new simplified names.
for (let j = 0; j < status.alias.length; j++) {
if (status.alias[j]) {
if (sharedCSS[status.alias[j]]) {
status.attr[j].classes = remove(sharedCSS[status.alias[j]], status.attr[j].classes);
}
if (links[status.alias[j]]) {
status.alias[j] = links[status.alias[j]];
}
}
}
// For every grouping processed, create new faux-locale entries to generate html files for, and
// re-insert the common root CSS classes back into the shared prerendered html code.
for (const l in links) {
const index = locales.indexOf(l);
status.alias[index] = links[l];
status.attr[index].classes = remove(sharedCSS[l], status.attr[index].classes);
locales.push(links[l]);
if (sharedCSS[l] && sharedCSS[l].length > 0) {
status.prerender[locales.length - 1] = status.prerender[index].replace(
/(<div[^>]*class="[^"]*)"/i,
'$1 ' + sharedCSS[l].join(' ') + '"'
);
} else {
status.prerender[locales.length - 1] = status.prerender[index];
}
status.prerender[index] = undefined;
}
}
// Extracts a valid language string from a locale.
function language(locale) {
const matchLang = locale.match(/\b([a-z]{2})\b/);
return matchLang && matchLang[1];
}
function rootInjection(html) {
const rootDiv = findRootDiv(html);
return function(prerender) {
if (rootDiv) {
return rootDiv.before + '<div id="root">' + prerender + '</div>' + rootDiv.after;
} else {
throw new Error(
'PrerenderPlugin: Unable find root div element. Please ' + 'verify it exists within your HTML template.'
);
}
};
}
// Find the location of the root div (can be empty or with contents) and return the
// contents of the HTML before and after it.
function findRootDiv(html, start, end) {
if (/^<div[^>]+id="root"/i.test(html.substring(start, end + 7))) {
return {before: html.substring(0, start), after: html.substring(end + 6)};
}
const a = html.indexOf('<div', start + 4);
const b = html.lastIndexOf('</div>', end);
if (a >= 0 && b >= 0 && a < b) {
return findRootDiv(html, a, b);
}
}
// Parse the prerendered HTML to extract any header elements
function parsePrerender(html) {
const elementParse = /<([^/][^>]*)\/*>([^<]*)/g;
const head = [];
const prerender = html.replace(/<!-- head append start -->([\s\S]*)<!-- head append end -->/, (m, content) => {
let match;
while ((match = elementParse.exec(content))) {
const tokens = match[1].split(/\s+/);
head.push({
tagName: tokens.shift(),
closeTag: !match[1].endsWith('/'),
attributes: tokens.reduce((result, curr) => {
const [, key, value] = curr.replace(/[/]$/, '').match(/^([^=]*)(?:="(.*)")*/);
return Object.assign(result, {[key]: value !== undefined ? value : 'true'});
}, {}),
innerHTML: match[1].endsWith('/')
? ''
: '\n\t\t' + match[2].replace(/^\s+|\s+$/g, '').replace(/\n/g, '\n\t\t') + '\n\t'
});
}
return '';
});
return {head, prerender};
}
// Adds a file entry with data to be emitted as an asset.
function emitAsset(compilation, file, data) {
compilation.assets[file] = {
size: function() {
return data.length;
},
source: function() {
return data;
},
updateHash: function(hash) {
return hash.update(data);
},
map: function() {
return null;
}
};
}
module.exports = PrerenderPlugin;