Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

core(i18n): localize strings at end of run #5655

Merged
merged 16 commits into from
Jul 23, 2018
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,9 @@ const UIStrings = {
description: 'Resources are blocking the first paint of your page. Consider ' +
'delivering critical JS/CSS inline and deferring all non-critical ' +
'JS/styles. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/blocking-resources).',
displayValue: `{itemCount, plural,
one {1 resource}
other {# resources}
} delayed first paint by {timeInMs, number, milliseconds} ms`,
};

const str_ = i18n.createStringFormatter(__filename, UIStrings);
const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);

/**
* Given a simulation's nodeTimings, return an object with the nodes/timing keyed by network URL
Expand Down Expand Up @@ -210,14 +206,14 @@ class RenderBlockingResources extends Audit {

let displayValue = '';
if (results.length > 0) {
displayValue = str_(UIStrings.displayValue, {timeInMs: wastedMs, itemCount: results.length});
displayValue = str_(i18n.UIStrings.displayValueWastedMs, {wastedMs});
}

/** @type {LH.Result.Audit.OpportunityDetails['headings']} */
const headings = [
{key: 'url', valueType: 'url', label: str_(i18n.UIStrings.columnURL)},
{key: 'totalBytes', valueType: 'bytes', label: str_(i18n.UIStrings.columnSize)},
{key: 'wastedMs', valueType: 'timespanMs', label: str_(i18n.UIStrings.columnWastedTime)},
{key: 'wastedMs', valueType: 'timespanMs', label: str_(i18n.UIStrings.columnWastedMs)},
];

const details = Audit.makeOpportunityDetails(headings, results, wastedMs);
Expand Down
2 changes: 1 addition & 1 deletion lighthouse-core/audits/metrics/interactive.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const UIStrings = {
'[Learn more](https://developers.google.com/web/tools/lighthouse/audits/consistently-interactive).',
};

const str_ = i18n.createStringFormatter(__filename, UIStrings);
const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);

/**
* @fileoverview This audit identifies the time the page is "consistently interactive".
Expand Down
32 changes: 25 additions & 7 deletions lighthouse-core/config/default-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,21 @@
*/
'use strict';

/* eslint-disable max-len */

const constants = require('./constants');
const i18n = require('../lib/i18n');

/* eslint-disable max-len */
const UIStrings = {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW @brendankenny I think these are the only strings we really need for performance section, so it's relatively small

if you want to be noodling on a much better config solution before we start on the other categories, nows a good time :)

performanceCategoryTitle: 'Performance',
metricGroupTitle: 'Metrics',
loadOpportunitiesGroupTitle: 'Opportunities',
loadOpportunitiesGroupDescription: 'These are opportunities to speed up your application by optimizing the following resources.',
diagnosticsGroupTitle: 'Diagnostics',
diagnosticsGroupDescription: 'More information about the performance of your application.',
};

const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);

