├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .prettierrc ├── .vscode └── launch.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.js ├── package.json ├── scripts ├── hardcode-ux-metadata.ts ├── pre-dev.ts └── ux-assets │ ├── @symfony │ ├── ux-autocomplete │ │ └── package.json │ ├── ux-chartjs │ │ └── package.json │ ├── ux-cropperjs │ │ └── package.json │ ├── ux-dropzone │ │ └── package.json │ ├── ux-lazy-image │ │ └── package.json │ ├── ux-live-component │ │ └── package.json │ ├── ux-notify │ │ └── package.json │ ├── ux-react │ │ └── package.json │ ├── ux-svelte │ │ └── package.json │ ├── ux-swup │ │ └── package.json │ ├── ux-toggle-password │ │ └── package.json │ ├── ux-translator │ │ └── package.json │ ├── ux-turbo │ │ └── package.json │ ├── ux-typed │ │ └── package.json │ └── ux-vue │ │ └── package.json │ └── controllers.json ├── src ├── entrypoints │ ├── depreciations.ts │ ├── entryPointsHelper.test.ts │ ├── entryPointsHelper.ts │ ├── index.test.ts │ ├── index.ts │ ├── pathMapping.ts │ ├── pluginOptions.test.ts │ ├── pluginOptions.ts │ ├── utils.test.ts │ ├── utils.ts │ ├── utils.vite-mocked.test.ts │ └── utils.win32.test.ts ├── index.ts ├── logger.ts ├── stimulus │ ├── env.d.ts │ ├── helpers │ │ ├── index.ts │ │ ├── react │ │ │ ├── index.ts │ │ │ ├── render_controller.ts │ │ │ ├── types.ts │ │ │ └── util.ts │ │ ├── svelte │ │ │ ├── index.ts │ │ │ ├── render_controller.ts │ │ │ ├── types.ts │ │ │ └── util.ts │ │ ├── svelte4 │ │ │ ├── index.ts │ │ │ ├── render_controller.ts │ │ │ ├── types.ts │ │ │ └── util.ts │ │ ├── types.d.ts │ │ └── vue │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ ├── util.ts │ │ │ └── vue.test.ts │ ├── node │ │ ├── bridge.test.ts │ │ ├── bridge.ts │ │ ├── hmr.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ └── utils.ts │ ├── pluginOptions.ts │ ├── types.d.ts │ ├── util.test.ts │ └── util.ts └── types.d.ts ├── static └── dev-server-404.html ├── tests ├── fixtures │ ├── disabled-autoimport.json │ ├── disabled-controller.json │ ├── eager-autoimport.json │ ├── eager-no-autoimport.json │ ├── empty.json │ ├── lazy-no-autoimport.json │ ├── load-named-controller.json │ ├── modules │ │ ├── @symfony │ │ │ └── mock-module │ │ │ │ ├── .gitignore │ │ │ │ ├── dist │ │ │ │ └── controller.js │ │ │ │ └── package.json │ │ └── stimulus-clipboard │ │ │ └── package.json │ ├── override-name.json │ └── third-party.json ├── mocks.ts └── reference │ └── package.json │ ├── ux-autocomplete.json │ ├── ux-chartjs.json │ ├── ux-cropperjs.json │ ├── ux-dropzone.json │ ├── ux-lazy-image.json │ ├── ux-live-component.json │ ├── ux-notify.json │ ├── ux-react.json │ ├── ux-svelte.json │ ├── ux-translator.json │ ├── ux-turbo.json │ ├── ux-typed.json │ └── ux-vue.json ├── tsconfig.json ├── tsup.config.js └── vitest.config.js /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | --- 5 | 6 | Please do not submit any issue here. They will be closed. 7 | 8 | Please submit your issue here instead: 9 | https://github.com/lhapaipai/symfony-vite-dev/issues 10 | 11 | This repository is what we call a "subtree split": a read-only subset of that main repository. 12 | We're looking forward to your issue there! -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Please do not submit any Pull Requests here. They will be closed. 2 | --- 3 | 4 | Please submit your PR here instead: 5 | https://github.com/lhapaipai/symfony-vite-dev 6 | 7 | This repository is what we call a "subtree split": a read-only subset of that main repository. 8 | We're looking forward to your PR there! 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .local/ 4 | coverage/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/prettierrc", 3 | "arrowParens": "always", 4 | "printWidth": 120, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "all", 8 | "useTabs": false 9 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Vitest all files", 9 | "program": "${workspaceFolder}/src/vite-plugin-symfony/node_modules/vite/bin/vite.jsst", 10 | "request": "launch", 11 | "skipFiles": ["/**"], 12 | "type": "node", 13 | "cwd": "${workspaceFolder}/src/vite-plugin-symfony", 14 | "env": { 15 | "NODE_ENV": "development" 16 | } 17 | }, 18 | { 19 | "name": "Vitest Current Test File", 20 | "program": "${workspaceFolder}/src/vite-plugin-symfony/node_modules/vite/bin/vite.jsst", 21 | "request": "launch", 22 | "skipFiles": ["/**", "**/node_modules/**"], 23 | "type": "node", 24 | "cwd": "${workspaceFolder}/src/vite-plugin-symfony", 25 | "autoAttachChildProcesses": true, 26 | "args": ["run", "${file}"], 27 | "smartStep": true, 28 | "console": "integratedTerminal" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # `pentatrion/vite-bundle` / `vite-plugin-symfony` Changelog 2 | 3 | ## v8.1.0 4 | 5 | stimulus with svelte : update to svelte 5 ([@faldor20](https://github.com/faldor20)) 6 | 7 | ## v8.0.2 8 | 9 | - vite-bundle fix #62 can't use environment variables for default_config 10 | 11 | ## v8.0.1 12 | 13 | - vite-plugin-symfony fix #60 move postinstall hook to pre-dev. 14 | 15 | ## v8.0.0 16 | 17 | - stimulus fix hmr option from `VitePluginSymfonyStimulusOptions` 18 | - stimulus fix hmr with lazy loaded controllers 19 | - stimulus prevent hmr when controller is not already registered (#56) 20 | - stimulus add `controllersDir` option to prevent analyse Stimulus meta for other files. 21 | 22 | ## v7.1.0 23 | 24 | - allow Vite 6 as peer dependency ([@skmedix](https://github.com/skmedix)) 25 | 26 | ## v7.0.5 27 | 28 | - add origin to internal tags ([@seggewiss](https://github.com/seggewiss)) 29 | 30 | ## v7.0.4 31 | 32 | - fix use `proxy_origin` in Debugger if configured (@andyexeter) 33 | 34 | ## v7.0.3 35 | 36 | - stimulus fix import.meta regex to support comments 37 | 38 | ## v7.0.2 39 | 40 | - stimulus plugin check module entrypoint inside controllers.json 41 | - fix vite-plugin-symfony partial options TypeScript type. 42 | 43 | ## v7.0.1 44 | 45 | - fix Symfony try to register twice `TypeExtension`. 46 | 47 | ## v7.0.0 48 | 49 | - new Profiler 50 | - change crossorin default value 51 | - better `PreloadAssetsEventListener` 52 | - stimulus refactorisation 53 | 54 | ## v6.5.3 55 | 56 | - fix vite-plugin-symfony tsup export when package is ESM. 57 | 58 | ## v6.5.2 59 | 60 | - fix dummy-non-existing-folder to be created when used with vitest UI. 61 | 62 | ## v6.5.1 63 | 64 | - fix overriding types from '@hotwired/stimulus' 65 | 66 | ## v6.5.0 67 | 68 | - move v6.4.7 to 6.5.0 : flex recipes accept only minor version number (not patch). 69 | 70 | ## v6.4.7 71 | 72 | - vite-bundle : prepare v7 flex recipe add pentatrion_vite.yaml route file into install directory 73 | 74 | ## v6.4.6 75 | 76 | - vite-bundle : add throw_on_missing_asset option 77 | 78 | ## v6.4.5 79 | 80 | - vite-bundle : fix Crossorigin attribute needs adding to Link headers (@andyexeter) 81 | - vite-bundle : Skip devServer lookup if proxy is defined (@Blackskyliner) 82 | - vite-bundle : fix typo in error message when outDir is outside project root (@acran) 83 | 84 | ## v6.4.4 85 | 86 | - vite-plugin-symfony : fix typo in error message when outDir is outside project root (@acran) 87 | - vite-plugin-symfony : revert emptying `outDir` in dev mode (thanks @nlemoine) 88 | 89 | ## v6.4.3 90 | 91 | - vite-bundle : fix deprecation warning with `configs` key in multiple config. 92 | 93 | ## v6.4.2 94 | 95 | - doc add https tip with symfony cli certificate. (@nlemoine) 96 | - fixed symfony/ux-react inability to load tsx components (@vladcos) 97 | 98 | ## v6.4.1 99 | 100 | - fix import.meta in cjs env 101 | - vite-plugin-symfony : fix Displaying the statuses of Stimulus controllers in production https://github.com/lhapaipai/vite-plugin-symfony/issues/38 102 | 103 | ## v6.4.0 104 | 105 | - vite-plugin-symfony : add exposedEnvVars option 106 | - vite-plugin-symfony : fix enforcePluginOrderingPosition https://github.com/lhapaipai/vite-bundle/issues/80 107 | ## v6.3.6 108 | 109 | - fix crossorigin attribute to Link header for scripts with type=module (@andyexeter) 110 | 111 | ## v6.3.5 112 | 113 | - fix vite-plugin-symfony support having externals dependencies. 114 | - increase vite-bundle php minimum compatibility to 8.0 115 | no major version because the bundle was unusable with php 7.4 because of mixed type. 116 | 117 | ## v6.3.4 118 | 119 | - Use Request::getUriForPath to build absolute URLs (@andyexeter) 120 | - Formatting fix in vite printUrls output (@andyexeter) 121 | 122 | ## v6.3.3 123 | 124 | - Fix dark mode issue with background 125 | - Fix worker mode (kernel.reset) 126 | 127 | ## v6.3.2 128 | 129 | - Moving package manager to pnpm 130 | 131 | ## v6.3.1 132 | 133 | - Fix React/Vue/Svelte dependencies with Stimulus helper (@santos-pierre) 134 | - vite-plugin-symfony Update dependencies 135 | 136 | ## v6.3.0 137 | 138 | - stimulus HMR 139 | - fix bug : stimulus restart vite dev server when controllers.json is updated 140 | - split vite-plugin-symfony into 2 plugins `vite-plugin-symfony-entrypoints` and `vite-plugin-symfony-stimulus`. 141 | - add new tests to vite-plugin-symfony 142 | - doc : add mermaid charts 143 | 144 | ## v6.2.0 145 | 146 | - fix #77 support Vite 5.x 147 | 148 | ## v6.1.3 149 | 150 | - fix #34 set warning when setting a build directory outside of your project 151 | 152 | ## v6.1.2 153 | 154 | - stimulus lazy controllers enhancement 155 | - Fix : prevent virtual controllers.json prebundling 156 | - Fix : Change dependency to the non-internal ServiceLocator class (@NanoSector) 157 | - Fix : Carelessly setting the outDir folder leads to recursive deletion (@Huppys) 158 | 159 | ## v6.1.0 160 | 161 | - add Stimulus and Symfony UX Integration 162 | 163 | ## v6.0.1 164 | 165 | - add `enforceServerOriginAfterListening` 166 | 167 | ## v6.0.0 168 | 169 | - make services privates. 170 | - add tests for EntrypointRenderer, EntrypointsLookup and TagRenderer. 171 | - add preload option (symfony/web-link) 172 | - add cache option 173 | - add crossorigin option 174 | - add preload_attributes option 175 | - change default_build/builds to default_config/configs 176 | - fix baseUrl to files #67 177 | - refactor RenderAssetTagEvent 178 | 179 | ## v5.0.1 180 | 181 | - remove deprecated options 182 | - fix `absolute_url` error in `shouldUseAbsoluteURL`. 183 | 184 | ## v5.0.0 185 | 186 | - change `entrypoints.json` property `isProd` to `isBuild` because you can be in dev env and want to build your js files. 187 | 188 | ## v4.3.2 189 | 190 | - fix #26 TypeError when no root option (@andyexeter) 191 | 192 | ## v4.3.1 193 | 194 | - add vendor, var and public to ignored directory for file watcher. 195 | 196 | ## v4.3.0 197 | 198 | - add `absolute_url` bundle option. 199 | - add `absolute_url` twig option. (@drazik) 200 | 201 | ## v4.2.0 202 | 203 | - add enforcePluginOrderingPosition option 204 | - fix Integrity hash issue 205 | - add `vite_mode` twig function 206 | 207 | ## v4.1.0 208 | 209 | - add `originOverride` (@elliason) 210 | - deprecate `viteDevServerHostname` 211 | 212 | ## v4.0.2 213 | 214 | - fix #24 normalized path 215 | 216 | ## v4.0.1 217 | 218 | - fix conditional imports generate modulepreloads for everything 219 | 220 | ## v4.0.0 221 | 222 | - add `sriAlgorithm` 223 | - fix react refresh when vite client is returned 224 | - add CDN feature 225 | 226 | ## v3.3.2 227 | 228 | - fix #16 entrypoints outside vite root directory 229 | 230 | ## v3.3.1 231 | 232 | - fix circular reference with imports. 233 | - deprecate `public_dir` / `base` 234 | - add `public_directory` / `build_directory` 235 | 236 | ## v3.3.0 237 | 238 | - add tests 239 | - versionning synchronization between pentatrion/vite-bundle and vite-plugin-symfony 240 | 241 | --- 242 | 243 | before version 3.3 the versions of ViteBundle and vite-plugin-symfony were not synchronized 244 | 245 | 246 | # `pentatrion/vite-bundle` Changelog 247 | 248 | ## v3.2.0 249 | 250 | - add throw_on_missing_entry option (@Magiczne) 251 | 252 | ## v3.1.4 253 | 254 | - add proxy_origin option (@FluffyDiscord) 255 | 256 | ## v3.1.0 257 | 258 | - allow vite multiple configuration files 259 | 260 | ## v3.0.0 261 | 262 | - Add vite 4 compatibility 263 | 264 | ## v2.2.1 265 | 266 | - the choice of the vite dev server port is no longer strict, if it is already used the application will use the next available port. 267 | 268 | ## v2.2.0 269 | 270 | - add extra attributes to script/link tags 271 | 272 | ## v2.1.1 273 | 274 | - update documentation, update with vite-plugin-symfony v0.6.0 275 | 276 | ## v2.1.0 277 | 278 | - add CSS Entrypoints management to prevent FOUC. 279 | 280 | ## v1.1.4 281 | 282 | - add EntrypointsLookup / EntrypointsRenderer as a service. 283 | 284 | ## v1.1.0 285 | 286 | - Add public_dir conf 287 | 288 | ## v1.0.2 289 | 290 | - fix vite.config path error with windows 291 | 292 | ## v1.0.1 293 | 294 | - fix exception when entrypoints.json is missing 295 | 296 | ## v1.0.0 297 | 298 | - Twig functions refer to named entry points not js file 299 | - Add vite-plugin-symfony 300 | 301 | ## v0.2.0 302 | 303 | Add proxy Controller 304 | 305 | 306 | --- 307 | 308 | # `vite-plugin-symfony` changelog 309 | 310 | ## v0.6.3 311 | 312 | - takes into account vite legacy plugin. 313 | 314 | ## v0.6.2 315 | 316 | - add `viteDevServerHost` plugin option 317 | 318 | ## v0.6.1 319 | 320 | - remove `strictPort: true` 321 | 322 | ## v0.6.0 323 | 324 | - add `publicDirectory`, `buildDirectory`, `refresh`, `verbose` plugin option 325 | - add `dev-server-404.html` page 326 | 327 | ## v0.5.2 328 | 329 | - add `servePublic` plugin option 330 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021-present Hugues Tavernier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | 20 | 21 | The src/stimulus directory is inspired by code written under the MIT license 22 | 23 | Copyright (c) 2020-present Fabien Potencier 24 | 25 | Permission is hereby granted, free of charge, to any person obtaining a copy 26 | of this software and associated documentation files (the "Software"), to deal 27 | in the Software without restriction, including without limitation the rights 28 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 29 | copies of the Software, and to permit persons to whom the Software is furnished 30 | to do so, subject to the following conditions: 31 | 32 | The above copyright notice and this permission notice shall be included in all 33 | copies or substantial portions of the Software. 34 | 35 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 36 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 37 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 38 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 39 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 40 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 41 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | Symfony logo 4 |

5 |

6 | 7 | 8 |

