UNPKG

@react-lib-tech/react-jsx-to-tsx

Version:

A tiny React component library (JS) for converting TSX → JSX and extracting text

288 lines (242 loc) 8.9 kB
import axios from 'axios'; import fs from 'fs'; import path from 'path'; import https from 'https'; import babel from '@babel/core'; const httpsAgent = new https.Agent({ rejectUnauthorized: false }); async function LovableDownload({ API_URL = "https://lovable-api.com/projects/5b9f446c-d608-4948-aed1-7e5ba9ad04c0/source-code", Authorization = "" }) { try { // 1. Fetch JSON from API const res = await axios.get(API_URL, { httpsAgent, headers: { Authorization: Authorization, }, }); const data = res?.data; // already JSON for (const file of data.files) { const filePath = path.join(process.cwd(), file.name); if (file.binary) { console.log(`⚠️ Skipping binary file: ${file.name}`); continue; } // 3. Ensure folder path exists fs.mkdirSync(path.dirname(filePath), { recursive: true }); // 4. Write file contents fs.writeFileSync(filePath, file.contents, "utf8"); console.log(`✅ Created: ${filePath}`); } console.log("🎉 Project restored with correct folder structure!"); } catch (err) { console.error("❌ Error:", err.message); } } const presets = [ [ "@babel/preset-typescript", { isTSX: true, allExtensions: true, }, ], ]; /** * 📌 Load path aliases from tsconfig.json */ function loadTsConfigAliases() { const tsconfigPath = path.resolve("tsconfig.json"); if (!fs.existsSync(tsconfigPath)) return {}; try { const tsconfig = JSON.parse(fs.readFileSync(tsconfigPath, "utf-8")); const paths = tsconfig.compilerOptions?.paths || {}; const baseUrl = tsconfig.compilerOptions?.baseUrl || "."; const aliases = {}; for (const alias in paths) { const target = paths[alias][0].replace(/\/\*$/, ""); const aliasKey = alias.replace(/\/\*$/, ""); aliases[aliasKey] = path.resolve(baseUrl, target); } return aliases; } catch { console.warn("⚠️ Could not parse tsconfig.json paths"); return {}; } } /** * 🔄 Fix JSX attributes for React/TSX */ function fixJsxAttributes(code) { return code .replace(/\bclass=/g, "className=") .replace(/\bfor=/g, "htmlFor=") .replace(/\bonclick=/gi, "onClick=") .replace(/\bonchange=/gi, "onChange=") .replace(/\bonfocus=/gi, "onFocus=") .replace(/\bonblur=/gi, "onBlur="); } /** * 🏷️ Convert PropTypes -> TS interface */ function extractPropTypes(code, componentName) { const propTypesRegex = new RegExp( `${componentName}\\.propTypes\\s*=\\s*{([\\s\\S]*?)};?`, "m" ); const match = code.match(propTypesRegex); if (!match) return null; const propsBlock = match[1]; const lines = propsBlock.split("\n").map((l) => l.trim()).filter(Boolean); const fields = lines.map((line) => { const [rawKey, rawType] = line.replace(/[,}]/g, "").split(":").map((s) => s.trim()); let tsType = "any"; let optional = true; if (/isRequired/.test(rawType)) optional = false; if (/string/.test(rawType)) tsType = "string"; else if (/number/.test(rawType)) tsType = "number"; else if (/bool/.test(rawType)) tsType = "boolean"; else if (/array/.test(rawType)) tsType = "any[]"; else if (/object/.test(rawType)) tsType = "Record<string, any>"; else if (/func/.test(rawType)) tsType = "() => void"; return ` ${rawKey}${optional ? "?" : ""}: ${tsType};`; }); return `\ninterface ${componentName}Props {\n${fields.join("\n")}\n}\n\n`; } /** * 🏷️ Extract defaultProps values */ function extractDefaultProps(code, componentName) { const defaultPropsRegex = new RegExp( `${componentName}\\.defaultProps\\s*=\\s*{([\\s\\S]*?)};?`, "m" ); const match = code.match(defaultPropsRegex); if (!match) return {}; const propsBlock = match[1]; const lines = propsBlock.split("\n").map((l) => l.trim()).filter(Boolean); const defaults = {}; for (const line of lines) { const [key, value] = line.replace(/[,}]/g, "").split(":").map((s) => s.trim()); if (key && value) defaults[key] = value; } return defaults; } /** * 🏷️ Add interface for function & arrow components */ function addInterfaceForProps(code, defaults) { const funcRegex = /function\s+([A-Z][A-Za-z0-9_]*)\s*\(\s*{([^}]*)}\s*\)/; const arrowRegex = /const\s+([A-Z][A-Za-z0-9_]*)\s*=\s*\(\s*{([^}]*)}\s*\)\s*=>/; let match = code.match(funcRegex) || code.match(arrowRegex); if (!match) return code; const componentName = match[1]; const propsRaw = match[2].split(",").map((p) => p.trim()).filter(Boolean); if (propsRaw.length === 0) return code; const interfaceName = `${componentName}Props`; const interfaceFields = propsRaw.map((p) => ` ${p}?: any;`).join("\n"); const interfaceCode = `\ninterface ${interfaceName} {\n${interfaceFields}\n}\n\n`; // Apply defaults const withDefaults = propsRaw .map((p) => (defaults[p] ? `${p} = ${defaults[p]}` : p)) .join(", "); if (funcRegex.test(code)) { code = code.replace( funcRegex, `function ${componentName}({ ${withDefaults} }: ${interfaceName}): JSX.Element` ); } else { code = code.replace( arrowRegex, `const ${componentName}: React.FC<${interfaceName}> = ({ ${withDefaults} }) =>` ); } if (!code.includes(`interface ${interfaceName}`)) { code = interfaceCode + code; } if (/React\.FC/.test(code) && !/import\s+.*React/.test(code)) { code = `import React from "react";\n` + code; } return code; } /** * 🏷️ Add typing for forwardRef components */ function addTypesForForwardRef(code) { const forwardRefRegex = /const\s+([A-Z][A-Za-z0-9_]*)\s*=\s*React\.forwardRef\(\s*\(([^)]*)\)\s*=>/; const match = code.match(forwardRefRegex); if (!match) return code; const componentName = match[1]; return code.replace( forwardRefRegex, `const ${componentName} = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(($2) =>` ); } /** * 📄 Convert single file */ async function jsxToTsx(srcPath, outPath) { try { const code = fs.readFileSync(srcPath, "utf-8"); const result = await babel.transformAsync(code, { filename: srcPath, presets }); if (!result?.code) throw new Error("Babel failed"); let output = result.code; // Detect component const componentMatch = output.match(/(function|const)\s+([A-Z][A-Za-z0-9_]*)/); const componentName = componentMatch ? componentMatch[2] : null; let defaults = {}; if (componentName) { defaults = extractDefaultProps(code, componentName); output = addInterfaceForProps(output, defaults); const interfaceFromPT = extractPropTypes(code, componentName); if (interfaceFromPT && !output.includes(`interface ${componentName}Props`)) { output = interfaceFromPT + output; } } // ✨ ForwardRef support output = addTypesForForwardRef(output); // ✨ Fix JSX attributes output = fixJsxAttributes(output); // 🔄 Replace aliases dynamically const aliases = loadTsConfigAliases(); for (const alias in aliases) { const target = aliases[alias]; const aliasRegex = new RegExp(`${alias}/`, "g"); output = output.replace(aliasRegex, target + "/"); } // 🧹 Remove propTypes & defaultProps if (componentName) { const propTypesRegex = new RegExp(`${componentName}\\.propTypes\\s*=\\s*{[\\s\\S]*?};?`, "m"); const defaultPropsRegex = new RegExp(`${componentName}\\.defaultProps\\s*=\\s*{[\\s\\S]*?};?`, "m"); output = output.replace(propTypesRegex, ""); output = output.replace(defaultPropsRegex, ""); } fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, output, "utf-8"); console.log(`✅ Converted: ${srcPath}${outPath}`); } catch (err) { console.error(`❌ Error converting ${srcPath}: ${err.message}`); } } /** * 📂 Walk folders */ async function convertFolder(srcFolder, outFolder) { const entries = fs.readdirSync(srcFolder, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(srcFolder, entry.name); const outPath = path.join(outFolder, entry.name); if (entry.isDirectory()) { await convertFolder(srcPath, outPath); } else if (entry.isFile()) { if (entry.name.endsWith(".jsx")) { await jsxToTsx(srcPath, outPath.replace(/\.jsx$/, ".tsx")); } else if (entry.name.endsWith(".js")) { await jsxToTsx(srcPath, outPath.replace(/\.js$/, ".ts")); } } } } export { LovableDownload, convertFolder };