diff --git a/web/.gitignore b/web/.gitignore index cb137929ab..73d8fe8068 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -2,6 +2,10 @@ # Created by https://www.gitignore.io/api/node # Edit at https://www.gitignore.io/?templates=node +#region Locale + +src/locales/*.ts + ### Node ### # Logs logs diff --git a/web/package.json b/web/package.json index 5407f40794..08aded4b17 100644 --- a/web/package.json +++ b/web/package.json @@ -5,12 +5,11 @@ "private": true, "scripts": { "build": "wireit", - "build-locales": "wireit", - "build-locales:build": "wireit", + "build-locales": "node scripts/build-locales.mjs", "build-proxy": "wireit", "build:sfe": "npm run build -w @goauthentik/web-sfe", - "esbuild:watch": "node scripts/build-web.mjs --watch", - "extract-locales": "wireit", + "bundler:watch": "node scripts/build-web.mjs --watch", + "extract-locales": "lit-localize extract", "format": "wireit", "lint": "eslint --fix .", "lint-check": "eslint --max-warnings 0 .", @@ -27,7 +26,7 @@ "test": "vitest", "test:e2e": "playwright test", "tsc": "wireit", - "watch": "run-s build-locales esbuild:watch" + "watch": "run-s build-locales bundler:watch" }, "type": "module", "exports": { @@ -55,6 +54,7 @@ }, "imports": { "#styles/*.css": "./src/styles/*.css", + "#styles/*": "./src/styles/*.js", "#common/*": "./src/common/*.js", "#elements/*.css": "./src/elements/*.css", "#elements/*": "./src/elements/*.js", @@ -244,25 +244,9 @@ "build-locales" ] }, - "build-locales:build": { - "command": "lit-localize build" - }, - "build-locales:repair": { + "locales:repair": { "command": "prettier --write ./src/locale-codes.ts" }, - "build-locales": { - "command": "node scripts/build-locales.mjs", - "files": [ - "./xliff/*.xlf" - ], - "output": [ - "./src/locales/*.ts", - "./src/locale-codes.ts" - ] - }, - "extract-locales": { - "command": "lit-localize extract" - }, "lint:components": { "command": "lit-analyzer src" }, diff --git a/web/scripts/build-locales.mjs b/web/scripts/build-locales.mjs index 4ed931af1a..fb2f646f21 100644 --- a/web/scripts/build-locales.mjs +++ b/web/scripts/build-locales.mjs @@ -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} + */ +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} + */ +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} + */ + 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 diff --git a/web/scripts/pseudolocalize.mjs b/web/scripts/pseudolocalize.mjs index 2e3cb1f10e..ca661e7203 100644 --- a/web/scripts/pseudolocalize.mjs +++ b/web/scripts/pseudolocalize.mjs @@ -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(); +} diff --git a/web/src/components/ak-status-label.ts b/web/src/components/ak-status-label.ts index 7d5dad9ba1..d584bc7a4d 100644 --- a/web/src/components/ak-status-label.ts +++ b/web/src/components/ak-status-label.ts @@ -2,7 +2,7 @@ import { AKElement } from "#elements/Base"; import Styles from "#components/ak-status-label.css"; -import { P4Disposition } from "src/styles/patternfly/constants"; +import { P4Disposition } from "#styles/patternfly/constants"; import { msg } from "@lit/localize"; import { html } from "lit"; diff --git a/web/src/elements/ak-locale-context/definitions.ts b/web/src/elements/ak-locale-context/definitions.ts index 543cf20b54..d710f8da07 100644 --- a/web/src/elements/ak-locale-context/definitions.ts +++ b/web/src/elements/ak-locale-context/definitions.ts @@ -1,15 +1,13 @@ import { AkLocale, LocaleRow } from "./types.js"; -import * as _enLocale from "#locales/en"; - import type { LocaleModule } from "@lit/localize"; import { msg } from "@lit/localize"; export const DEFAULT_FALLBACK = "en"; -const enLocale: LocaleModule = _enLocale; - -export { enLocale }; +export const enLocale: LocaleModule = { + templates: {}, +}; // NOTE: This table cannot be made any shorter, despite all the repetition of syntax. Bundlers look // for the `import` #a *string target* for doing alias substitution, so putting @@ -43,7 +41,7 @@ const debug: LocaleRow = [ // prettier-ignore const LOCALE_TABLE: LocaleRow[] = [ ["de", /^de([_-]|$)/i, () => msg("German"), () => import("#locales/de")], - ["en", /^en([_-]|$)/i, () => msg("English"), () => import("#locales/en")], + ["en", /^en([_-]|$)/i, () => msg("English"), () => Promise.resolve(enLocale)], ["es", /^es([_-]|$)/i, () => msg("Spanish"), () => import("#locales/es")], ["fr", /^fr([_-]|$)/i, () => msg("French"), () => import("#locales/fr")], ["it", /^it([_-]|$)/i, () => msg("Italian"), () => import("#locales/it")], diff --git a/web/src/elements/ak-locale-context/helpers.ts b/web/src/elements/ak-locale-context/helpers.ts index 89d6af000c..2a251ce434 100644 --- a/web/src/elements/ak-locale-context/helpers.ts +++ b/web/src/elements/ak-locale-context/helpers.ts @@ -17,17 +17,17 @@ export const LOCALES = RAW_LOCALES.map((locale) => locale.code === "en" ? { ...locale, locale: async () => enLocale } : locale, ); -export function getBestMatchLocale(locale: string): AkLocale | undefined { - return LOCALES.find((l) => l.match.test(locale)); +export function getBestMatchLocale(locale: string): AkLocale | null { + return LOCALES.find((l) => l.match.test(locale)) || null; } // This looks weird, but it's sensible: we have several candidates, and we want to find the first // one that has a supported locale. Then, from *that*, we have to extract that first supported // locale. -export function findSupportedLocale(candidates: string[]) { +export function findSupportedLocale(candidates: string[]): AkLocale | null { const candidate = candidates.find((candidate: string) => getBestMatchLocale(candidate)); - return candidate ? getBestMatchLocale(candidate) : undefined; + return candidate ? getBestMatchLocale(candidate) : null; } export function localeCodeFromUrl(param = "locale") { diff --git a/web/src/elements/ak-locale-context/types.ts b/web/src/elements/ak-locale-context/types.ts index 55147d2473..cd67286fa3 100644 --- a/web/src/elements/ak-locale-context/types.ts +++ b/web/src/elements/ak-locale-context/types.ts @@ -1,6 +1,11 @@ import type { LocaleModule } from "@lit/localize"; -export type LocaleRow = [string, RegExp, () => string, () => Promise]; +export type LocaleRow = [ + code: string, + pattern: RegExp, + label: () => string, + loader: () => Promise, +]; export type AkLocale = { code: string; diff --git a/web/src/locales/.gitignore b/web/src/locales/.gitignore deleted file mode 100644 index 6461deecd1..0000000000 --- a/web/src/locales/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.ts diff --git a/web/types/locale.d.ts b/web/types/locale.d.ts new file mode 100644 index 0000000000..6e569f6535 --- /dev/null +++ b/web/types/locale.d.ts @@ -0,0 +1,18 @@ +/** + * This module is used to satisfy imports from `#locales/*` which have either + * not yet been generated, or are missing. + * + * ```sh + * npm run build:locales + * ``` + */ + +declare module "#locales/*" { + /** + * If you see this, try running `npm run build:locales` to generate locale files. + */ + type MissingLocale = symbol & { readonly __brand?: never }; + + const missingLocale: MissingLocale; + export const templates: MissingLocale; +}