const fs = require("fs");
const path = require("path");
const rollup = require("rollup");
const commonjs = require("@rollup/plugin-commonjs");
const nodeResolve = require("@rollup/plugin-node-resolve").nodeResolve;
const jsdom = require("jsdom");
const { JSDOM } = jsdom;
/**
* The WebScience Rollup plugin. This plugin is necessary because certain WebScience assets (e.g., content
* scripts and HTML) are not handled by ordinary Rollup bundling. Developers building browser extensions
* with WebScience should, for all anticipated use cases, be able to use the Rollup plugin as-is.
*
* The plugin involves the following steps.
* * Identify WebScience asset dependencies, which are import statements that begin with an "include:" schema.
* * Resolve each WebScience asset dependency to a string containing an output path relative to the extension
* base directory. This format is required by WebExtensions APIs.
* * Allow Rollup tree shaking to occur, so that only necessary asset dependencies are bundled.
* * If an asset dependency is a content script (ending in .content.js), use Rollup to bundle the content script
* to the output directory in IIFE format, with support for Node module resolution and CommonJS module wrapping.
* This step allows WebScience content scripts to use components of the library and npm dependencies, and it
* makes debugging content script behavior straightforward.
* * If an asset dependency is an HTML file (ending in .html), copy the file to the output directory, parse the file,
* identify script and stylesheet dependencies (`` and ``),
* and copy those additional dependencies to the output directory. The plugin currently only supports additional HTML
* dependencies that are in the same source directory as the HTML file.
* * If an asset dependency is not one of the above types, copy the file to the output directory.
* @param {Object} [options] - Options for the plugin.
* @param {string} [options.manifestPath="./manifest.json"] - The path to the WebExtensions manifest.json, either
* absolute or relative to the current working directory. The plugin requires this path so that it can generate
* paths relative to the extension base directory for WebExtensions APIs.
* @param {string} [options.outputDirectory="./dist/webScience/"] - The directory where the plugin should output
* required dependencies, either absolute or relative to the current working directory. This directory must
* be equal to or a subdirectory of the directory containing the WebExtensions manifest.json.
* @returns {Object} - The generated Rollup plugin.
*/
module.exports = function webScienceRollupPlugin({
manifestPath = "./manifest.json",
outputDirectory = "./dist/webScience/"
} = {
manifestPath: "./manifest.json",
outputDirectory: "./dist/webScience/"
}) {
const includeScheme = "include:";
// If the manifest path or output directory is relative, convert it to absolute with the current working directory
manifestPath = path.resolve(process.cwd(), manifestPath);
const manifestDirectory = path.dirname(manifestPath);
outputDirectory = path.resolve(process.cwd(), outputDirectory) + path.sep;
// Check that the output directory is either the manifest directory or a subdirectory of the manifest directory
if(path.relative(manifestDirectory, outputDirectory).startsWith("..")) {
throw new Error("Error: the Webscience Rollup plugin requires that the output directory be either the same as the extension manifest directory or a subdirectory of that directory.");
}
// Check that the WebExtensions manifest path is correct, since we need to generate paths relative to the manifest
if(!fs.existsSync(manifestPath)) {
throw new Error(`Error: the Webscience Rollup plugin requires either running Rollup in the base directory for the extension or specifying an extension manifest path.`);
}
// Check that the output directory is a directory path
if(!outputDirectory.endsWith(path.sep)) {
throw new Error("Error: the Webscience Rollup plugin requires a valid directory path.")
}
// If the output directory doesn't already exist, create it
if(!fs.existsSync(outputDirectory)) {
fs.mkdirSync(outputDirectory, {
recursive: true
});
}
/**
* Generate the absolute file input path, absolute file output path, and output path relative to the WebExtensions manifest
* directory for an import ID that uses the include scheme.
*/
const pathsFromId = id => {
// Remove the scheme to obtain the absolute file input path
const inputPath = id.substring(includeScheme.length);
// Add the file name to the output directory to obtain the absolute file output path
const outputPath = outputDirectory + path.basename(inputPath);
// Generate the output path relative to the WebExtensions manifest directory, since that's what WebExtensions APIs require
const relativeOutputPath = path.relative(manifestDirectory, outputPath);
return { inputPath, outputPath, relativeOutputPath };
};
const plugin = {
name: "webscience-rollup-plugin",
// When the Rollup build starts, check that plugin dependencies are present
buildStart({ plugins }) {
const pluginDependencies = {
"commonjs": "@rollup/plugin-commonjs",
"node-resolve": "@rollup/plugin-node-resolve"
};
const pluginNames = new Set();
for(const plugin of plugins) {
pluginNames.add(plugin.name);
}
for(const pluginName in pluginDependencies) {
if(!pluginNames.has(pluginName)) {
throw new Error(`Error: bundling with the WebScience library requires ${pluginDependencies[pluginName]}.`);
}
}
},
async resolveId(source, importer) {
// Ignore bundling entry points
if(!importer) {
return null;
}
// Ignore import statements that don't start with the include scheme
if(!source.startsWith(includeScheme)) {
return null;
}
// Remove the scheme, resolve the absolute path for the import, then restore the scheme
source = source.substring(includeScheme.length);
const resolution = await this.resolve(source, importer, { skipSelf: true });
if(resolution === null || !("id" in resolution)) {
throw new Error(`Error: unable to resolve WebScience dependency: ${source}.`);
}
return includeScheme + resolution.id;
},
async load(id) {
// Ignore resolved import statements that don't start with the include scheme
if(!id.startsWith(includeScheme)) {
return null;
}
// Return a string that is the output path relative to the WebExtensions manifest directory, since that's what WebExtensions APIs require
return `export default "${pathsFromId(id).relativeOutputPath}";`;
},
// Generate output files with the generateBundle hook, rather than the load hook, so we can benefit from tree shaking
async generateBundle(options, bundle) {
// Identify all the import IDs with the include scheme
const idsWithIncludeScheme = new Set();
for(const info of Object.values(bundle)) {
if(info.type === "chunk") {
for(const id in info.modules) {
if(id.startsWith(includeScheme)) {
idsWithIncludeScheme.add(id);
}
}
}
}
// Generate output files for each import ID with the include scheme
for(const id of idsWithIncludeScheme) {
const { inputPath, outputPath } = pathsFromId(id);
// If the file is a content script (i.e., ends with .content.js), bundle it to the output directory in IIFE format
if(inputPath.endsWith(".content.js")) {
const outputBundle = await rollup.rollup({
input: inputPath,
plugins: [
// If we encounter an import with the include scheme when bundling, just return an empty string and let Rollup tree shake the import
{
name: "ignore-include-scheme",
resolveId: plugin.resolveId,
load(id) {
if(id.startsWith(includeScheme)) {
return `export default "";`;
}
}
},
commonjs(),
nodeResolve({
browser: true,
moduleDirectories: [
process.cwd() + path.sep + "node_modules"
]
})
]
});
await outputBundle.write({
output: {
file: outputPath,
format: "iife"
}
});
await outputBundle.close();
}
// If the file is HTML (i.e., ends with .html), copy the file and any script or stylesheet dependencies
else if(inputPath.endsWith("html")) {
// Copy the HTML file
fs.copyFileSync(inputPath, outputPath);
// Parse the HTML file and extract script and stylesheet dependency paths
const html = fs.readFileSync(inputPath, "utf8");
const dom = new JSDOM(html);
const embeddedFileRelativePaths = new Set();
const scriptElements = dom.window.document.querySelectorAll("script[src]");
for(const scriptElement of scriptElements) {
embeddedFileRelativePaths.add(scriptElement.src);
}
const stylesheetElements = dom.window.document.querySelectorAll("link[rel=stylesheet][href]");
for(const stylesheetElement of stylesheetElements) {
embeddedFileRelativePaths.add(stylesheetElement.href);
}
// Generate output paths for dependencies and copy the files
for(const embeddedFileRelativePath of embeddedFileRelativePaths) {
const embeddedFileAbsolutePath = path.resolve(path.dirname(inputPath), embeddedFileRelativePath);
if(path.dirname(embeddedFileAbsolutePath) === path.dirname(inputPath)) {
fs.copyFileSync(embeddedFileAbsolutePath, outputDirectory + path.basename(embeddedFileAbsolutePath));
}
else {
console.warn(`Warning: the Webscience Rollup plugin only supports HTML script and stylesheet embeds in the same directory as the HTML file. Unable to embed ${embeddedFileAbsolutePath} in ${outputPath}.`);
}
}
}
// For all other file types, copy the file to the output directory
else {
fs.copyFileSync(inputPath, outputPath);
}
}
}
};
return plugin;
};