Skip to content

Commit

Permalink
feat: at i18n namespace to UI (#36)
Browse files Browse the repository at this point in the history
* refactor: relocate "get" utility function
* refactor: isolate i18n logic to remove node dependencies
* feat: freeze translations to expose them via getTranslations
* test: freezing objects
* feat: add i18n to ui
* test: fix localization test
* deps: bump @elgato/schemas
* feat: add t alias for translate
* deps: bump @elgato/schemas

---------

Co-authored-by: Richard Herman <[email protected]>
  • Loading branch information
GeekyEggo and GeekyEggo authored Apr 23, 2024
1 parent 1fdb71f commit dc01721
Show file tree
Hide file tree
Showing 19 changed files with 695 additions and 318 deletions.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
"typescript": "^5.2.2"
},
"dependencies": {
"@elgato/schemas": "^0.1.3",
"@elgato/schemas": "^0.3.1",
"ws": "^8.14.2"
}
}
124 changes: 124 additions & 0 deletions src/common/__tests__/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { Language } from "../../api";
import { I18nProvider } from "../i18n";

jest.mock("../logging");

describe("I18nProvider", () => {
/**
* Asserts {@link I18nProvider} does not load locales unless they are requested.
*/
it("lazily evaluates locales", () => {
// Arrange, act.
const localeProvider = jest.fn();
new I18nProvider("en", localeProvider);

// Assert.
expect(localeProvider).toHaveBeenCalledTimes(0);
});

/**
* Asserts {@link I18nProvider} evaluates locales only once.
*/
it("loads locales once", () => {
// Arrange
const localeProvider = jest.fn().mockReturnValue(null);
const i18n = new I18nProvider("en", localeProvider);

// Act.
i18n.translate("Hello", "en");
i18n.translate("Hello", "en");
i18n.translate("Hello", "de");

// Assert.
expect(localeProvider).toHaveBeenCalledTimes(2);
expect(localeProvider).toHaveBeenNthCalledWith(1, "en");
expect(localeProvider).toHaveBeenNthCalledWith(2, "de");
});

/**
* Asserts {@link I18nProvider} evaluates locales only once.
*/
it("does not load unsupported locales", () => {
// Arrange
const localeProvider = jest.fn().mockReturnValue(null);
const i18n = new I18nProvider("en", localeProvider);

// Act.
// @ts-expect-error Testing unsupported language.
i18n.translate("Hello", "__");

// Assert.
expect(localeProvider).toHaveBeenCalledTimes(1);
expect(localeProvider).toHaveBeenCalledWith("en");
});

it("t is alias of translate", () => {
// Arrange.
const i18n = new I18nProvider("en", jest.fn());
const spyOnTranslate = jest.spyOn(i18n, "translate");

// Act.
i18n.t("test");
i18n.t("test", "de");

// Assert.
expect(spyOnTranslate).toHaveBeenCalledTimes(2);
expect(spyOnTranslate).toHaveBeenNthCalledWith(1, "test", "en");
expect(spyOnTranslate).toHaveBeenNthCalledWith(2, "test", "de");
});

describe("translating", () => {
const localeProvider = jest.fn().mockImplementation((language: Language) => {
switch (language) {
case "de":
return { Hello: "Hello welt" };
case "en":
return { Hello: "Hello world", Company: { Name: "Elgato" } };
default:
return null;
}
});

/**
* Asserts {@link I18nProvider} finds resources from the request language.
*/
it("find resources from the requested language", () => {
// Arrange, act, assert.
const i18n = new I18nProvider("en", localeProvider);
expect(i18n.translate("Hello", "de")).toBe("Hello welt");
});

/**
* Asserts {@link I18nProvider} finds resources from the fallback language.
*/
it("finds resources from the default language", () => {
// Arrange, act, assert.
const i18n = new I18nProvider("en", localeProvider);
expect(i18n.translate("Hello", "es")).toBe("Hello world");
});

/**
* Asserts {@link I18nProvider} returns the key for unknown resources.
*/
it("returns the key for unknown resources", () => {
// Arrange, act, assert.
const i18n = new I18nProvider("en", localeProvider);
expect(i18n.translate("Goodbye")).toBe("Goodbye");
});

/**
* Asserts {@link I18nProvider} is capable of finding nested resources.
*/
it("translates nested properties", () => {
// Arrange, act, assert.
const i18n = new I18nProvider("en", localeProvider);
expect(i18n.translate("Company.Name")).toBe("Elgato");
});

it("can translate with t alias", () => {
// Arrange, act, assert.
const i18n = new I18nProvider("en", localeProvider);
expect(i18n.t("Company.Name")).toBe("Elgato");
});
});
});
79 changes: 79 additions & 0 deletions src/common/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { freeze, get } from "../utils";

