Skip to content

Commit ffea2a7

Browse files
committed
feature #81 Add a per-entry opt-out of copied filenames hashing (pyrech)
This PR was merged into the main branch. Discussion ---------- Add a per-entry opt-out of copied filenames hashing | Q | A | -------------- | --- | Bug fix? | no | New feature? | yes <!-- please update CHANGELOG.md file --> | Deprecations? | no <!-- if yes, also update UPGRADE-*.md and CHANGELOG.md files --> | Documentation? | yes <!-- required for new features, or documentation updates --> | Issues | Fix #... <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead --> | License | MIT Encore's `copyFiles()` let projects keep stable physical paths for copied files (e.g. `to: 'images/[path][name].[ext]?[hash:8]'`: verbatim path on disk, hash in the query string). Large codebases rely on that contract: templates referencing copied files by a hardcoded `asset('/build/images/logo.svg')` without going through the manifest, Twig filters or PHP code reading them from a predictable location on disk, CDN path rules… Reprise's `copy` option always content-hashes the emitted filenames, which breaks those references and currently forces such projects into a custom plugin. While migrating a large Symfony site (5 sites, 800+ templates, ~300 hardcoded asset paths) from Encore to Reprise, we ended up maintaining a 75-line Vite plugin just to reproduce the Encore behavior. This PR adds a per-entry `hash` option (default `true`, current behavior unchanged). When `false`, the file keeps its logical path on disk and the content hash moves to the `manifest.json` value as a query string, so cache-busting through `asset()` keeps working: ```js Symfony({ copy: [ { from: 'assets/images', to: 'images', hash: false }, ], }) ``` ``` { "build/images/logo.svg": "/build/images/logo.svg?87dcc351" } ``` Dev-server behavior is unchanged (files were already copied verbatim, and dev manifest values stay unversioned). Two design notes: - Per entry rather than global, so hashed and stable copies can be mixed in one config. - The hash still lands in the manifest value rather than disappearing: it is Encore's historical contract, and asset() consumers keep per-file cache-busting. The trade-off (proxies/CDNs configured to ignore query strings will not pick up new versions) is documented, which is why hashed filenames remain the default. The PR also makes an empty `to: ''` copy files at the root of `outputPath` (previously producing a leading-slash fileName that Rollup rejects) — needed for files like `favicon.ico` or `site.webmanifest` that must live at a fixed top-level path. We verified the feature end-to-end on the migration mentioned above: swapping the custom plugin for `hash: false` entries produces byte-identical trees and manifests in build, and the same on-disk copies + manifest entries in dev. Commits ------- 75181d6 Add a per-entry opt-out of copied filenames hashing
2 parents 947c4a0 + 75181d6 commit ffea2a7

9 files changed

Lines changed: 196 additions & 21 deletions

File tree

CHANGELOG.md

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

3+
## 0.8.0
4+
5+
- Add a per-entry `hash` option to `copy` (default `true`): with `hash: false` the file is emitted at its logical path instead of a content-hashed one, and the hash moves to the `manifest.json` value as a `?<contenthash>` query string
6+
- Support an empty `to` on a `copy` entry, emitting the files at the root of `outputPath`
7+
38
## 0.7.0
49

510
- 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: 17 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: content-hashed in build, verbatim in dev and for `hash: false` entries. */
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,14 @@ 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+
const version = contentHash(source);
66+
return hash
67+
? { logicalName, physicalName: hashedName(logicalName, version), versionQuery: '', source }
68+
: { logicalName, physicalName: logicalName, versionQuery: `?${version}`, source };
6269
});
6370
}
6471

