🛠️ Create custom generate-registry script to convert SVGs to TSX + generate shadcn/ui registry

This commit is contained in:
pheralb
2025-08-27 18:23:43 +01:00
parent 6c49a2be2c
commit 06040d1427
+401
View File
@@ -0,0 +1,401 @@
import type { iSVG } from "./src/types/svg";
import fs from "fs";
import path from "path";
import { exec } from "child_process";
import { promisify } from "util";
import { svgs } from "./src/data/svgs";
import { optimize } from "svgo";
import { parse, print } from "@swc/core";
const execAsync = promisify(exec);
// ⚙️ Settings:
const MINIFY_TSX = false;
const REGENERATE_ALL = false;
const SVGS_DATA = svgs;
const PUBLIC_FOLDER = "static";
const SHADCN_COMMAND = "shadcn build --output ./static/r";
const OUTPUT_DIR = "./static/components-generated";
// 🛠️ Shadcn Schema:
interface RegistryFile {
path: string;
type: string;
target: string;
}
interface RegistryItem {
name: string;
type: string;
title: string;
registryDependencies: string[];
files: RegistryFile[];
}
interface ShadcnSchema {
$schema: string;
name: string;
homepage: string;
items: RegistryItem[];
}
const shadcnSchema: ShadcnSchema = {
$schema: "https://ui.shadcn.com/schema/registry.json",
name: "svgl",
homepage: "https://svgl.app",
items: [],
};
// 🧑‍🚀 Function to prepare registry.json content:
function prepareRegistryJson(): ShadcnSchema {
const registryItems: RegistryItem[] = [];
SVGS_DATA.forEach((svg) => {
if (!REGENERATE_ALL) return;
const componentName = svg.title
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
const files: RegistryFile[] = [];
const svgPaths = extractSvgPaths(svg);
svgPaths.forEach((svgFile) => {
const tsxComponentName = toComponentName(svgFile.filename);
files.push({
path: `./${OUTPUT_DIR}/${tsxComponentName}.tsx`,
type: "registry:component",
target: `components/ui/svgs/${tsxComponentName}.tsx`,
});
});
if (files.length > 0) {
registryItems.push({
name: componentName,
type: "registry:component",
title: componentName,
registryDependencies: [""],
files: files,
});
}
});
return {
...shadcnSchema,
items: registryItems,
};
}
// 🧑‍🚀 Function to generate registry.json:
async function generateRegistryJson(): Promise<void> {
try {
const registryContent = prepareRegistryJson();
const registryPath = "./registry.json";
await fs.promises.writeFile(
registryPath,
JSON.stringify(registryContent, null, 2),
"utf-8",
);
console.log(
`[📄] File registry.json generated with ${registryContent.items.length} TSX components`,
);
} catch (error) {
console.error("[❌] Error generating registry.json:", error);
throw new Error(error);
}
}
// 🧑‍🚀 Utility functions for extracting SVG paths:
function extractSvgPaths(svg: iSVG): { path: string; filename: string }[] {
const paths: { path: string; filename: string }[] = [];
if (typeof svg.route === "string") {
paths.push({
path: svg.route,
filename: svg.route.split("/").pop() || "",
});
} else if (
typeof svg.route === "object" &&
svg.route.light &&
svg.route.dark
) {
paths.push(
{
path: svg.route.light,
filename: svg.route.light.split("/").pop() || "",
},
{
path: svg.route.dark,
filename: svg.route.dark.split("/").pop() || "",
},
);
}
if (svg.wordmark) {
if (typeof svg.wordmark === "string") {
paths.push({
path: svg.wordmark,
filename: svg.wordmark.split("/").pop() || "",
});
} else if (
typeof svg.wordmark === "object" &&
svg.wordmark.light &&
svg.wordmark.dark
) {
paths.push(
{
path: svg.wordmark.light,
filename: svg.wordmark.light.split("/").pop() || "",
},
{
path: svg.wordmark.dark,
filename: svg.wordmark.dark.split("/").pop() || "",
},
);
}
}
return paths;
}
function getAllSvgFiles(): { path: string; filename: string }[] {
const allPaths: { path: string; filename: string }[] = [];
SVGS_DATA.forEach((svg) => {
const paths = extractSvgPaths(svg);
allPaths.push(...paths);
});
const uniquePaths = allPaths.filter(
(path, index, self) =>
index === self.findIndex((p) => p.filename === path.filename),
);
return uniquePaths;
}
function convertToFilesystemPath(svgPath: string): string {
const cleanPath = svgPath.startsWith("/") ? svgPath.slice(1) : svgPath;
return `./${PUBLIC_FOLDER}/${cleanPath}`;
}
function createSpinner() {
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let current = 0;
let interval;
return {
start(getMessage) {
interval = setInterval(() => {
const message =
typeof getMessage === "function" ? getMessage() : getMessage;
process.stdout.write(`\r${frames[current]} ${message}`);
current = (current + 1) % frames.length;
}, 100);
},
stop() {
if (interval) {
clearInterval(interval);
process.stdout.write("\r");
}
},
};
}
async function convertSvgToReact(svgPath: string): Promise<string> {
const rawSvg = await fs.promises.readFile(svgPath, "utf-8");
const { data: optimizedSvg } = optimize(rawSvg, {
multipass: true,
plugins: [
"removeDimensions",
"removeXMLNS",
"removeDoctype",
"removeComments",
"removeStyleElement",
"cleanupAttrs",
"cleanupEnableBackground",
"cleanupIds",
"minifyStyles",
"removeDoctype",
"removeDesc",
"removeEmptyAttrs",
"removeEmptyText",
"removeHiddenElems",
"removeNonInheritableGroupAttrs",
"removeUnknownsAndDefaults",
"removeUselessDefs",
"removeUselessStrokeAndFill",
"removeXMLProcInst",
{
name: "removeAttrs",
params: { attrs: "(data-name|id|class)" },
},
],
});
const componentName = toComponentName(path.basename(svgPath, ".svg"));
const componentCode = `
import type { SVGProps } from "react";
const ${componentName} = (props: SVGProps<SVGSVGElement>) => {
return ${optimizedSvg.replace("<svg", "<svg {...props}")}
}
export { ${componentName} }
`;
const ast = await parse(componentCode, { syntax: "typescript", tsx: true });
const { code } = await print(ast, { minify: MINIFY_TSX });
return code;
}
function toComponentName(file: string): string {
const name = file.replace(/\.svg$/i, "");
let component = name.replace(/(^\w|[-_]\w)/g, (m) =>
m.replace(/[-_]/, "").toUpperCase(),
);
if (/^\d/.test(component)) {
component = "Icon" + component;
}
const reserved = new Set([
"default",
"class",
"function",
"var",
"export",
"import",
"extends",
"new",
"delete",
"enum",
"package",
]);
if (reserved.has(component)) {
component = "Icon" + component[0].toUpperCase() + component.slice(1);
}
return component;
}
async function cleanupDirectory(dirPath: string) {
try {
if (
await fs.promises
.access(dirPath)
.then(() => true)
.catch(() => false)
) {
await fs.promises.rm(dirPath, { recursive: true, force: true });
console.log(`[🗑️] Folder ${dirPath} deleted successfully`);
}
} catch (error) {
console.warn(`[⚠️] Could not delete folder ${dirPath}: ${error.message}`);
}
}
async function runShadcnBuild() {
try {
console.log("[🔨] Running shadcn build...");
const { stdout, stderr } = await execAsync(SHADCN_COMMAND);
if (stdout) {
console.log("[✅] shadcn build completed:");
console.log(stdout);
}
if (stderr && !stderr.includes("warning")) {
console.error("[❌] Errors in shadcn build:");
console.error(stderr);
}
} catch (error) {
console.error("[❌] Error running shadcn build:", error);
throw new Error(error);
}
}
async function run() {
const spinner = createSpinner();
let convertedCount = 0;
let totalCount = 0;
try {
await fs.promises.mkdir(OUTPUT_DIR, { recursive: true });
const svgFiles = REGENERATE_ALL
? getAllSvgFiles()
: getAllSvgFiles().filter((svgFile) => {
const svgObj = SVGS_DATA.find((svg) => {
const paths = extractSvgPaths(svg);
return paths.some((p) => p.filename === svgFile.filename);
});
return svgObj && !svgObj.shadcnCommand;
});
totalCount = svgFiles.length;
if (totalCount === 0) {
console.log("[❌] No SVG files found in SVGS_DATA.");
return;
}
spinner.start(
() => `[📦] ${convertedCount}/${totalCount} SVGs converted to TSX`,
);
// Process files
for (const svgFile of svgFiles) {
try {
const filesystemPath = convertToFilesystemPath(svgFile.path);
// Check if file exists before processing
const fileExists = await fs.promises
.access(filesystemPath)
.then(() => true)
.catch(() => false);
if (!fileExists) {
console.error(`\n[⚠️] File not found: ${filesystemPath}`);
continue;
}
const tsx = await convertSvgToReact(filesystemPath);
const outPath = path.join(
OUTPUT_DIR,
toComponentName(svgFile.filename) + ".tsx",
);
await fs.promises.writeFile(outPath, tsx, "utf-8");
convertedCount++;
} catch (error) {
console.error(`\n[❌] Error processing ${svgFile.filename}:`, error);
throw new Error(error);
}
}
spinner.stop();
console.log(
`\n[✅] Conversion completed: ${convertedCount}/${totalCount} SVGs processed`,
);
if (convertedCount < totalCount) {
console.log(`[⚠️] ${totalCount - convertedCount} SVGs had errors.`);
}
if (convertedCount > 0) {
await generateRegistryJson();
await runShadcnBuild();
}
} catch (error) {
spinner.stop();
console.error("[❌] Error:", error);
throw new Error(error);
} finally {
await cleanupDirectory(OUTPUT_DIR);
console.log("[🎉] Process completed");
}
}
run();