Skip to content

Commit 2caaa06

Browse files
committed
Add a per-entry opt-out of copied filenames hashing
1 parent 7c5cbb8 commit 2caaa06

8 files changed

Lines changed: 141 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# CHANGELOG
22

3+
## Unreleased
4+
5+
- Add a per-entry `hash` option to `copy` (default `true`): when `false`, the copied file keeps its logical path on disk and the content hash moves to the `manifest.json` value as a query string, like Encore's `copyFiles()` allowed
6+
37
## 0.7.0
48

59
- Add the `RenderAssetTagEvent`, dispatched before each rendered `<script>`/`<link>` (including the injected dev client and React preamble), so listeners can add, change or remove attributes such as a CSP nonce

assets/src/core/copy.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import { joinUrl } from './format';
77
export interface CopyResult {
88
/** Path used for the manifest key, e.g. `images/icons/cat.svg`. */
99
logicalName: string;
10-
/** Path written under outputPath, hashed in build, verbatim in dev. */
10+
/** Path written under outputPath: hashed in build (verbatim for `hash: false` entries and in dev). */
1111
physicalName: string;
12+
/** `?<contenthash>` appended to the manifest value for `hash: false` entries in build, `''` otherwise. */
13+
versionQuery: string;
1214
source: Buffer;
1315
}
1416

@@ -25,8 +27,10 @@ function walk(dir: string, includeSubdirectories: boolean): string[] {
2527
return out;
2628
}
2729

