diff --git a/src/plugins/i18n.ts b/src/plugins/i18n.ts index 8ca9005096f..8c23af1896d 100644 --- a/src/plugins/i18n.ts +++ b/src/plugins/i18n.ts @@ -4,6 +4,7 @@ import LanguageDetector from "i18next-browser-languagedetector"; import HttpBackend from "i18next-http-backend"; import processor, { KoreanPostpositionProcessor } from "i18next-korean-postposition-processor"; import pkg from "../../package.json"; +import { namespaceMap } from "./utils-plugins"; //#region Interfaces/Types @@ -90,18 +91,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", -}; //#region Functions @@ -136,6 +125,8 @@ function i18nMoneyFormatter(amount: any): string { return `@[MONEY]{${i18next.t("common:money", { amount })}}`; } +const nsEn = []; + //#region Exports /** @@ -157,7 +148,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. * @@ -206,99 +199,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", - "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", }, diff --git a/src/plugins/utils-plugins.ts b/src/plugins/utils-plugins.ts new file mode 100644 index 00000000000..391aa01cfae --- /dev/null +++ b/src/plugins/utils-plugins.ts @@ -0,0 +1,50 @@ +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", +}; + +/** + * Transform a kebab-case string into a camelCase string + * @param str - The kebabCase string + * @returns A camelCase string + * + * @source {@link https://stackoverflow.com/a/23013726} + */ +export function kebabCaseToCamelCase(str: string): string { + return str.replace(/-./g, x => x[1].toUpperCase()); +} + +/** + * 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..59839f9bfe7 --- /dev/null +++ b/src/plugins/vite/namespaces-i18n-plugin.ts @@ -0,0 +1,102 @@ +import { normalizePath, type Plugin as VitePlugin } from "vite"; +import fs from "fs"; +import path from "path"; +import "#app/plugins/utils-plugins"; +import { objectSwap, namespaceMap, kebabCaseToCamelCase, isFileInsideDir } from "#app/plugins/utils-plugins"; + +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 (let i = 0; i < subnamespace.length; i++) { + let ns = subnamespace[i]; + if (namespaceMapSwap[file.replace(".json", "")]) { + ns = namespaceMapSwap[file.replace(".json", "")]; + } else if (kebabCaseToCamelCase(file).replace(".json", "").startsWith("mysteryEncounters")) { + ns = subnamespace[i].replace(/Dialogue$/, ""); + } + // format "directory/namespace" for namespace in folder + namespace.push(`${kebabCaseToCamelCase(file).replace(".json", "")}/${ns}`); + } +} + +function processJsonFile(file: string, namespace: string[]) { + let ns = kebabCaseToCamelCase(file).replace(".json", ""); + if (namespaceMapSwap[file.replace(".json", "")]) { + ns = namespaceMapSwap[file.replace(".json", "")]; + } + namespace.push(ns); +} + +export function LocaleNamespace(): VitePlugin { + const nsRelativePath = "./public/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 4b6ad687a0a..828e4d0fdf0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,10 @@ import { defineConfig, loadEnv, type Rollup, type UserConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { minifyJsonPlugin } from "./src/plugins/vite/vite-minify-json-plugin"; +import { LocaleNamespace } from "./src/plugins/vite/namespaces-i18n-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: {