9 |
10 | 11 | 12 | # Vite plugin Symfony 13 | 14 | > [!IMPORTANT] 15 | > This repository is a "subtree split": a read-only subset of that main repository [symfony-vite-dev](https://github.com/lhapaipai/symfony-vite-dev) which delivers to packagist only the necessary code. 16 | 17 | > [!IMPORTANT] 18 | > If you want to open issues, contribute, make PRs or consult examples you will have to go to the [symfony-vite-dev](https://github.com/lhapaipai/symfony-vite-dev) repository. 19 | 20 | A Vite plugin to easily integrate Vite into your Symfony application. 21 | 22 | - create a `entrypoints.json` file inside your build directory with your js/css/preload dependencies. 23 | - reload your browser when you update your twig files 24 | 25 | This package is intended for use with the Symfony Bundle : [pentatrion/vite-bundle](https://github.com/lhapaipai/vite-bundle). 26 | 27 | 28 | ## Installation 29 | 30 | ```console 31 | npm i vite-plugin-symfony 32 | ``` 33 | 34 | Create this directory structure : 35 | ``` 36 | ├──assets 37 | │ ├──app.js 38 | │ ├──app.css 39 | │... 40 | ├──public 41 | ├──composer.json 42 | ├──package.json 43 | ├──vite.config.js 44 | ``` 45 | 46 | Vite base config with vite 47 | 48 | ```js 49 | // vite.config.js 50 | import {defineConfig} from "vite"; 51 | import symfonyPlugin from "vite-plugin-symfony"; 52 | 53 | export default defineConfig({ 54 | plugins: [ 55 | symfonyPlugin(/* options */), 56 | ], 57 | 58 | build: { 59 | rollupOptions: { 60 | input: { 61 | app: "./assets/app.js" /* relative to the root option */ 62 | }, 63 | }, 64 | } 65 | }); 66 | ``` 67 | 68 | and your package.json : 69 | ```json 70 | { 71 | "scripts": { 72 | "dev": "vite", 73 | "build": "vite build" 74 | }, 75 | "devDependencies": { 76 | "vite": "^5.0", 77 | "vite-plugin-symfony": "^8.1" 78 | } 79 | } 80 | ``` 81 | 82 | [Read the Docs to Learn More](https://symfony-vite.pentatrion.com). 83 | 84 | ## Ecosystem 85 | 86 | | Package | Description | 87 | | ----------------------------------------------------------------------- | :------------------------ | 88 | | [vite-bundle](https://github.com/lhapaipai/vite-bundle) | Symfony Bundle (read-only)| 89 | | [symfony-vite-dev](https://github.com/lhapaipai/symfony-vite-dev) | Package for contributors | 90 | 91 | ## License 92 | 93 | [MIT](LICENSE). 94 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import tseslint from "typescript-eslint"; 4 | 5 | export default tseslint.config( 6 | { ignores: ["dist"] }, 7 | { 8 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 9 | files: ["**/*.{ts,tsx}"], 10 | languageOptions: { 11 | ecmaVersion: 2020, 12 | globals: globals.browser, 13 | }, 14 | plugins: {}, 15 | rules: { 16 | "@typescript-eslint/no-explicit-any": "off", 17 | }, 18 | }, 19 | ); 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-plugin-symfony", 3 | "version": "8.1.0", 4 | "description": "A Vite plugin to integrate easily Vite in your Symfony application", 5 | "type": "module", 6 | "main": "dist/index.cjs", 7 | "module": "dist/index.js", 8 | "exports": { 9 | ".": { 10 | "import": "./dist/index.js", 11 | "require": "./dist/index.cjs" 12 | }, 13 | "./stimulus/env": { 14 | "types": "./src/stimulus/env.d.ts" 15 | }, 16 | "./stimulus/helpers": { 17 | "import": "./dist/stimulus/helpers/index.js", 18 | "require": "./dist/stimulus/helpers/index.cjs" 19 | }, 20 | "./stimulus/helpers/react/render_controller": { 21 | "import": "./dist/stimulus/helpers/react/render_controller.js", 22 | "require": "./dist/stimulus/helpers/react/render_controller.cjs" 23 | }, 24 | "./stimulus/helpers/svelte/render_controller": { 25 | "import": "./dist/stimulus/helpers/svelte/render_controller.js", 26 | "require": "./dist/stimulus/helpers/svelte/render_controller.cjs" 27 | }, 28 | "./stimulus/helpers/svelte4/render_controller": { 29 | "import": "./dist/stimulus/helpers/svelte4/render_controller.js", 30 | "require": "./dist/stimulus/helpers/svelte4/render_controller.cjs" 31 | }, 32 | "./stimulus/helpers/vue": { 33 | "import": "./dist/stimulus/helpers/vue/index.js", 34 | "require": "./dist/stimulus/helpers/vue/index.cjs" 35 | }, 36 | "./stimulus/helpers/react": { 37 | "import": "./dist/stimulus/helpers/react/index.js", 38 | "require": "./dist/stimulus/helpers/react/index.cjs" 39 | }, 40 | "./stimulus/helpers/svelte": { 41 | "import": "./dist/stimulus/helpers/svelte/index.js", 42 | "require": "./dist/stimulus/helpers/svelte/index.cjs" 43 | }, 44 | "./stimulus/helpers/svelte4": { 45 | "import": "./dist/stimulus/helpers/svelte4/index.js", 46 | "require": "./dist/stimulus/helpers/svelte4/index.cjs" 47 | }, 48 | "./package.json": "./package.json" 49 | }, 50 | "types": "dist/index.d.ts", 51 | "author": { 52 | "name": "Hugues Tavernier", 53 | "email": "hugues.tavernier@protonmail.com" 54 | }, 55 | "license": "MIT", 56 | "repository": { 57 | "type": "git", 58 | "url": "https://github.com/lhapaipai/vite-plugin-symfony.git" 59 | }, 60 | "scripts": { 61 | "predev": "tsx ./scripts/pre-dev.ts", 62 | "dev": "tsup --watch", 63 | "build": "tsup", 64 | "test": "vitest", 65 | "test-run": "vitest --run", 66 | "coverage": "vitest --run --coverage", 67 | "tsc:check": "tsc --noEmit", 68 | "lint:check": "eslint -c eslint.config.js ./src" 69 | }, 70 | "files": [ 71 | "dist/", 72 | "src/", 73 | "static/" 74 | ], 75 | "devDependencies": { 76 | "@eslint/js": "^9.20.0", 77 | "@hotwired/stimulus": "^3.2.2", 78 | "@types/node": "^22.13.1", 79 | "@types/react": "^18.3.18", 80 | "@types/react-dom": "^18.3.5", 81 | "@types/ws": "^8.5.14", 82 | "@vitest/coverage-v8": "^3.0.5", 83 | "globals": "^15.14.0", 84 | "jsdom": "^26.0.0", 85 | "prettier": "^3.4.2", 86 | "react": "^18.3.1", 87 | "react-dom": "^18.3.1", 88 | "rollup": "^4.34.6", 89 | "svelte": "^5.25.0", 90 | "tsup": "^8.3.6", 91 | "tsx": "^4.19.2", 92 | "typescript-eslint": "^8.23.0", 93 | "vite": "^6.1.0", 94 | "vitest": "^3.0.5", 95 | "vue": "^3.5.13" 96 | }, 97 | "keywords": [ 98 | "vite-plugin", 99 | "vite plugin", 100 | "vite", 101 | "symfony" 102 | ], 103 | "bugs": { 104 | "url": "https://github.com/lhapaipai/vite-plugin-symfony/issues" 105 | }, 106 | "homepage": "https://symfony-vite.pentatrion.com", 107 | "peerDependencies": { 108 | "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" 109 | }, 110 | "volta": { 111 | "node": "22.13.1" 112 | }, 113 | "dependencies": { 114 | "debug": "^4.4.0", 115 | "fast-glob": "^3.3.3", 116 | "picocolors": "^1.1.1", 117 | "sirv": "^3.0.0" 118 | }, 119 | "symfony": { 120 | "controllers": { 121 | "react": { 122 | "main": "stimulus/helpers/react/render_controller", 123 | "name": "symfony/ux-react/react", 124 | "webpackMode": "eager", 125 | "fetch": "eager", 126 | "enabled": true 127 | }, 128 | "svelte": { 129 | "main": "stimulus/helpers/svelte/render_controller", 130 | "name": "symfony/ux-svelte/svelte", 131 | "webpackMode": "eager", 132 | "fetch": "eager", 133 | "enabled": true 134 | } 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /scripts/hardcode-ux-metadata.ts: -------------------------------------------------------------------------------- 1 | import { copyFile, mkdir } from "node:fs/promises"; 2 | import { dirname, join, resolve } from "node:path"; 3 | 4 | const packageNames = [ 5 | "ux-autocomplete", 6 | "ux-chartjs", 7 | "ux-cropperjs", 8 | "ux-dropzone", 9 | "ux-lazy-image", 10 | "ux-live-component", 11 | "ux-notify", 12 | "ux-react", 13 | "ux-svelte", 14 | "ux-swup", 15 | "ux-toggle-password", 16 | "ux-translator", 17 | "ux-turbo", 18 | "ux-typed", 19 | "ux-vue", 20 | ]; 21 | 22 | const playgroundDir = resolve(import.meta.dirname, "../../../playground"); 23 | const dstDir = resolve(import.meta.dirname, "ux-assets"); 24 | 25 | for (const packageName of packageNames) { 26 | const src = join(playgroundDir, "stimulus/vendor/symfony", packageName, "assets/package.json"); 27 | const dst = join(dstDir, "@symfony", packageName, "package.json"); 28 | await mkdir(dirname(dst), { recursive: true }); 29 | 30 | await copyFile(src, dst); 31 | } 32 | -------------------------------------------------------------------------------- /scripts/pre-dev.ts: -------------------------------------------------------------------------------- 1 | import { cp } from "fs/promises"; 2 | import { resolve } from "path"; 3 | 4 | const assetsDir = resolve(import.meta.dirname, "ux-assets"); 5 | const nodeModules = resolve(import.meta.dirname, "../node_modules"); 6 | 7 | await cp(assetsDir, nodeModules, { recursive: true }); 8 | -------------------------------------------------------------------------------- /scripts/ux-assets/@symfony/ux-autocomplete/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-autocomplete", 3 | "description": "JavaScript-powered autocompletion functionality for forms.", 4 | "main": "dist/controller.js", 5 | "types": "dist/controller.d.ts", 6 | "version": "1.0.0", 7 | "license": "MIT", 8 | "scripts": { 9 | "build": "node ../../../bin/build_package.js .", 10 | "watch": "node ../../../bin/build_package.js . --watch", 11 | "test": "../../../bin/test_package.sh .", 12 | "check": "biome check", 13 | "ci": "biome ci" 14 | }, 15 | "symfony": { 16 | "controllers": { 17 | "autocomplete": { 18 | "main": "dist/controller.js", 19 | "webpackMode": "eager", 20 | "fetch": "eager", 21 | "enabled": true, 22 | "autoimport": { 23 | "tom-select/dist/css/tom-select.default.css": true, 24 | "tom-select/dist/css/tom-select.bootstrap4.css": false, 25 | "tom-select/dist/css/tom-select.bootstrap5.css": false 26 | } 27 | } 28 | }, 29 | "importmap": { 30 | "@hotwired/stimulus": "^3.0.0", 31 | "tom-select": "^2.2.2" 32 | } 33 | }, 34 | "peerDependencies": { 35 | "@hotwired/stimulus": "^3.0.0", 36 | "tom-select": "^2.2.2" 37 | }, 38 | "devDependencies": { 39 | "@hotwired/stimulus": "^3.0.0", 40 | "tom-select": "^2.2.2", 41 | "vitest-fetch-mock": "^0.2.2" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /scripts/ux-assets/@symfony/ux-chartjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-chartjs", 3 | "description": "Chart.js integration for Symfony", 4 | "license": "MIT", 5 | "version": "1.1.0", 6 | "type": "module", 7 | "main": "dist/controller.js", 8 | "types": "dist/controller.d.ts", 9 | "scripts": { 10 | "build": "node ../../../bin/build_package.js .", 11 | "watch": "node ../../../bin/build_package.js . --watch", 12 | "test": "../../../bin/test_package.sh .", 13 | "check": "biome check", 14 | "ci": "biome ci" 15 | }, 16 | "symfony": { 17 | "controllers": { 18 | "chart": { 19 | "main": "dist/controller.js", 20 | "webpackMode": "eager", 21 | "fetch": "eager", 22 | "enabled": true 23 | } 24 | }, 25 | "importmap": { 26 | "@hotwired/stimulus": "^3.0.0", 27 | "chart.js": "^3.4.1 || ^4.0" 28 | } 29 | }, 30 | "peerDependencies": { 31 | "@hotwired/stimulus": "^3.0.0", 32 | "chart.js": "^3.4.1 || ^4.0" 33 | }, 34 | "devDependencies": { 35 | "@hotwired/stimulus": "^3.0.0", 36 | "chart.js": "^3.4.1 || ^4.0", 37 | "resize-observer-polyfill": "^1.5.1", 38 | "vitest-canvas-mock": "^0.3.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /scripts/ux-assets/@symfony/ux-cropperjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-cropperjs", 3 | "description": "Cropper.js integration for Symfony", 4 | "license": "MIT", 5 | "version": "1.1.0", 6 | "main": "dist/controller.js", 7 | "types": "dist/controller.d.ts", 8 | "config": { 9 | "css_source": "src/style.css" 10 | }, 11 | "scripts": { 12 | "build": "node ../../../bin/build_package.js .", 13 | "watch": "node ../../../bin/build_package.js . --watch", 14 | "test": "../../../bin/test_package.sh .", 15 | "check": "biome check", 16 | "ci": "biome ci" 17 | }, 18 | "symfony": { 19 | "controllers": { 20 | "cropper": { 21 | "main": "dist/controller.js", 22 | "webpackMode": "eager", 23 | "fetch": "eager", 24 | "enabled": true, 25 | "autoimport": { 26 | "cropperjs/dist/cropper.min.css": true, 27 | "@symfony/ux-cropperjs/dist/style.min.css": true 28 | } 29 | } 30 | }, 31 | "importmap": { 32 | "cropperjs": "^1.5.9", 33 | "@hotwired/stimulus": "^3.0.0" 34 | } 35 | }, 36 | "peerDependencies": { 37 | "@hotwired/stimulus": "^3.0.0", 38 | "cropperjs": "^1.5.9" 39 | }, 40 | "devDependencies": { 41 | "@hotwired/stimulus": "^3.0.0", 42 | "cropperjs": "^1.5.9" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /scripts/ux-assets/@symfony/ux-dropzone/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-dropzone", 3 | "description": "File input dropzones for Symfony Forms", 4 | "license": "MIT", 5 | "version": "1.1.0", 6 | "main": "dist/controller.js", 7 | "types": "dist/controller.d.ts", 8 | "config": { 9 | "css_source": "src/style.css" 10 | }, 11 | "scripts": { 12 | "build": "node ../../../bin/build_package.js .", 13 | "watch": "node ../../../bin/build_package.js . --watch", 14 | "test": "../../../bin/test_package.sh .", 15 | "check": "biome check", 16 | "ci": "biome ci" 17 | }, 18 | "symfony": { 19 | "controllers": { 20 | "dropzone": { 21 | "main": "dist/controller.js", 22 | "webpackMode": "eager", 23 | "fetch": "eager", 24 | "enabled": true, 25 | "autoimport": { 26 | "@symfony/ux-dropzone/dist/style.min.css": true 27 | } 28 | } 29 | }, 30 | "importmap": { 31 | "@hotwired/stimulus": "^3.0.0" 32 | } 33 | }, 34 | "peerDependencies": { 35 | "@hotwired/stimulus": "^3.0.0" 36 | }, 37 | "devDependencies": { 38 | "@hotwired/stimulus": "^3.0.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /scripts/ux-assets/@symfony/ux-lazy-image/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-lazy-image", 3 | "description": "Lazy image loader and utilities for Symfony", 4 | "license": "MIT", 5 | "version": "1.1.0", 6 | "main": "dist/controller.js", 7 | "types": "dist/controller.d.ts", 8 | "scripts": { 9 | "build": "node ../../../bin/build_package.js .", 10 | "watch": "node ../../../bin/build_package.js . --watch", 11 | "test": "../../../bin/test_package.sh .", 12 | "check": "biome check", 13 | "ci": "biome ci" 14 | }, 15 | "symfony": { 16 | "controllers": { 17 | "lazy-image": { 18 | "main": "dist/controller.js", 19 | "webpackMode": "eager", 20 | "fetch": "eager", 21 | "enabled": true 22 | } 23 | }, 24 | "importmap": { 25 | "@hotwired/stimulus": "^3.0.0" 26 | } 27 | }, 28 | "peerDependencies": { 29 | "@hotwired/stimulus": "^3.0.0" 30 | }, 31 | "devDependencies": { 32 | "@hotwired/stimulus": "^3.0.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /scripts/ux-assets/@symfony/ux-live-component/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-live-component", 3 | "description": "Live Component: bring server-side re-rendering & model binding to any element.", 4 | "main": "dist/live_controller.js", 5 | "types": "dist/live_controller.d.ts", 6 | "version": "1.0.0", 7 | "config": { 8 | "css_source": "styles/live.css" 9 | }, 10 | "license": "MIT", 11 | "scripts": { 12 | "build": "node ../../../bin/build_package.js .", 13 | "watch": "node ../../../bin/build_package.js . --watch", 14 | "test": "../../../bin/test_package.sh .", 15 | "check": "biome check", 16 | "ci": "biome ci" 17 | }, 18 | "symfony": { 19 | "controllers": { 20 | "live": { 21 | "main": "dist/live_controller.js", 22 | "name": "live", 23 | "webpackMode": "eager", 24 | "fetch": "eager", 25 | "enabled": true, 26 | "autoimport": { 27 | "@symfony/ux-live-component/dist/live.min.css": true 28 | } 29 | } 30 | }, 31 | "importmap": { 32 | "@hotwired/stimulus": "^3.0.0", 33 | "@symfony/ux-live-component": "path:%PACKAGE%/dist/live_controller.js" 34 | } 35 | }, 36 | "dependencies": { 37 | "idiomorph": "^0.3.0" 38 | }, 39 | "peerDependencies": { 40 | "@hotwired/stimulus": "^3.0.0" 41 | }, 42 | "devDependencies": { 43 | "@hotwired/stimulus": "^3.0.0", 44 | "@testing-library/dom": "^7.31.0", 45 | "@testing-library/user-event": "^13.1.9", 46 | "@types/node-fetch": "^2.6.2", 47 | "node-fetch": "^2.6.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /scripts/ux-assets/@symfony/ux-notify/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-notify", 3 | "description": "Native notification integration for Symfony using Mercure", 4 | "license": "MIT", 5 | "version": "1.0.0", 6 | "main": "dist/controller.js", 7 | "types": "dist/controller.d.ts", 8 | "scripts": { 9 | "build": "node ../../../bin/build_package.js .", 10 | "watch": "node ../../../bin/build_package.js . --watch", 11 | "test": "../../../bin/test_package.sh .", 12 | "check": "biome check", 13 | "ci": "biome ci" 14 | }, 15 | "symfony": { 16 | "controllers": { 17 | "notify": { 18 | "main": "dist/controller.js", 19 | "webpackMode": "eager", 20 | "fetch": "eager", 21 | "enabled": true 22 | } 23 | }, 24 | "importmap": { 25 | "@hotwired/stimulus": "^3.0.0" 26 | } 27 | }, 28 | "peerDependencies": { 29 | "@hotwired/stimulus": "^3.0.0" 30 | }, 31 | "devDependencies": { 32 | "@hotwired/stimulus": "^3.0.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /scripts/ux-assets/@symfony/ux-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-react", 3 | "description": "Integration of React in Symfony", 4 | "license": "MIT", 5 | "version": "1.0.0", 6 | "main": "dist/register_controller.js", 7 | "types": "dist/register_controller.d.ts", 8 | "scripts": { 9 | "build": "node ../../../bin/build_package.js .", 10 | "watch": "node ../../../bin/build_package.js . --watch", 11 | "test": "../../../bin/test_package.sh .", 12 | "check": "biome check", 13 | "ci": "biome ci" 14 | }, 15 | "symfony": { 16 | "controllers": { 17 | "react": { 18 | "main": "dist/render_controller.js", 19 | "webpackMode": "eager", 20 | "fetch": "eager", 21 | "enabled": true 22 | } 23 | }, 24 | "importmap": { 25 | "@hotwired/stimulus": "^3.0.0", 26 | "react": "^18.0", 27 | "react-dom": "^18.0", 28 | "@symfony/ux-react": "path:%PACKAGE%/dist/loader.js" 29 | } 30 | }, 31 | "peerDependencies": { 32 | "@hotwired/stimulus": "^3.0.0", 33 | "react": "^18.0", 34 | "react-dom": "^18.0" 35 | }, 36 | "devDependencies": { 37 | "@hotwired/stimulus": "^3.0.0", 38 | "@types/react": "^18.0", 39 | "@types/react-dom": "^18.0", 40 | "@types/webpack-env": "^1.16", 41 | "@vitejs/plugin-react": "^4.1.0", 42 | "react": "^18.0", 43 | "react-dom": "^18.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /scripts/ux-assets/@symfony/ux-svelte/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-svelte", 3 | "description": "Integration of Svelte in Symfony", 4 | "main": "dist/register_controller.js", 5 | "version": "1.0.0", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "node ../../../bin/build_package.js .", 9 | "watch": "node ../../../bin/build_package.js . --watch", 10 | "test": "../../../bin/test_package.sh .", 11 | "check": "biome check", 12 | "ci": "biome ci" 13 | }, 14 | "symfony": { 15 | "controllers": { 16 | "svelte": { 17 | "main": "dist/render_controller.js", 18 | "fetch": "eager", 19 | "enabled": true 20 | } 21 | }, 22 | "importmap": { 23 | "@hotwired/stimulus": "^3.0.0", 24 | "svelte/internal": "^3.0", 25 | "@symfony/ux-svelte": "path:%PACKAGE%/dist/loader.js" 26 | } 27 | }, 28 | "peerDependencies": { 29 | "@hotwired/stimulus": "^3.0.0", 30 | "svelte": "^3.0 || ^4.0" 31 | }, 32 | "devDependencies": { 33 | "@hotwired/stimulus": "^3.0.0", 34 | "@sveltejs/vite-plugin-svelte": "^2.4.6", 35 | "@types/webpack-env": "^1.16", 36 | "svelte": "^3.0 || ^4.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /scripts/ux-assets/@symfony/ux-swup/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-swup", 3 | "description": "Swup integration for Symfony", 4 | "license": "MIT", 5 | "version": "1.1.0", 6 | "main": "dist/controller.js", 7 | "types": "dist/controller.d.ts", 8 | "scripts": { 9 | "build": "node ../../../bin/build_package.js .", 10 | "watch": "node ../../../bin/build_package.js . --watch", 11 | "test": "../../../bin/test_package.sh .", 12 | "check": "biome check", 13 | "ci": "biome ci" 14 | }, 15 | "symfony": { 16 | "controllers": { 17 | "swup": { 18 | "main": "dist/controller.js", 19 | "webpackMode": "eager", 20 | "fetch": "eager", 21 | "enabled": true 22 | } 23 | }, 24 | "importmap": { 25 | "@swup/fade-theme": "^1.0", 26 | "@swup/slide-theme": "^1.0", 27 | "@swup/forms-plugin": "^2.0", 28 | "@swup/debug-plugin": "^3.0", 29 | "swup": "^3.0", 30 | "@hotwired/stimulus": "^3.0.0" 31 | } 32 | }, 33 | "peerDependencies": { 34 | "@hotwired/stimulus": "^3.0.0", 35 | "@swup/debug-plugin": "^3.0", 36 | "@swup/fade-theme": "^1.0", 37 | "@swup/forms-plugin": "^2.0", 38 | "@swup/slide-theme": "^1.0", 39 | "swup": "^3.0" 40 | }, 41 | "devDependencies": { 42 | "@hotwired/stimulus": "^3.0.0", 43 | "@swup/debug-plugin": "^3.0", 44 | "@swup/fade-theme": "^1.0", 45 | "@swup/forms-plugin": "^2.0", 46 | "@swup/slide-theme": "^1.0", 47 | "swup": "^3.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /scripts/ux-assets/@symfony/ux-toggle-password/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-toggle-password", 3 | "description": "Toggle visibility of password inputs for Symfony Forms", 4 | "license": "MIT", 5 | "version": "1.0.0", 6 | "main": "dist/controller.js", 7 | "types": "dist/controller.d.ts", 8 | "config": { 9 | "css_source": "src/style.css" 10 | }, 11 | "scripts": { 12 | "build": "node ../../../bin/build_package.js .", 13 | "watch": "node ../../../bin/build_package.js . --watch", 14 | "test": "../../../bin/test_package.sh .", 15 | "check": "biome check", 16 | "ci": "biome ci" 17 | }, 18 | "symfony": { 19 | "controllers": { 20 | "toggle-password": { 21 | "main": "dist/controller.js", 22 | "fetch": "eager", 23 | "enabled": true, 24 | "autoimport": { 25 | "@symfony/ux-toggle-password/dist/style.min.css": true 26 | } 27 | } 28 | }, 29 | "importmap": { 30 | "@hotwired/stimulus": "^3.0.0" 31 | } 32 | }, 33 | "peerDependencies": { 34 | "@hotwired/stimulus": "^3.0.0" 35 | }, 36 | "devDependencies": { 37 | "@hotwired/stimulus": "^3.0.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /scripts/ux-assets/@symfony/ux-translator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-translator", 3 | "description": "Symfony Translator for JavaScript", 4 | "license": "MIT", 5 | "version": "1.0.0", 6 | "main": "dist/translator_controller.js", 7 | "types": "dist/translator_controller.d.ts", 8 | "scripts": { 9 | "build": "node ../../../bin/build_package.js .", 10 | "watch": "node ../../../bin/build_package.js . --watch", 11 | "test": "../../../bin/test_package.sh .", 12 | "check": "biome check", 13 | "ci": "biome ci" 14 | }, 15 | "symfony": { 16 | "importmap": { 17 | "intl-messageformat": "^10.5.11", 18 | "@symfony/ux-translator": "path:%PACKAGE%/dist/translator_controller.js", 19 | "@app/translations": "path:var/translations/index.js", 20 | "@app/translations/configuration": "path:var/translations/configuration.js" 21 | } 22 | }, 23 | "peerDependencies": { 24 | "intl-messageformat": "^10.5.11" 25 | }, 26 | "peerDependenciesMeta": { 27 | "intl-messageformat": { 28 | "optional": false 29 | } 30 | }, 31 | "devDependencies": { 32 | "intl-messageformat": "^10.5.11" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /scripts/ux-assets/@symfony/ux-turbo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-turbo", 3 | "description": "Hotwire Turbo integration for Symfony", 4 | "license": "MIT", 5 | "private": true, 6 | "version": "0.1.0", 7 | "main": "dist/turbo_controller.js", 8 | "types": "dist/turbo_controller.d.ts", 9 | "scripts": { 10 | "build": "node ../../../bin/build_package.js .", 11 | "watch": "node ../../../bin/build_package.js . --watch", 12 | "test": "../../../bin/test_package.sh .", 13 | "check": "biome check", 14 | "ci": "biome ci" 15 | }, 16 | "symfony": { 17 | "controllers": { 18 | "turbo-core": { 19 | "main": "dist/turbo_controller.js", 20 | "webpackMode": "eager", 21 | "fetch": "eager", 22 | "enabled": true 23 | }, 24 | "mercure-turbo-stream": { 25 | "main": "dist/turbo_stream_controller.js", 26 | "fetch": "eager", 27 | "enabled": false 28 | } 29 | }, 30 | "importmap": { 31 | "@hotwired/turbo": "^7.1.0 || ^8.0", 32 | "@hotwired/stimulus": "^3.0.0" 33 | } 34 | }, 35 | "peerDependencies": { 36 | "@hotwired/stimulus": "^3.0.0", 37 | "@hotwired/turbo": "^7.1.1 || ^8.0" 38 | }, 39 | "devDependencies": { 40 | "@hotwired/stimulus": "^3.0.0", 41 | "@hotwired/turbo": "^7.1.0 || ^8.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /scripts/ux-assets/@symfony/ux-typed/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-typed", 3 | "description": "Typed integration for Symfony", 4 | "license": "MIT", 5 | "version": "1.0.0", 6 | "main": "dist/controller.js", 7 | "types": "dist/controller.d.ts", 8 | "scripts": { 9 | "build": "node ../../../bin/build_package.js .", 10 | "watch": "node ../../../bin/build_package.js . --watch", 11 | "test": "../../../bin/test_package.sh .", 12 | "check": "biome check", 13 | "ci": "biome ci" 14 | }, 15 | "symfony": { 16 | "controllers": { 17 | "typed": { 18 | "main": "dist/controller.js", 19 | "name": "symfony/ux-typed", 20 | "webpackMode": "eager", 21 | "fetch": "eager", 22 | "enabled": true 23 | } 24 | }, 25 | "importmap": { 26 | "typed.js": "^2.0", 27 | "@hotwired/stimulus": "^3.0.0" 28 | } 29 | }, 30 | "peerDependencies": { 31 | "@hotwired/stimulus": "^3.0.0", 32 | "typed.js": "^2.0" 33 | }, 34 | "devDependencies": { 35 | "@hotwired/stimulus": "^3.0.0", 36 | "typed.js": "^2.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /scripts/ux-assets/@symfony/ux-vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-vue", 3 | "description": "Integration of Vue.js in Symfony", 4 | "license": "MIT", 5 | "version": "1.0.0", 6 | "main": "dist/register_controller.js", 7 | "types": "dist/register_controller.d.ts", 8 | "scripts": { 9 | "build": "node ../../../bin/build_package.js .", 10 | "watch": "node ../../../bin/build_package.js . --watch", 11 | "test": "../../../bin/test_package.sh .", 12 | "check": "biome check", 13 | "ci": "biome ci" 14 | }, 15 | "symfony": { 16 | "controllers": { 17 | "vue": { 18 | "main": "dist/render_controller.js", 19 | "webpackMode": "eager", 20 | "fetch": "eager", 21 | "enabled": true 22 | } 23 | }, 24 | "importmap": { 25 | "@hotwired/stimulus": "^3.0.0", 26 | "vue": { 27 | "package": "vue/dist/vue.esm-bundler.js", 28 | "version": "^3.0" 29 | }, 30 | "@symfony/ux-vue": "path:%PACKAGE%/dist/loader.js" 31 | } 32 | }, 33 | "peerDependencies": { 34 | "@hotwired/stimulus": "^3.0.0", 35 | "vue": "^3.0" 36 | }, 37 | "devDependencies": { 38 | "@hotwired/stimulus": "^3.0.0", 39 | "@types/webpack-env": "^1.16", 40 | "@vitejs/plugin-vue": "^4.4.0", 41 | "vue": "^3.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /scripts/ux-assets/controllers.json: -------------------------------------------------------------------------------- 1 | { 2 | "controllers": { 3 | "@symfony/ux-autocomplete": { 4 | "autocomplete": { 5 | "main": "dist/controller.js", 6 | "webpackMode": "eager", 7 | "fetch": "eager", 8 | "enabled": true, 9 | "autoimport": { 10 | "tom-select/dist/css/tom-select.default.css": true, 11 | "tom-select/dist/css/tom-select.bootstrap5.css": false 12 | } 13 | } 14 | }, 15 | "@symfony/ux-chartjs": { 16 | "chart": { 17 | "main": "dist/controller.js", 18 | "webpackMode": "eager", 19 | "fetch": "eager", 20 | "enabled": true 21 | } 22 | }, 23 | "@symfony/ux-cropperjs": { 24 | "cropper": { 25 | "main": "dist/controller.js", 26 | "webpackMode": "eager", 27 | "fetch": "eager", 28 | "enabled": true, 29 | "autoimport": { 30 | "cropperjs/dist/cropper.min.css": true, 31 | "@symfony/ux-cropperjs/dist/style.min.css": true 32 | } 33 | } 34 | }, 35 | "@symfony/ux-dropzone": { 36 | "main": "dist/controller.js", 37 | "webpackMode": "eager", 38 | "fetch": "eager", 39 | "enabled": true, 40 | "autoimport": { 41 | "@symfony/ux-dropzone/dist/style.min.css": true 42 | } 43 | }, 44 | "@symfony/ux-lazy-image": { 45 | "lazy-image": { 46 | "main": "dist/controller.js", 47 | "webpackMode": "eager", 48 | "fetch": "eager", 49 | "enabled": true 50 | } 51 | }, 52 | "@symfony/ux-live-component": { 53 | "live": { 54 | "main": "dist/live_controller.js", 55 | "name": "live", 56 | "webpackMode": "eager", 57 | "fetch": "eager", 58 | "enabled": true, 59 | "autoimport": { 60 | "@symfony/ux-live-component/dist/live.min.css": true 61 | } 62 | } 63 | }, 64 | "@symfony/ux-notify": { 65 | "notify": { 66 | "main": "dist/controller.js", 67 | "webpackMode": "eager", 68 | "fetch": "eager", 69 | "enabled": true 70 | } 71 | }, 72 | "@symfony/ux-react": { 73 | "react": { 74 | "main": "dist/render_controller.js", 75 | "webpackMode": "eager", 76 | "fetch": "eager", 77 | "enabled": true 78 | } 79 | }, 80 | "@symfony/ux-svelte": { 81 | "svelte": { 82 | "main": "dist/render_controller.js", 83 | "fetch": "eager", 84 | "enabled": true 85 | } 86 | }, 87 | "@symfony/ux-swup": { 88 | "swup": { 89 | "main": "dist/controller.js", 90 | "webpackMode": "eager", 91 | "fetch": "eager", 92 | "enabled": true 93 | } 94 | }, 95 | "@symfony/ux-toggle-password": { 96 | "toggle-password": { 97 | "main": "dist/controller.js", 98 | "fetch": "eager", 99 | "enabled": true, 100 | "autoimport": { 101 | "@symfony/ux-toggle-password/dist/style.min.css": true 102 | } 103 | } 104 | }, 105 | "@symfony/ux-turbo": { 106 | "turbo-core": { 107 | "main": "dist/turbo_controller.js", 108 | "webpackMode": "eager", 109 | "fetch": "eager", 110 | "enabled": true 111 | }, 112 | "mercure-turbo-stream": { 113 | "main": "dist/turbo_stream_controller.js", 114 | "fetch": "eager", 115 | "enabled": false 116 | } 117 | }, 118 | "@symfony/ux-typed": { 119 | "typed": { 120 | "main": "dist/controller.js", 121 | "name": "symfony/ux-typed", 122 | "webpackMode": "eager", 123 | "fetch": "eager", 124 | "enabled": true 125 | } 126 | }, 127 | "@symfony/ux-vue": { 128 | "vue": { 129 | "main": "dist/render_controller.js", 130 | "webpackMode": "eager", 131 | "fetch": "eager", 132 | "enabled": true 133 | } 134 | }, 135 | "stimulus-color-picker": { 136 | "name": "color-picker", 137 | "enabled": true, 138 | "fetch": "lazy", 139 | "autoimport": { 140 | "@simonwep/pickr/dist/themes/classic.min.css": true 141 | } 142 | } 143 | }, 144 | "entrypoints": [] 145 | } 146 | -------------------------------------------------------------------------------- /src/entrypoints/depreciations.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "~/logger"; 2 | import { VitePluginSymfonyEntrypointsOptions } from "~/types"; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 5 | export function showDepreciationsWarnings(pluginOptions: VitePluginSymfonyEntrypointsOptions, logger: Logger) { 6 | // no deprecation 7 | } 8 | -------------------------------------------------------------------------------- /src/entrypoints/entryPointsHelper.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | import { getDevEntryPoints } from "./entryPointsHelper"; 3 | 4 | import type { ResolvedConfig } from "vite"; 5 | import { viteBaseConfig } from "~tests/mocks"; 6 | 7 | describe("getDevEntryPoints", () => { 8 | it("generate correct entrypoints", ({ expect }) => { 9 | expect( 10 | getDevEntryPoints( 11 | { 12 | ...viteBaseConfig, 13 | build: { 14 | rollupOptions: { 15 | input: { 16 | app: "./path/to/filename.ts", 17 | theme: "./other/place/to/theme.scss", 18 | }, 19 | }, 20 | }, 21 | } as unknown as ResolvedConfig, 22 | "http://localhost:5173", 23 | ), 24 | ).toMatchInlineSnapshot(` 25 | { 26 | "app": { 27 | "js": [ 28 | "http://localhost:5173/build/path/to/filename.ts", 29 | ], 30 | }, 31 | "theme": { 32 | "css": [ 33 | "http://localhost:5173/build/other/place/to/theme.scss", 34 | ], 35 | }, 36 | } 37 | `); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/entrypoints/entryPointsHelper.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | import type { ResolvedConfig } from "vite"; 3 | import { getLegacyName, prepareRollupInputs, resolveUserExternal } from "./utils"; 4 | import { EntryPoints, GeneratedFiles, FileInfos, FilesMetadatas, BuildEntryPoint } from "../types"; 5 | import { getOutputPath } from "./pathMapping"; 6 | 7 | export const getDevEntryPoints = (config: ResolvedConfig, viteDevServerUrl: string): EntryPoints => { 8 | const entryPoints: EntryPoints = {}; 9 | 10 | for (const [entryName, { inputRelPath, inputType }] of Object.entries(prepareRollupInputs(config))) { 11 | entryPoints[entryName] = { 12 | [inputType]: [`${viteDevServerUrl}${config.base}${inputRelPath}`], 13 | }; 14 | } 15 | return entryPoints; 16 | }; 17 | 18 | export const getFilesMetadatas = (base: string, generatedFiles: GeneratedFiles): FilesMetadatas => { 19 | return Object.fromEntries( 20 | Object.values(generatedFiles) 21 | .filter((fileInfos: FileInfos) => fileInfos.hash) 22 | .map((fileInfos: FileInfos) => [ 23 | `${base}${fileInfos.outputRelPath}`, 24 | { 25 | hash: fileInfos.hash, 26 | }, 27 | ]), 28 | ); 29 | }; 30 | 31 | export const getBuildEntryPoints = (generatedFiles: GeneratedFiles, viteConfig: ResolvedConfig): EntryPoints => { 32 | const entryPoints: EntryPoints = {}; 33 | let hasLegacyEntryPoint = false; 34 | 35 | /** get an Array of entryPoints from build.rollupOptions.input inside vite config file */ 36 | const entryFiles = prepareRollupInputs(viteConfig); 37 | 38 | for (const [entryName, entry] of Object.entries(entryFiles)) { 39 | const outputRelPath = getOutputPath(entry.inputRelPath); 40 | if (!outputRelPath) { 41 | console.error("unable to get outputPath", entry.inputRelPath); 42 | process.exit(1); 43 | } 44 | 45 | const fileInfos = generatedFiles[outputRelPath]; 46 | 47 | if (!fileInfos) { 48 | console.error("unable to map generatedFile", entry, outputRelPath, fileInfos); 49 | process.exit(1); 50 | } 51 | 52 | const legacyInputRelPath = getLegacyName(entry.inputRelPath); 53 | const legacyFileInfos = generatedFiles[getOutputPath(legacyInputRelPath)!] ?? null; 54 | 55 | if (legacyFileInfos) { 56 | hasLegacyEntryPoint = true; 57 | entryPoints[`${entryName}-legacy`] = resolveBuildEntrypoint(legacyFileInfos, generatedFiles, viteConfig, false); 58 | } 59 | 60 | entryPoints[entryName] = resolveBuildEntrypoint( 61 | fileInfos, 62 | generatedFiles, 63 | viteConfig, 64 | hasLegacyEntryPoint ? `${entryName}-legacy` : false, 65 | ); 66 | } 67 | 68 | if (hasLegacyEntryPoint && getOutputPath("vite/legacy-polyfills")) { 69 | const fileInfos = generatedFiles[getOutputPath("vite/legacy-polyfills")!] ?? null; 70 | if (fileInfos) { 71 | entryPoints["polyfills-legacy"] = resolveBuildEntrypoint(fileInfos, generatedFiles, viteConfig, false); 72 | } 73 | } 74 | 75 | return entryPoints; 76 | }; 77 | 78 | export const resolveBuildEntrypoint = ( 79 | fileInfos: FileInfos, 80 | generatedFiles: GeneratedFiles, 81 | config: ResolvedConfig, 82 | legacyEntryName: boolean | string, 83 | resolvedImportOutputRelPaths: string[] = [], 84 | ): BuildEntryPoint => { 85 | const css: string[] = []; 86 | const js: string[] = []; 87 | const preload: string[] = []; 88 | const dynamic: string[] = []; 89 | 90 | resolvedImportOutputRelPaths.push(fileInfos.outputRelPath); 91 | 92 | if (fileInfos.type === "js") { 93 | for (const importOutputRelPath of fileInfos.imports) { 94 | if (resolvedImportOutputRelPaths.indexOf(importOutputRelPath) !== -1) { 95 | continue; 96 | } 97 | 98 | resolvedImportOutputRelPaths.push(importOutputRelPath); 99 | 100 | const importFileInfos = generatedFiles[importOutputRelPath]; 101 | if (!importFileInfos) { 102 | const isExternal = config.build.rollupOptions.external 103 | ? resolveUserExternal( 104 | config.build.rollupOptions.external, 105 | importOutputRelPath, // use URL as id since id could not be resolved 106 | fileInfos.inputRelPath, 107 | false, 108 | ) 109 | : false; 110 | 111 | if (isExternal) { 112 | continue; 113 | } 114 | 115 | throw new Error(`Unable to find ${importOutputRelPath}`); 116 | } 117 | 118 | const { 119 | css: importCss, 120 | dynamic: importDynamic, 121 | js: importJs, 122 | preload: importPreload, 123 | } = resolveBuildEntrypoint(importFileInfos, generatedFiles, config, false, resolvedImportOutputRelPaths); 124 | 125 | for (const dependency of importCss) { 126 | if (css.indexOf(dependency) === -1) { 127 | css.push(dependency); 128 | } 129 | } 130 | 131 | // imports are preloaded not js files 132 | for (const dependency of importJs) { 133 | if (preload.indexOf(dependency) === -1) { 134 | preload.push(dependency); 135 | } 136 | } 137 | for (const dependency of importPreload) { 138 | if (preload.indexOf(dependency) === -1) { 139 | preload.push(dependency); 140 | } 141 | } 142 | for (const dependency of importDynamic) { 143 | if (dynamic.indexOf(dependency) === -1) { 144 | dynamic.push(dependency); 145 | } 146 | } 147 | } 148 | 149 | fileInfos.js.forEach((dependency) => { 150 | if (js.indexOf(dependency) === -1) { 151 | js.push(`${config.base}${dependency}`); 152 | } 153 | }); 154 | fileInfos.preload.forEach((dependency) => { 155 | if (preload.indexOf(dependency) === -1) { 156 | preload.push(`${config.base}${dependency}`); 157 | } 158 | }); 159 | fileInfos.dynamic.forEach((dependency) => { 160 | if (dynamic.indexOf(dependency) === -1) { 161 | dynamic.push(`${config.base}${dependency}`); 162 | } 163 | }); 164 | } 165 | 166 | if (fileInfos.type === "js" || fileInfos.type === "css") { 167 | fileInfos.css.forEach((dependency) => { 168 | if (css.indexOf(dependency) === -1) { 169 | css.push(`${config.base}${dependency}`); 170 | } 171 | }); 172 | } 173 | 174 | return { css, dynamic, js, legacy: legacyEntryName, preload }; 175 | }; 176 | -------------------------------------------------------------------------------- /src/entrypoints/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, vi } from "vitest"; 2 | 3 | import vitePluginSymfonyEntrypoints from "./index"; 4 | import type { OutputChunk, OutputAsset } from "rollup"; 5 | 6 | import { 7 | viteBaseConfig, 8 | asyncDepChunk, 9 | pageImports, 10 | indexCss, 11 | themeScssChunk, 12 | themeCss, 13 | welcomeJs, 14 | welcomeLegacyJs, 15 | legacyPolyfills, 16 | pageAssets, 17 | logoPng, 18 | circular1Js, 19 | circular2Js, 20 | viteUserConfigNoRoot, 21 | } from "~tests/mocks"; 22 | import { VitePluginSymfonyEntrypointsOptions } from "~/types"; 23 | import { createLogger, Logger } from "vite"; 24 | import { resolvePluginEntrypointsOptions } from "./pluginOptions"; 25 | 26 | function createBundleObject(files: (OutputChunk | OutputAsset)[]) { 27 | const bundles: { 28 | [fileName: string]: OutputChunk | OutputAsset; 29 | } = {}; 30 | files.forEach((file) => { 31 | bundles[file.fileName] = file; 32 | }); 33 | 34 | return bundles; 35 | } 36 | 37 | function plugin(userPluginEntrypointsOptions: Partial) { 38 | const entrypointsOptions = resolvePluginEntrypointsOptions(userPluginEntrypointsOptions); 39 | const logger: Logger = { 40 | ...createLogger(), 41 | info: vi.fn(), 42 | }; 43 | return vitePluginSymfonyEntrypoints(entrypointsOptions, logger); 44 | } 45 | 46 | describe("vitePluginSymfonyEntrypoints", () => { 47 | it("generate correct welcome build entrypoints", ({ expect }) => { 48 | const welcomePluginInstance = plugin({ debug: true }) as any; 49 | 50 | welcomePluginInstance.emitFile = vi.fn(); 51 | welcomePluginInstance.configResolved({ 52 | ...viteBaseConfig, 53 | build: { 54 | rollupOptions: { 55 | input: { 56 | welcome: "./assets/page/welcome/index.js", 57 | }, 58 | }, 59 | }, 60 | }); 61 | const welcomeBundle = createBundleObject([welcomeJs]); 62 | welcomePluginInstance.generateBundle({ format: "es" }, welcomeBundle); 63 | 64 | expect(welcomePluginInstance.emitFile).toHaveBeenCalledWith({ 65 | fileName: ".vite/entrypoints.json", 66 | source: JSON.stringify( 67 | { 68 | base: "/build/", 69 | entryPoints: { 70 | welcome: { 71 | css: [], 72 | dynamic: [], 73 | js: ["/build/assets/welcome-1e67239d.js"], 74 | legacy: false, 75 | preload: [], 76 | }, 77 | }, 78 | legacy: false, 79 | metadatas: {}, 80 | version: ["test"], 81 | viteServer: null, 82 | }, 83 | null, 84 | 2, 85 | ), 86 | type: "asset", 87 | }); 88 | }); 89 | 90 | it("generate correct integrity hash for build entrypoints", ({ expect }) => { 91 | const hashPluginInstance = plugin({ debug: true, sriAlgorithm: "sha256" }) as any; 92 | 93 | hashPluginInstance.emitFile = vi.fn(); 94 | hashPluginInstance.configResolved({ 95 | ...viteBaseConfig, 96 | build: { 97 | rollupOptions: { 98 | input: { 99 | welcome: "./assets/page/welcome/index.js", 100 | }, 101 | }, 102 | }, 103 | }); 104 | hashPluginInstance.generateBundle({ format: "es" }, createBundleObject([welcomeJs])); 105 | 106 | expect(hashPluginInstance.emitFile).toHaveBeenCalledWith({ 107 | fileName: ".vite/entrypoints.json", 108 | source: JSON.stringify( 109 | { 110 | base: "/build/", 111 | entryPoints: { 112 | welcome: { 113 | css: [], 114 | dynamic: [], 115 | js: ["/build/assets/welcome-1e67239d.js"], 116 | legacy: false, 117 | preload: [], 118 | }, 119 | }, 120 | legacy: false, 121 | metadatas: { 122 | "/build/assets/welcome-1e67239d.js": { 123 | hash: "sha256-w+Sit18/MC+LC1iX8MrNapOiCQ8wbPX8Rb6ErbfDX1Q=", 124 | }, 125 | }, 126 | version: ["test"], 127 | viteServer: null, 128 | }, 129 | null, 130 | 2, 131 | ), 132 | type: "asset", 133 | }); 134 | }); 135 | 136 | it("generate correct pageAssets build entrypoints", ({ expect }) => { 137 | const pageAssetsPluginInstance = plugin({ debug: true }) as any; 138 | pageAssetsPluginInstance.emitFile = vi.fn(); 139 | pageAssetsPluginInstance.configResolved({ 140 | ...viteBaseConfig, 141 | build: { 142 | rollupOptions: { 143 | input: { 144 | pageAssets: "./assets/page/assets/index.js", 145 | }, 146 | }, 147 | }, 148 | }); 149 | pageAssetsPluginInstance.generateBundle({ format: "es" }, createBundleObject([pageAssets, indexCss, logoPng])); 150 | 151 | expect(pageAssetsPluginInstance.emitFile).toHaveBeenCalledWith({ 152 | fileName: ".vite/entrypoints.json", 153 | source: JSON.stringify( 154 | { 155 | base: "/build/", 156 | entryPoints: { 157 | pageAssets: { 158 | css: ["/build/assets/index-aa7c8190.css"], 159 | dynamic: [], 160 | js: ["/build/assets/pageAssets-05cfe79c.js"], 161 | legacy: false, 162 | preload: [], 163 | }, 164 | }, 165 | legacy: false, 166 | metadatas: {}, 167 | version: ["test"], 168 | viteServer: null, 169 | }, 170 | null, 171 | 2, 172 | ), 173 | type: "asset", 174 | }); 175 | }); 176 | 177 | it("generate correct pageImports build entrypoints", ({ expect }) => { 178 | const pageImportsPluginInstance = plugin({ debug: true }) as any; 179 | pageImportsPluginInstance.emitFile = vi.fn(); 180 | pageImportsPluginInstance.configResolved({ 181 | ...viteBaseConfig, 182 | build: { 183 | rollupOptions: { 184 | input: { 185 | pageImports: "./assets/page/imports/index.js", 186 | }, 187 | }, 188 | }, 189 | }); 190 | pageImportsPluginInstance.generateBundle({ format: "es" }, createBundleObject([pageImports, asyncDepChunk])); 191 | 192 | expect(pageImportsPluginInstance.emitFile).toHaveBeenCalledWith({ 193 | fileName: ".vite/entrypoints.json", 194 | source: JSON.stringify( 195 | { 196 | base: "/build/", 197 | entryPoints: { 198 | pageImports: { 199 | css: [], 200 | dynamic: ["/build/assets/async-dep-e2ac9f96.js"], 201 | js: ["/build/assets/pageImports-53eb9fd1.js"], 202 | legacy: false, 203 | preload: [], 204 | }, 205 | }, 206 | legacy: false, 207 | metadatas: {}, 208 | version: ["test"], 209 | viteServer: null, 210 | }, 211 | null, 212 | 2, 213 | ), 214 | type: "asset", 215 | }); 216 | }); 217 | 218 | it("generate correct theme build entrypoints", ({ expect }) => { 219 | const themePluginInstance = plugin({ debug: true }) as any; 220 | themePluginInstance.emitFile = vi.fn(); 221 | themePluginInstance.configResolved({ 222 | ...viteBaseConfig, 223 | build: { 224 | rollupOptions: { 225 | input: { 226 | theme: "./assets/theme.scss", 227 | }, 228 | }, 229 | }, 230 | }); 231 | 232 | themePluginInstance.renderChunk("CODE", themeScssChunk); 233 | themePluginInstance.generateBundle({ format: "es" }, createBundleObject([themeCss])); 234 | 235 | expect(themePluginInstance.emitFile).toHaveBeenCalledWith({ 236 | fileName: ".vite/entrypoints.json", 237 | source: JSON.stringify( 238 | { 239 | base: "/build/", 240 | entryPoints: { 241 | theme: { 242 | css: ["/build/assets/theme-44b5be96.css"], 243 | dynamic: [], 244 | js: [], 245 | legacy: false, 246 | preload: [], 247 | }, 248 | }, 249 | legacy: false, 250 | metadatas: {}, 251 | version: ["test"], 252 | viteServer: null, 253 | }, 254 | null, 255 | 2, 256 | ), 257 | type: "asset", 258 | }); 259 | }); 260 | 261 | it("generate correct circular build entrypoints", ({ expect }) => { 262 | const circularPluginInstance = plugin({ debug: true }) as any; 263 | circularPluginInstance.emitFile = vi.fn(); 264 | circularPluginInstance.configResolved({ 265 | ...viteBaseConfig, 266 | build: { 267 | rollupOptions: { 268 | input: { 269 | circular: "./assets/page/circular1.js", 270 | }, 271 | }, 272 | }, 273 | }); 274 | 275 | circularPluginInstance.generateBundle({ format: "es" }, createBundleObject([circular1Js, circular2Js])); 276 | 277 | expect(circularPluginInstance.emitFile).toHaveBeenCalledWith({ 278 | fileName: ".vite/entrypoints.json", 279 | source: JSON.stringify( 280 | { 281 | base: "/build/", 282 | entryPoints: { 283 | circular: { 284 | css: [], 285 | dynamic: [], 286 | js: ["/build/assets/circular1-56785678.js"], 287 | legacy: false, 288 | preload: ["/build/assets/circular2-12341234.js"], 289 | }, 290 | }, 291 | legacy: false, 292 | metadatas: {}, 293 | version: ["test"], 294 | viteServer: null, 295 | }, 296 | null, 297 | 2, 298 | ), 299 | type: "asset", 300 | }); 301 | }); 302 | 303 | it("generate correct legacy build entrypoints", ({ expect }) => { 304 | const legacyPluginInstance = plugin({ debug: true }) as any; 305 | legacyPluginInstance.emitFile = vi.fn(); 306 | legacyPluginInstance.configResolved({ 307 | ...viteBaseConfig, 308 | build: { 309 | rollupOptions: { 310 | input: { 311 | welcome: "./assets/page/welcome/index.js", 312 | }, 313 | output: [{ format: "system" }, { format: "es" }], 314 | }, 315 | }, 316 | }); 317 | 318 | legacyPluginInstance.generateBundle({ format: "system" }, createBundleObject([welcomeLegacyJs, legacyPolyfills])); 319 | legacyPluginInstance.generateBundle({ format: "es" }, createBundleObject([welcomeJs])); 320 | 321 | expect(legacyPluginInstance.emitFile).toHaveBeenCalledWith({ 322 | fileName: ".vite/entrypoints.json", 323 | source: JSON.stringify( 324 | { 325 | base: "/build/", 326 | entryPoints: { 327 | "welcome-legacy": { 328 | css: [], 329 | dynamic: [], 330 | js: ["/build/assets/welcome-legacy-64979d13.js"], 331 | legacy: false, 332 | preload: [], 333 | }, 334 | welcome: { 335 | css: [], 336 | dynamic: [], 337 | js: ["/build/assets/welcome-1e67239d.js"], 338 | legacy: "welcome-legacy", 339 | preload: [], 340 | }, 341 | "polyfills-legacy": { 342 | css: [], 343 | dynamic: [], 344 | js: ["/build/assets/polyfills-legacy-40963d34.js"], 345 | legacy: false, 346 | preload: [], 347 | }, 348 | }, 349 | legacy: true, 350 | metadatas: {}, 351 | version: ["test"], 352 | viteServer: null, 353 | }, 354 | null, 355 | 2, 356 | ), 357 | type: "asset", 358 | }); 359 | }); 360 | 361 | it("loads correctly without root user config option", ({ expect }) => { 362 | const pluginInstance = plugin({ debug: true }) as any; 363 | const config = pluginInstance.config(viteUserConfigNoRoot, { mode: "development" }); 364 | 365 | expect(config).toEqual({ 366 | base: "/build/", 367 | publicDir: false, 368 | build: { 369 | manifest: true, 370 | outDir: "public/build", 371 | }, 372 | define: {}, 373 | optimizeDeps: { 374 | force: true, 375 | }, 376 | server: { 377 | watch: { 378 | ignored: ["**/vendor/**", process.cwd() + "/var/**", process.cwd() + "/public/**"], 379 | }, 380 | }, 381 | }); 382 | }); 383 | }); 384 | -------------------------------------------------------------------------------- /src/entrypoints/index.ts: -------------------------------------------------------------------------------- 1 | import path, { resolve, join, relative, dirname } from "node:path"; 2 | import { existsSync, mkdirSync, readFileSync } from "node:fs"; 3 | import { fileURLToPath } from "node:url"; 4 | import glob from "fast-glob"; 5 | import process from "node:process"; 6 | 7 | import { Logger, Plugin, UserConfig } from "vite"; 8 | import sirv from "sirv"; 9 | 10 | import colors from "picocolors"; 11 | 12 | import type { RenderedChunk, NormalizedOutputOptions, OutputBundle } from "rollup"; 13 | 14 | import { getDevEntryPoints, getBuildEntryPoints, getFilesMetadatas } from "./entryPointsHelper"; 15 | import { 16 | normalizePath, 17 | writeJson, 18 | isImportRequest, 19 | isInternalRequest, 20 | resolveDevServerUrl, 21 | isAddressInfo, 22 | isCssEntryPoint, 23 | getFileInfos, 24 | getInputRelPath, 25 | parseVersionString, 26 | extractExtraEnvVars, 27 | INFO_PUBLIC_PATH, 28 | normalizeConfig, 29 | } from "./utils"; 30 | import { resolveOutDir, refreshPaths } from "./pluginOptions"; 31 | 32 | import { GeneratedFiles, ResolvedConfigWithOrderablePlugins, VitePluginSymfonyEntrypointsOptions } from "../types"; 33 | import { addIOMapping } from "./pathMapping"; 34 | import { showDepreciationsWarnings } from "./depreciations"; 35 | 36 | // src and dist directory are in the same level; 37 | let pluginDir = dirname(dirname(fileURLToPath(import.meta.url))); 38 | let pluginVersion: [string] | [string, number, number, number]; 39 | let bundleVersion: [string] | [string, number, number, number]; 40 | 41 | if (process.env.VITEST) { 42 | pluginDir = dirname(pluginDir); 43 | pluginVersion = ["test"]; 44 | bundleVersion = ["test"]; 45 | } else { 46 | try { 47 | const packageJson = JSON.parse(readFileSync(join(pluginDir, "package.json")).toString()); 48 | pluginVersion = parseVersionString(packageJson?.version); 49 | } catch { 50 | pluginVersion = [""]; 51 | } 52 | try { 53 | const composerJson = JSON.parse(readFileSync("composer.lock").toString()); 54 | bundleVersion = parseVersionString( 55 | composerJson.packages?.find( 56 | (composerPackage: { name: string }) => composerPackage.name === "pentatrion/vite-bundle", 57 | )?.version, 58 | ); 59 | } catch { 60 | bundleVersion = [""]; 61 | } 62 | } 63 | 64 | export default function symfonyEntrypoints(pluginOptions: VitePluginSymfonyEntrypointsOptions, logger: Logger) { 65 | let viteConfig: ResolvedConfigWithOrderablePlugins; 66 | let viteDevServerUrl: string; 67 | 68 | const entryPointsFileName = ".vite/entrypoints.json"; 69 | 70 | const generatedFiles: GeneratedFiles = {}; 71 | 72 | let outputCount = 0; 73 | 74 | return { 75 | name: "symfony-entrypoints", 76 | enforce: "post", 77 | config(userConfig, { mode }) { 78 | const root = userConfig.root ? resolve(userConfig.root) : process.cwd(); 79 | 80 | const envDir = userConfig.envDir ? resolve(root, userConfig.envDir) : root; 81 | 82 | const extraEnvVars = extractExtraEnvVars(mode, envDir, pluginOptions.exposedEnvVars, userConfig.define); 83 | 84 | if (userConfig.build?.rollupOptions?.input instanceof Array) { 85 | logger.error(colors.red("rollupOptions.input must be an Objet like {app: './assets/app.js'}")); 86 | process.exit(1); 87 | } 88 | 89 | const base = userConfig.base ?? "/build/"; 90 | 91 | const extraConfig: UserConfig = { 92 | base, 93 | publicDir: false, 94 | build: { 95 | manifest: true, 96 | outDir: userConfig.build?.outDir ?? resolveOutDir(base), 97 | }, 98 | define: extraEnvVars, 99 | optimizeDeps: { 100 | //Set to true to force dependency pre-bundling. 101 | force: true, 102 | }, 103 | server: { 104 | watch: { 105 | ignored: userConfig.server?.watch?.ignored 106 | ? [] 107 | : ["**/vendor/**", glob.escapePath(root + "/var") + "/**", glob.escapePath(root + "/public") + "/**"], 108 | }, 109 | }, 110 | }; 111 | 112 | return extraConfig; 113 | }, 114 | configResolved(config) { 115 | viteConfig = config as ResolvedConfigWithOrderablePlugins; 116 | 117 | if (pluginOptions.enforcePluginOrderingPosition) { 118 | const pluginPos = viteConfig.plugins.findIndex((plugin) => plugin.name === "symfony-entrypoints"); 119 | const symfonyPlugin = viteConfig.plugins.splice(pluginPos, 1); 120 | 121 | const manifestPos = viteConfig.plugins.findIndex((plugin) => plugin.name === "vite:reporter"); 122 | viteConfig.plugins.splice(manifestPos, 0, symfonyPlugin[0]); 123 | } 124 | }, 125 | configureServer(devServer) { 126 | // vite server is running 127 | const { watcher, ws } = devServer; 128 | 129 | const _printUrls = devServer.printUrls; 130 | devServer.printUrls = () => { 131 | _printUrls(); 132 | const versions: string[] = []; 133 | if (pluginVersion[0]) { 134 | versions.push(colors.dim(`vite-plugin-symfony: `) + colors.bold(`v${pluginVersion[0]}`)); 135 | } 136 | if (bundleVersion[0]) { 137 | versions.push(colors.dim(`pentatrion/vite-bundle: `) + colors.bold(`${bundleVersion[0]}`)); 138 | } 139 | const versionStr = versions.length === 0 ? "" : versions.join(colors.dim(", ")); 140 | console.log(` ${colors.green("➜")} Vite ${colors.yellow("⚡️")} Symfony: ${versionStr}`); 141 | }; 142 | 143 | devServer.httpServer?.once("listening", () => { 144 | // empty the buildDir and create an entrypoints.json file inside. 145 | if (viteConfig.env.DEV && !process.env.VITEST) { 146 | showDepreciationsWarnings(pluginOptions, logger); 147 | 148 | const buildDir = resolve(viteConfig.root, viteConfig.build.outDir); 149 | const viteDir = resolve(buildDir, ".vite"); 150 | const address = devServer.httpServer?.address(); 151 | const entryPointsPath = resolve(viteConfig.root, viteConfig.build.outDir, entryPointsFileName); 152 | 153 | if (!isAddressInfo(address)) { 154 | logger.error( 155 | `address is not an object open an issue with your address value to fix the problem : ${address}`, 156 | ); 157 | process.exit(1); 158 | } 159 | 160 | if (!existsSync(buildDir)) { 161 | mkdirSync(buildDir, { recursive: true }); 162 | } 163 | 164 | mkdirSync(viteDir, { recursive: true }); 165 | 166 | viteDevServerUrl = resolveDevServerUrl(address, devServer.config, pluginOptions); 167 | if (pluginOptions.enforceServerOriginAfterListening) { 168 | viteConfig.server.origin = viteDevServerUrl; 169 | } 170 | 171 | writeJson(entryPointsPath, { 172 | base: viteConfig.base, 173 | entryPoints: getDevEntryPoints(viteConfig, viteDevServerUrl), 174 | legacy: false, 175 | metadatas: {}, 176 | version: pluginVersion, 177 | viteServer: viteDevServerUrl, 178 | }); 179 | } 180 | }); 181 | 182 | // full reload vite dev server if twig files are modified. 183 | if (pluginOptions.refresh !== false) { 184 | const paths = pluginOptions.refresh === true ? refreshPaths : pluginOptions.refresh; 185 | for (const path of paths) { 186 | watcher.add(path); 187 | } 188 | watcher.on("change", function (path) { 189 | if (path.endsWith(".twig")) { 190 | ws.send({ 191 | type: "full-reload", 192 | }); 193 | } 194 | }); 195 | } 196 | 197 | devServer.middlewares.use(function symfonyInternalsMiddleware(req, res, next) { 198 | if (req.url === "/" || req.url === viteConfig.base) { 199 | res.statusCode = 404; 200 | res.end(readFileSync(join(pluginDir, "static/dev-server-404.html"))); 201 | return; 202 | } 203 | 204 | if (req.url === path.posix.join(viteConfig.base, INFO_PUBLIC_PATH)) { 205 | res.statusCode = 200; 206 | res.setHeader("Content-Type", "application/json"); 207 | 208 | res.end(normalizeConfig(viteConfig)); 209 | return; 210 | } 211 | 212 | return next(); 213 | }); 214 | 215 | // inspired by https://github.com/vitejs/vite 216 | // file: packages/vite/src/node/server/middlewares/static.ts 217 | if (pluginOptions.servePublic !== false) { 218 | const serve = sirv(pluginOptions.servePublic, { 219 | dev: true, 220 | etag: true, 221 | extensions: [], 222 | setHeaders(res, pathname) { 223 | // Matches js, jsx, ts, tsx. 224 | // The reason this is done, is that the .ts file extension is reserved 225 | // for the MIME type video/mp2t. In almost all cases, we can expect 226 | // these files to be TypeScript files, and for Vite to serve them with 227 | // this Content-Type. 228 | if (/\.[tj]sx?$/.test(pathname)) { 229 | res.setHeader("Content-Type", "application/javascript"); 230 | } 231 | 232 | res.setHeader("Access-Control-Allow-Origin", "*"); 233 | }, 234 | }); 235 | 236 | devServer.middlewares.use(function viteServePublicMiddleware(req, res, next) { 237 | // skip import request and internal requests `/@fs/ /@vite-client` etc... 238 | if (isImportRequest(req.url!) || isInternalRequest(req.url!)) { 239 | return next(); 240 | } 241 | 242 | // only if servePublic is enabled 243 | serve(req, res, next); 244 | }); 245 | } 246 | }, 247 | async renderChunk(code: string, chunk: RenderedChunk) { 248 | // we need this step because css entrypoints doesn't have a facadeModuleId in `generateBundle` step. 249 | if (!isCssEntryPoint(chunk)) { 250 | return; 251 | } 252 | 253 | // Here we have only css entryPoints 254 | const cssAssetName = chunk.facadeModuleId 255 | ? normalizePath(relative(viteConfig.root, chunk.facadeModuleId)) 256 | : chunk.name; 257 | 258 | // chunk.viteMetadata.importedCss contains a Set of relative paths of generated css files 259 | // in our case we have only one file (it's a condition of isCssEntryPoint to be true). 260 | // eg: addIOMapping('assets/theme.scss', 'assets/theme-44b5be96.css'); 261 | chunk.viteMetadata?.importedCss.forEach((cssBuildFilename) => { 262 | addIOMapping(cssAssetName, cssBuildFilename); 263 | }); 264 | }, 265 | generateBundle(options: NormalizedOutputOptions, bundle: OutputBundle) { 266 | for (const chunk of Object.values(bundle)) { 267 | const inputRelPath = getInputRelPath(chunk, options, viteConfig); 268 | addIOMapping(inputRelPath, chunk.fileName); 269 | generatedFiles[chunk.fileName] = getFileInfos(chunk, inputRelPath, pluginOptions); 270 | } 271 | 272 | outputCount++; 273 | const output = viteConfig.build.rollupOptions?.output; 274 | 275 | // if we have multiple build passes output is an array of each pass. 276 | // else we have an object of this unique pass 277 | const outputLength = Array.isArray(output) ? output.length : 1; 278 | 279 | if (outputCount >= outputLength) { 280 | const entryPoints = getBuildEntryPoints(generatedFiles, viteConfig); 281 | 282 | this.emitFile({ 283 | fileName: entryPointsFileName, 284 | source: JSON.stringify( 285 | { 286 | base: viteConfig.base, 287 | entryPoints, 288 | legacy: typeof entryPoints["polyfills-legacy"] !== "undefined", 289 | metadatas: getFilesMetadatas(viteConfig.base, generatedFiles), 290 | version: pluginVersion, 291 | viteServer: null, 292 | }, 293 | null, 294 | 2, 295 | ), 296 | type: "asset", 297 | }); 298 | } 299 | }, 300 | } satisfies Plugin; 301 | } 302 | -------------------------------------------------------------------------------- /src/entrypoints/pathMapping.ts: -------------------------------------------------------------------------------- 1 | import { StringMapping } from "~/types"; 2 | 3 | const inputRelPath2outputRelPath: StringMapping = {}; 4 | 5 | export function addIOMapping(relInputPath: string, relOutputPath: string) { 6 | inputRelPath2outputRelPath[relInputPath] = relOutputPath; 7 | } 8 | 9 | export function getOutputPath(relInputPath: string): string | undefined { 10 | return inputRelPath2outputRelPath[relInputPath]; 11 | } 12 | 13 | export function getInputPath(relOutputPath: string): string | undefined { 14 | return Object.keys(inputRelPath2outputRelPath).find((key) => inputRelPath2outputRelPath[key] === relOutputPath); 15 | } 16 | -------------------------------------------------------------------------------- /src/entrypoints/pluginOptions.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | import { resolvePluginEntrypointsOptions, resolveOutDir } from "./pluginOptions"; 3 | 4 | describe("resolvePluginEntrypointsOptions", () => { 5 | it("resolves with default options when no config", ({ expect }) => { 6 | expect(resolvePluginEntrypointsOptions()).toMatchInlineSnapshot(` 7 | { 8 | "debug": false, 9 | "enforcePluginOrderingPosition": true, 10 | "enforceServerOriginAfterListening": true, 11 | "exposedEnvVars": [ 12 | "APP_ENV", 13 | ], 14 | "originOverride": null, 15 | "refresh": false, 16 | "servePublic": "public", 17 | "sriAlgorithm": false, 18 | "viteDevServerHostname": null, 19 | } 20 | `); 21 | }); 22 | }); 23 | 24 | describe("resolveOutDir", () => { 25 | it("resolve correctely `build.outDir` vite config option", ({ expect }) => { 26 | expect(resolveOutDir("/build/")).toBe("public/build"); 27 | expect(resolveOutDir("custom/build")).toBe("public/custom/build"); 28 | expect(resolveOutDir("https://other.com/build")).toBe("public/build"); 29 | expect(resolveOutDir("https://other.com/")).toBe("public"); 30 | expect(resolveOutDir("https://other.com")).toBe("public"); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/entrypoints/pluginOptions.ts: -------------------------------------------------------------------------------- 1 | import { VitePluginSymfonyEntrypointsOptions } from "~/types"; 2 | import { trimSlashes } from "./utils"; 3 | import { join } from "node:path"; 4 | 5 | export function resolvePluginEntrypointsOptions( 6 | userConfig: Partial = {}, 7 | ): VitePluginSymfonyEntrypointsOptions { 8 | if (typeof userConfig.servePublic === "undefined") { 9 | userConfig.servePublic = "public"; 10 | } 11 | 12 | if ( 13 | typeof userConfig.sriAlgorithm === "string" && 14 | ["sha256", "sha384", "sha512"].indexOf(userConfig.sriAlgorithm.toString()) === -1 15 | ) { 16 | userConfig.sriAlgorithm = false; 17 | } 18 | 19 | return { 20 | debug: userConfig.debug === true, 21 | enforcePluginOrderingPosition: userConfig.enforcePluginOrderingPosition === false ? false : true, 22 | enforceServerOriginAfterListening: userConfig.enforceServerOriginAfterListening === false ? false : true, 23 | exposedEnvVars: userConfig.exposedEnvVars ?? ["APP_ENV"], 24 | originOverride: userConfig.originOverride ?? null, 25 | refresh: userConfig.refresh ?? false, 26 | servePublic: userConfig.servePublic, 27 | sriAlgorithm: userConfig.sriAlgorithm ?? false, 28 | viteDevServerHostname: userConfig.viteDevServerHostname ?? null, 29 | }; 30 | } 31 | 32 | export function resolveOutDir(unknownBase: string): string { 33 | const baseURL = new URL(unknownBase, import.meta.url); 34 | 35 | const base = baseURL.protocol === "file:" ? unknownBase : baseURL.pathname; 36 | const publicDirectory = "public"; 37 | 38 | return join(publicDirectory, trimSlashes(base)); 39 | } 40 | 41 | export const refreshPaths = ["templates/**/*.twig"]; 42 | -------------------------------------------------------------------------------- /src/entrypoints/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, test } from "vitest"; 2 | import { 3 | getLegacyName, 4 | normalizePath, 5 | getFileInfos, 6 | getInputRelPath, 7 | prepareRollupInputs, 8 | isSubdirectory, 9 | parseVersionString, 10 | resolveDevServerUrl, 11 | isCssEntryPoint, 12 | resolveUserExternal, 13 | normalizeConfig, 14 | trimSlashes, 15 | } from "./utils"; 16 | import { resolvePluginEntrypointsOptions } from "./pluginOptions"; 17 | import { OutputChunk, OutputAsset, NormalizedOutputOptions } from "rollup"; 18 | import { 19 | asyncDepChunk, 20 | indexCss, 21 | legacyPolyfills, 22 | logoPng, 23 | pageAssets, 24 | themeCss, 25 | welcomeJs, 26 | welcomeLegacyJs, 27 | } from "~tests/mocks"; 28 | import { resolveConfig, type ResolvedConfig } from "vite"; 29 | import { VitePluginSymfonyOptions } from "~/types"; 30 | import type { RenderedChunk } from "rollup"; 31 | 32 | const viteBaseConfig = { 33 | root: "/home/me/project-dir", 34 | base: "/build/", 35 | } as unknown as ResolvedConfig; 36 | 37 | describe("isCssEntryPoint", () => { 38 | it.each([ 39 | [ 40 | { 41 | isEntry: false, 42 | } as unknown as RenderedChunk, 43 | false, 44 | ], 45 | [ 46 | { 47 | isEntry: true, 48 | modules: { 49 | "/path/to/module.js": {}, 50 | "/path/to/style.css": {}, 51 | }, 52 | } as unknown as RenderedChunk, 53 | false, 54 | ], 55 | [ 56 | { 57 | isEntry: true, 58 | modules: { 59 | "/path/to/style.module.css": {}, 60 | }, 61 | } as unknown as RenderedChunk, 62 | false, 63 | ], 64 | [ 65 | { 66 | // original entrypoint is wrapped into a js file 67 | fileName: "assets/theme-!~{001}~.js", 68 | facadeModuleId: "/path/to/assets/theme.scss", 69 | isEntry: true, 70 | modules: { 71 | // original entrypoint 72 | "/path/to/assets/theme.scss": {}, 73 | }, 74 | viteMetadata: { 75 | // Set of relative paths of generated css files 76 | importedCss: new Set(["assets/theme-hIRg7xK2.css"]), 77 | }, 78 | } as unknown as RenderedChunk, 79 | true, 80 | ], 81 | ])("find when entrypoint is a pure css file", (chunk: RenderedChunk, expectedValue: boolean) => { 82 | expect(isCssEntryPoint(chunk)).toBe(expectedValue); 83 | }); 84 | }); 85 | 86 | describe("parseVersionString", () => { 87 | it("basic", () => { 88 | expect(parseVersionString("1.2.3")).toEqual(["1.2.3", 1, 2, 3]); 89 | }); 90 | 91 | it("return usable version if uncommon string", () => { 92 | expect(parseVersionString("1.2.3-dev")).toEqual(["1.2.3-dev", 1, 2, 3]); 93 | expect(parseVersionString("1.2")).toEqual(["1.2", 1, 2, 0]); 94 | expect(parseVersionString("1")).toEqual(["1", 1, 0, 0]); 95 | expect(parseVersionString("1-dev")).toEqual(["1-dev", 1, 0, 0]); 96 | }); 97 | }); 98 | 99 | describe("normalizePath", () => { 100 | it("normalize correctly path", () => { 101 | expect(normalizePath("path//to/deep/../file.ts")).toBe("path/to/file.ts"); 102 | }); 103 | 104 | it("keep the path unchanged on UNIX", () => { 105 | expect(normalizePath("path/to/file.ts")).toBe("path/to/file.ts"); 106 | }); 107 | }); 108 | 109 | describe("getLegacyName", () => { 110 | it("suffix pathname with -legacy before extension", () => { 111 | expect(getLegacyName("assets/page/assets/index.js")).toBe("assets/page/assets/index-legacy.js"); 112 | }); 113 | }); 114 | 115 | /** 116 | * { 117 | viteConfig: InlineConfig, 118 | isIPv4: boolean, 119 | pluginOptions: Partial, 120 | expectedUrl: string, 121 | } 122 | */ 123 | describe("resolveDevServerUrl", () => { 124 | it.each([ 125 | { 126 | message: "resolve correctly default config", 127 | viteConfig: {}, 128 | isIPv4: true, 129 | pluginOptions: {}, 130 | expectedUrl: "http://127.0.0.1:5173", 131 | }, 132 | { 133 | message: "resolve host priority 1", 134 | viteConfig: {}, 135 | isIPv4: true, 136 | pluginOptions: { 137 | originOverride: "", 138 | viteDevServerHostname: "", 139 | }, 140 | expectedUrl: "", 141 | }, 142 | { 143 | message: "resolve host priority 2", 144 | viteConfig: { 145 | server: { 146 | hmr: { 147 | host: "hmr-host", 148 | }, 149 | host: "server-host", 150 | }, 151 | }, 152 | isIPv4: true, 153 | pluginOptions: { 154 | viteDevServerHostname: "plugin-host", 155 | }, 156 | expectedUrl: "http://hmr-host:5173", 157 | }, 158 | { 159 | message: "resolve host priority 3 (use case docker)", 160 | viteConfig: { 161 | server: { 162 | host: "0.0.0.0.", 163 | }, 164 | }, 165 | isIPv4: true, 166 | pluginOptions: { 167 | viteDevServerHostname: "plugin-host", 168 | }, 169 | expectedUrl: "http://plugin-host:5173", 170 | }, 171 | { 172 | message: "resolve host priority 4", 173 | viteConfig: { 174 | server: { 175 | host: "server-host", 176 | }, 177 | }, 178 | isIPv4: true, 179 | pluginOptions: {}, 180 | expectedUrl: "http://server-host:5173", 181 | }, 182 | { 183 | message: "resolve host priority 5", 184 | viteConfig: {}, 185 | isIPv4: false, 186 | pluginOptions: {}, 187 | expectedUrl: "http://[::1]:5173", 188 | }, 189 | ])("$message", async ({ viteConfig, isIPv4, pluginOptions, expectedUrl }) => { 190 | const address = isIPv4 191 | ? { 192 | family: "IPv4", 193 | address: "127.0.0.1", 194 | port: 5173, 195 | } 196 | : { 197 | family: "IPv6", 198 | address: "::1", 199 | port: 5173, 200 | }; 201 | 202 | const viteResolvedConfig = await resolveConfig(viteConfig, "serve"); 203 | const pluginConfig = resolvePluginEntrypointsOptions(pluginOptions); 204 | expect(resolveDevServerUrl(address, viteResolvedConfig, pluginConfig)).toBe(expectedUrl); 205 | }); 206 | }); 207 | 208 | describe("getFileInfos", () => { 209 | it("parse correctly an output", () => { 210 | expect(getFileInfos(asyncDepChunk, "assets/lib/async-dep.js", { sriAlgorithm: false } as VitePluginSymfonyOptions)) 211 | .toMatchInlineSnapshot(` 212 | { 213 | "assets": [], 214 | "css": [], 215 | "dynamic": [], 216 | "hash": null, 217 | "imports": [], 218 | "inputRelPath": "assets/lib/async-dep.js", 219 | "js": [ 220 | "assets/async-dep-e2ac9f96.js", 221 | ], 222 | "outputRelPath": "assets/async-dep-e2ac9f96.js", 223 | "preload": [], 224 | "type": "js", 225 | } 226 | `); 227 | expect(getFileInfos(indexCss, "_assets/index-aa7c8190.css", { sriAlgorithm: false } as VitePluginSymfonyOptions)) 228 | .toMatchInlineSnapshot(` 229 | { 230 | "css": [ 231 | "assets/index-aa7c8190.css", 232 | ], 233 | "hash": null, 234 | "inputRelPath": "_assets/index-aa7c8190.css", 235 | "outputRelPath": "assets/index-aa7c8190.css", 236 | "type": "css", 237 | } 238 | `); 239 | expect(getFileInfos(themeCss, "assets/theme.scss", { sriAlgorithm: false } as VitePluginSymfonyOptions)) 240 | .toMatchInlineSnapshot(` 241 | { 242 | "css": [ 243 | "assets/theme-44b5be96.css", 244 | ], 245 | "hash": null, 246 | "inputRelPath": "assets/theme.scss", 247 | "outputRelPath": "assets/theme-44b5be96.css", 248 | "type": "css", 249 | } 250 | `); 251 | expect(getFileInfos(logoPng, "_assets/logo-d015cc3f.png", { sriAlgorithm: false } as VitePluginSymfonyOptions)) 252 | .toMatchInlineSnapshot(` 253 | { 254 | "hash": null, 255 | "inputRelPath": "_assets/logo-d015cc3f.png", 256 | "outputRelPath": "assets/logo-d015cc3f.png", 257 | "type": "asset", 258 | } 259 | `); 260 | expect(getFileInfos(welcomeJs, "assets/page/welcome/index.js", { sriAlgorithm: false } as VitePluginSymfonyOptions)) 261 | .toMatchInlineSnapshot(` 262 | { 263 | "assets": [], 264 | "css": [], 265 | "dynamic": [], 266 | "hash": null, 267 | "imports": [], 268 | "inputRelPath": "assets/page/welcome/index.js", 269 | "js": [ 270 | "assets/welcome-1e67239d.js", 271 | ], 272 | "outputRelPath": "assets/welcome-1e67239d.js", 273 | "preload": [], 274 | "type": "js", 275 | } 276 | `); 277 | expect( 278 | getFileInfos(welcomeJs, "assets/page/welcome/index.js", { sriAlgorithm: "sha256" } as VitePluginSymfonyOptions), 279 | ).toMatchInlineSnapshot(` 280 | { 281 | "assets": [], 282 | "css": [], 283 | "dynamic": [], 284 | "hash": "sha256-w+Sit18/MC+LC1iX8MrNapOiCQ8wbPX8Rb6ErbfDX1Q=", 285 | "imports": [], 286 | "inputRelPath": "assets/page/welcome/index.js", 287 | "js": [ 288 | "assets/welcome-1e67239d.js", 289 | ], 290 | "outputRelPath": "assets/welcome-1e67239d.js", 291 | "preload": [], 292 | "type": "js", 293 | } 294 | `); 295 | expect(getFileInfos(pageAssets, "assets/page/assets/index.js", { sriAlgorithm: false } as VitePluginSymfonyOptions)) 296 | .toMatchInlineSnapshot(` 297 | { 298 | "assets": [ 299 | "assets/logo-d015cc3f.png", 300 | ], 301 | "css": [ 302 | "assets/index-aa7c8190.css", 303 | ], 304 | "dynamic": [], 305 | "hash": null, 306 | "imports": [], 307 | "inputRelPath": "assets/page/assets/index.js", 308 | "js": [ 309 | "assets/pageAssets-05cfe79c.js", 310 | ], 311 | "outputRelPath": "assets/pageAssets-05cfe79c.js", 312 | "preload": [], 313 | "type": "js", 314 | } 315 | `); 316 | expect( 317 | getFileInfos(welcomeLegacyJs, "assets/page/welcome/index-legacy.js", { 318 | sriAlgorithm: false, 319 | } as VitePluginSymfonyOptions), 320 | ).toMatchInlineSnapshot(` 321 | { 322 | "assets": [], 323 | "css": [], 324 | "dynamic": [], 325 | "hash": null, 326 | "imports": [], 327 | "inputRelPath": "assets/page/welcome/index-legacy.js", 328 | "js": [ 329 | "assets/welcome-legacy-64979d13.js", 330 | ], 331 | "outputRelPath": "assets/welcome-legacy-64979d13.js", 332 | "preload": [], 333 | "type": "js", 334 | } 335 | `); 336 | expect(getFileInfos(legacyPolyfills, "vite/legacy-polyfills", { sriAlgorithm: false } as VitePluginSymfonyOptions)) 337 | .toMatchInlineSnapshot(` 338 | { 339 | "assets": [], 340 | "css": [], 341 | "dynamic": [], 342 | "hash": null, 343 | "imports": [], 344 | "inputRelPath": "vite/legacy-polyfills", 345 | "js": [ 346 | "assets/polyfills-legacy-40963d34.js", 347 | ], 348 | "outputRelPath": "assets/polyfills-legacy-40963d34.js", 349 | "preload": [], 350 | "type": "js", 351 | } 352 | `); 353 | }); 354 | }); 355 | 356 | describe("prepareRollupInputs", () => { 357 | it("prepare inputs", () => { 358 | expect( 359 | prepareRollupInputs({ 360 | ...viteBaseConfig, 361 | build: { 362 | rollupOptions: { 363 | input: { 364 | app: "./path/to/filename.ts", 365 | theme: "./other/place/to/theme.scss", 366 | }, 367 | }, 368 | }, 369 | } as unknown as ResolvedConfig), 370 | ).toMatchInlineSnapshot(` 371 | { 372 | "app": { 373 | "inputRelPath": "path/to/filename.ts", 374 | "inputType": "js", 375 | }, 376 | "theme": { 377 | "inputRelPath": "other/place/to/theme.scss", 378 | "inputType": "css", 379 | }, 380 | } 381 | `); 382 | }); 383 | }); 384 | 385 | describe("getInputRelPath", () => { 386 | it("generate Correct path", () => { 387 | expect( 388 | getInputRelPath( 389 | { 390 | type: "asset", 391 | fileName: "theme.css", 392 | } as OutputAsset, 393 | { format: "es" } as NormalizedOutputOptions, 394 | viteBaseConfig, 395 | ), 396 | ).toBe("_theme.css"); 397 | 398 | expect( 399 | getInputRelPath( 400 | { 401 | type: "chunk", 402 | facadeModuleId: "/home/me/project-dir/assets/page/welcome/index.js", 403 | name: "welcome", 404 | } as OutputChunk, 405 | { format: "es" } as NormalizedOutputOptions, 406 | viteBaseConfig, 407 | ), 408 | ).toBe("assets/page/welcome/index.js"); 409 | 410 | expect( 411 | getInputRelPath( 412 | { 413 | type: "chunk", 414 | facadeModuleId: "/home/me/project-dir/assets/page/welcome/index.js", 415 | name: "welcome", 416 | } as OutputChunk, 417 | { format: "system" } as NormalizedOutputOptions, 418 | viteBaseConfig, 419 | ), 420 | ).toBe("assets/page/welcome/index-legacy.js"); 421 | }); 422 | }); 423 | 424 | describe("isAncestorDir", () => { 425 | it("subdirectory is a subdirectory", () => { 426 | expect(isSubdirectory("/projects/vite-project", "/projects/vite-project/public")).toBe(true); 427 | }); 428 | it("same folder is not a subdirectory", () => { 429 | expect(isSubdirectory("/projects/vite-project", "/projects/vite-project")).toBe(false); 430 | }); 431 | it("sibling folder is not a subdirectory", () => { 432 | expect(isSubdirectory("/projects/vite-project", "/projects/symfony-project")).toBe(false); 433 | }); 434 | it("sibling folder starting with same name is not a subdirectory", () => { 435 | expect(isSubdirectory("/projects/vite-project", "/projects/vite-project-2")).toBe(false); 436 | }); 437 | it("traversing up the tree and into a sibling folder is not a subdirectory", () => { 438 | expect(isSubdirectory("/projects/vite-project", "/projects/vite-project/../react-project")).toBe(false); 439 | }); 440 | it("unnormalized path in project folder: is a subdirectory", () => { 441 | expect(isSubdirectory("/projects/vite-project", "/projects/vite-project/./public")).toBe(true); 442 | }); 443 | it("unnormalized path with path traversal into subdirectory is a subdirectory", () => { 444 | expect(isSubdirectory("/vite-project/../projects", "/projects/vite-project")).toBe(true); 445 | }); 446 | it("unnormalized path relative to current directory: is not a subdirectory", () => { 447 | expect(isSubdirectory("/projects/vite-project", "./vite-project")).toBe(false); 448 | }); 449 | }); 450 | 451 | describe("resolveUserExternal()", () => { 452 | test.each([ 453 | { 454 | external: ["leaflet"], 455 | id: "leaflet", 456 | expectedValue: true, 457 | }, 458 | { 459 | external: ["other", "leaflet"], 460 | id: "leaflet", 461 | expectedValue: true, 462 | }, 463 | { 464 | external: [/node_modules/, "leaflet"], 465 | id: "leaflet", 466 | expectedValue: true, 467 | }, 468 | { 469 | external: ["other"], 470 | id: "leaflet", 471 | expectedValue: false, 472 | }, 473 | { 474 | external: [], 475 | id: "leaflet", 476 | expectedValue: false, 477 | }, 478 | ])("detect string as external dependency", ({ external, id, expectedValue }) => { 479 | expect(resolveUserExternal(external, id, "root", false)).toBe(expectedValue); 480 | }); 481 | 482 | test.each([ 483 | { 484 | external: [/leaflet/], 485 | id: "leaflet-draw", 486 | expectedValue: true, 487 | }, 488 | ])("detect regex as external dependency", ({ external, id, expectedValue }) => { 489 | expect(resolveUserExternal(external, id, "root", false)).toBe(expectedValue); 490 | }); 491 | 492 | test.each([ 493 | { 494 | external: (id: string) => { 495 | return id === "leaflet" ? true : false; 496 | }, 497 | id: "leaflet", 498 | expectedValue: true, 499 | }, 500 | ])("detect callback as external dependency", ({ external, id, expectedValue }) => { 501 | expect(resolveUserExternal(external, id, "root", false)).toBe(expectedValue); 502 | }); 503 | }); 504 | 505 | describe("normalizeConfig", () => { 506 | test.each([ 507 | { 508 | resolvedConfig: { 509 | plugins: [{ name: "vite:infos" }, { name: "other" }], 510 | }, 511 | expectedValue: { plugins: ["vite:infos", "other"] }, 512 | }, 513 | { 514 | resolvedConfig: { 515 | func1() {}, 516 | foo: { 517 | bar: "baz", 518 | func2() {}, 519 | }, 520 | }, 521 | expectedValue: { foo: { bar: "baz" } }, 522 | }, 523 | ])("pluginsConfigSimplification", ({ resolvedConfig, expectedValue }) => { 524 | const normalizedConfig = normalizeConfig(resolvedConfig as any as ResolvedConfig); 525 | 526 | expect(JSON.parse(normalizedConfig)).toMatchObject(expectedValue); 527 | }); 528 | }); 529 | 530 | describe("trimSlashes", () => { 531 | test("should remove slashes at the beginning and end of the string", () => { 532 | expect(trimSlashes("/example/path/")).toBe("example/path"); 533 | expect(trimSlashes("/example/path")).toBe("example/path"); 534 | expect(trimSlashes("example/path/")).toBe("example/path"); 535 | expect(trimSlashes("example/path")).toBe("example/path"); 536 | }); 537 | 538 | test("should handle strings with only slashes", () => { 539 | expect(trimSlashes("/")).toBe(""); 540 | expect(trimSlashes("//")).toBe(""); 541 | expect(trimSlashes("///")).toBe(""); 542 | }); 543 | 544 | test("should return the same string if there are no slashes at the beginning or end", () => { 545 | expect(trimSlashes("example/path")).toBe("example/path"); 546 | }); 547 | 548 | test("should handle empty strings", () => { 549 | expect(trimSlashes("")).toBe(""); 550 | }); 551 | }); 552 | -------------------------------------------------------------------------------- /src/entrypoints/utils.ts: -------------------------------------------------------------------------------- 1 | import { loadEnv, type ResolvedConfig } from "vite"; 2 | import os from "node:os"; 3 | import path from "node:path"; 4 | import type { AddressInfo } from "net"; 5 | import { writeFileSync, rmSync, readdirSync } from "fs"; 6 | import { join } from "path"; 7 | import type { RenderedChunk, OutputChunk, OutputAsset, NormalizedOutputOptions, ExternalOption } from "rollup"; 8 | import { resolve, extname, relative } from "path"; 9 | import { DevServerUrl, FileInfos, ParsedInputs, HashAlgorithm, VitePluginSymfonyEntrypointsOptions } from "../types"; 10 | import { BinaryLike, createHash } from "node:crypto"; 11 | import { getInputPath } from "./pathMapping"; 12 | 13 | export const isWindows = os.platform() === "win32"; 14 | 15 | export function parseVersionString(str: string): [string, number, number, number] { 16 | const [major, minor, patch] = str.split(".").map((nb) => parseInt(nb)); 17 | return [str, major ?? 0, minor ?? 0, patch ?? 0]; 18 | } 19 | 20 | export function slash(p: string): string { 21 | return p.replace(/\\/g, "/"); 22 | } 23 | 24 | export function trimSlashes(str: string): string { 25 | return str.replace(/^\/+|\/+$/g, ""); 26 | } 27 | 28 | export function isSubdirectory(parent: string, child: string) { 29 | parent = path.normalize(parent); 30 | child = path.normalize(child); 31 | 32 | if (parent == child) { 33 | return false; 34 | } 35 | 36 | const parentDirs = parent.split(path.sep).filter((dir) => dir !== ""); 37 | const childDirs = child.split(path.sep).filter((dir) => dir !== ""); 38 | return parentDirs.every((dir, i) => childDirs[i] === dir); 39 | } 40 | 41 | export function normalizePath(id: string): string { 42 | return path.posix.normalize(isWindows ? slash(id) : id); 43 | } 44 | 45 | export function getLegacyName(name: string) { 46 | const ext = extname(name); 47 | const endPos = ext.length !== 0 ? -ext.length : undefined; 48 | name = name.slice(0, endPos) + "-legacy" + ext; 49 | return name; 50 | } 51 | 52 | export function isIpv6(address: AddressInfo): boolean { 53 | return ( 54 | address.family === "IPv6" || 55 | // In node >=18.0 <18.4 this was an integer value. This was changed in a minor version. 56 | // See: https://github.com/laravel/vite-plugin/issues/103 57 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 58 | // @ts-ignore-next-line 59 | address.family === 6 60 | ); 61 | } 62 | 63 | export const writeJson = (filePath: string, jsonData: any) => { 64 | try { 65 | writeFileSync(filePath, JSON.stringify(jsonData, null, 2)); 66 | } catch (err: any) { 67 | throw new Error(`Error writing ${path.basename(filePath)}: ${err.message}`); 68 | } 69 | }; 70 | 71 | export const emptyDir = (dir: string) => { 72 | const files = readdirSync(dir); 73 | for (const file of files) { 74 | rmSync(join(dir, file), { recursive: true }); 75 | } 76 | }; 77 | 78 | export const INFO_PUBLIC_PATH = "/@vite/info"; 79 | 80 | /* not imported from vite because we don't want vite in package.json dependencies */ 81 | const FS_PREFIX = `/@fs/`; 82 | const VALID_ID_PREFIX = `/@id/`; 83 | const CLIENT_PUBLIC_PATH = `/@vite/client`; 84 | const ENV_PUBLIC_PATH = `/@vite/env`; 85 | 86 | const importQueryRE = /(\?|&)import=?(?:&|$)/; 87 | export const isImportRequest = (url: string): boolean => importQueryRE.test(url); 88 | 89 | const internalPrefixes = [FS_PREFIX, VALID_ID_PREFIX, CLIENT_PUBLIC_PATH, ENV_PUBLIC_PATH]; 90 | const InternalPrefixRE = new RegExp(`^(?:${internalPrefixes.join("|")})`); 91 | export const isInternalRequest = (url: string): boolean => InternalPrefixRE.test(url); 92 | 93 | const CSS_LANGS_RE = /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/; 94 | const cssModuleRE = new RegExp(`\\.module${CSS_LANGS_RE.source}`); 95 | const commonjsProxyRE = /\?commonjs-proxy/; 96 | const isCSSRequest = (request: string) => CSS_LANGS_RE.test(request); 97 | 98 | const polyfillId = "\0vite/legacy-polyfills"; 99 | 100 | export function resolveDevServerUrl( 101 | address: AddressInfo, 102 | config: ResolvedConfig, 103 | pluginOptions: VitePluginSymfonyEntrypointsOptions, 104 | ): DevServerUrl { 105 | if (pluginOptions.originOverride) { 106 | return pluginOptions.originOverride as DevServerUrl; 107 | } 108 | 109 | if (config.server?.origin) { 110 | return config.server.origin as DevServerUrl; 111 | } 112 | 113 | const configHmrProtocol = typeof config.server.hmr === "object" ? config.server.hmr.protocol : null; 114 | const clientProtocol = configHmrProtocol ? (configHmrProtocol === "wss" ? "https" : "http") : null; 115 | const serverProtocol = config.server.https ? "https" : "http"; 116 | const protocol = clientProtocol ?? serverProtocol; 117 | 118 | const configHmrHost = typeof config.server.hmr === "object" ? config.server.hmr.host : null; 119 | const configHost = typeof config.server.host === "string" ? config.server.host : null; 120 | const serverAddress = isIpv6(address) ? `[${address.address}]` : address.address; 121 | const host = configHmrHost ?? pluginOptions.viteDevServerHostname ?? configHost ?? serverAddress; 122 | 123 | const configHmrClientPort = typeof config.server.hmr === "object" ? config.server.hmr.clientPort : null; 124 | const port = configHmrClientPort ?? address.port; 125 | 126 | return `${protocol}://${host}:${port}`; 127 | } 128 | 129 | export const isAddressInfo = (x: string | AddressInfo | null | undefined): x is AddressInfo => typeof x === "object"; 130 | 131 | export const isCssEntryPoint = (chunk: RenderedChunk) => { 132 | if (!chunk.isEntry) { 133 | return false; 134 | } 135 | let isPureCssChunk = true; 136 | const ids = Object.keys(chunk.modules); 137 | for (const id of ids) { 138 | if (!isCSSRequest(id) || cssModuleRE.test(id) || commonjsProxyRE.test(id)) { 139 | isPureCssChunk = false; 140 | } 141 | } 142 | 143 | if (isPureCssChunk) { 144 | return chunk?.viteMetadata?.importedCss.size === 1; 145 | } 146 | 147 | return false; 148 | }; 149 | 150 | export const getFileInfos = ( 151 | chunk: OutputChunk | OutputAsset, 152 | inputRelPath: string, 153 | pluginOptions: VitePluginSymfonyEntrypointsOptions, 154 | ): FileInfos => { 155 | const alg = pluginOptions.sriAlgorithm; 156 | if (chunk.type === "asset") { 157 | if (chunk.fileName.endsWith(".css")) { 158 | return { 159 | css: [chunk.fileName], 160 | hash: alg === false ? null : generateHash(chunk.source, alg), 161 | inputRelPath, 162 | outputRelPath: chunk.fileName, 163 | type: "css", 164 | }; 165 | } else { 166 | return { 167 | hash: alg === false ? null : generateHash(chunk.source, alg), 168 | inputRelPath, 169 | outputRelPath: chunk.fileName, 170 | type: "asset", 171 | }; 172 | } 173 | } else if (chunk.type === "chunk") { 174 | const { imports, dynamicImports, viteMetadata, fileName } = chunk; 175 | 176 | return { 177 | assets: Array.from(viteMetadata?.importedAssets ?? []), 178 | css: Array.from(viteMetadata?.importedCss ?? []), 179 | hash: alg === false ? null : generateHash(chunk.code, alg), 180 | imports: imports, 181 | inputRelPath, 182 | js: [fileName], 183 | outputRelPath: fileName, 184 | preload: [], 185 | dynamic: dynamicImports, 186 | type: "js", 187 | }; 188 | } 189 | 190 | throw new Error(`Unknown chunktype ${(chunk as OutputChunk).type} for ${(chunk as OutputChunk).fileName}`); 191 | }; 192 | 193 | function generateHash(source: BinaryLike, alg: HashAlgorithm) { 194 | if (alg === false) { 195 | return null; 196 | } 197 | const hash = createHash(alg).update(source).digest().toString("base64"); 198 | return `${alg}-${hash}`; 199 | } 200 | 201 | /** 202 | * @description based on vite resolved config get an array of entrypoints and their type "css" | "js" 203 | */ 204 | export const prepareRollupInputs = (config: ResolvedConfig): ParsedInputs => { 205 | const inputParsed: ParsedInputs = {}; 206 | 207 | for (const [entryName, inputRelPath] of Object.entries(config.build.rollupOptions.input ?? {})) { 208 | const entryAbsolutePath = normalizePath(resolve(config.root, inputRelPath)); 209 | 210 | const extension = extname(inputRelPath); 211 | 212 | const inputType = 213 | [".css", ".scss", ".sass", ".less", ".styl", ".stylus", ".postcss"].indexOf(extension) !== -1 ? "css" : "js"; 214 | 215 | const entryRelativePath = normalizePath(relative(config.root, entryAbsolutePath)); 216 | 217 | inputParsed[entryName] = { 218 | inputType, 219 | inputRelPath: entryRelativePath, 220 | }; 221 | } 222 | 223 | return inputParsed; 224 | }; 225 | 226 | /** 227 | * @description used when generateBundle. 228 | * if chunk doesn't have a facadeModuleId his inputRelPath can be retrieve with inputRelPath2outputRelPath 229 | */ 230 | export const getInputRelPath = ( 231 | chunk: OutputAsset | OutputChunk, 232 | options: NormalizedOutputOptions, 233 | config: ResolvedConfig, 234 | ): string => { 235 | if (chunk.type === "asset" || !chunk.facadeModuleId) { 236 | const inputRelPath = getInputPath(chunk.fileName); 237 | if (inputRelPath) { 238 | return inputRelPath; 239 | } 240 | 241 | return `_${chunk.fileName}`; 242 | } 243 | 244 | if ([polyfillId].indexOf(chunk.facadeModuleId) !== -1) { 245 | return chunk.facadeModuleId.replace(/\0/g, ""); 246 | } 247 | 248 | let inputRelPath = normalizePath(path.relative(config.root, chunk.facadeModuleId)); 249 | 250 | /* when we generate legacy files, format === 'system'. after format is other value like 'es' */ 251 | if (options.format === "system" && !chunk.name.includes("-legacy")) { 252 | inputRelPath = getLegacyName(inputRelPath); 253 | } 254 | return inputRelPath.replace(/\0/g, ""); 255 | }; 256 | 257 | /** 258 | * vite/src/node/build.ts 259 | */ 260 | export function resolveUserExternal( 261 | user: ExternalOption, 262 | id: string, 263 | parentId: string | null, 264 | isResolved: boolean, 265 | ): boolean | null | void { 266 | if (typeof user === "function") { 267 | return user(id, parentId ?? undefined, isResolved); 268 | } else if (Array.isArray(user)) { 269 | return user.some((test) => isExternal(id, test)); 270 | } else { 271 | return isExternal(id, user); 272 | } 273 | } 274 | 275 | function isExternal(id: string, test: string | RegExp) { 276 | if (typeof test === "string") { 277 | return id === test; 278 | } else { 279 | return test.test(id); 280 | } 281 | } 282 | 283 | export function extractExtraEnvVars( 284 | mode: string, 285 | envDir: string, 286 | exposedEnvVars: string[], 287 | define?: Record, 288 | ) { 289 | const allVars = loadEnv(mode, envDir, ""); 290 | const availableKeys = Object.keys(allVars).filter((key) => exposedEnvVars.indexOf(key) !== -1); 291 | const extraDefine = Object.fromEntries( 292 | availableKeys.map((key) => [`import.meta.env.${key}`, JSON.stringify(allVars[key])]), 293 | ); 294 | 295 | return { 296 | ...extraDefine, 297 | ...(define ?? {}), 298 | }; 299 | } 300 | 301 | export function normalizeConfig(config: ResolvedConfig) { 302 | const result = JSON.stringify(config, function (k, v) { 303 | if (k === "plugins" && Array.isArray(v)) { 304 | return v.filter((v) => v.name).map((v) => v.name); 305 | } 306 | if (typeof v === "function") { 307 | return undefined; 308 | } 309 | return v; 310 | }); 311 | 312 | return result; 313 | } 314 | -------------------------------------------------------------------------------- /src/entrypoints/utils.vite-mocked.test.ts: -------------------------------------------------------------------------------- 1 | import { vi, describe, test, expect } from "vitest"; 2 | import { extractExtraEnvVars } from "./utils"; 3 | 4 | vi.mock("vite", async (original) => { 5 | return { 6 | ...(await original()), 7 | loadEnv: vi.fn(() => ({ 8 | APP_ENV: "prod", 9 | APP_SECRET: "I don't want to be exposed !!", 10 | })), 11 | }; 12 | }); 13 | 14 | describe("extractExtraEnvVars()", () => { 15 | test("extract Only Exposed Env vars and explicitly defined", () => { 16 | const result = extractExtraEnvVars("development", "/my-project", ["APP_ENV"], { __FOO__: '"bar"' }); 17 | expect(result).toMatchInlineSnapshot(` 18 | { 19 | "__FOO__": ""bar"", 20 | "import.meta.env.APP_ENV": ""prod"", 21 | } 22 | `); 23 | }); 24 | 25 | test("explicitly defined env vars takes precedence", () => { 26 | const result = extractExtraEnvVars("development", "/my-project", ["APP_ENV"], { 27 | "import.meta.env.APP_ENV": '"PRIORITY"', 28 | }); 29 | expect(result).toMatchInlineSnapshot(` 30 | { 31 | "import.meta.env.APP_ENV": ""PRIORITY"", 32 | } 33 | `); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/entrypoints/utils.win32.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, vi } from "vitest"; 2 | import { isSubdirectory, normalizePath } from "./utils"; 3 | 4 | vi.mock("node:path", async () => { 5 | const win32Path = await vi.importActual("node:path/win32"); 6 | return win32Path; 7 | }); 8 | 9 | vi.mock("node:process", async () => { 10 | const originalProcess = await vi.importActual("node:process"); 11 | return { 12 | ...originalProcess, 13 | platform: "win32", 14 | }; 15 | }); 16 | 17 | vi.mock("node:os", async () => { 18 | const originalOs = await vi.importActual("node:os"); 19 | return { 20 | ...originalOs, 21 | default: { 22 | ...originalOs.default, 23 | platform: () => "win32", 24 | }, 25 | }; 26 | }); 27 | 28 | describe("Windows: normalizePath", () => { 29 | it("change the path on windows", ({ expect }) => { 30 | expect(normalizePath("path\\to\\asset.svg")).toBe("path/to/asset.svg"); 31 | }); 32 | }); 33 | 34 | describe("Windows: isAncestorDir", () => { 35 | it("Windows: subdirectory is a subdirectory", ({ expect }) => { 36 | expect(isSubdirectory("C:\\projects", "C:\\projects\\vite-project")).toBe(true); 37 | }); 38 | it("Windows: different directory is not a subdirectory", ({ expect }) => { 39 | expect(isSubdirectory("C:\\projects", "C:\\Users")).toBe(false); 40 | }); 41 | it("Windows: subdirectory on another drive is not a subdirectory", ({ expect }) => { 42 | expect(isSubdirectory("C:\\projects", "D:\\projects\\svelte-project")).toBe(false); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "vite"; 2 | import symfonyEntrypoints from "./entrypoints"; 3 | import symfonyStimulus from "./stimulus/node"; 4 | 5 | import { VitePluginSymfonyPartialOptions } from "./types"; 6 | import { createLogger } from "./logger"; 7 | import { resolvePluginEntrypointsOptions } from "./entrypoints/pluginOptions"; 8 | import { resolvePluginStimulusOptions } from "./stimulus/pluginOptions"; 9 | 10 | export default function symfony(userPluginOptions: VitePluginSymfonyPartialOptions = {}): Plugin[] { 11 | const { stimulus: userStimulusOptions, ...userEntrypointsOptions } = userPluginOptions; 12 | 13 | const entrypointsOptions = resolvePluginEntrypointsOptions(userEntrypointsOptions); 14 | const stimulusOptions = resolvePluginStimulusOptions(userStimulusOptions); 15 | 16 | const plugins: Plugin[] = [ 17 | symfonyEntrypoints( 18 | entrypointsOptions, 19 | createLogger("info", { prefix: "[symfony:entrypoints]", allowClearScreen: true }), 20 | ), 21 | ]; 22 | 23 | if (typeof stimulusOptions === "object") { 24 | plugins.push( 25 | symfonyStimulus(stimulusOptions, createLogger("info", { prefix: "[symfony:stimulus]", allowClearScreen: true })), 26 | ); 27 | } 28 | 29 | return plugins; 30 | } 31 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | 3 | import readline from "node:readline"; 4 | import colors from "picocolors"; 5 | import type { RollupError } from "rollup"; 6 | 7 | export interface ResolvedServerUrls { 8 | local: string[]; 9 | network: string[]; 10 | } 11 | 12 | export type LogType = "error" | "warn" | "info"; 13 | export type LogLevel = LogType | "silent"; 14 | export interface Logger { 15 | info(msg: string, options?: LogOptions): void; 16 | warn(msg: string, options?: LogOptions): void; 17 | warnOnce(msg: string, options?: LogOptions): void; 18 | error(msg: string, options?: LogErrorOptions): void; 19 | clearScreen(type: LogType): void; 20 | hasErrorLogged(error: Error | RollupError): boolean; 21 | hasWarned: boolean; 22 | } 23 | 24 | export interface LogOptions { 25 | clear?: boolean; 26 | timestamp?: boolean; 27 | } 28 | 29 | export interface LogErrorOptions extends LogOptions { 30 | error?: Error | RollupError | null; 31 | } 32 | 33 | export const LogLevels: Record = { 34 | silent: 0, 35 | error: 1, 36 | warn: 2, 37 | info: 3, 38 | }; 39 | 40 | let lastType: LogType | undefined; 41 | let lastMsg: string | undefined; 42 | let sameCount = 0; 43 | 44 | function clearScreen() { 45 | const repeatCount = process.stdout.rows - 2; 46 | const blank = repeatCount > 0 ? "\n".repeat(repeatCount) : ""; 47 | console.log(blank); 48 | readline.cursorTo(process.stdout, 0, 0); 49 | readline.clearScreenDown(process.stdout); 50 | } 51 | 52 | export interface LoggerOptions { 53 | prefix?: string; 54 | allowClearScreen?: boolean; 55 | customLogger?: Logger; 56 | } 57 | 58 | export function createLogger(level: LogLevel = "info", options: LoggerOptions = {}): Logger { 59 | if (options.customLogger) { 60 | return options.customLogger; 61 | } 62 | 63 | const timeFormatter = new Intl.DateTimeFormat(undefined, { 64 | hour: "numeric", 65 | minute: "numeric", 66 | second: "numeric", 67 | }); 68 | const loggedErrors = new WeakSet(); 69 | const { prefix = "[vite]", allowClearScreen = true } = options; 70 | const thresh = LogLevels[level]; 71 | const canClearScreen = allowClearScreen && process.stdout.isTTY && !process.env.CI; 72 | const clear = canClearScreen ? clearScreen : () => {}; 73 | 74 | function output(type: LogType, msg: string, options: LogErrorOptions = {}) { 75 | if (thresh >= LogLevels[type]) { 76 | const method = type === "info" ? "log" : type; 77 | const format = () => { 78 | const tag = 79 | type === "info" 80 | ? colors.cyan(colors.bold(prefix)) 81 | : type === "warn" 82 | ? colors.yellow(colors.bold(prefix)) 83 | : colors.red(colors.bold(prefix)); 84 | if (options.timestamp) { 85 | return `${colors.dim(timeFormatter.format(new Date()))} ${tag} ${msg}`; 86 | } else { 87 | return `${tag} ${msg}`; 88 | } 89 | }; 90 | if (options.error) { 91 | loggedErrors.add(options.error); 92 | } 93 | if (canClearScreen) { 94 | if (type === lastType && msg === lastMsg) { 95 | sameCount++; 96 | clear(); 97 | console[method](format(), colors.yellow(`(x${sameCount + 1})`)); 98 | } else { 99 | sameCount = 0; 100 | lastMsg = msg; 101 | lastType = type; 102 | if (options.clear) { 103 | clear(); 104 | } 105 | console[method](format()); 106 | } 107 | } else { 108 | console[method](format()); 109 | } 110 | } 111 | } 112 | 113 | const warnedMessages = new Set(); 114 | 115 | const logger: Logger = { 116 | hasWarned: false, 117 | info(msg, opts) { 118 | output("info", msg, opts); 119 | }, 120 | warn(msg, opts) { 121 | logger.hasWarned = true; 122 | output("warn", msg, opts); 123 | }, 124 | warnOnce(msg, opts) { 125 | if (warnedMessages.has(msg)) return; 126 | logger.hasWarned = true; 127 | output("warn", msg, opts); 128 | warnedMessages.add(msg); 129 | }, 130 | error(msg, opts) { 131 | logger.hasWarned = true; 132 | output("error", msg, opts); 133 | }, 134 | clearScreen(type) { 135 | if (thresh >= LogLevels[type]) { 136 | clear(); 137 | } 138 | }, 139 | hasErrorLogged(error) { 140 | return loggedErrors.has(error); 141 | }, 142 | }; 143 | 144 | return logger; 145 | } 146 | 147 | export function printServerUrls( 148 | urls: ResolvedServerUrls, 149 | optionsHost: string | boolean | undefined, 150 | info: Logger["info"], 151 | ): void { 152 | const colorUrl = (url: string) => colors.cyan(url.replace(/:(\d+)\//, (_, port) => `:${colors.bold(port)}/`)); 153 | for (const url of urls.local) { 154 | info(` ${colors.green("➜")} ${colors.bold("Local")}: ${colorUrl(url)}`); 155 | } 156 | for (const url of urls.network) { 157 | info(` ${colors.green("➜")} ${colors.bold("Network")}: ${colorUrl(url)}`); 158 | } 159 | if (urls.network.length === 0 && optionsHost === undefined) { 160 | info( 161 | colors.dim(` ${colors.green("➜")} ${colors.bold("Network")}: use `) + 162 | colors.bold("--host") + 163 | colors.dim(" to expose"), 164 | ); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/stimulus/env.d.ts: -------------------------------------------------------------------------------- 1 | type LazyLoadedStimulusControllerModule = () => Promise<{ 2 | default: import("@hotwired/stimulus").ControllerConstructor; 3 | }>; 4 | 5 | type StimulusControllerInfos = 6 | | { 7 | identifier: string; 8 | enabled: boolean; 9 | fetch: "lazy"; 10 | controller: LazyLoadedStimulusControllerModule; 11 | } 12 | | { 13 | identifier: string; 14 | enabled: boolean; 15 | fetch: "eager"; 16 | controller: import("@hotwired/stimulus").ControllerConstructor; 17 | }; 18 | type StimulusControllerInfosImport = { 19 | default: StimulusControllerInfos; 20 | [Symbol.toStringTag]: "Module"; 21 | }; 22 | declare module "virtual:symfony/controllers" { 23 | const defaultExport: StimulusControllerInfos[]; 24 | export default defaultExport; 25 | } 26 | 27 | interface ImportMeta { 28 | stimulusFetch: "lazy" | "eager"; 29 | stimulusIdentifier: string; 30 | stimulusEnabled: boolean; 31 | } 32 | 33 | declare module "*?stimulus" { 34 | const defaultExport: StimulusControllerInfos; 35 | export default defaultExport; 36 | } 37 | -------------------------------------------------------------------------------- /src/stimulus/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { Application, Context, Controller, ControllerConstructor } from "@hotwired/stimulus"; 2 | import thirdPartyControllers from "virtual:symfony/controllers"; 3 | import { getStimulusControllerId } from "~/stimulus/util"; 4 | 5 | declare module "@hotwired/stimulus" { 6 | interface Controller { 7 | __stimulusLazyController: boolean; 8 | } 9 | } 10 | 11 | export function createLazyController(dynamicImportFactory: LazyLoadedStimulusControllerModule, exportName = "default") { 12 | return class extends Controller { 13 | constructor(context: Context) { 14 | context.logDebugActivity = function (functionName) { 15 | this.application.logDebugActivity(this.identifier + "-lazywrapper", functionName); 16 | }; 17 | super(context); 18 | this.__stimulusLazyController = true; 19 | } 20 | initialize() { 21 | if ( 22 | this.application.controllers.find((controller) => { 23 | return controller.identifier === this.identifier && controller.__stimulusLazyController; 24 | }) 25 | ) { 26 | return; 27 | } 28 | dynamicImportFactory().then((controllerModule) => { 29 | this.application.register(this.identifier, controllerModule[exportName as "default"]); 30 | }); 31 | } 32 | }; 33 | } 34 | 35 | export function startStimulusApp() { 36 | const app = Application.start(); 37 | 38 | app.debug = process.env.NODE_ENV === "development"; 39 | 40 | for (const controllerInfos of thirdPartyControllers) { 41 | if (controllerInfos.fetch === "lazy") { 42 | app.register(controllerInfos.identifier, createLazyController(controllerInfos.controller)); 43 | } else { 44 | app.register(controllerInfos.identifier, controllerInfos.controller); 45 | } 46 | } 47 | 48 | if (app.debug) { 49 | console.groupCollapsed("application #startStimulusApp and register controllers from controllers.json"); 50 | console.log( 51 | "controllers", 52 | thirdPartyControllers.map((infos) => infos.identifier), 53 | ); 54 | console.groupEnd(); 55 | } 56 | 57 | return app; 58 | } 59 | 60 | type Module = StimulusControllerInfosImport | LazyLoadedStimulusControllerModule | ControllerConstructor; 61 | type Modules = Record; 62 | 63 | function isLazyLoadedControllerModule( 64 | unknownController: Module, 65 | ): unknownController is LazyLoadedStimulusControllerModule { 66 | if (typeof unknownController === "function") { 67 | return true; 68 | } 69 | return false; 70 | } 71 | 72 | function isStimulusControllerConstructor(unknownController: Module): unknownController is ControllerConstructor { 73 | if ((unknownController as ControllerConstructor).prototype instanceof Controller) { 74 | return true; 75 | } 76 | return false; 77 | } 78 | 79 | function isStimulusControllerInfosImport( 80 | unknownController: Module, 81 | ): unknownController is StimulusControllerInfosImport { 82 | if ( 83 | typeof unknownController === "object" && 84 | unknownController[Symbol.toStringTag] === "Module" && 85 | unknownController.default 86 | ) { 87 | return true; 88 | } 89 | 90 | return false; 91 | } 92 | 93 | export function registerControllers(app: Application, modules: Modules) { 94 | const controllersAdded: string[] = []; 95 | 96 | if (app.debug) { 97 | console.groupCollapsed("application #registerControllers"); 98 | } 99 | 100 | Object.entries(modules).forEach(([filePath, unknownController]) => { 101 | const identifier = getStimulusControllerId(filePath, "snakeCase"); 102 | if (!identifier) { 103 | throw new Error(`Invalid filePath ${filePath}`); 104 | } 105 | if (isLazyLoadedControllerModule(unknownController)) { 106 | app.register(identifier, createLazyController(unknownController)); 107 | controllersAdded.push(identifier); 108 | } else if (isStimulusControllerConstructor(unknownController)) { 109 | app.register(identifier, unknownController); 110 | controllersAdded.push(identifier); 111 | } else if (isStimulusControllerInfosImport(unknownController)) { 112 | registerController(app, unknownController.default); 113 | controllersAdded.push(unknownController.default.identifier); 114 | } else { 115 | throw new Error( 116 | `unknown Stimulus controller for ${identifier}. if you use import.meta.glob, don't forget to enable the eager option to true`, 117 | ); 118 | } 119 | }); 120 | 121 | if (app.debug) { 122 | console.groupEnd(); 123 | } 124 | } 125 | 126 | export function registerController(app: Application, controllerInfos: StimulusControllerInfos) { 127 | if (!controllerInfos.enabled) { 128 | return; 129 | } 130 | if (controllerInfos.fetch === "lazy") { 131 | app.register(controllerInfos.identifier, createLazyController(controllerInfos.controller)); 132 | } else { 133 | app.register(controllerInfos.identifier, controllerInfos.controller); 134 | } 135 | 136 | if (app.debug) { 137 | console.log(`application #registerController ${controllerInfos.identifier}`); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/stimulus/helpers/react/index.ts: -------------------------------------------------------------------------------- 1 | export { ReactModule } from "./types"; 2 | export * from "./util"; 3 | export { default as ReactController } from "./render_controller"; 4 | -------------------------------------------------------------------------------- /src/stimulus/helpers/react/render_controller.ts: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { Controller } from "@hotwired/stimulus"; 4 | import { ReactModule } from "./index"; 5 | 6 | export default class extends Controller { 7 | declare readonly componentValue?: string; 8 | declare readonly propsValue?: object; 9 | 10 | static values = { 11 | component: String, 12 | props: Object, 13 | }; 14 | 15 | connect() { 16 | const props = this.propsValue ? this.propsValue : null; 17 | 18 | this.dispatchEvent("connect", { component: this.componentValue, props: props }); 19 | 20 | if (!this.componentValue) { 21 | throw new Error("No component specified."); 22 | } 23 | 24 | const importedReactModule = window.resolveReactComponent(this.componentValue); 25 | 26 | const onload = (reactModule: ReactModule) => { 27 | const component = reactModule.default; 28 | this._renderReactElement(React.createElement(component, props, null)); 29 | 30 | this.dispatchEvent("mount", { 31 | componentName: this.componentValue, 32 | component: component, 33 | props: props, 34 | }); 35 | }; 36 | 37 | if (typeof importedReactModule === "function") { 38 | importedReactModule().then(onload); 39 | } else { 40 | onload(importedReactModule); 41 | } 42 | } 43 | 44 | disconnect() { 45 | (this.element as any).root.unmount(); 46 | this.dispatchEvent("unmount", { 47 | component: this.componentValue, 48 | props: this.propsValue ? this.propsValue : null, 49 | }); 50 | } 51 | 52 | _renderReactElement(reactElement: ReactElement) { 53 | const element: any = this.element as any; 54 | 55 | // If a root has already been created for this element, reuse it 56 | if (!element.root) { 57 | element.root = createRoot(this.element); 58 | } 59 | element.root.render(reactElement); 60 | } 61 | 62 | private dispatchEvent(name: string, payload: any) { 63 | this.dispatch(name, { detail: payload, prefix: "react" }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/stimulus/helpers/react/types.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentClass, FunctionComponent } from "react"; 2 | export type ReactComponent = string | FunctionComponent | ComponentClass; 3 | export type ReactModule = { 4 | default: ReactComponent; 5 | }; 6 | -------------------------------------------------------------------------------- /src/stimulus/helpers/react/util.ts: -------------------------------------------------------------------------------- 1 | import { ImportedModule, ImportedModules } from "../types"; 2 | import { ReactModule } from "./types"; 3 | 4 | let reactImportedModules: ImportedModules = {}; 5 | 6 | export function registerReactControllerComponents( 7 | modules: ImportedModules, 8 | controllersDir = "./react/controllers", 9 | ) { 10 | reactImportedModules = { ...reactImportedModules, ...modules }; 11 | 12 | window.resolveReactComponent = (name: string): ImportedModule => { 13 | const reactModule = 14 | reactImportedModules[`${controllersDir}/${name}.jsx`] || reactImportedModules[`${controllersDir}/${name}.tsx`]; 15 | if (typeof reactModule === "undefined") { 16 | const possibleValues = Object.keys(reactImportedModules).map((key) => 17 | key.replace(`${controllersDir}/`, "").replace(".jsx", "").replace(".tsx", ""), 18 | ); 19 | throw new Error(`React controller "${name}" does not exist. Possible values: ${possibleValues.join(", ")}`); 20 | } 21 | 22 | return reactModule; 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/stimulus/helpers/svelte/index.ts: -------------------------------------------------------------------------------- 1 | export { SvelteModule } from "./types"; 2 | export * from "./util"; 3 | export { default as SvelteController } from "./render_controller"; 4 | -------------------------------------------------------------------------------- /src/stimulus/helpers/svelte/render_controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | import { SvelteComponent,mount,unmount } from "svelte"; 3 | import { SvelteModule } from "./types"; 4 | 5 | 6 | export default class extends Controller { 7 | private app: SvelteComponent | undefined; 8 | declare readonly componentValue: string; 9 | 10 | private props: Record | undefined; 11 | private intro: boolean | undefined; 12 | 13 | declare readonly propsValue: Record | null | undefined; 14 | declare readonly introValue: boolean | undefined; 15 | 16 | static values = { 17 | component: String, 18 | props: Object, 19 | intro: Boolean, 20 | }; 21 | 22 | connect() { 23 | this.element.innerHTML = ""; 24 | 25 | this.props = this.propsValue ?? undefined; 26 | this.intro = this.introValue ?? undefined; 27 | 28 | this.dispatchEvent("connect"); 29 | 30 | const importedSvelteModule = window.resolveSvelteComponent(this.componentValue); 31 | 32 | const onload = (svelteModule: SvelteModule) => { 33 | const Component = svelteModule.default; 34 | 35 | this._destroyIfExists(); 36 | 37 | // @ts-expect-error @see https://svelte.dev/docs#run-time-client-side-component-api-creating-a-component 38 | this.app = mount(Component,{ 39 | target: this.element, 40 | props: this.props, 41 | intro: this.intro, 42 | }); 43 | 44 | this.element.root = this.app; 45 | 46 | this.dispatchEvent("mount", { 47 | component: Component, 48 | }); 49 | }; 50 | 51 | if (typeof importedSvelteModule === "function") { 52 | importedSvelteModule().then(onload); 53 | } else { 54 | onload(importedSvelteModule); 55 | } 56 | } 57 | 58 | disconnect() { 59 | this._destroyIfExists(); 60 | this.dispatchEvent("unmount"); 61 | } 62 | 63 | _destroyIfExists() { 64 | if (this.element.root !== undefined) { 65 | unmount(this.element.root); 66 | delete this.element.root; 67 | } 68 | } 69 | 70 | private dispatchEvent(name: string, payload: object = {}) { 71 | const detail = { 72 | componentName: this.componentValue, 73 | props: this.props, 74 | intro: this.intro, 75 | ...payload, 76 | }; 77 | this.dispatch(name, { detail, prefix: "svelte" }); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/stimulus/helpers/svelte/types.ts: -------------------------------------------------------------------------------- 1 | import type { SvelteComponent } from "svelte"; 2 | 3 | export type SvelteModule = { 4 | default: SvelteComponent; 5 | }; 6 | -------------------------------------------------------------------------------- /src/stimulus/helpers/svelte/util.ts: -------------------------------------------------------------------------------- 1 | import { ImportedModule, ImportedModules } from "../types"; 2 | import { SvelteModule } from "./types"; 3 | 4 | let svelteImportedModules: ImportedModules = {}; 5 | 6 | export function registerSvelteControllerComponents( 7 | modules: ImportedModules, 8 | controllersDir = "./svelte/controllers", 9 | ) { 10 | svelteImportedModules = { ...svelteImportedModules, ...modules }; 11 | 12 | window.resolveSvelteComponent = (name: string): ImportedModule => { 13 | const svelteModule = svelteImportedModules[`${controllersDir}/${name}.svelte`]; 14 | if (typeof svelteModule === "undefined") { 15 | const possibleValues = Object.keys(svelteImportedModules).map((key) => 16 | key.replace(`${controllersDir}/`, "").replace(".svelte", ""), 17 | ); 18 | throw new Error(`Svelte controller "${name}" does not exist. Possible values: ${possibleValues.join(", ")}`); 19 | } 20 | 21 | return svelteModule; 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/stimulus/helpers/svelte4/index.ts: -------------------------------------------------------------------------------- 1 | export { SvelteModule } from "./types"; 2 | export * from "./util"; 3 | export { default as SvelteController } from "./render_controller"; 4 | -------------------------------------------------------------------------------- /src/stimulus/helpers/svelte4/render_controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | import { SvelteComponent } from "svelte"; 3 | import { SvelteModule } from "./types"; 4 | 5 | export default class extends Controller { 6 | private app: SvelteComponent | undefined; 7 | declare readonly componentValue: string; 8 | 9 | private props: Record | undefined; 10 | private intro: boolean | undefined; 11 | 12 | declare readonly propsValue: Record | null | undefined; 13 | declare readonly introValue: boolean | undefined; 14 | 15 | static values = { 16 | component: String, 17 | props: Object, 18 | intro: Boolean, 19 | }; 20 | 21 | connect() { 22 | this.element.innerHTML = ""; 23 | 24 | this.props = this.propsValue ?? undefined; 25 | this.intro = this.introValue ?? undefined; 26 | 27 | this.dispatchEvent("connect"); 28 | 29 | const importedSvelteModule = window.resolveSvelteComponent(this.componentValue); 30 | 31 | const onload = (svelteModule: SvelteModule) => { 32 | const Component = svelteModule.default; 33 | 34 | this._destroyIfExists(); 35 | 36 | // @ts-expect-error @see https://svelte.dev/docs#run-time-client-side-component-api-creating-a-component 37 | this.app = new Component({ 38 | target: this.element, 39 | props: this.props, 40 | intro: this.intro, 41 | }); 42 | 43 | this.element.root = this.app; 44 | 45 | this.dispatchEvent("mount", { 46 | component: Component, 47 | }); 48 | }; 49 | 50 | if (typeof importedSvelteModule === "function") { 51 | importedSvelteModule().then(onload); 52 | } else { 53 | onload(importedSvelteModule); 54 | } 55 | } 56 | 57 | disconnect() { 58 | this._destroyIfExists(); 59 | this.dispatchEvent("unmount"); 60 | } 61 | 62 | _destroyIfExists() { 63 | if (this.element.root !== undefined) { 64 | this.element.root.$destroy(); 65 | delete this.element.root; 66 | } 67 | } 68 | 69 | private dispatchEvent(name: string, payload: object = {}) { 70 | const detail = { 71 | componentName: this.componentValue, 72 | props: this.props, 73 | intro: this.intro, 74 | ...payload, 75 | }; 76 | this.dispatch(name, { detail, prefix: "svelte" }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/stimulus/helpers/svelte4/types.ts: -------------------------------------------------------------------------------- 1 | import type { SvelteComponent } from "svelte"; 2 | 3 | export type SvelteModule = { 4 | default: SvelteComponent; 5 | }; 6 | -------------------------------------------------------------------------------- /src/stimulus/helpers/svelte4/util.ts: -------------------------------------------------------------------------------- 1 | import { ImportedModule, ImportedModules } from "../types"; 2 | import { SvelteModule } from "./types"; 3 | 4 | let svelteImportedModules: ImportedModules = {}; 5 | 6 | export function registerSvelteControllerComponents( 7 | modules: ImportedModules, 8 | controllersDir = "./svelte/controllers", 9 | ) { 10 | svelteImportedModules = { ...svelteImportedModules, ...modules }; 11 | 12 | window.resolveSvelteComponent = (name: string): ImportedModule => { 13 | const svelteModule = svelteImportedModules[`${controllersDir}/${name}.svelte`]; 14 | if (typeof svelteModule === "undefined") { 15 | const possibleValues = Object.keys(svelteImportedModules).map((key) => 16 | key.replace(`${controllersDir}/`, "").replace(".svelte", ""), 17 | ); 18 | throw new Error(`Svelte controller "${name}" does not exist. Possible values: ${possibleValues.join(", ")}`); 19 | } 20 | 21 | return svelteModule; 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/stimulus/helpers/types.d.ts: -------------------------------------------------------------------------------- 1 | export type LazyModule = () => Promise; 2 | export type ImportedModule = M | LazyModule; 3 | export type ImportedModules = Record>; 4 | 5 | declare global { 6 | function resolveReactComponent(name: string): ImportedModule; 7 | function resolveVueComponent(name: string): VueComponent; 8 | function resolveSvelteComponent(name: string): ImportedModule; 9 | 10 | interface Window { 11 | resolveReactComponent(name: string): ImportedModule; 12 | resolveVueComponent(name: string): VueComponent; 13 | resolveSvelteComponent(name: string): ImportedModule; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/stimulus/helpers/vue/index.ts: -------------------------------------------------------------------------------- 1 | export { VueModule } from "./types"; 2 | export * from "./util"; 3 | -------------------------------------------------------------------------------- /src/stimulus/helpers/vue/types.ts: -------------------------------------------------------------------------------- 1 | import { Component as VueComponent } from "vue"; 2 | 3 | export type VueModule = { 4 | default: VueComponent; 5 | }; 6 | -------------------------------------------------------------------------------- /src/stimulus/helpers/vue/util.ts: -------------------------------------------------------------------------------- 1 | import { defineAsyncComponent } from "vue"; 2 | import type { Component } from "vue"; 3 | import { ImportedModules } from "../types"; 4 | import { VueModule } from "./types"; 5 | 6 | const vueComponentsOrLoaders: { 7 | [key: string]: (() => Promise) | Component; 8 | } = {}; 9 | 10 | export function registerVueControllerComponents( 11 | modules: ImportedModules, 12 | controllersDir = "./vue/controllers", 13 | ) { 14 | Object.entries(modules).forEach(([key, module]) => { 15 | if (typeof module !== "function") { 16 | vueComponentsOrLoaders[key] = module.default; 17 | } else { 18 | vueComponentsOrLoaders[key] = module; 19 | } 20 | }); 21 | 22 | function loadComponent(name: string): Component { 23 | const componentPath = `${controllersDir}/${name}.vue`; 24 | 25 | if (!(componentPath in vueComponentsOrLoaders)) { 26 | const possibleValues = Object.keys(vueComponentsOrLoaders).map((key) => 27 | key.replace("./", "").replace(".vue", ""), 28 | ); 29 | 30 | throw new Error(`Vue controller "${name}" does not exist. Possible values: ${possibleValues.join(", ")}`); 31 | } 32 | 33 | if (typeof vueComponentsOrLoaders[componentPath] === "function") { 34 | const module = vueComponentsOrLoaders[componentPath] as () => Promise; 35 | vueComponentsOrLoaders[componentPath] = defineAsyncComponent(module); 36 | } 37 | 38 | return vueComponentsOrLoaders[componentPath]; 39 | } 40 | 41 | window.resolveVueComponent = (name: string) => { 42 | return loadComponent(name); 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/stimulus/helpers/vue/vue.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment jsdom 3 | */ 4 | import { describe, expect, it } from "vitest"; 5 | import { registerVueControllerComponents } from "./index"; 6 | import { ImportedModules } from "../types"; 7 | import { VueModule } from "./types"; 8 | 9 | const fakeVueComponent = () => ({}); 10 | 11 | const createFakeImportedModules = () => { 12 | return { 13 | "./vue/controllers/Hello.vue": () => Promise.resolve(fakeVueComponent), 14 | } as any as ImportedModules; 15 | }; 16 | 17 | describe("registerVueControllerComponents", () => { 18 | it("should resolve components", () => { 19 | registerVueControllerComponents(createFakeImportedModules()); 20 | const resolveVueComponent = window.resolveVueComponent; 21 | 22 | expect(resolveVueComponent).not.toBeUndefined(); 23 | expect(resolveVueComponent("Hello")).toMatchInlineSnapshot(` 24 | { 25 | "__asyncHydrate": [Function], 26 | "__asyncLoader": [Function], 27 | "__asyncResolved": undefined, 28 | "name": "AsyncComponentWrapper", 29 | "setup": [Function], 30 | } 31 | `); 32 | }); 33 | 34 | it("errors with a bad name", () => { 35 | registerVueControllerComponents(createFakeImportedModules()); 36 | const resolveVueComponent = window.resolveVueComponent; 37 | expect(() => resolveVueComponent("Helloooo")).toThrowErrorMatchingInlineSnapshot( 38 | `[Error: Vue controller "Helloooo" does not exist. Possible values: vue/controllers/Hello]`, 39 | ); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/stimulus/node/bridge.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | import { createControllersModule, extractStimulusIdentifier, parseStimulusRequest, stimulusFetchRE } from "./bridge"; 3 | 4 | import { readFileSync } from "node:fs"; 5 | import { resolve } from "node:path"; 6 | import { fileURLToPath } from "node:url"; 7 | import { VitePluginSymfonyStimulusOptions } from "~/types"; 8 | import { ResolvedConfig } from "vite"; 9 | 10 | const testDir = resolve(fileURLToPath(import.meta.url), "../../../../tests"); 11 | 12 | console.log("TEST DIR", testDir); 13 | 14 | function loadControllerJson(filename: string) { 15 | return JSON.parse(readFileSync(resolve(testDir, `fixtures/${filename}`)).toString()); 16 | } 17 | 18 | function getPackageJson(path: string) { 19 | return JSON.parse(readFileSync(resolve(testDir, `fixtures/modules/${path}`)).toString()); 20 | } 21 | 22 | vi.mock("node:module", () => { 23 | const createRequire = () => vi.fn().mockImplementation(getPackageJson); 24 | return { createRequire }; 25 | }); 26 | 27 | const pluginDefaultOptions: VitePluginSymfonyStimulusOptions = { 28 | controllersDir: "./assets/controllers", 29 | controllersFilePath: "./assets.controllers.json", 30 | hmr: true, 31 | fetchMode: "eager", 32 | identifierResolutionMethod: "snakeCase", 33 | }; 34 | 35 | function createControllersModuleFactory(config: any) { 36 | return createControllersModule(config, pluginDefaultOptions).trim(); 37 | } 38 | 39 | describe("parseStimulusRequest", () => { 40 | it.each([ 41 | [`import.meta.stimulusFetch="eager"`, true], 42 | [` import.meta.stimulusFetch="eager"`, true], 43 | [ 44 | `const foo = "bar" 45 | import.meta.stimulusFetch="eager"`, 46 | true, 47 | ], 48 | [`// import.meta.stimulusFetch="eager"`, false], 49 | [`/* import.meta.stimulusFetch="eager" */`, false], 50 | [`const foo = "bar"; /* import.meta.stimulusFetch="eager" */ const foo = "bar"; `, false], 51 | 52 | /** 53 | * to be improved 54 | * need to begin with the import. sorry if you have time to find a better regex, PR are welcome 55 | */ 56 | [`const foo = "bar"; import.meta.stimulusFetch="eager"; const foo = "bar";`, false], 57 | ])("regex match import.meta %s", function (code, isMatching) { 58 | expect(stimulusFetchRE.test(code)).toBe(isMatching); 59 | }); 60 | 61 | it.each([ 62 | [`import.meta.stimulusIdentifier = "foo"`, "foo"], 63 | [``, null], 64 | ])("extract identifier %s from code or null", (code, result) => { 65 | expect(extractStimulusIdentifier(code)).toBe(result); 66 | }); 67 | 68 | it("parse import.meta from source code", () => { 69 | const code = ` 70 | import { Controller } from "@hotwired/stimulus"; 71 | 72 | import.meta.stimulusEnabled = false; 73 | import.meta.stimulusFetch = "eager"; 74 | import.meta.stimulusIdentifier = "other"; 75 | 76 | export default class controller extends Controller {} 77 | `; 78 | const path = "/path/to/project/assets/controllers/welcome_controller.js"; 79 | const result = parseStimulusRequest( 80 | code, 81 | path, 82 | { fetchMode: "lazy", identifierResolutionMethod: "snakeCase" } as VitePluginSymfonyStimulusOptions, 83 | { root: "/path/to/project" } as ResolvedConfig, 84 | ); 85 | 86 | expect(result).toMatchInlineSnapshot(` 87 | " 88 | import Controller from '/path/to/project/assets/controllers/welcome_controller.js'; 89 | export default { 90 | enabled: false, 91 | fetch: 'eager', 92 | identifier: 'other', 93 | controller: Controller 94 | } 95 | if (import.meta.hot) { import.meta.hot.accept(); }" 96 | `); 97 | }); 98 | 99 | it("not parse import.meta from source code when import.meta are in comments", () => { 100 | const code = ` 101 | import { Controller } from "@hotwired/stimulus"; 102 | 103 | // import.meta.stimulusEnabled = false; 104 | // import.meta.stimulusFetch = "eager"; 105 | // import.meta.stimulusIdentifier = "other"; 106 | 107 | export default class controller extends Controller {} 108 | `; 109 | const path = "/path/to/project/assets/controllers/welcome_controller.js"; 110 | const result = parseStimulusRequest( 111 | code, 112 | path, 113 | { fetchMode: "lazy", identifierResolutionMethod: "snakeCase" } as VitePluginSymfonyStimulusOptions, 114 | { root: "/path/to/project" } as ResolvedConfig, 115 | ); 116 | 117 | expect(result).toMatchInlineSnapshot(` 118 | " 119 | export default { 120 | enabled: true, 121 | fetch: 'lazy', 122 | identifier: 'welcome', 123 | controller: () => import('/path/to/project/assets/controllers/welcome_controller.js') 124 | } 125 | if (import.meta.hot) { import.meta.hot.accept(); }" 126 | `); 127 | }); 128 | }); 129 | 130 | describe("createControllersModule", () => { 131 | describe("empty.json", () => { 132 | it("must return empty array", () => { 133 | const config = loadControllerJson("empty.json"); 134 | expect(createControllersModuleFactory(config)).toMatchInlineSnapshot(` 135 | "export default [ 136 | 137 | ];" 138 | `); 139 | }); 140 | }); 141 | 142 | describe("disabled-controller.json", () => { 143 | it("must return an empty array", () => { 144 | const config = loadControllerJson("disabled-controller.json"); 145 | expect(createControllersModuleFactory(config)).toMatchInlineSnapshot(` 146 | "export default [ 147 | 148 | ];" 149 | `); 150 | }); 151 | }); 152 | 153 | describe("disabled-autoimport.json", () => { 154 | it("must return controller info without autoimport", () => { 155 | const config = loadControllerJson("disabled-autoimport.json"); 156 | expect(createControllersModuleFactory(config)).toMatchInlineSnapshot(` 157 | "import controller_0 from '@symfony/mock-module/dist/controller.js'; 158 | 159 | export default [ 160 | { 161 | enabled: true, 162 | fetch: "eager", 163 | identifier: "symfony--mock-module--mock", 164 | controller: controller_0 165 | } 166 | ];" 167 | `); 168 | }); 169 | }); 170 | 171 | describe("eager-no-autoimport.json", () => { 172 | it("must return controller info without autoimport", () => { 173 | const config = loadControllerJson("eager-no-autoimport.json"); 174 | expect(createControllersModuleFactory(config)).toMatchInlineSnapshot(` 175 | "import controller_0 from '@symfony/mock-module/dist/controller.js'; 176 | 177 | export default [ 178 | { 179 | enabled: true, 180 | fetch: "eager", 181 | identifier: "symfony--mock-module--mock", 182 | controller: controller_0 183 | } 184 | ];" 185 | `); 186 | }); 187 | }); 188 | 189 | describe("eager-autoimport.json", () => { 190 | it("must return a controller info with the controller constructor and auto-import", () => { 191 | const config = loadControllerJson("eager-autoimport.json"); 192 | expect(createControllersModuleFactory(config)).toMatchInlineSnapshot(` 193 | "import controller_0 from '@symfony/mock-module/dist/controller.js'; 194 | import '@symfony/mock-module/dist/style.css'; 195 | 196 | export default [ 197 | { 198 | enabled: true, 199 | fetch: "eager", 200 | identifier: "symfony--mock-module--mock", 201 | controller: controller_0 202 | } 203 | ];" 204 | `); 205 | }); 206 | }); 207 | 208 | describe("lazy-no-autoimport.json", () => { 209 | it("must return a controller info with a controller factory", () => { 210 | const config = loadControllerJson("lazy-no-autoimport.json"); 211 | expect(createControllersModuleFactory(config)).toMatchInlineSnapshot(` 212 | "export default [ 213 | { 214 | enabled: true, 215 | fetch: "lazy", 216 | identifier: "symfony--mock-module--mock", 217 | controller: () => import("@symfony/mock-module/dist/controller.js") 218 | } 219 | ];" 220 | `); 221 | }); 222 | }); 223 | 224 | describe("load-named-controller.json", () => { 225 | it("must register the custom name from package's package.json", () => { 226 | const config = loadControllerJson("load-named-controller.json"); 227 | expect(createControllersModuleFactory(config)).toMatchInlineSnapshot(` 228 | "import controller_0 from '@symfony/mock-module/dist/named_controller.js'; 229 | 230 | export default [ 231 | { 232 | enabled: true, 233 | fetch: "eager", 234 | identifier: "foo--custom_name", 235 | controller: controller_0 236 | } 237 | ];" 238 | `); 239 | }); 240 | }); 241 | 242 | describe("override-name.json", () => { 243 | it('must use the overridden "name" from user\'s config', () => { 244 | const config = loadControllerJson("override-name.json"); 245 | expect(createControllersModuleFactory(config)).toMatchInlineSnapshot(` 246 | "import controller_0 from '@symfony/mock-module/dist/controller.js'; 247 | 248 | export default [ 249 | { 250 | enabled: true, 251 | fetch: "eager", 252 | identifier: "foo--overridden_name", 253 | controller: controller_0 254 | } 255 | ];" 256 | `); 257 | }); 258 | }); 259 | 260 | describe("third-party.json", () => { 261 | it("can import stimulus controller without symfony property", () => { 262 | const config = loadControllerJson("third-party.json"); 263 | expect(createControllersModuleFactory(config)).toMatchInlineSnapshot(` 264 | "import controller_0 from 'stimulus-clipboard/dist/stimulus-clipboard.mjs'; 265 | 266 | export default [ 267 | { 268 | enabled: true, 269 | fetch: "eager", 270 | identifier: "stimulus-clipboard", 271 | controller: controller_0 272 | } 273 | ];" 274 | `); 275 | }); 276 | }); 277 | }); 278 | -------------------------------------------------------------------------------- /src/stimulus/node/bridge.ts: -------------------------------------------------------------------------------- 1 | import { Logger, ResolvedConfig } from "vite"; 2 | import { ControllersFileContent } from "../types"; 3 | import { generateStimulusId, getStimulusControllerId } from "../util"; 4 | import { createRequire } from "node:module"; 5 | import { VitePluginSymfonyStimulusOptions } from "~/types"; 6 | import { relative } from "node:path"; 7 | 8 | export const virtualSymfonyControllersModuleId = "virtual:symfony/controllers"; 9 | 10 | export function createControllersModule( 11 | controllersJsonContent: ControllersFileContent, 12 | pluginOptions: VitePluginSymfonyStimulusOptions, 13 | logger?: Logger, 14 | ) { 15 | const require = createRequire(import.meta.url); 16 | const controllerContents: string[] = []; 17 | let importStatementContents = ""; 18 | let controllerIndex = 0; 19 | 20 | if ("undefined" === typeof controllersJsonContent["controllers"]) { 21 | throw new Error('Your Stimulus configuration file (assets/controllers.json) lacks a "controllers" key.'); 22 | } 23 | 24 | for (const packageName in controllersJsonContent.controllers) { 25 | let packageJsonContent: any = null; 26 | let packageNameResolved; 27 | 28 | if (packageName === "@symfony/ux-svelte" || packageName === "@symfony/ux-react") { 29 | packageNameResolved = "vite-plugin-symfony"; 30 | } else { 31 | packageNameResolved = packageName; 32 | } 33 | 34 | try { 35 | // https://nodejs.org/api/esm.html#import-attributes 36 | // TODO : change to this when stable 37 | // packageJsonContent = (await import(`${packageName}/package.json`, { assert: { type: "json" } })).default; 38 | packageJsonContent = require(`${packageNameResolved}/package.json`); 39 | } catch (error: any) { 40 | logger?.error( 41 | `The file "${packageNameResolved}/package.json" could not be found. Try running "npm install --force".`, 42 | { error }, 43 | ); 44 | } 45 | 46 | // package can define multiple stimulus controllers 47 | // used only by @symfony/ux-turbo : turbo-core, mercure-turbo-stream 48 | for (const controllerName in controllersJsonContent.controllers[packageName]) { 49 | const controllerPackageConfig = packageJsonContent?.symfony?.controllers?.[controllerName] || {}; 50 | const controllerUserConfig = controllersJsonContent.controllers[packageName][controllerName]; 51 | 52 | if (!controllerUserConfig.enabled) { 53 | continue; 54 | } 55 | 56 | /** 57 | * sometimes default export of the package is not the controller entrypoint. 58 | * used by : @symfony/ux-react, @symfony/ux-vue, @symfony/ux-svelte, @symfony/ux-turbo 59 | * 60 | * ex: @symfony/ux-react (extract package.json) 61 | * 62 | * { 63 | * "module": "dist/register_controller.js", 64 | * "type": "module", 65 | * "symfony": { 66 | * "controllers": { 67 | * "react": { 68 | * "main": "dist/render_controller.js", 69 | * "fetch": "eager", 70 | * "enabled": true 71 | * } 72 | * }, 73 | * } 74 | * } 75 | */ 76 | const packageMain = 77 | controllerUserConfig.module ?? 78 | controllerUserConfig.main ?? 79 | controllerPackageConfig.module ?? 80 | controllerPackageConfig.main ?? 81 | packageJsonContent.module ?? 82 | packageJsonContent.main; 83 | const controllerMain = `${packageNameResolved}/${packageMain}`; 84 | 85 | const fetchMode = controllerUserConfig.fetch ?? controllerPackageConfig.fetch ?? pluginOptions.fetchMode; 86 | 87 | let moduleValueContents = ``; 88 | 89 | if (fetchMode === "eager") { 90 | // controller & dependencies are included in the JavaScript that's 91 | // downloaded when the page is loaded 92 | const controllerNameForVariable = `controller_${controllerIndex++}`; 93 | importStatementContents += `import ${controllerNameForVariable} from '${controllerMain}';\n`; 94 | 95 | moduleValueContents = controllerNameForVariable; 96 | } else if (fetchMode === "lazy") { 97 | // controller & dependencies are isolated into a separate file and only 98 | // downloaded asynchronously if (and when) the data-controller HTML appears 99 | // on the page. 100 | // moduleValueContents = generateLazyController(controllerMain); 101 | moduleValueContents = `() => import("${controllerMain}")`; 102 | } else { 103 | throw new Error(`Invalid fetch mode "${fetchMode}" in controllers.json. Expected "eager" or "lazy".`); 104 | } 105 | 106 | let controllerId = generateStimulusId(`${packageName}/${controllerName}`); 107 | // allow the package or user config to override name 108 | // used by ux-live-component 109 | if ("undefined" !== typeof controllerPackageConfig.name) { 110 | controllerId = controllerPackageConfig.name.replace(/\//g, "--"); 111 | } 112 | if ("undefined" !== typeof controllerUserConfig.name) { 113 | controllerId = controllerUserConfig.name.replace(/\//g, "--"); 114 | } 115 | 116 | controllerContents.push(`{ 117 | enabled: true, 118 | fetch: "${fetchMode}", 119 | identifier: "${controllerId}", 120 | controller: ${moduleValueContents} 121 | }`); 122 | 123 | if (controllerUserConfig.autoimport) { 124 | for (const autoimport in controllerUserConfig.autoimport) { 125 | if (controllerUserConfig.autoimport[autoimport]) { 126 | importStatementContents += "import '" + autoimport + "';\n"; 127 | } 128 | } 129 | } 130 | } 131 | } 132 | 133 | const moduleContent = `${importStatementContents}\nexport default [\n${controllerContents.join(",\n")}\n];\n`; 134 | return moduleContent; 135 | } 136 | 137 | const notACommentRE = /^(? import('${filePath}') 189 | }`; 190 | 191 | return `${dstCode}\nif (import.meta.hot) { import.meta.hot.accept(); }`; 192 | } 193 | -------------------------------------------------------------------------------- /src/stimulus/node/hmr.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "vite"; 2 | 3 | const applicationGlobalVarName = "$$stimulusApp$$"; 4 | 5 | export function addBootstrapHmrCode(code: string, logger: Logger) { 6 | /** 7 | * const app = startStimulusApp(); 8 | * matchArray = ["const app = startStimulusApp()", "app"] 9 | */ 10 | const appRegex = /[^\n]*?\s(\w+)(?:\s*=\s*startStimulusApp\(\))/; 11 | const appVariable = (code.match(appRegex) || [])[1]; 12 | if (appVariable) { 13 | logger.info(`stimulus app available globally for HMR with window.${applicationGlobalVarName}`); 14 | const exportFooter = `window.${applicationGlobalVarName} = ${appVariable}`; 15 | return `${code}\n${exportFooter}`; 16 | } 17 | return null; 18 | } 19 | 20 | export function addControllerHmrCode(code: string, identifier: string) { 21 | const metaHotFooter = ` 22 | if (import.meta.hot) { 23 | import.meta.hot.accept(newModule => { 24 | if (!window.${applicationGlobalVarName}) { 25 | console.warn('Stimulus app not available. Are you creating app with startStimulusApp() ?'); 26 | import.meta.hot.invalidate(); 27 | } else { 28 | if (window.${applicationGlobalVarName}.router.modulesByIdentifier.has('${identifier}') && newModule.default) { 29 | window.${applicationGlobalVarName}.register('${identifier}', newModule.default); 30 | } else { 31 | console.warn('Try to HMR not registered Stimulus controller', '${identifier}', 'full-reload'); 32 | import.meta.hot.invalidate(); 33 | } 34 | } 35 | }) 36 | }`; 37 | 38 | return `${code}\n${metaHotFooter}`; 39 | } 40 | -------------------------------------------------------------------------------- /src/stimulus/node/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | 3 | import { ConfigEnv, Logger, UserConfig, createLogger } from "vite"; 4 | import { describe, it, vi } from "vitest"; 5 | import symfonyStimulus from "./index"; 6 | import { resolvePluginStimulusOptions } from "~/stimulus/pluginOptions"; 7 | import { VitePluginSymfonyStimulusOptions } from "~/types"; 8 | 9 | const generateStimulusPlugin = async ( 10 | command: "build" | "serve", 11 | userPluginStimulusOptions: Partial = {}, 12 | ) => { 13 | const stimulusOptions = resolvePluginStimulusOptions(userPluginStimulusOptions); 14 | if (!stimulusOptions) { 15 | throw new Error("need to be enabled"); 16 | } 17 | const logger: Logger = { 18 | ...createLogger(), 19 | info: vi.fn(), 20 | }; 21 | const plugin = symfonyStimulus(stimulusOptions, logger); 22 | const userConfig: UserConfig = {}; 23 | const envConfig: ConfigEnv = { command, mode: "development" }; 24 | // @ts-ignore 25 | await plugin.configResolved({ 26 | root: "/path/to/project", 27 | }); 28 | plugin.config(userConfig, envConfig); 29 | 30 | return plugin; 31 | }; 32 | describe("stimulus index", () => { 33 | it("inject correctly Application global var when server is started", async ({ expect }) => { 34 | const plugin = await generateStimulusPlugin("serve"); 35 | // @ts-ignore 36 | const returnValue = plugin.transform(`const myApp = startStimulusApp();`, "/path/to/project/bootstrap.js", {}); 37 | expect(returnValue).toMatchInlineSnapshot(` 38 | "const myApp = startStimulusApp(); 39 | window.$$stimulusApp$$ = myApp" 40 | `); 41 | }); 42 | it("doesn't insert Application global var when startStimulusApp is not present", async ({ expect }) => { 43 | const plugin = await generateStimulusPlugin("serve"); 44 | // @ts-ignore 45 | const returnValue = plugin.transform(`const hello = "world;`, "/path/to/project/bootstrap.js", {}); 46 | expect(returnValue).toBeNull(); 47 | }); 48 | it("doesn't insert Application global var when server is started", async ({ expect }) => { 49 | const plugin = await generateStimulusPlugin("build"); 50 | // @ts-ignore 51 | const returnValue = plugin.transform(`const myApp = startStimulusApp();`, "/path/to/project/bootstrap.js", {}); 52 | expect(returnValue).toBeNull(); 53 | }); 54 | 55 | it("inject correctly Controller hot accept", async ({ expect }) => { 56 | const plugin = await generateStimulusPlugin("serve"); 57 | // @ts-ignore 58 | const returnValue = plugin.transform( 59 | `export default class controller extends Controller {}`, 60 | "/path/to/project/assets/controllers/welcome_controller.js", 61 | {}, 62 | ); 63 | expect(returnValue).toMatchInlineSnapshot(` 64 | "export default class controller extends Controller {} 65 | 66 | if (import.meta.hot) { 67 | import.meta.hot.accept(newModule => { 68 | if (!window.$$stimulusApp$$) { 69 | console.warn('Stimulus app not available. Are you creating app with startStimulusApp() ?'); 70 | import.meta.hot.invalidate(); 71 | } else { 72 | if (window.$$stimulusApp$$.router.modulesByIdentifier.has('welcome') && newModule.default) { 73 | window.$$stimulusApp$$.register('welcome', newModule.default); 74 | } else { 75 | console.warn('Try to HMR not registered Stimulus controller', 'welcome', 'full-reload'); 76 | import.meta.hot.invalidate(); 77 | } 78 | } 79 | }) 80 | }" 81 | `); 82 | }); 83 | it("doesn't insert Controller hot accept", async ({ expect }) => { 84 | const plugin = await generateStimulusPlugin("serve"); 85 | // @ts-ignore 86 | const returnValue = plugin.transform( 87 | `export default class controller extends Controller {}`, 88 | "/not/in/the/root/project/dir/assets/other.js", 89 | {}, 90 | ); 91 | expect(returnValue).toBeNull(); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/stimulus/node/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createControllersModule, 3 | virtualSymfonyControllersModuleId, 4 | parseStimulusRequest, 5 | extractStimulusIdentifier, 6 | } from "./bridge"; 7 | import { join, relative, resolve } from "node:path"; 8 | import { Logger, Plugin, ResolvedConfig, UserConfig } from "vite"; 9 | import { VitePluginSymfonyStimulusOptions } from "~/types"; 10 | import { ControllersFileContent } from "../types"; 11 | import { addBootstrapHmrCode, addControllerHmrCode } from "./hmr"; 12 | import { getStimulusControllerId } from "../util"; 13 | import { isPathIncluded } from "./utils"; 14 | import { readFile, stat } from "node:fs/promises"; 15 | 16 | const stimulusRE = /\?stimulus\b/; 17 | const virtualRE = /^virtual:/; 18 | 19 | const isStimulusRequest = (request: string): boolean => stimulusRE.test(request); 20 | const isVirtualRequest = (request: string): boolean => virtualRE.test(request); 21 | 22 | export default function symfonyStimulus(pluginOptions: VitePluginSymfonyStimulusOptions, logger: Logger) { 23 | let viteConfig: ResolvedConfig; 24 | let viteCommand: string; 25 | let controllersJsonContent: ControllersFileContent | null = null; 26 | let controllersFilePath: string; 27 | return { 28 | name: "symfony-stimulus", 29 | config(userConfig, { command }) { 30 | viteCommand = command; 31 | const extraConfig: UserConfig = { 32 | optimizeDeps: { 33 | exclude: [...(userConfig?.optimizeDeps?.exclude ?? []), virtualSymfonyControllersModuleId], 34 | }, 35 | }; 36 | 37 | return extraConfig; 38 | }, 39 | async configResolved(config) { 40 | viteConfig = config; 41 | 42 | controllersFilePath = resolve(viteConfig.root, pluginOptions.controllersFilePath); 43 | try { 44 | await stat(controllersFilePath); 45 | controllersJsonContent = JSON.parse((await readFile(controllersFilePath)).toString()); 46 | } catch { 47 | controllersJsonContent = { 48 | controllers: {}, 49 | entrypoints: {}, 50 | }; 51 | } 52 | }, 53 | resolveId(this: unknown, id) { 54 | if (id === virtualSymfonyControllersModuleId) { 55 | return id; 56 | } 57 | }, 58 | load(this: unknown, id) { 59 | if (id === virtualSymfonyControllersModuleId) { 60 | if (controllersJsonContent) { 61 | return createControllersModule(controllersJsonContent, pluginOptions, logger); 62 | } else { 63 | return `export default [];`; 64 | } 65 | } 66 | }, 67 | transform(this: unknown, code, id, options) { 68 | if ((options?.ssr && !process.env.VITEST) || id.includes("node_modules") || isVirtualRequest(id)) { 69 | return null; 70 | } 71 | 72 | if (isStimulusRequest(id)) { 73 | return parseStimulusRequest(code, id, pluginOptions, viteConfig); 74 | } 75 | 76 | if (viteCommand === "serve" && pluginOptions.hmr) { 77 | if (id.endsWith("bootstrap.js") || id.endsWith("bootstrap.ts")) { 78 | return addBootstrapHmrCode(code, logger); 79 | } 80 | 81 | const isInsideControllerDir = isPathIncluded(join(viteConfig.root, pluginOptions.controllersDir), id); 82 | 83 | if (!isInsideControllerDir) { 84 | return null; 85 | } 86 | 87 | const relativePath = relative(viteConfig.root, id); 88 | 89 | const identifier = 90 | extractStimulusIdentifier(code) ?? 91 | getStimulusControllerId(relativePath, pluginOptions.identifierResolutionMethod); 92 | 93 | if (identifier) { 94 | return addControllerHmrCode(code, identifier); 95 | } 96 | } 97 | 98 | return null; 99 | }, 100 | configureServer(devServer) { 101 | const { watcher } = devServer; 102 | watcher.on("change", (path) => { 103 | if (path === controllersFilePath) { 104 | logger.info("✨ controllers.json updated, we restart server."); 105 | devServer.restart(); 106 | } 107 | }); 108 | }, 109 | } satisfies Plugin; 110 | } 111 | -------------------------------------------------------------------------------- /src/stimulus/node/utils.ts: -------------------------------------------------------------------------------- 1 | import { resolve, sep } from "node:path"; 2 | 3 | export function isPathIncluded(basePath: string, targetPath: string): boolean { 4 | const normalizedBasePath = resolve(basePath); 5 | const normalizedTargetPath = resolve(targetPath); 6 | 7 | const basePathWithSep = normalizedBasePath.endsWith(sep) ? normalizedBasePath : normalizedBasePath + sep; 8 | 9 | return normalizedTargetPath.startsWith(basePathWithSep); 10 | } 11 | -------------------------------------------------------------------------------- /src/stimulus/pluginOptions.ts: -------------------------------------------------------------------------------- 1 | import { VitePluginSymfonyStimulusOptions } from "~/types"; 2 | 3 | export function resolvePluginStimulusOptions( 4 | userConfig?: boolean | string | Partial, 5 | ): false | VitePluginSymfonyStimulusOptions { 6 | let config: false | VitePluginSymfonyStimulusOptions; 7 | if (userConfig === true) { 8 | config = { 9 | controllersDir: "./assets/controllers", 10 | controllersFilePath: "./assets/controllers.json", 11 | hmr: true, 12 | fetchMode: "eager", 13 | identifierResolutionMethod: "snakeCase", 14 | }; 15 | } else if (typeof userConfig === "string") { 16 | config = { 17 | controllersDir: "./assets/controllers", 18 | controllersFilePath: userConfig, 19 | hmr: true, 20 | fetchMode: "eager", 21 | identifierResolutionMethod: "snakeCase", 22 | }; 23 | } else if (typeof userConfig === "object") { 24 | config = { 25 | controllersDir: userConfig.controllersDir ?? "./assets/controllers", 26 | controllersFilePath: userConfig.controllersFilePath ?? "./assets/controllers.json", 27 | hmr: userConfig.hmr !== false ? true : false, 28 | fetchMode: userConfig.fetchMode === "lazy" ? "lazy" : "eager", 29 | identifierResolutionMethod: userConfig.identifierResolutionMethod ?? "snakeCase", 30 | }; 31 | } else { 32 | config = false; 33 | } 34 | return config; 35 | } 36 | -------------------------------------------------------------------------------- /src/stimulus/types.d.ts: -------------------------------------------------------------------------------- 1 | export type ControllerConfig = { 2 | enabled?: boolean; 3 | fetch?: "eager" | "lazy"; 4 | 5 | /** 6 | * equivalent to controller identifier 7 | */ 8 | name?: string; 9 | autoimport?: { 10 | [path: string]: boolean; 11 | }; 12 | 13 | /** 14 | * Entrypoint. 15 | * if module is set : commonjs entrypoint 16 | */ 17 | main?: string; 18 | 19 | /** 20 | * for the future ? 21 | * ESM entrypoint 22 | */ 23 | module?: string; 24 | }; 25 | 26 | export type ControllersFileContent = { 27 | controllers: { 28 | [packageName: string]: { 29 | [controllerName: string]: ControllerConfig; 30 | }; 31 | }; 32 | entrypoints: { 33 | [key: string]: string; 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/stimulus/util.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { getStimulusControllerId, generateStimulusId } from "./util"; 3 | 4 | describe("stimulus generateStimulusId", () => { 5 | it("identifierFromThirdParty generate correct identifier", ({ expect }) => { 6 | const list = [ 7 | ["@symfony/ux-toggle-password/toggle-password", "symfony--ux-toggle-password--toggle-password"], 8 | ["my-custom-package/toggle-password", "my-custom-package--toggle-password"], 9 | ]; 10 | list.forEach(([input, result]) => { 11 | expect(generateStimulusId(input)).toBe(result); 12 | }); 13 | }); 14 | }); 15 | 16 | describe("stimulus getStimulusControllerId", () => { 17 | it.each([ 18 | { 19 | input: "./controllers/welcome_controller.js", 20 | expectedId: "welcome", 21 | }, 22 | { 23 | input: "./controllers/Welcome.js", 24 | expectedId: "welcome", 25 | }, 26 | { 27 | input: "./some-content-before/controllers/welcome_controller.js", 28 | expectedId: "welcome", 29 | }, 30 | { 31 | input: "/path/to/project/assets/controllers/welcome_controller.js", 32 | expectedId: "welcome", 33 | }, 34 | // without controllers 35 | { input: "../welcome_controller.js", expectedId: "welcome" }, 36 | // bare module 37 | { 38 | input: "library/welcome_controller.js", 39 | expectedId: "library--welcome", 40 | }, 41 | { 42 | // ./ is removed from computation 43 | input: "./library/welcome_controller.js", 44 | expectedId: "library--welcome", 45 | }, 46 | // some content after we add -- 47 | { 48 | input: "./controllers/foo/bar_controller.js", 49 | expectedId: "foo--bar", 50 | }, 51 | // we replace _ -> - 52 | { 53 | input: "./controllers/foo_bar_controller.js", 54 | expectedId: "foo-bar", 55 | }, 56 | { input: "./controllers/my_module.js", expectedId: "my-module" }, 57 | { input: "./path/to/file.js", expectedId: "path--to--file" }, 58 | { input: "not a controller", expectedId: null }, 59 | ])("getStimulusControllerId generate correct infos with snakecase resolution method", ({ input, expectedId }) => { 60 | const identifier = getStimulusControllerId(input, "snakeCase"); 61 | expect(identifier).toBe(expectedId); 62 | }); 63 | 64 | it.each([ 65 | { 66 | input: "./controllers/WelcomeController.js", 67 | expectedId: "welcome", 68 | }, 69 | { 70 | input: "./controllers/Welcome.js", 71 | expectedId: "welcome", 72 | }, 73 | { 74 | input: "./some-content-before/controllers/WelcomeController.js", 75 | expectedId: "welcome", 76 | }, 77 | // without controllers 78 | { input: "../WelcomeController.js", expectedId: "welcome" }, 79 | // bare module 80 | { 81 | input: "library/WelcomeController.js", 82 | expectedId: "library--welcome", 83 | }, 84 | { 85 | input: "./library/WelcomeController.js", 86 | expectedId: "library--welcome", 87 | }, 88 | { 89 | input: "./controllers/foo/BarController.js", 90 | expectedId: "foo--bar", 91 | }, 92 | { 93 | input: "./controllers/FooBarController.js", 94 | expectedId: "foo-bar", 95 | }, 96 | { input: "./controllers/MyModule.js", expectedId: "my-module" }, 97 | { input: "./path/to/file.js", expectedId: "path--to--file" }, 98 | { input: "not a controller", expectedId: null }, 99 | ])("getStimulusControllerId generate correct infos with camelCase resolution method", ({ input, expectedId }) => { 100 | const identifier = getStimulusControllerId(input, "camelCase"); 101 | expect(identifier).toBe(expectedId); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/stimulus/util.ts: -------------------------------------------------------------------------------- 1 | import { VitePluginSymfonyStimulusOptions } from "~/types"; 2 | 3 | export const CONTROLLER_FILENAME_REGEX = /^(?:.*?controllers\/|\.?\.\/)?(.+)\.[jt]sx?\b/; 4 | export const SNAKE_CONTROLLER_SUFFIX_REGEX = /^(.*)(?:[/_-]controller)$/; 5 | export const CAMEL_CONTROLLER_SUFFIX_REGEX = /^(.*)(?:Controller)$/; 6 | export function getStimulusControllerId( 7 | key: string, 8 | identifierResolutionMethod: VitePluginSymfonyStimulusOptions["identifierResolutionMethod"], 9 | ): string | null { 10 | if (typeof identifierResolutionMethod === "function") { 11 | return identifierResolutionMethod(key); 12 | } 13 | 14 | const [, relativePath] = key.match(CONTROLLER_FILENAME_REGEX) || []; 15 | if (!relativePath) { 16 | return null; 17 | } 18 | 19 | if (identifierResolutionMethod === "snakeCase") { 20 | const [, identifier] = relativePath.match(SNAKE_CONTROLLER_SUFFIX_REGEX) || []; 21 | return (identifier ?? relativePath).toLowerCase().replace(/_/g, "-").replace(/\//g, "--"); 22 | } else if (identifierResolutionMethod === "camelCase") { 23 | const [, identifier] = relativePath.match(CAMEL_CONTROLLER_SUFFIX_REGEX) || []; 24 | return kebabize(identifier ?? relativePath); 25 | } 26 | throw new Error("unknown identifierResolutionMethod valid entries 'snakeCase' or 'camelCase' or custom function"); 27 | } 28 | 29 | // Normalize the controller identifier from `${packageName}/${controllerName}` 30 | // remove the initial @ and use Stimulus format 31 | export function generateStimulusId(packageName: string) { 32 | if (packageName.startsWith("@")) { 33 | packageName = packageName.substring(1); 34 | } 35 | return packageName.replace(/_/g, "-").replace(/\//g, "--"); 36 | } 37 | 38 | function kebabize(str: string): string { 39 | return str 40 | .split("") 41 | .map((letter, idx) => { 42 | if (letter === "/") { 43 | return "--"; 44 | } 45 | return letter.toUpperCase() === letter 46 | ? `${idx !== 0 && str[idx - 1] !== "/" ? "-" : ""}${letter.toLowerCase()}` 47 | : letter; 48 | }) 49 | .join(""); 50 | } 51 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import "rollup"; 2 | import { Plugin, ResolvedConfig } from "vite"; 3 | 4 | declare module "rollup" { 5 | export interface RenderedChunk { 6 | viteMetadata?: ChunkMetadata; 7 | } 8 | } 9 | 10 | export type ResolvedConfigWithOrderablePlugins = Omit & { 11 | plugins: Plugin[]; 12 | }; 13 | 14 | export interface ChunkMetadata { 15 | importedAssets: Set; 16 | importedCss: Set; 17 | } 18 | 19 | export type FileMetadatas = { 20 | hash: string | null; 21 | }; 22 | 23 | export type EntryPointsFile = { 24 | base: string; 25 | entryPoints: EntryPoints; 26 | legacy: boolean; 27 | metadatas: FilesMetadatas; 28 | version: [string, number, number, number]; 29 | viteServer: string | null; 30 | }; 31 | 32 | export type FilesMetadatas = { 33 | [k: string]: FileMetadatas; 34 | }; 35 | 36 | export type EntryPoint = 37 | | { 38 | js?: string[]; 39 | } 40 | | { 41 | css?: string[]; 42 | } 43 | | BuildEntryPoint; 44 | 45 | export type BuildEntryPoint = { 46 | js: string[]; 47 | css: string[]; 48 | preload: string[]; 49 | dynamic: string[]; 50 | legacy: boolean | string; 51 | }; 52 | 53 | export type EntryPoints = { 54 | [k: string]: EntryPoint; 55 | }; 56 | 57 | export type StringMapping = { 58 | [k: string]: string; 59 | }; 60 | 61 | export type ParsedInputs = { 62 | [k: string]: ParsedEntry; 63 | }; 64 | 65 | export type ParsedEntry = { 66 | inputType: "js" | "css"; 67 | inputRelPath: string; 68 | }; 69 | 70 | export type EntryFilesMapping = { 71 | [k: string]: string; 72 | }; 73 | 74 | export type ManifestEntry = { 75 | file: string; 76 | src?: string; 77 | isDynamicEntry?: boolean; 78 | isEntry?: boolean; 79 | imports?: string[]; 80 | css?: string[]; 81 | }; 82 | 83 | export type ManifestFile = { 84 | [k: string]: ManifestEntry; 85 | }; 86 | 87 | export type FileInfos = JsFileInfos | CSSFileInfos | AssetFileInfos; 88 | 89 | export type JsFileInfos = { 90 | type: "js"; 91 | outputRelPath: string; 92 | inputRelPath: string | null; 93 | hash: string | null; 94 | 95 | imports: string[]; 96 | 97 | assets: string[]; 98 | js: string[]; 99 | preload: string[]; 100 | dynamic: string[]; 101 | 102 | css: string[]; 103 | }; 104 | export type CSSFileInfos = { 105 | type: "css"; 106 | outputRelPath: string; 107 | inputRelPath: string | null; 108 | hash: string | null; 109 | 110 | css: string[]; 111 | }; 112 | export type AssetFileInfos = { 113 | type: "asset"; 114 | outputRelPath: string; 115 | inputRelPath: string | null; 116 | hash: string | null; 117 | }; 118 | 119 | export type GeneratedFiles = { 120 | [inputRelPath: string]: FileInfos; 121 | }; 122 | 123 | export type DevServerUrl = `${"http" | "https"}://${string}:${number}`; 124 | 125 | export type HashAlgorithm = false | "sha256" | "sha384" | "sha512"; 126 | 127 | export type VitePluginSymfonyOptions = VitePluginSymfonyEntrypointsOptions & { 128 | /** 129 | * enable controllers.json loader for Symfony UX. 130 | * @default false 131 | */ 132 | stimulus: false | VitePluginSymfonyStimulusOptions; 133 | }; 134 | 135 | export type VitePluginSymfonyPartialOptions = Omit, "stimulus"> & { 136 | stimulus?: boolean | string | Partial; 137 | }; 138 | 139 | export type VitePluginSymfonyEntrypointsOptions = { 140 | /** 141 | * By default vite-plugin-symfony set vite option publicDir to false. 142 | * Because we don't want symfony entrypoint (index.php) and other files to 143 | * be copied into the build directory. 144 | * Related to this issue : https://github.com/lhapaipai/vite-bundle/issues/17 145 | * 146 | * Vite plugin Symfony use sirv to serve public directory. 147 | * 148 | * If you want to force vite option publicDir to true, set servePublic to false. 149 | * 150 | * @default 'public' 151 | */ 152 | servePublic: false | string; 153 | 154 | /** 155 | * Refresh vite dev server when your twig templates are updated. 156 | * - array of paths to files to be watched, or glob patterns 157 | * - true : equivalent to ["templates/**\/*.twig"] 158 | * @default false 159 | * 160 | * for additional glob documentation, check out low-level library picomatch : https://github.com/micromatch/picomatch 161 | */ 162 | refresh: boolean | string[]; 163 | 164 | /** 165 | * If you specify vite `server.host` option to '0.0.0.0' (usage with Docker) 166 | * You probably need to configure your `viteDevServerHostname` to 'localhost'. 167 | * Related to this issue : https://github.com/lhapaipai/vite-bundle/issues/26 168 | * 169 | * @default null 170 | */ 171 | viteDevServerHostname: null | string; 172 | 173 | /** 174 | * Add an integrity attribute to your