28-
export function enumerateCopyFiles(entries: ResolvedCopyEntry[]): Array<{ absPath: string; logicalName: string }> {
29-
const out: Array<{ absPath: string; logicalName: string }> = [];
30+
export function enumerateCopyFiles(
31+
entries: ResolvedCopyEntry[]
32+
): Array<{ absPath: string; logicalName: string; hash: boolean }> {
33+
const out: Array<{ absPath: string; logicalName: string; hash: boolean }> = [];
3034
for (const entry of entries) {
3135
let files: string[];
3236
try {
@@ -38,7 +42,7 @@ export function enumerateCopyFiles(entries: ResolvedCopyEntry[]): Array<{ absPat
3842
for (const absPath of files) {
3943
const rel = relative(entry.from, absPath).split(sep).join('/');
4044
if (!entry.pattern.test(rel)) continue;
41-
out.push({ absPath, logicalName: `${entry.to}/${rel}` });
45+
out.push({ absPath, logicalName: entry.to ? `${entry.to}/${rel}` : rel, hash: entry.hash });
4246
}
4347
}
4448
return out;
@@ -54,11 +58,19 @@ export function hashedName(logicalName: string, hash: string): string {
5458
return `${base}.${hash}${ext}`;
5559
}
5660

57-
export function resolveCopyFiles(entries: ResolvedCopyEntry[], hashed: boolean): CopyResult[] {
58-
return enumerateCopyFiles(entries).map(({ absPath, logicalName }) => {
61+
export function resolveCopyFiles(entries: ResolvedCopyEntry[], build: boolean): CopyResult[] {
62+
return enumerateCopyFiles(entries).map(({ absPath, logicalName, hash }) => {
5963
const source = readFileSync(absPath);
60-
const physicalName = hashed ? hashedName(logicalName, contentHash(source)) : logicalName;
61-
return { logicalName, physicalName, source };
64+
if (!build) return { logicalName, physicalName: logicalName, versionQuery: '', source };
65+
if (hash) {
66+
return {
67+
logicalName,
68+
physicalName: hashedName(logicalName, contentHash(source)),
69+
versionQuery: '',
70+
source,
71+
};
72+
}
73+
return { logicalName, physicalName: logicalName, versionQuery: `?${contentHash(source)}`, source };
6274
});
6375
}
6476

@@ -68,7 +80,8 @@ export function copyManifest(
6880
): Record<string, string> {
6981
const manifest: Record<string, string> = {};
7082
for (const file of files) {
71-
manifest[opts.manifestKeyPrefix + file.logicalName] = joinUrl(opts.publicPath, file.physicalName);
83+
manifest[opts.manifestKeyPrefix + file.logicalName] =
84+
joinUrl(opts.publicPath, file.physicalName) + file.versionQuery;
7285
}
7386
return manifest;
7487
}

assets/src/core/options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ function normalizeCopy(copy: CopyEntry[] | undefined, cwd: string): ResolvedCopy
3333
to: normalizeCopyTo(entry.to),
3434
pattern: entry.pattern ?? /.*/,
3535
includeSubdirectories: entry.includeSubdirectories ?? true,
36+
hash: entry.hash ?? true,
3637
}));
3738
}
3839

assets/src/types.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,19 +139,28 @@ export interface ResolvedStimulusOptions {
139139
export interface CopyEntry {
140140
/** Source directory, relative to the project root (cwd) or absolute. */
141141
from: string;
142-
/** Logical destination prefix used for the manifest key (e.g. `images`). */
142+
/** Logical destination prefix used for the manifest key (e.g. `images`); `''` copies at the root of `outputPath`. */
143143
to: string;
144144
/** Only files whose path relative to `from` matches this regex are copied. Default: every file. */
145145
pattern?: RegExp;
146146
/** Recurse into subdirectories of `from`. Default: true. */
147147
includeSubdirectories?: boolean;
148+
/**
149+
* Content-hash the emitted filename. Default: true.
150+
*
151+
* When false, the file keeps its logical path on disk and the content hash
152+
* moves to the manifest value as a query string (Encore's `copyFiles()`
153+
* contract) — for files referenced by a stable path outside the manifest.
154+
*/
155+
hash?: boolean;
148156
}
149157

150158
export interface ResolvedCopyEntry {
151159
from: string;
152160
to: string;
153161
pattern: RegExp;
154162
includeSubdirectories: boolean;
163+
hash: boolean;
155164
}
156165

157166
/** Map of Stimulus identifier -> controller class (registered eagerly). */

assets/test/core/copy.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const src = join(import.meta.dirname, '../fixtures/copy-src');
77
const binSrc = join(import.meta.dirname, '../fixtures/copy-binary');
88

99
function entry(over: Partial<ResolvedCopyEntry> = {}): ResolvedCopyEntry {
10-
return { from: src, to: 'images', pattern: /.*/, includeSubdirectories: true, ...over };
10+
return { from: src, to: 'images', pattern: /.*/, includeSubdirectories: true, hash: true, ...over };
1111
}
1212

1313
describe('enumerateCopyFiles', () => {
@@ -36,10 +36,17 @@ describe('enumerateCopyFiles', () => {
3636
expect(enumerateCopyFiles([entry({ from: join(src, 'does-not-exist') })])).toEqual([]);
3737
});
3838

39+
it('copies at the root of outputPath when `to` is empty', () => {
40+
const names = enumerateCopyFiles([entry({ to: '', pattern: /\.txt$/ })])
41+
.map((f) => f.logicalName)
42+
.sort();
43+
expect(names).toEqual(['notes.txt']);
44+
});
45+
3946
it('aggregates multiple entries under their own `to` prefixes', () => {
4047
const names = enumerateCopyFiles([
4148
entry({ to: 'a', pattern: /\.svg$/ }),
42-
{ from: binSrc, to: 'b', pattern: /.*/, includeSubdirectories: true },
49+
{ from: binSrc, to: 'b', pattern: /.*/, includeSubdirectories: true, hash: true },
4350
])
4451
.map((f) => f.logicalName)
4552
.sort();
@@ -67,6 +74,21 @@ describe('resolveCopyFiles', () => {
6774
it('uses verbatim physical names when hashed=false', () => {
6875
const logo = resolveCopyFiles([entry()], false).find((f) => f.logicalName === 'images/logo.svg')!;
6976
expect(logo.physicalName).toBe('images/logo.svg');
77+
expect(logo.versionQuery).toBe('');
78+
});
79+
80+
it('keeps the logical path and moves the hash to versionQuery for `hash: false` entries in build', () => {
81+
const logo = resolveCopyFiles([entry({ hash: false })], true).find((f) => f.logicalName === 'images/logo.svg')!;
82+
expect(logo.physicalName).toBe('images/logo.svg');
83+
expect(logo.versionQuery).toMatch(/^\?[0-9a-f]{8}$/);
84+
});
85+
86+
it('does not version `hash: false` entries in dev', () => {
87+
const logo = resolveCopyFiles([entry({ hash: false })], false).find(
88+
(f) => f.logicalName === 'images/logo.svg'
89+
)!;
90+
expect(logo.physicalName).toBe('images/logo.svg');
91+
expect(logo.versionQuery).toBe('');
7092
});
7193
});
7294

@@ -77,6 +99,12 @@ describe('copyManifest', () => {
7799
expect(manifest['build/images/logo.svg']).toMatch(/^\/build\/images\/logo\.[0-9a-f]{8}\.svg$/);
78100
expect(manifest['build/images/icons/cat.svg']).toMatch(/^\/build\/images\/icons\/cat\.[0-9a-f]{8}\.svg$/);
79101
});
102+
103+
it('appends the version query for `hash: false` entries', () => {
104+
const files = resolveCopyFiles([entry({ hash: false })], true);
105+
const manifest = copyManifest(files, { publicPath: '/build/', manifestKeyPrefix: 'build/' });
106+
expect(manifest['build/images/logo.svg']).toMatch(/^\/build\/images\/logo\.svg\?[0-9a-f]{8}$/);
107+
});
80108
});
81109

82110
describe('contentHash', () => {

assets/test/core/options.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,13 @@ describe('normalizeOptions', () => {
115115
it('resolves a relative copy `from` against cwd and applies defaults', () => {
116116
const r = normalizeOptions({ copy: [{ from: 'assets/images', to: 'images' }] }, '/app');
117117
expect(r.copy).toEqual([
118-
{ from: join('/app', 'assets/images'), to: 'images', pattern: /.*/, includeSubdirectories: true },
118+
{
119+
from: join('/app', 'assets/images'),
120+
to: 'images',
121+
pattern: /.*/,
122+
includeSubdirectories: true,
123+
hash: true,
124+
},
119125
]);
120126
});
121127

@@ -130,6 +136,11 @@ describe('normalizeOptions', () => {
130136
expect(r.copy[0].includeSubdirectories).toBe(false);
131137
});
132138

139+
it('honors `hash: false` on a copy entry', () => {
140+
const r = normalizeOptions({ copy: [{ from: '/src/img', to: 'images', hash: false }] }, '/app');
141+
expect(r.copy[0].hash).toBe(false);
142+
});
143+
133144
it('normalizes a `to` with a leading "./" and trailing slash to a clean prefix', () => {
134145
// A leading "./" would leak into the manifest key ("build/./to-copy/…") and, in Vite,
135146
// Rollup rejects an emitted fileName that looks like a relative path ("./to-copy/…").

assets/test/integration/copy.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,26 @@ describe('vite copy', () => {
3131
expect(existsSync(join(out, physical))).toBe(true);
3232
}, 30_000);
3333

34+
it('build: `hash: false` entries keep their logical path, versioned in the manifest query', async () => {
35+
const out = mkdtempSync(join(tmpdir(), 'ups-copy-vite-nohash-'));
36+
await build({
37+
root: fixture,
38+
logLevel: 'silent',
39+
build: { emptyOutDir: true, rollupOptions: { input: { app: join(fixture, 'app.js') } } },
40+
plugins: [
41+
SymfonyVite({
42+
outputPath: out,
43+
publicPath: '/build/',
44+
copy: [{ from: copySrc, to: 'images', hash: false }],
45+
}),
46+
],
47+
});
48+
49+
const manifest = JSON.parse(readFileSync(join(out, 'manifest.json'), 'utf8'));
50+
expect(manifest['build/images/logo.svg']).toMatch(/^\/build\/images\/logo\.svg\?[0-9a-f]{8}$/);
51+
expect(existsSync(join(out, 'images/logo.svg'))).toBe(true);
52+
}, 30_000);
53+
3454
it('build: no copy option leaves the manifest without image keys', async () => {
3555
const out = mkdtempSync(join(tmpdir(), 'ups-copy-vite-off-'));
3656
await build({
@@ -143,6 +163,26 @@ describe('rsbuild copy', () => {
143163
expect(existsSync(join(out, physical))).toBe(true);
144164
}, 60_000);
145165

166+
it('build: `hash: false` entries keep their logical path, versioned in the manifest query', async () => {
167+
const out = mkdtempSync(join(tmpdir(), 'ups-copy-vite-nohash-'));
168+
await build({
169+
root: fixture,
170+
logLevel: 'silent',
171+
build: { emptyOutDir: true, rollupOptions: { input: { app: join(fixture, 'app.js') } } },
172+
plugins: [
173+
SymfonyVite({
174+
outputPath: out,
175+
publicPath: '/build/',
176+
copy: [{ from: copySrc, to: 'images', hash: false }],
177+
}),
178+
],
179+
});
180+
181+
const manifest = JSON.parse(readFileSync(join(out, 'manifest.json'), 'utf8'));
182+
expect(manifest['build/images/logo.svg']).toMatch(/^\/build\/images\/logo\.svg\?[0-9a-f]{8}$/);
183+
expect(existsSync(join(out, 'images/logo.svg'))).toBe(true);
184+
}, 30_000);
185+
146186
it('build: no copy option leaves the manifest without image keys', async () => {
147187
const out = mkdtempSync(join(tmpdir(), 'ups-copy-rsbuild-off-'));
148188
const rsbuild = await createRsbuild({

doc/index.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,28 @@ the destination prefix used for the manifest key. Restrict which files are copie
328328
expression tested against each file's path relative to ``from`` (by default every file is copied).
329329
``includeSubdirectories`` defaults to ``true``; set it to ``false`` to turn off recursion.
330330

331+
Some copied files must keep a stable path on disk: templates referencing them by a hardcoded
332+
``asset('/build/images/logo.svg')``, code reading them from a predictable location, CDN rules… Set ``hash: false``
333+
on the entry to keep the logical path verbatim; the content hash then moves to the ``manifest.json`` value as a
334+
query string, so cache-busting through ``asset()`` keeps working:
335+
336+
.. code-block:: javascript
337+
338+
copy: [
339+
{
340+
from: 'assets/images',
341+
to: 'images',
342+
hash: false,
343+
},
344+
],
345+
346+
.. code-block:: json
347+
348+
{ "build/images/logo.svg": "/build/images/logo.svg?87dcc351" }
349+
350+
Note that proxies or CDNs configured to ignore query strings will not pick up new versions of these files — which
351+
is why hashed filenames remain the default.
352+
331353
How copied files are handled depends on the mode:
332354

333355
- **Build**: each file gets a content hash in its filename for cache busting.

0 commit comments

Comments
 (0)