From 37b06a5b777ef465868810bdeef62bb30a1c3161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20T=2E?= <99520451+Vassiat@users.noreply.github.com> Date: Mon, 6 Oct 2025 12:15:05 -0400 Subject: [PATCH] [Refactor] Automate namespace collection for `en` locale (#4625) * create and use namespace-i18n-plugin.ts * Changes to src/utils.ts to ensure correct importing by Vite plugins and extraction of the amespaceMap constant to its own file. * Added more comments for create help a new namespace * create utils-plugins.ts and more docs * console info appearance * chore: handle merge conflicts * chore: run biome * add biome to namespace map dropped after merge --------- Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> --- src/plugins/i18n.ts | 117 ++------------------- src/plugins/utils-plugins.ts | 40 +++++++ src/plugins/vite/namespaces-i18n-plugin.ts | 103 ++++++++++++++++++ vite.config.ts | 3 +- 4 files changed, 153 insertions(+), 110 deletions(-) create mode 100644 src/plugins/utils-plugins.ts create mode 100644 src/plugins/vite/namespaces-i18n-plugin.ts diff --git a/src/plugins/i18n.ts b/src/plugins/i18n.ts index 724a14e33c5..11dd2467476 100644 --- a/src/plugins/i18n.ts +++ b/src/plugins/i18n.ts @@ -4,6 +4,7 @@ import i18next from "i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import HttpBackend from "i18next-http-backend"; import processor, { KoreanPostpositionProcessor } from "i18next-korean-postposition-processor"; +import { namespaceMap } from "./utils-plugins"; //#region Interfaces/Types @@ -89,20 +90,6 @@ const fonts: Array = [ }, ]; -/** maps namespaces that deviate from the file-name */ -const namespaceMap = { - titles: "trainer-titles", - moveTriggers: "move-trigger", - abilityTriggers: "ability-trigger", - battlePokemonForm: "pokemon-form-battle", - miscDialogue: "dialogue-misc", - battleSpecDialogue: "dialogue-final-boss", - doubleBattleDialogue: "dialogue-double-battle", - splashMessages: "splash-texts", - mysteryEncounterMessages: "mystery-encounter-texts", - biome: "biomes", -}; - //#region Functions async function initFonts(language: string | undefined) { @@ -136,6 +123,8 @@ function i18nMoneyFormatter(amount: any): string { return `@[MONEY]{${i18next.t("common:money", { amount })}}`; } +const nsEn: string[] = []; + //#region Exports /** @@ -157,7 +146,9 @@ export async function initI18n(): Promise { * Don't forget to declare new language in `supportedLngs` i18next initializer * * Q: How do I add a new namespace? - * A: To add a new namespace, create a new file in each language folder with the translations. + * A: To add a new namespace, create a new file .json in each language folder with the translations. + * The expected format for the file-name is kebab-case {@link https://developer.mozilla.org/en-US/docs/Glossary/Kebab_case} + * If you want the namespace name to be different from the file name, configure it in namespacemap.ts. * Then update the config file for that language in its locale directory * and the CustomTypeOptions interface in the @types/i18next.d.ts file. * @@ -209,100 +200,7 @@ export async function initI18n(): Promise { }, }, defaultNS: "menu", - ns: [ - "ability", - "abilityTriggers", - "arenaFlyout", - "arenaTag", - "battle", - "battleScene", - "battleInfo", - "battleMessageUiHandler", - "battlePokemonForm", - "battlerTags", - "berry", - "bgmName", - "biome", - "challenges", - "commandUiHandler", - "common", - "achv", - "dialogue", - "battleSpecDialogue", - "miscDialogue", - "doubleBattleDialogue", - "egg", - "fightUiHandler", - "filterBar", - "filterText", - "gameMode", - "gameStatsUiHandler", - "growth", - "menu", - "menuUiHandler", - "modifier", - "modifierType", - "move", - "nature", - "pokeball", - "pokedexUiHandler", - "pokemon", - "pokemonCategory", - "pokemonEvolutions", - "pokemonForm", - "pokemonInfo", - "pokemonInfoContainer", - "pokemonSummary", - "saveSlotSelectUiHandler", - "settings", - "splashMessages", - "starterSelectUiHandler", - "statusEffect", - "terrain", - "titles", - "trainerClasses", - "trainersCommon", - "trainerNames", - "tutorial", - "voucher", - "weather", - "partyUiHandler", - "modifierSelectUiHandler", - "moveTriggers", - "runHistory", - "mysteryEncounters/mysteriousChallengers", - "mysteryEncounters/mysteriousChest", - "mysteryEncounters/darkDeal", - "mysteryEncounters/fightOrFlight", - "mysteryEncounters/slumberingSnorlax", - "mysteryEncounters/trainingSession", - "mysteryEncounters/departmentStoreSale", - "mysteryEncounters/shadyVitaminDealer", - "mysteryEncounters/fieldTrip", - "mysteryEncounters/safariZone", - "mysteryEncounters/lostAtSea", - "mysteryEncounters/fieryFallout", - "mysteryEncounters/theStrongStuff", - "mysteryEncounters/thePokemonSalesman", - "mysteryEncounters/anOfferYouCantRefuse", - "mysteryEncounters/delibirdy", - "mysteryEncounters/absoluteAvarice", - "mysteryEncounters/aTrainersTest", - "mysteryEncounters/trashToTreasure", - "mysteryEncounters/berriesAbound", - "mysteryEncounters/clowningAround", - "mysteryEncounters/partTimer", - "mysteryEncounters/dancingLessons", - "mysteryEncounters/weirdDream", - "mysteryEncounters/theWinstrateChallenge", - "mysteryEncounters/teleportingHijinks", - "mysteryEncounters/bugTypeSuperfan", - "mysteryEncounters/funAndGames", - "mysteryEncounters/uncommonBreed", - "mysteryEncounters/globalTradeSystem", - "mysteryEncounters/theExpertPokemonBreeder", - "mysteryEncounterMessages", - ], + ns: nsEn, // assigned with #app/plugins/vite/namespaces-i18n-plugin.ts detection: { lookupLocalStorage: "prLang", }, @@ -324,6 +222,7 @@ export function getIsInitialized(): boolean { return isInitialized; } +// biome-ignore lint/style/noDefaultExport: necessary for i18next usage export default i18next; //#endregion diff --git a/src/plugins/utils-plugins.ts b/src/plugins/utils-plugins.ts new file mode 100644 index 00000000000..f0d98754b49 --- /dev/null +++ b/src/plugins/utils-plugins.ts @@ -0,0 +1,40 @@ +import path from "path"; // vite externalize in production, see https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility + +/** + * Maps namespaces that deviate from the file-name + * + * @remarks expects file-name as value and custom-namespace as key + */ +export const namespaceMap = { + titles: "trainer-titles", + moveTriggers: "move-trigger", + abilityTriggers: "ability-trigger", + battlePokemonForm: "pokemon-form-battle", + miscDialogue: "dialogue-misc", + battleSpecDialogue: "dialogue-final-boss", + doubleBattleDialogue: "dialogue-double-battle", + splashMessages: "splash-texts", + mysteryEncounterMessages: "mystery-encounter-texts", + biome: "biomes", +}; + +/** + * Swap the value with the key and the key with the value + * @param json type {[key: string]: string} + * @returns [value]: key + * + * @source {@link https://stackoverflow.com/a/23013726} + */ +export function objectSwap(json: { [key: string]: string }): { [value: string]: string } { + const ret = {}; + for (const key in json) { + ret[json[key]] = key; + } + return ret; +} + +export function isFileInsideDir(file: string, dir: string): boolean { + const filePath = path.normalize(file); + const dirPath = path.normalize(dir); + return filePath.startsWith(dirPath); +} diff --git a/src/plugins/vite/namespaces-i18n-plugin.ts b/src/plugins/vite/namespaces-i18n-plugin.ts new file mode 100644 index 00000000000..374c7aa855b --- /dev/null +++ b/src/plugins/vite/namespaces-i18n-plugin.ts @@ -0,0 +1,103 @@ +import fs from "fs"; +import path from "path"; +import { normalizePath, type Plugin as VitePlugin } from "vite"; +import "#app/plugins/utils-plugins"; +import { isFileInsideDir, namespaceMap, objectSwap } from "#app/plugins/utils-plugins"; +import { toCamelCase } from "#app/utils/strings"; + +const namespaceMapSwap = objectSwap(namespaceMap); + +/** + * Crawl a directory recursively for json files to return their name with camelCase format. + * Also if file is in directory returns format "dir/fileName" format + * @param dir - The directory to crawl + */ +function getNameSpaces(dir: string): string[] { + const namespace: string[] = []; + const files = fs.readdirSync(dir); + + for (const file of files) { + const filePath = path.join(dir, file); + const stat = fs.lstatSync(filePath); + + if (stat.isDirectory()) { + processDirectory(file, filePath, namespace); + } else if (path.extname(file) === ".json") { + processJsonFile(file, namespace); + } + } + + return namespace; +} + +function processDirectory(file: string, filePath: string, namespace: string[]) { + const subnamespace = getNameSpaces(filePath); + for (const subNameSpace of subnamespace) { + let ns = subNameSpace; + if (namespaceMapSwap[file.replace(".json", "")]) { + ns = namespaceMapSwap[file.replace(".json", "")]; + } else if (toCamelCase(file).replace(".json", "").startsWith("mysteryEncounters")) { + ns = subNameSpace.replace(/Dialogue$/, ""); + } + // format "directory/namespace" for namespace in folder + namespace.push(`${toCamelCase(file).replace(".json", "")}/${ns}`); + } +} + +function processJsonFile(file: string, namespace: string[]) { + let ns = toCamelCase(file).replace(".json", ""); + if (namespaceMapSwap[file.replace(".json", "")]) { + ns = namespaceMapSwap[file.replace(".json", "")]; + } + namespace.push(ns); +} + +export function LocaleNamespace(): VitePlugin { + const nsRelativePath = "./locales"; + const nsEn = nsRelativePath + "/en"; // Default namespace + let namespaces = getNameSpaces(nsEn); + const nsAbsolutePath = path.resolve(process.cwd(), nsRelativePath); + + return { + name: "namespaces-i18next", + buildStart() { + if (process.env.NODE_ENV === "production") { + console.log("Collect namespaces"); + } + }, + configureServer(server) { + const restartHandler = async (file: string, action: string) => { + /* + * If any JSON file in nsLocation is created/modified.. + * refresh the page to update the namespaces of i18next + */ + if (isFileInsideDir(file, nsAbsolutePath) && file.endsWith(".json")) { + const timestamp = new Date().toLocaleTimeString(); + const filePath = nsRelativePath.replace(/^\.\/(?=.*)/, "") + normalizePath(file.replace(nsAbsolutePath, "")); + console.info( + `${timestamp} \x1b[36m\x1b[1m[ns-plugin]\x1b[0m reloading page, \x1b[32m${filePath}\x1b[0m ${action}...`, + ); + + namespaces = getNameSpaces(nsEn); + server.moduleGraph.invalidateAll(); + server.ws.send({ + type: "full-reload", + }); + } + }; + + server.watcher + .on("change", file => restartHandler(file, "updated")) + .on("add", file => restartHandler(file, "added")) + .on("unlink", file => restartHandler(file, "removed")); + }, + transform: { + handler(code, id) { + if (id.endsWith("i18n.ts")) { + return code.replace("const nsEn = [];", `const nsEn = ${JSON.stringify(namespaces)};`); + } + return code; + }, + }, + }; +} diff --git a/vite.config.ts b/vite.config.ts index b1a0475925f..569c3786b79 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,10 +6,11 @@ import { defineConfig, loadEnv, type Rollup, type UserConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; +import { LocaleNamespace } from "./src/plugins/vite/namespaces-i18n-plugin"; import { minifyJsonPlugin } from "./src/plugins/vite/vite-minify-json-plugin"; export const defaultConfig: UserConfig = { - plugins: [tsconfigPaths(), minifyJsonPlugin(["images", "battle-anims"], true)], + plugins: [tsconfigPaths(), minifyJsonPlugin(["images", "battle-anims"], true), LocaleNamespace()], clearScreen: false, appType: "mpa", build: {