react-inlinesvg
Version:
An SVG loader for React
551 lines (544 loc) • 15.5 kB
JavaScript
"use client";
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
// src/index.tsx
import React, {
cloneElement,
isValidElement,
useCallback,
useEffect as useEffect2,
useReducer,
useRef as useRef2,
useState
} from "react";
import convert2 from "react-from-dom";
// src/config.ts
var CACHE_NAME = "react-inlinesvg";
var CACHE_MAX_RETRIES = 10;
var STATUS = {
IDLE: "idle",
LOADING: "loading",
LOADED: "loaded",
FAILED: "failed",
READY: "ready",
UNSUPPORTED: "unsupported"
};
// src/modules/helpers.ts
function randomCharacter(character) {
return character[Math.floor(Math.random() * character.length)];
}
function canUseDOM() {
return !!(typeof window !== "undefined" && window.document?.createElement);
}
function isSupportedEnvironment() {
return supportsInlineSVG() && typeof window !== "undefined" && window !== null;
}
function omit(input, ...filter) {
const output = {};
for (const key in input) {
if ({}.hasOwnProperty.call(input, key)) {
if (!filter.includes(key)) {
output[key] = input[key];
}
}
}
return output;
}
function randomString(length) {
const letters = "abcdefghijklmnopqrstuvwxyz";
const numbers = "1234567890";
const charset = `${letters}${letters.toUpperCase()}${numbers}`;
let R = "";
for (let index = 0; index < length; index++) {
R += randomCharacter(charset);
}
return R;
}
async function request(url, options) {
const response = await fetch(url, options);
const contentType = response.headers.get("content-type");
const [fileType] = (contentType ?? "").split(/ ?; ?/);
if (response.status > 299) {
throw new Error("Not found");
}
if (!["image/svg+xml", "text/plain"].some((d) => fileType.includes(d))) {
throw new Error(`Content type isn't valid: ${fileType}`);
}
return response.text();
}
function sleep(seconds = 1) {
return new Promise((resolve) => {
setTimeout(resolve, seconds * 1e3);
});
}
function supportsInlineSVG() {
if (!document) {
return false;
}
const div = document.createElement("div");
div.innerHTML = "<svg />";
const svg = div.firstChild;
return !!svg && svg.namespaceURI === "http://www.w3.org/2000/svg";
}
// src/modules/cache.ts
var CacheStore = class {
constructor() {
__publicField(this, "cacheApi");
__publicField(this, "cacheStore");
__publicField(this, "subscribers", []);
__publicField(this, "isReady", false);
this.cacheStore = /* @__PURE__ */ new Map();
let cacheName = CACHE_NAME;
let usePersistentCache = false;
if (canUseDOM()) {
cacheName = window.REACT_INLINESVG_CACHE_NAME ?? CACHE_NAME;
usePersistentCache = !!window.REACT_INLINESVG_PERSISTENT_CACHE && "caches" in window;
}
if (usePersistentCache) {
caches.open(cacheName).then((cache) => {
this.cacheApi = cache;
}).catch((error) => {
console.error(`Failed to open cache: ${error.message}`);
this.cacheApi = void 0;
}).finally(() => {
this.isReady = true;
const callbacks = [...this.subscribers];
this.subscribers.length = 0;
callbacks.forEach((callback) => {
try {
callback();
} catch (error) {
console.error(`Error in CacheStore subscriber callback: ${error.message}`);
}
});
});
} else {
this.isReady = true;
}
}
onReady(callback) {
if (this.isReady) {
callback();
} else {
this.subscribers.push(callback);
}
}
async get(url, fetchOptions) {
await (this.cacheApi ? this.fetchAndAddToPersistentCache(url, fetchOptions) : this.fetchAndAddToInternalCache(url, fetchOptions));
return this.cacheStore.get(url)?.content ?? "";
}
set(url, data) {
this.cacheStore.set(url, data);
}
isCached(url) {
return this.cacheStore.get(url)?.status === STATUS.LOADED;
}
async fetchAndAddToInternalCache(url, fetchOptions) {
const cache = this.cacheStore.get(url);
if (cache?.status === STATUS.LOADING) {
await this.handleLoading(url, async () => {
this.cacheStore.set(url, { content: "", status: STATUS.IDLE });
await this.fetchAndAddToInternalCache(url, fetchOptions);
});
return;
}
if (!cache?.content) {
this.cacheStore.set(url, { content: "", status: STATUS.LOADING });
try {
const content = await request(url, fetchOptions);
this.cacheStore.set(url, { content, status: STATUS.LOADED });
} catch (error) {
this.cacheStore.set(url, { content: "", status: STATUS.FAILED });
throw error;
}
}
}
async fetchAndAddToPersistentCache(url, fetchOptions) {
const cache = this.cacheStore.get(url);
if (cache?.status === STATUS.LOADED) {
return;
}
if (cache?.status === STATUS.LOADING) {
await this.handleLoading(url, async () => {
this.cacheStore.set(url, { content: "", status: STATUS.IDLE });
await this.fetchAndAddToPersistentCache(url, fetchOptions);
});
return;
}
this.cacheStore.set(url, { content: "", status: STATUS.LOADING });
const data = await this.cacheApi?.match(url);
if (data) {
const content = await data.text();
this.cacheStore.set(url, { content, status: STATUS.LOADED });
return;
}
try {
await this.cacheApi?.add(new Request(url, fetchOptions));
const response = await this.cacheApi?.match(url);
const content = await response?.text() ?? "";
this.cacheStore.set(url, { content, status: STATUS.LOADED });
} catch (error) {
this.cacheStore.set(url, { content: "", status: STATUS.FAILED });
throw error;
}
}
async handleLoading(url, callback) {
for (let retryCount = 0; retryCount < CACHE_MAX_RETRIES; retryCount++) {
if (this.cacheStore.get(url)?.status !== STATUS.LOADING) {
return;
}
await sleep(0.1);
}
await callback();
}
keys() {
return [...this.cacheStore.keys()];
}
data() {
return [...this.cacheStore.entries()].map(([key, value]) => ({ [key]: value }));
}
async delete(url) {
if (this.cacheApi) {
await this.cacheApi.delete(url);
}
this.cacheStore.delete(url);
}
async clear() {
if (this.cacheApi) {
const keys = await this.cacheApi.keys();
await Promise.allSettled(keys.map((key) => this.cacheApi.delete(key)));
}
this.cacheStore.clear();
}
};
// src/modules/hooks.tsx
import { useEffect, useRef } from "react";
function usePrevious(state) {
const ref = useRef(void 0);
useEffect(() => {
ref.current = state;
});
return ref.current;
}
// src/modules/utils.ts
import convert from "react-from-dom";
function getNode(options) {
const {
baseURL,
content,
description,
handleError,
hash,
preProcessor,
title,
uniquifyIDs = false
} = options;
try {
const svgText = processSVG(content, preProcessor);
const node = convert(svgText, { nodeOnly: true });
if (!node || !(node instanceof SVGSVGElement)) {
throw new Error("Could not convert the src to a DOM Node");
}
const svg = updateSVGAttributes(node, { baseURL, hash, uniquifyIDs });
if (description) {
const originalDesc = svg.querySelector("desc");
if (originalDesc?.parentNode) {
originalDesc.parentNode.removeChild(originalDesc);
}
const descElement = document.createElementNS("http://www.w3.org/2000/svg", "desc");
descElement.innerHTML = description;
svg.prepend(descElement);
}
if (typeof title !== "undefined") {
const originalTitle = svg.querySelector("title");
if (originalTitle?.parentNode) {
originalTitle.parentNode.removeChild(originalTitle);
}
if (title) {
const titleElement = document.createElementNS("http://www.w3.org/2000/svg", "title");
titleElement.innerHTML = title;
svg.prepend(titleElement);
}
}
return svg;
} catch (error) {
return handleError(error);
}
}
function processSVG(content, preProcessor) {
if (preProcessor) {
return preProcessor(content);
}
return content;
}
function updateSVGAttributes(node, options) {
const { baseURL = "", hash, uniquifyIDs } = options;
const replaceableAttributes = ["id", "href", "xlink:href", "xlink:role", "xlink:arcrole"];
const linkAttributes = ["href", "xlink:href"];
const isDataValue = (name, value) => linkAttributes.includes(name) && (value ? !value.includes("#") : false);
if (!uniquifyIDs) {
return node;
}
[...node.children].forEach((d) => {
if (d.attributes?.length) {
const attributes = Object.values(d.attributes).map((a) => {
const attribute = a;
const match = /url\((.*?)\)/.exec(a.value);
if (match?.[1]) {
attribute.value = a.value.replace(match[0], `url(${baseURL}${match[1]}__${hash})`);
}
return attribute;
});
replaceableAttributes.forEach((r) => {
const attribute = attributes.find((a) => a.name === r);
if (attribute && !isDataValue(r, attribute.value)) {
attribute.value = `${attribute.value}__${hash}`;
}
});
}
if (d.children.length) {
return updateSVGAttributes(d, options);
}
return d;
});
return node;
}
// src/index.tsx
var cacheStore;
function ReactInlineSVG(props) {
const {
cacheRequests = true,
children = null,
description,
fetchOptions,
innerRef,
loader = null,
onError,
onLoad,
src,
title,
uniqueHash
} = props;
const [state, setState] = useReducer(
(previousState2, nextState) => ({
...previousState2,
...nextState
}),
{
content: "",
element: null,
isCached: cacheRequests && cacheStore.isCached(props.src),
status: STATUS.IDLE
}
);
const { content, element, isCached, status } = state;
const previousProps = usePrevious(props);
const previousState = usePrevious(state);
const hash = useRef2(uniqueHash ?? randomString(8));
const isActive = useRef2(false);
const isInitialized = useRef2(false);
const handleError = useCallback(
(error) => {
if (isActive.current) {
setState({
status: error.message === "Browser does not support SVG" ? STATUS.UNSUPPORTED : STATUS.FAILED
});
onError?.(error);
}
},
[onError]
);
const handleLoad = useCallback((loadedContent, hasCache = false) => {
if (isActive.current) {
setState({
content: loadedContent,
isCached: hasCache,
status: STATUS.LOADED
});
}
}, []);
const fetchContent = useCallback(async () => {
const responseContent = await request(src, fetchOptions);
handleLoad(responseContent);
}, [fetchOptions, handleLoad, src]);
const getElement = useCallback(() => {
try {
const node = getNode({ ...props, handleError, hash: hash.current, content });
const convertedElement = convert2(node);
if (!convertedElement || !isValidElement(convertedElement)) {
throw new Error("Could not convert the src to a React element");
}
setState({
element: convertedElement,
status: STATUS.READY
});
} catch (error) {
handleError(error);
}
}, [content, handleError, props]);
const getContent = useCallback(async () => {
const dataURI = /^data:image\/svg[^,]*?(;base64)?,(.*)/u.exec(src);
let inlineSrc;
if (dataURI) {
inlineSrc = dataURI[1] ? window.atob(dataURI[2]) : decodeURIComponent(dataURI[2]);
} else if (src.includes("<svg")) {
inlineSrc = src;
}
if (inlineSrc) {
handleLoad(inlineSrc);
return;
}
try {
if (cacheRequests) {
const cachedContent = await cacheStore.get(src, fetchOptions);
handleLoad(cachedContent, true);
} else {
await fetchContent();
}
} catch (error) {
handleError(error);
}
}, [cacheRequests, fetchContent, fetchOptions, handleError, handleLoad, src]);
const load = useCallback(async () => {
if (isActive.current) {
setState({
content: "",
element: null,
isCached: false,
status: STATUS.LOADING
});
}
}, []);
useEffect2(
() => {
isActive.current = true;
if (!canUseDOM() || isInitialized.current) {
return void 0;
}
try {
if (status === STATUS.IDLE) {
if (!isSupportedEnvironment()) {
throw new Error("Browser does not support SVG");
}
if (!src) {
throw new Error("Missing src");
}
load();
}
} catch (error) {
handleError(error);
}
isInitialized.current = true;
return () => {
isActive.current = false;
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
useEffect2(() => {
if (!canUseDOM() || !previousProps) {
return;
}
if (previousProps.src !== src) {
if (!src) {
handleError(new Error("Missing src"));
return;
}
load();
}
}, [handleError, load, previousProps, src]);
useEffect2(() => {
if (status === STATUS.LOADED) {
getElement();
}
}, [status, getElement]);
useEffect2(() => {
if (!canUseDOM() || !previousProps || previousProps.src !== src) {
return;
}
if (previousProps.title !== title || previousProps.description !== description) {
getElement();
}
}, [description, getElement, previousProps, src, title]);
useEffect2(() => {
if (!previousState) {
return;
}
switch (status) {
case STATUS.LOADING: {
if (previousState.status !== STATUS.LOADING) {
getContent();
}
break;
}
case STATUS.LOADED: {
if (previousState.status !== STATUS.LOADED) {
getElement();
}
break;
}
case STATUS.READY: {
if (previousState.status !== STATUS.READY) {
onLoad?.(src, isCached);
}
break;
}
}
}, [getContent, getElement, isCached, onLoad, previousState, src, status]);
const elementProps = omit(
props,
"baseURL",
"cacheRequests",
"children",
"description",
"fetchOptions",
"innerRef",
"loader",
"onError",
"onLoad",
"preProcessor",
"src",
"title",
"uniqueHash",
"uniquifyIDs"
);
if (!canUseDOM()) {
return loader;
}
if (element) {
return cloneElement(element, {
ref: innerRef,
...elementProps
});
}
if ([STATUS.UNSUPPORTED, STATUS.FAILED].includes(status)) {
return children;
}
return loader;
}
function InlineSVG(props) {
if (!cacheStore) {
cacheStore = new CacheStore();
}
const { loader } = props;
const [isReady, setReady] = useState(cacheStore.isReady);
useEffect2(() => {
if (isReady) {
return;
}
cacheStore.onReady(() => {
setReady(true);
});
}, [isReady]);
if (!isReady) {
return loader;
}
return /* @__PURE__ */ React.createElement(ReactInlineSVG, { ...props });
}
export {
cacheStore,
InlineSVG as default
};
//# sourceMappingURL=index.mjs.map