to-userscript
Version:
Converts simple browser extensions to userscripts
921 lines (808 loc) • 27.1 kB
JavaScript
const fs = require("fs").promises;
const path = require("path");
const debug = require("debug")("to-userscript:assets");
const { normalizePath, matchGlobPattern } = require("./utils");
/**
* Validates that a file exists and is readable for base64 conversion
* @param {string} filePath - The file path to validate
* @returns {Promise<void>}
* @throws {Error} If file doesn't exist, isn't readable, or is too large
*/
async function validateAssetFile(filePath) {
try {
const stats = await fs.stat(filePath);
if (!stats.isFile()) {
throw new Error(`Asset path exists but is not a file: ${filePath}`);
}
const maxSize = 10 * 1024 * 1024; // 10MB limit for assets
if (stats.size > maxSize) {
throw new Error(
`Asset file is too large (${Math.round(stats.size / 1024 / 1024)}MB > 10MB): ${filePath}`
);
}
await fs.access(filePath, fs.constants.R_OK);
} catch (error) {
if (error.code === "ENOENT") {
throw new Error(`Asset file not found: ${filePath}`);
} else if (error.code === "EACCES") {
throw new Error(`Asset file is not readable: ${filePath}`);
} else {
throw new Error(
`Asset file validation failed: ${filePath} - ${error.message}`
);
}
}
}
/**
* Reads a file and returns its base64-encoded content.
* @param {string} filePath Absolute path to the file.
* @returns {Promise<string>} Base64 string of the file contents.
*/
async function fileToBase64(filePath) {
try {
if (!filePath || typeof filePath !== "string") {
throw new Error(
`Invalid file path provided for base64 conversion: ${filePath}`
);
}
await validateAssetFile(filePath);
const buffer = await fs.readFile(filePath);
if (!buffer || buffer.length === 0) {
debug("Asset file is empty: %s", filePath);
return "";
}
const base64String = buffer.toString("base64");
if (!base64String || base64String.length === 0) {
throw new Error(
`Base64 conversion resulted in empty string for: ${filePath}`
);
}
debug(
"Converted asset to base64: %s (%dKB -> %dKB)",
path.relative(process.cwd(), filePath),
Math.round(buffer.length / 1024),
Math.round(base64String.length / 1024)
);
return base64String;
} catch (error) {
const errorMsg = `Error converting file to base64 ${filePath}: ${error.message}`;
debug("Error converting file to base64 %s: %s", filePath, error.message);
throw new Error(errorMsg);
}
}
/**
* Enhanced mime-type helper based on file extension with better coverage.
* @param {string} p Path or filename.
* @returns {string} Mime type string.
*/
function guessMimeType(p) {
if (!p || typeof p !== "string") {
debug("Invalid path provided for MIME type detection: %s", p);
return "application/octet-stream";
}
const ext = path.extname(p).replace(".", "").toLowerCase() || "";
const mimeMap = {
html: "text/html",
htm: "text/html",
js: "text/javascript",
mjs: "text/javascript",
css: "text/css",
json: "application/json",
txt: "text/plain",
xml: "application/xml",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
svg: "image/svg+xml",
webp: "image/webp",
ico: "image/x-icon",
bmp: "image/bmp",
woff: "font/woff",
woff2: "font/woff2",
ttf: "font/ttf",
otf: "font/otf",
eot: "application/vnd.ms-fontobject",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
mp4: "video/mp4",
webm: "video/webm",
zip: "application/zip",
pdf: "application/pdf",
};
const mimeType = mimeMap[ext] || "application/octet-stream";
if (mimeType === "application/octet-stream" && ext) {
debug(
"Unknown file extension for MIME type detection: .%s (file: %s)",
ext,
p
);
}
return mimeType;
}
/**
* Checks if a file extension represents a text asset
* @param {string} ext - File extension (including the dot)
* @returns {boolean} Whether it's a text asset
*/
function isTextAsset(ext) {
const textExtensions = [
".html",
".htm",
".js",
".mjs",
".css",
".json",
".svg",
".txt",
".xml",
];
return textExtensions.includes(ext.toLowerCase());
}
/**
* Unified Asset Generator class that handles all asset processing
*/
class AssetGenerator {
constructor(extensionRoot, locale, ignoredAssetExtensions = null) {
this.extensionRoot = extensionRoot;
this.processedFiles = new Set();
this.assetsMap = {};
this.locale = locale;
this.PROCESSABLE_ASSET_TYPES = {
HTML: [".html", ".htm"],
CSS: [".css"],
};
// Default ignored patterns
const defaultIgnoredPatterns = [
/\.(woff|woff2|ttf|otf|eot)$/i,
/\.(mp4|webm|ogg|mp3|wav)$/i,
];
// Parse custom ignored extensions if provided
if (ignoredAssetExtensions && typeof ignoredAssetExtensions === "string") {
const customExtensions = ignoredAssetExtensions
.split(",")
.map((ext) => ext.trim().toLowerCase())
.filter((ext) => ext.length > 0)
.map((ext) => (ext.startsWith(".") ? ext : "." + ext));
if (customExtensions.length > 0) {
debug(
"Using custom ignored asset extensions: %s",
customExtensions.join(", ")
);
const customPattern = new RegExp(
`\\.(${customExtensions.map((ext) => ext.replace(".", "")).join("|")})$`,
"i"
);
this.IGNORED_INLINE_PATTERNS = [customPattern];
} else {
this.IGNORED_INLINE_PATTERNS = defaultIgnoredPatterns;
}
} else {
this.IGNORED_INLINE_PATTERNS = defaultIgnoredPatterns;
}
this.REGEX_PATTERNS = {
HTML_ASSETS: /(src|href)\s*=\s*(?:["']([^"']+)["']|([^\s>]+))/gi,
CSS_ASSETS: /url\s*\(\s*["']?([^"')]+)["']?\s*\)/gi,
EXTERNAL_URLS: /^(data:|https?:\/\/|\/\/|#|javascript:|mailto:)/,
};
}
/**
* Checks if an asset should be ignored for inlining
* @param {string} assetPath
* @returns {boolean}
*/
shouldIgnoreAsset(assetPath) {
return this.IGNORED_INLINE_PATTERNS.some((pattern) =>
pattern.test(assetPath)
);
}
/**
* Determines if a file type should be processed for asset extraction
* @param {string} filePath
* @returns {string|null} Asset type or null if not processable
*/
getAssetType(filePath) {
const ext = path.extname(filePath).toLowerCase();
for (const [type, extensions] of Object.entries(
this.PROCESSABLE_ASSET_TYPES
)) {
if (extensions.includes(ext)) {
return type;
}
}
return null;
}
/**
* Validates and resolves asset path
* @param {string} assetPath
* @param {string} currentDir
* @returns {Promise<string>}
*/
async validateAssetPath(assetPath, currentDir) {
if (!assetPath || typeof assetPath !== "string") {
throw new Error(`Invalid asset path: ${assetPath}`);
}
let absAssetPath = path.join(currentDir, assetPath);
const relativePath = path.relative(this.extensionRoot, absAssetPath);
if (relativePath.startsWith("..")) {
throw new Error(
`Asset path resolves outside extension directory: ${assetPath}`
);
}
async function checkFileExists(file) {
return fs
.access(file, fs.constants.F_OK)
.then(() => true)
.catch(() => false);
}
if (!(await checkFileExists(absAssetPath))) {
absAssetPath = path.join(this.extensionRoot, assetPath);
}
try {
const stats = await fs.stat(absAssetPath);
if (!stats.isFile()) {
throw new Error(`Asset path exists but is not a file: ${assetPath}`);
}
} catch (error) {
if (error.code === "ENOENT") {
throw new Error(`Asset file not found: ${absAssetPath}`);
} else if (error.code === "EACCES") {
throw new Error(`Asset file is not readable: ${absAssetPath}`);
} else {
throw error;
}
}
return absAssetPath;
}
/**
* Extracts asset paths from content using unified logic
* @param {string} content
* @param {string} contentType - 'HTML' or 'CSS'
* @returns {Array<string>}
*/
extractAssetPaths(content, contentType) {
try {
if (
!content ||
typeof content !== "string" ||
content.trim().length === 0
) {
return [];
}
const regex =
contentType === "HTML"
? this.REGEX_PATTERNS.HTML_ASSETS
: this.REGEX_PATTERNS.CSS_ASSETS;
const assets = [];
let match;
let matchCount = 0;
const maxMatches = 1000;
while (
(match = regex.exec(content)) !== null &&
matchCount < maxMatches
) {
matchCount++;
const url = contentType === "HTML" ? match[2] : match[1];
if (!url || typeof url !== "string") {
continue;
}
// Skip external URLs using unified pattern
if (this.REGEX_PATTERNS.EXTERNAL_URLS.test(url)) {
continue;
}
assets.push(url);
}
const uniqueAssets = [...new Set(assets)];
debug(
"Extracted %d unique %s asset path(s)",
uniqueAssets.length,
contentType
);
return uniqueAssets;
} catch (error) {
debug("Error extracting %s asset paths: %s", contentType, error.message);
return [];
}
}
/**
* Replaces asset references in content using unified regex patterns
* @param {string} content
* @param {string} assetPath
* @param {string} dataUrl
* @param {string} assetType
* @returns {string}
*/
replaceAssetReferences(content, assetPath, dataUrl, assetType) {
const escapedAssetPath = assetPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (assetType === "CSS" || content.includes("url(")) {
// Replace CSS url() references
const cssRegex = new RegExp(
`url\\s*\\(\\s*["']?${escapedAssetPath}["']?\\s*\\)`,
"gi"
);
content = content.replace(cssRegex, `url("${dataUrl}")`);
}
// Replace HTML src/href references
const htmlRegex = new RegExp(
`(src|href)\\s*=\\s*["']${escapedAssetPath}["']`,
"gi"
);
content = content.replace(htmlRegex, (match) => {
return match.replace(assetPath, dataUrl);
});
return content;
}
/**
* Inlines assets into content
* @param {string} content
* @param {Array<string>} assetPaths
* @param {string} currentDir
* @returns {Promise<string>}
*/
async inlineAssets(content, assetPaths, currentDir) {
try {
let updatedContent = content;
let successCount = 0;
debug("Attempting to inline %d asset(s)...", assetPaths.length);
for (const relativePath of assetPaths) {
try {
if (this.shouldIgnoreAsset(relativePath)) {
debug("Ignoring asset (matches ignore pattern): %s", relativePath);
continue;
}
const absAssetPath = await this.validateAssetPath(
relativePath,
currentDir
);
const assetType = this.getAssetType(absAssetPath);
if (assetType) {
// Recursively process CSS/HTML assets
const assetContent = await fs.readFile(absAssetPath, "utf-8");
const processedContent = await this.processAssetRecursively(
assetContent,
absAssetPath,
assetType
);
const base64 = Buffer.from(processedContent, "utf-8").toString(
"base64"
);
const mime = guessMimeType(relativePath);
const dataUrl = `data:${mime};base64,${base64}`;
updatedContent = this.replaceAssetReferences(
updatedContent,
relativePath,
dataUrl,
assetType
);
successCount++;
debug(
"Successfully inlined and processed %s: %s",
assetType,
relativePath
);
} else {
// Handle binary assets (images, etc.)
const base64 = await fileToBase64(absAssetPath);
const mime = guessMimeType(relativePath);
const dataUrl = `data:${mime};base64,${base64}`;
updatedContent = this.replaceAssetReferences(
updatedContent,
relativePath,
dataUrl,
"BINARY"
);
successCount++;
debug("Successfully inlined binary asset: %s", relativePath);
}
} catch (assetError) {
debug(
"Failed to inline asset %s: %s",
relativePath,
assetError.message
);
}
}
debug(
"Asset inlining complete: %d/%d successful",
successCount,
assetPaths.length
);
return updatedContent;
} catch (error) {
debug("Error during asset inlining: %s", error.message);
return content;
}
}
/**
* Recursively processes assets and their dependencies
* @param {string} content
* @param {string} contentPath
* @param {string} assetType
* @returns {Promise<string>}
*/
async processAssetRecursively(content, contentPath, assetType) {
const normalizedPath = normalizePath(contentPath);
if (this.processedFiles.has(normalizedPath)) {
debug("Skipping already processed file: %s", normalizedPath);
return content;
}
this.processedFiles.add(normalizedPath);
debug("Processing %s file: %s", assetType, normalizedPath);
const assetPaths = this.extractAssetPaths(content, assetType);
if (assetPaths.length === 0) {
debug("No assets found in %s file: %s", assetType, normalizedPath);
return content;
}
return await this.inlineAssets(
content,
assetPaths,
path.dirname(contentPath)
);
}
/**
* Recursively walks a directory and returns all file paths
* @param {string} dir - Directory to walk
* @returns {Promise<Array<string>>} - Array of absolute file paths
*/
async walkDirectory(dir) {
const files = [];
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (
!["node_modules", ".git", ".svn", "_metadata"].includes(entry.name)
) {
const subFiles = await this.walkDirectory(fullPath);
files.push(...subFiles);
}
} else if (entry.isFile()) {
files.push(fullPath);
}
}
} catch (error) {
debug("Error walking directory %s: %s", dir, error.message);
}
return files;
}
/**
* Finds files matching a single glob pattern
* @param {string} pattern - Glob pattern
* @returns {Promise<Array<string>>} - Array of absolute file paths
*/
async findFilesForPattern(pattern) {
const files = [];
if (pattern.includes("*")) {
const allFiles = await this.walkDirectory(this.extensionRoot);
for (const filePath of allFiles) {
const relativePath = normalizePath(
path.relative(this.extensionRoot, filePath)
);
if (matchGlobPattern(pattern, relativePath)) {
files.push(filePath);
}
}
} else {
const exactPath = path.resolve(this.extensionRoot, pattern);
try {
const stats = await fs.stat(exactPath);
if (stats.isFile()) {
files.push(exactPath);
}
} catch (error) {
if (error.code !== "ENOENT") {
debug("Error checking file %s: %s", exactPath, error.message);
}
}
}
return files;
}
/**
* Finds all files that match the given glob patterns
* @param {Array<string>} patterns - Array of glob patterns
* @returns {Promise<Array<string>>} - Array of absolute file paths
*/
async findMatchingFiles(patterns) {
const matchingFiles = [];
for (const pattern of patterns) {
try {
const files = await this.findFilesForPattern(pattern);
matchingFiles.push(...files);
} catch (error) {
debug("Error finding files for pattern %s: %s", pattern, error.message);
}
}
return [...new Set(matchingFiles)];
}
/**
* Gets page path from manifest based on page type and manifest structure
* @param {object} manifest - Parsed manifest object
* @param {string} pageType - 'options' or 'popup'
* @returns {string|null} Relative path to the page or null
*/
getPagePath(manifest, pageType) {
try {
if (!manifest || typeof manifest !== "object") {
debug("Invalid manifest provided to get%sPagePath", pageType);
return null;
}
if (pageType === "options") {
if (
manifest.options_ui &&
typeof manifest.options_ui === "object" &&
manifest.options_ui.page
) {
if (typeof manifest.options_ui.page === "string") {
return manifest.options_ui.page;
} else {
debug(
"options_ui.page exists but is not a string: %s",
typeof manifest.options_ui.page
);
}
}
if (manifest.options_page) {
if (typeof manifest.options_page === "string") {
return manifest.options_page;
} else {
debug(
"options_page exists but is not a string: %s",
typeof manifest.options_page
);
}
}
} else if (pageType === "popup") {
if (
manifest.action &&
typeof manifest.action === "object" &&
manifest.action.default_popup
) {
if (typeof manifest.action.default_popup === "string") {
return manifest.action.default_popup;
} else {
debug(
"action.default_popup exists but is not a string: %s",
typeof manifest.action.default_popup
);
}
}
// Check manifest v2
if (
manifest.browser_action &&
typeof manifest.browser_action === "object" &&
manifest.browser_action.default_popup
) {
if (typeof manifest.browser_action.default_popup === "string") {
return manifest.browser_action.default_popup;
} else {
debug(
"browser_action.default_popup exists but is not a string: %s",
typeof manifest.browser_action.default_popup
);
}
}
if (
manifest.page_action &&
typeof manifest.page_action === "object" &&
manifest.page_action.default_popup
) {
if (typeof manifest.page_action.default_popup === "string") {
return manifest.page_action.default_popup;
} else {
debug(
"page_action.default_popup exists but is not a string: %s",
typeof manifest.page_action.default_popup
);
}
}
}
return null;
} catch (error) {
debug(
"Error extracting %s page path from manifest: %s",
pageType,
error.message
);
return null;
}
}
/**
* Unified method to process HTML pages (options or popup)
* @param {object} manifest - Parsed manifest object
* @param {string} pageType - 'options' or 'popup'
* @returns {Promise<{[pageType + 'PagePath']: string|null}>}
*/
async processHtmlPage(manifest, pageType) {
try {
const pageRel = this.getPagePath(manifest, pageType);
if (!pageRel) {
debug("No %s page declared in manifest.", pageType);
return { [`${pageType}PagePath`]: null };
}
debug("Processing %s page: %s", pageType, pageRel);
const normalizedPageRel = normalizePath(pageRel);
const pageFullPath = path.resolve(this.extensionRoot, normalizedPageRel);
// Validate that the page is within the extension directory
const relativePath = path.relative(this.extensionRoot, pageFullPath);
if (relativePath.startsWith("..")) {
throw new Error(
`${pageType} page path resolves outside extension directory: ${pageRel}`
);
}
// Validate page file exists and is readable
try {
const stats = await fs.stat(pageFullPath);
if (!stats.isFile()) {
throw new Error(
`${pageType} page path exists but is not a file: ${pageRel}`
);
}
await fs.access(pageFullPath, fs.constants.R_OK);
} catch (fileError) {
if (fileError.code === "ENOENT") {
throw new Error(`${pageType} page file not found: ${pageRel}`);
} else if (fileError.code === "EACCES") {
throw new Error(`${pageType} page file is not readable: ${pageRel}`);
} else {
throw new Error(
`${pageType} page file validation failed: ${pageRel} - ${fileError.message}`
);
}
}
const htmlContent = await fs.readFile(pageFullPath, "utf-8");
if (!htmlContent || htmlContent.trim().length === 0) {
debug("%s page file is empty: %s", pageType, pageRel);
}
const updatedHtml = await this.processAssetRecursively(
htmlContent,
pageFullPath,
"HTML"
);
this.assetsMap[normalizedPageRel] = this.locale(updatedHtml);
debug(
"Successfully processed %s page: %s (%dKB)",
pageType,
normalizedPageRel,
Math.round(updatedHtml.length / 1024)
);
return { [`${pageType}PagePath`]: normalizedPageRel };
} catch (error) {
const errorMsg = `Error processing ${pageType} page: ${error.message}`;
debug("Error processing %s page: %s", pageType, error.message);
debug(
"%s page processing failed, continuing without %s page",
pageType,
pageType
);
return { [`${pageType}PagePath`]: null };
}
}
/**
* Processes web accessible resources and adds them to the assets map
* @param {object} manifest - Parsed manifest object
* @returns {Promise<void>}
*/
async processWebAccessibleResources(manifest) {
try {
const webAccessibleResources = manifest.web_accessible_resources || [];
if (webAccessibleResources.length === 0) {
debug("No web accessible resources declared in manifest.");
return;
}
debug("Processing web accessible resources...");
const resourcePatterns = [];
for (const resource of webAccessibleResources) {
if (typeof resource === "string") {
// Manifest v2
resourcePatterns.push(resource);
} else if (resource && Array.isArray(resource.resources)) {
// Manifest v3
resourcePatterns.push(...resource.resources);
}
}
const matchingFiles = await this.findMatchingFiles(resourcePatterns);
debug("Found %d web accessible resource file(s)", matchingFiles.length);
for (const filePath of matchingFiles) {
// --- FIX STARTS HERE ---
const normalizedFilePath = normalizePath(filePath);
if (this.processedFiles.has(normalizedFilePath)) {
debug(
" Skipping web accessible resource (already processed): %s",
path.relative(this.extensionRoot, filePath)
);
continue; // Don't re-process a file that's already been handled
}
// --- FIX ENDS HERE ---
try {
const relativePath = normalizePath(
path.relative(this.extensionRoot, filePath)
);
const ext = path.extname(filePath).toLowerCase();
debug(" Processing web accessible resource: %s", relativePath);
if (ext === ".css") {
const cssContent = await fs.readFile(filePath, "utf-8");
const processedContent = await this.processAssetRecursively(
cssContent,
filePath,
"CSS"
);
this.assetsMap[relativePath] = processedContent;
debug(" Processed CSS with asset inlining: %s", relativePath);
} else if (isTextAsset(ext)) {
const textContent = this.locale(
await fs.readFile(filePath, "utf-8")
);
this.assetsMap[relativePath] = textContent;
// Mark it as processed even if it didn't need inlining itself
this.processedFiles.add(normalizedFilePath);
debug(" Processed text asset: %s", relativePath);
} else {
const base64Content = await fileToBase64(filePath);
this.assetsMap[relativePath] = base64Content;
// Mark it as processed
this.processedFiles.add(normalizedFilePath);
debug(" Processed binary asset: %s", relativePath);
}
} catch (fileError) {
debug(
" Failed to process web accessible resource %s: %s",
filePath,
fileError.message
);
}
}
debug(
"Successfully processed %d web accessible resource(s)",
Object.keys(this.assetsMap).length
);
} catch (error) {
const errorMsg = `Error processing web accessible resources: ${error.message}`;
debug("Error processing web accessible resources: %s", error.message);
debug(
"Web accessible resources processing failed, continuing without them"
);
}
}
/**
* Generates the complete assets map for the extension
* @param {object} manifest - Parsed manifest object
* @returns {Promise<{assetsMap: object, optionsPagePath: string|null, popupPagePath: string|null}>}
*/
async generateAssetsMap(manifest) {
debug("Generating unified assets map...");
this.assetsMap = {};
this.processedFiles.clear();
const optionsPath = this.getPagePath(manifest, "options");
const popupPath = this.getPagePath(manifest, "popup");
if (optionsPath && popupPath && optionsPath === popupPath) {
debug("Options and popup share the same page: %s", optionsPath);
const { popupPagePath } = await this.processHtmlPage(manifest, "popup");
return {
assetsMap: { ...this.assetsMap },
optionsPagePath: popupPagePath,
popupPagePath: popupPagePath,
};
} else {
const { optionsPagePath } = await this.processHtmlPage(
manifest,
"options"
);
const { popupPagePath } = await this.processHtmlPage(manifest, "popup");
await this.processWebAccessibleResources(manifest);
return {
assetsMap: { ...this.assetsMap },
optionsPagePath,
popupPagePath,
};
}
}
}
module.exports = {
fileToBase64,
guessMimeType,
normalizePath,
isTextAsset,
AssetGenerator,
};