diff --git a/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js index 4abf287c0850..4892c7049d23 100644 --- a/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js +++ b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js @@ -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 @@ -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); diff --git a/lighthouse-core/audits/metrics/interactive.js b/lighthouse-core/audits/metrics/interactive.js index 672d73f3e4d6..ec5014b1c9ca 100644 --- a/lighthouse-core/audits/metrics/interactive.js +++ b/lighthouse-core/audits/metrics/interactive.js @@ -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". diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js index e81a856b4ad2..2a75aea04b23 100644 --- a/lighthouse-core/config/default-config.js +++ b/lighthouse-core/config/default-config.js @@ -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 = { + 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, @@ -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', @@ -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'}, @@ -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, +}); diff --git a/lighthouse-core/index.js b/lighthouse-core/index.js index 063d40399b89..7b91a8b0dce7 100644 --- a/lighthouse-core/index.js +++ b/lighthouse-core/index.js @@ -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'); /* @@ -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'; diff --git a/lighthouse-core/lib/i18n.js b/lighthouse-core/lib/i18n.js index 192fa7f4b927..75a6f5bd7cce 100644 --- a/lighthouse-core/lib/i18n.js +++ b/lighthouse-core/lib/i18n.js @@ -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 = { @@ -42,58 +50,171 @@ const formats = { }; /** - * @param {string} msg - * @param {Record} values + * @param {string} icuMessage + * @param {Record} [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} 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} */ +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} 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, }; diff --git a/lighthouse-core/lib/locales/en-US.json b/lighthouse-core/lib/locales/en-US.json index ebe0d04af942..ef621e4826b8 100644 --- a/lighthouse-core/lib/locales/en-US.json +++ b/lighthouse-core/lib/locales/en-US.json @@ -1,29 +1,47 @@ { - "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js!#title": { + "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js | title": { "message": "Eliminate render-blocking resources" }, - "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js!#description": { + "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js | description": { "message": "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)." }, - "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js!#displayValue": { - "message": "{itemCount, plural,\n one {1 resource}\n other {# resources}\n } delayed first paint by {timeInMs, number, milliseconds} ms" - }, - "lighthouse-core/audits/metrics/interactive.js!#title": { + "lighthouse-core/audits/metrics/interactive.js | title": { "message": "Time to Interactive" }, - "lighthouse-core/audits/metrics/interactive.js!#description": { + "lighthouse-core/audits/metrics/interactive.js | description": { "message": "Interactive marks the time at which the page is fully interactive. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/consistently-interactive)." }, - "lighthouse-core/lib/i18n.js!#ms": { + "lighthouse-core/config/default-config.js | performanceCategoryTitle": { + "message": "Performance" + }, + "lighthouse-core/config/default-config.js | metricGroupTitle": { + "message": "Metrics" + }, + "lighthouse-core/config/default-config.js | loadOpportunitiesGroupTitle": { + "message": "Opportunities" + }, + "lighthouse-core/config/default-config.js | loadOpportunitiesGroupDescription": { + "message": "These are opportunities to speed up your application by optimizing the following resources." + }, + "lighthouse-core/config/default-config.js | diagnosticsGroupTitle": { + "message": "Diagnostics" + }, + "lighthouse-core/config/default-config.js | diagnosticsGroupDescription": { + "message": "More information about the performance of your application." + }, + "lighthouse-core/lib/i18n.js | ms": { "message": "{timeInMs, number, milliseconds} ms" }, - "lighthouse-core/lib/i18n.js!#columnURL": { + "lighthouse-core/lib/i18n.js | columnURL": { "message": "URL" }, - "lighthouse-core/lib/i18n.js!#columnSize": { + "lighthouse-core/lib/i18n.js | columnSize": { "message": "Size (KB)" }, - "lighthouse-core/lib/i18n.js!#columnWastedTime": { + "lighthouse-core/lib/i18n.js | columnWastedMs": { "message": "Potential Savings (ms)" + }, + "lighthouse-core/lib/i18n.js | displayValueWastedMs": { + "message": "Potential savings of {wastedMs, number, milliseconds} ms" } } diff --git a/lighthouse-core/lib/locales/en-XA.json b/lighthouse-core/lib/locales/en-XA.json index 22ae187819ae..a14708926426 100644 --- a/lighthouse-core/lib/locales/en-XA.json +++ b/lighthouse-core/lib/locales/en-XA.json @@ -1,29 +1,47 @@ { - "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js!#title": { + "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js | title": { "message": "Êĺîḿîńât́ê ŕêńd̂ér̂-b́l̂óĉḱîńĝ ŕêśôúr̂ćêś" }, - "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js!#description": { + "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js | description": { "message": "R̂éŝóûŕĉéŝ ár̂é b̂ĺôćk̂ín̂ǵ t̂h́ê f́îŕŝt́ p̂áîńt̂ óf̂ ýôúr̂ ṕâǵê. Ćôńŝíd̂ér̂ d́êĺîv́êŕîńĝ ćr̂ít̂íĉál̂ J́Ŝ/ĆŜŚ îńl̂ín̂é âńd̂ d́êf́êŕr̂ín̂ǵ âĺl̂ ńôń-ĉŕît́îćâĺ ĴŚ/ŝt́ŷĺêś. [L̂éâŕn̂ ḿôŕê](h́t̂t́p̂ś://d̂év̂él̂óp̂ér̂ś.ĝóôǵl̂é.ĉóm̂/ẃêb́/t̂óôĺŝ/ĺîǵĥt́ĥóûśê/áûd́ît́ŝ/b́l̂óĉḱîńĝ-ŕêśôúr̂ćêś)." }, - "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js!#displayValue": { - "message": "{itemCount, plural,\n one {1 resource}\n other {# resources}\n } d̂él̂áŷéd̂ f́îŕŝt́ p̂áîńt̂ b́ŷ {timeInMs, number, milliseconds} ḿŝ" - }, - "lighthouse-core/audits/metrics/interactive.js!#title": { + "lighthouse-core/audits/metrics/interactive.js | title": { "message": "T̂ím̂é t̂ó Îńt̂ér̂áĉt́îv́ê" }, - "lighthouse-core/audits/metrics/interactive.js!#description": { + "lighthouse-core/audits/metrics/interactive.js | description": { "message": "Îńt̂ér̂áĉt́îv́ê ḿâŕk̂ś t̂h́ê t́îḿê át̂ ẃĥíĉh́ t̂h́ê ṕâǵê íŝ f́ûĺl̂ý îńt̂ér̂áĉt́îv́ê. [Ĺêár̂ń m̂ór̂é](ĥt́t̂ṕŝ://d́êv́êĺôṕêŕŝ.ǵôóĝĺê.ćôḿ/ŵéb̂/t́ôól̂ś/l̂íĝh́t̂h́ôúŝé/âúd̂ít̂ś/ĉón̂śîśt̂én̂t́l̂ý-îńt̂ér̂áĉt́îv́ê)." }, - "lighthouse-core/lib/i18n.js!#ms": { + "lighthouse-core/config/default-config.js | performanceCategoryTitle": { + "message": "P̂ér̂f́ôŕm̂án̂ćê" + }, + "lighthouse-core/config/default-config.js | metricGroupTitle": { + "message": "M̂ét̂ŕîćŝ" + }, + "lighthouse-core/config/default-config.js | loadOpportunitiesGroupTitle": { + "message": "Ôṕp̂ór̂t́ûńît́îéŝ" + }, + "lighthouse-core/config/default-config.js | loadOpportunitiesGroupDescription": { + "message": "T̂h́êśê ár̂é ôṕp̂ór̂t́ûńît́îéŝ t́ô śp̂éêd́ ûṕ ŷóûŕ âṕp̂ĺîćât́îón̂ b́ŷ óp̂t́îḿîźîńĝ t́ĥé f̂ól̂ĺôẃîńĝ ŕêśôúr̂ćêś." + }, + "lighthouse-core/config/default-config.js | diagnosticsGroupTitle": { + "message": "D̂íâǵn̂óŝt́îćŝ" + }, + "lighthouse-core/config/default-config.js | diagnosticsGroupDescription": { + "message": "M̂ór̂é îńf̂ór̂ḿât́îón̂ áb̂óût́ t̂h́ê ṕêŕf̂ór̂ḿâńĉé ôf́ ŷóûŕ âṕp̂ĺîćât́îón̂." + }, + "lighthouse-core/lib/i18n.js | ms": { "message": "{timeInMs, number, milliseconds} m̂ś" }, - "lighthouse-core/lib/i18n.js!#columnURL": { + "lighthouse-core/lib/i18n.js | columnURL": { "message": "ÛŔL̂" }, - "lighthouse-core/lib/i18n.js!#columnSize": { + "lighthouse-core/lib/i18n.js | columnSize": { "message": "Ŝíẑé (K̂B́)" }, - "lighthouse-core/lib/i18n.js!#columnWastedTime": { + "lighthouse-core/lib/i18n.js | columnWastedMs": { "message": "P̂ót̂én̂t́îál̂ Śâv́îńĝś (m̂ś)" + }, + "lighthouse-core/lib/i18n.js | displayValueWastedMs": { + "message": "P̂ót̂én̂t́îál̂ śâv́îńĝś ôf́ {wastedMs, number, milliseconds} m̂ś" } } diff --git a/lighthouse-core/lib/locales/index.js b/lighthouse-core/lib/locales/index.js index 9f70b5f3ca6b..c0505399e879 100644 --- a/lighthouse-core/lib/locales/index.js +++ b/lighthouse-core/lib/locales/index.js @@ -7,5 +7,6 @@ 'use strict'; module.exports = { + 'en-US': require('./en-US.json'), 'en-XA': require('./en-XA.json'), }; diff --git a/lighthouse-core/runner.js b/lighthouse-core/runner.js index e84410700e06..d11111baf232 100644 --- a/lighthouse-core/runner.js +++ b/lighthouse-core/runner.js @@ -11,6 +11,7 @@ const GatherRunner = require('./gather/gather-runner'); const ReportScoring = require('./scoring'); const Audit = require('./audits/audit'); const log = require('lighthouse-logger'); +const i18n = require('./lib/i18n'); const assetSaver = require('./lib/asset-saver'); const fs = require('fs'); const path = require('path'); @@ -31,6 +32,7 @@ class Runner { try { const startTime = Date.now(); const settings = opts.config.settings; + settings.locale = settings.locale || i18n.getDefaultLocale(); /** * List of top-level warnings for this Lighthouse run. @@ -135,6 +137,8 @@ class Runner { timing: {total: Date.now() - startTime}, }; + i18n.replaceIcuMessageInstanceIds(lhr, settings.locale); + const report = generateReport(lhr, settings.output); return {lhr, artifacts, report}; } catch (err) { diff --git a/lighthouse-core/scripts/i18n/collect-strings.js b/lighthouse-core/scripts/i18n/collect-strings.js index 582d8197175d..ce8afc382e82 100644 --- a/lighthouse-core/scripts/i18n/collect-strings.js +++ b/lighthouse-core/scripts/i18n/collect-strings.js @@ -40,7 +40,7 @@ function collectAllStringsInDir(dir, strings = {}) { const mod = require(fullPath); if (!mod.UIStrings) continue; for (const [key, value] of Object.entries(mod.UIStrings)) { - strings[`${relativePath}!#${key}`] = value; + strings[`${relativePath} | ${key}`] = value; } } } diff --git a/lighthouse-core/test/audits/metrics/interactive-test.js b/lighthouse-core/test/audits/metrics/interactive-test.js index fcb8f52f6712..d1b66c356e88 100644 --- a/lighthouse-core/test/audits/metrics/interactive-test.js +++ b/lighthouse-core/test/audits/metrics/interactive-test.js @@ -7,7 +7,6 @@ const Interactive = require('../../../audits/metrics/interactive.js'); const Runner = require('../../../runner.js'); -const Util = require('../../../report/html/renderer/util'); const assert = require('assert'); const options = Interactive.defaultOptions; @@ -35,7 +34,7 @@ describe('Performance: interactive audit', () => { return Interactive.audit(artifacts, {options, settings}).then(output => { assert.equal(output.score, 1); assert.equal(Math.round(output.rawValue), 1582); - assert.equal(Util.formatDisplayValue(output.displayValue), '1,580\xa0ms'); + assert.ok(output.displayValue); }); }); @@ -53,7 +52,7 @@ describe('Performance: interactive audit', () => { return Interactive.audit(artifacts, {options, settings}).then(output => { assert.equal(output.score, 0.97); assert.equal(Math.round(output.rawValue), 2712); - assert.equal(Util.formatDisplayValue(output.displayValue), '2,710\xa0ms'); + assert.ok(output.displayValue); }); }); }); diff --git a/lighthouse-core/test/lib/i18n-test.js b/lighthouse-core/test/lib/i18n-test.js new file mode 100644 index 000000000000..300bded7a1ce --- /dev/null +++ b/lighthouse-core/test/lib/i18n-test.js @@ -0,0 +1,57 @@ +/** + * @license Copyright 2018 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ +'use strict'; + +const path = require('path'); +const i18n = require('../../lib/i18n'); + +/* eslint-env jest */ + +describe('i18n', () => { + describe('#_formatPathAsString', () => { + it('handles simple paths', () => { + expect(i18n._formatPathAsString(['foo'])).toBe('foo'); + expect(i18n._formatPathAsString(['foo', 'bar', 'baz'])).toBe('foo.bar.baz'); + }); + + it('handles array paths', () => { + expect(i18n._formatPathAsString(['foo', 0])).toBe('foo[0]'); + }); + + it('handles complex paths', () => { + const propertyPath = ['foo', 'what-the', 'bar', 0, 'no']; + expect(i18n._formatPathAsString(propertyPath)).toBe('foo[what-the].bar[0].no'); + }); + + it('throws on unhandleable paths', () => { + expect(() => i18n._formatPathAsString(['Bobby "DROP TABLE'])).toThrow(/Cannot handle/); + }); + }); + + describe('#createMessageInstanceIdFn', () => { + it('returns a string reference', () => { + const fakeFile = path.join(__dirname, 'fake-file.js'); + const templates = {daString: 'use me!'}; + const formatter = i18n.createMessageInstanceIdFn(fakeFile, templates); + + const expected = 'lighthouse-core/test/lib/fake-file.js | daString # 0'; + expect(formatter(templates.daString, {x: 1})).toBe(expected); + }); + }); + + describe('#replaceIcuMessageInstanceIds', () => { + it('replaces the references in the LHR', () => { + const templateID = 'lighthouse-core/test/lib/fake-file.js | daString'; + const reference = templateID + ' # 0'; + const lhr = {audits: {'fake-audit': {title: reference}}}; + + i18n.replaceIcuMessageInstanceIds(lhr, 'en-US'); + expect(lhr.audits['fake-audit'].title).toBe('use me!'); + expect(lhr.i18n.icuMessagePaths).toEqual({ + [templateID]: [{path: 'audits[fake-audit].title', values: {x: 1}}]}); + }); + }); +}); diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json index 02899b656756..b87617f1d2a9 100644 --- a/lighthouse-core/test/results/sample_v2.json +++ b/lighthouse-core/test/results/sample_v2.json @@ -1920,7 +1920,7 @@ "score": 0.46, "scoreDisplayMode": "numeric", "rawValue": 1129, - "displayValue": "5 resources delayed first paint by 1,130 ms", + "displayValue": "Potential savings of 1,130 ms", "details": { "type": "opportunity", "headings": [ @@ -2771,7 +2771,7 @@ "gatherMode": false, "disableStorageReset": false, "disableDeviceEmulation": false, - "locale": null, + "locale": "en-US", "blockedUrlPatterns": null, "additionalTraceCategories": null, "extraHeaders": null, @@ -3428,5 +3428,64 @@ "title": "Crawling and Indexing", "description": "To appear in search results, crawlers need access to your app." } + }, + "i18n": { + "icuMessagePaths": { + "lighthouse-core/audits/metrics/interactive.js | title": [ + "audits.interactive.title" + ], + "lighthouse-core/audits/metrics/interactive.js | description": [ + "audits.interactive.description" + ], + "lighthouse-core/lib/i18n.js | ms": [ + { + "values": { + "timeInMs": 4927.278 + }, + "path": "audits.interactive.displayValue" + } + ], + "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js | title": [ + "audits[render-blocking-resources].title" + ], + "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js | description": [ + "audits[render-blocking-resources].description" + ], + "lighthouse-core/lib/i18n.js | displayValueWastedMs": [ + { + "values": { + "wastedMs": 1129 + }, + "path": "audits[render-blocking-resources].displayValue" + } + ], + "lighthouse-core/lib/i18n.js | columnURL": [ + "audits[render-blocking-resources].details.headings[0].label" + ], + "lighthouse-core/lib/i18n.js | columnSize": [ + "audits[render-blocking-resources].details.headings[1].label" + ], + "lighthouse-core/lib/i18n.js | columnWastedMs": [ + "audits[render-blocking-resources].details.headings[2].label" + ], + "lighthouse-core/config/default-config.js | performanceCategoryTitle": [ + "categories.performance.title" + ], + "lighthouse-core/config/default-config.js | metricGroupTitle": [ + "categoryGroups.metrics.title" + ], + "lighthouse-core/config/default-config.js | loadOpportunitiesGroupTitle": [ + "categoryGroups[load-opportunities].title" + ], + "lighthouse-core/config/default-config.js | loadOpportunitiesGroupDescription": [ + "categoryGroups[load-opportunities].description" + ], + "lighthouse-core/config/default-config.js | diagnosticsGroupTitle": [ + "categoryGroups.diagnostics.title" + ], + "lighthouse-core/config/default-config.js | diagnosticsGroupDescription": [ + "categoryGroups.diagnostics.description" + ] + } } } \ No newline at end of file diff --git a/lighthouse-extension/app/src/lighthouse-background.js b/lighthouse-extension/app/src/lighthouse-background.js index 523afb9ab068..32520faab192 100644 --- a/lighthouse-extension/app/src/lighthouse-background.js +++ b/lighthouse-extension/app/src/lighthouse-background.js @@ -8,6 +8,7 @@ const RawProtocol = require('../../../lighthouse-core/gather/connections/raw'); const Runner = require('../../../lighthouse-core/runner'); const Config = require('../../../lighthouse-core/config/config'); +const i18n = require('../../../lighthouse-core/lib/i18n'); const defaultConfig = require('../../../lighthouse-core/config/default-config.js'); const log = require('lighthouse-logger'); @@ -26,7 +27,10 @@ function runLighthouseForConnection( updateBadgeFn = function() { }) { const config = new Config({ extends: 'lighthouse:default', - settings: {onlyCategories: categoryIDs}, + settings: { + locale: i18n.getDefaultLocale(), + onlyCategories: categoryIDs, + }, }, options.flags); // Add url and config to fresh options object. diff --git a/typings/lhr.d.ts b/typings/lhr.d.ts index 45724ba7cfaf..a86b974dd0a9 100644 --- a/typings/lhr.d.ts +++ b/typings/lhr.d.ts @@ -6,6 +6,12 @@ declare global { module LH { + export type I18NMessageEntry = string | {path: string, values: any}; + + export interface I18NMessages { + [templateID: string]: I18NMessageEntry[]; + } + /** * The full output of a Lighthouse run. */ @@ -35,6 +41,8 @@ declare global { userAgent: string; /** Execution timings for the Lighthouse run */ timing: {total: number, [t: string]: number}; + /** The record of all formatted string locations in the LHR and their corresponding source values. */ + i18n?: {icuMessagePaths: I18NMessages}; } // Result namespace