diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 80976aba..00000000 --- a/.editorconfig +++ /dev/null @@ -1,8 +0,0 @@ -[*] -end_of_line = lf -charset = utf-8 -indent_style = space -indent_size = 2 -insert_final_newline = true -trim_trailing_whitespace = true -max_line_length = 80 \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index dae76163..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Build - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - uses: denoland/setup-deno@v1 - with: - deno-version: v1.x - - - name: Format - run: deno fmt --check - - - name: Lint - run: deno lint - - - name: Check - run: deno check src/mod.ts - - - name: Test - run: deno task test - - - name: Backport - run: deno task dnt 0.0.0-workflow.0 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 35524dc9..00000000 --- a/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -out/ -node_modules/ -package-lock.json -deno.lock diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 64546169..00000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "recommendations": [ - "denoland.vscode-deno", - "editorconfig.editorconfig" - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 1ffa96f9..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "deno.enable": true, - "deno.lint": true, - "[typescript]": { - "editor.defaultFormatter": "denoland.vscode-deno" - } -} diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 00000000..a8784809 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,60 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "lsp": { + "deno": { + "settings": { + "deno": { + "enable": true, + }, + }, + }, + "json-language-server": { + "settings": { + "json": { + "schemas": [ + { + "fileMatch": ["deno.json", "deno.jsonc"], + "url": "https://raw.githubusercontent.com/denoland/deno/refs/heads/main/cli/schemas/config-file.v1.json", + }, + { + "fileMatch": ["package.json"], + "url": "https://www.schemastore.org/package", + }, + ], + }, + }, + }, + }, + "languages": { + "JavaScript": { + "language_servers": [ + "deno", + "!typescript-language-server", + "!vtsls", + "!eslint", + ], + "formatter": "language_server", + }, + "TypeScript": { + "language_servers": [ + "deno", + "!typescript-language-server", + "!vtsls", + "!eslint", + ], + "formatter": "language_server", + }, + "TSX": { + "language_servers": [ + "deno", + "!typescript-language-server", + "!vtsls", + "!eslint", + ], + "formatter": "language_server", + }, + }, +} diff --git a/LICENSE b/LICENSE index 4a98ef82..ff510f05 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022-2024 Dunkan +Copyright (c) 2022-2026 Dunkan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md deleted file mode 100644 index a2678f9c..00000000 --- a/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# grammY i18n - -Internationalization plugin for [grammY](https://grammy.dev) based on [Project Fluent](https://projectfluent.org). -Check out [the official documentation](https://grammy.dev/plugins/i18n.html) for this plugin. - -## Installation - -Node.js - -```sh -npm install @grammyjs/i18n -``` - -Deno - -```ts -import { I18n, I18nFlavor } from "https://deno.land/x/grammy_i18n/mod.ts"; -``` - -## Example - -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. - -``` -. -├── 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/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): - -```ts -import { Bot, Context } from "https://deno.land/x/grammy/mod.ts"; -import { I18n, I18nFlavor } from "https://deno.land/x/grammy_i18n/mod.ts"; - -// For proper typings and auto-completions in IDEs, -// customize the `Context` using `I18nFlavor`. -type MyContext = Context & I18nFlavor; - -// Create a new I18n instance. -const i18n = new I18n({ - defaultLocale: "en", - directory: "locales", -}); - -// Create a bot as usual, but use the modified Context type. -const bot = new Bot(""); // <- Put your bot token here - -// Remember to register this middleware before registering -// your handlers. -bot.use(i18n); - -bot.command("start", async (ctx) => { - // Use the method `t` or `translate` from the context and pass - // in the message id (key) of the message you want to get. - await ctx.reply(ctx.t("greeting")); -}); - -// Start your bot -bot.start(); -``` - -See the [documentation](https://grammy.dev/plugins/i18n.html) and -[examples/](examples/) for more detailed examples. - -## Credits - -Thanks to... - -- Slava Fomin II ([@slavafomin](https://github.com/slavafomin)) for the Node.js implementation of the [original Fluent plugin](https://github.com/the-moebius/grammy-fluent) and the [better Fluent integration](https://github.com/the-moebius/fluent). -- Roj ([@roj1512](https://github.com/roj1512)) for the [Deno port](https://github.com/roj1512/fluent) of the original [@fluent/bundle](https://github.com/projectfluent/fluent.js/tree/master/fluent-bundle) and [@fluent/langneg](https://github.com/projectfluent/fluent.js/tree/master/fluent-langneg) packages. -- Dunkan ([@dcdunkan](https://github.com/dcdunkan)) for the [Deno port](https://github.com/dcdunkan/deno_fluent) of the [@moebius/fluent](https://github.com/the-moebius/fluent). -- And all the previous maintainers and contributors of this i18n plugin. diff --git a/adapters/fluent/adapter.ts b/adapters/fluent/adapter.ts new file mode 100644 index 00000000..0cb20f9d --- /dev/null +++ b/adapters/fluent/adapter.ts @@ -0,0 +1,167 @@ +import { createDebug } from "@grammyjs/debug"; +import { FluentBundle, FluentResource, type Message } from "@fluent/bundle"; +import type { + FormatAdapter, + Locales, + LocalesTypings, + MessageKey, + Messages, + ResourceLoadable, +} from "../../types.ts"; +import { isValidLocale } from "../../utilities.ts"; +import { negotiateLanguages } from "@fluent/langneg"; + +const debug = createDebug("grammy:i18n-fluent"); + +export type FluentPattern = Message["attributes"][string]; +export type FluentBundleOptions = ConstructorParameters[1]; +export interface ResourceOptions { + allowOverrides?: boolean; + bundleOptions?: Partial; +} +export interface FluentMessageKey { + id: string; + attr?: string; +} + +const DEFAULT_ALLOW_OVERRIDES = false; + +/** + * Official {@link FormatAdapter} for the Fluent syntax by Mozilla. This adapter + * also supports loading resources; hence this can be plugged in with the + * locales directory loading utilities for convenience. + * + * @see https://projectfluent.org/fluent/guide Syntax guide for Fluent syntax. + */ +export class FluentAdapter + implements FormatAdapter, ResourceLoadable { + // While FluentBundle-s are capable of being the carrier of more than one + // locales at a time, here each bundle can carry only one locale. + #bundles: Map; + + #locales: string[]; + + constructor( + private options?: { + /** + * Bundle options to be used when creating a Fluent bundle. This + * configuration is added to every bundle (each bundle is for each + * registered locale). This can be overridden by passing a different + * set of bundle options when loading a resource. + * + * One of the common usage of this option would be to load bundles + * with `useIsolating` set to false by default, to globally disable + * the Unicode Isolation done by Fluent, or to install custom Fluent + * functions. + */ + bundleOptions?: FluentBundleOptions; + }, + ) { + this.#bundles = new Map(); + this.#locales = []; + } + + get locales(): string[] { + return this.#locales; + } + + loadResource( + locale: string, + source: string, + resourceOptions?: ResourceOptions, + ): Error[] { + if (!isValidLocale(locale)) + throw new Error(`The locale ${locale} seems invalid.`); + + let bundle: FluentBundle | undefined = this.#bundles.get(locale); + if (bundle == null || !(bundle instanceof FluentBundle)) { + bundle = new FluentBundle(locale, { + ...this.options?.bundleOptions, + ...resourceOptions?.bundleOptions, + }); + debug(`Creating a bundle for the locale '${locale}'`); + this.#bundles.set(locale, bundle); + + for (const locale of bundle.locales) + if (!this.#locales.includes(locale)) + this.#locales.push(locale); + } + + const resource = new FluentResource(source); + const errors = bundle.addResource(resource, { + allowOverrides: resourceOptions?.allowOverrides ?? + DEFAULT_ALLOW_OVERRIDES, + }); + // todo: do something better with this + return errors; + } + + negotiateLocales(requestedLocale: string): string[] { + const negotiatedLocales = negotiateLanguages( + [requestedLocale], + this.locales, + { strategy: "filtering" }, + ); + return negotiatedLocales; + } + + translate< + L extends Locales, + M extends Messages, + MK extends MessageKey, + >( + locale: L, + messageKey: MK, + ...args: M[MK] extends never ? [] + : Messages[string] extends M[MK] + ? [variables?: M[MK]] + : [variables: M[MK]] + ): string | undefined { + const variables = args[0]; + const bundle = this.#bundles.get(locale); + if (bundle == null) return; + const pattern = getPattern(bundle, messageKey); + if (pattern == null) return; + return formatPattern(bundle, pattern, variables); + } +} + +function getPattern( + bundle: FluentBundle, + messageKey: string, +): FluentPattern | null | undefined { + const key = parseMessageKey(messageKey); + const message = bundle.getMessage(key.id); + if (message == null) + return undefined; + return key.attr === undefined + ? message?.value + : message?.attributes[key.attr]; +} + +function formatPattern< + LT extends LocalesTypings, + M extends Messages, + MK extends MessageKey, +>( + bundle: FluentBundle, + pattern: FluentPattern, + variables?: M[MK], +): string { + const errors: Error[] = []; + const formatted = bundle.formatPattern(pattern, variables, errors); + for (const error of errors) + console.error(error); // todo: handle this + return formatted; +} + +export function parseMessageKey(key: string): FluentMessageKey { + const segments = key.trim().split("."); + if ( + segments.length > 2 || + segments.some((s) => s.trim().length === 0) + ) { + throw new Error(`Invalid message key segments in key: '${key}'`); + } + return { id: segments[0], attr: segments[1] }; +} diff --git a/adapters/fluent/adapter_test.ts b/adapters/fluent/adapter_test.ts new file mode 100644 index 00000000..c2e09705 --- /dev/null +++ b/adapters/fluent/adapter_test.ts @@ -0,0 +1,211 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { FluentAdapter, parseMessageKey } from "../fluent/adapter.ts"; + +describe("parse message key", () => { + it("message id only", () => { + const parsed = parseMessageKey("id"); + expect(parsed).toStrictEqual({ id: "id", attr: undefined }); + }); + + it("message id + attr", () => { + const parsed = parseMessageKey("id.attr"); + expect(parsed).toStrictEqual({ id: "id", attr: "attr" }); + }); + + it("invalid ones", () => { + const keys = [ + "id.", + "id..", + ".attr", + "...", + ".", + ".attr.", + "id.attr.", + "", + ]; + for (const key of keys) { + expect(() => parseMessageKey(key)) + .toThrow(`Invalid message key segments in key: '${key}'`); + } + }); +}); + +describe("fluent adapter", () => { + it("should register locales", () => { + const adapter = new FluentAdapter(); + expect(adapter.locales).toStrictEqual([]); + + adapter.loadResource("en", "msg = message"); + expect(adapter.locales).toStrictEqual(["en"]); + + adapter.loadResource("de", "msg = message"); + expect(adapter.locales).toStrictEqual(["en", "de"]); + }); + + it("should not register invalid locale", () => { + const adapter = new FluentAdapter(); + expect(adapter.loadResource("en", "source = message")) + .toStrictEqual([]); + expect(() => adapter.loadResource("de-", "source = message")) + .toThrow(`The locale de- seems invalid.`); + }); + + it("should return resource errors", () => { + const adapter = new FluentAdapter(); + const rt1 = adapter.loadResource("en", "msg = message"); + expect(rt1.length).toBe(0); + const rt2 = adapter.loadResource("en", "msg = message"); + expect(rt2.length).toBe(1); + expect(() => { + throw rt2[0]; + }).toThrow(`Attempt to override an existing message: "msg"`); + const rt3 = adapter.loadResource( + "en", + [ + "msg2 = message two", + " .attr = attr one", + " .attr = attr two", + ].join("\n"), + ); + expect(rt3.length).toBe(0); // for some reason + }); + + it("should not return errors with allow overriding", () => { + const adapter = new FluentAdapter(); + const rt1 = adapter.loadResource("en", "msg = message"); + expect(rt1.length).toBe(0); + const rt2 = adapter.loadResource("en", "msg = message", { + allowOverrides: true, + }); + expect(rt2.length).toBe(0); + }); + + it("shoud not override existing messages", () => { + const adapter = new FluentAdapter(); + adapter.loadResource("en", "msg = message"); + adapter.loadResource("en", "msg1 = message 1"); + expect(adapter.translate("en", "msg")).toBe("message"); + }); + + const FSI = "\u2068", PDI = "\u2069"; + const v = (value: string) => FSI + value + PDI; + + it("should translate valid messages", () => { + const adapter = new FluentAdapter(); + adapter.loadResource( + "en", + [ + "msg1 = message one", + "msg2 = message two { $v }", + " .attr1 = attr one", + ].join("\n"), + ); + expect(adapter.translate("en", "msg1")).toBe("message one"); + expect(adapter.translate("en", "msg2")) + .toBe(`message two ${v("{$v}")}`); + expect(adapter.translate("en", "msg2", { v: 1 })) + .toBe(`message two ${v("1")}`); + expect(adapter.translate("en", "msg2.attr1")) + .toBe("attr one"); + }); + + it("should not try to validate invalid messages", () => { + const adapter = new FluentAdapter(); + adapter.loadResource( + "en", + [ + "msg1 = message one", + "msg2 = message two { $v }", + " .attr1 = attr one", + ].join("\n"), + ); + expect(adapter.translate("de", "msg1")).toBe(undefined); + expect(adapter.translate("en", "non-existent")).toBe(undefined); + }); + + it("should respect bundle options (useIsolating)", () => { + const adapter = new FluentAdapter({ + bundleOptions: { + useIsolating: true, + }, + }); + + adapter.loadResource("en", "link1 = click here -> { $link }"); + expect(adapter.translate("en", "link1", { link: "https://grammy.dev" })) + .toBe(`click here -> ${v("https://grammy.dev")}`); + + adapter.loadResource("de", "link2 = click here -> {$link}", { + bundleOptions: { + useIsolating: false, + }, + }); + expect(adapter.translate("de", "link2", { link: "https://grammy.dev" })) + .toBe(`click here -> https://grammy.dev`); + }); + + describe("negotiateLocales", () => { + it("should return empty array when no locales are registered", () => { + const adapter = new FluentAdapter(); + expect(adapter.negotiateLocales("en")).toStrictEqual([]); + }); + + it("should return exact match when available", () => { + const adapter = new FluentAdapter(); + adapter.loadResource("en", "msg = message"); + adapter.loadResource("de", "msg = Nachricht"); + expect(adapter.negotiateLocales("en")).toStrictEqual(["en"]); + expect(adapter.negotiateLocales("de")).toStrictEqual(["de"]); + }); + + it("should return empty array when no locale matches", () => { + const adapter = new FluentAdapter(); + adapter.loadResource("en", "msg = message"); + adapter.loadResource("de", "msg = Nachricht"); + expect(adapter.negotiateLocales("fr")).toStrictEqual([]); + }); + + it("should match a region-specific locale to a base locale", () => { + // e.g. "en-US" requested, but only "en" registered + const adapter = new FluentAdapter(); + adapter.loadResource("en", "msg = message"); + expect(adapter.negotiateLocales("en-US")).toStrictEqual(["en"]); + }); + + it("should match a base locale to a region-specific registered locale", () => { + // e.g. "en" requested, but only "en-US" registered + const adapter = new FluentAdapter(); + adapter.loadResource("en-US", "msg = message"); + expect(adapter.negotiateLocales("en")).toStrictEqual(["en-US"]); + }); + + it("should prefer an exact locale over a partial match", () => { + const adapter = new FluentAdapter(); + adapter.loadResource("en", "msg = message"); + adapter.loadResource("en-GB", "msg = message"); + const result = adapter.negotiateLocales("en-GB"); + expect(result[0]).toBe("en-GB"); + expect(result).toContain("en"); + }); + + it("should return multiple matches when several locales are compatible", () => { + const adapter = new FluentAdapter(); + adapter.loadResource("en-US", "msg = message"); + adapter.loadResource("en-GB", "msg = message"); + adapter.loadResource("de", "msg = Nachricht"); + + const result = adapter.negotiateLocales("en"); + expect(result).toContain("en-US"); + expect(result).toContain("en-GB"); + expect(result).not.toContain("de"); + }); + + it("should return empty array for a completely unrelated locale", () => { + const adapter = new FluentAdapter(); + adapter.loadResource("en", "msg = message"); + adapter.loadResource("de", "msg = Nachricht"); + adapter.loadResource("fr", "msg = message"); + expect(adapter.negotiateLocales("ja")).toStrictEqual([]); + }); + }); +}); diff --git a/adapters/fluent/cli.ts b/adapters/fluent/cli.ts new file mode 100644 index 00000000..c47bf741 --- /dev/null +++ b/adapters/fluent/cli.ts @@ -0,0 +1,141 @@ +// i18n CLI capabilities for Fluent adapter. + +import { type Expression, parse, type PatternElement } from "@fluent/syntax"; +import { yellow } from "@std/fmt/colors"; +import type { AdapterCliConfig } from "../types.ts"; +import { parseArgs } from "node:util"; + +export default { + version: 1, + extensions: [".ftl"], + features: { + "type-gen": generateTypes, + }, +}; + +async function generateTypes(sources: Set, rawArgs: string[]): Promise<{ + messages: Record>; + additional: string; +}> { + const args = parseArgs({ + args: rawArgs, + strict: true, + options: { + "allow-override": { + type: "boolean", + default: false, + }, + }, + }); + + const messages = new Map; + }>(); + + for (const file of sources) { + let content: string; + try { + content = await Deno.readTextFile(file); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + sources.delete(file); + console.info(yellow("stopped watching: file not found"), file); + continue; + } else { + throw err; + } + } + + const resource = parse(content, {}); + + for (const entry of resource.body) { + if (entry.type !== "Message") + continue; + + if (entry.value != null) { + const expressions = extractExpressions(entry.value.elements); + const key = entry.id.name; + if (messages.has(key) && !args.values["allow-override"]) { + console.error( + `duplicate key: '${key}' was already specified in`, + messages.get(key)?.source === file + ? `the same file before.` + : messages.get(key)?.source + + ` but ${file} is trying to override.`, + ); + continue; + } + messages.set(key, { + source: file, + placeables: getPlaceables(expressions), + }); + } + + if (entry.attributes.length > 0) { + for (const attr of entry.attributes) { + const expressions = extractExpressions(attr.value.elements); + const key = `${entry.id.name}.${attr.id.name}`; + if (key in messages && !args.values["allow-override"]) { + console.error( + `duplicate key: '${key}' was already specified in`, + messages.get(key)?.source === file + ? `the same file before.` + : messages.get(key)?.source + + ` but ${file} is trying to override.`, + ); + continue; + } + messages.set(key, { + source: file, + placeables: getPlaceables(expressions), + }); + } + } + } + } + + const additional = "type Value = string | number | Date;"; + const output: Record> = {}; + for (const [messageKey, { placeables }] of messages.entries()) { + const variableMap: Record = {}; + for (const placeable of placeables) + variableMap[placeable] = "Value"; + output[messageKey] = variableMap; + } + + return { + messages: output, + additional: additional, + }; +} + +function extractExpressions(elements: PatternElement[]): Expression[] { + return elements + .filter((element) => element.type === "Placeable") + .map((element) => element.expression); +} + +function getPlaceables(expressions: Expression[]): Set { + let placeables = new Set(); + for (const expression of expressions) { + switch (expression.type) { + case "FunctionReference": { + const args = expression.arguments.positional; + placeables = placeables.union(getPlaceables(args)); + break; + } + case "VariableReference": { + placeables.add(expression.id.name); + break; + } + case "SelectExpression": { + const selector = expression.selector; + if (selector.type === "VariableReference") + placeables.add(selector.id.name); + break; + } + } + } + return placeables; +} diff --git a/adapters/mod.ts b/adapters/mod.ts new file mode 100644 index 00000000..01b6b99b --- /dev/null +++ b/adapters/mod.ts @@ -0,0 +1 @@ +export type { AdapterCliConfig } from "./types.ts"; diff --git a/adapters/types.ts b/adapters/types.ts new file mode 100644 index 00000000..f9bca64b --- /dev/null +++ b/adapters/types.ts @@ -0,0 +1,33 @@ +type MaybePromise = T | Promise; + +interface AdapterCliConfigV1 { + /** Version of the adapter configuration. */ + version: 1; + /** File extensions associated with the adapter, to be read by the CLI. */ + extensions: [string, ...string[]]; + /** Features available in the adapter configuration. */ + features: Partial<{ + /** + * **TypeScript Types Generation** + * + * Exposes the adapter's ability to generate types from a given set of + * filepaths. This adapter feature shall take in paths to the source + * files and return a record of messages & variables and any additional + * raw TypeScript text that is required for making the types work. + */ + "type-gen": ( + sources: Set, + rawArgs: string[], + ) => MaybePromise<{ + messages: Record>; + additional: string | null; + }>; + // Additional "fun" features that could be added in the future: + // * "check": error checking from parsing + // * "sync-check": check for message equality across locales + // * "cleaner": unused messages finder across source code (obviously difficult) + }>; +} + +/** Configuration for the adapter's CLI capabilities. */ +export type AdapterCliConfig = AdapterCliConfigV1; diff --git a/cli/constants.ts b/cli/constants.ts new file mode 100644 index 00000000..58a222e9 --- /dev/null +++ b/cli/constants.ts @@ -0,0 +1,11 @@ +import type { AdapterCliConfig } from "../adapters/mod.ts"; + +export const VERSION = "0.1"; + +export const ADAPTER_CONFIG_SCHEMA_VERSIONS: AdapterCliConfig["version"][] = [ + 1, +]; + +export const GENERATED_FILE_OUTPUT_PREFIX = `\ +// This file is auto-generated by grammY i18n CLI ${VERSION} +// Changes made to this file will be overwritten.`; diff --git a/cli/generate_types.ts b/cli/generate_types.ts new file mode 100644 index 00000000..72ec9aa0 --- /dev/null +++ b/cli/generate_types.ts @@ -0,0 +1,496 @@ +import { bold, cyan, dim, green, yellow } from "@std/fmt/colors"; +import { + basename, + common, + dirname, + extname, + isAbsolute, + join, + relative, + resolve, + SEPARATOR, +} from "@std/path"; +import type { AdapterCliConfig } from "../adapters/mod.ts"; +import { isValidLocale, walk } from "../utilities.ts"; +import { GENERATED_FILE_OUTPUT_PREFIX } from "./constants.ts"; +import { + isValidString, + loadAdapterConfig, + log, + makeIndent, +} from "./utilities.ts"; + +type SourceConfig = { + mode: "locales-dir"; + dirpath: string; + fallback: string; +} | { + mode: "explicit"; + paths: string[]; +}; + +import { command } from "cleye"; + +export default command({ + name: "generate-types", + help: { + description: + "Generate TypeScript types for i18n messages from locale files.", + // examples: [ + // "jsr:@grammyjs/i18n/adapter-fluent/cli locales/types.d.ts -d locales -f en", + // ], + }, + parameters: [ + "", + "", + "[paths...]", + "--", + "[arguments...]", + ], + flags: { + localesDir: { + type: String, + alias: "d", + description: "Path to the locales directory", + placeholder: "", + }, + fallback: { + type: String, + description: + "The fallback locale inside the locales directory. Required in locales directory mode", + alias: "f", + placeholder: "", + }, + watch: { + type: Boolean, + alias: "w", + description: "Run in watch mode (useful for development)", + default: false, + }, + followSymlinks: { + type: Boolean, + description: "Follow symlinks", + default: false, + }, + ignoreDotFiles: { + type: Boolean, + description: "Ignore dot (hidden) files", + default: true, + }, + }, + booleanFlagNegation: true, + strictFlags: true, +}, async (argv) => { + const adapterConfig = await loadAdapterConfig(argv._.adapter) + .then((config) => config) + .catch((error) => { + console.error(error); + if (error instanceof Error) log.error(error.message); + else log.error("Failed to load the configuration"); + Deno.exit(1); + }); + + if ( + !("type-gen" in adapterConfig.features) || + typeof adapterConfig.features["type-gen"] !== "function" + ) { + log.error("Feature not supported by adapter: type-gen"); + Deno.exit(1); + } + + await generateTypes(adapterConfig, { + rawPaths: argv._.paths, + localesDirectory: argv.flags.localesDir, + fallbackLocale: argv.flags.fallback, + followSymlinks: argv.flags.followSymlinks, + ignoreDotFiles: argv.flags.ignoreDotFiles, + outputPath: argv._.output, + watchMode: argv.flags.watch, + }, argv._.arguments); +}); + +async function generateTypes(adapterConfig: AdapterCliConfig, args: { + rawPaths: string[]; + localesDirectory?: string; + fallbackLocale?: string; + outputPath: string; + watchMode: boolean; + ignoreDotFiles: boolean; + followSymlinks: boolean; +}, featureArguments: string[]): Promise { + const featureFn = adapterConfig.features["type-gen"]; + if (typeof featureFn !== "function") + throw new Error("must be checked inside the run fn"); + + let source: SourceConfig; + + if (isValidString(args.localesDirectory)) { + if (isValidString(args.fallbackLocale)) { + source = { + mode: "locales-dir", + dirpath: args.localesDirectory, + fallback: args.fallbackLocale, + }; + if (args.rawPaths.length > 0) { + log.info( + "Path arguments and locales directory cannot be used together.", + ); + log.info("Ignoring path arguments..."); + args.rawPaths.splice(0, args.rawPaths.length); + } + } else { + log.error( + "Fallback locale must be specified when locales directory is specified.", + ); + Deno.exit(1); + } + } else if (isValidString(args.fallbackLocale)) { + log.error( + "Locales directory must be specified when fallback is specified.", + ); + Deno.exit(1); + } else if ( + args.rawPaths.length === 0 || + args.rawPaths.every((arg) => !isValidString(arg)) + ) { + log.error("Specify at least one file/directory path to read from."); + Deno.exit(1); + } else { + source = { + mode: "explicit", + paths: args.rawPaths, + }; + } + + // Source message files for passing to adapter type generator + const sources = new Set(); + // Initial set of watchpaths for the FS watcher + const watchpaths: string[] = []; + // Locales found under the locales directory + const locales = new Set(); + + if (source.mode === "locales-dir") { + const localesDir = await resolvePath( + source.dirpath, + args.followSymlinks, + ); + if (!localesDir.dir) { + log.error("Specified locales directory is not a directory"); + Deno.exit(1); + } + for await (const dirent of Deno.readDir(localesDir.path)) { + if (dirent.isDirectory) { + locales.add(dirent.name); + } else if ( + dirent.isFile && + adapterConfig.extensions.includes(extname(dirent.name)) + ) { + const filepath = resolve(localesDir.path, dirent.name); + sources.add(filepath); + } else if (dirent.isSymlink) { + if (args.followSymlinks) { + const direntpath = resolve(localesDir.path, dirent.name); + const resolved = await resolvePath(direntpath, true); + if (resolved.dir) { + if (isValidLocale(dirent.name)) { + locales.add(dirent.name); + watchpaths.push(resolved.path); + } + } else if ( + adapterConfig.extensions.includes(extname(dirent.name)) + ) { + sources.add(direntpath); + } + } + } + } + if (!locales.has(source.fallback)) { + log.error( + "Could not find the specified fallback locale inside the locales directory", + ); + Deno.exit(1); + } + + for await ( + const file of walk( + join(localesDir.path, source.fallback), + adapterConfig.extensions, + { + followSymlinks: args.followSymlinks, + ignoreDotFiles: args.ignoreDotFiles, + }, + ) + ) { + sources.add(file); + } + + watchpaths.push(localesDir.path); + } else { + for (const arg of args.rawPaths) { + const resolved = await resolvePath(arg, args.followSymlinks); + log.info( + yellow(args.watchMode ? `Watching` : `Reading`), + resolved.path, + ); + for await ( + const file of walk( + resolved.path, + adapterConfig.extensions, + { + followSymlinks: args.followSymlinks, + ignoreDotFiles: args.ignoreDotFiles, + }, + ) + ) { + sources.add(file); + } + watchpaths.push(resolved.path); + } + } + + console.log(bold(green(`Found sources (${sources.size}):`))); + sources.forEach((source) => { + const relativePath = relative(Deno.cwd(), source); + const commonPrefix = common([Deno.cwd(), source]); + console.log(" *", join(dim(commonPrefix), relativePath)); + }); + + await writeGenerated( + locales, + await featureFn(sources, featureArguments), + args.outputPath, + ); + if (!args.watchMode) Deno.exit(0); + + /// === Watcher Mode + + log.info("Starting file watcher"); + + using watcher = Deno.watchFs(watchpaths, { recursive: true }); + function closeWatcher() { + log.info("Closing the file watcher"); + watcher.close(); + } + Deno.addSignalListener("SIGINT", closeWatcher); + Deno.addSignalListener("SIGTERM", closeWatcher); + + const resolvedLocalesDirpath = source.mode === "locales-dir" + ? resolve(source.dirpath) + : undefined; + + for await (const event of watcher) { + if (event.paths.length !== 1) + continue; + if ( + event.kind !== "create" && event.kind !== "modify" && + event.kind !== "remove" && event.kind !== "rename" + ) { + continue; + } + + const filepath = event.paths[0]; + + // Locales directory mode: a locale dir was created/deleted + if ( + source.mode === "locales-dir" && + dirname(filepath) === resolvedLocalesDirpath + ) { + const localeName = basename(filepath); + try { + const realpath = args.followSymlinks + ? await Deno.realPath(filepath) + : filepath; + const stat = await Deno.stat(realpath); + + if (stat.isDirectory) { + if (isValidLocale(localeName)) { + locales.add(localeName); + await writeGenerated( + locales, + await featureFn(sources, featureArguments), + args.outputPath, + ); + } else { + log.error( + "Found changes in", + filepath, + "but ignoring because the directory name seems invalid for a locale", + ); + } + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + if (localeName === args.fallbackLocale) { + log.error( + "Fallback locale directory no longer exists. Exiting...", + ); + closeWatcher(); + Deno.exit(1); + } + locales.delete(localeName); + await writeGenerated( + locales, + await featureFn(sources, featureArguments), + args.outputPath, + ); + } else { + log.error("Some error occurred:"); + console.error(error); + } + } + + continue; + } + + // Directories have been handled, now need to handle file events + + if (!adapterConfig.extensions.includes(extname(filepath))) + continue; + + if (source.mode === "locales-dir") { + // We only want to watch the files underneath the fallback locale directory & the common files + const parent = resolve(source.dirpath, source.fallback); + const relativePath = relative(filepath, parent); + if ( + // If its in some other locale directory, ignore. + (isAbsolute(relativePath) || + relativePath.split(SEPARATOR) + .some((part) => part !== "..")) && + // If its not a common file, ignore. + dirname(filepath) !== resolve(source.dirpath) + ) { + continue; + } + } + + switch (event.kind) { + case "create": { + if (sources.has(filepath)) + continue; + const info = await Deno.stat(filepath); + if (info.isFile) { + sources.add(filepath); + log.info(yellow(`Watching`), filepath); + } else { + continue; + } + break; + } + case "modify": + if (await isFile(filepath) && !sources.has(filepath)) { + sources.add(filepath); + log.info(yellow(`Watching`), filepath); + } + break; + case "remove": + if (!sources.has(filepath)) + continue; + sources.delete(filepath); + log.info(yellow(`Stopped watching`), filepath); + break; + case "rename": + if (await isFile(filepath) && !sources.has(filepath)) { + sources.add(filepath); + log.info(yellow(`Watching`), filepath); + } + continue; + default: + throw new Error("unhandled event type"); + } + + await writeGenerated( + locales, + await featureFn(sources, featureArguments), + args.outputPath, + ); + } +} + +async function writeGenerated( + locales: Set, + generatedOutput: { + messages: Record>; + additional: string | null; + }, + outputFile: string, +) { + log.info("Generating output file..."); + + const indent = makeIndent(4); + + const availableLocales: string = locales.size > 0 + ? Array.from(locales) + .map((locale) => `"${locale}"`) + .reduce((p, locale) => { + if (p[p.length - 1].length === 5) { + p.push([locale]); + return p; + } + p[p.length - 1].push(locale); + return p; + }, [[]] as string[][]) + .map((line) => line.join(" | ")) + .join(`\n${indent(1)}| `) + : "string"; + + const availableMessages: string = Object + .entries(generatedOutput.messages) + .map(([messageKey, variables]) => { + const variableKeys = Object.keys(variables); + const variableType = variableKeys.length === 0 + ? "never" + : `{\n${ + variableKeys.map((key) => { + return `${indent(2)}"${key}": ${variables[key]};`; + }).join("\n") + }\n${indent(1)}}`; + return `${indent(1)}"${messageKey}": ${variableType};`; + }) + .join("\n"); + + const additionalContent = generatedOutput.additional != null + ? `\n${generatedOutput.additional}\n` + : ""; + + const output = `${GENERATED_FILE_OUTPUT_PREFIX} +${additionalContent}\ + +type AvailableLocales = ${availableLocales}; + +type AvailableMessages = {\n${availableMessages}\n}; + +export type GeneratedLocalesTypings = { + locales: AvailableLocales; + messages: AvailableMessages; +};\n`; + + await Deno.writeTextFile(outputFile, output); + log.info(`Written to output file ${cyan(resolve(outputFile))}`); +} + +async function resolvePath( + arg: string, + followSymlinks: boolean, +): Promise<{ path: string; dir: boolean }> { + const file = await Deno.lstat(arg); + if (file.isFile || file.isDirectory) + return { path: resolve(arg), dir: file.isDirectory }; + else if (file.isSymlink && followSymlinks) { + const resolved = await Deno.readLink(arg); + return resolvePath(resolved, followSymlinks); + } else { + console.error(`'${arg}' is not a file, directory, or symlink.`); + Deno.exit(1); + } +} + +async function isFile(path: string): Promise { + try { + const stat = await Deno.lstat(path); + return stat.isFile; + } catch (error) { + if (error instanceof Deno.errors.NotFound) + return false; + throw error; + } +} diff --git a/cli/main.ts b/cli/main.ts new file mode 100644 index 00000000..0007971d --- /dev/null +++ b/cli/main.ts @@ -0,0 +1,15 @@ +import { VERSION } from "./constants.ts"; +import { default as generateTypes } from "./generate_types.ts"; +import { cli } from "cleye"; + +cli({ + name: "i18n-cli", + version: VERSION, + strictFlags: true, + help: { + description: "Official CLI for @grammyjs/i18n.", + }, + commands: [ + generateTypes, + ], +}); diff --git a/cli/utilities.ts b/cli/utilities.ts new file mode 100644 index 00000000..e367c0e6 --- /dev/null +++ b/cli/utilities.ts @@ -0,0 +1,65 @@ +import { bold, cyan, dim, magenta, red } from "@std/fmt/colors"; +import { resolve, toFileUrl } from "@std/path"; +import type { AdapterCliConfig } from "../adapters/mod.ts"; +import { ADAPTER_CONFIG_SCHEMA_VERSIONS } from "./constants.ts"; + +class Logger { + quiet: boolean = false; + info(...data: unknown[]) { + !this.quiet && console.info(...data); + } + error(...data: unknown[]) { + console.error(red(bold("error:")), ...data); + } +} + +export const log = new Logger(); + +export function makeIndent(width: number): (level: number) => string { + const baseIndent = " ".repeat(width); + return (level: number) => baseIndent.repeat(level); +} + +export function isValidString(str: string | undefined): str is string { + return str != null && str.trim().length > 0; +} + +export async function loadAdapterConfig( + moduleSrc: string, +): Promise { + log.info("Reading adapter configuration:", dim(moduleSrc)); + + const resolved = URL.canParse(moduleSrc) + ? moduleSrc + : toFileUrl(resolve(moduleSrc)).href; + + try { + const adapterModule = await import(resolved); + const adapterConfig = adapterModule.default as AdapterCliConfig; + if (adapterConfig == null || typeof adapterConfig !== "object") + throw new Error( + "No default import found in the specified adapter module", + ); + if (!ADAPTER_CONFIG_SCHEMA_VERSIONS.includes(adapterConfig.version)) { + throw new Error("Unknown version of adapter config"); + } + + log.info( + "Loaded configuration:", + magenta(`version ${adapterConfig.version}`), + ); + log.info( + "Configuration features:", + cyan(Object.keys(adapterConfig.features).join(", ")), + ); + return adapterConfig; + } catch (error) { + if (error instanceof TypeError && "code" in error) { + if (error.code === "ERR_MODULE_NOT_FOUND") + throw new Error( + `Specified adapter module not found: ${moduleSrc}`, + ); + } + throw error; + } +} diff --git a/deno.jsonc b/deno.jsonc index 06cfb145..44a6bcd3 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,18 +1,38 @@ { - "lock": false, - "fmt": { - "proseWrap": "preserve", - "exclude": ["./out/"] - }, - "lint": { - "exclude": ["./out/"] - }, - "tasks": { - "example": "cd examples && deno run --allow-net --allow-read deno.ts", - "test": "deno test --allow-read --allow-run --allow-write", - "dnt": "deno run --allow-env --allow-net --allow-read --allow-run --allow-write scripts/dnt.ts" - }, - "test": { - "exclude": ["./out/"] - } + "name": "@grammyjs/i18n", + "version": "2.0.0-beta.0", + "exports": { + ".": "./mod.ts", + "./cli": "./cli/main.ts", + + // Adapters + "./adapter-fluent": "./adapters/fluent/adapter.ts", + "./adapter-fluent/cli": "./adapters/fluent/cli.ts" + }, + "lock": false, + "exclude": [".*/"], + "imports": { + "@fluent/bundle": "npm:@fluent/bundle@0.19.1", + "@fluent/langneg": "npm:@fluent/langneg@^0.7.0", + "@fluent/syntax": "npm:@fluent/syntax@^0.19.0", + "@grammyjs/debug": "jsr:@grammyjs/debug@0.3.1", + "@grammyjs/grammy": "jsr:@grammyjs/grammy@^2.0.0-beta.3", + "@std/expect": "jsr:@std/expect@^1.0.18", + "@std/fmt": "jsr:@std/fmt@^1", + "@std/cli": "jsr:@std/cli@^1", + "@std/path": "jsr:@std/path@^1", + "@std/testing": "jsr:@std/testing@^1.0.17", + "@std/text": "jsr:@std/text@^1.0.17", + "cleye": "npm:cleye@^2.3.0" + }, + "tasks": { + "test": "deno test -R" + }, + "fmt": { + "useTabs": false, + "indentWidth": 4, + "lineWidth": 80, + "useBraces": "maintain", + "singleBodyPosition": "maintain" + } } diff --git a/example/locales.ts b/example/locales.ts new file mode 100644 index 00000000..aae59547 --- /dev/null +++ b/example/locales.ts @@ -0,0 +1,29 @@ +// This file is auto-generated by grammY i18n CLI 0.1 +// Changes made to this file will be overwritten. + +type Value = string | number | Date; + +type AvailableLocales = "en"; + +type AvailableMessages = { + "start": never; + "start.ping-button": never; + "start.ping-alert": never; + "status.downloading": { + "size": Value; + }; + "status.uploading": never; + "image-info": { + "width": Value; + "height": Value; + "size": Value; + }; + "about": { + "projectUrl": Value; + }; +}; + +export type GeneratedLocalesTypings = { + locales: AvailableLocales; + messages: AvailableMessages; +}; diff --git a/example/locales/en/main.ftl b/example/locales/en/main.ftl new file mode 100644 index 00000000..34c8a9cf --- /dev/null +++ b/example/locales/en/main.ftl @@ -0,0 +1,11 @@ +start = Hi there! Send an image to convert it to a document. + .ping-button = Ping! + .ping-alert = Pong! + +status = + .downloading = Downloading {$size}... + .uploading = Uploading... + +image-info = Width: {$width} x Height: {$height}, size: {$size} + +about = Visit {$projectUrl} to see the code! diff --git a/example/locales/types.d.ts b/example/locales/types.d.ts new file mode 100644 index 00000000..aae59547 --- /dev/null +++ b/example/locales/types.d.ts @@ -0,0 +1,29 @@ +// This file is auto-generated by grammY i18n CLI 0.1 +// Changes made to this file will be overwritten. + +type Value = string | number | Date; + +type AvailableLocales = "en"; + +type AvailableMessages = { + "start": never; + "start.ping-button": never; + "start.ping-alert": never; + "status.downloading": { + "size": Value; + }; + "status.uploading": never; + "image-info": { + "width": Value; + "height": Value; + "size": Value; + }; + "about": { + "projectUrl": Value; + }; +}; + +export type GeneratedLocalesTypings = { + locales: AvailableLocales; + messages: AvailableMessages; +}; diff --git a/example/main.ts b/example/main.ts new file mode 100644 index 00000000..4051bad3 --- /dev/null +++ b/example/main.ts @@ -0,0 +1,86 @@ +// todo: write a better example of a useful bot. +import { Bot, type Context, InputFile } from "@grammyjs/grammy"; +import { InlineKeyboard } from "@grammyjs/grammy/keyboard"; +import { FluentAdapter } from "../adapters/fluent/adapter.ts"; +import { I18n, type I18nFlavor, loadLocalesDirectory } from "../mod.ts"; +import type { GeneratedLocalesTypings } from "./locales.ts"; + +type EContext = I18nFlavor; + +const BOT_TOKEN = Deno.env.get("BOT_TOKEN"); +if (!BOT_TOKEN) { + throw new Error("Set BOT_TOKEN environment variable"); +} +const bot = new Bot(BOT_TOKEN); + +const fluent = new FluentAdapter(); +await loadLocalesDirectory(fluent, "./locales", { + extensions: [".ftl"], // extension to walk through. + // optional configuration + followSymlinks: false, + ignoreDotFiles: true, + includeCommonSources: true, +}); + +const i18n = new I18n({ + adapter: fluent, + fallbackLocale: "en", + localeNegotiator: (ctx) => ctx.from?.language_code, + onMissingKey: (event) => { + console.error("Missing key:", event); + if (event.fallback) { + return "Custom fallback message"; + } + }, +}); +bot.use(i18n.middleware()); + +bot.command("start", async (ctx) => { + await ctx.sendMessage(ctx.translate("start"), { + reply_markup: new InlineKeyboard() + .text(ctx.translate("start.ping-button"), "ping"), + }); +}); + +bot.use(i18n.hears("start.ping-button"), async (ctx) => { + await ctx.send(ctx.translate("start.ping-alert")); +}); + +bot.callbackQuery("ping", async (ctx) => { + await ctx.answerCallbackQuery(ctx.translate("start.ping-alert")); +}); + +bot.command("developer_info", async (ctx) => { + await ctx.sendMessage( + ctx.translate("about", { + projectUrl: "https://github.com/grammyjs/i18n/tree/v2/example", + }), + ); +}); + +bot.on("message:photo", async (ctx) => { + const { width, height, file_size } = + ctx.message.photo[ctx.message.photo.length - 1]; + + await ctx.sendMessage( + ctx.translate("image-info", { + height, + width, + size: file_size ? `${file_size} bytes` : "Unknown", + }), + ); + + await ctx.sendMessage( + ctx.translate("status.downloading", { + size: file_size ?? "Unknown size", + }), + ); + const { file_path } = await ctx.getFile(); + const url = `https://api.telegram.org/file/bot${bot.token}/${file_path}`; + const response = await fetch(url); + + await ctx.sendMessage(ctx.translate("status.uploading")); + await ctx.sendDocument(new InputFile(response, "doc.jpg")); +}); + +bot.start({ drop_pending_updates: true }); diff --git a/examples/deno.ts b/examples/deno.ts deleted file mode 100644 index 4ba57873..00000000 --- a/examples/deno.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - Bot, - Context, - session, - SessionFlavor, -} from "https://deno.land/x/grammy@v1.21.1/mod.ts"; -import { I18n, I18nFlavor } from "../src/mod.ts"; - -interface SessionData { - apples: number; -} - -type MyContext = - & Context - & I18nFlavor - & SessionFlavor; - -const bot = new Bot(""); // <-- put your bot token here (https://t.me/BotFather) - -bot.use(session({ - initial: () => ({ apples: 0 }), -})); - -const i18n = new I18n({ - defaultLocale: "en", - useSession: true, - directory: "locales", - globalTranslationContext(ctx) { - return { - first_name: ctx.from?.first_name ?? "", - }; - }, -}); - -bot.use(i18n); - -bot.command("start", async (ctx) => { - await ctx.reply(ctx.t("greeting")); -}); - -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")); -}); - -bot.command("add", async (ctx) => { - ctx.session.apples++; - await ctx.reply(ctx.t("cart", { - apples: ctx.session.apples, - })); -}); - -bot.command("cart", async (ctx) => { - await ctx.reply(ctx.t("cart", { - apples: ctx.session.apples, - })); -}); - -bot.command("checkout", async (ctx) => { - ctx.session.apples = 0; - 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 deleted file mode 100644 index 8f2f11bb..00000000 --- a/examples/locales/ckb.ftl +++ /dev/null @@ -1,10 +0,0 @@ -greeting = سڵاو، { $first_name }! -cart = سڵاو، { $first_name }، لە سەبەتەکەتدا{ $apples } سێو هەن. -checkout = سپاس بۆ بازاڕیکردنەکەت! -language-set = کوردی هەڵبژێردرا! -multiline = - ئەمەش نموونەی... - ئە - فرە هێڵی - پەیام - بۆ ئەوەی بزانین چۆن فۆرمات کراون! diff --git a/examples/locales/de.ftl b/examples/locales/de.ftl deleted file mode 100644 index a0bc5725..00000000 --- a/examples/locales/de.ftl +++ /dev/null @@ -1,19 +0,0 @@ -greeting = Hallo { $first_name }! - -cart = { $first_name }, es { - $apples -> - [0] ist kein Apfel - [one] ist ein Apfel - *[other] sind { $apples } Äpfel - } in deinem Einkaufswagen. - -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! diff --git a/examples/locales/en.ftl b/examples/locales/en.ftl deleted file mode 100644 index ba84c8a2..00000000 --- a/examples/locales/en.ftl +++ /dev/null @@ -1,19 +0,0 @@ -greeting = Hello { $first_name }! - -cart = { $first_name }, there { - $apples -> - [0] are no apples - [one] is one apple - *[other] are { $apples } apples - } in your cart. - -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 deleted file mode 100644 index b05ee8da..00000000 --- a/examples/locales/ku.ftl +++ /dev/null @@ -1,10 +0,0 @@ -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/cart.ftl b/examples/locales/ru/cart.ftl deleted file mode 100644 index 6ce3ca27..00000000 --- a/examples/locales/ru/cart.ftl +++ /dev/null @@ -1,8 +0,0 @@ -cart = { $first_name }, у вас { - $apples -> - [0] нет яблок - [one] одно яблоко - *[other] { $apples } яблок - } в корзине. - -checkout = Спасибо за покупку! diff --git a/examples/locales/ru/greeting.ftl b/examples/locales/ru/greeting.ftl deleted file mode 100644 index 6a428837..00000000 --- a/examples/locales/ru/greeting.ftl +++ /dev/null @@ -1 +0,0 @@ -greeting = Привет { $first_name }! diff --git a/examples/locales/ru/language.ftl b/examples/locales/ru/language.ftl deleted file mode 100644 index e57621a1..00000000 --- a/examples/locales/ru/language.ftl +++ /dev/null @@ -1 +0,0 @@ -language-set = Язык был изменен на Русский! diff --git a/examples/locales/ru/multiline.ftl b/examples/locales/ru/multiline.ftl deleted file mode 100644 index d2cc6c53..00000000 --- a/examples/locales/ru/multiline.ftl +++ /dev/null @@ -1,5 +0,0 @@ -multiline = - Это пример - многострочных - сообщений - чтобы увидеть, как они отформатированы! diff --git a/examples/node.ts b/examples/node.ts deleted file mode 100644 index 77b9ad66..00000000 --- a/examples/node.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Bot, Context, session, SessionFlavor } from "grammy"; -import { I18n, I18nFlavor } from "@grammyjs/i18n"; - -interface SessionData { - apples: number; -} - -type MyContext = - & Context - & I18nFlavor - & SessionFlavor; - -const bot = new Bot(""); // <-- put your bot token here (https://t.me/BotFather) - -bot.use(session({ - initial: () => ({ apples: 0 }), -})); - -const i18n = new I18n({ - defaultLocale: "en", - useSession: true, - directory: "locales", - globalTranslationContext(ctx) { - return { - first_name: ctx.from?.first_name ?? "", - }; - }, -}); - -bot.use(i18n); - -bot.command("start", async (ctx) => { - await ctx.reply(ctx.t("greeting")); -}); - -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")); -}); - -// Add apple to cart -bot.command("add", async (ctx) => { - ctx.session.apples++; - await ctx.reply(ctx.t("cart", { - apples: ctx.session.apples, - })); -}); - -bot.command("cart", async (ctx) => { - await ctx.reply(ctx.t("cart", { - apples: ctx.session.apples, - })); -}); - -bot.command("checkout", async (ctx) => { - ctx.session.apples = 0; - await ctx.reply(ctx.t("checkout")); -}); - -bot.command("multiline", async (ctx) => { - await ctx.reply(ctx.t("multiline")); -}); - -bot.start(); diff --git a/mod.ts b/mod.ts new file mode 100644 index 00000000..aa2640f2 --- /dev/null +++ b/mod.ts @@ -0,0 +1,18 @@ +export { + I18n, + type I18nFlavor, + type LocaleNegotiator, + type MissingKeyEvent, + type NegotiatorResult, + type TranslateFunction, +} from "./plugin.ts"; +export type { + FormatAdapter, + Locales, + LocalesTypings, + MessageKey, + Messages, + MessageVariables, + ResourceLoadable, +} from "./types.ts"; +export { isValidLocale, loadLocalesDirectory } from "./utilities.ts"; diff --git a/plugin.ts b/plugin.ts new file mode 100644 index 00000000..a62c57bb --- /dev/null +++ b/plugin.ts @@ -0,0 +1,348 @@ +import { createDebug } from "@grammyjs/debug"; +import type { Context, HearsContext, MiddlewareFn } from "@grammyjs/grammy"; +import type { + FormatAdapter, + Locales, + LocalesTypings, + MessageKey, + Messages, + MessageVariables, +} from "./types.ts"; +import { isValidLocale } from "./utilities.ts"; + +const debug = createDebug("grammy:i18n"); + +export type NegotiatorResult = string | undefined; +export type LocaleNegotiator = ( + ctx: C, +) => NegotiatorResult | Promise; + +export type TranslateFunction = < + MK extends MessageKey>, +>( + messageKey: MK, + ...args: MessageVariables, MK> +) => string; + +/** + * Context flavor for the outside middleware tree. Installs `ctx.translate` and + * `ctx.i18n` that can be used for translating and handling the i18n instance of + * the current update. + */ +export type I18nFlavor< + C extends Context, + LT extends LocalesTypings = LocalesTypings, +> = C & { + /** + * `I18n` context namespace object. + */ + i18n: { + /** + * Uses the locale specified to be used in rest of the translations. + * + * @param locale Locale to use in rest of the translations. + */ + useLocale: (locale: string) => void; + /** + * Returns the locale currently set for translations. + * + * @returns The current locale. + */ + getLocale: () => string; + /** + * Calls the locale negotiator and sets the negotiated locale. + * + * @returns The locale returned by the locale negotiator. + */ + negotiateLocale: () => Promise; + }; + /** + * Formats and returns a message string using the adapter. Fallback + * mechanism is also triggered by this. + * + * @param locale Locale to use when translating. + * @param messageKey Message key to be used. + * @param args Variables to be passed for formatting the message data. + * + * @returns The translated string. + */ + translate: TranslateFunction; +}; + +/** + * Details about the missing key event, such as which locale and key were it and + * whether it was called upon falling back to the set fallback locale. + */ +export type MissingKeyEvent = { + /** The locale the translate function originally requested */ + requestedLocale: string; + /** The locale it negotiated into, i.e., the locale currently in use */ + currentLocale: string; + /** The requested message key */ + messageKey: string; + /** Whether the translation was called for the fallback locale set */ + fallback: boolean; +}; + +/** + * Locale negotiator used by i18n if one isn't set. It reads the language code + * of user in the current update, which can be undefined. + */ +export function defaultLocaleNegotiator(ctx: C) { + return ctx.from?.language_code; +} + +/** + * The core class for enabling internationalization in bots. + * + * Wraps a {@link FormatAdapter} and exposes translation utilities both + * directly (via {@link I18n.translate}) and as grammY middleware (via + * {@link I18n.middleware}), which installs `ctx.translate` and `ctx.i18n` + * onto every update's context. + */ +export class I18n< + C extends Context = Context, + LT extends LocalesTypings = LocalesTypings, +> { + constructor( + /** + * Configuration options for the i18n plugin. + */ + private options: { + /** + * Adapter for parsing and managing translation sources. You can + * plug in one of the official adapters or a custom one. + */ + adapter: FormatAdapter; + /** + * Fallback (default) locale of the instance. This must be set in + * order to prevent panicking if the requested locale has no message + * of that key. An error will be thrown in case there was no bundle + * registered for this fallback locale. + */ + fallbackLocale: Locales; + /** + * Custom locale negotiator for utilising external sources or + * databases for choosing the best possible locale for the user. + * + * The default locale negotiator reads the `language_code` of the + * user from the incoming update. This default behavior can be + * overriden by defining a custom locale negotiator. If the locale + * negotiator does not return a string, the set fallback locale is + * used instead. + */ + localeNegotiator?: LocaleNegotiator; + /** + * Handle when a key is missing. You can utilise this to throw + * errors or print warnings. Handler should either return a string + * or nothing. + * + * If this does not return a string, an error is thrown after + * invoking the handler (if the locale was the set fallback locale), + * to ensure the user don't accidentally reference a key that is not + * in at least the fallback locale. This behavior can be overridden + * by returning a translation-missing message from the handler to + * show as the result. + * + * @param event Details about the event, such as which locale, key + * and whether it was called upon falling back to the set fallback + * locale. + * + * @returns Either a string or nothing. If string is returned, the + * string is returned as the result of `translate` instead of the + * actual formatted string that maybe resolved later in the case of + * non-fallback locales. + */ + onMissingKey?: (event: MissingKeyEvent) => string | void; + }, + ) { + if (!isValidLocale(options.fallbackLocale)) + throw new Error("Must set a valid fallback (default) locale."); + + options.localeNegotiator ??= defaultLocaleNegotiator; + } + + /** + * Fallback (default) locale of the adapter. + */ + get fallbackLocale(): string { + return this.options.fallbackLocale; + } + + /** + * Get the list of locales registered in the adapter. + */ + get locales(): string[] { + return this.options.adapter.locales; + } + + /** + * Formats and returns a message string using the adapter. Locale + * negotiation and fallbacks are handled by this function bound to the i18n + * instance. + * + * @param locale Locale to use when translating. + * @param messageKey Message key to be used. + * @param args Variables to be passed for formatting the message data. + */ + translate< + L extends Locales, + MK extends MessageKey>, + >( + locale: L, + messageKey: MK, + ...args: MessageVariables, MK> + ): string { + debug(`Translating message '${messageKey}' in locale '${locale}'`); + + const negotiatedLocales = this.options.adapter.negotiateLocales(locale); + for (const negotiatedLocale of negotiatedLocales) { + debug(`Translating using '${negotiatedLocale}' (from '${locale}')`); + const tr = this.options.adapter.translate( + negotiatedLocale, + messageKey, + ...args, + ); + if (tr != null) return tr; + + debug(`Message ${messageKey} not found in ${negotiatedLocale}`); + const result = this.options?.onMissingKey?.({ + fallback: false, + requestedLocale: locale, + currentLocale: negotiatedLocale, + messageKey: messageKey, + }); + if (typeof result === "string") return result; + else continue; + } + + // falls back + debug(`Falling back to '${this.fallbackLocale}'`); + const tr = this.options.adapter.translate( + this.fallbackLocale, + messageKey, + ...args, + ); + if (tr != null) return tr; + + const result = this.options?.onMissingKey?.({ + fallback: true, + requestedLocale: locale, + currentLocale: this.fallbackLocale, + messageKey: messageKey, + }); + if (typeof result === "string") return result; + + throw new Error( + `Couldn't find the message '${messageKey}' in the fallback locale '${this.fallbackLocale}'. ` + + `The fallback locale must have all the messages you reference.`, + ); + } + + /** + * Predicate middleware for filtering messages that contains the message + * translated using the locale negotiated for the user. This takes in the + * message key and the variables (if any) as arguments. + * + * This is very useful when custom keyboards are present in the bot, as the + * translated messages may be inconvenient to be hard-coded and handled + * manually. + * + * @param messageKey Message key to be used. + * @param args Variables to be passed for formatting the message data. + * + * @example + * ```ts + * // A bug report button. + * bot.use(i18n.hears("feedback.report-button"), async (ctx) => { + * await ctx.send(ctx.translate("feedback.report-choose-category")); + * // ... + * }); + * + * // Or specific messages with specific values for variables. + * bot.use(i18n.hears("remind", { target: "me" }), (ctx) => {}); + * ``` + */ + hears>>( + messageKey: MK, + ...args: MessageVariables, MK> + ): >(ctx: FC) => ctx is HearsContext { + return >( + ctx: FC, + ): ctx is HearsContext => { + const expected = ctx.translate(messageKey, ...args); + return ctx.hasText(expected); + }; + } + + /** + * Middleware for the i18n plugin. + * + * This middleware installs the `translate` function to the context object + * of the current update bounded to the locale negotiated. It is important + * that you install this middleware before you install any other middleware + * that calls the `translate` function. + */ + middleware(): MiddlewareFn> { + const { + fallbackLocale, + localeNegotiator, + } = this.options; + + const withLocale = (locale: string) => + this.translate.bind(this, locale) as TranslateFunction; + + return async function (ctx, next): Promise { + let currentLocale: string = fallbackLocale; + let boundTranslate: TranslateFunction; + + function useLocale(locale: string) { + if (!isValidLocale(locale)) { + throw new Error( + "Cannot use an invalid locale for translations.", + ); + } + debug(`Using locale '${locale}' for translating`); + currentLocale = locale; + boundTranslate = withLocale(locale); + } + function getLocale(): string { + return currentLocale; + } + async function negotiateLocale(): Promise { + const negotiated = await localeNegotiator?.(ctx); + debug( + negotiated == null + ? `Could not negotiate a valid language. Falling back to '${fallbackLocale}'` + : `Negotiated locale: '${negotiated}'`, + ); + useLocale(negotiated ?? fallbackLocale); + return negotiated; // todo: decide whether to have `?? fallbackLocale` + } + + Object.defineProperty(ctx, "i18n", { + writable: true, + value: { + useLocale: useLocale, + getLocale: getLocale, + negotiateLocale: negotiateLocale, + } satisfies I18nFlavor["i18n"], + }); + + ctx.translate = function < + MK extends MessageKey>, + >( + messageKey: MK, + ...args: Messages[MK] extends never ? [] + : Messages[string] extends Messages[MK] + ? [variables?: Messages[MK]] + : [variables: Messages[MK]] + ): string { + return boundTranslate(messageKey, ...args); + }; + + await negotiateLocale(); // initial negotiation + await next(); + }; + } +} diff --git a/plugin_test.ts b/plugin_test.ts new file mode 100644 index 00000000..26a378e8 --- /dev/null +++ b/plugin_test.ts @@ -0,0 +1,707 @@ +import { Api, Composer, Context } from "@grammyjs/grammy"; +import type { Update, UserFromGetMe } from "@grammyjs/grammy/types"; +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { assertSpyCall, assertSpyCalls, spy } from "@std/testing/mock"; +import { compareSimilarity } from "@std/text/compare-similarity"; +import { + defaultLocaleNegotiator, + I18n, + type I18nFlavor, + type MissingKeyEvent, + type NegotiatorResult, +} from "./plugin.ts"; +import type { + FormatAdapter, + Locales, + LocalesTypings, + MessageKey, + Messages, +} from "./types.ts"; + +class CustomAdapter implements FormatAdapter { + #locales: string[] = []; + #messages: Record> = {}; + + get locales(): string[] { + return this.#locales; + } + + negotiateLocales(requestedLocale: string): string[] { + // note: a very fake implementation of negotiation, this is not at all how it works. + const [base] = requestedLocale.split("-", 1); + const matchingBases = this.#locales + .filter((locale) => locale.split("-", 1)[0] === base) + .sort(compareSimilarity(requestedLocale)); + return matchingBases; + } + + setMessage(locale: string, key: string, message: string) { + if (!this.#locales.includes(locale)) { + this.#locales.push(locale); + } + this.#messages[locale] ??= {}; + this.#messages[locale][key] = message; + } + + #getMessage(locale: string, key: string) { + return this.#messages[locale]?.[key]; + } + + #format, MK extends MessageKey>( + message: string, + variables?: M[MK], + ): string { + let start: number | null = null; + let end: number | null = null; + + for (let i = 0; i <= message.length; i++) { + if (start === i - 1) { + if (message[i] === "%") { + start = null; + continue; + } + } + if (message[i] === "%") { + start = i; + continue; + } + if ( + start != null && message.charCodeAt(i) >= 97 && + message.charCodeAt(i) <= 122 + ) { + end = i; + continue; + } + if (start != null && end != null && variables != null) { + const varname = message.slice(start + 1, end + 1); + if (varname in variables && variables[varname] != null) { + const pre = message.slice(0, start) + variables[varname]; + message = pre + message.slice(end + 1); + i = pre.length - 1; + } + start = null, end = null; + } + } + return message; + } + + translate< + L extends Locales, + M extends Messages, + MK extends MessageKey, + >( + locale: L, + messageKey: MK, + ...args: M[MK] extends never ? [] + : Messages[string] extends M[MK] + ? [variables?: M[MK]] + : [variables: M[MK]] + ): string | undefined { + const variables = args[0]; + const message = this.#getMessage(locale, messageKey); + if (message == null) return; + return this.#format(message, variables); + } +} + +describe("format adapters", () => { + it("custom adapter", () => { + const adapter = new CustomAdapter(); + + expect(adapter.translate("en", "msg")).toBe(undefined); + + adapter.setMessage("en", "msg", "message"); + expect(adapter.translate("en", "msg")).toBe("message"); + expect(adapter.translate("de", "msg")).toBe(undefined); + + adapter.setMessage("en", "hello", "hello %name"); + expect(adapter.translate("en", "hello")).toBe("hello %name"); + + adapter.setMessage("en", "hello", "hello %%name"); + expect(adapter.translate("en", "hello")).toBe("hello %%name"); + + adapter.setMessage("en", "hello", "hello %%name"); + expect(adapter.translate("en", "hello", { name: "durov" })) + .toBe("hello %%name"); + + adapter.setMessage("en", "hello", "hello %name"); + expect(adapter.translate("en", "hello", { name: "durov" })) + .toBe("hello durov"); + }); +}); + +describe("i18n", () => { + it("should throw when fallback locale is invalid", () => { + expect(() => + new I18n({ + adapter: new CustomAdapter(), + fallbackLocale: "en-", + }) + ).toThrow("Must set a valid fallback (default) locale."); + + expect(() => + // @ts-expect-error intentional for test + new I18n({ + adapter: new CustomAdapter(), + }) + ).toThrow("Must set a valid fallback (default) locale."); + }); + + it("should set fallback locale correctly", () => { + const adapter = new CustomAdapter(); + const i18n = new I18n({ + adapter: adapter, + fallbackLocale: "en", + }); + expect(i18n.fallbackLocale).toBe("en"); + }); + + type TestContext = I18nFlavor; + function mkctx(languageCode: string): TestContext; + function mkctx(update: Omit): TestContext; + function mkctx(arg: string | Omit): TestContext { + if (typeof arg === "string") { + return mkctx({ + message: { from: { language_code: arg } }, + } as Update); + } else { + return new Context( + arg as Update, + new Api("dummy"), + {} as UserFromGetMe, + ) as TestContext; + } + } + const next = () => Promise.resolve(); + + // fallback + it("should handle translations and falling back", () => { + const adapter = new CustomAdapter(); + adapter.setMessage("en", "key", "value in en"); + const i18n = new I18n({ + adapter: adapter, + fallbackLocale: "en", + }); + expect(i18n.translate("en", "key")).toBe("value in en"); + expect(i18n.translate("de", "key")).toBe("value in en"); + + adapter.setMessage("de", "key", "value in de"); + expect(i18n.translate("de", "key")).toBe("value in de"); + + // should support falling back to related locales + // de-US (absent) -> de -> en + expect(i18n.translate("de-US", "key")).toBe("value in de"); + + adapter.setMessage("de-US", "key", "value in de-US"); + // should support direct + expect(i18n.translate("de-US", "key")).toBe("value in de-US"); + + // de -> de-US + adapter.setMessage("de-US", "key-1", "value-1 in de-US"); + expect(i18n.translate("de", "key-1")).toBe("value-1 in de-US"); + }); + + // translation + + // locale negotiation + it("should use default locale negotiation", async () => { + const defaultNegotiatorSpy = spy(defaultLocaleNegotiator); + const adapter = new CustomAdapter(); + const i18n = new I18n({ + adapter: adapter, + fallbackLocale: "en", + localeNegotiator: defaultNegotiatorSpy, + }); + + const composer = new Composer(); + composer.use(i18n.middleware()); + + await composer.middleware()(mkctx("ml"), next); + + assertSpyCalls(defaultNegotiatorSpy, 1); + assertSpyCall(defaultNegotiatorSpy, 0, { returned: "ml" }); + + await composer.middleware()(mkctx("es"), next); + assertSpyCalls(defaultNegotiatorSpy, 2); + assertSpyCall(defaultNegotiatorSpy, 1, { returned: "es" }); + }); + + it("should use custom locale negotiation", async () => { + const map = ["fr", "zh", "nl"]; + const custom = spy((ctx: TestContext) => { + return ctx.from?.id != null && map.length > ctx.from.id + ? map[ctx.from.id] + : "ro"; + }); + const adapter = new CustomAdapter(); + const i18n = new I18n({ + adapter: adapter, + fallbackLocale: "en", + localeNegotiator: custom, + }); + + const composer = new Composer(); + composer.use(i18n.middleware()); + + for (const [index, lang] of map.entries()) { + const ctx = mkctx({ + message: { from: { id: index, language_code: lang } }, + } as Update); + await composer.middleware()(ctx, next); + assertSpyCall(custom, index, { returned: lang }); + } + + assertSpyCalls(custom, map.length); + + const ctx = mkctx({ + message: { from: { language_code: "de" } }, + } as Update); + await composer.middleware()(ctx, next); + assertSpyCall(custom, map.length, { returned: "ro" }); + + assertSpyCalls(custom, map.length + 1); + }); + + it("should not let negotiator set invalid locale", async () => { + const adapter = new CustomAdapter(); + const i18n = new I18n({ + adapter: adapter, + fallbackLocale: "en", + localeNegotiator: () => { + return "kk-*"; // something invalid + }, + }); + const composer = new Composer(); + composer.use(i18n.middleware()); + + const ctx = mkctx({} as Update); + await expect(composer.middleware()(ctx, next)) + .rejects.toThrow("Cannot use an invalid locale for translations."); + }); + + it("should use fallback locale if negotiator returns null", async () => { + const adapter = new CustomAdapter(); + const i18n = new I18n({ + adapter: adapter, + fallbackLocale: "en", + localeNegotiator: () => { + return undefined; // nullish + }, + }); + const composer = new Composer(); + composer.use(i18n.middleware()); + adapter.setMessage("en", "msg", "value"); + + composer.use((ctx) => { + throw new Error(ctx.translate("msg")); + }); + + const ctx = mkctx({} as Update); + await expect(composer.middleware()(ctx, next)) + .rejects.toThrow("value"); + }); + + // on missing keys + it("should throw error on missing keys", () => { + const adapter = new CustomAdapter(); + const i18n = new I18n({ + adapter: adapter, + fallbackLocale: "de", + }); + expect(() => i18n.translate("en", "key-1")) + .toThrow( + `Couldn't find the message 'key-1' in the fallback locale 'de'. ` + + "The fallback locale must have all the messages you reference.", + ); + + adapter.setMessage("de", "msg", "value in de"); + adapter.setMessage("de-US", "msg-1", "value in de-US"); + adapter.setMessage("de-IN", "msg-2", "value in de-IN"); + expect(i18n.translate("de", "msg-1")).toBe("value in de-US"); + }); + + it("should use the missing key handler", () => { + const handler = spy((_event: MissingKeyEvent) => { + throw new Error("missed"); + }); + const adapter = new CustomAdapter(); + const i18n = new I18n({ + adapter: adapter, + fallbackLocale: "en", + onMissingKey: handler, + }); + expect(() => i18n.translate("en-US", "non-existent")).toThrow("missed"); + + assertSpyCalls(handler, 1); + assertSpyCall(handler, 0, { + error: { Class: Error, msgIncludes: "missed" }, + }); + }); + + it("should use the missing key handler", () => { + const handler = spy((_event: MissingKeyEvent) => { + return "fallback message to show instead"; + }); + const adapter = new CustomAdapter(); + const i18n = new I18n({ + adapter: adapter, + fallbackLocale: "en", + onMissingKey: handler, + }); + // fallback + adapter.setMessage("ab-CD", "msg", "value"); + adapter.setMessage("ab-XY", "msg", "value"); + adapter.setMessage("ab", "msg-1", "value"); + + expect(i18n.translate("ab", "msg")) + .toBe("fallback message to show instead"); + + expect(i18n.translate("en", "non-existent")) + .toBe("fallback message to show instead"); + + assertSpyCalls(handler, 2); + assertSpyCall(handler, 1, { + returned: "fallback message to show instead", + }); + }); + + it("should use the missing key handler", () => { + const handler = spy((event: MissingKeyEvent) => { + if (event.fallback) { + return "fallback message to show instead"; + } + return; + }); + const adapter = new CustomAdapter(); + const i18n = new I18n({ + adapter: adapter, + fallbackLocale: "en", + onMissingKey: handler, + }); + adapter.setMessage("en-US", "msg-1", "msg-1 value"); + adapter.setMessage("en-IN", "msg-2", "msg-2 value"); + adapter.setMessage("en-UK", "msg-3", "msg-3 value"); + + expect(i18n.translate("en-IN", "msg")) + .toBe("fallback message to show instead"); + + assertSpyCalls(handler, 4); + assertSpyCall(handler, 0, { + args: [{ + currentLocale: "en-IN", + fallback: false, + messageKey: "msg", + requestedLocale: "en-IN", + }], + returned: undefined, + }); + assertSpyCall(handler, 1, { + args: [{ + currentLocale: "en-US", + fallback: false, + messageKey: "msg", + requestedLocale: "en-IN", + }], + returned: undefined, + }); + assertSpyCall(handler, 2, { + args: [{ + currentLocale: "en-UK", + fallback: false, + messageKey: "msg", + requestedLocale: "en-IN", + }], + returned: undefined, + }); + assertSpyCall(handler, 3, { + args: [{ + currentLocale: "en", + fallback: true, + messageKey: "msg", + requestedLocale: "en-IN", + }], + returned: "fallback message to show instead", + }); + }); + + // middleware + describe("middleware", () => { + it("translate() should translate using the negotiated locale", async () => { + const adapter = new CustomAdapter(); + adapter.setMessage("en", "msg", "hello in en"); + adapter.setMessage("de", "msg", "hello in de"); + const i18n = new I18n({ adapter, fallbackLocale: "en" }); + + const composer = new Composer(); + composer.use(i18n.middleware()); + + let result: string | undefined; + composer.use((ctx) => { + result = ctx.translate("msg"); + }); + + await composer.middleware()(mkctx("de"), next); + expect(result).toBe("hello in de"); + + await composer.middleware()(mkctx("en"), next); + expect(result).toBe("hello in en"); + }); + + it("translate() should fall back when locale has no message", async () => { + const adapter = new CustomAdapter(); + adapter.setMessage("en", "msg", "hello in en"); + const i18n = new I18n({ adapter, fallbackLocale: "en" }); + + const composer = new Composer(); + composer.use(i18n.middleware()); + + let result: string | undefined; + composer.use((ctx) => { + result = ctx.translate("msg"); + }); + + // fr -> en + await composer.middleware()(mkctx("fr"), next); + expect(result).toBe("hello in en"); + }); + + it("getLocale() should return the negotiated locale", async () => { + const adapter = new CustomAdapter(); + const i18n = new I18n({ adapter, fallbackLocale: "en" }); + + const composer = new Composer(); + composer.use(i18n.middleware()); + + let locale: string | undefined; + composer.use((ctx) => { + locale = ctx.i18n.getLocale(); + }); + + await composer.middleware()(mkctx("de"), next); + expect(locale).toBe("de"); + + await composer.middleware()(mkctx("fr"), next); + expect(locale).toBe("fr"); + }); + + it("getLocale() should return fallback locale when negotiator returns undefined", async () => { + const adapter = new CustomAdapter(); + const i18n = new I18n({ + adapter, + fallbackLocale: "en", + localeNegotiator: () => undefined, + }); + + const composer = new Composer(); + composer.use(i18n.middleware()); + + let locale: string | undefined; + composer.use((ctx) => { + locale = ctx.i18n.getLocale(); + }); + + await composer.middleware()(mkctx({} as Update), next); + expect(locale).toBe("en"); + }); + + it("useLocale() should override the negotiated locale", async () => { + const adapter = new CustomAdapter(); + adapter.setMessage("en", "msg", "hello in en"); + adapter.setMessage("fr", "msg", "hello in fr"); + const i18n = new I18n({ adapter, fallbackLocale: "en" }); + + const composer = new Composer(); + composer.use(i18n.middleware()); + + let result: string | undefined; + composer.use((ctx) => { + ctx.i18n.useLocale("fr"); + result = ctx.translate("msg"); + }); + + await composer.middleware()(mkctx("de"), next); + expect(result).toBe("hello in fr"); // de -> fr (override) + }); + + it("useLocale() should update getLocale", async () => { + const adapter = new CustomAdapter(); + const i18n = new I18n({ adapter, fallbackLocale: "en" }); + + const composer = new Composer(); + composer.use(i18n.middleware()); + + let before: string | undefined; + let after: string | undefined; + composer.use((ctx) => { + before = ctx.i18n.getLocale(); + ctx.i18n.useLocale("ja"); + after = ctx.i18n.getLocale(); + }); + + await composer.middleware()(mkctx("de"), next); + expect(before).toBe("de"); // worked as expected first + expect(after).toBe("ja"); // and then got overridden: de -> ja + }); + + it("useLocale() should throw on invalid locale", async () => { + const adapter = new CustomAdapter(); + const i18n = new I18n({ adapter, fallbackLocale: "en" }); + + const composer = new Composer(); + composer.use(i18n.middleware()); + composer.use((ctx) => { + ctx.i18n.useLocale("not-valid-*"); + }); + + await expect(composer.middleware()(mkctx("en"), next)) + .rejects.toThrow( + "Cannot use an invalid locale for translations.", + ); + }); + + it("negotiateLocale() should re-run the negotiator", async () => { + let callCount = 0; + const locales = ["de", "fr"]; + const custom = spy((_ctx: TestContext) => locales[callCount++]); + + const adapter = new CustomAdapter(); + adapter.setMessage("en", "msg", "hello in en"); + adapter.setMessage("de", "msg", "hello in de"); + adapter.setMessage("fr", "msg", "hello in fr"); + const i18n = new I18n({ + adapter, + fallbackLocale: "en", + localeNegotiator: custom, + }); + + const composer = new Composer(); + composer.use(i18n.middleware()); + + const results: string[] = []; + composer.use(async (ctx) => { + results.push(ctx.translate("msg")); // de (initial) + await ctx.i18n.negotiateLocale(); + results.push(ctx.translate("msg")); // fr (re-negotiated) + }); + + await composer.middleware()(mkctx({} as Update), next); + expect(results).toEqual(["hello in de", "hello in fr"]); + assertSpyCalls(custom, 2); + }); + + it("negotiateLocale() should return the negotiated locale", async () => { + const adapter = new CustomAdapter(); + const i18n = new I18n({ adapter, fallbackLocale: "en" }); + + const composer = new Composer(); + composer.use(i18n.middleware()); + + let negotiated: NegotiatorResult; + composer.use(async (ctx) => { + negotiated = await ctx.i18n.negotiateLocale(); + }); + + await composer.middleware()(mkctx("ja"), next); + expect(negotiated).toBe("ja"); + }); + + it("negotiateLocale() should fall back and return undefined when negotiator returns undefined", async () => { + const adapter = new CustomAdapter(); + adapter.setMessage("en", "msg", "hello in en"); + const i18n = new I18n({ + adapter, + fallbackLocale: "en", + localeNegotiator: () => undefined, + }); + + const composer = new Composer(); + composer.use(i18n.middleware()); + + let negotiated: NegotiatorResult; + let result: string | undefined; + composer.use(async (ctx) => { + negotiated = await ctx.i18n.negotiateLocale(); + result = ctx.translate("msg"); + }); + + await composer.middleware()(mkctx({} as Update), next); + expect(negotiated).toBeUndefined(); + expect(result).toBe("hello in en"); + }); + }); + + // hears + describe("hears", () => { + function mktextctx(text: string, languageCode = "en"): TestContext { + return new Context( + { + message: { + text: text, + from: { language_code: languageCode }, + }, + } as unknown as Update, + new Api("dummy"), + {} as UserFromGetMe, + ) as TestContext; + } + + it("should match message text against translated key", async () => { + const adapter = new CustomAdapter(); + adapter.setMessage("en", "menu-btn", "menu in en"); + adapter.setMessage("de", "menu-btn", "menu in de"); + const i18n = new I18n({ adapter, fallbackLocale: "en" }); + + const composer = new Composer(); + composer.use(i18n.middleware()); + + const nextmw = spy(); + composer.filter(i18n.hears("menu-btn"), nextmw); + + await composer.middleware()(mktextctx("menu in en", "en"), next); + assertSpyCalls(nextmw, 1); + await composer.middleware()(mktextctx("menu in de", "de"), next); + assertSpyCalls(nextmw, 2); + await composer.middleware()(mktextctx("blah", "en"), next); + assertSpyCalls(nextmw, 2); // didnt match + }); + + it("should match against the active locale set by useLocale", async () => { + const adapter = new CustomAdapter(); + adapter.setMessage("en", "menu-btn", "menu in en"); + adapter.setMessage("fr", "menu-btn", "menu in fr"); + const i18n = new I18n({ adapter, fallbackLocale: "en" }); + + const composer = new Composer(); + composer.use(i18n.middleware()); + composer.use((ctx, next) => { + ctx.i18n.useLocale("fr"); + return next(); + }); + + const nextmw = spy(); + composer.filter(i18n.hears("menu-btn"), nextmw); + + await composer.middleware()(mktextctx("menu in fr", "en"), next); + assertSpyCalls(nextmw, 1); + await composer.middleware()(mktextctx("menu in en", "en"), next); + assertSpyCalls(nextmw, 1); // didnt match + }); + + it("should fall back to fallback locale when no translation found", async () => { + const adapter = new CustomAdapter(); + adapter.setMessage("en", "menu-btn", "menu in en"); + const i18n = new I18n({ adapter, fallbackLocale: "en" }); + + const composer = new Composer(); + composer.use(i18n.middleware()); + + const nextmw = spy(); + composer.filter(i18n.hears("menu-btn"), nextmw); + + // fr -> en + await composer.middleware()(mktextctx("menu in en", "fr"), next); + assertSpyCalls(nextmw, 1); + }); + }); +}); diff --git a/scripts/dnt.ts b/scripts/dnt.ts deleted file mode 100644 index dfe8fb5c..00000000 --- a/scripts/dnt.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - dirname, - fromFileUrl, - join, -} from "https://deno.land/std@0.217.0/path/mod.ts"; -import { build, emptyDir } from "jsr:@deno/dnt"; - -import package_ from "./package.json" with { type: "json" }; - -const version = Deno.args[0]; -if (!version) { - throw new Error("Provide the version as an argument"); -} - -const rootDir = join(dirname(fromFileUrl(import.meta.url)), "../"); -const outDir = join(rootDir, "out"); - -await emptyDir(outDir); - -await build({ - outDir, - shims: { - deno: true, - }, - package: { - version, - ...package_, - }, - esModule: false, - entryPoints: ["./src/mod.ts"], - mappings: { - "https://lib.deno.dev/x/grammy@1.x/mod.ts": { - name: "grammy", - version: "^1.10.0", - peerDependency: true, - }, - "https://lib.deno.dev/x/grammy@1.x/types.ts": { - name: "grammy", - version: "^1.10.0", - subPath: "types", - peerDependency: true, - }, - "https://deno.land/x/fluent@0.0.1/bundle/mod.ts": { - name: "@fluent/bundle", - version: "^0.17.1", - }, - "https://deno.land/x/fluent@0.0.1/langneg/mod.ts": { - name: "@fluent/langneg", - version: "^0.6.2", - }, - "./tests/platform.deno.ts": "./tests/platform.node.ts", - }, -}); - -Deno.copyFileSync(join(rootDir, "LICENSE"), join(outDir, "LICENSE")); diff --git a/scripts/package.json b/scripts/package.json deleted file mode 100644 index 0dc61e6a..00000000 --- a/scripts/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "@grammyjs/i18n", - "description": "Internationalization plugin for grammY based on Fluent.", - "keywords": [ - "bot", - "bot-framework", - "fluent", - "ftl", - "globalization", - "grammy", - "grammy-middleware", - "i18n", - "internationalization", - "l10n", - "languages", - "locales", - "localization", - "mozilla", - "telegram", - "telegram-bot", - "translation" - ], - "license": "MIT", - "homepage": "https://github.com/grammyjs/i18n#readme", - "repository": "github:grammyjs/i18n", - "bugs": { - "url": "https://github.com/grammyjs/i18n/issues" - }, - "author": { - "name": "dcdunkan", - "email": "dcdunkan@gmail.com", - "url": "https://github.com/dcdunkan" - }, - "engines": { - "node": ">=12" - }, - "publishConfig": { - "access": "public" - } -} diff --git a/src/README.md b/src/README.md deleted file mode 100644 index c5c22166..00000000 --- a/src/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# grammY Internationalization - -Internationalization plugin for [grammY](https://grammy.dev) based on [Project Fluent](https://projectfluent.org). - -Please visit the [main page of the repository](https://github.com/grammyjs/i18n). - -Also, be sure to check out [the grammY i18n page](https://grammy.dev/plugins/i18n.html) in the docs. diff --git a/src/deps.ts b/src/deps.ts deleted file mode 100644 index 2fa195ee..00000000 --- a/src/deps.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { - FluentBundle, - FluentResource, - type FluentVariable, -} from "https://deno.land/x/fluent@0.0.1/bundle/mod.ts"; - -export { negotiateLanguages } from "https://deno.land/x/fluent@0.0.1/langneg/mod.ts"; - -export { - type Context, - type HearsContext, - type MiddlewareFn, -} from "https://lib.deno.dev/x/grammy@1.x/mod.ts"; - -export { extname, join, SEP } from "https://deno.land/std@0.192.0/path/mod.ts"; - -export { walk, walkSync } from "https://deno.land/std@0.192.0/fs/walk.ts"; diff --git a/src/fluent.ts b/src/fluent.ts deleted file mode 100644 index b11913fc..00000000 --- a/src/fluent.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { - defaultWarningHandler, - TranslateWarnings, - WarningHandler, -} from "./warning.ts"; -import type { - AddTranslationOptions, - FluentBundleOptions, - FluentOptions, - LocaleId, - MaybeArray, - TranslationVariables, -} from "./types.ts"; -import { FluentBundle, FluentResource, negotiateLanguages } from "./deps.ts"; - -export class Fluent { - private readonly bundles = new Set(); - private defaultBundle?: FluentBundle; - private handleWarning: WarningHandler = defaultWarningHandler(); - - constructor(options?: FluentOptions) { - if (options?.warningHandler) { - this.handleWarning = options.warningHandler; - } - } - - public async addTranslation(options: AddTranslationOptions): Promise { - const source = "source" in options && "filePath" in options - ? undefined - : "source" in options && options.source - ? options.source - : "filePath" in options && options.filePath - ? await Deno.readTextFile(options.filePath) - : undefined; - if (source === undefined) { - throw new Error( - "Provide either filePath or string source as translation source.", - ); - } - this.addBundle(options, source); - } - - public addTranslationSync(options: AddTranslationOptions): void { - const source = "source" in options && "filePath" in options - ? undefined - : "source" in options && options.source - ? options.source - : "filePath" in options && options.filePath - ? Deno.readTextFileSync(options.filePath) - : undefined; - if (source === undefined) { - throw new Error( - "Provide either filePath or string source as translation source.", - ); - } - this.addBundle(options, source); - } - - public translate( - localeOrLocales: MaybeArray, - path: string, - context?: TranslationVariables, - ): string { - const locales = Array.isArray(localeOrLocales) - ? localeOrLocales - : [localeOrLocales]; - const [messageId, attributeName] = path.split(".", 2); - const bundles = this.matchBundles(locales); - const warning = { locales, path, matchedBundles: bundles, context }; - - for (const bundle of bundles) { - const message = bundle.getMessage(messageId); - if (message === undefined) { - this.handleWarning({ - ...warning, - type: TranslateWarnings.MISSING_MESSAGE, - bundle, - messageId, - }); - continue; - } - let pattern = message.value ?? ""; - if (attributeName) { - if (message.attributes?.[attributeName]) { - pattern = message.attributes?.[attributeName]; - } else { - this.handleWarning({ - ...warning, - type: TranslateWarnings.MISSING_ATTRIBUTE, - attributeName, - bundle, - messageId, - }); - continue; - } - } - return bundle.formatPattern(pattern, context); - } - // None of the bundles worked out for the given message. - this.handleWarning({ - ...warning, - type: TranslateWarnings.MISSING_TRANSLATION, - }); - return `{${path}}`; - } - - /** - * Returns translation function bound to the specified locale(s). - */ - public withLocale(localeOrLocales: MaybeArray) { - return this.translate.bind(this, localeOrLocales); - } - - private createBundle( - locales: MaybeArray, - source: string, - bundleOptions?: FluentBundleOptions, - ): FluentBundle { - const bundle = new FluentBundle(locales, bundleOptions); - const resource = new FluentResource(source); - const errors = bundle.addResource(resource, { allowOverrides: true }); - if (errors.length === 0) return bundle; - for (const error of errors) console.error(error); - throw new Error( - "Failed to add resource to the bundle, see the errors above.", - ); - } - - private addBundle(options: AddTranslationOptions, source: string) { - const bundle = this.createBundle( - options.locales, - source, - options.bundleOptions, - ); - this.bundles.add(bundle); - if (!this.defaultBundle || options.isDefault) { - this.defaultBundle = bundle; - } - } - - private matchBundles(locales: LocaleId[]): Set { - const bundles = Array.from(this.bundles); - - // Building a list of all the registered locales - const availableLocales = bundles.reduce( - (locales, bundle) => [...locales, ...bundle.locales], - [], - ); - // Find the best match for the specified locale - const matchedLocales = negotiateLanguages(locales, availableLocales); - // For matched locales, find the first bundle they're in. - const matchedBundles = matchedLocales.map((locale) => { - return bundles.find((bundle) => bundle.locales.includes(locale)); - }).filter((bundle) => bundle !== undefined) as FluentBundle[]; - - // Add the default bundle to the end, so it'll be used if other bundles fails. - if (this.defaultBundle) matchedBundles.push(this.defaultBundle); - return new Set(matchedBundles); - } -} diff --git a/src/i18n.ts b/src/i18n.ts deleted file mode 100644 index 05e9994b..00000000 --- a/src/i18n.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { Context, HearsContext, MiddlewareFn } from "./deps.ts"; -import { Fluent } from "./fluent.ts"; -import type { - I18nConfig, - I18nFlavor, - LoadLocaleOptions, - LocaleId, - TranslateFunction, - TranslationVariables, -} from "./types.ts"; -import { readLocalesDir, readLocalesDirSync } from "./utils.ts"; - -export class I18n { - private config: I18nConfig; - readonly fluent: Fluent; - readonly locales = new Array(); - - constructor(config: Partial>) { - this.config = { defaultLocale: "en", ...config }; - this.fluent = new Fluent(this.config.fluentOptions); - if (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. - */ - async loadLocalesDir(directory: string): Promise { - const localeFiles = await readLocalesDir(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. - */ - loadLocalesDirSync(directory: string): void { - for (const file of readLocalesDirSync(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 - * @param options Options to specify the source and behavior of the translation - */ - async loadLocale( - locale: LocaleId, - options: LoadLocaleOptions, - ): Promise { - await this.fluent.addTranslation({ - locales: locale, - isDefault: locale === this.config.defaultLocale, - bundleOptions: this.config.fluentBundleOptions, - ...options, - }); - - this.locales.push(locale); - } - - /** - * Synchronously registers a locale in the Fluent instance based on the provided options. - * @param locale Locale ID - * @param options Options to specify the source and behavior of the translation - */ - loadLocaleSync( - locale: LocaleId, - options: LoadLocaleOptions, - ): void { - this.fluent.addTranslationSync({ - locales: locale, - isDefault: locale === this.config.defaultLocale, - bundleOptions: this.config.fluentBundleOptions, - ...options, - }); - - this.locales.push(locale); - } - - /** - * Gets a message by its key from the specified locale. - * Alias of `translate`. - */ - t( - locale: LocaleId, - key: string, - variables?: TranslationVariables, - ): string { - return this.translate(locale, key, variables); - } - - /** Gets a message by its key from the specified locale. */ - translate( - locale: LocaleId, - key: string, - variables?: TranslationVariables, - ): string { - return this.fluent.translate(locale, key, variables); - } - - /** Returns a middleware to .use on the `Bot` instance. */ - middleware(): MiddlewareFn { - return middleware(this.fluent, this.config); - } -} - -function middleware( - fluent: Fluent, - { - defaultLocale, - localeNegotiator, - useSession, - globalTranslationContext, - }: I18nConfig, -): MiddlewareFn { - return async function (ctx, next): Promise { - let translate: TranslateFunction; - - function useLocale(locale: LocaleId): void { - translate = fluent.withLocale(locale); - } - - async function getNegotiatedLocale(): Promise { - return await localeNegotiator?.(ctx) ?? - // deno-lint-ignore no-explicit-any - (await (useSession && (ctx as any).session))?.__language_code ?? - ctx.from?.language_code ?? - defaultLocale; - } - - async function setLocale(locale: LocaleId): Promise { - if (!useSession) { - throw new Error( - "You are calling `ctx.i18n.setLocale()` without setting `useSession` to `true` \ -in the configuration. It doesn't make sense because you cannot set a locale in \ -the session that way. When you call `ctx.i18n.setLocale()`, the bot tries to \ -store the user locale in the session storage. But since you don't have session \ -enabled, it cannot store the locale information in the session storage. You \ -should either enable sessions or use `ctx.i18n.useLocale()` instead.", - ); - } - - // deno-lint-ignore no-explicit-any - (await (ctx as any).session).__language_code = locale; - await negotiateLocale(); - } - - // Determining the locale to use for translations - async function negotiateLocale(): Promise { - const negotiatedLocale = await getNegotiatedLocale(); - useLocale(negotiatedLocale); - } - - Object.defineProperty(ctx, "i18n", { - value: { - fluent, - renegotiateLocale: negotiateLocale, - useLocale, - getLocale: getNegotiatedLocale, - setLocale, - }, - // Allow redefine property. This is necessary to be able to install the plugin - // inside the conversation even if the plugin is already installed globally. - writable: true, - }); - - ctx.translate = ( - key: string, - translationVariables?: TranslationVariables, - ): string => { - return translate(key, { - ...globalTranslationContext?.(ctx), - ...translationVariables, - }); - }; - ctx.t = ctx.translate; - - await negotiateLocale(); - await next(); - }; -} - -/** - * A filter middleware for listening to the messages send by the in their language. - * It is useful when you have to listen for custom keyboard texts. - * - * ```ts - * bot.filter(hears("menu-btn"), (ctx) => ...) - * ``` - * - * @param key Key of the message to listen for. - */ -export function hears(key: string) { - return function ( - ctx: C, - ): ctx is HearsContext { - const expected = ctx.t(key); - return ctx.hasText(expected); - }; -} diff --git a/src/mod.ts b/src/mod.ts deleted file mode 100644 index eb3e2e54..00000000 --- a/src/mod.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./types.ts"; -export { hears, I18n } from "./i18n.ts"; -export * from "./warning.ts"; -export * from "./fluent.ts"; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 076583c5..00000000 --- a/src/types.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Context, FluentBundle, FluentVariable } from "./deps.ts"; -import { Fluent } from "./fluent.ts"; -import { WarningHandler } from "./warning.ts"; - -export type MaybeArray = T | T[]; -export type LocaleId = string; -export type FluentBundleOptions = ConstructorParameters[1]; - -export type FilepathOrSource = { filePath: string } | { source: string }; - -export type AddTranslationOptions = { - locales: MaybeArray; - bundleOptions?: FluentBundleOptions; - isDefault?: boolean; -} & FilepathOrSource; - -export type LoadLocaleOptions = FilepathOrSource & { - isDefault?: boolean; - bundleOptions?: FluentBundleOptions; -}; - -export interface NestedTranslation { - belongsTo: LocaleId; - translationSource: string; -} - -export interface FluentOptions { - warningHandler?: WarningHandler; -} - -export type LocaleNegotiator = (ctx: C) => - | LocaleId - | undefined - | PromiseLike; - -export type TranslationVariables = Record< - K, - FluentVariable ->; -export type TranslateFunction = ( - key: string, - variables?: TranslationVariables, -) => string; - -export interface I18nFlavor { - /** I18n context namespace object */ - i18n: { - /** Fluent instance used internally. */ - fluent: Fluent; - /** Returns the current locale. */ - getLocale(): Promise; - /** - * Equivalent for manually setting the locale in session and calling `renegotiateLocale()`. - * If the `useSession` in the i18n configuration is set to true, sets the locale in session. - * Otherwise throws an error. - * You can suppress the error by using `useLocale()` instead. - * @param locale Locale ID to set in the session. - */ - setLocale(locale: LocaleId): Promise; - /** - * Sets the specified locale to be used for future translations. - * Effect lasts only for the duration of current update and is not preserved. - * Could be used to change the translation locale in the middle of update processing - * (e.g. when user changes the language). - * @param locale Locale ID to set. - */ - useLocale(locale: LocaleId): void; - /** - * You can manually trigger additional locale negotiation by calling this method. - * This could be useful if locale negotiation conditions has changed and new locale must be applied - * (e.g. user has changed the language and you need to display an answer in new locale). - */ - renegotiateLocale(): Promise; - }; - /** Translation function bound to the current locale. */ - translate: TranslateFunction; - /** Translation function bound to the current locale. */ - t: TranslateFunction; -} - -export interface I18nConfig { - /** - * A locale ID to use by default. - * This is used when locale negotiator and session (if enabled) returns an empty result. - * The default value is "_en_". - */ - defaultLocale: LocaleId; - /** - * Directory to load translations from. - */ - directory?: string; - /** - * Whether to use session to get and set language code. - * You must be using session with it. - */ - useSession?: boolean; - /** Configuration for the Fluent instance used internally. */ - fluentOptions?: FluentOptions; - /** Bundle options to use when adding a translation to the Fluent instance. */ - fluentBundleOptions?: FluentBundleOptions; - /** - * An optional function that determines which locale to use. - * See [Locale Negotiation](https://grammy.dev/plugins/i18n.html#custom-locale-negotiation) for more details. - */ - localeNegotiator?: LocaleNegotiator; - /** - * Convenience function for defining global variables that are used frequently in the translation context. - * Variables defined inside this can be used directly in the translation source file without having to specifying them when calling the translate function. - * It is possible to overwrite the values by re-defining them in the translation context of translate function. - * - * @example - * ```ts - * function defaultTranslationContext(ctx: Context) { - * return { - * name: ctx.from?.first_name || "", - * fullName: `${ctx.from?.first_name}${ - * ctx.from?.last_name ? ` ${ctx.from.last_name}` : "" - * }`, - * // ... - * }; - * } - * ``` - */ - globalTranslationContext?: (ctx: C) => Record; -} diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 27195c60..00000000 --- a/src/utils.ts +++ /dev/null @@ -1,81 +0,0 @@ -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 locales = new Set(); - - 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 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): NestedTranslation[] { - const files = new Array(); - const locales = new Set(); - - 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 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"), - }; - }); -} diff --git a/src/warning.ts b/src/warning.ts deleted file mode 100644 index fe2bd025..00000000 --- a/src/warning.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { FluentBundle } from "./deps.ts"; -import { TranslationVariables } from "./types.ts"; - -export enum TranslateWarnings { - MISSING_MESSAGE, - MISSING_ATTRIBUTE, - MISSING_TRANSLATION, -} - -export type Warning = - | TranslateMissingMessageWarning - | TranslateMissingAttributeWarning - | TranslateMissingTranslationWarning; - -export interface TranslateWarning { - type: TranslateWarnings; - locales: string[]; - path: string; - matchedBundles: Set; - context?: TranslationVariables; -} - -export interface TranslateMissingMessageWarning extends TranslateWarning { - type: TranslateWarnings.MISSING_MESSAGE; - messageId: string; - bundle: FluentBundle; -} - -export interface TranslateMissingAttributeWarning extends TranslateWarning { - type: TranslateWarnings.MISSING_ATTRIBUTE; - messageId: string; - attributeName: string; - bundle: FluentBundle; -} - -export interface TranslateMissingTranslationWarning extends TranslateWarning { - type: TranslateWarnings.MISSING_TRANSLATION; -} - -export type WarningHandler = (warning: Warning) => void; - -export function defaultWarningHandler( - // deno-lint-ignore no-explicit-any - logFn: (...args: any) => void = console.warn, -): WarningHandler { - return function (w: Warning) { - switch (w.type) { - case TranslateWarnings.MISSING_MESSAGE: - logFn( - `Translation message "${w.messageId}" is missing in the following ` + - `locale(s): ${w.bundle.locales.join(", ")}.`, - ); - break; - case TranslateWarnings.MISSING_ATTRIBUTE: - logFn( - `"${w.attributeName}" attribute is missing in message ` + - `"${w.messageId}" for locale(s): ${w.bundle.locales.join(", ")}`, - ); - break; - case TranslateWarnings.MISSING_TRANSLATION: - logFn( - `The translation "${w.path}" is missing in the locales: ` + - w.locales.join(", "), - ); - break; - default: - logFn(`Unknown warning: ${(w as { type: string }).type}`); - } - }; -} diff --git a/tests/bot.ts b/tests/bot.ts deleted file mode 100644 index 436b9ad7..00000000 --- a/tests/bot.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Bot, Context, session, SessionFlavor } from "./deps.ts"; -import { hears, I18n, I18nFlavor } from "../src/mod.ts"; -import { makeTempLocalesDir } from "./utils.ts"; - -interface SessionData { - apples: number; -} - -type MyContext = - & Context - & I18nFlavor - & SessionFlavor; - -export const bot = new Bot("TOKEN"); - -bot.use(session({ - initial: () => ({ apples: 0 }), -})); - -export const i18n = new I18n({ - defaultLocale: "en", - directory: makeTempLocalesDir(), - fluentBundleOptions: { - useIsolating: false, - }, - globalTranslationContext: (ctx) => ({ - name: ctx.from?.first_name || "", - }), -}); - -bot.use(i18n); - -bot.chatType("private").command("start", async (ctx) => { - await ctx.reply(ctx.t("greeting")); -}); - -bot.chatType("private").command("add", async (ctx) => { - ctx.session.apples++; - await ctx.reply(ctx.t("cart", { - apples: ctx.session.apples, - })); -}); - -bot.chatType("private").command("cart", async (ctx) => { - await ctx.reply(ctx.t("cart", { - apples: ctx.session.apples, - })); -}); - -bot.chatType("private").command("checkout", async (ctx) => { - ctx.session.apples = 0; - await ctx.reply(ctx.t("checkout")); -}); - -bot.filter(hears("hello"), async (ctx) => { - await ctx.reply(ctx.t("hello")); -}); diff --git a/tests/bot_test.ts b/tests/bot_test.ts deleted file mode 100644 index 9041fabb..00000000 --- a/tests/bot_test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { bot, i18n } from "./bot.ts"; -import { assertEquals, assertNotEquals, Chats } from "./deps.ts"; - -const chats = new Chats(bot); - -Deno.test("Load locales and check registered", () => { - assertEquals(i18n.locales.sort(), ["en", "ru"]); -}); - -Deno.test("English user", async (t) => { - const user = chats.newUser({ - id: 5147129198, // E N G L I S H - first_name: "English", - last_name: "User", - language_code: "en", - }); - - await t.step("Hears `Hello!` but not `Здравствуйте!`", async () => { - await user.sendMessage("Hello!"); - assertEquals(user.last.text, "Hello!"); - - await user.sendMessage("Здравствуйте!"); - assertNotEquals(user.last.text, "Здравствуйте!"); - }); - - await t.step("start command", async () => { - await user.command("start"); - assertEquals(user.last.text, "Hello, English!"); - }); - - await t.step("empty cart", async () => { - await user.command("cart"); - assertEquals( - user.last.text, - "Hey English, there are no apples in your cart.", - ); - }); - - await t.step("add one apple in session", async () => { - await user.command("add"); - assertEquals( - user.last.text, - "Hey English, there is one apple in your cart.", - ); - }); - - await t.step("checkout command", async () => { - await user.command("checkout"); - assertEquals(user.last.text, "Thank you for purchasing!"); - }); - - await t.step("check if cart is empty after checkout", async () => { - await user.command("cart"); - assertEquals( - user.last.text, - "Hey English, there are no apples in your cart.", - ); - }); - - await t.step("add 10 apples in session", async () => { - for (let i = 0; i < 10; i++) { - await user.command("add"); - } - assertEquals( - user.last.text, - "Hey English, there are 10 apples in your cart.", - ); - }); - - await t.step("there are 10 apples in session", async () => { - await user.command("cart"); - assertEquals( - user.last.text, - "Hey English, there are 10 apples in your cart.", - ); - }); - - await t.step("add 5 more apples in session", async () => { - for (let i = 0; i < 5; i++) { - await user.command("add"); - } - assertEquals( - user.last.text, - "Hey English, there are 15 apples in your cart.", - ); - }); - - await t.step("checkout again", async () => { - await user.command("checkout"); - assertEquals(user.last.text, "Thank you for purchasing!"); - }); -}); - -Deno.test("Russian user", async (t) => { - const user = chats.newUser({ - id: 182119199114, // R U S S I A N - first_name: "Russian", - last_name: "User", - language_code: "ru", - }); - - await t.step("Hears `Здравствуйте!` but not `Hello!`", async () => { - await user.sendMessage("Здравствуйте!"); - assertEquals(user.last.text, "Здравствуйте!"); - - await user.sendMessage("Hello!"); - assertNotEquals(user.last.text, "Hello!"); - }); - - await t.step("start command", async () => { - await user.command("start"); - assertEquals(user.last.text, "Здравствуйте, Russian!"); - }); - - await t.step("empty cart", async () => { - await user.command("cart"); - assertEquals( - user.last.text, - "Привет Russian, в твоей корзине нет яблок.", - ); - }); - - await t.step("checkout command", async () => { - await user.command("checkout"); - assertEquals(user.last.text, "Спасибо за покупку!"); - }); - - await t.step("check if cart is empty after checkout", async () => { - await user.command("cart"); - assertEquals( - user.last.text, - "Привет Russian, в твоей корзине нет яблок.", - ); - }); - - await t.step("add 10 apples in session", async () => { - for (let i = 0; i < 10; i++) { - await user.command("add"); - } - assertEquals( - user.last.text, - "Привет Russian, в твоей корзине 10 яблок.", - ); - }); - - await t.step("there are 10 apples in session", async () => { - await user.command("cart"); - assertEquals( - user.last.text, - "Привет Russian, в твоей корзине 10 яблок.", - ); - }); - - await t.step("add 5 more apples in session", async () => { - for (let i = 0; i < 5; i++) { - await user.command("add"); - } - assertEquals( - user.last.text, - "Привет Russian, в твоей корзине 15 яблок.", - ); - }); - - await t.step("checkout again", async () => { - await user.command("checkout"); - assertEquals(user.last.text, "Спасибо за покупку!"); - }); -}); diff --git a/tests/deps.ts b/tests/deps.ts deleted file mode 100644 index cbe754ac..00000000 --- a/tests/deps.ts +++ /dev/null @@ -1,120 +0,0 @@ -export { - assert, - assertEquals, - assertNotEquals, - assertStringIncludes, -} from "https://deno.land/std@0.217.0/testing/asserts.ts"; -export { join } from "https://deno.land/std@0.217.0/path/mod.ts"; -export { - Bot, - Context, - session, - type SessionFlavor, -} from "https://lib.deno.dev/x/grammy@1.x/mod.ts"; - -import { Bot, Context } from "https://lib.deno.dev/x/grammy@1.x/mod.ts"; -import { - Chat, - MessageEntity, - Update, - User, - UserFromGetMe, -} from "https://lib.deno.dev/x/grammy@1.x/types.ts"; - -export class Chats { - constructor(private bot: Bot, botInfo?: UserFromGetMe) { - this.bot.botInfo = botInfo ?? { - id: 42, - first_name: "Test Bot", - is_bot: true, - username: "test_bot", - 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(() => { - // deno-lint-ignore no-explicit-any - return { ok: true, result: true } as any; - }); - } - - newUser(user: Omit) { - return new TestUser(this.bot, user); - } -} - -interface BotResponse { - method: string; - // deno-lint-ignore no-explicit-any - payload: any; -} - -interface SendMessageOptions { - id?: number; - entities?: MessageEntity[]; -} - -type Optional = Pick, K> & Omit; - -class TestUser { - private user: User; - private chat: Chat.PrivateChat; - private message_id = 1; - private update_id = 100000; - public responses: BotResponse[] = []; - - constructor(private bot: Bot, user: Omit) { - this.user = { ...user, is_bot: false }; - this.chat = { - first_name: user.first_name, - id: user.id, - type: "private", - last_name: user.last_name, - username: user.username, - }; - this.bot.api.config.use((prev, method, payload, signal) => { - if (method.startsWith("send")) this.message_id++; - if ("chat_id" in payload && payload.chat_id === this.user.id) { - this.responses.push({ method, payload }); - } - return prev(method, payload, signal); - }); - } - - get last() { - return this.responses[this.responses.length - 1].payload; - } - - async sendUpdate(update: Optional) { - const updateToSend = { update_id: this.update_id++, ...update }; - await this.bot.handleUpdate(updateToSend); - return updateToSend; - } - - sendMessage(text: string, options?: SendMessageOptions) { - return this.sendUpdate({ - message: { - text, - chat: this.chat, - from: this.user, - date: Date.now(), - message_id: options?.id ?? this.message_id++, - entities: options?.entities, - }, - }); - } - - command(command: string, payload?: string) { - const text = `/${command}${payload ? ` ${payload}` : ""}`; - return this.sendMessage(text, { - entities: [{ - type: "bot_command", - offset: 0, - length: 1 + command.length, - }], - }); - } -} diff --git a/tests/fluent_test.ts b/tests/fluent_test.ts deleted file mode 100644 index 74a4ba5d..00000000 --- a/tests/fluent_test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { Fluent } from "../src/fluent.ts"; -import { assert, assertEquals, assertStringIncludes } from "./deps.ts"; -import { evalCode } from "./platform.deno.ts"; - -Deno.test("source for translations", async (t) => { - await t.step( - "should throw if both filepath and source are given", - async () => { - const { success, stderr } = await evalCode(` - const fluent = new Fluent(); - fluent.addTranslationSync({ locales: "locale", filePath: "f", source: "s" });`); - assert(success === false); - assertStringIncludes( - stderr, - "Provide either filePath or string source as translation source.", - ); - }, - ); - - await t.step( - "should throw if both filepath and source aren't given", - async () => { - const { success, stderr } = await evalCode(` - const fluent = new Fluent(); - fluent.addTranslationSync({ locales: "locale" });`); - assert(success === false); - assertStringIncludes( - stderr, - "Provide either filePath or string source as translation source.", - ); - }, - ); - - await t.step("string source (async)", async () => { - const fluent = new Fluent(); - await fluent.addTranslation({ locales: "locale", source: "msg = hi" }); - assertEquals(fluent.translate("locale", "msg"), "hi"); - }); - - await t.step("string source (sync)", () => { - const fluent = new Fluent(); - fluent.addTranslationSync({ locales: "locale", source: "msg = hi" }); - assertEquals(fluent.translate("locale", "msg"), "hi"); - }); - - const file = await Deno.makeTempFile(); - await Deno.writeTextFile(file, "msg = hi"); - - await t.step("filepath source (async)", async () => { - const fluent = new Fluent(); - await fluent.addTranslation({ locales: "locale", filePath: file }); - assertEquals(fluent.translate("locale", "msg"), "hi"); - }); - - await t.step("filepath source (sync)", () => { - const fluent = new Fluent(); - fluent.addTranslationSync({ locales: "locale", filePath: file }); - assertEquals(fluent.translate("locale", "msg"), "hi"); - }); - - await Deno.remove(file); -}); - -Deno.test("translate", async (t) => { - await t.step("translate", async () => { - const fluent = new Fluent(); - await fluent.addTranslation({ locales: "locale", source: "key = message" }); - assertEquals(fluent.translate("locale", "key"), "message"); - }); - - await t.step("falls back to default translation", async () => { - const fluent = new Fluent(); - await fluent.addTranslation({ - locales: "default", - source: "msg = hi", - isDefault: true, - }); - await fluent.addTranslation({ - locales: "notDefault", - source: "message = kek", - }); - assertEquals( - fluent.translate("notDefault", "msg"), - fluent.translate("default", "msg"), - ); - }); -}); - -const warningScenes: Record< - string, - { code: (warningHandler: string) => string; expected: string } -> = { - "missing translation": { - code: (wh) => ` - new Fluent({ warningHandler: ${wh} }).translate("locale", "path");`, - expected: 'The translation "path" is missing in the locales: locale', - }, - "missing message": { - code: (wh) => ` - const fluent = new Fluent({ warningHandler: ${wh} }); - fluent.addTranslationSync({ - locales: "locale", - source: "message", - }); - fluent.translate("locale", "key");`, - expected: - `Translation message "key" is missing in the following locale(s): locale. -The translation "key" is missing in the locales: locale`, - }, - "missing attribute": { - code: (wh) => ` - const fluent = new Fluent({ warningHandler: ${wh} }); - fluent.addTranslationSync({ - locales: "locale", - source: "key=string", - }); - fluent.translate("locale", "key.attr");`, - expected: - `"attr" attribute is missing in message "key" for locale(s): locale -The translation "key.attr" is missing in the locales: locale`, - }, -}; - -Deno.test("warnings", async (t) => { - await t.step("default warning handler", async (t) => { - for (const scene in warningScenes) { - await t.step(scene, async () => { - const { code, expected } = warningScenes[scene]; - const { success, stderr } = await evalCode( - code("defaultWarningHandler()"), - ); - assert(success); - assertEquals(stderr, expected); - }); - } - }); - await t.step( - "default warning handler but logFn is console.log", - async (t) => { - for (const scene in warningScenes) { - await t.step(scene, async () => { - const { code, expected } = warningScenes[scene]; - const { success, stdout } = await evalCode( - code("defaultWarningHandler(console.log)"), - ); - assert(success); - assertEquals(stdout, expected); - }); - } - }, - ); - await t.step( - "default warning handler but logFn does nothing", - async (t) => { - for (const scene in warningScenes) { - await t.step(scene, async () => { - const { code } = warningScenes[scene]; - const { success, stdout } = await evalCode( - code("defaultWarningHandler(function () {})"), - ); - assert(success); - assertEquals(stdout, ""); - }); - } - }, - ); - await t.step( - "custom warning handler", - async (t) => { - for (const scene in warningScenes) { - await t.step(scene, async () => { - const { code, expected } = warningScenes[scene]; - const { success, stdout } = await evalCode( - code("function () {console.log('kek')}"), - ); - assert(success); - assertEquals( - stdout.split("\n").length, - expected.split("\n").length, - ); - }); - } - }, - ); -}); diff --git a/tests/i18n_test.ts b/tests/i18n_test.ts deleted file mode 100644 index 2ce0ec2a..00000000 --- a/tests/i18n_test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { I18n } from "../src/mod.ts"; -import { assertEquals, join } from "./deps.ts"; -import { makeTempLocalesDir } from "./utils.ts"; - -const localesDir = makeTempLocalesDir(); - -const i18n = new I18n({ - defaultLocale: "en", - directory: localesDir, -}); - -Deno.test("Load locales and check registered", () => { - assertEquals(i18n.locales.sort(), ["en", "ru"]); -}); - -Deno.test("English", async (t) => { - await t.step("hello", () => { - assertEquals(i18n.t("en", "hello"), "Hello!"); - }); - await t.step("pluralize", () => { - assertEquals( - i18n.t("en", "cart", { - name: "Name", - apples: 0, - }), - "Hey \u2068Name\u2069, there \u2068are no apples\u2069 in your cart.", - ); - assertEquals( - i18n.t("en", "cart", { - name: "Name", - apples: 1, - }), - "Hey \u2068Name\u2069, there \u2068is one apple\u2069 in your cart.", - ); - assertEquals( - i18n.t("en", "cart", { - name: "Name", - apples: 5, - }), - "Hey \u2068Name\u2069, there \u2068are \u20685\u2069 apples\u2069 in your cart.", - ); - }); - - await t.step("checkout", () => { - assertEquals( - i18n.t("en", "checkout"), - "Thank you for purchasing!", - ); - }); -}); - -Deno.test("Russian", async (t) => { - await t.step("hello", () => { - assertEquals(i18n.t("ru", "hello"), "Здравствуйте!"); - }); - - await t.step("pluralize", () => { - assertEquals( - i18n.t("ru", "cart", { - name: "Имя", - apples: 0, - }), - "Привет \u2068Имя\u2069, в твоей корзине \u2068нет яблок\u2069.", - ); - assertEquals( - i18n.t("ru", "cart", { - name: "Имя", - apples: 1, - }), - "Привет \u2068Имя\u2069, в твоей корзине \u2068\u20681\u2069 яблоко\u2069.", - ); - assertEquals( - i18n.t("ru", "cart", { - name: "Имя", - apples: 3, - }), - "Привет \u2068Имя\u2069, в твоей корзине \u2068\u20683\u2069 яблока\u2069.", - ); - assertEquals( - i18n.t("ru", "cart", { - name: "Имя", - apples: 7, - }), - "Привет \u2068Имя\u2069, в твоей корзине \u2068\u20687\u2069 яблок\u2069.", - ); - assertEquals( - i18n.t("ru", "cart", { - name: "Имя", - apples: 11, - }), - "Привет \u2068Имя\u2069, в твоей корзине \u2068\u206811\u2069 яблок\u2069.", - ); - assertEquals( - i18n.t("ru", "cart", { - name: "Имя", - apples: 101, - }), - "Привет \u2068Имя\u2069, в твоей корзине \u2068\u2068101\u2069 яблоко\u2069.", - ); - assertEquals( - i18n.t("ru", "cart", { - name: "Имя", - apples: 123, - }), - "Привет \u2068Имя\u2069, в твоей корзине \u2068\u2068123\u2069 яблока\u2069.", - ); - }); - - await t.step("checkout", () => { - assertEquals( - i18n.t("ru", "checkout"), - "Спасибо за покупку!", - ); - }); -}); - -Deno.test("Add locale", async (t) => { - await t.step("From file", () => { - i18n.loadLocaleSync("en2", { - filePath: join(localesDir, "en.ftl"), - }); - assertEquals(i18n.t("en2", "hello"), "Hello!"); - }); - - await t.step("From source text", () => { - i18n.loadLocaleSync("ml", { - source: "hello = നമസ്കാരം", - }); - assertEquals(i18n.t("ml", "hello"), "നമസ്കാരം"); - }); -}); - -Deno.test("Interface", () => { - interface A { - a: string; - } - const a: A = { a: "123" }; - assertEquals( - i18n.t("en", "checkout", a), - "Thank you for purchasing!", - ); -}); diff --git a/tests/platform.deno.ts b/tests/platform.deno.ts deleted file mode 100644 index 20b5837c..00000000 --- a/tests/platform.deno.ts +++ /dev/null @@ -1,18 +0,0 @@ -const fluentImport = - 'import { Fluent, defaultWarningHandler } from "./src/mod.ts";'; - -const decoder = new TextDecoder(); - -export async function evalCode(code: string) { - const evalCommand = new Deno.Command("deno", { - args: ["eval", `${fluentImport}\n${code.trim()}`], - stderr: "piped", - stdout: "piped", - }); - const output = await evalCommand.output(); - return { - success: output.success, - stdout: decoder.decode(output.stdout).trim(), - stderr: decoder.decode(output.stderr).trim(), - }; -} diff --git a/tests/platform.node.ts b/tests/platform.node.ts deleted file mode 100644 index 013fd479..00000000 --- a/tests/platform.node.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -const exec = promisify(execFile); - -const fluentImport = - 'const { Fluent, defaultWarningHandler } = require("./src/mod.js");'; - -interface EvalCodeOutput { - success: boolean; - stdout: string; - stderr: string; -} - -export async function evalCode(code: string): Promise { - try { - const command = await exec("node", [ - "--eval", - `${fluentImport}\n${code.trim()}`, - ]); - return { - success: true, - stdout: command.stdout.trim(), - stderr: command.stderr.trim(), - }; - } catch (error) { - return { - success: false, - stdout: error.stdout.trim(), - stderr: error.stderr.trim(), - }; - } -} diff --git a/tests/session_bot.ts b/tests/session_bot.ts deleted file mode 100644 index 93d54849..00000000 --- a/tests/session_bot.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Bot, Context, session, SessionFlavor } from "./deps.ts"; -import { I18n, I18nFlavor } from "../src/mod.ts"; -import { makeTempLocalesDir } from "./utils.ts"; - -type SessionData = Record; -type MyContext = - & Context - & I18nFlavor - & SessionFlavor; - -export const bot = new Bot("TOKEN"); - -bot.use(session({ - initial: () => ({}), -})); - -const i18n = new I18n({ - defaultLocale: "en", - directory: makeTempLocalesDir(), - useSession: true, - fluentBundleOptions: { - useIsolating: false, - }, - globalTranslationContext: (ctx) => ({ - name: ctx.from?.first_name || "", - }), -}); - -bot.use(i18n); - -bot.chatType("private").command("start", async (ctx) => { - await ctx.reply(ctx.t("greeting")); -}); - -bot.chatType("private").command("language", async (ctx) => { - if (ctx.match === "") { - return await ctx.reply(ctx.t("language.hint")); - } - - // `i18n.locales` contains all the locales that have been registered - if (!i18n.locales.includes(ctx.match)) { - return await ctx.reply(ctx.t("language.invalid-locale")); - } - - // `ctx.i18n.getLocale` returns the locale currently using. - if (await ctx.i18n.getLocale() === ctx.match) { - return await ctx.reply(ctx.t("language.already-set")); - } - - await ctx.i18n.setLocale(ctx.match); - await ctx.reply(ctx.t("language.language-set")); -}); diff --git a/tests/session_bot_test.ts b/tests/session_bot_test.ts deleted file mode 100644 index f01b5443..00000000 --- a/tests/session_bot_test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { bot } from "./session_bot.ts"; -import { assertEquals, Chats } from "./deps.ts"; - -const chats = new Chats(bot); - -const user = chats.newUser({ - first_name: "Test", - id: 1234567890, - language_code: "en", -}); - -Deno.test("/start", async () => { - await user.command("start"); - assertEquals(user.last.text, "Hello, Test!"); -}); - -Deno.test("/language", async (t) => { - await t.step("no match", async () => { - await user.command("language"); - assertEquals(user.last.text, "Enter a language with the command"); - }); - - await t.step("invalid language", async () => { - await user.command("language", "blah"); - assertEquals(user.last.text, "Invalid language"); - }); - - await t.step("already set", async () => { - await user.command("language", "en"); - assertEquals(user.last.text, "Language is already set!"); - }); - - await t.step("set 'ru'", async () => { - await user.command("language", "ru"); - assertEquals(user.last.text, "Язык успешно установлен!"); - }); - - await t.step("'ru': already set", async () => { - await user.command("language", "ru"); - assertEquals(user.last.text, "Этот язык уже установлен!"); - }); - - await t.step("back to 'en'", async () => { - await user.command("language", "en"); - assertEquals(user.last.text, "Language set successfullY!"); - }); -}); diff --git a/tests/utils.ts b/tests/utils.ts deleted file mode 100644 index 44ff4724..00000000 --- a/tests/utils.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { join } from "./deps.ts"; - -export function makeTempLocalesDir() { - const dir = Deno.makeTempDirSync(); - - const englishTranslation = `hello = Hello! - -greeting = Hello, { $name }! - -cart = Hey { $name }, there { - $apples -> - [0] are no apples - [one] is one apple - *[other] are { $apples } apples -} in your cart. - -checkout = Thank you for purchasing! - -language = - .hint = Enter a language with the command - .invalid-locale = Invalid language - .already-set = Language is already set! - .language-set = Language set successfullY!`; - - const russianTranslation = `hello = Здравствуйте! - -greeting = Здравствуйте, { $name }! - -cart = Привет { $name }, в твоей корзине { - $apples -> - [0] нет яблок - [one] {$apples} яблоко - [few] {$apples} яблока - *[other] {$apples} яблок -}. - -checkout = Спасибо за покупку! - -language = - .hint = Отправьте язык после команды - .invalid-locale = Неверный язык - .already-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; -} diff --git a/types.ts b/types.ts new file mode 100644 index 00000000..a026f12b --- /dev/null +++ b/types.ts @@ -0,0 +1,101 @@ +type KeyOf = string & keyof T; + +export type LocalesTypings< + L extends string = string, + M extends string = string, + VK extends string = string, + VV extends string | number | Date | boolean = + | string + | number + | Date + | boolean, // todo: fix this, what was this?!! +> = { + locales: L; + messages: { + readonly [message in M]: + | { readonly [variable in VK]: VV } + | never; + }; +}; +export type Locales = LT["locales"]; +export type Messages = LT["messages"]; +export type MessageKey< + LT extends LocalesTypings, + M extends Messages, +> = KeyOf; +export type MessageVariables< + LT extends LocalesTypings, + M extends Messages, + MK extends MessageKey, +> = M[MK] extends never ? [] + : Messages[string] extends M[MK] ? [variables?: M[MK]] + : [variables: M[MK]]; + +/** + * A format adapter is an abstraction that provides translation capabilities to + * any localization format. Format adapters helps enable localization regardless + * of the localization format used. Format adapters should manage the + * translation resources and expose a translate function that can be called from + * the i18n instance. + */ +export interface FormatAdapter< + LT extends LocalesTypings, +> { + /** + * List of locales registered in the adapter. + */ + locales: string[]; + /** + * Compiles an array of the best-matched list of locales. If none of the + * registered locales matched, then an empty array is returned, and fallback + * is handled by i18n. + * + * @param requestedLocale The locale, for which the best matches are requested for. + */ + negotiateLocales(requestedLocale: string): string[]; + /** + * Formats and returns a message string if the message exists. + * + * @param locale Locale to use when translating. + * @param messageKey Message key to be used. + * @param args Variables to be passed for formatting the message data. + */ + translate< + L extends Locales, + MK extends MessageKey>, + >( + locale: L, + messageKey: MK, + ...args: Messages[MK] extends never ? [] + : Messages[string] extends Messages[MK] + ? [variables?: Messages[MK]] + : [variables: Messages[MK]] + ): string | undefined; +} + +export interface ResourceLoadable { + /** + * Accepts a translation resource as string. + * + * @param locale Locale which the resource belongs to. + * @param source Resource content. + * @param options Additional resource options. + */ + loadResource(locale: string, source: string, options?: T): unknown; +} + +export interface LoadLocalesDirectoryConfig { + /** Extensions of the files to read from. */ + extensions: string[]; + /** Resource options that are passed `loadResource`. */ + resourceOptions?: T; + /** + * Whether to include the common source files that are at the root of the + * locales directory. These common source files are loaded into every locale. + */ + includeCommonSources?: boolean; + /** Whether to ignore dot (hidden) files */ + ignoreDotFiles?: boolean; + /** Whether to follow symlinks to the realpath. */ + followSymlinks?: boolean; +} diff --git a/utilities.ts b/utilities.ts new file mode 100644 index 00000000..c19ff49b --- /dev/null +++ b/utilities.ts @@ -0,0 +1,155 @@ +import * as fs from "node:fs"; +import { basename, extname, join, relative } from "node:path"; +import { createDebug } from "@grammyjs/debug"; +import type { LoadLocalesDirectoryConfig, ResourceLoadable } from "./types.ts"; + +const debug = createDebug("grammy:i18n"); + +/** + * A basic IETF tag validator. Doesn't bother about lengths of the subtags, yet. + * + * @see https://en.wikipedia.org/wiki/IETF_language_tag#Syntax_of_language_tags + */ +export function isValidLocale(locale: string): boolean { + if (typeof locale !== "string") + return false; + return locale.split("-") + .map((subtag) => subtag.trim()) + .every((subtag) => subtag.length > 0 && !/[^a-zA-Z0-9]/.test(subtag)); +} + +/** + * Utility function for finding, reading translation source files from a + * standard locales directory, and passing the contents to the attached + * adapter. + * + * A standard locales directory looks like this (using Fluent as example): + * + * ```asciiart + * locales/ + * ├── de/ + * │ └── main.ftl + * ├── en/ + * │ ├── nested/ + * │ │ └── buttons.ftl + * │ ├── help.ftl + * │ └── main.ftl + * ├── ru/ + * │ └── main.ftl + * ├── common.ftl + * └── another-common.ftl + * ``` + * + * It should contain directories with corresponding locale names. Such locale + * directories can have the translation sources split into multiple files if + * needed. Nested directories are also supported. + * + * If you have common files that you need to have registered in all the locales, + * regardless of the actual locale, then such files can be placed in the root of the directory. + * + * @param adapter Format adapter to assign the resources to. + * @param dirpath Path to the locales directory. + * @param options Additional options for loading the resource files. File + * extension must be specified to filter out the files. Resource loading options + * for the adapter can also be passed through here. + */ +export async function loadLocalesDirectory( + adapter: ResourceLoadable, + dirpath: string, + options: LoadLocalesDirectoryConfig, +) { + options = { + followSymlinks: false, + ignoreDotFiles: true, + includeCommonSources: true, + ...options, + }; + + const data: { + locales: string[]; + common: string[]; + } = { locales: [], common: [] }; + + debug(`reading locales directory: ${dirpath}`); + + const dir = await fs.promises.opendir(dirpath); + for await (const dirent of dir) { + if (dirent.name.startsWith(".") && options.ignoreDotFiles) + continue; + + const direntpath = join(dirpath, dirent.name); + const filepath = options.followSymlinks && dirent.isSymbolicLink() + ? await fs.promises.realpath(direntpath) + : direntpath; + const entry = await fs.promises.lstat(filepath); + + if ( + entry.isFile() && options.includeCommonSources && + options.extensions.includes(extname(dirent.name)) && entry.size > 0 + ) { + debug(`found common file: ${filepath}`); + data.common.push(filepath); + } else if (entry.isDirectory()) { + if (isValidLocale(dirent.name)) { + debug(`found locale directory: ${dirent.name}`); + data.locales.push(dirent.name); + } else { + debug(`ignoring locale dir with invalid name ${dirent.name}`); + } + } + // symbolic links are already handled, ignore the others + } + + for (const locale of data.locales) { + const localeDirPath = join(dirpath, locale); + debug(`reading locale directory: ${locale}`); + + const itr = walk(localeDirPath, options.extensions, { + followSymlinks: !!options.followSymlinks, + ignoreDotFiles: !!options.ignoreDotFiles, + }); + for await (const filepath of itr) { + debug(`reading resource: ${relative(localeDirPath, filepath)}`); + const content = await fs.promises.readFile(filepath, "utf8"); + adapter.loadResource(locale, content, options?.resourceOptions); + } + } + + if (options.includeCommonSources) { + for (const filepath of data.common) { + debug(`reading resource: ${filepath}`); + const content = await fs.promises.readFile(filepath, "utf8"); + for (const locale of data.locales) + adapter.loadResource(locale, content, options?.resourceOptions); + } + } +} + +export async function* walk( + path: string, + extensions: string[], + options: { + ignoreDotFiles: boolean; + followSymlinks: boolean; + }, +): AsyncGenerator { + const filename = basename(path); + const stat = await fs.promises.lstat(path); + + if (stat.isFile() && extensions.includes(extname(filename))) { + yield path; + } else if (stat.isDirectory()) { + const dir = await fs.promises.opendir(path); + for await (const dirent of dir) { + const resolved = join(path, dirent.name); + if (dirent.name.startsWith(".") && options.ignoreDotFiles) + continue; + yield* walk(resolved, extensions, options); + } + } else if (stat.isSymbolicLink() && options.followSymlinks) { + const realpath = await fs.promises.realpath(path); + yield* walk(realpath, extensions, options); + } else { + // ignore + } +} diff --git a/utilities_test.ts b/utilities_test.ts new file mode 100644 index 00000000..58268b9b --- /dev/null +++ b/utilities_test.ts @@ -0,0 +1,514 @@ +/// + +import { expect } from "@std/expect"; +import { afterAll, beforeAll, describe, it } from "@std/testing/bdd"; +import { type Stub, stub } from "@std/testing/mock"; +import * as fs from "node:fs"; +import { normalize } from "node:path"; +import type { ResourceLoadable } from "./types.ts"; +import { isValidLocale, loadLocalesDirectory, walk } from "./utilities.ts"; + +describe("locale string validation", () => { + it("should be a string", () => { + // @ts-expect-error only string is allowed type-wise. + expect(isValidLocale(1)).toBe(false); + // @ts-expect-error only string is allowed type-wise. + expect(isValidLocale(null)).toBe(false); + // @ts-expect-error only string is allowed type-wise. + expect(isValidLocale(true)).toBe(false); + // @ts-expect-error only string is allowed type-wise. + expect(isValidLocale({})).toBe(false); + // @ts-expect-error only string is allowed type-wise. + expect(isValidLocale([])).toBe(false); + expect(isValidLocale("en")).toBe(true); + }); + + it("should be valid", () => { + const good = [ + "en", + "fr-CA", + "zh-Hant-TW", + "de-AT", + "sr-Cyrl", + "es-419", + "ja-JP-u-ca-japanese", + "und", + "ar-SA", + "hi-IN", + "pt-BR-abnt2", + "x-pirate", + "mul", + "az-Latn-AZ", + "sl-nedis", + "en-US-u-em-emoji", + "ku-Arab-IQ", + "i-default", + "de-CH-1901", + "sgn-US", + "zh-min-nan", + "th-Thai", + "el-Grek", + "fr-x-verlan", + "ru-RU-u-tz-moscow", + "pa-Guru", + "hy-Armn", + "art-lojban", + "it-x-lombard", + "ps-Arab-AF", + "zxx", + "mn-Cyrl-MN", + "nl-x-brabant", + "qaa", + "sr-Latn-RS", + "en-GB-oxendict", + "zh-Hans-CN", + "es-MX", + "he-Hebr", + "ka-Geor", + "tg-Cyrl-TJ", + "fr-FR-u-hc-h12", + "x-klingon", + "uz-Latn-UZ", + "sw-KE", + "bn-BD", + "pt-PT-u-va-posix", + ]; + for (const locale of good) { + expect(isValidLocale(locale)).toBe(true); + } + }); + + it("should not be valid", () => { + // not all of the following cases are covered. + const bad = [ + "en-", // Trailing hyphen + "-US", // Missing primary language + // "123", // Numeric primary tag + "en_GB", // Underscore instead of hyphen + // "eng", // 3-letter primary tag (invalid for English) + // "xx-YY", // Nonexistent language + region + // "zh-ABCD", // Invalid script subtag + // "de-DE-1901-1901", // Duplicate variant + // "es-LATN", // Script in wrong case (must be titlecase) + // "fr-CA-x", // Empty private-use extension + "x-", // Empty private-use prefix + // "i", // Incomplete grandfathered tag + "en-US-u-", // Empty Unicode extension + "zh-Hans-CN-", // Trailing hyphen + // "sr-Cyrl-Latn", // Conflicting scripts + // "en-emoji", // Invalid variant (no prefix) + // "pt-BR-ABNT2", // Variant in wrong case (must be lowercase) + "x-😊", // Non-ASCII private-use tag + // "und-001", // Invalid region with 'und' + // "mul-ZZ", // Invalid region with 'mul' + // "en-US-u-xx-zzzz", // Invalid Unicode extension key + "de-DE@euro", // Old-style locale syntax (invalid in BCP 47) + // "en-GB-oxford", // Fake variant (not in registry) + "ja-JP-u-ca-", // Incomplete Unicode extension + // "x-EN-PIRATE", // Invalid private-use capitalization + ]; + for (const locale of bad) { + expect(isValidLocale(locale)).toBe(false); + } + }); +}); + +describe("walk", () => { + it("should match extension", async () => { + const itr = walk(".", [".ts"], { + followSymlinks: false, + ignoreDotFiles: true, + }); + const files = await Array.fromAsync(itr); + expect(files.toSorted()).toStrictEqual([ + "adapters/fluent/adapter.ts", + "adapters/fluent/adapter_test.ts", + "adapters/fluent/cli.ts", + "adapters/mod.ts", + "adapters/types.ts", + "cli/constants.ts", + "cli/generate_types.ts", + "cli/main.ts", + "cli/utilities.ts", + "example/locales.ts", + "example/locales/types.d.ts", + "example/main.ts", + "mod.ts", + "plugin.ts", + "plugin_test.ts", + "types.ts", + "utilities.ts", + "utilities_test.ts", + ]); + }); + + it("should match extension-less files", async () => { + const itr = walk(".", [""], { + followSymlinks: false, + ignoreDotFiles: true, + }); + const files = await Array.fromAsync(itr); + expect(files.toSorted()).toStrictEqual(["LICENSE"]); + }); +}); + +// === Simple functions for mocking fs +const SEPARATOR = "/"; + +type Entry = EntryFile | EntrySymlink | EntryDir | EntryOther; +type EntryFile = string[]; +type EntrySymlink = string; +type EntryOther = null; +type EntryDir = { [name: string]: Entry }; +type ResolvedDirent = + | { type: "dir"; content: EntryDir } + | { type: "file"; content: string } + | { type: "symlink"; linked: string } + | { type: "unknown" }; + +function resolvePath(root: EntryDir, path: string) { + if (path.trim() === "") { + throw new Error("File or directory not found"); + } + + function recurse( + root: EntryDir, + current: EntryDir, + segments: string[], + ): ResolvedDirent { + if ( + segments.length === 0 || + (segments.length === 1 && segments[0] === "") + ) return { type: "dir", content: current }; + if (segments[0] === ".") + return recurse(root, root, segments.slice(1)); + + for (const entryName in current) { + if (segments[0] !== entryName) continue; + const entry = current[entryName]; + if (entry == null) { + return { type: "unknown" }; + } else if (typeof entry === "string") { + return { type: "symlink", linked: entry }; + } else if (Array.isArray(entry)) { + if (entry.some((line) => typeof line !== "string")) + throw new Error("Invalid file content"); + return { type: "file", content: entry.join("\n") }; + } else if (typeof entry === "object") { + return recurse(root, entry, segments.slice(1)); + } else { + throw new Error("Invalid file system entry type"); + } + } + + throw new Error("File or directory not found"); + } + + return recurse(root, root, normalize(path).split(SEPARATOR)); +} + +function exists(root: EntryDir, path: string) { + try { + resolvePath(root, path); + return true; + } catch (error) { + if ( + error instanceof Error && + error.message === "File or directory not found" + ) return false; + throw error; + } +} + +describe("(internal) mock fs", () => { + const localesDir: EntryDir = { + "locales": { + "en": { + "main.ftl": [""], + }, + "common.ftl": ["common = this is another common file"], + "another-common.ftl": "locales/en/main.ftl", + }, + "outside.ftl": [], + }; + + it("should resolve paths and check existence", () => { + const paths: Record = { + "": undefined, + ".": "dir", + "locales": "dir", + "./locales": "dir", + "./outside.ftl": "file", + "outside.ftl": "file", + "non-existent.ftl": undefined, + "deep/non-existent.ftl": undefined, + "locales/en": "dir", + "locales/en/": "dir", + "locales/en/main.ftl": "file", + "locales/another-common.ftl": "symlink", + }; + for (const path in paths) { + if (exists(localesDir, path)) { + const { type } = resolvePath(localesDir, path); + expect(paths[path]).toBe(type); + } else { + expect(paths[path]).toBe(undefined); + } + } + }); +}); + +describe("load locales directory", () => { + const rootdir: EntryDir = { + "locales": { + ".dotfile": ["content"], + "en": { + "unknown-type": null, + "main.ftl": ["some = content"], + "other.ftl": "locales/en/main.ftl", + }, + "invalid-name-": { + "main.ftl": [ + "some = this dir will be ignored due to invalid name", + ], + }, + "common.ftl": [ + "common = this is another common file", + ], + "another-common.ftl": "locales/en/main.ftl", + }, + "outside.ftl": [], + }; + + const stubs: Stub[] = []; + + beforeAll(() => { + stubs.push( + // opendir + // deno-lint-ignore require-await + stub(fs.promises, "opendir", async (path) => { + if (typeof path !== "string") throw new Error("unsupported"); + const resolved = resolvePath(rootdir, path); + if (resolved.type !== "dir") + throw new Error("Not a directory"); + + return { + async *[Symbol.asyncIterator](): NodeJS.AsyncIterator< + fs.Dirent, + undefined + > { + for (const entryName in resolved.content) { + const entry = resolvePath( + resolved.content, + entryName, + ); + const dirent: fs.Dirent = { + name: entryName, + + parentPath: path, + isFile: () => entry.type === "file", + isDirectory: () => entry.type === "dir", + isSymbolicLink: () => entry.type === "symlink", + + isBlockDevice: () => entry.type === "unknown", + isCharacterDevice: () => + entry.type === "unknown", + isFIFO: () => entry.type === "unknown", + isSocket: () => entry.type === "unknown", + }; + yield Promise.resolve(dirent); + } + }, + path: "", + close: async () => {}, + closeSync: () => {}, + read: () => Promise.resolve(null), + readSync: () => null, + [Symbol.dispose]() {}, + async [Symbol.asyncDispose]() {}, + } satisfies fs.Dir; + }), + // realpath + // deno-lint-ignore require-await + stub(fs.promises, "realpath", async (path) => { + if (typeof path !== "string") throw new Error("unsupported"); + const resolved = resolvePath(rootdir, path); + if (resolved.type === "symlink") { + const linkedResolve = resolvePath(rootdir, resolved.linked); + if (linkedResolve.type === "symlink") + return fs.promises.realpath(resolved.linked); + else return resolved.linked; + } else { + return path; + } + }), + // lstat + // deno-lint-ignore require-await + stub(fs.promises, "lstat", async (path) => { + if (typeof path !== "string") throw new Error("unsupported"); + const resolved = resolvePath(rootdir, path); + return { + size: resolved.type === "file" + ? resolved.content.length + : 0, + isFile: () => resolved.type === "file", + isDirectory: () => resolved.type === "dir", + isSymbolicLink: () => resolved.type === "symlink", + + isCharacterDevice: () => resolved.type === "unknown", + isBlockDevice: () => resolved.type === "unknown", + isFIFO: () => resolved.type === "unknown", + isSocket: () => resolved.type === "unknown", + atime: new Date(), + mtime: new Date(), + ctime: new Date(), + birthtime: new Date(), + uid: 0, + atimeMs: 0, + birthtimeMs: 0, + blksize: 0, + blocks: 0, + ctimeMs: 0, + dev: 0, + gid: 0, + ino: 0, + mode: 0, + mtimeMs: 0, + nlink: 0, + rdev: 0, + } satisfies fs.Stats; + }), + // readFile + // deno-lint-ignore require-await + stub(fs.promises, "readFile", async (path, options) => { + if (typeof path !== "string") throw new Error("unsupported"); + if (options !== "utf8") throw new Error("unsupported"); + const resolved = resolvePath(rootdir, path); + if (resolved.type !== "file") + throw new Error("Not a file"); + return Promise.resolve(resolved.content); + }), + ); + }); + + afterAll(() => { + for (const stub of stubs) { + stub.restore(); + } + }); + + it("load", async () => { + const loaded: { locale: string; content: string }[] = []; + const fake: ResourceLoadable & { + locales: Set; + } = { + locales: new Set(), + loadResource: (locale, content) => { + fake.locales.add(locale); + loaded.push({ locale, content }); + }, + }; + await loadLocalesDirectory(fake, "locales", { + extensions: [".ftl"], + ignoreDotFiles: true, + followSymlinks: true, + includeCommonSources: true, + }); + + // Only "en" is a valid locale name + expect(Array.from(fake.locales.values())).toStrictEqual(["en"]); + + // "invalid-name-" directory is ignored due to invalid locale name + expect(fake.locales.has("invalid-name-")).toBe(false); + + // Exactly one locale was discovered + expect(fake.locales.size).toBe(1); + + const enLoads = loaded.filter((e) => e.locale === "en"); + const contents = enLoads.map((e) => e.content); + + // main.ftl content is loaded + expect(contents).toContain("some = content"); + + // main.ftl, other.ftl (symlink -> main.ftl), and another-common.ftl (common symlink -> main.ftl) + expect(contents.filter((c) => c === "some = content").length).toBe(3); + + // common.ftl at the root is loaded as a common source for all locales + expect(contents).toContain("common = this is another common file"); + + // .dotfile content never appears + expect(contents).not.toContain("content"); + }); + + it("stubbed opendir", async () => { + await expect(fs.promises.opendir("outside.ftl")) + .rejects.toThrow("Not a directory"); + + const itr = await fs.promises.opendir("locales"); + const arr = await Array.fromAsync(itr); + + expect(arr.every((e) => e.parentPath === "locales")).toBe(true); + + expect( + arr.map((a) => { + return { + type: a.isFile() + ? "file" + : a.isDirectory() + ? "dir" + : a.isSymbolicLink() + ? "symlink" + : "unknown", + name: a.name, + }; + }).sort((a, b) => a.name.localeCompare(b.name)), + ).toEqual([ + { name: ".dotfile", type: "file" }, + { name: "another-common.ftl", type: "symlink" }, + { name: "common.ftl", type: "file" }, + { name: "en", type: "dir" }, + { name: "invalid-name-", type: "dir" }, + ]); + }); + + it("stubbed realpath", async () => { + expect(await fs.promises.realpath("locales")).toBe("locales"); + + expect(await fs.promises.realpath("locales/common.ftl")) + .toBe("locales/common.ftl"); + + expect(await fs.promises.realpath("locales/another-common.ftl")) + .toBe("locales/en/main.ftl"); + }); + + it("stubbed lstat", async () => { + const stat1 = await fs.promises.lstat("locales"); + expect(stat1.isDirectory()).toBe(true); + + const stat2 = await fs.promises.lstat("locales/common.ftl"); + expect(stat2.isFile()).toBe(true); + + const stat3 = await fs.promises.lstat("locales/another-common.ftl"); + expect(stat3.isSymbolicLink()).toBe(true); + }); + + it("stubbed readFile", async () => { + await expect(fs.promises.readFile("locales", "utf8")) + .rejects.toThrow("Not a file"); + + await expect(fs.promises.readFile("locales/another-common.ftl", "utf8")) + .rejects.toThrow("Not a file"); + + const content1 = await fs.promises.readFile( + "locales/common.ftl", + "utf8", + ); + expect(content1).toBe("common = this is another common file"); + + const content2 = await fs.promises.readFile( + "locales/en/main.ftl", + "utf8", + ); + expect(content2).toBe("some = content"); + }); +});