@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
137 lines (125 loc) • 5.52 kB
JavaScript
/**
* 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 };