@@ -68,7 +75,8 @@ export function copyManifest(
6875
): Record<string, string> {
6976
const manifest: Record<string, string> = {};
7077
for (const file of files) {
71-
manifest[opts.manifestKeyPrefix + file.logicalName] = joinUrl(opts.publicPath, file.physicalName);
78+
manifest[opts.manifestKeyPrefix + file.logicalName] =
79+
joinUrl(opts.publicPath, file.physicalName) + file.versionQuery;
7280
}
7381
return manifest;
7482
}

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/index.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { UnpluginFactory } from 'unplugin';
22
import type { RspackStats } from './collectors/rspack';
3+
import type { CopyResult } from './core/copy';
34
import type { BuildContext, ManifestJson, NormalizedGraph, Options } from './types';
45
import { writeFileSync } from 'node:fs';
56
import { join } from 'node:path';
@@ -237,6 +238,8 @@ export const unpluginFactory: UnpluginFactory<Options | undefined> = (options, _
237238
// Build: emit copied files into the compilation so Rspack writes/cleans them and
238239
// `sourceFilename` lets statsToGraph key them in the manifest. Dev writes them to disk in
239240
// `done` instead (served by Symfony, not the dev server), so they aren't in-memory assets.
241+
// Resolved per compilation, so a rebuild picks up edits to the copied files.
242+
let copiedInBuild: CopyResult[] = [];
240243
if (!isDev) {
241244
c.hooks.thisCompilation.tap('@symfony/reprise:copy', (compilation) => {
242245
compilation.hooks.processAssets.tap(
@@ -245,7 +248,8 @@ export const unpluginFactory: UnpluginFactory<Options | undefined> = (options, _
245248
stage: c.rspack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
246249
},
247250
() => {
248-
for (const file of resolveCopyFiles(resolved.copy, true)) {
251+
copiedInBuild = resolveCopyFiles(resolved.copy, true);
252+
for (const file of copiedInBuild) {
249253
compilation.emitAsset(
250254
file.physicalName,
251255
new c.rspack.sources.RawSource(file.source),
@@ -290,15 +294,16 @@ export const unpluginFactory: UnpluginFactory<Options | undefined> = (options, _
290294
resolved.integrity.algorithms
291295
);
292296
}
293-
// Copied files: build emits them into the compilation (statsToGraph keys them); dev isn't
294-
// emitted, so write them to disk and key them here.
297+
// Copied files: build emits them into the compilation, so statsToGraph already keys them,
298+
// but only `copyManifest` knows the `hash: false` version query, hence the overlay. Dev
299+
// isn't emitted, so write them to disk and key them here.
295300
let manifest: ManifestJson;
296301
if (isDev) {
297302
const copyFiles = resolveCopyFiles(resolved.copy, false);
298303
writeCopyFiles(copyFiles, resolved.outputPath);
299304
manifest = copyManifest(copyFiles, resolved);
300305
} else {
301-
manifest = buildManifest(graph, ctx);
306+
manifest = { ...buildManifest(graph, ctx), ...copyManifest(copiedInBuild, resolved) };
302307
}
303308
try {
304309
writeSymfonyFiles(resolved.outputPath, buildEntrypoints(graph, ctx), manifest);

assets/src/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,19 +139,26 @@ 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. Set it to false for files referenced by a
150+
* stable path outside the manifest: the file keeps its logical path and the hash moves to the
151+
* manifest value as a query string, as Encore's `copyFiles()` allowed.
152+
*/
153+
hash?: boolean;
148154
}
149155

150156
export interface ResolvedCopyEntry {
151157
from: string;
152158
to: string;
153159
pattern: RegExp;
154160
includeSubdirectories: boolean;
161+
hash: boolean;
155162
}
156163

157164
/** 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: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,46 @@ 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+
54+
it('build: an empty `to` copies at the root of outputPath', async () => {
55+
const out = mkdtempSync(join(tmpdir(), 'ups-copy-vite-root-'));
56+
await build({
57+
root: fixture,
58+
logLevel: 'silent',
59+
build: { emptyOutDir: true, rollupOptions: { input: { app: join(fixture, 'app.js') } } },
60+
plugins: [
61+
SymfonyVite({
62+
outputPath: out,
63+
publicPath: '/build/',
64+
copy: [{ from: copySrc, to: '', hash: false }],
65+
}),
66+
],
67+
});
68+
69+
const manifest = JSON.parse(readFileSync(join(out, 'manifest.json'), 'utf8'));
70+
expect(manifest['build/logo.svg']).toMatch(/^\/build\/logo\.svg\?[0-9a-f]{8}$/);
71+
expect(existsSync(join(out, 'logo.svg'))).toBe(true);
72+
}, 30_000);
73+
3474
it('build: no copy option leaves the manifest without image keys', async () => {
3575
const out = mkdtempSync(join(tmpdir(), 'ups-copy-vite-off-'));
3676
await build({
@@ -143,6 +183,52 @@ describe('rsbuild copy', () => {
143183
expect(existsSync(join(out, physical))).toBe(true);
144184
}, 60_000);
145185

186+
it('build: `hash: false` entries keep their logical path, versioned in the manifest query', async () => {
187+
const out = mkdtempSync(join(tmpdir(), 'ups-copy-rsbuild-nohash-'));
188+
const rsbuild = await createRsbuild({
189+
cwd: fixture,
190+
rsbuildConfig: {
191+
mode: 'production',
192+
source: { entry: { app: join(fixture, 'app.js') } },
193+
plugins: [
194+
SymfonyRsbuild({
195+
outputPath: out,
196+
publicPath: '/build/',
197+
copy: [{ from: copySrc, to: 'images', hash: false }],
198+
}),
199+
],
200+
},
201+
});
202+
await rsbuild.build();
203+
204+
const manifest = JSON.parse(readFileSync(join(out, 'manifest.json'), 'utf8'));
205+
expect(manifest['build/images/logo.svg']).toMatch(/^\/build\/images\/logo\.svg\?[0-9a-f]{8}$/);
206+
expect(existsSync(join(out, 'images/logo.svg'))).toBe(true);
207+
}, 60_000);
208+
209+
it('build: an empty `to` copies at the root of outputPath', async () => {
210+
const out = mkdtempSync(join(tmpdir(), 'ups-copy-rsbuild-root-'));
211+
const rsbuild = await createRsbuild({
212+
cwd: fixture,
213+
rsbuildConfig: {
214+
mode: 'production',
215+
source: { entry: { app: join(fixture, 'app.js') } },
216+
plugins: [
217+
SymfonyRsbuild({
218+
outputPath: out,
219+
publicPath: '/build/',
220+
copy: [{ from: copySrc, to: '', hash: false }],
221+
}),
222+
],
223+
},
224+
});
225+
await rsbuild.build();
226+
227+
const manifest = JSON.parse(readFileSync(join(out, 'manifest.json'), 'utf8'));
228+
expect(manifest['build/logo.svg']).toMatch(/^\/build\/logo\.svg\?[0-9a-f]{8}$/);
229+
expect(existsSync(join(out, 'logo.svg'))).toBe(true);
230+
}, 60_000);
231+
146232
it('build: no copy option leaves the manifest without image keys', async () => {
147233
const out = mkdtempSync(join(tmpdir(), 'ups-copy-rsbuild-off-'));
148234
const rsbuild = await createRsbuild({

doc/index.rst

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -324,13 +324,37 @@ manifest (below), the ``asset()`` helper resolves the logical path to the hashed
324324
})
325325
326326
``from`` and ``to`` are both required: ``from`` is the source directory (relative to your project root), ``to`` is
327-
the destination prefix used for the manifest key. Restrict which files are copied with ``pattern``, a regular
328-
expression tested against each file's path relative to ``from`` (by default every file is copied).
329-
``includeSubdirectories`` defaults to ``true``; set it to ``false`` to turn off recursion.
327+
the destination prefix used for the manifest key. Pass an empty ``to`` to copy the files at the root of
328+
``outputPath``, for things like ``favicon.ico`` or ``site.webmanifest`` that have to live at a fixed URL. Restrict
329+
which files are copied with ``pattern``, a regular expression tested against each file's path relative to ``from``
330+
(by default every file is copied). ``includeSubdirectories`` defaults to ``true``; set it to ``false`` to turn off
331+
recursion.
332+
333+
Some copied files have to keep a stable path on disk: templates referencing them through a hardcoded
334+
``asset('/build/images/logo.svg')``, code reading them from a predictable location, CDN rules, and so on. Set
335+
``hash: false`` on the entry and the file keeps its logical path. The content hash then moves to the
336+
``manifest.json`` value as a query string, so cache-busting through ``asset()`` still works:
337+
338+
.. code-block:: javascript
339+
340+
copy: [
341+
{
342+
from: 'assets/images',
343+
to: 'images',
344+
hash: false,
345+
},
346+
],
347+
348+
.. code-block:: json
349+
350+
{ "build/images/logo.svg": "/build/images/logo.svg?87dcc351" }
351+
352+
Be aware that proxies or CDNs configured to ignore query strings will not pick up new versions of these files.
353+
That is why hashed filenames remain the default.
330354

331355
How copied files are handled depends on the mode:
332356

333-
- **Build**: each file gets a content hash in its filename for cache busting.
357+
- **Build**: each file gets a content hash in its filename for cache busting, unless the entry sets ``hash: false``.
334358
- **Dev**: files are copied verbatim, no hash.
335359

336360
Either way they land in ``public/build`` and are served by the Symfony web server, not the Vite/Rsbuild dev server,

0 commit comments

Comments
 (0)