Skip to content

Commit c896836

Browse files
committed
Support other plugins in keepAssets
This extends the keepAssets feature in `@embroider/addon-dev` so that it composes nicely with other plugins. For example, if you use a plugin that synthesizes CSS imports, it's nice for keepAssets to consume those and keep them as a real CSS files on disk when you build your addon.
1 parent 5b4f982 commit c896836

File tree

3 files changed

+194
-33
lines changed

3 files changed

+194
-33
lines changed
+74-32
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,93 @@
1-
import walkSync from 'walk-sync';
21
import type { Plugin } from 'rollup';
3-
import { readFileSync } from 'fs';
4-
import { dirname, join, resolve } from 'path';
52
import minimatch from 'minimatch';
3+
import { dirname, relative } from 'path';
4+
5+
// randomly chosen, we're just looking to have high-entropy identifiers that
6+
// won't collide with anyting else in the source
7+
let counter = 11559;
68

79
export default function keepAssets({
810
from,
911
include,
12+
exports,
1013
}: {
1114
from: string;
1215
include: string[];
16+
exports?: undefined | 'default' | '*';
1317
}): Plugin {
18+
const marker = `__copy_asset_marker_${counter++}__`;
19+
1420
return {
1521
name: 'copy-assets',
1622

17-
// imports of assets should be left alone in the source code. This can cover
18-
// the case of .css as defined in the embroider v2 addon spec.
19-
async resolveId(source, importer, options) {
20-
const resolution = await this.resolve(source, importer, {
21-
skipSelf: true,
22-
...options,
23-
});
24-
if (
25-
resolution &&
26-
importer &&
27-
include.some((pattern) => minimatch(resolution.id, pattern))
28-
) {
29-
return { id: resolve(dirname(importer), source), external: 'relative' };
30-
}
31-
return resolution;
32-
},
33-
34-
// the assets go into the output directory in the same relative locations as
35-
// in the input directory
36-
async generateBundle() {
37-
for (let name of walkSync(from, {
38-
globs: include,
39-
directories: false,
40-
})) {
41-
this.addWatchFile(join(from, name));
42-
43-
this.emitFile({
23+
transform(code: string, id: string) {
24+
if (include.some((pattern) => minimatch(id, pattern))) {
25+
let ref = this.emitFile({
4426
type: 'asset',
45-
fileName: name,
46-
source: readFileSync(join(from, name)),
27+
fileName: relative(from, id),
28+
source: code,
4729
});
30+
if (exports === '*') {
31+
return `export * from ${marker}("${ref}")`;
32+
} else if (exports === 'default') {
33+
return `export default ${marker}("${ref}")`;
34+
} else {
35+
// side-effect only
36+
return `${marker}("${ref}")`;
37+
}
4838
}
4939
},
40+
renderChunk(code, chunk) {
41+
const { getName, imports } = nameTracker(code, exports);
42+
43+
code = code.replace(
44+
new RegExp(`${marker}\\("([^"]+)"\\)`, 'g'),
45+
(_x, ref) => {
46+
let assetFileName = this.getFileName(ref);
47+
let relativeName =
48+
'./' + relative(dirname(chunk.fileName), assetFileName);
49+
return getName(relativeName) ?? '';
50+
}
51+
);
52+
return imports() + code;
53+
},
5054
};
5155
}
56+
57+
function nameTracker(code: string, exports: undefined | 'default' | '*') {
58+
let counter = 0;
59+
let assets = new Map<string, string | undefined>();
60+
61+
function getName(assetName: string): string | undefined {
62+
if (assets.has(assetName)) {
63+
return assets.get(assetName)!;
64+
}
65+
if (!exports) {
66+
assets.set(assetName, undefined);
67+
return undefined;
68+
}
69+
while (true) {
70+
let candidate = `_asset_${counter++}_`;
71+
if (!code.includes(candidate)) {
72+
assets.set(assetName, candidate);
73+
return candidate;
74+
}
75+
}
76+
}
77+
78+
function imports() {
79+
return (
80+
[...assets]
81+
.map(([assetName, importedName]) => {
82+
if (importedName) {
83+
return `import ${importedName} from "${assetName}"`;
84+
} else {
85+
return `import "${assetName}"`;
86+
}
87+
})
88+
.join('\n') + '\n'
89+
);
90+
}
91+
92+
return { getName, imports };
93+
}

packages/addon-dev/src/rollup.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,11 @@ export class Addon {
7474
// to leave those imports alone and to make sure the corresponding .css files
7575
// are kept in the same relative locations in the destDir as they were in the
7676
// srcDir.
77-
keepAssets(patterns: string[]) {
77+
keepAssets(patterns: string[], exports?: undefined | 'default' | '*') {
7878
return keepAssets({
7979
from: this.#srcDir,
8080
include: patterns,
81+
exports: exports,
8182
});
8283
}
8384

tests/scenarios/v2-addon-dev-test.ts

+118
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ appScenarios
4848
'rollup.config.mjs': `
4949
import { babel } from '@rollup/plugin-babel';
5050
import { Addon } from '@embroider/addon-dev/rollup';
51+
import { resolve, dirname } from 'path';
5152
5253
const addon = new Addon({
5354
srcDir: 'src',
@@ -64,6 +65,7 @@ appScenarios
6465
plugins: [
6566
addon.publicEntrypoints([
6667
'components/**/*.js',
68+
'asset-examples/**/*.js',
6769
], {
6870
exclude: ['**/-excluded/**/*'],
6971
}),
@@ -79,6 +81,37 @@ appScenarios
7981
addon.gjs(),
8082
addon.dependencies(),
8183
addon.publicAssets('public'),
84+
addon.keepAssets(["**/*.css"]),
85+
86+
// this works with custom-asset plugin below to exercise whether we can keepAssets
87+
// for generated files that have exports
88+
addon.keepAssets(["**/*.xyz"], "default"),
89+
{
90+
name: 'virtual-css',
91+
resolveId(source, importer) {
92+
if (source.endsWith('virtual.css')) {
93+
return { id: resolve(dirname(importer), source) }
94+
}
95+
},
96+
load(id) {
97+
if (id.endsWith('virtual.css')) {
98+
return '.my-blue-example { color: blue }'
99+
}
100+
}
101+
},
102+
{
103+
name: 'custom-plugin',
104+
resolveId(source, importer) {
105+
if (source.endsWith('.xyz')) {
106+
return { id: resolve(dirname(importer), source) }
107+
}
108+
},
109+
load(id) {
110+
if (id.endsWith('.xyz')) {
111+
return 'Custom Content';
112+
}
113+
}
114+
},
82115
83116
babel({ babelHelpers: 'bundled', extensions: ['.js', '.hbs', '.gjs'] }),
84117
@@ -156,6 +189,23 @@ appScenarios
156189
`,
157190
},
158191
},
192+
'asset-examples': {
193+
'has-css-import.js': `
194+
import "./styles.css";
195+
`,
196+
'styles.css': `
197+
.my-red-example { color: red }
198+
`,
199+
'has-virtual-css-import.js': `
200+
import "./my-virtual.css";
201+
`,
202+
'has-custom-asset-import.js': `
203+
import value from './custom.xyz';
204+
export function example() {
205+
return value;
206+
}
207+
`,
208+
},
159209
},
160210
public: {
161211
'thing.txt': 'hello there',
@@ -286,8 +336,54 @@ appScenarios
286336
});
287337
});
288338
`,
339+
'asset-test.js': `
340+
import { module, test } from 'qunit';
341+
342+
module('keepAsset', function (hooks) {
343+
let initialClassList;
344+
hooks.beforeEach(function() {
345+
initialClassList = document.body.classList;
346+
});
347+
348+
hooks.afterEach(function() {
349+
document.body.classList = initialClassList;
350+
});
351+
352+
test('Normal CSS', async function (assert) {
353+
await import("v2-addon/asset-examples/has-css-import");
354+
document.body.classList.add('my-red-example');
355+
assert.strictEqual(getComputedStyle(document.querySelector('body')).color, 'rgb(255, 0, 0)');
356+
});
357+
358+
test("Virtual CSS", async function (assert) {
359+
await import("v2-addon/asset-examples/has-virtual-css-import");
360+
document.body.classList.add('my-blue-example');
361+
assert.strictEqual(getComputedStyle(document.querySelector('body')).color, 'rgb(0, 0, 255)');
362+
});
363+
364+
test("custom asset with export", async function(assert) {
365+
let { example } = await import("v2-addon/asset-examples/has-custom-asset-import");
366+
assert.strictEqual(example(), "Custom Content");
367+
});
368+
})
369+
`,
289370
},
290371
});
372+
373+
project.files['vite.config.mjs'] = (project.files['vite.config.mjs'] as string).replace(
374+
'contentFor(),',
375+
`
376+
contentFor(),
377+
{
378+
name: "xyz-handler",
379+
transform(code, id) {
380+
if (id.endsWith('.xyz')) {
381+
return \`export default "\${code}"\`
382+
}
383+
}
384+
},
385+
`
386+
);
291387
})
292388
.forEachScenario(scenario => {
293389
Qmodule(scenario.name, function (hooks) {
@@ -399,6 +495,28 @@ export { SingleFileComponent as default };
399495
'./public/other.txt': '/other.txt',
400496
});
401497
});
498+
499+
test('keepAssets works for real css files', async function () {
500+
expectFile('dist/asset-examples/has-css-import.js').equalsCode(`import './styles.css'`);
501+
expectFile('dist/asset-examples/styles.css').matches('.my-red-example { color: red }');
502+
});
503+
504+
test('keepAssets works for css generated by another plugin', async function () {
505+
expectFile('dist/asset-examples/has-virtual-css-import.js').equalsCode(`import './my-virtual.css'`);
506+
expectFile('dist/asset-examples/my-virtual.css').matches('.my-blue-example { color: blue }');
507+
});
508+
509+
test('keepAssets tolerates non-JS content that is interpreted as having a default export', async function () {
510+
expectFile('dist/asset-examples/has-custom-asset-import.js').equalsCode(`
511+
import _asset_0_ from './custom.xyz'
512+
var value = _asset_0_;
513+
function example() {
514+
return value;
515+
}
516+
export { example }
517+
`);
518+
expectFile('dist/asset-examples/custom.xyz').matches(`Custom Content`);
519+
});
402520
});
403521

404522
Qmodule('Consuming app', function () {

0 commit comments

Comments
 (0)