UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

281 lines (244 loc) 6.55 kB
/** * 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); }