-
Notifications
You must be signed in to change notification settings - Fork 9.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
i18n: use better types for intl-messageformat #9570
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,10 +9,12 @@ 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 lookupClosestLocale = require('lookup-closest-locale'); | ||
const LOCALES = require('./locales.js'); | ||
|
||
/** @typedef {import('intl-messageformat-parser').Element} MessageElement */ | ||
/** @typedef {import('intl-messageformat-parser').ArgumentElement} ArgumentElement */ | ||
|
||
const LH_ROOT = path.join(__dirname, '../../../'); | ||
const MESSAGE_INSTANCE_ID_REGEX = /(.* \| .*) # (\d+)$/; | ||
// Above regex is very slow against large strings. Use QUICK_REGEX as a much quicker discriminator. | ||
|
@@ -29,6 +31,10 @@ const MESSAGE_INSTANCE_ID_QUICK_REGEX = / # \d+$/; | |
|
||
Intl.NumberFormat = IntlPolyfill.NumberFormat; | ||
Intl.DateTimeFormat = IntlPolyfill.DateTimeFormat; | ||
|
||
// Intl.PluralRules polyfilled on require(). | ||
// @ts-ignore | ||
require('intl-pluralrules'); | ||
} catch (_) { | ||
log.warn('i18n', 'Failed to install `intl` polyfill'); | ||
} | ||
|
@@ -126,16 +132,18 @@ function lookupLocale(locale) { | |
} | ||
|
||
/** | ||
* Preformat values for the message based on how they're used, like KB or milliseconds. | ||
* @param {string} icuMessage | ||
* @param {MessageFormat} messageFormatter | ||
* @param {Record<string, string | number>} [values] | ||
* @return {Record<string, string | number>} | ||
*/ | ||
function _preprocessMessageValues(icuMessage, values = {}) { | ||
function _preformatValues(icuMessage, messageFormatter, values = {}) { | ||
const clonedValues = JSON.parse(JSON.stringify(values)); | ||
const parsed = MessageParser.parse(icuMessage); | ||
|
||
const elements = _collectAllCustomElementsFromICU(parsed.elements); | ||
const elements = _collectAllCustomElementsFromICU(messageFormatter.getAst().elements); | ||
|
||
return _processParsedElements(Array.from(elements.values()), clonedValues); | ||
return _processParsedElements(icuMessage, Array.from(elements.values()), clonedValues); | ||
} | ||
|
||
/** | ||
|
@@ -152,23 +160,23 @@ function _preprocessMessageValues(icuMessage, values = {}) { | |
* 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<import('intl-messageformat-parser').Element>} icuElements | ||
* @param {Map<string, import('intl-messageformat-parser').Element>} seenElementsById | ||
* @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; | ||
// @ts-ignore - el.id is always defined when el.format is defined | ||
|
||
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); | ||
_collectAllCustomElementsFromICU(option.value.elements, seenElementsById); | ||
} | ||
} | ||
|
||
|
@@ -180,36 +188,40 @@ function _collectAllCustomElementsFromICU(icuElements, seenElementsById = new Ma | |
* will apply Lighthouse custom formatting to the values based on the argumentElement | ||
* format style. | ||
* | ||
* @param {Array<import('intl-messageformat-parser').Element>} argumentElements | ||
* @param {string} icuMessage | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. included for logging. While playing around with some other error conditions I found it basically impossible to know which message the erroring |
||
* @param {Array<ArgumentElement>} argumentElements | ||
* @param {Record<string, string | number>} [values] | ||
* @return {Record<string, string | number>} | ||
*/ | ||
function _processParsedElements(argumentElements, values = {}) { | ||
// Throw an error if a message's value isn't provided | ||
argumentElements | ||
.filter(el => el.type === 'argumentElement') | ||
.forEach(el => { | ||
if (el.id && (el.id in values) === false) { | ||
throw new Error(`ICU Message contains a value reference ("${el.id}") that wasn't provided`); | ||
} | ||
}); | ||
|
||
// Round all milliseconds to the nearest 10 | ||
argumentElements | ||
.filter(el => el.format && el.format.style === 'milliseconds') | ||
// @ts-ignore - el.id is always defined when el.format is defined | ||
.forEach(el => (values[el.id] = Math.round(values[el.id] / 10) * 10)); | ||
|
||
// Convert all seconds to the correct unit | ||
argumentElements | ||
.filter(el => el.format && el.format.style === 'seconds' && el.id === 'timeInMs') | ||
// @ts-ignore - el.id is always defined when el.format is defined | ||
.forEach(el => (values[el.id] = Math.round(values[el.id] / 100) / 10)); | ||
|
||
// Replace all the bytes with KB | ||
argumentElements | ||
.filter(el => el.format && el.format.style === 'bytes') | ||
// @ts-ignore - el.id is always defined when el.format is defined | ||
.forEach(el => (values[el.id] = values[el.id] / 1024)); | ||
function _processParsedElements(icuMessage, argumentElements, values = {}) { | ||
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 "${icuMessage}" contains a value reference ("${id}") ` + | ||
`that wasn't provided`); | ||
} | ||
|
||
// Direct `{id}` replacement and non-numeric values need no formatting. | ||
if (!format || format.type !== 'numberFormat') continue; | ||
|
||
const value = values[id]; | ||
if (typeof value !== 'number') { | ||
throw new Error(`ICU Message "${icuMessage}" 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. | ||
values[id] = Math.round(value / 10) * 10; | ||
} else if (format.style === 'seconds' && id === 'timeInMs') { | ||
// Convert all seconds to the correct unit (currently only for `timeInMs`). | ||
values[id] = Math.round(value / 100) / 10; | ||
} else if (format.style === 'bytes') { | ||
// Replace all the bytes with KB. | ||
values[id] = value / 1024; | ||
} | ||
} | ||
|
||
return values; | ||
} | ||
|
@@ -257,12 +269,13 @@ function _formatIcuMessage(locale, icuMessageId, uiStringMessage, values) { | |
|
||
// 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; | ||
// pre-process values for the message format like KB and milliseconds | ||
const valuesForMessageFormat = _preprocessMessageValues(localeMessage, values); | ||
|
||
const formatter = new MessageFormat(localeMessage, localeForMessageFormat, formats); | ||
const formattedString = formatter.format(valuesForMessageFormat); | ||
|
||
// preformat values for the message format like KB and milliseconds | ||
const valuesForMessageFormat = _preformatValues(localeMessage, formatter, values); | ||
|
||
const formattedString = formatter.format(valuesForMessageFormat); | ||
return {formattedString, icuMessage: localeMessage}; | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -79,7 +79,6 @@ | |
"@types/gh-pages": "^2.0.0", | ||
"@types/google.analytics": "0.0.39", | ||
"@types/inquirer": "^0.0.35", | ||
"@types/intl-messageformat": "^1.3.0", | ||
"@types/jest": "^24.0.9", | ||
"@types/jpeg-js": "^0.3.0", | ||
"@types/lodash.isequal": "^4.5.2", | ||
|
@@ -140,8 +139,8 @@ | |
"http-link-header": "^0.8.0", | ||
"inquirer": "^3.3.0", | ||
"intl": "^1.2.5", | ||
"intl-messageformat": "^2.2.0", | ||
"intl-messageformat-parser": "^1.4.0", | ||
"intl-messageformat": "^4.4.0", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. wow that's a big jump, didn't we just add this a year ago? I thought intl rules would be more stable by now 😆 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
yeah, it was unchanged for two years until three months ago, at which point the versions started exploding :) https://www.npmjs.com/package/intl-messageformat?activeTab=versions I mentioned above, but I didn't pick the latest version because of the change in the AST format in 5.0.0 that didn't seem worth updating for given that everything works today. |
||
"intl-pluralrules": "^1.0.3", | ||
"jpeg-js": "0.1.2", | ||
"js-library-detector": "^5.4.0", | ||
"jsonld": "^1.5.0", | ||
|
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is still a direct reference to what is now a transitive dependency, but the
intl-messageformat
d.ts files type these by reference into its dependencies, so I don't know any other nice way to type these but this (unless we want to do some kind ofReturnType<MessageFormat['getAst']>['elements'][0]
:)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I definitely prefer the approach you already have here :)