mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -87,7 +87,7 @@ jobs:
|
||||
echo >> "$PR_BODY"
|
||||
cat "$REJECTION_REPORT" >> "$PR_BODY"
|
||||
cat "$REJECTION_REPORT" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::warning::Crowdin returned translations that failed catalog validation; they were omitted and are listed in the pull request body."
|
||||
echo "::warning::Some messages fall back to English in this export; they are listed in the pull request body."
|
||||
fi
|
||||
|
||||
- name: Create translation pull request
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' },
|
||||
})
|
||||
|
||||
@@ -99,7 +99,7 @@ After an English source change reaches `main`:
|
||||
2. Translators update target strings in Crowdin.
|
||||
3. The scheduled or manually dispatched `Crowdin Translation Sync` workflow verifies that Crowdin's source keys match `en.json`.
|
||||
4. The workflow requests only translated strings, removes the empty values Crowdin emits for untranslated nested JSON entries, and validates the resulting sparse catalogs.
|
||||
5. Before writing, the workflow rejects exports that omit an existing translation.
|
||||
5. Before writing, the workflow normalizes punctuation the catalogs prohibit, omits messages that fail validation, and rejects an export that drops far more translations than translator churn explains.
|
||||
6. Only after validation and retention checks pass, the workflow updates `l10n_main` and opens a pull request to `main`.
|
||||
7. CI verifies that the pull request changes only the twenty-four target catalogs and runs the normal client checks.
|
||||
8. A maintainer reviews and squash-merges the pull request.
|
||||
@@ -109,9 +109,13 @@ Deleting that branch is required, not tidiness. It guarantees that every later e
|
||||
|
||||
Translation pull requests retain the configured `i18n(client)` title and commit format. The `i18n` commit type produces a patch release and an Internationalization release-note section. `CROWDIN_PR_TOKEN` must contain a fine-grained GitHub token with repository contents and pull-request write access. A separate token is required because pull requests created with the workflow's default `GITHUB_TOKEN` do not trigger normal pull-request workflows.
|
||||
|
||||
The retention check compares presence, never message content. Exports request only translated strings, so Crowdin omits a key as soon as it stops carrying a translation, and every key already present in a target catalog must come back in the export. A translation that legitimately matches the English source, such as `Error` in Spanish or a product name left untranslated, is a real translation and never counts as a loss. Reading it as one would reject the routine corrections that translators make to short labels, abbreviations, and loanwords.
|
||||
The retention check compares presence, never message content. Exports request only translated strings, so Crowdin omits a key as soon as it stops carrying a translation. A translation that legitimately matches the English source, such as `Error` in Spanish or a product name left untranslated, is a real translation and never counts as a loss. Reading it as one would reject the routine corrections that translators make to short labels, abbreviations, and loanwords.
|
||||
|
||||
An intentional removal requires a manual workflow dispatch with its exact `locale:message.key` value in `allowed_translation_losses`. Separate multiple acknowledgements with commas. Unknown, misspelled, duplicate, and unused acknowledgements fail the run, so this input cannot act as a broad bypass. Scheduled runs never acknowledge translation loss automatically.
|
||||
The check guards against a truncated or empty export, not against churn. Single translations leave Crowdin constantly: a reviewer unapproves a string, or an English edit invalidates the translation attached to it. A locale may lose up to twenty-five keys, or one percent of its translated keys where that is larger, before the run fails. Losses below that limit are listed in the pull request body and the English source renders for them. Failing on each one blocked every other locale over a single message and needed a hand-typed acknowledgement that a scheduled run cannot supply, which is why the export stalled repeatedly.
|
||||
|
||||
A message this workflow omits, because it carries HTML or breaks its ICU contract, is reported as a rejection rather than counted as a loss. Punctuation the catalogs prohibit is rewritten rather than rejected: a Unicode em dash returned by Crowdin becomes the hyphen the English source uses, so the translation survives and the catalogs stay free of the character. Fix the em dash in Crowdin when a translator's wording depends on it.
|
||||
|
||||
A manual workflow dispatch may pass exact `locale:message.key` values in `allowed_translation_losses`, separated by commas, to exclude known removals from the limit. Unknown, misspelled, and duplicate acknowledgements fail the run, so this input cannot act as a broad bypass.
|
||||
|
||||
## Adding a New Language
|
||||
|
||||
|
||||
Reference in New Issue
Block a user