/// /** * @file Lit Localize build script. * * @remarks * Determines if all the Xliff translation source files are present and * if the Typescript source files generated from those sources are up-to-date. * * If they are not, it runs the locale building script, intercepting the * 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 { Stats } from "node:fs"; */ 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"; 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 outdatedMessagePattern = /([\w_-]+)\smessage\s(?:[\w_.-]+)\sdoes\snot\sexist/; 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 { sourceLocale } = localizeRules; localizeRules.targetLocales = localizeRules.targetLocales.filter((locale) => { return locale !== sourceLocale; }); const XLIFFPath = resolve(PackageRoot, localizeRules.interchange.xliffDir); const EmittedLocalesDirectory = resolve( PackageRoot, /** @type {string} */ (localizeRules.output.outputDir), ); const targetLocales = localizeRules.targetLocales.filter((localeCode) => { return localeCode !== "en-XA"; }); //#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} */ async function checkIfEmittedFileCurrent(localeCode) { const xliffPath = path.join(XLIFFPath, `${localeCode}.xlf`); const emittedPath = path.join(EmittedLocalesDirectory, `${localeCode}.ts`); /** * @type {Stats} */ let xliffStat; try { xliffStat = await fs.stat(xliffPath); } catch (_error) { logger.error(`XLIFF source file missing for locale '${localeCode}': ${xliffPath}`); process.exit(1); } /** * @type {Stats} */ let emittedStat; // If the generated file doesn't exist, of course it's not up to date. try { emittedStat = await fs.stat(emittedPath); } catch (_error) { return false; } // 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 emittedStat.mtimeMs >= xliffStat.mtimeMs; } /** * Checks if all the locale source files are up-to-date with their XLIFF sources. * @returns {Promise} */ async function checkIfLocalesAreCurrent() { logger.info("Reading locale configuration..."); logger.info(`Checking ${targetLocales.length} source files...`); 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} */ const missingTranslationWarnings = new Map(); /** * @type {Map} */ const outdatedTranslationWarnings = new Map(); const initialConsoleWarn = console.warn; console.warn = (arg0, ...args) => { if (typeof arg0 !== "string") { initialConsoleWarn(arg0, ...args); return; } const [, matchedMissingTranslation] = arg0.match(missingMessagePattern) || []; if (matchedMissingTranslation) { const count = missingTranslationWarnings.get(matchedMissingTranslation) || 0; missingTranslationWarnings.set(matchedMissingTranslation, count + 1); logger.debug(arg0); return; } const [, matchedOutdatedTranslation] = arg0.match(outdatedMessagePattern) || []; if (matchedOutdatedTranslation) { const count = outdatedTranslationWarnings.get(matchedOutdatedTranslation) || 0; outdatedTranslationWarnings.set(matchedOutdatedTranslation, count + 1); logger.debug(arg0); return; } initialConsoleWarn(arg0, ...args); }; // @ts-expect-error: Type is too broad. const localizer = new RuntimeLitLocalizer(localizeRules); await localizer.build(); const missingTranslationsReport = Array.from(missingTranslationWarnings) .filter(([, count]) => count) .sort(([, totalsA], [, totalsB]) => { return totalsB - totalsA; }) .map(([locale, count]) => `${locale}: ${count.toLocaleString()}`) .join("\n"); logger.info(`Missing translations:\n${missingTranslationsReport || "None"}`); const outdatedTranslationsReport = Array.from(outdatedTranslationWarnings) .filter(([, count]) => count) .sort(([, totalsA], [, totalsB]) => { return totalsB - totalsA; }) .map(([locale, count]) => `${locale}: ${count.toLocaleString()}`) .join("\n"); logger.info(`Outdated translations:\n${outdatedTranslationsReport || "None"}`); localizer.assertTranslationsAreValid(); logger.info("Complete."); } //#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