This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Reprise (symfony/reprise) — a Symfony bundle that brings Webpack Encore's key features to modern bundlers: Vite and Rsbuild/Rspack. The bundle is a Composer package (PHP src//tests/ at the repo root); its JS side is the @symfony/reprise npm package, an unplugin living under assets/. Full ESM. Past the stub stage: the Encore-parity feature set (entrypoints/manifest, asset versioning, CDN publicPath, dev server + HMR, SRI, file copy, Stimulus) works on both bundlers, and the PHP RepriseBundle renders the tags.
Design principle — do NOT re-implement what the bundler already does. Vite and Rsbuild natively handle Sass/Less/PostCSS, TypeScript, code splitting, content hashing, source maps, minification, HMR, and the dev server. This plugin does NOT wrap or re-expose any of those (so no enableSassLoader()-style API from Encore). Its job is the Symfony integration glue that the bundlers do not provide — see below.
The repo is a Composer bundle (symfony/reprise, PHP src//tests/ at the root) plus the assets/ npm package (@symfony/reprise, the JS plugin and its tests), tied together by a pnpm workspace at the repo root.
Package manager is pnpm (enforced via packageManager field). Node 22 (.nvmrc). The root pnpm build/dev/typecheck/test/lint/fmt scripts run from the workspace root; build/dev/typecheck/test delegate to the assets package, while lint (Oxlint) and fmt (Oxfmt) run at the root over the whole repo.
pnpm build— delegates toassets, build viatsdown(bundles everyassets/src/*.tstoassets/dist/)pnpm dev— delegates toassets,tsdown -w, watch/rebuildpnpm typecheck— delegates toassets,tsc --noEmitviaassets/tsconfig.typecheck.json(bundler resolution + DOM libs, so it type-checks the sources the way tsdown/Vite/Rspack resolve them; the buildtsconfig.jsonstaysnodenext). tsdown does not type-check, so CI runs this in thelintjob.pnpm lint—oxlintat the root (config in.oxlintrc.json);pnpm lint:fixauto-fixes.playground/,assets/test/fixtures/anddocs/are ignored (not library source)pnpm fmt/pnpm fmt:check—oxfmtat the root (config in.oxfmtrc.json);fmt:checkis the read-only variant CI runspnpm test— delegates toassets, run tests (vitest, scoped toassets/test/viavitest.config.tsso it never picks up.references/clones)pnpm vitest run assets/test/index.test.ts— run a single test filepnpm vitest run -t "hi vitest"— run a single test by name
playground/ is a full Symfony 7 PHP app used to exercise the plugin against a real backend. It defines two entries (app, admin) and imports the plugin directly from ../assets/src (Vite via playground/vite.config.ts, Rsbuild via playground/rsbuild.config.ts). nodemon rebuilds on assets/src/**/*.ts changes.
Run from the playground dir (there is no root wrapper script):
npm -C playground run vite:dev/npm -C playground run vite:buildnpm -C playground run rsbuild:dev/npm -C playground run rsbuild:build
Bundler-agnostic core + per-bundler adapters, all under assets/src/:
assets/src/core/— pure, no bundler imports:options.ts(normalizeOptions+ CDN guard +resolvePublicPath),dev-server.ts(resolveDevOrigin),format.ts(buildEntrypoints/buildManifestin the frozen v1 format),emit.ts(writeSymfonyFiles).assets/src/collectors/— turn a bundler's output into the sharedNormalizedGraph:vite.ts(bundleToGraphfrom the Rollup bundle in build,configToDevGraphfrom the resolved config in serve) andrspack.ts(statsToGraphfrom the Rspack stats JSON).assets/src/index.ts— theunpluginFactory(Vite + Rsbuild) +createUnplugindefault export. UniversalresolveId/loadserve the Stimulus virtual module for both bundlers. Itsvitehooks call the collectors + core:config()setsbase/outDirand disables Vite's own manifest/publicDir copy;generateBundleemits the two files on build;configureServerwrites the dev-flavoured files pointing at the dev-server origin. Itsrsbuildhook'ssetup(api)does the same job for Rsbuild —api.modifyRsbuildConfigforcestools.htmlPlugin = false(no per-entry HTML), disables the public-dir copy (output lives underpublic/build), and sets the output paths + dev origin;api.onAfterCreateCompilertapscompiler.hooks.doneto runstatsToGraph+ core.@rsbuild/coreis an optional peer, so it isawait import()ed lazily insidesetup(never a static import) to keep it out of the Vite bundle.assets/src/vite.ts— one-line unplugin adaptercreateVitePlugin(unpluginFactory)(@symfony/reprise/vite).assets/src/rsbuild.ts— one-line unplugin adaptercreateRsbuildPlugin(unpluginFactory)(@symfony/reprise/rsbuild). unplugin's Rsbuild adapter forwards the real Rsbuild pluginapito the factory'srsbuild.setuphook (so full config-level control is preserved) and injects the raw plugin into the Rspack config viaapi.modifyRspackConfig, which is how the universalresolveId/loadreach Rspack.assets/src/types.ts— publicOptions(outputPath,publicPath,manifestKeyPrefix,devServerOrigin) + the frozenEntrypointsJson/ManifestJson/EntryFilesshapes (js/css/preload/dynamic).
Both bundlers go through the one unpluginFactory: Vite via createVitePlugin, Rsbuild via createRsbuildPlugin (unplugin ≥3 added Rsbuild support — the earlier hand-written native RsbuildPlugin existed only because unplugin 2.x had none). The shared factory is what lets the Stimulus virtual module be a single universal resolveId/load implementation instead of per-bundler plumbing. The raw Rspack unplugin adapter is not used — Rsbuild is the supported Rspack layer.
Encore's real value to Symfony is two JSON files written into outputPath, consumed by Reprise's own Symfony bundle (RepriseBundle, the PHP side under src/) via its Twig helpers that render the <script>/<link>/asset() tags. Reprise does not use symfony/webpack-encore-bundle. Generating these two files in Encore-compatible format is the primary work:
entrypoints.json— maps each entry name to its asset URLs grouped by type, in load order (runtime chunks before app chunks). Optionalintegritysection for SRI hashes.{ "entrypoints": { "app": { "js": ["/build/runtime.js", "/build/app.js"], "css": ["/build/app.css"] } } }manifest.json— maps logical filename -> versioned/hashed URL, for cache-busting. Keys are prefixed withmanifestKeyPrefix(defaults topublicPathminus leading slash). WhenpublicPathpoints off the docroot — an absolute CDN URL (://) or a protocol-relative one (//), as classified byisAbsolutePublicPath—manifestKeyPrefixmust be set explicitly. Reprise ports the relevant half of Encore'svalidatePublicPathAndManifestKeyPrefix(../webpack-encore/lib/config/path-util.js) innormalizeOptions: an absolute or protocol-relativepublicPathwithout an explicitmanifestKeyPrefixthrows. Encore's second branch — rejecting apublicPathnot contained inoutputPath— is intentionally not ported: Reprise'soutputPath(a filesystem dir) andpublicPath(a URL prefix) are decoupled, so that heuristic would reject valid configs. CDN URLs inentrypoints.json/manifest.jsonare covered end-to-end byassets/test/integration/cdn.test.ts.
The plugin must behave differently depending on the bundler mode:
- Build mode (
vite build,rsbuild build): assets are written tooutputPathwith content hashes;entrypoints.json/manifest.jsonpoint at those files underpublicPath. - Serve/dev mode (
vite,rsbuild dev): the bundler's own dev server holds modules in memory and serves them over HTTP with native ESM + HMR. Hereentrypoints.jsonmust instead point at the dev server origin (e.g.http://127.0.0.1:5173/build/app.js) and inject the HMR client (@vite/client; React additionally needs the refresh preamble), so RepriseBundle's Twig tags load from the running dev server rather than from disk.
The dev server itself is native to Vite/Rsbuild — this plugin does not run one. Its only dev-server responsibility is detecting the mode (unplugin meta, or Vite's configResolved command === 'serve' vs 'build'; Rsbuild/Rspack expose the same distinction) and emitting the dev-flavored entrypoints.json plus client injection. Encore's counterpart is configureDevServerOptions() (webpack-dev-server) in the reference index.ts, but that whole layer is replaced by the native dev server.
Symfony UX ships Stimulus controllers from Composer packages, declared in assets/controllers.json (which controllers are enabled, fetch: eager|lazy, and each package's autoimport CSS). Local project controllers live in assets/controllers/.
Encore wires this with enableStimulusBridge(controllerJsonPath) (reference lib/WebpackConfig.ts:882), which only (1) adds the entries declared in controllers.json's entrypoints map and (2) aliases @symfony/stimulus-bridge/controllers.json to the real file. The actual controller registration lives in the @symfony/stimulus-bridge npm package, whose webpack loader (@symfony/stimulus-bridge/loader!./controllers.json) plus a require.context-based lazy loader turn that JSON and the assets/controllers/ dir into a registered Stimulus Application.
That loader is webpack-only, so it must be reimplemented here as a bundler-agnostic virtual module (unplugin resolveId/load): parse controllers.json, resolve each third-party controller from its npm package (honoring enabled + eager/lazy + autoimport), glob the local assets/controllers/ dir, and emit the code that registers them on the Stimulus app. Prior art: vite-plugin-symfony's virtual:symfony/controllers module.
Implemented (see the README feature list): entrypoints.json (build + dev), manifest.json, asset versioning wired into the manifest, absolute/CDN publicPath, dev-server + HMR, SRI hashes, file copy, Symfony UX / Stimulus controllers — all on both Vite and Rsbuild.
The original Encore lives at ../webpack-encore. Consult it for exact output formats and semantics:
index.ts— full Encore public API (what to selectively port vs. drop as bundler-native)lib/webpack/entry-points-plugin.ts— canonicalentrypoints.jsongeneration logiclib/plugins/manifest.tsandlib/utils/manifest-key-prefix-helper.ts—manifest.json+ key-prefix edge casestest_apps/npm-with-babel/public/build/{entrypoints,manifest}.json— real example outputs
The Encore bundle for Symfony lives at ../webpack-encore-bundle.
Read-only clones under .references/ (git-ignored) show how mature unplugins are built — same index.ts factory + thin per-bundler entry convention we use. See reference-repos.md for the list; each clone has an AGENTS.md on what to study. Most relevant: .references/unplugin-icons (virtual resolveId/load — the model for the Stimulus module) and .references/unplugin-auto-import (virtual-module code injection + .d.ts generation).
- ESM only, strict TypeScript (type-checked in CI via
pnpm typecheck), ES2022 target (via@tsconfig/node22). Use thenode:prefix for Node builtins. - New public options go in
assets/src/types.tswith JSDoc; keep bundler adapters trivial. - Documentation: any user-facing feature ships with a short section in
doc/index.rst, and that section shows both a Vite and an Rsbuild example (the two supported bundlers) — never document one without the other. Add the feature to the README feature list when it lands. Match the existing sections' natural voice; draft/polish the prose with thenatural-writing-editoragent. - Tests: a functional/integration test for one bundler (Vite or Rsbuild) always ships with its equivalent for the other — never cover one bundler without the other, including the negative/off cases.
- Commit messages: Symfony style
[<Scope>] <Short description>— PascalCase scope, imperative mood, capitalized first word, no trailing period. A feature commit uses the feature's own name as the scope (e.g.[Integrity],[Manifest]) and does not tack on[Tests]or[Docs]for the tests and docs it naturally includes;[Tests]/[Docs]are only for changes that are exclusively tests or documentation. Combine scopes as[A][B]only when a change genuinely spans several distinct components. E.g.[Stimulus] Emit forward-slash local controller paths,[Docs] Frame Stimulus usage as the Encore experience,[CI] Cancel superseded runs with a concurrency group. This is the convention used across Symfony UX and WebpackEncoreBundle — not Conventional Commits (nofeat:/fix:/chore:prefixes). - Releases: the published npm package lives in
assets/(@symfony/reprise); itsprepublishOnlyruns thetsdownbuild before publish.