UNPKG

astro-svgs

Version:

A compact solution for SVG sprites in Astro projects. It automates symbol ID management, supports hot reloading, and generates optimized SVG sprites with minimal setup—ideal for seamless SVG icon integration.

275 lines (268 loc) 9.46 kB
import fs from 'fs/promises'; import path from 'path'; import { createHash } from 'crypto'; import 'readline'; const md5 = (content) => createHash("md5").update(content).digest("hex").slice(0, 8); async function mkdir(path2) { const dirPath = path2 instanceof URL ? path2.pathname : path2; try { await fs.mkdir(dirPath, { recursive: true }); } catch (error) { console.error(`Error creating directory at ${dirPath}:`, error); } } async function getSvgFiles(dir) { const entries = await fs.readdir(dir, { withFileTypes: true }); const files = await Promise.all( entries.map((entry) => { const res = path.resolve(dir, entry.name); return entry.isDirectory() ? getSvgFiles(res) : res; }) ); return Array.prototype.concat(...files).filter((file) => file.endsWith(".svg")); } function minify(content, level) { content = content.replace(/\s{2,}/g, " "); if (level === "low") { return content; } content = content.replace(/\n/g, ""); if (level === "medium") { return content; } content = content.replace(/>\s+</g, "><").replace(/<!--.*?-->/g, "").replace(/"/g, "'"); return content; } function format(content) { const indent = (depth) => " ".repeat(depth); const formatTag = (tag, depth) => { return `${indent(depth)}${tag} `; }; const formatBody = (content2, depth) => { return content2.replace( /<([a-zA-Z0-9]+)([^>]*)>(.*?)<\/\1>/gs, (_, tagName, attrs, body) => { const formattedTag = formatTag(`<${tagName}${attrs}>`, depth); const formattedBody = formatBody(body, depth + 1); return formattedTag + formattedBody + formatTag(`</${tagName}>`, depth); } ).replace(/<([a-zA-Z0-9]+)([^>]*)\/>/g, (_, tagName, attrs) => { return formatTag(`<${tagName}${attrs}>`, depth) + formatTag(`</${tagName}>`, depth); }); }; const cleanedBody = content.replace(/\s+/g, " ").trim(); return formatBody(cleanedBody, 0); } const defaults = { input: "src/svgs", compress: "high" }; async function compose({ input, compress }) { let data = "", hash = "", symbolIds = []; try { let err; const inputs = Array.isArray(input) ? input : [input]; const svgFiles = []; for (const inputDir of inputs) { if (!inputDir || !await fs.stat(inputDir).catch(() => false)) { if (inputDir === "src/svgs") { await mkdir(inputDir); } else { err = new Error1(`Invalid directory`); err.hint = `Ensure \`${inputDir}\` exists and is accessible.`; throw err; } } const dirFiles = await getSvgFiles(inputDir); svgFiles.push(...dirFiles.map((filePath) => ({ inputDir, filePath }))); } const defsSet = /* @__PURE__ */ new Set(); const symbolBodySet = /* @__PURE__ */ new Set(); const symbols = (await Promise.all( svgFiles.map(async ({ inputDir, filePath }) => { let body = await fs.readFile(filePath, "utf8"); if (!body.includes("<svg")) { return ""; } const defsMatch = body.match(/<defs>([\s\S]*?)<\/defs>/); if (defsMatch) { defsSet.add(defsMatch[1]); } const bodyHash = md5(body); if (symbolBodySet.has(bodyHash)) { return ""; } symbolBodySet.add(bodyHash); const fileName = path.basename(filePath, ".svg"); let symbolId; if (bodyHash) { symbolId = fileName; } else { const relativePath = path.relative(inputDir, filePath); symbolId = relativePath.replace(/\.svg$/, "").replace(/[^a-zA-Z0-9_-]/g, "_"); } const viewBoxMatch = body.match(/viewBox="([^"]+)"/); let viewBox = ""; if (viewBoxMatch) { viewBox = `viewBox="${viewBoxMatch[1]}"`; } else { const widthMatch = body.match(/width="([^"]+)"/); const heightMatch = body.match(/height="([^"]+)"/); if (widthMatch && heightMatch) { const width = parseFloat(widthMatch[1]); const height = parseFloat(heightMatch[1]); if (!isNaN(width) && !isNaN(height)) { viewBox = `viewBox="0 0 ${width} ${height}"`; } } } body = body.replace(/<\?xml[^>]*\?>\s*/g, "").replace(/<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "").replace(/\s*id="[^"]*"/g, "").replace(/<style[^>]*>[\s\S]*?<\/style>/g, "").replace(/<defs>[\s\S]*?<\/defs>/g, "").replace(/<!--[\s\S]*?-->/g, ""); symbolIds.push(symbolId); return `<symbol id="${symbolId}" ${viewBox}>${body}</symbol>`; }) )).filter(Boolean).join("\n"); let defs = defsSet.size > 0 ? `<defs>${Array.from(defsSet).join("")}</defs>` : ""; defs = defs.replace(/<!--[\s\S]*?-->/g, ""); data = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">${defs}${symbols}</svg>`; data = compress !== "beautify" ? minify(data, compress) : format(data); hash = md5(data); } catch (err) { if (err instanceof Error1) { throw err; } } return { data, hash, symbolIds }; } async function virtual(opts) { let filename = "types.d.ts"; const { symbolIds } = await compose(opts); const SymbolId = symbolIds.length > 0 ? symbolIds.map((id) => ` | '${id}'`).join("\n ") : " | null"; const content = `declare module 'virtual:${name}' { export type SymbolId = ${SymbolId}; export const file: string; }`.trim(); return { filename, content }; } async function genTypeFile(file, opts, cfg) { const inputs = Array.isArray(opts.input) ? opts.input : [opts.input]; let typeDir = `.astro/integrations/${name}`; if (!inputs.some((input) => file.includes(input)) || !file.endsWith(".svg")) { return false; } const { filename, content } = await virtual(opts); const typeFile = new URL(`${typeDir}/${filename}`, cfg.root); try { await mkdir(new URL(typeDir, cfg.root)); await fs.writeFile(typeFile, content); } catch (err) { console.error(err); } return true; } function create(options, config) { const virtualModuleId = `virtual:${name}`; const resolvedVirtualModuleId = "\0" + virtualModuleId; const base = "/@svgs/sprite.svg"; let fileId, data, hash, filePath; const inputs = Array.isArray(options.input) ? options.input : [options.input]; let typeFileUpdated; return { name, async configureServer(server) { server.middlewares.use(async (req, res, next) => { ({ data, hash } = await compose(options)); if (req.url?.startsWith(base)) { res.setHeader("Content-Type", "image/svg+xml"); res.setHeader("Cache-Control", "no-cache"); res.end(data); } else { next(); } }); server.watcher.on("unlink", async (file) => { typeFileUpdated = await genTypeFile(file, options, config); }); server.watcher.on("add", async (file) => { typeFileUpdated = await genTypeFile(file, options, config); }); }, async buildStart() { ({ data, hash } = await compose(options)); if (!this.meta.watchMode) { fileId = this.emitFile({ type: "asset", fileName: `${config.build.assets}/sprite.${hash}.svg`, source: data }); const assetsPrefix = typeof config.build.assetsPrefix === "object" ? config.build.assetsPrefix.svg ?? config.build.assetsPrefix.fallback : config.build.assetsPrefix ?? ""; filePath = `${assetsPrefix}/${this.getFileName(fileId)}`; } }, resolveId(id) { if (id === virtualModuleId) { return resolvedVirtualModuleId; } }, async load(id) { if (id === resolvedVirtualModuleId) { filePath = filePath ?? `${base}?v=${hash}`; return `export const file = "${filePath}";`; } }, async handleHotUpdate({ file, server }) { const includes = inputs.some((input) => file.includes(input)) && file.endsWith(".svg"); if (includes || typeFileUpdated) { const { data: Data, hash: Hash } = await compose(options); if (Hash !== hash) { hash = Hash; data = Data; filePath = `${base}?v=${hash}`; const mod = server.moduleGraph.getModuleById(resolvedVirtualModuleId); if (mod) { server.moduleGraph.invalidateModule(mod); } server.ws.send({ type: "full-reload" }); } return []; } } }; } var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => { __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); return value; }; const name = "astro-svgs"; class Error1 extends Error { constructor(message) { super(message); __publicField(this, "hint"); } } function svgs(options) { let opts = { ...defaults, ...options }; return { name, hooks: { "astro:config:setup": async ({ config, command: cmd, updateConfig }) => { opts.compress = cmd === "dev" ? "beautify" : opts?.compress; updateConfig({ vite: { plugins: [create(opts, config)] } }); }, "astro:config:done": async ({ injectTypes }) => { injectTypes(await virtual(opts)); } } }; } export { Error1, svgs as default, name };