Skip to content
This repository was archived by the owner on Aug 1, 2025. It is now read-only.
Open

I18n #15

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"eslint-plugin-import": "^2.29.1",
"jest": "^29.7.0",
"prettier": "^3.2.4",
"swc": "^1.0.11",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
Expand Down
147 changes: 147 additions & 0 deletions src/i18n/classes/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {
FluentBundle,
FluentResource
} from "@fluent/bundle";
import { Collection } from 'discord.js';
import { readFile, readdir } from 'fs/promises';
import { join } from 'path';
import {
Locale, LocalizationMap, fluentVariables
} from "../types";
import { LocaleBundle } from "./locale";

export class i18n {
/**
* the fallback if presented locale is not present
*/
private _fallbackLocale?: Locale;

/**
* The global resource used in the case of Markdown
*/
private global?: FluentResource;

/**
* locales to call from
*/
private locales = new Collection<Locale, LocaleBundle>();

get fallbacklLocale() {
return this._fallbackLocale;
}

/**
* Gets the fall back Locale bundle
* @returns LocaleBundle for the fall back locale
*/
getFallbackLocale() {
return this.getLocale(this._fallbackLocale);
}

/**
* Set the gobal resource file
* @param filePath file path to the file in question
* @returns the i18n object
*/
async setGlobalResource(filePath:string) {
// get file
const file = await readFile(join(filePath, 'global.ftl'), { encoding: 'utf-8' });
// resovle file
this.global = new FluentResource(file);
return this;
}

async setLocale(filePath:string, locale: Locale) {
// get files
const files = (await readdir(filePath))
.filter((file) => file.endsWith('.ftl'));

const local = new LocaleBundle(this,locale);

// for each of the files creates a new FluentBundle
for (const file of files) {
const bundle = new FluentBundle(locale);
const resource = new FluentResource(await readFile(join(filePath, file), { encoding: 'utf-8' }));
// gets bundle's name from file name
const bundleName = file.slice(0, -4);

// adds globals if present
if (this.global) bundle.addResource(this.global);
bundle.addResource(resource);

local.addBundle(bundleName, bundle);

}

// Adds locale to collection
this.locales.set(locale, local);
return this;
}

/**
* set the fallback locale
* @param locale the locale to set
* @returns this
*/
setFallbackLocale(locale:Locale) {
this._fallbackLocale = locale;
return this;
}

/**
* Get the localeBundle
* @param locale the locale to get
* @returns LocaleBundle
*/
getLocale(locale:Locale) {
const hasLocale = this.locales.has(locale);
const hasFallbackLocale = this.locales.has(this._fallbackLocale);
let returnLocale:Locale;

// Return requested locale
if (hasLocale)
returnLocale = locale;

// Return fallback locale
else if (this._fallbackLocale && hasFallbackLocale)
returnLocale = this._fallbackLocale;

// Throw if fallback is not set
else if (!this._fallbackLocale)
throw Error('Fallback Locale not set');

// Throw if fallback is present but not added
else
throw Error('Fallback Locale not added to i18n');


return this.locales.get(returnLocale);

}

/**
* Translate and formate a key
* @param key key for the message to get
* @param bundleName the bundle wher it is located
* @param locale the locale to target
* @param options Additional options
* @returns The traslated and formated string
*/
t(key:string, bundleName:string, locale:Locale, options?: fluentVariables) {
return this.getLocale(locale).t(key,bundleName,options);
}

/**
* For Use with Discord.js command builder to localize commands
* @param key key to resolve
* @param bundleName name of the bundle where the key is
* @returns A map of the with the values of all added locale
* @see {@link https://discord-api-types.dev/api/discord-api-types-payloads/common#LocalizationMap}
*/
DiscordlocalizationRecord(key: string, bundleName: string): LocalizationMap {
const res: LocalizationMap = {};
for (const [ locale, obj ] of this.locales)
res[locale] = obj.t(key, bundleName);
return res;
}
}
160 changes: 160 additions & 0 deletions src/i18n/classes/locale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { FluentBundle, Message } from '@fluent/bundle';
import { Collection } from 'discord.js';
import {
Locale, common, fluentVariables
} from '../types';
import { i18n } from './i18n';

