Skip to content

Commit

Permalink
Refactor SVG handling
Browse files Browse the repository at this point in the history
Replaced `node-html-parser` with `@xmldom/xmldom` for improved SVG parsing and manipulation. Enhanced SVG spritesheet generation logic by using DOM operations for better maintainability.
  • Loading branch information
Anton Savoskin committed Feb 4, 2025
1 parent 01b26d2 commit 76cbef6
Show file tree
Hide file tree
Showing 22 changed files with 231 additions and 273 deletions.
285 changes: 113 additions & 172 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@
],
"homepage": "https://github.com/forge42dev/vite-plugin-icons-spritesheet#readme",
"dependencies": {
"@xmldom/xmldom": "^0.9.6",
"chalk": "^5.4.1",
"glob": "^11.0.1",
"node-html-parser": "^7.0.1",
"tinyexec": "^0.3.2"
},
"peerDependencies": {
Expand All @@ -86,4 +86,4 @@
"vite": "6.0.11",
"vitest": "^3.0.5"
}
}
}
117 changes: 67 additions & 50 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { mkdir } from "node:fs/promises";
import path from "node:path";
import { stderr } from "node:process";
import { Readable } from "node:stream";
import { DOMParser, DOMImplementation, MIME_TYPE, NAMESPACE, Node } from "@xmldom/xmldom";
import chalk from "chalk";
import { glob } from "glob";
import { parse } from "node-html-parser";
import { exec } from "tinyexec";
import type { Plugin } from "vite";
import { normalizePath } from "vite";
Expand Down Expand Up @@ -45,7 +45,7 @@ interface PluginProps {
* @default no formatter
* @example "biome"
*/
formatter?: Formatter;
formatter?: Formatter;
/**
* The cwd, defaults to process.cwd()
* @default process.cwd()
Expand All @@ -67,7 +67,7 @@ const generateIcons = async ({
cwd,
formatter,
fileName = "sprite.svg",
iconNameTransformer,
iconNameTransformer = fileNameToCamelCase,
}: PluginProps) => {
const cwdToUse = cwd ?? process.cwd();
const inputDirRelative = path.relative(cwdToUse, inputDir);
Expand Down Expand Up @@ -98,7 +98,7 @@ const generateIcons = async ({

await mkdir(typesOutputDirRelative, { recursive: true });
await generateTypes({
names: files.map((file: string) => transformIconName(file, iconNameTransformer ?? fileNameToCamelCase)),
names: files.map((file: string) => transformIconName(file, iconNameTransformer)),
outputPath: path.join(typesOutputDir, typesFile),
formatter,
});
Expand All @@ -115,6 +115,45 @@ function fileNameToCamelCase(fileName: string): string {
const capitalizedWords = words.map((word) => word.charAt(0).toUpperCase() + word.slice(1));
return capitalizedWords.join("");
}

const EXCLUDED_ATTRIBUTES = ["xmlns", "xmlns:xlink", "version", "width", "height"];
const parser = new DOMParser();
function parseSvg(input: string) {
try {
return parser.parseFromString(input, MIME_TYPE.XML_SVG_IMAGE);
} catch (error) {
console.error(error instanceof Error ? error.message : error);
}
}

async function createSvgSymbol(file: string, inputDir: string, iconNameTransformer: (fileName: string) => string) {
const fileName = transformIconName(file, iconNameTransformer);
const input = await fs.readFile(path.join(inputDir, file), "utf8");

const root = parseSvg(input);
if (!root || !root.ownerDocument) {
console.log(`⚠️ No SVG tag found in ${file}`);
return;
}
const svg = root.documentElement;
if (!svg) return;

const symbol = root.ownerDocument.createElementNS(NAMESPACE.SVG, "symbol");
symbol.setAttribute("id", fileName);

for (const node of svg.childNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
symbol.appendChild(node);
}
}

for (const attr of svg.attributes) {
if (!EXCLUDED_ATTRIBUTES.includes(attr.name)) {
symbol.setAttribute(attr.name, attr.value);
}
}
return symbol;
}
/**
* Creates a single SVG file that contains all the icons
*/
Expand All @@ -130,50 +169,37 @@ async function generateSvgSprite({
inputDir: string;
outputPath: string;
outputDirRelative?: string;
iconNameTransformer?: (fileName: string) => string;
iconNameTransformer: (fileName: string) => string;
/**
* What formatter to use to format the generated files. Can be "biome" or "prettier"
* @default no formatter
* @example "biome"
*/
formatter?: Formatter;
}) {
// Each SVG becomes a symbol and we wrap them all in a single SVG
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");
// Each SVG becomes a symbol, and we wrap them all in a single SVG
const xmlDoc = new DOMImplementation().createDocument(NAMESPACE.SVG, "svg");
const defsElement = xmlDoc.createElementNS(NAMESPACE.SVG, "defs");
if (!xmlDoc.documentElement) throw new Error("documentElement is null");
xmlDoc.documentElement.setAttributeNS(NAMESPACE.XMLNS, "xmlns:xlink", "http://www.w3.org/1999/xlink");
xmlDoc.documentElement.setAttribute("width", "0");
xmlDoc.documentElement.setAttribute("height", "0");
xmlDoc.documentElement.appendChild(defsElement);

const root = parse(input);
const svg = root.querySelector("svg");
if (!svg) {
console.log(`⚠️ 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");
for (const file of files) {
const symbol = await createSvgSymbol(file, inputDir, iconNameTransformer);
if (symbol) defsElement.appendChild(symbol);
}
// eslint-disable-next-line quotes
const xmlDeclaration = '<?xml version="1.0" encoding="UTF-8"?>';
const xmlString = xmlDoc.toString();
const output = [xmlDeclaration, xmlString, ""].join("\n");
const formattedOutput = await lintFileContent(output, formatter, "svg");

return writeIfChanged(
outputPath,
formattedOutput,
`🖼️ Generated SVG spritesheet in ${chalk.green(outputDirRelative)}`,
`🖼️ Generated SVG spritesheet in ${chalk.green(outputDirRelative)}`
);
}

Expand All @@ -194,13 +220,13 @@ async function lintFileContent(fileContent: string, formatter: Formatter | undef
stdinStream.push(null);

const { process } = exec(formatter, options, {});
if (!process?.stdin) {
if (!process?.stdin) {
return fileContent;
}
stdinStream.pipe(process.stdin);
process.stderr?.pipe(stderr);
process.on("error", ( ) => {
return fileContent
process.on("error", () => {
return fileContent;
});
const formattedContent = await new Promise<string>((resolve) => {
process.stdout?.on("data", (data) => {
Expand Down Expand Up @@ -242,7 +268,7 @@ async function generateTypes({
const file = await writeIfChanged(
outputPath,
formattedOutput,
`${chalk.blueBright("TS")} Generated icon types in ${chalk.green(outputPath)}`,
`${chalk.blueBright("TS")} Generated icon types in ${chalk.green(outputPath)}`
);
return file;
}
Expand Down Expand Up @@ -270,16 +296,7 @@ export const iconsSpritesheet: (args: PluginProps | PluginProps[]) => any = (may
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 { withTypes, inputDir, outputDir, typesOutputFile, fileName, cwd, iconNameTransformer, formatter } = config;
const iconGenerator = async () =>
generateIcons({
withTypes,
Expand All @@ -290,7 +307,7 @@ export const iconsSpritesheet: (args: PluginProps | PluginProps[]) => any = (may
iconNameTransformer,
formatter,
});

const workDir = cwd ?? process.cwd();
return {
name: `icon-spritesheet-generator${i > 0 ? i.toString() : ""}`,
Expand Down
9 changes: 4 additions & 5 deletions test-apps/remix-vite-cjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@
"sideEffects": false,
"type": "commonjs",
"scripts": {
"build": "remix vite:build",
"dev": "remix vite:dev",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "remix-serve ./build/server/index.js",
"build": "react-router build",
"dev": "react-router dev",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "tsc"
},
"dependencies": {
Expand All @@ -31,4 +30,4 @@
"engines": {
"node": ">=20.0.0"
}
}
}
6 changes: 1 addition & 5 deletions test-apps/remix-vite-cjs/public/icons/sprite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 3 additions & 5 deletions test-apps/remix-vite-cjs/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { vitePlugin as remix } from "@remix-run/dev";
import { installGlobals } from "@remix-run/node";
// @ts-ignore
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { iconsSpritesheet } from "vite-plugin-icons-spritesheet";

installGlobals();

export default defineConfig({
plugins: [
remix(),
reactRouter(),
tsconfigPaths(),
iconsSpritesheet({
withTypes: true,
Expand Down
22 changes: 13 additions & 9 deletions test-apps/remix-vite/app/icons/sprite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion test-apps/remix-vite/app/icons/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// This file is generated by icon spritesheet generator

export const iconNames = ["Test", "Test copy", "De", "C", "B", "A"] as const;
export const iconNames = ["Width", "Version", "Polygon", "Namespace", "Multi", "Empty", "Circle"] as const;

export type IconName = (typeof iconNames)[number];
1 change: 0 additions & 1 deletion test-apps/remix-vite/icons/a.svg

This file was deleted.

1 change: 0 additions & 1 deletion test-apps/remix-vite/icons/c.svg

This file was deleted.

3 changes: 3 additions & 0 deletions test-apps/remix-vite/icons/circle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes
13 changes: 13 additions & 0 deletions test-apps/remix-vite/icons/multi.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions test-apps/remix-vite/icons/namespace.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions test-apps/remix-vite/icons/polygon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 0 additions & 3 deletions test-apps/remix-vite/icons/test copy.svg

This file was deleted.

3 changes: 0 additions & 3 deletions test-apps/remix-vite/icons/test.svg

This file was deleted.

1 change: 1 addition & 0 deletions test-apps/remix-vite/icons/version.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes
2 changes: 1 addition & 1 deletion test-apps/remix-vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@
"engines": {
"node": ">=20.0.0"
}
}
}
15 changes: 1 addition & 14 deletions test-apps/remix-vite/public/icons/sprite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion test-apps/remix-vite/public/icons/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// This file is generated by icon spritesheet generator

export const iconNames = ["Test", "Test copy", "De", "C", "B", "A"] as const
export const iconNames = ["Width", "Version", "Polygon", "Namespace", "Multi", "Empty", "Circle"] as const

export type IconName = (typeof iconNames)[number]

0 comments on commit 76cbef6

Please sign in to comment.