Repository: GoogleChrome/lighthouse
Pull request #5655 (2018-07-11 18:32:19)

core(i18n): localize strings at end of run
An example of how we might be able to "fix" the strings before shipping the LHR, basically the i18n formatting function just returns a reference to the string to be used which is stored in a map, then at the end before returning, runner asks i18n to replace all the references in the LHR with the string of the appropriate locale. BOOM done! a log is kept of what replacements were made and added to the LHR as `localeLog` so that later the report could be translated again without re-running.

![image](https://user-images.githubusercontent.com/2301202/42592201-b021805a-84fd-11e8-95da-0dcb7c13a375.png)


patrickhulce: I think this is ready for ready for re-review.

I'm going to throw one more thought out there as I was thinking about report strings. instead of `lhr.localeLog`...

```js
lhr.localization = {
  log: {...},
  uiMessages: {},
}
```

thoughts?
patrickhulce: alright @paulirish I think the names have been sorted out except this area https://github.com/GoogleChrome/lighthouse/pull/5655/files#diff-7e3be24bffdebd4c067d12d0596858f9R171

now that it's not log and we also have usages saved in our `formattedStringUsages` not sure what to call these two different things

Patch:
diff --git a/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
index 4abf287c0..4892c7049 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/byte-efficiency/unused-css-rules.js b/lighthouse-core/audits/byte-efficiency/unused-css-rules.js
index 8858ddaa7..347af4d40 100644
--- a/lighthouse-core/audits/byte-efficiency/unused-css-rules.js
+++ b/lighthouse-core/audits/byte-efficiency/unused-css-rules.js
@@ -23,7 +23,7 @@ class UnusedCSSRules extends ByteEfficiencyAudit {
       scoreDisplayMode: ByteEfficiencyAudit.SCORING_MODES.NUMERIC,
       description: 'Remove unused rules from stylesheets to reduce unnecessary ' +
           'bytes consumed by network activity. ' +
-          '[Learn more](https://developers.google.com/speed/docs/insights/OptimizeCSSDelivery).',
+          '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/unused-css).',
       requiredArtifacts: ['CSSUsage', 'URL', 'devtoolsLogs'],
     };
   }
diff --git a/lighthouse-core/audits/metrics/interactive.js b/lighthouse-core/audits/metrics/interactive.js
index 672d73f3e..ec5014b1c 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/audits/time-to-first-byte.js b/lighthouse-core/audits/time-to-first-byte.js
index 244ac31af..3bbebbc0c 100644
--- a/lighthouse-core/audits/time-to-first-byte.js
+++ b/lighthouse-core/audits/time-to-first-byte.js
@@ -35,46 +35,32 @@ class TTFBMetric extends Audit {
    * @param {LH.Artifacts} artifacts
    * @return {Promise<LH.Audit.Product>}
    */
-  static audit(artifacts) {
-    const devtoolsLogs = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
+  static async audit(artifacts) {
+    const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
+    const mainResource = await artifacts.requestMainResource({devtoolsLog, URL: artifacts.URL});
 
-    return artifacts.requestNetworkRecords(devtoolsLogs)
-      .then((networkRecords) => {
-        /** @type {LH.Audit.DisplayValue} */
-        let displayValue = '';
+    const ttfb = TTFBMetric.caclulateTTFB(mainResource);
+    const passed = ttfb < TTFB_THRESHOLD;
 
-        const finalUrl = artifacts.URL.finalUrl;
-        const finalUrlRequest = networkRecords.find(record => record.url === finalUrl);
-        if (!finalUrlRequest) {
-          throw new Error(`finalUrl '${finalUrl} not found in network records.`);
-        }
-        const ttfb = TTFBMetric.caclulateTTFB(finalUrlRequest);
-        const passed = ttfb < TTFB_THRESHOLD;
-
-        if (!passed) {
-          displayValue = ['Root document took %10d', ttfb];
-        }
-
-        /** @type {LH.Result.Audit.OpportunityDetails} */
-        const details = {
-          type: 'opportunity',
-          overallSavingsMs: ttfb - TTFB_THRESHOLD,
-          headings: [],
-          items: [],
-        };
+    /** @type {LH.Result.Audit.OpportunityDetails} */
+    const details = {
+      type: 'opportunity',
+      overallSavingsMs: ttfb - TTFB_THRESHOLD,
+      headings: [],
+      items: [],
+    };
 
-        return {
-          rawValue: ttfb,
-          score: Number(passed),
-          displayValue,
-          details,
-          extendedInfo: {
-            value: {
-              wastedMs: ttfb - TTFB_THRESHOLD,
-            },
-          },
-        };
-      });
+    return {
+      rawValue: ttfb,
+      score: Number(passed),
+      displayValue: passed ? '' : ['Root document took %10d', ttfb],
+      details,
+      extendedInfo: {
+        value: {
+          wastedMs: ttfb - TTFB_THRESHOLD,
+        },
+      },
+    };
   }
 }
 
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index e81a856b4..2a75aea04 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 063d40399..7b91a8b0d 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/asset-saver.js b/lighthouse-core/lib/asset-saver.js
index e149d670b..7fa75246e 100644
--- a/lighthouse-core/lib/asset-saver.js
+++ b/lighthouse-core/lib/asset-saver.js
@@ -326,13 +326,16 @@ async function saveAssets(artifacts, audits, pathWithBasename) {
 async function logAssets(artifacts, audits) {
   const allAssets = await prepareAssets(artifacts, audits);
   allAssets.map(passAssets => {
-    log.log(`devtoolslog-${passAssets.passName}.json`, JSON.stringify(passAssets.devtoolsLog));
+    const dtlogdata = JSON.stringify(passAssets.devtoolsLog);
+    // eslint-disable-next-line no-console
+    console.log(`loggedAsset %%% devtoolslog-${passAssets.passName}.json %%% ${dtlogdata}`);
     const traceIter = traceJsonGenerator(passAssets.traceData);
     let traceJson = '';
     for (const trace of traceIter) {
       traceJson += trace;
     }
-    log.log(`trace-${passAssets.passName}.json`, traceJson);
+    // eslint-disable-next-line no-console
+    console.log(`loggedAsset %%% trace-${passAssets.passName}.json %%% ${traceJson}`);
   });
 }
 
diff --git a/lighthouse-core/lib/i18n.js b/lighthouse-core/lib/i18n.js
index 192fa7f4b..75a6f5bd7 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<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,
 };
diff --git a/lighthouse-core/lib/locales/en-US.json b/lighthouse-core/lib/locales/en-US.json
index ebe0d04af..ef621e482 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 22ae18781..a14708926 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": "Êĺîḿîńât́ê ŕêńd̂ér̂-b́l̂óĉḱîńĝ ŕêśôúr̂ćêś"
   },
