UNPKG

vite-plugin-svg-spritemap

Version:
361 lines (342 loc) 11.8 kB
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 };