vite-plugin-svg-spritemap
Version:
Generates a SVG spritemap from multiple .svg files
361 lines (342 loc) • 11.8 kB
JavaScript
import { createHash } from "node:crypto";
import path from "node:path";
import chokidar from "chokidar";
import picomatch from "picomatch";
import { globSync } from "tinyglobby";
import fs from "node:fs";
import { optimize } from "svgo";
import { parse } from "node-html-parser";
//#region src/getSpriteContent.ts
const DEFAULT_SYMBOL_ID = "[name]";
function toPosix(value) {
return value.split(path.sep).join("/");
}
function tidy(id) {
return id.replace(/-{2,}/g, "-").replace(/^-|-$/g, "");
}
function resolveSymbolId(file, base, symbolId) {
const name = path.basename(file, ".svg");
let id;
if (typeof symbolId === "function") id = symbolId(toPosix(file), name);
else {
const relativeDir = toPosix(path.relative(base, path.dirname(file)));
const dir = relativeDir === "" || relativeDir === "." ? "" : relativeDir.replace(/\//g, "-");
id = symbolId.replaceAll("[dir]", dir).replaceAll("[name]", name);
}
return tidy(id);
}
/**
* Namespaces every id in a single icon so that gradients, masks and clip paths
* coming from different files cannot overwrite each other once they share the
* sprite's `<defs>`.
*/
function isolateIds(markup, namespace) {
const ids = new Set([...markup.matchAll(/\bid="([^"]*)"/g)].map((match) => match[1]).filter(Boolean));
let result = markup;
for (const id of ids) {
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const next = `${namespace}-${id}`;
result = result.replace(new RegExp(`\\bid="${escaped}"`, "g"), `id="${next}"`).replace(new RegExp(`url\\(#${escaped}\\)`, "g"), `url(#${next})`).replace(new RegExp(`href="#${escaped}"`, "g"), `href="#${next}"`);
}
return result;
}
function parseViewBox(viewBox) {
if (!viewBox) return;
const parts = viewBox.trim().split(/[\s,]+/).map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) return;
const [, , width, height] = parts;
return width > 0 && height > 0 ? {
width,
height
} : void 0;
}
function getSpriteContent({ pattern, svgo, currentColor, symbolId = DEFAULT_SYMBOL_ID, view = false, root = process.cwd() }) {
const svgFiles = globSync(pattern, {
cwd: root,
expandDirectories: false
});
const base = picomatch.scan(pattern).base || ".";
const symbols = [];
const symbolIds = [];
const definitions = [];
const views = [];
const seen = /* @__PURE__ */ new Map();
let viewOffset = 0;
const svgoConfig = typeof svgo === "object" ? { ...svgo } : {};
if (currentColor) svgoConfig.plugins = [...svgoConfig.plugins ?? [], {
name: "convertColors",
params: { currentColor: true }
}];
svgFiles.forEach((file) => {
const code = fs.readFileSync(path.resolve(root, file), "utf-8");
if (!code.trim()) return;
let result;
try {
result = svgo ? optimize(code, svgoConfig).data : code;
} catch (error) {
console.warn(`[vite-plugin-svg-spritemap] skipped ${file}: ${error.message}`);
return;
}
const id = resolveSymbolId(file, base, symbolId);
const previous = seen.get(id);
if (previous !== void 0) {
console.warn(`[vite-plugin-svg-spritemap] skipped ${file}: id "${id}" is already used by ${previous}. Set the \`symbolId\` option (for example \`"[dir]-[name]"\`) to keep them apart.`);
return;
}
const svgElement = parse(isolateIds(result, id)).querySelector("svg");
if (!svgElement) {
console.warn(`[vite-plugin-svg-spritemap] skipped ${file}: no <svg> root element`);
return;
}
const symbol = parse("<symbol/>").querySelector("symbol");
const defs = svgElement.querySelector("defs");
if (defs) {
defs.childNodes.forEach((def) => definitions.push(def.toString()));
svgElement.removeChild(defs);
}
symbol.setAttribute("id", id);
const viewBox = svgElement.attributes.viewBox;
if (viewBox) symbol.setAttribute("viewBox", viewBox);
svgElement.childNodes.forEach((child) => symbol.appendChild(child));
symbols.push(symbol.toString());
symbolIds.push(id);
seen.set(id, file);
if (!view) return;
const size = parseViewBox(viewBox);
if (!size) {
console.warn(`[vite-plugin-svg-spritemap] no <view> generated for ${file}: it has no usable viewBox.`);
return;
}
views.push(`<use href="#${id}" x="0" y="${viewOffset}" width="${size.width}" height="${size.height}"/><view id="${id}-view" viewBox="0 ${viewOffset} ${size.width} ${size.height}"/>`);
viewOffset += size.height;
});
return {
content: `<svg xmlns="http://www.w3.org/2000/svg">` + (definitions.length > 0 ? `<defs>${definitions.join("")}</defs>` : "") + symbols.join("") + views.join("") + `</svg>`,
symbolIds
};
}
//#endregion
//#region src/hmrClient.ts
const HMR_EVENT = "vite-plugin-svg-spritemap:update";
const XLINK_NS = "http://www.w3.org/1999/xlink";
/**
* Builds the client module injected into the page in dev.
*
* The sprite is an external document referenced by `<use href="sprite.svg#id">`,
* so the browser will not re-fetch it just because the file changed. Pointing the
* references at a new query string is what forces a re-resolve, which lets the
* icons update without reloading the page.
*/
function createHmrClient(spritePath) {
return `
if (import.meta.hot) {
const SPRITE_PATH = ${JSON.stringify(spritePath)};
const XLINK_NS = ${JSON.stringify(XLINK_NS)};
function rebuild(value, timestamp) {
const hashIndex = value.indexOf('#');
const fragment = hashIndex === -1 ? '' : value.slice(hashIndex);
const path = (hashIndex === -1 ? value : value.slice(0, hashIndex)).split('?')[0];
if (!path.endsWith(SPRITE_PATH)) return null;
return path + '?t=' + timestamp + fragment;
}
function refresh(timestamp) {
let updated = 0;
for (const use of document.querySelectorAll('use')) {
const href = use.getAttribute('href');
const xlinkHref = use.getAttributeNS(XLINK_NS, 'href');
const current = href ?? xlinkHref;
if (!current) continue;
const next = rebuild(current, timestamp);
if (!next) continue;
if (href !== null) use.setAttribute('href', next);
if (xlinkHref !== null) use.setAttributeNS(XLINK_NS, 'xlink:href', next);
updated++;
}
// \`view\` mode also makes the sprite usable from <img>.
for (const img of document.querySelectorAll('img')) {
const current = img.getAttribute('src');
if (!current) continue;
const next = rebuild(current, timestamp);
if (!next) continue;
img.setAttribute('src', next);
updated++;
}
return updated;
}
import.meta.hot.on(${JSON.stringify(HMR_EVENT)}, ({ timestamp }) => {
const updated = refresh(timestamp);
console.debug('[vite-plugin-svg-spritemap] updated ' + updated + ' reference(s)');
});
}
`;
}
//#endregion
//#region src/writeTypes.ts
function createTypesContent(symbolIds, spritemapUrl) {
const entries = symbolIds.map((id) => ` ${JSON.stringify(id)},\n`).join("");
const list = entries ? `[\n${entries}]` : "[]";
return `// Generated by vite-plugin-svg-spritemap. Do not edit.
export const spritemapUrl = ${JSON.stringify(spritemapUrl)};
export const iconNames = ${list} as const;
export type IconName = (typeof iconNames)[number];
export function iconHref(name: IconName): string {
return \`\${spritemapUrl}#\${name}\`;
}
`;
}
/**
* Writes the generated module, skipping the write when nothing changed — the
* file usually lives inside the project source, so rewriting it would make the
* dev server reload for no reason.
*/
function writeTypes(filePath, symbolIds, spritemapUrl) {
const content = createTypesContent(symbolIds, spritemapUrl);
if (fs.existsSync(filePath) && fs.readFileSync(filePath, "utf-8") === content) return;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
//#endregion
//#region src/index.ts
const PLUGIN_NAME = "vite-plugin-svg-spritemap";
const CLIENT_MODULE_ID = `virtual:${PLUGIN_NAME}/client`;
const DEFAULT_TYPES_PATH = "src/spritemap-icons.ts";
function svgSpritemap({ pattern, filename = "spritemap.svg", svgo = true, currentColor = false, hmr = true, types = false, symbolId, view = false }) {
let config;
let watcher;
let typesPath;
const spriteOptions = {
pattern,
svgo,
currentColor,
symbolId,
view
};
function resolveConfig(resolved) {
config = resolved;
typesPath = types ? path.resolve(config.root, types === true ? DEFAULT_TYPES_PATH : types) : void 0;
}
function generate() {
return getSpriteContent({
...spriteOptions,
root: config.root
});
}
/**
* `[hash]` is resolved from the sprite contents on build. The dev server keeps
* a stable name instead, so that references in source do not go stale on edit.
*/
function resolveFilename(content) {
if (!filename.includes("[hash]")) return filename;
const hash = content === void 0 ? "dev" : createHash("sha256").update(content).digest("hex").slice(0, 8);
return filename.replaceAll("[hash]", hash);
}
function build(hashed) {
const sprite = generate();
const fileName = resolveFilename(hashed ? sprite.content : void 0);
if (typesPath) writeTypes(typesPath, sprite.symbolIds, config.base + fileName);
return {
...sprite,
fileName
};
}
let bundled;
/**
* An SSR pass runs over the same sources as the client build, which already
* produced the sprite. Astro is the exception: every one of its passes is an
* SSR pass, and it turns on `ssrEmitAssets` precisely because those passes own
* the assets of the final output.
*/
function skipPass() {
return Boolean(config.build.ssr) && !config.build.ssrEmitAssets;
}
return [{
name: `${PLUGIN_NAME}:build`,
apply: "build",
async configResolved(_config) {
resolveConfig(_config);
},
buildStart() {
if (skipPass()) return;
bundled = build(true);
},
generateBundle() {
if (skipPass()) return;
const sprite = bundled ?? build(true);
this.emitFile({
type: "asset",
fileName: sprite.fileName,
source: sprite.content
});
}
}, {
name: `${PLUGIN_NAME}:serve`,
apply: "serve",
async configResolved(_config) {
resolveConfig(_config);
},
resolveId(id) {
return id === CLIENT_MODULE_ID ? CLIENT_MODULE_ID : void 0;
},
load(id) {
return id === CLIENT_MODULE_ID ? createHmrClient("/" + resolveFilename()) : void 0;
},
transformIndexHtml() {
if (!hmr) return [];
return [{
tag: "script",
attrs: {
type: "module",
src: `/@id/${CLIENT_MODULE_ID}`
},
injectTo: "head-prepend"
}];
},
configureServer(server) {
const hot = server.hot ?? server.ws;
const devFileName = resolveFilename();
let cached;
function getSprite() {
cached ??= build(false);
return cached;
}
function notifyClients() {
if (hmr) hot.send({
type: "custom",
event: HMR_EVENT,
data: { timestamp: Date.now() }
});
else hot.send({
type: "full-reload",
path: "*"
});
}
const baseDir = picomatch.scan(pattern).base || ".";
const isMatch = picomatch(pattern);
function onWatchEvent(file) {
if (!isMatch(file.split(path.sep).join("/"))) return;
cached = void 0;
if (typesPath) getSprite();
notifyClients();
}
if (typesPath) getSprite();
watcher = chokidar.watch(baseDir, {
cwd: config.root,
ignoreInitial: true,
ignored: (file) => file.includes("node_modules") || file.includes(".git")
}).on("add", onWatchEvent).on("change", onWatchEvent).on("unlink", onWatchEvent);
server.middlewares.use((req, res, next) => {
if (!((req.originalUrl ?? req.url)?.split("?")[0])?.endsWith("/" + devFileName)) return next();
res.writeHead(200, {
"Content-Type": "image/svg+xml, charset=utf-8",
"Cache-Control": "no-cache"
});
res.end(getSprite().content);
});
},
async closeBundle() {
await watcher?.close();
}
}];
}
//#endregion
export { svgSpritemap as default, svgSpritemap };