// Name: SVG to React TSX // Description: Convert an SVG into a React TypeScript component with title and root-only 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 cleanRootAttrs(attrs: string) { // Remove existing fill/class/className/width/height on root <svg> let a = attrs .replace(/\s(fill|class|className|width|height)\s*=\s*"(?:[^"]*)"/gi, "") .replace(/\s(fill|class|className|width|height)\s*=\s*'(?:[^']*)'/gi, "") .replace(/\s+/g, " ") .trim() return a } function stripInnerFillAttributes(content: string) { // Remove fill="..." and fill='...' from all inner tags // Note: excludes '>' to avoid over-matching return content.replace(/\s+fill\s*=\s*(['"])[^'">]*\1/gi, "") } function stripFillInStyle(content: string) { // Remove "fill: ..." from style="..." declarations. If style becomes empty, remove style attr. return content.replace(/style\s*=\s*(['"])([\s\S]*?)\1/gi, (m, q, style: string) => { let newStyle = style.replace(/(^|;)\s*fill\s*:\s*[^;]+;?/gi, (s, pre) => (pre ? pre : "")) // Clean up duplicated semicolons/spaces newStyle = newStyle.replace(/;;+/g, ";").replace(/^\s*;|;\s*$/g, "").trim() if (!newStyle) return "" return `style=${q}${newStyle}${q}` }) } 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> let attrs = openTagMatch[1] || "" attrs = cleanRootAttrs(attrs) // Build new opening tag with className/fill and {...props} const newOpen = `<svg${attrs ? " " + attrs : ""} className={className} fill={fill || 'currentColor'} {...props}>` // Get remainder after original open tag const afterOpenIndex = svg.indexOf(openTagMatch[0]) + openTagMatch[0].length let remainder = svg.slice(afterOpenIndex) // Remove inner fills from all child elements and in style attributes remainder = stripInnerFillAttributes(remainder) remainder = stripFillInStyle(remainder) // Reassemble SVG with title directly after new open tag svg = `${newOpen} \t\t\t<title>${titleText}</title>${remainder}` // Convert known kebab-case attributes to React-friendly names svg = kebabToReactAttr(svg) // Build TSX component const tsx = `import type { SVGProps } from 'react'; type Props = Omit<SVGProps<SVGSVGElement>, 'fill'> & { fill?: string }; export default function ${componentName}({ fill, className, ...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.", })