Skip to content

Commit 1857056

Browse files
committed
feature #16 [Copy] Copy static files into the build and register them in the manifest (Kocal)
This PR was squashed before being merged into the main branch. Discussion ---------- [Copy] Copy static files into the build and register them in the manifest | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | Fix #... <!-- prefix each issue number with "Fix #", no need to open an issue if none exists, explain below instead --> | License | MIT Add a `copy` option that copies static files (images, fonts) referenced by a stable path from Twig into the build output and records them in manifest.json, so the asset() helper resolves them to their URL. Names are content-hashed in build mode and verbatim in dev; files are written under outputPath (public/build) and served by the Symfony web server, so they are available whether or not the dev server is running. Implemented for both Vite and Rsbuild on a shared, bundler-agnostic core. Commits ------- b8e4809 [Copy] Copy static files into the build and register them in the manifest
2 parents e361cc7 + b8e4809 commit 1857056

86 files changed

Lines changed: 1726 additions & 16 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Symfony Reprise covers only the Symfony-side glue the bundlers leave out:
2828
- 📄 **`entrypoints.json`**: generated in both build and dev-server modes
2929
- 🗺️ **`manifest.json`**: maps each logical filename to its hashed URL
3030
- 🔖 **Asset versioning**: content-hash cache busting, wired into the manifest
31+
- 📁 **File copy**: copy static files (images, fonts…) into the build, keyed in the manifest
3132
- 🔥 **Dev server & HMR**: points Twig at the running Vite/Rsbuild server
3233
- 🧩 **Symfony UX / Stimulus**: registers `controllers.json` and local controllers, eager or lazy
3334
- 🌐 **CDN support**: serve built assets from an absolute `publicPath`

assets/src/core/copy.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { ResolvedCopyEntry } from '../types';
2+
import { createHash } from 'node:crypto';
3+
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4+
import { dirname, extname, join, relative, sep } from 'node:path';
5+
import { joinUrl } from './format';
6+
7+
export interface CopyResult {
8+
/** Path used for the manifest key, e.g. `images/icons/cat.svg`. */
9+
logicalName: string;
10+
/** Path written under outputPath, hashed in build, verbatim in dev. */
11+
physicalName: string;
12+
source: Buffer;
13+
}
14+
15+
function walk(dir: string, includeSubdirectories: boolean): string[] {
16+
const out: string[] = [];
17+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
18+
const abs = join(dir, entry.name);
19+
if (entry.isDirectory()) {
20+
if (includeSubdirectories) out.push(...walk(abs, includeSubdirectories));
21+
} else {
22+
out.push(abs);
23+
}
24+
}
25+
return out;
26+
}
27+
28+
export function enumerateCopyFiles(entries: ResolvedCopyEntry[]): Array<{ absPath: string; logicalName: string }> {
29+
const out: Array<{ absPath: string; logicalName: string }> = [];
30+
for (const entry of entries) {
31+
let files: string[];
32+
try {
33+
files = walk(entry.from, entry.includeSubdirectories);
34+
} catch {
35+
console.warn(`[@symfony/reprise] copy: source directory "${entry.from}" not found, skipping`);
36+
continue;
37+
}
38+
for (const absPath of files) {
39+
const rel = relative(entry.from, absPath).split(sep).join('/');
40+
if (!entry.pattern.test(rel)) continue;
41+
out.push({ absPath, logicalName: `${entry.to}/${rel}` });
42+
}
43+
}
44+
return out;
45+
}
46+
47+
export function contentHash(source: Buffer): string {
48+
return createHash('sha256').update(source).digest('hex').slice(0, 8);
49+
}
50+
51+
export function hashedName(logicalName: string, hash: string): string {
52+
const ext = extname(logicalName);
53+
const base = ext ? logicalName.slice(0, -ext.length) : logicalName;
54+
return `${base}.${hash}${ext}`;
55+
}
56+
57+
export function resolveCopyFiles(entries: ResolvedCopyEntry[], hashed: boolean): CopyResult[] {
58+
return enumerateCopyFiles(entries).map(({ absPath, logicalName }) => {
59+
const source = readFileSync(absPath);
60+
const physicalName = hashed ? hashedName(logicalName, contentHash(source)) : logicalName;
61+
return { logicalName, physicalName, source };
62+
});
63+
}
64+
65+
export function copyManifest(
66+
files: CopyResult[],
67+
opts: { publicPath: string; manifestKeyPrefix: string }
68+
): Record<string, string> {
69+
const manifest: Record<string, string> = {};
70+
for (const file of files) {
71+
manifest[opts.manifestKeyPrefix + file.logicalName] = joinUrl(opts.publicPath, file.physicalName);
72+
}
73+
return manifest;
74+
}
75+
76+
export function writeCopyFiles(files: CopyResult[], outputPath: string): void {
77+
for (const file of files) {
78+
const dest = join(outputPath, file.physicalName);
79+
mkdirSync(dirname(dest), { recursive: true });
80+
writeFileSync(dest, file.source);
81+
}
82+
}

