Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# CHANGELOG

## 0.8.0

- 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
- Support an empty `to` on a `copy` entry, emitting the files at the root of `outputPath`

## 0.7.0

- 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
Expand Down
26 changes: 17 additions & 9 deletions assets/src/core/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { joinUrl } from './format';
export interface CopyResult {
/** Path used for the manifest key, e.g. `images/icons/cat.svg`. */
logicalName: string;
/** Path written under outputPath, hashed in build, verbatim in dev. */
/** Path written under outputPath: content-hashed in build, verbatim in dev and for `hash: false` entries. */
physicalName: string;
/** `?<contenthash>` appended to the manifest value for `hash: false` entries in build, `''` otherwise. */
versionQuery: string;
source: Buffer;
}

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

export function enumerateCopyFiles(entries: ResolvedCopyEntry[]): Array<{ absPath: string; logicalName: string }> {
const out: Array<{ absPath: string; logicalName: string }> = [];
export function enumerateCopyFiles(
entries: ResolvedCopyEntry[]
): Array<{ absPath: string; logicalName: string; hash: boolean }> {
const out: Array<{ absPath: string; logicalName: string; hash: boolean }> = [];
for (const entry of entries) {
let files: string[];
try {
Expand All @@ -38,7 +42,7 @@ export function enumerateCopyFiles(entries: ResolvedCopyEntry[]): Array<{ absPat
for (const absPath of files) {
const rel = relative(entry.from, absPath).split(sep).join('/');
if (!entry.pattern.test(rel)) continue;
out.push({ absPath, logicalName: `${entry.to}/${rel}` });
out.push({ absPath, logicalName: entry.to ? `${entry.to}/${rel}` : rel, hash: entry.hash });
}
}
return out;
Expand All @@ -54,11 +58,14 @@ export function hashedName(logicalName: string, hash: string): string {
return `${base}.${hash}${ext}`;
}

export function resolveCopyFiles(entries: ResolvedCopyEntry[], hashed: boolean): CopyResult[] {
return enumerateCopyFiles(entries).map(({ absPath, logicalName }) => {
export function resolveCopyFiles(entries: ResolvedCopyEntry[], build: boolean): CopyResult[] {
return enumerateCopyFiles(entries).map(({ absPath, logicalName, hash }) => {
const source = readFileSync(absPath);
const physicalName = hashed ? hashedName(logicalName, contentHash(source)) : logicalName;
return { logicalName, physicalName, source };
if (!build) return { logicalName, physicalName: logicalName, versionQuery: '', source };
const version = contentHash(source);
return hash
? { logicalName, physicalName: hashedName(logicalName, version), versionQuery: '', source }
: { logicalName, physicalName: logicalName, versionQuery: `?${version}`, source };
});
}

Expand All @@ -68,7 +75,8 @@ export function copyManifest(
): Record<string, string> {
const manifest: Record<string, string> = {};
for (const file of files) {
manifest[opts.manifestKeyPrefix + file.logicalName] = joinUrl(opts.publicPath, file.physicalName);
manifest[opts.manifestKeyPrefix + file.logicalName] =
joinUrl(opts.publicPath, file.physicalName) + file.versionQuery;
}
return manifest;
}
Expand Down
1 change: 1 addition & 0 deletions assets/src/core/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function normalizeCopy(copy: CopyEntry[] | undefined, cwd: string): ResolvedCopy
to: normalizeCopyTo(entry.to),
pattern: entry.pattern ?? /.*/,
includeSubdirectories: entry.includeSubdirectories ?? true,
hash: entry.hash ?? true,
}));
}

Expand Down
13 changes: 9 additions & 4 deletions assets/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { UnpluginFactory } from 'unplugin';
import type { RspackStats } from './collectors/rspack';
import type { CopyResult } from './core/copy';
import type { BuildContext, ManifestJson, NormalizedGraph, Options } from './types';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
Expand Down Expand Up @@ -237,6 +238,8 @@ export const unpluginFactory: UnpluginFactory<Options | undefined> = (options, _
// Build: emit copied files into the compilation so Rspack writes/cleans them and
// `sourceFilename` lets statsToGraph key them in the manifest. Dev writes them to disk in
// `done` instead (served by Symfony, not the dev server), so they aren't in-memory assets.
// Resolved per compilation, so a rebuild picks up edits to the copied files.
let copiedInBuild: CopyResult[] = [];
if (!isDev) {
c.hooks.thisCompilation.tap('@symfony/reprise:copy', (compilation) => {
compilation.hooks.processAssets.tap(
Expand All @@ -245,7 +248,8 @@ export const unpluginFactory: UnpluginFactory<Options | undefined> = (options, _
stage: c.rspack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
() => {
for (const file of resolveCopyFiles(resolved.copy, true)) {
copiedInBuild = resolveCopyFiles(resolved.copy, true);
for (const file of copiedInBuild) {
compilation.emitAsset(
file.physicalName,
new c.rspack.sources.RawSource(file.source),
Expand Down Expand Up @@ -290,15 +294,16 @@ export const unpluginFactory: UnpluginFactory<Options | undefined> = (options, _
resolved.integrity.algorithms
);
}
// Copied files: build emits them into the compilation (statsToGraph keys them); dev isn't
// emitted, so write them to disk and key them here.
// Copied files: build emits them into the compilation, so statsToGraph already keys them,
// but only `copyManifest` knows the `hash: false` version query, hence the overlay. Dev
// isn't emitted, so write them to disk and key them here.
let manifest: ManifestJson;
if (isDev) {
const copyFiles = resolveCopyFiles(resolved.copy, false);
writeCopyFiles(copyFiles, resolved.outputPath);
manifest = copyManifest(copyFiles, resolved);
} else {
manifest = buildManifest(graph, ctx);
manifest = { ...buildManifest(graph, ctx), ...copyManifest(copiedInBuild, resolved) };
}
try {
writeSymfonyFiles(resolved.outputPath, buildEntrypoints(graph, ctx), manifest);
Expand Down
9 changes: 8 additions & 1 deletion assets/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,19 +139,26 @@ export interface ResolvedStimulusOptions {
export interface CopyEntry {
/** Source directory, relative to the project root (cwd) or absolute. */
from: string;
/** Logical destination prefix used for the manifest key (e.g. `images`). */
/** Logical destination prefix used for the manifest key (e.g. `images`); `''` copies at the root of `outputPath`. */
to: string;
/** Only files whose path relative to `from` matches this regex are copied. Default: every file. */
pattern?: RegExp;
/** Recurse into subdirectories of `from`. Default: true. */
includeSubdirectories?: boolean;
/**
* Content-hash the emitted filename. Default: true. Set it to false for files referenced by a
* stable path outside the manifest: the file keeps its logical path and the hash moves to the
* manifest value as a query string, as Encore's `copyFiles()` allowed.
*/
hash?: boolean;
}

export interface ResolvedCopyEntry {
from: string;
to: string;
pattern: RegExp;
includeSubdirectories: boolean;
hash: boolean;
}

/** Map of Stimulus identifier -> controller class (registered eagerly). */
Expand Down
32 changes: 30 additions & 2 deletions assets/test/core/copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const src = join(import.meta.dirname, '../fixtures/copy-src');
const binSrc = join(import.meta.dirname, '../fixtures/copy-binary');

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

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

it('copies at the root of outputPath when `to` is empty', () => {
const names = enumerateCopyFiles([entry({ to: '', pattern: /\.txt$/ })])
.map((f) => f.logicalName)
.sort();
expect(names).toEqual(['notes.txt']);
});

it('aggregates multiple entries under their own `to` prefixes', () => {
const names = enumerateCopyFiles([
entry({ to: 'a', pattern: /\.svg$/ }),
{ from: binSrc, to: 'b', pattern: /.*/, includeSubdirectories: true },
{ from: binSrc, to: 'b', pattern: /.*/, includeSubdirectories: true, hash: true },
])
.map((f) => f.logicalName)
.sort();
Expand Down Expand Up @@ -67,6 +74,21 @@ describe('resolveCopyFiles', () => {
it('uses verbatim physical names when hashed=false', () => {
const logo = resolveCopyFiles([entry()], false).find((f) => f.logicalName === 'images/logo.svg')!;
expect(logo.physicalName).toBe('images/logo.svg');
expect(logo.versionQuery).toBe('');
});

it('keeps the logical path and moves the hash to versionQuery for `hash: false` entries in build', () => {
const logo = resolveCopyFiles([entry({ hash: false })], true).find((f) => f.logicalName === 'images/logo.svg')!;
expect(logo.physicalName).toBe('images/logo.svg');
expect(logo.versionQuery).toMatch(/^\?[0-9a-f]{8}$/);
});

it('does not version `hash: false` entries in dev', () => {
const logo = resolveCopyFiles([entry({ hash: false })], false).find(
(f) => f.logicalName === 'images/logo.svg'
)!;
expect(logo.physicalName).toBe('images/logo.svg');
expect(logo.versionQuery).toBe('');
});
});

Expand All @@ -77,6 +99,12 @@ describe('copyManifest', () => {
expect(manifest['build/images/logo.svg']).toMatch(/^\/build\/images\/logo\.[0-9a-f]{8}\.svg$/);
expect(manifest['build/images/icons/cat.svg']).toMatch(/^\/build\/images\/icons\/cat\.[0-9a-f]{8}\.svg$/);
});

it('appends the version query for `hash: false` entries', () => {
const files = resolveCopyFiles([entry({ hash: false })], true);
const manifest = copyManifest(files, { publicPath: '/build/', manifestKeyPrefix: 'build/' });
expect(manifest['build/images/logo.svg']).toMatch(/^\/build\/images\/logo\.svg\?[0-9a-f]{8}$/);
});
});

describe('contentHash', () => {
Expand Down
13 changes: 12 additions & 1 deletion assets/test/core/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,13 @@ describe('normalizeOptions', () => {
it('resolves a relative copy `from` against cwd and applies defaults', () => {
const r = normalizeOptions({ copy: [{ from: 'assets/images', to: 'images' }] }, '/app');
expect(r.copy).toEqual([
{ from: join('/app', 'assets/images'), to: 'images', pattern: /.*/, includeSubdirectories: true },
{
from: join('/app', 'assets/images'),
to: 'images',
pattern: /.*/,
includeSubdirectories: true,
hash: true,
},
]);
});

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

it('honors `hash: false` on a copy entry', () => {
const r = normalizeOptions({ copy: [{ from: '/src/img', to: 'images', hash: false }] }, '/app');
expect(r.copy[0].hash).toBe(false);
});

it('normalizes a `to` with a leading "./" and trailing slash to a clean prefix', () => {
// A leading "./" would leak into the manifest key ("build/./to-copy/…") and, in Vite,
// Rollup rejects an emitted fileName that looks like a relative path ("./to-copy/…").
Expand Down
86 changes: 86 additions & 0 deletions assets/test/integration/copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,46 @@ describe('vite copy', () => {
expect(existsSync(join(out, physical))).toBe(true);
}, 30_000);

it('build: `hash: false` entries keep their logical path, versioned in the manifest query', async () => {
const out = mkdtempSync(join(tmpdir(), 'ups-copy-vite-nohash-'));
await build({
root: fixture,
logLevel: 'silent',
build: { emptyOutDir: true, rollupOptions: { input: { app: join(fixture, 'app.js') } } },
plugins: [
SymfonyVite({
outputPath: out,
publicPath: '/build/',
copy: [{ from: copySrc, to: 'images', hash: false }],
}),
],
});

const manifest = JSON.parse(readFileSync(join(out, 'manifest.json'), 'utf8'));
expect(manifest['build/images/logo.svg']).toMatch(/^\/build\/images\/logo\.svg\?[0-9a-f]{8}$/);
expect(existsSync(join(out, 'images/logo.svg'))).toBe(true);
}, 30_000);

it('build: an empty `to` copies at the root of outputPath', async () => {
const out = mkdtempSync(join(tmpdir(), 'ups-copy-vite-root-'));
await build({
root: fixture,
logLevel: 'silent',
build: { emptyOutDir: true, rollupOptions: { input: { app: join(fixture, 'app.js') } } },
plugins: [
SymfonyVite({
outputPath: out,
publicPath: '/build/',
copy: [{ from: copySrc, to: '', hash: false }],
}),
],
});

const manifest = JSON.parse(readFileSync(join(out, 'manifest.json'), 'utf8'));
expect(manifest['build/logo.svg']).toMatch(/^\/build\/logo\.svg\?[0-9a-f]{8}$/);
expect(existsSync(join(out, 'logo.svg'))).toBe(true);
}, 30_000);

it('build: no copy option leaves the manifest without image keys', async () => {
const out = mkdtempSync(join(tmpdir(), 'ups-copy-vite-off-'));
await build({
Expand Down Expand Up @@ -143,6 +183,52 @@ describe('rsbuild copy', () => {
expect(existsSync(join(out, physical))).toBe(true);
}, 60_000);

it('build: `hash: false` entries keep their logical path, versioned in the manifest query', async () => {
const out = mkdtempSync(join(tmpdir(), 'ups-copy-rsbuild-nohash-'));
const rsbuild = await createRsbuild({
cwd: fixture,
rsbuildConfig: {
mode: 'production',
source: { entry: { app: join(fixture, 'app.js') } },
plugins: [
SymfonyRsbuild({
outputPath: out,
publicPath: '/build/',
copy: [{ from: copySrc, to: 'images', hash: false }],
}),
],
},
});
await rsbuild.build();

const manifest = JSON.parse(readFileSync(join(out, 'manifest.json'), 'utf8'));
expect(manifest['build/images/logo.svg']).toMatch(/^\/build\/images\/logo\.svg\?[0-9a-f]{8}$/);
expect(existsSync(join(out, 'images/logo.svg'))).toBe(true);
}, 60_000);

it('build: an empty `to` copies at the root of outputPath', async () => {
const out = mkdtempSync(join(tmpdir(), 'ups-copy-rsbuild-root-'));
const rsbuild = await createRsbuild({
cwd: fixture,
rsbuildConfig: {
mode: 'production',
source: { entry: { app: join(fixture, 'app.js') } },
plugins: [
SymfonyRsbuild({
outputPath: out,
publicPath: '/build/',
copy: [{ from: copySrc, to: '', hash: false }],
}),
],
},
});
await rsbuild.build();

const manifest = JSON.parse(readFileSync(join(out, 'manifest.json'), 'utf8'));
expect(manifest['build/logo.svg']).toMatch(/^\/build\/logo\.svg\?[0-9a-f]{8}$/);
expect(existsSync(join(out, 'logo.svg'))).toBe(true);
}, 60_000);

it('build: no copy option leaves the manifest without image keys', async () => {
const out = mkdtempSync(join(tmpdir(), 'ups-copy-rsbuild-off-'));
const rsbuild = await createRsbuild({
Expand Down
32 changes: 28 additions & 4 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -324,13 +324,37 @@ manifest (below), the ``asset()`` helper resolves the logical path to the hashed
})

``from`` and ``to`` are both required: ``from`` is the source directory (relative to your project root), ``to`` is
the destination prefix used for the manifest key. Restrict which files are copied with ``pattern``, a regular
expression tested against each file's path relative to ``from`` (by default every file is copied).
``includeSubdirectories`` defaults to ``true``; set it to ``false`` to turn off recursion.
the destination prefix used for the manifest key. Pass an empty ``to`` to copy the files at the root of
``outputPath``, for things like ``favicon.ico`` or ``site.webmanifest`` that have to live at a fixed URL. Restrict
which files are copied with ``pattern``, a regular expression tested against each file's path relative to ``from``
(by default every file is copied). ``includeSubdirectories`` defaults to ``true``; set it to ``false`` to turn off
recursion.

Some copied files have to keep a stable path on disk: templates referencing them through a hardcoded
``asset('/build/images/logo.svg')``, code reading them from a predictable location, CDN rules, and so on. Set
``hash: false`` on the entry and the file keeps its logical path. The content hash then moves to the
``manifest.json`` value as a query string, so cache-busting through ``asset()`` still works:

.. code-block:: javascript

copy: [
{
from: 'assets/images',
to: 'images',
hash: false,
},
],

.. code-block:: json

{ "build/images/logo.svg": "/build/images/logo.svg?87dcc351" }

Be aware that proxies or CDNs configured to ignore query strings will not pick up new versions of these files.
That is why hashed filenames remain the default.

How copied files are handled depends on the mode:

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

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