diff --git a/README.md b/README.md index aad68af..19fa6e4 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ So the main template remains clean and without bloat. - [ ] Mattermost Client - [ ] OCR Module - [ ] Twitch Module -- [ ] Discord-Music Module - [ ] Service Polling Module - [ ] Orizuru Module +- [x] i18n Module (internal) but you can also create your own modules, and publish them to npm. we'll try to mantain the name format as `udm-` diff --git a/config/example.index.js b/config/example.index.js index bd280c3..46890d5 100644 --- a/config/example.index.js +++ b/config/example.index.js @@ -25,6 +25,15 @@ module.exports = { discord: { prefix: "!", token: process.env.DISCORD_TOKEN, + activity: { + type: "PLAYING", + name: "UtilityDust development", + } + }, + //$StripStart + i18n: { + baseLanguage: "en", } + //$StripEnd } }; \ No newline at end of file diff --git a/lang/en/default.json b/lang/en/default.json new file mode 100644 index 0000000..f9fec2c --- /dev/null +++ b/lang/en/default.json @@ -0,0 +1,3 @@ +{ + "i18nModuleInitialized": "i18n Module has been initialized correctly" +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3791e4d..4a2ed06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "discord.js": "^14.14.1", "eventemitter2": "^6.4.9", "express": "^4.18.2", + "i18next": "^23.11.2", + "i18next-fs-backend": "^2.3.1", "mysql2": "^3.6.5", "node-schedule": "^2.1.1", "parzival": "^0.5.7", @@ -50,6 +52,17 @@ "node": ">=0.10.0" } }, + "node_modules/@babel/runtime": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz", + "integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -2690,6 +2703,33 @@ "ms": "^2.0.0" } }, + "node_modules/i18next": { + "version": "23.11.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.11.2.tgz", + "integrity": "sha512-qMBm7+qT8jdpmmDw/kQD16VpmkL9BdL+XNAK5MNbNFaf1iQQq35ZbPrSlqmnNPOSUY4m342+c0t0evinF5l7sA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-fs-backend": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-2.3.1.tgz", + "integrity": "sha512-tvfXskmG/9o+TJ5Fxu54sSO5OkY6d+uMn+K6JiUGLJrwxAVfer+8V3nU8jq3ts9Pe5lXJv4b1N7foIjJ8Iy2Gg==" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -3994,6 +4034,11 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index 83003ef..8d000a1 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "discord.js": "^14.14.1", "eventemitter2": "^6.4.9", "express": "^4.18.2", + "i18next": "^23.11.2", + "i18next-fs-backend": "^2.3.1", "mysql2": "^3.6.5", "node-schedule": "^2.1.1", "parzival": "^0.5.7", diff --git a/src/config/modules/i18n.ts b/src/config/modules/i18n.ts new file mode 100644 index 0000000..86fadb1 --- /dev/null +++ b/src/config/modules/i18n.ts @@ -0,0 +1,9 @@ +import { Parseable, ValidateProperty } from "parzival"; + +@Parseable() +export default class I18nConfig { + @ValidateProperty({ + type: "string", + }) + baseLanguage!: string; +} \ No newline at end of file diff --git a/src/config/modules/index.ts b/src/config/modules/index.ts index f79a6da..f3d9199 100644 --- a/src/config/modules/index.ts +++ b/src/config/modules/index.ts @@ -1,6 +1,7 @@ import { Parseable, ValidateProperty } from "parzival"; import DiscordConfig from "./discord"; //$StripStart +import I18nConfig from "./i18n"; //$StripEnd @Parseable() @@ -11,6 +12,13 @@ export default class ModuleConfigs { className: "DiscordConfig", }) discord!: DiscordConfig; + //$StripStart + @ValidateProperty({ + type: "object", + recurse: true, + className: "I18nConfig", + }) + i18n!: I18nConfig; //$StripEnd } \ No newline at end of file diff --git a/src/modules/i18n/hooks/failedLoading.ts b/src/modules/i18n/hooks/failedLoading.ts new file mode 100644 index 0000000..bcd7d34 --- /dev/null +++ b/src/modules/i18n/hooks/failedLoading.ts @@ -0,0 +1,6 @@ +import { debug, warn } from "@src/engine/utils/Logger"; +import { I18nModule } from "../module"; + +export default async (i18n: I18nModule, lng: string, ns: string, msg: string) => { + warn("Failed to load translation file", { lng, ns, msg }); +}; \ No newline at end of file diff --git a/src/modules/i18n/hooks/initialized.ts b/src/modules/i18n/hooks/initialized.ts new file mode 100644 index 0000000..6a66e81 --- /dev/null +++ b/src/modules/i18n/hooks/initialized.ts @@ -0,0 +1,6 @@ +import { debug } from "@src/engine/utils/Logger"; +import { I18nModule } from "../module"; + +export default async (i18n: I18nModule) => { + debug("I18n Module Initialized"); +}; \ No newline at end of file diff --git a/src/modules/i18n/hooks/loaded.ts b/src/modules/i18n/hooks/loaded.ts new file mode 100644 index 0000000..6e71c9c --- /dev/null +++ b/src/modules/i18n/hooks/loaded.ts @@ -0,0 +1,6 @@ +import { debug, info } from "@src/engine/utils/Logger"; +import { I18nModule } from "../module"; + +export default async (i18n: I18nModule) => { + info("I18n Loaded"); +} \ No newline at end of file diff --git a/src/modules/i18n/hooks/missingKey.ts b/src/modules/i18n/hooks/missingKey.ts new file mode 100644 index 0000000..84af112 --- /dev/null +++ b/src/modules/i18n/hooks/missingKey.ts @@ -0,0 +1,6 @@ +import { debug, warn } from "@src/engine/utils/Logger"; +import { I18nModule } from "../module"; + +export default async (i18n: I18nModule) => { + warn("Missing key in translation file") +}; \ No newline at end of file diff --git a/src/modules/i18n/index.ts b/src/modules/i18n/index.ts new file mode 100644 index 0000000..14826fb --- /dev/null +++ b/src/modules/i18n/index.ts @@ -0,0 +1,16 @@ +import Module from "@src/engine/modules"; +import { debug } from "@src/engine/utils/Logger"; +import { I18nModule } from "./module"; + + +export default { + name: "i18n", + hooksInnerPath: "hooks", + loadFunction: async (config) => { + return new I18nModule() + }, + initFunction: async (ctx, config) => { + await ctx.initialize(config) + debug(ctx.t("default:i18nModuleInitialized") as string) + } +} satisfies Module; \ No newline at end of file diff --git a/src/modules/i18n/module.ts b/src/modules/i18n/module.ts new file mode 100644 index 0000000..9b203b7 --- /dev/null +++ b/src/modules/i18n/module.ts @@ -0,0 +1,56 @@ +import I18nConfig from "@src/config/modules/i18n"; +import Module from "@src/engine/modules"; +import { debug } from "@src/engine/utils/Logger"; +import { getProcessPath } from "@src/engine/utils/Runtime"; +import { EventEmitter } from "events"; +import i18next, { i18n, TFunction } from "i18next"; +import Backend, { FsBackendOptions } from 'i18next-fs-backend'; +import path from "path"; + +const ReboundEvents = [ + "initialized", + "languageChanged", + "loaded", + "failedLoading", + "missingKey", +] as const + + +export class I18nModule extends EventEmitter { + private i18n: i18n + private fixedTranslators: Map = new Map() + constructor() { + super() + + this.i18n = i18next.createInstance() + for (const event of ReboundEvents) { + this.i18n.on(event, (...args) => { + this.emit(event, ...args) + }) + } + } + async initialize(config: I18nConfig) { + await this.i18n + .use(Backend) + .init({ + fallbackLng: config.baseLanguage, + ns: ["default"], + defaultNS: "default", + backend: { + loadPath: path.join(getProcessPath(), "/lang/{{lng}}/{{ns}}.json"), + addPath: path.join(getProcessPath(), "/lang/{{lng}}/{{ns}}.missing.json"), + } + }) + } + translateTo(key: string, lang: string, opts?: any) { + let translator = this.fixedTranslators.get(lang) + if (!translator) { + translator = this.i18n.getFixedT(lang) + this.fixedTranslators.set(lang, translator) + } + return translator(key, opts) + } + t(key: string, opts?: any) { + return this.i18n.t(key, opts) + } +} \ No newline at end of file