vite-plugin-svg-icons-ng
Version:
Vite plugin for easily creating an SVG sprite and injecting it for use, with a brand-new version.
285 lines (279 loc) • 11 kB
JavaScript
import { normalizePath } from 'vite';
import { optimize } from 'svgo';
import fg from 'fast-glob';
import { createHash } from 'crypto';
import fs from 'fs-extra';
import path from 'pathe';
import { parse } from 'node-html-parser';
const VIRTUAL_REGISTER_DEPRECATED = "virtual:svg-icons-register";
const VIRTUAL_REGISTER = "virtual:svg-icons/register";
const VIRTUAL_NAMES_DEPRECATED = "virtual:svg-icons-names";
const VIRTUAL_IDS = "virtual:svg-icons/ids";
const VIRTUAL_REGISTER_URL_DEPRECATED = `/@id/__x00__${VIRTUAL_REGISTER_DEPRECATED}`;
const VIRTUAL_REGISTER_URL = `/@id/__x00__${VIRTUAL_REGISTER}`;
const VIRTUAL_NAMES_URL_DEPRECATED = `/@id/__x00__${VIRTUAL_NAMES_DEPRECATED}`;
const VIRTUAL_IDS_URL = `/@id/__x00__${VIRTUAL_IDS}`;
const SVG_DOM_ID = "__svg__icons__dom__";
const XMLNS = "http://www.w3.org/2000/svg";
const XMLNS_LINK = "http://www.w3.org/1999/xlink";
const REGEXP_SYMBOL_ID = /^[A-Za-z][A-Za-z0-9_-]*$/;
const REGEXP_DOM_ID = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
const PLUGIN_NAME = "vite-plugin-svg-icons-ng";
const ERR_ICON_DIRS_REQUIRED = `[${PLUGIN_NAME}]: 'iconDirs' is required!`;
const ERR_SYMBOL_ID_NO_NAME = `[${PLUGIN_NAME}]: 'symbolId' must contain [name] string!`;
const ERR_SYMBOL_ID_SYNTAX = `[${PLUGIN_NAME}]: 'symbolId' must be a valid ASCII letter, number, underline, hyphen, and starting with a letter! (Except for placeholder symbols)`;
const ERR_INJECT_MODE = `[${PLUGIN_NAME}]: 'inject' must be 'body-first' or 'body-last'!`;
const ERR_CUSTOM_DOM_ID_SYNTAX = `[${PLUGIN_NAME}]: 'customDomId' must be a valid ASCII letter, number, underline, hyphen, and starting with a letter or underline!`;
const ERR_SVGO_EXCEPTION = (file, error) => `[${PLUGIN_NAME}]: SVGO optimize failure, skip this file (${file}), caused by:
${error}`;
const SPRITE_TEMPLATE = (symbols, customDomId, inject) => `if (typeof window !== 'undefined') {
function load() {
var body = document.body;
var el = document.getElementById('${customDomId}');
if (!el) {
el = document.createElementNS('${XMLNS}', 'svg');
el.style.position = 'absolute';
el.style.width = '0';
el.style.height = '0';
el.id = '${customDomId}';
el.setAttribute('xmlns', '${XMLNS}');
el.setAttribute('xmlns:link', '${XMLNS_LINK}');
el.setAttribute('aria-hidden', true);
}
el.innerHTML = ${JSON.stringify(symbols)};
body.insertBefore(el, ${inject === "body-first" ? "body.firstChild" : null});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', load);
} else {
load();
}
}
export default {}`;
function convertSvgToSymbol(id, content) {
const root = parse(content);
const svg = root.querySelector("svg");
if (!svg) {
throw new Error("Invalid SVG content, missing <svg> element.");
}
removeUselessAttrs(svg);
unifySizeToViewBox(svg);
prefixInternalId(svg, id);
svg.tagName = "symbol";
svg.setAttribute("id", id);
return svg.toString();
}
function removeUselessAttrs(svg) {
svg.removeAttribute("xmlns");
svg.removeAttribute("xmlns:xlink");
svg.removeAttribute("class");
svg.removeAttribute("style");
svg.removeAttribute("role");
svg.removeAttribute("aria-hidden");
}
function unifySizeToViewBox(svg) {
const { viewBox, width, height } = svg.attributes;
if (!viewBox && width && height) {
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
}
svg.removeAttribute("width");
svg.removeAttribute("height");
}
function prefixInternalId(svg, id) {
const idMap = /* @__PURE__ */ new Map();
for (const defs of svg.querySelectorAll("defs")) {
for (const child of defs.children) {
const oldId = child.getAttribute("id");
if (oldId) {
const newId = `${id}_${oldId}`;
child.setAttribute("id", newId);
idMap.set(oldId, newId);
child.removeAttribute("maskUnits");
child.removeAttribute("patternUnits");
child.removeAttribute("gradientUnits");
child.removeAttribute("clipPathUnits");
child.removeAttribute("markerUnits");
child.removeAttribute("filterUnits");
}
}
}
if (idMap.size > 0) {
for (const el of svg.querySelectorAll("*")) {
for (const [attrName, attrValue] of Object.entries(el.attributes)) {
if ((attrName === "xlink:href" || attrName === "href") && attrValue.startsWith("#")) {
const refId = attrValue.slice(1);
if (idMap.has(refId)) {
el.setAttribute("xlink:href", `#${idMap.get(refId)}`);
el.setAttribute("href", `#${idMap.get(refId)}`);
}
}
if (attrValue.indexOf("url(#") !== -1) {
const newValue = attrValue.replace(/url\(#(.*?)\)/g, (match, refId) => {
if (idMap.has(refId)) {
return `url(#${idMap.get(refId)})`;
}
return match;
});
el.setAttribute(attrName, newValue);
}
}
}
}
}
function validate(opt) {
if (!opt.iconDirs || opt.iconDirs.length === 0) {
throw new Error(ERR_ICON_DIRS_REQUIRED);
}
if (opt.symbolId) {
if (!opt.symbolId.includes("[name]")) {
throw new Error(ERR_SYMBOL_ID_NO_NAME);
} else {
const clearSymbolId = opt.symbolId.replaceAll(/\[name]/g, "").replaceAll(/\[dir]/g, "");
if (!REGEXP_SYMBOL_ID.test(clearSymbolId)) {
throw new Error(ERR_SYMBOL_ID_SYNTAX);
}
}
}
if (opt.inject && !["body-first", "body-last"].includes(opt.inject)) {
throw new Error(ERR_INJECT_MODE);
}
if (opt.customDomId && !REGEXP_DOM_ID.test(opt.customDomId)) {
throw new Error(ERR_CUSTOM_DOM_ID_SYNTAX);
}
}
function createSvgIconsPlugin(userOptions) {
validate(userOptions);
const options = mergeOptions(userOptions);
let isBuild = false;
const cache = /* @__PURE__ */ new Map();
return {
name: "vite:svg-icons",
configResolved(resolvedConfig) {
isBuild = resolvedConfig.command === "build";
},
resolveId(id) {
return [VIRTUAL_REGISTER_DEPRECATED, VIRTUAL_NAMES_DEPRECATED, VIRTUAL_REGISTER, VIRTUAL_IDS].includes(id) ? "\0" + id : null;
},
load: async (id, ssr) => {
if (!isBuild && !ssr) return null;
const isVirtualRegister = id === "\0" + VIRTUAL_REGISTER_DEPRECATED || id === "\0" + VIRTUAL_REGISTER;
const isVirtualNames = id === "\0" + VIRTUAL_NAMES_DEPRECATED || id === "\0" + VIRTUAL_IDS;
if (ssr && !isBuild && (isVirtualRegister || isVirtualNames)) {
return `export default {}`;
}
if (isVirtualRegister) {
return await createSpriteModule(cache, options);
}
if (isVirtualNames) {
return await createIdsModule(cache, options);
}
},
configureServer: ({ middlewares }) => {
middlewares.use(async (req, res, next) => {
const url = normalizePath(req.url);
if (url.endsWith(VIRTUAL_REGISTER_URL_DEPRECATED) || url.endsWith(VIRTUAL_NAMES_URL_DEPRECATED) || url.endsWith(VIRTUAL_REGISTER_URL) || url.endsWith(VIRTUAL_IDS_URL)) {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Content-Type", "application/javascript");
res.setHeader("Cache-Control", "no-cache");
let content = "";
if (url.endsWith(VIRTUAL_REGISTER_URL_DEPRECATED) || url.endsWith(VIRTUAL_REGISTER_URL)) {
content = await createSpriteModule(cache, options);
}
if (url.endsWith(VIRTUAL_NAMES_URL_DEPRECATED) || url.endsWith(VIRTUAL_IDS_URL)) {
content = await createIdsModule(cache, options);
}
res.setHeader("Etag", getWeakETag(content));
res.statusCode = 200;
res.end(content);
} else {
next();
}
});
}
};
}
async function createIdsModule(cache, options) {
const list = await compilerIcons(cache, options);
return `export default ${JSON.stringify(list.map((i) => i.symbolId))}`;
}
async function createSpriteModule(cache, options) {
const list = await compilerIcons(cache, options);
return SPRITE_TEMPLATE(list.map((i) => i.symbol).join(""), options.customDomId, options.inject);
}
async function compilerIcons(cache, options) {
const dirPromises = options.iconDirs.map(async (dir) => {
const entryList = await fg.glob("**/*.svg", { cwd: dir, stats: true, absolute: true });
const entryPromises = entryList.map(async (e) => {
return await process(e, cache, dir, options);
});
return (await Promise.all(entryPromises)).filter(Boolean);
});
return (await Promise.all(dirPromises)).flat();
}
async function process(e, cache, dir, options) {
const { path: path2, stats: { mtimeMs } = {} } = e;
const cached = cache.get(path2);
if (cached && cached.mtimeMs === mtimeMs) {
return cached.entry;
}
try {
const relativePath = normalizePath(path2).replace(normalizePath(dir + "/"), "") || "";
const symbolId = generateSymbolId(relativePath, options);
const symbol = await processIcon(path2, symbolId, options);
const entry = { symbolId, symbol };
cache.set(path2, { mtimeMs, entry });
return entry;
} catch {
return null;
}
}
async function processIcon(file, symbolId, options) {
let svg = await fs.promises.readFile(file, "utf-8");
if (options.svgoOptions) {
try {
svg = optimize(svg, options.svgoOptions).data;
} catch (error) {
console.warn(ERR_SVGO_EXCEPTION(file, error));
}
}
if (options.strokeOverride === true) {
svg = svg.replace(/\bstroke="[^"]*"/gi, 'stroke="currentColor"');
} else if (options.strokeOverride !== null && typeof options.strokeOverride === "object" && options.strokeOverride.color) {
svg = svg.replace(/\bstroke="[^"]*"/gi, `stroke="${options.strokeOverride.color}"`);
}
return convertSvgToSymbol(symbolId, svg);
}
function generateSymbolId(relativePath, options) {
const { symbolId } = options;
const { dirName, baseName } = parseDirName(relativePath);
const id = symbolId.replace(/\[dir]/g, dirName).replace(/\[name]/g, baseName);
return id.replace(/-+/g, "-").replace(/(^-|-$)/g, "");
}
function parseDirName(name) {
let dirName = "";
let baseName = name;
const lastSeparators = name.lastIndexOf("/");
if (lastSeparators !== -1) {
dirName = name.slice(0, lastSeparators).split("/").filter(Boolean).join("-");
baseName = name.slice(lastSeparators + 1);
}
return {
dirName,
baseName: path.basename(baseName, path.extname(baseName))
};
}
function getWeakETag(str) {
return str.length === 0 ? '"W/0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"' : `W/${Buffer.byteLength(str, "utf8")}-${createHash("sha1").update(str, "utf8").digest("base64").substring(0, 27)}`;
}
function mergeOptions(userOptions) {
return {
symbolId: "icon-[dir]-[name]",
svgoOptions: {},
inject: "body-last",
customDomId: SVG_DOM_ID,
strokeOverride: false,
...userOptions
};
}
const __TEST__ = { generateSymbolId, parseDirName, validate };
export { __TEST__, createSvgIconsPlugin };