├── .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 | 141 | 142 | 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 | 199 | 200 | 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 | 356 | 357 | 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 | 409 | 410 | 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 | ``, 568 | ``, 569 | ...symbols, 570 | ``, 571 | ``, 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 | --------------------------------------------------------------------------------