express-translify
Version:
A drop-in translation module for real-world Express applications
208 lines (144 loc) • 9.09 kB
JavaScript
class InvalidLocaleError extends Error {
constructor(locale) {
super(`Invalid locale '${locale}'`);
this.name = "InvalidLocaleError";
};
};
(() => {
let hashes;
let terms = [];
let currentLanguage;
const initialEmitter = new EventTarget();
window.translify = async (language) => {
currentLanguage = language.split("-")[0];
await new Promise((resolve) => initialEmitter.addEventListener("translationCompleted", resolve));
return true;
};
const cache = new Map(Object.entries(JSON.parse(localStorage.getItem("locales") || "{}")));
async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${url}`);
return res.json();
};
async function loadConfig() {
const config = await fetchJSON("/translify.json");
terms = config.terms || [];
hashes = config.hashes;
return config;
};
async function loadLanguage(language) {
if (cache.has(language) && (cache.get(language)[0] === hashes[language])) return cache.get(language)[1];
const dataPromise = fetchJSON(`locales/${language}.json`).catch(() => ({}));
cache.set(language, [hashes[language], dataPromise]);
setTimeout(async () => {
localStorage.setItem("locales", JSON.stringify(Object.fromEntries(await Promise.all(Array.from(cache).map(async (locale) => [locale[0], [hashes[language], await locale[1][1]]])))));
}, 0);
return dataPromise;
};
function extractIndent(string) {
const lines = string.split("\n").filter((line) => line.trim() !== "");
if (!lines.length) return ["", ""];
const line = lines[0];
const leadingMatch = line.match(/^(\s*)/);
const trailingMatch = line.match(/(\s*)$/);
const leading = (leadingMatch) ? leadingMatch[1] : "";
const trailing = (trailingMatch) ? trailingMatch[1] : "";
return [leading, trailing];
};
function applyIndent(string, [leading, trailing]) {
return string.split("\n").map((line) => (line.trim() === "") ? line : (leading + line + trailing)).join("\n");
};
const translations = new Proxy({}, {
get(target, language) {
if (typeof language !== "string") return undefined;
if (!cache.has(language) || (cache.get(language)[0] !== hashes[language])) {
cache.set(language, [hashes[language], loadLanguage(language)]);
setTimeout(async () => {
localStorage.setItem("locales", JSON.stringify(Object.fromEntries(await Promise.all(Array.from(cache).map(async (locale) => [locale[0], [hashes[language], await locale[1][1]]])))));
}, 0);
};
return cache.get(language)[1];
}
});
loadConfig().then(async ({ default: defaultLanguage, languages }) => {
const userLanguage = ([...[defaultLanguage], ...languages].includes((navigator.language || defaultLanguage).split("-")[0])) ? (navigator.language || defaultLanguage).split("-")[0] : defaultLanguage;
if (userLanguage === defaultLanguage) return;
await loadLanguage(userLanguage);
const regularExpressions = terms.filter((term) => term.includes("[...]")).map((term) => [term, new RegExp("^" + term.split(/\[\.\.\.\]/).map((part) => part.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")).join("(.+)") + "$")]);
async function translateTextNode(textNode, { language, reverse = false } = {}) {
const originalText = textNode.textContent.trim();
if (!originalText) return;
const dictionary = (!reverse) ? ((await translations?.[language || userLanguage]) || {}) : Object.fromEntries(Object.entries((await translations?.[reverse]) || {}).map(([key, value]) => [
value, key
]));
const [rawExpression, regularExpression] = (!Object.hasOwn(dictionary, originalText)) ? (((!reverse) ? regularExpressions : Object.keys(dictionary).filter((term) => term.includes("[...]")).map((term) => [term, new RegExp("^" + term.split(/\[\.\.\.\]/).map((part) => part.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")).join("(.+)") + "$")])).find((pair) => pair[0].replaceAll("[...]", " ").split(" ").every((part) => originalText.includes(part)) && pair[1].test(originalText)) || []) : [];
if (!Object.hasOwn(dictionary, originalText) && !rawExpression) return;
let index = 1;
textNode.textContent = applyIndent((regularExpression) ? dictionary[rawExpression].replace(/\[\.\.\.\]/g, () => originalText.match(regularExpression)[index++]) : dictionary[originalText], extractIndent(textNode.textContent));
};
async function translateElementNode(elementNode, { language, reverse = false } = {}) {
const dictionary = (!reverse) ? ((await translations?.[language || userLanguage]) || {}) : Object.fromEntries(Object.entries((await translations?.[reverse]) || {}).map(([key, value]) => [
value, key
]));
if (elementNode === "title") {
const originalText = document.title.trim();
if (!originalText) return;
const [rawExpression, regularExpression] = (!Object.hasOwn(dictionary, originalText)) ? (((!reverse) ? regularExpressions : Object.keys(dictionary).filter((term) => term.includes("[...]")).map((term) => [term, new RegExp("^" + term.split(/\[\.\.\.\]/).map((part) => part.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")).join("(.+)") + "$")])).find((pair) => pair[0].replaceAll("[...]", " ").split(" ").every((part) => originalText.includes(part)) && pair[1].test(originalText)) || []) : [];
if (!Object.hasOwn(dictionary, originalText) && !rawExpression) return;
let index = 1;
document.title = applyIndent((regularExpression) ? dictionary[rawExpression].replace(/\[\.\.\.\]/g, () => originalText.match(regularExpression)[index++]) : dictionary[originalText], extractIndent(document.title));
} else {
[
"title",
"placeholder"
].forEach((attribute) => {
const originalText = elementNode.getAttribute(attribute)?.trim();
if (!originalText) return;
const [rawExpression, regularExpression] = (!Object.hasOwn(dictionary, originalText)) ? (((!reverse) ? regularExpressions : Object.keys(dictionary).filter((term) => term.includes("[...]")).map((term) => [term, new RegExp("^" + term.split(/\[\.\.\.\]/).map((part) => part.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")).join("(.+)") + "$")])).find((pair) => pair[0].replaceAll("[...]", " ").split(" ").every((part) => originalText.includes(part)) && pair[1].test(originalText)) || []) : [];
if (!Object.hasOwn(dictionary, originalText) && !rawExpression) return;
let index = 1;
elementNode.setAttribute(attribute, applyIndent((regularExpression) ? dictionary[rawExpression].replace(/\[\.\.\.\]/g, () => originalText.match(regularExpression)[index++]) : dictionary[originalText], extractIndent(elementNode.getAttribute(attribute))));
});
};
};
function scanAndTranslate(node, { language, reverse = false } = {}) {
if (!reverse && ((language || userLanguage) === "en")) return;
translateElementNode("title", { language, reverse });
if (node.nodeType === Node.TEXT_NODE) {
translateTextNode(node, { language, reverse });
} else {
if ((node.nodeType === Node.ELEMENT_NODE) && ["LABEL", "BUTTON", "INPUT", "TEXTAREA"].includes(node.tagName) && (node.title?.trim() || node.placeholder?.trim())) translateElementNode(node, { language, reverse });
node.childNodes.forEach((node) => scanAndTranslate(node, { language, reverse }));
};
};
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => scanAndTranslate(node));
};
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
document.addEventListener("DOMContentLoaded", () => {
scanAndTranslate(document.body);
});
if (currentLanguage) {
if (![...[defaultLanguage], ...languages].includes(currentLanguage)) {
scanAndTranslate(document.body);
throw new InvalidLocaleError(currentLanguage);
};
scanAndTranslate(document.body, { language: currentLanguage });
initialEmitter.dispatchEvent(new CustomEvent("translationCompleted"));
} else {
scanAndTranslate(document.body);
};
window.translify = async (language) => {
if (![...[defaultLanguage], ...languages].includes(language.split("-")[0])) throw new InvalidLocaleError(language.split("-")[0]);
if ((currentLanguage || userLanguage) !== defaultLanguage) await scanAndTranslate(document.body, { reverse: (currentLanguage || userLanguage) });
if (language !== defaultLanguage) await scanAndTranslate(document.body, { language: language.split("-")[0] });
currentLanguage = language.split("-")[0];
return true;
};
}).catch(console.error);
})();