UNPKG

@schukai/monster

Version:

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

137 lines (125 loc) 5.52 kB
/** * Copyright © schukai GmbH 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 schukai GmbH. * * SPDX-License-Identifier: AGPL-3.0 */ import { isString } from "../../../types/is.mjs"; import { fireCustomEvent } from "../../../dom/events.mjs"; import { validateInstance, validateString } from "../../../types/validate.mjs"; /** * Traverse the element's ancestors to find an existing Shadow DOM. * * @param {HTMLElement} element - The starting element. * @returns {ShadowRoot|null} - The found Shadow DOM or null if none exists. */ function findShadowRoot(element) { while (element) { if (element?.shadowRoot) { return element?.shadowRoot; } element = element?.parentNode; } return null; } /** * Loads content from a URL and assigns it to an element. * Optionally, the loaded content can be filtered using a CSS selector. * Additionally, any <script> elements within the content are extracted and executed. * * @param {HTMLElement} element - The target element to insert the content. * @param {string|URL} url - The URL from which to load the content. * @param {Object} options - Options for the fetch call. * @param {string} [filter] - Optional CSS selector to filter the loaded content. * @returns {Promise<Object>} A promise that resolves to an object containing { content: string, type: string | null }. * @throws {Error} When the content cannot be read or the response contains an error. * @throws {TypeError} When the provided parameters do not match the expected types. */ function loadAndAssignContent(element, url, options, filter) { return loadContent(url, options).then((response) => { let content = response.content; // Optional filtering: if a valid, non-empty CSS selector is provided, // only the matching elements will be retained. if (isString(filter) && filter !== "") { const filteredContainer = document.createElement("div"); const tempContainer = document.createElement("div"); tempContainer.innerHTML = content; const matchingNodes = tempContainer.querySelectorAll(filter); matchingNodes.forEach((node) => { filteredContainer.appendChild(node); }); content = filteredContainer.innerHTML; } // Temporary container for processing the content and extracting scripts const tempDiv = document.createElement("div"); tempDiv.innerHTML = content; // Extract and execute all <script> elements by appending them to the document head const scriptElements = tempDiv.querySelectorAll("script"); scriptElements.forEach((oldScript) => { const newScript = document.createElement("script"); if (oldScript.src) newScript.src = oldScript.src; if (oldScript.type) newScript.type = oldScript.type; if (oldScript.async) newScript.async = oldScript.async; if (oldScript.defer) newScript.defer = oldScript.defer; if (oldScript.crossOrigin) newScript.crossOrigin = oldScript.crossOrigin; if (oldScript.integrity) newScript.integrity = oldScript.integrity; if (oldScript.referrerPolicy) newScript.referrerPolicy = oldScript.referrerPolicy; newScript.textContent = oldScript.textContent; document.head.appendChild(newScript); if (oldScript.parentNode) { oldScript.parentNode.removeChild(oldScript); } }); // Assign the processed content to the target element validateInstance(element, HTMLElement).innerHTML = tempDiv.innerHTML; // If the element is within a Shadow DOM, use the host as the event target const shadowRoot = findShadowRoot(element); const eventTarget = shadowRoot !== null ? shadowRoot.host : element; if (eventTarget instanceof HTMLElement) { fireCustomEvent(eventTarget, "monster-fetched", { url }); } return response; }); } /** * Loads content from a URL using fetch and returns an object with the loaded content * and the Content-Type header. * * @param {string|URL} url - The URL from which to load the content. * @param {Object} options - Options for the fetch call. * @returns {Promise<Object>} A promise that resolves to an object { content: string, type: string | null }. * @throws {Error} When the content cannot be read or the response contains an error. * @throws {TypeError} When the URL cannot be validated as a string. */ function loadContent(url, options) { if (url instanceof URL) { url = url.toString(); } return fetch(validateString(url), options).then((response) => { if (!response.ok) { if (["error", "opaque", "opaqueredirect"].includes(response.type)) { throw new Error(`we won't be able to read the data (${response.type})`); } const statusClass = String(response.status).charAt(0); if (statusClass === "4") { throw new Error(`client error ${response.statusText}`); } throw new Error( `undefined status (${response.status} / ${response.statusText}) or type (${response.type})`, ); } return response.text().then((content) => ({ content, type: response.headers.get("Content-Type"), })); }); } export { loadAndAssignContent, loadContent };