Skip to content
Merged
71 changes: 49 additions & 22 deletions src/js/translations.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
let translations = {};

async function loadTranslations(lang) {
const loadTranslations = async (lang) => {
try {
const response = await fetch(`./locales/${lang}.json`);
if (!response.ok) throw new Error("Translation file not found");
Expand All @@ -13,65 +13,92 @@ async function loadTranslations(lang) {
}
return {};
}
}
};

function getNestedProperty(obj, path) {
const getNestedProperty = (obj, path) => {
return path.split(".").reduce((current, key) => current?.[key], obj);
}
};

function getBrowserLanguage() {
const getBrowserLanguage = () => {
const browserLang = navigator.language.split("-")[0];
const supportedLangs = ["en", "es", "fr", "pt"];
return supportedLangs.includes(browserLang) ? browserLang : "en";
}
};

function getCurrentLanguage() {
const getCurrentLanguage = () => {
return localStorage.getItem("language") || getBrowserLanguage();
}

async function setLanguage(lang) {
translations = await loadTranslations(lang);

localStorage.setItem("language", lang);
};

document.title = translations.title;
const updateDocumentMeta = (lang, translationsData) => {
document.title = translationsData.title;

document
.querySelector('meta[name="description"]')
.setAttribute("content", translations.metaDescription);
.setAttribute("content", translationsData.metaDescription);
document
.querySelector('meta[property="og:description"]')
.setAttribute("content", translations.metaDescription);
document.querySelector('meta[property="og:title"]').setAttribute("content", translations.title);
.setAttribute("content", translationsData.metaDescription);
document
.querySelector('meta[property="og:title"]')
.setAttribute("content", translationsData.title);

document.documentElement.setAttribute("lang", lang);
};

const updateTranslations = (translationsData) => {
document.querySelectorAll("[data-translate]").forEach((element) => {
const key = element.getAttribute("data-translate");
const value = getNestedProperty(translations, key);
const value = getNestedProperty(translationsData, key);
if (value) {
element.textContent = value;
}
});

document.querySelectorAll("[data-translate-alt]").forEach((element) => {
const key = element.getAttribute("data-translate-alt");
const value = getNestedProperty(translations, key);
const value = getNestedProperty(translationsData, key);
if (value) {
element.setAttribute("alt", value);
}
});

document.querySelectorAll("[data-translate-html]").forEach((element) => {
const key = element.getAttribute("data-translate-html");
const value = getNestedProperty(translations, key);
const value = getNestedProperty(translationsData, key);
if (value) {
element.innerHTML = value;
}
});
};

document.documentElement.setAttribute("lang", lang);
}
const setLanguage = async (lang) => {
translations = await loadTranslations(lang);
localStorage.setItem("language", lang);
updateDocumentMeta(lang, translations);
updateTranslations(translations);
};

document.addEventListener("DOMContentLoaded", async () => {
const currentLang = getCurrentLanguage();
await setLanguage(currentLang);
});

// Export for Node.js/Jest (not executed in browser)
/* eslint-disable no-undef */
if (typeof module !== "undefined" && module.exports) {
module.exports = {
loadTranslations,
getNestedProperty,
getBrowserLanguage,
getCurrentLanguage,
updateDocumentMeta,
updateTranslations,
setLanguage
};
/* eslint-enable no-undef */
}

if (typeof window !== "undefined") {
window.setLanguage = setLanguage;
window.getCurrentLanguage = getCurrentLanguage;
}
112 changes: 64 additions & 48 deletions src/js/translations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,36 @@
* @jest-environment jsdom
*/

const {
loadTranslations,
getNestedProperty,
getBrowserLanguage,
getCurrentLanguage,
updateDocumentMeta,
updateTranslations,
setLanguage
} = require("./translations");

global.fetch = jest.fn();
console.error = jest.fn();

describe("Translations Module", () => {
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
document.body.innerHTML = "";
document.head.innerHTML =
'<title>Test</title><meta name="description" content="Test"><meta property="og:title" content="Test"><meta property="og:description" content="Test">';
});

