Skip to content

Commit 597fb32

Browse files
authored
Merge pull request #3069 from modernweb-dev/feat/preserve-assets-input-structure
Support preserving assets input structure in the output
2 parents 5260164 + 99af061 commit 597fb32

68 files changed

Lines changed: 2268 additions & 743 deletions

Some content is hidden

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

.changeset/early-geese-fly.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@web/rollup-plugin-import-meta-assets': minor
3+
---
4+
5+
Add option `preserveDynamicStructure` that emits dynamic assets and rewrites the URL pattern to resolve the original dynamic path relative to the first emitted asset.
6+
It requires that the output preserves both filenames (no hashing) and directory structure from the dynamic expression onwards.

.changeset/ten-scissors-explain.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@web/rollup-plugin-html': minor
3+
---
4+
5+
Support preserving the assets input structure in the output.
6+
Support transforming of assets found in CSS.

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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import path from 'path';
2+
import type { 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 hashes encoded as HEX strings like "abc123" from placeholders like "__ROLLUP_ASSET_URL_abc123__".
8+
*/
9+
const ASSET_URL_PLACEHOLDER_REGEX = /__ROLLUP_ASSET_URL_([a-f0-9]+)__/g;
10+
11+
/**
12+
* Creates a placeholder string for the given hash.
13+
*
14+
* @param hash - Hash encoded as a HEX string (e.g. "abc123")
15+
* @returns Placeholder string like "__ROLLUP_ASSET_URL_abc123__"
16+
*/
17+
export function createAssetPlaceholder(hash: string): string {
18+
return `__ROLLUP_ASSET_URL_${hash}__`;
19+
}
20+
21+
/**
22+
* Replaces all asset URL placeholders in CSS content with resolved paths.
23+
*
24+
* @param cssContent - The CSS content with placeholders
25+
* @param resolver - Function that resolves a hash to the final path
26+
* @returns CSS content with placeholders replaced
27+
*/
28+
export function replacePlaceholders(
29+
cssContent: string,
30+
resolver: (hash: string) => string | undefined,
31+
): string {
32+
return cssContent.replace(ASSET_URL_PLACEHOLDER_REGEX, (match, hash) => {
33+
const resolvedPath = resolver(hash);
34+
return resolvedPath ?? match;
35+
});
36+
}
37+
38+
/**
39+
* Calculates the path from a CSS file to a referenced asset in the output.
40+
* If publicPath is provided, returns an absolute path. Otherwise returns a relative path.
41+
*
42+
* @param cssFilePath - The CSS file's path in the bundle (e.g. 'styles/main.css')
43+
* @param assetFilePath - The asset's path in the bundle (e.g. 'assets/image.png')
44+
* @param publicPath - Optional public path prefix (e.g. '/static/')
45+
* @returns Absolute path if publicPath provided, otherwise relative path from CSS to asset
46+
*/
47+
export function calculateRelativePath(
48+
cssFilePath: string,
49+
assetFilePath: string,
50+
publicPath?: string,
51+
): string {
52+
if (publicPath) {
53+
return toBrowserPath(path.join(publicPath, assetFilePath));
54+
}
55+
56+
const cssDir = path.dirname(cssFilePath);
57+
const relativePath = path.relative(cssDir, assetFilePath);
58+
return toBrowserPath(relativePath);
59+
}
60+
61+
/**
62+
* Processes all CSS files in the bundle, replacing placeholders with resolved paths.
63+
*
64+
* @param {PluginContext} pluginContext - The Rollup plugin context
65+
* @param {OutputBundle} bundle - The Rollup output bundle
66+
* @param {Record<string, { ref: string }>} assetsInCssByHash - Map of asset hashes to their Rollup refs for assets found in CSS
67+
* @param {string} [publicPath] - Optional public path prefix for absolute URLs (e.g. '/static/')
68+
*/
69+
export function processCssAssets(
70+
pluginContext: PluginContext,
71+
bundle: OutputBundle,
72+
assetsInCssByHash: Record<string, { ref: string }>,
73+
publicPath?: string,
74+
): void {
75+
for (const [filePath, asset] of Object.entries(bundle)) {
76+
if (asset.type !== 'asset' || !filePath.endsWith('.css')) continue;
77+
78+
const cssContent =
79+
typeof asset.source === 'string' ? asset.source : Buffer.from(asset.source).toString('utf-8');
80+
81+
const resolvedContent = replacePlaceholders(cssContent, (hash: string) => {
82+
const ref = assetsInCssByHash[hash]!.ref;
83+
const assetFilePath = pluginContext.getFileName(ref);
84+
return calculateRelativePath(filePath, assetFilePath, publicPath);
85+
});
86+
87+
asset.source = resolvedContent;
88+
}
89+
}
Lines changed: 95 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1+
import { createHash } from 'node:crypto';
12
import { PluginContext } from 'rollup';
23
import path from 'path';
34
import { bundleAsync, transform } from 'lightningcss';
45
import fs from 'fs';
56

67
import { InputAsset, InputData } from '../input/InputData';
7-
import { toBrowserPath } from './utils.js';
88
import { createAssetPicomatchMatcher } from '../assets/utils.js';
99
import { RollupPluginHTMLOptions, TransformAssetFunction } from '../RollupPluginHTMLOptions';
10+
import { createAssetPlaceholder } from './css.js';
1011

1112
export interface EmittedAssets {
1213
static: Map<string, string>;
1314
hashed: Map<string, string>;
15+
assetsInCssByHash: Record<string, { ref: string }>;
1416
}
1517

1618
const allowedFileExtensions = [
@@ -56,9 +58,28 @@ export async function emitAssets(
5658
transforms.push(options.transformAsset);
5759
}
5860
}
61+
62+
async function getTransformedAsset(content: Buffer, filePath: string): Promise<Buffer> {
63+
let source: Buffer = content;
64+
for (const transform of transforms) {
65+
const result = await transform(content, filePath);
66+
if (result != null) {
67+
source = typeof result === 'string' ? Buffer.from(result, 'utf-8') : result;
68+
}
69+
}
70+
return source;
71+
}
72+
5973
const staticAssets: InputAsset[] = [];
6074
const hashedAssets: InputAsset[] = [];
6175

76+
let assetsInCssCounter = 0;
77+
const assetsInCssByAbsPath: Record<
78+
string,
79+
{ tempPlaceholder: string; ref?: string; outputPath?: string; hash?: string }
80+
> = {};
81+
const assetsInCssByHash: Record<string, { ref: string }> = {};
82+
6283
for (const input of inputs) {
6384
for (const asset of input.assets) {
6485
if (asset.hashed) {
@@ -75,20 +96,10 @@ export async function emitAssets(
7596
for (const asset of allAssets) {
7697
const map = asset.hashed ? emittedHashedAssets : emittedStaticAssets;
7798
if (!map.has(asset.filePath)) {
78-
let source: Buffer = asset.content;
79-
80-
// run user's transform functions
81-
for (const transform of transforms) {
82-
const result = await transform(asset.content, asset.filePath);
83-
if (result != null) {
84-
source = typeof result === 'string' ? Buffer.from(result, 'utf-8') : result;
85-
}
86-
}
87-
99+
let source = await getTransformedAsset(asset.content, asset.filePath);
88100
let ref: string;
89101
let basename = path.basename(asset.filePath);
90102
const isExternal = createAssetPicomatchMatcher(options.externalAssets);
91-
const emittedExternalAssets = new Map();
92103
if (asset.hashed) {
93104
if (basename.endsWith('.css') && extractAssets) {
94105
const { code } = await (bundleCss ? bundleAsync : transform)({
@@ -99,69 +110,88 @@ export async function emitAssets(
99110
Url: url => {
100111
// Support foo.svg#bar
101112
// https://www.w3.org/TR/html4/types.html#:~:text=ID%20and%20NAME%20tokens%20must,tokens%20defined%20by%20other%20attributes.
102-
const [filePath, idRef] = url.url.split('#');
103-
104-
if (shouldHandleAsset(filePath) && !isExternal(filePath)) {
105-
// Read the asset file, get the asset from the source location on the FS using asset.filePath
106-
const assetLocation = path.resolve(path.dirname(asset.filePath), filePath);
107-
const assetContent = fs.readFileSync(assetLocation);
108-
109-
// Avoid duplicates
110-
if (!emittedExternalAssets.has(assetLocation)) {
111-
const basename = path.basename(filePath);
112-
const fileRef = this.emitFile({
113-
type: 'asset',
114-
name: extractAssetsLegacyCss ? `assets/${basename}` : basename,
115-
source: assetContent,
116-
});
117-
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-
}
135-
} 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-
}
113+
const [srcAssetPath, srcAssetId] = url.url.split('#');
114+
115+
if (shouldHandleAsset(srcAssetPath) && !isExternal(srcAssetPath)) {
116+
const assetAbsPath = path.resolve(path.dirname(asset.filePath), srcAssetPath);
117+
118+
let assetInCss = assetsInCssByAbsPath[assetAbsPath];
119+
120+
if (!assetInCss) {
121+
// Avoid duplicates
122+
assetsInCssCounter++;
123+
assetInCss = {
124+
tempPlaceholder: createAssetPlaceholder(assetsInCssCounter.toString()),
125+
ref: undefined,
126+
hash: undefined,
127+
};
128+
assetsInCssByAbsPath[assetAbsPath] = assetInCss;
152129
}
130+
131+
url.url = srcAssetId
132+
? `${assetInCss.tempPlaceholder}#${srcAssetId}`
133+
: assetInCss.tempPlaceholder;
153134
}
135+
154136
return url;
155137
},
156138
},
157139
});
158-
const codeBuffer = Buffer.from(code);
140+
141+
let codeString = code.toString();
142+
143+
for (const assetInCssAbsPath of Object.keys(assetsInCssByAbsPath)) {
144+
const assetInCss = assetsInCssByAbsPath[assetInCssAbsPath];
145+
146+
if (!assetInCss.ref) {
147+
const basename = path.basename(assetInCssAbsPath);
148+
const content = await fs.promises.readFile(assetInCssAbsPath);
149+
const transformedContent = await getTransformedAsset(content, assetInCssAbsPath);
150+
const ref = this.emitFile({
151+
type: 'asset',
152+
name: extractAssetsLegacyCss ? `assets/${basename}` : basename,
153+
originalFileName: assetInCssAbsPath,
154+
source: transformedContent,
155+
});
156+
assetInCss.ref = ref;
157+
assetInCss.outputPath = this.getFileName(ref);
158+
if (!extractAssetsLegacyCss) {
159+
assetInCss.hash = createHash('sha256')
160+
.update(transformedContent)
161+
.update('\0')
162+
.update(assetInCss.outputPath)
163+
.digest('hex');
164+
}
165+
}
166+
167+
if (extractAssetsLegacyCss) {
168+
const outputName = path.basename(assetInCss.outputPath!);
169+
codeString = codeString.replaceAll(
170+
assetInCss.tempPlaceholder,
171+
`assets/${outputName}`,
172+
);
173+
} else {
174+
const hash = assetInCss.hash!;
175+
assetsInCssByHash[hash] = { ref: assetInCss.ref };
176+
codeString = codeString.replaceAll(
177+
assetInCss.tempPlaceholder,
178+
createAssetPlaceholder(hash),
179+
);
180+
}
181+
}
182+
183+
const codeBuffer = Buffer.from(codeString);
159184
if (!asset.content.equals(codeBuffer)) {
160185
source = codeBuffer;
161186
}
162187
}
163188

164-
ref = this.emitFile({ type: 'asset', name: basename, source });
189+
ref = this.emitFile({
190+
type: 'asset',
191+
name: basename,
192+
originalFileName: asset.filePath,
193+
source,
194+
});
165195
} else {
166196
// ensure the output filename is unique
167197
let i = 1;
@@ -179,5 +209,5 @@ export async function emitAssets(
179209
}
180210
}
181211

182-
return { static: emittedStaticAssets, hashed: emittedHashedAssets };
212+
return { static: emittedStaticAssets, hashed: emittedHashedAssets, assetsInCssByHash };
183213
}

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
}

0 commit comments

Comments
 (0)