export class LocaleBundle {

/**
* Contains a collection of fluent bundles
*/
private bundles = new Collection<string, FluentBundle>();

/**
* Locale of this objects set at this of creation
*/
readonly locale: Locale;

/**
* notes if this object is the fallback locale for i18n
*/
readonly isFallback: boolean;

/**
* The host i18n object
*/
readonly i18n: i18n;

/**
*
* @param i18n Host i18n object
* @param locale The locale of this object
*/
constructor(i18n: i18n, locale:Locale) {
this.locale = locale;
this.i18n = i18n;
if(this.locale === i18n.fallbacklLocale) this.isFallback = true;
}

/**
* Add a fluent bundle to the locale bundle
* @param name The name of the bundle
* @param bundle The bundle you wish to add
* @returns The LocaleBundle
*/
addBundle(name: string, bundle:FluentBundle) {
this.bundles.set(name, bundle);
return this;
}

/**
* Add a fluent bundle as a comman bundle
* @param bundle The bundle you wish to add
* @returns The LocaleBundle
*/
setCommonBundle(bundle:FluentBundle) {
this.bundles.set(common,bundle);
return this;
}

/**
* get a message from bundle
* @param key the key of the message
* @param bundleName The name of the budle where the message should be retreved from
* @returns Fluent message
*/
private getMessageBundle(key:string, bundleName:string): { bundle: FluentBundle, message: Message } {
let bundle: FluentBundle | undefined;

// Checks for the bundle with the provided name
if (this.has(bundleName)) {
bundle = this.get(bundleName);
if(bundle.hasMessage(key))
return {
bundle,
message: bundle.getMessage(key)
};

}

// Checks comman file of this Locale
if(this.has(common)){
bundle = this.get(common);
if(bundle.hasMessage(key))
return {
bundle,
message: bundle.getMessage(key)
};

}

const fallback = this.i18n.getFallbackLocale();

// Checks if the fallback has a bundle of the fallback locale
if(fallback.has(bundleName)) {
bundle = fallback.get(bundleName);
if(bundle.hasMessage(key))
return {
bundle,
message: bundle.getMessage(key)
};

}

// Checks fallback common file
if(fallback.has(common)){
bundle = fallback.get(common);
if(bundle.hasMessage(key))
return {
bundle,
message: bundle.getMessage(key)
};

}
throw Error(`${key} not found in common in fallback locale`);
}

/**
* Check if bundle is present
* @param bundleName Name of the bundle to check
* @returns `true` or `false`
*/
has(bundleName:string) {
return this.bundles.has(bundleName);
}

/**
* Gets bundle
* @param bundleName Name of the bundle to get
* @returns FluentBundle
*/
get(bundleName:string) {
return this.bundles.get(bundleName);
}

/**
* Resove bundle key and variables
* @param key key of message to be resoved
* @param bundleName name of the bendle wich to pull from
* @param options veriables to be resoved
* @returns the resolved message as a string
*/
t(key:string, bundleName:string, options?: fluentVariables) {

// finds the message and retuens it with the budle where it was found
const { bundle, message } = this.getMessageBundle(key,bundleName);

const errors: Error[] = [];

// appliy formating
const res = bundle.formatPattern(message.value, options, errors);

// Returns if any errors occured
if (errors.length)
throw Error(`i18n - Errors with ${key}`, { cause: errors });

return res;
}
}
5 changes: 5 additions & 0 deletions src/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { i18n } from './classes/i18n';

export { LocaleBundle } from './classes/locale';

export { Locale, fluentVariables } from './types';
69 changes: 69 additions & 0 deletions src/i18n/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Localization

This folder contains an implementations of [Project Fluent](https://github.com/projectfluent/fluent.js)

## How to use

Creat the i18n object
```ts
import i18n from '@progressive-victory/client';
const localize = new i18n();
```
Then add Locales to the object
```ts
import {Locale} from 'discord.js'
async () => {
// Optional add gobal vaules across locals
await localize.setGlobalResource('./Path/Global.ftl')


await localize.setLocale(./Path/en-US, Locale.EnglishUS)
}
```
### Translation
Import localize from where it was created. To trasnlate there are to options:

First Full Translation

```ts
localize.t('key', 'bundlename', Locale.EnglishUS, options)
```

Staged Translation
```ts
const tLocale = localize.getLocale(Locale.EnglishUS)
tLocale.t('key', 'bundlename', options)
```
the option peramiter is for [Variables](https://projectfluent.org/fluent/guide/variables.html)

#### Debugging

If you see the following error:

```bash
[cause]: [
ReferenceError: Unknown variable: $channel
at resolveVariableReference (/d/bots/crm-bot/node_modules/@fluent/bundle/index.js:213:31)
at resolveExpression (/d/bots/crm-bot/node_modules/@fluent/bundle/index.js:181:24)
at resolveComplexPattern (/d/bots/crm-bot/node_modules/@fluent/bundle/index.js:349:25)
at FluentBundle.formatPattern (/d/bots/crm-bot/node_modules/@fluent/bundle/index.js:702:29)
at i18n.t (/d/bots/crm-bot/dist/i18n/i18n.js:104:28)
at t (/d/bots/crm-bot/dist/i18n/index.js:32:17)
at renameOrganizing (/d/bots/crm-bot/dist/structures/helpers.js:264:41)
at Event.onReady [as execute] (/d/bots/crm-bot/dist/events/ready.js:43:49)
at ExtendedClient.<anonymous> (/d/bots/crm-bot/dist/Client/Client.js:170:56)
at Object.onceWrapper (node:events:640:26)
]
```

Then you are missing `args` in your translation function.
In this case, the solution would be:

```ts
t({
key: 'vc-rename-error',
locale: channel.guild.preferredLocale,
ns: 'lead',
args: { channel: channel.name } // Add this object
})
```
Loading