@react-lib-tech/react-tsx-to-jsx
Version:
A tiny React component library (JS) for converting TSX → JSX and extracting text
223 lines (190 loc) • 6.72 kB
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 });
const API_URL = "https://lovable-api.com/projects/";
/**
* LovableDownload options:
* - API_URL: string (source URL)
* - Authorization: string (auth header)
* - outputDir: string (where to write files) - defaults to process.cwd()
* - allowBinary: boolean - if true, will write binary files (assumed base64). Default false.
*/
async function LovableDownload({
PROJECT_Id = "5b9f446c-d608-4948-aed1-7e5ba9ad04c0",
Authorization = "",
outputDir = process.cwd(),
allowBinary = false
} = {}) {
// resolve and normalize output directory
const baseOut = path.resolve(outputDir);
// helper to ensure destination stays inside outputDir
function safeResolve(rel) {
// normalize incoming path so ../ segments are collapsed
const resolved = path.resolve(baseOut, rel);
// allow exactly baseOut or anything under it
if (resolved === baseOut || resolved.startsWith(baseOut + path.sep)) {
return resolved;
}
throw new Error(`Unsafe path detected: ${rel}`);
}
try {
// 1. Fetch JSON from API
const res = await axios.get(API_URL+PROJECT_Id+"/source-code", {
httpsAgent,
headers: { Authorization: Authorization },
});
const data = res?.data;
if (!data || !Array.isArray(data.files)) {
console.log("No files returned by API");
return;
}
for (const file of data?.files) {
try {
// compute the destination path safely (prevents ../ traversal)
const destPath = safeResolve(file.name);
if (file.binary && !allowBinary) {
console.log(`⚠️ Skipping binary file: ${file.name}`);
continue;
}
// ensure parent folder exists
fs.mkdirSync(path.dirname(destPath), { recursive: true });
// write file
if (file.binary) {
// assume base64-encoded binary content
const contents = file.contents ?? "";
try {
const buffer = Buffer.from(contents, "base64");
fs.writeFileSync(destPath, buffer);
} catch (e) {
console.warn(`⚠️ Failed to write binary file ${file.name}: ${e.message}`);
continue;
}
} else {
// ensure we write string data
const contents = typeof file.contents === "string" ? file.contents : JSON.stringify(file.contents ?? "", null, 2);
fs.writeFileSync(destPath, contents, "utf8");
}
console.log(`✅ Created: ${destPath}`);
} catch (innerErr) {
console.warn(`⚠️ Skipped file ${file.name}: ${innerErr.message}`);
}
}
console.log("🎉 Project restored with correct folder structure!");
console.log(`Files written to: ${baseOut}`);
} catch (err) {
console.error("❌ Error:", err?.message ?? err);
}
}
// ✅ Load and parse .gitignore manually
function loadGitignore(rootPath = ".") {
const gitignorePath = path.join(rootPath, ".gitignore");
if (!fs.existsSync(gitignorePath)) return [];
return fs
.readFileSync(gitignorePath, "utf8")
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"));
}
// ✅ Convert gitignore patterns → regex
function patternToRegex(pattern) {
let regexStr = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex special chars
.replace(/\*\*/g, ".*") // ** → deep any
.replace(/\*/g, "[^/]*"); // * → single folder/file
if (pattern.endsWith("/")) {
regexStr = regexStr + ".*"; // match everything under folder
}
return new RegExp(regexStr);
}
function createIgnoreMatcher(rootPath, userSkips = []) {
const patterns = [
...loadGitignore(rootPath).map(patternToRegex),
...userSkips.map(patternToRegex),
];
return function isIgnored(relPath) {
// Normalize to forward-slashes
const normalized = relPath.replace(/\\/g, "/");
return patterns.some((regex) => regex.test(normalized));
};
}
// ✅ Default Replacement Rules
const defaultReplacementRules = [
{ find: /@\/components\//g, replace: "../components/" },
{ find: /@\/lib\//g, replace: "../../lib/" },
{ find: /@\/utils\//g, replace: "../utils/" },
{ find: /@\/assets\//g, replace: "../public/assets/" },
];
// ✅ Convert single file
async function tsxToJsx(
tsPath,
outPath,
isTsx,
InputreplacementRules = []
) {
try {
const code = fs.readFileSync(tsPath, "utf-8");
const result = await babel.transformAsync(code, {
filename: tsPath,
presets: ["@babel/preset-typescript"],
});
let output = result.code || "";
const replacementRules = [
...defaultReplacementRules,
...InputreplacementRules,
];
replacementRules.forEach((rule) => {
output = output.replace(rule.find, rule.replace);
});
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, output, "utf-8");
console.log(`✅ Converted: ${tsPath} -> ${outPath}`);
} catch (err) {
console.error(`❌ Error converting ${tsPath}:`, err.message);
}
}
// ✅ Walk folders recursively
const convertFolder = async ({
srcFolder = "src",
outFolder = "dist",
InputreplacementRules = [],
typeConvert = ".jsx",
skipFiles = [],
}) => {
if (!fs.existsSync(srcFolder)) {
console.error(`❌ Source folder not found: ${srcFolder}`);
return;
}
const isIgnored = createIgnoreMatcher(process.cwd(), skipFiles);
const entries = fs.readdirSync(srcFolder, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(srcFolder, entry.name);
const relPath = path.relative(process.cwd(), srcPath); // relative to root
// 🔄 Skip gitignored OR user-specified
if (isIgnored(relPath)) {
console.log(`⏭️ Skipped: ${relPath}`);
continue;
}
const outPath = path.join(outFolder, entry.name);
if (entry.isDirectory()) {
await convertFolder({
srcFolder: srcPath,
outFolder: outPath,
InputreplacementRules,
typeConvert,
skipFiles,
});
} else if (entry.isFile()) {
if (entry.name.endsWith(".tsx")) {
const jsxPath = outPath.replace(/\.tsx$/, typeConvert);
await tsxToJsx(srcPath, jsxPath, true, InputreplacementRules);
} else if (entry.name.endsWith(".ts")) {
const jsPath = outPath.replace(/\.ts$/, ".js");
await tsxToJsx(srcPath, jsPath, false, InputreplacementRules);
}
}
}
};
export { LovableDownload, convertFolder };