mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🌍 ci: Harden Locize Translation Sync (#14784)
This commit is contained in:
parent
155f71f81a
commit
6755544cee
2 changed files with 147 additions and 5 deletions
27
.github/workflows/locize-i18n-sync.yml
vendored
27
.github/workflows/locize-i18n-sync.yml
vendored
|
|
@ -3,11 +3,15 @@ name: Sync Locize Translations & Create Translation PR
|
|||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
paths:
|
||||
- 'client/src/locales/en/**'
|
||||
repository_dispatch:
|
||||
types: [locize/versionPublished]
|
||||
|
||||
concurrency:
|
||||
group: locize-i18n-sync
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
|
@ -29,7 +33,8 @@ jobs:
|
|||
- name: Install locize CLI
|
||||
run: npm install -g locize-cli@12.2.0 --ignore-scripts --no-audit --no-fund
|
||||
|
||||
# Sync translations (Push missing keys & remove deleted ones)
|
||||
# Git owns English source values. Push changed values to Locize without
|
||||
# allowing a stale checkout to delete keys that still exist remotely.
|
||||
- name: Sync Locize with Repository
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
env:
|
||||
|
|
@ -37,7 +42,7 @@ jobs:
|
|||
LOCIZE_PROJECT_ID: ${{ secrets.LOCIZE_PROJECT_ID }}
|
||||
run: |
|
||||
cd client/src/locales
|
||||
locize sync --cdn-type pro --api-key "$LOCIZE_API_KEY" --project-id "$LOCIZE_PROJECT_ID" --language en
|
||||
locize sync --cdn-type pro --api-key "$LOCIZE_API_KEY" --project-id "$LOCIZE_PROJECT_ID" --language en --skip-delete true --update-values true
|
||||
|
||||
# When triggered by repository_dispatch, skip sync step.
|
||||
- name: Skip sync step on non-push events
|
||||
|
|
@ -46,6 +51,7 @@ jobs:
|
|||
|
||||
create-pull-request:
|
||||
name: Create Translation PR on Version Published
|
||||
if: ${{ github.event_name == 'repository_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: sync-translations
|
||||
permissions:
|
||||
|
|
@ -57,12 +63,23 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# 2. Download translation files from locize.
|
||||
# Keep a baseline so generated changes can be checked before opening a PR.
|
||||
- name: Snapshot Repository Locales
|
||||
run: cp -R client/src/locales "$RUNNER_TEMP/locize-locale-baseline"
|
||||
|
||||
# Download the latest published translation version from Locize.
|
||||
- name: Download Translations from locize
|
||||
uses: locize/download@v2
|
||||
with:
|
||||
project-id: ${{ secrets.LOCIZE_PROJECT_ID }}
|
||||
path: "client/src/locales"
|
||||
version: latest
|
||||
cdn-type: pro
|
||||
|
||||
- name: Validate Downloaded Translations
|
||||
run: node scripts/validate-locize-download.mjs \
|
||||
--base-dir "$RUNNER_TEMP/locize-locale-baseline" \
|
||||
--current-dir client/src/locales
|
||||
|
||||
# 3. Create a Pull Request using a dedicated fine-grained PAT so this
|
||||
# workflow does not depend on the global GITHUB_TOKEN PR-creation setting.
|
||||
|
|
|
|||
125
scripts/validate-locize-download.mjs
Normal file
125
scripts/validate-locize-download.mjs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 1) {
|
||||
if (!process.argv[index].startsWith('--')) continue;
|
||||
args.set(process.argv[index].slice(2), process.argv[index + 1]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const baseDir = args.get('base-dir');
|
||||
const currentDir = args.get('current-dir');
|
||||
if (!baseDir || !currentDir) {
|
||||
console.error('Usage: validate-locize-download.mjs --base-dir <path> --current-dir <path>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
const placeholderPattern = /\{\{[^{}]+\}\}|\{[^{}]+\}|%[-+0-9.#]*[a-zA-Z]/g;
|
||||
|
||||
async function jsonFiles(directory, relative = '') {
|
||||
const entries = await readdir(path.join(directory, relative), { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const entryRelative = path.join(relative, entry.name);
|
||||
if (entry.isDirectory()) files.push(...(await jsonFiles(directory, entryRelative)));
|
||||
else if (entry.isFile() && entry.name.endsWith('.json')) files.push(entryRelative);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function loadJson(filePath, label) {
|
||||
try {
|
||||
return JSON.parse(await readFile(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
errors.push(`${label}: invalid JSON (${error.message})`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function flatten(value, prefix = '', output = new Map()) {
|
||||
if (value && typeof value === 'object') {
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
flatten(child, prefix ? `${prefix}.${key}` : key, output);
|
||||
}
|
||||
} else if (prefix) {
|
||||
output.set(prefix, value);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function placeholders(value) {
|
||||
return (value.match(placeholderPattern) ?? []).sort().join('\u0000');
|
||||
}
|
||||
|
||||
function compareFile(relative, base, current) {
|
||||
const baseValues = flatten(base);
|
||||
const currentValues = flatten(current);
|
||||
for (const [key, baseValue] of baseValues) {
|
||||
if (!currentValues.has(key)) {
|
||||
errors.push(`${relative}: deleted key ${key}`);
|
||||
continue;
|
||||
}
|
||||
const currentValue = currentValues.get(key);
|
||||
if (typeof baseValue !== 'string' || typeof currentValue !== 'string') continue;
|
||||
if (placeholders(baseValue) !== placeholders(currentValue)) {
|
||||
errors.push(`${relative}: changed placeholders for ${key}`);
|
||||
}
|
||||
if (currentValue !== baseValue && currentValue !== currentValue.trim()) {
|
||||
errors.push(`${relative}: introduced leading/trailing whitespace for ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [baseFiles, currentFiles] = await Promise.all([jsonFiles(baseDir), jsonFiles(currentDir)]);
|
||||
const baseSet = new Set(baseFiles);
|
||||
const currentSet = new Set(currentFiles);
|
||||
const baselineValuesByFile = new Map();
|
||||
for (const relative of baseFiles) {
|
||||
if (!currentSet.has(relative)) {
|
||||
errors.push(`deleted file ${relative}`);
|
||||
continue;
|
||||
}
|
||||
const [base, current] = await Promise.all([
|
||||
loadJson(path.join(baseDir, relative), `baseline/${relative}`),
|
||||
loadJson(path.join(currentDir, relative), `download/${relative}`),
|
||||
]);
|
||||
if (base !== null && current !== null) {
|
||||
baselineValuesByFile.set(relative, flatten(base));
|
||||
compareFile(relative, base, current);
|
||||
}
|
||||
}
|
||||
|
||||
const englishRelative = path.join('en', 'translation.json');
|
||||
if (currentSet.has(englishRelative)) {
|
||||
const english = await loadJson(path.join(currentDir, englishRelative), `download/${englishRelative}`);
|
||||
if (english !== null) {
|
||||
const englishValues = flatten(english);
|
||||
for (const relative of currentFiles) {
|
||||
if (relative === englishRelative) continue;
|
||||
const locale = await loadJson(path.join(currentDir, relative), `download/${relative}`);
|
||||
if (locale === null) continue;
|
||||
for (const [key, value] of flatten(locale)) {
|
||||
const source = englishValues.get(key);
|
||||
const baseline = baselineValuesByFile.get(relative)?.get(key);
|
||||
const changedSinceBaseline = baseline === undefined || baseline !== value;
|
||||
if (changedSinceBaseline && typeof source === 'string' && typeof value === 'string' && placeholders(source) !== placeholders(value)) {
|
||||
errors.push(`${relative}: placeholder mismatch with English for ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errors.push(`missing required source file ${englishRelative}`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`Locize translation validation failed with ${errors.length} issue(s):`);
|
||||
for (const error of errors) console.error(`- ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Locize translation validation passed (${baseSet.size} baseline JSON files checked).`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue