import {normalizePath, PluginOption, ViteDevServer} from "vite"; import SVGSpriter from 'svg-sprite' import sprite, {Config} from 'svg-sprite' import glob from 'glob'; import path, {resolve} from "path"; import fs from "fs"; import crypto from "crypto"; import {EmittedAsset} from "rollup"; import getEtag from 'etag'; import cors from 'cors' import picomatch from 'picomatch' const SVG_ICONS_REGISTER_NAME = 'virtual:svg-icons-sprite-register'; const registerId = `/@id/${SVG_ICONS_REGISTER_NAME}`; interface SvgSpriteOptions { svgPath: string; shape?: sprite.Shape; svg?: sprite.Svg; variables?: any; base?: string; outDir?: string; } export function normalizePaths(root: string, path: string | string[]): string[] { return (Array.isArray(path) ? path : [path]).map(path => resolve(root, path)).map(normalizePath) } let config_default: Config = { dest: '.', // Main output directory log: undefined, // Logging verbosity (default: no logging) shape: { // SVG shape related options id: { // SVG shape ID related options separator: '--', // Separator for directory name traversal pseudo: '~' // File name separator for shape states (e.g. ':hover') }, dimension: {// Dimension related options maxWidth: 2000, // Max. shape width maxHeight: 2000, // Max. shape height precision: 2, // Floating point precision attributes: false, // Width and height attributes on embedded shapes }, spacing: { // Spacing related options padding: 0, // Padding around all shapes box: 'content' // Padding strategy (similar to CSS `box-sizing`) }, transform: [{ svgo: { plugins: [ {removeTitle: true}, {removeStyleElement: true}, {removeUselessStrokeAndFill: true}, // {removeAttrs: {attrs: '(stroke|fill)', preserveCurrentColor: true}} ] as any[] } }], // List of transformations / optimizations // meta: null, // Path to YAML file with meta / accessibility data // align: null, // Path to YAML file with extended alignment data // dest: null // Output directory for optimized intermediate SVG shapes }, svg: { // General options for created SVG files xmlDeclaration: true, // Add XML declaration to SVG sprite doctypeDeclaration: true, // Add DOCTYPE declaration to SVG sprite namespaceIDs: true, // Add namespace token to all IDs in SVG shapes namespaceClassnames: true, // Add namespace token to all CSS class names in SVG shapes dimensionAttributes: true, // Width and height attributes on the sprite }, variables: {}, mode: { // css: true, // Create a «css» sprite // view: true, // Create a «view» sprite // defs: true, // Create a «defs» sprite // stack: true // Create a «stack» sprite symbol: { dest: './out', prefix: 'svg-', sprite: 'spritemap.svg' } } } export default function (options: SvgSpriteOptions): PluginOption { let cacheFiles = ''; let isBuild = false; let cacheContent: any = {}; let viteConfig = {}; let outDir = ''; const debounce = function (func: any, timeout = 300) { let timer: NodeJS.Timer; return (...args: []) => { clearTimeout(timer); timer = setTimeout(() => { // @ts-ignore func.apply(this, args); }, timeout); }; } const returnJs = (content: string) => { return ` let app = document.getElementById("app"); let div = document.createElement('div'); div.style.cssText = 'display:none;'; div.classList.add('spritemapArea'); div.innerHTML = \`${content}\`; app.parentNode.appendChild(div)`; } async function generateMap(force: boolean = false): Promise { return new Promise(async (resolve) => { const icons = glob.sync(`${options.svgPath}**/*.svg`); const joined = icons.join('|'); if (cacheFiles === joined && !force) { resolve(cacheContent); } else { cacheFiles = joined; const spriter = new SVGSpriter(config_default); icons.forEach((item) => { const file = path.resolve(item); let norm = path.normalize(item).replace(path.normalize(options.svgPath), ''); if (norm.startsWith('\\')) norm = norm.replace('\\', ''); if (norm.startsWith('/')) norm = norm.replace('/', ''); spriter.add(file, norm, fs.readFileSync(file, 'utf-8')) }); await spriter.compile((_: any, result: any) => { const hash = crypto.createHash('sha256'); const contents = result['symbol']['sprite'].contents; hash.update(contents); let filename = 'spritemap.[hash].svg'.replace(/\[hash\]/g, hash.digest('hex').slice(0, 6)); cacheContent = { type: 'asset', name: filename, fileName: `${filename}`, source: contents, }; resolve(cacheContent); }); } }) } return { name: 'vite-svg-sprite-map', config(config) { if (options.variables) config_default.variables = options.variables; if (options.shape) config_default.shape = options.shape; if (options.svg) config_default.svg = options.svg; viteConfig = config; outDir = options.outDir ? options.outDir + '/' : ''; return {server: {watch: {disableGlobbing: false}}}; }, resolveId(id) { if ([SVG_ICONS_REGISTER_NAME].includes(id)) { return id; } return null; }, load: async function (id) { if (!isBuild) return null if (id === SVG_ICONS_REGISTER_NAME) { const contentSvg = await generateMap(); if (isBuild) { this.emitFile(contentSvg); } return 'export {}'; } return null; }, configResolved(resolvedConfig) { isBuild = resolvedConfig.command === 'build' }, configureServer({watcher, ws, middlewares}: ViteDevServer) { const files = normalizePaths(process.cwd(), `${options.svgPath}**/*`) const shouldReload = picomatch(files); const checkReload = async (path: string) => { if (shouldReload(path)) { await generateMap(true); setTimeout(() => ws.send({type: 'full-reload', path: !path ? '*' : path}), 100) } } console.log(files) watcher.on('add', checkReload); watcher.on('change', checkReload); watcher.on('unlink', checkReload); middlewares.use(cors({origin: '*'})) middlewares.use(async (req, res, next) => { const url = normalizePath(req.url!) if ([registerId].some((item) => url.endsWith(item))) { const contentSvg = await generateMap(); res.setHeader('Content-Type', 'application/javascript') res.setHeader('Cache-Control', 'no-cache') const content = returnJs(contentSvg.source); res.setHeader('Etag', getEtag(content, {weak: true})) res.statusCode = 200 res.end(content) } else { next() } }); }, async transform(source) { if (source.indexOf('spritemap.svg') > -1) { isBuild ? source = source.replace(/spritemap\.svg/g, `${outDir}${cacheContent.fileName}`) : source = source.replace(/spritemap\.svg/g, ''); return { code: source, map: null } } return {code: source, map: {mappings: ''}}; } }; }