assets/src/core/format.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { BuildContext, EntryFiles, EntrypointsJson, ManifestJson, NormalizedGraph } from '../types';
22

3-
function joinUrl(prefix: string, name: string): string {
3+
export function joinUrl(prefix: string, name: string): string {
44
return prefix.endsWith('/') ? prefix + name : `${prefix}/${name}`;
55
}
66

assets/src/core/options.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,32 @@
1-
import type { Options, ResolvedOptions, ResolvedStimulusOptions } from '../types';
1+
import type { CopyEntry, Options, ResolvedCopyEntry, ResolvedOptions, ResolvedStimulusOptions } from '../types';
22
import * as path from 'node:path';
33

44
function normalizeIntegrity(integrity: Options['integrity']): ResolvedOptions['integrity'] {
55
if (!integrity?.enabled) return undefined;
66
return { algorithms: integrity.algorithms?.length ? [...integrity.algorithms] : ['sha384'] };
77
}
88

9+
function normalizeCopyTo(to: string): string {
10+
// Collapse "." segments and redundant slashes, then drop any leading "./" or "/" and trailing
11+
// "/". A leading "./" or "/" would corrupt the manifest key ("build/./to-copy/…") and, in the
12+
// Vite path, make Rollup reject the emitted asset fileName (it forbids relative-looking paths).
13+
const normalized = path.posix.normalize(to.replace(/\\/g, '/'));
14+
return normalized
15+
.replace(/^\.?\/+/, '')
16+
.replace(/^\.$/, '')
17+
.replace(/\/+$/, '');
18+
}
19+
20+
function normalizeCopy(copy: CopyEntry[] | undefined, cwd: string): ResolvedCopyEntry[] {
21+
if (!copy) return [];
22+
return copy.map((entry) => ({
23+
from: path.isAbsolute(entry.from) ? entry.from : path.join(cwd, entry.from),
24+
to: normalizeCopyTo(entry.to),
25+
pattern: entry.pattern ?? /.*/,
26+
includeSubdirectories: entry.includeSubdirectories ?? true,
27+
}));
28+
}
29+
930
function normalizeStimulus(stimulus: Options['stimulus'], cwd: string): ResolvedStimulusOptions | undefined {
1031
if (!stimulus) return undefined;
1132
const raw = typeof stimulus === 'string' ? { controllersJson: stimulus } : stimulus;
@@ -44,6 +65,7 @@ export function normalizeOptions(options: Options | undefined, cwd: string): Res
4465
devServerOrigin: options?.devServerOrigin,
4566
stimulus: normalizeStimulus(options?.stimulus, cwd),
4667
integrity: normalizeIntegrity(options?.integrity),
68+
copy: normalizeCopy(options?.copy, cwd),
4769
};
4870
}
4971

