@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
JavaScript
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.");
}
}