Skip to content

Commit 4c085d6

Browse files
committed
feat: transform assets in CSS and fix hash recalculation
1 parent 62f03e2 commit 4c085d6

10 files changed

Lines changed: 547 additions & 183 deletions

File tree

package-lock.json

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/rollup-plugin-html/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
"html-minifier-terser": "^7.1.0",
5050
"lightningcss": "^1.24.0",
5151
"parse5": "^6.0.1",
52-
"picomatch": "^2.2.2"
52+
"picomatch": "^2.2.2",
53+
"xxhash-wasm": "^1.1.0"
5354
},
5455
"devDependencies": {
5556
"@prettier/sync": "^0.6.1",

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

Lines changed: 24 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,40 +4,39 @@ import { toBrowserPath } from './utils.js';
44

55
/**
66
* 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).
7+
* Captures the hashes encoded as HEX strings like "abc123" from placeholders like "__ROLLUP_ASSET_URL_abc123__".
98
*/
10-
const ASSET_URL_PLACEHOLDER_REGEX = /__ROLLUP_ASSET_URL_([a-zA-Z0-9_$]+)__/g;
9+
const ASSET_URL_PLACEHOLDER_REGEX = /__ROLLUP_ASSET_URL_([a-f0-9]+)__/g;
1110

1211
/**
13-
* Creates a placeholder string for the given reference ID.
14-
* @param refId - The Rollup file reference ID
12+
* Creates a placeholder string for the given hash.
13+
*
14+
* @param hash - Hash encoded as a HEX string (e.g. "abc123")
1515
* @returns Placeholder string like "__ROLLUP_ASSET_URL_abc123__"
1616
*/
17-
export function createAssetPlaceholder(refId: string): string {
18-
return `__ROLLUP_ASSET_URL_${refId}__`;
17+
export function createAssetPlaceholder(hash: string): string {
18+
return `__ROLLUP_ASSET_URL_${hash}__`;
1919
}
2020

2121
/**
2222
* Replaces all asset URL placeholders in CSS content with resolved paths.
23-
* Anything after the placeholder (#id, ?queryString) is preserved naturally.
2423
*
2524
* @param cssContent - The CSS content with placeholders
26-
* @param resolver - Function that resolves a reference ID to the final path
25+
* @param resolver - Function that resolves a hash to the final path
2726
* @returns CSS content with placeholders replaced
2827
*/
2928
export function replacePlaceholders(
3029
cssContent: string,
31-
resolver: (refId: string) => string | undefined,
30+
resolver: (hash: string) => string | undefined,
3231
): string {
33-
return cssContent.replace(ASSET_URL_PLACEHOLDER_REGEX, (match, refId) => {
34-
const resolvedPath = resolver(refId);
32+
return cssContent.replace(ASSET_URL_PLACEHOLDER_REGEX, (match, hash) => {
33+
const resolvedPath = resolver(hash);
3534
return resolvedPath ?? match;
3635
});
3736
}
3837

3938
/**
40-
* Calculates the path from a CSS file to a referenced asset.
39+
* Calculates the path from a CSS file to a referenced asset in the output.
4140
* If publicPath is provided, returns an absolute path. Otherwise returns a relative path.
4241
*
4342
* @param cssFilePath - The CSS file's path in the bundle (e.g. 'styles/main.css')
@@ -50,44 +49,39 @@ export function calculateRelativePath(
5049
assetFilePath: string,
5150
publicPath?: string,
5251
): string {
53-
// If publicPath is provided, return an absolute path
5452
if (publicPath) {
55-
return toBrowserPath(`${publicPath}${assetFilePath}`);
53+
return toBrowserPath(path.join(publicPath, assetFilePath));
5654
}
5755

58-
// Otherwise, calculate relative path
5956
const cssDir = path.dirname(cssFilePath);
6057
const relativePath = path.relative(cssDir, assetFilePath);
61-
62-
// Convert to browser-style forward slashes
6358
return toBrowserPath(relativePath);
6459
}
6560

6661
/**
6762
* Processes all CSS files in the bundle, replacing placeholders with resolved paths.
6863
*
69-
* @param {PluginContext} pluginContext - the Rollup plugin context
70-
* @param {OutputBundle} bundle - the Rollup output bundle
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
7167
* @param {string} [publicPath] - Optional public path prefix for absolute URLs (e.g. '/static/')
7268
*/
7369
export function processCssAssets(
7470
pluginContext: PluginContext,
7571
bundle: OutputBundle,
72+
assetsInCssByHash: Record<string, { ref: string }>,
7673
publicPath?: string,
7774
): void {
78-
for (const [fileName, asset] of Object.entries(bundle)) {
79-
if (asset.type !== 'asset' || !fileName.endsWith('.css')) continue;
75+
for (const [filePath, asset] of Object.entries(bundle)) {
76+
if (asset.type !== 'asset' || !filePath.endsWith('.css')) continue;
8077

81-
const content =
78+
const cssContent =
8279
typeof asset.source === 'string' ? asset.source : Buffer.from(asset.source).toString('utf-8');
8380

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-
}
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);
9185
});
9286

9387
asset.source = resolvedContent;

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

Lines changed: 86 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { PluginContext } from 'rollup';
22
import path from 'path';
33
import { bundleAsync, transform } from 'lightningcss';
44
import fs from 'fs';
5+
import xxhash from 'xxhash-wasm';
56

67
import { InputAsset, InputData } from '../input/InputData';
78
import { createAssetPicomatchMatcher } from '../assets/utils.js';
@@ -11,6 +12,7 @@ import { createAssetPlaceholder } from './css.js';
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 = [
@@ -40,6 +42,8 @@ export async function emitAssets(
4042
inputs: InputData[],
4143
options: RollupPluginHTMLOptions,
4244
) {
45+
const { create64 } = await xxhash();
46+
4347
const extractAssets = options.extractAssets ?? true;
4448
const bundleCss = options.bundleCss ?? true;
4549
const minifyCss = options.minifyCss ?? false;
@@ -56,9 +60,28 @@ export async function emitAssets(
5660
transforms.push(options.transformAsset);
5761
}
5862
}
63+
64+
async function getTransformedAsset(content: Buffer, filePath: string): Promise<Buffer> {
65+
let source: Buffer = content;
66+
for (const transform of transforms) {
67+
const result = await transform(content, filePath);
68+
if (result != null) {
69+
source = typeof result === 'string' ? Buffer.from(result, 'utf-8') : result;
70+
}
71+
}
72+
return source;
73+
}
74+
5975
const staticAssets: InputAsset[] = [];
6076
const hashedAssets: InputAsset[] = [];
6177

78+
let assetsInCssCounter = 0;
79+
const assetsInCssByAbsPath: Record<
80+
string,
81+
{ tempPlaceholder: string; ref?: string; outputPath?: string; hash?: string }
82+
> = {};
83+
const assetsInCssByHash: Record<string, { ref: string }> = {};
84+
6285
for (const input of inputs) {
6386
for (const asset of input.assets) {
6487
if (asset.hashed) {
@@ -75,20 +98,10 @@ export async function emitAssets(
7598
for (const asset of allAssets) {
7699
const map = asset.hashed ? emittedHashedAssets : emittedStaticAssets;
77100
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-
101+
let source = await getTransformedAsset(asset.content, asset.filePath);
88102
let ref: string;
89103
let basename = path.basename(asset.filePath);
90104
const isExternal = createAssetPicomatchMatcher(options.externalAssets);
91-
const emittedAssets = new Map<string, { filePath: string; refId: string }>();
92105
if (asset.hashed) {
93106
if (basename.endsWith('.css') && extractAssets) {
94107
const { code } = await (bundleCss ? bundleAsync : transform)({
@@ -99,48 +112,79 @@ export async function emitAssets(
99112
Url: url => {
100113
// Support foo.svg#bar
101114
// 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('#');
115+
const [srcAssetPath, srcAssetId] = url.url.split('#');
103116

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);
117+
if (shouldHandleAsset(srcAssetPath) && !isExternal(srcAssetPath)) {
118+
const assetAbsPath = path.resolve(path.dirname(asset.filePath), srcAssetPath);
108119

109-
let emittedAsset = emittedAssets.get(assetLocation);
120+
let assetInCss = assetsInCssByAbsPath[assetAbsPath];
110121

111-
if (!emittedAsset) {
122+
if (!assetInCss) {
112123
// Avoid duplicates
113-
const basename = path.basename(filePath);
114-
const fileRef = this.emitFile({
115-
type: 'asset',
116-
name: extractAssetsLegacyCss ? `assets/${basename}` : basename,
117-
originalFileName: assetLocation,
118-
source: assetContent,
119-
});
120-
const emittedAssetFilepath = this.getFileName(fileRef);
121-
emittedAsset = {
122-
filePath: emittedAssetFilepath,
123-
refId: fileRef,
124+
assetsInCssCounter++;
125+
assetInCss = {
126+
tempPlaceholder: createAssetPlaceholder(assetsInCssCounter.toString()),
127+
ref: undefined,
128+
hash: undefined,
124129
};
125-
emittedAssets.set(assetLocation, emittedAsset);
130+
assetsInCssByAbsPath[assetAbsPath] = assetInCss;
126131
}
127132

128-
if (extractAssetsLegacyCss) {
129-
const emittedAssetBasename = path.basename(emittedAsset.filePath);
130-
url.url = `assets/${emittedAssetBasename}`;
131-
} else {
132-
url.url = createAssetPlaceholder(emittedAsset.refId);
133-
}
134-
135-
if (idRef) {
136-
url.url = `${url.url}#${idRef}`;
137-
}
133+
url.url = srcAssetId
134+
? `${assetInCss.tempPlaceholder}#${srcAssetId}`
135+
: assetInCss.tempPlaceholder;
138136
}
137+
139138
return url;
140139
},
141140
},
142141
});
143-
const codeBuffer = Buffer.from(code);
142+
143+
let codeString = code.toString();
144+
145+
for (const assetInCssAbsPath of Object.keys(assetsInCssByAbsPath)) {
146+
const assetInCss = assetsInCssByAbsPath[assetInCssAbsPath];
147+
148+
if (!assetInCss.ref) {
149+
const basename = path.basename(assetInCssAbsPath);
150+
const content = await fs.promises.readFile(assetInCssAbsPath);
151+
const transformedContent = await getTransformedAsset(content, assetInCssAbsPath);
152+
const ref = this.emitFile({
153+
type: 'asset',
154+
name: extractAssetsLegacyCss ? `assets/${basename}` : basename,
155+
originalFileName: assetInCssAbsPath,
156+
source: transformedContent,
157+
});
158+
assetInCss.ref = ref;
159+
assetInCss.outputPath = this.getFileName(ref);
160+
if (!extractAssetsLegacyCss) {
161+
assetInCss.hash = create64()
162+
.update(transformedContent)
163+
.update('\0')
164+
.update(assetInCss.outputPath)
165+
.digest()
166+
.toString(16)
167+
.padStart(16, '0');
168+
}
169+
}
170+
171+
if (extractAssetsLegacyCss) {
172+
const outputName = path.basename(assetInCss.outputPath!);
173+
codeString = codeString.replaceAll(
174+
assetInCss.tempPlaceholder,
175+
`assets/${outputName}`,
176+
);
177+
} else {
178+
const hash = assetInCss.hash!;
179+
assetsInCssByHash[hash] = { ref: assetInCss.ref };
180+
codeString = codeString.replaceAll(
181+
assetInCss.tempPlaceholder,
182+
createAssetPlaceholder(hash),
183+
);
184+
}
185+
}
186+
187+
const codeBuffer = Buffer.from(codeString);
144188
if (!asset.content.equals(codeBuffer)) {
145189
source = codeBuffer;
146190
}
@@ -169,5 +213,5 @@ export async function emitAssets(
169213
}
170214
}
171215

172-
return { static: emittedStaticAssets, hashed: emittedHashedAssets };
216+
return { static: emittedStaticAssets, hashed: emittedHashedAssets, assetsInCssByHash };
173217
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export function rollupPluginHTML(pluginOptions: RollupPluginHTMLOptions = {}): R
142142

143143
const emittedAssets = await emitAssets.call(this, inputs, pluginOptions);
144144

145-
processCssAssets(this, bundle, pluginOptions.publicPath);
145+
processCssAssets(this, bundle, emittedAssets.assetsInCssByHash, pluginOptions.publicPath);
146146

147147
const outputs = await createHTMLOutput({
148148
outputDir: path.resolve(options.dir),
@@ -204,7 +204,12 @@ export function rollupPluginHTML(pluginOptions: RollupPluginHTMLOptions = {}): R
204204

205205
const emittedAssets = await emitAssets.call(this, inputs, pluginOptions);
206206

207-
processCssAssets(this, bundle, pluginOptions.publicPath);
207+
processCssAssets(
208+
this,
209+
bundle,
210+
emittedAssets.assetsInCssByHash,
211+
pluginOptions.publicPath,
212+
);
208213

209214
const outputs = await createHTMLOutput({
210215
outputDir: path.resolve(options.dir),

0 commit comments

Comments
 (0)