mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
ci(i18n): enforce controlled crowdin localization workflow and pr validation
Add Crowdin pull request validation script, centralize locale configuration, and update GitHub workflows to enforce sparse target catalog validation.
This commit is contained in:
@@ -2,7 +2,7 @@ import { validateLocaleMessage, validateSlotCountMessage } from './locale-messag
|
||||
|
||||
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')
|
||||
throw new Error(`${prefix || 'catalog root'} must be a message object`)
|
||||
}
|
||||
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
|
||||
@@ -15,6 +15,11 @@ function catalogs(target = {}) {
|
||||
}
|
||||
|
||||
describe('locale catalog validation', () => {
|
||||
it('reports invalid catalog objects with their path', () => {
|
||||
expect(() => flattenCatalog(null)).toThrow('catalog root must be a message object')
|
||||
expect(() => flattenCatalog(null, 'common')).toThrow('common must be a message object')
|
||||
})
|
||||
|
||||
it('accepts a sparse target catalog', () => {
|
||||
expect(validateCatalogs({ catalogs: catalogs({ common: { save: 'Uložit' } }) })).toEqual([])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const scriptDirectory = import.meta.dirname ?? path.join(process.cwd(), 'scripts')
|
||||
const repositoryRoot = path.resolve(scriptDirectory, '../..')
|
||||
const localeTypesPath = path.join(repositoryRoot, 'packages/types/src/locale.ts')
|
||||
|
||||
export async function readSupportedLocales(sourcePath = localeTypesPath) {
|
||||
const source = await readFile(sourcePath, 'utf8')
|
||||
const match = source.match(/SUPPORTED_LOCALES\s*=\s*(\[[\s\S]*?\])\s+as const/)
|
||||
if (!match) throw new Error('Unable to read SUPPORTED_LOCALES from packages/types/src/locale.ts')
|
||||
return JSON.parse(match[1].replace(/,(\s*])$/, '$1'))
|
||||
}
|
||||
|
||||
export const SUPPORTED_LOCALES = await readSupportedLocales()
|
||||
|
||||
const CROWDIN_LANGUAGE_ID_OVERRIDES = new Map([
|
||||
['es', 'es-ES'],
|
||||
['pt', 'pt-BR'],
|
||||
['sv', 'sv-SE'],
|
||||
['zh', 'zh-CN'],
|
||||
])
|
||||
|
||||
for (const locale of CROWDIN_LANGUAGE_ID_OVERRIDES.keys()) {
|
||||
if (!SUPPORTED_LOCALES.includes(locale)) {
|
||||
throw new Error(`Crowdin language ID override references unsupported locale ${locale}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const TARGET_CATALOGS = SUPPORTED_LOCALES.filter((locale) => locale !== 'en').map((locale) => ({
|
||||
languageId: CROWDIN_LANGUAGE_ID_OVERRIDES.get(locale) ?? locale,
|
||||
locale,
|
||||
}))
|
||||
|
||||
const expectedCatalogPaths = TARGET_CATALOGS.map(({ locale }) => `client/src/locales/${locale}.json`)
|
||||
|
||||
function parseCrowdinLanguageIds(source) {
|
||||
const match = source.match(/^export_languages:\s*\n((?:\s+-\s+[^\n]+\n?)+)/m)
|
||||
if (!match) throw new Error('Unable to read export_languages from crowdin.yml')
|
||||
return [...match[1].matchAll(/^\s+-\s+([^\s#]+)\s*$/gm)].map((entry) => entry[1])
|
||||
}
|
||||
|
||||
function parseWorkflowCatalogPaths(source) {
|
||||
const match = source.match(/^\s+add-paths:\s*\|\s*\n((?:\s+client\/src\/locales\/[^\n]+\n?)+)/m)
|
||||
if (!match) throw new Error('Unable to read Crowdin add-paths from crowdin-translations.yml')
|
||||
return [...match[1].matchAll(/^\s+(client\/src\/locales\/[^\s]+)\s*$/gm)].map((entry) => entry[1])
|
||||
}
|
||||
|
||||
function parseClassifierCatalogPaths(source) {
|
||||
const match = source.match(/allowed_paths=\(\s*([\s\S]*?)\s*\)/)
|
||||
if (!match) throw new Error('Unable to read allowed_paths from classify-crowdin-pr.sh')
|
||||
return [...match[1].matchAll(/"(client\/src\/locales\/[^"]+)"/g)].map((entry) => entry[1])
|
||||
}
|
||||
|
||||
function assertExactList(label, actual, expected) {
|
||||
if (new Set(actual).size !== actual.length) throw new Error(`${label} contains duplicate entries`)
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) return
|
||||
throw new Error(`${label} must match the supported target locales: expected ${expected.join(', ')}, found ${actual.join(', ')}`)
|
||||
}
|
||||
|
||||
export function validateCrowdinTargetConfiguration({ crowdinLanguageIds, workflowCatalogPaths, classifierCatalogPaths }) {
|
||||
assertExactList(
|
||||
'crowdin.yml export_languages',
|
||||
crowdinLanguageIds,
|
||||
TARGET_CATALOGS.map(({ languageId }) => languageId),
|
||||
)
|
||||
assertExactList('Crowdin workflow add-paths', workflowCatalogPaths, expectedCatalogPaths)
|
||||
assertExactList('Crowdin PR classifier allowed_paths', classifierCatalogPaths, expectedCatalogPaths)
|
||||
}
|
||||
|
||||
export async function assertCrowdinTargetConfiguration({
|
||||
crowdinConfigPath = path.join(repositoryRoot, 'crowdin.yml'),
|
||||
workflowPath = path.join(repositoryRoot, '.github/workflows/crowdin-translations.yml'),
|
||||
classifierPath = path.join(repositoryRoot, 'scripts/classify-crowdin-pr.sh'),
|
||||
} = {}) {
|
||||
const [crowdinConfig, workflow, classifier] = await Promise.all([
|
||||
readFile(crowdinConfigPath, 'utf8'),
|
||||
readFile(workflowPath, 'utf8'),
|
||||
readFile(classifierPath, 'utf8'),
|
||||
])
|
||||
validateCrowdinTargetConfiguration({
|
||||
crowdinLanguageIds: parseCrowdinLanguageIds(crowdinConfig),
|
||||
workflowCatalogPaths: parseWorkflowCatalogPaths(workflow),
|
||||
classifierCatalogPaths: parseClassifierCatalogPaths(classifier),
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { isIP } from 'node:net'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import { TARGET_CATALOGS, assertCrowdinTargetConfiguration } from './locale-configuration.mjs'
|
||||
import { flattenCatalog, validateCatalogs } from './locale-catalog-validation.mjs'
|
||||
|
||||
const API = 'https://api.crowdin.com/api/v2'
|
||||
@@ -9,26 +11,11 @@ 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 scriptDirectory = import.meta.dirname ?? path.join(process.cwd(), 'scripts')
|
||||
const clientRoot = path.resolve(scriptDirectory, '..')
|
||||
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' },
|
||||
]
|
||||
export { TARGET_CATALOGS }
|
||||
|
||||
const TARGET_LOCALES = new Set(TARGET_CATALOGS.map(({ locale }) => locale))
|
||||
|
||||
@@ -65,36 +52,76 @@ function parseJson(text, context) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeDownloadUrl(value) {
|
||||
function ipv4Value(hostname) {
|
||||
return hostname.split('.').reduce((value, octet) => value * 256 + Number(octet), 0) >>> 0
|
||||
}
|
||||
|
||||
function isInIpv4Range(value, network, prefixLength) {
|
||||
const mask = prefixLength === 0 ? 0 : (0xffffffff << (32 - prefixLength)) >>> 0
|
||||
return (value & mask) === (network & mask)
|
||||
}
|
||||
|
||||
function isBlockedIpv4(hostname) {
|
||||
const value = ipv4Value(hostname)
|
||||
return [
|
||||
[ipv4Value('0.0.0.0'), 8],
|
||||
[ipv4Value('10.0.0.0'), 8],
|
||||
[ipv4Value('100.64.0.0'), 10],
|
||||
[ipv4Value('127.0.0.0'), 8],
|
||||
[ipv4Value('169.254.0.0'), 16],
|
||||
[ipv4Value('172.16.0.0'), 12],
|
||||
[ipv4Value('192.0.0.0'), 24],
|
||||
[ipv4Value('192.0.2.0'), 24],
|
||||
[ipv4Value('192.168.0.0'), 16],
|
||||
[ipv4Value('198.18.0.0'), 15],
|
||||
[ipv4Value('198.51.100.0'), 24],
|
||||
[ipv4Value('203.0.113.0'), 24],
|
||||
[ipv4Value('224.0.0.0'), 4],
|
||||
[ipv4Value('240.0.0.0'), 4],
|
||||
].some(([network, prefixLength]) => isInIpv4Range(value, network, prefixLength))
|
||||
}
|
||||
|
||||
function ipv6Groups(hostname) {
|
||||
const [head, tail = ''] = hostname.split('::')
|
||||
const headGroups = head ? head.split(':').map((group) => Number.parseInt(group, 16)) : []
|
||||
const tailGroups = tail ? tail.split(':').map((group) => Number.parseInt(group, 16)) : []
|
||||
return [...headGroups, ...Array(8 - headGroups.length - tailGroups.length).fill(0), ...tailGroups]
|
||||
}
|
||||
|
||||
function isBlockedIpv6(hostname) {
|
||||
const groups = ipv6Groups(hostname)
|
||||
const first = groups[0]
|
||||
const ipv4Mapped = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff
|
||||
if (ipv4Mapped) {
|
||||
const embedded = `${groups[6] >> 8}.${groups[6] & 0xff}.${groups[7] >> 8}.${groups[7] & 0xff}`
|
||||
return isBlockedIpv4(embedded)
|
||||
}
|
||||
|
||||
return (
|
||||
groups.every((group) => group === 0) ||
|
||||
(groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) ||
|
||||
groups.slice(0, 6).every((group) => group === 0) ||
|
||||
(first & 0xfe00) === 0xfc00 ||
|
||||
(first & 0xffc0) === 0xfe80 ||
|
||||
(first & 0xffc0) === 0xfec0 ||
|
||||
(first & 0xff00) === 0xff00
|
||||
)
|
||||
}
|
||||
|
||||
export 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:')
|
||||
) {
|
||||
const hostname = url.hostname
|
||||
.replace(/^\[|\]$/g, '')
|
||||
.replace(/\.$/, '')
|
||||
.toLowerCase()
|
||||
const addressType = isIP(hostname)
|
||||
if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local')) {
|
||||
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')
|
||||
}
|
||||
if ((addressType === 4 && isBlockedIpv4(hostname)) || (addressType === 6 && isBlockedIpv6(hostname))) {
|
||||
throw new Error('Crowdin export URL must not target a local network host')
|
||||
}
|
||||
|
||||
return url
|
||||
@@ -172,7 +199,7 @@ function orderedSparseCatalog(reference, messages, prefix = '') {
|
||||
const messageKey = prefix ? `${prefix}.${key}` : key
|
||||
if (typeof child === 'string') {
|
||||
const translated = messages.get(messageKey)
|
||||
if (translated) output[key] = translated
|
||||
if (translated !== undefined) output[key] = translated
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -252,9 +279,15 @@ export function findTranslationLosses({ locale, reference, current, exported })
|
||||
return losses
|
||||
}
|
||||
|
||||
export function assertTranslationRetention({ reference, currentCatalogs, exportedCatalogs, allowedLosses = new Set() }) {
|
||||
export function assertTranslationRetention({
|
||||
reference,
|
||||
currentCatalogs,
|
||||
exportedCatalogs,
|
||||
allowedLosses = new Set(),
|
||||
targetCatalogs = TARGET_CATALOGS,
|
||||
}) {
|
||||
const losses = []
|
||||
for (const { locale } of TARGET_CATALOGS) {
|
||||
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`)
|
||||
@@ -282,14 +315,17 @@ export async function syncCrowdinTranslations({
|
||||
catalogDirectory = localesDirectory,
|
||||
outputDirectory = catalogDirectory,
|
||||
allowedLosses = new Set(),
|
||||
targetCatalogs = TARGET_CATALOGS,
|
||||
assertTargetConfiguration = assertCrowdinTargetConfiguration,
|
||||
}) {
|
||||
if (!token) throw new Error('CROWDIN_TOKEN is required')
|
||||
await assertTargetConfiguration()
|
||||
|
||||
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 }) => {
|
||||
targetCatalogs.map(async ({ locale }) => {
|
||||
const catalog = JSON.parse(await readFile(path.join(catalogDirectory, `${locale}.json`), 'utf8'))
|
||||
return [locale, flattenCatalog(catalog)]
|
||||
}),
|
||||
@@ -307,7 +343,7 @@ export async function syncCrowdinTranslations({
|
||||
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 }) => ({
|
||||
const downloaded = await mapWithConcurrency(targetCatalogs, 4, async ({ languageId, locale }) => ({
|
||||
locale,
|
||||
catalog: normalizeCrowdinCatalog(await client.exportedCatalog(fileId, languageId), reference),
|
||||
}))
|
||||
@@ -321,6 +357,7 @@ export async function syncCrowdinTranslations({
|
||||
currentCatalogs,
|
||||
exportedCatalogs: catalogs,
|
||||
allowedLosses,
|
||||
targetCatalogs,
|
||||
})
|
||||
|
||||
await mkdir(outputDirectory, { recursive: true })
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { assertCrowdinTargetConfiguration, validateCrowdinTargetConfiguration } from './locale-configuration.mjs'
|
||||
import {
|
||||
TARGET_CATALOGS,
|
||||
assertSafeDownloadUrl,
|
||||
assertTranslationRetention,
|
||||
createCrowdinClient,
|
||||
findTranslationLosses,
|
||||
normalizeCrowdinCatalog,
|
||||
parseAllowedTranslationLosses,
|
||||
sourceDrift,
|
||||
syncCrowdinTranslations,
|
||||
} from './sync-crowdin-translations.mjs'
|
||||
|
||||
const reference = {
|
||||
@@ -14,6 +20,47 @@ const reference = {
|
||||
books: { count: '{count, plural, one {# book} other {# books}}' },
|
||||
}
|
||||
|
||||
const temporaryDirectories = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function createCatalogFixture(targetCatalogs, currentCatalogs = new Map()) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'bookorbit-crowdin-sync-'))
|
||||
temporaryDirectories.push(directory)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(path.join(directory, 'en.json'), `${JSON.stringify({ common: { save: 'Save' } }, null, 2)}\n`)
|
||||
await Promise.all(
|
||||
targetCatalogs.map(({ locale }) =>
|
||||
writeFile(path.join(directory, `${locale}.json`), `${JSON.stringify(currentCatalogs.get(locale) ?? {}, null, 2)}\n`),
|
||||
),
|
||||
)
|
||||
return directory
|
||||
}
|
||||
|
||||
function createSynchronizationFetch({ identifiers = ['common.save'], catalogs = new Map(), onDownload = async () => {} } = {}) {
|
||||
return vi.fn(async (input, init = {}) => {
|
||||
const url = new URL(input)
|
||||
if (url.hostname === 'api.crowdin.com' && url.pathname.endsWith('/files')) {
|
||||
return new Response(JSON.stringify({ data: [{ data: { id: 7, path: '/client/src/locales/en.json' } }] }))
|
||||
}
|
||||
if (url.hostname === 'api.crowdin.com' && url.pathname.endsWith('/strings')) {
|
||||
return new Response(JSON.stringify({ data: identifiers.map((identifier) => ({ data: { identifier } })) }))
|
||||
}
|
||||
if (url.hostname === 'api.crowdin.com' && url.pathname.includes('/translations/builds/files/')) {
|
||||
const { targetLanguageId } = JSON.parse(init.body)
|
||||
return new Response(JSON.stringify({ data: { url: `https://downloads.example.test/${encodeURIComponent(targetLanguageId)}.json` } }))
|
||||
}
|
||||
if (url.hostname === 'downloads.example.test') {
|
||||
const languageId = decodeURIComponent(path.basename(url.pathname, '.json'))
|
||||
await onDownload(languageId)
|
||||
return new Response(JSON.stringify(catalogs.get(languageId) ?? { common: { save: `Translated ${languageId}` } }))
|
||||
}
|
||||
throw new Error(`Unexpected request ${url.href}`)
|
||||
})
|
||||
}
|
||||
|
||||
describe('Crowdin translation synchronization', () => {
|
||||
it('removes empty untranslated messages and follows English key order', () => {
|
||||
const exported = {
|
||||
@@ -127,11 +174,152 @@ describe('Crowdin translation synchronization', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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' } })))
|
||||
it('rejects non-HTTPS export URLs returned by Crowdin', () => {
|
||||
expect(() => assertSafeDownloadUrl('http://127.0.0.1/catalog.json')).toThrow('Crowdin export URL must use HTTPS')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://127.0.0.1/catalog.json',
|
||||
'https://127.0.0.1./catalog.json',
|
||||
'https://10.0.0.5/catalog.json',
|
||||
'https://2130706433/catalog.json',
|
||||
'https://[::1]/catalog.json',
|
||||
'https://[::ffff:127.0.0.1]/catalog.json',
|
||||
'https://[fd00::1]/catalog.json',
|
||||
'https://[fe90::1]/catalog.json',
|
||||
'https://localhost./catalog.json',
|
||||
])('rejects private or local export URL %s', (url) => {
|
||||
expect(() => assertSafeDownloadUrl(url)).toThrow('Crowdin export URL must not target a local network host')
|
||||
})
|
||||
|
||||
it.each(['https://fdn.example.com/x.json', 'https://fc-cdn.example.com/x.json'])(
|
||||
'allows public hostnames that begin with IPv6-looking prefixes: %s',
|
||||
(url) => {
|
||||
expect(assertSafeDownloadUrl(url).href).toBe(url)
|
||||
},
|
||||
)
|
||||
|
||||
it('allows an IPv4-mapped public address', () => {
|
||||
expect(assertSafeDownloadUrl('https://[::ffff:8.8.8.8]/x.json').hostname).toBe('[::ffff:808:808]')
|
||||
})
|
||||
|
||||
it('revalidates redirects before following them', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ data: { url: 'https://downloads.example.test/cs.json' } })))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: 'https://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)
|
||||
await expect(client.exportedCatalog(7, 'cs')).rejects.toThrow('Crowdin export URL must not target a local network host')
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps every Crowdin target list synchronized with the shared locale list', async () => {
|
||||
await expect(assertCrowdinTargetConfiguration()).resolves.toBeUndefined()
|
||||
const targetPaths = TARGET_CATALOGS.map(({ locale }) => `client/src/locales/${locale}.json`)
|
||||
expect(() =>
|
||||
validateCrowdinTargetConfiguration({
|
||||
crowdinLanguageIds: TARGET_CATALOGS.map(({ languageId }) => languageId),
|
||||
workflowCatalogPaths: targetPaths,
|
||||
classifierCatalogPaths: targetPaths.slice(1),
|
||||
}),
|
||||
).toThrow('Crowdin PR classifier allowed_paths must match the supported target locales')
|
||||
})
|
||||
|
||||
it('resolves repository configuration independently of the working directory', async () => {
|
||||
const originalDirectory = process.cwd()
|
||||
try {
|
||||
process.chdir(tmpdir())
|
||||
await expect(assertCrowdinTargetConfiguration()).resolves.toBeUndefined()
|
||||
} finally {
|
||||
process.chdir(originalDirectory)
|
||||
}
|
||||
})
|
||||
|
||||
it('synchronizes catalogs with at most four concurrent language downloads', async () => {
|
||||
const targetCatalogs = TARGET_CATALOGS.slice(0, 6)
|
||||
const catalogDirectory = await createCatalogFixture(targetCatalogs)
|
||||
const outputDirectory = path.join(catalogDirectory, 'output')
|
||||
let activeDownloads = 0
|
||||
let maximumDownloads = 0
|
||||
const fetchImpl = createSynchronizationFetch({
|
||||
onDownload: async () => {
|
||||
activeDownloads += 1
|
||||
maximumDownloads = Math.max(maximumDownloads, activeDownloads)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
activeDownloads -= 1
|
||||
},
|
||||
})
|
||||
|
||||
await syncCrowdinTranslations({
|
||||
token: 'secret',
|
||||
fetchImpl,
|
||||
catalogDirectory,
|
||||
outputDirectory,
|
||||
targetCatalogs,
|
||||
assertTargetConfiguration: async () => {},
|
||||
})
|
||||
|
||||
expect(maximumDownloads).toBe(4)
|
||||
await expect(readFile(path.join(outputDirectory, 'cs.json'), 'utf8')).resolves.toContain('Translated cs')
|
||||
})
|
||||
|
||||
it('rejects source drift before downloading or writing catalogs', async () => {
|
||||
const targetCatalogs = TARGET_CATALOGS.slice(0, 2)
|
||||
const catalogDirectory = await createCatalogFixture(targetCatalogs)
|
||||
const outputDirectory = path.join(catalogDirectory, 'output')
|
||||
const fetchImpl = createSynchronizationFetch({ identifiers: [] })
|
||||
|
||||
await expect(
|
||||
syncCrowdinTranslations({
|
||||
token: 'secret',
|
||||
fetchImpl,
|
||||
catalogDirectory,
|
||||
outputDirectory,
|
||||
targetCatalogs,
|
||||
assertTargetConfiguration: async () => {},
|
||||
}),
|
||||
).rejects.toThrow('Crowdin source is not synchronized with en.json')
|
||||
expect(fetchImpl.mock.calls.some(([input]) => new URL(input).hostname === 'downloads.example.test')).toBe(false)
|
||||
await expect(access(outputDirectory)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('validates every downloaded catalog before writing any files', async () => {
|
||||
const targetCatalogs = TARGET_CATALOGS.slice(0, 2)
|
||||
const catalogDirectory = await createCatalogFixture(targetCatalogs)
|
||||
const outputDirectory = path.join(catalogDirectory, 'output')
|
||||
const catalogs = new Map([[targetCatalogs[0].languageId, { common: { save: 'Bad \u2014 value' } }]])
|
||||
|
||||
await expect(
|
||||
syncCrowdinTranslations({
|
||||
token: 'secret',
|
||||
fetchImpl: createSynchronizationFetch({ catalogs }),
|
||||
catalogDirectory,
|
||||
outputDirectory,
|
||||
targetCatalogs,
|
||||
assertTargetConfiguration: async () => {},
|
||||
}),
|
||||
).rejects.toThrow('Unicode em dash is not allowed')
|
||||
await expect(access(outputDirectory)).rejects.toThrow()
|
||||
})
|
||||
|
||||
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 outputDirectory = path.join(catalogDirectory, 'output')
|
||||
const catalogs = new Map([[targetCatalogs[0].languageId, {}]])
|
||||
|
||||
await expect(
|
||||
syncCrowdinTranslations({
|
||||
token: 'secret',
|
||||
fetchImpl: createSynchronizationFetch({ catalogs }),
|
||||
catalogDirectory,
|
||||
outputDirectory,
|
||||
targetCatalogs,
|
||||
assertTargetConfiguration: async () => {},
|
||||
}),
|
||||
).rejects.toThrow('missing from Crowdin export')
|
||||
await expect(access(outputDirectory)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import { SUPPORTED_LOCALES } from './locale-configuration.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')
|
||||
|
||||
async function sourceFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
@@ -45,21 +45,14 @@ async function sourceMessageKeys() {
|
||||
return { keys, slotCountKeys }
|
||||
}
|
||||
|
||||
const localeTypes = await readFile(localeTypesPath, 'utf8')
|
||||
const supportedMatch = localeTypes.match(/SUPPORTED_LOCALES\s*=\s*(\[[\s\S]*?\])\s+as const/)
|
||||
if (!supportedMatch) throw new Error('Unable to read SUPPORTED_LOCALES from packages/types/src/locale.ts')
|
||||
// Prettier wraps the array once the locale list outgrows the print width, which
|
||||
// adds a trailing comma that JSON.parse rejects.
|
||||
const supportedLocales = JSON.parse(supportedMatch[1].replace(/,(\s*])$/, '$1'))
|
||||
|
||||
const localeFiles = (await readdir(localesDirectory)).filter((file) => file.endsWith('.json')).sort()
|
||||
const expectedFiles = supportedLocales.map((locale) => `${locale}.json`).sort()
|
||||
const expectedFiles = SUPPORTED_LOCALES.map((locale) => `${locale}.json`).sort()
|
||||
if (JSON.stringify(localeFiles) !== JSON.stringify(expectedFiles)) {
|
||||
throw new Error(`Locale files do not match SUPPORTED_LOCALES: expected ${expectedFiles.join(', ')}, found ${localeFiles.join(', ')}`)
|
||||
}
|
||||
|
||||
const catalogs = new Map()
|
||||
for (const locale of supportedLocales) {
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const raw = await readFile(path.join(localesDirectory, `${locale}.json`), 'utf8')
|
||||
catalogs.set(locale, flattenCatalog(JSON.parse(raw)))
|
||||
}
|
||||
@@ -74,4 +67,4 @@ if (errors.length > 0) {
|
||||
throw new Error(`Locale validation failed:\n${errors.join('\n')}`)
|
||||
}
|
||||
|
||||
console.log(`Validated ${supportedLocales.length} locale catalogs against ${reference.size} English messages`)
|
||||
console.log(`Validated ${SUPPORTED_LOCALES.length} locale catalogs against ${reference.size} English messages`)
|
||||
|
||||
Reference in New Issue
Block a user