Skip to content

Commit

Permalink
Merge pull request embroider-build#2156 from embroider-build/keep-ass…
Browse files Browse the repository at this point in the history
…ets-plugin-support

Support other plugins in keepAssets
  • Loading branch information
ef4 authored Oct 22, 2024
2 parents 5b4f982 + 4ea3966 commit c51ca2b
Show file tree
Hide file tree
Showing 4 changed files with 214 additions and 33 deletions.
20 changes: 20 additions & 0 deletions packages/addon-dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@ For a guide on porting a V1 addon to V2, see https://github.com/embroider-build/

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`.

### addon.keepAssets(patterns: string[], exports?: 'default' | '*')

A rollup plugin to preserve imports of non-Javascript assets unchanged in your published package. For example, the v2-addon-blueprint uses:

```js
addon.keepAssets(['**/*.css'])
```

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.

`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.

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:

```js
addon.keepAssets(["**/*.png"], "default")
```

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.

## addon-dev command

The `addon-dev` command helps with common tasks in v2 addons.
Expand Down
106 changes: 74 additions & 32 deletions packages/addon-dev/src/rollup-keep-assets.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,93 @@
import walkSync from 'walk-sync';
import type { Plugin } from 'rollup';
import { readFileSync } from 'fs';
import { dirname, join, resolve } from 'path';
import minimatch from 'minimatch';
import { dirname, relative } from 'path';

// randomly chosen, we're just looking to have high-entropy identifiers that
// won't collide with anyting else in the source
let counter = 11559;

