├── .gitignore
├── LICENSE
├── README.md
├── bun.lockb
├── index.js
└── package.json
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Alex Anderson
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SVG Icons CLI
2 |
3 | > A command line tool for creating SVG spirte sheets and rendering them with a React Icon component. Based on "[Use svg sprite icons in React](https://www.jacobparis.com/content/svg-icons)" by Jacob Paris and the [Epic Stack](https://github.com/epicweb-dev/epic-stack)
4 |
5 | ## The Problem
6 |
7 | Including SVGs in your JavaScript bundles is convenient, but [slow and expensive](https://x.com/_developit/status/1382838799420514317?s=20). Using `
` tags with SVGs isn't flexible. The best way to use icons is an SVG spritesheet, but there isn't an out-of-the-box tool to create those spritesheets.
8 |
9 | > [!TIP]
10 | > Don't know what SVG sprite sheets are? Check out [this blog post explaining this technique](https://benadam.me/thoughts/react-svg-sprites/).
11 |
12 | ## The Solution
13 |
14 | A CLI tool that
15 |
16 | - Sets you up with a TypeScript-ready, Tailwind-ready `` component
17 | - Automatically generates an SVG sprite sheet for you
18 |
19 | ## Installation
20 |
21 | The `icons` CLI can be installed as a dev dependency.
22 |
23 | ```bash
24 | npm install --save-dev svg-icons-cli
25 | ```
26 |
27 | And then use it in your package.json
28 |
29 | ```json
30 | {
31 | "scripts": {
32 | "build:icons": "icons build"
33 | }
34 | }
35 | ```
36 |
37 | or call it directly with `npx`
38 |
39 | ```bash
40 | npx svg-icons-cli build
41 | ```
42 |
43 | ## Usage
44 |
45 | The CLI has two commands: `init` for creating an `` React component inside your app, and `build` for generating an SVG sprite sheet.
46 |
47 | ### `init`
48 |
49 | This command installs a Tailwind-compatible `` component in your app. If you're using TypeScript, it will also install a default type definition file which is used by TypeScript before a more exact type definition file is generated by the `build` command.
50 |
51 | Run it with no options to interactively set your options. It will automatically guess the values based on which framework you're using (Remix, Next.js, or Vite), and whether you're using TypeScript.
52 |
53 | ```bash
54 | npx svg-icons-cli init
55 | ```
56 |
57 | #### Options
58 |
59 | - `-o, --output`: Where to store the Icon component. Defaults to `components/ui`
60 | - `-s --spriteDir`: Where to store the sprite svg. Defaults to output arg value
61 | - `-t, --types`: Where to store the default type definition file. Defaults to `types/icon-name.d.ts`
62 |
63 | > [!NOTE]
64 | >
65 | > **Why not export `` from this package?**
66 | > The `` component is built using Tailwind classes, which is my preferred way to write CSS. Your app might use your own classes, CSS modules, or some other styling method. Instead of shipping a million different implementations, the CLI will put a small component in your app that you can modify to your hearts content. Or, you can follow the manual installation instructions below.
67 |
68 | #### Manual Installation
69 |
70 | First, copy/paste one of these components into your project:
71 |
72 | Icon.tsx
73 |
74 | ```tsx
75 | import { type SVGProps } from "react";
76 | // Configure this path in your tsconfig.json
77 | import { type IconName } from "~/icon-name";
78 | import { type ClassValue, clsx } from "clsx";
79 | import { twMerge } from "tailwind-merge";
80 | import href from "./icons/sprite.svg";
81 |
82 | export { href };
83 |
84 | export { IconName };
85 |
86 | const sizeClassName = {
87 | font: "w-[1em] h-[1em]",
88 | xs: "w-3 h-3",
89 | sm: "w-4 h-4",
90 | md: "w-5 h-5",
91 | lg: "w-6 h-6",
92 | xl: "w-7 h-7",
93 | } as const;
94 |
95 | type Size = keyof typeof sizeClassName;
96 |
97 | const childrenSizeClassName = {
98 | font: "gap-1.5",
99 | xs: "gap-1.5",
100 | sm: "gap-1.5",
101 | md: "gap-2",
102 | lg: "gap-2",
103 | xl: "gap-3",
104 | } satisfies Record;
105 |
106 | /**
107 | * Renders an SVG icon. The icon defaults to the size of the font. To make it
108 | * align vertically with neighboring text, you can pass the text as a child of
109 | * the icon and it will be automatically aligned.
110 | * Alternatively, if you're not ok with the icon being to the left of the text,
111 | * you need to wrap the icon and text in a common parent and set the parent to
112 | * display "flex" (or "inline-flex") with "items-center" and a reasonable gap.
113 | */
114 | export function Icon({
115 | name,
116 | size = "font",
117 | className,
118 | children,
119 | ...props
120 | }: SVGProps & {
121 | name: IconName;
122 | size?: Size;
123 | }) {
124 | if (children) {
125 | return (
126 |
129 |
130 | {children}
131 |
132 | );
133 | }
134 | return (
135 |
143 | );
144 | }
145 | ```
146 |
147 |
148 |
149 | Icon.jsx
150 |
151 | ```jsx
152 | import { clsx } from "clsx";
153 | import { twMerge } from "tailwind-merge";
154 | import href from "./icons/sprite.svg";
155 |
156 | export { href };
157 | export { IconName };
158 |
159 | const sizeClassName = {
160 | font: "w-[1em] h-[1em]",
161 | xs: "w-3 h-3",
162 | sm: "w-4 h-4",
163 | md: "w-5 h-5",
164 | lg: "w-6 h-6",
165 | xl: "w-7 h-7",
166 | };
167 | const childrenSizeClassName = {
168 | font: "gap-1.5",
169 | xs: "gap-1.5",
170 | sm: "gap-1.5",
171 | md: "gap-2",
172 | lg: "gap-2",
173 | xl: "gap-3",
174 | };
175 | /**
176 | * Renders an SVG icon. The icon defaults to the size of the font. To make it
177 | * align vertically with neighboring text, you can pass the text as a child of
178 | * the icon and it will be automatically aligned.
179 | * Alternatively, if you're not ok with the icon being to the left of the text,
180 | * you need to wrap the icon and text in a common parent and set the parent to
181 | * display "flex" (or "inline-flex") with "items-center" and a reasonable gap.
182 | */
183 | export function Icon({ name, size = "font", className, children, ...props }) {
184 | if (children) {
185 | return (
186 |
189 |
190 | {children}
191 |
192 | );
193 | }
194 | return (
195 |
201 | );
202 | }
203 | ```
204 |
205 |
206 |
207 | > [!NOTE]
208 | >
209 | > **Be careful with how you load your sprites**
210 | > Note how we're `import`ing the sprite asset in the components above. This assumes you're using a framework that automatically [adds a content hash to your sprite's filename](https://vitejs.dev/guide/assets#importing-asset-as-url) when you build your app. If your framework doesn't allow you to import assets like that, you might want to put it in your `/public` folder. This is fine, so long as [your framework doesn't instruct browsers to cache these assets](https://nextjs.org/docs/pages/building-your-application/optimizing/static-assets#caching). If your sprites are cached by browsers, any changes you make to the sprite sheet wouldn't be loaded by those browsers, so some of your sprites might look wrong or go missing.
211 |
212 | Install the dependencies.
213 |
214 | ```bash
215 | npm install --save tailwind-merge clsx
216 | ```
217 |
218 | If you're using TypeScript, add a default type definition file.
219 |
220 | ```ts
221 | // types/icon-name.d.ts
222 | // This file is a fallback until you run npm run icons build
223 |
224 | export type IconName = string;
225 | ```
226 |
227 | And set up your paths in tsconfig.json
228 |
229 | ```json
230 | "paths": {
231 | "~/icon-name": ["${iconsOutput}/name.d.ts", "${types}"]
232 | }
233 | ```
234 |
235 | Then add some icons and run the `build` CLI command, making sure your output folder matches the `href` in your `Icon` component.
236 |
237 | Import your `` component and pass an icon name and optionally a `size` or `className`.
238 |
239 | ```jsx
240 |
243 | ```
244 |
245 | ### `build`
246 |
247 | This command takes an input folder and an output folder, combines all the SVG files in the input folder, and puts an SVG sprite sheet and `icon-names.d.ts` file inside the output folder.
248 |
249 | Run it with no options to interactively set your options. It will automatically guess the values based on which framework you're using (Remix, Next.js, or Vite). The CLI will also print the appropriate command that you can copy/paste and reuse - you should consider putting the command into a package.json script so you don't have to type it every time.
250 |
251 | ```bash
252 | npx svg-icons-cli build
253 | ```
254 |
255 | #### Options
256 |
257 | - `-i, --input`: The folder where the source SVG icons are stored
258 | - `-o, --output`: Where to output the sprite sheet and types
259 | - `--optimize`: Automatically optimize the output SVG using SVGO. You can [configure SVGO](https://github.com/svg/svgo#configuration) by placing a `svgo.config.js` file in the directory where you run the CLI.
260 |
261 | > [!TIP]
262 | > We recommend using the [Sly CLI](https://sly-cli.fly.dev) to bring icons into your project. It can be configured with many icon repositories, and can run the build command after new icons have been added.
263 |
264 | ## Contributing
265 |
266 | This project was thrown together in a few hours, and works great if you follow the happy path. That said, there's a lot possible contributions that would be welcome.
267 |
268 | - [ ] File issues to suggest how the project could be better.
269 | - [ ] Improve this documentation.
270 | - [ ] Make non-React `` components for different frameworks.
271 | - [ ] Automatically add the `build` script to `package.json` when `init` is run.
272 | - [ ] Automatically update `tsconfig.json` when `init` is run.
273 | - [ ] Add Github Actions to automatically publish to NPM when pushed to `main`.
274 |
275 | Bun is used to install dependencies, but the project works just fine in Node.js too.
276 |
277 | PRs welcome!
278 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexanderson1993/svg-icons-cli/9f0152e6ed709e4150b16dfd7a9db4a0948f153a/bun.lockb
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env node
2 | // @ts-check
3 | import * as path from "node:path";
4 | import { promises as fs, mkdirSync } from "node:fs";
5 | import { parse } from "node-html-parser";
6 | import {
7 | intro,
8 | text,
9 | outro,
10 | log,
11 | cancel,
12 | note,
13 | isCancel,
14 | spinner,
15 | confirm,
16 | } from "@clack/prompts";
17 | import parseArgv from "tiny-parse-argv";
18 | import { glob } from "glob";
19 | import { exec } from "node:child_process";
20 | import { loadConfig, optimize } from "svgo";
21 | const cwd = process.cwd();
22 |
23 | const args = parseArgv(process.argv.slice(2));
24 | const command = args._[0];
25 | const verbose = args.v || args.verbose;
26 |
27 | function logVerbose(message) {
28 | if (verbose) log.info(message);
29 | }
30 |
31 | const framework = await detectFramework();
32 |
33 | let hasSrc = await fs
34 | .stat("./src")
35 | .then(() => true)
36 | .catch(() => false);
37 |
38 | let hasApp = await fs
39 | .stat("./app")
40 | .then(() => true)
41 | .catch(() => false);
42 |
43 | if (!hasApp) {
44 | hasApp = await fs
45 | .stat("./src/app")
46 | .then(() => true)
47 | .catch(() => false);
48 | }
49 | let componentFolder = `components/ui`;
50 | if (hasSrc) {
51 | componentFolder = `src/components/ui`;
52 | }
53 | if (framework !== "next" && hasApp) {
54 | componentFolder = `app/components/ui`;
55 | if (hasSrc) {
56 | componentFolder = `src/app/components/ui`;
57 | }
58 | }
59 |
60 | intro(`Icons CLI`);
61 |
62 | switch (command) {
63 | case "build":
64 | if (args.help) {
65 | log.message(
66 | `icons build
67 | Build SVG icons into a sprite sheet
68 | Options:
69 | -i, --input The relative path where the source SVGs are stored
70 | -o, --output Where the output sprite sheet and types should be stored
71 | -s --spriteDir Where the output sprite sheet should be stored (default to output param)
72 | --optimize Optimize the output SVG using SVGO.
73 | --help Show help
74 | `,
75 | { symbol: "👋" }
76 | );
77 | break;
78 | }
79 | await build();
80 | break;
81 | case "init":
82 | if (args.help) {
83 | log.message(
84 | `icons init
85 | Initialize the Icon component
86 | Options:
87 | -o, --output Where to store the Icon component
88 | -t, --types Where to store the default type definition file
89 | --help Show help
90 | `,
91 | { symbol: "👋" }
92 | );
93 | break;
94 | }
95 | await init();
96 | break;
97 | default:
98 | log.message(
99 | `icons
100 | Commands:
101 | icons build Build SVG icons into a sprite sheet
102 | icons init Initialize the Icon component
103 | Options:
104 | --help Show help
105 | `,
106 | { symbol: "👋" }
107 | );
108 | break;
109 | }
110 |
111 | async function build() {
112 | let shouldOptimize = !!args.optimize;
113 | let input = args.i || args.input;
114 | let output = args.o || args.output;
115 | let giveHint = false;
116 | const hasNoInput = !input;
117 | if (!input) {
118 | giveHint = true;
119 | input = await text({
120 | message: "Where are the input SVGs stored?",
121 | initialValue: "other/svg-icons",
122 |
123 | validate(value) {
124 | if (value.length === 0) return `Input is required!`;
125 | },
126 | });
127 | }
128 | if (isCancel(input)) process.exit(1);
129 | const inputDir = path.join(cwd, input);
130 | const inputDirRelative = path.relative(cwd, inputDir);
131 |
132 | if (!output) {
133 | giveHint = true;
134 | let initialValue = `${componentFolder}/icons`;
135 | if (framework === "next") {
136 | initialValue = `public/icons`;
137 | }
138 | output = await text({
139 | message: "Where should the output be stored?",
140 | initialValue,
141 | validate(value) {
142 | if (value.length === 0) return `Output is required!`;
143 | },
144 | });
145 | }
146 | if (isCancel(output)) process.exit(1);
147 | const outputDir = path.join(cwd, output);
148 | const spriteDir = path.join(cwd, (args.s || args.spriteDir) ?? output);
149 | if (typeof args.optimize === "undefined" && !hasNoInput) {
150 | const choseOptimize = await confirm({
151 | message: "Optimize the output SVG using SVGO?",
152 | });
153 | if (isCancel(choseOptimize)) process.exit(1);
154 | shouldOptimize = choseOptimize;
155 | }
156 | if (giveHint) {
157 | note(
158 | `You can also pass these options as flags:
159 |
160 | icons build -i ${input} -o ${output}${shouldOptimize ? " --optimize" : ""}`,
161 | "Psst"
162 | );
163 | }
164 | const files = glob
165 | .sync("**/*.svg", {
166 | cwd: inputDir,
167 | })
168 | .sort((a, b) => a.localeCompare(b));
169 |
170 | if (files.length === 0) {
171 | cancel(`No SVG files found in ${inputDirRelative}`);
172 | process.exit(1);
173 | } else {
174 | mkdirSync(outputDir, { recursive: true });
175 | const spriteFilepath = path.join(spriteDir, "sprite.svg");
176 | const typeOutputFilepath = path.join(outputDir, "name.d.ts");
177 | const currentSprite = await fs
178 | .readFile(spriteFilepath, "utf8")
179 | .catch(() => "");
180 | const currentTypes = await fs
181 | .readFile(typeOutputFilepath, "utf8")
182 | .catch(() => "");
183 | const iconNames = files.map((file) => iconName(file));
184 | const spriteUpToDate = iconNames.every((name) =>
185 | currentSprite.includes(`id=${name}`)
186 | );
187 | const typesUpToDate = iconNames.every((name) =>
188 | currentTypes.includes(`"${name}"`)
189 | );
190 | if (spriteUpToDate && typesUpToDate) {
191 | logVerbose(`Icons are up to date`);
192 | return;
193 | }
194 | logVerbose(`Generating sprite for ${inputDirRelative}`);
195 | const spriteChanged = await generateSvgSprite({
196 | files,
197 | inputDir,
198 | outputPath: spriteFilepath,
199 | shouldOptimize,
200 | });
201 | for (const file of files) {
202 | logVerbose(`✅ ${file}`);
203 | }
204 | logVerbose(`Saved to ${path.relative(cwd, spriteFilepath)}`);
205 | const stringifiedIconNames = iconNames.map((name) => JSON.stringify(name));
206 | const typeOutputContent = `// This file is generated by npm run build:icons
207 |
208 | export type IconName =
209 | \t| ${stringifiedIconNames.join("\n\t| ")};
210 | `;
211 | const typesChanged = await writeIfChanged(
212 | typeOutputFilepath,
213 | typeOutputContent
214 | );
215 | logVerbose(`Manifest saved to ${path.relative(cwd, typeOutputFilepath)}`);
216 | const readmeChanged = await writeIfChanged(
217 | path.join(outputDir, "README.md"),
218 | `# Icons
219 |
220 | This directory contains SVG icons that are used by the app.
221 |
222 | Everything in this directory is generated by running \`icons build\`.
223 | `
224 | );
225 | if (spriteChanged || typesChanged || readmeChanged) {
226 | log.info(`Generated ${files.length} icons`);
227 | }
228 | }
229 | }
230 |
231 | async function init() {
232 | let output = args.o || args.output;
233 | let types = args.t || args.types;
234 |
235 | let isTs = !!types;
236 | if (!types) {
237 | if (await fs.stat("./tsconfig.json").catch(() => false)) {
238 | isTs = true;
239 | types = await text({
240 | message: "Where should the default Icon types be stored?",
241 | initialValue: `types/icon-name.d.ts`,
242 | validate(value) {
243 | if (value.length === 0) return `Type is required!`;
244 | },
245 | });
246 | if (isCancel(types)) {
247 | log.warn(
248 | `You'll need to create a types/icon-name.d.ts file yourself and update your tsconfig.json to include it.`
249 | );
250 | }
251 | // Set up the default types folder and file
252 | const typesFile = `// This file is a fallback until you run npm run icons build
253 |
254 | export type IconName = string;
255 | `;
256 |
257 | const typesDir = path.join(cwd, types);
258 | try {
259 | await fs.mkdir(path.dirname(typesDir), { recursive: true });
260 | await fs.writeFile(typesDir, typesFile);
261 | } catch {
262 | log.error(`Could not write to ${typesDir}`);
263 | log.warn(
264 | `You'll need to create a types/icon-name.d.ts file yourself and update your tsconfig.json to include it.`
265 | );
266 | }
267 | }
268 | }
269 |
270 | if (!output) {
271 | output = await text({
272 | message: "Where should the Icon component be stored?",
273 | initialValue: `${componentFolder}/Icon.${isTs ? "tsx" : "jsx"}`,
274 | validate(value) {
275 | if (value.length === 0) return `Output is required!`;
276 | },
277 | });
278 | }
279 | if (isCancel(output)) process.exit(1);
280 | const outputDir = path.join(cwd, output);
281 |
282 | let hrefImportExport = `import href from "./icons/sprite.svg";
283 |
284 | export { href };`;
285 |
286 | if (framework === "next") {
287 | hrefImportExport = `// Be sure to configure the icon generator to output to the public folder
288 | const href = "/icons/sprite.svg";
289 |
290 | export { href };`;
291 | }
292 |
293 | const iconFileTs = `
294 | import { type SVGProps } from "react";
295 | // Configure this path in your tsconfig.json
296 | import { type IconName } from "~/icon-name";
297 | import { type ClassValue, clsx } from "clsx";
298 | import { twMerge } from "tailwind-merge";
299 | ${hrefImportExport}
300 |
301 | export { IconName };
302 |
303 | const sizeClassName = {
304 | font: "w-[1em] h-[1em]",
305 | xs: "w-3 h-3",
306 | sm: "w-4 h-4",
307 | md: "w-5 h-5",
308 | lg: "w-6 h-6",
309 | xl: "w-7 h-7",
310 | } as const;
311 |
312 | type Size = keyof typeof sizeClassName;
313 |
314 | const childrenSizeClassName = {
315 | font: "gap-1.5",
316 | xs: "gap-1.5",
317 | sm: "gap-1.5",
318 | md: "gap-2",
319 | lg: "gap-2",
320 | xl: "gap-3",
321 | } satisfies Record;
322 |
323 | /**
324 | * Renders an SVG icon. The icon defaults to the size of the font. To make it
325 | * align vertically with neighboring text, you can pass the text as a child of
326 | * the icon and it will be automatically aligned.
327 | * Alternatively, if you're not ok with the icon being to the left of the text,
328 | * you need to wrap the icon and text in a common parent and set the parent to
329 | * display "flex" (or "inline-flex") with "items-center" and a reasonable gap.
330 | */
331 | export function Icon({
332 | name,
333 | size = "font",
334 | className,
335 | children,
336 | ...props
337 | }: SVGProps & {
338 | name: IconName;
339 | size?: Size;
340 | }) {
341 | if (children) {
342 | return (
343 |
346 |
347 | {children}
348 |
349 | );
350 | }
351 | return (
352 |
358 | );
359 | }
360 |
361 | `;
362 |
363 | const iconFileJs = `
364 | import { clsx } from "clsx";
365 | import { twMerge } from "tailwind-merge";
366 | ${hrefImportExport}
367 | export { IconName };
368 |
369 | const sizeClassName = {
370 | font: "w-[1em] h-[1em]",
371 | xs: "w-3 h-3",
372 | sm: "w-4 h-4",
373 | md: "w-5 h-5",
374 | lg: "w-6 h-6",
375 | xl: "w-7 h-7",
376 | };
377 | const childrenSizeClassName = {
378 | font: "gap-1.5",
379 | xs: "gap-1.5",
380 | sm: "gap-1.5",
381 | md: "gap-2",
382 | lg: "gap-2",
383 | xl: "gap-3",
384 | };
385 | /**
386 | * Renders an SVG icon. The icon defaults to the size of the font. To make it
387 | * align vertically with neighboring text, you can pass the text as a child of
388 | * the icon and it will be automatically aligned.
389 | * Alternatively, if you're not ok with the icon being to the left of the text,
390 | * you need to wrap the icon and text in a common parent and set the parent to
391 | * display "flex" (or "inline-flex") with "items-center" and a reasonable gap.
392 | */
393 | export function Icon({ name, size = "font", className, children, ...props }) {
394 | if (children) {
395 | return (
396 |
399 |
400 | {children}
401 |
402 | );
403 | }
404 | return (
405 |
411 | );
412 | }
413 | `;
414 |
415 | // Write files
416 | try {
417 | await fs.mkdir(path.dirname(outputDir), { recursive: true });
418 | await fs.writeFile(outputDir, isTs ? iconFileTs : iconFileJs, "utf8");
419 | } catch (err) {
420 | log.error(`Could not write to ${outputDir}`);
421 | log.warn(`You'll need to create an Icon component yourself`);
422 | process.exit(1);
423 | }
424 |
425 | // Install dependencies
426 | const dependencies = ["clsx", "tailwind-merge"];
427 |
428 | // Detect the package manager
429 | let command = "npm install --save";
430 | if (await fs.stat("yarn.lock").catch(() => false)) {
431 | command = "yarn add";
432 | }
433 | // pnpm
434 | if (await fs.stat("pnpm-lock.yaml").catch(() => false)) {
435 | command = "pnpm add";
436 | }
437 | // bun
438 | if (await fs.stat("bun.lockb").catch(() => false)) {
439 | command = "bun add";
440 | }
441 |
442 | const s = spinner();
443 | s.start("Installing dependencies");
444 | try {
445 | await new Promise((res, fail) => {
446 | const op = exec(`${command} ${dependencies.join(" ")}`, (err, stdout) => {
447 | if (err) {
448 | fail(err);
449 | }
450 | res(true);
451 | });
452 |
453 | op.addListener("message", (message) => {
454 | message
455 | .toString()
456 | .trim()
457 | .split("\n")
458 | .forEach((line) => {
459 | s.message(line);
460 | });
461 | });
462 | });
463 |
464 | s.stop("Installed dependencies");
465 | } catch (err) {
466 | s.stop("Failed to install dependencies");
467 | log.error(err);
468 | process.exit(1);
469 | }
470 |
471 | let iconsOutput = `${componentFolder}/icons`;
472 | if (framework === "next") {
473 | iconsOutput = `public/icons`;
474 | }
475 |
476 | outro(`Icon component created at ${outputDir}
477 |
478 | Be sure to run \`icons build\` to generate the icons. You can also add something like this to your build script:
479 |
480 | "build:icons": "icons build -i other/svg-icons -o ${iconsOutput}"
481 |
482 | Consider using https://sly-cli.fly.dev to automatically add icons and run the build script for you.
483 | ${
484 | isTs
485 | ? `
486 | If you're using TypeScript, you'll need to configure your tsconfig.json to include the generated types:
487 |
488 | "paths": {
489 | "~/icon-name": ["${iconsOutput}/name.d.ts", "${types}"]
490 | }`
491 | : ""
492 | }`);
493 | }
494 |
495 | async function detectFramework() {
496 | // Read the package.json and look for the dependencies
497 | // Check for next.js, remix, or vite
498 | // If none of those are found, ask the user
499 | try {
500 | var packageJson = await parsePackageJson();
501 | } catch {
502 | return "unknown";
503 | }
504 |
505 | if (packageJson.dependencies["next"]) return "next";
506 | if (packageJson.dependencies["vite"] || packageJson.devDependencies["vite"])
507 | return "vite";
508 | if (
509 | packageJson.dependencies["remix"] ||
510 | packageJson.dependencies["@remix-run/react"]
511 | )
512 | return "remix";
513 | return "unknown";
514 | }
515 |
516 | async function parsePackageJson() {
517 | let dir = process.cwd();
518 | let packageJson;
519 | while (!packageJson) {
520 | console.log(path.join(dir, "package.json"));
521 | try {
522 | packageJson = await fs.readFile(path.join(dir, "package.json"), "utf8");
523 | } catch (err) {
524 | console.log(err);
525 | if (dir === "/") {
526 | throw new Error("Could not find package.json");
527 | }
528 | dir = path.dirname(dir);
529 | }
530 | }
531 | return JSON.parse(packageJson);
532 | }
533 |
534 | function iconName(file) {
535 | return file.replace(/\.svg$/, "");
536 | }
537 |
538 | /**
539 | * Creates a single SVG file that contains all the icons
540 | */
541 | async function generateSvgSprite({
542 | files,
543 | inputDir,
544 | outputPath,
545 | shouldOptimize,
546 | }) {
547 | // Each SVG becomes a symbol and we wrap them all in a single SVG
548 | const symbols = await Promise.all(
549 | files.map(async (file) => {
550 | const input = await fs.readFile(path.join(inputDir, file), "utf8");
551 | const root = parse(input);
552 | const svg = root.querySelector("svg");
553 | if (!svg) throw new Error("No SVG element found");
554 | svg.tagName = "symbol";
555 | svg.setAttribute("id", iconName(file));
556 | svg.removeAttribute("xmlns");
557 | svg.removeAttribute("xmlns:xlink");
558 | svg.removeAttribute("version");
559 | svg.removeAttribute("width");
560 | svg.removeAttribute("height");
561 | return svg.toString().trim();
562 | })
563 | );
564 | let output = [
565 | ``,
566 | ``,
567 | ``,
572 | "", // trailing newline
573 | ].join("\n");
574 |
575 | if (shouldOptimize) {
576 | const config = (await loadConfig()) || { plugins: [] };
577 | if (!config.plugins) {
578 | config.plugins = [];
579 | }
580 | config.plugins.push({
581 | name: "preset-default",
582 | params: {
583 | overrides: {
584 | removeHiddenElems: false,
585 | cleanupIds: false,
586 | convertPathData: {
587 | floatPrecision: 5,
588 | },
589 | },
590 | },
591 | });
592 | output = optimize(output, config).data;
593 | }
594 |
595 | return writeIfChanged(outputPath, output);
596 | }
597 |
598 | async function writeIfChanged(filepath, newContent) {
599 | const currentContent = await fs.readFile(filepath, "utf8").catch(() => "");
600 | if (currentContent === newContent) return false;
601 | await fs.writeFile(filepath, newContent, "utf8");
602 | return true;
603 | }
604 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "svg-icons-cli",
3 | "version": "0.0.8",
4 | "description": "",
5 | "main": "index.js",
6 | "type": "module",
7 | "scripts": {
8 | "test": "echo \"Error: no test specified\" && exit 1"
9 | },
10 | "bin": {
11 | "icons": "./index.js"
12 | },
13 | "repository": {
14 | "type": "git",
15 | "url": "https://github.com/alexanderson1993/svg-icons-cli"
16 | },
17 | "readme": "https://github.com/alexanderson1993/svg-icons-cli",
18 | "keywords": [
19 | "icons",
20 | "svg",
21 | "react",
22 | "cli"
23 | ],
24 | "author": {
25 | "name": "Alex Anderson"
26 | },
27 | "license": "ISC",
28 | "dependencies": {
29 | "@clack/prompts": "^0.7.0",
30 | "clsx": "^2.0.0",
31 | "glob": "^10.3.10",
32 | "node-html-parser": "^6.1.11",
33 | "svgo": "^3.0.4",
34 | "tailwind-merge": "^2.0.0",
35 | "tiny-parse-argv": "^2.2.0",
36 | "typescript": "^5.2.2"
37 | },
38 | "devDependencies": {
39 | "@svgr/cli": "^8.1.0",
40 | "@svgr/plugin-jsx": "^8.1.0",
41 | "@svgr/plugin-prettier": "^8.1.0",
42 | "@svgr/plugin-svgo": "^8.1.0",
43 | "@types/node": "^20.9.0",
44 | "npm-run-all": "^4.1.5",
45 | "npm-watch": "^0.11.0"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------