Files
bookorbit/client/scripts/locale-message-validation.mjs
T
neon 90bb087180 i18n(client): fill a plural branch Crowdin omitted instead of dropping the message
Complete a plural category the target locale needs from the message's own
`other` branch during sync, so one missing branch no longer costs the whole
translation. Romanian arrived with `one` and `other` for all 156 of its
plurals and rendered English for every one of them. See docs/LOCALIZATION.md.
2026-08-27 18:54:17 +00:00

323 lines
11 KiB
JavaScript

import { parse, TYPE } from '@formatjs/icu-messageformat-parser'
import { baseCompile } from '@intlify/message-compiler'
const ICU_PLURAL_PATTERN = /\{\s*[\w.-]+\s*,\s*(?:plural|selectordinal)\s*,/
export function isIcuPluralMessage(message) {
return ICU_PLURAL_PATTERN.test(message)
}
function argumentSignature(element) {
const type = {
[TYPE.argument]: 'argument',
[TYPE.number]: 'number',
[TYPE.date]: 'date',
[TYPE.time]: 'time',
[TYPE.select]: 'select',
[TYPE.plural]: element.pluralType,
}[element.type]
const style = 'style' in element && element.style !== null ? `:${JSON.stringify(element.style)}` : ''
return `${element.value}:${type}${style}`
}
function optionHasContent(elements) {
return elements.some((element) => {
if (element.type === TYPE.literal) return element.value.trim().length > 0
if (element.type === TYPE.tag) return optionHasContent(element.children)
return true
})
}
function walkOptions(element, analysis) {
for (const [selector, option] of Object.entries(element.options)) {
if (!optionHasContent(option.value)) {
analysis.emptyOptions.push({
argument: element.value,
selector,
})
}
walkElements(option.value, analysis)
}
}
function walkElements(elements, analysis) {
for (const element of elements) {
if (
element.type === TYPE.argument ||
element.type === TYPE.number ||
element.type === TYPE.date ||
element.type === TYPE.time ||
element.type === TYPE.select ||
element.type === TYPE.plural
) {
analysis.arguments.add(element.value)
analysis.argumentSignatures.add(argumentSignature(element))
}
if (element.type === TYPE.select) {
analysis.selects.push({
argument: element.value,
selectors: Object.keys(element.options),
})
walkOptions(element, analysis)
}
if (element.type === TYPE.plural) {
analysis.plurals.push({
argument: element.value,
offset: element.offset,
type: element.pluralType,
selectors: Object.keys(element.options),
})
walkOptions(element, analysis)
}
if (element.type === TYPE.tag) walkElements(element.children, analysis)
}
}
export function analyzeIcuMessage(message) {
const analysis = {
arguments: new Set(),
argumentSignatures: new Set(),
emptyOptions: [],
plurals: [],
selects: [],
}
walkElements(parse(message), analysis)
return {
arguments: [...analysis.arguments].sort(),
argumentSignatures: [...analysis.argumentSignatures].sort(),
emptyOptions: analysis.emptyOptions,
plurals: analysis.plurals.map((plural) => ({
...plural,
selectors: [...plural.selectors].sort(),
})),
selects: analysis.selects.map((select) => ({
...select,
selectors: [...select.selectors].sort(),
})),
}
}
// Every plural in the catalogs counts whole items, and a library tops out well below a million.
// CLDR still lists categories that only fractional counts (Czech and Slovak `many`) or counts of a
// million and over (Spanish, French, Italian and Portuguese `many`) can ever select, so demanding a
// translation for them rejects otherwise correct Crowdin exports for a branch that cannot render.
// Sampling 0..1000 selects exactly the categories every integer below a million selects.
const REACHABLE_COUNT_SAMPLE = Array.from({ length: 1001 }, (_, index) => index)
function pluralCategories(locale, type) {
const rules = new Intl.PluralRules(locale, { type })
const reachable = new Set(REACHABLE_COUNT_SAMPLE.map((count) => rules.select(count)))
return rules.resolvedOptions().pluralCategories.filter((category) => reachable.has(category))
}
function exactSelectors(selectors) {
return selectors.filter((selector) => selector.startsWith('='))
}
function analyzeVueMessage(message) {
const errors = []
const { ast } = baseCompile(message, {
onError: (error) => errors.push(error.message),
})
const placeholders = new Set()
function walk(value) {
if (Array.isArray(value)) {
for (const child of value) walk(child)
return
}
if (!value || typeof value !== 'object') return
if (value.type === 4) placeholders.add(`named:${value.key}`)
if (value.type === 5) placeholders.add(`list:${value.index}`)
for (const [key, child] of Object.entries(value)) {
if (key !== 'loc') walk(child)
}
}
walk(ast.body)
return {
errors,
isPlural: ast.body.type === 1,
placeholders: [...placeholders].sort(),
}
}
function pluralOptionPounds(elements) {
let totals = [0]
const combine = (other) => {
totals = [...new Set(totals.flatMap((total) => other.map((addition) => total + addition)))]
}
for (const element of elements) {
if (element.type === TYPE.pound) combine([1])
else if (element.type === TYPE.tag) combine(pluralOptionPounds(element.children))
else if (element.type === TYPE.select || element.type === TYPE.plural) {
combine(Object.values(element.options).flatMap((option) => pluralOptionPounds(option.value)))
}
}
return totals
}
// A translator types the whole ICU message by hand, so a locale with more plural categories than
// English loses every plural to a missing branch: Romanian arrived with `one` and `other` for all 156
// of its plurals and rendered English for every one of them. The branch a translator writes as
// `other` is the one a reader sees for small counts, so copying it into the missing categories keeps
// the message in its own language, and the copy disappears as soon as Crowdin carries a real branch.
function pluralElements(elements, found = []) {
for (const element of elements) {
if (element.type === TYPE.plural) found.push(element)
if (element.type === TYPE.select || element.type === TYPE.plural) {
for (const option of Object.values(element.options)) pluralElements(option.value, found)
}
if (element.type === TYPE.tag) pluralElements(element.children, found)
}
return found
}
export function completePluralCategories({ locale, message }) {
if (!isIcuPluralMessage(message)) return null
let elements
try {
elements = parse(message, { captureLocation: true })
} catch {
return null
}
const insertions = []
for (const element of pluralElements(elements)) {
const other = element.options.other
if (!other) continue
const missing = pluralCategories(locale, element.pluralType).filter((category) => !(category in element.options))
if (missing.length === 0) continue
const text = message.slice(other.location.start.offset + 1, other.location.end.offset - 1)
insertions.push({
offset: other.location.end.offset,
text: missing.map((category) => ` ${category} {${text}}`).join(''),
})
}
if (insertions.length === 0) return null
let completed = message
for (const { offset, text } of insertions.sort((first, second) => second.offset - first.offset)) {
completed = `${completed.slice(0, offset)}${text}${completed.slice(offset)}`
}
return completed
}
export function validateSlotCountMessage({ key, locale, message }) {
if (!isIcuPluralMessage(message)) return [`${locale}: ICU plural syntax required for slot count message ${key}`]
let totals
try {
totals = pluralOptionPounds(parse(message))
} catch {
return []
}
return totals.every((total) => total === 1) ? [] : [`${locale}: slot count message ${key} must render exactly one # in every branch`]
}
export function validateLocaleMessage({ key, locale, message, referenceMessage }) {
const errors = []
const referenceIsIcu = isIcuPluralMessage(referenceMessage)
const messageIsIcu = isIcuPluralMessage(message)
if (!referenceIsIcu) {
const referenceAnalysis = analyzeVueMessage(referenceMessage)
const messageAnalysis = analyzeVueMessage(message)
if (messageIsIcu) errors.push(`${locale}: unexpected ICU plural syntax in ${key}`)
if (referenceAnalysis.isPlural) errors.push(`${locale}: legacy plural branches are not allowed for ${key}`)
if (messageAnalysis.isPlural && message !== referenceMessage) {
errors.push(`${locale}: legacy plural branches are not allowed for ${key}`)
}
if (messageAnalysis.errors.length > 0) {
errors.push(`${locale}: invalid Vue I18n syntax in ${key}: ${messageAnalysis.errors.join('; ')}`)
return errors
}
if (messageAnalysis.placeholders.join(',') !== referenceAnalysis.placeholders.join(',')) {
errors.push(`${locale}: placeholders differ for ${key}`)
}
return errors
}
if (message.includes(' | ')) errors.push(`${locale}: legacy plural branches are not allowed for ICU message ${key}`)
if (!messageIsIcu) {
errors.push(`${locale}: ICU plural syntax required for ${key}`)
return errors
}
let referenceAnalysis
let messageAnalysis
try {
referenceAnalysis = analyzeIcuMessage(referenceMessage)
} catch (error) {
errors.push(`en: invalid ICU syntax in ${key}: ${error.message}`)
return errors
}
try {
messageAnalysis = analyzeIcuMessage(message)
} catch (error) {
errors.push(`${locale}: invalid ICU syntax in ${key}: ${error.message}`)
return errors
}
const argumentsMatch = messageAnalysis.arguments.join(',') === referenceAnalysis.arguments.join(',')
if (!argumentsMatch) {
errors.push(`${locale}: ICU arguments differ for ${key}`)
}
if (argumentsMatch && messageAnalysis.argumentSignatures.join(',') !== referenceAnalysis.argumentSignatures.join(',')) {
errors.push(`${locale}: ICU argument types differ for ${key}`)
}
for (const option of messageAnalysis.emptyOptions) {
errors.push(`${locale}: empty ICU option ${option.selector} for ${option.argument} in ${key}`)
}
const selectSignatures = (analysis) => analysis.selects.map((select) => `${select.argument}:${select.selectors.join(',')}`).sort()
if (selectSignatures(messageAnalysis).join('|') !== selectSignatures(referenceAnalysis).join('|')) {
errors.push(`${locale}: ICU select expressions differ for ${key}`)
}
if (messageAnalysis.plurals.length !== referenceAnalysis.plurals.length) {
errors.push(`${locale}: ICU plural expressions differ for ${key}`)
return errors
}
const remainingPlurals = [...messageAnalysis.plurals]
for (const referencePlural of referenceAnalysis.plurals) {
const matchingIndex = remainingPlurals.findIndex((plural) => plural.argument === referencePlural.argument)
const messagePlural = remainingPlurals.splice(matchingIndex === -1 ? 0 : matchingIndex, 1)[0]
if (
messagePlural.argument !== referencePlural.argument ||
messagePlural.offset !== referencePlural.offset ||
messagePlural.type !== referencePlural.type
) {
errors.push(`${locale}: ICU plural expressions differ for ${key}`)
continue
}
const selectors = new Set(messagePlural.selectors)
for (const selector of exactSelectors(referencePlural.selectors)) {
if (!selectors.has(selector)) errors.push(`${locale}: ICU selector ${selector} missing for ${key}`)
}
const requiredCategories = new Set([...pluralCategories(locale, messagePlural.type), 'other'])
for (const category of requiredCategories) {
if (!selectors.has(category)) errors.push(`${locale}: ICU plural category ${category} missing for ${key}`)
}
}
return errors
}