UNPKG

vite-plugin-svg-sfc

Version:

Convert SVGs to Vue single file component(SFC), support <style> tag

338 lines (337 loc) 10.9 kB
import { readFileSync } from "fs"; import { optimize } from "svgo"; function preItem(fn) { return (_, __, info) => ({ element: { enter: node => fn(node, info) } }); } /** * Replace attributes with reactive value, used when `responsive` is true. * * If you prefer to control the size with CSS, * you can use modifySVGAttrs plugin to remove `width` & `height`. */ export const responsiveSVGAttrs = { name: "responsiveSVGAttrs", fn: preItem(({ name, attributes }) => { if (name === "svg") { const { fill, stroke } = attributes; if (stroke && stroke !== "none") { attributes.stroke = "currentColor"; } if (fill !== "none") { attributes.fill = "currentColor"; } attributes.width = attributes.height = "1em"; } }), }; /** * Modify attributes of the outermost <svg> element, used by `svgProps` option. * * SVGO has a addAttributesToSVGElement plugin similar to this, * but it cannot override existing attributes. * * @param params Attributes to add to <svg>, or a function to modify attributes. */ export function modifySVGAttrs(params) { const fn = typeof params === "function" ? params : (attrs) => Object.assign(attrs, params); return { name: "modifySVGAttrs", fn: preItem((node, info) => { const { name, attributes } = node; const { path, multipassCount } = info; if (name === "svg") { fn(attributes, path, multipassCount); } }), }; } function dynamicId(idMap) { return { name: "dynamicId", fn: preItem(node => { const { name, attributes } = node; if (name === "svg") { return; } const id = attributes.id; if (id) { const reference = "_SVG_ID_" + idMap.size; idMap.set(id, reference); delete attributes.id; attributes[":id"] = reference; } }), }; } function dynamicUse(idMap) { return { name: "dynamicUse", fn: preItem(node => { const { attributes } = node; const xhref = attributes["xlink:href"]; const href = attributes["href"]; if (href?.charCodeAt(0) === 35 /* # */) { const ref = idMap.get(href.slice(1)); if (ref) { delete attributes.href; attributes[":href"] = "'#'+" + ref; } } if (xhref?.charCodeAt(0) === 35 /* # */) { const ref = idMap.get(xhref.slice(1)); if (ref) { delete attributes["xlink:href"]; attributes[":xlink:href"] = "'#'+" + ref; } } for (const [k, v] of Object.entries(attributes)) { const match = /url\(#([^)]+)\)/i.exec(v); if (match) { const ref = idMap.get(match[1]); if (ref) { delete attributes[k]; attributes[":" + k] = "`url(#${" + ref + "})`"; } } } }), }; } /** * Remove all <style> elements and collect their content。 * * @param styles store <style>'s content. */ export function extractCSS(styles) { function enter(node, parent) { if (node.name !== "style") { return; } for (const child of node.children) { styles.push(child.value); } parent.children = parent.children .filter((c) => c !== node); } return { name: "extractCSS", fn: () => ({ element: { enter } }), }; } // Ensure the SVG component has single root node. const essential = [ "removeComments", "removeDoctype", "removeXMLProcInst", ]; export class SVGSFCConvertor { plugins = []; /* * It's ok to use shared state between each module, * because SVGO runs synchronously, just reset them before optimize. */ styles = []; idMap = new Map(); svgo; constructor(options = {}) { const { svgo = {} } = options; this.svgo = svgo; // Determine which SVGO plugins to use. if (svgo === false) { // SVGO optimization disabled. } else if (svgo.plugins) { this.resolve(svgo.plugins); } else { this.applyPresets(options); } } resolve(src) { const { plugins, styles } = this; for (const plugin of src) { let name; let params; if (typeof plugin === "string") { name = plugin; } else { name = plugin.name; params = plugin.params; } switch (name) { case "extractCSS": plugins.push(extractCSS(styles)); break; case "responsiveSVGAttrs": plugins.push(responsiveSVGAttrs); break; case "modifySVGAttrs": plugins.push(modifySVGAttrs(params)); break; default: plugins.push(plugin); } } } applyPresets(options) { const { plugins, styles, idMap } = this; const { uniqueId = false, minify, svgProps, extractStyles = true, responsive = true, } = options; const overrides = { // Don't remove IDs, it may be referenced from outside. cleanupIds: false, }; if (responsive) { plugins.push(responsiveSVGAttrs); } if (uniqueId) { plugins.push(dynamicId(idMap)); plugins.push(dynamicUse(idMap)); } if (minify) { plugins.push("removeTitle"); plugins.push({ name: "preset-default", params: { overrides }, }); } else { plugins.push(...essential); } plugins.push(modifySVGAttrs(attrs => { // https://stackoverflow.com/a/34249810 delete attrs.xmlns; delete attrs.version; // Deprecated & removed from the standards. delete attrs["xml:space"]; })); if (svgProps) { plugins.push(modifySVGAttrs(svgProps)); } if (extractStyles) { overrides.inlineStyles = false; plugins.push(extractCSS(styles)); } if (minify) { // Ensure sortAttrs can handle new attributes added by modifySVGAttrs. overrides.sortAttrs = false; plugins.push("sortAttrs"); // Move it to after extractCSS since remove <style> may leave empty <defs>. overrides.removeUselessDefs = false; plugins.push("removeUselessDefs"); } } /** * Convert the SVG XML to Vue SFC code. * * @param svg the SVG code. * @param path The path of the SVG file, used by plugins. */ convert(svg, path) { const { styles, idMap, plugins, svgo } = this; idMap.clear(); styles.length = 0; if (svgo) { svg = optimize(svg, { ...svgo, path, plugins }).data; } svg = `<template>${svg}</template>`; if (idMap.size !== 0) { svg += "<script setup>\n" + "import { useId } from 'vue';"; for (const ref of idMap.values()) { svg += `\nconst ${ref} = useId()`; } svg += "\n</script>"; } if (styles.length === 0) { return svg; } else { const css = styles.join(""); return `${svg}<style scoped>${css}</style>`; } } } function parseRequest(id) { const [path, query] = id.split("?", 2); return [path, new URLSearchParams(query), query]; } /** * Convert SVG to Vue SFC, you need another plugin to process the .vue file。 */ export default function (options = {}) { const { mark = "sfc" } = options; let svg2sfc; return { name: "vite-plugin-svg-sfc", /** * This plugin must run before vite:asset and other plugins that process .vue files. */ enforce: "pre", configResolved({ mode }) { const minify = mode === "production"; svg2sfc = new SVGSFCConvertor({ minify, ...options }); }, /** * For Rollup compatibility. This hook is called after configResolved(). */ buildStart() { svg2sfc ??= new SVGSFCConvertor(options); }, /** * When a svg file changed, we look for corresponding SFC modules, * if present, add them to affected module list. */ handleHotUpdate(ctx) { const { file, server, modules } = ctx; if (file.endsWith(".svg")) { const graph = server.moduleGraph; const id = file + ".vue"; const vMods = graph.getModulesByFile(id); if (vMods) { return [...modules, ...vMods]; } } }, /** * Resolve "*.svg?<mark>" import to a virtual .vue file. * e.g. "./image.svg?sfc" -> "/path/to/image.svg.vue?sfc" * * About the suffix: * The `.vue` extension makes other plugins treat it as a vue file. * Keep the `?sfc` query to prevent vite:dep-scan to process it. */ async resolveId(id, importer) { if (id.startsWith("/@")) { return null; } const [path, params, query] = parseRequest(id); if (path.endsWith(".svg") && params.has(mark)) { // Original import (*.svg?sfc) id = path; } else { // virtual .vue file (*.svg.vue?sfc) // or SFC submodule (*.svg.vue?vue). if (!path.endsWith(".svg.vue")) { return null; } id = path.slice(0, -4); } const r = await this.resolve(id, importer); if (!r) { throw new Error("Cannot resolve file: " + id); } return query ? `${r.id}.vue?${query}` : `${r.id}.vue`; }, load(id) { const [vPath, params] = parseRequest(id); if (vPath.endsWith(".svg.vue") && params.has(mark)) { const path = vPath.slice(0, -4); return svg2sfc.convert(readFileSync(path, "utf8"), path); } }, }; }