UNPKG

vite-plugin-svg-icons-ng

Version:

Vite plugin for easily creating an SVG sprite and injecting it for use, with a brand-new version.

645 lines (627 loc) 21.9 kB
import { createBaker } from 'svg-icon-baker'; import { normalizePath } from 'vite'; import { glob } from 'tinyglobby'; import { readFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { isAbsolute, resolve } from '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 glob("**/*.svg", { cwd: iconDir, absolute: true, onlyFiles: true }); return files.map((file) => { const normalizedFile = normalizePath(file); const normalizedDir = 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/"${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 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 = normalizePath(filePath); return process.platform === "win32" ? normalized.toLowerCase() : normalized; } function createCompiler(ctx) { const baker = 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 normalizePath(isAbsolute(iconDir) ? iconDir : 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 }; } export { createSvgIconsPlugin };