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
JavaScript
;
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.
*/