core: share localization between core and report (#13146)

This commit is contained in:
Brendan Kenny 2021-10-04 13:26:24 -05:00 коммит произвёл GitHub
Родитель f66b0fe6d0
Коммит e113614f56
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
88 изменённых файлов: 915 добавлений и 794 удалений

Просмотреть файл

@ -32,8 +32,8 @@ const audits = LighthouseRunner.getAuditList()
const gatherers = LighthouseRunner.getGathererList()
.map(f => './lighthouse-core/gather/gatherers/' + f.replace(/\.js$/, ''));
const locales = fs.readdirSync(LH_ROOT + '/lighthouse-core/lib/i18n/locales/')
.map(f => require.resolve(`../lighthouse-core/lib/i18n/locales/${f}`));
const locales = fs.readdirSync(LH_ROOT + '/shared/localization/locales/')
.map(f => require.resolve(`../shared/localization/locales/${f}`));
// HACK: manually include the lighthouse-plugin-publisher-ads audits.
/** @type {Array<string>} */

Просмотреть файл

@ -9,7 +9,7 @@ const rollup = require('rollup');
const rollupPlugins = require('./rollup-plugins.js');
const fs = require('fs');
const {LH_ROOT} = require('../root.js');
const {getIcuMessageIdParts} = require('../lighthouse-core/lib/i18n/i18n.js');
const {getIcuMessageIdParts} = require('../shared/localization/format.js');
/**
* Extract only the strings needed for the flow report into
@ -17,7 +17,7 @@ const {getIcuMessageIdParts} = require('../lighthouse-core/lib/i18n/i18n.js');
* are locale codes (en-US, es, etc.) and values are localized UIStrings.
*/
function buildFlowStrings() {
const locales = require('../lighthouse-core/lib/i18n/locales.js');
const locales = require('../shared/localization/locales.js');
// TODO(esmodules): use dynamic import when build/ is esm.
const i18nCode = fs.readFileSync(`${LH_ROOT}/flow-report/src/i18n/ui-strings.js`, 'utf-8');
const UIStrings = eval(i18nCode.replace(/export /g, '') + '\nmodule.exports = UIStrings;');

Просмотреть файл

@ -10,8 +10,8 @@
/* eslint-disable no-console */
const fs = require('fs');
const path = require('path');
const swapLocale = require('../lighthouse-core/lib/i18n/swap-locale.js');
const swapFlowLocale = require('../lighthouse-core/lib/i18n/swap-flow-locale.js');
const swapLocale = require('../shared/localization/swap-locale.js');
const swapFlowLocale = require('../shared/localization/swap-flow-locale.js');
const ReportGenerator = require('../report/generator/report-generator.js');
const {defaultSettings} = require('../lighthouse-core/config/constants.js');
const lighthouse = require('../lighthouse-core/index.js');

Просмотреть файл

@ -8,7 +8,7 @@
const fs = require('fs');
const GhPagesApp = require('./gh-pages-app.js');
const {LH_ROOT} = require('../root.js');
const {getIcuMessageIdParts} = require('../lighthouse-core/lib/i18n/i18n.js');
const {getIcuMessageIdParts} = require('../shared/localization/format.js');
/**
* Extract only the strings needed for lighthouse-treemap into
@ -16,7 +16,7 @@ const {getIcuMessageIdParts} = require('../lighthouse-core/lib/i18n/i18n.js');
* are locale codes (en-US, es, etc.) and values are localized UIStrings.
*/
function buildStrings() {
const locales = require('../lighthouse-core/lib/i18n/locales.js');
const locales = require('../shared/localization/locales.js');
// TODO(esmodules): use dynamic import when build/ is esm.
const utilCode = fs.readFileSync(LH_ROOT + '/lighthouse-treemap/app/src/util.js', 'utf-8');
const {UIStrings} = eval(utilCode.replace(/export /g, '') + '\nmodule.exports = TreemapUtil;');

Просмотреть файл

@ -8,7 +8,8 @@
const lighthouse = require('../lighthouse-core/index.js');
const RawProtocol = require('../lighthouse-core/gather/connections/raw.js');
const log = require('lighthouse-logger');
const {registerLocaleData, lookupLocale} = require('../lighthouse-core/lib/i18n/i18n.js');
const {lookupLocale} = require('../lighthouse-core/lib/i18n/i18n.js');
const {registerLocaleData} = require('../shared/localization/format.js');
const constants = require('../lighthouse-core/config/constants.js');
/** @typedef {import('../lighthouse-core/gather/connections/connection.js')} Connection */

Просмотреть файл

@ -9,7 +9,7 @@
const SettingsController = require('../../extension/scripts/settings-controller.js');
const defaultConfig = require('../../../lighthouse-core/config/default-config.js');
const i18n = require('../../../lighthouse-core/lib/i18n/i18n.js');
const format = require('../../../shared/localization/format.js');
describe('Lighthouse chrome extension SettingsController', () => {
it('default categories should be correct', () => {
@ -17,7 +17,7 @@ describe('Lighthouse chrome extension SettingsController', () => {
.map(([id, category]) => {
return {
id,
title: i18n.getFormatted(category.title, 'en-US'),
title: format.getFormatted(category.title, 'en-US'),
};
});
expect(SettingsController.DEFAULT_CATEGORIES).toEqual(categories);

Просмотреть файл

@ -17,6 +17,7 @@ module.exports = {
'**/lighthouse-viewer/**/*-test.js',
'**/third-party/**/*-test.js',
'**/clients/test/**/*-test.js',
'**/shared/**/*-test.js',
],
transform: {},
prettierPath: null,

Просмотреть файл

@ -13,7 +13,7 @@ import yargs from 'yargs';
import * as yargsHelpers from 'yargs/helpers';
import {LH_ROOT} from '../root.js';
import {isObjectOfUnknownValues} from '../lighthouse-core/lib/type-verifiers.js';
import {isObjectOfUnknownValues} from '../shared/type-verifiers.js';
/**
* @param {string=} manualArgv

Просмотреть файл

@ -8,7 +8,7 @@
const defaultConfigPath = './default-config.js';
const defaultConfig = require('./default-config.js');
const constants = require('./constants.js');
const i18n = require('./../lib/i18n/i18n.js');
const format = require('../../shared/localization/format.js');
const validation = require('./../fraggle-rock/config/validation.js');
const log = require('lighthouse-logger');
@ -248,7 +248,7 @@ class Config {
}
// Printed config is more useful with localized strings.
i18n.replaceIcuMessages(jsonConfig, jsonConfig.settings.locale);
format.replaceIcuMessages(jsonConfig, jsonConfig.settings.locale);
return JSON.stringify(jsonConfig, null, 2);
}

Просмотреть файл

@ -20,7 +20,7 @@ const emulation = require('../../lib/emulation.js');
const {defaultNavigationConfig} = require('../../config/constants.js');
const {initializeConfig} = require('../config/config.js');
const {getBaseArtifacts, finalizeArtifacts} = require('./base-artifacts.js');
const i18n = require('../../lib/i18n/i18n.js');
const format = require('../../../shared/localization/format.js');
const LighthouseError = require('../../lib/lh-error.js');
const {getPageLoadError} = require('../../lib/navigation-error.js');
const Trace = require('../../gather/gatherers/trace.js');
@ -165,7 +165,7 @@ async function _computeNavigationResult(
if (pageLoadError) {
const locale = navigationContext.config.settings.locale;
const localizedMessage = i18n.getFormatted(pageLoadError.friendlyMessage, locale);
const localizedMessage = format.getFormatted(pageLoadError.friendlyMessage, locale);
log.error('NavigationRunner', localizedMessage, navigationContext.requestedUrl);
/** @type {Partial<LH.GathererArtifacts>} */

Просмотреть файл

@ -10,7 +10,7 @@ const NetworkRecords = require('../computed/network-records.js');
const {getPageLoadError} = require('../lib/navigation-error.js');
const emulation = require('../lib/emulation.js');
const constants = require('../config/constants.js');
const i18n = require('../lib/i18n/i18n.js');
const format = require('../../shared/localization/format.js');
const {getBenchmarkIndex, getEnvironmentWarnings} = require('./driver/environment.js');
const prepare = require('./driver/prepare.js');
const storage = require('./driver/storage.js');
@ -574,7 +574,7 @@ class GatherRunner {
networkRecords: loadData.networkRecords,
});
if (pageLoadError) {
const localizedMessage = i18n.getFormatted(pageLoadError.friendlyMessage,
const localizedMessage = format.getFormatted(pageLoadError.friendlyMessage,
passContext.settings.locale);
log.error('GatherRunner', localizedMessage, passContext.url);

Просмотреть файл

@ -18,6 +18,7 @@ const {Directive} = require('csp_evaluator/dist/csp.js');
const log = require('lighthouse-logger');
const i18n = require('../lib/i18n/i18n.js');
const {isIcuMessage} = require('../../shared/localization/format.js');
const UIStrings = {
/** Message shown when a CSP does not have a base-uri directive. Shown in a table with a list of other CSP vulnerabilities and suggestions. "CSP" stands for "Content Security Policy". "base-uri", "'none'", and "'self'" do not need to be translated. */
@ -130,7 +131,7 @@ function getTranslatedDescription(finding) {
}
// Return if translated result found.
if (i18n.isIcuMessage(result)) return result;
if (isIcuMessage(result)) return result;
// If result was not translated, that means `finding.value` is included in the UI string.
if (typeof result === 'string') return str_(result, {keyword: finding.value || ''});

Просмотреть файл

@ -1,7 +1,7 @@
# Terminology
* **CTC format**: The [Chrome extension & Chrome app i18n format](https://developer.chrome.com/extensions/i18n-messages) with some minor changes. JSON with their specified model for declaring placeholders, examples, etc. Used as an interchange data format.
* **LHL syntax** (Lighthouse Localizable syntax): The ICU-friendly string syntax that is used to author `UIStrings` and is seen in the locale files in `i18n/locales/*.json`. Lighthouse has a custom syntax these strings combines many ICU message features along with some markdown.
* **LHL syntax** (Lighthouse Localizable syntax): The ICU-friendly string syntax that is used to author `UIStrings` and is seen in the locale files in `shared/localization/locales/*.json`. Lighthouse has a custom syntax these strings combines many ICU message features along with some markdown.
* **ICU**: ICU (International Components for Unicode) is a localization project and standard defined by the Unicode consortium. In general, we refer to "ICU" as the [ICU message formatting](http://userguide.icu-project.org/formatparse/messages) syntax.
# The Lighthouse i18n pipeline
@ -12,11 +12,11 @@ The collection and translation pipeline:
```
Source files: Locale files:
+---------------------------+ +----------------------------------------------
| ++ | lighthouse-core/lib/i18n/locales/en-US.json |
| const UIStrings = { ... };|-+ +---> | lighthouse-core/lib/i18n/locales/en-XL.json |
| ++ | shared/localization/locales/en-US.json |
| const UIStrings = { ... };|-+ +---> | shared/localization/locales/en-XL.json |
| |-| | +----------------------------------------------+
+-----------------------------| | | ||
+----------------------------| | | lighthouse-core/lib/i18n/locales/*.json |-<+
+----------------------------| | | shared/localization/locales/*.json |-<+
+---------------------------+ | | || |
| | +----------------------------------------------| |
$ yarn | | +---------------------------------------------+ |
@ -218,7 +218,7 @@ CTC is a name that is distinct and identifies this as the Chrome translation for
1. String called in `.js` file, converted to `LH.IcuMessage` object.
1. Message object is replaced with the localized string via
`i18n.replaceIcuMessages` and `i18n.getFormatted`.
`format.replaceIcuMessages` and `format.getFormatted`.
#### Example:
@ -251,7 +251,7 @@ CTC is a name that is distinct and identifies this as the Chrome translation for
}
```
3. Lookup in `i18n.replaceIcuMessages` and `i18n.getFormatted` will attempt to find the message in this order:
3. Lookup in `format.replaceIcuMessages` and `format.getFormatted` will attempt to find the message in this order:
1. `locales/{locale}.json` The best result. `icuMessage.i18nId` is found in the target locale and the resulting string should appear correct.

Просмотреть файл

@ -5,23 +5,17 @@
*/
'use strict';
/** @typedef {import('../../lib/i18n/locales').LhlMessages} LhlMessages */
/** @typedef {import('../../../shared/localization/locales').LhlMessages} LhlMessages */
const path = require('path');
const MessageFormat = require('intl-messageformat').default;
const lookupClosestLocale = require('lookup-closest-locale');
const LOCALES = require('./locales.js');
const {isObjectOfUnknownValues, isObjectOrArrayOfUnknownValues} = require('../type-verifiers.js');
const {getAvailableLocales} = require('../../../shared/localization/format.js');
const log = require('lighthouse-logger');
const {LH_ROOT} = require('../../../root.js');
const {isIcuMessage, _formatMessage} = require('../../../shared/localization/format.js');
const DEFAULT_LOCALE = 'en';
/** @typedef {import('intl-messageformat-parser').Element} MessageElement */
/** @typedef {import('intl-messageformat-parser').ArgumentElement} ArgumentElement */
const MESSAGE_I18N_ID_REGEX = / | [^\s]+$/;
const UIStrings = {
/** Used to show the duration in milliseconds that something lasted. The `{timeInMs}` placeholder will be replaced with the time duration, shown in milliseconds (e.g. 63 ms) */
ms: '{timeInMs, number, milliseconds}\xa0ms',
@ -117,27 +111,6 @@ const UIStrings = {
itemSeverityHigh: 'High',
};
const formats = {
number: {
bytes: {
maximumFractionDigits: 0,
},
milliseconds: {
maximumFractionDigits: 0,
},
seconds: {
// Force the seconds to the tenths place for limited output and ease of scanning
minimumFractionDigits: 1,
maximumFractionDigits: 1,
},
extendedPercent: {
// Force allow up to two digits after decimal place in percentages. (Intl.NumberFormat options)
maximumFractionDigits: 2,
style: 'percent',
},
},
};
/**
* Look up the best available locale for the requested language through these fall backs:
* - exact match
@ -160,7 +133,12 @@ function lookupLocale(locales) {
// Filter by what's available in this runtime.
const availableLocales = Intl.NumberFormat.supportedLocalesOf(canonicalLocales);
const closestLocale = lookupClosestLocale(availableLocales, LOCALES);
// Get available locales and transform into object to match `lookupClosestLocale`'s API.
const localesWithMessages = getAvailableLocales();
const localesWithmessagesObj = /** @type {Record<LH.Locale, LhlMessages>} */ (
Object.fromEntries(localesWithMessages.map(l => [l, {}])));
const closestLocale = lookupClosestLocale(availableLocales, localesWithmessagesObj);
if (!closestLocale) {
// Log extra info if we're pretty sure this version of Node was built with `--with-intl=small-icu`.
@ -174,191 +152,6 @@ function lookupLocale(locales) {
return closestLocale || DEFAULT_LOCALE;
}
/**
* Function to retrieve all 'argumentElement's from an ICU message. An argumentElement
* is an ICU element with an argument in it, like '{varName}' or '{varName, number, bytes}'. This
* differs from 'messageElement's which are just arbitrary text in a message.
*
* Notes:
* This function will recursively inspect plural elements for nested argumentElements.
*
* We need to find all the elements from the plural format sections, but
* they need to be deduplicated. I.e. "=1{hello {icu}} =other{hello {icu}}"
* the variable "icu" would appear twice if it wasn't de duplicated. And they cannot
* be stored in a set because they are not equal since their locations are different,
* thus they are stored via a Map keyed on the "id" which is the ICU varName.
*
* @param {Array<MessageElement>} icuElements
* @param {Map<string, ArgumentElement>} [seenElementsById]
* @return {Map<string, ArgumentElement>}
*/
function collectAllCustomElementsFromICU(icuElements, seenElementsById = new Map()) {
for (const el of icuElements) {
// We are only interested in elements that need ICU formatting (argumentElements)
if (el.type !== 'argumentElement') continue;
seenElementsById.set(el.id, el);
// Plurals need to be inspected recursively
if (!el.format || el.format.type !== 'pluralFormat') continue;
// Look at all options of the plural (=1{} =other{}...)
for (const option of el.format.options) {
// Run collections on each option's elements
collectAllCustomElementsFromICU(option.value.elements, seenElementsById);
}
}
return seenElementsById;
}
/**
* Returns a copy of the `values` object, with the values formatted based on how
* they will be used in their icuMessage, e.g. KB or milliseconds. The original
* object is unchanged.
* @param {MessageFormat} messageFormatter
* @param {Readonly<Record<string, string | number>>} values
* @param {string} lhlMessage Used for clear error logging.
* @return {Record<string, string | number>}
*/
function _preformatValues(messageFormatter, values, lhlMessage) {
const elementMap = collectAllCustomElementsFromICU(messageFormatter.getAst().elements);
const argumentElements = [...elementMap.values()];
/** @type {Record<string, string | number>} */
const formattedValues = {};
for (const {id, format} of argumentElements) {
// Throw an error if a message's value isn't provided
if (id && (id in values) === false) {
throw new Error(`ICU Message "${lhlMessage}" contains a value reference ("${id}") ` +
`that wasn't provided`);
}
const value = values[id];
// Direct `{id}` replacement and non-numeric values need no formatting.
if (!format || format.type !== 'numberFormat') {
formattedValues[id] = value;
continue;
}
if (typeof value !== 'number') {
throw new Error(`ICU Message "${lhlMessage}" contains a numeric reference ("${id}") ` +
'but provided value was not a number');
}
// Format values for known styles.
if (format.style === 'milliseconds') {
// Round all milliseconds to the nearest 10.
formattedValues[id] = Math.round(value / 10) * 10;
} else if (format.style === 'seconds' && id === 'timeInMs') {
// Convert all seconds to the correct unit (currently only for `timeInMs`).
formattedValues[id] = Math.round(value / 100) / 10;
} else if (format.style === 'bytes') {
// Replace all the bytes with KB.
formattedValues[id] = value / 1024;
} else {
// For all other number styles, the value isn't changed.
formattedValues[id] = value;
}
}
// Throw an error if a value is provided but has no placeholder in the message.
for (const valueId of Object.keys(values)) {
if (valueId in formattedValues) continue;
// errorCode is a special case always allowed to help LHError ease-of-use.
if (valueId === 'errorCode') {
formattedValues.errorCode = values.errorCode;
continue;
}
throw new Error(`Provided value "${valueId}" does not match any placeholder in ` +
`ICU message "${lhlMessage}"`);
}
return formattedValues;
}
/**
* Format string `message` by localizing `values` and inserting them.
* @param {string} message
* @param {Record<string, string | number>} values
* @param {LH.Locale} locale
* @return {string}
*/
function _formatMessage(message, values = {}, locale) {
// When using accented english, force the use of a different locale for number formatting.
const localeForMessageFormat = (locale === 'en-XA' || locale === 'en-XL') ? 'de-DE' : locale;
const formatter = new MessageFormat(message, localeForMessageFormat, formats);
// Preformat values for the message format like KB and milliseconds.
const valuesForMessageFormat = _preformatValues(formatter, values, message);
return formatter.format(valuesForMessageFormat);
}
/**
* Retrieves the localized version of `icuMessage` and formats with any given
* value replacements.
* @param {LH.IcuMessage} icuMessage
* @param {LH.Locale} locale
* @return {string}
*/
function _localizeIcuMessage(icuMessage, locale) {
const localeMessages = LOCALES[locale];
if (!localeMessages) throw new Error(`Unsupported locale '${locale}'`);
const localeMessage = localeMessages[icuMessage.i18nId];
// Fall back to the default (usually the original english message) if we couldn't find a
// message in the specified locale. This could be because of string drift between
// Lighthouse versions or because new strings haven't been updated yet. Better to have
// an english message than no message at all; in some cases it won't even matter.
if (!localeMessage) {
return icuMessage.formattedDefault;
}
return _formatMessage(localeMessage.message, icuMessage.values, locale);
}
/** @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;
}
/**
* @param {LH.Locale} locale
* @return {Record<string, string>}
*/
function getRendererFormattedStrings(locale) {
const localeMessages = LOCALES[locale];
if (!localeMessages) throw new Error(`Unsupported locale '${locale}'`);
const icuMessageIds = Object.keys(localeMessages).filter(f => f.startsWith('report/'));
/** @type {Record<string, string>} */
const strings = {};
for (const icuMessageId of icuMessageIds) {
const {filename, key} = getIcuMessageIdParts(icuMessageId);
if (!filename.endsWith('util.js')) throw new Error(`Unexpected message: ${icuMessageId}`);
strings[key] = localeMessages[icuMessageId].message;
}
return strings;
}
/**
* Returns a function that generates `LH.IcuMessage` objects to localize the
* messages in `fileStrings` and the shared `i18n.UIStrings`.
@ -396,125 +189,6 @@ function createIcuMessageFn(filename, fileStrings) {
return getIcuMessageFn;
}
/**
* Returns whether `icuMessageOrNot`` is an `LH.IcuMessage` instance.
* @param {unknown} icuMessageOrNot
* @return {icuMessageOrNot is LH.IcuMessage}
*/
function isIcuMessage(icuMessageOrNot) {
if (!isObjectOfUnknownValues(icuMessageOrNot)) {
return false;
}
const {i18nId, values, formattedDefault} = icuMessageOrNot;
if (typeof i18nId !== 'string') {
return false;
}
// formattedDefault is required.
if (typeof formattedDefault !== 'string') {
return false;
}
// Values is optional.
if (values !== undefined) {
if (!isObjectOfUnknownValues(values)) {
return false;
}
for (const value of Object.values(values)) {
if (typeof value !== 'string' && typeof value !== 'number') {
return false;
}
}
}
// Finally return true if i18nId seems correct.
return MESSAGE_I18N_ID_REGEX.test(i18nId);
}
/**
* Get the localized and formatted form of `icuMessageOrRawString` if it's an
* LH.IcuMessage, or get it back directly if it's already a string.
* Warning: this function throws if `icuMessageOrRawString` is not the expected
* type (use function from `createIcuMessageFn` to create a valid LH.IcuMessage)
* or `locale` isn't supported (use `lookupLocale` to find a valid locale).
* @param {LH.IcuMessage | string} icuMessageOrRawString
* @param {LH.Locale} locale
* @return {string}
*/
function getFormatted(icuMessageOrRawString, locale) {
if (isIcuMessage(icuMessageOrRawString)) {
return _localizeIcuMessage(icuMessageOrRawString, locale);
}
if (typeof icuMessageOrRawString === 'string') {
return icuMessageOrRawString;
}
// Should be impossible from types, but do a strict check in case malformed JSON makes it this far.
throw new Error('Attempted to format invalid icuMessage type');
}
/**
* Recursively walk the input object, looking for property values that are
* `LH.IcuMessage`s and replace them with their localized values. Primarily
* used with the full LHR or a Config as input.
* Returns a map of locations that were replaced to the `IcuMessage` that was at
* that location.
* @param {unknown} inputObject
* @param {LH.Locale} locale
* @return {LH.Result.IcuMessagePaths}
*/
function replaceIcuMessages(inputObject, locale) {
/**
* @param {unknown} subObject
* @param {LH.Result.IcuMessagePaths} icuMessagePaths
* @param {string[]} pathInLHR
*/
function replaceInObject(subObject, icuMessagePaths, pathInLHR = []) {
if (!isObjectOrArrayOfUnknownValues(subObject)) return;
for (const [property, possibleIcuMessage] of Object.entries(subObject)) {
const currentPathInLHR = pathInLHR.concat([property]);
// Replace any IcuMessages with a localized string.
if (isIcuMessage(possibleIcuMessage)) {
const formattedString = _localizeIcuMessage(possibleIcuMessage, locale);
const messageInstancesInLHR = icuMessagePaths[possibleIcuMessage.i18nId] || [];
const currentPathAsString = _formatPathAsString(currentPathInLHR);
messageInstancesInLHR.push(
possibleIcuMessage.values ?
{values: possibleIcuMessage.values, path: currentPathAsString} :
currentPathAsString
);
// @ts-ignore - tsc doesn't like that `property` can be either string key or array index.
subObject[property] = formattedString;
icuMessagePaths[possibleIcuMessage.i18nId] = messageInstancesInLHR;
} else {
replaceInObject(possibleIcuMessage, icuMessagePaths, currentPathInLHR);
}
}
}
/** @type {LH.Result.IcuMessagePaths} */
const icuMessagePaths = {};
replaceInObject(inputObject, icuMessagePaths);
return icuMessagePaths;
}
/**
* Populate the i18n string lookup dict with locale data
* Used when the host environment selects the locale and serves lighthouse the intended locale file
* @see https://docs.google.com/document/d/1jnt3BqKB-4q3AE94UWFA0Gqspx8Sd_jivlB7gQMlmfk/edit
* @param {LH.Locale} locale
* @param {LhlMessages} lhlMessages
*/
function registerLocaleData(locale, lhlMessages) {
LOCALES[locale] = lhlMessages;
}
/**
* Returns true if the given value is a string or an LH.IcuMessage.
* @param {unknown} value
@ -524,30 +198,11 @@ function isStringOrIcuMessage(value) {
return typeof value === 'string' || isIcuMessage(value);
}
/**
* @param {string} i18nMessageId
*/
function getIcuMessageIdParts(i18nMessageId) {
if (!MESSAGE_I18N_ID_REGEX.test(i18nMessageId)) {
throw Error(`"${i18nMessageId}" does not appear to be a valid ICU message id`);
}
const [filename, key] = i18nMessageId.split(' | ');
return {filename, key};
}
module.exports = {
_formatPathAsString,
UIStrings,
lookupLocale,
getRendererFormattedStrings,
createIcuMessageFn,
getFormatted,
replaceIcuMessages,
isIcuMessage,
collectAllCustomElementsFromICU,
registerLocaleData,
isStringOrIcuMessage,
// TODO: exported for backwards compatibility. Consider removing on future breaking change.
createMessageInstanceIdFn: createIcuMessageFn,
getIcuMessageIdParts,
};

Просмотреть файл

@ -11,7 +11,7 @@ const GatherRunner = require('./gather/gather-runner.js');
const ReportScoring = require('./scoring.js');
const Audit = require('./audits/audit.js');
const log = require('lighthouse-logger');
const i18n = require('./lib/i18n/i18n.js');
const format = require('../shared/localization/format.js');
const stackPacks = require('./lib/stack-packs.js');
const assetSaver = require('./lib/asset-saver.js');
const fs = require('fs');
@ -152,14 +152,14 @@ class Runner {
categoryGroups: runOpts.config.groups || undefined,
timing: this._getTiming(artifacts),
i18n: {
rendererFormattedStrings: i18n.getRendererFormattedStrings(settings.locale),
rendererFormattedStrings: format.getRendererFormattedStrings(settings.locale),
icuMessagePaths: {},
},
stackPacks: stackPacks.getStackPacks(artifacts.Stacks),
};
// Replace ICU message references with localized strings; save replaced paths in lhr.
i18nLhr.i18n.icuMessagePaths = i18n.replaceIcuMessages(i18nLhr, settings.locale);
i18nLhr.i18n.icuMessagePaths = format.replaceIcuMessages(i18nLhr, settings.locale);
// LHR has now been localized.
const lhr = /** @type {LH.Result} */ (i18nLhr);
@ -177,7 +177,7 @@ class Runner {
} catch (err) {
// i18n LighthouseError strings.
if (err.friendlyMessage) {
err.friendlyMessage = i18n.getFormatted(err.friendlyMessage, settings.locale);
err.friendlyMessage = format.getFormatted(err.friendlyMessage, settings.locale);
}
await Sentry.captureException(err, {level: 'fatal'});
throw err;
@ -301,7 +301,7 @@ class Runner {
static async _runAudit(auditDefn, artifacts, sharedAuditContext, runWarnings) {
const audit = auditDefn.implementation;
const status = {
msg: `Auditing: ${i18n.getFormatted(audit.meta.title, 'en-US')}`,
msg: `Auditing: ${format.getFormatted(audit.meta.title, 'en-US')}`,
id: `lh:audit:${audit.meta.id}`,
};
log.time(status);

Просмотреть файл

@ -26,7 +26,7 @@ node "$lhroot_path/lighthouse-core/scripts/i18n/collect-strings.js" || exit 1
set +x
colorText "Diff'ing committed strings against the fresh strings" "$purple"
git --no-pager diff --color=always --exit-code "$lhroot_path/lighthouse-core/lib/i18n/locales/"
git --no-pager diff --color=always --exit-code "$lhroot_path/shared/localization/locales/"
# Use the return value from last command
retVal=$?
@ -35,6 +35,6 @@ if [ $retVal -eq 0 ]; then
colorText "✅ PASS. All strings have been collected." "$green"
else
colorText "❌ FAIL. Strings have changed." "$red"
echo "Check lighthouse-core/lib/i18n/locales/ for unexpected string changes."
echo "Check shared/localization/locales/ for unexpected string changes."
fi
exit $retVal

Просмотреть файл

@ -598,7 +598,7 @@ async function collectAllStringsInDir(dir) {
* @param {Record<string, CtcMessage>} strings
*/
function writeStringsToCtcFiles(locale, strings) {
const fullPath = path.join(LH_ROOT, `lighthouse-core/lib/i18n/locales/${locale}.ctc.json`);
const fullPath = path.join(LH_ROOT, `shared/localization/locales/${locale}.ctc.json`);
/** @type {Record<string, CtcMessage>} */
const output = {};
const sortedEntries = Object.entries(strings).sort(([keyA], [keyB]) => keyA.localeCompare(keyB));
@ -705,7 +705,7 @@ async function main() {
console.log('Written to disk!', 'en-XL.ctc.json');
// Bake the ctc en-US and en-XL files into en-US and en-XL LHL format
const lhl = collectAndBakeCtcStrings(path.join(LH_ROOT, 'lighthouse-core/lib/i18n/locales/'));
const lhl = collectAndBakeCtcStrings(path.join(LH_ROOT, 'shared/localization/locales/'));
lhl.forEach(function(locale) {
console.log(`Baked ${locale} into LHL format.`);
});

Просмотреть файл

@ -5,14 +5,14 @@
*/
'use strict';
/** @typedef {import('../../lib/i18n/locales').LhlMessages} LhlMessages */
/** @typedef {import('../../../shared/localization/locales').LhlMessages} LhlMessages */
import glob from 'glob';
import {LH_ROOT, readJson} from '../../../root.js';
/** @type {LhlMessages} */
const enUsLhl = readJson('lighthouse-core/lib/i18n/locales/en-US.json');
const enUsLhl = readJson('shared/localization/locales/en-US.json');
/**
* Count how many locale files have a translated version of each string found in
@ -26,7 +26,7 @@ function countTranslatedMessages() {
'**/en-US.json',
'**/en-XL.json',
];
const globPattern = 'lighthouse-core/lib/i18n/locales/**/+([-a-zA-Z0-9]).json';
const globPattern = 'shared/localization/locales/**/+([-a-zA-Z0-9]).json';
const localeFilenames = glob.sync(globPattern, {
ignore,
cwd: LH_ROOT,

Просмотреть файл

@ -11,7 +11,7 @@ import path from 'path';
import glob from 'glob';
import MessageParser from 'intl-messageformat-parser';
import {collectAllCustomElementsFromICU} from '../../lib/i18n/i18n.js';
import {collectAllCustomElementsFromICU} from '../../../shared/localization/format.js';
import {LH_ROOT, readJson} from '../../../root.js';
/** @typedef {Record<string, {message: string}>} LhlMessages */
@ -117,7 +117,7 @@ function getGoldenLocaleArgumentIds(goldenLhl) {
* (e.g. by picking a new message id).
*/
function pruneObsoleteLhlMessages() {
const goldenLhl = readJson('lighthouse-core/lib/i18n/locales/en-US.json');
const goldenLhl = readJson('shared/localization/locales/en-US.json');
const goldenLocaleArgumentIds = getGoldenLocaleArgumentIds(goldenLhl);
// Find all locale files, ignoring self-generated en-US, en-XL, and ctc files.
@ -126,7 +126,7 @@ function pruneObsoleteLhlMessages() {
'**/en-US.json',
'**/en-XL.json',
];
const globPattern = 'lighthouse-core/lib/i18n/locales/**/+([-a-zA-Z0-9]).json';
const globPattern = 'shared/localization/locales/**/+([-a-zA-Z0-9]).json';
const localePaths = glob.sync(globPattern, {
ignore,
cwd: LH_ROOT,

Просмотреть файл

@ -22,7 +22,7 @@ import path from 'path';
import colors from 'colors';
import LegacyJavascript from '../../audits/byte-efficiency/legacy-javascript.js';
import i18n from '../../lib/i18n/i18n.js';
import format from '../../../shared/localization/format.js';
import {LH_ROOT, readJson} from '../../../root.js';
const LATEST_RUN_DIR = path.join(LH_ROOT, 'latest-run');
@ -86,7 +86,7 @@ async function main() {
for (let i = 0; i < signals.length; i++) {
const signal = signals[i];
const location = locations[i];
if (typeof location !== 'object' || i18n.isIcuMessage(location) ||
if (typeof location !== 'object' || format.isIcuMessage(location) ||
location.type !== 'source-location') {
continue;
}

Просмотреть файл

@ -65,7 +65,7 @@ rsync -avh dist/dt-report-resources/ "$fe_lh_report_assets_dir" --delete
echo -e "$check Report resources copied."
# copy locale JSON files (but not the .ctc.json ones)
lh_locales_dir="lighthouse-core/lib/i18n/locales/"
lh_locales_dir="shared/localization/locales/"
fe_locales_dir="$fe_lh_dir/locales"
rsync -avh "$lh_locales_dir" "$fe_locales_dir" --exclude="*.ctc.json" --delete
echo -e "$check Locale JSON files copied."

Просмотреть файл

@ -14,6 +14,7 @@ const log = require('lighthouse-logger');
const Gatherer = require('../../gather/gatherers/gatherer.js');
const Audit = require('../../audits/audit.js');
const i18n = require('../../lib/i18n/i18n.js');
const format = require('../../../shared/localization/format.js');
const {isNode12SmallIcu} = require('../test-utils.js');
/* eslint-env jest */
@ -1464,8 +1465,8 @@ describe('Config', () => {
Object.entries(printedConfig.categories).forEach(([printedCategoryId, printedCategory]) => {
const origTitle = origConfig.categories[printedCategoryId].title;
if (i18n.isIcuMessage(origTitle)) localizableCount++;
const i18nOrigTitle = i18n.getFormatted(origTitle, origConfig.settings.locale);
if (format.isIcuMessage(origTitle)) localizableCount++;
const i18nOrigTitle = format.getFormatted(origTitle, origConfig.settings.locale);
assert.strictEqual(printedCategory.title, i18nOrigTitle);
});

Просмотреть файл

@ -5,7 +5,7 @@
*/
'use strict';
const {isIcuMessage} = require('../../lib/i18n/i18n.js');
const {isIcuMessage} = require('../../../shared/localization/format.js');
const {getTranslatedDescription, parseCsp} = require('../../lib/csp-evaluator.js');
const {

Просмотреть файл

@ -13,26 +13,6 @@ const {isNode12SmallIcu} = require('../../test-utils.js');
/* 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 an IcuMessage reference', () => {
const fakeFile = path.join(__dirname, 'fake-file.js');
@ -47,94 +27,6 @@ describe('i18n', () => {
});
});
describe('#replaceIcuMessages', () => {
it('replaces the references in the LHR', () => {
const fakeFile = path.join(__dirname, 'fake-file-number-2.js');
const UIStrings = {aString: 'different {x}!'};
const formatter = i18n.createMessageInstanceIdFn(fakeFile, UIStrings);
const title = formatter(UIStrings.aString, {x: 1});
const lhr = {audits: {'fake-audit': {title}}};
const icuMessagePaths = i18n.replaceIcuMessages(lhr, 'en-US');
expect(lhr.audits['fake-audit'].title).toBe('different 1!');
const expectedPathId = 'lighthouse-core/test/lib/i18n/fake-file-number-2.js | aString';
expect(icuMessagePaths).toEqual({
[expectedPathId]: [{path: 'audits[fake-audit].title', values: {x: 1}}]});
});
});
describe('#getRendererFormattedStrings', () => {
it('returns icu messages in the specified locale', () => {
const strings = i18n.getRendererFormattedStrings('en-XA');
expect(strings.passedAuditsGroupTitle).toEqual('[Þåššéð åûðîţš one two]');
expect(strings.snippetCollapseButtonLabel).toEqual('[Çöļļåþšé šñîþþéţ one two]');
});
it('throws an error for invalid locales', () => {
expect(_ => i18n.getRendererFormattedStrings('not-a-locale'))
.toThrow(`Unsupported locale 'not-a-locale'`);
});
});
describe('#getFormatted', () => {
it('returns the formatted string', () => {
const UIStrings = {testMessage: 'happy test'};
const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
const formattedStr = i18n.getFormatted(str_(UIStrings.testMessage), 'en');
expect(formattedStr).toEqual('happy test');
});
it('returns the formatted string with replacements', () => {
const UIStrings = {testMessage: 'replacement test ({errorCode})'};
const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
const formattedStr = i18n.getFormatted(str_(UIStrings.testMessage, {errorCode: 'BOO'}), 'en');
expect(formattedStr).toEqual('replacement test (BOO)');
});
it('throws an error for invalid locales', () => {
// Populate a string to try to localize to a bad locale.
const UIStrings = {testMessage: 'testy test'};
const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
expect(_ => i18n.getFormatted(str_(UIStrings.testMessage), 'still-not-a-locale'))
.toThrow(`Unsupported locale 'still-not-a-locale'`);
});
it('does not alter the passed-in replacement values object', () => {
const UIStrings = {
testMessage: 'needs {count, number, bytes}KB test {str} in {timeInMs, number, seconds}s',
};
const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
const replacements = {
count: 2555,
str: '*units*',
timeInMs: 314159265,
};
const replacementsClone = JSON.parse(JSON.stringify(replacements));
const formattedStr = i18n.getFormatted(str_(UIStrings.testMessage, replacements), 'en');
expect(formattedStr).toEqual('needs 2KB test *units* in 314,159.3s');
expect(replacements).toEqual(replacementsClone);
});
it('returns a message that is already a string unchanged', () => {
const testString = 'kind of looks like it needs ({formatting})';
const formattedStr = i18n.getFormatted(testString, 'pl');
expect(formattedStr).toBe(testString);
});
it('throws an error if formatting something other than IcuMessages or strings', () => {
expect(_ => i18n.getFormatted(15, 'lt'))
.toThrow(`Attempted to format invalid icuMessage type`);
expect(_ => i18n.getFormatted(new Date(), 'sr-Latn'))
.toThrow(`Attempted to format invalid icuMessage type`);
});
});
describe('#lookupLocale', () => {
const invalidLocale = 'jk-Latn-DE-1996-a-ext-x-phonebk-i-klingon';
@ -189,257 +81,4 @@ describe('i18n', () => {
expect(i18n.lookupLocale(invalidLocale)).toEqual('en');
});
});
describe('#registerLocaleData', () => {
// Store original locale data so we can restore at the end
const moduleLocales = require('../../../lib/i18n/locales.js');
const clonedLocales = JSON.parse(JSON.stringify(moduleLocales));
it('installs new locale strings', () => {
const localeData = {
'lighthouse-core/test/lib/i18n/i18n-test.js | testString': {
'message': 'en-XZ cuerda!',
},
};
i18n.registerLocaleData('en-XZ', localeData);
const UIStrings = {testString: 'en-US string!'};
const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
const formattedStr = i18n.getFormatted(str_(UIStrings.testString), 'en-XZ');
expect(formattedStr).toEqual('en-XZ cuerda!');
});
it('overwrites existing locale strings', () => {
const filename = 'lighthouse-core/audits/is-on-https.js';
const UIStrings = require('../../../../lighthouse-core/audits/is-on-https.js').UIStrings;
const str_ = i18n.createMessageInstanceIdFn(filename, UIStrings);
// To start with, we get back the intended string..
const origTitle = i18n.getFormatted(str_(UIStrings.title), 'es-419');
expect(origTitle).toEqual('Usa HTTPS');
const origFailureTitle = i18n.getFormatted(str_(UIStrings.failureTitle), 'es-419');
expect(origFailureTitle).toEqual('No usa HTTPS');
// Now we declare and register the new string...
const localeData = {
'lighthouse-core/audits/is-on-https.js | title': {
'message': 'new string for es-419 uses https!',
},
};
i18n.registerLocaleData('es-419', localeData);
// And confirm that's what is returned
const newTitle = i18n.getFormatted(str_(UIStrings.title), 'es-419');
expect(newTitle).toEqual('new string for es-419 uses https!');
// Meanwhile another string that wasn't set in registerLocaleData just falls back to english
const newFailureTitle = i18n.getFormatted(str_(UIStrings.failureTitle), 'es-419');
expect(newFailureTitle).toEqual('Does not use HTTPS');
// Restore overwritten strings to avoid messing with other tests
moduleLocales['es-419'] = clonedLocales['es-419'];
const title = i18n.getFormatted(str_(UIStrings.title), 'es-419');
expect(title).toEqual('Usa HTTPS');
});
});
describe('#isIcuMessage', () => {
const icuMessage = {
i18nId: 'lighthouse-core/test/lib/i18n/fake-file.js | title',
values: {x: 1},
formattedDefault: 'a default',
};
it('passes a valid LH.IcuMessage', () => {
expect(i18n.isIcuMessage(icuMessage)).toBe(true);
});
it('fails non-objects', () => {
expect(i18n.isIcuMessage(undefined)).toBe(false);
expect(i18n.isIcuMessage(null)).toBe(false);
expect(i18n.isIcuMessage('ICU!')).toBe(false);
expect(i18n.isIcuMessage(55)).toBe(false);
expect(i18n.isIcuMessage([
icuMessage,
icuMessage,
])).toBe(false);
});
it('fails invalid or missing i18nIds', () => {
const badIdMessage = {...icuMessage, i18nId: 0};
expect(i18n.isIcuMessage(badIdMessage)).toBe(false);
const noIdMessage = {...icuMessage};
delete noIdMessage.i18nId;
expect(i18n.isIcuMessage(noIdMessage)).toBe(false);
});
it('fails invalid or missing formattedDefault', () => {
const badDefaultMessage = {...icuMessage, formattedDefault: -0};
expect(i18n.isIcuMessage(badDefaultMessage)).toBe(false);
const noDefaultMessage = {...icuMessage};
delete noDefaultMessage.formattedDefault;
expect(i18n.isIcuMessage(noDefaultMessage)).toBe(false);
});
it('passes missing values', () => {
const emptyValuesMessage = {...icuMessage, values: {}};
expect(i18n.isIcuMessage(emptyValuesMessage)).toBe(true);
const noValuesMessage = {...icuMessage};
delete noValuesMessage.values;
expect(i18n.isIcuMessage(noValuesMessage)).toBe(true);
});
it('fails invalid values types', () => {
const badValuesMessage = {...icuMessage, values: NaN};
expect(i18n.isIcuMessage(badValuesMessage)).toBe(false);
const nullValuesMessage = {...icuMessage, values: null};
expect(i18n.isIcuMessage(nullValuesMessage)).toBe(false);
});
it(`fails invalid values' values types`, () => {
const badValuesValuesMessage = {...icuMessage, values: {a: false}};
expect(i18n.isIcuMessage(badValuesValuesMessage)).toBe(false);
});
});
describe('#getIcuMessageIdParts', () => {
it('returns valid ICU message id parts', () => {
const {filename, key} = i18n.getIcuMessageIdParts('path/to/file.js | modeName');
expect(filename).toEqual('path/to/file.js');
expect(key).toEqual('modeName');
});
it('throws on invalid ICU message id', () => {
expect(() => {
i18n.getIcuMessageIdParts('path/to/file.js');
}).toThrow();
});
});
describe('Message values are properly formatted', () => {
// Message strings won't be in locale files, so will fall back to values given here.
const UIStrings = {
helloWorld: 'Hello World',
helloBytesWorld: 'Hello {in, number, bytes} World',
helloMsWorld: 'Hello {in, number, milliseconds} World',
helloSecWorld: 'Hello {in, number, seconds} World',
helloTimeInMsWorld: 'Hello {timeInMs, number, seconds} World',
helloPercentWorld: 'Hello {in, number, extendedPercent} World',
helloWorldMultiReplace: '{hello} {world}',
helloPlural: '{itemCount, plural, =1{1 hello} other{hellos}}',
helloPluralNestedICU: '{itemCount, plural, ' +
'=1{1 hello {in, number, bytes}} ' +
'other{hellos {in, number, bytes}}}',
helloPluralNestedPluralAndICU: '{itemCount, plural, ' +
'=1{{innerItemCount, plural, ' +
'=1{1 hello 1 goodbye {in, number, bytes}} ' +
'other{1 hello, goodbyes {in, number, bytes}}}} ' +
'other{{innerItemCount, plural, ' +
'=1{hellos 1 goodbye {in, number, bytes}} ' +
'other{hellos, goodbyes {in, number, bytes}}}}}',
};
const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
it('formats a basic message', () => {
const helloStr = str_(UIStrings.helloWorld);
expect(helloStr).toBeDisplayString('Hello World');
});
it('formats a message with bytes', () => {
const helloBytesStr = str_(UIStrings.helloBytesWorld, {in: 1875});
expect(helloBytesStr).toBeDisplayString('Hello 2 World');
});
it('formats a message with milliseconds', () => {
const helloMsStr = str_(UIStrings.helloMsWorld, {in: 432});
expect(helloMsStr).toBeDisplayString('Hello 430 World');
});
it('formats a message with seconds', () => {
const helloSecStr = str_(UIStrings.helloSecWorld, {in: 753});
expect(helloSecStr).toBeDisplayString('Hello 753.0 World');
});
it('formats a message with seconds timeInMs', () => {
const helloTimeInMsStr = str_(UIStrings.helloTimeInMsWorld, {timeInMs: 753543});
expect(helloTimeInMsStr).toBeDisplayString('Hello 753.5 World');
});
it('formats a message with extended percent', () => {
const helloPercentStr = str_(UIStrings.helloPercentWorld, {in: 0.43078});
expect(helloPercentStr).toBeDisplayString('Hello 43.08% World');
});
it('throws an error when values are needed but not provided', () => {
expect(_ => i18n.getFormatted(str_(UIStrings.helloBytesWorld), 'en-US'))
// eslint-disable-next-line max-len
.toThrow(`ICU Message "Hello {in, number, bytes} World" contains a value reference ("in") that wasn't provided`);
});
it('throws an error when a value is missing', () => {
expect(_ => i18n.getFormatted(str_(UIStrings.helloWorldMultiReplace,
{hello: 'hello'}), 'en-US'))
// eslint-disable-next-line max-len
.toThrow(`ICU Message "{hello} {world}" contains a value reference ("world") that wasn't provided`);
});
it('formats a message with plurals', () => {
const helloStr = str_(UIStrings.helloPlural, {itemCount: 3});
expect(helloStr).toBeDisplayString('hellos');
});
it('throws an error when a plural control value is missing', () => {
expect(_ => i18n.getFormatted(str_(UIStrings.helloPlural), 'en-US'))
// eslint-disable-next-line max-len
.toThrow(`ICU Message "{itemCount, plural, =1{1 hello} other{hellos}}" contains a value reference ("itemCount") that wasn't provided`);
});
it('formats a message with plurals and nested custom ICU', () => {
const helloStr = str_(UIStrings.helloPluralNestedICU, {itemCount: 3, in: 1875});
expect(helloStr).toBeDisplayString('hellos 2');
});
it('formats a message with plurals and nested custom ICU and nested plural', () => {
const helloStr = str_(UIStrings.helloPluralNestedPluralAndICU, {itemCount: 3,
innerItemCount: 1,
in: 1875});
expect(helloStr).toBeDisplayString('hellos 1 goodbye 2');
});
it('throws an error if a string value is used for a numeric placeholder', () => {
expect(_ => str_(UIStrings.helloTimeInMsWorld, {
timeInMs: 'string not a number',
}))
// eslint-disable-next-line max-len
.toThrow(`ICU Message "Hello {timeInMs, number, seconds} World" contains a numeric reference ("timeInMs") but provided value was not a number`);
});
it('throws an error if a value is provided that has no placeholder in the message', () => {
expect(_ => str_(UIStrings.helloTimeInMsWorld, {
timeInMs: 55,
sirNotAppearingInThisString: 66,
}))
// eslint-disable-next-line max-len
.toThrow(`Provided value "sirNotAppearingInThisString" does not match any placeholder in ICU message "Hello {timeInMs, number, seconds} World"`);
});
it('formats correctly with NaN and Infinity numeric values', () => {
const helloInfinityStr = str_(UIStrings.helloBytesWorld, {in: Infinity});
expect(helloInfinityStr).toBeDisplayString('Hello ∞ World');
const helloNaNStr = str_(UIStrings.helloBytesWorld, {in: NaN});
// TODO(COMPAT): workaround can be removed after Node 13 is retired.
// expect(helloNaNStr).toBeDisplayString('Hello NaN World');
// Node 13/V8 7.9 and 8.0 have a bug where `({a: NaN}).a.toLocaleString() === "-NaN"`. It
// works correctly in Node 12 and 14, so work around it since NaN isn't essential for
// user-facing strings and it will eventually correct itself.
const formattedNaNStr = i18n.getFormatted(helloNaNStr, 'en-US');
expect(formattedNaNStr === 'Hello NaN World' || formattedNaNStr === 'Hello -NaN World')
.toBe(true);
});
});
});

Просмотреть файл

@ -8,14 +8,14 @@
/* eslint-env jest */
const fs = require('fs');
const i18n = require('../lib/i18n/i18n.js');
const format = require('../../shared/localization/format.js');
const mockCommands = require('./gather/mock-commands.js');
const {default: {toBeCloseTo}} = require('expect/build/matchers.js');
const {LH_ROOT} = require('../../root.js');
expect.extend({
toBeDisplayString(received, expected) {
if (!i18n.isIcuMessage(received)) {
if (!format.isIcuMessage(received)) {
const message = () =>
[
`${this.utils.matcherHint('.toBeDisplayString')}\n`,
@ -27,7 +27,7 @@ expect.extend({
return {message, pass: false};
}
const actual = i18n.getFormatted(received, 'en-US');
const actual = format.getFormatted(received, 'en-US');
const pass = expected instanceof RegExp ?
expected.test(actual) :
actual === expected;

Просмотреть файл

@ -29,7 +29,7 @@
"build-viewer": "node ./build/build-viewer.js",
"reset-link": "(yarn unlink || true) && yarn link && yarn link lighthouse",
"c8": "bash lighthouse-core/scripts/c8.sh",
"clean": "rm -r dist proto/scripts/*.json proto/scripts/*_pb2.* proto/scripts/*_pb.* proto/scripts/__pycache__ proto/scripts/*.pyc *.report.html *.report.dom.html *.report.json *.devtoolslog.json *.trace.json lighthouse-core/lib/i18n/locales/*.ctc.json || true",
"clean": "rm -r dist proto/scripts/*.json proto/scripts/*_pb2.* proto/scripts/*_pb.* proto/scripts/__pycache__ proto/scripts/*.pyc *.report.html *.report.dom.html *.report.json *.devtoolslog.json *.trace.json shared/localization/locales/*.ctc.json || true",
"lint": "[ \"$CI\" = true ] && eslint --quiet -f codeframe . || eslint .",
"smoke": "node lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js",
"debug": "node --inspect-brk ./lighthouse-cli/index.js",

Просмотреть файл

@ -0,0 +1,408 @@
/**
* @license Copyright 2021 The Lighthouse Authors. 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 fs = require('fs');
const MessageFormat = require('intl-messageformat').default;
const {isObjectOfUnknownValues, isObjectOrArrayOfUnknownValues} = require('../type-verifiers.js');
/** Contains available locales with messages. May be an empty object if bundled. */
const LOCALE_MESSAGES = require('./locales.js');
/**
* The locale tags for the localized messages available to Lighthouse on disk.
* When bundled, these will be inlined by brfs.
*/
const DEFAULT_LOCALES = fs.readdirSync(__dirname + '/locales/')
.filter(basename => basename.endsWith('.json') && !basename.endsWith('.ctc.json'))
.map(locale => locale.replace('.json', ''));
/** @typedef {import('intl-messageformat-parser').Element} MessageElement */
/** @typedef {import('intl-messageformat-parser').ArgumentElement} ArgumentElement */
const MESSAGE_I18N_ID_REGEX = / | [^\s]+$/;
const formats = {
number: {
bytes: {
maximumFractionDigits: 0,
},
milliseconds: {
maximumFractionDigits: 0,
},
seconds: {
// Force the seconds to the tenths place for limited output and ease of scanning
minimumFractionDigits: 1,
maximumFractionDigits: 1,
},
extendedPercent: {
// Force allow up to two digits after decimal place in percentages. (Intl.NumberFormat options)
maximumFractionDigits: 2,
style: 'percent',
},
},
};
/**
* Function to retrieve all 'argumentElement's from an ICU message. An argumentElement
* is an ICU element with an argument in it, like '{varName}' or '{varName, number, bytes}'. This
* differs from 'messageElement's which are just arbitrary text in a message.
*
* Notes:
* This function will recursively inspect plural elements for nested argumentElements.
*
* We need to find all the elements from the plural format sections, but
* they need to be deduplicated. I.e. "=1{hello {icu}} =other{hello {icu}}"
* the variable "icu" would appear twice if it wasn't de duplicated. And they cannot
* be stored in a set because they are not equal since their locations are different,
* thus they are stored via a Map keyed on the "id" which is the ICU varName.
*
* @param {Array<MessageElement>} icuElements
* @param {Map<string, ArgumentElement>} [seenElementsById]
* @return {Map<string, ArgumentElement>}
*/
function collectAllCustomElementsFromICU(icuElements, seenElementsById = new Map()) {
for (const el of icuElements) {
// We are only interested in elements that need ICU formatting (argumentElements)
if (el.type !== 'argumentElement') continue;
seenElementsById.set(el.id, el);
// Plurals need to be inspected recursively
if (!el.format || el.format.type !== 'pluralFormat') continue;
// Look at all options of the plural (=1{} =other{}...)
for (const option of el.format.options) {
// Run collections on each option's elements
collectAllCustomElementsFromICU(option.value.elements, seenElementsById);
}
}
return seenElementsById;
}
/**
* Returns a copy of the `values` object, with the values formatted based on how
* they will be used in their icuMessage, e.g. KB or milliseconds. The original
* object is unchanged.
* @param {MessageFormat} messageFormatter
* @param {Readonly<Record<string, string | number>>} values
* @param {string} lhlMessage Used for clear error logging.
* @return {Record<string, string | number>}
*/
function _preformatValues(messageFormatter, values, lhlMessage) {
const elementMap = collectAllCustomElementsFromICU(messageFormatter.getAst().elements);
const argumentElements = [...elementMap.values()];
/** @type {Record<string, string | number>} */
const formattedValues = {};
for (const {id, format} of argumentElements) {
// Throw an error if a message's value isn't provided
if (id && (id in values) === false) {
throw new Error(`ICU Message "${lhlMessage}" contains a value reference ("${id}") ` +
`that wasn't provided`);
}
const value = values[id];
// Direct `{id}` replacement and non-numeric values need no formatting.
if (!format || format.type !== 'numberFormat') {
formattedValues[id] = value;
continue;
}
if (typeof value !== 'number') {
throw new Error(`ICU Message "${lhlMessage}" contains a numeric reference ("${id}") ` +
'but provided value was not a number');
}
// Format values for known styles.
if (format.style === 'milliseconds') {
// Round all milliseconds to the nearest 10.
formattedValues[id] = Math.round(value / 10) * 10;
} else if (format.style === 'seconds' && id === 'timeInMs') {
// Convert all seconds to the correct unit (currently only for `timeInMs`).
formattedValues[id] = Math.round(value / 100) / 10;
} else if (format.style === 'bytes') {
// Replace all the bytes with KB.
formattedValues[id] = value / 1024;
} else {
// For all other number styles, the value isn't changed.
formattedValues[id] = value;
}
}
// Throw an error if a value is provided but has no placeholder in the message.
for (const valueId of Object.keys(values)) {
if (valueId in formattedValues) continue;
// errorCode is a special case always allowed to help LHError ease-of-use.
if (valueId === 'errorCode') {
formattedValues.errorCode = values.errorCode;
continue;
}
throw new Error(`Provided value "${valueId}" does not match any placeholder in ` +
`ICU message "${lhlMessage}"`);
}
return formattedValues;
}
/**
* Format string `message` by localizing `values` and inserting them. `message`
* is assumed to already be in the given locale.
* If you need to localize a messagem `getFormatted` is probably what you want.
* @param {string} message
* @param {Record<string, string | number>} values
* @param {LH.Locale} locale
* @return {string}
*/
function _formatMessage(message, values = {}, locale) {
// When using accented english, force the use of a different locale for number formatting.
const localeForMessageFormat = (locale === 'en-XA' || locale === 'en-XL') ? 'de-DE' : locale;
const formatter = new MessageFormat(message, localeForMessageFormat, formats);
// Preformat values for the message format like KB and milliseconds.
const valuesForMessageFormat = _preformatValues(formatter, values, message);
return formatter.format(valuesForMessageFormat);
}
/**
* Retrieves the localized version of `icuMessage` and formats with any given
* value replacements.
* @param {LH.IcuMessage} icuMessage
* @param {LH.Locale} locale
* @return {string}
*/
function _localizeIcuMessage(icuMessage, locale) {
const localeMessages = LOCALE_MESSAGES[locale];
if (!localeMessages) throw new Error(`Unsupported locale '${locale}'`);
const localeMessage = localeMessages[icuMessage.i18nId];
// Fall back to the default (usually the original english message) if we couldn't find a
// message in the specified locale. This could be because of string drift between
// Lighthouse versions or because new strings haven't been updated yet. Better to have
// an english message than no message at all; in some cases it won't even matter.
if (!localeMessage) {
return icuMessage.formattedDefault;
}
return _formatMessage(localeMessage.message, icuMessage.values, locale);
}
/**
* @param {LH.Locale} locale
* @return {Record<string, string>}
*/
function getRendererFormattedStrings(locale) {
const localeMessages = LOCALE_MESSAGES[locale];
if (!localeMessages) throw new Error(`Unsupported locale '${locale}'`);
const icuMessageIds = Object.keys(localeMessages).filter(f => f.startsWith('report/'));
/** @type {Record<string, string>} */
const strings = {};
for (const icuMessageId of icuMessageIds) {
const {filename, key} = getIcuMessageIdParts(icuMessageId);
if (!filename.endsWith('util.js')) throw new Error(`Unexpected message: ${icuMessageId}`);
strings[key] = localeMessages[icuMessageId].message;
}
return strings;
}
/**
* Returns whether `icuMessageOrNot`` is an `LH.IcuMessage` instance.
* @param {unknown} icuMessageOrNot
* @return {icuMessageOrNot is LH.IcuMessage}
*/
function isIcuMessage(icuMessageOrNot) {
if (!isObjectOfUnknownValues(icuMessageOrNot)) {
return false;
}
const {i18nId, values, formattedDefault} = icuMessageOrNot;
if (typeof i18nId !== 'string') {
return false;
}
// formattedDefault is required.
if (typeof formattedDefault !== 'string') {
return false;
}
// Values is optional.
if (values !== undefined) {
if (!isObjectOfUnknownValues(values)) {
return false;
}
for (const value of Object.values(values)) {
if (typeof value !== 'string' && typeof value !== 'number') {
return false;
}
}
}
// Finally return true if i18nId seems correct.
return MESSAGE_I18N_ID_REGEX.test(i18nId);
}
/**
* Get the localized and formatted form of `icuMessageOrRawString` if it's an
* LH.IcuMessage, or get it back directly if it's already a string.
* Warning: this function throws if `icuMessageOrRawString` is not the expected
* type (use function from `createIcuMessageFn` to create a valid LH.IcuMessage)
* or `locale` isn't supported (use `lookupLocale` to find a valid locale).
* @param {LH.IcuMessage | string} icuMessageOrRawString
* @param {LH.Locale} locale
* @return {string}
*/
function getFormatted(icuMessageOrRawString, locale) {
if (isIcuMessage(icuMessageOrRawString)) {
return _localizeIcuMessage(icuMessageOrRawString, locale);
}
if (typeof icuMessageOrRawString === 'string') {
return icuMessageOrRawString;
}
// Should be impossible from types, but do a strict check in case malformed JSON makes it this far.
throw new Error('Attempted to format invalid icuMessage type');
}
/** @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;
}
/**
* Recursively walk the input object, looking for property values that are
* `LH.IcuMessage`s and replace them with their localized values. Primarily
* used with the full LHR or a Config as input.
* Returns a map of locations that were replaced to the `IcuMessage` that was at
* that location.
* @param {unknown} inputObject
* @param {LH.Locale} locale
* @return {LH.Result.IcuMessagePaths}
*/
function replaceIcuMessages(inputObject, locale) {
/**
* @param {unknown} subObject
* @param {LH.Result.IcuMessagePaths} icuMessagePaths
* @param {string[]} pathInLHR
*/
function replaceInObject(subObject, icuMessagePaths, pathInLHR = []) {
if (!isObjectOrArrayOfUnknownValues(subObject)) return;
for (const [property, possibleIcuMessage] of Object.entries(subObject)) {
const currentPathInLHR = pathInLHR.concat([property]);
// Replace any IcuMessages with a localized string.
if (isIcuMessage(possibleIcuMessage)) {
const formattedString = getFormatted(possibleIcuMessage, locale);
const messageInstancesInLHR = icuMessagePaths[possibleIcuMessage.i18nId] || [];
const currentPathAsString = _formatPathAsString(currentPathInLHR);
messageInstancesInLHR.push(
possibleIcuMessage.values ?
{values: possibleIcuMessage.values, path: currentPathAsString} :
currentPathAsString
);
// @ts-ignore - tsc doesn't like that `property` can be either string key or array index.
subObject[property] = formattedString;
icuMessagePaths[possibleIcuMessage.i18nId] = messageInstancesInLHR;
} else {
replaceInObject(possibleIcuMessage, icuMessagePaths, currentPathInLHR);
}
}
}
/** @type {LH.Result.IcuMessagePaths} */
const icuMessagePaths = {};
replaceInObject(inputObject, icuMessagePaths);
return icuMessagePaths;
}
/**
* Returns whether the `requestedLocale` can be used.
* @param {LH.Locale} requestedLocale
* @return {boolean}
*/
function hasLocale(requestedLocale) {
const hasIntlSupport = Intl.NumberFormat.supportedLocalesOf([requestedLocale]).length > 0;
const hasMessages = Boolean(LOCALE_MESSAGES[requestedLocale]);
return hasIntlSupport && hasMessages;
}
/**
* Returns a list of available locales.
* - if full build, this includes all default locales, aliases, and any locale added
* via `registerLocaleData`.
* - if bundled and locale messages have been stripped, this includes default locales
* (perhaps available in a separate bundle) and any locales from `registerLocaleData`.
* @return {Array<LH.Locale>}
*/
function getAvailableLocales() {
// Take union of DEFAULT_LOCALES and keys of LOCALE_MESSAGES. This means that
// the default locales will always be included (even if trimmed by a bundler)
// as well as those added by `registerLocaleData`.
const allLocales = new Set([...DEFAULT_LOCALES, ...Object.keys(LOCALE_MESSAGES)]);
// Note: cast because this is in theory correct, but not tested at runtime.
return /** @type {Array<LH.Locale>} */ ([...allLocales].sort());
}
/**
* Populate the i18n string lookup dict with locale data
* Used when the host environment selects the locale and serves lighthouse the intended locale file
* @see https://docs.google.com/document/d/1jnt3BqKB-4q3AE94UWFA0Gqspx8Sd_jivlB7gQMlmfk/edit
* @param {LH.Locale} locale
* @param {import('./locales').LhlMessages} lhlMessages
*/
function registerLocaleData(locale, lhlMessages) {
LOCALE_MESSAGES[locale] = lhlMessages;
}
/**
* @param {string} i18nMessageId
*/
function getIcuMessageIdParts(i18nMessageId) {
if (!MESSAGE_I18N_ID_REGEX.test(i18nMessageId)) {
throw Error(`"${i18nMessageId}" does not appear to be a valid ICU message id`);
}
const [filename, key] = i18nMessageId.split(' | ');
return {filename, key};
}
module.exports = {
_formatPathAsString,
collectAllCustomElementsFromICU,
isIcuMessage,
getFormatted,
getRendererFormattedStrings,
replaceIcuMessages,
hasLocale,
registerLocaleData,
_formatMessage,
getIcuMessageIdParts,
getAvailableLocales,
};

Просмотреть файл

@ -16,6 +16,7 @@
// TODO(paulirish): Centralize locale inheritance (combining this & i18n.lookupLocale()), adopt cldr parentLocale rules.
/** @typedef {import('../../types/lhr/settings').Locale} Locale */
/** @typedef {Record<string, {message: string}>} LhlMessages */
const fs = require('fs');
@ -73,7 +74,7 @@ const files = {
};
// The keys within this const must exactly match the LH.Locale type in externs.d.ts
/** @type {Record<LH.Locale, LhlMessages>} */
/** @type {Record<Locale, LhlMessages>} */
const locales = {
'en-US': files['en-US'], // The 'source' strings, with descriptions
'en': files['en-US'], // According to CLDR/ICU, 'en' == 'en-US' dates/numbers (Why?!)

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

Просмотреть файл

@ -8,7 +8,7 @@
const _set = require('lodash.set');
const _get = require('lodash.get');
const i18n = require('./i18n.js');
const format = require('./format.js');
/**
* @fileoverview Use the lhr.i18n.icuMessagePaths object to change locales.
@ -16,7 +16,7 @@ const i18n = require('./i18n.js');
* `icuMessagePaths` is an object keyed by `LH.IcuMessage['i18nId']`s. Within each is either
* 1) an array of strings, which are just object paths to where that message is used in the LHR
* 2) an array of `LH.IcuMessagePath`s which include both a `path` and a `values` object
* which will be used in the replacement within `i18n.getFormatted()`
* which will be used in the replacement within `format.getFormatted()`
*
* An example:
"icuMessagePaths": {
@ -49,7 +49,9 @@ function swapLocale(lhr, requestedLocale) {
// Copy LHR to avoid mutating provided LHR.
lhr = JSON.parse(JSON.stringify(lhr));
const locale = i18n.lookupLocale(requestedLocale);
if (!format.hasLocale(requestedLocale)) {
throw new Error(`Unsupported locale '${requestedLocale}'`);
}
const originalLocale = lhr.configSettings.locale;
const {icuMessagePaths} = lhr.i18n;
const missingIcuMessageIds = [];
@ -83,11 +85,11 @@ function swapLocale(lhr, requestedLocale) {
formattedDefault: originalString,
};
// Get new formatted strings in revised locale.
const relocalizedString = i18n.getFormatted(icuMessage, locale);
const relocalizedString = format.getFormatted(icuMessage, requestedLocale);
// If we couldn't find a new replacement message, keep things as is.
if (relocalizedString === originalString) {
if (locale !== originalLocale) {
if (requestedLocale !== originalLocale) {
// If the string remained the same while the locale changed, there may have been an issue.
missingIcuMessageIds.push(i18nId);
}
@ -99,9 +101,9 @@ function swapLocale(lhr, requestedLocale) {
}
}
lhr.i18n.rendererFormattedStrings = i18n.getRendererFormattedStrings(locale);
lhr.i18n.rendererFormattedStrings = format.getRendererFormattedStrings(requestedLocale);
// Tweak the config locale
lhr.configSettings.locale = locale;
lhr.configSettings.locale = requestedLocale;
return {
lhr,
missingIcuMessageIds,

Просмотреть файл

@ -0,0 +1,377 @@
/**
* @license Copyright 2018 The Lighthouse Authors. 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 format = require('../../localization/format.js');
const i18n = require('../../../lighthouse-core/lib/i18n/i18n.js');
/* eslint-env jest */
describe('format', () => {
describe('#_formatPathAsString', () => {
it('handles simple paths', () => {
expect(format._formatPathAsString(['foo'])).toBe('foo');
expect(format._formatPathAsString(['foo', 'bar', 'baz'])).toBe('foo.bar.baz');
});
it('handles array paths', () => {
expect(format._formatPathAsString(['foo', 0])).toBe('foo[0]');
});
it('handles complex paths', () => {
const propertyPath = ['foo', 'what-the', 'bar', 0, 'no'];
expect(format._formatPathAsString(propertyPath)).toBe('foo[what-the].bar[0].no');
});
it('throws on unhandleable paths', () => {
expect(() => format._formatPathAsString(['Bobby "DROP TABLE'])).toThrow(/Cannot handle/);
});
});
describe('#replaceIcuMessages', () => {
it('replaces the references in the LHR', () => {
const fakeFile = path.join(__dirname, 'fake-file-number-2.js');
const UIStrings = {aString: 'different {x}!'};
const formatter = i18n.createMessageInstanceIdFn(fakeFile, UIStrings);
const title = formatter(UIStrings.aString, {x: 1});
const lhr = {audits: {'fake-audit': {title}}};
const icuMessagePaths = format.replaceIcuMessages(lhr, 'en-US');
expect(lhr.audits['fake-audit'].title).toBe('different 1!');
const expectedPathId = 'shared/test/localization/fake-file-number-2.js | aString';
expect(icuMessagePaths).toEqual({
[expectedPathId]: [{path: 'audits[fake-audit].title', values: {x: 1}}]});
});
});
describe('#getRendererFormattedStrings', () => {
it('returns icu messages in the specified locale', () => {
const strings = format.getRendererFormattedStrings('en-XA');
expect(strings.passedAuditsGroupTitle).toEqual('[Þåššéð åûðîţš one two]');
expect(strings.snippetCollapseButtonLabel).toEqual('[Çöļļåþšé šñîþþéţ one two]');
});
it('throws an error for invalid locales', () => {
expect(_ => format.getRendererFormattedStrings('not-a-locale'))
.toThrow(`Unsupported locale 'not-a-locale'`);
});
});
describe('#getFormatted', () => {
it('returns the formatted string', () => {
const UIStrings = {testMessage: 'happy test'};
const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
const formattedStr = format.getFormatted(str_(UIStrings.testMessage), 'en');
expect(formattedStr).toEqual('happy test');
});
it('returns the formatted string with replacements', () => {
const UIStrings = {testMessage: 'replacement test ({errorCode})'};
const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
const formattedStr = format.getFormatted(str_(UIStrings.testMessage,
{errorCode: 'BOO'}), 'en');
expect(formattedStr).toEqual('replacement test (BOO)');
});
it('throws an error for invalid locales', () => {
// Populate a string to try to localize to a bad locale.
const UIStrings = {testMessage: 'testy test'};
const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
expect(_ => format.getFormatted(str_(UIStrings.testMessage), 'still-not-a-locale'))
.toThrow(`Unsupported locale 'still-not-a-locale'`);
});
it('does not alter the passed-in replacement values object', () => {
const UIStrings = {
testMessage: 'needs {count, number, bytes}KB test {str} in {timeInMs, number, seconds}s',
};
const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
const replacements = {
count: 2555,
str: '*units*',
timeInMs: 314159265,
};
const replacementsClone = JSON.parse(JSON.stringify(replacements));
const formattedStr = format.getFormatted(str_(UIStrings.testMessage, replacements), 'en');
expect(formattedStr).toEqual('needs 2KB test *units* in 314,159.3s');
expect(replacements).toEqual(replacementsClone);
});
it('returns a message that is already a string unchanged', () => {
const testString = 'kind of looks like it needs ({formatting})';
const formattedStr = format.getFormatted(testString, 'pl');
expect(formattedStr).toBe(testString);
});
it('throws an error if formatting something other than IcuMessages or strings', () => {
expect(_ => format.getFormatted(15, 'lt'))
.toThrow(`Attempted to format invalid icuMessage type`);
expect(_ => format.getFormatted(new Date(), 'sr-Latn'))
.toThrow(`Attempted to format invalid icuMessage type`);
});
});
describe('#registerLocaleData', () => {
// Store original locale data so we can restore at the end
const moduleLocales = require('../../localization/locales.js');
const clonedLocales = JSON.parse(JSON.stringify(moduleLocales));
it('installs new locale strings', () => {
const localeData = {
'shared/test/localization/format-test.js | testString': {
'message': 'en-XZ cuerda!',
},
};
format.registerLocaleData('en-XZ', localeData);
const UIStrings = {testString: 'en-US string!'};
const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
const formattedStr = format.getFormatted(str_(UIStrings.testString), 'en-XZ');
expect(formattedStr).toEqual('en-XZ cuerda!');
});
it('overwrites existing locale strings', () => {
const filename = 'lighthouse-core/audits/is-on-https.js';
const UIStrings = require('../../../lighthouse-core/audits/is-on-https.js').UIStrings;
const str_ = i18n.createMessageInstanceIdFn(filename, UIStrings);
// To start with, we get back the intended string..
const origTitle = format.getFormatted(str_(UIStrings.title), 'es-419');
expect(origTitle).toEqual('Usa HTTPS');
const origFailureTitle = format.getFormatted(str_(UIStrings.failureTitle), 'es-419');
expect(origFailureTitle).toEqual('No usa HTTPS');
// Now we declare and register the new string...
const localeData = {
'lighthouse-core/audits/is-on-https.js | title': {
'message': 'new string for es-419 uses https!',
},
};
format.registerLocaleData('es-419', localeData);
// And confirm that's what is returned
const newTitle = format.getFormatted(str_(UIStrings.title), 'es-419');
expect(newTitle).toEqual('new string for es-419 uses https!');
// Meanwhile another string that wasn't set in registerLocaleData just falls back to english
const newFailureTitle = format.getFormatted(str_(UIStrings.failureTitle), 'es-419');
expect(newFailureTitle).toEqual('Does not use HTTPS');
// Restore overwritten strings to avoid messing with other tests
moduleLocales['es-419'] = clonedLocales['es-419'];
const title = format.getFormatted(str_(UIStrings.title), 'es-419');
expect(title).toEqual('Usa HTTPS');
});
});
describe('#isIcuMessage', () => {
const icuMessage = {
i18nId: 'shared/test/localization/fake-file.js | title',
values: {x: 1},
formattedDefault: 'a default',
};
it('passes a valid LH.IcuMessage', () => {
expect(format.isIcuMessage(icuMessage)).toBe(true);
});
it('fails non-objects', () => {
expect(format.isIcuMessage(undefined)).toBe(false);
expect(format.isIcuMessage(null)).toBe(false);
expect(format.isIcuMessage('ICU!')).toBe(false);
expect(format.isIcuMessage(55)).toBe(false);
expect(format.isIcuMessage([
icuMessage,
icuMessage,
])).toBe(false);
});
it('fails invalid or missing i18nIds', () => {
const badIdMessage = {...icuMessage, i18nId: 0};
expect(format.isIcuMessage(badIdMessage)).toBe(false);
const noIdMessage = {...icuMessage};
delete noIdMessage.i18nId;
expect(format.isIcuMessage(noIdMessage)).toBe(false);
});
it('fails invalid or missing formattedDefault', () => {
const badDefaultMessage = {...icuMessage, formattedDefault: -0};
expect(format.isIcuMessage(badDefaultMessage)).toBe(false);
const noDefaultMessage = {...icuMessage};
delete noDefaultMessage.formattedDefault;
expect(format.isIcuMessage(noDefaultMessage)).toBe(false);
});
it('passes missing values', () => {
const emptyValuesMessage = {...icuMessage, values: {}};
expect(format.isIcuMessage(emptyValuesMessage)).toBe(true);
const noValuesMessage = {...icuMessage};
delete noValuesMessage.values;
expect(format.isIcuMessage(noValuesMessage)).toBe(true);
});
it('fails invalid values types', () => {
const badValuesMessage = {...icuMessage, values: NaN};
expect(format.isIcuMessage(badValuesMessage)).toBe(false);
const nullValuesMessage = {...icuMessage, values: null};
expect(format.isIcuMessage(nullValuesMessage)).toBe(false);
});
it(`fails invalid values' values types`, () => {
const badValuesValuesMessage = {...icuMessage, values: {a: false}};
expect(format.isIcuMessage(badValuesValuesMessage)).toBe(false);
});
});
describe('#getIcuMessageIdParts', () => {
it('returns valid ICU message id parts', () => {
const {filename, key} = format.getIcuMessageIdParts('path/to/file.js | modeName');
expect(filename).toEqual('path/to/file.js');
expect(key).toEqual('modeName');
});
it('throws on invalid ICU message id', () => {
expect(() => {
format.getIcuMessageIdParts('path/to/file.js');
}).toThrow();
});
});
describe('Message values are properly formatted', () => {
// Message strings won't be in locale files, so will fall back to values given here.
const UIStrings = {
helloWorld: 'Hello World',
helloBytesWorld: 'Hello {in, number, bytes} World',
helloMsWorld: 'Hello {in, number, milliseconds} World',
helloSecWorld: 'Hello {in, number, seconds} World',
helloTimeInMsWorld: 'Hello {timeInMs, number, seconds} World',
helloPercentWorld: 'Hello {in, number, extendedPercent} World',
helloWorldMultiReplace: '{hello} {world}',
helloPlural: '{itemCount, plural, =1{1 hello} other{hellos}}',
helloPluralNestedICU: '{itemCount, plural, ' +
'=1{1 hello {in, number, bytes}} ' +
'other{hellos {in, number, bytes}}}',
helloPluralNestedPluralAndICU: '{itemCount, plural, ' +
'=1{{innerItemCount, plural, ' +
'=1{1 hello 1 goodbye {in, number, bytes}} ' +
'other{1 hello, goodbyes {in, number, bytes}}}} ' +
'other{{innerItemCount, plural, ' +
'=1{hellos 1 goodbye {in, number, bytes}} ' +
'other{hellos, goodbyes {in, number, bytes}}}}}',
};
const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
it('formats a basic message', () => {
const helloStr = str_(UIStrings.helloWorld);
expect(helloStr).toBeDisplayString('Hello World');
});
it('formats a message with bytes', () => {
const helloBytesStr = str_(UIStrings.helloBytesWorld, {in: 1875});
expect(helloBytesStr).toBeDisplayString('Hello 2 World');
});
it('formats a message with milliseconds', () => {
const helloMsStr = str_(UIStrings.helloMsWorld, {in: 432});
expect(helloMsStr).toBeDisplayString('Hello 430 World');
});
it('formats a message with seconds', () => {
const helloSecStr = str_(UIStrings.helloSecWorld, {in: 753});
expect(helloSecStr).toBeDisplayString('Hello 753.0 World');
});
it('formats a message with seconds timeInMs', () => {
const helloTimeInMsStr = str_(UIStrings.helloTimeInMsWorld, {timeInMs: 753543});
expect(helloTimeInMsStr).toBeDisplayString('Hello 753.5 World');
});
it('formats a message with extended percent', () => {
const helloPercentStr = str_(UIStrings.helloPercentWorld, {in: 0.43078});
expect(helloPercentStr).toBeDisplayString('Hello 43.08% World');
});
it('throws an error when values are needed but not provided', () => {
expect(_ => format.getFormatted(str_(UIStrings.helloBytesWorld), 'en-US'))
// eslint-disable-next-line max-len
.toThrow(`ICU Message "Hello {in, number, bytes} World" contains a value reference ("in") that wasn't provided`);
});
it('throws an error when a value is missing', () => {
expect(_ => format.getFormatted(str_(UIStrings.helloWorldMultiReplace,
{hello: 'hello'}), 'en-US'))
// eslint-disable-next-line max-len
.toThrow(`ICU Message "{hello} {world}" contains a value reference ("world") that wasn't provided`);
});
it('formats a message with plurals', () => {
const helloStr = str_(UIStrings.helloPlural, {itemCount: 3});
expect(helloStr).toBeDisplayString('hellos');
});
it('throws an error when a plural control value is missing', () => {
expect(_ => i18n.getFormatted(str_(UIStrings.helloPlural), 'en-US'))
// eslint-disable-next-line max-len
.toThrow(`ICU Message "{itemCount, plural, =1{1 hello} other{hellos}}" contains a value reference ("itemCount") that wasn't provided`);
});
it('formats a message with plurals and nested custom ICU', () => {
const helloStr = str_(UIStrings.helloPluralNestedICU, {itemCount: 3, in: 1875});
expect(helloStr).toBeDisplayString('hellos 2');
});
it('formats a message with plurals and nested custom ICU and nested plural', () => {
const helloStr = str_(UIStrings.helloPluralNestedPluralAndICU, {itemCount: 3,
innerItemCount: 1,
in: 1875});
expect(helloStr).toBeDisplayString('hellos 1 goodbye 2');
});
it('throws an error if a string value is used for a numeric placeholder', () => {
expect(_ => str_(UIStrings.helloTimeInMsWorld, {
timeInMs: 'string not a number',
}))
// eslint-disable-next-line max-len
.toThrow(`ICU Message "Hello {timeInMs, number, seconds} World" contains a numeric reference ("timeInMs") but provided value was not a number`);
});
it('throws an error if a value is provided that has no placeholder in the message', () => {
expect(_ => str_(UIStrings.helloTimeInMsWorld, {
timeInMs: 55,
sirNotAppearingInThisString: 66,
}))
// eslint-disable-next-line max-len
.toThrow(`Provided value "sirNotAppearingInThisString" does not match any placeholder in ICU message "Hello {timeInMs, number, seconds} World"`);
});
it('formats correctly with NaN and Infinity numeric values', () => {
const helloInfinityStr = str_(UIStrings.helloBytesWorld, {in: Infinity});
expect(helloInfinityStr).toBeDisplayString('Hello ∞ World');
const helloNaNStr = str_(UIStrings.helloBytesWorld, {in: NaN});
// TODO(COMPAT): workaround can be removed after Node 13 is retired.
// expect(helloNaNStr).toBeDisplayString('Hello NaN World');
// Node 13/V8 7.9 and 8.0 have a bug where `({a: NaN}).a.toLocaleString() === "-NaN"`. It
// works correctly in Node 12 and 14, so work around it since NaN isn't essential for
// user-facing strings and it will eventually correct itself.
const formattedNaNStr = format.getFormatted(helloNaNStr, 'en-US');
expect(formattedNaNStr === 'Hello NaN World' || formattedNaNStr === 'Hello -NaN World')
.toBe(true);
});
});
});

Просмотреть файл

@ -5,7 +5,7 @@
*/
'use strict';
const locales = require('../../../lib/i18n/locales.js');
const locales = require('../../localization/locales.js');
const assert = require('assert').strict;
/* eslint-env jest */

Просмотреть файл

@ -5,27 +5,19 @@
*/
'use strict';
const swapLocale = require('../../../lib/i18n/swap-locale.js');
const {isNode12SmallIcu} = require('../../test-utils.js');
const swapLocale = require('../../localization/swap-locale.js');
const {isNode12SmallIcu} = require('../../../lighthouse-core/test/test-utils.js');
const lhr = require('../../results/sample_v2.json');
const lhr = require('../../../lighthouse-core/test/results/sample_v2.json');
/* eslint-env jest */
describe('swap-locale', () => {
// COMPAT: Node 12 only has 'en' by default. Skip these tests since they're all about swapping locales.
if (isNode12SmallIcu()) {
// Jest requires at least one test per suite.
it('runs even if other locales are not supported', () => {
/** @type {LH.Result} */
const lhrClone = JSON.parse(JSON.stringify(lhr));
it('throws if locale is not supported', () => {
// Even though 'pt' is requested, 'en' is all that's available.
const lhrEn = swapLocale(lhr, 'pt').lhr;
expect(lhrEn.configSettings.locale).toBe('en');
// Set locale back to full 'en-US' do do the comparison.
lhrEn.configSettings.locale = 'en-US';
expect(lhrEn).toStrictEqual(lhrClone);
expect(() => swapLocale(lhr, 'pt')).toThrow('Unsupported locale \'pt\'');
});
return;

22
shared/tsconfig.json Normal file
Просмотреть файл

@ -0,0 +1,22 @@
{
"extends": "../tsconfig-base.json",
"compilerOptions": {
"outDir": "../../.tmp/tsbuildinfo/shared/",
// Limit defs to base JS.
"lib": ["es2020"],
// Only include `@types/node` from node_modules/.
"types": ["node"],
// "listFiles": true,
},
"references": [
{"path": "../types/lhr/"},
],
"include": [
"**/*.js",
"types/**/*.d.ts",
],
"exclude": [
"test/**/*.js",
],
}

Просмотреть файл

24
shared/types/shared.d.ts поставляемый Normal file
Просмотреть файл

@ -0,0 +1,24 @@
/**
* @license Copyright 2021 The Lighthouse Authors. 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.
*/
import {IcuMessage as IcuMessage_} from '../../types/lhr/i18n';
import LHResult from '../../types/lhr/lhr';
import FlowResult_ from '../../types/lhr/flow';
import {Locale as Locale_} from '../../types/lhr/settings';
declare global {
// Expose global types in LH namespace.
module LH {
export import Result = LHResult;
export import FlowResult = FlowResult_;
export type IcuMessage = IcuMessage_;
export type IcuMessagePaths = LHResult.IcuMessagePaths;
export type Locale = Locale_;
}
}
export {};

Просмотреть файл

@ -9,6 +9,7 @@
{"path": "./lighthouse-viewer/"},
{"path": "./lighthouse-treemap/"},
{"path": "./flow-report/"},
{"path": "./shared/"},
],
"files": [],
"include": [],

Просмотреть файл

@ -19,6 +19,6 @@
// "listFiles": true,
// "noErrorTruncation": true,
"extendedDiagnostics": true,
// "extendedDiagnostics": true,
},
}

Просмотреть файл

@ -10,6 +10,7 @@
{"path": "./types/lhr/"},
{"path": "./report/"},
{"path": "./report/generator/"},
{"path": "./shared/"},
],
"include": [
"root.js",
@ -22,13 +23,13 @@
// TODO(esmodules): JSON files included via resolveJsonModule. Removable on the switch to ES Modules.
"package.json",
"lighthouse-core/lib/i18n/locales/en-US.json",
"lighthouse-core/test/results/sample_v2.json",
"lighthouse-core/lib/sd-validation/assets/*.json",
"lighthouse-core/test/fixtures/unresolved-perflog.json",
"lighthouse-core/test/fixtures/traces/lcp-m78.devtools.log.json",
"third-party/snyk/snapshot.json",
"lighthouse-core/audits/byte-efficiency/polyfill-graph-data.json",
"shared/localization/locales/en-US.json",
],
"exclude": [
"lighthouse-core/test/audits/**/*.js",
@ -79,8 +80,6 @@
"lighthouse-core/test/lib/dependency-graph/simulator/simulator-test.js",
"lighthouse-core/test/lib/emulation-test.js",
"lighthouse-core/test/lib/i18n/i18n-test.js",
"lighthouse-core/test/lib/i18n/locales-test.js",
"lighthouse-core/test/lib/i18n/swap-locale-test.js",
"lighthouse-core/test/lib/icons-test.js",
"lighthouse-core/test/lib/lh-element-test.js",
"lighthouse-core/test/lib/manifest-parser-test.js",

3
types/lhr/i18n.d.ts поставляемый
Просмотреть файл

@ -4,9 +4,6 @@
* 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.
*/
// TODO(bckenny): i18n types should not be necessary for the LHR, but too difficult
// to unravel from audit-details right now.
export type IcuMessage = {
// NOTE: `i18nId` rather than just `id` to make tsc typing easier (vs type branding which won't survive JSON roundtrip).
/** The id locating this message in the locale message json files. */