export default function keepAssets({
from,
include,
exports,
}: {
from: string;
include: string[];
exports?: undefined | 'default' | '*';
}): Plugin {
const marker = `__copy_asset_marker_${counter++}__`;

return {
name: 'copy-assets',

// imports of assets should be left alone in the source code. This can cover
// the case of .css as defined in the embroider v2 addon spec.
async resolveId(source, importer, options) {
const resolution = await this.resolve(source, importer, {
skipSelf: true,
...options,
});
if (
resolution &&
importer &&
include.some((pattern) => minimatch(resolution.id, pattern))
) {
return { id: resolve(dirname(importer), source), external: 'relative' };
}
return resolution;
},

// the assets go into the output directory in the same relative locations as
// in the input directory
async generateBundle() {
for (let name of walkSync(from, {
globs: include,
directories: false,
})) {
this.addWatchFile(join(from, name));

this.emitFile({
transform(code: string, id: string) {
if (include.some((pattern) => minimatch(id, pattern))) {
let ref = this.emitFile({
type: 'asset',
fileName: name,
source: readFileSync(join(from, name)),
fileName: relative(from, id),
source: code,
});
if (exports === '*') {
return `export * from ${marker}("${ref}")`;
} else if (exports === 'default') {
return `export default ${marker}("${ref}")`;
} else {
// side-effect only
return `${marker}("${ref}")`;
}
}
},
renderChunk(code, chunk) {
const { getName, imports } = nameTracker(code, exports);

code = code.replace(
new RegExp(`${marker}\\("([^"]+)"\\)`, 'g'),
(_x, ref) => {
let assetFileName = this.getFileName(ref);
let relativeName =
'./' + relative(dirname(chunk.fileName), assetFileName);
return getName(relativeName) ?? '';
}
);
return imports() + code;
},
};
}

function nameTracker(code: string, exports: undefined | 'default' | '*') {
let counter = 0;
let assets = new Map<string, string | undefined>();

function getName(assetName: string): string | undefined {
if (assets.has(assetName)) {
return assets.get(assetName)!;
}
if (!exports) {
assets.set(assetName, undefined);
return undefined;
}
while (true) {
let candidate = `_asset_${counter++}_`;
if (!code.includes(candidate)) {
assets.set(assetName, candidate);
return candidate;
}
}
}

function imports() {
return (
[...assets]
.map(([assetName, importedName]) => {
if (importedName) {
return `import ${importedName} from "${assetName}"`;
} else {
return `import "${assetName}"`;
}
})
.join('\n') + '\n'
);
}

return { getName, imports };
}
3 changes: 2 additions & 1 deletion packages/addon-dev/src/rollup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,11 @@ export class Addon {
// to leave those imports alone and to make sure the corresponding .css files
// are kept in the same relative locations in the destDir as they were in the
// srcDir.
keepAssets(patterns: string[]) {
keepAssets(patterns: string[], exports?: undefined | 'default' | '*') {
return keepAssets({
from: this.#srcDir,
include: patterns,
exports: exports,
});
}

Expand Down
118 changes: 118 additions & 0 deletions tests/scenarios/v2-addon-dev-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ appScenarios
'rollup.config.mjs': `
import { babel } from '@rollup/plugin-babel';
import { Addon } from '@embroider/addon-dev/rollup';
import { resolve, dirname } from 'path';
const addon = new Addon({
srcDir: 'src',
Expand All @@ -64,6 +65,7 @@ appScenarios
plugins: [
addon.publicEntrypoints([
'components/**/*.js',
'asset-examples/**/*.js',
], {
exclude: ['**/-excluded/**/*'],
}),
Expand All @@ -79,6 +81,37 @@ appScenarios
addon.gjs(),
addon.dependencies(),
addon.publicAssets('public'),
addon.keepAssets(["**/*.css"]),
// this works with custom-asset plugin below to exercise whether we can keepAssets
// for generated files that have exports
addon.keepAssets(["**/*.xyz"], "default"),
{
name: 'virtual-css',
resolveId(source, importer) {
if (source.endsWith('virtual.css')) {
return { id: resolve(dirname(importer), source) }
}
},
load(id) {
if (id.endsWith('virtual.css')) {
return '.my-blue-example { color: blue }'
}
}
},
{
name: 'custom-plugin',
resolveId(source, importer) {
if (source.endsWith('.xyz')) {
return { id: resolve(dirname(importer), source) }
}
},
load(id) {
if (id.endsWith('.xyz')) {
return 'Custom Content';
}
}
},
babel({ babelHelpers: 'bundled', extensions: ['.js', '.hbs', '.gjs'] }),
Expand Down Expand Up @@ -156,6 +189,23 @@ appScenarios
`,
},
},
'asset-examples': {
'has-css-import.js': `
import "./styles.css";
`,
'styles.css': `
.my-red-example { color: red }
`,
'has-virtual-css-import.js': `
import "./my-virtual.css";
`,
'has-custom-asset-import.js': `
import value from './custom.xyz';
export function example() {
return value;
}
`,
},
},
public: {
'thing.txt': 'hello there',
Expand Down Expand Up @@ -286,8 +336,54 @@ appScenarios
});
});
`,
'asset-test.js': `
import { module, test } from 'qunit';
module('keepAsset', function (hooks) {
let initialClassList;
hooks.beforeEach(function() {
initialClassList = document.body.classList;
});
hooks.afterEach(function() {
document.body.classList = initialClassList;
});
test('Normal CSS', async function (assert) {
await import("v2-addon/asset-examples/has-css-import");
document.body.classList.add('my-red-example');
assert.strictEqual(getComputedStyle(document.querySelector('body')).color, 'rgb(255, 0, 0)');
});
test("Virtual CSS", async function (assert) {
await import("v2-addon/asset-examples/has-virtual-css-import");
document.body.classList.add('my-blue-example');
assert.strictEqual(getComputedStyle(document.querySelector('body')).color, 'rgb(0, 0, 255)');
});
test("custom asset with export", async function(assert) {
let { example } = await import("v2-addon/asset-examples/has-custom-asset-import");
assert.strictEqual(example(), "Custom Content");
});
})
`,
},
});

project.files['vite.config.mjs'] = (project.files['vite.config.mjs'] as string).replace(
'contentFor(),',
`
contentFor(),
{
name: "xyz-handler",
transform(code, id) {
if (id.endsWith('.xyz')) {
return \`export default "\${code}"\`
}
}
},
`
);
})
.forEachScenario(scenario => {
Qmodule(scenario.name, function (hooks) {
Expand Down Expand Up @@ -399,6 +495,28 @@ export { SingleFileComponent as default };
'./public/other.txt': '/other.txt',
});
});

test('keepAssets works for real css files', async function () {
expectFile('dist/asset-examples/has-css-import.js').equalsCode(`import './styles.css'`);
expectFile('dist/asset-examples/styles.css').matches('.my-red-example { color: red }');
});

test('keepAssets works for css generated by another plugin', async function () {
expectFile('dist/asset-examples/has-virtual-css-import.js').equalsCode(`import './my-virtual.css'`);
expectFile('dist/asset-examples/my-virtual.css').matches('.my-blue-example { color: blue }');
});

test('keepAssets tolerates non-JS content that is interpreted as having a default export', async function () {
expectFile('dist/asset-examples/has-custom-asset-import.js').equalsCode(`
import _asset_0_ from './custom.xyz'
var value = _asset_0_;
function example() {
return value;
}
export { example }
`);
expectFile('dist/asset-examples/custom.xyz').matches(`Custom Content`);
});
});

Qmodule('Consuming app', function () {
Expand Down

0 comments on commit c51ca2b

Please sign in to comment.