diff --git a/docs/docs/building/rollup-plugin-import-meta-assets.md b/docs/docs/building/rollup-plugin-import-meta-assets.md
index 3272a7be37..80a12d81f2 100644
--- a/docs/docs/building/rollup-plugin-import-meta-assets.md
+++ b/docs/docs/building/rollup-plugin-import-meta-assets.md
@@ -121,6 +121,52 @@ export default {
};
```
+### `preserveDynamicStructure`
+
+Type: `Boolean`
+Default: `false`
+
+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.
+
+**Requirements:** The output must preserve both filenames (no hashing) and the directory structure from the dynamic expression onwards.
+If filenames are hashed or the directory structure changes, the runtime URL resolution will fail.
+
+This is useful when your application or CDN already has versioned URLs, so you don't need filename hashing.
+It also avoids generating a large switch statement in the output when you have many dynamic assets (e.g. an icon library).
+
+```js
+import { importMetaAssets } from '@web/rollup-plugin-import-meta-assets';
+
+const projectRoot = process.cwd();
+
+export default {
+ input: 'src/index.js',
+ output: {
+ dir: 'output',
+ format: 'es',
+ // preserve original file paths, relative to the project root
+ assetFileNames: asset =>
+ path.relative(projectRoot, asset.originalFileNames[0]).split(path.sep).join('/'),
+ },
+ plugins: [
+ importMetaAssets({
+ preserveDynamicStructure: true,
+ }),
+ ],
+};
+```
+
+Given this source code:
+
+```js
+const icon = new URL(`./assets/icons/${category}/${name}.svg`, import.meta.url);
+```
+
+The plugin will:
+
+1. Emit all matching assets (e.g. `./assets/icons/outline/arrow.svg`, `./assets/icons/solid/check.svg`, etc..)
+2. Rewrite the URL to resolve relative to the first emitted asset
+
## Examples
Source directory:
diff --git a/packages/rollup-plugin-html/src/RollupPluginHTMLOptions.ts b/packages/rollup-plugin-html/src/RollupPluginHTMLOptions.ts
index 884f669fb5..48e6fe977f 100644
--- a/packages/rollup-plugin-html/src/RollupPluginHTMLOptions.ts
+++ b/packages/rollup-plugin-html/src/RollupPluginHTMLOptions.ts
@@ -31,7 +31,7 @@ export interface RollupPluginHTMLOptions {
extractAssets?: boolean | 'legacy-html' | 'legacy-html-and-css';
/** Whether to bundle extracted CSS assets. Bundling is done via Lightning CSS. Defaults to true. */
bundleCss?: boolean;
- /** Whether to minify extracted CSS assets. Minificaiton is done via Lightning CSS. Defaults to false. */
+ /** Whether to minify extracted CSS assets. Minification is done via Lightning CSS. Defaults to false. */
minifyCss?: boolean;
/** Whether to ignore assets referenced in HTML and CSS with glob patterns. */
externalAssets?: string | string[];
diff --git a/packages/rollup-plugin-html/src/output/css.ts b/packages/rollup-plugin-html/src/output/css.ts
new file mode 100644
index 0000000000..80a65f74ec
--- /dev/null
+++ b/packages/rollup-plugin-html/src/output/css.ts
@@ -0,0 +1,95 @@
+import path from 'path';
+import { OutputBundle, PluginContext } from 'rollup';
+import { toBrowserPath } from './utils.js';
+
+/**
+ * Regular expression to match asset URL placeholders in CSS content.
+ * Captures the reference ID like "abc123" from placeholders like "__ROLLUP_ASSET_URL_abc123__".
+ * Note: Rollup reference IDs can contain alphanumeric characters, underscores, and $ (base-64-like encoding).
+ */
+const ASSET_URL_PLACEHOLDER_REGEX = /__ROLLUP_ASSET_URL_([a-zA-Z0-9_$]+)__/g;
+
+/**
+ * Creates a placeholder string for the given reference ID.
+ * @param refId - The Rollup file reference ID
+ * @returns Placeholder string like "__ROLLUP_ASSET_URL_abc123__"
+ */
+export function createAssetPlaceholder(refId: string): string {
+ return `__ROLLUP_ASSET_URL_${refId}__`;
+}
+
+/**
+ * Replaces all asset URL placeholders in CSS content with resolved paths.
+ * Anything after the placeholder (#id, ?queryString) is preserved naturally.
+ *
+ * @param cssContent - The CSS content with placeholders
+ * @param resolver - Function that resolves a reference ID to the final path
+ * @returns CSS content with placeholders replaced
+ */
+export function replacePlaceholders(
+ cssContent: string,
+ resolver: (refId: string) => string | undefined,
+): string {
+ return cssContent.replace(ASSET_URL_PLACEHOLDER_REGEX, (match, refId) => {
+ const resolvedPath = resolver(refId);
+ return resolvedPath ?? match;
+ });
+}
+
+/**
+ * Calculates the path from a CSS file to a referenced asset.
+ * If publicPath is provided, returns an absolute path. Otherwise returns a relative path.
+ *
+ * @param cssFilePath - The CSS file's path in the bundle (e.g. 'styles/main.css')
+ * @param assetFilePath - The asset's path in the bundle (e.g. 'assets/image.png')
+ * @param publicPath - Optional public path prefix (e.g. '/static/')
+ * @returns Absolute path if publicPath provided, otherwise relative path from CSS to asset
+ */
+export function calculateRelativePath(
+ cssFilePath: string,
+ assetFilePath: string,
+ publicPath?: string,
+): string {
+ // If publicPath is provided, return an absolute path
+ if (publicPath) {
+ return toBrowserPath(`${publicPath}${assetFilePath}`);
+ }
+
+ // Otherwise, calculate relative path
+ const cssDir = path.dirname(cssFilePath);
+ const relativePath = path.relative(cssDir, assetFilePath);
+
+ // Convert to browser-style forward slashes
+ return toBrowserPath(relativePath);
+}
+
+/**
+ * Processes all CSS files in the bundle, replacing placeholders with resolved paths.
+ *
+ * @param {PluginContext} pluginContext - the Rollup plugin context
+ * @param {OutputBundle} bundle - the Rollup output bundle
+ * @param {string} [publicPath] - Optional public path prefix for absolute URLs (e.g. '/static/')
+ */
+export function processCssAssets(
+ pluginContext: PluginContext,
+ bundle: OutputBundle,
+ publicPath?: string,
+): void {
+ for (const [fileName, asset] of Object.entries(bundle)) {
+ if (asset.type !== 'asset' || !fileName.endsWith('.css')) continue;
+
+ const content =
+ typeof asset.source === 'string' ? asset.source : Buffer.from(asset.source).toString('utf-8');
+
+ const resolvedContent = replacePlaceholders(content, (refId: string) => {
+ try {
+ const assetFileName = pluginContext.getFileName(refId);
+ return calculateRelativePath(fileName, assetFileName, publicPath);
+ } catch {
+ pluginContext.error(`Could not resolve CSS asset reference '${refId}' in ${fileName}`);
+ }
+ });
+
+ asset.source = resolvedContent;
+ }
+}
diff --git a/packages/rollup-plugin-html/src/output/emitAssets.ts b/packages/rollup-plugin-html/src/output/emitAssets.ts
index 377db1f25a..bc6c499fa7 100644
--- a/packages/rollup-plugin-html/src/output/emitAssets.ts
+++ b/packages/rollup-plugin-html/src/output/emitAssets.ts
@@ -4,9 +4,9 @@ import { bundleAsync, transform } from 'lightningcss';
import fs from 'fs';
import { InputAsset, InputData } from '../input/InputData';
-import { toBrowserPath } from './utils.js';
import { createAssetPicomatchMatcher } from '../assets/utils.js';
import { RollupPluginHTMLOptions, TransformAssetFunction } from '../RollupPluginHTMLOptions';
+import { createAssetPlaceholder } from './css.js';
export interface EmittedAssets {
static: Map;
@@ -88,7 +88,7 @@ export async function emitAssets(
let ref: string;
let basename = path.basename(asset.filePath);
const isExternal = createAssetPicomatchMatcher(options.externalAssets);
- const emittedExternalAssets = new Map();
+ const emittedAssets = new Map();
if (asset.hashed) {
if (basename.endsWith('.css') && extractAssets) {
const { code } = await (bundleCss ? bundleAsync : transform)({
@@ -106,49 +106,34 @@ export async function emitAssets(
const assetLocation = path.resolve(path.dirname(asset.filePath), filePath);
const assetContent = fs.readFileSync(assetLocation);
- // Avoid duplicates
- if (!emittedExternalAssets.has(assetLocation)) {
+ let emittedAsset = emittedAssets.get(assetLocation);
+
+ if (!emittedAsset) {
+ // Avoid duplicates
const basename = path.basename(filePath);
const fileRef = this.emitFile({
type: 'asset',
name: extractAssetsLegacyCss ? `assets/${basename}` : basename,
+ originalFileName: assetLocation,
source: assetContent,
});
const emittedAssetFilepath = this.getFileName(fileRef);
- const emittedAssetBasename = path.basename(emittedAssetFilepath);
- emittedExternalAssets.set(assetLocation, emittedAssetFilepath);
- // Update the URL in the original CSS file to point to the emitted asset file
- if (extractAssetsLegacyCss) {
- url.url = `assets/${emittedAssetBasename}`;
- } else {
- if (options.publicPath) {
- url.url = toBrowserPath(
- path.join(options.publicPath, emittedAssetFilepath),
- );
- } else {
- url.url = emittedAssetBasename;
- }
- }
- if (idRef) {
- url.url = `${url.url}#${idRef}`;
- }
+ emittedAsset = {
+ filePath: emittedAssetFilepath,
+ refId: fileRef,
+ };
+ emittedAssets.set(assetLocation, emittedAsset);
+ }
+
+ if (extractAssetsLegacyCss) {
+ const emittedAssetBasename = path.basename(emittedAsset.filePath);
+ url.url = `assets/${emittedAssetBasename}`;
} else {
- const emittedAssetFilepath = emittedExternalAssets.get(assetLocation);
- const emittedAssetBasename = path.basename(emittedAssetFilepath);
- if (extractAssetsLegacyCss) {
- url.url = `assets/${emittedAssetBasename}`;
- } else {
- if (options.publicPath) {
- url.url = toBrowserPath(
- path.join(options.publicPath, emittedAssetFilepath),
- );
- } else {
- url.url = emittedAssetBasename;
- }
- }
- if (idRef) {
- url.url = `${url.url}#${idRef}`;
- }
+ url.url = createAssetPlaceholder(emittedAsset.refId);
+ }
+
+ if (idRef) {
+ url.url = `${url.url}#${idRef}`;
}
}
return url;
@@ -161,7 +146,12 @@ export async function emitAssets(
}
}
- ref = this.emitFile({ type: 'asset', name: basename, source });
+ ref = this.emitFile({
+ type: 'asset',
+ name: basename,
+ originalFileName: asset.filePath,
+ source,
+ });
} else {
// ensure the output filename is unique
let i = 1;
diff --git a/packages/rollup-plugin-html/src/output/getEntrypointBundles.ts b/packages/rollup-plugin-html/src/output/getEntrypointBundles.ts
index 12c1bbb5ca..871f009de3 100644
--- a/packages/rollup-plugin-html/src/output/getEntrypointBundles.ts
+++ b/packages/rollup-plugin-html/src/output/getEntrypointBundles.ts
@@ -74,9 +74,9 @@ export function getEntrypointBundles(params: GetEntrypointBundlesParams) {
outputDir,
fileOutputDir: options.dir ?? '',
htmlFileName,
- fileName: chunkOrAsset.fileName,
+ fileName: chunk.fileName,
});
- entrypoints.push({ importPath, chunk: chunkOrAsset, attributes: found.attributes });
+ entrypoints.push({ importPath, chunk, attributes: found.attributes });
}
}
}
diff --git a/packages/rollup-plugin-html/src/rollupPluginHTML.ts b/packages/rollup-plugin-html/src/rollupPluginHTML.ts
index 388c329675..135468b614 100644
--- a/packages/rollup-plugin-html/src/rollupPluginHTML.ts
+++ b/packages/rollup-plugin-html/src/rollupPluginHTML.ts
@@ -14,6 +14,7 @@ import {
} from './RollupPluginHTMLOptions.js';
import { createError, NOOP_IMPORT } from './utils.js';
import { emitAssets } from './output/emitAssets.js';
+import { processCssAssets } from './output/css.js';
export interface RollupPluginHtml extends Plugin {
api: {
@@ -140,6 +141,9 @@ export function rollupPluginHTML(pluginOptions: RollupPluginHTMLOptions = {}): R
generatedBundles.push({ name: 'default', options, bundle });
const emittedAssets = await emitAssets.call(this, inputs, pluginOptions);
+
+ processCssAssets(this, bundle, pluginOptions.publicPath);
+
const outputs = await createHTMLOutput({
outputDir: path.resolve(options.dir),
inputs,
@@ -199,6 +203,9 @@ export function rollupPluginHTML(pluginOptions: RollupPluginHTMLOptions = {}): R
}
const emittedAssets = await emitAssets.call(this, inputs, pluginOptions);
+
+ processCssAssets(this, bundle, pluginOptions.publicPath);
+
const outputs = await createHTMLOutput({
outputDir: path.resolve(options.dir),
inputs,
diff --git a/packages/rollup-plugin-html/test/rollup-plugin-html.test.ts b/packages/rollup-plugin-html/test/rollup-plugin-html.test.ts
index 564cb96c28..feae3bf8b0 100644
--- a/packages/rollup-plugin-html/test/rollup-plugin-html.test.ts
+++ b/packages/rollup-plugin-html/test/rollup-plugin-html.test.ts
@@ -2,7 +2,15 @@ import { rollup, OutputChunk, OutputOptions, Plugin } from 'rollup';
import { expect } from 'chai';
import path from 'path';
import { rollupPluginHTML } from '../src/index.js';
-import { html, css, js, svg, generateTestBundle, createApp, cleanApp } from './utils.js';
+import {
+ html,
+ css,
+ js,
+ svg,
+ generateTestBundle,
+ createApp,
+ cleanApp,
+} from '../../../test-utils/rollup-test-utils.js';
const outputConfig: OutputOptions = {
format: 'es',
@@ -2115,20 +2123,20 @@ describe('rollup-plugin-html', () => {
expect(assets).to.have.keys([
'assets/font-normal-Cht9ZB76.woff2',
'assets/font-bold-eQjSonqH.woff2',
- 'assets/styles-Dhs3ufep.css',
+ 'assets/styles-CJ7-ESJg.css',
'index.html',
]);
expect(assets['index.html']).to.equal(html`
-
+
`);
- expect(assets['assets/styles-Dhs3ufep.css']).to.equal(css`
+ expect(assets['assets/styles-CJ7-ESJg.css']).to.equal(css`
@font-face {
font-family: Font;
src: url('font-normal-Cht9ZB76.woff2') format('woff2');
@@ -2280,20 +2288,20 @@ describe('rollup-plugin-html', () => {
expect(assets).to.have.keys([
'assets/font-normal-Cht9ZB76.woff2',
'assets/font-bold-eQjSonqH.woff2',
- 'assets/styles-Dhs3ufep.css',
+ 'assets/styles-CJ7-ESJg.css',
'index.html',
]);
expect(assets['index.html']).to.equal(html`
-
+
`);
- expect(assets['assets/styles-Dhs3ufep.css']).to.equal(css`
+ expect(assets['assets/styles-CJ7-ESJg.css']).to.equal(css`
@font-face {
font-family: Font;
src: url('font-normal-Cht9ZB76.woff2') format('woff2');
@@ -2445,22 +2453,22 @@ describe('rollup-plugin-html', () => {
expect(assets).to.have.keys([
'assets/font-normal-Cht9ZB76.woff2',
- 'assets/styles-a-jFIfrzm8.css',
- 'assets/styles-b-B-8m1N7T.css',
+ 'assets/styles-a-Dhot3uqa.css',
+ 'assets/styles-b-BRueNdBl.css',
'index.html',
]);
expect(assets['index.html']).to.equal(html`
-
-
+
+
`);
- expect(assets['assets/styles-a-jFIfrzm8.css']).to.equal(css`
+ expect(assets['assets/styles-a-Dhot3uqa.css']).to.equal(css`
@font-face {
font-family: Font;
src: url('font-normal-Cht9ZB76.woff2') format('woff2');
@@ -2470,7 +2478,7 @@ describe('rollup-plugin-html', () => {
}
`);
- expect(assets['assets/styles-b-B-8m1N7T.css']).to.equal(css`
+ expect(assets['assets/styles-b-BRueNdBl.css']).to.equal(css`
@font-face {
font-family: Font2;
src: url('font-normal-Cht9ZB76.woff2') format('woff2');
@@ -2557,20 +2565,20 @@ describe('rollup-plugin-html', () => {
'assets/star-CXig10q7.png',
'assets/star-CwhgM_z4.svg',
'assets/star-CKbh5mKn.webp',
- 'assets/styles-mywkihBc.css',
+ 'assets/styles-Ee2nhCU_.css',
'index.html',
]);
expect(assets['index.html']).to.equal(html`
-
+
`);
- expect(assets['assets/styles-mywkihBc.css']).to.equal(css`
+ expect(assets['assets/styles-Ee2nhCU_.css']).to.equal(css`
#a {
background-image: url('star-D_LO5feX.avif');
}
@@ -2806,7 +2814,7 @@ describe('rollup-plugin-html', () => {
expect(assets).to.have.keys([
'assets/image-a-XOCPHCrV.png',
'assets/image-b-BgQHKcRn.png',
- 'assets/styles-Bv-4gk2N.css',
+ 'assets/styles-CxU15QYj.css',
'assets/webmanifest-BkrOR1WG.json',
'index.html',
]);
@@ -2819,7 +2827,7 @@ describe('rollup-plugin-html', () => {
-
+
@@ -2832,7 +2840,7 @@ describe('rollup-plugin-html', () => {