// Name: SVG to React TSX // Description: Convert an SVG into a React TypeScript component with title and fill prop. // Author: benschlegel // GitHub: benschlegel import "@johnlindquist/kit" const toPascalCase = (str: string) => str .replace(/[_\-]+/g, " ") .replace(/[^a-zA-Z0-9 ]/g, " ") .replace(/\s+(\w)/g, (_, c) => c.toUpperCase()) .replace(/^\s*(\w)/, (_, c) => c.toUpperCase()) .replace(/\s+/g, "") const deriveTitleFromName = (name: string) => name.replace(/Icon$/i, "").replace(/^Svg/i, "") const kebabToReactAttr = (svg: string) => { const map: Record<string, string> = { "class=": "className=", "clip-path=": "clipPath=", "fill-rule=": "fillRule=", "clip-rule=": "clipRule=", "stroke-width=": "strokeWidth=", "stroke-linecap=": "strokeLinecap=", "stroke-linejoin=": "strokeLinejoin=", "stroke-miterlimit=": "strokeMiterlimit=", "stroke-dasharray=": "strokeDasharray=", "stroke-dashoffset=": "strokeDashoffset=", "stop-color=": "stopColor=", "stop-opacity=": "stopOpacity=", "font-family=": "fontFamily=", "font-size=": "fontSize=", "text-anchor=": "textAnchor=", "xlink:href=": "xlinkHref=", "xml:space=": "xmlSpace=", } let out = svg for (const [k, v] of Object.entries(map)) { const re = new RegExp(`\\b${k.replace(/[-:[\]]/g, m => `\\${m}`)}`, "g") out = out.replace(re, v) } return out } function ensureRootFillOnOpenTag(attrs: string) { // remove any existing fill attr on root svg let a = attrs .replace(/\s(fill)\s*=\s*"(?:[^"]*)"/gi, "") .replace(/\s(fill)\s*=\s*'(?:[^']*)'/gi, "") .trim() return a } function replaceBlackFills(svg: string) { // Replace fill="black" or fill='#000' with fill={fill} let out = svg.replace(/\bfill\s*=\s*(['"])black\1/gi, "fill={fill}") out = out.replace(/\bfill\s*=\s*(['"])(#000|#000000)\1/gi, "fill={fill}") return out } function transformSvgToReact(svgRaw: string, componentName: string, titleText: string) { let svg = svgRaw.trim() // Normalize spacing svg = svg.replace(/\r\n/g, "\n") // Ensure we have an opening <svg ...> to work with const openTagMatch = svg.match(/<svg\b([^>]*)>/i) if (!openTagMatch) throw new Error("No <svg> tag found in input.") // Clean attributes on <svg>: remove width/height and manage fill let attrs = openTagMatch[1] || "" attrs = attrs .replace(/\s(width|height)\s*=\s*"(?:[^"]*)"/gi, "") .replace(/\s(width|height)\s*=\s*'(?:[^']*)'/gi, "") .replace(/\s+/g, " ") .trim() attrs = ensureRootFillOnOpenTag(attrs) // Rebuild opening tag with fill and {...props} const newOpen = `<svg${attrs ? " " + attrs : ""} fill={fill} {...props}>` // Replace opening tag svg = svg.replace(openTagMatch[0], newOpen) // Insert <title> after opening tag svg = svg.replace(newOpen, `${newOpen}\n\t\t\t<title>${titleText}</title>`) // Convert known kebab-case attributes to React-friendly names svg = kebabToReactAttr(svg) // Replace black fills within the SVG content svg = replaceBlackFills(svg) // Build TSX component const tsx = `import type { SVGProps } from 'react'; type Props = SVGProps<SVGSVGElement> & { fill?: string }; export default function ${componentName}({ fill = '#000', ...props }: Props) { \treturn ( \t\t${svg} \t); } ` return tsx } // Try clipboard first let svgInput = await paste() let useClipboard = false if (svgInput && /<svg\b/i.test(svgInput)) { useClipboard = (await arg("Detected SVG in clipboard. Use it?", ["Yes", "No"])) === "Yes" } if (!useClipboard) { const method = await arg("Provide SVG", ["Paste in Editor", "Drop .svg File"]) if (method === "Drop .svg File") { let dropped: any = await drop({ placeholder: "Drop an SVG file", enter: "Use File", hint: "Drop a single .svg file here", }) if (typeof dropped === "string") { if (/<svg\b/i.test(dropped)) { svgInput = dropped } else { await notify("Dropped text is not valid SVG.") exit() } } else if (Array.isArray(dropped) && dropped.length) { const file = dropped[0] if (!file?.path || !/\.svg$/i.test(file.path)) { await notify("Please drop a single .svg file.") exit() } svgInput = await readFile(file.path, "utf8") } else { await notify("No file dropped.") exit() } } else { svgInput = await editor({ value: "", hint: "Paste SVG here, then submit", onEscape: input => submit(input), }) } } if (!svgInput || !/<svg\b/i.test(svgInput)) { await notify("No valid SVG input provided.") exit() } const suggestedName = toPascalCase( (svgInput.match(/<title>([^<]+)<\/title>/i)?.[1] || svgInput.match(/id="([^"]+)"/i)?.[1] || "SvgIcon") + "Icon" ) const componentName = toPascalCase( await arg({ placeholder: "Component Name (e.g., PixelChevronDownIcon)", input: suggestedName, }) ) const titleText = await arg({ placeholder: "Title text", input: deriveTitleFromName(componentName), }) let result = "" try { result = transformSvgToReact(svgInput, componentName, titleText) } catch (e: any) { await editor(`Error: ${e?.message || e}\n\nInput Preview:\n${svgInput.slice(0, 500)}...`) exit() } await copy(result) await notify("TSX component copied to clipboard.") await editor({ value: result, hint: "Saved to clipboard. Edit or save as needed.", })