-  "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js!#description": {
+  "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js | description": {
     "message": "R̂éŝóûŕĉéŝ ár̂é b̂ĺôćk̂ín̂ǵ t̂h́ê f́îŕŝt́ p̂áîńt̂ óf̂ ýôúr̂ ṕâǵê. Ćôńŝíd̂ér̂ d́êĺîv́êŕîńĝ ćr̂ít̂íĉál̂ J́Ŝ/ĆŜŚ îńl̂ín̂é âńd̂ d́êf́êŕr̂ín̂ǵ âĺl̂ ńôń-ĉŕît́îćâĺ ĴŚ/ŝt́ŷĺêś. [L̂éâŕn̂ ḿôŕê](h́t̂t́p̂ś://d̂év̂él̂óp̂ér̂ś.ĝóôǵl̂é.ĉóm̂/ẃêb́/t̂óôĺŝ/ĺîǵĥt́ĥóûśê/áûd́ît́ŝ/b́l̂óĉḱîńĝ-ŕêśôúr̂ćêś)."
   },
-  "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js!#displayValue": {
-    "message": "{itemCount, plural,\n    one {1 resource}\n    other {# resources}\n    } d̂él̂áŷéd̂ f́îŕŝt́ p̂áîńt̂ b́ŷ {timeInMs, number, milliseconds} ḿŝ"
-  },
-  "lighthouse-core/audits/metrics/interactive.js!#title": {
+  "lighthouse-core/audits/metrics/interactive.js | title": {
     "message": "T̂ím̂é t̂ó Îńt̂ér̂áĉt́îv́ê"
   },
-  "lighthouse-core/audits/metrics/interactive.js!#description": {
+  "lighthouse-core/audits/metrics/interactive.js | description": {
     "message": "Îńt̂ér̂áĉt́îv́ê ḿâŕk̂ś t̂h́ê t́îḿê át̂ ẃĥíĉh́ t̂h́ê ṕâǵê íŝ f́ûĺl̂ý îńt̂ér̂áĉt́îv́ê. [Ĺêár̂ń m̂ór̂é](ĥt́t̂ṕŝ://d́êv́êĺôṕêŕŝ.ǵôóĝĺê.ćôḿ/ŵéb̂/t́ôól̂ś/l̂íĝh́t̂h́ôúŝé/âúd̂ít̂ś/ĉón̂śîśt̂én̂t́l̂ý-îńt̂ér̂áĉt́îv́ê)."
   },
-  "lighthouse-core/lib/i18n.js!#ms": {
+  "lighthouse-core/config/default-config.js | performanceCategoryTitle": {
+    "message": "P̂ér̂f́ôŕm̂án̂ćê"
+  },
+  "lighthouse-core/config/default-config.js | metricGroupTitle": {
+    "message": "M̂ét̂ŕîćŝ"
+  },
+  "lighthouse-core/config/default-config.js | loadOpportunitiesGroupTitle": {
+    "message": "Ôṕp̂ór̂t́ûńît́îéŝ"
+  },
+  "lighthouse-core/config/default-config.js | loadOpportunitiesGroupDescription": {
+    "message": "T̂h́êśê ár̂é ôṕp̂ór̂t́ûńît́îéŝ t́ô śp̂éêd́ ûṕ ŷóûŕ âṕp̂ĺîćât́îón̂ b́ŷ óp̂t́îḿîźîńĝ t́ĥé f̂ól̂ĺôẃîńĝ ŕêśôúr̂ćêś."
+  },
+  "lighthouse-core/config/default-config.js | diagnosticsGroupTitle": {
+    "message": "D̂íâǵn̂óŝt́îćŝ"
+  },
+  "lighthouse-core/config/default-config.js | diagnosticsGroupDescription": {
+    "message": "M̂ór̂é îńf̂ór̂ḿât́îón̂ áb̂óût́ t̂h́ê ṕêŕf̂ór̂ḿâńĉé ôf́ ŷóûŕ âṕp̂ĺîćât́îón̂."
+  },
+  "lighthouse-core/lib/i18n.js | ms": {
     "message": "{timeInMs, number, milliseconds} m̂ś"
   },
-  "lighthouse-core/lib/i18n.js!#columnURL": {
+  "lighthouse-core/lib/i18n.js | columnURL": {
     "message": "ÛŔL̂"
   },
-  "lighthouse-core/lib/i18n.js!#columnSize": {
+  "lighthouse-core/lib/i18n.js | columnSize": {
     "message": "Ŝíẑé (K̂B́)"
   },
-  "lighthouse-core/lib/i18n.js!#columnWastedTime": {
+  "lighthouse-core/lib/i18n.js | columnWastedMs": {
     "message": "P̂ót̂én̂t́îál̂ Śâv́îńĝś (m̂ś)"
+  },
+  "lighthouse-core/lib/i18n.js | displayValueWastedMs": {
+    "message": "P̂ót̂én̂t́îál̂ śâv́îńĝś ôf́ {wastedMs, number, milliseconds} m̂ś"
   }
 }
diff --git a/lighthouse-core/lib/locales/index.js b/lighthouse-core/lib/locales/index.js
index 9f70b5f3c..c0505399e 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 e84410700..d11111baf 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 582d81971..ce8afc382 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-extension/app/src/lighthouse-background.js b/lighthouse-extension/app/src/lighthouse-background.js
index 523afb9ab..32520faab 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 45724ba7c..a86b974dd 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


Test patch:
diff --git a/lighthouse-core/test/audits/metrics/interactive-test.js b/lighthouse-core/test/audits/metrics/interactive-test.js
index fcb8f52f6..d1b66c356 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/audits/time-to-first-byte-test.js b/lighthouse-core/test/audits/time-to-first-byte-test.js
index 5a668ae7d..d1f4258c0 100644
--- a/lighthouse-core/test/audits/time-to-first-byte-test.js
+++ b/lighthouse-core/test/audits/time-to-first-byte-test.js
@@ -11,14 +11,15 @@ const assert = require('assert');
 /* eslint-env jest */
 describe('Performance: time-to-first-byte audit', () => {
   it('fails when ttfb of root document is higher than 600ms', () => {
-    const networkRecords = [
-      {url: 'https://example.com/', requestId: '0', timing: {receiveHeadersEnd: 830, sendEnd: 200}},
-      {url: 'https://google.com/styles.css', requestId: '1', timing: {receiveHeadersEnd: 450, sendEnd: 200}},
-      {url: 'https://google.com/image.jpg', requestId: '2', timing: {receiveHeadersEnd: 600, sendEnd: 400}},
-    ];
+    const mainResource = {
+      url: 'https://example.com/',
+      requestId: '0',
+      timing: {receiveHeadersEnd: 830, sendEnd: 200},
+    };
+
     const artifacts = {
       devtoolsLogs: {[TimeToFirstByte.DEFAULT_PASS]: []},
-      requestNetworkRecords: () => Promise.resolve(networkRecords),
+      requestMainResource: () => Promise.resolve(mainResource),
       URL: {finalUrl: 'https://example.com/'},
     };
 
@@ -29,14 +30,15 @@ describe('Performance: time-to-first-byte audit', () => {
   });
 
   it('succeeds when ttfb of root document is lower than 600ms', () => {
-    const networkRecords = [
-      {url: 'https://example.com/', requestId: '0', timing: {receiveHeadersEnd: 400, sendEnd: 200}},
-      {url: 'https://google.com/styles.css', requestId: '1', timing: {receiveHeadersEnd: 850, sendEnd: 200}},
-      {url: 'https://google.com/image.jpg', requestId: '2', timing: {receiveHeadersEnd: 1000, sendEnd: 400}},
-    ];
+    const mainResource = {
+      url: 'https://example.com/',
+      requestId: '0',
+      timing: {receiveHeadersEnd: 400, sendEnd: 200},
+    };
+
     const artifacts = {
       devtoolsLogs: {[TimeToFirstByte.DEFAULT_PASS]: []},
-      requestNetworkRecords: () => Promise.resolve(networkRecords),
+      requestMainResource: () => Promise.resolve(mainResource),
       URL: {finalUrl: 'https://example.com/'},
     };
 
@@ -45,25 +47,4 @@ describe('Performance: time-to-first-byte audit', () => {
       assert.strictEqual(result.score, 1);
     });
   });
-
-  it('throws when somehow finalUrl is not in network records', async () => {
-    const networkRecords = [
-      {url: 'https://example.com/', requestId: '0', timing: {receiveHeadersEnd: 400, sendEnd: 200}},
-      {url: 'https://google.com/styles.css', requestId: '1', timing: {receiveHeadersEnd: 850, sendEnd: 200}},
-      {url: 'https://google.com/image.jpg', requestId: '2', timing: {receiveHeadersEnd: 1000, sendEnd: 400}},
-    ];
-    const badFinalUrl = 'https://badexample.com/';
-    const artifacts = {
-      devtoolsLogs: {[TimeToFirstByte.DEFAULT_PASS]: []},
-      requestNetworkRecords: () => Promise.resolve(networkRecords),
-      URL: {finalUrl: badFinalUrl},
-    };
-
-    try {
-      await TimeToFirstByte.audit(artifacts);
-      throw new Error('TimeToFirstByte did not throw');
-    } catch (e) {
-      assert.ok(e.message.includes(badFinalUrl));
-    }
-  });
 });
diff --git a/lighthouse-core/test/lib/i18n-test.js b/lighthouse-core/test/lib/i18n-test.js
new file mode 100644
index 000000000..300bded7a
--- /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 02899b656..b87617f1d 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