Skip to content

Commit c51ca2b

Browse files
authored
Merge pull request embroider-build#2156 from embroider-build/keep-assets-plugin-support
Support other plugins in keepAssets
2 parents 5b4f982 + 4ea3966 commit c51ca2b

File tree

4 files changed

+214
-33
lines changed

4 files changed

+214
-33
lines changed

packages/addon-dev/README.md

+20
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,26 @@ For a guide on porting a V1 addon to V2, see https://github.com/embroider-build/
2222

2323
A rollup plugin to expose a folder of assets. `path` is a required to define which folder to expose. `options.include` is a glob pattern passed to `walkSync.include` to pick files. `options.exlude` is a glob pattern passed to `walkSync.ignore` to exclude files. `options.namespace` is the namespace to expose files, defaults to the package name + the path that you provided e.g. if you call `addon.publicAssets('public')` in a v2 addon named `super-addon` then your namespace will default to `super-addon/public`.
2424

25+
### addon.keepAssets(patterns: string[], exports?: 'default' | '*')
26+
27+
A rollup plugin to preserve imports of non-Javascript assets unchanged in your published package. For example, the v2-addon-blueprint uses:
28+
29+
```js
30+
addon.keepAssets(['**/*.css'])
31+
```
32+
33+
so that the line `import "./my.css"` in your addon will be preserved and the corresponding CSS file will get included at the right path.
34+
35+
`keepAssets` is intended to compose correctly with other plugins that synthesize CSS imports, like `glimmer-scoped-css`. It will capture their output and produce real CSS files in your published package.
36+
37+
The `exports` option defaults to `undefined` which means the assets are used for side-effect only and don't export any values. This is the supported way to use CSS in v2 addons. But you can also preserve assets that present themselves as having default exports with the value `"default"` or arbitrary named exports with the value `"*"`. For example:
38+
39+
```js
40+
addon.keepAssets(["**/*.png"], "default")
41+
```
42+
43+
lets you say `import imageURL from './my-image.png'`. Not that this pattern is **not** automatically supported in V2 addons and you would need to tell apps that consume your addon to handle it in a custom way.
44+
2545
## addon-dev command
2646

2747
The `addon-dev` command helps with common tasks in v2 addons.
+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)