describe("Core Helper Functions", () => {
test("getNestedProperty - should handle nested translation keys", () => {
const getNestedProperty = (obj, path) =>
path.split(".").reduce((current, key) => current?.[key], obj);

const translations = { user: { profile: { name: "Jane" } } };

expect(getNestedProperty(translations, "user.profile.name")).toBe("Jane");
expect(getNestedProperty(translations, "missing.key")).toBeUndefined();
});

test("getBrowserLanguage - should return supported language or default to English", () => {
const getBrowserLanguage = () => {
const browserLang = navigator.language.split("-")[0];
const supportedLangs = ["en", "es", "fr", "pt"];
return supportedLangs.includes(browserLang) ? browserLang : "en";
};

Object.defineProperty(navigator, "language", { value: "es-ES", configurable: true });
expect(getBrowserLanguage()).toBe("es");

Expand All @@ -40,73 +43,86 @@ describe("Translations Module", () => {
describe("Translation Loading", () => {
test("should successfully fetch translations", async () => {
const mockData = { title: "Test Title", role: "Developer" };
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockData
});
global.fetch.mockResolvedValueOnce({ ok: true, json: async () => mockData });

const response = await fetch("./locales/en.json");
const data = await response.json();
const data = await loadTranslations("en");

expect(fetch).toHaveBeenCalledWith("./locales/en.json");
expect(data).toEqual(mockData);
});

test("should handle fetch errors gracefully", async () => {
global.fetch.mockRejectedValueOnce(new Error("Network error"));
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ title: "English" })
});

const data = await loadTranslations("fr");

try {
await fetch("./locales/es.json");
} catch (error) {
expect(error.message).toBe("Network error");
}
expect(console.error).toHaveBeenCalled();
expect(data).toEqual({ title: "English" });
});
});

describe("localStorage Integration", () => {
test("should store and retrieve selected language", () => {
localStorage.setItem("language", "es");
expect(localStorage.getItem("language")).toBe("es");
expect(getCurrentLanguage()).toBe("es");
});

localStorage.setItem("language", "fr");
expect(localStorage.getItem("language")).toBe("fr");
test("should fall back to browser language if no stored language", () => {
Object.defineProperty(navigator, "language", { value: "fr-FR", configurable: true });
expect(getCurrentLanguage()).toBe("fr");
});
});

describe("DOM Updates", () => {
beforeEach(() => {
document.documentElement.innerHTML = `
<head>
<title>Original Title</title>
<meta name="description" content="Original Description">
</head>
<body>
<h1 data-translate="role">Original Role</h1>
<img data-translate-alt="imageAlt" alt="Original Alt">
</body>
`;
});

test("should update document title and meta tags", () => {
document.title = "New Title";
const metaDesc = document.querySelector('meta[name="description"]');
metaDesc.setAttribute("content", "New Description");
const translationsData = { title: "New Title", metaDescription: "New Description" };
updateDocumentMeta("es", translationsData);

expect(document.title).toBe("New Title");
expect(metaDesc.getAttribute("content")).toBe("New Description");
expect(document.querySelector('meta[name="description"]').getAttribute("content")).toBe(
"New Description"
);
expect(document.documentElement.getAttribute("lang")).toBe("es");
});

test("should update elements with data-translate attribute", () => {
const element = document.querySelector('[data-translate="role"]');
element.textContent = "Software Developer";

expect(element.textContent).toBe("Software Developer");
document.body.innerHTML = '<p data-translate="role">Default</p>';
updateTranslations({ role: "Software Developer" });
expect(document.querySelector('[data-translate="role"]').textContent).toBe(
"Software Developer"
);
});

test("should update alt attributes with data-translate-alt", () => {
const img = document.querySelector('[data-translate-alt="imageAlt"]');
img.setAttribute("alt", "New Alt Text");
document.body.innerHTML = '<img data-translate-alt="imageAlt" alt="Default">';
updateTranslations({ imageAlt: "Profile picture" });
expect(
document.querySelector('[data-translate-alt="imageAlt"]').getAttribute("alt")
).toBe("Profile picture");
});
});

describe("setLanguage Integration", () => {
test("should load translations and update DOM", async () => {
const mockTranslations = {
title: "Título",
metaDescription: "Descripción",
role: "Desarrollador"
};
global.fetch.mockResolvedValueOnce({ ok: true, json: async () => mockTranslations });
document.body.innerHTML = '<p data-translate="role">Default</p>';

expect(img.getAttribute("alt")).toBe("New Alt Text");
await setLanguage("es");

expect(localStorage.getItem("language")).toBe("es");
expect(document.title).toBe("Título");
expect(document.querySelector('[data-translate="role"]').textContent).toBe(
"Desarrollador"
);
});
});
});