UNPKG

nuxt-svg-sprite-icon

Version:

A powerful SVG sprite module for Nuxt 3 & 4 that automatically generates SVG sprites from your assets and provides an easy-to-use component for displaying icons.

477 lines (471 loc) 17.1 kB
'use strict'; const kit = require('@nuxt/kit'); const path = require('path'); const promises = require('fs/promises'); const fs = require('fs'); var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null; const SVG_TAG_REGEX = /<svg[^>]*>|<\/svg>/g; const VIEWBOX_REGEX = /viewBox="([^"]*)"/; const WIDTH_REGEX = /width="([^"]*)"/; const HEIGHT_REGEX = /height="([^"]*)"/; const STYLE_REGEX = /\s*style="[^"]*"/g; const SIZE_ATTRS_REGEX = /\s*(width|height)="[^"]*"/g; const DEFS_REGEX = /<defs[^>]*>[\s\S]*?<\/defs>/gi; const STYLE_TAG_REGEX = /<style[^>]*>[\s\S]*?<\/style>/gi; function processCompatibleSvg(svgContent, symbolId) { if (!svgContent || typeof svgContent !== "string") { throw new Error("Invalid SVG content provided"); } let processedContent = svgContent; processedContent = removeSvgRootSizeAttributes(processedContent); const hasStyleTags = STYLE_TAG_REGEX.test(processedContent); if (hasStyleTags) { processedContent = processDefsAndStyles(processedContent); } processedContent = processIdAttributes(processedContent, symbolId); return processedContent.trim(); } function processDefsAndStyles(svgContent, symbolId) { let processedContent = svgContent; const extractedStyles = {}; processedContent = processedContent.replace(STYLE_TAG_REGEX, (match) => { const cssText = match.replace(/<\/?style[^>]*>/g, "").trim(); const rules = parseCssRules(cssText); Object.assign(extractedStyles, rules); return ""; }); processedContent = processedContent.replace(DEFS_REGEX, (match) => { if (/<style[^>]*>[\s\S]*?<\/style>/i.test(match) && !/<(?:clipPath|linearGradient|radialGradient|pattern|marker|filter)/i.test(match)) { return ""; } return match.replace(STYLE_TAG_REGEX, ""); }); if (Object.keys(extractedStyles).length > 0) { processedContent = applyCssRulesToElements(processedContent, extractedStyles); } return processedContent; } function parseCssRules(cssText) { const rules = {}; const rulePattern = /\.([^{]+)\{([^}]+)\}/g; let match; while ((match = rulePattern.exec(cssText)) !== null) { const className = match[1].trim(); const declarations = match[2].trim(); rules[className] = declarations; } return rules; } function applyCssRulesToElements(svgContent, cssRules) { let processedContent = svgContent; for (const [className, declarations] of Object.entries(cssRules)) { const elementRegex = new RegExp(`<([^>\\s]+)[^>]*class=["'][^"']*\\b${escapeRegex(className)}\\b[^"']*["'][^>]*/?\\s*>`, "g"); processedContent = processedContent.replace(elementRegex, (match) => { const existingStyleMatch = match.match(/style=["']([^"']*)["']/); if (existingStyleMatch) { const currentStyle = existingStyleMatch[1]; const newStyle = `${currentStyle}; ${declarations}`; return match.replace(/style=["'][^"']*["']/, `style="${newStyle}"`); } else { if (match.includes("/>")) { return match.replace(/\s*\/>/, ` style="${declarations}"/>`); } else { return match.replace(/\s*>$/, ` style="${declarations}">`); } } }); } for (const className of Object.keys(cssRules)) { processedContent = processedContent.replace( new RegExp(`\\s*class=["']\\s*${escapeRegex(className)}\\s*["']`, "g"), "" ); processedContent = processedContent.replace( new RegExp(`(class=["'][^"']*)\\b${escapeRegex(className)}\\b\\s*([^"']*["'])`, "g"), "$1$2" ); processedContent = processedContent.replace(/\s*class=["']\s*["']/g, ""); } return processedContent; } function processIdAttributes(svgContent, symbolId) { let processedContent = svgContent; const functionalIds = /* @__PURE__ */ new Set(); const urlReferences = processedContent.match(/url\(#([^)]+)\)/g); if (urlReferences) { urlReferences.forEach((ref) => { const id = ref.match(/url\(#([^)]+)\)/)?.[1]; if (id) functionalIds.add(id); }); } for (const id of functionalIds) { const newId = `${symbolId}-${id}`; processedContent = processedContent.replace( new RegExp(`url\\(#${escapeRegex(id)}\\)`, "g"), `url(#${newId})` ); } processedContent = processedContent.replace(/\s+id="([^"]+)"/g, (match, id) => { if (functionalIds.has(id)) { const newId = `${symbolId}-${id}`; return ` id="${newId}"`; } return ""; }); return processedContent; } function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function processSvg(svgContent) { if (!svgContent || typeof svgContent !== "string") { throw new Error("Invalid SVG content provided"); } return svgContent.replace(SIZE_ATTRS_REGEX, "").replace(STYLE_REGEX, "").trim(); } function removeSvgRootSizeAttributes(svgContent) { let result = svgContent; result = result.replace(/(<svg[^>]*)\s+width="[^"]*"/g, "$1"); result = result.replace(/(<svg[^>]*)\s+height="[^"]*"/g, "$1"); return result; } function svgToSymbol(svgContent, id) { if (!svgContent || !id) { throw new Error("SVG content and ID are required"); } const viewBox = extractViewBox(svgContent); const processedContent = processCompatibleSvg(svgContent, id); const content = processedContent.replace(SVG_TAG_REGEX, "").trim(); return `<symbol id="${escapeHtml(id)}" viewBox="${viewBox}">${content}</symbol>`; } function extractViewBox(svgContent) { const viewBoxMatch = svgContent.match(VIEWBOX_REGEX); if (viewBoxMatch?.[1]) { return viewBoxMatch[1]; } const widthMatch = svgContent.match(WIDTH_REGEX); const heightMatch = svgContent.match(HEIGHT_REGEX); if (widthMatch?.[1] && heightMatch?.[1]) { const width = parseFloat(widthMatch[1]); const height = parseFloat(heightMatch[1]); if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) { return `0 0 ${width} ${height}`; } } return "0 0 24 24"; } function escapeHtml(text) { return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;"); } const BATCH_SIZE = 50; async function generateSprites(inputPath, outputPath, options) { try { if (!inputPath || !outputPath) { throw new Error("Input and output paths are required"); } await ensureDirectory(outputPath); const svgFiles = await getSvgFiles(inputPath); if (svgFiles.length === 0) { console.warn(`No SVG files found in ${inputPath}`); return { spriteMap: {}, spriteContent: {} }; } const fileGroups = groupFilesBySprite(svgFiles, inputPath, options); const results = await processSpriteGroups(fileGroups, outputPath, options); return results; } catch (error) { console.error("Error generating sprites:", error); throw error; } } async function ensureDirectory(dirPath) { if (!fs.existsSync(dirPath)) { await promises.mkdir(dirPath, { recursive: true }); } } async function getSvgFiles(dir) { const files = []; if (!fs.existsSync(dir)) { return files; } try { const dirStat = await promises.stat(dir); if (!dirStat.isDirectory()) { return files; } await collectSvgFilesRecursive(dir, files); return files; } catch (error) { console.warn(`Error reading directory ${dir}:`, error); return files; } } async function collectSvgFilesRecursive(dir, files) { try { const entries = await promises.readdir(dir, { withFileTypes: true }); const directories = []; for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { directories.push(fullPath); } else if (entry.isFile() && entry.name.endsWith(".svg")) { files.push(fullPath); } } await Promise.all( directories.map((subDir) => collectSvgFilesRecursive(subDir, files)) ); } catch (error) { console.warn(`Error reading directory ${dir}:`, error); } } function groupFilesBySprite(svgFiles, inputPath, options) { const groups = /* @__PURE__ */ new Map(); for (const filePath of svgFiles) { const relativePath = path.relative(inputPath, filePath); const dir = path.dirname(relativePath); const name = path.basename(relativePath, ".svg"); const spriteName = dir === "." ? options.defaultSprite : dir.replace(/[/\\]/g, "-"); const symbolName = dir === "." ? name : `${dir.replace(/[/\\]/g, "-")}-${name}`; const fileInfo = { filePath, relativePath, spriteName, symbolName }; if (!groups.has(spriteName)) { groups.set(spriteName, []); } groups.get(spriteName).push(fileInfo); } return groups; } async function processSpriteGroups(fileGroups, outputPath, options) { const spriteMap = {}; const spriteContent = {}; const spritePromises = Array.from(fileGroups.entries()).map( ([spriteName, files]) => processSingleSprite(spriteName, files, outputPath, options) ); const results = await Promise.all(spritePromises); for (const result of results) { if (result) { spriteMap[result.spriteName] = { path: result.spritePath, symbols: result.symbols }; spriteContent[result.spriteName] = result.content; } } return { spriteMap, spriteContent }; } async function processSingleSprite(spriteName, files, outputPath, options) { try { const symbols = []; const symbolElements = []; for (let i = 0; i < files.length; i += BATCH_SIZE) { const batch = files.slice(i, i + BATCH_SIZE); const batchPromises = batch.map(async (fileInfo) => { try { const svgContent = await promises.readFile(fileInfo.filePath, "utf-8"); const processedSvg = options.optimize ? processSvg(svgContent) : svgContent; const symbolElement = svgToSymbol(processedSvg, fileInfo.symbolName); return { symbolName: fileInfo.symbolName, symbolElement }; } catch (error) { console.warn(`Error processing SVG file ${fileInfo.filePath}:`, error); return null; } }); const batchResults = await Promise.all(batchPromises); for (const result of batchResults) { if (result) { symbols.push(result.symbolName); symbolElements.push(result.symbolElement); } } } if (symbolElements.length === 0) { console.warn(`No valid SVG files found for sprite: ${spriteName}`); return null; } const spriteContent = generateSpriteContent(symbolElements); const spritePath = path.join(outputPath, `${spriteName}.svg`); await promises.writeFile(spritePath, spriteContent, "utf-8"); return { spriteName, spritePath, symbols, content: spriteContent }; } catch (error) { console.error(`Error processing sprite ${spriteName}:`, error); return null; } } function generateSpriteContent(symbolElements) { return `<svg xmlns="http://www.w3.org/2000/svg" style="display: none;"> ${symbolElements.join("\n")} </svg>`; } const module$1 = kit.defineNuxtModule({ meta: { name: "nuxt-svg-sprite-icon", configKey: "svgSprite", compatibility: { nuxt: "^3.0.0 || ^4.0.0" } }, defaults: { input: "~/assets/svg", output: "~/assets/sprite/gen", defaultSprite: "icons", elementClass: "svg-icon", optimize: false }, async setup(options, nuxt) { const { resolve } = kit.createResolver((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('module.cjs', document.baseURI).href))); const logger = kit.useLogger("nuxt-svg-sprite-icon"); const inputPath = resolveInputPath(options.input, nuxt); const outputPath = resolveOutputPath(options.output, nuxt); logger.info(`Input path resolved to: ${inputPath}`); logger.info(`Output path resolved to: ${outputPath}`); logger.info(`Nuxt compatibility version: ${nuxt.options.future?.compatibilityVersion || "default"}`); validateOptions(options, logger); let spriteContent = {}; const generateSpritesWithErrorHandling = async () => { try { logger.info(`Attempting to generate sprites from: ${inputPath}`); const result = await generateSprites(inputPath, outputPath, options); spriteContent = result.spriteContent; if (Object.keys(spriteContent).length === 0) { logger.warn(`No SVG files found in: ${inputPath}`); logger.warn("Please check if the path exists and contains SVG files"); } else { logger.success(`Generated ${Object.keys(spriteContent).length} sprite(s)`); } return result; } catch (error) { logger.error("Failed to generate sprites:", error); logger.error(`Input path: ${inputPath}`); logger.error(`Output path: ${outputPath}`); spriteContent = {}; return { spriteMap: {}, spriteContent: {} }; } }; await generateSpritesWithErrorHandling(); const svgTemplate = kit.addTemplate({ filename: "svg-sprite-data.mjs", write: true, getContents: () => generateSpriteModule(spriteContent, options) }); kit.addTemplate({ filename: "svg-sprite-data.d.ts", write: true, getContents: () => generateTypeDeclaration() }); kit.addComponent({ name: "SvgIcon", filePath: resolve("./runtime/components/SvgIcon.vue"), export: "default", chunkName: "components/svg-icon" }); kit.addPlugin({ src: resolve("./runtime/plugins/svg-sprite.client"), mode: "client" }); nuxt.options.alias["#svg-sprite-data"] = svgTemplate.dst; nuxt.hook("build:before", async () => { if (!nuxt.options.dev) { await generateSpritesWithErrorHandling(); } }); } }); function resolveInputPath(input, nuxt) { if (input.startsWith("./") || input.startsWith("../")) { return path.join(nuxt.options.rootDir, input); } if (nuxt.options.alias[input]) { return nuxt.options.alias[input]; } if (input.startsWith("~/")) { const relativePath = input.replace("~/", ""); const isNuxt42 = nuxt.options.future?.compatibilityVersion === 4 || nuxt.options.srcDir && nuxt.options.srcDir.includes("/app"); if (isNuxt42) { return path.join(nuxt.options.rootDir, "app", relativePath); } else { return path.join(nuxt.options.srcDir, relativePath); } } const isNuxt4 = nuxt.options.future?.compatibilityVersion === 4 || nuxt.options.srcDir && nuxt.options.srcDir.includes("/app"); if (isNuxt4) { return path.join(nuxt.options.rootDir, "app", input); } else { return path.join(nuxt.options.srcDir, input); } } function resolveOutputPath(output, nuxt) { if (output.startsWith("./") || output.startsWith("../")) { return path.join(nuxt.options.rootDir, output); } if (nuxt.options.alias[output]) { return nuxt.options.alias[output]; } if (output.startsWith("~/")) { const relativePath = output.replace("~/", ""); const isNuxt42 = nuxt.options.future?.compatibilityVersion === 4 || nuxt.options.srcDir && nuxt.options.srcDir.includes("/app"); if (isNuxt42) { return path.join(nuxt.options.rootDir, "app", relativePath); } else { return path.join(nuxt.options.srcDir, relativePath); } } const isNuxt4 = nuxt.options.future?.compatibilityVersion === 4 || nuxt.options.srcDir && nuxt.options.srcDir.includes("/app"); if (isNuxt4) { return path.join(nuxt.options.rootDir, "app", output); } else { return path.join(nuxt.options.srcDir, output); } } function validateOptions(options, logger) { if (!options.input) { logger.warn("Input path is not specified, using default: ~/assets/svg"); } if (!options.defaultSprite) { logger.warn("Default sprite name is not specified, using default: icons"); } if (options.optimize && !options.svgoOptions) { logger.info("SVG optimization enabled without custom options, using defaults"); } } function generateSpriteModule(spriteContent, options) { const contentEntries = Object.entries(spriteContent); if (contentEntries.length === 0) { return `export const spriteContent = {}; export const options = ${JSON.stringify(options, null, 2)};`; } const contentLines = contentEntries.map( ([key, content]) => ` "${escapeKey(key)}": \`${escapeSvgContent(content)}\`` ); return [ "export const spriteContent = {", contentLines.join(",\n"), "};", "", `export const options = ${JSON.stringify(options, null, 2)};` ].join("\n"); } function escapeSvgContent(svg) { return svg.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$/g, "\\$"); } function escapeKey(key) { return key.replace(/"/g, '\\"'); } function generateTypeDeclaration() { return `declare module '#svg-sprite-data' { export const spriteContent: Record<string, string>; export const options: import('./types').ModuleOptions; }`; } module.exports = module$1;