diff --git a/src/govuk/i18n.mjs b/src/govuk/i18n.mjs index d56156323d..976850f949 100644 --- a/src/govuk/i18n.mjs +++ b/src/govuk/i18n.mjs @@ -138,51 +138,75 @@ I18n.prototype.hasIntlNumberFormatSupport = function () { /** * Get the appropriate suffix for the plural form. * - * The locale may include a regional indicator (such as en-GB), but we don't - * usually care about this part, as pluralisation rules are usually the same - * regardless of region. There are exceptions, however, (e.g. Portuguese) so - * this searches by both the full and shortened locale codes, just to be sure. - * * @param {number} count - Number used to determine which pluralisation to use. * @returns {string} - The suffix associated with the correct pluralisation for this locale. */ I18n.prototype.getPluralSuffix = function (count) { - var locale = this.locale - var localeShort = locale.split('-')[0] - var keySuffix = 'other' - // Validate that the number is actually a number. // // Number(count) will turn anything that can't be converted to a Number type // into 'NaN'. isFinite filters out NaN, as it isn't a finite number. count = Number(count) - if (!isFinite(count)) { return keySuffix } + if (!isFinite(count)) { return 'other' } // Check to verify that all the requirements for Intl.PluralRules are met. // If so, we can use that instead of our custom implementation. Otherwise, // use the hardcoded fallback. if (this.hasIntlPluralRulesSupport()) { - var pluralRules = new Intl.PluralRules(this.locale) - keySuffix = pluralRules.select(count) + return new Intl.PluralRules(this.locale).select(count) } else { - // Currently our custom code can only handle positive integers, so let's - // make sure our number is one of those. - count = Math.abs(Math.floor(count)) + return this.selectPluralRuleFromFallback(count) + } +} + +/** + * Get the plural rule using our fallback implementation + * + * This is split out into a separate function to make it easier to test the + * fallback behaviour in an environment where Intl.PluralRules exists. + * + * @param {Number} count - Number used to determine which pluralisation to use. + * @returns {string} - The suffix associated with the correct pluralisation for this locale. + */ +I18n.prototype.selectPluralRuleFromFallback = function (count) { + // Currently our custom code can only handle positive integers, so let's + // make sure our number is one of those. + count = Math.abs(Math.floor(count)) + + var ruleset = this.getPluralRulesForLocale() - // Look through the plural rules map to find which `pluralRule` is - // appropriate for our current `locale`. - for (var pluralRule in this.pluralRulesMap) { - if (Object.prototype.hasOwnProperty.call(this.pluralRulesMap, pluralRule)) { - var languages = this.pluralRulesMap[pluralRule] - if (languages.indexOf(locale) > -1 || languages.indexOf(localeShort) > -1) { - keySuffix = this.pluralRules[pluralRule](count) - break - } + if (ruleset) { + return I18n.pluralRules[ruleset](count) + } + + return 'other' +} + +/** + * Work out which pluralisation rules to use for the current locale + * + * The locale may include a regional indicator (such as en-GB), but we don't + * usually care about this part, as pluralisation rules are usually the same + * regardless of region. There are exceptions, however, (e.g. Portuguese) so + * this searches by both the full and shortened locale codes, just to be sure. + * + * @returns {string} - The name of the pluralisation rule to use (a key for one + * of the functions in this.pluralRules) + */ +I18n.prototype.getPluralRulesForLocale = function () { + var locale = this.locale + var localeShort = locale.split('-')[0] + + // Look through the plural rules map to find which `pluralRule` is + // appropriate for our current `locale`. + for (var pluralRule in I18n.pluralRulesMap) { + if (Object.prototype.hasOwnProperty.call(I18n.pluralRulesMap, pluralRule)) { + var languages = I18n.pluralRulesMap[pluralRule] + if (languages.indexOf(locale) > -1 || languages.indexOf(localeShort) > -1) { + return pluralRule } } } - - return keySuffix } /** @@ -215,7 +239,7 @@ I18n.prototype.getPluralSuffix = function (count) { * Spanish: European Portuguese (pt-PT), Italian (it), Spanish (es) * Welsh: Welsh (cy) */ -I18n.prototype.pluralRulesMap = { +I18n.pluralRulesMap = { arabic: ['ar'], chinese: ['my', 'zh', 'id', 'ja', 'jv', 'ko', 'ms', 'th', 'vi'], french: ['hy', 'bn', 'fr', 'gu', 'hi', 'fa', 'pa', 'zu'], @@ -242,7 +266,7 @@ I18n.prototype.pluralRulesMap = { * @param {number} n - The `count` number being passed through. This must be a positive integer. Negative numbers and decimals aren't accounted for. * @returns {string} - The string that needs to be suffixed to the key (without separator). */ -I18n.prototype.pluralRules = { +I18n.pluralRules = { arabic: function (n) { if (n === 0) { return 'zero' } if (n === 1) { return 'one' } diff --git a/src/govuk/i18n.unit.test.mjs b/src/govuk/i18n.unit.test.mjs index 6078037b15..0789da7ede 100644 --- a/src/govuk/i18n.unit.test.mjs +++ b/src/govuk/i18n.unit.test.mjs @@ -5,7 +5,7 @@ import { I18n } from './i18n.mjs' describe('I18n', () => { - describe('retrieving translations', () => { + describe('.t', () => { let config = {} beforeEach(() => { @@ -37,250 +37,196 @@ describe('I18n', () => { const i18n = new I18n(config) expect(() => i18n.t()).toThrow('i18n: lookup key missing') }) - }) - - describe('string interpolation', () => { - const config = { - nameString: 'My name is %{name}' - } - - it('throws an error if the options data is not present', () => { - const i18n = new I18n(config) - expect(() => { i18n.t('nameString') }).toThrowError('i18n: cannot replace placeholders in string if no option data provided') - }) - it('throws an error if the options object is empty', () => { - const i18n = new I18n(config) - expect(() => { i18n.t('nameString', {}) }).toThrowError('i18n: no data found to replace %{name} placeholder in string') - }) - - it('throws an error if the options object does not have a matching key', () => { - const i18n = new I18n(config) - expect(() => { i18n.t('nameString', { unrelatedThing: 'hello' }) }).toThrowError('i18n: no data found to replace %{name} placeholder in string') - }) + describe('string interpolation', () => { + const config = { + nameString: 'My name is %{name}' + } - it('only matches %{} as a placeholder', () => { - const i18n = new I18n({ - price: '%{name}, this } item %{ costs $5.00' + it('throws an error if the options data is not present', () => { + const i18n = new I18n(config) + expect(() => { i18n.t('nameString') }).toThrowError('i18n: cannot replace placeholders in string if no option data provided') }) - expect(i18n.t('price', { name: 'John' })).toBe('John, this } item %{ costs $5.00') - }) - it('can lookup a placeholder value with non-alphanumeric key', () => { - const i18n = new I18n({ - age: 'My age is %{current-age}' + it('throws an error if the options object is empty', () => { + const i18n = new I18n(config) + expect(() => { i18n.t('nameString', {}) }).toThrowError('i18n: no data found to replace %{name} placeholder in string') }) - expect(i18n.t('age', { 'current-age': 55 })).toBe('My age is 55') - }) - it('can lookup a placeholder value with reserved name as key', () => { - const i18n = new I18n({ - age: 'My age is %{valueOf}' + it('throws an error if the options object does not have a matching key', () => { + const i18n = new I18n(config) + expect(() => { i18n.t('nameString', { unrelatedThing: 'hello' }) }).toThrowError('i18n: no data found to replace %{name} placeholder in string') }) - expect(i18n.t('age', { valueOf: 55 })).toBe('My age is 55') - }) - it('throws an expected error if placeholder key with reserved name is not present in options', () => { - const i18n = new I18n({ - age: 'My age is %{valueOf}' + it('only matches %{} as a placeholder', () => { + const i18n = new I18n({ + price: '%{name}, this } item %{ costs $5.00' + }) + expect(i18n.t('price', { name: 'John' })).toBe('John, this } item %{ costs $5.00') }) - expect(() => { i18n.t('age', {}) }).toThrowError('i18n: no data found to replace %{valueOf} placeholder in string') - }) - - it('replaces the placeholder with the provided data', () => { - const i18n = new I18n(config) - expect(i18n.t('nameString', { name: 'John' })).toBe('My name is John') - }) - it('can replace a placeholder with a falsey value', () => { - const i18n = new I18n({ - nameString: 'My name is %{name}', - stock: 'Stock level: %{quantity}' + it('can lookup a placeholder value with non-alphanumeric key', () => { + const i18n = new I18n({ + age: 'My age is %{current-age}' + }) + expect(i18n.t('age', { 'current-age': 55 })).toBe('My age is 55') }) - expect(i18n.t('nameString', { name: '' })).toBe('My name is ') - expect(i18n.t('stock', { quantity: 0 })).toBe('Stock level: 0') - }) - it('can pass false as a placeholder replacement to hide the value', () => { - const i18n = new I18n({ - personalDetails: 'John Smith %{age}' + it('can lookup a placeholder value with reserved name as key', () => { + const i18n = new I18n({ + age: 'My age is %{valueOf}' + }) + expect(i18n.t('age', { valueOf: 55 })).toBe('My age is 55') }) - expect(i18n.t('personalDetails', { age: false })).toBe('John Smith ') - }) - it('selects the correct data to replace in the string', () => { - const i18n = new I18n(config) - expect(i18n.t('nameString', { number: 50, name: 'Claire', otherName: 'Zoe' })).toBe('My name is Claire') - }) - - it('replaces multiple placeholders, if present', () => { - const i18n = new I18n({ - nameString: 'Their name is %{name}. %{name} is %{age} years old' + it('throws an expected error if placeholder key with reserved name is not present in options', () => { + const i18n = new I18n({ + age: 'My age is %{valueOf}' + }) + expect(() => { i18n.t('age', {}) }).toThrowError('i18n: no data found to replace %{valueOf} placeholder in string') }) - expect(i18n.t('nameString', { number: 50, name: 'Andrew', otherName: 'Vic', age: 22 })).toBe('Their name is Andrew. Andrew is 22 years old') - }) - it('nested placeholder only resolves with a matching key', () => { - const i18n = new I18n({ - nameString: 'Their name is %{name%{age}}' + it('replaces the placeholder with the provided data', () => { + const i18n = new I18n(config) + expect(i18n.t('nameString', { name: 'John' })).toBe('My name is John') }) - expect(i18n.t('nameString', { name: 'Andrew', age: 55, 'name%{age}': 'Testing' })).toBe('Their name is Testing') - }) - - it('handles placeholder-style text within options values', () => { - const i18n = new I18n(config) - expect(i18n.t('nameString', { name: '%{name}' })).toBe('My name is %{name}') - }) - it('formats numbers that are passed as placeholders', () => { - const translations = { ageString: 'I am %{age} years old' } - const i18nEn = new I18n(translations, { locale: 'en' }) - const i18nDe = new I18n(translations, { locale: 'de' }) - - expect(i18nEn.t('ageString', { age: 2000 })).toBe('I am 2,000 years old') - expect(i18nDe.t('ageString', { age: 2000 })).toBe('I am 2.000 years old') - }) - - it('does not format number-like strings that are passed as placeholders', () => { - const i18n = new I18n({ - yearString: 'Happy new year %{year}' + it('can replace a placeholder with a falsey value', () => { + const i18n = new I18n({ + nameString: 'My name is %{name}', + stock: 'Stock level: %{quantity}' + }) + expect(i18n.t('nameString', { name: '' })).toBe('My name is ') + expect(i18n.t('stock', { quantity: 0 })).toBe('Stock level: 0') }) - expect(i18n.t('yearString', { year: '2023' })).toBe('Happy new year 2023') - }) - }) - - describe('pluralisation', () => { - it('throws an error if a required plural form is not provided ', () => { - const i18n = new I18n({ - 'test.other': 'testing testing' - }, { - locale: 'en' + it('can pass false as a placeholder replacement to hide the value', () => { + const i18n = new I18n({ + personalDetails: 'John Smith %{age}' + }) + expect(i18n.t('personalDetails', { age: false })).toBe('John Smith ') }) - expect(() => { i18n.t('test', { count: 1 }) }).toThrowError('i18n: Plural form ".one" is required for "en" locale') - }) - it('interpolates the count variable into the correct plural form', () => { - const i18n = new I18n({ - 'test.one': '%{count} test', - 'test.other': '%{count} tests' - }, { - locale: 'en' + it('selects the correct data to replace in the string', () => { + const i18n = new I18n(config) + expect(i18n.t('nameString', { number: 50, name: 'Claire', otherName: 'Zoe' })).toBe('My name is Claire') }) - expect(i18n.t('test', { count: 1 })).toBe('1 test') - expect(i18n.t('test', { count: 5 })).toBe('5 tests') - }) - - describe('fallback plural rules', () => { - const testNumbers = [0, 1, 2, 5, 25, 100] - - it('returns the correct plural form for a given count (Arabic rules)', () => { - const locale = 'ar' - const localeNumbers = [105, 125] - - const i18n = new I18n({}, { locale }) - const intl = new Intl.PluralRules(locale) - - testNumbers.concat(localeNumbers).forEach(num => { - expect(i18n.pluralRules.arabic(num)).toBe(intl.select(num)) + it('replaces multiple placeholders, if present', () => { + const i18n = new I18n({ + nameString: 'Their name is %{name}. %{name} is %{age} years old' }) + expect(i18n.t('nameString', { number: 50, name: 'Andrew', otherName: 'Vic', age: 22 })).toBe('Their name is Andrew. Andrew is 22 years old') }) - it('returns the correct plural form for a given count (Chinese rules)', () => { - const locale = 'zh' - const localeNumbers = [] - - const i18n = new I18n({}, { locale }) - const intl = new Intl.PluralRules(locale) - - testNumbers.concat(localeNumbers).forEach(num => { - expect(i18n.pluralRules.chinese(num)).toBe(intl.select(num)) + it('nested placeholder only resolves with a matching key', () => { + const i18n = new I18n({ + nameString: 'Their name is %{name%{age}}' }) + expect(i18n.t('nameString', { name: 'Andrew', age: 55, 'name%{age}': 'Testing' })).toBe('Their name is Testing') }) - it('returns the correct plural form for a given count (French rules)', () => { - const locale = 'fr' - const localeNumbers = [] - - const i18n = new I18n({}, { locale }) - const intl = new Intl.PluralRules(locale) - - testNumbers.concat(localeNumbers).forEach(num => { - expect(i18n.pluralRules.french(num)).toBe(intl.select(num)) - }) + it('handles placeholder-style text within options values', () => { + const i18n = new I18n(config) + expect(i18n.t('nameString', { name: '%{name}' })).toBe('My name is %{name}') }) - it('returns the correct plural form for a given count (German rules)', () => { - const locale = 'de' - const localeNumbers = [] - - const i18n = new I18n({}, { locale }) - const intl = new Intl.PluralRules(locale) + it('formats numbers that are passed as placeholders', () => { + const translations = { ageString: 'I am %{age} years old' } + const i18nEn = new I18n(translations, { locale: 'en' }) + const i18nDe = new I18n(translations, { locale: 'de' }) - testNumbers.concat(localeNumbers).forEach(num => { - expect(i18n.pluralRules.german(num)).toBe(intl.select(num)) - }) + expect(i18nEn.t('ageString', { age: 2000 })).toBe('I am 2,000 years old') + expect(i18nDe.t('ageString', { age: 2000 })).toBe('I am 2.000 years old') }) - it('returns the correct plural form for a given count (Irish rules)', () => { - const locale = 'ga' - const localeNumbers = [9] + it('does not format number-like strings that are passed as placeholders', () => { + const i18n = new I18n({ + yearString: 'Happy new year %{year}' + }) - const i18n = new I18n({}, { locale }) - const intl = new Intl.PluralRules(locale) + expect(i18n.t('yearString', { year: '2023' })).toBe('Happy new year 2023') + }) + }) - testNumbers.concat(localeNumbers).forEach(num => { - expect(i18n.pluralRules.irish(num)).toBe(intl.select(num)) + describe('pluralisation', () => { + it('throws an error if a required plural form is not provided ', () => { + const i18n = new I18n({ + 'test.other': 'testing testing' + }, { + locale: 'en' }) + expect(() => { i18n.t('test', { count: 1 }) }).toThrowError('i18n: Plural form ".one" is required for "en" locale') }) - it('returns the correct plural form for a given count (Russian rules)', () => { - const locale = 'ru' - const localeNumbers = [3, 13, 101] - - const i18n = new I18n({}, { locale }) - const intl = new Intl.PluralRules(locale) - - testNumbers.concat(localeNumbers).forEach(num => { - expect(i18n.pluralRules.russian(num)).toBe(intl.select(num)) + it('interpolates the count variable into the correct plural form', () => { + const i18n = new I18n({ + 'test.one': '%{count} test', + 'test.other': '%{count} tests' + }, { + locale: 'en' }) + + expect(i18n.t('test', { count: 1 })).toBe('1 test') + expect(i18n.t('test', { count: 5 })).toBe('5 tests') }) + }) + }) - it('returns the correct plural form for a given count (Scottish rules)', () => { - const locale = 'gd' - const localeNumbers = [15] + describe('.getPluralRulesForLocale', () => { + it('returns the correct rules for a locale in the map', () => { + const locale = 'ar' + const i18n = new I18n({}, { locale }) + expect(i18n.getPluralRulesForLocale()).toBe('arabic') + }) - const i18n = new I18n({}, { locale }) - const intl = new Intl.PluralRules(locale) + it('returns the correct rules for a locale in the map with regional indicator', () => { + const locale = 'pt-PT' + const i18n = new I18n({}, { locale }) + expect(i18n.getPluralRulesForLocale()).toBe('spanish') + }) - testNumbers.concat(localeNumbers).forEach(num => { - expect(i18n.pluralRules.scottish(num)).toBe(intl.select(num)) - }) - }) + it('returns the correct rules for a locale allowing for no regional indicator', () => { + const locale = 'cy-GB' + const i18n = new I18n({}, { locale }) + expect(i18n.getPluralRulesForLocale()).toBe('welsh') + }) + }) - it('returns the correct plural form for a given count (Spanish rules)', () => { - const locale = 'es' - const localeNumbers = [1000000, 2000000] + describe('.selectPluralRuleFromFallback', () => { + // The locales we want to test, with numbers for any 'special cases' in + // those locales we want to ensure are handled correctly + const locales = [ + ['ar', [105, 125]], + ['zh'], + ['fr'], + ['de'], + ['ga', [9]], + ['ru', [3, 13, 101]], + ['gd', [15]], + ['es', [1000000, 2000000]], + ['cy', [3, 6]] + ] - const i18n = new I18n({}, { locale }) - const intl = new Intl.PluralRules(locale) + it.each(locales)('matches `Intl.PluralRules.select()` for %s locale', (locale, localeNumbers = []) => { + const i18n = new I18n({}, { locale }) + const intl = new Intl.PluralRules(locale) - testNumbers.concat(localeNumbers).forEach(num => { - expect(i18n.pluralRules.spanish(num)).toBe(intl.select(num)) - }) + const numbersToTest = [0, 1, 2, 5, 25, 100, ...localeNumbers] + + numbersToTest.forEach(num => { + expect(i18n.selectPluralRuleFromFallback(num)).toBe(intl.select(num)) }) + }) - it('returns the correct plural form for a given count (Welsh rules)', () => { - const locale = 'cy' - const localeNumbers = [3, 6] + it('returns "other" for unsupported locales', () => { + const locale = 'la' + const i18n = new I18n({}, { locale }) - const i18n = new I18n({}, { locale }) - const intl = new Intl.PluralRules(locale) + const numbersToTest = [0, 1, 2, 5, 25, 100] - testNumbers.concat(localeNumbers).forEach(num => { - expect(i18n.pluralRules.welsh(num)).toBe(intl.select(num)) - }) + numbersToTest.forEach(num => { + expect(i18n.selectPluralRuleFromFallback(num)).toBe('other') }) }) })