|
1 | 1 | import fs from "fs"; |
2 | 2 | import path from "path"; |
| 3 | +import crypto from "crypto"; |
3 | 4 | import { Plugin, OutputOptions } from "rollup"; |
4 | 5 | import { createFilter, FilterPattern } from "@rollup/pluginutils"; |
5 | 6 | import { parse, print, types, visit } from "recast"; |
6 | 7 |
|
7 | | -interface PluginOptions { |
8 | | - /** |
9 | | - * A picomatch pattern, or array of patterns, |
10 | | - * which correspond to modules the plugin should operate on. |
11 | | - * By default all modules are targeted. |
12 | | - */ |
13 | | - include?: FilterPattern; |
14 | | - /** |
15 | | - * A picomatch pattern, or array of patterns, |
16 | | - * which correspond to modules the plugin should ignore. |
17 | | - * By default no modules are ignored. |
18 | | - */ |
19 | | - exclude?: FilterPattern; |
20 | | -} |
21 | | - |
22 | 8 | const PLUGIN_NAME = "external-assets"; |
23 | | -const REGEX_ESCAPED_PLUGIN_NAME = PLUGIN_NAME.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 9 | +const PREFIX = `\0${PLUGIN_NAME}:`; |
24 | 10 |
|
25 | 11 | function getOutputId(filename: string, outputOptions: OutputOptions) { |
26 | 12 | // Extract output directory from outputOptions. |
@@ -52,102 +38,71 @@ function getRelativeImportPath(from: string, to: string) { |
52 | 38 | * which correspond to assets the plugin should operate on. |
53 | 39 | * @param options - The options object. |
54 | 40 | */ |
55 | | -export default function externalAssets(pattern: FilterPattern, options?: PluginOptions): Plugin { |
| 41 | +export default function externalAssets(pattern: FilterPattern): Plugin { |
56 | 42 | if (!pattern) throw new Error("please specify a pattern for targeted assets"); |
57 | 43 |
|
58 | | - const importerFilter = createFilter(options?.include, options?.exclude); |
59 | | - const sourceFilter = createFilter(pattern); |
| 44 | + const idFilter = createFilter(pattern); |
| 45 | + const hashToIdMap: Partial<Record<string, string>> = {}; |
60 | 46 |
|
61 | 47 | return { |
62 | | - async buildStart() { |
63 | | - this.warn("'options' parameter is deprecated. Please update to the latest version."); |
64 | | - }, |
65 | | - |
66 | 48 | name: PLUGIN_NAME, |
67 | 49 |
|
68 | | - async options(inputOptions) { |
69 | | - const plugins = inputOptions.plugins; |
70 | | - |
71 | | - // No transformations. |
72 | | - if (!plugins) return null; |
73 | | - |
74 | | - // Separate our plugin from other plugins. |
75 | | - const externalAssetsPlugins: Plugin[] = []; |
76 | | - const otherPlugins = plugins.filter(plugin => { |
77 | | - if (plugin.name !== PLUGIN_NAME) return true; |
78 | | - |
79 | | - externalAssetsPlugins.push(plugin); |
80 | | - return false; |
81 | | - }); |
| 50 | + async resolveId(source, importer) { |
| 51 | + if ( |
| 52 | + !importer // Skip entrypoints. |
| 53 | + || !source.startsWith(PREFIX) // Not a hash that was calculated in the `load` hook. |
| 54 | + ) return null; |
82 | 55 |
|
83 | | - // Re-position our plugin to be the first in the list. |
84 | | - // Otherwise, if there's a plugin that resolves paths before ours, |
85 | | - // non-external imports can trigger the load hook for assets that can't be parsed by other plugins. |
86 | 56 | return { |
87 | | - ...inputOptions, |
88 | | - plugins: [ |
89 | | - ...externalAssetsPlugins, |
90 | | - ...otherPlugins, |
91 | | - ], |
| 57 | + id: source, |
| 58 | + external: true |
92 | 59 | }; |
93 | 60 | }, |
94 | 61 |
|
95 | | - async resolveId(source, importer, options) { |
96 | | - // `this.resolve` was called from another instance of this plugin. skip to avoid infinite loop. |
97 | | - // or skip resolving entrypoints. |
98 | | - // or don't resolve imports from filtered out modules. |
| 62 | + async load(id) { |
99 | 63 | if ( |
100 | | - options.custom?.[PLUGIN_NAME]?.skip |
101 | | - || !importer |
102 | | - || !importerFilter(importer) |
| 64 | + id.startsWith("\0") // Virtual module. |
| 65 | + || id.includes("?") // Id reserved by some other plugin. |
| 66 | + || !idFilter(id) // Filtered out id. |
103 | 67 | ) return null; |
104 | 68 |
|
105 | | - // We'll delegate resolving to other plugins (alias, node-resolve ...), |
106 | | - // or eventually, rollup itself. |
107 | | - // We need to skip this plugin to avoid an infinite loop. |
108 | | - const resolution = await this.resolve(source, importer, { |
109 | | - skipSelf: true, |
110 | | - custom: { |
111 | | - [PLUGIN_NAME]: { |
112 | | - skip: true, |
113 | | - } |
114 | | - } |
115 | | - }); |
| 69 | + const hash = crypto.createHash('md5').update(id).digest('hex'); |
116 | 70 |
|
117 | | - // If it cannot be resolved, or if the id is filtered out, |
118 | | - // return `null` so that Rollup displays an error. |
119 | | - if (!resolution || !sourceFilter(resolution.id)) return null; |
| 71 | + // In the output phase, |
| 72 | + // We'll use this mapping to replace the hash with a relative path from a chunk to the emitted asset. |
| 73 | + hashToIdMap[hash] = id; |
120 | 74 |
|
121 | | - return { |
122 | | - ...resolution, |
123 | | - // We'll need `target_id` to emit the asset in the output phase. |
124 | | - id: `${resolution.id}?${PLUGIN_NAME}&target_id=${resolution.id}`, |
125 | | - external: true, |
126 | | - }; |
| 75 | + // Load a proxy module with a hash as the import. |
| 76 | + // The hash will be resolved as external. |
| 77 | + // The benefit of doing it this way, instead of resolving asset imports to external ids, |
| 78 | + // is that we get watch mode support out of the box. |
| 79 | + return `export * from "${PREFIX + hash}";\n` |
| 80 | + + `export { default } from "${PREFIX + hash}";\n`; |
127 | 81 | }, |
128 | 82 |
|
129 | 83 | async renderChunk(code, chunk, outputOptions) { |
130 | 84 | const chunk_id = getOutputId(chunk.fileName, outputOptions); |
131 | 85 | const chunk_basename = path.basename(chunk_id); |
132 | 86 |
|
133 | 87 | const ast = parse(code, { sourceFileName: chunk_basename }); |
134 | | - const pattern = new RegExp(`.+\\?${REGEX_ESCAPED_PLUGIN_NAME}&target_id=(.+)`); |
135 | 88 | const rollup_context = this; |
136 | 89 |
|
137 | 90 | visit(ast, { |
138 | 91 | visitLiteral(nodePath) { |
139 | | - const node = nodePath.node; |
| 92 | + const value = nodePath.node.value; |
140 | 93 |
|
141 | | - // We're only concerned with string literals. |
142 | | - if (typeof node.value !== "string") return this.traverse(nodePath); |
| 94 | + if ( |
| 95 | + typeof value !== "string" // We're only concerned with string literals. |
| 96 | + || !value.startsWith(PREFIX) // Not a hash that was calculated in the `load` hook. |
| 97 | + ) return this.traverse(nodePath); |
143 | 98 |
|
144 | | - const match = node.value.match(pattern); |
| 99 | + const hash = value.slice(PREFIX.length); |
| 100 | + const target_id = hashToIdMap[hash]; |
145 | 101 |
|
146 | | - // This string does not refer to an import path that we resolved in the `resolveId` hook. |
147 | | - if (!match) return this.traverse(nodePath); |
| 102 | + // The hash belongs to another instance of this plugin. |
| 103 | + if (!target_id) return this.traverse(nodePath); |
148 | 104 |
|
149 | 105 | // Emit the targeted asset. |
150 | | - const target_id = match[1]; |
151 | 106 | const asset_reference_id = rollup_context.emitFile({ |
152 | 107 | type: "asset", |
153 | 108 | source: fs.readFileSync(target_id), |
|
0 commit comments