From 504e62aa399a65faab7275a25b695ef6e1728fe5 Mon Sep 17 00:00:00 2001 From: Connor Clark Date: Wed, 6 Apr 2022 15:15:49 -0700 Subject: [PATCH 01/13] first pass, localize units --- report/renderer/i18n.js | 94 +++++++++++++++++-------------- report/test/renderer/i18n-test.js | 8 +-- 2 files changed, 55 insertions(+), 47 deletions(-) diff --git a/report/renderer/i18n.js b/report/renderer/i18n.js index 0e67d4fd7965..20d832fa254f 100644 --- a/report/renderer/i18n.js +++ b/report/renderer/i18n.js @@ -22,9 +22,7 @@ export class I18n { // When testing, use a locale with more exciting numeric formatting. if (locale === 'en-XA') locale = 'de'; - this._numberDateLocale = locale; - this._numberFormatter = new Intl.NumberFormat(locale); - this._percentFormatter = new Intl.NumberFormat(locale, {style: 'percent'}); + this._locale = locale; this._strings = strings; } @@ -32,6 +30,31 @@ export class I18n { return this._strings; } + /** + * @param {number} number + * @param {number} granularity + * @param {Intl.NumberFormatOptions} opts + * @return {string} + */ + _formatNumberWithGranularity(number, granularity, opts = {}) { + opts = {...opts}; + const log10 = -Math.log10(granularity); + if (!Number.isFinite(log10) || (granularity > 1 && Math.floor(log10) !== log10)) { + throw new Error(`granularity of ${granularity} is invalid`); + } + + if (granularity < 1) { + opts.minimumFractionDigits = opts.maximumFractionDigits = Math.ceil(log10); + } + + number = Math.round(number / granularity) * granularity; + + // Avoid displaying a negative value that rounds to zero as "0". + if (Object.is(number, -0)) number = 0; + + return new Intl.NumberFormat(this._locale, opts).format(number).replace(' ', NBSP2); + } + /** * Format number. * @param {number} number @@ -39,8 +62,7 @@ export class I18n { * @return {string} */ formatNumber(number, granularity = 0.1) { - const coarseValue = Math.round(number / granularity) * granularity; - return this._numberFormatter.format(coarseValue); + return this._formatNumberWithGranularity(number, granularity); } /** @@ -49,7 +71,7 @@ export class I18n { * @return {string} */ formatPercent(number) { - return this._percentFormatter.format(number); + return new Intl.NumberFormat(this._locale, {style: 'percent'}).format(number); } /** @@ -58,9 +80,7 @@ export class I18n { * @return {string} */ formatBytesToKiB(size, granularity = 0.1) { - const formatter = this._byteFormatterForGranularity(granularity); - const kbs = formatter.format(Math.round(size / 1024 / granularity) * granularity); - return `${kbs}${NBSP2}KiB`; + return this._formatNumberWithGranularity(size / KiB, granularity) + `${NBSP2}KiB`; } /** @@ -69,9 +89,7 @@ export class I18n { * @return {string} */ formatBytesToMiB(size, granularity = 0.1) { - const formatter = this._byteFormatterForGranularity(granularity); - const kbs = formatter.format(Math.round(size / (1024 ** 2) / granularity) * granularity); - return `${kbs}${NBSP2}MiB`; + return this._formatNumberWithGranularity(size / MiB, granularity) + `${NBSP2}MiB`; } /** @@ -80,9 +98,11 @@ export class I18n { * @return {string} */ formatBytes(size, granularity = 1) { - const formatter = this._byteFormatterForGranularity(granularity); - const kbs = formatter.format(Math.round(size / granularity) * granularity); - return `${kbs}${NBSP2}bytes`; + return this._formatNumberWithGranularity(size, granularity, { + style: 'unit', + unit: 'byte', + unitDisplay: 'long', + }); } /** @@ -93,26 +113,7 @@ export class I18n { formatBytesWithBestUnit(size, granularity = 0.1) { if (size >= MiB) return this.formatBytesToMiB(size, granularity); if (size >= KiB) return this.formatBytesToKiB(size, granularity); - return this.formatNumber(size, granularity) + '\xa0B'; - } - - /** - * Format bytes with a constant number of fractional digits, i.e. for a granularity of 0.1, 10 becomes '10.0' - * @param {number} granularity Controls how coarse the displayed value is - * @return {Intl.NumberFormat} - */ - _byteFormatterForGranularity(granularity) { - // assume any granularity above 1 will not contain fractional parts, i.e. will never be 1.5 - let numberOfFractionDigits = 0; - if (granularity < 1) { - numberOfFractionDigits = -Math.floor(Math.log10(granularity)); - } - - return new Intl.NumberFormat(this._numberDateLocale, { - ...this._numberFormatter.resolvedOptions(), - maximumFractionDigits: numberOfFractionDigits, - minimumFractionDigits: numberOfFractionDigits, - }); + return this.formatBytes(size, granularity); } /** @@ -121,10 +122,11 @@ export class I18n { * @return {string} */ formatMilliseconds(ms, granularity = 10) { - const coarseTime = Math.round(ms / granularity) * granularity; - return coarseTime === 0 - ? `${this._numberFormatter.format(0)}${NBSP2}ms` - : `${this._numberFormatter.format(coarseTime)}${NBSP2}ms`; + return this._formatNumberWithGranularity(ms, granularity, { + style: 'unit', + unit: 'millisecond', + unitDisplay: 'short', + }); } /** @@ -133,8 +135,11 @@ export class I18n { * @return {string} */ formatSeconds(ms, granularity = 0.1) { - const coarseTime = Math.round(ms / 1000 / granularity) * granularity; - return `${this._numberFormatter.format(coarseTime)}${NBSP2}s`; + return this._formatNumberWithGranularity(ms / 1000, granularity, { + style: 'unit', + unit: 'second', + unitDisplay: 'short', + }); } /** @@ -154,10 +159,10 @@ export class I18n { // and https://github.com/GoogleChrome/lighthouse/pull/9822 let formatter; try { - formatter = new Intl.DateTimeFormat(this._numberDateLocale, options); + formatter = new Intl.DateTimeFormat(this._locale, options); } catch (err) { options.timeZone = 'UTC'; - formatter = new Intl.DateTimeFormat(this._numberDateLocale, options); + formatter = new Intl.DateTimeFormat(this._locale, options); } return formatter.format(new Date(date)); @@ -169,6 +174,9 @@ export class I18n { * @return {string} */ formatDuration(timeInMilliseconds) { + // There is a proposal for a Intl.DurationFormat. + // https://github.com/tc39/proposal-intl-duration-format + let timeInSeconds = timeInMilliseconds / 1000; if (Math.round(timeInSeconds) === 0) { return 'None'; diff --git a/report/test/renderer/i18n-test.js b/report/test/renderer/i18n-test.js index e5aff96c6eff..43d2bc150688 100644 --- a/report/test/renderer/i18n-test.js +++ b/report/test/renderer/i18n-test.js @@ -22,8 +22,8 @@ const NBSP = '\xa0'; describe('util helpers', () => { it('formats a number', () => { const i18n = new I18n('en', {...Util.UIStrings}); - assert.strictEqual(i18n.formatNumber(10), '10'); - assert.strictEqual(i18n.formatNumber(100.01), '100'); + assert.strictEqual(i18n.formatNumber(10), '10.0'); + assert.strictEqual(i18n.formatNumber(100.01), '100.0'); assert.strictEqual(i18n.formatNumber(13000.456), '13,000.5'); }); @@ -114,7 +114,7 @@ describe('util helpers', () => { assert.strictEqual(i18n.formatNumber(number), '12.346,9'); assert.strictEqual(i18n.formatBytesToKiB(number), `12,1${NBSP}KiB`); assert.strictEqual(i18n.formatMilliseconds(number), `12.350${NBSP}ms`); - assert.strictEqual(i18n.formatSeconds(number), `12,3${NBSP}s`); + assert.strictEqual(i18n.formatSeconds(number), `12,3${NBSP}Sek.`); }); it('uses decimal comma with en-XA test locale', () => { @@ -125,7 +125,7 @@ describe('util helpers', () => { assert.strictEqual(i18n.formatNumber(number), '12.346,9'); assert.strictEqual(i18n.formatBytesToKiB(number), `12,1${NBSP}KiB`); assert.strictEqual(i18n.formatMilliseconds(number), `12.350${NBSP}ms`); - assert.strictEqual(i18n.formatSeconds(number), `12,3${NBSP}s`); + assert.strictEqual(i18n.formatSeconds(number), `12,3${NBSP}Sek.`); }); it('should not crash on unknown locales', () => { From 0b8814274bc2f10dfee6b8d21a6cc9515fce997e Mon Sep 17 00:00:00 2001 From: Connor Clark Date: Wed, 6 Apr 2022 15:44:48 -0700 Subject: [PATCH 02/13] localize formatDuration --- report/renderer/i18n.js | 35 ++++++++++++++++++++++--------- report/test/renderer/i18n-test.js | 15 +++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/report/renderer/i18n.js b/report/renderer/i18n.js index 20d832fa254f..2d3ae6075bfe 100644 --- a/report/renderer/i18n.js +++ b/report/renderer/i18n.js @@ -176,6 +176,7 @@ export class I18n { formatDuration(timeInMilliseconds) { // There is a proposal for a Intl.DurationFormat. // https://github.com/tc39/proposal-intl-duration-format + // Until then, we do things a bit more manually. let timeInSeconds = timeInMilliseconds / 1000; if (Math.round(timeInSeconds) === 0) { @@ -185,19 +186,33 @@ export class I18n { /** @type {Array} */ const parts = []; /** @type {Record} */ - const unitLabels = { - d: 60 * 60 * 24, - h: 60 * 60, - m: 60, - s: 1, + const unitToSecondsPer = { + day: 60 * 60 * 24, + hour: 60 * 60, + minute: 60, + second: 1, + }; + /** @type {Record} */ + const unitToDefaultLabel = { + day: 'd', + hour: 'h', + minute: 'm', + second: 's', }; - Object.keys(unitLabels).forEach(label => { - const unit = unitLabels[label]; - const numberOfUnits = Math.floor(timeInSeconds / unit); + Object.keys(unitToSecondsPer).forEach(unit => { + const unitFormatter = new Intl.NumberFormat(this._locale, { + style: 'unit', + unit, + unitDisplay: 'narrow', + }); + const label = unitFormatter.formatToParts(0) + .find(p => p.type === 'unit')?.value || unitToDefaultLabel[unit]; + const secondsPerUnit = unitToSecondsPer[unit]; + const numberOfUnits = Math.floor(timeInSeconds / secondsPerUnit); if (numberOfUnits > 0) { - timeInSeconds -= numberOfUnits * unit; - parts.push(`${numberOfUnits}\xa0${label}`); + timeInSeconds -= numberOfUnits * secondsPerUnit; + parts.push(`${numberOfUnits}${NBSP2}${label}`); } }); diff --git a/report/test/renderer/i18n-test.js b/report/test/renderer/i18n-test.js index 43d2bc150688..165c405fc139 100644 --- a/report/test/renderer/i18n-test.js +++ b/report/test/renderer/i18n-test.js @@ -106,6 +106,21 @@ describe('util helpers', () => { assert.equal(i18n.formatDuration(28 * 60 * 60 * 1000 + 5000), `1${NBSP}d 4${NBSP}h 5${NBSP}s`); }); + it('formats a duration based on locale', () => { + let i18n = new I18n('de', {...Util.UIStrings}); + assert.equal(i18n.formatDuration(60 * 1000), `1${NBSP}Min.`); + assert.equal(i18n.formatDuration(60 * 60 * 1000 + 5000), `1${NBSP}Std. 5${NBSP}Sek.`); + assert.equal( + i18n.formatDuration(28 * 60 * 60 * 1000 + 5000), `1${NBSP}T 4${NBSP}Std. 5${NBSP}Sek.`); + + // idk? + i18n = new I18n('ar', {...Util.UIStrings}); + // assert.equal(i18n.formatDuration(60 * 1000), `1${NBSP}د`); + // assert.equal(i18n.formatDuration(60 * 60 * 1000 + 5000), `1${NBSP}س 5${NBSP}ث`); + // assert.equal( + // i18n.formatDuration(28 * 60 * 60 * 1000 + 5000), `1${NBSP}T 4${NBSP}Std. 5${NBSP}Sek.`); + }); + it('formats numbers based on locale', () => { // Requires full-icu or Intl polyfill. const number = 12346.858558; From 7dd7d014fa0f182f6b13b07a081f1e7255f5b9c5 Mon Sep 17 00:00:00 2001 From: Connor Clark Date: Wed, 6 Apr 2022 16:31:46 -0700 Subject: [PATCH 03/13] hmm --- flow-report/src/summary/category.tsx | 2 +- report/renderer/i18n.js | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/flow-report/src/summary/category.tsx b/flow-report/src/summary/category.tsx index 198971526324..ef57f79ff712 100644 --- a/flow-report/src/summary/category.tsx +++ b/flow-report/src/summary/category.tsx @@ -132,7 +132,7 @@ const SummaryTooltip: FunctionComponent<{ { !displayAsFraction && category.score !== null && <> · - {i18n.formatNumber(category.score * 100)} + {i18n.formatNumber(category.score * 100, 1)} } diff --git a/report/renderer/i18n.js b/report/renderer/i18n.js index 2d3ae6075bfe..7ea5be5951ab 100644 --- a/report/renderer/i18n.js +++ b/report/renderer/i18n.js @@ -113,7 +113,11 @@ export class I18n { formatBytesWithBestUnit(size, granularity = 0.1) { if (size >= MiB) return this.formatBytesToMiB(size, granularity); if (size >= KiB) return this.formatBytesToKiB(size, granularity); - return this.formatBytes(size, granularity); + return this._formatNumberWithGranularity(size, granularity, { + style: 'unit', + unit: 'byte', + unitDisplay: 'narrow', + }); } /** From 08bc98b730f1a77f22b714a2831b49844f143492 Mon Sep 17 00:00:00 2001 From: Connor Clark Date: Wed, 6 Apr 2022 17:14:52 -0700 Subject: [PATCH 04/13] update --- flow-report/src/summary/category.tsx | 2 +- lighthouse-core/audits/bootup-time.js | 1 + lighthouse-core/util-commonjs.js | 10 +++++----- report/renderer/i18n.js | 11 +++++++++++ report/renderer/util.js | 10 +++++----- report/test/renderer/i18n-test.js | 3 +++ report/test/renderer/util-test.js | 2 +- treemap/app/src/util.js | 4 ++-- 8 files changed, 29 insertions(+), 14 deletions(-) diff --git a/flow-report/src/summary/category.tsx b/flow-report/src/summary/category.tsx index ef57f79ff712..363d4f966bd7 100644 --- a/flow-report/src/summary/category.tsx +++ b/flow-report/src/summary/category.tsx @@ -132,7 +132,7 @@ const SummaryTooltip: FunctionComponent<{ { !displayAsFraction && category.score !== null && <> · - {i18n.formatNumber(category.score * 100, 1)} + {i18n.formatInteger(category.score * 100)} } diff --git a/lighthouse-core/audits/bootup-time.js b/lighthouse-core/audits/bootup-time.js index af99e0cbd9b9..af1fb67c61a2 100644 --- a/lighthouse-core/audits/bootup-time.js +++ b/lighthouse-core/audits/bootup-time.js @@ -204,6 +204,7 @@ class BootupTime extends Audit { totalBootupTime ); + return { score, numericValue: totalBootupTime, diff --git a/lighthouse-core/util-commonjs.js b/lighthouse-core/util-commonjs.js index 3990a2091565..ae6a2238dbae 100644 --- a/lighthouse-core/util-commonjs.js +++ b/lighthouse-core/util-commonjs.js @@ -434,9 +434,9 @@ class Util { case 'devtools': { const {cpuSlowdownMultiplier, requestLatencyMs} = throttling; cpuThrottling = `${Util.i18n.formatNumber(cpuSlowdownMultiplier)}x slowdown (DevTools)`; - networkThrottling = `${Util.i18n.formatNumber(requestLatencyMs)}${NBSP}ms HTTP RTT, ` + - `${Util.i18n.formatNumber(throttling.downloadThroughputKbps)}${NBSP}Kbps down, ` + - `${Util.i18n.formatNumber(throttling.uploadThroughputKbps)}${NBSP}Kbps up (DevTools)`; + networkThrottling = `${Util.i18n.formatMilliseconds(requestLatencyMs, 1)} HTTP RTT, ` + + `${Util.i18n.formatInteger(throttling.downloadThroughputKbps)}${NBSP}Kbps down, ` + + `${Util.i18n.formatInteger(throttling.uploadThroughputKbps)}${NBSP}Kbps up (DevTools)`; const isSlow4G = () => { return requestLatencyMs === 150 * 3.75 && @@ -449,8 +449,8 @@ class Util { case 'simulate': { const {cpuSlowdownMultiplier, rttMs, throughputKbps} = throttling; cpuThrottling = `${Util.i18n.formatNumber(cpuSlowdownMultiplier)}x slowdown (Simulated)`; - networkThrottling = `${Util.i18n.formatNumber(rttMs)}${NBSP}ms TCP RTT, ` + - `${Util.i18n.formatNumber(throughputKbps)}${NBSP}Kbps throughput (Simulated)`; + networkThrottling = `${Util.i18n.formatMilliseconds(rttMs)} TCP RTT, ` + + `${Util.i18n.formatInteger(throughputKbps)}${NBSP}Kbps throughput (Simulated)`; const isSlow4G = () => { return rttMs === 150 && throughputKbps === 1.6 * 1024; diff --git a/report/renderer/i18n.js b/report/renderer/i18n.js index 7ea5be5951ab..a91a973b5710 100644 --- a/report/renderer/i18n.js +++ b/report/renderer/i18n.js @@ -65,6 +65,17 @@ export class I18n { return this._formatNumberWithGranularity(number, granularity); } + /** + * Format integer. + * Just like {@link formatNumber} but uses a granularity of 1, rounding to the nearest + * whole number. + * @param {number} number + * @return {string} + */ + formatInteger(number) { + return this._formatNumberWithGranularity(number, 1); + } + /** * Format percent. * @param {number} number 0–1 diff --git a/report/renderer/util.js b/report/renderer/util.js index b5723ecc8094..a45adec367a4 100644 --- a/report/renderer/util.js +++ b/report/renderer/util.js @@ -431,9 +431,9 @@ class Util { case 'devtools': { const {cpuSlowdownMultiplier, requestLatencyMs} = throttling; cpuThrottling = `${Util.i18n.formatNumber(cpuSlowdownMultiplier)}x slowdown (DevTools)`; - networkThrottling = `${Util.i18n.formatNumber(requestLatencyMs)}${NBSP}ms HTTP RTT, ` + - `${Util.i18n.formatNumber(throttling.downloadThroughputKbps)}${NBSP}Kbps down, ` + - `${Util.i18n.formatNumber(throttling.uploadThroughputKbps)}${NBSP}Kbps up (DevTools)`; + networkThrottling = `${Util.i18n.formatMilliseconds(requestLatencyMs, 1)} HTTP RTT, ` + + `${Util.i18n.formatInteger(throttling.downloadThroughputKbps)}${NBSP}Kbps down, ` + + `${Util.i18n.formatInteger(throttling.uploadThroughputKbps)}${NBSP}Kbps up (DevTools)`; const isSlow4G = () => { return requestLatencyMs === 150 * 3.75 && @@ -446,8 +446,8 @@ class Util { case 'simulate': { const {cpuSlowdownMultiplier, rttMs, throughputKbps} = throttling; cpuThrottling = `${Util.i18n.formatNumber(cpuSlowdownMultiplier)}x slowdown (Simulated)`; - networkThrottling = `${Util.i18n.formatNumber(rttMs)}${NBSP}ms TCP RTT, ` + - `${Util.i18n.formatNumber(throughputKbps)}${NBSP}Kbps throughput (Simulated)`; + networkThrottling = `${Util.i18n.formatMilliseconds(rttMs)} TCP RTT, ` + + `${Util.i18n.formatInteger(throughputKbps)}${NBSP}Kbps throughput (Simulated)`; const isSlow4G = () => { return rttMs === 150 && throughputKbps === 1.6 * 1024; diff --git a/report/test/renderer/i18n-test.js b/report/test/renderer/i18n-test.js index 165c405fc139..3497f3b6595b 100644 --- a/report/test/renderer/i18n-test.js +++ b/report/test/renderer/i18n-test.js @@ -25,6 +25,9 @@ describe('util helpers', () => { assert.strictEqual(i18n.formatNumber(10), '10.0'); assert.strictEqual(i18n.formatNumber(100.01), '100.0'); assert.strictEqual(i18n.formatNumber(13000.456), '13,000.5'); + assert.strictEqual(i18n.formatInteger(10), '10'); + assert.strictEqual(i18n.formatInteger(100.01), '100'); + assert.strictEqual(i18n.formatInteger(13000.6), '13,001'); }); it('formats a date', () => { diff --git a/report/test/renderer/util-test.js b/report/test/renderer/util-test.js index 058bf33cde58..b1996ff0078c 100644 --- a/report/test/renderer/util-test.js +++ b/report/test/renderer/util-test.js @@ -73,7 +73,7 @@ describe('util helpers', () => { // eslint-disable-next-line max-len assert.equal(descriptions.networkThrottling, '150\xa0ms TCP RTT, 1,600\xa0Kbps throughput (Simulated)'); - assert.equal(descriptions.cpuThrottling, '2x slowdown (Simulated)'); + assert.equal(descriptions.cpuThrottling, '2.0x slowdown (Simulated)'); }); describe('#prepareReportResult', () => { diff --git a/treemap/app/src/util.js b/treemap/app/src/util.js index 787d29a19c61..6abe713b1828 100644 --- a/treemap/app/src/util.js +++ b/treemap/app/src/util.js @@ -147,8 +147,8 @@ class TreemapUtil { * @param {string} unit */ static format(value, unit) { - if (unit === 'bytes') return this.i18n.formatBytes(value); - if (unit === 'time') return `${this.i18n.formatNumber(value)}\xa0ms`; + if (unit === 'byte') return this.i18n.formatBytes(value); + if (unit === 'ms') return this.i18n.formatMilliseconds(value); return `${this.i18n.formatNumber(value)}\xa0${unit}`; } From 91c2243980b18bcf934ee1067278a7799d736563 Mon Sep 17 00:00:00 2001 From: Connor Clark Date: Thu, 7 Apr 2022 15:19:44 -0700 Subject: [PATCH 05/13] i18n: upgrade to latest icu formatter --- build/build-bundle.js | 2 +- .../audits/installable-manifest.js | 4 +- lighthouse-core/lib/csp-evaluator.js | 12 +- .../scripts/i18n/collect-strings.js | 50 ++-- .../i18n/prune-obsolete-lhl-messages.js | 15 +- .../test/scripts/i18n/collect-strings-test.js | 8 +- package.json | 4 +- shared/localization/format.js | 98 +++++--- shared/localization/locales/en-US.json | 224 +++++++++--------- shared/localization/locales/en-XL.json | 224 +++++++++--------- yarn.lock | 51 +++- 11 files changed, 384 insertions(+), 308 deletions(-) diff --git a/build/build-bundle.js b/build/build-bundle.js index 71aef80bfc10..24fa0aa6fe7e 100644 --- a/build/build-bundle.js +++ b/build/build-bundle.js @@ -130,7 +130,7 @@ async function build(entryPath, distPath, opts = {minify: true}) { '__filename': (id) => `'${path.relative(LH_ROOT, id)}'`, // This package exports to default in a way that causes Rollup to get confused, // resulting in MessageFormat being undefined. - 'require(\'intl-messageformat\').default': 'require(\'intl-messageformat\')', + // 'require(\'intl-messageformat\').default': 'require(\'intl-messageformat\')', // Below we replace lighthouse-logger with a local copy, which is ES modules. Need // to change every require of the package to reflect this. 'require(\'lighthouse-logger\');': 'require(\'lighthouse-logger\').default;', diff --git a/lighthouse-core/audits/installable-manifest.js b/lighthouse-core/audits/installable-manifest.js index 42448dd7b272..5ed5df5ca70d 100644 --- a/lighthouse-core/audits/installable-manifest.js +++ b/lighthouse-core/audits/installable-manifest.js @@ -40,9 +40,9 @@ const UIStrings = { /** Error message explaining that the provided manifest URL is invalid. */ 'start-url-not-valid': `Manifest start URL is not valid`, /** Error message explaining that the provided manifest does not contain a name or short_name field. */ - 'manifest-missing-name-or-short-name': `Manifest does not contain a 'name' or 'short_name' field`, + 'manifest-missing-name-or-short-name': 'Manifest does not contain a `name` or `short_name` field', /** Error message explaining that the manifest display property must be one of 'standalone', 'fullscreen', or 'minimal-ui'. */ - 'manifest-display-not-supported': `Manifest 'display' property must be one of 'standalone', 'fullscreen', or 'minimal-ui'`, + 'manifest-display-not-supported': 'Manifest `display` property must be one of `standalone`, `fullscreen`, or `minimal-ui`', /** Error message explaining that the manifest could not be fetched, might be empty, or could not be parsed. */ 'manifest-empty': `Manifest could not be fetched, is empty, or could not be parsed`, /** Error message explaining that no matching service worker was detected, diff --git a/lighthouse-core/lib/csp-evaluator.js b/lighthouse-core/lib/csp-evaluator.js index 5ca26bdbdd29..3f405bea2b2b 100644 --- a/lighthouse-core/lib/csp-evaluator.js +++ b/lighthouse-core/lib/csp-evaluator.js @@ -24,25 +24,25 @@ 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. */ missingBaseUri: 'Missing base-uri allows injected tags to set the base URL for all ' + 'relative URLs (e.g. scripts) to an attacker controlled domain. ' + - 'Consider setting base-uri to \'none\' or \'self\'.', + 'Consider setting base-uri to `none` or `self`.', /** Message shown when a CSP does not have a script-src directive. Shown in a table with a list of other CSP vulnerabilities and suggestions. "CSP" stands for "Content Security Policy". "script-src" does not need to be translated. */ missingScriptSrc: 'script-src directive is missing. ' + 'This can allow the execution of unsafe scripts.', /** Message shown when a CSP does not have a script-src directive. Shown in a table with a list of other CSP vulnerabilities and suggestions. "CSP" stands for "Content Security Policy". "object-src" and "'none'" do not need to be translated. */ missingObjectSrc: 'Missing object-src allows the injection of plugins ' + - 'that execute unsafe scripts. Consider setting object-src to \'none\' if you can.', + 'that execute unsafe scripts. Consider setting object-src to `none` if you can.', /** Message shown when a CSP uses a domain allowlist to filter out malicious scripts. Shown in a table with a list of other CSP vulnerabilities and suggestions. "CSP" stands for "Content Security Policy". "CSP", "'strict-dynamic'", "nonces", and "hashes" do not need to be translated. "allowlists" can be interpreted as "whitelist". */ strictDynamic: 'Host allowlists can frequently be bypassed. Consider using ' + - 'CSP nonces or hashes instead, along with \'strict-dynamic\' if necessary.', + 'CSP nonces or hashes instead, along with `strict-dynamic` if necessary.', /** Message shown when a CSP allows inline scripts to be run in the page. Shown in a table with a list of other CSP vulnerabilities and suggestions. "CSP" stands for "Content Security Policy". "CSP", "'unsafe-inline'", "nonces", and "hashes" do not need to be translated. */ - unsafeInline: '\'unsafe-inline\' allows the execution of unsafe in-page scripts ' + + unsafeInline: '`unsafe-inline` allows the execution of unsafe in-page scripts ' + 'and event handlers. Consider using CSP nonces or hashes to allow scripts individually.', /** Message shown when a CSP is not backwards compatible with browsers that do not support CSP nonces/hashes. Shown in a table with a list of other CSP vulnerabilities and suggestions. "CSP" stands for "Content Security Policy". "'unsafe-inline'", "nonces", and "hashes" do not need to be translated. */ - unsafeInlineFallback: 'Consider adding \'unsafe-inline\' (ignored by browsers supporting ' + + unsafeInlineFallback: 'Consider adding `unsafe-inline` (ignored by browsers supporting ' + 'nonces/hashes) to be backward compatible with older browsers.', /** Message shown when a CSP is not backwards compatible with browsers that do not support the 'strict-dynamic' keyword. Shown in a table with a list of other CSP vulnerabilities and suggestions. "CSP" stands for "Content Security Policy". "http:", "https:", and "'strict-dynamic'" do not need to be translated. */ allowlistFallback: 'Consider adding https: and http: URL schemes (ignored by browsers ' + - 'supporting \'strict-dynamic\') to be backward compatible with older browsers.', + 'supporting `strict-dynamic`) to be backward compatible with older browsers.', /** Message shown when a CSP only provides a reporting destination through the report-to directive. Shown in a table with a list of other CSP vulnerabilities and suggestions. "CSP" stands for "Content Security Policy". "report-to", "report-uri", and "Chromium" do not need to be translated. */ reportToOnly: 'The reporting destination is only configured via the report-to directive. ' + 'This directive is only supported in Chromium-based browsers so it is ' + diff --git a/lighthouse-core/scripts/i18n/collect-strings.js b/lighthouse-core/scripts/i18n/collect-strings.js index d3ec21020f1a..120145d8046b 100644 --- a/lighthouse-core/scripts/i18n/collect-strings.js +++ b/lighthouse-core/scripts/i18n/collect-strings.js @@ -14,7 +14,7 @@ import path from 'path'; import glob from 'glob'; import expect from 'expect'; import tsc from 'typescript'; -import MessageParser from 'intl-messageformat-parser'; +import MessageParser from '@formatjs/icu-messageformat-parser'; import esMain from 'es-main'; import {Util} from '../../../lighthouse-core/util-commonjs.js'; @@ -23,6 +23,7 @@ import {pruneObsoleteLhlMessages} from './prune-obsolete-lhl-messages.js'; import {countTranslatedMessages} from './count-translated.js'; import {LH_ROOT} from '../../../root.js'; import {resolveModulePath} from '../esm-utils.js'; +import {escapeIcuMessage} from '../../../shared/localization/format.js'; // Match declarations of UIStrings, terminating in either a `};\n` (very likely to always be right) // or `}\n\n` (allowing semicolon to be optional, but insisting on a double newline so that an @@ -188,36 +189,35 @@ function convertMessageToCtc(lhlMessage, examples = {}) { * @param {string} lhlMessage */ function _lhlValidityChecks(lhlMessage) { - let parsedMessage; + let parsedMessageElements; try { - parsedMessage = MessageParser.parse(lhlMessage); + parsedMessageElements = MessageParser.parse(lhlMessage); } catch (err) { if (err.name !== 'SyntaxError') throw err; - // Improve the intl-messageformat-parser syntax error output. - /** @type {Array<{text: string}>} */ - const expected = err.expected; - const expectedStr = expected.map(exp => `'${exp.text}'`).join(', '); - throw new Error(`Did not find the expected syntax (one of ${expectedStr}) in message "${lhlMessage}"`); + throw new Error(`[${err.message}] Did not find the expected syntax in message: ${err.originalMessage}`); } - for (const element of parsedMessage.elements) { - if (element.type !== 'argumentElement' || !element.format) continue; - - if (element.format.type === 'pluralFormat' || element.format.type === 'selectFormat') { - // `plural`/`select` arguments can't have content before or after them. - // See http://userguide.icu-project.org/formatparse/messages#TOC-Complex-Argument-Types - // e.g. https://github.com/GoogleChrome/lighthouse/pull/11068#discussion_r451682796 - if (parsedMessage.elements.length > 1) { - throw new Error(`Content cannot appear outside plural or select ICU messages. Instead, repeat that content in each option (message: '${lhlMessage}')`); - } - - // Each option value must also be a valid lhlMessage. - for (const option of element.format.options) { - const optionStr = lhlMessage.slice(option.value.location.start.offset, option.value.location.end.offset); - _lhlValidityChecks(optionStr); + /** + * @param {MessageParser.MessageFormatElement[]} elements + */ + function validate(elements) { + for (const element of elements) { + if (element.type === MessageParser.TYPE.plural || element.type === MessageParser.TYPE.select) { + // `plural`/`select` arguments can't have content before or after them. + // See http://userguide.icu-project.org/formatparse/messages#TOC-Complex-Argument-Types + // e.g. https://github.com/GoogleChrome/lighthouse/pull/11068#discussion_r451682796 + if (elements.length > 1) { + throw new Error(`Content cannot appear outside plural or select ICU messages. Instead, repeat that content in each option (message: '${lhlMessage}')`); + } + + for (const option of Object.values(element.options)) { + validate(option.value); + } } } } + + validate(parsedMessageElements); } /** @@ -388,7 +388,7 @@ function _processPlaceholderDirectIcu(icu, examples) { for (const [key, value] of Object.entries(examples)) { // Make sure all examples have ICU vars if (!icu.message.includes(`{${key}}`)) { - throw Error(`Example '${key}' provided, but has not corresponding ICU replacement in message "${icu.message}"`); + throw Error(`Example '${key}' provided, but has no corresponding ICU replacement in message "${icu.message}"`); } const eName = `ICU_${idx++}`; tempMessage = tempMessage.replace(`{${key}}`, `$${eName}$`); @@ -516,7 +516,7 @@ function parseUIStrings(sourceStr, liveUIStrings) { const key = getIdentifier(property); // Use live message to avoid having to e.g. concat strings broken into parts. - const message = liveUIStrings[key]; + const message = escapeIcuMessage(liveUIStrings[key]); // @ts-expect-error - Not part of the public tsc interface yet. const jsDocComments = tsc.getJSDocCommentsAndTags(property); diff --git a/lighthouse-core/scripts/i18n/prune-obsolete-lhl-messages.js b/lighthouse-core/scripts/i18n/prune-obsolete-lhl-messages.js index 4314f27a6c49..9bc1bd78aa9e 100644 --- a/lighthouse-core/scripts/i18n/prune-obsolete-lhl-messages.js +++ b/lighthouse-core/scripts/i18n/prune-obsolete-lhl-messages.js @@ -9,9 +9,12 @@ import fs from 'fs'; import path from 'path'; import glob from 'glob'; -import MessageParser from 'intl-messageformat-parser'; +import MessageParser from '@formatjs/icu-messageformat-parser'; -import {collectAllCustomElementsFromICU} from '../../../shared/localization/format.js'; +import { + collectAllCustomElementsFromICU, + escapeIcuMessage, +} from '../../../shared/localization/format.js'; import {LH_ROOT, readJson} from '../../../root.js'; /** @typedef {Record} LhlMessages */ @@ -24,8 +27,8 @@ import {LH_ROOT, readJson} from '../../../root.js'; * @return {boolean} */ function equalArguments(goldenArgumentIds, lhlMessage) { - const parsedMessage = MessageParser.parse(lhlMessage); - const lhlArgumentElements = collectAllCustomElementsFromICU(parsedMessage.elements); + const parsedMessageElements = MessageParser.parse(escapeIcuMessage(lhlMessage)); + const lhlArgumentElements = collectAllCustomElementsFromICU(parsedMessageElements); const lhlArgumentIds = [...lhlArgumentElements.keys()]; if (goldenArgumentIds.length !== lhlArgumentIds.length) return false; @@ -96,8 +99,8 @@ function getGoldenLocaleArgumentIds(goldenLhl) { const goldenLocaleArgumentIds = {}; for (const [messageId, {message}] of Object.entries(goldenLhl)) { - const parsedMessage = MessageParser.parse(message); - const goldenArgumentElements = collectAllCustomElementsFromICU(parsedMessage.elements); + const parsedMessageElements = MessageParser.parse(message); + const goldenArgumentElements = collectAllCustomElementsFromICU(parsedMessageElements); const goldenArgumentIds = [...goldenArgumentElements.keys()].sort(); goldenLocaleArgumentIds[messageId] = goldenArgumentIds; diff --git a/lighthouse-core/test/scripts/i18n/collect-strings-test.js b/lighthouse-core/test/scripts/i18n/collect-strings-test.js index 85dfdbb74e93..bd87da89d675 100644 --- a/lighthouse-core/test/scripts/i18n/collect-strings-test.js +++ b/lighthouse-core/test/scripts/i18n/collect-strings-test.js @@ -337,7 +337,7 @@ describe('#_lhlValidityChecks', () => { it('errors when using non-supported custom-formatted ICU format', () => { const message = 'Hello World took {var, badFormat, milliseconds}.'; expect(() => collect.convertMessageToCtc(message)).toThrow( - /Did not find the expected syntax \(one of 'number', 'date', 'time', 'plural', 'selectordinal', 'select'\) in message "Hello World took {var, badFormat, milliseconds}."$/); + /\[INVALID_ARGUMENT_TYPE\] Did not find the expected syntax in message: Hello World took {var, badFormat, milliseconds}.$/); }); it('errors when there is content outside of a plural argument', () => { @@ -370,14 +370,14 @@ describe('#_lhlValidityChecks', () => { /Content cannot appear outside plural or select ICU messages.*=1 {1 request} other {# requests}}'\)$/); }); - it('errors when there is content outside of nested plural aguments', () => { + it('errors when there is content outside of nested plural arguments', () => { const message = `{user_gender, select, female {Ms. {name} received {count, plural, =1 {one award.} other {# awards.}}} male {Mr. {name} received {count, plural, =1 {one award.} other {# awards.}}} other {{name} received {count, plural, =1 {one award.} other {# awards.}}} }`; expect(() => collect.convertMessageToCtc(message, {name: 'Elbert'})).toThrow( - /Content cannot appear outside plural or select ICU messages.*\(message: 'Ms. {name} received {count, plural, =1 {one award.} other {# awards.}}'\)$/); + /Content cannot appear outside plural or select ICU messages.*\(message: '{user_gender, select/); }); /* eslint-enable max-len */ }); @@ -562,7 +562,7 @@ describe('Convert Message to Placeholder', () => { const message = 'Hello name.'; expect(() => collect.convertMessageToCtc(message, {name: 'Mary'})) // eslint-disable-next-line max-len - .toThrow(/Example 'name' provided, but has not corresponding ICU replacement in message "Hello name."/); + .toThrow(/Example 'name' provided, but has no corresponding ICU replacement in message "Hello name."/); }); it('errors when direct ICU has no examples', () => { diff --git a/package.json b/package.json index cbc6e39aa2db..d16b4cf504e4 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ }, "devDependencies": { "@build-tracker/cli": "^1.0.0-beta.15", + "@formatjs/icu-messageformat-parser": "^2.0.19", "@rollup/plugin-alias": "^3.1.2", "@rollup/plugin-commonjs": "^20.0.0", "@rollup/plugin-dynamic-import-vars": "^1.1.1", @@ -151,7 +152,7 @@ "gh-pages": "^2.0.1", "glob": "^7.1.3", "idb-keyval": "2.2.0", - "intl-messageformat-parser": "^1.8.1", + "intl-messageformat": "^9.12.0", "jest": "27.1.1", "jsdom": "^12.2.0", "jsonld": "^5.2.0", @@ -189,7 +190,6 @@ "cssstyle": "1.2.1", "enquirer": "^2.3.6", "http-link-header": "^0.8.0", - "intl-messageformat": "^4.4.0", "jpeg-js": "^0.4.3", "js-library-detector": "^6.4.0", "lighthouse-logger": "^1.3.0", diff --git a/shared/localization/format.js b/shared/localization/format.js index 6b6e4e45a132..f9771651f173 100644 --- a/shared/localization/format.js +++ b/shared/localization/format.js @@ -5,6 +5,7 @@ */ 'use strict'; +const {TYPE} = require('@formatjs/icu-messageformat-parser'); const fs = require('fs'); const MessageFormat = require('intl-messageformat'); @@ -28,11 +29,11 @@ const CANONICAL_LOCALES = fs.readdirSync(__dirname + '/locales/') .map(locale => locale.replace('.json', '')) .sort(); -/** @typedef {import('intl-messageformat-parser').Element} MessageElement */ -/** @typedef {import('intl-messageformat-parser').ArgumentElement} ArgumentElement */ +/** @typedef {import('@formatjs/icu-messageformat-parser').MessageFormatElement} MessageFormatElement */ const MESSAGE_I18N_ID_REGEX = / | [^\s]+$/; +/** @type {Partial} */ const formats = { number: { bytes: { @@ -55,40 +56,39 @@ const formats = { }; /** - * 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. + * Function to retrieve all elements from an ICU message AST that are associated + * with a named input, like '{varName}' or '{varName, number, bytes}'. This + * differs from literal message types which are just arbitrary text. * - * Notes: - * This function will recursively inspect plural elements for nested argumentElements. + * This function recursively inspects plural elements for nested elements, + * but since the output is a Map they are deduplicated. + * e.g. "=1{hello {icu}} =other{hello {icu}}" will produce one element in the output, + * with "icu" as its key. * - * 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. + * TODO: don't do that deduplication because messages within a plural message could be number + * messages with different styles. * - * @param {Array} icuElements - * @param {Map} [seenElementsById] - * @return {Map} + * @param {Array} icuElements + * @param {Map} [customElements] + * @return {Map} */ -function collectAllCustomElementsFromICU(icuElements, seenElementsById = new Map()) { +function collectAllCustomElementsFromICU(icuElements, customElements = new Map()) { for (const el of icuElements) { - // We are only interested in elements that need ICU formatting (argumentElements) - if (el.type !== 'argumentElement') continue; + if (el.type === TYPE.literal || el.type === TYPE.pound) continue; - seenElementsById.set(el.id, el); + customElements.set(el.value, 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); + if (el.type === TYPE.plural) { + // Look at all options of the plural (=1{} =other{}...) + for (const option of Object.values(el.options)) { + // Run collections on each option's elements + collectAllCustomElementsFromICU(option.value, customElements); + } } } - return seenElementsById; + return customElements; } /** @@ -101,15 +101,14 @@ function collectAllCustomElementsFromICU(icuElements, seenElementsById = new Map * @return {Record} */ function _preformatValues(messageFormatter, values, lhlMessage) { - const elementMap = collectAllCustomElementsFromICU(messageFormatter.getAst().elements); - const argumentElements = [...elementMap.values()]; + const customElements = collectAllCustomElementsFromICU(messageFormatter.getAst()); /** @type {Record} */ const formattedValues = {}; - for (const {id, format} of argumentElements) { + for (const [id, element] of customElements) { // Throw an error if a message's value isn't provided - if (id && (id in values) === false) { + if (!(id in values)) { throw new Error(`ICU Message "${lhlMessage}" contains a value reference ("${id}") ` + `that wasn't provided`); } @@ -117,7 +116,7 @@ function _preformatValues(messageFormatter, values, lhlMessage) { const value = values[id]; // Direct `{id}` replacement and non-numeric values need no formatting. - if (!format || format.type !== 'numberFormat') { + if (element.type !== TYPE.number) { formattedValues[id] = value; continue; } @@ -128,13 +127,13 @@ function _preformatValues(messageFormatter, values, lhlMessage) { } // Format values for known styles. - if (format.style === 'milliseconds') { + if (element.style === 'milliseconds') { // Round all milliseconds to the nearest 10. formattedValues[id] = Math.round(value / 10) * 10; - } else if (format.style === 'seconds' && id === 'timeInMs') { + } else if (element.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') { + } else if (element.style === 'bytes') { // Replace all the bytes with KB. formattedValues[id] = value / 1024; } else { @@ -160,6 +159,21 @@ function _preformatValues(messageFormatter, values, lhlMessage) { return formattedValues; } +/** + * Escape ICU syntax: we use brackets when referencing HTML (), but + * ICU syntax now supports xml-like annotations. Until we actually want to + * use those, and to avoid churn in our messages, auto-escape these characters + * for now. + * @param {string} message + * @return {string} + */ +function escapeIcuMessage(message) { + return message + .replace(/'/g, `''`) + .replace(//g, `'>`); +} + /** * Format string `message` by localizing `values` and inserting them. `message` * is assumed to already be in the given locale. @@ -173,15 +187,24 @@ 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; + message = escapeIcuMessage(message); + + // TODO: I think this is no longer the case, but my local mac is crashing on + // yarn open-devtools so I can't really confirm yet... // This package is not correctly bundled by Rollup. - /** @type {typeof MessageFormat.IntlMessageFormat} */ - const MessageFormatCtor = MessageFormat.IntlMessageFormat || MessageFormat; - const formatter = new MessageFormatCtor(message, localeForMessageFormat, formats); + // /** @type {typeof MessageFormat.IntlMessageFormat} */ + // const MessageFormatCtor = MessageFormat.IntlMessageFormat || MessageFormat; + const formatter = new MessageFormat.IntlMessageFormat(message, localeForMessageFormat, formats); // Preformat values for the message format like KB and milliseconds. const valuesForMessageFormat = _preformatValues(formatter, values, message); - return formatter.format(valuesForMessageFormat); + const formattedResult = formatter.format(valuesForMessageFormat); + // We only format to strings. + if (Array.isArray(formattedResult) || typeof formattedResult === 'number') { + throw new Error('unexpected formatted result'); + } + return formattedResult; } /** @@ -453,4 +476,5 @@ module.exports = { getIcuMessageIdParts, getAvailableLocales, getCanonicalLocales, + escapeIcuMessage, }; diff --git a/shared/localization/locales/en-US.json b/shared/localization/locales/en-US.json index eddfda6db095..4cfd0d66849e 100644 --- a/shared/localization/locales/en-US.json +++ b/shared/localization/locales/en-US.json @@ -150,7 +150,7 @@ "message": "`[aria-*]` attributes match their roles" }, "lighthouse-core/audits/accessibility/aria-command-name.js | description": { - "message": "When an element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/)." + "message": "When an element doesn''t have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/)." }, "lighthouse-core/audits/accessibility/aria-command-name.js | failureTitle": { "message": "`button`, `link`, and `menuitem` elements do not have accessible names." @@ -159,13 +159,13 @@ "message": "`button`, `link`, and `menuitem` elements have accessible names" }, "lighthouse-core/audits/accessibility/aria-hidden-body.js | description": { - "message": "Assistive technologies, like screen readers, work inconsistently when `aria-hidden=\"true\"` is set on the document ``. [Learn more](https://web.dev/aria-hidden-body/)." + "message": "Assistive technologies, like screen readers, work inconsistently when `aria-hidden=\"true\"` is set on the document `'`. [Learn more](https://web.dev/aria-hidden-body/)." }, "lighthouse-core/audits/accessibility/aria-hidden-body.js | failureTitle": { - "message": "`[aria-hidden=\"true\"]` is present on the document ``" + "message": "`[aria-hidden=\"true\"]` is present on the document `'`" }, "lighthouse-core/audits/accessibility/aria-hidden-body.js | title": { - "message": "`[aria-hidden=\"true\"]` is not present on the document ``" + "message": "`[aria-hidden=\"true\"]` is not present on the document `'`" }, "lighthouse-core/audits/accessibility/aria-hidden-focus.js | description": { "message": "Focusable descendents within an `[aria-hidden=\"true\"]` element prevent those interactive elements from being available to users of assistive technologies like screen readers. [Learn more](https://web.dev/aria-hidden-focus/)." @@ -177,7 +177,7 @@ "message": "`[aria-hidden=\"true\"]` elements do not contain focusable descendents" }, "lighthouse-core/audits/accessibility/aria-input-field-name.js | description": { - "message": "When an input field doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/)." + "message": "When an input field doesn''t have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/)." }, "lighthouse-core/audits/accessibility/aria-input-field-name.js | failureTitle": { "message": "ARIA input fields do not have accessible names" @@ -186,7 +186,7 @@ "message": "ARIA input fields have accessible names" }, "lighthouse-core/audits/accessibility/aria-meter-name.js | description": { - "message": "When an element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/)." + "message": "When an element doesn''t have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/)." }, "lighthouse-core/audits/accessibility/aria-meter-name.js | failureTitle": { "message": "ARIA `meter` elements do not have accessible names." @@ -195,7 +195,7 @@ "message": "ARIA `meter` elements have accessible names" }, "lighthouse-core/audits/accessibility/aria-progressbar-name.js | description": { - "message": "When a `progressbar` element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/)." + "message": "When a `progressbar` element doesn''t have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/)." }, "lighthouse-core/audits/accessibility/aria-progressbar-name.js | failureTitle": { "message": "ARIA `progressbar` elements do not have accessible names." @@ -240,7 +240,7 @@ "message": "`[role]` values are valid" }, "lighthouse-core/audits/accessibility/aria-toggle-field-name.js | description": { - "message": "When a toggle field doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/)." + "message": "When a toggle field doesn''t have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/)." }, "lighthouse-core/audits/accessibility/aria-toggle-field-name.js | failureTitle": { "message": "ARIA toggle fields do not have accessible names" @@ -249,7 +249,7 @@ "message": "ARIA toggle fields have accessible names" }, "lighthouse-core/audits/accessibility/aria-tooltip-name.js | description": { - "message": "When an element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/)." + "message": "When an element doesn''t have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/)." }, "lighthouse-core/audits/accessibility/aria-tooltip-name.js | failureTitle": { "message": "ARIA `tooltip` elements do not have accessible names." @@ -258,7 +258,7 @@ "message": "ARIA `tooltip` elements have accessible names" }, "lighthouse-core/audits/accessibility/aria-treeitem-name.js | description": { - "message": "When an element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/)." + "message": "When an element doesn''t have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more](https://web.dev/aria-name/)." }, "lighthouse-core/audits/accessibility/aria-treeitem-name.js | failureTitle": { "message": "ARIA `treeitem` elements do not have accessible names." @@ -267,7 +267,7 @@ "message": "ARIA `treeitem` elements have accessible names" }, "lighthouse-core/audits/accessibility/aria-valid-attr-value.js | description": { - "message": "Assistive technologies, like screen readers, can't interpret ARIA attributes with invalid values. [Learn more](https://web.dev/aria-valid-attr-value/)." + "message": "Assistive technologies, like screen readers, can''t interpret ARIA attributes with invalid values. [Learn more](https://web.dev/aria-valid-attr-value/)." }, "lighthouse-core/audits/accessibility/aria-valid-attr-value.js | failureTitle": { "message": "`[aria-*]` attributes do not have valid values" @@ -276,7 +276,7 @@ "message": "`[aria-*]` attributes have valid values" }, "lighthouse-core/audits/accessibility/aria-valid-attr.js | description": { - "message": "Assistive technologies, like screen readers, can't interpret ARIA attributes with invalid names. [Learn more](https://web.dev/aria-valid-attr/)." + "message": "Assistive technologies, like screen readers, can''t interpret ARIA attributes with invalid names. [Learn more](https://web.dev/aria-valid-attr/)." }, "lighthouse-core/audits/accessibility/aria-valid-attr.js | failureTitle": { "message": "`[aria-*]` attributes are not valid or misspelled" @@ -288,7 +288,7 @@ "message": "Failing Elements" }, "lighthouse-core/audits/accessibility/button-name.js | description": { - "message": "When a button doesn't have an accessible name, screen readers announce it as \"button\", making it unusable for users who rely on screen readers. [Learn more](https://web.dev/button-name/)." + "message": "When a button doesn''t have an accessible name, screen readers announce it as \"button\", making it unusable for users who rely on screen readers. [Learn more](https://web.dev/button-name/)." }, "lighthouse-core/audits/accessibility/button-name.js | failureTitle": { "message": "Buttons do not have an accessible name" @@ -318,31 +318,31 @@ "message": "When definition lists are not properly marked up, screen readers may produce confusing or inaccurate output. [Learn more](https://web.dev/definition-list/)." }, "lighthouse-core/audits/accessibility/definition-list.js | failureTitle": { - "message": "`
`'s do not contain only properly-ordered `
` and `
` groups, `