vite-plugin-svg-icons-ng
Version:
Vite plugin for easily creating an SVG sprite and injecting it for use, with a brand-new version.
647 lines (628 loc) • 22 kB
JavaScript
;
const svgIconBaker = require('svg-icon-baker');
const vite = require('vite');
const tinyglobby = require('tinyglobby');
const promises = require('node:fs/promises');
const node_crypto = require('node:crypto');
const node_path = require('node:path');
function createMemoryCache() {
const store = /* @__PURE__ */ new Map();
return {
get(path, hash) {
const cached = store.get(path);
if (!cached) {
return null;
}
return cached.hash === hash ? cached.icon : null;
},
set(path, entry) {
store.set(path, entry);
},
invalidate(path) {
store.delete(path);
}
};
}
const VIRTUAL_ID_PREFIX = "virtual:svg-icons";
const VIRTUAL_REGISTER_DEPRECATED = `${VIRTUAL_ID_PREFIX}-register`;
const VIRTUAL_REGISTER = `${VIRTUAL_ID_PREFIX}/register`;
const VIRTUAL_NAMES_DEPRECATED = `${VIRTUAL_ID_PREFIX}-names`;
const VIRTUAL_IDS = `${VIRTUAL_ID_PREFIX}/ids`;
const VIRTUAL_SPRITE = `${VIRTUAL_ID_PREFIX}/sprite`;
const HMR_EVENT_SVG_ICONS_UPDATE = "svg-icons:update";
const SVG_DOM_ID = "__svg__icons__dom__";
const XMLNS = "http://www.w3.org/2000/svg";
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 produce a valid ASCII letter, number, underline, hyphen id, starting with a letter! (Supported placeholders: [name], [dir])`;
const ERR_INJECT_MODE = `[${PLUGIN_NAME}]: 'inject' must be 'body-first' or 'body-last'!`;
const ERR_HTML_MODE = `[${PLUGIN_NAME}]: 'htmlMode' must be 'script', 'inline', or 'none'!`;
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!`;
function warn(ctx, message) {
const normalized = `[${PLUGIN_NAME}] ${message}`;
if (ctx.logger) {
ctx.logger.warn(normalized);
return;
}
console.warn(normalized);
}
function buildCompileResult(compiledIcons, options) {
const sortedIcons = [...compiledIcons].sort((a, b) => a.id.localeCompare(b.id));
const symbols = sortedIcons.map((icon) => icon.symbol);
return {
ids: sortedIcons.map((icon) => icon.id),
symbols,
sprite: renderSprite(symbols, options),
iconsByFile: new Map(sortedIcons.map((icon) => [icon.file, icon]))
};
}
function renderSprite(symbols, options) {
return `<svg id="${options.customDomId}" xmlns="${XMLNS}" aria-hidden="true" style="position:absolute;width:0;height:0">${symbols.join("")}</svg>`;
}
async function scanIconDirs(iconDirs) {
const groups = await Promise.all(
iconDirs.map(async (iconDir) => {
const files = await tinyglobby.glob("**/*.svg", {
cwd: iconDir,
absolute: true,
onlyFiles: true
});
return files.map((file) => {
const normalizedFile = vite.normalizePath(file);
const normalizedDir = vite.normalizePath(iconDir);
const relativePath = normalizedFile.replace(normalizedDir.endsWith("/") ? normalizedDir : `${normalizedDir}/`, "");
return {
file,
iconDir,
relativePath
};
}).sort((a, b) => a.relativePath.localeCompare(b.relativePath));
})
);
return groups.flat();
}
function getWeakETag(str) {
return str.length === 0 ? 'W/"2jmj7l5rSw0yVb/vlWAYkK/YBwk="' : `W/"${node_crypto.createHash("sha1").update(str, "utf8").digest("base64")}"`;
}
function generateSymbolId(filePath, options) {
const { symbolId } = options;
const { dir, name } = splitPath(filePath);
return renderSymbolIdTemplate(symbolId, { dir, name });
}
function renderSymbolIdTemplate(symbolId, placeholder) {
return symbolId.replace(/\[dir]/g, placeholder.dir).replace(/\[name]/g, placeholder.name).replace(/-+/g, "-").replace(/^-|-$/g, "");
}
function splitPath(filePath) {
const normalized = filePath.replace(/\\/g, "/");
const lastSlash = normalized.lastIndexOf("/");
const dirPart = lastSlash > 0 ? normalized.slice(0, lastSlash) : "";
const filePart = lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
const dir = dirPart ? dirPart.split("/").filter(Boolean).join("-") : "";
const dotIndex = filePart.lastIndexOf(".");
const name = dotIndex > 0 ? filePart.slice(0, dotIndex) : filePart.startsWith(".") ? filePart : filePart;
return { dir, name };
}
async function loadIconSource(iconFile) {
const code = await promises.readFile(iconFile.file, "utf-8");
return {
...iconFile,
code,
hash: getWeakETag(code)
};
}
async function transformIcon(source, options, baker) {
const id = generateSymbolId(source.relativePath, options);
if (!REGEXP_SYMBOL_ID.test(id)) {
throw new Error(
`[${PLUGIN_NAME}]: Generated symbolId "${id}" for "${source.file}" is invalid. Check the file name/path and 'symbolId' template "${options.symbolId}".`
);
}
const { content, issues } = baker.bakeIcon({ name: id, content: source.code });
return {
file: source.file,
id,
symbol: applyStrokeOverride(content, options),
hash: source.hash,
issues
};
}
function applyStrokeOverride(symbol, options) {
if (options.strokeOverride === false) {
return symbol;
}
return symbol.replace(/\bstroke="[^"]*"/gi, `stroke="${options.strokeOverride}"`);
}
function normalizeFsPath(filePath) {
const normalized = vite.normalizePath(filePath);
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
}
function createCompiler(ctx) {
const baker = svgIconBaker.createBaker(ctx.options.bakerOptions);
const iconDirs = ctx.options.iconDirs.map((dir) => {
const normalized = normalizeFsPath(dir);
return normalized.endsWith("/") ? normalized : `${normalized}/`;
});
let inFlight = null;
const state = {
dirty: true,
result: null
};
function reportBakeIssues(icon) {
for (const issue of icon.issues) {
const targetSuffix = issue.targetId ? ` (targetId: ${issue.targetId})` : "";
warn(ctx, `Bake issue in "${icon.file}" [${issue.code}]: ${issue.message}${targetSuffix}`);
}
}
function resolveSymbolIdConflicts(icons) {
const seen = /* @__PURE__ */ new Map();
const resolved = [];
for (const icon of icons) {
const existing = seen.get(icon.id);
if (!existing) {
seen.set(icon.id, icon);
resolved.push(icon);
continue;
}
const error = new Error(`Duplicate symbolId "${icon.id}" generated by "${icon.file}". First defined by "${existing.file}".`);
if (ctx.options.failOnError) {
throw error;
}
warn(ctx, `${error.message} Skip duplicate icon.`);
}
return resolved;
}
async function compileIcons() {
const files = await scanIconDirs(ctx.options.iconDirs);
const compiledIcons = (await Promise.all(
files.map(async (iconFile) => {
try {
const source = await loadIconSource(iconFile);
const cached = ctx.cache.get(iconFile.file, source.hash);
if (cached) {
return cached;
}
const icon = await transformIcon(source, ctx.options, baker);
ctx.cache.set(icon.file, { hash: icon.hash, icon });
return icon;
} catch (error) {
const normalizedError = normalizeBuildError(iconFile.file, error);
if (ctx.options.failOnError) {
throw normalizedError;
}
warn(ctx, `Skip broken icon: ${normalizedError.message}`);
return null;
}
})
)).filter((icon) => icon !== null);
for (const icon of compiledIcons) {
reportBakeIssues(icon);
}
const icons = resolveSymbolIdConflicts(compiledIcons);
return buildCompileResult(icons, ctx.options);
}
function isIconFile(file) {
const normalized = normalizeFsPath(file);
if (!normalized.toLowerCase().endsWith(".svg")) {
return false;
}
return iconDirs.some((dir) => normalized.startsWith(dir));
}
async function getResult() {
if (!state.dirty && state.result) {
return state.result;
}
if (inFlight) {
return await inFlight;
}
inFlight = compileIcons().then((result) => {
state.result = result;
state.dirty = false;
return result;
}).finally(() => {
inFlight = null;
});
return await inFlight;
}
function invalidate(file) {
if (file) {
ctx.cache.invalidate(file);
}
state.dirty = true;
}
return { getResult, invalidate, isIconFile };
}
function normalizeBuildError(file, error) {
if (error instanceof Error) {
if (error.message.includes(file)) {
return error;
}
return new Error(`Failed on icon ${file}, ${error.message}`, { cause: error });
}
return new Error(`Failed on icon ${file}, ${String(error)}`, { cause: error });
}
function renderRegisterModule(result, options) {
return renderSpriteMountCode(result.sprite, options, {
withHmr: true,
exportDefault: true,
stringContext: "module"
});
}
function renderInlineMountScript(result, options) {
return renderSpriteMountCode(result.sprite, options, {
withHmr: true,
exportDefault: false,
stringContext: "html"
});
}
function renderSpriteMountCode(sprite, options, renderOptions) {
const spriteLiteral = toRuntimeStringLiteral(sprite, renderOptions.stringContext);
const svgNsLiteral = JSON.stringify(XMLNS);
const domIdLiteral = JSON.stringify(options.customDomId);
const runtimeKeyLiteral = JSON.stringify("__SVG_ICONS_RUNTIME_STORE__");
const insertBeforeExpr = options.inject === "body-first" ? "body.firstChild" : "null";
const hmrCode = renderOptions.withHmr ? `
if (import.meta.hot && !state.hmrBound) {
import.meta.hot.on(${JSON.stringify(HMR_EVENT_SVG_ICONS_UPDATE)}, function(data) {
if (data && typeof data.sprite === 'string' && typeof state.mountSvgSprite === 'function') {
state.mountSvgSprite(data.sprite);
}
});
state.hmrBound = true;
}` : "";
return `if (typeof window !== 'undefined') {
(function() {
const SVG_NS = ${svgNsLiteral};
const DOM_ID = ${domIdLiteral};
const RUNTIME_KEY = ${runtimeKeyLiteral};
const parser = new DOMParser();
const runtimeStore = window[RUNTIME_KEY] || (window[RUNTIME_KEY] = {});
const state = runtimeStore[DOM_ID] || (runtimeStore[DOM_ID] = {});
const getAttributeNames = function(node) {
if (typeof node.getAttributeNames === 'function') {
return node.getAttributeNames();
}
return [];
};
const parseSpriteRoot = function(html) {
const doc = parser.parseFromString(html, 'image/svg+xml');
const root = doc && doc.documentElement;
if (!root || root.nodeName.toLowerCase() !== 'svg') {
return null;
}
if (typeof root.querySelector === 'function' && root.querySelector('parsererror')) {
return null;
}
return root;
};
const isSvgRoot = function(node) {
return !!node && typeof node.nodeName === 'string' && node.nodeName.toLowerCase() === 'svg';
};
const createSpriteRoot = function() {
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('id', DOM_ID);
svg.setAttribute('xmlns', SVG_NS);
return svg;
};
const resolveMountTarget = function() {
const current = document.getElementById(DOM_ID);
if (isSvgRoot(current)) {
return current;
}
const replacement = createSpriteRoot();
const parent = current && current.parentNode;
if (current && parent && typeof parent.replaceChild === 'function') {
parent.replaceChild(replacement, current);
return replacement;
}
const body = document.body;
if (!body) {
return null;
}
body.insertBefore(replacement, ${insertBeforeExpr});
return replacement;
};
const syncAttributes = function(target, source) {
const sourceAttrNames = getAttributeNames(source);
for (const name of getAttributeNames(target)) {
if (!sourceAttrNames.includes(name)) {
target.removeAttribute(name);
}
}
for (const name of sourceAttrNames) {
const value = source.getAttribute(name);
if (value !== null) {
target.setAttribute(name, value);
}
}
};
const syncChildren = function(target, source) {
while (target.firstChild) {
target.removeChild(target.firstChild);
}
for (const child of Array.from(source.childNodes)) {
const nextChild = document.importNode ? document.importNode(child, true) : child.cloneNode(true);
target.appendChild(nextChild);
}
};
const mountSvgSprite = function(html) {
const source = parseSpriteRoot(html);
if (!source) {
return;
}
const target = resolveMountTarget();
if (!target) {
return;
}
syncAttributes(target, source);
syncChildren(target, source);
};
state.mountSvgSprite = mountSvgSprite;
const loadSvgSprite = function() {
mountSvgSprite(${spriteLiteral});
};${hmrCode}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadSvgSprite);
} else {
loadSvgSprite();
}
})();
}
${renderOptions.exportDefault ? "export default {}" : ""}`;
}
function toRuntimeStringLiteral(value, context) {
let literal = JSON.stringify(value).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
if (context === "html") {
literal = literal.replace(/</g, "\\u003C");
}
return literal;
}
function resolveVirtualTypeFromId(id) {
const normalizedId = id.startsWith("\0") ? id.slice(1) : id;
if (normalizedId === VIRTUAL_REGISTER_DEPRECATED || normalizedId === VIRTUAL_REGISTER) {
return "register";
}
if (normalizedId === VIRTUAL_NAMES_DEPRECATED || normalizedId === VIRTUAL_IDS) {
return "ids";
}
if (normalizedId === VIRTUAL_SPRITE) {
return "sprite";
}
return null;
}
async function renderVirtualModule(ctx, moduleType, renderCtx) {
if (moduleType === "register" && renderCtx.ssr && !renderCtx.isBuild) {
return "export default {}";
}
const result = await ctx.compiler.getResult();
if (moduleType === "register") {
return toRegisterCode(result, ctx.options);
}
if (moduleType === "sprite") {
return toSpriteModule(result);
}
return toIdsCode(result);
}
function toIdsCode(result) {
return `export default ${JSON.stringify(result.ids)}`;
}
function toRegisterCode(result, options) {
return renderRegisterModule(result, options);
}
function toSpriteModule(result) {
return `export default ${JSON.stringify(renderSpriteElement(result))}`;
}
function renderSpriteElement(result) {
return result.sprite;
}
function resolveVirtualId(id) {
if (!resolveVirtualTypeFromId(id)) {
return null;
}
return id.startsWith("\0") ? id : "\0" + id;
}
function parseSsr(loadOptions) {
if (typeof loadOptions === "boolean") {
return loadOptions;
}
if (!loadOptions || typeof loadOptions !== "object") {
return false;
}
return !!loadOptions.ssr;
}
async function pluginLoad(ctx, id, isBuild, loadOptions) {
const moduleType = resolveVirtualTypeFromId(id);
if (!moduleType) {
return null;
}
const ssr = parseSsr(loadOptions);
return await renderVirtualModule(ctx, moduleType, {
isBuild,
ssr
});
}
async function pluginTransformIndexHtml(ctx, html) {
if (ctx.options.htmlMode === "none") {
return html;
}
if (!canInjectIntoHtml(html, ctx.options.inject)) {
return html;
}
if (ctx.options.htmlMode === "inline" && hasSpriteDom(html, ctx.options.customDomId)) {
return html;
}
const result = await ctx.compiler.getResult();
if (ctx.options.htmlMode === "inline") {
return injectSpriteIntoHtml(html, renderSpriteElement(result), ctx.options.inject);
}
return injectMountScriptIntoHtml(html, renderInlineMountScript(result, ctx.options), ctx.options.inject);
}
function hasSpriteDom(html, domId) {
return html.includes(`id="${domId}"`) || html.includes(`id='${domId}'`);
}
function canInjectIntoHtml(html, inject) {
if (inject === "body-first") {
return /<body[^>]*>/i.test(html);
}
return /<\/body>/i.test(html);
}
function injectMountScriptIntoHtml(html, script, inject) {
const scriptHtml = `<script type="module">${escapeInlineScript(script)}<\/script>`;
if (inject === "body-first") {
return html.replace(/<body[^>]*>/i, (tag) => `${tag}${scriptHtml}`);
}
return html.replace(/<\/body>/i, `${scriptHtml}</body>`);
}
function injectSpriteIntoHtml(html, spriteHtml, inject) {
if (inject === "body-first") {
return html.replace(/<body[^>]*>/i, (tag) => `${tag}${spriteHtml}`);
}
return html.replace(/<\/body>/i, `${spriteHtml}</body>`);
}
function escapeInlineScript(script) {
return script.replace(/<\/script/gi, "<\\/script");
}
const VIRTUAL_MODULE_IDS = [VIRTUAL_REGISTER, VIRTUAL_REGISTER_DEPRECATED, VIRTUAL_IDS, VIRTUAL_NAMES_DEPRECATED, VIRTUAL_SPRITE].map((id) => `\0${id}`);
function invalidateVirtualModules(server) {
for (const id of VIRTUAL_MODULE_IDS) {
const mod = server.moduleGraph.getModuleById(id);
if (!mod) {
continue;
}
server.moduleGraph.invalidateModule(mod);
}
if (typeof server.moduleGraph.invalidateAll === "function") {
server.moduleGraph.invalidateAll();
}
}
async function applyIconChange(ctx, server, file) {
if (!ctx.compiler.isIconFile(file)) {
return false;
}
ctx.compiler.invalidate(file);
invalidateVirtualModules(server);
const result = await ctx.compiler.getResult();
server.ws.send({
type: "custom",
event: HMR_EVENT_SVG_ICONS_UPDATE,
data: { sprite: result.sprite }
});
return true;
}
function pluginConfigureServer(ctx, server) {
for (const dir of ctx.options.iconDirs) {
server.watcher.add(dir);
}
const onIconFileAddedOrRemoved = (file) => {
void applyIconChange(ctx, server, file);
};
server.watcher.on("add", onIconFileAddedOrRemoved);
server.watcher.on("unlink", onIconFileAddedOrRemoved);
}
async function pluginHandleHotUpdate(ctx, hotUpdateCtx) {
if (!await applyIconChange(ctx, hotUpdateCtx.server, hotUpdateCtx.file)) {
return;
}
return [];
}
const defaultOptions = {
symbolId: "icon-[dir]-[name]",
inject: "body-last",
htmlMode: "inline",
customDomId: SVG_DOM_ID,
failOnError: false,
bakerOptions: {}
};
function normalizeStrokeOverride(value) {
if (value === true) {
return "currentColor";
}
if (typeof value === "string") {
return value;
}
return false;
}
function resolveOptions(userOptions) {
return {
iconDirs: userOptions.iconDirs.map((dir) => resolveIconDir(dir, process.cwd())),
symbolId: userOptions.symbolId ?? defaultOptions.symbolId,
inject: userOptions.inject ?? defaultOptions.inject,
htmlMode: userOptions.htmlMode ?? defaultOptions.htmlMode,
customDomId: userOptions.customDomId ?? defaultOptions.customDomId,
strokeOverride: normalizeStrokeOverride(userOptions.strokeOverride),
failOnError: userOptions.failOnError ?? defaultOptions.failOnError,
bakerOptions: userOptions.bakerOptions ?? defaultOptions.bakerOptions
};
}
function resolveOptionsWithContext(userOptions, ctx) {
return {
...resolveOptions(userOptions),
iconDirs: userOptions.iconDirs.map((dir) => resolveIconDir(dir, ctx.root))
};
}
function resolveIconDir(iconDir, root) {
return vite.normalizePath(node_path.isAbsolute(iconDir) ? iconDir : node_path.resolve(root, iconDir));
}
function validateOptions(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 renderedSymbolId = renderSymbolIdTemplate(opt.symbolId, { dir: "dir", name: "name" });
if (!REGEXP_SYMBOL_ID.test(renderedSymbolId)) {
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.htmlMode && !["script", "inline", "none"].includes(opt.htmlMode)) {
throw new Error(ERR_HTML_MODE);
}
if (opt.customDomId && !REGEXP_DOM_ID.test(opt.customDomId)) {
throw new Error(ERR_CUSTOM_DOM_ID_SYNTAX);
}
}
function createSvgIconsPlugin(userOptions) {
validateOptions(userOptions);
let isBuild = false;
let ctx = null;
const getContext = () => {
if (!ctx) {
throw new Error("[vite-plugin-svg-icons-ng]: plugin context is not initialized. Vite configResolved hook must run before this hook.");
}
return ctx;
};
return {
name: "vite:svg-icons",
configResolved(resolvedConfig) {
isBuild = resolvedConfig.command === "build";
ctx = createPluginContext(userOptions, resolvedConfig.root, resolvedConfig.logger);
},
resolveId(id) {
return resolveVirtualId(id);
},
load: {
handler: async (id, loadOptions) => await pluginLoad(getContext(), id, isBuild, loadOptions)
},
transformIndexHtml: {
order: userOptions.htmlMode === "script" ? "pre" : null,
handler: async (html) => await pluginTransformIndexHtml(getContext(), html)
},
configureServer: (server) => pluginConfigureServer(getContext(), server),
handleHotUpdate: (hotUpdateCtx) => pluginHandleHotUpdate(getContext(), hotUpdateCtx)
};
}
function createPluginContext(userOptions, root, logger) {
const options = resolveOptionsWithContext(userOptions, { root });
const cache = createMemoryCache();
const compiler = createCompiler({ options, cache });
return { options, cache, compiler, logger };
}
exports.createSvgIconsPlugin = createSvgIconsPlugin;