336 lines
8.2 KiB
JavaScript
336 lines
8.2 KiB
JavaScript
/**
|
||
* @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<Schema>} 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:
|
||
* <https://github.com/formatjs/formatjs/blob/a15e757/packages/intl-locale/index.ts#L254>
|
||
* 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<string>} */
|
||
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<string>|Array<Extension>} value
|
||
* @returns {boolean}
|
||
*/
|
||
function remove(object, key, value) {
|
||
let removed = false
|
||
/** @type {string|Array<string>|Array<Extension>|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<string>|Array<Extension>} value
|
||
* @returns {void}
|
||
*/
|
||
function add(object, key, value) {
|
||
/** @type {string|Array<string>|Array<Extension>|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
|
||
}
|