Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/docs/building/rollup-plugin-import-meta-assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,52 @@ export default {
};
```

### `preserveDynamicStructure`

Type: `Boolean`<br>
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:
Expand Down
2 changes: 1 addition & 1 deletion packages/rollup-plugin-html/src/RollupPluginHTMLOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
95 changes: 95 additions & 0 deletions packages/rollup-plugin-html/src/output/css.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
66 changes: 28 additions & 38 deletions packages/rollup-plugin-html/src/output/emitAssets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
Expand Down Expand Up @@ -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<string, { filePath: string; refId: string }>();
if (asset.hashed) {
if (basename.endsWith('.css') && extractAssets) {
const { code } = await (bundleCss ? bundleAsync : transform)({
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions packages/rollup-plugin-html/src/rollupPluginHTML.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading