UNPKG

@reliverse/rse

Version:

@reliverse/rse is your all-in-one companion for bootstrapping and improving any kind of projects (especially web apps built with frameworks like Next.js) — whether you're kicking off something new or upgrading an existing app. It is also a little AI-power

231 lines (230 loc) 6.75 kB
import path from "@reliverse/pathkit"; import fs from "@reliverse/relifso"; import { relinka } from "@reliverse/relinka"; import escapeStringRegexp from "escape-string-regexp"; export function extractRepoInfo(templateUrl) { const formattedTemplateUrl = templateUrl.startsWith("github:") ? templateUrl : `github:${templateUrl}`; const match = /^github:(?:https?:\/\/github\.com\/)?([^/]+)\/([^/]+)/.exec( formattedTemplateUrl ); if (!match) { return { inputRepoAuthor: "", inputRepoName: "" }; } const [, repoAuthor, repoName] = match; const inputRepoAuthor = repoAuthor ?? ""; const inputRepoName = repoName?.replace(".git", "") ?? ""; return { inputRepoAuthor, inputRepoName }; } function looksLikeBinary(buffer, size) { for (let i = 0; i < size; i++) { if (buffer[i] === 0) return true; } return false; } async function isBinaryFile(filePath, chunkSize = 1e3) { const fd = await fs.open(filePath, "r"); try { const buffer = Buffer.alloc(chunkSize); const { bytesRead } = await fd.read(buffer, 0, chunkSize, 0); return looksLikeBinary(buffer, bytesRead); } finally { await fd.close(); } } async function gatherAllFiles(dir, shouldSkipDir) { const filesInDir = await fs.readdir(dir); const result = []; for (const file of filesInDir) { const fullPath = path.join(dir, file); const stat = await fs.lstat(fullPath); if (stat.isDirectory()) { if (!shouldSkipDir(file)) { const nested = await gatherAllFiles(fullPath, shouldSkipDir); result.push(...nested); } } else { result.push(fullPath); } } return result; } async function runWithConcurrency(items, concurrency, taskFn, stopOnError) { return new Promise((resolve, reject) => { let index = 0; let active = 0; let isRejected = false; let completedCount = 0; const next = () => { if (isRejected) return; if (completedCount === items.length) { return resolve(); } while (active < concurrency && index < items.length) { const currentItem = items[index]; if (currentItem === void 0) { index++; continue; } active++; index++; taskFn(currentItem).catch((err) => { if (stopOnError) { isRejected = true; return reject( err instanceof Error ? err : new Error(String(err)) ); } else { relinka("error", `Error processing item: ${String(err)}`); } }).finally(() => { active--; completedCount++; next(); }); } }; next(); }); } export async function replaceStringsInFiles(projectPath, oldValues, config = {}) { if (!projectPath || typeof projectPath !== "string") { throw new Error("Target directory is required and must be a string"); } if (!oldValues || typeof oldValues !== "object") { throw new Error("oldValues must be a non-null object"); } const { fileExtensions = [ ".js", ".ts", ".json", ".md", ".mdx", ".html", ".jsx", ".tsx", ".css", ".scss", ".mjs", ".cjs" ], excludedDirs = [ "node_modules", ".git", "build", ".next", "dist", "dist-jsr", "dist-npm", "coverage" ], stringExclusions = [ "https://api.github.com/repos/blefnk/relivator", "https://api.github.com/repos/blefnk/relivator-nextjs-template", "https://api.github.com/repos/blefnk/versator", "https://api.github.com/repos/blefnk/versator-nextjs-template" ], verbose = false, dryRun = false, skipBinaryFiles = false, maxConcurrency = 8, stopOnError = false } = config; const exactFileNames = /* @__PURE__ */ new Set(); const extensionSet = /* @__PURE__ */ new Set(); for (const pattern of fileExtensions) { if (pattern.startsWith(".")) { extensionSet.add(pattern.toLowerCase()); } else { exactFileNames.add(pattern.toLowerCase()); } } function shouldProcessFile(filePath) { const base = path.basename(filePath).toLowerCase(); const ext = path.extname(base).toLowerCase(); if (exactFileNames.has(base)) { return true; } return extensionSet.has(ext); } function shouldSkipDirectory(dirName) { return excludedDirs.includes(dirName); } async function replaceInFile(filePath) { try { if (skipBinaryFiles && await isBinaryFile(filePath)) { verbose && relinka("verbose", `Skipping binary file: ${filePath}`); return; } const fileContent = await fs.readFile(filePath, "utf8"); let newContent = fileContent; let hasChanges = false; const changesMade = []; for (const [key, value] of Object.entries(oldValues)) { if (!key || !value || stringExclusions.includes(key)) continue; const safeKey = escapeStringRegexp(key); const regex = new RegExp(safeKey, "g"); let localChangeCount = 0; newContent = newContent.replace(regex, (_match) => { hasChanges = true; localChangeCount++; return value; }); if (localChangeCount > 0) { changesMade.push(`${key} => ${value} (${localChangeCount}x)`); } } if (hasChanges) { if (!dryRun) { await fs.writeFile(filePath, newContent, "utf8"); } if (verbose) { const relativePath = path.relative(projectPath, filePath); relinka("verbose", `Updated ${relativePath}:`); changesMade.forEach((c) => relinka("verbose", ` - ${c}`)); } else { relinka("verbose", `Updated strings in ${filePath}`); } } } catch (error) { throw new Error(`Error processing file ${filePath}: ${String(error)}`); } } const errors = []; const exists = await fs.pathExists(projectPath); if (!exists) { throw new Error(`Target directory does not exist: ${projectPath}`); } let allFiles = []; try { allFiles = await gatherAllFiles(projectPath, shouldSkipDirectory); } catch (err) { const e = `Error reading directory structure: ${String(err)}`; errors.push(e); relinka("error", e); } const targetFiles = allFiles.filter( (filePath) => shouldProcessFile(filePath) ); try { await runWithConcurrency( targetFiles, maxConcurrency, replaceInFile, stopOnError ); } catch (err) { errors.push(String(err)); } if (errors.length > 0) { relinka( "error", `Some files could not be processed: ${errors.join(", ")}` ); throw new Error("Failed to replace strings in some files."); } }