/** * @typedef {import('bcp-47').Warning} Warning * @typedef {import('bcp-47').Schema} Schema * @typedef {import('bcp-47').Extension} Extension * * @typedef Options * Configuration (optional). * @property {boolean} [forgiving] * Passed to `bcp-47` as `options.forgiving`. * @property {Warning} [warning] * Passed to `bcp-47` as `options.warning`. * * One additional warning is given: * * | code | reason | * | :--- | :--------------------------------------------------------- | * | 7 | Deprecated region `CURRENT`, expected one of `SUGGESTIONS` | * * This warning is only given if the region cannot be automatically fixed * (when regions split into multiple regions). */ import {parse, stringify} from 'bcp-47' import {extendedFilter} from 'bcp-47-match' import {matches} from './matches.js' import {fields} from './fields.js' import {many} from './many.js' import {likely} from './likely.js' const own = {}.hasOwnProperty /** * @param {Schema} base * @param {Partial} changes * @returns {Schema} */ function merge(base, changes) { if (!base.language) base.language = changes.language if (!base.script) base.script = changes.script if (!base.region) base.region = changes.region if (changes.variants) base.variants.push(...changes.variants) return base } /** * Mostly like: * * But doesn’t crash. * * @param {Schema} schema * @returns {string} */ function addLikelySubtags(schema) { const {language, script, region} = schema /** @type {string|undefined} */ let match if ( script && region && (match = likely[stringify({language, script, region})]) ) { schema.script = undefined schema.region = undefined } else if (script && (match = likely[stringify({language, script})])) { schema.script = undefined } else if (region && (match = likely[stringify({language, region})])) { schema.region = undefined } else if (language && (match = likely[language])) { // Empty. } if (match) { schema.language = undefined merge(schema, parse(match)) } return stringify(schema) } /** * @param {Schema} schema */ function removeLikelySubtags(schema) { addLikelySubtags(schema) const {language, script, region} = schema if (!language) return schema const maxLocale = stringify({language, script, region}) if (maxLocale === addLikelySubtags(parse(language))) { schema.script = undefined schema.region = undefined } else if ( region && maxLocale === addLikelySubtags(parse(language + '-' + region)) ) { schema.script = undefined } else if ( script && maxLocale === addLikelySubtags(parse(language + '-' + script)) ) { schema.region = undefined } return schema } /** * Normalize the given BCP 47 tag according to Unicode CLDR suggestions. * * @param {string} tag * BCP 47 tag. * @param {Options} [options] * Configuration (optional). * @returns {string} * Normal, canonical, and pretty BCP 47 tag. */ export function bcp47Normalize(tag, options) { const settings = options || {} // 1. normalize and lowercase the tag (`sgn-be-fr` -> `sfb`). const schema = parse(String(tag || '').toLowerCase(), settings) const value = stringify(schema) if (!value) { return value } let index = -1 // 2. Do fancy, expensive replaces (`ha-latn-gh` -> `ha-gh`). while (++index < matches.length) { let from = matches[index].from if (from.slice(0, 4) === 'und-' && schema.language) { from = schema.language + from.slice(3) } if (extendedFilter(value, from).length > 0) { replace(schema, from, matches[index].to) } } // 3. Do basic field replaces (`en-840` -> `en-us`). index = -1 while (++index < fields.length) { if (remove(schema, fields[index].from.field, fields[index].from.value)) { add(schema, fields[index].to.field, fields[index].to.value) } } // 4. Minimize. removeLikelySubtags(schema) // 5. Sort variants, and sort extensions on singleton. schema.variants.sort() schema.extensions.sort(compareSingleton) // 6. Warn if fields (currently only regions) should be updated but have // multiple choices. if (settings.warning) { /** @type {keyof many} */ let key for (key in many) { if (own.call(many, key)) { const map = many[key] const value = schema[key] if (value && own.call(map, value)) { const replacements = map[value] settings.warning( 'Deprecated ' + key + ' `' + value + '`, expected one of `' + replacements.join('`, `') + '`', -1, 7 ) } } } } // 7. Add proper casing back. // Format script (ISO 15924) as titlecase (example: `Latn`): if (schema.script) { schema.script = schema.script.charAt(0).toUpperCase() + schema.script.slice(1) } // Format region (ISO 3166) as uppercase (note: this doesn’t affect numeric // codes, which is fine): if (schema.region) { schema.region = schema.region.toUpperCase() } return stringify(schema) } /** * @param {Schema} schema * @param {string} from * @param {string} to * @returns {void} */ function replace(schema, from, to) { const left = parse(from) const right = parse(to) /** @type {Array} */ const removed = [] /** @type {string|null|undefined} */ const lang = left.language /** @type {keyof schema} */ let key // Remove values from `from`: for (key in left) { if (own.call(left, key)) { const value = left[key] if (value && remove(schema, key, value)) { removed.push(key) } } } // Add values from `to`: for (key in right) { if (own.call(right, key)) { const value = right[key] // Only add values that are defined on `to`, and that were either removed by // `from` or are currently empty. if (lang && value && (removed.includes(key) || !schema[key])) { add(schema, key, key === 'language' && value === 'und' ? lang : value) } } } } /** * @param {Schema} object * @param {keyof Schema} key * @param {string|Array|Array} value * @returns {boolean} */ function remove(object, key, value) { let removed = false /** @type {string|Array|Array|null|undefined} */ let result if (value) { const current = object[key] result = current if (Array.isArray(current)) { result = [] let index = -1 while (++index < current.length) { const item = current[index] // @ts-expect-error: TS can’t handle the two lists. if (value.includes(item)) { removed = true } else { // @ts-expect-error: TS can’t handle the two lists. result.push(item) } } } else if (current === value) { result = null removed = true } // @ts-expect-error: Assume the value matches. object[key] = result } return removed } /** * @param {Schema} object * @param {keyof Schema} key * @param {string|Array|Array} value * @returns {void} */ function add(object, key, value) { /** @type {string|Array|Array|null|undefined} */ const current = object[key] if (Array.isArray(current)) { const list = Array.isArray(value) ? value : [value] /** @type {number} */ let index = -1 while (++index < list.length) { const item = list[index] // @ts-expect-error: TS can’t handle the two lists. if (!current.includes(item)) { // @ts-expect-error: TS can’t handle the two lists. current.push(item) } } } else { // @ts-expect-error: Assume the value matches. object[key] = value } } /** * @param {Extension} left * @param {Extension} right * @returns {number} */ function compareSingleton(left, right) { if (left.singleton > right.singleton) { return 1 } if (left.singleton < right.singleton) { return -1 } // It is invalid to have more than one extension with the same singleton so // we should never reach this code. return 0 }