Files
bookorbit/client/scripts/locale-configuration.mjs
T
neon b9e041d1b7 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.
2026-08-06 00:00:04 +00:00

87 lines
3.7 KiB
JavaScript

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),
})
}