/**
* Provides assertions for {@link freeze}.
*/
describe("freeze", () => {
it("top-level properties", () => {
// Arrange.
const obj = {
name: "Elgato"
};

// Act.
freeze(obj);

// Assert.
expect(() => (obj.name = "Other")).toThrowError();
expect(obj.name).toEqual("Elgato");
});

it("nested properties", () => {
// Arrange.
const obj = {
company: {
name: "Elgato"
}
};

// Act.
freeze(obj);

// Assert.
expect(() => (obj.company.name = "Other")).toThrowError();
expect(obj.company.name).toEqual("Elgato");
});

it("handles undefined", () => {
// Arrange, act.
const value = undefined;
freeze(value);

// Assert.
expect(value).toBeUndefined();
});

it("handles null", () => {
// Arrange, act.
const value = null;
freeze(value);

// Assert.
expect(value).toBeNull();
});
});

/**
* Provides assertions for {@link get}.
*/
describe("get", () => {
it("gets the value for a top-level path", () => {
const obj = { foo: "bar" };
expect(get("foo", obj)).toBe("bar");
});

it("gets the value for a nested path", () => {
const obj = { nested: { number: 13 } };
expect(get("nested.number", obj)).toBe(13);
});

it("handles falsy values", () => {
const obj = { falsy: false };
expect(get("falsy", obj)).toBe(false);
});

it("defaults to undefined", () => {
const obj = {};
expect(get("__unknown.__prop", obj)).toBe(undefined);
});
});
95 changes: 95 additions & 0 deletions src/common/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { supportedLanguages, type Language } from "../api";
import { JsonObject } from "../common/json";
import { freeze, get } from "./utils";

/**
* Internalization provider, responsible for managing localizations and translating resources.
*/
export class I18nProvider {
/**
* Default language to be used when a resource does not exist for the desired language.
*/
private static readonly DEFAULT_LANGUAGE: Language = "en";

/**
* Map of localized resources, indexed by their language.
*/
private readonly _translations: Map<Language, JsonObject | null> = new Map();

/**
* Initializes a new instance of the {@link I18nProvider} class.
* @param language The default language to be used when retrieving translations for a given key.
* @param readTranslations Function responsible for loading translations.
*/
constructor(
private readonly language: Language,
private readonly readTranslations: TranslationsReader
) {}

/**
* Translates the specified {@link key}, as defined within the resources for the {@link language}. When the key is not found, the default language is checked.
*
* Alias of `I18nProvider.translate(string, Language)`
* @param key Key of the translation.
* @param language Optional language to get the translation for; otherwise the default language.
* @returns The translation; otherwise the key.
*/
public t(key: string, language: Language = this.language): string {
return this.translate(key, language);
}

/**
* Translates the specified {@link key}, as defined within the resources for the {@link language}. When the key is not found, the default language is checked.
* @param key Key of the translation.
* @param language Optional language to get the translation for; otherwise the default language.
* @returns The translation; otherwise the key.
*/
public translate(key: string, language: Language = this.language): string {
// When the language and default are the same, only check the language.
if (language === I18nProvider.DEFAULT_LANGUAGE) {
return get(key, this.getTranslations(language))?.toString() || key;
}

// Otherwise check the language and default.
return get(key, this.getTranslations(language))?.toString() || get(key, this.getTranslations(I18nProvider.DEFAULT_LANGUAGE))?.toString() || key;
}

/**
* Gets the translations for the specified language.
* @param language Language whose translations are being retrieved.
* @returns The translations, otherwise `null`.
*/
private getTranslations(language: Language): JsonObject | null {
let translations = this._translations.get(language);

if (translations === undefined) {
translations = supportedLanguages.includes(language) ? this.readTranslations(language) : null;
freeze(translations);

this._translations.set(language, translations);
}

return translations;
}
}

/**
* Function responsible for providing localized resources.
* @param language The language whose resources should be retrieved.
* @returns Localized resources represented as a JSON object.
*/
export type TranslationsReader = (language: Language) => JsonObject | null;

/**
* Parses the localizations from the specified contents, or throws a `TypeError` when unsuccessful.
* @param contents Contents that represent the stringified JSON containing the localizations.
* @returns The localizations; otherwise a `TypeError`.
*/
export function parseLocalizations(contents: string): JsonObject {
const json = JSON.parse(contents);
if (json !== undefined && json !== null && typeof json === "object" && "Localization" in json) {
return json["Localization"] as JsonObject;
}

throw new TypeError(`Translations must be a JSON object nested under a property named "Localization"`);
}
21 changes: 21 additions & 0 deletions src/common/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Prevents the modification of existing property attributes and values on the value, and all of its child properties, and prevents the addition of new properties.
* @param value Value to freeze.
*/
export function freeze<T>(value: T): void {
if (value !== undefined && value !== null && typeof value === "object" && !Object.isFrozen(value)) {
Object.freeze(value);
Object.values(value).forEach(freeze);
}
}

/**
* Gets the value at the specified {@link path}.
* @param path Path to the property to get.
* @param source Source object that is being read from.
* @returns Value of the property.
*/
export function get(path: string, source: unknown): unknown {
const props: string[] = path.split(".");
return props.reduce((obj, prop) => obj && obj[prop as keyof object], source);
}
Loading

0 comments on commit dc01721

Please sign in to comment.