Files
Spoolman/client_v2/scripts/strip-empty-locales.mjs
Donkie a34d52a597 client_v2: fall back empty locale values to base language
Weblate writes an empty string for every untranslated key. Paraglide
compiles a present-but-empty value into a message function that returns
"", so the site rendered a blank instead of falling back to English. A
key that is absent from a locale, by contrast, is re-exported from the
base locale and falls back correctly.

Add scripts/strip-empty-locales.mjs to delete empty/whitespace-only
leaves from the non-base locales/*/common.json before paraglide compiles
them, turning "present-but-empty" into "absent". Idempotent, base en
untouched, key order and formatting preserved. Wired as predev/prebuild
so builds stay correct if empties reappear from a later Weblate sync;
deliberately not a lint/CI gate, since empties are a benign expected
input to be neutralised, not forbidden.

Cleans 125 existing empties across 10 locales.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-20 16:32:04 +02:00

115 lines
4.2 KiB
JavaScript

#!/usr/bin/env node
// Strip empty translation values from the non-base locale files before paraglide
// compiles them.
//
// node scripts/strip-empty-locales.mjs [--check]
//
// Why this exists
// ---------------
// Weblate writes an *empty string* for every key a translator hasn't filled in
// yet. Paraglide (via plugin-i18next) treats an empty string as a real, present
// translation: it compiles a message function that returns "" for that locale,
// so the site renders a blank instead of falling back to English. A key that is
// *absent* from a locale file, on the other hand, is re-exported from the base
// locale (`export { key } from "./en.js"`) and falls back correctly.
//
// So the fix is simply to delete empty (or whitespace-only) leaves from every
// non-base locale before compilation. This is a mechanical data-cleaning pass,
// not translation — it never invents or edits a real translation, it only drops
// the placeholder empties Weblate leaves behind. The base locale (en) is the
// source of truth and is left untouched.
//
// It runs automatically before `dev` and `build` (see package.json), so the
// compiled output is always correct even if empties reappear (e.g. from a future
// Weblate sync). Empties are a benign, expected input here — they're neutralised,
// not forbidden — so this is deliberately NOT a CI gate that would block partial
// translations. Pass `--check` for a read-only diagnostic that makes no changes
// and exits non-zero if any empty leaves remain.
import { readFileSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const SETTINGS = join(ROOT, 'project.inlang', 'settings.json');
const settings = JSON.parse(readFileSync(SETTINGS, 'utf8'));
const baseLocale = settings.baseLocale;
const locales = settings.locales ?? [];
// e.g. "./locales/{locale}/common.json"
const pathPattern = settings['plugin.inlang.i18next']?.pathPattern ?? './locales/{locale}/common.json';
const isEmpty = (v) => typeof v === 'string' && v.trim() === '';
/**
* Recursively drop empty string leaves and any object left empty as a result.
* Returns { value, removed }. Handles both the flat dotted-key files used today
* and nested objects, so it stays correct if the layout ever changes.
*/
function strip(node) {
if (Array.isArray(node) || node === null || typeof node !== 'object') {
return { value: node, removed: 0 };
}
const out = {};
let removed = 0;
for (const [key, value] of Object.entries(node)) {
if (isEmpty(value)) {
removed++;
continue;
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
const res = strip(value);
removed += res.removed;
// Drop objects that became empty once their empty leaves were removed.
if (Object.keys(res.value).length === 0) continue;
out[key] = res.value;
} else {
out[key] = value;
}
}
return { value: out, removed };
}
const checkMode = process.argv.includes('--check');
let totalRemoved = 0;
const offenders = [];
for (const locale of locales) {
if (locale === baseLocale) continue;
const file = join(ROOT, pathPattern.replace('{locale}', locale));
let raw;
try {
raw = readFileSync(file, 'utf8');
} catch {
continue; // locale declared but no file on disk yet — nothing to strip.
}
const { value, removed } = strip(JSON.parse(raw));
if (removed === 0) continue;
totalRemoved += removed;
if (checkMode) {
offenders.push(`${locale}: ${removed} empty value(s)`);
continue;
}
// Match the existing formatting: 4-space indent, trailing newline. Key order
// is preserved because JSON.stringify walks own-property insertion order.
writeFileSync(file, JSON.stringify(value, null, 4) + '\n', 'utf8');
console.log(`stripped ${removed} empty value(s) from ${locale}`);
}
if (checkMode) {
if (offenders.length) {
console.error('Empty translation values found (run `node scripts/strip-empty-locales.mjs`):');
for (const line of offenders) console.error(` ${line}`);
process.exit(1);
}
console.log('No empty translation values found.');
} else {
console.log(
totalRemoved === 0
? 'No empty translation values to strip.'
: `Stripped ${totalRemoved} empty value(s) total.`
);
}