UNPKG

vite-plugin-magical-svg

Version:

An all-in-one Vite plugin that magically makes working with SVGs and bundling them a breeze

500 lines (495 loc) 18.4 kB
"use strict"; 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 __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); // dist/index.js var index_exports = {}; __export(index_exports, { default: () => index_default, magicalSvgPlugin: () => magicalSvgPlugin }); module.exports = __toCommonJS(index_exports); var import_node_crypto = require("node:crypto"); var import_promises2 = require("node:fs/promises"); var import_node_path = require("node:path"); var import_node_url = require("node:url"); var import_vite = require("vite"); var import_xml2js2 = require("xml2js"); var import_svgo = require("svgo"); var import_magic_string = __toESM(require("magic-string"), 1); // dist/resolve.js var import_path = require("path"); var import_promises = require("fs/promises"); var import_fs = require("fs"); async function findPackageRoot(path) { do { path = (0, import_path.dirname)(path); const res = await (0, import_promises.readdir)(path); if (res.includes("package.json")) return path; } while (path !== "/"); return null; } async function dumbNodeResolve(id, importer) { const pkgBase = await findPackageRoot(importer); if (!pkgBase) return null; const candidate = (0, import_path.resolve)(pkgBase, "node_modules", id); if (!(0, import_fs.existsSync)(candidate)) return null; return candidate; } // dist/codegen.js var import_xml2js = require("xml2js"); function renderHtml(xml, useSymbol) { const symbol = new import_xml2js.Builder({ headless: true, renderOpts: { pretty: false } }).buildObject({ symbol: xml.svg }); return useSymbol ? JSON.stringify(`${symbol}<use href='#${xml.svg.$.id}'/>`) : JSON.stringify(symbol); } function generateDev(target, xml) { return ` import { createSvgDEV } from 'vite-plugin-magical-svg/runtime/${target}.js'; export default /*#__PURE__*/ createSvgDEV( '${xml.svg.$.viewBox || ""}' || void 0, '${xml.svg.$.width || ""}' || void 0, '${xml.svg.$.height || ""}' || void 0, ${renderHtml(xml, true)} ); `; } function generateProd(target, viewBox, width, height, symbol) { return ` import { createSvg } from 'vite-plugin-magical-svg/runtime/${target}.js'; export default /*#__PURE__*/ createSvg( '${viewBox || ""}' || void 0, '${width || ""}' || void 0, '${height || ""}' || void 0, ${symbol} ); `; } function inlineSymbol(xml) { return ` ;(() => { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') svg.setAttribute('height', '0') svg.setAttribute('width', '0') svg.setAttribute('viewBox', '${xml.svg.$.viewBox}') svg.innerHTML = ${renderHtml(xml, false)} document.body.prepend(svg) })(); `; } // dist/index.js var ROOT = "/"; var ASSET_RE = /__MAGICAL_SVG_SPRITE__(_[0-9a-f]{8})__/g; function generateId(str) { return "_" + (0, import_node_crypto.createHash)("sha256").update(str).digest("hex").slice(0, 8); } function traverseSvg(xml, handler) { if (typeof xml !== "object") return Promise.resolve(); const promises = []; for (const tag in xml) { if (tag in xml && tag !== "$") { if (Array.isArray(xml[tag])) { for (const element of xml[tag]) { promises.push(handler(tag, element), traverseSvg(element, handler)); } } else { promises.push(handler(tag, xml[tag]), traverseSvg(xml[tag], handler)); } } } return Promise.all(promises); } function transformRefs(xml, fn) { return traverseSvg(xml, async (tag, element) => { if ((tag === "image" || tag === "use") && element.$?.href) { const ref = await fn(element.$.href, tag === "image"); if (ref) element.$.href = ref; } }); } function hashSymbols(xml) { return traverseSvg(xml, (tag, element) => { if (tag === "use" && element.$?.href) { element.$.href = `#${generateId(element.$.href)}`; } }); } function setFillStrokeColor(value, xml) { const color = value === true ? "currentColor" : value; return traverseSvg(xml, (_, element) => { if (!element.$) return; if ("fill" in element.$ && element.$.fill !== "none") element.$.fill = color; if ("stroke" in element.$ && element.$.stroke !== "none") element.$.stroke = color; }); } async function load(ctx, file, serve, symbolIdGen) { const fileFriendlyName = (0, import_node_path.relative)(ROOT, file); const imports = []; const raw = await (0, import_promises2.readFile)(file, "utf8"); let xml; try { xml = await (0, import_xml2js2.parseStringPromise)(raw); } catch (e) { const msg = e instanceof Error ? e.message : e?.toString(); ctx.error(`Could not load SVG: invalid XML (${msg}) (in ${fileFriendlyName})`); } if (!xml) { ctx.error(`Could not load SVG: empty file (in ${fileFriendlyName})`); } if (!("svg" in xml)) { ctx.error(`Could not load SVG: Top-level XML element isn't \`svg\` (in ${fileFriendlyName})`); } if (!xml.svg) { ctx.warn(`${fileFriendlyName} is an empty SVG.`); } await transformRefs(xml.svg, async (ref, isFile) => { const resolved = await ctx.resolve(ref, file); if (!resolved?.id) return null; const url = new URL(`file:///${resolved.id}`); if (isFile) url.searchParams.set("file", "true"); else if (serve) url.searchParams.set("sprite", "inline"); const importUrl = url.toString().slice(7); if (!imports.includes(importUrl)) imports.push(importUrl); return importUrl; }); if (typeof xml.svg !== "object") xml.svg = { _: xml.svg }; xml.svg.$ = xml.svg.$ ?? {}; xml.svg.$.id = symbolIdGen?.(file, raw) || generateId(raw); return [raw, xml, imports]; } function generateFilename(template, file, raw) { if (typeof template === "string") { const ext = (0, import_node_path.extname)(file); const name = (0, import_node_path.basename)(file, ext); const hash = template.includes("[hash]") ? (0, import_node_crypto.createHash)("sha256").update(raw).digest("hex").slice(0, 8) : ""; return template.replace(/\[name]/g, name).replace(/\[extname]/g, ext).replace(/\[ext]/g, ext.slice(1)).replace(/\[hash]/g, hash); } return template({ type: "asset", source: raw, name: file }); } function magicalSvgPlugin(config = {}) { let fileName = "assets/[name].[hash].[ext]"; let base = "/"; let treeshake = true; let sourcemap = false; let serve = false; const filter = (0, import_vite.createFilter)(config.include, config.exclude); const assets = /* @__PURE__ */ new Map(); const viewBoxes = /* @__PURE__ */ new Map(); const symbolIds = /* @__PURE__ */ new Map(); const files = /* @__PURE__ */ new Map(); const sprites = /* @__PURE__ */ new Map(); const usedAssets = /* @__PURE__ */ new Map(); return { name: "vite-plugin-magical-svg", enforce: "pre", configResolved(cfg) { ROOT = cfg.root ?? ROOT; base = cfg.base ?? base; sourcemap = !!cfg.build.sourcemap; treeshake = cfg.build.rollupOptions.treeshake !== false; const { output } = cfg.build.rollupOptions; if (cfg.command === "serve") { serve = true; fileName = (info) => (0, import_node_path.relative)(cfg.root, info.name); } else if (output && !Array.isArray(output) && output.assetFileNames) { fileName = output.assetFileNames; } }, async transformIndexHtml(html) { if (assets.has("inline")) { const inline = assets.get("inline"); const bodyTagStart = html.indexOf("<body"); const bodyStart = html.indexOf(">", bodyTagStart) + 1; const head = html.slice(0, bodyStart); const body = html.slice(bodyStart); const svg = new import_xml2js2.Builder({ headless: true }).buildObject(inline.xml); return head + svg + body; } return; }, resolveId(id, importer) { if (!importer || !id.endsWith(".svg") || id.startsWith(".") || id.startsWith("/")) return; if (!filter(id)) return; return dumbNodeResolve(id, importer); }, async load(id) { const url = new URL(`file:///${id}`); if (!filter(id) || !url.pathname.endsWith(".svg")) return null; const filePath = (0, import_node_url.fileURLToPath)(url); const [raw, xml, imports] = await load(this, filePath, serve, config.symbolId); if (config.restoreMissingViewBox && !xml.svg.$.viewBox && xml.svg.$.width && xml.svg.$.height) { xml.svg.$.viewBox = `0 0 ${xml.svg.$.width} ${xml.svg.$.height}`; } if (config.setFillStrokeColor && !url.searchParams.has("skip-recolor")) { await setFillStrokeColor(config.setFillStrokeColor, xml); } if (!config.preserveWidthHeight) { delete xml.svg.$.width; delete xml.svg.$.height; } if (config.setWidthHeight && !xml.svg.$.width && !xml.svg.$.height) { xml.svg.$.width = config.setWidthHeight.width; xml.svg.$.height = config.setWidthHeight.height; } viewBoxes.set(id, { viewBox: xml.svg.$.viewBox, width: xml.svg.$.width, height: xml.svg.$.height }); if (url.searchParams.has("file") || serve) { assets.set(id, { sources: [], xml }); usedAssets.set(id, /* @__PURE__ */ new Set()); } else { const spriteId = url.searchParams.get("sprite") ?? "sprite"; const sprite = assets.get(spriteId) ?? { sources: [], xml: { svg: { $: { width: 0, height: 0 }, symbol: [] } } }; if (!assets.has(spriteId)) { assets.set(spriteId, sprite); usedAssets.set(spriteId, /* @__PURE__ */ new Set()); } if (spriteId !== "inline") { for (const attr of Object.keys(xml.svg.$)) { if (attr === "class" || attr.startsWith("aria-") || attr.startsWith("data-")) delete xml.svg.$[attr]; } } sprite.xml.svg.symbol.push(xml.svg); sprite.sources.push(raw); symbolIds.set(id, xml.svg.$.id); } const imp = imports.map((i) => `import ${JSON.stringify(i)};`).join("\n"); const file = generateFilename(fileName, filePath, raw); return { code: `${imp} export default ${JSON.stringify(`/${file}`)}`, moduleSideEffects: false }; }, async transform(code, id) { const url = new URL(`file:///${id}`); if (!filter(id) || !url.pathname.endsWith(".svg")) return null; const assetId = url.searchParams.has("file") ? id : url.searchParams.get("sprite") ?? "sprite"; const exportIndex = code.indexOf("export default"); if (url.searchParams.has("file")) { const file = code.slice(exportIndex + 16, -1); files.set(assetId, file.slice(1)); return { code, map: { mappings: "" } }; } const target = config.target ?? "dom"; const preamble = code.slice(0, exportIndex); if (serve) { const asset2 = assets.get(id); await hashSymbols(asset2.xml.svg); if (assetId === "inline") { asset2.xml.svg.$.id = generateId(id); return { code: [ preamble, generateProd(target, asset2.xml.svg.$.viewBox, asset2.xml.svg.$.width, asset2.xml.svg.$.height, `'#${asset2.xml.svg.$.id}'`), inlineSymbol(asset2.xml) ].join("\n"), map: { mappings: "" } }; } return { code: [preamble, generateDev(target, asset2.xml)].join("\n"), map: { mappings: "" } }; } const symbolId = symbolIds.get(id); if (assetId === "inline") { const vb2 = viewBoxes.get(id); return { code: [ preamble, generateProd(target, vb2.viewBox, vb2.width, vb2.height, `'#${symbolId}'`) ].join("\n"), map: { mappings: "" } }; } sprites.set(symbolId, assetId); const asset = assets.get(assetId); files.set(assetId, generateFilename(fileName, `${assetId}.svg`, asset.sources.sort().join(""))); const vb = viewBoxes.get(id); return { code: [ preamble, generateProd(target, vb.viewBox, vb.width, vb.height, `__MAGICAL_SVG_SPRITE__${symbolId}__`) ].join("\n"), map: { mappings: "" } }; }, renderChunk(code) { let match; let magicString; while (match = ASSET_RE.exec(code)) { magicString = magicString || (magicString = new import_magic_string.default(code)); const spriteId = match[1]; const assetId = sprites.get(spriteId); const used = usedAssets.get(assetId); used.add(spriteId); magicString.overwrite(match.index, match.index + match[0].length, JSON.stringify(`${base}${files.get(assetId)}#${match[1]}`)); } if (!magicString) return null; return { code: magicString.toString(), map: sourcemap ? magicString.generateMap({ hires: true }) : null }; }, async generateBundle() { for (const assetId of assets.keys()) { if (assetId === "inline") continue; const asset = assets.get(assetId); if (treeshake) { if (asset.xml.svg.symbol) { const used = usedAssets.get(assetId); asset.xml.svg.symbol = asset.xml.svg.symbol.filter((s) => used.has(s.$.id)); } else { const mdl = this.getModuleInfo(assetId); if (!mdl?.isIncluded) continue; } } await transformRefs(asset.xml.svg, async (ref, isFile) => { if (!isFile) { const url = new URL(`file:///${ref}`); const file2 = files.get(url.searchParams.get("sprite") || "sprite"); if (!file2) return null; return `${base}${file2}#${symbolIds.get(ref)}`; } const file = files.get(ref); return file ? `${base}${file}` : null; }); const builder = new import_xml2js2.Builder(); let xml = builder.buildObject(asset.xml); if (config.svgo !== false) { const opts = { plugins: [ { name: "preset-default", params: { overrides: { cleanupNumericValues: false, removeHiddenElems: false, removeUselessDefs: files.has(assetId) ? false : null, cleanupIds: { minify: false, remove: false }, convertPathData: false } } }, "removeTitle" ] }; try { const res = (0, import_svgo.optimize)(xml, opts); xml = res.data; } catch (e) { if (e instanceof Error && e.name === "SvgoParserError") { const { message, line, column } = e; this.error(message, { column, line }); } else { throw e; } } } this.emitFile({ type: "asset", fileName: files.get(assetId), source: xml }); } } }; } var index_default = magicalSvgPlugin; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { magicalSvgPlugin }); /*! * Copyright (c) Cynthia Rey et al., All rights reserved. * SPDX-License-Identifier: BSD-3-Clause * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * 1. Redistributions of source code must retain the above copyright notice, this * list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * 3. Neither the name of the copyright holder nor the names of its contributors * may be used to endorse or promote products derived from this software without * specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */