web/i18n: Clean up locale scripts (#18163)

* web/i81n: Clean up locale scripts.

* Fix logs, clean up caching.

* Use previous script name.

* Fix path.
This commit is contained in:
Teffen Ellis
2025-11-18 18:25:00 +01:00
committed by GitHub
parent 0913b1050d
commit de1b7d7d81
10 changed files with 242 additions and 90 deletions
+183 -49
View File
@@ -11,93 +11,227 @@
* long spew of "this string is not translated" and replacing it with a
* summary of how many strings are missing with respect to the source locale.
*
* @import { ConfigFile } from "@lit/localize-tools/lib/types/config.js"
* @import { Stats } from "node:fs";
* @import { RuntimeOutputConfig } from "@lit/localize-tools/lib/types/modes.js"
*/
import { spawnSync } from "node:child_process";
import { readFileSync, statSync } from "node:fs";
import path from "node:path";
import * as fs from "node:fs/promises";
import path, { resolve } from "node:path";
import { generatePseudoLocaleModule } from "./pseudolocalize.mjs";
import { ConsoleLogger } from "#logger/node";
import { PackageRoot } from "#paths/node";
/**
* @type {ConfigFile}
*/
const localizeRules = JSON.parse(
readFileSync(path.join(PackageRoot, "lit-localize.json"), "utf-8"),
import { readConfigFileAndWriteSchema } from "@lit/localize-tools/lib/config.js";
import { RuntimeLitLocalizer } from "@lit/localize-tools/lib/modes/runtime.js";
//#region Setup
const missingMessagePattern = /([\w_-]+)\smessage\s(?:[\w_-]+)\sis\smissing/;
const logger = ConsoleLogger.child({ name: "Locales" });
const localizeRules = readConfigFileAndWriteSchema(path.join(PackageRoot, "lit-localize.json"));
if (localizeRules.interchange.format !== "xliff") {
logger.error("Unsupported interchange type, expected 'xliff'");
process.exit(1);
}
const XLIFFPath = resolve(PackageRoot, localizeRules.interchange.xliffDir);
const EmittedLocalesDirectory = resolve(
PackageRoot,
/** @type {string} */ (localizeRules.output.outputDir),
);
/**
*
* @param {string} loc
* @returns {boolean}
*/
function generatedFileIsUpToDateWithXliffSource(loc) {
const xliff = path.join("./xliff", `${loc}.xlf`);
const gened = path.join("./src/locales", `${loc}.ts`);
const targetLocales = localizeRules.targetLocales.filter((localeCode) => {
return localeCode !== "pseudo-LOCALE";
});
// Returns false if: the expected XLF file doesn't exist, The expected
// generated file doesn't exist, or the XLF file is newer (has a higher date)
// than the generated file. The missing XLF file is important enough it
// generates a unique error message and halts the build.
//#endregion
//#region Utilities
/**
* Cleans the emitted locales directory.
*/
async function cleanEmittedLocales() {
logger.info("♻️ Cleaning previously emitted locales...");
logger.info(`♻️ ${EmittedLocalesDirectory}`);
await fs.rm(EmittedLocalesDirectory, {
recursive: true,
force: true,
});
await fs.mkdir(EmittedLocalesDirectory, {
recursive: true,
});
logger.info(`♻️ Done!`);
}
/**
* Returns false if: the expected XLF file doesn't exist, The expected
* generated file doesn't exist, or the XLF file is newer (has a higher date)
* than the generated file. The missing XLF file is important enough it
* generates a unique error message and halts the build.
*
* @param {string} localeCode
* @returns {Promise<boolean>}
*/
async function checkIfEmittedFileCurrent(localeCode) {
const xliffPath = path.join(XLIFFPath, `${localeCode}.xlf`);
const emittedPath = path.join(EmittedLocalesDirectory, `${localeCode}.ts`);
/**
* @type {Stats}
*/
let xlfStat;
let xliffStat;
try {
xlfStat = statSync(xliff);
xliffStat = await fs.stat(xliffPath);
} catch (_error) {
console.error(`lit-localize expected '${loc}.xlf', but XLF file is not present`);
logger.error(`XLIFF source file missing for locale '${localeCode}': ${xliffPath}`);
process.exit(1);
}
/**
* @type {Stats}
*/
let genedStat;
let emittedStat;
// If the generated file doesn't exist, of course it's not up to date.
try {
genedStat = statSync(gened);
emittedStat = await fs.stat(emittedPath);
} catch (_error) {
return false;
}
// if the generated file is the same age or newer (date is greater) than the xliff file, it's
// Possible if the script was interrupted between clearing and generating.
if (emittedStat.size === 0) {
return false;
}
// If the emitted file is the same age or newer (date is greater) than the xliff file, it's
// presumed to have been generated by that file and is up-to-date.
return genedStat.mtimeMs >= xlfStat.mtimeMs;
return emittedStat.mtimeMs >= xliffStat.mtimeMs;
}
// For all the expected files, find out if any aren't up-to-date.
const upToDate = localizeRules.targetLocales.reduce(
(acc, loc) => acc && generatedFileIsUpToDateWithXliffSource(loc),
true,
);
/**
* Checks if all the locale source files are up-to-date with their XLIFF sources.
* @returns {Promise<boolean>}
*/
async function checkIfLocalesAreCurrent() {
logger.info("Reading locale configuration...");
if (!upToDate) {
const status = spawnSync("npm", ["run", "build-locales:build"], { encoding: "utf8" });
logger.info(`Checking ${targetLocales.length} source files...`);
// Count all the missing message warnings
const counts = status.stderr.split("\n").reduce((acc, line) => {
const match = /^([\w-]+) message/.exec(line);
if (!match) {
return acc;
let outOfDateCount = 0;
await Promise.all(
targetLocales.map(async (localeCode) => {
const current = await checkIfEmittedFileCurrent(localeCode);
if (!current) {
logger.info(`Locale '${localeCode}' is out-of-date.`);
outOfDateCount++;
}
}),
);
return outOfDateCount === 0;
}
export async function generateLocaleModules() {
logger.info("Updating pseudo-locale...");
await generatePseudoLocaleModule();
logger.info("Generating locale modules...");
/**
* @type {Map<string, number>}
*/
const localeWarnings = new Map();
const initialConsoleWarn = console.warn;
console.warn = (arg0, ...args) => {
if (typeof arg0 !== "string") {
initialConsoleWarn(arg0, ...args);
return;
}
acc.set(match[1], (acc.get(match[1]) || 0) + 1);
return acc;
}, new Map());
const locales = Array.from(counts.keys());
locales.sort();
const [, matchedLocale] = arg0.match(missingMessagePattern) || [];
const report = locales
.map((locale) => `Locale '${locale}' has ${counts.get(locale)} missing translations`)
if (matchedLocale) {
const count = localeWarnings.get(matchedLocale) || 0;
localeWarnings.set(matchedLocale, count + 1);
return;
}
initialConsoleWarn(arg0, ...args);
};
// @ts-expect-error: Type is too broad.
const localizer = new RuntimeLitLocalizer(localizeRules);
await localizer.build();
const report = Array.from(localeWarnings)
.filter(([, count]) => count)
.sort(([, totalsA], [, totalsB]) => {
return totalsB - totalsA;
})
.map(([locale, count]) => `${locale}: ${count.toLocaleString()}`)
.join("\n");
console.log(`Translation tables rebuilt.\n${report}\n`);
logger.info(`Missing translations:\n${report}`);
localizer.assertTranslationsAreValid();
logger.info("Complete.");
}
console.log("Locale ./src is up-to-date");
//#endregion
//#region Commands
async function delegateCommand() {
const command = process.argv[2];
switch (command) {
case "--clean":
return cleanEmittedLocales();
case "--check":
return checkIfLocalesAreCurrent();
case "--force":
return cleanEmittedLocales().then(generateLocaleModules);
}
const upToDate = await checkIfLocalesAreCurrent();
if (upToDate) {
logger.info("Locale is up-to-date!");
return;
}
logger.info("Locale ./src is out-of-date, rebuilding...");
return generateLocaleModules();
}
await delegateCommand()
.then(() => {
process.exit(0);
})
.catch((error) => {
logger.error(`Error during locale build: ${error}`);
process.exit(1);
});
//#endregion
+16 -6
View File
@@ -11,9 +11,12 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { PackageRoot } from "#paths/node";
import { isMain } from "@goauthentik/core/scripting/node";
import pseudolocale from "pseudolocale";
import { makeFormatter } from "@lit/localize-tools/lib/formatters/index.js";
@@ -22,6 +25,7 @@ import { TransformLitLocalizer } from "@lit/localize-tools/lib/modes/transform.j
const pseudoLocale = /** @type {Locale} */ ("pseudo-LOCALE");
const targetLocales = [pseudoLocale];
const __dirname = fileURLToPath(new URL(".", import.meta.url));
/**
* @type {ConfigFile}
@@ -59,10 +63,16 @@ const pseudoMessagify = (message) => ({
),
});
const localizer = new TransformLitLocalizer(config);
const { messages } = localizer.extractSourceMessages();
const translations = messages.map(pseudoMessagify);
const sorted = sortProgramMessages([...messages]);
const formatter = makeFormatter(config);
export async function generatePseudoLocaleModule() {
const localizer = new TransformLitLocalizer(config);
const { messages } = localizer.extractSourceMessages();
const translations = messages.map(pseudoMessagify);
const sorted = sortProgramMessages([...messages]);
const formatter = makeFormatter(config);
formatter.writeOutput(sorted, new Map([[pseudoLocale, translations]]));
await formatter.writeOutput(sorted, new Map([[pseudoLocale, translations]]));
}
if (isMain(import.meta)) {
generatePseudoLocaleModule();
}