to-userscript
Version:
Converts simple browser extensions to userscripts
366 lines (321 loc) • 11.3 kB
JavaScript
const { normalizePath, scriptBlacklist } = require("./utils");
const fs = require("fs").promises;
const path = require("path");
const debug = require("debug")("to-userscript:resources");
/**
* Validates that a file exists and is readable
* @param {string} filePath - The file path to validate
* @returns {Promise<void>}
* @throws {Error} If file doesn't exist or isn't readable
*/
async function validateFileAccess(filePath) {
try {
const stats = await fs.stat(filePath);
if (!stats.isFile()) {
throw new Error(`Path exists but is not a file: ${filePath}`);
}
await fs.access(filePath, fs.constants.R_OK);
} catch (error) {
if (error.code === "ENOENT") {
throw new Error(`File not found: ${filePath}`);
} else if (error.code === "EACCES") {
throw new Error(`File is not readable: ${filePath}`);
} else {
throw new Error(
`File access validation failed: ${filePath} - ${error.message}`,
);
}
}
}
async function readScript(filePath) {
try {
if (!filePath || typeof filePath !== "string") {
throw new Error(`Invalid file path provided: ${filePath}`);
}
await validateFileAccess(filePath);
const content = await fs.readFile(filePath, "utf-8");
if (content === null || content === undefined) {
throw new Error(`File content is null or undefined: ${filePath}`);
}
return content;
} catch (error) {
const errorMsg = `Error reading script file ${filePath}: ${error.message}`;
debug("Error reading script file %s: %s", filePath, error.message);
if (
error.message.includes("not found") ||
error.message.includes("not readable")
) {
debug("Returning empty content for inaccessible script: %s", filePath);
return "";
}
throw new Error(errorMsg);
}
}
async function readCSS(filePath) {
try {
if (!filePath || typeof filePath !== "string") {
throw new Error(`Invalid CSS file path provided: ${filePath}`);
}
await validateFileAccess(filePath);
const content = await fs.readFile(filePath, "utf-8");
if (content === null || content === undefined) {
throw new Error(`CSS file content is null or undefined: ${filePath}`);
}
return content;
} catch (error) {
const errorMsg = `Error reading CSS file ${filePath}: ${error.message}`;
debug("Error reading CSS file %s: %s", filePath, error.message);
// For CSS files, missing files are less critical than JS files
if (
error.message.includes("not found") ||
error.message.includes("not readable")
) {
debug("Returning empty content for inaccessible CSS: %s", filePath);
return "";
}
throw new Error(errorMsg);
}
}
/**
* Reads JS and CSS files specified in content script configurations.
* @param {string} baseDir - The root directory of the extension.
* @param {Array<object>} contentScriptConfigs - Array of content script objects from the manifest.
* @returns {Promise<{jsContents: object, cssContents: object}>} - Object containing script and style contents keyed by relative path.
*/
async function readScriptsAndStyles(baseDir, contentScriptConfigs) {
if (!baseDir || typeof baseDir !== "string") {
throw new Error(`Invalid base directory provided: ${baseDir}`);
}
if (!Array.isArray(contentScriptConfigs)) {
throw new Error(
`Content script configs must be an array, got: ${typeof contentScriptConfigs}`,
);
}
const jsContents = {};
const cssContents = {};
const processedJsPaths = new Set();
const processedCssPaths = new Set();
const errors = [];
try {
await validateFileAccess(baseDir);
} catch (error) {
try {
const stats = await fs.stat(baseDir);
if (!stats.isDirectory()) {
throw new Error(`Base directory is not a directory: ${baseDir}`);
}
} catch (dirError) {
throw new Error(
`Base directory is not accessible: ${baseDir} - ${dirError.message}`,
);
}
}
for (const [configIndex, config] of contentScriptConfigs.entries()) {
if (!config || typeof config !== "object") {
debug(
"Skipping invalid content script config at index %d: %o",
configIndex,
config,
);
continue;
}
// Process JS files
if (config.js && Array.isArray(config.js)) {
for (const [jsIndex, jsPath] of config.js.entries()) {
try {
if (!jsPath || typeof jsPath !== "string") {
throw new Error(
`Invalid JS path at config[${configIndex}].js[${jsIndex}]: ${jsPath}`,
);
}
const relativePath = normalizePath(jsPath);
if (!processedJsPaths.has(relativePath)) {
if (
Object.keys(scriptBlacklist).includes(path.basename(relativePath))
) {
debug("Skipping JS: %s (blacklisted)", relativePath);
jsContents[relativePath] =
scriptBlacklist[path.basename(relativePath)];
processedJsPaths.add(relativePath);
} else {
const fullPath = path.join(baseDir, relativePath);
debug("Reading JS: %s", relativePath);
try {
jsContents[relativePath] = await readScript(fullPath);
processedJsPaths.add(relativePath);
} catch (scriptError) {
errors.push(
`Failed to read JS file ${relativePath}: ${scriptError.message}`,
);
// Continue processing other files even if one fails
jsContents[relativePath] = ""; // Use empty content as fallback
processedJsPaths.add(relativePath);
}
}
}
} catch (error) {
const errorMsg = `Error processing JS path at config[${configIndex}].js[${jsIndex}]: ${error.message}`;
errors.push(errorMsg);
debug(
"Error processing JS path at config[%d].js[%d]: %s",
configIndex,
jsIndex,
error.message,
);
}
}
}
// Process CSS files
if (config.css && Array.isArray(config.css)) {
for (const [cssIndex, cssPath] of config.css.entries()) {
try {
if (!cssPath || typeof cssPath !== "string") {
throw new Error(
`Invalid CSS path at config[${configIndex}].css[${cssIndex}]: ${cssPath}`,
);
}
const relativePath = normalizePath(cssPath);
if (!processedCssPaths.has(relativePath)) {
const fullPath = path.join(baseDir, relativePath);
debug("Reading CSS: %s", relativePath);
try {
cssContents[relativePath] = await readCSS(fullPath);
processedCssPaths.add(relativePath);
} catch (cssError) {
errors.push(
`Failed to read CSS file ${relativePath}: ${cssError.message}`,
);
// Continue processing other files even if one fails
cssContents[relativePath] = "";
processedCssPaths.add(relativePath);
}
}
} catch (error) {
const errorMsg = `Error processing CSS path at config[${configIndex}].css[${cssIndex}]: ${error.message}`;
errors.push(errorMsg);
debug(
"Error processing CSS path at config[%d].css[%d]: %s",
configIndex,
cssIndex,
error.message,
);
}
}
}
}
// Log summary of errors if any occurred
if (errors.length > 0) {
debug(
"Encountered %d error(s) while reading scripts and styles:",
errors.length,
);
errors.forEach((error, index) => debug(" %d. %s", index + 1, error));
// Only throw if we couldn't read any files at all
const totalExpectedFiles = contentScriptConfigs.reduce((count, config) => {
return (
count +
(config.js ? config.js.length : 0) +
(config.css ? config.css.length : 0)
);
}, 0);
const totalReadFiles =
Object.keys(jsContents).length + Object.keys(cssContents).length;
if (totalReadFiles === 0 && totalExpectedFiles > 0) {
throw new Error(
`Failed to read any of the ${totalExpectedFiles} expected script/style files. Check file paths and permissions.`,
);
}
}
debug(
"Successfully processed %d JS file(s) and %d CSS file(s)",
Object.keys(jsContents).length,
Object.keys(cssContents).length,
);
return { jsContents, cssContents };
}
/**
* Reads background script files specified in manifest.background.scripts.
* @param {string} baseDir Root directory of the extension.
* @param {Array<string>} bgScripts Array of script paths.
* @returns {Promise<object>} Object mapping normalized path -> script content.
*/
async function readBackgroundScripts(baseDir, bgScripts = []) {
if (!baseDir || typeof baseDir !== "string") {
throw new Error(
`Invalid base directory for background scripts: ${baseDir}`,
);
}
if (!Array.isArray(bgScripts)) {
throw new Error(
`Background scripts must be an array, got: ${typeof bgScripts}`,
);
}
const bgContents = {};
const processed = new Set();
const errors = [];
if (bgScripts.length === 0) {
debug("No background scripts to process");
return bgContents;
}
debug("Processing %d background script(s)...", bgScripts.length);
for (const [index, bgPath] of bgScripts.entries()) {
try {
if (!bgPath || typeof bgPath !== "string") {
throw new Error(
`Invalid background script path at index ${index}: ${bgPath}`,
);
}
const rel = normalizePath(bgPath);
if (processed.has(rel)) {
debug("Skipping duplicate background script: %s", rel);
continue;
}
debug("Reading Background JS: %s", rel);
const full = path.join(baseDir, rel);
try {
bgContents[rel] = await readScript(full);
processed.add(rel);
} catch (scriptError) {
errors.push(
`Failed to read background script ${rel}: ${scriptError.message}`,
);
// Add empty content as fallback
bgContents[rel] = "";
processed.add(rel);
}
} catch (error) {
const errorMsg = `Error processing background script at index ${index}: ${error.message}`;
errors.push(errorMsg);
debug(
"Error processing background script at index %d: %s",
index,
error.message,
);
}
}
// Log summary
if (errors.length > 0) {
debug(
"Encountered %d error(s) while reading background scripts:",
errors.length,
);
errors.forEach((error, index) => debug(" %d. %s", index + 1, error));
// Only throw if we couldn't read any background scripts at all
if (Object.keys(bgContents).length === 0 && bgScripts.length > 0) {
throw new Error(
`Failed to read any of the ${bgScripts.length} expected background scripts. Check file paths and permissions.`,
);
}
}
debug(
"Successfully processed %d background script(s)",
Object.keys(bgContents).length,
);
return bgContents;
}
module.exports = {
readScript,
readCSS,
readScriptsAndStyles,
readBackgroundScripts,
};