Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: built-in fluent integration #35

Merged
merged 13 commits into from
Feb 7, 2023
3 changes: 2 additions & 1 deletion deno.jsonc
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"lock": false,
"fmt": {
"options": { "proseWrap": "preserve" },
"files": {
Expand All @@ -12,7 +13,7 @@
},
"tasks": {
"example": "cd examples && deno run --allow-net --allow-read deno.ts",
"test": "deno test --allow-read --allow-write",
"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": {
Expand Down
2 changes: 1 addition & 1 deletion examples/deno.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
Context,
session,
SessionFlavor,
} from "https://deno.land/x/grammy@v1.11.0/mod.ts";
} from "https://deno.land/x/grammy@v1.14.1/mod.ts";
import { I18n, I18nFlavor } from "../src/mod.ts";

interface SessionData {
Expand Down
14 changes: 11 additions & 3 deletions scripts/dnt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ import {
dirname,
fromFileUrl,
join,
} from "https://deno.land/std@0.154.0/path/mod.ts";

import { build, emptyDir } from "https://deno.land/x/dnt@0.30.0/mod.ts";
} from "https://deno.land/std@0.176.0/path/mod.ts";
import { build, emptyDir } from "https://deno.land/x/dnt@0.33.0/mod.ts";

import package_ from "./package.json" assert { type: "json" };

Expand Down Expand Up @@ -41,6 +40,15 @@ await build({
subPath: "out/types",
peerDependency: true,
},
"https://deno.land/x/fluent@v0.0.0/bundle/mod.ts": {
name: "@fluent/bundle",
version: "^0.17.1",
},
"https://deno.land/x/fluent@v0.0.0/langneg/mod.ts": {
name: "@fluent/langneg",
version: "^0.6.2",
},
"./tests/platform.deno.ts": "./tests/platform.node.ts",
},
});

Expand Down
14 changes: 6 additions & 8 deletions src/deps.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
export {
Fluent,
type FluentBundleOptions,
type FluentOptions,
type LocaleId,
type TranslationContext,
} from "https://deno.land/x/better_fluent@v1.0.0/mod.ts";
FluentBundle,
FluentResource,
type FluentVariable,
} from "https://deno.land/x/fluent@v0.0.0/bundle/mod.ts";

export { type FluentVariable } from "https://deno.land/x/fluent@v0.0.0/bundle/mod.ts";
export { negotiateLanguages } from "https://deno.land/x/fluent@v0.0.0/langneg/mod.ts";

export {
type Context,
type HearsContext,
type MiddlewareFn,
} from "https://lib.deno.dev/x/grammy@1.x/mod.ts";

export { extname, resolve } from "https://deno.land/std@0.154.0/path/mod.ts";
export { extname, resolve } from "https://deno.land/std@0.176.0/path/mod.ts";
160 changes: 160 additions & 0 deletions src/fluent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
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<FluentBundle>();
private defaultBundle?: FluentBundle;
private handleWarning: WarningHandler = defaultWarningHandler();

constructor(options?: FluentOptions) {
if (options?.warningHandler) {
this.handleWarning = options.warningHandler;
}
}

public async addTranslation(options: AddTranslationOptions): Promise<void> {
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<K extends string>(
localeOrLocales: MaybeArray<LocaleId>,
path: string,
context?: TranslationVariables<K>,
): 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<LocaleId>) {
return this.translate.bind(this, localeOrLocales);
}

private createBundle(
locales: MaybeArray<LocaleId>,
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<FluentBundle> {
const bundles = Array.from(this.bundles);

// Building a list of all the registered locales
const availableLocales = bundles.reduce<LocaleId[]>(
(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);
}
}
69 changes: 29 additions & 40 deletions src/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import {
type Context,
Fluent,
type FluentBundleOptions,
type HearsContext,
type LocaleId,
type MiddlewareFn,
resolve,
type TranslationContext,
} 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";

import type { I18nConfig, I18nFlavor, TranslateFunction } from "./types.ts";

export class I18n<C extends Context = Context> {
private config: I18nConfig<C>;
readonly fluent: Fluent;
Expand Down Expand Up @@ -66,12 +68,7 @@ export class I18n<C extends Context = Context> {
*/
async loadLocale(
locale: LocaleId,
options: {
filePath?: string;
source?: string;
isDefault?: boolean;
bundleOptions?: FluentBundleOptions;
},
options: LoadLocaleOptions,
): Promise<void> {
await this.fluent.addTranslation({
locales: locale,
Expand All @@ -90,12 +87,7 @@ export class I18n<C extends Context = Context> {
*/
loadLocaleSync(
locale: LocaleId,
options: {
filePath?: string;
source?: string;
isDefault?: boolean;
bundleOptions?: FluentBundleOptions;
},
options: LoadLocaleOptions,
): void {
this.fluent.addTranslationSync({
locales: locale,
Expand All @@ -109,23 +101,23 @@ export class I18n<C extends Context = Context> {

/**
* Gets a message by its key from the specified locale.
* Alias of `translate` method.
* Alias of `translate`.
*/
t(
t<K extends string>(
locale: LocaleId,
key: string,
context?: TranslationContext,
variables?: TranslationVariables<K>,
): string {
return this.translate(locale, key, context);
return this.translate(locale, key, variables);
}

/** Gets a message by its key from the specified locale. */
translate(
translate<K extends string>(
locale: LocaleId,
key: string,
context?: TranslationContext,
variables?: TranslationVariables<K>,
): string {
return this.fluent.translate(locale, key, context);
return this.fluent.translate(locale, key, variables);
}

/** Returns a middleware to .use on the `Bot` instance. */
Expand Down Expand Up @@ -181,18 +173,6 @@ should either enable sessions or use `ctx.i18n.useLocale()` instead.",
useLocale(negotiatedLocale);
}

// Also exports ctx object properties for accessing them directly from
// the translation source files.
function translateWrapper(
key: string,
translationContext?: TranslationContext,
): string {
return translate(key, {
...globalTranslationContext?.(ctx),
...translationContext,
});
}

Object.defineProperty(ctx, "i18n", {
value: {
fluent,
Expand All @@ -205,8 +185,17 @@ should either enable sessions or use `ctx.i18n.useLocale()` instead.",
// inside the conversation even if the plugin is already installed globally.
writable: true,
});
ctx.t = translateWrapper;
ctx.translate = translateWrapper;

ctx.translate = <K extends string>(
key: string,
translationVariables?: TranslationVariables<K>,
): string => {
return translate(key, {
...globalTranslationContext?.(ctx),
...translationVariables,
});
};
ctx.t = ctx.translate;

await negotiateLocale();
await next();
Expand Down
2 changes: 2 additions & 0 deletions src/mod.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from "./types.ts";
export { hears, I18n } from "./i18n.ts";
export * from "./warning.ts";
export * from "./fluent.ts";
Loading