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:
neon
2026-08-06 00:00:04 +00:00
parent 6a0a9a3d53
commit b9e041d1b7
16 changed files with 608 additions and 71 deletions
+1 -1
View File
@@ -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([])
})
+86
View File
@@ -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),
})
}
+85 -48
View File
@@ -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()
})
})
+4 -11
View File
@@ -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`)