Skip to content

Commit 62f03e2

Browse files
committed
feat: support preserving assets input structure
1 parent 1141a47 commit 62f03e2

65 files changed

Lines changed: 1868 additions & 707 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/docs/building/rollup-plugin-import-meta-assets.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,52 @@ export default {
121121
};
122122
```
123123
124+
### `preserveDynamicStructure`
125+
126+
Type: `Boolean`<br>
127+
Default: `false`
128+
129+
When enabled, dynamic asset URLs (using template literals) are emitted to the Rollup pipeline and the URL pattern is rewritten to resolve relative to the first emitted asset.
130+
131+
**Requirements:** The output must preserve both filenames (no hashing) and the directory structure from the dynamic expression onwards.
132+
If filenames are hashed or the directory structure changes, the runtime URL resolution will fail.
133+
134+
This is useful when your application or CDN already has versioned URLs, so you don't need filename hashing.
135+
It also avoids generating a large switch statement in the output when you have many dynamic assets (e.g. an icon library).
136+
137+
```js
138+
import { importMetaAssets } from '@web/rollup-plugin-import-meta-assets';
139+
140+
const projectRoot = process.cwd();
141+
142+
export default {
143+
input: 'src/index.js',
144+
output: {
145+
dir: 'output',
146+
format: 'es',
147+
// preserve original file paths, relative to the project root
148+
assetFileNames: asset =>
149+
path.relative(projectRoot, asset.originalFileNames[0]).split(path.sep).join('/'),
150+
},
151+
plugins: [
152+
importMetaAssets({
153+
preserveDynamicStructure: true,
154+
}),
155+
],
156+
};
157+
```
158+
159+
Given this source code:
160+
161+
```js
162+
const icon = new URL(`./assets/icons/${category}/${name}.svg`, import.meta.url);
163+
```
164+
165+
The plugin will:
166+
167+
1. Emit all matching assets (e.g. `./assets/icons/outline/arrow.svg`, `./assets/icons/solid/check.svg`, etc..)
168+
2. Rewrite the URL to resolve relative to the first emitted asset
169+
124170
## Examples
125171
126172
Source directory:

packages/rollup-plugin-html/src/RollupPluginHTMLOptions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export interface RollupPluginHTMLOptions {
3131
extractAssets?: boolean | 'legacy-html' | 'legacy-html-and-css';
3232
/** Whether to bundle extracted CSS assets. Bundling is done via Lightning CSS. Defaults to true. */
3333
bundleCss?: boolean;
34-
/** Whether to minify extracted CSS assets. Minificaiton is done via Lightning CSS. Defaults to false. */
34+
/** Whether to minify extracted CSS assets. Minification is done via Lightning CSS. Defaults to false. */
3535
minifyCss?: boolean;
3636
/** Whether to ignore assets referenced in HTML and CSS with glob patterns. */
3737
externalAssets?: string | string[];
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import path from 'path';
2+
import { OutputBundle, PluginContext } from 'rollup';
3+
import { toBrowserPath } from './utils.js';
4+
5+
/**
6+
* Regular expression to match asset URL placeholders in CSS content.
7+
* Captures the reference ID like "abc123" from placeholders like "__ROLLUP_ASSET_URL_abc123__".
8+
* Note: Rollup reference IDs can contain alphanumeric characters, underscores, and $ (base-64-like encoding).
9+
*/
10+
const ASSET_URL_PLACEHOLDER_REGEX = /__ROLLUP_ASSET_URL_([a-zA-Z0-9_$]+)__/g;
11+
12+
/**
13+
* Creates a placeholder string for the given reference ID.
14+
* @param refId - The Rollup file reference ID
15+
* @returns Placeholder string like "__ROLLUP_ASSET_URL_abc123__"
16+
*/
17+
export function createAssetPlaceholder(refId: string): string {
18+
return `__ROLLUP_ASSET_URL_${refId}__`;
19+
}
20+
21+
/**
22+
* Replaces all asset URL placeholders in CSS content with resolved paths.
23+
* Anything after the placeholder (#id, ?queryString) is preserved naturally.
24+
*
25+
* @param cssContent - The CSS content with placeholders
26+
* @param resolver - Function that resolves a reference ID to the final path
27+
* @returns CSS content with placeholders replaced
28+
*/
29+
export function replacePlaceholders(
30+
cssContent: string,
31+
resolver: (refId: string) => string | undefined,
32+
): string {
33+
return cssContent.replace(ASSET_URL_PLACEHOLDER_REGEX, (match, refId) => {
34+
const resolvedPath = resolver(refId);
35+
return resolvedPath ?? match;
36+
});
37+
}
38+
39+
/**
40+
* Calculates the path from a CSS file to a referenced asset.
41+
* If publicPath is provided, returns an absolute path. Otherwise returns a relative path.
42+
*
43+
* @param cssFilePath - The CSS file's path in the bundle (e.g. 'styles/main.css')
44+
* @param assetFilePath - The asset's path in the bundle (e.g. 'assets/image.png')
45+
* @param publicPath - Optional public path prefix (e.g. '/static/')
46+
* @returns Absolute path if publicPath provided, otherwise relative path from CSS to asset
47+
*/
48+
export function calculateRelativePath(
49+
cssFilePath: string,
50+
assetFilePath: string,
51+
publicPath?: string,
52+
): string {
53+
// If publicPath is provided, return an absolute path
54+
if (publicPath) {
55+
return toBrowserPath(`${publicPath}${assetFilePath}`);
56+
}
57+
58+
// Otherwise, calculate relative path
59+
const cssDir = path.dirname(cssFilePath);
60+
const relativePath = path.relative(cssDir, assetFilePath);
61+
62+
// Convert to browser-style forward slashes
63+
return toBrowserPath(relativePath);
64+
}
65+
66+
/**
67+
* Processes all CSS files in the bundle, replacing placeholders with resolved paths.
68+
*
69+
* @param {PluginContext} pluginContext - the Rollup plugin context
70+
* @param {OutputBundle} bundle - the Rollup output bundle
71+
* @param {string} [publicPath] - Optional public path prefix for absolute URLs (e.g. '/static/')
72+
*/
73+
export function processCssAssets(
74+
pluginContext: PluginContext,
75+
bundle: OutputBundle,
76+
publicPath?: string,
77+
): void {
78+
for (const [fileName, asset] of Object.entries(bundle)) {
79+
if (asset.type !== 'asset' || !fileName.endsWith('.css')) continue;
80+
81+
const content =
82+
typeof asset.source === 'string' ? asset.source : Buffer.from(asset.source).toString('utf-8');
83+
84+
const resolvedContent = replacePlaceholders(content, (refId: string) => {
85+
try {
86+
const assetFileName = pluginContext.getFileName(refId);
87+
return calculateRelativePath(fileName, assetFileName, publicPath);
88+
} catch {
89+
pluginContext.error(`Could not resolve CSS asset reference '${refId}' in ${fileName}`);
90+
}
91+
});
92+
93+
asset.source = resolvedContent;
94+
}
95+
}

packages/rollup-plugin-html/src/output/emitAssets.ts

Lines changed: 28 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { bundleAsync, transform } from 'lightningcss';
44
import fs from 'fs';
55

66
import { InputAsset, InputData } from '../input/InputData';
7-
import { toBrowserPath } from './utils.js';
87
import { createAssetPicomatchMatcher } from '../assets/utils.js';
98
import { RollupPluginHTMLOptions, TransformAssetFunction } from '../RollupPluginHTMLOptions';
9+
import { createAssetPlaceholder } from './css.js';
1010

1111
export interface EmittedAssets {
1212
static: Map<string, string>;
@@ -88,7 +88,7 @@ export async function emitAssets(
8888
let ref: string;
8989
let basename = path.basename(asset.filePath);
9090
const isExternal = createAssetPicomatchMatcher(options.externalAssets);
91-
const emittedExternalAssets = new Map();
91+
const emittedAssets = new Map<string, { filePath: string; refId: string }>();
9292
if (asset.hashed) {
9393
if (basename.endsWith('.css') && extractAssets) {
9494
const { code } = await (bundleCss ? bundleAsync : transform)({
@@ -106,49 +106,34 @@ export async function emitAssets(
106106
const assetLocation = path.resolve(path.dirname(asset.filePath), filePath);
107107
const assetContent = fs.readFileSync(assetLocation);
108108

109-
// Avoid duplicates
110-
if (!emittedExternalAssets.has(assetLocation)) {
109+
let emittedAsset = emittedAssets.get(assetLocation);
110+
111+
if (!emittedAsset) {
112+
// Avoid duplicates
111113
const basename = path.basename(filePath);
112114
const fileRef = this.emitFile({
113115
type: 'asset',
114116
name: extractAssetsLegacyCss ? `assets/${basename}` : basename,
117+
originalFileName: assetLocation,
115118
source: assetContent,
116119
});
117120
const emittedAssetFilepath = this.getFileName(fileRef);
118-
const emittedAssetBasename = path.basename(emittedAssetFilepath);
119-
emittedExternalAssets.set(assetLocation, emittedAssetFilepath);
120-
// Update the URL in the original CSS file to point to the emitted asset file
121-
if (extractAssetsLegacyCss) {
122-
url.url = `assets/${emittedAssetBasename}`;
123-
} else {
124-
if (options.publicPath) {
125-
url.url = toBrowserPath(
126-
path.join(options.publicPath, emittedAssetFilepath),
127-
);
128-
} else {
129-
url.url = emittedAssetBasename;
130-
}
131-
}
132-
if (idRef) {
133-
url.url = `${url.url}#${idRef}`;
134-
}
121+
emittedAsset = {
122+
filePath: emittedAssetFilepath,
123+
refId: fileRef,
124+
};
125+
emittedAssets.set(assetLocation, emittedAsset);
126+
}
127+
128+
if (extractAssetsLegacyCss) {
129+
const emittedAssetBasename = path.basename(emittedAsset.filePath);
130+
url.url = `assets/${emittedAssetBasename}`;
135131
} else {
136-
const emittedAssetFilepath = emittedExternalAssets.get(assetLocation);
137-
const emittedAssetBasename = path.basename(emittedAssetFilepath);
138-
if (extractAssetsLegacyCss) {
139-
url.url = `assets/${emittedAssetBasename}`;
140-
} else {
141-
if (options.publicPath) {
142-
url.url = toBrowserPath(
143-
path.join(options.publicPath, emittedAssetFilepath),
144-
);
145-
} else {
146-
url.url = emittedAssetBasename;
147-
}
148-
}
149-
if (idRef) {
150-
url.url = `${url.url}#${idRef}`;
151-
}
132+
url.url = createAssetPlaceholder(emittedAsset.refId);
133+
}
134+
135+
if (idRef) {
136+
url.url = `${url.url}#${idRef}`;
152137
}
153138
}
154139
return url;
@@ -161,7 +146,12 @@ export async function emitAssets(
161146
}
162147
}
163148

