vite-plugin-magical-svg
Version:
An all-in-one Vite plugin that magically makes working with SVGs and bundling them a breeze
419 lines (418 loc) • 17.8 kB
JavaScript
/*!
* 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.
*/
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { basename, extname, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createFilter } from 'vite';
import { Builder, parseStringPromise as parseXml } from 'xml2js';
import { optimize as svgoOptimize } from 'svgo';
import MagicString from 'magic-string';
import resolve from './resolve.js';
import { generateDev, generateProd, inlineSymbol } from './codegen.js';
let ROOT = '/';
const ASSET_RE = /__MAGICAL_SVG_SPRITE__(_[0-9a-f]{8})__/g;
function generateId(str) {
return '_' + 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 = relative(ROOT, file);
const imports = [];
const raw = await readFile(file, 'utf8');
let xml;
try {
xml = await parseXml(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 = extname(file);
const name = basename(file, ext);
const hash = template.includes('[hash]') // only compute hash when needed
? 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
});
}
export function magicalSvgPlugin(config = {}) {
let fileName = 'assets/[name].[hash].[ext]';
let base = '/';
let treeshake = true;
let sourcemap = false;
let serve = false;
const filter = createFilter(config.include, config.exclude);
const assets = new Map();
const viewBoxes = new Map();
const symbolIds = new Map();
const files = new Map();
const sprites = new Map();
const usedAssets = 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) => 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 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;
// I'm implementing my own naive resolve as I need to *avoid* `exports` compliance
// which is something Vite's resolver won't let me do it seems :<
return resolve(id, importer);
},
async load(id) {
const url = new URL(`file:///${id}`);
if (!filter(id) || !url.pathname.endsWith('.svg'))
return null;
const filePath = fileURLToPath(url);
const [raw, xml, imports] = await load(this, filePath, serve, config.symbolId);
// Add viewbox if missing
if (config.restoreMissingViewBox && !xml.svg.$.viewBox && xml.svg.$.width && xml.svg.$.height) {
xml.svg.$.viewBox = `0 0 ${xml.svg.$.width} ${xml.svg.$.height}`;
}
// Transform fill and stroke if configured
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: xml });
usedAssets.set(id, 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, new Set());
}
if (spriteId !== 'inline') {
// Clean (common) useless attributes
// Don't do this for the inline sprite as this would be a breaking change
// + it may be useful for JS code :shrug:
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}\nexport 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: code,
map: { mappings: '' },
};
}
const target = config.target ?? 'dom';
const preamble = code.slice(0, exportIndex);
if (serve) {
const asset = assets.get(id);
await hashSymbols(asset.xml.svg);
if (assetId === 'inline') {
asset.xml.svg.$.id = generateId(id);
return {
code: [
preamble,
generateProd(target, asset.xml.svg.$.viewBox, asset.xml.svg.$.width, asset.xml.svg.$.height, `'#${asset.xml.svg.$.id}'`),
inlineSymbol(asset.xml)
].join('\n'),
map: { mappings: '' },
};
}
return {
code: [preamble, generateDev(target, asset.xml)].join('\n'),
map: { mappings: '' },
};
}
const symbolId = symbolIds.get(id);
if (assetId === 'inline') {
const vb = viewBoxes.get(id);
return {
code: [
preamble,
generateProd(target, vb.viewBox, vb.width, vb.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 MagicString(code));
const spriteId = match[1];
const assetId = sprites.get(spriteId);
// Mark the symbol as used (for tree-shaking)
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);
// Treeshake symbols
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 {
// This is a file. We can know if the file has been tree-shaken by checking `isIncluded`.
const mdl = this.getModuleInfo(assetId);
if (!mdl?.isIncluded)
continue; // Skip the file
}
}
await transformRefs(asset.xml.svg, async (ref, isFile) => {
if (!isFile) {
const url = new URL(`file:///${ref}`);
const file = files.get(url.searchParams.get('sprite') || 'sprite');
if (!file)
return null;
return `${base}${file}#${symbolIds.get(ref)}`;
}
const file = files.get(ref);
return file ? `${base}${file}` : null;
});
const builder = new 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 = svgoOptimize(xml, opts);
xml = res.data;
}
catch (e) {
if (e instanceof Error && e.name === 'SvgoParserError') {
// @ts-expect-error -- SvgoParserError is not exported by svgo :pensive:
const { message, line, column } = e;
this.error(message, { column, line });
}
else {
throw e;
}
}
}
this.emitFile({
type: 'asset',
fileName: files.get(assetId),
source: xml
});
}
}
};
}
export default magicalSvgPlugin;