-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
Showing
19 changed files
with
695 additions
and
318 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"`); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
Oops, something went wrong.