Files
Haven/scripts/validate-locales.js
T
ancsemi 24be24db8f feat(#5357): auto-validate translation-only PRs
The ask was to decouple translations from the release cycle so
community translators don't have to wait on a maintainer to merge
strings. Loading translations from a CDN was offered, but pulling
untrusted JSON into the UI at runtime adds attack surface and breaks
the offline-friendly self-hosted story.

Instead, automate the merge path that already exists:

- New scripts/validate-locales.js checks every public/locales/*.json
  for JSON validity, _meta fields, and shape compatibility with
  en.json (same key paths, same value types). Missing keys are
  warnings (partial translations are OK, English is the fallback).
  Also runnable locally with 'npm run validate-locales'.
- New .github/workflows/translations-autovalidate.yml runs that
  validator on every PR that touches public/locales/**. If the PR is
  translations-only (no en.json, no code) and validation passes,
  it labels the PR 'translations-only' and turns on GitHub's
  auto-merge so it goes in as soon as required checks are green.
- Workflow runs on pull_request (not pull_request_target) so it
  cannot read secrets from fork PRs.
2026-05-23 23:31:04 -04:00

169 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
/**
* validate-locales.js (#5357)
* ----------------------------------------------------------------------
* Validates one or more `public/locales/*.json` files against the master
* English locale (`en.json`). Used by the translations auto-merge CI
* workflow (.github/workflows/translations-autovalidate.yml) and also
* runnable locally:
*
* node scripts/validate-locales.js # check all locales
* node scripts/validate-locales.js public/locales/fr.json
*
* Exit code 0 = pass, 1 = fail. Warnings (extra keys, missing keys) do
* NOT fail the run — only structural problems do, so partial translations
* are still mergeable.
*
* Checks performed:
* 1. File parses as JSON.
* 2. Top-level `_meta.locale`, `_meta.language`, `_meta.flag` exist
* and are strings (and `_meta.locale` matches the filename).
* 3. Every key that's present in the target has the same VALUE TYPE
* (string vs object) as the corresponding key in en.json. (i.e.
* no accidentally turning a string into an object or vice versa,
* which would break the i18n lookup at runtime.)
* 4. No keys reference paths that don't exist in en.json (warn — could
* indicate a typo or a stale string).
* 5. Reports missing keys (warn — fine for in-progress translations).
*
* IMPORTANT FOR FUTURE AGENTS:
* If you add new translation keys to en.json, the other locale files
* will fall behind (missing keys are reported as warnings, not errors).
* That's intentional so en.json can ship without blocking on every
* other language. The CI workflow only auto-merges PRs that touch
* non-en locales; PRs that touch en.json still need normal review.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const LOCALES_DIR = path.join(__dirname, '..', 'public', 'locales');
const MASTER_FILE = path.join(LOCALES_DIR, 'en.json');
function loadJson(file) {
const raw = fs.readFileSync(file, 'utf8');
return JSON.parse(raw); // throws on parse error — caught by caller
}
// Walk an object and yield "dot.path" -> typeof value pairs.
// Arrays are treated as terminal values (we store their type as 'array').
function* walk(obj, prefix = '') {
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
yield [prefix, Array.isArray(obj) ? 'array' : typeof obj];
return;
}
for (const k of Object.keys(obj)) {
const child = obj[k];
const next = prefix ? `${prefix}.${k}` : k;
if (child !== null && typeof child === 'object' && !Array.isArray(child)) {
yield* walk(child, next);
} else {
yield [next, Array.isArray(child) ? 'array' : typeof child];
}
}
}
function buildShape(obj) {
const map = new Map();
for (const [k, t] of walk(obj)) map.set(k, t);
return map;
}
function validateLocale(filePath, master) {
const result = { file: filePath, errors: [], warnings: [] };
let data;
try {
data = loadJson(filePath);
} catch (err) {
result.errors.push(`Invalid JSON: ${err.message}`);
return result;
}
// _meta requirements
if (!data || typeof data !== 'object') {
result.errors.push('Top-level value is not an object');
return result;
}
const meta = data._meta;
if (!meta || typeof meta !== 'object') {
result.errors.push('Missing _meta object');
} else {
for (const field of ['locale', 'language', 'flag']) {
if (typeof meta[field] !== 'string' || !meta[field].trim()) {
result.errors.push(`Missing or non-string _meta.${field}`);
}
}
const fname = path.basename(filePath, '.json');
if (typeof meta.locale === 'string' && meta.locale !== fname) {
result.errors.push(`_meta.locale "${meta.locale}" doesn't match filename "${fname}.json"`);
}
}
// Structure compatibility with master
const shape = buildShape(data);
for (const [k, t] of shape) {
if (k.startsWith('_meta')) continue;
if (!master.has(k)) {
result.warnings.push(`Unknown key "${k}" — not present in en.json (possible typo or stale)`);
continue;
}
const masterType = master.get(k);
if (masterType !== t) {
result.errors.push(`Key "${k}" type mismatch — en.json has "${masterType}", this file has "${t}"`);
}
}
// Missing keys (warn only)
let missing = 0;
for (const k of master.keys()) {
if (k.startsWith('_meta')) continue;
if (!shape.has(k)) missing++;
}
if (missing > 0) {
result.warnings.push(`${missing} keys missing from this locale (partial translation — OK, English will be used as fallback)`);
}
return result;
}
function main() {
let master;
try {
master = buildShape(loadJson(MASTER_FILE));
} catch (err) {
console.error(`FATAL: cannot load master locale ${MASTER_FILE}: ${err.message}`);
process.exit(2);
}
const argv = process.argv.slice(2);
let targets;
if (argv.length > 0) {
targets = argv.map(p => path.resolve(p));
} else {
targets = fs.readdirSync(LOCALES_DIR)
.filter(f => f.endsWith('.json') && f !== 'en.json')
.map(f => path.join(LOCALES_DIR, f));
}
let failed = 0;
for (const file of targets) {
const rel = path.relative(process.cwd(), file);
const res = validateLocale(file, master);
if (res.errors.length === 0) {
console.log(`✓ ${rel} (${res.warnings.length} warning${res.warnings.length === 1 ? '' : 's'})`);
} else {
failed++;
console.log(`✗ ${rel}`);
}
for (const w of res.warnings) console.log(` warn: ${w}`);
for (const e of res.errors) console.log(` ERROR: ${e}`);
}
if (failed > 0) {
console.error(`\n${failed} locale file(s) failed validation.`);
process.exit(1);
}
console.log(`\nAll ${targets.length} locale file(s) passed.`);
}
if (require.main === module) main();
module.exports = { validateLocale, buildShape };