svg-icons-cli
Version:
https://github.com/alexanderson1993/svg-icons-cli
604 lines (549 loc) • 16.1 kB
JavaScript
// @ts-check
import * as path from "node:path";
import { promises as fs, mkdirSync } from "node:fs";
import { parse } from "node-html-parser";
import {
intro,
text,
outro,
log,
cancel,
note,
isCancel,
spinner,
confirm,
} from "@clack/prompts";
import parseArgv from "tiny-parse-argv";
import { glob } from "glob";
import { exec } from "node:child_process";
import { loadConfig, optimize } from "svgo";
const cwd = process.cwd();
const args = parseArgv(process.argv.slice(2));
const command = args._[0];
const verbose = args.v || args.verbose;
function logVerbose(message) {
if (verbose) log.info(message);
}
const framework = await detectFramework();
let hasSrc = await fs
.stat("./src")
.then(() => true)
.catch(() => false);
let hasApp = await fs
.stat("./app")
.then(() => true)
.catch(() => false);
if (!hasApp) {
hasApp = await fs
.stat("./src/app")
.then(() => true)
.catch(() => false);
}
let componentFolder = `components/ui`;
if (hasSrc) {
componentFolder = `src/components/ui`;
}
if (framework !== "next" && hasApp) {
componentFolder = `app/components/ui`;
if (hasSrc) {
componentFolder = `src/app/components/ui`;
}
}
intro(`Icons CLI`);
switch (command) {
case "build":
if (args.help) {
log.message(
`icons build
Build SVG icons into a sprite sheet
Options:
-i, --input The relative path where the source SVGs are stored
-o, --output Where the output sprite sheet and types should be stored
-s --spriteDir Where the output sprite sheet should be stored (default to output param)
--optimize Optimize the output SVG using SVGO.
--help Show help
`,
{ symbol: "👋" }
);
break;
}
await build();
break;
case "init":
if (args.help) {
log.message(
`icons init
Initialize the Icon component
Options:
-o, --output Where to store the Icon component
-t, --types Where to store the default type definition file
--help Show help
`,
{ symbol: "👋" }
);
break;
}
await init();
break;
default:
log.message(
`icons <command>
Commands:
icons build Build SVG icons into a sprite sheet
icons init Initialize the Icon component
Options:
--help Show help
`,
{ symbol: "👋" }
);
break;
}
async function build() {
let shouldOptimize = !!args.optimize;
let input = args.i || args.input;
let output = args.o || args.output;
let giveHint = false;
const hasNoInput = !input;
if (!input) {
giveHint = true;
input = await text({
message: "Where are the input SVGs stored?",
initialValue: "other/svg-icons",
validate(value) {
if (value.length === 0) return `Input is required!`;
},
});
}
if (isCancel(input)) process.exit(1);
const inputDir = path.join(cwd, input);
const inputDirRelative = path.relative(cwd, inputDir);
if (!output) {
giveHint = true;
let initialValue = `${componentFolder}/icons`;
if (framework === "next") {
initialValue = `public/icons`;
}
output = await text({
message: "Where should the output be stored?",
initialValue,
validate(value) {
if (value.length === 0) return `Output is required!`;
},
});
}
if (isCancel(output)) process.exit(1);
const outputDir = path.join(cwd, output);
const spriteDir = path.join(cwd, (args.s || args.spriteDir) ?? output);
if (typeof args.optimize === "undefined" && !hasNoInput) {
const choseOptimize = await confirm({
message: "Optimize the output SVG using SVGO?",
});
if (isCancel(choseOptimize)) process.exit(1);
shouldOptimize = choseOptimize;
}
if (giveHint) {
note(
`You can also pass these options as flags:
icons build -i ${input} -o ${output}${shouldOptimize ? " --optimize" : ""}`,
"Psst"
);
}
const files = glob
.sync("**/*.svg", {
cwd: inputDir,
})
.sort((a, b) => a.localeCompare(b));
if (files.length === 0) {
cancel(`No SVG files found in ${inputDirRelative}`);
process.exit(1);
} else {
mkdirSync(outputDir, { recursive: true });
const spriteFilepath = path.join(spriteDir, "sprite.svg");
const typeOutputFilepath = path.join(outputDir, "name.d.ts");
const currentSprite = await fs
.readFile(spriteFilepath, "utf8")
.catch(() => "");
const currentTypes = await fs
.readFile(typeOutputFilepath, "utf8")
.catch(() => "");
const iconNames = files.map((file) => iconName(file));
const spriteUpToDate = iconNames.every((name) =>
currentSprite.includes(`id=${name}`)
);
const typesUpToDate = iconNames.every((name) =>
currentTypes.includes(`"${name}"`)
);
if (spriteUpToDate && typesUpToDate) {
logVerbose(`Icons are up to date`);
return;
}
logVerbose(`Generating sprite for ${inputDirRelative}`);
const spriteChanged = await generateSvgSprite({
files,
inputDir,
outputPath: spriteFilepath,
shouldOptimize,
});
for (const file of files) {
logVerbose(`✅ ${file}`);
}
logVerbose(`Saved to ${path.relative(cwd, spriteFilepath)}`);
const stringifiedIconNames = iconNames.map((name) => JSON.stringify(name));
const typeOutputContent = `// This file is generated by npm run build:icons
export type IconName =
\t| ${stringifiedIconNames.join("\n\t| ")};
`;
const typesChanged = await writeIfChanged(
typeOutputFilepath,
typeOutputContent
);
logVerbose(`Manifest saved to ${path.relative(cwd, typeOutputFilepath)}`);
const readmeChanged = await writeIfChanged(
path.join(outputDir, "README.md"),
`# Icons
This directory contains SVG icons that are used by the app.
Everything in this directory is generated by running \`icons build\`.
`
);
if (spriteChanged || typesChanged || readmeChanged) {
log.info(`Generated ${files.length} icons`);
}
}
}
async function init() {
let output = args.o || args.output;
let types = args.t || args.types;
let isTs = !!types;
if (!types) {
if (await fs.stat("./tsconfig.json").catch(() => false)) {
isTs = true;
types = await text({
message: "Where should the default Icon types be stored?",
initialValue: `types/icon-name.d.ts`,
validate(value) {
if (value.length === 0) return `Type is required!`;
},
});
if (isCancel(types)) {
log.warn(
`You'll need to create a types/icon-name.d.ts file yourself and update your tsconfig.json to include it.`
);
}
// Set up the default types folder and file
const typesFile = `// This file is a fallback until you run npm run icons build
export type IconName = string;
`;
const typesDir = path.join(cwd, types);
try {
await fs.mkdir(path.dirname(typesDir), { recursive: true });
await fs.writeFile(typesDir, typesFile);
} catch {
log.error(`Could not write to ${typesDir}`);
log.warn(
`You'll need to create a types/icon-name.d.ts file yourself and update your tsconfig.json to include it.`
);
}
}
}
if (!output) {
output = await text({
message: "Where should the Icon component be stored?",
initialValue: `${componentFolder}/Icon.${isTs ? "tsx" : "jsx"}`,
validate(value) {
if (value.length === 0) return `Output is required!`;
},
});
}
if (isCancel(output)) process.exit(1);
const outputDir = path.join(cwd, output);
let hrefImportExport = `import href from "./icons/sprite.svg";
export { href };`;
if (framework === "next") {
hrefImportExport = `// Be sure to configure the icon generator to output to the public folder
const href = "/icons/sprite.svg";
export { href };`;
}
const iconFileTs = `
import { type SVGProps } from "react";
// Configure this path in your tsconfig.json
import { type IconName } from "~/icon-name";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
${hrefImportExport}
export { IconName };
const sizeClassName = {
font: "w-[1em] h-[1em]",
xs: "w-3 h-3",
sm: "w-4 h-4",
md: "w-5 h-5",
lg: "w-6 h-6",
xl: "w-7 h-7",
} as const;
type Size = keyof typeof sizeClassName;
const childrenSizeClassName = {
font: "gap-1.5",
xs: "gap-1.5",
sm: "gap-1.5",
md: "gap-2",
lg: "gap-2",
xl: "gap-3",
} satisfies Record<Size, string>;
/**
* Renders an SVG icon. The icon defaults to the size of the font. To make it
* align vertically with neighboring text, you can pass the text as a child of
* the icon and it will be automatically aligned.
* Alternatively, if you're not ok with the icon being to the left of the text,
* you need to wrap the icon and text in a common parent and set the parent to
* display "flex" (or "inline-flex") with "items-center" and a reasonable gap.
*/
export function Icon({
name,
size = "font",
className,
children,
...props
}: SVGProps<SVGSVGElement> & {
name: IconName;
size?: Size;
}) {
if (children) {
return (
<span
className={\`inline-flex items-center \${childrenSizeClassName[size]}\`}
>
<Icon name={name} size={size} className={className} {...props} />
{children}
</span>
);
}
return (
<svg
{...props}
className={twMerge(clsx(sizeClassName[size], "inline self-center", className))}
>
<use href={\`\${href}#\${name}\`} />
</svg>
);
}
`;
const iconFileJs = `
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
${hrefImportExport}
export { IconName };
const sizeClassName = {
font: "w-[1em] h-[1em]",
xs: "w-3 h-3",
sm: "w-4 h-4",
md: "w-5 h-5",
lg: "w-6 h-6",
xl: "w-7 h-7",
};
const childrenSizeClassName = {
font: "gap-1.5",
xs: "gap-1.5",
sm: "gap-1.5",
md: "gap-2",
lg: "gap-2",
xl: "gap-3",
};
/**
* Renders an SVG icon. The icon defaults to the size of the font. To make it
* align vertically with neighboring text, you can pass the text as a child of
* the icon and it will be automatically aligned.
* Alternatively, if you're not ok with the icon being to the left of the text,
* you need to wrap the icon and text in a common parent and set the parent to
* display "flex" (or "inline-flex") with "items-center" and a reasonable gap.
*/
export function Icon({ name, size = "font", className, children, ...props }) {
if (children) {
return (
<span
className={\`inline-flex items-center \${childrenSizeClassName[size]}\`}
>
<Icon name={name} size={size} className={className} {...props} />
{children}
</span>
);
}
return (
<svg
{...props}
className={twMerge(clsx(sizeClassName[size], "inline self-center", className))}
>
<use href={\`\${href}#\${name}\`} />
</svg>
);
}
`;
// Write files
try {
await fs.mkdir(path.dirname(outputDir), { recursive: true });
await fs.writeFile(outputDir, isTs ? iconFileTs : iconFileJs, "utf8");
} catch (err) {
log.error(`Could not write to ${outputDir}`);
log.warn(`You'll need to create an Icon component yourself`);
process.exit(1);
}
// Install dependencies
const dependencies = ["clsx", "tailwind-merge"];
// Detect the package manager
let command = "npm install --save";
if (await fs.stat("yarn.lock").catch(() => false)) {
command = "yarn add";
}
// pnpm
if (await fs.stat("pnpm-lock.yaml").catch(() => false)) {
command = "pnpm add";
}
// bun
if (await fs.stat("bun.lockb").catch(() => false)) {
command = "bun add";
}
const s = spinner();
s.start("Installing dependencies");
try {
await new Promise((res, fail) => {
const op = exec(`${command} ${dependencies.join(" ")}`, (err, stdout) => {
if (err) {
fail(err);
}
res(true);
});
op.addListener("message", (message) => {
message
.toString()
.trim()
.split("\n")
.forEach((line) => {
s.message(line);
});
});
});
s.stop("Installed dependencies");
} catch (err) {
s.stop("Failed to install dependencies");
log.error(err);
process.exit(1);
}
let iconsOutput = `${componentFolder}/icons`;
if (framework === "next") {
iconsOutput = `public/icons`;
}
outro(`Icon component created at ${outputDir}
Be sure to run \`icons build\` to generate the icons. You can also add something like this to your build script:
"build:icons": "icons build -i other/svg-icons -o ${iconsOutput}"
Consider using https://sly-cli.fly.dev to automatically add icons and run the build script for you.
${
isTs
? `
If you're using TypeScript, you'll need to configure your tsconfig.json to include the generated types:
"paths": {
"~/icon-name": ["${iconsOutput}/name.d.ts", "${types}"]
}`
: ""
}`);
}
async function detectFramework() {
// Read the package.json and look for the dependencies
// Check for next.js, remix, or vite
// If none of those are found, ask the user
try {
var packageJson = await parsePackageJson();
} catch {
return "unknown";
}
if (packageJson.dependencies["next"]) return "next";
if (packageJson.dependencies["vite"] || packageJson.devDependencies["vite"])
return "vite";
if (
packageJson.dependencies["remix"] ||
packageJson.dependencies["@remix-run/react"]
)
return "remix";
return "unknown";
}
async function parsePackageJson() {
let dir = process.cwd();
let packageJson;
while (!packageJson) {
console.log(path.join(dir, "package.json"));
try {
packageJson = await fs.readFile(path.join(dir, "package.json"), "utf8");
} catch (err) {
console.log(err);
if (dir === "/") {
throw new Error("Could not find package.json");
}
dir = path.dirname(dir);
}
}
return JSON.parse(packageJson);
}
function iconName(file) {
return file.replace(/\.svg$/, "");
}
/**
* Creates a single SVG file that contains all the icons
*/
async function generateSvgSprite({
files,
inputDir,
outputPath,
shouldOptimize,
}) {
// Each SVG becomes a symbol and we wrap them all in a single SVG
const symbols = await Promise.all(
files.map(async (file) => {
const input = await fs.readFile(path.join(inputDir, file), "utf8");
const root = parse(input);
const svg = root.querySelector("svg");
if (!svg) throw new Error("No SVG element found");
svg.tagName = "symbol";
svg.setAttribute("id", iconName(file));
svg.removeAttribute("xmlns");
svg.removeAttribute("xmlns:xlink");
svg.removeAttribute("version");
svg.removeAttribute("width");
svg.removeAttribute("height");
return svg.toString().trim();
})
);
let output = [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<!-- This file is generated by npm run build:icons -->`,
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="0" height="0">`,
`<defs>`,
...symbols,
`</defs>`,
`</svg>`,
"", // trailing newline
].join("\n");
if (shouldOptimize) {
const config = (await loadConfig()) || { plugins: [] };
if (!config.plugins) {
config.plugins = [];
}
config.plugins.push({
name: "preset-default",
params: {
overrides: {
removeHiddenElems: false,
cleanupIds: false,
convertPathData: {
floatPrecision: 5,
},
},
},
});
output = optimize(output, config).data;
}
return writeIfChanged(outputPath, output);
}
async function writeIfChanged(filepath, newContent) {
const currentContent = await fs.readFile(filepath, "utf8").catch(() => "");
if (currentContent === newContent) return false;
await fs.writeFile(filepath, newContent, "utf8");
return true;
}