Skip to content

Commit e9206c1

Browse files
jp-knjascorbicematipico
authored
fix(astro): fix svg content-based deduplication (#14031)
Co-authored-by: Matt Kane <[email protected]> Co-authored-by: Emanuele Stoppa <[email protected]>
1 parent 976879a commit e9206c1

File tree

12 files changed

+244
-15
lines changed

12 files changed

+244
-15
lines changed

.changeset/chubby-falcons-fly.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Optimized the build pipeline for SVG images. Now, Astro doesn't reprocess images that have already been processed.

packages/astro/src/assets/utils/node/emitAsset.ts

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,57 @@ import fs from 'node:fs/promises';
22
import path from 'node:path';
33
import { fileURLToPath, pathToFileURL } from 'node:url';
44
import type * as vite from 'vite';
5+
import { generateContentHash } from '../../../core/encryption.js';
56
import { prependForwardSlash, slash } from '../../../core/path.js';
67
import type { ImageMetadata } from '../../types.js';
78
import { imageMetadata } from '../metadata.js';
89

910
type FileEmitter = vite.Rollup.EmitFile;
1011
type ImageMetadataWithContents = ImageMetadata & { contents?: Buffer };
1112

13+
type SvgCacheKey = { hash: string };
14+
15+
// Global cache for SVG content deduplication
16+
const svgContentCache = new WeakMap<SvgCacheKey, { handle: string; filename: string }>();
17+
18+
const keyRegistry = new Map<string, SvgCacheKey>();
19+
20+
function keyFor(hash: string): SvgCacheKey {
21+
let key = keyRegistry.get(hash);
22+
if (!key) {
23+
key = { hash };
24+
keyRegistry.set(hash, key);
25+
}
26+
return key;
27+
}
28+
29+
/**
30+
* Handles SVG deduplication by checking if the content already exists in cache.
31+
*/
32+
async function handleSvgDeduplication(
33+
fileData: Buffer,
34+
filename: string,
35+
fileEmitter: FileEmitter
36+
): Promise<string> {
37+
const contentHash = await generateContentHash(fileData);
38+
const key = keyFor(contentHash);
39+
const existing = svgContentCache.get(key);
40+
41+
if (existing) {
42+
// Reuse existing handle for duplicate SVG content
43+
return existing.handle;
44+
} else {
45+
// First time seeing this SVG content - emit it
46+
const handle = fileEmitter({
47+
name: filename,
48+
source: fileData,
49+
type: 'asset',
50+
});
51+
svgContentCache.set(key, { handle, filename });
52+
return handle;
53+
}
54+
}
55+
1256
/**
1357
* Processes an image file and emits its metadata and optionally its contents. This function supports both build and development modes.
1458
*
@@ -62,13 +106,20 @@ export async function emitESMImage(
62106
const filename = path.basename(pathname, path.extname(pathname) + `.${fileMetadata.format}`);
63107

64108
try {
65-
// fileEmitter throws in dev
66-
const handle = fileEmitter!({
67-
name: filename,
68-
source: await fs.readFile(url),
69-
type: 'asset',
70-
});
71-
109+
let handle: string;
110+
111+
if (fileMetadata.format === 'svg') {
112+
// check if this content already exists
113+
handle = await handleSvgDeduplication(fileData, filename, fileEmitter!);
114+
} else {
115+
// Non-SVG assets: emit normally
116+
handle = fileEmitter!({
117+
name: filename,
118+
source: fileData,
119+
type: 'asset',
120+
});
121+
}
122+
72123
emittedImage.src = `__ASTRO_ASSET_IMAGE__${handle}__`;
73124
} catch {
74125
isBuild = false;
@@ -131,13 +182,20 @@ export async function emitImageMetadata(
131182
const filename = path.basename(pathname, path.extname(pathname) + `.${fileMetadata.format}`);
132183

133184
try {
134-
// fileEmitter throws in dev
135-
const handle = fileEmitter!({
136-
name: filename,
137-
source: await fs.readFile(url),
138-
type: 'asset',
139-
});
140-
185+
let handle: string;
186+
187+
if (fileMetadata.format === 'svg') {
188+
// check if this content already exists
189+
handle = await handleSvgDeduplication(fileData, filename, fileEmitter!);
190+
} else {
191+
// Non-SVG assets: emit normally
192+
handle = fileEmitter!({
193+
name: filename,
194+
source: fileData,
195+
type: 'asset',
196+
});
197+
}
198+
141199
emittedImage.src = `__ASTRO_ASSET_IMAGE__${handle}__`;
142200
} catch {
143201
isBuild = false;

packages/astro/src/core/encryption.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,14 @@ export async function generateCspDigest(data: string, algorithm: CspAlgorithm):
123123
const hash = encodeBase64(new Uint8Array(hashBuffer));
124124
return `${ALGORITHMS[algorithm]}${hash}`;
125125
}
126+
127+
/**
128+
* Generate SHA-256 hash of buffer.
129+
* @param {ArrayBuffer} data The buffer data to hash
130+
* @returns {Promise<string>} A hex string of the first 16 characters of the SHA-256 hash
131+
*/
132+
export async function generateContentHash(data: ArrayBuffer): Promise<string> {
133+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
134+
const hashArray = new Uint8Array(hashBuffer);
135+
return encodeBase64(hashArray);
136+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { defineConfig } from 'astro/config';
2+
3+
export default defineConfig({});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "@test/svg-deduplication",
3+
"version": "0.0.0",
4+
"private": true,
5+
"dependencies": {
6+
"astro": "workspace:*"
7+
}
8+
}
Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
import Duplicate1 from '../assets/duplicate1.svg';
3+
import Duplicate2 from '../assets/duplicate2.svg';
4+
import Unique from '../assets/unique.svg';
5+
---
6+
7+
<html>
8+
<head>
9+
<title>SVG Deduplication Test</title>
10+
</head>
11+
<body>
12+
<div id="duplicate1">
13+
<Duplicate1 />
14+
</div>
15+
<div id="duplicate2">
16+
<Duplicate2 />
17+
</div>
18+
<div id="unique">
19+
<Unique />
20+
</div>
21+
</body>
22+
</html>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "../../../tsconfigs/strictest.json",
3+
"compilerOptions": {
4+
"baseUrl": ".",
5+
"paths": {
6+
"~/*": ["./src/*"]
7+
}
8+
},
9+
"include": ["src/**/*"]
10+
}

0 commit comments

Comments
 (0)