module.exports = {
settings: constants.defaultSettings,
Expand Down Expand Up @@ -188,15 +200,15 @@ module.exports = {

groups: {
'metrics': {
title: 'Metrics',
title: str_(UIStrings.metricGroupTitle),
},
'load-opportunities': {
title: 'Opportunities',
description: 'These are opportunities to speed up your application by optimizing the following resources.',
title: str_(UIStrings.loadOpportunitiesGroupTitle),
description: str_(UIStrings.loadOpportunitiesGroupDescription),
},
'diagnostics': {
title: 'Diagnostics',
description: 'More information about the performance of your application.',
title: str_(UIStrings.diagnosticsGroupTitle),
description: str_(UIStrings.diagnosticsGroupDescription),
},
'a11y-color-contrast': {
title: 'Color Contrast Is Satisfactory',
Expand Down Expand Up @@ -246,7 +258,7 @@ module.exports = {
},
categories: {
'performance': {
title: 'Performance',
title: str_(UIStrings.performanceCategoryTitle),
auditRefs: [
{id: 'first-contentful-paint', weight: 3, group: 'metrics'},
{id: 'first-meaningful-paint', weight: 1, group: 'metrics'},
Expand Down Expand Up @@ -409,3 +421,9 @@ module.exports = {
},
},
};

// Use `defineProperty` so that the strings are accesible from original but ignored when we copy it
Object.defineProperty(module.exports, 'UIStrings', {
enumerable: false,
get: () => UIStrings,
});
2 changes: 0 additions & 2 deletions lighthouse-core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
const Runner = require('./runner');
const log = require('lighthouse-logger');
const ChromeProtocol = require('./gather/connections/cri.js');
const i18n = require('./lib/i18n');
const Config = require('./config/config');

/*
Expand All @@ -35,7 +34,6 @@ const Config = require('./config/config');
async function lighthouse(url, flags, configJSON) {
// TODO(bckenny): figure out Flags types.
flags = flags || /** @type {LH.Flags} */ ({});
i18n.setLocale(flags.locale);

// set logging preferences, assume quiet
flags.logLevel = flags.logLevel || 'error';
Expand Down
233 changes: 177 additions & 56 deletions lighthouse-core/lib/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,39 @@
'use strict';

const path = require('path');
const isDeepEqual = require('lodash.isequal');
const log = require('lighthouse-logger');
const MessageFormat = require('intl-messageformat').default;
const MessageParser = require('intl-messageformat-parser');
const LOCALES = require('./locales');

let locale = MessageFormat.defaultLocale;

const LH_ROOT = path.join(__dirname, '../../');

try {
// Node usually doesn't come with the locales we want built-in, so load the polyfill.
// In browser environments, we won't need the polyfill, and this will throw so wrap in try/catch.
(() => {
// Node usually doesn't come with the locales we want built-in, so load the polyfill if we can.

try {
// @ts-ignore
const IntlPolyfill = require('intl');
// In browser environments where we don't need the polyfill, this won't exist
if (!IntlPolyfill.NumberFormat) return;

// @ts-ignore
Intl.NumberFormat = IntlPolyfill.NumberFormat;
// @ts-ignore
Intl.DateTimeFormat = IntlPolyfill.DateTimeFormat;
} catch (_) {
log.warn('i18n', 'Failed to install `intl` polyfill');
}
})();

// @ts-ignore
const IntlPolyfill = require('intl');
// @ts-ignore
Intl.NumberFormat = IntlPolyfill.NumberFormat;
// @ts-ignore
Intl.DateTimeFormat = IntlPolyfill.DateTimeFormat;
} catch (_) {}

const UIStrings = {
ms: '{timeInMs, number, milliseconds}\xa0ms',
columnURL: 'URL',
columnSize: 'Size (KB)',
columnWastedTime: 'Potential Savings (ms)',
columnWastedMs: 'Potential Savings (ms)',
displayValueWastedMs: 'Potential savings of {wastedMs, number, milliseconds}\xa0ms',
};

const formats = {
Expand All @@ -42,58 +50,171 @@ const formats = {
};

/**
* @param {string} msg
* @param {Record<string, *>} values
* @param {string} icuMessage
* @param {Record<string, *>} [values]
*/
function preprocessMessageValues(msg, values) {
const parsed = MessageParser.parse(msg);
// Round all milliseconds to 10s place
function _preprocessMessageValues(icuMessage, values) {
if (!values) return;

const clonedValues = JSON.parse(JSON.stringify(values));
const parsed = MessageParser.parse(icuMessage);
// Round all milliseconds to the nearest 10
parsed.elements
.filter(el => el.format && el.format.style === 'milliseconds')
.forEach(el => (values[el.id] = Math.round(values[el.id] / 10) * 10));
.forEach(el => (clonedValues[el.id] = Math.round(clonedValues[el.id] / 10) * 10));

// Replace all the bytes with KB
parsed.elements
.filter(el => el.format && el.format.style === 'bytes')
.forEach(el => (values[el.id] = values[el.id] / 1024));
.forEach(el => (clonedValues[el.id] = clonedValues[el.id] / 1024));

return clonedValues;
}

module.exports = {
UIStrings,
/**
* @param {string} filename
* @param {Record<string, string>} fileStrings
*/
createStringFormatter(filename, fileStrings) {
const mergedStrings = {...UIStrings, ...fileStrings};

/** @param {string} msg @param {*} [values] */
const formatFn = (msg, values) => {
const keyname = Object.keys(mergedStrings).find(key => mergedStrings[key] === msg);
if (!keyname) throw new Error(`Could not locate: ${msg}`);
preprocessMessageValues(msg, values);

const filenameToLookup = keyname in UIStrings ? __filename : filename;
const lookupKey = path.relative(LH_ROOT, filenameToLookup) + '!#' + keyname;
const localeStrings = LOCALES[locale] || {};
const localeString = localeStrings[lookupKey] && localeStrings[lookupKey].message;
// fallback to the original english message if we couldn't find a message in the specified locale
// better to have an english message than no message at all, in some number cases it won't even matter
const messageForMessageFormat = localeString || msg;
// when using accented english, force the use of a different locale for number formatting
const localeForMessageFormat = locale === 'en-XA' ? 'de-DE' : locale;

const formatter = new MessageFormat(messageForMessageFormat, localeForMessageFormat, formats);
return formatter.format(values);
};

return formatFn;
},
/**
* @typedef IcuMessageInstance
* @prop {string} icuMessageId
* @prop {string} icuMessage
* @prop {*} [values]
*/

/** @type {Map<string, IcuMessageInstance[]>} */
const _icuMessageInstanceMap = new Map();

/**
*
* @param {LH.Locale} locale
* @param {string} icuMessageId
* @param {string} icuMessage
* @param {*} [values]
* @return {{formattedString: string, icuMessage: string}}
*/
function _formatIcuMessage(locale, icuMessageId, icuMessage, values) {
const localeMessages = LOCALES[locale] || {};
const localeMessage = localeMessages[icuMessageId] && localeMessages[icuMessageId].message;
// fallback to the original english message if we couldn't find a message in the specified locale
// better to have an english message than no message at all, in some number cases it won't even matter
const messageForMessageFormat = localeMessage || icuMessage;
// when using accented english, force the use of a different locale for number formatting
const localeForMessageFormat = locale === 'en-XA' ? 'de-DE' : locale;
// pre-process values for the message format like KB and milliseconds
const valuesForMessageFormat = _preprocessMessageValues(icuMessage, values);

const formatter = new MessageFormat(messageForMessageFormat, localeForMessageFormat, formats);
const formattedString = formatter.format(valuesForMessageFormat);

return {formattedString, icuMessage: messageForMessageFormat};
}

/** @param {string[]} pathInLHR */
function _formatPathAsString(pathInLHR) {
let pathAsString = '';
for (const property of pathInLHR) {
if (/^[a-z]+$/i.test(property)) {
if (pathAsString.length) pathAsString += '.';
pathAsString += property;
} else {
if (/]|"|'|\s/.test(property)) throw new Error(`Cannot handle "${property}" in i18n`);
pathAsString += `[${property}]`;
}
}

return pathAsString;
}

/**
* @return {LH.Locale}
*/
function getDefaultLocale() {
const defaultLocale = MessageFormat.defaultLocale;
if (defaultLocale in LOCALES) return /** @type {LH.Locale} */ (defaultLocale);
return 'en-US';
}

/**
* @param {string} filename
* @param {Record<string, string>} fileStrings
*/
function createMessageInstanceIdFn(filename, fileStrings) {
const mergedStrings = {...UIStrings, ...fileStrings};

/** @param {string} icuMessage @param {*} [values] */
const getMessageInstanceIdFn = (icuMessage, values) => {
const keyname = Object.keys(mergedStrings).find(key => mergedStrings[key] === icuMessage);
if (!keyname) throw new Error(`Could not locate: ${icuMessage}`);

const filenameToLookup = keyname in fileStrings ? filename : __filename;
const unixStyleFilename = path.relative(LH_ROOT, filenameToLookup).replace(/\\/g, '/');
const icuMessageId = `${unixStyleFilename} | ${keyname}`;
const icuMessageInstances = _icuMessageInstanceMap.get(icuMessageId) || [];

let indexOfInstance = icuMessageInstances.findIndex(inst => isDeepEqual(inst.values, values));
if (indexOfInstance === -1) {
icuMessageInstances.push({icuMessageId, icuMessage, values});
indexOfInstance = icuMessageInstances.length - 1;
}

_icuMessageInstanceMap.set(icuMessageId, icuMessageInstances);

return `${icuMessageId} # ${indexOfInstance}`;
};

return getMessageInstanceIdFn;
}

/**
* @param {LH.Result} lhr
* @param {LH.Locale} locale
*/
function replaceIcuMessageInstanceIds(lhr, locale) {
const MESSAGE_INSTANCE_ID_REGEX = /(.* \| .*) # (\d+)$/;

/**
* @param {LH.Locale|null} [newLocale]
* @param {*} objectInLHR
* @param {LH.I18NMessages} icuMessagePaths
* @param {string[]} pathInLHR
*/
setLocale(newLocale) {
if (!newLocale) return;
locale = newLocale;
},
function replaceInObject(objectInLHR, icuMessagePaths, pathInLHR = []) {
if (typeof objectInLHR !== 'object' || !objectInLHR) return;

for (const [property, value] of Object.entries(objectInLHR)) {
const currentPathInLHR = pathInLHR.concat([property]);

// Check to see if the value in the LHR looks like a string reference. If it is, replace it.
if (typeof value === 'string' && MESSAGE_INSTANCE_ID_REGEX.test(value)) {
// @ts-ignore - Guaranteed to match from .test call above
const [_, icuMessageId, icuMessageInstanceIndex] = value.match(MESSAGE_INSTANCE_ID_REGEX);
const messageInstancesInLHR = icuMessagePaths[icuMessageId] || [];
const icuMessageInstances = _icuMessageInstanceMap.get(icuMessageId) || [];
const icuMessageInstance = icuMessageInstances[Number(icuMessageInstanceIndex)];
const currentPathAsString = _formatPathAsString(currentPathInLHR);

messageInstancesInLHR.push(
icuMessageInstance.values ?
{values: icuMessageInstance.values, path: currentPathAsString} :
currentPathAsString
);

const {formattedString} = _formatIcuMessage(locale, icuMessageId,
icuMessageInstance.icuMessage, icuMessageInstance.values);

objectInLHR[property] = formattedString;
icuMessagePaths[icuMessageId] = messageInstancesInLHR;
} else {
replaceInObject(value, icuMessagePaths, currentPathInLHR);
}
}
}

const icuMessagePaths = {};
replaceInObject(lhr, icuMessagePaths);
lhr.i18n = {icuMessagePaths};
}

module.exports = {
_formatPathAsString,
UIStrings,
getDefaultLocale,
createMessageInstanceIdFn,
replaceIcuMessageInstanceIds,
};
Loading