refactor: convert Translator to ES module (#4202)

Another step towards ES modules: this converts `Translator` to a proper
module and imports it where needed, instead of relying on it being
globally available.

The goal is to gradually get rid of the old global-script style in the
core. It makes dependencies explicit, reduces hidden coupling and keeps
the codebase easier to maintain going forward.

The code change is tiny - the bigger and harder part was adapting the
tests to the new loading behavior.
This commit is contained in:
Kristjan ESPERANTO
2026-07-12 21:36:12 +02:00
committed by GitHub
parent a489d930e1
commit ed95826d19
9 changed files with 133 additions and 124 deletions
-1
View File
@@ -49,7 +49,6 @@
<script type="text/javascript" src="defaultmodules/utils.js"></script>
<script type="text/javascript" src="js/logger.js"></script>
<script type="text/javascript" src="translations/translations.js"></script>
<script type="text/javascript" src="js/translator.js"></script>
<script type="text/javascript" src="config/basepath.js"></script>
<script type="text/javascript" src="js/animateCSS.js"></script>
<script type="text/javascript" src="js/positions.js"></script>
+3 -1
View File
@@ -2,6 +2,8 @@
// eslint-disable-next-line import-x/extensions
import { loadModules } from "./loader.js";
// eslint-disable-next-line import-x/extensions
import { Translator } from "./translator.js";
let modules = [];
@@ -571,7 +573,7 @@ export const MM = {
Log.setLogLevel(config.logLevel);
await globalThis.Translator.loadCoreTranslations(config.language);
await Translator.loadCoreTranslations(config.language);
await loadModules();
},
+2
View File
@@ -4,6 +4,8 @@
import { loadFileForModule } from "./loader.js";
// eslint-disable-next-line import-x/extensions
import { MMSocket } from "./socketclient.js";
// eslint-disable-next-line import-x/extensions
import { Translator } from "./translator.js";
/*
* Module Blueprint.
+1 -3
View File
@@ -1,6 +1,6 @@
/* global translations */
const Translator = (function () {
export const Translator = (function () {
/**
* Load a JSON file via fetch.
@@ -125,5 +125,3 @@ const Translator = (function () {
}
};
}());
window.Translator = Translator;
+42 -70
View File
@@ -2,27 +2,23 @@ const fs = require("node:fs");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const helmet = require("helmet");
const { JSDOM } = require("jsdom");
const express = require("express");
const translations = require("../../translations/translations");
const {
setupTranslationTestEnvironment,
TRANSLATOR_MODULE_URL,
resetTranslatorState
} = require("../utils/translation_test_environment");
/**
* Helper function to create a fresh Translator instance with DOM environment.
* @returns {object} Object containing window and Translator
* Create a fresh Translator state for each test.
* @returns {Promise<object>} Shared Translator singleton with cleared state
*/
function createTranslationTestEnvironment () {
// Setup DOM environment with Translator
const translatorJs = fs.readFileSync(path.join(__dirname, "..", "..", "js", "translator.js"), "utf-8");
const dom = new JSDOM("", { url: "http://localhost:3000", runScripts: "outside-only" });
dom.window.Log = { log: vi.fn(), error: vi.fn() };
dom.window.translations = translations;
dom.window.fetch = fetch;
dom.window.eval(translatorJs);
const window = dom.window;
return { window, Translator: window.Translator };
async function getFreshTranslator () {
setupTranslationTestEnvironment(3000);
const { Translator } = await import(TRANSLATOR_MODULE_URL);
resetTranslatorState(Translator);
return Translator;
}
describe("translations", () => {
@@ -40,6 +36,13 @@ describe("translations", () => {
server = app.listen(3000);
});
afterEach(() => {
vi.restoreAllMocks();
delete global.document;
delete global.Log;
delete global.config;
});
afterAll(async () => {
await server.close();
});
@@ -53,101 +56,70 @@ describe("translations", () => {
});
describe("loadTranslations", () => {
let dom;
let Translator;
let Module;
let config;
beforeEach(async () => {
// Create a new translation test environment for each test
const env = createTranslationTestEnvironment();
const window = env.window;
global.Log = { log: vi.fn(), error: vi.fn(), warn: vi.fn() };
config = { language: "de" };
global.config = config;
// Bridge JSDOM globals to Node.js so module.js (ES module) can access them
global.Log = window.Log;
global.Translator = window.Translator;
global.config = { language: "de" };
global.window = { name: "", mmVersion: "2.0.0" };
global.MM = { hideModule: () => {}, showModule: () => {}, sendNotification: () => {}, updateDom: () => {} };
global.nunjucks = {
Environment () {
this.addFilter = () => {};
this.renderString = () => "";
this.render = (_t, _d, cb) => cb(null, "");
},
WebLoader () {},
runtime: { markSafe: (str) => str }
};
// module.js and translator.js are ES modules that read these globals at call time.
Translator = await getFreshTranslator();
// Import Module directly — eval can't handle ES module syntax
const modulePath = pathToFileURL(path.join(__dirname, "..", "..", "js", "module.js")).href;
const { Module } = await import(`${modulePath}?test=${Date.now()}`);
window.Module = Module;
// Expose config on window so tests can modify dom.window.config
window.config = global.config;
dom = { window };
});
afterEach(() => {
delete global.Log;
delete global.Translator;
delete global.config;
delete global.window;
delete global.MM;
delete global.nunjucks;
({ Module } = await import(modulePath));
});
it("should load translation file", async () => {
const { Translator, Module, config } = dom.window;
config.language = "en";
Translator.load = vi.fn().mockImplementation(() => null);
const loadSpy = vi.spyOn(Translator, "load").mockResolvedValue(null);
Module.register("name", { getTranslations: () => translations });
const MMM = Module.create("name");
await MMM.loadTranslations();
expect(Translator.load.mock.calls).toHaveLength(1);
expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/en.json", false);
expect(loadSpy.mock.calls).toHaveLength(1);
expect(loadSpy).toHaveBeenCalledWith(MMM, "translations/en.json", false);
});
it("should load translation + fallback file", async () => {
const { Translator, Module } = dom.window;
Translator.load = vi.fn().mockImplementation(() => null);
const loadSpy = vi.spyOn(Translator, "load").mockResolvedValue(null);
Module.register("name", { getTranslations: () => translations });
const MMM = Module.create("name");
await MMM.loadTranslations();
expect(Translator.load.mock.calls).toHaveLength(2);
expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/de.json", false);
expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/en.json", true);
expect(loadSpy.mock.calls).toHaveLength(2);
expect(loadSpy).toHaveBeenCalledWith(MMM, "translations/de.json", false);
expect(loadSpy).toHaveBeenCalledWith(MMM, "translations/en.json", true);
});
it("should load translation fallback file", async () => {
const { Translator, Module, config } = dom.window;
config.language = "--";
Translator.load = vi.fn().mockImplementation(() => null);
const loadSpy = vi.spyOn(Translator, "load").mockResolvedValue(null);
Module.register("name", { getTranslations: () => translations });
const MMM = Module.create("name");
await MMM.loadTranslations();
expect(Translator.load.mock.calls).toHaveLength(1);
expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/en.json", true);
expect(loadSpy.mock.calls).toHaveLength(1);
expect(loadSpy).toHaveBeenCalledWith(MMM, "translations/en.json", true);
});
it("should load no file", async () => {
const { Translator, Module } = dom.window;
Translator.load = vi.fn();
const loadSpy = vi.spyOn(Translator, "load").mockResolvedValue(null);
Module.register("name", {});
const MMM = Module.create("name");
await MMM.loadTranslations();
expect(Translator.load.mock.calls).toHaveLength(0);
expect(loadSpy.mock.calls).toHaveLength(0);
});
});
@@ -161,7 +133,7 @@ describe("translations", () => {
describe("parsing language files through the Translator class", () => {
for (const language in translations) {
it(`should parse ${language}`, async () => {
const { Translator } = createTranslationTestEnvironment();
const Translator = await getFreshTranslator();
await Translator.load(mmm, translations[language], false);
expect(typeof Translator.translations[mmm.name]).toBe("object");
@@ -193,7 +165,7 @@ describe("translations", () => {
// Function to initialize JSDOM and load translations
const initializeTranslationDOM = async (language) => {
const { Translator } = createTranslationTestEnvironment();
const Translator = await getFreshTranslator();
await Translator.load(mmm, translations[language], false);
return Translator.translations[mmm.name];
};
-7
View File
@@ -9,7 +9,6 @@ describe("File js/module (cloneObject)", () => {
let originalLog;
let originalConfig;
let originalMM;
let originalTranslator;
let originalNunjucks;
beforeAll(async () => {
@@ -17,7 +16,6 @@ describe("File js/module (cloneObject)", () => {
originalLog = global.Log;
originalConfig = global.config;
originalMM = global.MM;
originalTranslator = global.Translator;
originalNunjucks = global.nunjucks;
global.window = { mmVersion: "2.0.0" };
@@ -29,10 +27,6 @@ describe("File js/module (cloneObject)", () => {
sendNotification: () => {},
updateDom: () => {}
};
global.Translator = {
load: () => Promise.resolve(),
translate: () => ""
};
global.nunjucks = {
Environment () {
this.addFilter = () => {};
@@ -57,7 +51,6 @@ describe("File js/module (cloneObject)", () => {
global.Log = originalLog;
global.config = originalConfig;
global.MM = originalMM;
global.Translator = originalTranslator;
global.nunjucks = originalNunjucks;
});
+42 -35
View File
@@ -1,22 +1,22 @@
const fs = require("node:fs");
const path = require("node:path");
const helmet = require("helmet");
const { JSDOM } = require("jsdom");
const express = require("express");
const {
setupTranslationTestEnvironment,
TRANSLATOR_MODULE_URL,
resetTranslatorState
} = require("../../utils/translation_test_environment");
/**
* Helper function to create a fresh Translator instance with DOM environment.
* @returns {object} Object containing window and Translator
* Create a fresh Translator state for each test.
* @returns {Promise<object>} Shared Translator singleton with cleared state
*/
function createTranslationTestEnvironment () {
const translatorJs = fs.readFileSync(path.join(__dirname, "..", "..", "..", "js", "translator.js"), "utf-8");
const dom = new JSDOM("", { url: "http://localhost:3001", runScripts: "outside-only" });
dom.window.Log = { log: vi.fn(), error: vi.fn() };
dom.window.fetch = fetch;
dom.window.eval(translatorJs);
return { window: dom.window, Translator: dom.window.Translator };
async function getFreshTranslator () {
setupTranslationTestEnvironment(3001);
const { Translator } = await import(TRANSLATOR_MODULE_URL);
resetTranslatorState(Translator);
return Translator;
}
describe("Translator", () => {
@@ -49,6 +49,13 @@ describe("Translator", () => {
await server.close();
});
afterEach(() => {
vi.restoreAllMocks();
delete global.document;
delete global.Log;
delete global.translations;
});
describe("translate", () => {
const translations = {
"MMM-Module": {
@@ -93,8 +100,8 @@ describe("Translator", () => {
Translator.coreTranslationsFallback = coreTranslationsFallback;
};
it("should return custom module translation", () => {
const { Translator } = createTranslationTestEnvironment();
it("should return custom module translation", async () => {
const Translator = await getFreshTranslator();
setTranslations(Translator);
let translation = Translator.translate({ name: "MMM-Module" }, "Hello");
@@ -104,8 +111,8 @@ describe("Translator", () => {
expect(translation).toBe("Hallo fewieden");
});
it("should return core translation", () => {
const { Translator } = createTranslationTestEnvironment();
it("should return core translation", async () => {
const Translator = await getFreshTranslator();
setTranslations(Translator);
let translation = Translator.translate({ name: "MMM-Module" }, "FOO");
expect(translation).toBe("Foo");
@@ -113,29 +120,29 @@ describe("Translator", () => {
expect(translation).toBe("Bar Lorem Ipsum");
});
it("should return custom module translation fallback", () => {
const { Translator } = createTranslationTestEnvironment();
it("should return custom module translation fallback", async () => {
const Translator = await getFreshTranslator();
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "A key");
expect(translation).toBe("A translation");
});
it("should return core translation fallback", () => {
const { Translator } = createTranslationTestEnvironment();
it("should return core translation fallback", async () => {
const Translator = await getFreshTranslator();
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "Fallback");
expect(translation).toBe("core fallback");
});
it("should return translation with placeholder for missing variables", () => {
const { Translator } = createTranslationTestEnvironment();
it("should return translation with placeholder for missing variables", async () => {
const Translator = await getFreshTranslator();
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "Hello {username}");
expect(translation).toBe("Hallo {username}");
});
it("should return key if no translation was found", () => {
const { Translator } = createTranslationTestEnvironment();
it("should return key if no translation was found", async () => {
const Translator = await getFreshTranslator();
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "MISSING");
expect(translation).toBe("MISSING");
@@ -151,7 +158,7 @@ describe("Translator", () => {
};
it("should load translations", async () => {
const { Translator } = createTranslationTestEnvironment();
const Translator = await getFreshTranslator();
const file = "translation_test.json";
await Translator.load(mmm, file, false);
@@ -160,7 +167,7 @@ describe("Translator", () => {
});
it("should load translation fallbacks", async () => {
const { Translator } = createTranslationTestEnvironment();
const Translator = await getFreshTranslator();
const file = "translation_test.json";
await Translator.load(mmm, file, true);
@@ -169,7 +176,7 @@ describe("Translator", () => {
});
it("should not load translations, if module fallback exists", async () => {
const { Translator } = createTranslationTestEnvironment();
const Translator = await getFreshTranslator();
const file = "translation_test.json";
Translator.translationsFallback[mmm.name] = {
@@ -186,8 +193,8 @@ describe("Translator", () => {
describe("loadCoreTranslations", () => {
it("should load core translations and fallback", async () => {
const { window, Translator } = createTranslationTestEnvironment();
window.translations = { en: "http://localhost:3001/translations/translation_test.json" };
const Translator = await getFreshTranslator();
global.translations = { en: "http://localhost:3001/translations/translation_test.json" };
await Translator.loadCoreTranslations("en");
const en = translationTestData;
@@ -197,8 +204,8 @@ describe("Translator", () => {
});
it("should load core fallback if language cannot be found", async () => {
const { window, Translator } = createTranslationTestEnvironment();
window.translations = { en: "http://localhost:3001/translations/translation_test.json" };
const Translator = await getFreshTranslator();
global.translations = { en: "http://localhost:3001/translations/translation_test.json" };
await Translator.loadCoreTranslations("MISSINGLANG");
const en = translationTestData;
@@ -210,8 +217,8 @@ describe("Translator", () => {
describe("loadCoreTranslationsFallback", () => {
it("should load core translations fallback", async () => {
const { window, Translator } = createTranslationTestEnvironment();
window.translations = { en: "http://localhost:3001/translations/translation_test.json" };
const Translator = await getFreshTranslator();
global.translations = { en: "http://localhost:3001/translations/translation_test.json" };
await Translator.loadCoreTranslationsFallback();
const en = translationTestData;
@@ -220,8 +227,8 @@ describe("Translator", () => {
});
it("should load core fallback if language cannot be found", async () => {
const { window, Translator } = createTranslationTestEnvironment();
window.translations = {};
const Translator = await getFreshTranslator();
global.translations = {};
await Translator.loadCoreTranslations();
expect(Translator.coreTranslationsFallback).toEqual({});
@@ -7,7 +7,6 @@ describe("Test function cmpVersions in js/module.js", () => {
let originalLog;
let originalConfig;
let originalMM;
let originalTranslator;
let originalNunjucks;
beforeAll(async () => {
@@ -15,7 +14,6 @@ describe("Test function cmpVersions in js/module.js", () => {
originalLog = global.Log;
originalConfig = global.config;
originalMM = global.MM;
originalTranslator = global.Translator;
originalNunjucks = global.nunjucks;
global.window = { mmVersion: "2.0.0" };
@@ -27,10 +25,6 @@ describe("Test function cmpVersions in js/module.js", () => {
sendNotification: () => {},
updateDom: () => {}
};
global.Translator = {
load: () => Promise.resolve(),
translate: () => ""
};
global.nunjucks = {
Environment () {
this.addFilter = () => {};
@@ -53,7 +47,6 @@ describe("Test function cmpVersions in js/module.js", () => {
global.Log = originalLog;
global.config = originalConfig;
global.MM = originalMM;
global.Translator = originalTranslator;
global.nunjucks = originalNunjucks;
});
@@ -0,0 +1,43 @@
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const { JSDOM } = require("jsdom");
const TRANSLATOR_MODULE_URL = pathToFileURL(path.join(__dirname, "..", "..", "js", "translator.js")).href;
/**
* Reset mutable Translator state between tests.
* Clears all non-function plain-object properties to remain resilient
* when additional translation stores are introduced.
* @param {object} Translator The shared Translator module instance.
*/
function resetTranslatorState (Translator) {
for (const [key, value] of Object.entries(Translator)) {
if (typeof value === "function") {
continue;
}
if (Object.prototype.toString.call(value) === "[object Object]") {
Translator[key] = {};
}
}
}
/**
* Set up DOM globals used by translation tests.
* @param {number} [port] Base URI port used to resolve relative translation paths.
* @returns {void}
*/
function setupTranslationTestEnvironment (port = 3000) {
const dom = new JSDOM("", { url: `http://localhost:${port}` });
global.document = dom.window.document;
if (!global.Log) {
global.Log = { log: vi.fn(), error: vi.fn() };
}
}
module.exports = {
setupTranslationTestEnvironment,
TRANSLATOR_MODULE_URL,
resetTranslatorState
};