@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
text/typescript
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 };