ci(i18n): stop the crowdin sync failing on a single translator edit

Fail the nightly export only when a catalog loses far more than churn
explains, rewrite a Crowdin em dash to the hyphen the source uses instead
of discarding the translation, and report a rejected message as a
rejection rather than a lost one. See docs/LOCALIZATION.md.
This commit is contained in:
neon
2026-08-27 18:29:42 +00:00
parent e81848f654
commit 5ff6bdfecb
5 changed files with 183 additions and 88 deletions
+20
View File
@@ -0,0 +1,20 @@
// 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
}
+69 -26
View File
@@ -7,6 +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'
const API = 'https://api.crowdin.com/api/v2'
const SOURCE_PATH_SUFFIX = '/client/src/locales/en.json'
@@ -265,19 +266,31 @@ export function parseAllowedTranslationLosses(value = '') {
// Exports are built with skipUntranslatedStrings, so Crowdin drops a key entirely once it stops
// carrying a translation. Retention therefore compares presence, never message content: a translation
// that equals the English source, such as Spanish "Error" or a product name, is a real translation.
export function findTranslationLosses({ locale, reference, current, exported, rejected = new Map() }) {
// A message this sync rejected is absent by our own decision and is reported as a rejection instead.
export function findTranslationLosses({ locale, reference, current, exported, rejected = new Set() }) {
const losses = []
for (const key of current.keys()) {
if (!reference.has(key) || exported.has(key)) continue
const rejection = rejected.get(key)
losses.push({ locale, key, reason: rejection ? `rejected by catalog validation - ${rejection[0]}` : 'missing from Crowdin export' })
if (!reference.has(key) || exported.has(key) || rejected.has(key)) continue
losses.push({ locale, key })
}
return losses
}
// Single translations leave Crowdin as a matter of course: a reviewer unapproves a string, or an
// English edit invalidates the translation attached to it. Failing the whole sync on that churn
// blocks every other locale over one message and demands a hand-typed acknowledgement that only a
// manual run can supply, which is why this guard kept stalling the nightly export. It now watches for
// the accident it was built to catch, an export that arrives empty or truncated, and lets ordinary
// churn through to the pull request body.
const RETENTION_LOSS_FLOOR = 25
const RETENTION_LOSS_RATIO = 0.01
export function retentionLossLimit(translatedKeyCount) {
return Math.max(RETENTION_LOSS_FLOOR, Math.ceil(translatedKeyCount * RETENTION_LOSS_RATIO))
}
export function assertTranslationRetention({
reference,
currentCatalogs,
@@ -287,31 +300,31 @@ export function assertTranslationRetention({
rejections = [],
}) {
const rejectedByLocale = new Map()
for (const { locale, key, errors } of rejections) {
if (!rejectedByLocale.has(locale)) rejectedByLocale.set(locale, new Map())
rejectedByLocale.get(locale).set(key, errors)
for (const { locale, key } of rejections) {
if (!rejectedByLocale.has(locale)) rejectedByLocale.set(locale, new Set())
rejectedByLocale.get(locale).add(key)
}
const losses = []
const excessive = []
for (const { locale } of targetCatalogs) {
const current = currentCatalogs.get(locale)
const exported = exportedCatalogs.get(locale)
if (!current || !exported) throw new Error(`Translation retention comparison is missing the ${locale} catalog`)
losses.push(...findTranslationLosses({ locale, reference, current, exported, rejected: rejectedByLocale.get(locale) }))
const detected = findTranslationLosses({ locale, reference, current, exported, rejected: rejectedByLocale.get(locale) })
const unacknowledged = detected.filter(({ key }) => !allowedLosses.has(`${locale}:${key}`))
const limit = retentionLossLimit(current.size)
if (unacknowledged.length > limit) excessive.push({ locale, count: unacknowledged.length, limit })
losses.push(...unacknowledged)
}
const detected = new Set(losses.map(({ locale, key }) => `${locale}:${key}`))
const unacknowledged = losses.filter(({ locale, key }) => !allowedLosses.has(`${locale}:${key}`))
const unused = [...allowedLosses].filter((entry) => !detected.has(entry))
if (unacknowledged.length === 0 && unused.length === 0) return
if (excessive.length > 0) {
const details = excessive.map(({ locale, count, limit }) => `${locale}: ${count} translations dropped, more than the ${limit} allowed`)
throw new Error(`Crowdin export would lose existing translations:\n${details.join('\n')}`)
}
const details = [
...unacknowledged.slice(0, 25).map(({ locale, key, reason }) => `${locale}:${key} - ${reason}`),
...unused.slice(0, 25).map((entry) => `${entry} - acknowledgement does not match an exported loss`),
]
const remaining = unacknowledged.length + unused.length - details.length
if (remaining > 0) details.push(`...and ${remaining} more`)
throw new Error(`Crowdin export would lose existing translations:\n${details.join('\n')}`)
return losses
}
const MAX_REPORTED_REJECTIONS = 50
@@ -344,7 +357,32 @@ export function formatProtectedTermReport(corrections) {
].join('\n')}\n`
}
async function reportSyncIssues({ rejections, corrections, reportPath }) {
export function formatTranslationLossReport(losses) {
if (losses.length === 0) return ''
const listed = losses.slice(0, MAX_REPORTED_REJECTIONS)
const lines = [
`### Translations no longer in Crowdin (${losses.length})`,
'',
'Crowdin stopped returning a translation for these messages, so the English source renders instead.',
'',
...listed.map(({ locale, key }) => `- ${locale}: ${key}`),
]
if (losses.length > listed.length) lines.push(`- ...and ${losses.length - listed.length} more`)
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`)
}
if (losses.length > 0) {
console.log(`Crowdin no longer translates ${losses.length} messages; the English source renders instead:`)
for (const { locale, key } of losses.slice(0, MAX_REPORTED_REJECTIONS)) console.log(` ${locale}: ${key}`)
if (losses.length > MAX_REPORTED_REJECTIONS) console.log(` ...and ${losses.length - MAX_REPORTED_REJECTIONS} more`)
}
if (corrections.length > 0) {
console.log(`Restored ${corrections.length} protected terms to the English source:`)
for (const { locale, key, message } of corrections) console.log(` ${locale}: ${key} was "${message}"`)
@@ -356,7 +394,9 @@ async function reportSyncIssues({ rejections, corrections, reportPath }) {
if (rejections.length > MAX_REPORTED_REJECTIONS) console.log(` ...and ${rejections.length - MAX_REPORTED_REJECTIONS} more`)
}
if (reportPath) await writeFile(reportPath, `${formatProtectedTermReport(corrections)}${formatRejectionReport(rejections)}`)
if (reportPath) {
await writeFile(reportPath, `${formatProtectedTermReport(corrections)}${formatRejectionReport(rejections)}${formatTranslationLossReport(losses)}`)
}
}
export async function syncCrowdinTranslations({
@@ -407,17 +447,20 @@ 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 { 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].map(({ locale }) => locale))
const rewrittenLocales = new Set([...rejections, ...corrections, ...normalizations].map(({ locale }) => locale))
for (const entry of downloaded) {
if (rewrittenLocales.has(entry.locale)) entry.catalog = orderedSparseCatalog(reference, catalogs.get(entry.locale))
}
const errors = validateCatalogs({ catalogs, slotCountKeys })
if (errors.length > 0) throw new Error(`Crowdin export validation failed:\n${errors.join('\n')}`)
assertTranslationRetention({
const losses = assertTranslationRetention({
reference: referenceMessages,
currentCatalogs,
exportedCatalogs: catalogs,
@@ -430,9 +473,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, reportPath })
await reportSyncIssues({ rejections, corrections, normalizations, losses, reportPath })
console.log(`Synchronized ${downloaded.length} sparse translation catalogs from Crowdin`)
return { rejections, corrections }
return { rejections, corrections, normalizations, losses }
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -14,6 +14,7 @@ import {
findTranslationLosses,
normalizeCrowdinCatalog,
parseAllowedTranslationLosses,
retentionLossLimit,
sourceDrift,
syncCrowdinTranslations,
} from './sync-crowdin-translations.mjs'
@@ -99,8 +100,8 @@ describe('Crowdin translation synchronization', () => {
const exported = new Map()
expect(findTranslationLosses({ locale: 'cs', reference: referenceMessages, current, exported })).toEqual([
{ locale: 'cs', key: 'common.save', reason: 'missing from Crowdin export' },
{ locale: 'cs', key: 'common.cancel', reason: 'missing from Crowdin export' },
{ locale: 'cs', key: 'common.save' },
{ locale: 'cs', key: 'common.cancel' },
])
})
@@ -112,7 +113,7 @@ describe('Crowdin translation synchronization', () => {
const current = new Map([['common.save', 'Save']])
expect(findTranslationLosses({ locale: 'cs', reference: referenceMessages, current, exported: new Map() })).toEqual([
{ locale: 'cs', key: 'common.save', reason: 'missing from Crowdin export' },
{ locale: 'cs', key: 'common.save' },
])
})
@@ -133,22 +134,16 @@ describe('Crowdin translation synchronization', () => {
).toEqual([])
})
it('reports why catalog validation dropped a key instead of a bare absence', () => {
it('counts a message this sync rejected as a rejection rather than a lost translation', () => {
expect(
findTranslationLosses({
locale: 'cs',
reference: new Map([['common.save', 'Save']]),
current: new Map([['common.save', 'Uložit']]),
exported: new Map(),
rejected: new Map([['common.save', ['cs: Unicode em dash is not allowed in common.save']]]),
rejected: new Set(['common.save']),
}),
).toEqual([
{
locale: 'cs',
key: 'common.save',
reason: 'rejected by catalog validation - cs: Unicode em dash is not allowed in common.save',
},
])
).toEqual([])
})
it('ignores catalog keys that English no longer defines', () => {
@@ -168,42 +163,47 @@ describe('Crowdin translation synchronization', () => {
const exportedCatalogs = new Map(TARGET_CATALOGS.map(({ locale }) => [locale, new Map([['bookDock.tab.error', 'Error']])]))
currentCatalogs.set('es', new Map([['bookDock.tab.error', 'error']]))
expect(() => assertTranslationRetention({ reference: referenceMessages, currentCatalogs, exportedCatalogs })).not.toThrow()
expect(() =>
assertTranslationRetention({
reference: referenceMessages,
currentCatalogs,
exportedCatalogs,
allowedLosses: parseAllowedTranslationLosses('es:bookDock.tab.error'),
}),
).toThrow('es:bookDock.tab.error - acknowledgement does not match an exported loss')
expect(assertTranslationRetention({ reference: referenceMessages, currentCatalogs, exportedCatalogs })).toEqual([])
})
it('requires exact acknowledgements for intentional translation losses', () => {
it('reports ordinary Crowdin churn instead of failing the whole export', () => {
const referenceMessages = new Map([['common.save', 'Save']])
const currentCatalogs = new Map(TARGET_CATALOGS.map(({ locale }) => [locale, new Map()]))
const exportedCatalogs = new Map(TARGET_CATALOGS.map(({ locale }) => [locale, new Map()]))
currentCatalogs.set('cs', new Map([['common.save', 'Uložit']]))
expect(() => assertTranslationRetention({ reference: referenceMessages, currentCatalogs, exportedCatalogs })).toThrow(
'cs:common.save - missing from Crowdin export',
)
expect(() =>
expect(assertTranslationRetention({ reference: referenceMessages, currentCatalogs, exportedCatalogs })).toEqual([
{ locale: 'cs', key: 'common.save' },
])
expect(
assertTranslationRetention({
reference: referenceMessages,
currentCatalogs,
exportedCatalogs,
allowedLosses: parseAllowedTranslationLosses('cs:common.save'),
}),
).not.toThrow()
expect(() =>
assertTranslationRetention({
reference: referenceMessages,
currentCatalogs,
exportedCatalogs,
allowedLosses: parseAllowedTranslationLosses('de:common.save'),
}),
).toThrow('de:common.save - acknowledgement does not match an exported loss')
).toEqual([])
})
it('fails when an export drops more of a catalog than churn explains', () => {
const keys = Array.from({ length: 200 }, (_, index) => `common.key${index}`)
const referenceMessages = new Map(keys.map((key) => [key, 'Save']))
const currentCatalogs = new Map(TARGET_CATALOGS.map(({ locale }) => [locale, new Map()]))
const exportedCatalogs = new Map(TARGET_CATALOGS.map(({ locale }) => [locale, new Map()]))
currentCatalogs.set('cs', new Map(keys.map((key) => [key, 'Uložit'])))
expect(retentionLossLimit(200)).toBe(25)
expect(() => assertTranslationRetention({ reference: referenceMessages, currentCatalogs, exportedCatalogs })).toThrow(
'cs: 200 translations dropped, more than the 25 allowed',
)
exportedCatalogs.set('cs', new Map(keys.slice(0, 190).map((key) => [key, 'Uložit'])))
expect(() => assertTranslationRetention({ reference: referenceMessages, currentCatalogs, exportedCatalogs })).not.toThrow()
})
it('scales the retention limit with the size of the translated catalog', () => {
expect(retentionLossLimit(0)).toBe(25)
expect(retentionLossLimit(2500)).toBe(25)
expect(retentionLossLimit(5640)).toBe(57)
})
it('rejects malformed and duplicate loss acknowledgements', () => {
@@ -348,7 +348,7 @@ describe('Crowdin translation synchronization', () => {
const catalogDirectory = await createCatalogFixture(targetCatalogs)
const outputDirectory = path.join(catalogDirectory, 'output')
const reportPath = path.join(catalogDirectory, 'rejections.md')
const catalogs = new Map([[targetCatalogs[0].languageId, { common: { save: 'Bad \u2014 value' } }]])
const catalogs = new Map([[targetCatalogs[0].languageId, { common: { save: 'Bad <b>value</b>' } }]])
const { rejections } = await syncCrowdinTranslations({
token: 'secret',
@@ -365,55 +365,83 @@ describe('Crowdin translation synchronization', () => {
{
locale: targetCatalogs[0].locale,
key: 'common.save',
errors: [`${targetCatalogs[0].locale}: Unicode em dash is not allowed in common.save`],
errors: [`${targetCatalogs[0].locale}: HTML is not allowed in common.save`],
},
])
expect(JSON.parse(await readFile(path.join(outputDirectory, `${targetCatalogs[0].locale}.json`), 'utf8'))).toEqual({})
expect(JSON.parse(await readFile(path.join(outputDirectory, `${targetCatalogs[1].locale}.json`), 'utf8'))).toEqual({
common: { save: `Translated ${targetCatalogs[1].languageId}` },
})
expect(await readFile(reportPath, 'utf8')).toContain('Unicode em dash is not allowed in common.save')
expect(await readFile(reportPath, 'utf8')).toContain('HTML is not allowed in common.save')
})
it('fails when a rejected message would drop an existing translation', async () => {
it('keeps exporting when a rejected message drops an existing translation', async () => {
const targetCatalogs = TARGET_CATALOGS.slice(0, 2)
const currentCatalogs = new Map([[targetCatalogs[0].locale, { common: { save: 'Ulozit' } }]])
const catalogDirectory = await createCatalogFixture(targetCatalogs, currentCatalogs)
const outputDirectory = path.join(catalogDirectory, 'output')
const catalogs = new Map([[targetCatalogs[0].languageId, { common: { save: 'Bad \u2014 value' } }]])
const catalogs = new Map([[targetCatalogs[0].languageId, { common: { save: 'Bad <b>value</b>' } }]])
await expect(
syncCrowdinTranslations({
token: 'secret',
fetchImpl: createSynchronizationFetch({ catalogs }),
catalogDirectory,
outputDirectory,
targetCatalogs,
assertTargetConfiguration: async () => {},
protectedTerms: [],
}),
).rejects.toThrow('rejected by catalog validation')
await expect(access(outputDirectory)).rejects.toThrow()
const { rejections, losses } = await syncCrowdinTranslations({
token: 'secret',
fetchImpl: createSynchronizationFetch({ catalogs }),
catalogDirectory,
outputDirectory,
targetCatalogs,
assertTargetConfiguration: async () => {},
protectedTerms: [],
})
expect(rejections).toHaveLength(1)
expect(losses).toEqual([])
expect(JSON.parse(await readFile(path.join(outputDirectory, `${targetCatalogs[0].locale}.json`), 'utf8'))).toEqual({})
})
it('rewrites a Crowdin em dash instead of discarding the translation', async () => {
const targetCatalogs = TARGET_CATALOGS.slice(0, 2)
const currentCatalogs = new Map([[targetCatalogs[0].locale, { common: { save: 'Ulozit - hned' } }]])
const catalogDirectory = await createCatalogFixture(targetCatalogs, currentCatalogs)
const outputDirectory = path.join(catalogDirectory, 'output')
const catalogs = new Map([[targetCatalogs[0].languageId, { common: { save: 'Ulozit \u2014 hned' } }]])
const { rejections, losses, normalizations } = await syncCrowdinTranslations({
token: 'secret',
fetchImpl: createSynchronizationFetch({ catalogs }),
catalogDirectory,
outputDirectory,
targetCatalogs,
assertTargetConfiguration: async () => {},
protectedTerms: [],
})
expect(rejections).toEqual([])
expect(losses).toEqual([])
expect(normalizations).toEqual([{ locale: targetCatalogs[0].locale, key: 'common.save', message: 'Ulozit - hned' }])
expect(JSON.parse(await readFile(path.join(outputDirectory, `${targetCatalogs[0].locale}.json`), 'utf8'))).toEqual({
common: { save: 'Ulozit - hned' },
})
})
it('checks translation retention before writing any files', async () => {
const targetCatalogs = TARGET_CATALOGS.slice(0, 2)
const currentCatalogs = new Map([[targetCatalogs[0].locale, { common: { save: 'Ulozit' } }]])
const catalogDirectory = await createCatalogFixture(targetCatalogs, currentCatalogs)
const keys = Array.from({ length: 40 }, (_, index) => `key${index}`)
const referenceCatalog = { common: Object.fromEntries(keys.map((key) => [key, 'Save'])) }
const currentCatalogs = new Map([[targetCatalogs[0].locale, { common: Object.fromEntries(keys.map((key) => [key, 'Ulozit'])) }]])
const catalogDirectory = await createCatalogFixture(targetCatalogs, currentCatalogs, referenceCatalog)
const outputDirectory = path.join(catalogDirectory, 'output')
const catalogs = new Map([[targetCatalogs[0].languageId, {}]])
const catalogs = new Map(targetCatalogs.map(({ languageId }) => [languageId, {}]))
await expect(
syncCrowdinTranslations({
token: 'secret',
fetchImpl: createSynchronizationFetch({ catalogs }),
fetchImpl: createSynchronizationFetch({ identifiers: keys.map((key) => `common.${key}`), catalogs }),
catalogDirectory,
outputDirectory,
targetCatalogs,
assertTargetConfiguration: async () => {},
protectedTerms: [],
}),
).rejects.toThrow('missing from Crowdin export')
).rejects.toThrow('40 translations dropped, more than the 25 allowed')
await expect(access(outputDirectory)).rejects.toThrow()
})
@@ -434,7 +462,7 @@ describe('Crowdin translation synchronization', () => {
assertTargetConfiguration: async () => {},
protectedTerms: [],
}),
).resolves.toEqual({ rejections: [], corrections: [] })
).resolves.toEqual({ rejections: [], corrections: [], normalizations: [], losses: [] })
expect(JSON.parse(await readFile(path.join(outputDirectory, `${targetCatalogs[0].locale}.json`), 'utf8'))).toEqual({
common: { save: 'Save' },
})