@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
281 lines (244 loc) • 6.55 kB
JavaScript
/**
* Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
* Node module: @schukai/monster
*
* This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
* The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
*
* For those who do not wish to adhere to the AGPLv3, a commercial license is available.
* Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
* For more information about purchasing a commercial license, please contact Volker Schukai.
*
* SPDX-License-Identifier: AGPL-3.0
*/
import { registerCustomElement } from "../dom/customelement.mjs";
import { sanitizeHtml } from "../dom/sanitize-html.mjs";
import { addErrorAttribute } from "../dom/error.mjs";
import { getDocument } from "../dom/util.mjs";
import { getDocumentTranslations } from "./translations.mjs";
import { isObject, isString } from "../types/is.mjs";
import { validateObject, validateString } from "../types/validate.mjs";
export {
I18nReplaceMarker,
setI18nReplaceSanitizePolicy,
setI18nReplaceDefaultSanitizePolicy,
getI18nReplaceSanitizePolicy,
};
/**
* @private
* @type {string}
*/
const TAG_PREFIX = "monster-";
/**
* @private
* @type {string}
*/
const TAG_NAME = `${TAG_PREFIX}i18n-replace`;
/**
* @private
* @type {Map<string, object>}
*/
const sanitizePolicies = new Map();
/**
* @private
* @type {string}
*/
let defaultSanitizePolicy = "default";
sanitizePolicies.set("default", {});
sanitizePolicies.set("strict", {
blockedTags: ["script", "iframe", "object", "embed", "link", "meta"],
});
/**
* Register or update a sanitize policy used by <monster-i18n-replace>.
*
* @param {string} name
* @param {object} options
* @return {void}
*/
function setI18nReplaceSanitizePolicy(name, options = {}) {
name = validateString(name);
if (!isObject(options)) {
options = {};
}
sanitizePolicies.set(name, validateObject(options));
}
/**
* Set the default sanitize policy name.
*
* @param {string} name
* @return {void}
*/
function setI18nReplaceDefaultSanitizePolicy(name) {
defaultSanitizePolicy = validateString(name);
}
/**
* Get a sanitize policy by name.
*
* @param {string} name
* @return {object|undefined}
*/
function getI18nReplaceSanitizePolicy(name) {
if (!isString(name)) {
return sanitizePolicies.get(defaultSanitizePolicy);
}
return sanitizePolicies.get(name);
}
/**
* Replace marker element with translated HTML.
*/
class I18nReplaceMarker extends HTMLElement {
/**
* @return {string}
*/
static getTag() {
return TAG_NAME;
}
connectedCallback() {
try {
replaceWithTranslation.call(this);
} catch (e) {
addErrorAttribute(this, e);
}
}
}
registerCustomElement(I18nReplaceMarker);
/**
* @private
* @return {void}
*/
function replaceWithTranslation() {
const key = this.getAttribute("key");
if (!isString(key) || key.trim() === "") {
return;
}
let translations = null;
try {
translations = getDocumentTranslations();
} catch (e) {}
const { countValue, countKeyword } = parseCount.call(this);
const fallback = resolveFallback.call(
this,
countValue,
countKeyword,
translations,
);
let value = fallback;
if (translations) {
try {
if (countValue !== undefined || countKeyword !== undefined) {
const keyOrCount = countKeyword ?? countValue;
value = translations.getPluralRuleText(key, keyOrCount, fallback);
} else {
value = translations.getText(key, fallback);
}
} catch (e) {
value = fallback;
}
}
if (!isString(value)) {
value = fallback;
}
if (countValue !== undefined) {
value = replaceCountPlaceholder(value, countValue, translations);
}
const policyName = this.getAttribute("sanitize") || defaultSanitizePolicy;
const policy = getI18nReplaceSanitizePolicy(policyName) || {};
const sanitized = sanitizeHtml(value, policy);
replaceElementWithHtml(this, sanitized);
}
/**
* @private
* @return {{countValue: number|undefined, countKeyword: string|undefined}}
*/
function parseCount() {
const raw = this.getAttribute("count");
if (!isString(raw) || raw.trim() === "") {
return { countValue: undefined, countKeyword: undefined };
}
const trimmed = raw.trim();
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isNaN(parsed)) {
return { countValue: parsed, countKeyword: undefined };
}
const keyword = trimmed.toLowerCase();
return { countValue: undefined, countKeyword: keyword };
}
/**
* @private
* @param {number|undefined} countValue
* @param {string|undefined} countKeyword
* @param {object|null} translations
* @return {string}
*/
function resolveFallback(countValue, countKeyword, translations) {
if (countValue !== undefined || countKeyword !== undefined) {
const keyword =
countKeyword ?? resolvePluralKeyword(countValue, translations);
const specific = this.getAttribute(`fallback-${keyword}`);
if (isString(specific) && specific.length > 0) {
return specific;
}
}
const generic = this.getAttribute("fallback");
if (isString(generic)) {
return generic;
}
return this.innerHTML || "";
}
/**
* @private
* @param {number|undefined} countValue
* @param {object|null} translations
* @return {string}
*/
function resolvePluralKeyword(countValue, translations) {
if (countValue === undefined) {
return "other";
}
let locale = undefined;
try {
locale = translations?.locale?.toString();
} catch (e) {}
try {
const pr = new Intl.PluralRules(locale);
return pr.select(countValue);
} catch (e) {
return "other";
}
}
/**
* @private
* @param {string} html
* @param {number} countValue
* @param {object|null} translations
* @return {string}
*/
function replaceCountPlaceholder(html, countValue, translations) {
let locale = undefined;
try {
locale = translations?.locale?.toString();
} catch (e) {}
let formatted = `${countValue}`;
try {
formatted = new Intl.NumberFormat(locale).format(countValue);
} catch (e) {}
return html.replaceAll("{count}", formatted);
}
/**
* @private
* @param {HTMLElement} element
* @param {string} html
* @return {void}
*/
function replaceElementWithHtml(element, html) {
const document = element.ownerDocument || getDocument();
const template = document.createElement("template");
template.innerHTML = html;
const parent = element.parentNode;
if (!parent) {
return;
}
const fragment = template.content;
parent.insertBefore(fragment, element);
parent.removeChild(element);
}