mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { completePluralCategories } from './locale-message-validation.mjs'
|
||||
|
||||
// Catalogs never carry a Unicode em dash. That is a house style rule rather than a correctness one,
|
||||
// and translators reach for the character constantly, so rejecting the message would discard a sound
|
||||
// translation over punctuation and stall the sync until somebody edits Crowdin by hand. The English
|
||||
// source writes a plain hyphen in the same place, so the sync rewrites the character instead.
|
||||
export function normalizeMessageTypography(message) {
|
||||
return message.replaceAll('\u2014', '-')
|
||||
}
|
||||
|
||||
// Repairs applied to a Crowdin export before validation. Each one keeps a translation that is sound
|
||||
// apart from a mechanical defect, and each disappears on its own once Crowdin carries a clean
|
||||
// message, so nothing here hides a translation problem from the pull request body.
|
||||
export function findExportRepairs({ catalogs }) {
|
||||
const repairs = []
|
||||
for (const [locale, catalog] of catalogs) {
|
||||
if (locale === 'en') continue
|
||||
|
||||
for (const [key, message] of catalog) {
|
||||
const kinds = []
|
||||
const normalized = normalizeMessageTypography(message)
|
||||
if (normalized !== message) kinds.push('typography')
|
||||
|
||||
const completed = completePluralCategories({ locale, message: normalized })
|
||||
if (completed) kinds.push('plural categories')
|
||||
|
||||
if (kinds.length > 0) repairs.push({ locale, key, message: completed ?? normalized, kinds })
|
||||
}
|
||||
}
|
||||
|
||||
return repairs
|
||||
}
|
||||
@@ -164,6 +164,57 @@ function pluralOptionPounds(elements) {
|
||||
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}`]
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
// Catalogs never carry a Unicode em dash. That is a house style rule rather than a correctness one,
|
||||
// and translators reach for the character constantly, so rejecting the message would discard a sound
|
||||
// translation over punctuation and stall the sync until somebody edits Crowdin by hand. The English
|
||||
// source writes a plain hyphen in the same place, so the sync rewrites the character instead.
|
||||
export function normalizeMessageTypography(message) {
|
||||
return message.replaceAll('\u2014', '-')
|
||||
}
|
||||
|
||||
export function findTypographyDrift({ catalogs }) {
|
||||
const drift = []
|
||||
for (const [locale, catalog] of catalogs) {
|
||||
if (locale === 'en') continue
|
||||
for (const [key, message] of catalog) {
|
||||
const normalized = normalizeMessageTypography(message)
|
||||
if (normalized !== message) drift.push({ locale, key, message: normalized })
|
||||
}
|
||||
}
|
||||
|
||||
return drift
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { TARGET_CATALOGS, assertCrowdinTargetConfiguration } from './locale-conf
|
||||
import { findInvalidTargetMessages, flattenCatalog, validateCatalogs } from './locale-catalog-validation.mjs'
|
||||
import { collectSourceMessageKeys } from './locale-source-keys.mjs'
|
||||
import { PROTECTED_SOURCE_TERMS, findProtectedTermDrift } from './locale-protected-terms.mjs'
|
||||
import { findTypographyDrift } from './locale-typography.mjs'
|
||||
import { findExportRepairs } from './locale-export-repairs.mjs'
|
||||
|
||||
const API = 'https://api.crowdin.com/api/v2'
|
||||
const SOURCE_PATH_SUFFIX = '/client/src/locales/en.json'
|
||||
@@ -372,9 +372,11 @@ export function formatTranslationLossReport(losses) {
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
async function reportSyncIssues({ rejections, corrections, normalizations, losses, reportPath }) {
|
||||
if (normalizations.length > 0) {
|
||||
console.log(`Normalized punctuation in ${normalizations.length} Crowdin messages`)
|
||||
async function reportSyncIssues({ rejections, corrections, repairs, losses, reportPath }) {
|
||||
if (repairs.length > 0) {
|
||||
const kinds = repairs.flatMap(({ kinds: repaired }) => repaired)
|
||||
const counts = [...new Set(kinds)].map((kind) => `${kind}=${kinds.filter((entry) => entry === kind).length}`)
|
||||
console.log(`Repaired ${repairs.length} Crowdin messages before validation: ${counts.join(' ')}`)
|
||||
}
|
||||
|
||||
if (losses.length > 0) {
|
||||
@@ -447,13 +449,13 @@ export async function syncCrowdinTranslations({
|
||||
const corrections = findProtectedTermDrift({ catalogs, terms: protectedTerms })
|
||||
for (const { locale, key, source } of corrections) catalogs.get(locale).set(key, source)
|
||||
|
||||
const normalizations = findTypographyDrift({ catalogs })
|
||||
for (const { locale, key, message } of normalizations) catalogs.get(locale).set(key, message)
|
||||
const repairs = findExportRepairs({ catalogs })
|
||||
for (const { locale, key, message } of repairs) catalogs.get(locale).set(key, message)
|
||||
|
||||
const { slotCountKeys } = await collectMessageKeys()
|
||||
const rejections = findInvalidTargetMessages({ catalogs, slotCountKeys })
|
||||
for (const { locale, key } of rejections) catalogs.get(locale).delete(key)
|
||||
const rewrittenLocales = new Set([...rejections, ...corrections, ...normalizations].map(({ locale }) => locale))
|
||||
const rewrittenLocales = new Set([...rejections, ...corrections, ...repairs].map(({ locale }) => locale))
|
||||
for (const entry of downloaded) {
|
||||
if (rewrittenLocales.has(entry.locale)) entry.catalog = orderedSparseCatalog(reference, catalogs.get(entry.locale))
|
||||
}
|
||||
@@ -473,9 +475,9 @@ export async function syncCrowdinTranslations({
|
||||
await Promise.all(
|
||||
downloaded.map(({ locale, catalog }) => writeFile(path.join(outputDirectory, `${locale}.json`), `${JSON.stringify(catalog, null, 2)}\n`)),
|
||||
)
|
||||
await reportSyncIssues({ rejections, corrections, normalizations, losses, reportPath })
|
||||
await reportSyncIssues({ rejections, corrections, repairs, losses, reportPath })
|
||||
console.log(`Synchronized ${downloaded.length} sparse translation catalogs from Crowdin`)
|
||||
return { rejections, corrections, normalizations, losses }
|
||||
return { rejections, corrections, repairs, losses }
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
|
||||
@@ -404,7 +404,7 @@ describe('Crowdin translation synchronization', () => {
|
||||
const outputDirectory = path.join(catalogDirectory, 'output')
|
||||
const catalogs = new Map([[targetCatalogs[0].languageId, { common: { save: 'Ulozit \u2014 hned' } }]])
|
||||
|
||||
const { rejections, losses, normalizations } = await syncCrowdinTranslations({
|
||||
const { rejections, losses, repairs } = await syncCrowdinTranslations({
|
||||
token: 'secret',
|
||||
fetchImpl: createSynchronizationFetch({ catalogs }),
|
||||
catalogDirectory,
|
||||
@@ -416,12 +416,38 @@ describe('Crowdin translation synchronization', () => {
|
||||
|
||||
expect(rejections).toEqual([])
|
||||
expect(losses).toEqual([])
|
||||
expect(normalizations).toEqual([{ locale: targetCatalogs[0].locale, key: 'common.save', message: 'Ulozit - hned' }])
|
||||
expect(repairs).toEqual([{ locale: targetCatalogs[0].locale, key: 'common.save', message: 'Ulozit - hned', kinds: ['typography'] }])
|
||||
expect(JSON.parse(await readFile(path.join(outputDirectory, `${targetCatalogs[0].locale}.json`), 'utf8'))).toEqual({
|
||||
common: { save: 'Ulozit - hned' },
|
||||
})
|
||||
})
|
||||
|
||||
it('completes a plural category the target locale needs and Crowdin omitted', async () => {
|
||||
const targetCatalogs = [TARGET_CATALOGS.find(({ locale }) => locale === 'ro')]
|
||||
const referenceCatalog = { books: { count: '{count, plural, one {# book} other {# books}}' } }
|
||||
const catalogDirectory = await createCatalogFixture(targetCatalogs, new Map(), referenceCatalog)
|
||||
const outputDirectory = path.join(catalogDirectory, 'output')
|
||||
const catalogs = new Map([['ro', { books: { count: '{count, plural, one {# carte} other {# de carti}}' } }]])
|
||||
|
||||
const { rejections, repairs } = await syncCrowdinTranslations({
|
||||
token: 'secret',
|
||||
fetchImpl: createSynchronizationFetch({ identifiers: ['books.count'], catalogs }),
|
||||
catalogDirectory,
|
||||
outputDirectory,
|
||||
targetCatalogs,
|
||||
assertTargetConfiguration: async () => {},
|
||||
protectedTerms: [],
|
||||
})
|
||||
|
||||
expect(rejections).toEqual([])
|
||||
expect(repairs).toEqual([
|
||||
{ locale: 'ro', key: 'books.count', message: expect.stringContaining('few {# de carti}'), kinds: ['plural categories'] },
|
||||
])
|
||||
expect(JSON.parse(await readFile(path.join(outputDirectory, 'ro.json'), 'utf8'))).toEqual({
|
||||
books: { count: '{count, plural, one {# carte} other {# de carti} few {# de carti}}' },
|
||||
})
|
||||
})
|
||||
|
||||
it('checks translation retention before writing any files', async () => {
|
||||
const targetCatalogs = TARGET_CATALOGS.slice(0, 2)
|
||||
const keys = Array.from({ length: 40 }, (_, index) => `key${index}`)
|
||||
@@ -462,7 +488,7 @@ describe('Crowdin translation synchronization', () => {
|
||||
assertTargetConfiguration: async () => {},
|
||||
protectedTerms: [],
|
||||
}),
|
||||
).resolves.toEqual({ rejections: [], corrections: [], normalizations: [], losses: [] })
|
||||
).resolves.toEqual({ rejections: [], corrections: [], repairs: [], losses: [] })
|
||||
expect(JSON.parse(await readFile(path.join(outputDirectory, `${targetCatalogs[0].locale}.json`), 'utf8'))).toEqual({
|
||||
common: { save: 'Save' },
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user