164-
ref = this.emitFile({ type: 'asset', name: basename, source });
149+
ref = this.emitFile({
150+
type: 'asset',
151+
name: basename,
152+
originalFileName: asset.filePath,
153+
source,
154+
});
165155
} else {
166156
// ensure the output filename is unique
167157
let i = 1;

packages/rollup-plugin-html/src/output/getEntrypointBundles.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,9 @@ export function getEntrypointBundles(params: GetEntrypointBundlesParams) {
7474
outputDir,
7575
fileOutputDir: options.dir ?? '',
7676
htmlFileName,
77-
fileName: chunkOrAsset.fileName,
77+
fileName: chunk.fileName,
7878
});
79-
entrypoints.push({ importPath, chunk: chunkOrAsset, attributes: found.attributes });
79+
entrypoints.push({ importPath, chunk, attributes: found.attributes });
8080
}
8181
}
8282
}

packages/rollup-plugin-html/src/rollupPluginHTML.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from './RollupPluginHTMLOptions.js';
1515
import { createError, NOOP_IMPORT } from './utils.js';
1616
import { emitAssets } from './output/emitAssets.js';
17+
import { processCssAssets } from './output/css.js';
1718

1819
export interface RollupPluginHtml extends Plugin {
1920
api: {
@@ -140,6 +141,9 @@ export function rollupPluginHTML(pluginOptions: RollupPluginHTMLOptions = {}): R
140141
generatedBundles.push({ name: 'default', options, bundle });
141142

142143
const emittedAssets = await emitAssets.call(this, inputs, pluginOptions);
144+
145+
processCssAssets(this, bundle, pluginOptions.publicPath);
146+
143147
const outputs = await createHTMLOutput({
144148
outputDir: path.resolve(options.dir),
145149
inputs,
@@ -199,6 +203,9 @@ export function rollupPluginHTML(pluginOptions: RollupPluginHTMLOptions = {}): R
199203
}
200204

201205
const emittedAssets = await emitAssets.call(this, inputs, pluginOptions);
206+
207+
processCssAssets(this, bundle, pluginOptions.publicPath);
208+
202209
const outputs = await createHTMLOutput({
203210
outputDir: path.resolve(options.dir),
204211
inputs,

0 commit comments

Comments
 (0)