vite-plugin-icons-spritesheet
Version:
Vite plugin that generates a spritesheet and types out of your icons folder.
250 lines (249 loc) • 8.25 kB
JavaScript
// src/index.ts
import { promises as fs } from "node:fs";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { stderr } from "node:process";
import { Readable } from "node:stream";
import chalk from "chalk";
import { glob } from "glob";
import { parse } from "node-html-parser";
import { exec } from "tinyexec";
import { normalizePath } from "vite";
var generateIcons = async ({
withTypes = false,
inputDir,
outputDir,
typesOutputFile = `${outputDir}/types.ts`,
cwd,
formatter,
fileName = "sprite.svg",
iconNameTransformer
}) => {
const cwdToUse = cwd ?? process.cwd();
const inputDirRelative = path.relative(cwdToUse, inputDir);
const outputDirRelative = path.relative(cwdToUse, outputDir);
const files = glob.sync("**/*.svg", {
cwd: inputDir
}).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
if (files.length === 0) {
console.log(`\u26A0\uFE0F No SVG files found in ${chalk.red(inputDirRelative)}`);
return;
}
await mkdir(outputDirRelative, { recursive: true });
await generateSvgSprite({
files,
inputDir,
outputPath: path.join(outputDir, fileName),
outputDirRelative,
iconNameTransformer,
formatter
});
if (withTypes) {
const typesOutputDir = path.dirname(typesOutputFile);
const typesFile = path.basename(typesOutputFile);
const typesOutputDirRelative = path.relative(cwdToUse, typesOutputDir);
await mkdir(typesOutputDirRelative, { recursive: true });
await generateTypes({
names: files.map((file) => transformIconName(file, iconNameTransformer ?? fileNameToCamelCase)),
outputPath: path.join(typesOutputDir, typesFile),
formatter
});
}
};
var transformIconName = (fileName, transformer) => {
const iconName = fileName.replace(/\.svg$/, "");
return transformer(iconName);
};
function fileNameToCamelCase(fileName) {
const words = fileName.split("-");
const capitalizedWords = words.map((word) => word.charAt(0).toUpperCase() + word.slice(1));
return capitalizedWords.join("");
}
async function generateSvgSprite({
files,
inputDir,
outputPath,
outputDirRelative,
iconNameTransformer,
formatter
}) {
const symbols = await Promise.all(
files.map(async (file) => {
const fileName = transformIconName(file, iconNameTransformer ?? fileNameToCamelCase);
const input = await fs.readFile(path.join(inputDir, file), "utf8");
const root = parse(input);
const svg = root.querySelector("svg");
if (!svg) {
console.log(`\u26A0\uFE0F No SVG tag found in ${file}`);
return;
}
svg.tagName = "symbol";
svg.setAttribute("id", fileName);
svg.removeAttribute("xmlns");
svg.removeAttribute("xmlns:xlink");
svg.removeAttribute("version");
svg.removeAttribute("width");
svg.removeAttribute("height");
return svg.toString().trim();
})
);
const output = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="0" height="0">',
"<defs>",
// for semantics: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs
...symbols.filter(Boolean),
"</defs>",
"</svg>"
].join("\n");
const formattedOutput = await lintFileContent(output, formatter, "svg");
return writeIfChanged(
outputPath,
formattedOutput,
`\u{1F5BC}\uFE0F Generated SVG spritesheet in ${chalk.green(outputDirRelative)}`
);
}
async function lintFileContent(fileContent, formatter, typeOfFile) {
if (!formatter) {
return fileContent;
}
if (formatter === "biome" && typeOfFile === "svg") {
return fileContent;
}
const prettierOptions = ["--parser", typeOfFile === "ts" ? "typescript" : "html"];
const biomeOptions = ["format", "--stdin-file-path", `file.${typeOfFile}`];
const oxfmtOptions = ["--stdin-filepath", `file.${typeOfFile === "ts" ? "ts" : "html"}`];
const optionsMap = {
prettier: prettierOptions,
biome: biomeOptions,
oxfmt: oxfmtOptions
};
const options = optionsMap[formatter];
const stdinStream = new Readable();
stdinStream.push(fileContent);
stdinStream.push(null);
const { process: process2 } = exec(formatter, options, {});
if (!process2?.stdin) {
return fileContent;
}
stdinStream.pipe(process2.stdin);
process2.stderr?.pipe(stderr);
let formattedContent = "";
process2.stdout?.on("data", (data) => {
formattedContent = formattedContent + data.toString();
});
return new Promise((resolve) => {
let settled = false;
const settle = (value) => {
if (!settled) {
settled = true;
resolve(value);
}
};
process2.on("error", (err) => {
console.warn(`[icons-spritesheet] formatter "${formatter}" could not be started: ${err.message}`);
settle(fileContent);
});
process2.on("exit", (code) => {
settle(code === 0 ? formattedContent : fileContent);
});
});
}
async function generateTypes({
names,
outputPath,
formatter
}) {
const output = [
"// This file is generated by icon spritesheet generator",
"",
"export const iconNames = [",
...names.map((name) => ` "${name}",`),
"] as const",
"",
"export type IconName = typeof iconNames[number]",
""
].join("\n");
const formattedOutput = await lintFileContent(output, formatter, "ts");
const file = await writeIfChanged(
outputPath,
formattedOutput,
`${chalk.blueBright("TS")} Generated icon types in ${chalk.green(outputPath)}`
);
return file;
}
async function writeIfChanged(filepath, newContent, message) {
try {
const currentContent = await fs.readFile(filepath, "utf8");
if (currentContent !== newContent) {
await fs.writeFile(filepath, newContent, "utf8");
console.log(message);
}
} catch (e) {
await fs.writeFile(filepath, newContent, "utf8");
console.log(message);
}
}
var iconsSpritesheet = (maybeConfigs) => {
const configs = Array.isArray(maybeConfigs) ? maybeConfigs : [maybeConfigs];
const allSpriteSheetNames = configs.map((config) => config.fileName ?? "sprite.svg");
return configs.map((config, i) => {
const { withTypes, inputDir, outputDir, typesOutputFile, fileName, cwd, iconNameTransformer, formatter } = config;
const iconGenerator = async () => generateIcons({
withTypes,
inputDir,
outputDir,
typesOutputFile,
fileName,
iconNameTransformer,
formatter
});
const workDir = cwd ?? process.cwd();
return {
name: `icon-spritesheet-generator${i > 0 ? i.toString() : ""}`,
async buildStart() {
await iconGenerator();
},
async watchChange(file, type) {
const inputPath = normalizePath(path.join(workDir, inputDir));
if (file.includes(inputPath) && file.endsWith(".svg") && ["create", "delete"].includes(type.event)) {
await iconGenerator();
}
},
async handleHotUpdate({ file }) {
const inputPath = normalizePath(path.join(workDir, inputDir));
if (file.includes(inputPath) && file.endsWith(".svg")) {
await iconGenerator();
}
},
async config(config2) {
if (i > 0) {
return;
}
config2.build = config2.build ?? {};
const subFunc = typeof config2.build.assetsInlineLimit === "function" ? config2.build.assetsInlineLimit : void 0;
const limit = typeof config2.build.assetsInlineLimit === "number" ? config2.build.assetsInlineLimit : void 0;
const assetsInlineLimitFunction = (name, content) => {
const isSpriteSheet = allSpriteSheetNames.some((spriteSheetName) => {
return name.endsWith(normalizePath(`${outputDir}/${spriteSheetName}`));
});
if (isSpriteSheet) {
return false;
}
if (limit) {
const size = content.byteLength;
return size <= limit;
}
if (typeof subFunc === "function") {
return subFunc(name, content);
}
return void 0;
};
config2.build.assetsInlineLimit = assetsInlineLimitFunction;
}
};
});
};
export {
iconsSpritesheet
};