From 411862d96cb6241ad3e2fbdfd21a4561d39ddbe4 Mon Sep 17 00:00:00 2001 From: strbit Date: Wed, 28 Aug 2024 18:11:29 +0500 Subject: [PATCH 01/14] feat: add translation splitting I guess you could say this is a "prototype" --- examples/deno.ts | 2 +- examples/locales/ru/cart.ftl | 8 +++++ examples/locales/ru/greeting.ftl | 1 + examples/locales/ru/language.ftl | 1 + src/deps.ts | 4 ++- src/i18n.ts | 39 +++++++++++++++++++++++-- src/types.ts | 21 ++++++++++++++ src/utils.ts | 50 +++++++++++++++++++++++++++++++- 8 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 examples/locales/ru/cart.ftl create mode 100644 examples/locales/ru/greeting.ftl create mode 100644 examples/locales/ru/language.ftl diff --git a/examples/deno.ts b/examples/deno.ts index f176e27b..f1b8701d 100644 --- a/examples/deno.ts +++ b/examples/deno.ts @@ -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")); diff --git a/examples/locales/ru/cart.ftl b/examples/locales/ru/cart.ftl new file mode 100644 index 00000000..6ce3ca27 --- /dev/null +++ b/examples/locales/ru/cart.ftl @@ -0,0 +1,8 @@ +cart = { $first_name }, у вас { + $apples -> + [0] нет яблок + [one] одно яблоко + *[other] { $apples } яблок + } в корзине. + +checkout = Спасибо за покупку! diff --git a/examples/locales/ru/greeting.ftl b/examples/locales/ru/greeting.ftl new file mode 100644 index 00000000..6a428837 --- /dev/null +++ b/examples/locales/ru/greeting.ftl @@ -0,0 +1 @@ +greeting = Привет { $first_name }! diff --git a/examples/locales/ru/language.ftl b/examples/locales/ru/language.ftl new file mode 100644 index 00000000..e57621a1 --- /dev/null +++ b/examples/locales/ru/language.ftl @@ -0,0 +1 @@ +language-set = Язык был изменен на Русский! diff --git a/src/deps.ts b/src/deps.ts index b91deb16..ba51db0c 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -12,4 +12,6 @@ export { type MiddlewareFn, } from "https://lib.deno.dev/x/grammy@1.x/mod.ts"; -export { extname, resolve } from "https://deno.land/std@0.192.0/path/mod.ts"; +export { extname, resolve, join, SEP } from "https://deno.land/std@0.192.0/path/mod.ts"; + +export { walk, walkSync, type WalkEntry} from "https://deno.land/std@0.224.0/fs/walk.ts"; diff --git a/src/i18n.ts b/src/i18n.ts index ab9704f8..57d7036d 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -13,7 +13,11 @@ import type { TranslateFunction, TranslationVariables, } from "./types.ts"; -import { readLocalesDir, readLocalesDirSync } from "./utils.ts"; +import { + readLocalesDir, + readLocalesDirSync, + readNestedLocalesDirSync, +} from "./utils.ts"; export class I18n { private config: I18nConfig; @@ -24,7 +28,11 @@ export class I18n { this.config = { defaultLocale: "en", ...config }; this.fluent = new Fluent(this.config.fluentOptions); if (config.directory) { - this.loadLocalesDirSync(config.directory); + if (config.useNestedTranslations) { + this.loadNestedLocalesDirSync(config.directory); + } else { + this.loadLocalesDirSync(config.directory); + } } } @@ -61,6 +69,33 @@ export class I18n { } } + /** + * 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. + */ + // async loadNestedLocalesDir(directory: string): Promise { + // const localeFiles = await readNestedLocalesDir(directory); + // await Promise.all(localeFiles.map(async (file) => { + // await this.loadLocale(file.belongsTo, { + // source: file.translationSource, + // bundleOptions: this.config.fluentBundleOptions, + // }); + // })); + // } + + /** + * 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. + */ + loadNestedLocalesDirSync(directory: string): void { + for (const file of readNestedLocalesDirSync(directory)) { + this.loadLocaleSync(file.belongsTo, { + source: file.translationSource, + bundleOptions: this.config.fluentBundleOptions, + }); + } + } + /** * Registers a locale in the Fluent instance based on the provided options. * @param locale Locale ID diff --git a/src/types.ts b/src/types.ts index ef672752..febd7d07 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,11 @@ export type LoadLocaleOptions = FilepathOrSource & { bundleOptions?: FluentBundleOptions; }; +export type NestedTranslation = { + belongsTo: LocaleId; + translationSource: string; +}; + export interface FluentOptions { warningHandler?: WarningHandler; } @@ -89,6 +94,22 @@ export interface I18nConfig { * You must be using session with it. */ useSession?: boolean; + /** + * To prevent any breaking changes, this configuration allows translations to be split into different files across the directory from which they are loaded from. + * It is disabled by default. An overview of how split translations can be used is shown as an example below. + * + * @example + * ```txt + * . + * └── locales/ + * └── en/ + * ├── dialogues/ + * │ ├── greeting.ftl + * │ └── goodbye.ftl + * └── help.ftl + * ``` + */ + useNestedTranslations?: boolean; /** Configuration for the Fluent instance used internally. */ fluentOptions?: FluentOptions; /** Bundle options to use when adding a translation to the Fluent instance. */ diff --git a/src/utils.ts b/src/utils.ts index b7898fcd..cbac31f2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ -import { extname } from "./deps.ts"; +import { extname, join, walkSync, SEP, WalkEntry } from "./deps.ts"; +import { NestedTranslation } from "./types.ts"; export async function readLocalesDir(path: string): Promise { const files = new Array(); @@ -21,3 +22,50 @@ export function readLocalesDirSync(path: string): string[] { } return files; } + +export function readNestedLocalesDirSync(path: string): NestedTranslation[] { + const files = new Array(); + const filtered = new Array(); + const locales = new Array(); + + function readAndPushFile(translation: WalkEntry): void { + const extention = extname(translation.name); + if (translation.isFile && extention === ".ftl") { + const decoder = new TextDecoder("utf-8"); + const locale = translation.path.split(SEP)[1].split(".")[0]; + const filePath = join( + Deno.cwd(), + path, + translation.path.replace(path, ""), + ); + const contents = decoder.decode(Deno.readFileSync(filePath)); + + if (contents.length === 0) { + throw new Error( + `The translation file '${translation.name}' resulted in an empty string during file read, which means that \ +the file is most likely empty. Please add atleast one (1) translation key to this file (or simply just delete it) to solve \ +this error. Restart your bot once you have fixed this issue.`, + ); + } + + files.push({ + belongsTo: locale, + translationSource: contents, + }); + } + } + for (const dirEntry of walkSync(path)) readAndPushFile(dirEntry); + for (const file of files) { + const locale = file.belongsTo; + if (locales.indexOf(locale) === -1) locales.push(locale); + } + for (const locale of locales) { + const sameLocale = files.filter((file) => file.belongsTo === locale); + const sourceOnly = sameLocale.map((match) => match.translationSource); + filtered.push({ + belongsTo: locale, + translationSource: sourceOnly.join("\n"), + }); + } + return filtered; +} From c57609475f16e1120286f46c8643920f7024b15b Mon Sep 17 00:00:00 2001 From: strbit Date: Wed, 28 Aug 2024 18:24:17 +0500 Subject: [PATCH 02/14] feat: add documentation --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index deb8b0eb..44a6900b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ import { I18n, I18nFlavor } from "https://deno.land/x/grammy_i18n/mod.ts"; ## Example -Example project structure: +Example project structure using standard translations: ``` . @@ -30,6 +30,19 @@ Example project structure: └── bot.ts ``` +Or using nested translations. For this to work, you have to enable `useNestedTranslations` in your `i18n` instance. + +``` +. +├── locales/ +│ └── en/ +│ ├── dialogues/ +│ │ ├── greeting.ftl +│ │ └── goodbye.ftl +│ └── help.ftl +└── bot.ts +``` + Example bot [not using sessions](https://grammy.dev/plugins/i18n.html#without-sessions): @@ -45,6 +58,8 @@ type MyContext = Context & I18nFlavor; const i18n = new I18n({ defaultLocale: "en", directory: "locales", + // In order for the above example to work, this must be uncommented. + // useNestedTranslations: true, }); // Create a bot as usual, but use the modified Context type. From 210b16a12a609f6b3c0cf0f3740ab3006c3e3562 Mon Sep 17 00:00:00 2001 From: strbit Date: Thu, 29 Aug 2024 08:04:28 +0500 Subject: [PATCH 03/14] fix: update files --- README.md | 27 +++++++++------------------ src/deps.ts | 9 +++++++-- src/i18n.ts | 41 ++++------------------------------------- src/types.ts | 16 ---------------- src/utils.ts | 49 ++++++++++++++++--------------------------------- 5 files changed, 36 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index 44a6900b..3d9b671d 100644 --- a/README.md +++ b/README.md @@ -19,27 +19,18 @@ import { I18n, I18nFlavor } from "https://deno.land/x/grammy_i18n/mod.ts"; ## Example -Example project structure using standard translations: - -``` -. -├─ locales/ -│ ├── en.ftl -│ ├── it.ftl -│ └── ru.ftl -└── bot.ts -``` - -Or using nested translations. For this to work, you have to enable `useNestedTranslations` in your `i18n` instance. - +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. ``` . ├── locales/ -│ └── en/ -│ ├── dialogues/ -│ │ ├── greeting.ftl -│ │ └── goodbye.ftl -│ └── help.ftl +│ ├── en/ +│ │ ├── dialogues/ +│ │ │ ├── greeting.ftl +│ │ │ └── goodbye.ftl +│ │ └── help.ftl +│ ├── it.ftl +│ └── ru.ftl └── bot.ts ``` diff --git a/src/deps.ts b/src/deps.ts index ba51db0c..ae8c0290 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -12,6 +12,11 @@ export { type MiddlewareFn, } from "https://lib.deno.dev/x/grammy@1.x/mod.ts"; -export { extname, resolve, join, SEP } from "https://deno.land/std@0.192.0/path/mod.ts"; +export { + extname, + join, + resolve, + SEP, +} from "https://deno.land/std@0.192.0/path/mod.ts"; -export { walk, walkSync, type WalkEntry} from "https://deno.land/std@0.224.0/fs/walk.ts"; +export { walkSync } from "https://deno.land/std@0.224.0/fs/walk.ts"; diff --git a/src/i18n.ts b/src/i18n.ts index 57d7036d..58e538f1 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -16,7 +16,6 @@ import type { import { readLocalesDir, readLocalesDirSync, - readNestedLocalesDirSync, } from "./utils.ts"; export class I18n { @@ -28,17 +27,15 @@ export class I18n { this.config = { defaultLocale: "en", ...config }; this.fluent = new Fluent(this.config.fluentOptions); if (config.directory) { - if (config.useNestedTranslations) { - this.loadNestedLocalesDirSync(config.directory); - } else { - this.loadLocalesDirSync(config.directory); - } + this.loadLocalesDirSync(config.directory); } } /** * Loads locales from the specified directory and registers them in the Fluent instance. * @param directory Path to the directory to look for the translation files. + * + * @todo convert to support nested translations */ async loadLocalesDir(directory: string): Promise { const localeFiles = await readLocalesDir(directory); @@ -54,41 +51,11 @@ export class I18n { } /** - * 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, - bundleOptions: this.config.fluentBundleOptions, - }); - } - } - - /** - * 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. - */ - // async loadNestedLocalesDir(directory: string): Promise { - // const localeFiles = await readNestedLocalesDir(directory); - // await Promise.all(localeFiles.map(async (file) => { - // await this.loadLocale(file.belongsTo, { - // source: file.translationSource, - // bundleOptions: this.config.fluentBundleOptions, - // }); - // })); - // } - - /** - * 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. - */ - loadNestedLocalesDirSync(directory: string): void { - for (const file of readNestedLocalesDirSync(directory)) { this.loadLocaleSync(file.belongsTo, { source: file.translationSource, bundleOptions: this.config.fluentBundleOptions, diff --git a/src/types.ts b/src/types.ts index febd7d07..fcc88154 100644 --- a/src/types.ts +++ b/src/types.ts @@ -94,22 +94,6 @@ export interface I18nConfig { * You must be using session with it. */ useSession?: boolean; - /** - * To prevent any breaking changes, this configuration allows translations to be split into different files across the directory from which they are loaded from. - * It is disabled by default. An overview of how split translations can be used is shown as an example below. - * - * @example - * ```txt - * . - * └── locales/ - * └── en/ - * ├── dialogues/ - * │ ├── greeting.ftl - * │ └── goodbye.ftl - * └── help.ftl - * ``` - */ - useNestedTranslations?: boolean; /** Configuration for the Fluent instance used internally. */ fluentOptions?: FluentOptions; /** Bundle options to use when adding a translation to the Fluent instance. */ diff --git a/src/utils.ts b/src/utils.ts index cbac31f2..0936d9a1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,7 @@ -import { extname, join, walkSync, SEP, WalkEntry } from "./deps.ts"; +import { extname, join, SEP, walkSync } from "./deps.ts"; import { NestedTranslation } from "./types.ts"; +// todo: convert to support nested translations export async function readLocalesDir(path: string): Promise { const files = new Array(); for await (const entry of Deno.readDir(path)) { @@ -12,56 +13,38 @@ export async function readLocalesDir(path: string): Promise { return files; } -export function readLocalesDirSync(path: string): string[] { - const files = new Array(); - for (const entry of Deno.readDirSync(path)) { - if (!entry.isFile) continue; - const extension = extname(entry.name); - if (extension !== ".ftl") continue; - files.push(entry.name); - } - return files; -} - -export function readNestedLocalesDirSync(path: string): NestedTranslation[] { +export function readLocalesDirSync(path: string): NestedTranslation[] { const files = new Array(); const filtered = new Array(); const locales = new Array(); - function readAndPushFile(translation: WalkEntry): void { - const extention = extname(translation.name); - if (translation.isFile && extention === ".ftl") { + for (const entry of walkSync(path)) { + const extension = extname(entry.name); + if (entry.isFile && extension === ".ftl") { const decoder = new TextDecoder("utf-8"); - const locale = translation.path.split(SEP)[1].split(".")[0]; - const filePath = join( - Deno.cwd(), - path, - translation.path.replace(path, ""), - ); - const contents = decoder.decode(Deno.readFileSync(filePath)); + const filePath = join(path, entry.path.replace(path, "")); + const contents = Deno.readFileSync(filePath); - if (contents.length === 0) { + if (!contents) { throw new Error( - `The translation file '${translation.name}' resulted in an empty string during file read, which means that \ -the file is most likely empty. Please add atleast one (1) translation key to this file (or simply just delete it) to solve \ -this error. Restart your bot once you have fixed this issue.`, + `Translation file ${entry.name} resulted in an empty string, which means the file is most likely empty. \ +Please add at least one translation key to this file (or simply just delete it) to solve this error.`, ); } files.push({ - belongsTo: locale, - translationSource: contents, + belongsTo: entry.path.split(SEP)[1].split(".")[0], + translationSource: decoder.decode(contents), }); } } - for (const dirEntry of walkSync(path)) readAndPushFile(dirEntry); for (const file of files) { - const locale = file.belongsTo; - if (locales.indexOf(locale) === -1) locales.push(locale); + if (locales.indexOf(file.belongsTo) === -1) locales.push(file.belongsTo); } for (const locale of locales) { const sameLocale = files.filter((file) => file.belongsTo === locale); - const sourceOnly = sameLocale.map((match) => match.translationSource); + const sourceOnly = sameLocale.map((file) => file.translationSource); + filtered.push({ belongsTo: locale, translationSource: sourceOnly.join("\n"), From f8b0dc17ecbb472d2d572b653ff061428e0e2b4f Mon Sep 17 00:00:00 2001 From: strbit Date: Thu, 29 Aug 2024 08:29:22 +0500 Subject: [PATCH 04/14] feat: add async functions --- src/deps.ts | 9 ++------- src/i18n.ts | 21 ++++----------------- src/utils.ts | 48 +++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 45 insertions(+), 33 deletions(-) diff --git a/src/deps.ts b/src/deps.ts index ae8c0290..67d4a0af 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -12,11 +12,6 @@ export { type MiddlewareFn, } from "https://lib.deno.dev/x/grammy@1.x/mod.ts"; -export { - extname, - join, - resolve, - SEP, -} from "https://deno.land/std@0.192.0/path/mod.ts"; +export { extname, join, SEP } from "https://deno.land/std@0.192.0/path/mod.ts"; -export { walkSync } from "https://deno.land/std@0.224.0/fs/walk.ts"; +export { walk, walkSync } from "https://deno.land/std@0.224.0/fs/walk.ts"; diff --git a/src/i18n.ts b/src/i18n.ts index 58e538f1..a9e7ffc3 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,9 +1,4 @@ -import { - type Context, - type HearsContext, - type MiddlewareFn, - resolve, -} from "./deps.ts"; +import { type Context, type HearsContext, type MiddlewareFn } from "./deps.ts"; import { Fluent } from "./fluent.ts"; import type { I18nConfig, @@ -13,10 +8,7 @@ import type { TranslateFunction, TranslationVariables, } from "./types.ts"; -import { - readLocalesDir, - readLocalesDirSync, -} from "./utils.ts"; +import { readLocalesDir, readLocalesDirSync } from "./utils.ts"; export class I18n { private config: I18nConfig; @@ -34,17 +26,12 @@ export class I18n { /** * Loads locales from the specified directory and registers them in the Fluent instance. * @param directory Path to the directory to look for the translation files. - * - * @todo convert to support nested translations */ async loadLocalesDir(directory: string): Promise { 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, }); })); diff --git a/src/utils.ts b/src/utils.ts index 0936d9a1..f2819f59 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,16 +1,46 @@ -import { extname, join, SEP, walkSync } from "./deps.ts"; +import { extname, join, SEP, walk, walkSync } from "./deps.ts"; import { NestedTranslation } from "./types.ts"; -// todo: convert to support nested translations -export async function readLocalesDir(path: string): Promise { - const files = new Array(); - for await (const entry of Deno.readDir(path)) { - if (!entry.isFile) continue; +export async function readLocalesDir( + path: string, +): Promise { + const files = new Array(); + const filtered = new Array(); + const locales = new Array(); + + for await (const entry of walk(path)) { const extension = extname(entry.name); - if (extension !== ".ftl") continue; - files.push(entry.name); + if (entry.isFile && extension === ".ftl") { + const decoder = new TextDecoder("utf-8"); + const filePath = join(path, entry.path.replace(path, "")); + const contents = await Deno.readFile(filePath); + + if (!contents) { + throw new Error( + `Translation file ${entry.name} resulted in an empty string, which means the file is most likely empty. \ +Please add at least one translation key to this file (or simply just delete it) to solve this error.`, + ); + } + + files.push({ + belongsTo: entry.path.split(SEP)[1].split(".")[0], + translationSource: decoder.decode(contents), + }); + } + } + for (const file of files) { + if (locales.indexOf(file.belongsTo) === -1) locales.push(file.belongsTo); + } + for (const locale of locales) { + const sameLocale = files.filter((file) => file.belongsTo === locale); + const sourceOnly = sameLocale.map((file) => file.translationSource); + + filtered.push({ + belongsTo: locale, + translationSource: sourceOnly.join("\n"), + }); } - return files; + return filtered; } export function readLocalesDirSync(path: string): NestedTranslation[] { From f83ba72d8b8f5fa82a1940330513b729b68aa7e3 Mon Sep 17 00:00:00 2001 From: strbit Date: Thu, 29 Aug 2024 10:42:46 +0500 Subject: [PATCH 05/14] feat: better rewrite --- README.md | 2 - src/utils.ts | 101 +++++++++++++++++++++++++-------------------------- 2 files changed, 49 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 3d9b671d..d6f662b4 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,6 @@ type MyContext = Context & I18nFlavor; const i18n = new I18n({ defaultLocale: "en", directory: "locales", - // In order for the above example to work, this must be uncommented. - // useNestedTranslations: true, }); // Create a bot as usual, but use the modified Context type. diff --git a/src/utils.ts b/src/utils.ts index f2819f59..039cf533 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,84 +1,81 @@ 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 { const files = new Array(); - const filtered = new Array(); - const locales = new Array(); + const locales = new Set(); for await (const entry of walk(path)) { - const extension = extname(entry.name); - if (entry.isFile && extension === ".ftl") { - const decoder = new TextDecoder("utf-8"); - const filePath = join(path, entry.path.replace(path, "")); - const contents = await Deno.readFile(filePath); + if (entry.isFile && extname(entry.name) === ".ftl") { + try { + const decoder = new TextDecoder("utf-8"); + const filePath = join(path, entry.path.replace(path, "")); + const contents = Deno.readFileSync(filePath); - if (!contents) { - throw new Error( - `Translation file ${entry.name} resulted in an empty string, which means the file is most likely empty. \ -Please add at least one translation key to this file (or simply just delete it) to solve this error.`, - ); - } + const belongsTo = entry.path.split(SEP)[1].split(".")[0]; + const translationSource = decoder.decode(contents); - files.push({ - belongsTo: entry.path.split(SEP)[1].split(".")[0], - translationSource: decoder.decode(contents), - }); + files.push({ + belongsTo, + translationSource, + }); + locales.add(belongsTo); + } catch { + throwReadFileError(entry.path); + } } } - for (const file of files) { - if (locales.indexOf(file.belongsTo) === -1) locales.push(file.belongsTo); - } - for (const locale of locales) { + + return Array.from(locales).map((locale) => { const sameLocale = files.filter((file) => file.belongsTo === locale); const sourceOnly = sameLocale.map((file) => file.translationSource); - - filtered.push({ + return { belongsTo: locale, translationSource: sourceOnly.join("\n"), - }); - } - return filtered; + }; + }); } export function readLocalesDirSync(path: string): NestedTranslation[] { const files = new Array(); - const filtered = new Array(); - const locales = new Array(); + const locales = new Set(); for (const entry of walkSync(path)) { - const extension = extname(entry.name); - if (entry.isFile && extension === ".ftl") { - const decoder = new TextDecoder("utf-8"); - const filePath = join(path, entry.path.replace(path, "")); - const contents = Deno.readFileSync(filePath); + if (entry.isFile && extname(entry.name) === ".ftl") { + try { + const decoder = new TextDecoder("utf-8"); + const filePath = join(path, entry.path.replace(path, "")); + const contents = Deno.readFileSync(filePath); - if (!contents) { - throw new Error( - `Translation file ${entry.name} resulted in an empty string, which means the file is most likely empty. \ -Please add at least one translation key to this file (or simply just delete it) to solve this error.`, - ); - } + const belongsTo = entry.path.split(SEP)[1].split(".")[0]; + const translationSource = decoder.decode(contents); - files.push({ - belongsTo: entry.path.split(SEP)[1].split(".")[0], - translationSource: decoder.decode(contents), - }); + files.push({ + belongsTo, + translationSource, + }); + locales.add(belongsTo); + } catch { + throwReadFileError(entry.path); + } } } - for (const file of files) { - if (locales.indexOf(file.belongsTo) === -1) locales.push(file.belongsTo); - } - for (const locale of locales) { + + return Array.from(locales).map((locale) => { const sameLocale = files.filter((file) => file.belongsTo === locale); const sourceOnly = sameLocale.map((file) => file.translationSource); - - filtered.push({ + return { belongsTo: locale, translationSource: sourceOnly.join("\n"), - }); - } - return filtered; + }; + }); } From 1b16657a21f27139423097f117636503fe43cb96 Mon Sep 17 00:00:00 2001 From: strbit Date: Thu, 29 Aug 2024 12:20:25 +0500 Subject: [PATCH 06/14] fix: small changes --- README.md | 1 + examples/deno.ts | 4 ++++ examples/locales/ckb.ftl | 6 ++++++ examples/locales/de.ftl | 7 +++++++ examples/locales/en.ftl | 7 +++++++ examples/locales/ku.ftl | 6 ++++++ examples/locales/ru/multiline.ftl | 5 +++++ examples/node.ts | 6 +++++- 8 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 examples/locales/ru/multiline.ftl diff --git a/README.md b/README.md index d6f662b4..f6fec036 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ import { I18n, I18nFlavor } from "https://deno.land/x/grammy_i18n/mod.ts"; 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. + ``` . ├── locales/ diff --git a/examples/deno.ts b/examples/deno.ts index f1b8701d..4ba57873 100644 --- a/examples/deno.ts +++ b/examples/deno.ts @@ -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(); diff --git a/examples/locales/ckb.ftl b/examples/locales/ckb.ftl index c495dd15..8f2f11bb 100644 --- a/examples/locales/ckb.ftl +++ b/examples/locales/ckb.ftl @@ -2,3 +2,9 @@ greeting = سڵاو، { $first_name }! cart = سڵاو، { $first_name }، لە سەبەتەکەتدا{ $apples } سێو هەن. checkout = سپاس بۆ بازاڕیکردنەکەت! language-set = کوردی هەڵبژێردرا! +multiline = + ئەمەش نموونەی... + ئە + فرە هێڵی + پەیام + بۆ ئەوەی بزانین چۆن فۆرمات کراون! diff --git a/examples/locales/de.ftl b/examples/locales/de.ftl index c99062ec..19db75e7 100644 --- a/examples/locales/de.ftl +++ b/examples/locales/de.ftl @@ -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 sind! diff --git a/examples/locales/en.ftl b/examples/locales/en.ftl index fb1350e9..ba84c8a2 100644 --- a/examples/locales/en.ftl +++ b/examples/locales/en.ftl @@ -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! diff --git a/examples/locales/ku.ftl b/examples/locales/ku.ftl index 06419c44..b05ee8da 100644 --- a/examples/locales/ku.ftl +++ b/examples/locales/ku.ftl @@ -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! diff --git a/examples/locales/ru/multiline.ftl b/examples/locales/ru/multiline.ftl new file mode 100644 index 00000000..d2cc6c53 --- /dev/null +++ b/examples/locales/ru/multiline.ftl @@ -0,0 +1,5 @@ +multiline = + Это пример + многострочных + сообщений + чтобы увидеть, как они отформатированы! diff --git a/examples/node.ts b/examples/node.ts index 126e41c9..77b9ad66 100644 --- a/examples/node.ts +++ b/examples/node.ts @@ -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")); @@ -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(); From 9170c76771eb028f5598646c9d0975e08bd71f4f Mon Sep 17 00:00:00 2001 From: strbit Date: Thu, 29 Aug 2024 13:54:38 +0500 Subject: [PATCH 07/14] fix: fix for tests --- src/utils.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 039cf533..7d528be2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -18,10 +18,10 @@ export async function readLocalesDir( if (entry.isFile && extname(entry.name) === ".ftl") { try { const decoder = new TextDecoder("utf-8"); - const filePath = join(path, entry.path.replace(path, "")); - const contents = Deno.readFileSync(filePath); + const excludeRoot = entry.path.replace(path, ""); + const contents = await Deno.readFile(join(path, excludeRoot)); - const belongsTo = entry.path.split(SEP)[1].split(".")[0]; + const belongsTo = excludeRoot.split(SEP)[1].split(".")[0]; const translationSource = decoder.decode(contents); files.push({ @@ -53,10 +53,10 @@ export function readLocalesDirSync(path: string): NestedTranslation[] { if (entry.isFile && extname(entry.name) === ".ftl") { try { const decoder = new TextDecoder("utf-8"); - const filePath = join(path, entry.path.replace(path, "")); - const contents = Deno.readFileSync(filePath); + const excludeRoot = entry.path.replace(path, ""); + const contents = Deno.readFileSync(join(path, excludeRoot)); - const belongsTo = entry.path.split(SEP)[1].split(".")[0]; + const belongsTo = excludeRoot.split(SEP)[1].split(".")[0]; const translationSource = decoder.decode(contents); files.push({ From 15e7d4608a0e0b2947ba0749c6d77b2e86390598 Mon Sep 17 00:00:00 2001 From: strbit Date: Wed, 11 Sep 2024 16:40:51 +0500 Subject: [PATCH 08/14] fix: add missing properties --- tests/deps.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/deps.ts b/tests/deps.ts index f4a687a6..ebb438df 100644 --- a/tests/deps.ts +++ b/tests/deps.ts @@ -31,6 +31,8 @@ export class Chats { 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(() => { From 2fc7c86257db6d7f61347bb2da9c4f1bb8863bbd Mon Sep 17 00:00:00 2001 From: strbit Date: Wed, 11 Sep 2024 18:08:16 +0500 Subject: [PATCH 09/14] fix: add comma --- tests/deps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/deps.ts b/tests/deps.ts index ebb438df..cbe754ac 100644 --- a/tests/deps.ts +++ b/tests/deps.ts @@ -32,7 +32,7 @@ export class Chats { can_read_all_group_messages: false, supports_inline_queries: false, can_connect_to_business: false, - has_main_web_app: false + has_main_web_app: false, }; this.bot.api.config.use(() => { From 76deb156d336c371a4ec73b6b7994ac4c9acf983 Mon Sep 17 00:00:00 2001 From: strbit Date: Wed, 11 Sep 2024 22:56:49 +0500 Subject: [PATCH 10/14] fix: small changes --- examples/locales/de.ftl | 2 +- src/deps.ts | 2 +- src/i18n.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/locales/de.ftl b/examples/locales/de.ftl index 19db75e7..a0bc5725 100644 --- a/examples/locales/de.ftl +++ b/examples/locales/de.ftl @@ -16,4 +16,4 @@ multiline = eine mehrzeilige Nachricht, - um zu sehen, wie sie formatiert sind! + um zu sehen, wie sie formatiert ist! diff --git a/src/deps.ts b/src/deps.ts index 67d4a0af..2fa195ee 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -14,4 +14,4 @@ export { export { extname, join, SEP } from "https://deno.land/std@0.192.0/path/mod.ts"; -export { walk, walkSync } from "https://deno.land/std@0.224.0/fs/walk.ts"; +export { walk, walkSync } from "https://deno.land/std@0.192.0/fs/walk.ts"; diff --git a/src/i18n.ts b/src/i18n.ts index a9e7ffc3..05e9994b 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,4 +1,4 @@ -import { type Context, type HearsContext, type MiddlewareFn } from "./deps.ts"; +import type { Context, HearsContext, MiddlewareFn } from "./deps.ts"; import { Fluent } from "./fluent.ts"; import type { I18nConfig, From 284a8db5eea95eba2701cd89fee6987a04241c29 Mon Sep 17 00:00:00 2001 From: strbit Date: Thu, 12 Sep 2024 14:18:50 +0500 Subject: [PATCH 11/14] fix: small changes Changed `NestedTranslation` from `type` to `interface`. --- README.md | 2 ++ src/types.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f6fec036..e8db9f9a 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ Nested translations don't change how you use translation keys, so nothing should └── 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 the `locales/it.ftl` and `locales/en/dialogues/greeting.ftl` files can be retrieved by simply using `ctx.t("greeting")`. + Example bot [not using sessions](https://grammy.dev/plugins/i18n.html#without-sessions): diff --git a/src/types.ts b/src/types.ts index fcc88154..076583c5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,10 +19,10 @@ export type LoadLocaleOptions = FilepathOrSource & { bundleOptions?: FluentBundleOptions; }; -export type NestedTranslation = { +export interface NestedTranslation { belongsTo: LocaleId; translationSource: string; -}; +} export interface FluentOptions { warningHandler?: WarningHandler; From e0397135123355adb57149dd16a83cb61a0555fd Mon Sep 17 00:00:00 2001 From: strbit Date: Thu, 12 Sep 2024 14:54:12 +0500 Subject: [PATCH 12/14] fix: simplify sentence --- README.md | 2 +- src/utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e8db9f9a..b3e48847 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Nested translations don't change how you use translation keys, so nothing should └── 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 the `locales/it.ftl` and `locales/en/dialogues/greeting.ftl` files can be retrieved by simply using `ctx.t("greeting")`. +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")`. Example bot [not using sessions](https://grammy.dev/plugins/i18n.html#without-sessions): diff --git a/src/utils.ts b/src/utils.ts index 7d528be2..27195c60 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,7 +4,7 @@ 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.`, +If it is, please add at least one translation key to this file (or simply just delete it) to solve this error.`, ); } From 698588e7d6baa52e81c57b183f70f64766abf44a Mon Sep 17 00:00:00 2001 From: strbit Date: Thu, 12 Sep 2024 16:16:38 +0500 Subject: [PATCH 13/14] test: add support for split translations --- tests/utils.ts | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/tests/utils.ts b/tests/utils.ts index 4b14426f..44ff4724 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -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 }! @@ -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 }! @@ -34,7 +31,7 @@ cart = Привет { $name }, в твоей корзине { [0] нет яблок [one] {$apples} яблоко [few] {$apples} яблока - *[other] {$apples} яблок + *[other] {$apples} яблок }. checkout = Спасибо за покупку! @@ -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; } From 08b431fe613df0c2f2a7777202eb95316cfdad5e Mon Sep 17 00:00:00 2001 From: strbit Date: Mon, 16 Sep 2024 20:27:06 +0500 Subject: [PATCH 14/14] fix: rephrase example --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b3e48847..a2678f9c 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,7 @@ import { I18n, I18nFlavor } from "https://deno.land/x/grammy_i18n/mod.ts"; ## Example -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. +Below is an example featuring both nested (`locales/en/...`) and standard (`locales/it.ftl`) file structure variants. Nested translations allow you to seperate your keys into different files (making it easier to maintain larger projects) while also letting you use the standard variant at the same time. Using a nested file structure alongside the standard variant won't break any existing translations. ``` . @@ -35,7 +34,7 @@ Nested translations don't change how you use translation keys, so nothing should └── 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")`. +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/en.ftl` or `locales/en/dialogues/greeting.ftl` can be retrieved by simply using `ctx.t("greeting")`. Example bot [not using sessions](https://grammy.dev/plugins/i18n.html#without-sessions):