assets/src/index.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { join } from 'node:path';
55
import * as process from 'node:process';
66
import { createUnplugin } from 'unplugin';
77
import { bundleToGraph, configToDevGraph } from './collectors/vite';
8+
import { copyManifest, resolveCopyFiles, writeCopyFiles } from './core/copy';
89
import { resolveDevOrigin } from './core/dev-server';
910
import { writeSymfonyFiles } from './core/emit';
1011
import { buildEntrypoints, buildManifest } from './core/format';
@@ -49,10 +50,21 @@ export const unpluginFactory: UnpluginFactory<Options | undefined> = (options, _
4950
fileName: 'entrypoints.json',
5051
source: `${JSON.stringify(buildEntrypoints(graph, ctx), null, 2)}\n`,
5152
});
53+
const copyFiles = resolveCopyFiles(resolved.copy, true);
54+
for (const file of copyFiles) {
55+
this.emitFile({ type: 'asset', fileName: file.physicalName, source: file.source });
56+
}
57+
const manifest = {
58+
...buildManifest(graph, ctx),
59+
...copyManifest(copyFiles, {
60+
publicPath: resolved.publicPath,
61+
manifestKeyPrefix: resolved.manifestKeyPrefix,
62+
}),
63+
};
5264
this.emitFile({
5365
type: 'asset',
5466
fileName: 'manifest.json',
55-
source: `${JSON.stringify(buildManifest(graph, ctx), null, 2)}\n`,
67+
source: `${JSON.stringify(manifest, null, 2)}\n`,
5668
});
5769
// SRI must hash the bytes that ship: Vite only finalizes chunks (replacing markers like
5870
// `__VITE_PRELOAD__`) when writing to disk, so the in-memory bundle differs from the file.
@@ -115,10 +127,15 @@ export const unpluginFactory: UnpluginFactory<Options | undefined> = (options, _
115127
manifestKeyPrefix: resolved.manifestKeyPrefix,
116128
};
117129
try {
130+
const copyFiles = resolveCopyFiles(resolved.copy, false);
131+
writeCopyFiles(copyFiles, resolved.outputPath);
118132
writeSymfonyFiles(
119133
resolved.outputPath,
120134
buildEntrypoints(configToDevGraph(server.config), ctx),
121-
{}
135+
copyManifest(copyFiles, {
136+
publicPath: resolved.publicPath,
137+
manifestKeyPrefix: resolved.manifestKeyPrefix,
138+
})
122139
);
123140
} catch (err) {
124141
server.config.logger.error(

assets/src/rsbuild.ts

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { RsbuildPlugin } from '@rsbuild/core';
22
import type { RspackStats } from './collectors/rspack';
3-
import type { BuildContext, Options } from './types';
3+
import type { BuildContext, ManifestJson, Options } from './types';
44
import * as path from 'node:path';
55
import * as process from 'node:process';
66
import { rspack } from '@rsbuild/core';
77
import { statsToGraph } from './collectors/rspack';
8+
import { copyManifest, resolveCopyFiles, writeCopyFiles } from './core/copy';
89
import { writeSymfonyFiles } from './core/emit';
910
import { integrityFromDisk, referencedFileNames } from './core/integrity';
1011
import { buildEntrypoints, buildManifest } from './core/format';
@@ -103,6 +104,32 @@ export default function symfony(options?: Options): RsbuildPlugin {
103104
api.onAfterCreateCompiler(({ compiler }) => {
104105
const compilers = 'compilers' in compiler ? compiler.compilers : [compiler];
105106
for (const c of compilers) {
107+
// Build: emit the copied files into the compilation, so Rspack writes them, lists
108+
// them in the build output, and cleans them like any other asset. `sourceFilename`
109+
// lets the existing statsToGraph collector key them in manifest.json (no manual
110+
// merge — see the `done` tap). Dev instead writes them to disk in `done`: there they
111+
// are served by the Symfony web server from `public/build`, not by the dev server,
112+
// so they must not become in-memory compilation assets.
113+
if (!isDev) {
114+
c.hooks.thisCompilation.tap('@symfony/reprise:copy', (compilation) => {
115+
compilation.hooks.processAssets.tap(
116+
{
117+
name: '@symfony/reprise:copy',
118+
stage: rspack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
119+
},
120+
() => {
121+
for (const file of resolveCopyFiles(resolved.copy, true)) {
122+
compilation.emitAsset(
123+
file.physicalName,
124+
new rspack.sources.RawSource(file.source),
125+
{ sourceFilename: file.logicalName }
126+
);
127+
}
128+
}
129+
);
130+
});
131+
}
132+
106133
c.hooks.done.tap('@symfony/reprise', (stats) => {
107134
const isDev = c.watchMode;
108135
// The dev URLs we advertise must be the dev-server origin joined with our `publicPath`
@@ -141,10 +168,22 @@ export default function symfony(options?: Options): RsbuildPlugin {
141168
resolved.integrity.algorithms
142169
);
143170
}
144-
// In dev the manifest is empty: assets are served from the dev server, never looked
145-
// up on disk by hash, so cache-busting is moot. entrypoints.json alone drives loading.
146-
// Matches the Vite dev path (see `configureServer` in index.ts), which also writes `{}`.
147-
const manifest = isDev ? {} : buildManifest(graph, ctx);
171+
// Copied static files: in build they were emitted into the compilation (see the
172+
// processAssets tap above), so statsToGraph already carries them in `graph.assets`
173+
// and `buildManifest` keys them. In dev they are not emitted (served by the Symfony
174+
// web server from disk, not the dev server), so write them out here and key them by
175+
// their relative publicPath URL.
176+
let manifest: ManifestJson;
177+
if (isDev) {
178+
const copyFiles = resolveCopyFiles(resolved.copy, false);
179+
writeCopyFiles(copyFiles, resolved.outputPath);
180+
manifest = copyManifest(copyFiles, {
181+
publicPath: resolved.publicPath,
182+
manifestKeyPrefix: resolved.manifestKeyPrefix,
183+
});
184+
} else {
185+
manifest = buildManifest(graph, ctx);
186+
}
148187
try {
149188
writeSymfonyFiles(resolved.outputPath, buildEntrypoints(graph, ctx), manifest);
150189
} catch (err) {

assets/src/types.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,18 @@ export interface Options {
9898
* ```
9999
*/
100100
integrity?: IntegrityOptions;
101+
102+
/**
103+
* Copy static files (images, fonts…) into the build output and register them
104+
* in manifest.json, so Twig's `asset('<to>/<path>')` resolves to the file URL.
105+
* Works in both build (content-hashed names) and dev (verbatim names). Files are
106+
* written under `outputPath` and served by the Symfony web server from `public/`.
107+
*
108+
* ```js
109+
* Symfony({ copy: [{ from: 'assets/images', to: 'images' }] })
110+
* ```
111+
*/
112+
copy?: CopyEntry[];
101113
}
102114

103115
/** Hash algorithm used for Subresource Integrity. */
@@ -124,6 +136,24 @@ export interface ResolvedStimulusOptions {
124136
controllersDir: string;
125137
}
126138

139+
export interface CopyEntry {
140+
/** Source directory, relative to the project root (cwd) or absolute. */
141+
from: string;
142+
/** Logical destination prefix used for the manifest key (e.g. `images`). */
143+
to: string;
144+
/** Only files whose path relative to `from` matches this regex are copied. Default: every file. */
145+
pattern?: RegExp;
146+
/** Recurse into subdirectories of `from`. Default: true. */
147+
includeSubdirectories?: boolean;
148+
}
149+
150+
export interface ResolvedCopyEntry {
151+
from: string;
152+
to: string;
153+
pattern: RegExp;
154+
includeSubdirectories: boolean;
155+
}
156+
127157
/** Map of Stimulus identifier -> controller class (registered eagerly). */
128158
export type EagerControllersCollection = Record<string, ControllerConstructor>;
129159
/** Map of Stimulus identifier -> dynamic-import factory (registered lazily). */
@@ -137,6 +167,7 @@ export interface ResolvedOptions {
137167
stimulus?: ResolvedStimulusOptions;
138168
/** Present (with a non-empty algorithm list) only when SRI is enabled. */
139169
integrity?: { algorithms: string[] };
170+
copy: ResolvedCopyEntry[];
140171
}
141172

142173
export interface EntryFiles {

0 commit comments

Comments
 (0)