mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(client): prevent Crowdin translation loss
Use sparse target catalogs and validate Crowdin exports before proposing updates. Reject translation loss unless a maintainer acknowledges each key.
This commit is contained in:
@@ -259,6 +259,7 @@ jobs:
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.github/actions/setup-node-pnpm/action.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/crowdin-translations.yml'
|
||||
|
||||
- name: Print change scope
|
||||
shell: bash
|
||||
|
||||
@@ -9,11 +9,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
delete-service-branch:
|
||||
# Crowdin appends every export to the same service branch and never rebases it.
|
||||
# A branch that outlives its pull request keeps its original merge base, so each
|
||||
# later export diverges further from main and eventually conflicts on every
|
||||
# catalog. Deleting it forces Crowdin to recreate it from the current main.
|
||||
# This runs on close as well as merge, because Crowdin reuses the branch either way.
|
||||
# The controlled export workflow reuses this branch for its translation PR.
|
||||
# Removing it after close guarantees that the next export starts from current main.
|
||||
if: github.event.pull_request.head.ref == 'l10n_main' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
name: Delete l10n_main
|
||||
runs-on: ubuntu-latest
|
||||
@@ -27,7 +24,7 @@ jobs:
|
||||
run: |
|
||||
if gh api "repos/$REPO/git/refs/heads/$BRANCH" >/dev/null 2>&1; then
|
||||
gh api -X DELETE "repos/$REPO/git/refs/heads/$BRANCH"
|
||||
echo "Deleted $BRANCH; Crowdin will recreate it from main on the next export."
|
||||
echo "Deleted $BRANCH; the translation workflow will recreate it from main when needed."
|
||||
else
|
||||
echo "$BRANCH is already absent; nothing to delete."
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
name: Crowdin Translation Sync
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
allowed_translation_losses:
|
||||
description: "Comma-separated locale:message.key acknowledgements for intentional translation removals"
|
||||
required: false
|
||||
type: string
|
||||
schedule:
|
||||
- cron: "47 5 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: crowdin-translation-sync
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24"
|
||||
PNPM_VERSION: "10"
|
||||
|
||||
jobs:
|
||||
synchronize:
|
||||
name: Validate and propose translations
|
||||
if: github.repository == 'bookorbit/bookorbit'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Set up Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
prefetch: "true"
|
||||
|
||||
- name: Check synchronization credentials
|
||||
env:
|
||||
CROWDIN_TOKEN: ${{ secrets.CROWDIN_TOKEN }}
|
||||
CROWDIN_PR_TOKEN: ${{ secrets.CROWDIN_PR_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$CROWDIN_TOKEN" ]; then
|
||||
echo "::error::CROWDIN_TOKEN is required."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$CROWDIN_PR_TOKEN" ]; then
|
||||
echo "::error::CROWDIN_PR_TOKEN is required so pull request workflows are triggered."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build sparse translation catalogs
|
||||
env:
|
||||
CROWDIN_ALLOWED_TRANSLATION_LOSSES: ${{ inputs.allowed_translation_losses }}
|
||||
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_TOKEN: ${{ secrets.CROWDIN_TOKEN }}
|
||||
run: node client/scripts/sync-crowdin-translations.mjs
|
||||
|
||||
- name: Validate translation catalogs
|
||||
run: pnpm --filter client validate:locales
|
||||
|
||||
- name: Test localization runtime and synchronization
|
||||
run: >-
|
||||
pnpm --filter client exec vitest run
|
||||
scripts/locale-catalog-validation.spec.mjs
|
||||
scripts/sync-crowdin-translations.spec.mjs
|
||||
src/i18n/icu.spec.ts
|
||||
|
||||
- name: Create translation pull request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ secrets.CROWDIN_PR_TOKEN }}
|
||||
add-paths: |
|
||||
client/src/locales/cs.json
|
||||
client/src/locales/da.json
|
||||
client/src/locales/de.json
|
||||
client/src/locales/es.json
|
||||
client/src/locales/fi.json
|
||||
client/src/locales/fr.json
|
||||
client/src/locales/it.json
|
||||
client/src/locales/nl.json
|
||||
client/src/locales/pl.json
|
||||
client/src/locales/pt.json
|
||||
client/src/locales/ru.json
|
||||
client/src/locales/sl.json
|
||||
client/src/locales/sv.json
|
||||
client/src/locales/uk.json
|
||||
client/src/locales/zh.json
|
||||
commit-message: |-
|
||||
i18n(client): sync translations from Crowdin
|
||||
|
||||
Keep sparse translated catalogs synchronized with reviewed Crowdin content.
|
||||
committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
|
||||
author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
|
||||
branch: l10n_main
|
||||
delete-branch: true
|
||||
base: main
|
||||
title: "i18n(client): sync translations from Crowdin"
|
||||
body: |-
|
||||
Automated sparse translation export from Crowdin.
|
||||
|
||||
Untranslated entries are omitted so Vue I18n falls back to the English catalog.
|
||||
@@ -0,0 +1,43 @@
|
||||
import { validateLocaleMessage, validateSlotCountMessage } from './locale-message-validation.mjs'
|
||||
|
||||
export function flattenCatalog(value, prefix = '', output = new Map()) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(prefix || 'catalog root must be a message object')
|
||||
}
|
||||
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const messageKey = prefix ? `${prefix}.${key}` : key
|
||||
if (typeof child === 'string') output.set(messageKey, child)
|
||||
else if (child && typeof child === 'object' && !Array.isArray(child)) flattenCatalog(child, messageKey, output)
|
||||
else throw new Error(`${messageKey} must be a string or message object`)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export function validateCatalogs({ catalogs, referencedKeys = new Set(), slotCountKeys = new Set() }) {
|
||||
const reference = catalogs.get('en')
|
||||
if (!reference) throw new Error('English reference catalog is required')
|
||||
|
||||
const errors = []
|
||||
for (const key of referencedKeys) {
|
||||
if (!reference.has(key)) errors.push(`en: missing key referenced in source ${key}`)
|
||||
}
|
||||
|
||||
for (const [locale, catalog] of catalogs) {
|
||||
for (const [key, message] of catalog) {
|
||||
const referenceMessage = reference.get(key)
|
||||
if (referenceMessage === undefined) {
|
||||
errors.push(`${locale}: unexpected key ${key}`)
|
||||
continue
|
||||
}
|
||||
if (message.length === 0) errors.push(`${locale}: empty message ${key}`)
|
||||
if (message.includes('\u2014')) errors.push(`${locale}: Unicode em dash is not allowed in ${key}`)
|
||||
if (/<[^>]+>/.test(message)) errors.push(`${locale}: HTML is not allowed in ${key}`)
|
||||
errors.push(...validateLocaleMessage({ key, locale, message, referenceMessage }))
|
||||
if (slotCountKeys.has(key)) errors.push(...validateSlotCountMessage({ key, locale, message }))
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { flattenCatalog, validateCatalogs } from './locale-catalog-validation.mjs'
|
||||
|
||||
function catalogs(target = {}) {
|
||||
return new Map([
|
||||
[
|
||||
'en',
|
||||
flattenCatalog({
|
||||
common: { save: 'Save' },
|
||||
books: { count: '{count, plural, one {# book} other {# books}}' },
|
||||
}),
|
||||
],
|
||||
['cs', flattenCatalog(target)],
|
||||
])
|
||||
}
|
||||
|
||||
describe('locale catalog validation', () => {
|
||||
it('accepts a sparse target catalog', () => {
|
||||
expect(validateCatalogs({ catalogs: catalogs({ common: { save: 'Uložit' } }) })).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects unknown and empty target messages', () => {
|
||||
expect(validateCatalogs({ catalogs: catalogs({ common: { save: '', unknown: 'Neznámé' } }) })).toEqual([
|
||||
'cs: empty message common.save',
|
||||
'cs: unexpected key common.unknown',
|
||||
])
|
||||
})
|
||||
|
||||
it('validates ICU structure for translated messages that are present', () => {
|
||||
const target = {
|
||||
books: { count: '{count, plural, one {# kniha} other {# knih}}' },
|
||||
}
|
||||
|
||||
expect(validateCatalogs({ catalogs: catalogs(target) })).toEqual([
|
||||
'cs: ICU plural category few missing for books.count',
|
||||
'cs: ICU plural category many missing for books.count',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,342 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import { flattenCatalog, validateCatalogs } from './locale-catalog-validation.mjs'
|
||||
|
||||
const API = 'https://api.crowdin.com/api/v2'
|
||||
const SOURCE_PATH_SUFFIX = '/client/src/locales/en.json'
|
||||
const MAX_API_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||
const MAX_CATALOG_BYTES = 10 * 1024 * 1024
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const clientRoot = path.basename(process.cwd()) === 'client' ? process.cwd() : path.join(process.cwd(), 'client')
|
||||
const localesDirectory = path.join(clientRoot, 'src/locales')
|
||||
|
||||
export const TARGET_CATALOGS = [
|
||||
{ languageId: 'cs', locale: 'cs' },
|
||||
{ languageId: 'da', locale: 'da' },
|
||||
{ languageId: 'de', locale: 'de' },
|
||||
{ languageId: 'es-ES', locale: 'es' },
|
||||
{ languageId: 'fi', locale: 'fi' },
|
||||
{ languageId: 'fr', locale: 'fr' },
|
||||
{ languageId: 'it', locale: 'it' },
|
||||
{ languageId: 'nl', locale: 'nl' },
|
||||
{ languageId: 'pl', locale: 'pl' },
|
||||
{ languageId: 'pt-BR', locale: 'pt' },
|
||||
{ languageId: 'ru', locale: 'ru' },
|
||||
{ languageId: 'sl', locale: 'sl' },
|
||||
{ languageId: 'sv-SE', locale: 'sv' },
|
||||
{ languageId: 'uk', locale: 'uk' },
|
||||
{ languageId: 'zh-CN', locale: 'zh' },
|
||||
]
|
||||
|
||||
const TARGET_LOCALES = new Set(TARGET_CATALOGS.map(({ locale }) => locale))
|
||||
|
||||
async function responseText(response, maxBytes) {
|
||||
const contentLength = Number(response.headers.get('content-length'))
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
||||
throw new Error(`Response exceeds ${maxBytes} bytes`)
|
||||
}
|
||||
|
||||
if (!response.body) return ''
|
||||
const reader = response.body.getReader()
|
||||
const chunks = []
|
||||
let total = 0
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
total += value.byteLength
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel()
|
||||
throw new Error(`Response exceeds ${maxBytes} bytes`)
|
||||
}
|
||||
chunks.push(Buffer.from(value))
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks).toString('utf8')
|
||||
}
|
||||
|
||||
function parseJson(text, context) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(`${context} returned invalid JSON`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeDownloadUrl(value) {
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== 'https:') throw new Error('Crowdin export URL must use HTTPS')
|
||||
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname.endsWith('.localhost') ||
|
||||
hostname.endsWith('.local') ||
|
||||
hostname === '::1' ||
|
||||
hostname.startsWith('fc') ||
|
||||
hostname.startsWith('fd') ||
|
||||
hostname.startsWith('fe80:')
|
||||
) {
|
||||
throw new Error('Crowdin export URL must not target a local network host')
|
||||
}
|
||||
|
||||
const octets = hostname.split('.').map(Number)
|
||||
if (octets.length === 4 && octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)) {
|
||||
const [first, second] = octets
|
||||
if (
|
||||
first === 0 ||
|
||||
first === 10 ||
|
||||
first === 127 ||
|
||||
(first === 169 && second === 254) ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && second === 168)
|
||||
) {
|
||||
throw new Error('Crowdin export URL must not target a local network host')
|
||||
}
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
async function downloadCatalog(fetchImpl, value) {
|
||||
let url = assertSafeDownloadUrl(value)
|
||||
|
||||
for (let redirect = 0; redirect <= 3; redirect += 1) {
|
||||
const response = await fetchImpl(url, {
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location')
|
||||
if (!location || redirect === 3) throw new Error('Crowdin export exceeded the redirect limit')
|
||||
url = assertSafeDownloadUrl(new URL(location, url).href)
|
||||
continue
|
||||
}
|
||||
if (!response.ok) throw new Error(`Crowdin export download failed with HTTP ${response.status}`)
|
||||
return parseJson(await responseText(response, MAX_CATALOG_BYTES), 'Crowdin export')
|
||||
}
|
||||
|
||||
throw new Error('Crowdin export exceeded the redirect limit')
|
||||
}
|
||||
|
||||
export function createCrowdinClient({ token, projectId, fetchImpl = fetch }) {
|
||||
async function request(endpoint, init = {}) {
|
||||
const response = await fetchImpl(`${API}${endpoint}`, {
|
||||
...init,
|
||||
headers: { Authorization: `Bearer ${token}`, ...(init.headers ?? {}) },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
const body = await responseText(response, MAX_API_RESPONSE_BYTES)
|
||||
if (!response.ok) throw new Error(`Crowdin API ${endpoint} failed with HTTP ${response.status}: ${body.slice(0, 300)}`)
|
||||
return body ? parseJson(body, `Crowdin API ${endpoint}`) : null
|
||||
}
|
||||
|
||||
return {
|
||||
async sourceFileId() {
|
||||
for (let offset = 0; offset < 10_000; offset += 500) {
|
||||
const page = await request(`/projects/${projectId}/files?limit=500&offset=${offset}`)
|
||||
const match = page.data.find((entry) => entry.data.path.endsWith(SOURCE_PATH_SUFFIX))
|
||||
if (match) return match.data.id
|
||||
if (page.data.length < 500) break
|
||||
}
|
||||
throw new Error(`Crowdin source file ending in ${SOURCE_PATH_SUFFIX} was not found`)
|
||||
},
|
||||
|
||||
async sourceIdentifiers(fileId) {
|
||||
const identifiers = new Set()
|
||||
for (let offset = 0; offset < 20_000; offset += 500) {
|
||||
const page = await request(`/projects/${projectId}/strings?fileId=${fileId}&limit=500&offset=${offset}`)
|
||||
for (const entry of page.data) identifiers.add(entry.data.identifier)
|
||||
if (page.data.length < 500) return identifiers
|
||||
}
|
||||
throw new Error('Crowdin source contains more than 20,000 messages')
|
||||
},
|
||||
|
||||
async exportedCatalog(fileId, languageId) {
|
||||
const build = await request(`/projects/${projectId}/translations/builds/files/${fileId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ targetLanguageId: languageId, skipUntranslatedStrings: true }),
|
||||
})
|
||||
return downloadCatalog(fetchImpl, build.data.url)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function orderedSparseCatalog(reference, messages, prefix = '') {
|
||||
const output = {}
|
||||
for (const [key, child] of Object.entries(reference)) {
|
||||
const messageKey = prefix ? `${prefix}.${key}` : key
|
||||
if (typeof child === 'string') {
|
||||
const translated = messages.get(messageKey)
|
||||
if (translated) output[key] = translated
|
||||
continue
|
||||
}
|
||||
|
||||
const nested = orderedSparseCatalog(child, messages, messageKey)
|
||||
if (Object.keys(nested).length > 0) output[key] = nested
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
export function normalizeCrowdinCatalog(exported, reference) {
|
||||
const referenceMessages = flattenCatalog(reference)
|
||||
const exportedMessages = flattenCatalog(exported)
|
||||
|
||||
for (const key of exportedMessages.keys()) {
|
||||
if (!referenceMessages.has(key)) throw new Error(`Crowdin export contains unknown key ${key}`)
|
||||
}
|
||||
for (const [key, message] of exportedMessages) {
|
||||
if (message.length === 0) exportedMessages.delete(key)
|
||||
}
|
||||
|
||||
return orderedSparseCatalog(reference, exportedMessages)
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(values, concurrency, operation) {
|
||||
const results = new Array(values.length)
|
||||
let nextIndex = 0
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < values.length) {
|
||||
const index = nextIndex
|
||||
nextIndex += 1
|
||||
results[index] = await operation(values[index])
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, worker))
|
||||
return results
|
||||
}
|
||||
|
||||
export function sourceDrift(referenceMessages, identifiers) {
|
||||
const missing = [...referenceMessages.keys()].filter((key) => !identifiers.has(key))
|
||||
const unexpected = [...identifiers].filter((key) => !referenceMessages.has(key))
|
||||
return { missing, unexpected }
|
||||
}
|
||||
|
||||
export function parseAllowedTranslationLosses(value = '') {
|
||||
const allowed = new Set()
|
||||
for (const entry of value.split(/[\s,]+/).filter(Boolean)) {
|
||||
const separator = entry.indexOf(':')
|
||||
const locale = entry.slice(0, separator)
|
||||
const key = entry.slice(separator + 1)
|
||||
if (separator < 1 || !TARGET_LOCALES.has(locale) || !/^[A-Za-z0-9_.-]+$/.test(key)) {
|
||||
throw new Error(`Invalid translation loss acknowledgement ${entry}; expected locale:message.key`)
|
||||
}
|
||||
if (allowed.has(entry)) throw new Error(`Duplicate translation loss acknowledgement ${entry}`)
|
||||
allowed.add(entry)
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
export function findTranslationLosses({ locale, reference, current, exported }) {
|
||||
const legacyComplete = current.size === reference.size && [...reference.keys()].every((key) => current.has(key))
|
||||
const losses = []
|
||||
|
||||
for (const [key, currentMessage] of current) {
|
||||
const referenceMessage = reference.get(key)
|
||||
if (referenceMessage === undefined || (legacyComplete && currentMessage === referenceMessage)) continue
|
||||
|
||||
const exportedMessage = exported.get(key)
|
||||
if (exportedMessage === undefined) {
|
||||
losses.push({ locale, key, reason: 'missing from Crowdin export' })
|
||||
} else if (currentMessage !== referenceMessage && exportedMessage === referenceMessage) {
|
||||
losses.push({ locale, key, reason: 'replaced by English source text' })
|
||||
}
|
||||
}
|
||||
|
||||
return losses
|
||||
}
|
||||
|
||||
export function assertTranslationRetention({ reference, currentCatalogs, exportedCatalogs, allowedLosses = new Set() }) {
|
||||
const losses = []
|
||||
for (const { locale } of TARGET_CATALOGS) {
|
||||
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 }))
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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')}`)
|
||||
}
|
||||
|
||||
export async function syncCrowdinTranslations({
|
||||
token,
|
||||
projectId = '912891',
|
||||
fetchImpl = fetch,
|
||||
catalogDirectory = localesDirectory,
|
||||
outputDirectory = catalogDirectory,
|
||||
allowedLosses = new Set(),
|
||||
}) {
|
||||
if (!token) throw new Error('CROWDIN_TOKEN is required')
|
||||
|
||||
const reference = JSON.parse(await readFile(path.join(catalogDirectory, 'en.json'), 'utf8'))
|
||||
const referenceMessages = flattenCatalog(reference)
|
||||
const currentCatalogs = new Map(
|
||||
await Promise.all(
|
||||
TARGET_CATALOGS.map(async ({ locale }) => {
|
||||
const catalog = JSON.parse(await readFile(path.join(catalogDirectory, `${locale}.json`), 'utf8'))
|
||||
return [locale, flattenCatalog(catalog)]
|
||||
}),
|
||||
),
|
||||
)
|
||||
const client = createCrowdinClient({ token, projectId, fetchImpl })
|
||||
const fileId = await client.sourceFileId()
|
||||
const identifiers = await client.sourceIdentifiers(fileId)
|
||||
const drift = sourceDrift(referenceMessages, identifiers)
|
||||
if (drift.missing.length > 0 || drift.unexpected.length > 0) {
|
||||
const details = [
|
||||
...drift.missing.slice(0, 10).map((key) => `missing in Crowdin: ${key}`),
|
||||
...drift.unexpected.slice(0, 10).map((key) => `missing in Git: ${key}`),
|
||||
]
|
||||
throw new Error(`Crowdin source is not synchronized with en.json\n${details.join('\n')}`)
|
||||
}
|
||||
|
||||
const downloaded = await mapWithConcurrency(TARGET_CATALOGS, 4, async ({ languageId, locale }) => ({
|
||||
locale,
|
||||
catalog: normalizeCrowdinCatalog(await client.exportedCatalog(fileId, languageId), reference),
|
||||
}))
|
||||
const catalogs = new Map([['en', referenceMessages]])
|
||||
for (const { locale, catalog } of downloaded) catalogs.set(locale, flattenCatalog(catalog))
|
||||
|
||||
const errors = validateCatalogs({ catalogs })
|
||||
if (errors.length > 0) throw new Error(`Crowdin export validation failed:\n${errors.join('\n')}`)
|
||||
assertTranslationRetention({
|
||||
reference: referenceMessages,
|
||||
currentCatalogs,
|
||||
exportedCatalogs: catalogs,
|
||||
allowedLosses,
|
||||
})
|
||||
|
||||
await mkdir(outputDirectory, { recursive: true })
|
||||
await Promise.all(
|
||||
downloaded.map(({ locale, catalog }) => writeFile(path.join(outputDirectory, `${locale}.json`), `${JSON.stringify(catalog, null, 2)}\n`)),
|
||||
)
|
||||
console.log(`Synchronized ${downloaded.length} sparse translation catalogs from Crowdin`)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
syncCrowdinTranslations({
|
||||
token: process.env.CROWDIN_TOKEN,
|
||||
projectId: process.env.CROWDIN_PROJECT_ID || undefined,
|
||||
allowedLosses: parseAllowedTranslationLosses(process.env.CROWDIN_ALLOWED_TRANSLATION_LOSSES),
|
||||
}).catch((error) => {
|
||||
console.error(error.message)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
TARGET_CATALOGS,
|
||||
assertTranslationRetention,
|
||||
createCrowdinClient,
|
||||
findTranslationLosses,
|
||||
normalizeCrowdinCatalog,
|
||||
parseAllowedTranslationLosses,
|
||||
sourceDrift,
|
||||
} from './sync-crowdin-translations.mjs'
|
||||
|
||||
const reference = {
|
||||
common: { save: 'Save', cancel: 'Cancel' },
|
||||
books: { count: '{count, plural, one {# book} other {# books}}' },
|
||||
}
|
||||
|
||||
describe('Crowdin translation synchronization', () => {
|
||||
it('removes empty untranslated messages and follows English key order', () => {
|
||||
const exported = {
|
||||
books: { count: '' },
|
||||
common: { cancel: 'Zrušit', save: 'Uložit' },
|
||||
}
|
||||
|
||||
expect(normalizeCrowdinCatalog(exported, reference)).toEqual({
|
||||
common: { save: 'Uložit', cancel: 'Zrušit' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects keys that do not exist in English', () => {
|
||||
expect(() => normalizeCrowdinCatalog({ common: { unknown: 'Neznámé' } }, reference)).toThrow('Crowdin export contains unknown key common.unknown')
|
||||
})
|
||||
|
||||
it('detects when Crowdin has not synchronized the current English keys', () => {
|
||||
expect(sourceDrift(new Map([['common.save', 'Save']]), new Set(['common.cancel']))).toEqual({
|
||||
missing: ['common.save'],
|
||||
unexpected: ['common.cancel'],
|
||||
})
|
||||
})
|
||||
|
||||
it('protects real translations in complete legacy catalogs without treating English fallbacks as translations', () => {
|
||||
const referenceMessages = new Map([
|
||||
['common.save', 'Save'],
|
||||
['common.cancel', 'Cancel'],
|
||||
])
|
||||
const current = new Map([
|
||||
['common.save', 'Uložit'],
|
||||
['common.cancel', 'Cancel'],
|
||||
])
|
||||
const exported = new Map()
|
||||
|
||||
expect(findTranslationLosses({ locale: 'cs', reference: referenceMessages, current, exported })).toEqual([
|
||||
{ locale: 'cs', key: 'common.save', reason: 'missing from Crowdin export' },
|
||||
])
|
||||
})
|
||||
|
||||
it('protects every existing key after a target catalog becomes sparse', () => {
|
||||
const referenceMessages = new Map([
|
||||
['common.save', 'Save'],
|
||||
['common.cancel', 'Cancel'],
|
||||
])
|
||||
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' },
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects translations that Crowdin replaces with English source text', () => {
|
||||
const referenceMessages = new Map([['common.save', 'Save']])
|
||||
const current = new Map([['common.save', 'Uložit']])
|
||||
const exported = new Map([['common.save', 'Save']])
|
||||
|
||||
expect(findTranslationLosses({ locale: 'cs', reference: referenceMessages, current, exported })).toEqual([
|
||||
{ locale: 'cs', key: 'common.save', reason: 'replaced by English source text' },
|
||||
])
|
||||
})
|
||||
|
||||
it('requires exact acknowledgements for intentional translation losses', () => {
|
||||
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(() =>
|
||||
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')
|
||||
})
|
||||
|
||||
it('rejects malformed and duplicate loss acknowledgements', () => {
|
||||
expect(() => parseAllowedTranslationLosses('xx:common.save')).toThrow('expected locale:message.key')
|
||||
expect(() => parseAllowedTranslationLosses('cs:common.save,cs:common.save')).toThrow('Duplicate translation loss acknowledgement')
|
||||
})
|
||||
|
||||
it('requests untranslated-string omission and downloads the bounded export', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: { url: 'https://downloads.example.test/cs.json' } }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ common: { save: 'Uložit' } })))
|
||||
const client = createCrowdinClient({ token: 'secret', projectId: '42', fetchImpl })
|
||||
|
||||
await expect(client.exportedCatalog(7, 'cs')).resolves.toEqual({ common: { save: 'Uložit' } })
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
expect(fetchImpl.mock.calls[0][0]).toBe('https://api.crowdin.com/api/v2/projects/42/translations/builds/files/7')
|
||||
expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual({
|
||||
targetLanguageId: 'cs',
|
||||
skipUntranslatedStrings: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects private-network export URLs returned by Crowdin', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ data: { url: 'http://127.0.0.1/catalog.json' } })))
|
||||
const client = createCrowdinClient({ token: 'secret', projectId: '42', fetchImpl })
|
||||
|
||||
await expect(client.exportedCatalog(7, 'cs')).rejects.toThrow('Crowdin export URL must use HTTPS')
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,22 +1,12 @@
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import { validateLocaleMessage, validateSlotCountMessage } from './locale-message-validation.mjs'
|
||||
import { flattenCatalog, validateCatalogs } from './locale-catalog-validation.mjs'
|
||||
|
||||
const clientRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const localesDirectory = path.join(clientRoot, 'src/locales')
|
||||
const localeTypesPath = path.join(clientRoot, '../packages/types/src/locale.ts')
|
||||
|
||||
function flatten(value, prefix = '', output = new Map()) {
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const messageKey = prefix ? `${prefix}.${key}` : key
|
||||
if (typeof child === 'string') output.set(messageKey, child)
|
||||
else if (child && typeof child === 'object' && !Array.isArray(child)) flatten(child, messageKey, output)
|
||||
else throw new Error(`${messageKey} must be a string or message object`)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
async function sourceFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const files = await Promise.all(
|
||||
@@ -71,38 +61,17 @@ if (JSON.stringify(localeFiles) !== JSON.stringify(expectedFiles)) {
|
||||
const catalogs = new Map()
|
||||
for (const locale of supportedLocales) {
|
||||
const raw = await readFile(path.join(localesDirectory, `${locale}.json`), 'utf8')
|
||||
catalogs.set(locale, flatten(JSON.parse(raw)))
|
||||
catalogs.set(locale, flattenCatalog(JSON.parse(raw)))
|
||||
}
|
||||
|
||||
const reference = catalogs.get('en')
|
||||
if (!reference) throw new Error('English reference catalog is required')
|
||||
|
||||
const errors = []
|
||||
const { keys: referencedKeys, slotCountKeys } = await sourceMessageKeys()
|
||||
for (const key of referencedKeys) {
|
||||
if (!reference.has(key)) errors.push(`en: missing key referenced in source ${key}`)
|
||||
}
|
||||
|
||||
for (const [locale, catalog] of catalogs) {
|
||||
for (const [key, referenceMessage] of reference) {
|
||||
const message = catalog.get(key)
|
||||
if (message === undefined) {
|
||||
errors.push(`${locale}: missing key ${key}`)
|
||||
continue
|
||||
}
|
||||
if (message.length === 0) errors.push(`${locale}: empty message ${key}`)
|
||||
if (message.includes('\u2014')) errors.push(`${locale}: Unicode em dash is not allowed in ${key}`)
|
||||
if (/<[^>]+>/.test(message)) errors.push(`${locale}: HTML is not allowed in ${key}`)
|
||||
errors.push(...validateLocaleMessage({ key, locale, message, referenceMessage }))
|
||||
if (slotCountKeys.has(key)) errors.push(...validateSlotCountMessage({ key, locale, message }))
|
||||
}
|
||||
for (const key of catalog.keys()) {
|
||||
if (!reference.has(key)) errors.push(`${locale}: unexpected key ${key}`)
|
||||
}
|
||||
}
|
||||
const errors = validateCatalogs({ catalogs, referencedKeys, slotCountKeys })
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Locale validation failed:\n${errors.join('\n')}`)
|
||||
}
|
||||
|
||||
console.log(`Validated ${supportedLocales.length} locale catalogs with ${reference.size} messages each`)
|
||||
console.log(`Validated ${supportedLocales.length} locale catalogs against ${reference.size} English messages`)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import type { Locale } from '@bookorbit/types'
|
||||
import cs from '@/locales/cs.json'
|
||||
@@ -18,6 +18,9 @@ import sv from '@/locales/sv.json'
|
||||
import uk from '@/locales/uk.json'
|
||||
import zh from '@/locales/zh.json'
|
||||
import { compileIcuCatalog, icuCountValues, isIcuPluralMessage, splitIcuCount } from './icu'
|
||||
import { i18n as applicationI18n } from './index'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
function flattenMessages(value: unknown, prefix = '', output = new Map<string, string>()): Map<string, string> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return output
|
||||
@@ -49,15 +52,39 @@ function createCatalogI18n(locale: Locale, catalog: MessageTree) {
|
||||
en: compileIcuCatalog(en, 'en'),
|
||||
[locale]: compileIcuCatalog(catalog, locale),
|
||||
}
|
||||
return createI18n({ legacy: false, locale, fallbackLocale: 'en', messages })
|
||||
return createI18n({ legacy: false, locale, fallbackLocale: 'en', missingWarn: false, fallbackWarn: false, messages })
|
||||
}
|
||||
|
||||
describe('ICU message compilation', () => {
|
||||
it('suppresses warnings for intentional English catalog fallback', () => {
|
||||
expect(applicationI18n.global.missingWarn).toBe(false)
|
||||
expect(applicationI18n.global.fallbackWarn).toBe(false)
|
||||
})
|
||||
|
||||
it('detects ICU plurals without treating ordinary interpolation as ICU', () => {
|
||||
expect(isIcuPluralMessage('{count, plural, one {One book} other {# books}}')).toBe(true)
|
||||
expect(isIcuPluralMessage('Hello, {name}!')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses English plural rules when a target catalog omits an ICU message', () => {
|
||||
const testI18n = createCatalogI18n('zh', {})
|
||||
|
||||
expect(testI18n.global.t('book.move.title', { count: 1 })).toBe('Move 1 book')
|
||||
expect(testI18n.global.t('book.move.title', { count: 2 })).toBe('Move 2 books')
|
||||
})
|
||||
|
||||
it('falls back per key inside a partially translated branch without warning noise', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const testI18n = createCatalogI18n('cs', {
|
||||
book: { move: { subtitle: 'Soubory se přesunou do cílové knihovny.' } },
|
||||
})
|
||||
|
||||
expect(testI18n.global.t('book.move.subtitle')).toBe('Soubory se přesunou do cílové knihovny.')
|
||||
expect(testI18n.global.t('book.move.searchPlaceholder')).toBe('Search libraries')
|
||||
expect(testI18n.global.t('book.move.title', { count: 2 })).toBe('Move 2 books')
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('compiles ICU plurals while preserving ordinary Vue messages', () => {
|
||||
const messages = compileIcuCatalog(
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import en from '@/locales/en.json'
|
||||
import { compileIcuCatalog } from './icu'
|
||||
|
||||
export type MessageSchema = typeof en
|
||||
type LocaleMessageTree = { [key: string]: string | LocaleMessageTree }
|
||||
|
||||
// `legacy: false` selects the Composition API overload, so `i18n.global` is a Composer
|
||||
// and `i18n.global.locale` is a writable ref.
|
||||
@@ -12,6 +13,8 @@ export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: DEFAULT_LOCALE,
|
||||
fallbackLocale: DEFAULT_LOCALE,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: { en: compileIcuCatalog(en, DEFAULT_LOCALE) },
|
||||
})
|
||||
|
||||
@@ -19,8 +22,9 @@ const loaded = new Set<Locale>([DEFAULT_LOCALE])
|
||||
|
||||
export async function loadLocaleMessages(locale: Locale): Promise<void> {
|
||||
if (loaded.has(locale)) return
|
||||
const messages = (await import(`../locales/${locale}.json`)) as { default: MessageSchema }
|
||||
i18n.global.setLocaleMessage(locale, compileIcuCatalog(messages.default, locale))
|
||||
const messages = (await import(`../locales/${locale}.json`)) as { default: LocaleMessageTree }
|
||||
const compiled = compileIcuCatalog(messages.default, locale)
|
||||
i18n.global.setLocaleMessage(locale, compiled as MessageSchema)
|
||||
loaded.add(locale)
|
||||
}
|
||||
|
||||
|
||||
+37
-22
@@ -34,9 +34,10 @@ Chinese ships as Simplified only under the bare `zh` locale, so every `zh-*` bro
|
||||
Feature branches must:
|
||||
|
||||
1. Add the source message to `client/src/locales/en.json`.
|
||||
2. Add the same English value under the same key in every target catalog as a temporary fallback.
|
||||
3. Preserve every placeholder and the plural-message structure.
|
||||
4. Run `pnpm --filter client validate:locales`.
|
||||
2. Preserve every placeholder and the plural-message structure when editing an existing message.
|
||||
3. Run `pnpm --filter client validate:locales`.
|
||||
|
||||
Do not add the key to target catalogs. Target catalogs are sparse and contain only real Crowdin translations. Vue I18n resolves an absent target key through the complete English catalog, including English ICU plural rules.
|
||||
|
||||
Do not create or improve target-language translations directly in Git. Crowdin does not continuously import repository translations after initial setup, so a later Crowdin export would overwrite those changes. Apply any emergency target-catalog fix in Crowdin before the next export.
|
||||
|
||||
@@ -56,7 +57,7 @@ Pass the raw numeric value through the interpolation object:
|
||||
t("library.bookCount", { count });
|
||||
```
|
||||
|
||||
Do not use Vue I18n's positional plural argument, pre-format `count`, or add pipe-separated branches. ICU selects CLDR plural categories and formats `#` for the active locale. Every target message must preserve the source arguments and exact selectors such as `=0`, and must include every cardinal category required by its locale. Slovenian therefore includes `one`, `two`, `few`, and `other`; other locales use their own CLDR categories.
|
||||
Do not use Vue I18n's positional plural argument, pre-format `count`, or add pipe-separated branches. ICU selects CLDR plural categories and formats `#` for the active locale. Every translated target message that is present must preserve the source arguments and exact selectors such as `=0`, and must include every cardinal category required by its locale. Slovenian therefore includes `one`, `two`, `few`, and `other`; other locales use their own CLDR categories. An untranslated target message is omitted so Vue I18n formats the English fallback with English plural rules.
|
||||
|
||||
Repository validation compiles ordinary Vue I18n messages and rejects malformed syntax. For ICU plurals, it rejects malformed syntax, empty options, changed argument types or styles, changed plural offsets, missing locale categories, mismatched arguments or exact selectors, and legacy pipe branches. It also rejects embedded HTML and Unicode em dash characters. Human review is still required for the wording of each locale's plural branches.
|
||||
|
||||
@@ -85,15 +86,21 @@ After an English source change reaches `main`:
|
||||
|
||||
1. Crowdin synchronizes `client/src/locales/en.json`.
|
||||
2. Translators update target strings in Crowdin.
|
||||
3. Crowdin exports the target catalogs to `l10n_main`.
|
||||
4. Crowdin opens a pull request to `main`.
|
||||
5. CI verifies that the pull request changes only the fifteen target catalogs and runs the normal client checks.
|
||||
6. A maintainer reviews and squash-merges the pull request.
|
||||
7. The `.github/workflows/crowdin-branch-cleanup.yml` workflow deletes the `l10n_main` service branch. Crowdin recreates it from the current `main` for the next export.
|
||||
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 or replace one with English source text.
|
||||
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 fifteen target catalogs and runs the normal client checks.
|
||||
8. A maintainer reviews and squash-merges the pull request.
|
||||
9. The `.github/workflows/crowdin-branch-cleanup.yml` workflow deletes the `l10n_main` service branch. The next export recreates it from the current `main` when translations change.
|
||||
|
||||
Deleting that branch is required, not tidiness. Crowdin appends every export to the same service branch and never rebases it, so a branch that outlives its pull request keeps its original merge base. Each later export then diverges further from `main` and eventually conflicts on every catalog. The workflow runs when a pull request from `l10n_main` is closed as well as merged, because Crowdin reuses the branch either way.
|
||||
Deleting that branch is required, not tidiness. It guarantees that every later export starts from the current `main`. The cleanup workflow runs when a pull request from `l10n_main` is closed as well as merged.
|
||||
|
||||
Crowdin pull requests must retain the configured `i18n(client)` title and commit format. The `i18n` commit type produces a patch release and an Internationalization release-note section. Crowdin's default `[ci skip]` commit suffix is disabled so pull-request validation runs before merge.
|
||||
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 distinguishes the initial complete legacy catalogs from later sparse catalogs. In a complete legacy catalog, it protects values that differ from English and ignores copied English fallbacks. Once a catalog is sparse, every existing key is treated as an intentional translation, including technical terms that legitimately match English.
|
||||
|
||||
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.
|
||||
|
||||
## Adding a New Language
|
||||
|
||||
@@ -131,24 +138,24 @@ The shared locale list automatically updates the language picker, server prefere
|
||||
|
||||
### 3. Add the catalog and locale behavior
|
||||
|
||||
Create `client/src/locales/<locale>.json` with the exact key structure from `en.json` and English fallback values. Do not add partial catalogs. Crowdin will replace fallback values as translations are completed.
|
||||
Create `client/src/locales/<locale>.json` as an empty object. The catalog remains sparse and gains entries only when the controlled Crowdin workflow exports real translations.
|
||||
|
||||
A catalog may instead arrive already translated, for example from a bulk translation pass. That is allowed, but the translations then exist only in Git and Crowdin will overwrite them unless they are imported first. Follow "Seed existing translations into Crowdin" below before enabling export.
|
||||
|
||||
Review `client/src/stores/locale.ts` and add browser-locale matching coverage to `client/src/stores/__tests__/locale.spec.ts`, especially for regional variants.
|
||||
|
||||
Each ICU plural message must carry exactly the CLDR categories reported for the locale, plus every exact selector such as `=0` from the English source. Both directions matter:
|
||||
Each translated ICU plural message that is present must carry exactly the CLDR categories reported for the locale, plus every exact selector such as `=0` from the English source. Both directions matter:
|
||||
|
||||
- A missing category fails `validate:locales`. Czech, Russian, and Ukrainian need `few` and `many` added to every plural message, seeded from the English `other` branch because the source has no corresponding text.
|
||||
- A missing category fails `validate:locales`. Czech, Russian, and Ukrainian translations need `few` and `many` in every translated plural message.
|
||||
- An extra category leaves Crowdin nowhere to store it, because the editor renders only the categories the locale defines. Chinese has just `other`, so an inherited `one` branch produces a permanent difference that `scripts/crowdin-verify/verify.mjs --reconcile` can never close.
|
||||
|
||||
Extend the exhaustive ICU runtime tests in `client/src/i18n/icu.spec.ts` with counts that reach every category. Derive them from `Intl.PluralRules` rather than by analogy, because languages that report the same category set do not map counts the same way: Russian and Ukrainian select `many` for 5 and reach `other` only through a fraction such as 1.5, while Czech selects `other` for 5 and reserves `many` for fractions.
|
||||
|
||||
### 4. Seed existing translations into Crowdin
|
||||
|
||||
Skip this step when the catalog holds English fallback values. It applies only when the catalog already contains real translations.
|
||||
This applies only when real translations already exist in Git. English fallback values are omitted rather than imported.
|
||||
|
||||
Crowdin does not import repository translations on its own, and untranslated strings export as English source text. A pre-translated catalog that is never imported is therefore silently reverted to English by the first export.
|
||||
Crowdin does not import repository translations on its own. Without untranslated-string omission, a pre-translated catalog that was never imported can be silently reverted to English by an export. The controlled workflow requests omission and prunes Crowdin's empty nested-JSON placeholders instead.
|
||||
|
||||
Order matters, because merging the `crowdin.yml` change enables export for the language:
|
||||
|
||||
@@ -171,7 +178,7 @@ The script exits non-zero while any key differs. Pass `--reconcile` to push the
|
||||
CROWDIN_TOKEN=... node scripts/crowdin-verify/verify.mjs es-ES es.json --reconcile
|
||||
```
|
||||
|
||||
A language is safe to export only when the script reports zero differing keys. Run it for every seeded language before resuming synchronization.
|
||||
A language is safe to export only when every real Git translation is present in Crowdin. Do not reconcile a full legacy catalog blindly because that would record English fallback values as translations.
|
||||
|
||||
### 5. Allow Crowdin export and PR delivery
|
||||
|
||||
@@ -180,9 +187,11 @@ Update `crowdin.yml`:
|
||||
- Add the exact Crowdin language ID to `export_languages`.
|
||||
- Add `languages_mapping` when the desired filename is not the language's unique `%two_letters_code%` output.
|
||||
|
||||
Add the Crowdin language ID and application locale mapping to `TARGET_CATALOGS` in `client/scripts/sync-crowdin-translations.mjs`.
|
||||
|
||||
Update `scripts/classify-crowdin-pr.sh` to allow the new target catalog path. Do not allow `en.json` or broaden the rule to the whole locales directory.
|
||||
|
||||
Before merging, add the target language in Crowdin while the current `main` configuration still excludes it from `export_languages`, or pause translation synchronization. Merge the repository support before allowing the first export. After merge, run a manual source and translation sync and confirm that the generated PR changes only explicitly allowed target catalogs.
|
||||
Before merging, add the target language in Crowdin while the current `main` configuration still excludes it from `export_languages`, or pause translation synchronization. Merge the repository support before allowing the first export. After Crowdin synchronizes the new English source, dispatch `Crowdin Translation Sync` manually and confirm that the generated pull request changes only explicitly allowed target catalogs.
|
||||
|
||||
### 6. Verify the new language
|
||||
|
||||
@@ -215,7 +224,7 @@ Only enable scheduled export for the new language after this manual round trip s
|
||||
|
||||
## Crowdin Project Settings
|
||||
|
||||
Use the native GitHub integration in **Source and translation files mode** with only `main` connected. Do not use Target file bundles mode or automatic feature-branch discovery.
|
||||
Use the native GitHub integration in **Source and translation files mode** with only `main` connected. Do not use Target file bundles mode or automatic feature-branch discovery. Leave source synchronization enabled, but disable the integration's scheduled translation synchronization and pull-request creation. The repository workflow owns translation export and delivery.
|
||||
|
||||
Initial import settings:
|
||||
|
||||
@@ -226,11 +235,17 @@ Initial import settings:
|
||||
|
||||
Export settings:
|
||||
|
||||
- Skip untranslated strings: off.
|
||||
- Skip untranslated strings: on.
|
||||
- Skip untranslated files: off.
|
||||
- Export only approved translations: off during the initial rollout.
|
||||
- Export only approved translations: off unless the review policy changes explicitly.
|
||||
|
||||
These settings make Crowdin export English source text for untranslated entries instead of empty JSON values, preserving BookOrbit's complete-catalog requirement.
|
||||
Crowdin preserves untranslated nested JSON keys with empty values even when untranslated strings are skipped. `client/scripts/sync-crowdin-translations.mjs` removes those empty entries, restores English source-key ordering, validates every translated message, and refuses to write catalogs if Crowdin has not synchronized the current English keys.
|
||||
|
||||
The `Crowdin Translation Sync` workflow requires:
|
||||
|
||||
- `CROWDIN_TOKEN`, with permission to read project files, strings, and translation builds.
|
||||
- `CROWDIN_PR_TOKEN`, a fine-grained GitHub token with repository contents and pull-request write access.
|
||||
- Optional repository variable `CROWDIN_PROJECT_ID`; the BookOrbit project ID is the script default.
|
||||
|
||||
Configure variable mismatches and leading or trailing whitespace as Crowdin QA errors. Keep punctuation and length checks enabled, and include translator instructions for Vue I18n plural branches, the HTML prohibition, and the Unicode em dash prohibition.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user