Skip to content

Commit

Permalink
feat: added support for watch mode
Browse files Browse the repository at this point in the history
BREAKING CHANGE: The `options` parameter is now deprecated, the reason
is that `exclude` and `include` do not make sense when importing the same
asset from both excluded and included modules
  • Loading branch information
recursive-beast committed Mar 3, 2021
1 parent 730ac5b commit f080246
Show file tree
Hide file tree
Showing 6 changed files with 37 additions and 119 deletions.
1 change: 1 addition & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default {
// ...Object.keys(pkg.peerDependencies),
"fs",
"path",
"crypto",
],
plugins: [
nodeResolve(),
Expand Down
115 changes: 35 additions & 80 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,12 @@
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { Plugin, OutputOptions } from "rollup";
import { createFilter, FilterPattern } from "@rollup/pluginutils";
import { parse, print, types, visit } from "recast";

interface PluginOptions {
/**
* A picomatch pattern, or array of patterns,
* which correspond to modules the plugin should operate on.
* By default all modules are targeted.
*/
include?: FilterPattern;
/**
* A picomatch pattern, or array of patterns,
* which correspond to modules the plugin should ignore.
* By default no modules are ignored.
*/
exclude?: FilterPattern;
}

const PLUGIN_NAME = "external-assets";
const REGEX_ESCAPED_PLUGIN_NAME = PLUGIN_NAME.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const PREFIX = `\0${PLUGIN_NAME}:`;

function getOutputId(filename: string, outputOptions: OutputOptions) {
// Extract output directory from outputOptions.
Expand Down Expand Up @@ -52,102 +38,71 @@ function getRelativeImportPath(from: string, to: string) {
* which correspond to assets the plugin should operate on.
* @param options - The options object.
*/
export default function externalAssets(pattern: FilterPattern, options?: PluginOptions): Plugin {
export default function externalAssets(pattern: FilterPattern): Plugin {
if (!pattern) throw new Error("please specify a pattern for targeted assets");

const importerFilter = createFilter(options?.include, options?.exclude);
const sourceFilter = createFilter(pattern);
const idFilter = createFilter(pattern);
const hashToIdMap: Partial<Record<string, string>> = {};

return {
async buildStart() {
this.warn("'options' parameter is deprecated. Please update to the latest version.");
},

name: PLUGIN_NAME,

async options(inputOptions) {
const plugins = inputOptions.plugins;

// No transformations.
if (!plugins) return null;

// Separate our plugin from other plugins.
const externalAssetsPlugins: Plugin[] = [];
const otherPlugins = plugins.filter(plugin => {
if (plugin.name !== PLUGIN_NAME) return true;

externalAssetsPlugins.push(plugin);
return false;
});
async resolveId(source, importer) {
if (
!importer // Skip entrypoints.
|| !source.startsWith(PREFIX) // Not a hash that was calculated in the `load` hook.
) return null;

// Re-position our plugin to be the first in the list.
// Otherwise, if there's a plugin that resolves paths before ours,
// non-external imports can trigger the load hook for assets that can't be parsed by other plugins.
return {
...inputOptions,
plugins: [
...externalAssetsPlugins,
...otherPlugins,
],
id: source,
external: true
};
},

async resolveId(source, importer, options) {
// `this.resolve` was called from another instance of this plugin. skip to avoid infinite loop.
// or skip resolving entrypoints.
// or don't resolve imports from filtered out modules.
async load(id) {
if (
options.custom?.[PLUGIN_NAME]?.skip
|| !importer
|| !importerFilter(importer)
id.startsWith("\0") // Virtual module.
|| id.includes("?") // Id reserved by some other plugin.
|| !idFilter(id) // Filtered out id.
) return null;

// We'll delegate resolving to other plugins (alias, node-resolve ...),
// or eventually, rollup itself.
// We need to skip this plugin to avoid an infinite loop.
const resolution = await this.resolve(source, importer, {
skipSelf: true,
custom: {
[PLUGIN_NAME]: {
skip: true,
}
}
});
const hash = crypto.createHash('md5').update(id).digest('hex');

// If it cannot be resolved, or if the id is filtered out,
// return `null` so that Rollup displays an error.
if (!resolution || !sourceFilter(resolution.id)) return null;
// In the output phase,
// We'll use this mapping to replace the hash with a relative path from a chunk to the emitted asset.
hashToIdMap[hash] = id;

return {
...resolution,
// We'll need `target_id` to emit the asset in the output phase.
id: `${resolution.id}?${PLUGIN_NAME}&target_id=${resolution.id}`,
external: true,
};
// Load a proxy module with a hash as the import.
// The hash will be resolved as external.
// The benefit of doing it this way, instead of resolving asset imports to external ids,
// is that we get watch mode support out of the box.
return `export * from "${PREFIX + hash}";\n`
+ `export { default } from "${PREFIX + hash}";\n`;
},

async renderChunk(code, chunk, outputOptions) {
const chunk_id = getOutputId(chunk.fileName, outputOptions);
const chunk_basename = path.basename(chunk_id);

const ast = parse(code, { sourceFileName: chunk_basename });
const pattern = new RegExp(`.+\\?${REGEX_ESCAPED_PLUGIN_NAME}&target_id=(.+)`);
const rollup_context = this;

visit(ast, {
visitLiteral(nodePath) {
const node = nodePath.node;
const value = nodePath.node.value;

// We're only concerned with string literals.
if (typeof node.value !== "string") return this.traverse(nodePath);
if (
typeof value !== "string" // We're only concerned with string literals.
|| !value.startsWith(PREFIX) // Not a hash that was calculated in the `load` hook.
) return this.traverse(nodePath);

const match = node.value.match(pattern);
const hash = value.slice(PREFIX.length);
const target_id = hashToIdMap[hash];

// This string does not refer to an import path that we resolved in the `resolveId` hook.
if (!match) return this.traverse(nodePath);
// The hash belongs to another instance of this plugin.
if (!target_id) return this.traverse(nodePath);

// Emit the targeted asset.
const target_id = match[1];
const asset_reference_id = rollup_context.emitFile({
type: "asset",
source: fs.readFileSync(target_id),
Expand Down
16 changes: 0 additions & 16 deletions tests/general.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
const test = require("ava");
const { nodeResolve } = require("@rollup/plugin-node-resolve");
const { rollup } = require("rollup");
const { outputSnapshotMacro } = require("./macros");
const externalAssets = require("..");

Expand All @@ -16,21 +15,6 @@ for (const value of falsy) {
});
}

// Solved by re-positioning the plugin to be the first on the list.
test("Plugin works even if it's not the first in the list", async t => {
await t.notThrowsAsync(
rollup({
input: "tests/fixtures/src/index2.js",
plugins: [
nodeResolve({
moduleDirectories: ["tests/fixtures/node_modules"],
}),
externalAssets(["tests/fixtures/assets/*", /@fontsource\/open-sans/]),
],
})
);
});

test("Multiple instances of the plugin can be used at the same time", outputSnapshotMacro,
{
input: "tests/fixtures/src/index2.js",
Expand Down
22 changes: 0 additions & 22 deletions tests/resolve.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
const test = require("ava");
const { rollup } = require("rollup");
const { outputSnapshotMacro } = require("./macros");
const { nodeResolve } = require("@rollup/plugin-node-resolve");
const alias = require("@rollup/plugin-alias");
Expand All @@ -12,27 +11,6 @@ test("Skips resolving entrypoints", async t => {
t.is(resolution, null);
});

// Rollup will not be able to parse assets imported from excluded modules.
test("Doesn't process imports from excluded modules", async t => {
await t.throwsAsync(
rollup({
input: "tests/fixtures/src/index1.js",
plugins: [
externalAssets(
"tests/fixtures/assets/*",
{
exclude: /1\.js$/,
include: "tests/fixtures/src/*.js",
}
),
],
}),
{
code: "PARSE_ERROR",
}
);
});

test(`Resolve with @rollup/plugin-node-resolve`, outputSnapshotMacro,
{
input: "tests/fixtures/src/index2.js",
Expand Down
2 changes: 1 addition & 1 deletion tests/snapshots/output.test.js.md
Original file line number Diff line number Diff line change
Expand Up @@ -10895,7 +10895,7 @@ Generated by [AVA](https://avajs.dev).

[
{
code: `define(["./assets/image-0fc60877.png", "./assets/text-6d7076f2.txt", "./assets/styles-fc0ceb37.css"], function (png, text, styles_css) { 'use strict';␊
code: `define(["./assets/image-0fc60877.png", "./assets/text-6d7076f2.txt", "./assets/styles-fc0ceb37.css"], function (png, text, _externalAssets_4310739eb4843f3840c8bd0630aa60b0) { 'use strict';␊
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }␊
Expand Down
Binary file modified tests/snapshots/output.test.js.snap
Binary file not shown.

0 comments on commit f080246

Please sign in to comment.