Skip to content

Commit

Permalink
Support other plugins in keepAssets
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
ef4 committed Oct 21, 2024
1 parent 5b4f982 commit c896836
Show file tree
Hide file tree
Showing 3 changed files with 194 additions and 33 deletions.
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 c896836

Please sign in to comment.