react-inlinesvg
Version:
An SVG loader for React
641 lines (630 loc) • 19.9 kB
JavaScript
"use client";
;
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
// src/index.tsx
var index_exports = {};
__export(index_exports, {
cacheStore: () => cacheStore,
default: () => InlineSVG
});
module.exports = __toCommonJS(index_exports);
var import_react4 = require("react");
// 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 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(options = {}) {
__publicField(this, "cacheApi");
__publicField(this, "cacheStore");
__publicField(this, "subscribers", []);
__publicField(this, "isReady", false);
const { name = CACHE_NAME, persistent = false } = options;
this.cacheStore = /* @__PURE__ */ new Map();
const usePersistentCache = persistent && canUseDOM() && "caches" in window;
if (usePersistentCache) {
caches.open(name).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();
return () => {
};
}
this.subscribers.push(callback);
return () => {
const index = this.subscribers.indexOf(callback);
if (index >= 0) {
this.subscribers.splice(index, 1);
}
};
}
waitForReady() {
if (this.isReady) {
return Promise.resolve();
}
return new Promise((resolve) => {
this.onReady(resolve);
});
}
async get(url, fetchOptions) {
await this.fetchAndCache(url, fetchOptions);
return this.cacheStore.get(url)?.content ?? "";
}
getContent(url) {
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 fetchAndCache(url, fetchOptions) {
if (!this.isReady) {
await this.waitForReady();
}
const cache = this.cacheStore.get(url);
if (cache?.status === STATUS.LOADED) {
return;
}
if (cache?.status === STATUS.LOADING) {
await this.handleLoading(url, fetchOptions?.signal || void 0, async () => {
this.cacheStore.set(url, { content: "", status: STATUS.IDLE });
await this.fetchAndCache(url, fetchOptions);
});
return;
}
this.cacheStore.set(url, { content: "", status: STATUS.LOADING });
try {
const content = this.cacheApi ? await this.fetchFromPersistentCache(url, fetchOptions) : 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 fetchFromPersistentCache(url, fetchOptions) {
const data = await this.cacheApi?.match(url);
if (data) {
return data.text();
}
await this.cacheApi?.add(new Request(url, fetchOptions));
const response = await this.cacheApi?.match(url);
return await response?.text() ?? "";
}
async handleLoading(url, signal, callback) {
for (let retryCount = 0; retryCount < CACHE_MAX_RETRIES; retryCount++) {
if (signal?.aborted) {
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError");
}
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();
}
};
function sleep(seconds = 1) {
return new Promise((resolve) => {
setTimeout(resolve, seconds * 1e3);
});
}
// src/modules/useInlineSVG.ts
var import_react2 = require("react");
var import_react_from_dom2 = __toESM(require("react-from-dom"));
// src/modules/hooks.tsx
var import_react = require("react");
function useMount(effect) {
(0, import_react.useEffect)(effect, []);
}
function usePrevious(state) {
const ref = (0, import_react.useRef)(void 0);
(0, import_react.useEffect)(() => {
ref.current = state;
});
return ref.current;
}
// src/modules/utils.ts
var import_react_from_dom = __toESM(require("react-from-dom"));
function uniquifyStyleIds(svgText, hash, baseURL) {
const idMatches = svgText.matchAll(/\bid=(["'])([^"']+)\1/g);
const ids = [...new Set([...idMatches].map((m) => m[2]))];
if (!ids.length) {
return svgText;
}
ids.sort((a, b) => b.length - a.length);
return svgText.replace(/<style[^>]*>([\S\s]*?)<\/style>/gi, (fullMatch, cssContent) => {
let modified = cssContent;
for (const id of ids) {
const escaped = id.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
modified = modified.replace(
new RegExp(`url\\((['"]?)#${escaped}\\1\\)`, "g"),
`url($1${baseURL}#${id}__${hash}$1)`
);
modified = modified.replace(
new RegExp(`#${escaped}(?![a-zA-Z0-9_-])`, "g"),
`#${id}__${hash}`
);
}
return fullMatch.replace(cssContent, modified);
});
}
function getNode(options) {
const {
baseURL,
content,
description,
handleError,
hash,
preProcessor,
title,
uniquifyIDs = false
} = options;
try {
let svgText = processSVG(content, preProcessor);
if (uniquifyIDs) {
svgText = uniquifyStyleIds(svgText, hash, baseURL ?? "");
}
const node = (0, import_react_from_dom.default)(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/modules/useInlineSVG.ts
function useInlineSVG(props, cacheStore2) {
const {
baseURL,
cacheRequests = true,
description,
fetchOptions,
onError,
onLoad,
preProcessor,
src,
title,
uniqueHash,
uniquifyIDs
} = props;
const hash = (0, import_react2.useRef)(uniqueHash ?? randomString(8));
const fetchOptionsRef = (0, import_react2.useRef)(fetchOptions);
const onErrorRef = (0, import_react2.useRef)(onError);
const onLoadRef = (0, import_react2.useRef)(onLoad);
const preProcessorRef = (0, import_react2.useRef)(preProcessor);
fetchOptionsRef.current = fetchOptions;
onErrorRef.current = onError;
onLoadRef.current = onLoad;
preProcessorRef.current = preProcessor;
const [state, setState] = (0, import_react2.useReducer)(
(previousState2, nextState) => ({
...previousState2,
...nextState
}),
{
content: "",
element: null,
isCached: false,
status: STATUS.IDLE
},
(initial) => {
const cached = cacheRequests && cacheStore2.isCached(src);
if (!cached) {
return initial;
}
const cachedContent = cacheStore2.getContent(src);
try {
const node = getNode({
...props,
handleError: () => {
},
hash: hash.current,
content: cachedContent
});
if (!node) {
return { ...initial, content: cachedContent, isCached: true, status: STATUS.LOADED };
}
const convertedElement = (0, import_react_from_dom2.default)(node);
if (convertedElement && (0, import_react2.isValidElement)(convertedElement)) {
return {
content: cachedContent,
element: convertedElement,
isCached: true,
status: STATUS.READY
};
}
} catch {
}
return {
...initial,
content: cachedContent,
isCached: true,
status: STATUS.LOADED
};
}
);
const { content, element, isCached, status } = state;
const previousProps = usePrevious(props);
const previousState = usePrevious(state);
const isActive = (0, import_react2.useRef)(false);
const isInitialized = (0, import_react2.useRef)(false);
const handleError = (0, import_react2.useCallback)((error) => {
if (isActive.current) {
setState({
status: error.message === "Browser does not support SVG" ? STATUS.UNSUPPORTED : STATUS.FAILED
});
onErrorRef.current?.(error);
}
}, []);
const getElement = (0, import_react2.useCallback)(() => {
try {
const node = getNode({
baseURL,
content,
description,
handleError,
hash: hash.current,
preProcessor: preProcessorRef.current,
src,
title,
uniquifyIDs
});
const convertedElement = (0, import_react_from_dom2.default)(node);
if (!convertedElement || !(0, import_react2.isValidElement)(convertedElement)) {
throw new Error("Could not convert the src to a React element");
}
setState({
element: convertedElement,
status: STATUS.READY
});
} catch (error) {
handleError(error);
}
}, [baseURL, content, description, handleError, src, title, uniquifyIDs]);
useMount(() => {
isActive.current = true;
if (!canUseDOM() || isInitialized.current) {
return void 0;
}
try {
if (status === STATUS.READY) {
onLoadRef.current?.(src, isCached);
} else if (status === STATUS.IDLE) {
if (!isSupportedEnvironment()) {
throw new Error("Browser does not support SVG");
}
if (!src) {
throw new Error("Missing src");
}
setState({ content: "", element: null, isCached: false, status: STATUS.LOADING });
}
} catch (error) {
handleError(error);
}
isInitialized.current = true;
return () => {
isActive.current = false;
};
});
(0, import_react2.useEffect)(() => {
if (!canUseDOM() || !previousProps) {
return;
}
if (previousProps.src !== src) {
if (!src) {
handleError(new Error("Missing src"));
return;
}
setState({ content: "", element: null, isCached: false, status: STATUS.LOADING });
}
}, [handleError, previousProps, src]);
(0, import_react2.useEffect)(() => {
if (status !== STATUS.LOADING) {
return void 0;
}
const controller = new AbortController();
let active = true;
(async () => {
try {
const dataURI = /^data:image\/svg[^,]*?(;base64)?,(.*)/.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) {
if (active) {
setState({ content: inlineSrc, isCached: false, status: STATUS.LOADED });
}
return;
}
const fetchParameters = { ...fetchOptionsRef.current, signal: controller.signal };
let loadedContent;
let hasCache = false;
if (cacheRequests) {
hasCache = cacheStore2.isCached(src);
loadedContent = await cacheStore2.get(src, fetchParameters);
} else {
loadedContent = await request(src, fetchParameters);
}
if (active) {
setState({ content: loadedContent, isCached: hasCache, status: STATUS.LOADED });
}
} catch (error) {
if (active && error.name !== "AbortError") {
handleError(error);
}
}
})();
return () => {
active = false;
controller.abort();
};
}, [cacheRequests, cacheStore2, handleError, src, status]);
(0, import_react2.useEffect)(() => {
if (status === STATUS.LOADED && content) {
getElement();
}
}, [content, getElement, status]);
(0, import_react2.useEffect)(() => {
if (!canUseDOM() || !previousProps || previousProps.src !== src) {
return;
}
if (previousProps.title !== title || previousProps.description !== description) {
getElement();
}
}, [description, getElement, previousProps, src, title]);
(0, import_react2.useEffect)(() => {
if (!previousState) {
return;
}
if (status === STATUS.READY && previousState.status !== STATUS.READY) {
onLoadRef.current?.(src, isCached);
}
}, [isCached, previousState, src, status]);
return { element, status };
}
// src/provider.tsx
var import_react3 = __toESM(require("react"));
var CacheContext = (0, import_react3.createContext)(null);
function useCacheStore() {
return (0, import_react3.useContext)(CacheContext);
}
// src/index.tsx
var cacheStore = new CacheStore();
function InlineSVG(props) {
const { children = null, innerRef, loader = null } = props;
const contextStore = useCacheStore();
const store = contextStore ?? cacheStore;
const { element, status } = useInlineSVG(props, store);
if (!canUseDOM()) {
return loader;
}
if (element) {
return (0, import_react4.cloneElement)(element, {
ref: innerRef,
...omit(
props,
"baseURL",
"cacheRequests",
"children",
"description",
"fetchOptions",
"innerRef",
"loader",
"onError",
"onLoad",
"preProcessor",
"src",
"title",
"uniqueHash",
"uniquifyIDs"
)
});
}
if ([STATUS.UNSUPPORTED, STATUS.FAILED].includes(status)) {
return children;
}
return loader;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
cacheStore
});
//# sourceMappingURL=index.js.map
// fix-cjs-exports
if (module.exports.default) {
Object.assign(module.exports.default, module.exports);
module.exports = module.exports.default;
delete module.exports.default;
}