From dc01721903d1b126696a3db708ed2bd5a55cd8d4 Mon Sep 17 00:00:00 2001 From: Richard Herman <1429781+GeekyEggo@users.noreply.github.com> Date: Tue, 23 Apr 2024 23:00:19 +0200 Subject: [PATCH] feat: at i18n namespace to UI (#36) * 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 --- package-lock.json | 8 +- package.json | 2 +- src/common/__tests__/i18n.test.ts | 124 +++++++++++++ src/common/__tests__/utils.test.ts | 79 +++++++++ src/common/i18n.ts | 95 ++++++++++ src/common/utils.ts | 21 +++ src/plugin/__tests__/i18n.test.ts | 189 +++++--------------- src/plugin/__tests__/index.test.ts | 10 +- src/plugin/common/__tests__/utils.test.ts | 27 +-- src/plugin/common/utils.ts | 11 -- src/plugin/i18n.ts | 127 ++------------ src/plugin/index.ts | 33 ++-- src/plugin/logging/__mocks__/index.ts | 1 - src/plugin/logging/index.ts | 2 - src/ui/__mocks__/logging.ts | 1 + src/ui/__tests__/i18n.test.ts | 201 ++++++++++++++++++++++ src/ui/__tests__/index.test.ts | 16 ++ src/ui/i18n.ts | 56 ++++++ src/ui/index.ts | 10 +- 19 files changed, 695 insertions(+), 318 deletions(-) create mode 100644 src/common/__tests__/i18n.test.ts create mode 100644 src/common/__tests__/utils.test.ts create mode 100644 src/common/i18n.ts create mode 100644 src/common/utils.ts create mode 100644 src/ui/__mocks__/logging.ts create mode 100644 src/ui/__tests__/i18n.test.ts create mode 100644 src/ui/i18n.ts diff --git a/package-lock.json b/package-lock.json index a0d9c84c..3193f8ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.4.0-beta.1", "license": "MIT", "dependencies": { - "@elgato/schemas": "^0.1.3", + "@elgato/schemas": "^0.3.1", "ws": "^8.14.2" }, "devDependencies": { @@ -543,9 +543,9 @@ "dev": true }, "node_modules/@elgato/schemas": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@elgato/schemas/-/schemas-0.1.3.tgz", - "integrity": "sha512-O/WoPeXtCNydp3A1S27ixJ0ZYgvSSvgEahNfnNOM3ORz/CMZx7ZPpsgmKEYULkHgrfk1xYDISk1rUGmVjIHvlg==" + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@elgato/schemas/-/schemas-0.3.1.tgz", + "integrity": "sha512-X6O673Le0PXCO3mIwBxTKMGKJWR4ZgQLKs7mPCWmbxSr2E5KUxOyrp/pUgplOPuPufA8lRU5nEIkvOlUC6ngJg==" }, "node_modules/@es-joy/jsdoccomment": { "version": "0.40.1", diff --git a/package.json b/package.json index 8edb6bf1..86ba7ba6 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "typescript": "^5.2.2" }, "dependencies": { - "@elgato/schemas": "^0.1.3", + "@elgato/schemas": "^0.3.1", "ws": "^8.14.2" } } diff --git a/src/common/__tests__/i18n.test.ts b/src/common/__tests__/i18n.test.ts new file mode 100644 index 00000000..8269196a --- /dev/null +++ b/src/common/__tests__/i18n.test.ts @@ -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"); + }); + }); +}); diff --git a/src/common/__tests__/utils.test.ts b/src/common/__tests__/utils.test.ts new file mode 100644 index 00000000..4fba9342 --- /dev/null +++ b/src/common/__tests__/utils.test.ts @@ -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); + }); +}); diff --git a/src/common/i18n.ts b/src/common/i18n.ts new file mode 100644 index 00000000..6ce3f857 --- /dev/null +++ b/src/common/i18n.ts @@ -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 = 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"`); +} diff --git a/src/common/utils.ts b/src/common/utils.ts new file mode 100644 index 00000000..7ff08e6b --- /dev/null +++ b/src/common/utils.ts @@ -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(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); +} diff --git a/src/plugin/__tests__/i18n.test.ts b/src/plugin/__tests__/i18n.test.ts index fa87d5e7..285e3cc3 100644 --- a/src/plugin/__tests__/i18n.test.ts +++ b/src/plugin/__tests__/i18n.test.ts @@ -1,189 +1,90 @@ -import fs, { Dirent } from "node:fs"; +import fs from "node:fs"; import path from "node:path"; -import { LogLevel, Logger } from "../../common/logging"; -import { I18nProvider } from "../i18n"; +import { fileSystemLocaleProvider } from "../i18n"; import { logger } from "../logging"; jest.mock("../logging"); -describe("I18nProvider", () => { - /** - * Defines a set of mock resources. - */ - type MockTranslations = { - Localization: { - greeting?: string; - manifestOnly?: string; - englishOnly?: string; - frenchOnly?: string; - germanOnly?: string; - }; - }; - +describe("fileSystemLocaleProvider", () => { const mockedCwd = "c:\\temp"; - const mockedResources = new Map(); - mockedResources.set("de.json", { Localization: { greeting: "Hello welt", germanOnly: "German" } }); - mockedResources.set("en.json", { Localization: { greeting: "Hello world", englishOnly: "English" } }); - mockedResources.set("fr.json", { Localization: { greeting: "Bonjour le monde", frenchOnly: "French" } }); - - let scopedLogger!: Logger; - - beforeEach(() => { - scopedLogger = new Logger({ - level: LogLevel.TRACE, - targets: [{ write: jest.fn }] - }); - - jest.spyOn(logger, "createScope").mockReturnValue(scopedLogger); - jest.spyOn(process, "cwd").mockReturnValue(mockedCwd); - }); + beforeEach(() => jest.spyOn(process, "cwd").mockReturnValue(mockedCwd)); afterEach(() => jest.resetAllMocks()); /** - * Asserts {@link I18nProvider} uses a scoped {@link Logger}. - */ - it("creates a scoped logger", () => { - // Arrange. - jest.spyOn(fs, "readdirSync").mockReturnValueOnce([] as unknown[] as Dirent[]); - jest.spyOn(fs, "readFileSync").mockImplementation(() => "{}"); - - const createScopeSpy = jest.spyOn(logger, "createScope"); - - // Act. - new I18nProvider("en", logger); - - // Assert. - expect(createScopeSpy).toHaveBeenCalledTimes(1); - expect(createScopeSpy).toHaveBeenCalledWith("I18nProvider"); - }); - - /** - * Asserts {@link I18nProvider} only reads from known languages. - */ - it("only reads recognized languages", () => { - // Arrange. - jest.spyOn(fs, "readdirSync").mockReturnValueOnce(["de.json", "en.json", "es.json", "fr.json", "ja.json", "zh_CN.json", "other.json"] as unknown[] as Dirent[]); - const readFileSyncSpy = jest.spyOn(fs, "readFileSync").mockImplementation(() => "{}"); - - // Act. - const i18n = new I18nProvider("en", logger); - i18n.translate("test"); - - // Assert. - expect(readFileSyncSpy).toHaveBeenCalledTimes(6); - - const opts = { flag: "r" }; - expect(readFileSyncSpy).toHaveBeenCalledWith("de.json", opts); - expect(readFileSyncSpy).toHaveBeenCalledWith("en.json", opts); - expect(readFileSyncSpy).toHaveBeenCalledWith("es.json", opts); - expect(readFileSyncSpy).toHaveBeenCalledWith("fr.json", opts); - expect(readFileSyncSpy).toHaveBeenCalledWith("ja.json", opts); - expect(readFileSyncSpy).toHaveBeenCalledWith("zh_CN.json", opts); - expect(readFileSyncSpy).not.toHaveBeenCalledWith("other.json", opts); - expect(readFileSyncSpy).not.toHaveBeenCalledWith(path.join(process.cwd(), "manifest.json"), opts); - }); - - /** - * Asserts {@link I18nProvider} correctly resorts to default language. + * Assert {@link fileSystemLocaleProvider} parses translation files. */ - it("falls back to the default language", () => { + it("reads from the language JSON file", () => { // Arrange. - jest.spyOn(fs, "readdirSync").mockReturnValue(["de.json", "en.json", "fr.json"] as unknown[] as Dirent[]); - jest.spyOn(fs, "readFileSync").mockImplementation((path) => JSON.stringify(mockedResources.get(path as string))); - - const i18n = new I18nProvider("de", logger); - - // Act. - const greeting = i18n.translate("greeting"); - const englishOnly = i18n.translate("englishOnly"); - - // Assert. - expect(greeting).toBe("Hello welt"); - expect(englishOnly).toBe("English"); - }); - - /** - * Asserts {@link I18nProvider} returns the key when the resource could not be found in either the current resource, or the default. - */ - it("returns the key for an unknown resource (logMissingKey: true)", () => { - // Arrange. - jest.spyOn(fs, "readdirSync").mockReturnValue([]); - jest.spyOn(fs, "readFileSync").mockReturnValue("{}"); - const spyOnWarn = jest.spyOn(scopedLogger, "warn"); - - const i18n = new I18nProvider("en", logger); + jest.spyOn(fs, "existsSync").mockReturnValue(true); + const spyOnReadFileSync = jest.spyOn(fs, "readFileSync").mockReturnValue( + JSON.stringify({ + Localization: { + Hello: "Hallo Welt" + } + }) + ); // Act. - i18n.logMissingKey = true; - const result = i18n.translate("hello"); + const translations = fileSystemLocaleProvider("de"); // Assert. - expect(result).toBe("hello"); - expect(spyOnWarn).toHaveBeenCalledTimes(1); - expect(spyOnWarn).toHaveBeenCalledWith("Missing translation: hello"); + expect(translations).toEqual({ Hello: "Hallo Welt" }); + expect(spyOnReadFileSync).toHaveBeenCalledTimes(1); + expect(spyOnReadFileSync).toHaveBeenCalledWith(path.join(mockedCwd, "de.json"), { flag: "r" }); }); /** - * Asserts {@link I18nProvider} returns the key when the resource could not be found in either the current resource, or the default. + * Assert {@link fileSystemLocaleProvider} returns null when the translation file does not exist. */ - it("returns the key for an unknown resource (logMissingKey: false)", () => { + it("returns null when the file is not found", () => { // Arrange. - jest.spyOn(fs, "readdirSync").mockReturnValue([]); - jest.spyOn(fs, "readFileSync").mockReturnValue("{}"); - const spyOnWarn = jest.spyOn(scopedLogger, "warn"); - - const i18n = new I18nProvider("en", logger); + jest.spyOn(fs, "existsSync").mockReturnValue(false); + const spyOnReadFileSync = jest.spyOn(fs, "readFileSync"); // Act. - i18n.logMissingKey = false; - const result = i18n.translate("hello"); + const translations = fileSystemLocaleProvider("en"); // Assert. - expect(result).toBe("hello"); - expect(spyOnWarn).toHaveBeenCalledTimes(0); + expect(translations).toBeNull(); + expect(spyOnReadFileSync).toHaveBeenCalledTimes(0); }); /** - * Asserts {@link I18nProvider} logs to the scoped-logger when a resource file could not be parsed. + * Assert {@link fileSystemLocaleProvider} returns null, and logs an error, when the contents of the file are not JSON. */ - it("logs when a resource file could not be parsed.", () => { + it("logs an error when the contents are not JSON", () => { // Arrange. - jest.spyOn(fs, "readdirSync").mockReturnValue(["en.json"] as unknown[] as Dirent[]); - jest.spyOn(fs, "readFileSync").mockReturnValue("{INVALID}"); - const spyOnError = jest.spyOn(scopedLogger, "error"); + jest.spyOn(fs, "existsSync").mockReturnValue(true); + const spyOnReadFileSync = jest.spyOn(fs, "readFileSync").mockReturnValue(`{"value":invalid}`); + const spyOnLogError = jest.spyOn(logger, "error"); // Act. - const i18n = new I18nProvider("en", logger); - i18n.translate("test"); + const translations = fileSystemLocaleProvider("es"); // Assert. - expect(spyOnError).toHaveBeenCalledTimes(1); - expect(spyOnError).toHaveBeenCalledWith("Failed to load translations from en.json", expect.any(Error)); + expect(translations).toBeNull(); + expect(spyOnReadFileSync).toHaveBeenCalledTimes(1); + expect(spyOnLogError).toHaveBeenCalledTimes(1); + expect(spyOnLogError).toHaveBeenCalledWith(`Failed to load translations from ${path.join(mockedCwd, "es.json")}`, expect.any(SyntaxError)); }); /** - * Asserts {@link I18nProvider} is capable of reading from nested properties. + * Assert {@link fileSystemLocaleProvider} returns null, and logs an error, when the contents of the file are not the expected structure. */ - it("translates nested properties", () => { + it("logs an error when the structure is incorrect", () => { // Arrange. - jest.spyOn(fs, "readdirSync").mockReturnValue(["en.json"] as unknown[] as Dirent[]); - jest.spyOn(fs, "readFileSync").mockReturnValue( - JSON.stringify({ - Localization: { - parent: { - child: "Hello world" - } - } - }) - ); - - const i18n = new I18nProvider("en", logger); + jest.spyOn(fs, "existsSync").mockReturnValue(true); + const spyOnReadFileSync = jest.spyOn(fs, "readFileSync").mockReturnValue(`{"NotLocalization":"Incorrect format"}`); + const spyOnLogError = jest.spyOn(logger, "error"); // Act. - const result = i18n.translate("parent.child"); + const translations = fileSystemLocaleProvider("ja"); // Assert. - expect(result).toBe("Hello world"); + expect(translations).toBeNull(); + expect(spyOnReadFileSync).toHaveBeenCalledTimes(1); + expect(spyOnLogError).toHaveBeenCalledTimes(1); + expect(spyOnLogError).toHaveBeenCalledWith(`Failed to load translations from ${path.join(mockedCwd, "ja.json")}`, expect.any(TypeError)); }); }); diff --git a/src/plugin/__tests__/index.test.ts b/src/plugin/__tests__/index.test.ts index 53fbe81c..82ed0d42 100644 --- a/src/plugin/__tests__/index.test.ts +++ b/src/plugin/__tests__/index.test.ts @@ -1,17 +1,18 @@ import { BarSubType, DeviceType, Target } from "../../api"; import { EventEmitter } from "../../common/event-emitter"; +import { I18nProvider } from "../../common/i18n"; import { LogLevel } from "../../common/logging"; import { Action } from "../actions/action"; import { SingletonAction } from "../actions/singleton-action"; import { connection } from "../connection"; -import { I18nProvider } from "../i18n"; import streamDeckAsDefaultExport, { streamDeck } from "../index"; import { logger } from "../logging"; +import { route } from "../ui/route"; +jest.mock("../../common/i18n"); jest.mock("../logging"); jest.mock("../manifest"); jest.mock("../connection"); -jest.mock("../i18n"); describe("index", () => { /** @@ -65,9 +66,9 @@ describe("index", () => { }); /** - * Asserts supporting enums and classes are exported. + * Asserts supporting enums, classes, and functions are exported. */ - it("exports enums and classes", async () => { + it("exports enums, classes, and functions", async () => { // Arrange. const index = (await require("../index")) as typeof import("../index"); @@ -80,6 +81,7 @@ describe("index", () => { expect(index.LogLevel).toBe(LogLevel); expect(index.SingletonAction).toBe(SingletonAction); expect(index.Target).toBe(Target); + expect(index.route).toBe(route); }); /** diff --git a/src/plugin/common/__tests__/utils.test.ts b/src/plugin/common/__tests__/utils.test.ts index 935e14cd..0acb021d 100644 --- a/src/plugin/common/__tests__/utils.test.ts +++ b/src/plugin/common/__tests__/utils.test.ts @@ -1,32 +1,7 @@ import path from "node:path"; import type { isDebugMode } from "../utils"; -import { get, getPluginUUID } from "../utils"; - -/** - * Asserts {@link get} correct reads values from objects based on the specified path. - */ -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); - }); -}); +import { getPluginUUID } from "../utils"; /** * Asserts {@link getPluginUUID} is correctly parsed from the current working directory. diff --git a/src/plugin/common/utils.ts b/src/plugin/common/utils.ts index c6e452d1..89737ddf 100644 --- a/src/plugin/common/utils.ts +++ b/src/plugin/common/utils.ts @@ -2,17 +2,6 @@ import path from "node:path"; let __isDebugMode: boolean | undefined = undefined; -/** - * 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); -} - /** * Determines whether the current plugin is running in a debug environment; this is determined by the command-line arguments supplied to the plugin by Stream. Specifically, the result * is `true` when either `--inspect`, `--inspect-brk` or `--inspect-port` are present as part of the processes' arguments. diff --git a/src/plugin/i18n.ts b/src/plugin/i18n.ts index 1dc0d0e6..3e044116 100644 --- a/src/plugin/i18n.ts +++ b/src/plugin/i18n.ts @@ -1,117 +1,28 @@ -import file from "node:fs"; +import fs from "node:fs"; import path from "node:path"; -import { supportedLanguages, type Language } from "../api"; -import { JsonObject } from "../common/json"; -import { get } from "./common/utils"; -import { Logger } from "./logging"; +import { type Language } from "../api"; +import { parseLocalizations } from "../common/i18n"; +import { type JsonObject } from "../common/json"; +import { logger } from "./logging"; /** - * Provides locales and translations for internalization. + * Loads a locale from the file system. + * @param language Language to load. + * @returns Contents of the locale. */ -export class I18nProvider { - /** - * Determines whether a log should be written when a resource could not be found. - */ - public logMissingKey = true; - - /** - * Default language to be used when a resource does not exist for the desired language. - */ - private static readonly DEFAULT_LANGUAGE: Language = "en"; - - /** - * Private backing field for {@link I18nProvider.locales}. - */ - private _locales: Map | undefined; - - /** - * Logger scoped to this class. - */ - private readonly logger: Logger; - - /** - * 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 logger Logger responsible for capturing log entries. - */ - constructor( - private readonly language: Language, - logger: Logger - ) { - this.logger = logger.createScope("I18nProvider"); - } - - /** - * Collection of loaded locales and their translations. - * @returns The locales that contains the translations. - */ - private get locales(): Map { - if (this._locales !== undefined) { - return this._locales; - } - - const locales = new Map(); - for (const filePath of file.readdirSync(process.cwd())) { - const { ext, name } = path.parse(filePath); - const lng = name as Language; - - if (ext.toLowerCase() == ".json" && supportedLanguages.includes(lng)) { - const contents = this.readFile(filePath); - if (contents !== undefined) { - locales.set(lng, contents); - } - } - } - - return (this._locales = locales); +export function fileSystemLocaleProvider(language: Language): JsonObject | null { + const filePath = path.join(process.cwd(), `${language}.json`); + if (!fs.existsSync(filePath)) { + return null; } - /** - * Gets the translation for the specified {@link key} from the resources defined for {@link language}. When the key is not found, the default language is checked. - * @param key Key that represents the translation. - * @param language Optional language to get the translation for; otherwise the default language. - * @returns The translation; otherwise an empty string. - */ - public translate(key: string, language: Language | undefined = this.language): string { - const translation = this.translateOrDefault(key, language); - - if (translation === undefined && this.logMissingKey) { - this.logger.warn(`Missing translation: ${key}`); - } - - return translation || key; - } - - /** - * Reads the contents of the {@link filePath} and parses it as JSON. - * @param filePath File path to read. - * @returns Parsed object; otherwise `undefined`. - */ - private readFile(filePath: string): JsonObject | undefined { - try { - const contents = file.readFileSync(filePath, { flag: "r" })?.toString(); - return JSON.parse(contents); - } catch (err) { - this.logger.error(`Failed to load translations from ${filePath}`, err); - } - } - - /** - * Gets the resource for the specified {@link key}; when the resource is not available for the {@link language}, the default language is used. - * @param key Key that represents the translation. - * @param language Language to retrieve the resource from. - * @returns The resource; otherwise the default language's resource, or `undefined`. - */ - private translateOrDefault(key: string, language: Language = this.language): string | undefined { - key = `Localization.${key}`; - - // When the language and default are the same, only check the language. - if (language === I18nProvider.DEFAULT_LANGUAGE) { - return get(key, this.locales.get(language))?.toString(); - } - - // Otherwise check the language and default. - return get(key, this.locales.get(language))?.toString() || get(key, this.locales.get(I18nProvider.DEFAULT_LANGUAGE))?.toString(); + try { + // Parse the translations from the file. + const contents = fs.readFileSync(filePath, { flag: "r" })?.toString(); + return parseLocalizations(contents); + } catch (err) { + logger.error(`Failed to load translations from ${filePath}`, err); + return null; } } diff --git a/src/plugin/index.ts b/src/plugin/index.ts index bebbcc2f..827ccef5 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,5 +1,17 @@ import type { Manifest, RegistrationInfo } from "../api"; +import { I18nProvider } from "../common/i18n"; +import { registerCreateLogEntryRoute, type Logger } from "../common/logging"; +import * as actions from "./actions"; +import { connection } from "./connection"; +import { devices } from "./devices"; +import { fileSystemLocaleProvider } from "./i18n"; +import { logger } from "./logging"; import { getManifest } from "./manifest"; +import * as profiles from "./profiles"; +import * as settings from "./settings"; +import * as system from "./system"; +import { ui, type UIController } from "./ui"; +import { router } from "./ui/router"; export { BarSubType, @@ -21,26 +33,15 @@ export { } from "../api"; export { EventEmitter, EventsOf } from "../common/event-emitter"; export { type JsonObject, type JsonPrimitive, type JsonValue } from "../common/json"; +export { LogLevel } from "../common/logging"; export { type MessageRequestOptions, type MessageResponder, type MessageResponse, type RouteConfiguration, type StatusCode } from "../common/messaging"; export { Action, ImageOptions, TitleOptions, TriggerDescriptionOptions } from "./actions/action"; export { action } from "./actions/decorators"; export { SingletonAction } from "./actions/singleton-action"; -export { Device } from "./devices"; +export { type Device } from "./devices"; export * from "./events"; -export { LogLevel } from "./logging"; export { route, type MessageRequest, type PropertyInspector } from "./ui"; - -import { registerCreateLogEntryRoute } from "../common/logging"; -import * as actions from "./actions"; -import { connection } from "./connection"; -import { devices } from "./devices"; -import { I18nProvider } from "./i18n"; -import { logger, type Logger } from "./logging"; -import * as profiles from "./profiles"; -import * as settings from "./settings"; -import * as system from "./system"; -import { ui, type UIController } from "./ui"; -import { router } from "./ui/router"; +export { type Logger }; let i18n: I18nProvider | undefined; @@ -62,11 +63,11 @@ export const streamDeck = { }, /** - * Namespace for internalization, including translations, see {@link https://docs.elgato.com/sdk/plugins/localization}. + * Internalization provider, responsible for managing localizations and translating resources. * @returns Internalization provider. */ get i18n(): I18nProvider { - return (i18n ??= new I18nProvider(this.info.application.language, this.logger)); + return (i18n ??= new I18nProvider(this.info.application.language, fileSystemLocaleProvider)); }, /** diff --git a/src/plugin/logging/__mocks__/index.ts b/src/plugin/logging/__mocks__/index.ts index 5660b1d5..ec3a2af1 100644 --- a/src/plugin/logging/__mocks__/index.ts +++ b/src/plugin/logging/__mocks__/index.ts @@ -5,5 +5,4 @@ const options: LoggerOptions = { targets: [{ write: jest.fn() }] }; -export { LogLevel, Logger }; export const logger = new Logger(options); diff --git a/src/plugin/logging/index.ts b/src/plugin/logging/index.ts index 9b5d68f3..1be4e4aa 100644 --- a/src/plugin/logging/index.ts +++ b/src/plugin/logging/index.ts @@ -6,8 +6,6 @@ import { ConsoleTarget } from "../../common/logging/console-target"; import { getPluginUUID, isDebugMode } from "../common/utils"; import { FileTarget } from "./file-target"; -export { LogLevel, Logger } from "../../common/logging"; - // Log all entires to a log file. const fileTarget = new FileTarget({ dest: path.join(cwd(), "logs"), diff --git a/src/ui/__mocks__/logging.ts b/src/ui/__mocks__/logging.ts new file mode 100644 index 00000000..96341f1f --- /dev/null +++ b/src/ui/__mocks__/logging.ts @@ -0,0 +1 @@ +export { logger } from "../../plugin/logging/__mocks__/index"; diff --git a/src/ui/__tests__/i18n.test.ts b/src/ui/__tests__/i18n.test.ts new file mode 100644 index 00000000..50d88d40 --- /dev/null +++ b/src/ui/__tests__/i18n.test.ts @@ -0,0 +1,201 @@ +/** + * @jest-environment jsdom + */ + +jest.mock("../logging"); + +/** + * Provides assertions for the singleton `i18n`. + */ +describe("i18n", () => { + afterEach(() => { + jest.resetAllMocks(); + jest.resetModules(); + }); + + /** + * Asserts the default language is determined from the window's navigator language value. + */ + it("should use navigator language as default", async () => { + // Arrange. + const mockedXMLHttpRequest = { + open: jest.fn(), + send: jest.fn(), + response: JSON.stringify({ Localization: { Hello: "Hallo Welt" } }) + }; + + jest.spyOn(window, "location", "get").mockReturnValue({ href: "file:///c:/temp/com.elgato.test.sdPlugin/ui/pi.html" } as unknown as Location); + jest.spyOn(window, "XMLHttpRequest").mockImplementation(() => mockedXMLHttpRequest as unknown as XMLHttpRequest); + jest.spyOn(window.navigator, "language", "get").mockReturnValue("de"); + + const { i18n } = (await require("../i18n")) as typeof import("../i18n"); + + // Act. + const result = i18n.translate("Hello"); + + // Assert. + expect(result).toBe("Hallo Welt"); + expect(mockedXMLHttpRequest.open).toHaveBeenCalledTimes(1); + expect(mockedXMLHttpRequest.open).toHaveBeenCalledWith("GET", "file:///c:/temp/com.elgato.test.sdPlugin/de.json", false); + }); + + /** + * Asserts the default language is determined from the window's navigator language value, when the language contains the region. + */ + it("should ignore localized navigation language", async () => { + // Arrange. + const mockedXMLHttpRequest = { + open: jest.fn(), + send: jest.fn(), + response: JSON.stringify({ Localization: { Hello: "Hello world" } }) + }; + + jest.spyOn(window, "location", "get").mockReturnValue({ href: "file:///c:/temp/com.elgato.test.sdPlugin/ui/pi.html" } as unknown as Location); + jest.spyOn(window, "XMLHttpRequest").mockImplementation(() => mockedXMLHttpRequest as unknown as XMLHttpRequest); + jest.spyOn(window.navigator, "language", "get").mockReturnValue("en-US"); + + const { i18n } = (await require("../i18n")) as typeof import("../i18n"); + + // Act. + const result = i18n.translate("Hello"); + + // Assert. + expect(result).toBe("Hello world"); + expect(mockedXMLHttpRequest.open).toHaveBeenCalledTimes(1); + expect(mockedXMLHttpRequest.open).toHaveBeenCalledWith("GET", "file:///c:/temp/com.elgato.test.sdPlugin/en.json", false); + }); +}); + +/** + * Provides assertions for loading locale translations using `xmlHttpRequestLocaleProviderSync`. + */ +describe("xmlHttpRequestLocaleProviderSync", () => { + let xmlHttpRequestLocaleProviderSync: typeof import("../i18n").xmlHttpRequestLocaleProviderSync; + + beforeEach(async () => { + jest.spyOn(window, "location", "get").mockReturnValue({ href: "file:///c:/temp/com.elgato.test.sdPlugin/ui/pi.html" } as unknown as Location); + ({ xmlHttpRequestLocaleProviderSync } = await require("../i18n")); + }); + + afterEach(() => jest.resetAllMocks()); + + /** + * Assert `xmlHttpRequestLocaleProviderSync` parses translation files. + */ + it("reads from the language JSON file", () => { + // Arrange. + const mockedXMLHttpRequest = { + open: jest.fn(), + send: jest.fn(), + response: JSON.stringify({ + Localization: { + Hello: "Hello world" + } + }) + }; + + jest.spyOn(window, "XMLHttpRequest").mockImplementation(() => mockedXMLHttpRequest as unknown as XMLHttpRequest); + + // Act. + const translations = xmlHttpRequestLocaleProviderSync("en"); + + // Assert. + expect(translations).toEqual({ Hello: "Hello world" }); + expect(mockedXMLHttpRequest.open).toHaveBeenCalledTimes(1); + expect(mockedXMLHttpRequest.open).toHaveBeenCalledWith("GET", "file:///c:/temp/com.elgato.test.sdPlugin/en.json", false); + expect(mockedXMLHttpRequest.send).toHaveBeenCalledTimes(1); + }); + + /** + * Assert `xmlHttpRequestLocaleProviderSync` returns null when the translation file does not exist. + */ + it("returns null when the file is not found", () => { + // Arrange. + const mockedXMLHttpRequest = { + open: jest.fn(), + send: jest.fn().mockImplementation(() => { + throw new DOMException(undefined, "NOT_FOUND_ERR"); + }), + response: null + }; + + jest.spyOn(window, "XMLHttpRequest").mockImplementation(() => mockedXMLHttpRequest as unknown as XMLHttpRequest); + const spyOnConsoleWarn = jest.spyOn(console, "warn").mockImplementationOnce(() => {}); + + // Act. + const translations = xmlHttpRequestLocaleProviderSync("de"); + + // Assert. + expect(translations).toBeNull(); + expect(spyOnConsoleWarn).toHaveBeenCalledTimes(1); + expect(spyOnConsoleWarn).toHaveBeenCalledWith("Missing localization file: de.json"); + }); + + /** + * Assert `xmlHttpRequestLocaleProviderSync` returns null, and logs an error, when the contents of the file are not JSON. + */ + it("logs an error when the contents are not JSON", async () => { + // Arrange. + const mockedXMLHttpRequest = { + open: jest.fn(), + send: jest.fn(), + response: `{"value":invalid}` + }; + + jest.spyOn(window, "XMLHttpRequest").mockImplementation(() => mockedXMLHttpRequest as unknown as XMLHttpRequest); + const { logger } = await require("../logging"); + const spyOnLogError = jest.spyOn(logger, "error"); + + // Act. + const translations = xmlHttpRequestLocaleProviderSync("es"); + + // Assert. + expect(translations).toBeNull(); + expect(spyOnLogError).toHaveBeenCalledTimes(1); + expect(spyOnLogError).toHaveBeenCalledWith("Failed to load translations from file:///c:/temp/com.elgato.test.sdPlugin/es.json", expect.any(SyntaxError)); + }); + + /** + * Assert `xmlHttpRequestLocaleProviderSync` returns null, and logs an error, when the contents of the file are not the expected structure. + */ + it("logs an error when the structure is incorrect", async () => { + // Arrange. + const mockedXMLHttpRequest = { + open: jest.fn(), + send: jest.fn(), + response: `{"NotLocalization":"Incorrect format"}` + }; + + jest.spyOn(window, "XMLHttpRequest").mockImplementation(() => mockedXMLHttpRequest as unknown as XMLHttpRequest); + const { logger } = await require("../logging"); + const spyOnLogError = jest.spyOn(logger, "error"); + + // Act. + const translations = xmlHttpRequestLocaleProviderSync("ja"); + + // Assert. + expect(translations).toBeNull(); + expect(spyOnLogError).toHaveBeenCalledTimes(1); + expect(spyOnLogError).toHaveBeenCalledWith(`Failed to load translations from file:///c:/temp/com.elgato.test.sdPlugin/ja.json`, expect.any(TypeError)); + }); +}); + +/** + * Provides assertions for `cwd()`. + */ +describe("cwd", () => { + let cwd: typeof import("../i18n").cwd; + beforeAll(async () => ({ cwd } = await require("../i18n"))); + + it("should find folder ending in .sdPlugin", () => { + // Arrange, act, asset. + jest.spyOn(window, "location", "get").mockReturnValue({ href: "file:///c:/plugins/com.elgato.test.sdPlugin/pi.html" } as unknown as Location); + expect(cwd()).toBe("file:///c:/plugins/com.elgato.test.sdPlugin/"); + }); + + it("should return the entire path minus the file when .sdPlugin not found", () => { + // Arrange, act, asset. + jest.spyOn(window, "location", "get").mockReturnValue({ href: "file:///c:/test/folder/ui/pi.html" } as unknown as Location); + expect(cwd()).toBe("file:///c:/test/folder/ui/"); + }); +}); diff --git a/src/ui/__tests__/index.test.ts b/src/ui/__tests__/index.test.ts index bd91cdd9..62271e87 100644 --- a/src/ui/__tests__/index.test.ts +++ b/src/ui/__tests__/index.test.ts @@ -3,7 +3,10 @@ */ import streamDeck from "../"; +import { DeviceType } from "../../api"; import { actionInfo, registrationInfo } from "../../api/registration/__mocks__"; +import { EventEmitter } from "../../common/event-emitter"; +import { LogLevel } from "../../common/logging"; import { connection } from "../connection"; import { plugin } from "../plugin"; import * as settings from "../settings"; @@ -22,6 +25,19 @@ describe("streamDeck", () => { expect(streamDeck.system).toStrictEqual(system); }); + /** + * Asserts supporting enums, classes, and functions are exported. + */ + it("exports enums, classes, and functions", async () => { + // Arrange. + const index = (await require("../index")) as typeof import("../index"); + + // Act, assert. + expect(index.DeviceType).toBe(DeviceType); + expect(index.EventEmitter).toBe(EventEmitter); + expect(index.LogLevel).toBe(LogLevel); + }); + /** * Asserts {@link streamDeck.onDidConnect} is propagated when the connection emits `connected`. */ diff --git a/src/ui/i18n.ts b/src/ui/i18n.ts new file mode 100644 index 00000000..1740a90e --- /dev/null +++ b/src/ui/i18n.ts @@ -0,0 +1,56 @@ +import type { Language } from "../api"; +import { I18nProvider, parseLocalizations } from "../common/i18n"; +import type { JsonObject } from "../common/json"; +import { logger } from "./logging"; + +const __cwd = cwd(); + +/** + * Internalization provider, responsible for managing localizations and translating resources. + */ +export const i18n = new I18nProvider((window.navigator.language ? window.navigator.language.split("-")[0] : "en") as Language, xmlHttpRequestLocaleProviderSync); + +/** + * Loads a locale from the file system using `fetch`. + * @param language Language to load. + * @returns Contents of the locale. + */ +export function xmlHttpRequestLocaleProviderSync(language: Language): JsonObject | null { + const filePath = `${__cwd}${language}.json`; + + try { + const req = new XMLHttpRequest(); + req.open("GET", filePath, false); + req.send(); + + return parseLocalizations(req.response); + } catch (err) { + if (err instanceof DOMException && err.name === "NOT_FOUND_ERR") { + // Browser consoles will inherently log an error if a resource cannot be found; we should provide + // a more forgiving warning alongside the error, without cluttering the main log file. + console.warn(`Missing localization file: ${language}.json`); + } else { + logger.error(`Failed to load translations from ${filePath}`, err); + } + + return null; + } +} + +/** + * Gets the current working directory. + * @returns The directory. + */ +export function cwd(): string { + let path = ""; + + const segments = window.location.href.split("/"); + for (let i = 0; i < segments.length - 1; i++) { + path += `${segments[i]}/`; + if (segments[i].endsWith(".sdPlugin")) { + break; + } + } + + return path; +} diff --git a/src/ui/index.ts b/src/ui/index.ts index 0cb595e1..383591b4 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -1,18 +1,26 @@ import { type ActionInfo, type RegistrationInfo } from "../api"; import type { IDisposable } from "../common/disposable"; import { connection } from "./connection"; +import { i18n } from "./i18n"; import { logger } from "./logging"; import { plugin } from "./plugin"; import * as settings from "./settings"; import * as system from "./system"; -export { type ActionInfo, type ConnectElgatoStreamDeckSocketFn, type RegistrationInfo } from "../api"; +export { DeviceType, type ActionInfo, type ConnectElgatoStreamDeckSocketFn, type Controller, type RegistrationInfo } from "../api"; +export { EventEmitter } from "../common/event-emitter"; export { type JsonObject, type JsonPrimitive, type JsonValue } from "../common/json"; +export { LogLevel, type Logger } from "../common/logging"; export { type MessageRequestOptions, type MessageResponder, type MessageResponse, type RouteConfiguration, type StatusCode } from "../common/messaging"; export * from "./events"; export { type MessageHandler, type MessageRequest } from "./plugin"; const streamDeck = { + /** + * Internalization provider, responsible for managing localizations and translating resources. + */ + i18n, + /** * Logger responsible for capturing log messages. */