Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow splitting translations #52

Merged
merged 14 commits into from
Sep 24, 2024
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,24 @@ import { I18n, I18nFlavor } from "https://deno.land/x/grammy_i18n/mod.ts";

## Example

Example project structure:
An example project structure, you can also seperate your translations into different files to make it easier to maintain large projects.
Nested translations don't change how you use translation keys, so nothing should break if you decide to use them.
EdJoPaTo marked this conversation as resolved.
Show resolved Hide resolved
strbit marked this conversation as resolved.
Show resolved Hide resolved

```
.
├─ locales/
│ ├── en.ftl
│ ├── it.ftl
│ └── ru.ftl
├── locales/
│ ├── en/
│ │ ├── dialogues/
│ │ │ ├── greeting.ftl
│ │ │ └── goodbye.ftl
│ │ └── help.ftl
│ ├── it.ftl
│ └── ru.ftl
└── bot.ts
```

By splitting translations you don't change how you retrieve the keys contained within them, so for example, a key called `greeting` which is located in either `locales/it.ftl` or `locales/en/dialogues/greeting.ftl` can be retrieved by simply using `ctx.t("greeting")`.
EdJoPaTo marked this conversation as resolved.
Show resolved Hide resolved

Example bot
[not using sessions](https://grammy.dev/plugins/i18n.html#without-sessions):

Expand Down
6 changes: 5 additions & 1 deletion examples/deno.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ bot.command("start", async (ctx) => {
await ctx.reply(ctx.t("greeting"));
});

bot.command(["en", "de", "ku", "ckb"], async (ctx) => {
bot.command(["en", "de", "ku", "ckb", "ru"], async (ctx) => {
const locale = ctx.msg.text.substring(1).split(" ")[0];
await ctx.i18n.setLocale(locale);
await ctx.reply(ctx.t("language-set"));
Expand All @@ -62,4 +62,8 @@ bot.command("checkout", async (ctx) => {
await ctx.reply(ctx.t("checkout"));
});

bot.command("multiline", async (ctx) => {
await ctx.reply(ctx.t("multiline"));
});

bot.start();
6 changes: 6 additions & 0 deletions examples/locales/ckb.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@ greeting = سڵاو، { $first_name }!
cart = سڵاو، { $first_name }، لە سەبەتەکەتدا{ $apples } سێو هەن.
checkout = سپاس بۆ بازاڕیکردنەکەت!
language-set = کوردی هەڵبژێردرا!
multiline =
ئەمەش نموونەی...
ئە
فرە هێڵی
پەیام
بۆ ئەوەی بزانین چۆن فۆرمات کراون!
7 changes: 7 additions & 0 deletions examples/locales/de.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@ cart = { $first_name }, es {
checkout = Danke für deinen Einkauf!

language-set = Die Sprache wurde zu Deutsch geändert!

multiline =
Dies ist ein Beispiel für
eine
mehrzeilige
Nachricht,
um zu sehen, wie sie formatiert ist!
7 changes: 7 additions & 0 deletions examples/locales/en.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@ cart = { $first_name }, there {
checkout = Thank you for purchasing!

language-set = Language has been set to English!

multiline =
This is an example of
a
multiline
message
to see how they are formatted!
6 changes: 6 additions & 0 deletions examples/locales/ku.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@ greeting = Silav, { $first_name }!
cart = { $first_name }, di sepeta te de { $apples } sêv hene.
checkout = Spas bo kirîna te!
language-set = Kurdî hate hilbijartin!
multiline =
Ev mînakek e
yek
multiline
agah
da ku bibînin ka ew çawa têne format kirin!
8 changes: 8 additions & 0 deletions examples/locales/ru/cart.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
cart = { $first_name }, у вас {
$apples ->
[0] нет яблок
[one] одно яблоко
*[other] { $apples } яблок
} в корзине.

checkout = Спасибо за покупку!
1 change: 1 addition & 0 deletions examples/locales/ru/greeting.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
greeting = Привет { $first_name }!
1 change: 1 addition & 0 deletions examples/locales/ru/language.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
language-set = Язык был изменен на Русский!
5 changes: 5 additions & 0 deletions examples/locales/ru/multiline.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
multiline =
Это пример
многострочных
сообщений
чтобы увидеть, как они отформатированы!
6 changes: 5 additions & 1 deletion examples/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ bot.command("start", async (ctx) => {
await ctx.reply(ctx.t("greeting"));
});

bot.command(["en", "de", "ku", "ckb"], async (ctx) => {
bot.command(["en", "de", "ku", "ckb", "ru"], async (ctx) => {
const locale = ctx.msg.text.substring(1).split(" ")[0];
await ctx.i18n.setLocale(locale);
await ctx.reply(ctx.t("language-set"));
Expand All @@ -58,4 +58,8 @@ bot.command("checkout", async (ctx) => {
await ctx.reply(ctx.t("checkout"));
});

bot.command("multiline", async (ctx) => {
await ctx.reply(ctx.t("multiline"));
});

bot.start();
4 changes: 3 additions & 1 deletion src/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ export {
type MiddlewareFn,
} from "https://lib.deno.dev/x/[email protected]/mod.ts";

export { extname, resolve } from "https://deno.land/[email protected]/path/mod.ts";
export { extname, join, SEP } from "https://deno.land/[email protected]/path/mod.ts";

export { walk, walkSync } from "https://deno.land/[email protected]/fs/walk.ts";
23 changes: 6 additions & 17 deletions src/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import {
type Context,
type HearsContext,
type MiddlewareFn,
resolve,
} from "./deps.ts";
import type { Context, HearsContext, MiddlewareFn } from "./deps.ts";
import { Fluent } from "./fluent.ts";
import type {
I18nConfig,
Expand Down Expand Up @@ -35,27 +30,21 @@ export class I18n<C extends Context = Context> {
async loadLocalesDir(directory: string): Promise<void> {
const localeFiles = await readLocalesDir(directory);
await Promise.all(localeFiles.map(async (file) => {
const path = resolve(directory, file);
const locale = file.substring(0, file.lastIndexOf("."));

await this.loadLocale(locale, {
filePath: path,
await this.loadLocale(file.belongsTo, {
source: file.translationSource,
bundleOptions: this.config.fluentBundleOptions,
});
}));
}

/**
* Loads locales from the specified directory and registers them in the Fluent instance.
* Loads locales from any existing nested file or folder within the specified directory and registers them in the Fluent instance.
* @param directory Path to the directory to look for the translation files.
*/
loadLocalesDirSync(directory: string): void {
for (const file of readLocalesDirSync(directory)) {
const path = resolve(directory, file);
const locale = file.substring(0, file.lastIndexOf("."));

this.loadLocaleSync(locale, {
filePath: path,
this.loadLocaleSync(file.belongsTo, {
source: file.translationSource,
bundleOptions: this.config.fluentBundleOptions,
});
}
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export type LoadLocaleOptions = FilepathOrSource & {
bundleOptions?: FluentBundleOptions;
};

export interface NestedTranslation {
belongsTo: LocaleId;
translationSource: string;
}

export interface FluentOptions {
warningHandler?: WarningHandler;
}
Expand Down
94 changes: 76 additions & 18 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,81 @@
import { extname } from "./deps.ts";

export async function readLocalesDir(path: string): Promise<string[]> {
const files = new Array<string>();
for await (const entry of Deno.readDir(path)) {
if (!entry.isFile) continue;
const extension = extname(entry.name);
if (extension !== ".ftl") continue;
files.push(entry.name);
import { extname, join, SEP, walk, walkSync } from "./deps.ts";
import { NestedTranslation } from "./types.ts";

function throwReadFileError(path: string) {
throw new Error(
`Something went wrong while reading the "${path}" file, usually, this can be caused by the file being empty. \
If it is, please add at least one translation key to this file (or simply just delete it) to solve this error.`,
);
}

export async function readLocalesDir(
path: string,
): Promise<NestedTranslation[]> {
const files = new Array<NestedTranslation>();
const locales = new Set<string>();

for await (const entry of walk(path)) {
if (entry.isFile && extname(entry.name) === ".ftl") {
try {
const decoder = new TextDecoder("utf-8");
const excludeRoot = entry.path.replace(path, "");
const contents = await Deno.readFile(join(path, excludeRoot));

const belongsTo = excludeRoot.split(SEP)[1].split(".")[0];
const translationSource = decoder.decode(contents);

files.push({
belongsTo,
translationSource,
});
locales.add(belongsTo);
} catch {
throwReadFileError(entry.path);
}
}
}
return files;

return Array.from(locales).map((locale) => {
const sameLocale = files.filter((file) => file.belongsTo === locale);
const sourceOnly = sameLocale.map((file) => file.translationSource);
return {
belongsTo: locale,
translationSource: sourceOnly.join("\n"),
};
});
}

export function readLocalesDirSync(path: string): string[] {
const files = new Array<string>();
for (const entry of Deno.readDirSync(path)) {
if (!entry.isFile) continue;
const extension = extname(entry.name);
if (extension !== ".ftl") continue;
files.push(entry.name);
export function readLocalesDirSync(path: string): NestedTranslation[] {
const files = new Array<NestedTranslation>();
const locales = new Set<string>();

for (const entry of walkSync(path)) {
if (entry.isFile && extname(entry.name) === ".ftl") {
try {
const decoder = new TextDecoder("utf-8");
const excludeRoot = entry.path.replace(path, "");
const contents = Deno.readFileSync(join(path, excludeRoot));

const belongsTo = excludeRoot.split(SEP)[1].split(".")[0];
const translationSource = decoder.decode(contents);

files.push({
belongsTo,
translationSource,
});
locales.add(belongsTo);
} catch {
throwReadFileError(entry.path);
}
}
}
return files;

return Array.from(locales).map((locale) => {
const sameLocale = files.filter((file) => file.belongsTo === locale);
const sourceOnly = sameLocale.map((file) => file.translationSource);
return {
belongsTo: locale,
translationSource: sourceOnly.join("\n"),
};
});
}
2 changes: 2 additions & 0 deletions tests/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export class Chats<C extends Context> {
can_join_groups: true,
can_read_all_group_messages: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
};

this.bot.api.config.use(() => {
Expand Down
38 changes: 27 additions & 11 deletions tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ import { join } from "./deps.ts";

export function makeTempLocalesDir() {
const dir = Deno.makeTempDirSync();
Deno.writeTextFileSync(
join(dir, "en.ftl"),
`hello = Hello!

const englishTranslation = `hello = Hello!

greeting = Hello, { $name }!

Expand All @@ -21,11 +20,9 @@ language =
.hint = Enter a language with the command
.invalid-locale = Invalid language
.already-set = Language is already set!
.language-set = Language set successfullY!`,
);
Deno.writeTextFileSync(
join(dir, "ru.ftl"),
`hello = Здравствуйте!
.language-set = Language set successfullY!`;

const russianTranslation = `hello = Здравствуйте!

greeting = Здравствуйте, { $name }!

Expand All @@ -34,7 +31,7 @@ cart = Привет { $name }, в твоей корзине {
[0] нет яблок
[one] {$apples} яблоко
[few] {$apples} яблока
*[other] {$apples} яблок
*[other] {$apples} яблок
}.

checkout = Спасибо за покупку!
Expand All @@ -43,7 +40,26 @@ language =
.hint = Отправьте язык после команды
.invalid-locale = Неверный язык
.already-set = Этот язык уже установлен!
.language-set = Язык успешно установлен!`,
);
.language-set = Язык успешно установлен!`;

function writeNestedFiles() {
const nestedPath = join(dir, "/ru/test/nested/");
const keys = russianTranslation.split(/\n\s*\n/);

Deno.mkdirSync(nestedPath, { recursive: true });

for (const key of keys) {
const fileName = key.split(" ")[0] + ".ftl";
const filePath = join(nestedPath, fileName);

Deno.writeTextFileSync(filePath, key);
}
}

// Using normal, singular translation files.
Deno.writeTextFileSync(join(dir, "en.ftl"), englishTranslation);
// Using split translation files.
writeNestedFiles();

return dir;
}
Loading