external-svg-loader
Version:
Plug 'n play external SVG loader
310 lines (252 loc) • 9.34 kB
JavaScript
;
const { get, set, del } = require("idb-keyval");
const cssScope = require("./lib/scope-css");
const cssUrlFixer = require("./lib/css-url-fixer");
const counter = require("./lib/counter");
const isCacheAvailable = async (url) => {
try {
let item = await get(`loader_${url}`);
if (!item) {
return;
}
item = JSON.parse(item);
if (Date.now() < item.expiry) {
return item.data;
} else {
del(`loader_${url}`);
return;
}
} catch (e) {
return;
}
};
const setCache = async (url, data, cacheOpt) => {
try {
const cacheExp = parseInt(cacheOpt, 10);
await set(`loader_${url}`, JSON.stringify({
data,
expiry: Date.now() + (Number.isNaN(cacheExp) ? 60 * 60 * 1000 * 24 : cacheExp)
}));
} catch (e) {
console.error(e);
};
};
const DOM_EVENTS = [];
const getAllEventNames = () => {
if (DOM_EVENTS.length) {
return DOM_EVENTS;
}
for (const prop in document.head) {
if (prop.startsWith("on")) {
DOM_EVENTS.push(prop);
}
}
return DOM_EVENTS;
};
const attributesSet = {};
const renderBody = (elem, options, body) => {
const { enableJs, disableUniqueIds, disableCssScoping } = options;
const parser = new DOMParser();
const doc = parser.parseFromString(body, "text/html");
const fragment = doc.querySelector("svg");
const eventNames = getAllEventNames();
// When svg-loader is loading in the same element, it's
// important to keep track of original properties.
const elemAttributesSet = attributesSet[elem.getAttribute("data-id")] || new Set();
const elemUniqueId = elem.getAttribute("data-id") || `svg-loader_${counter.incr()}`;
const idMap = {};
if (!disableUniqueIds) {
// Append a unique suffix for every ID so elements don't conflict.
Array.from(doc.querySelectorAll("[id]")).forEach((elem) => {
const id = elem.getAttribute("id");
const newId = `${id}_${counter.incr()}`;
elem.setAttribute("id", newId);
idMap[id] = newId;
});
}
Array.from(doc.querySelectorAll("*")).forEach((elem) => {
// Unless explicitly set, remove JS code (default)
if (elem.tagName === "script") {
if (!enableJs) {
elem.remove();
return;
} else {
const scriptEl = document.createElement("script");
scriptEl.innerHTML = elem.innerHTML;
document.body.appendChild(scriptEl);
}
}
for (let i = 0; i < elem.attributes.length; i++) {
const {
name,
value
} = elem.attributes[i];
const newValue = cssUrlFixer(idMap, value, name);
if (value !== newValue) {
elem.setAttribute(name, newValue);
}
// Remove event functions: onmouseover, onclick ... unless specifically enabled
if (eventNames.includes(name.toLowerCase()) && !enableJs) {
elem.removeAttribute(name);
continue;
}
// Remove "javascript:..." unless specifically enabled
if (["href", "xlink:href"].includes(name) && value.startsWith("javascript") && !enableJs) {
elem.removeAttribute(name);
}
}
// .first -> [data-id="svg_loader_341xx"] .first
// Makes sure that class names don't conflict with each other.
if (elem.tagName === "style" && !disableCssScoping) {
let newValue = cssScope(elem.innerHTML, `[data-id="${elemUniqueId}"]`);
newValue = cssUrlFixer(idMap, newValue);
if (newValue !== elem.innerHTML)
elem.innerHTML = newValue;
}
});
for (let i = 0; i < fragment.attributes.length; i++) {
const {
name,
value
} = fragment.attributes[i];
// Don't override the attributes already defined, but override the ones that
// were in the original element
if (!elem.getAttribute(name) || elemAttributesSet.has(name)) {
elemAttributesSet.add(name);
elem.setAttribute(name, value);
}
}
attributesSet[elemUniqueId] = elemAttributesSet;
elem.setAttribute("data-id", elemUniqueId);
elem.innerHTML = fragment.innerHTML;
};
const requestsInProgress = {};
const memoryCache = {};
const renderIcon = async (elem) => {
const src = elem.getAttribute("data-src");
const cacheOpt = elem.getAttribute("data-cache");
const enableJs = elem.getAttribute("data-js") === "enabled";
const disableUniqueIds = elem.getAttribute("data-unique-ids") === "disabled";
const disableCssScoping = elem.getAttribute("data-css-scoping") === "disabled";
const lsCache = await isCacheAvailable(src);
const isCachingEnabled = cacheOpt !== "disabled";
const renderBodyCb = renderBody.bind(this, elem, { enableJs, disableUniqueIds, disableCssScoping });
// Memory cache optimizes same icon requested multiple
// times on the page
if (memoryCache[src] || (isCachingEnabled && lsCache)) {
const cache = memoryCache[src] || lsCache;
renderBodyCb(cache);
} else {
// If the same icon is being requested to rendered
// avoid firing multiple XHRs
if (requestsInProgress[src]) {
setTimeout(() => renderIcon(elem), 20);
return;
}
requestsInProgress[src] = true;
fetch(src)
.then((response) => {
if (!response.ok) {
throw Error(`Request for '${src}' returned ${response.status} (${response.statusText})`);
}
return response.text();
})
.then((body) => {
const bodyLower = body.toLowerCase().trim();
if (!(bodyLower.startsWith("<svg") || bodyLower.startsWith("<?xml"))) {
throw Error(`Resource '${src}' returned an invalid SVG file`);
}
if (isCachingEnabled) {
setCache(src, body, cacheOpt);
}
memoryCache[src] = body;
renderBodyCb(body);
})
.catch((e) => {
console.error(e);
})
.finally(() => {
delete requestsInProgress[src];
});
}
};
let intObserver;
if (globalThis.IntersectionObserver) {
const intObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
renderIcon(entry.target);
// Unobserve as soon as soon the icon is rendered
intObserver.unobserve(entry.target);
}
});
}, {
// Keep high root margin because intersection observer
// can be slow to react
rootMargin: "1200px"
}
);
}
const handled = [];
function renderAllSVGs() {
Array.from(document.querySelectorAll("svg[data-src]:not([data-id])"))
.forEach((element) => {
if (handled.indexOf(element) !== -1) {
return;
}
handled.push(element);
if (element.getAttribute("data-loading") === "lazy") {
intObserver.observe(element);
} else {
renderIcon(element);
}
});
}
let observerAdded = false;
const addObservers = () => {
if (observerAdded) {
return;
}
observerAdded = true;
const observer = new MutationObserver((mutationRecords) => {
const shouldTriggerRender = mutationRecords.some(
(record) => Array.from(record.addedNodes).some(
(elem) => elem.nodeType === Node.ELEMENT_NODE
&& ((elem.getAttribute("data-src") && !elem.getAttribute("data-id")) // Check if the element needs to be rendered
|| elem.querySelector("svg[data-src]:not([data-id])")) // Check if any of the element's children need to be rendered
)
);
// If any node is added, render all new nodes because the nodes that have already
// been rendered won't be rendered again.
if (shouldTriggerRender) {
renderAllSVGs();
}
// If data-src is changed, re-render
mutationRecords.forEach((record) => {
if (record.type === "attributes") {
renderIcon(record.target);
}
});
});
observer.observe(
document.documentElement,
{
attributeFilter: ["data-src"],
attributes: true,
childList: true,
subtree: true
}
);
};
if (globalThis.addEventListener) {
// Start rendering SVGs as soon as possible
const intervalCheck = setInterval(() => {
renderAllSVGs();
}, 100);
globalThis.addEventListener("DOMContentLoaded", () => {
clearInterval(intervalCheck);
renderAllSVGs();
addObservers();
});
}