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
JavaScript
;
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
}
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;