UNPKG

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
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 };