├── install ├── assets │ ├── app.css │ └── app.js ├── package.json ├── config │ └── routes │ │ └── pentatrion_vite.yaml └── vite.config.js ├── src ├── Exception │ ├── VersionMismatchException.php │ ├── EntrypointNotFoundException.php │ ├── UndefinedConfigNameException.php │ └── EntrypointsFileNotFoundException.php ├── Resources │ ├── config │ │ ├── routing.yaml │ │ └── services.yaml │ └── views │ │ ├── Collector │ │ ├── icon.svg │ │ └── vite_collector.html.twig │ │ └── Profiler │ │ └── info.html.twig ├── PentatrionViteBundle.php ├── Twig │ ├── TypeExtension.php │ └── EntryFilesTwigExtension.php ├── Event │ └── RenderAssetTagEvent.php ├── Controller │ ├── ProfilerController.php │ └── ViteController.php ├── Service │ ├── TagRendererCollection.php │ ├── EntrypointsLookupCollection.php │ ├── FileAccessor.php │ ├── Debug.php │ ├── EntrypointsLookup.php │ ├── TagRenderer.php │ └── EntrypointRenderer.php ├── CacheWarmer │ └── EntrypointsCacheWarmer.php ├── DataCollector │ └── ViteCollector.php ├── Util │ └── InlineContent.php ├── EventListener │ └── PreloadAssetsEventListener.php ├── Asset │ └── ViteAssetVersionStrategy.php ├── Model │ └── Tag.php └── DependencyInjection │ ├── Configuration.php │ └── PentatrionViteExtension.php ├── bin └── phpunit ├── docs ├── migration.md ├── manual-installation.md ├── migration-webpack-encore.md ├── vitejs.svg ├── symfony.svg ├── vite-legacy.md └── symfony-vite.svg ├── LICENSE ├── README.md ├── composer.json └── CHANGELOG.md /install/assets/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #eeeeee; 3 | } 4 | -------------------------------------------------------------------------------- /install/assets/app.js: -------------------------------------------------------------------------------- 1 | import "./app.css"; 2 | 3 | console.log("Happy coding !!"); -------------------------------------------------------------------------------- /src/Exception/VersionMismatchException.php: -------------------------------------------------------------------------------- 1 | ['html']])]; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Resources/views/Collector/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /install/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import symfonyPlugin from "vite-plugin-symfony"; 3 | 4 | /* if you're using React */ 5 | // import react from '@vitejs/plugin-react'; 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | /* react(), // if you're using React */ 10 | symfonyPlugin(), 11 | ], 12 | build: { 13 | rollupOptions: { 14 | input: { 15 | app: "./assets/app.js" 16 | }, 17 | } 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /src/Event/RenderAssetTagEvent.php: -------------------------------------------------------------------------------- 1 | build; 25 | } 26 | 27 | public function getTag(): Tag 28 | { 29 | return $this->tag; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /bin/phpunit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | debug->getViteCompleteConfigs(); 20 | 21 | $response = new Response( 22 | $this->twig->render('@PentatrionVite/Profiler/info.html.twig', [ 23 | 'viteConfigs' => $viteConfigs, 24 | ]) 25 | ); 26 | 27 | return $response; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /docs/migration.md: -------------------------------------------------------------------------------- 1 | 2 | ## Migration from v0.2.x to v1.x 3 | 4 | In version v0.2.x, you have to specify your entry points in an array in your `vite.config.js` file. in v1.x you need to specify your entry points in an object. 5 | 6 | ```diff 7 | -input: ["./assets/app.js"], 8 | +input: { 9 | + app: "./assets/app.js" 10 | +}, 11 | ``` 12 | 13 | this way you need to specify the named entry point in your twig functions. 14 | 15 | ```diff 16 | -{{ vite_entry_script_tags('app.js') }} 17 | +{{ vite_entry_script_tags('app') }} 18 | -{{ vite_entry_link_tags('app.js') }} 19 | +{{ vite_entry_link_tags('app') }} 20 | ``` 21 | 22 | In v1.x, your symfonyPlugin is a **function** and come from the `vite-plugin-symfony` package. 23 | 24 | ```diff 25 | + import symfonyPlugin from 'vite-plugin-symfony'; 26 | 27 | // ... 28 | plugins: [ 29 | /* react(), // if you're using React */ 30 | - symfonyPlugin, 31 | + symfonyPlugin(), 32 | ], 33 | ``` -------------------------------------------------------------------------------- /src/Service/TagRendererCollection.php: -------------------------------------------------------------------------------- 1 | $tagRendererLocator */ 11 | public function __construct( 12 | private ServiceLocator $tagRendererLocator, 13 | private string $defaultConfigName, 14 | ) { 15 | } 16 | 17 | public function getTagRenderer(?string $configName = null): TagRenderer 18 | { 19 | if (is_null($configName)) { 20 | $configName = $this->defaultConfigName; 21 | } 22 | 23 | if (!$this->tagRendererLocator->has($configName)) { 24 | throw new UndefinedConfigNameException(sprintf('The config "%s" is not set.', $configName)); 25 | } 26 | 27 | return $this->tagRendererLocator->get($configName); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Service/EntrypointsLookupCollection.php: -------------------------------------------------------------------------------- 1 | $entrypointsLookupLocator */ 11 | public function __construct( 12 | private ServiceLocator $entrypointsLookupLocator, 13 | private string $defaultConfigName, 14 | ) { 15 | } 16 | 17 | public function getEntrypointsLookup(?string $configName = null): EntrypointsLookup 18 | { 19 | if (is_null($configName)) { 20 | $configName = $this->defaultConfigName; 21 | } 22 | 23 | if (!$this->entrypointsLookupLocator->has($configName)) { 24 | throw new UndefinedConfigNameException(sprintf('The config "%s" is not set.', $configName)); 25 | } 26 | 27 | return $this->entrypointsLookupLocator->get($configName); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /docs/manual-installation.md: -------------------------------------------------------------------------------- 1 | ## Manual installation 2 | 3 | ```console 4 | composer require pentatrion/vite-bundle 5 | ``` 6 | 7 | if you do not want to use the recipe or want to see in depth what is modified by it, create a directory structure for your js/css files: 8 | 9 | ``` 10 | ├──assets 11 | │ ├──app.js 12 | │ ├──app.css 13 | │... 14 | ├──public 15 | ├──composer.json 16 | ├──package.json 17 | ├──vite.config.js 18 | ``` 19 | 20 | add vite route to your dev Symfony app. 21 | ```yaml 22 | # config/routes/dev/pentatrion_vite.yaml 23 | _pentatrion_vite: 24 | prefix: /build 25 | resource: "@PentatrionViteBundle/Resources/config/routing.yaml" 26 | ``` 27 | 28 | create or complete your [`package.json`](https://github.com/lhapaipai/vite-bundle/blob/main/install/package.json). 29 | 30 | 31 | create a [`vite.config.js`](https://github.com/lhapaipai/vite-bundle/blob/main/install/vite.config.js) file on your project root directory. 32 | the symfonyPlugin and the `manifest: true` are required for the bundle to work. when you run the `npm run dev` the plugin remove the manifest.json file so ViteBundle know that he must return the served files. 33 | when you run the `npm run build` the manifest.json is constructed and ViteBundle read his content to return the build files. 34 | -------------------------------------------------------------------------------- /src/CacheWarmer/EntrypointsCacheWarmer.php: -------------------------------------------------------------------------------- 1 | > $configs 14 | */ 15 | public function __construct( 16 | private string $publicPath, 17 | private array $configs, 18 | string $phpCacheFile) 19 | { 20 | parent::__construct($phpCacheFile); 21 | } 22 | 23 | protected function doWarmUp(string $cacheDir, ArrayAdapter $arrayAdapter, ?string $buildDir = null): bool 24 | { 25 | $fileAccessor = new FileAccessor($this->publicPath, $this->configs, $arrayAdapter); 26 | 27 | foreach ($this->configs as $configName => $config) { 28 | try { 29 | if ($fileAccessor->hasFile($configName, FileAccessor::ENTRYPOINTS)) { 30 | $fileAccessor->getData($configName, FileAccessor::ENTRYPOINTS); 31 | } 32 | } catch (\Exception) { 33 | // ignore exception 34 | } 35 | 36 | try { 37 | if ($fileAccessor->hasFile($configName, FileAccessor::MANIFEST)) { 38 | $fileAccessor->getData($configName, FileAccessor::MANIFEST); 39 | } 40 | } catch (\Exception) { 41 | // ignore exception 42 | } 43 | } 44 | 45 | return true; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/DataCollector/ViteCollector.php: -------------------------------------------------------------------------------- 1 | data = $this->entrypointRenderer->getRenderedTags(); 21 | } 22 | 23 | /** 24 | * @return array 25 | */ 26 | public function getRenderedTags(): array 27 | { 28 | /* @phpstan-ignore-next-line data is array */ 29 | return $this->data; 30 | } 31 | 32 | /** 33 | * @return array 34 | */ 35 | public function getRenderedScripts(): array 36 | { 37 | /* @phpstan-ignore-next-line data is array */ 38 | return array_filter($this->data, fn (Tag $tag) => $tag->isScriptTag()); 39 | } 40 | 41 | /** 42 | * @return array 43 | */ 44 | public function getRenderedStylesheets(): array 45 | { 46 | /* @phpstan-ignore-next-line data is array */ 47 | return array_filter($this->data, fn (Tag $tag) => $tag->isStylesheet()); 48 | } 49 | 50 | public function getName(): string 51 | { 52 | return 'pentatrion_vite.vite_collector'; 53 | } 54 | 55 | public static function getTemplate(): ?string 56 | { 57 | return '@PentatrionVite/Collector/vite_collector.html.twig'; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /docs/migration-webpack-encore.md: -------------------------------------------------------------------------------- 1 | ### Migration from Webpack Encore 2 | 3 | ## Install 4 | 5 | WebpackEncoreBundle is linked with a Symfony Recipe. Before remove this bundle, backup your `assets` content and `package.json`/`package-lock.json` in another location. They will be deleted when you'll remove the bundle. 6 | 7 | ```console 8 | mv assets assets.bak 9 | mv package.json package.json.bak 10 | mv package-lock.json package-lock.json.bak 11 | composer remove symfony/webpack-encore-bundle 12 | ``` 13 | 14 | You can safely rename your backup and install the ViteBundle 15 | ```console 16 | mv assets.bak assets 17 | mv package.json.bak package.json 18 | mv package-lock.json.bak package-lock.json 19 | composer require pentatrion/vite-bundle 20 | ``` 21 | 22 | You need to add manually the `vite` and `vite-plugin-symfony` packages and scripts in your existant `package.json`. check the [package.json](https://github.com/lhapaipai/vite-bundle/blob/main/install/package.json) reference file. 23 | 24 | 25 | ## Configuration 26 | 27 | There is some minor differences with the twig functions 28 | 29 | 30 | ```diff 31 | // webpack.config.js 32 | -Encore.addEntry("app", "./assets/app.js"); 33 | ``` 34 | 35 | ```diff 36 | // vite.config.js 37 | +export default { 38 | + // ... 39 | + plugins: [ 40 | + symfonyPlugin() 41 | + ], 42 | + build: { 43 | + rollupOptions: { 44 | + input: { 45 | + app: "./assets/app.js" 46 | + }, 47 | + }, 48 | + }, 49 | +}; 50 | ``` 51 | 52 | 53 | ```diff 54 | {% block stylesheets %} 55 | - {{ encore_entry_link_tags('app') }} 56 | + {{ vite_entry_link_tags("app") }} 57 | {% endblock %} 58 | 59 | {% block javascripts %} 60 | - {{ encore_entry_script_tags('app') }} 61 | + {{ vite_entry_script_tags("app") }} 62 | {% endblock %} 63 | ``` 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/Controller/ViteController.php: -------------------------------------------------------------------------------- 1 | defaultConfig; 24 | } 25 | 26 | $entrypointsLookup = $this->entrypointsLookupCollection->getEntrypointsLookup($configName); 27 | $origin = $this->proxyOrigin ?? $this->resolveDevServer($entrypointsLookup); 28 | $base = $entrypointsLookup->getBase(); 29 | 30 | $response = $this->httpClient->request( 31 | 'GET', 32 | $origin.$base.$path, 33 | ['headers' => ['Accept-Encoding' => '']], 34 | ); 35 | 36 | $content = $response->getContent(); 37 | $statusCode = $response->getStatusCode(); 38 | $headers = $response->getHeaders(); 39 | 40 | return new Response($content, $statusCode, $headers); 41 | } 42 | 43 | private function resolveDevServer(EntrypointsLookup $entrypointsLookup): string 44 | { 45 | $viteDevServer = $entrypointsLookup->getViteServer(); 46 | 47 | if (is_null($viteDevServer)) { 48 | throw new \Exception('Vite dev server not available'); 49 | } 50 | 51 | return $viteDevServer; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Twig/EntryFilesTwigExtension.php: -------------------------------------------------------------------------------- 1 | , 13 | * dependency?: "react"|null 14 | * } 15 | * @phpstan-type ViteEntryLinkTagsOptions array{ 16 | * absolute_url?: bool, 17 | * attr?: array, 18 | * preloadDynamicImports?: bool 19 | * } 20 | */ 21 | class EntryFilesTwigExtension extends AbstractExtension 22 | { 23 | public function __construct(private EntrypointRenderer $entrypointRenderer) 24 | { 25 | } 26 | 27 | public function getFunctions(): array 28 | { 29 | return [ 30 | new TwigFunction('vite_entry_script_tags', [$this, 'renderViteScriptTags'], ['is_safe' => ['html']]), 31 | new TwigFunction('vite_entry_link_tags', [$this, 'renderViteLinkTags'], ['is_safe' => ['html']]), 32 | new TwigFunction('vite_mode', [$this, 'getViteMode']), 33 | ]; 34 | } 35 | 36 | public function getViteMode(?string $configName = null): ?string 37 | { 38 | return $this->entrypointRenderer->getMode($configName); 39 | } 40 | 41 | /** 42 | * @param ViteEntryScriptTagsOptions $options 43 | */ 44 | public function renderViteScriptTags(string $entryName, array $options = [], ?string $configName = null): string 45 | { 46 | return $this->entrypointRenderer->renderScripts($entryName, $options, $configName); 47 | } 48 | 49 | /** 50 | * @param ViteEntryLinkTagsOptions $options 51 | */ 52 | public function renderViteLinkTags(string $entryName, array $options = [], ?string $configName = null): string 53 | { 54 | return $this->entrypointRenderer->renderLinks($entryName, $options, $configName); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | Symfony logo 4 |

5 |

6 | 7 | 8 | 9 |

10 |
11 | 12 | 13 | 14 | # ViteBundle : Symfony integration with Vite 15 | 16 | > [!IMPORTANT] 17 | > 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. 18 | 19 | > [!IMPORTANT] 20 | > 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. 21 | 22 | 23 | This bundle helps you render all the dynamic `script` and `link` tags needed. 24 | Essentially, it provides two twig functions to load the correct scripts into your templates. 25 | 26 | ## Installation 27 | 28 | Install the bundle with: 29 | 30 | ```console 31 | composer require pentatrion/vite-bundle 32 | ``` 33 | 34 | ```bash 35 | npm install 36 | 37 | # start your vite dev server 38 | npm run dev 39 | ``` 40 | 41 | Add these twig functions in any template or base layout where you need to include a JavaScript entry: 42 | 43 | ```twig 44 | {% block stylesheets %} 45 | {{ vite_entry_link_tags('app') }} 46 | {% endblock %} 47 | 48 | {% block javascripts %} 49 | {{ vite_entry_script_tags('app') }} 50 | 51 | {# if you are using React, you have to replace with this #} 52 | {{ vite_entry_script_tags('app', { dependency: 'react' }) }} 53 | {% endblock %} 54 | ``` 55 | 56 | [Read the Docs to Learn More](https://symfony-vite.pentatrion.com). 57 | 58 | ## Ecosystem 59 | 60 | | Package | Description | 61 | | ----------------------------------------------------------------------- | :------------------------ | 62 | | [vite-plugin-symfony](https://github.com/lhapaipai/vite-plugin-symfony) | Vite plugin (read-only) | 63 | | [symfony-vite-dev](https://github.com/lhapaipai/symfony-vite-dev) | Package for contributors | 64 | 65 | ## License 66 | 67 | [MIT](LICENSE). 68 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pentatrion/vite-bundle", 3 | "description": "Vite integration for your Symfony app", 4 | "keywords": [ 5 | "bundle", 6 | "symfony", 7 | "vite", 8 | "vitejs" 9 | ], 10 | "type": "symfony-bundle", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Hugues Tavernier", 15 | "email": "hugues.tavernier@protonmail.com" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.0", 20 | "symfony/asset": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0", 21 | "symfony/config": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0", 22 | "symfony/dependency-injection": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0", 23 | "symfony/http-client": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0", 24 | "symfony/http-kernel": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0", 25 | "symfony/framework-bundle": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0", 26 | "symfony/twig-bundle": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Pentatrion\\ViteBundle\\": "src/" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Pentatrion\\ViteBundle\\Tests\\": "tests/" 36 | } 37 | }, 38 | "require-dev": { 39 | "friendsofphp/php-cs-fixer": "^3.9", 40 | "phpstan/phpstan": "^1.8", 41 | "phpstan/phpstan-symfony": "^1.3", 42 | "phpunit/phpunit": "^9.5", 43 | "symfony/phpunit-bridge": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0", 44 | "symfony/web-link": "^4.4 || ^5.0 || ^6.0 || ^7.0 || ^8.0" 45 | }, 46 | "scripts": { 47 | "cs-fix": "php8.3 vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php", 48 | "ci-check": [ 49 | "find -L . -path ./vendor -prune -o -type f -name '*.php' -print0 | xargs -0 -n 1 -P $(nproc) php8.1 -l", 50 | "find -L . -path ./vendor -prune -o -type f -name '*.php' -print0 | xargs -0 -n 1 -P $(nproc) php8.2 -l", 51 | "find -L . -path ./vendor -prune -o -type f -name '*.php' -print0 | xargs -0 -n 1 -P $(nproc) php8.3 -l", 52 | "php8.3 vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --diff --dry-run --stop-on-violation --using-cache=no", 53 | "php8.3 vendor/bin/phpstan analyse --configuration=phpstan.neon" 54 | ], 55 | "phpstan-82": "php8.2 vendor/bin/phpstan analyse --configuration=phpstan.neon", 56 | "phpstan-80": "php8.0 vendor/bin/phpstan analyse --configuration=phpstan.php80.neon" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Resources/views/Collector/vite_collector.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "@WebProfiler/Profiler/layout.html.twig" %} 2 | 3 | {% block toolbar %} 4 | {% set icon %} 5 | {{ include('@PentatrionVite/Collector/icon.svg') }} 6 | Vite 7 | {% endset %} 8 | {% set text %} 9 |
10 | Vite dev Server 11 | 12 | Config 13 | 14 |
15 | 16 |
17 | Rendered scripts 18 | {{ collector.renderedScripts | length }} 19 |
20 |
21 | Rendered links 22 | {{ collector.renderedStylesheets | length }} 23 |
24 | {% endset %} 25 | {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { 'link': true }) }} 26 | {% endblock %} 27 | 28 | {% block menu %} 29 | 30 | 31 | {{ include('@PentatrionVite/Collector/icon.svg') }} 32 | 33 | Vite 34 | 35 | {% endblock %} 36 | 37 | {% block panel %} 38 |

Rendered tags

39 | {% for tag in collector.renderedTags %} 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | {% for key, value in tag.validAttributes %} 71 | 72 | 73 | 76 | 77 | {% endfor %} 78 | 79 |
{{ tag.filename }}
tag<{{ tag.tagName }}>
origin{{ tag.origin }}
renderAsTag{{ tag.renderAsTag | symfonyvite_stringify }}
renderAsLinkHeader{{ tag.renderAsLinkHeader | symfonyvite_stringify }}
content{{ tag.content | symfonyvite_stringify }}
Attributes
{{ key }} 74 |
{{ value | symfonyvite_stringify }}
75 |
80 | {% endfor %} 81 | 82 | {% endblock %} 83 | -------------------------------------------------------------------------------- /src/Util/InlineContent.php: -------------------------------------------------------------------------------- 1 | anyway 9 | * https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc 10 | */ 11 | public const SAFARI10_NO_MODULE_FIX_INLINE_CODE = '!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",(function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()}),!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();'; 12 | 13 | /** 14 | * set or not the __vite_is_modern_browser variable 15 | * https://github.com/vitejs/vite/pull/15021. 16 | */ 17 | public const DETECT_MODERN_BROWSER_INLINE_CODE = 'import.meta.url;import("_").catch(()=>1);(async function*(){})().next();if(location.protocol!="file:"){window.__vite_is_modern_browser=true}'; 18 | 19 | /* if your browser understands the modules but not dynamic import, 20 | * load the legacy entrypoints 21 | * 22 | * load the 23 | * and the 24 | * if browser accept modules but don't dynamic import or import.meta 25 | */ 26 | public const DYNAMIC_FALLBACK_INLINE_CODE = << {} 55 | window.\$RefreshSig$ = () => (type) => type 56 | window.__vite_plugin_react_preamble_installed__ = true\n 57 | INLINE; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/EventListener/PreloadAssetsEventListener.php: -------------------------------------------------------------------------------- 1 | isMainRequest()) { 21 | return; 22 | } 23 | 24 | $request = $event->getRequest(); 25 | 26 | if (!$request->attributes->has('_links')) { 27 | $request->attributes->set( 28 | '_links', 29 | new GenericLinkProvider() 30 | ); 31 | } 32 | 33 | /** @var GenericLinkProvider $linkProvider */ 34 | $linkProvider = $request->attributes->get('_links'); 35 | 36 | foreach ($this->entrypointRenderer->getRenderedTags() as $tag) { 37 | if (!$tag->isRenderAsLinkHeader()) { 38 | continue; 39 | } 40 | 41 | $link = null; 42 | 43 | if ($tag->isStylesheet()) { 44 | $href = $tag->getAttribute('href'); 45 | if (!is_string($href)) { 46 | continue; 47 | } 48 | 49 | $link = $this->createLink('preload', $href)->withAttribute('as', 'style'); 50 | } elseif ($tag->isPreload()) { 51 | $href = $tag->getAttribute('href'); 52 | $rel = $tag->getAttribute('rel'); 53 | if (!is_string($href) || !is_string($rel)) { 54 | continue; 55 | } 56 | 57 | if ('modulepreload' === $rel) { 58 | $link = $this->createLink('modulepreload', $href); 59 | } elseif ('preload' === $rel) { 60 | $link = $this->createLink('preload', $href)->withAttribute('as', $tag->getAttribute('as') ?? false); 61 | } 62 | } elseif ($tag->isScriptTag()) { 63 | $src = $tag->getAttribute('src'); 64 | if (!is_string($src)) { 65 | continue; 66 | } 67 | 68 | if ($tag->isModule()) { 69 | $link = $this->createLink('modulepreload', $src); 70 | } else { 71 | $link = $this->createLink('preload', $src)->withAttribute('as', 'script'); 72 | } 73 | } 74 | 75 | if (is_null($link)) { 76 | continue; 77 | } 78 | 79 | $crossOrigin = $tag->getAttribute('crossorigin'); 80 | if (true === $crossOrigin || is_string($crossOrigin)) { 81 | $link = $link->withAttribute('crossorigin', $crossOrigin); 82 | } 83 | 84 | $linkProvider = $linkProvider->withLink($link); 85 | } 86 | 87 | $request->attributes->set('_links', $linkProvider); 88 | } 89 | 90 | private function createLink(string $rel, string $href): Link 91 | { 92 | return new Link($rel, $href); 93 | } 94 | 95 | public static function getSubscribedEvents(): array 96 | { 97 | return [ 98 | // must run before AddLinkHeaderListener 99 | 'kernel.response' => ['onKernelResponse', 50], 100 | ]; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /docs/vitejs.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 20 | 21 | 23 | image/svg+xml 24 | 26 | 27 | 28 | 29 | 30 | 50 | 53 | 54 | 58 | 63 | 68 | 69 | 71 | 78 | 81 | 85 | 86 | 93 | 96 | 100 | 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /docs/symfony.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 60 | 61 | 62 | 63 | 74 | 75 | -------------------------------------------------------------------------------- /src/Resources/config/services.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | pentatrion_vite.entrypoints_lookup_collection: 3 | class: Pentatrion\ViteBundle\Service\EntrypointsLookupCollection 4 | 5 | Pentatrion\ViteBundle\Service\EntrypointsLookupCollection: 6 | alias: pentatrion_vite.entrypoints_lookup_collection 7 | 8 | pentatrion_vite.entrypoint_renderer: 9 | class: Pentatrion\ViteBundle\Service\EntrypointRenderer 10 | tags: 11 | - { name: "kernel.reset", method: reset } 12 | arguments: 13 | - "@pentatrion_vite.entrypoints_lookup_collection" 14 | - "@pentatrion_vite.tag_renderer_collection" 15 | - "%pentatrion_vite.default_config%" 16 | - "%pentatrion_vite.absolute_url%" 17 | - "@?request_stack" 18 | - "@?event_dispatcher" 19 | 20 | Pentatrion\ViteBundle\Service\EntrypointRenderer: 21 | alias: "pentatrion_vite.entrypoint_renderer" 22 | 23 | pentatrion_vite.tag_renderer_collection: 24 | class: Pentatrion\ViteBundle\Service\TagRendererCollection 25 | 26 | Pentatrion\ViteBundle\Service\TagRendererCollection: 27 | alias: pentatrion_vite.tag_renderer_collection 28 | 29 | 30 | pentatrion_vite.twig_type_extension: 31 | class: Pentatrion\ViteBundle\Twig\TypeExtension 32 | tags: ["twig.extension"] 33 | 34 | pentatrion_vite.twig_entry_files_extension: 35 | class: Pentatrion\ViteBundle\Twig\EntryFilesTwigExtension 36 | tags: ["twig.extension"] 37 | arguments: 38 | - "@pentatrion_vite.entrypoint_renderer" 39 | 40 | Pentatrion\ViteBundle\DataCollector\ViteCollector: 41 | tags: 42 | - { name: "data_collector", id: pentatrion_vite.vite_collector } 43 | arguments: 44 | - "@pentatrion_vite.entrypoint_renderer" 45 | 46 | Pentatrion\ViteBundle\Service\Debug: 47 | alias: pentatrion_vite.debug 48 | 49 | pentatrion_vite.debug: 50 | class: Pentatrion\ViteBundle\Service\Debug 51 | arguments: 52 | - "%pentatrion_vite.configs%" 53 | - "@http_client" 54 | - "@pentatrion_vite.entrypoints_lookup_collection" 55 | - "%pentatrion_vite.proxy_origin%" 56 | 57 | Pentatrion\ViteBundle\Controller\ViteController: 58 | tags: ["controller.service_arguments"] 59 | arguments: 60 | - "%pentatrion_vite.default_config%" 61 | - "@http_client" 62 | - "@pentatrion_vite.entrypoints_lookup_collection" 63 | - "%pentatrion_vite.proxy_origin%" 64 | 65 | Pentatrion\ViteBundle\Controller\ProfilerController: 66 | tags: ["controller.service_arguments"] 67 | arguments: 68 | - "@pentatrion_vite.debug" 69 | - "@twig" 70 | 71 | Pentatrion\ViteBundle\Asset\ViteAssetVersionStrategy: 72 | arguments: 73 | - "@pentatrion_vite.file_accessor" 74 | - "%pentatrion_vite.default_config%" 75 | - "%pentatrion_vite.absolute_url%" 76 | - "@?request_stack" 77 | - "%pentatrion_vite.throw_on_missing_asset%" 78 | 79 | pentatrion_vite.preload_assets_event_listener: 80 | class: Pentatrion\ViteBundle\EventListener\PreloadAssetsEventListener 81 | tags: ["kernel.event_subscriber"] 82 | arguments: 83 | - "@pentatrion_vite.entrypoint_renderer" 84 | 85 | pentatrion_vite.file_accessor: 86 | class: Pentatrion\ViteBundle\Service\FileAccessor 87 | arguments: 88 | - "%kernel.project_dir%%pentatrion_vite.public_directory%" 89 | - "%pentatrion_vite.configs%" 90 | - null 91 | 92 | pentatrion_vite.cache: 93 | class: Symfony\Component\Cache\Adapter\PhpArrayAdapter 94 | factory: [Symfony\Component\Cache\Adapter\PhpArrayAdapter, create] 95 | arguments: 96 | - "%kernel.cache_dir%/pentatrion_vite.cache.php" 97 | - "@cache.pentatrion_vite_fallback" 98 | 99 | pentatrion_vite.cache_warmer: 100 | class: Pentatrion\ViteBundle\CacheWarmer\EntrypointsCacheWarmer 101 | tags: ["kernel.cache_warmer"] 102 | arguments: 103 | - "%kernel.project_dir%%pentatrion_vite.public_directory%" 104 | - "%pentatrion_vite.configs%" 105 | - "%kernel.cache_dir%/pentatrion_vite.cache.php" 106 | 107 | cache.pentatrion_vite_fallback: 108 | tags: ["cache.pool"] 109 | parent: cache.system 110 | -------------------------------------------------------------------------------- /docs/vite-legacy.md: -------------------------------------------------------------------------------- 1 | 2 | with Vite Dev Server 3 | 4 | ```html 5 | 6 | 7 | ``` 8 | 9 | after build 10 | 11 | ```html 12 | 13 | 14 | 15 | 16 | 17 | 20 | 21 | 34 | 35 | 36 | 37 | 38 | 39 | 42 | 43 | 44 | 61 | 62 | 63 | 64 | 65 | 66 | 69 | 70 | 71 | 97 | 98 | 99 | 100 | 101 | 102 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | ``` 112 | -------------------------------------------------------------------------------- /src/Asset/ViteAssetVersionStrategy.php: -------------------------------------------------------------------------------- 1 | setConfig($this->configName); 30 | } 31 | 32 | public function setConfig(string $configName): void 33 | { 34 | $this->viteMode = null; 35 | $this->configName = $configName; 36 | } 37 | 38 | /** 39 | * With a entrypoints, we don't really know or care about what 40 | * the version is. Instead, this returns the path to the 41 | * versioned file. as it contains a hashed and different path 42 | * with each new config, this is enough for us. 43 | */ 44 | public function getVersion(string $path): string 45 | { 46 | return $this->applyVersion($path); 47 | } 48 | 49 | public function applyVersion(string $path): string 50 | { 51 | return $this->getAssetPath($path) ?: $path; 52 | } 53 | 54 | private function completeURL(string $path): string 55 | { 56 | if (str_starts_with($path, 'http') || false === $this->useAbsoluteUrl || null === $this->requestStack || null === $this->requestStack->getCurrentRequest()) { 57 | return $path; 58 | } 59 | 60 | return $this->requestStack->getCurrentRequest()->getUriForPath($path); 61 | } 62 | 63 | private function getAssetPath(string $path): string 64 | { 65 | if (null === $this->viteMode) { 66 | $this->entrypointsData = $this->fileAccessor->getData($this->configName, FileAccessor::ENTRYPOINTS); 67 | $this->viteMode = empty($this->entrypointsData['viteServer']) ? 'build' : 'dev'; 68 | 69 | $this->manifestData = 'build' === $this->viteMode ? $this->fileAccessor->getData($this->configName, FileAccessor::MANIFEST) : null; 70 | } 71 | 72 | if ('build' === $this->viteMode) { 73 | if (isset($this->manifestData[$path])) { 74 | return $this->completeURL($this->entrypointsData['base'].$this->manifestData[$path]['file']); 75 | } 76 | } else { 77 | return $this->entrypointsData['viteServer'].$this->entrypointsData['base'].$path; 78 | } 79 | 80 | if ($this->strictMode) { 81 | $message = sprintf('assets "%s" not found in manifest file from config "%s".', $path, $this->configName); 82 | $alternatives = $this->findAlternatives($path, $this->manifestData); 83 | if (\count($alternatives) > 0) { 84 | $message .= sprintf(' Did you mean one of these? "%s".', implode('", "', $alternatives)); 85 | } 86 | 87 | throw new AssetNotFoundException($message, $alternatives); 88 | } 89 | 90 | return $this->entrypointsData['base'].$path; 91 | } 92 | 93 | /** 94 | * @param ManifestFile|null $manifestData 95 | * 96 | * @return array 97 | */ 98 | private function findAlternatives(string $path, ?array $manifestData): array 99 | { 100 | $path = strtolower($path); 101 | $alternatives = []; 102 | 103 | if (is_null($manifestData)) { 104 | return $alternatives; 105 | } 106 | 107 | foreach ($manifestData as $key => $value) { 108 | $lev = levenshtein($path, strtolower($key)); 109 | if ($lev <= \strlen($path) / 3 || false !== stripos($key, $path)) { 110 | $alternatives[$key] = isset($alternatives[$key]) ? min($lev, $alternatives[$key]) : $lev; 111 | } 112 | } 113 | 114 | asort($alternatives); 115 | 116 | return array_keys($alternatives); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Model/Tag.php: -------------------------------------------------------------------------------- 1 | $attributes 15 | */ 16 | public function __construct( 17 | private string $tagName, 18 | private array $attributes = [], 19 | private string $content = '', 20 | private string $origin = '', 21 | string $preloadOption = 'link-tag', 22 | private bool $internal = false, 23 | ) { 24 | if (self::LINK_TAG === $tagName && isset($attributes['rel'])) { 25 | if (in_array($attributes['rel'], ['modulepreload', 'preload']) && 'link-tag' !== $preloadOption) { 26 | $this->renderAsTag = false; 27 | } 28 | 29 | if ('link-header' === $preloadOption) { 30 | $this->renderAsLinkHeader = true; 31 | } 32 | } 33 | if (self::SCRIPT_TAG === $tagName) { 34 | if ('link-header' === $preloadOption && isset($attributes['src'])) { 35 | $this->renderAsLinkHeader = true; 36 | } 37 | } 38 | } 39 | 40 | public function getFilename(): string 41 | { 42 | $src = self::SCRIPT_TAG === $this->tagName ? ($this->attributes['src'] ?? null) : ($this->attributes['href'] ?? null); 43 | 44 | if (is_string($src)) { 45 | return basename($src); 46 | } 47 | 48 | return 'unknown'; 49 | } 50 | 51 | public function getTagName(): string 52 | { 53 | return $this->tagName; 54 | } 55 | 56 | public function isScriptTag(): bool 57 | { 58 | return self::SCRIPT_TAG === $this->tagName; 59 | } 60 | 61 | public function isLinkTag(): bool 62 | { 63 | return self::LINK_TAG === $this->tagName; 64 | } 65 | 66 | public function isStylesheet(): bool 67 | { 68 | return self::LINK_TAG === $this->tagName && 'stylesheet' === $this->getAttribute('rel'); 69 | } 70 | 71 | public function isPreload(): bool 72 | { 73 | return self::LINK_TAG === $this->tagName && in_array($this->getAttribute('rel'), ['preload', 'modulepreload']); 74 | } 75 | 76 | public function isModule(): bool 77 | { 78 | return self::SCRIPT_TAG === $this->tagName && 'module' === $this->getAttribute('type'); 79 | } 80 | 81 | /** 82 | * @return array 83 | */ 84 | public function getAttributes(): array 85 | { 86 | return $this->attributes; 87 | } 88 | 89 | /** 90 | * @return array 91 | */ 92 | public function getValidAttributes(): array 93 | { 94 | return array_filter( 95 | $this->attributes, 96 | function ($value, $key) { 97 | return null !== $value && false !== $value; 98 | }, 99 | ARRAY_FILTER_USE_BOTH 100 | ); 101 | } 102 | 103 | public function getAttribute(string $key): string|bool|null 104 | { 105 | return $this->attributes[$key] ?? null; 106 | } 107 | 108 | /** 109 | * @param string $name The attribute name 110 | * @param string|bool $value Value can be "true" to have an attribute without a value (e.g. "defer") 111 | */ 112 | public function setAttribute(string $name, string|bool $value): self 113 | { 114 | $this->attributes[$name] = $value; 115 | 116 | return $this; 117 | } 118 | 119 | public function removeAttribute(string $name): self 120 | { 121 | unset($this->attributes[$name]); 122 | 123 | return $this; 124 | } 125 | 126 | public function getContent(): string 127 | { 128 | return $this->content; 129 | } 130 | 131 | public function setContent(string $content): self 132 | { 133 | $this->content = $content; 134 | 135 | return $this; 136 | } 137 | 138 | public function removeContent(): self 139 | { 140 | $this->content = ''; 141 | 142 | return $this; 143 | } 144 | 145 | public function getOrigin(): string 146 | { 147 | return $this->origin; 148 | } 149 | 150 | public function isInternal(): bool 151 | { 152 | return $this->internal; 153 | } 154 | 155 | public function isRenderAsTag(): bool 156 | { 157 | return $this->renderAsTag; 158 | } 159 | 160 | public function setRenderAsTag(bool $val): self 161 | { 162 | $this->renderAsTag = $val; 163 | 164 | return $this; 165 | } 166 | 167 | public function isRenderAsLinkHeader(): bool 168 | { 169 | return $this->renderAsLinkHeader; 170 | } 171 | 172 | public function setRenderAsLinkHeader(bool $val): self 173 | { 174 | $this->renderAsLinkHeader = $val; 175 | 176 | return $this; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/Service/FileAccessor.php: -------------------------------------------------------------------------------- 1 | , 13 | * js?: array, 14 | * css?: array, 15 | * preload?: array, 16 | * dynamic?: array, 17 | * legacy: false|string, 18 | * } 19 | * @phpstan-type FileMetadatas array{ 20 | * hash: string|null 21 | * } 22 | * @phpstan-type EntryPointsFile array{ 23 | * base: string, 24 | * entryPoints: array, 25 | * legacy: bool, 26 | * metadatas: array, 27 | * version: array{0: string, 1: int, 2: int, 3: int}, 28 | * viteServer: string|null 29 | * } 30 | * @phpstan-type ManifestEntry array{ 31 | * file: string, 32 | * src?: string, 33 | * isDynamicEntry?: bool, 34 | * isEntry?: bool, 35 | * imports?: array, 36 | * css?: array 37 | * } 38 | * @phpstan-type ManifestFile array 39 | */ 40 | class FileAccessor 41 | { 42 | public const ENTRYPOINTS = 'entrypoints'; 43 | public const MANIFEST = 'manifest'; 44 | 45 | public const FILES = [ 46 | self::ENTRYPOINTS => 'entrypoints.json', 47 | self::MANIFEST => 'manifest.json', 48 | ]; 49 | 50 | /** @var array> */ 51 | private array $content; 52 | 53 | /** @param array> $configs */ 54 | public function __construct( 55 | private string $publicPath, 56 | private array $configs, 57 | private ?CacheItemPoolInterface $cache = null, 58 | ) { 59 | } 60 | 61 | public function hasFile(string $configName, string $fileType): bool 62 | { 63 | $basePath = $this->publicPath.$this->configs[$configName]['base']; 64 | 65 | return file_exists($basePath.'.vite/'.self::FILES[$fileType]) || file_exists($basePath.self::FILES[$fileType]); 66 | } 67 | 68 | /** 69 | * @param key-of $fileType 70 | * 71 | * @phpstan-return ($fileType is 'entrypoints' ? EntryPointsFile : ManifestFile) 72 | */ 73 | public function getData(string $configName, string $fileType): array 74 | { 75 | $cacheItem = null; 76 | if (!isset($this->content[$configName][$fileType])) { 77 | if ($this->cache) { 78 | $cacheItem = $this->cache->getItem("$configName.$fileType"); 79 | 80 | if ($cacheItem->isHit()) { 81 | /** @var EntryPointsFile|ManifestFile $data */ 82 | $data = $cacheItem->get(); 83 | $this->content[$configName][$fileType] = $data; 84 | } 85 | } 86 | 87 | if (!isset($this->content[$configName][$fileType])) { 88 | $filePath = $this->publicPath.$this->configs[$configName]['base'].self::FILES[$fileType]; 89 | $basePath = $this->publicPath.$this->configs[$configName]['base']; 90 | 91 | if (($scheme = parse_url($filePath, \PHP_URL_SCHEME)) && str_starts_with($scheme, 'http')) { 92 | throw new \Exception('You can\'t use a remote manifest with pentatrion/vite-bundle'); 93 | } 94 | 95 | if (file_exists($basePath.'.vite/'.self::FILES[$fileType])) { 96 | $filePath = $basePath.'.vite/'.self::FILES[$fileType]; 97 | } elseif (file_exists($basePath.self::FILES[$fileType])) { 98 | $filePath = $basePath.self::FILES[$fileType]; 99 | } else { 100 | throw new EntrypointsFileNotFoundException("$fileType not found at $basePath. Did you forget configure your `build_directory` in pentatrion_vite.yml"); 101 | } 102 | 103 | /** @var EntryPointsFile|ManifestFile $content */ 104 | $content = json_decode((string) file_get_contents($filePath), true, flags: \JSON_THROW_ON_ERROR); 105 | 106 | if (self::ENTRYPOINTS === $fileType) { 107 | /** @var EntryPointsFile $content */ 108 | $pluginVersion = $content['version']; 109 | // VERSION[1] => Major version number 110 | if (PentatrionViteBundle::VERSION[1] !== $pluginVersion[1]) { 111 | throw new VersionMismatchException('your vite-plugin-symfony is outdated, run : npm install vite-plugin-symfony@^'.PentatrionViteBundle::VERSION[1]); 112 | } 113 | } 114 | 115 | if ($this->cache && null !== $cacheItem) { 116 | $this->cache->save($cacheItem->set($content)); 117 | } 118 | 119 | $this->content[$configName][$fileType] = $content; 120 | } 121 | } 122 | 123 | return $this->content[$configName][$fileType]; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Service/Debug.php: -------------------------------------------------------------------------------- 1 | |null 35 | * }> 36 | */ 37 | public function getViteCompleteConfigs(): array 38 | { 39 | $viteServerRequests = array_map( 40 | function ($configName) { 41 | $entrypointsLookup = $this->entrypointsLookupCollection->getEntrypointsLookup($configName); 42 | $viteServer = $this->proxyOrigin ?? $entrypointsLookup->getViteServer(); 43 | 44 | return [ 45 | 'configName' => $configName, 46 | 'response' => is_null($viteServer) 47 | ? null 48 | : $this->httpClient->request('GET', self::getInfoUrl($viteServer, $entrypointsLookup->getBase())), 49 | ]; 50 | }, 51 | array_keys($this->configs) 52 | ); 53 | 54 | $viteConfigs = array_map( 55 | function ($request) { 56 | $content = null; 57 | try { 58 | if (!is_null($request['response'])) { 59 | /** @var array $data */ 60 | $data = json_decode($request['response']->getContent(), true); 61 | $content = self::prepareViteConfig($data); 62 | } 63 | } catch (\Exception $e) { 64 | // dev server is not running 65 | } 66 | 67 | return [ 68 | 'configName' => $request['configName'], 69 | 'content' => $content, 70 | ]; 71 | }, 72 | $viteServerRequests 73 | ); 74 | 75 | return $viteConfigs; 76 | } 77 | 78 | /** 79 | * @param array $config 80 | * 81 | * @return array 82 | */ 83 | public static function prepareViteConfig($config) 84 | { 85 | $output = [ 86 | 'principal' => [], 87 | ]; 88 | $groupKeys = ['build', 'define', 'env', 'esbuild', 'experimental', 'inlineConfig', 'logger', 'optimizeDeps', 'resolve', 'server', 'ssr', 'worker']; 89 | /** @var array $value */ 90 | foreach ($config as $key => $value) { 91 | if (in_array($key, $groupKeys)) { 92 | if (\is_array($value)) { 93 | ksort($value); 94 | } 95 | $output[$key] = $value; 96 | } else { 97 | $output['principal'][$key] = $value; 98 | } 99 | } 100 | 101 | ksort($output['principal']); 102 | 103 | return $output; 104 | } 105 | 106 | public static function stringifyScalar(mixed $value): string 107 | { 108 | if (!is_scalar($value)) { 109 | throw new \Exception('Unable to stringify no scalar value'); 110 | } 111 | 112 | if (is_bool($value)) { 113 | return $value ? 'true' : 'false'; 114 | } 115 | if ('' === $value) { 116 | return 'Empty string'; 117 | } 118 | 119 | return (string) $value; 120 | } 121 | 122 | public static function stringify(mixed $value): string 123 | { 124 | if (is_null($value)) { 125 | return 'null'; 126 | } 127 | 128 | if (is_scalar($value)) { 129 | return self::stringifyScalar($value); 130 | } 131 | 132 | if (is_array($value)) { 133 | if (0 === count($value)) { 134 | return '[]'; 135 | } 136 | $content = '
    '; 137 | foreach ($value as $k => $v) { 138 | $content .= '
  • '; 139 | 140 | if (is_string($k)) { 141 | $content .= $k.': '; 142 | } 143 | 144 | if (is_scalar($v)) { 145 | $content .= self::stringifyScalar($v); 146 | } else { 147 | $content .= self::stringify($v); 148 | } 149 | 150 | $content .= '
  • '; 151 | } 152 | $content .= '
'; 153 | 154 | return $content; 155 | } 156 | 157 | return '
'.print_r($value, true).'
'; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/Service/EntrypointsLookup.php: -------------------------------------------------------------------------------- 1 | fileAccessor->hasFile($this->configName, FileAccessor::ENTRYPOINTS); 26 | } 27 | 28 | /** 29 | * @phpstan-return EntryPointsFile 30 | */ 31 | private function getFileContent(): array 32 | { 33 | if (is_null($this->fileContent)) { 34 | $this->fileContent = $this->fileAccessor->getData($this->configName, FileAccessor::ENTRYPOINTS); 35 | 36 | if (!array_key_exists('entryPoints', $this->fileContent) 37 | || !array_key_exists('viteServer', $this->fileContent) 38 | || !array_key_exists('base', $this->fileContent) 39 | ) { 40 | throw new \Exception("$this->configName entrypoints.json : entryPoints, base or viteServer not exists"); 41 | } 42 | } 43 | 44 | return $this->fileContent; 45 | } 46 | 47 | public function getFileHash(string $filePath): ?string 48 | { 49 | $infos = $this->getFileContent(); 50 | 51 | /* @phpstan-ignore-next-line always evaluate to false but can be possible with legacy vite-plugin-symfony versions */ 52 | if (is_null($infos['metadatas']) || !array_key_exists($filePath, $infos['metadatas'])) { 53 | return null; 54 | } 55 | 56 | return $infos['metadatas'][$filePath]['hash']; 57 | } 58 | 59 | public function isLegacyPluginEnabled(): bool 60 | { 61 | $infos = $this->getFileContent(); 62 | 63 | return array_key_exists('legacy', $infos) && true === $infos['legacy']; 64 | } 65 | 66 | public function isBuild(): bool 67 | { 68 | return null === $this->getFileContent()['viteServer']; 69 | } 70 | 71 | public function getViteServer(): ?string 72 | { 73 | return $this->getFileContent()['viteServer']; 74 | } 75 | 76 | public function getBase(): string 77 | { 78 | return $this->getFileContent()['base']; 79 | } 80 | 81 | public function hasModernPolyfillsEntry(): bool 82 | { 83 | return isset($this->getFileContent()['entryPoints']['polyfills']); 84 | } 85 | 86 | /** 87 | * @return array 88 | */ 89 | public function getJSFiles(string $entryName): array 90 | { 91 | $this->throwIfEntrypointIsMissing($entryName); 92 | 93 | return $this->getFileContent()['entryPoints'][$entryName]['js'] ?? []; 94 | } 95 | 96 | /** 97 | * @return array 98 | */ 99 | public function getCSSFiles(string $entryName): array 100 | { 101 | $this->throwIfEntrypointIsMissing($entryName); 102 | 103 | return $this->getFileContent()['entryPoints'][$entryName]['css'] ?? []; 104 | } 105 | 106 | /** 107 | * @return array 108 | */ 109 | public function getJavascriptDependencies(string $entryName): array 110 | { 111 | $this->throwIfEntrypointIsMissing($entryName); 112 | 113 | return $this->getFileContent()['entryPoints'][$entryName]['preload'] ?? []; 114 | } 115 | 116 | /** 117 | * @return array 118 | */ 119 | public function getJavascriptDynamicDependencies(string $entryName): array 120 | { 121 | $this->throwIfEntrypointIsMissing($entryName); 122 | 123 | return $this->getFileContent()['entryPoints'][$entryName]['dynamic'] ?? []; 124 | } 125 | 126 | public function hasLegacy(string $entryName): bool 127 | { 128 | $this->throwIfEntrypointIsMissing($entryName); 129 | 130 | $entryInfos = $this->getFileContent(); 131 | 132 | return isset($entryInfos['entryPoints'][$entryName]['legacy']) && false !== $entryInfos['entryPoints'][$entryName]['legacy']; 133 | } 134 | 135 | public function getLegacyJSFile(string $entryName): string 136 | { 137 | $this->throwIfEntrypointIsMissing($entryName); 138 | 139 | $entryInfos = $this->getFileContent(); 140 | 141 | $legacyEntryName = $entryInfos['entryPoints'][$entryName]['legacy']; 142 | 143 | if (!is_string($legacyEntryName)) { 144 | throw new \Exception("Entrypoint doesn't have legacy entrypoint"); 145 | } 146 | 147 | $legacyEntry = $entryInfos['entryPoints'][$legacyEntryName]; 148 | 149 | if (!isset($legacyEntry['js'][0])) { 150 | throw new \Exception("Entrypoint legacy doesn't have js script"); 151 | } 152 | 153 | return $legacyEntry['js'][0]; 154 | } 155 | 156 | private function throwIfEntrypointIsMissing(string $entryName): void 157 | { 158 | if (!$this->throwOnMissingEntry) { 159 | return; 160 | } 161 | 162 | if (!array_key_exists($entryName, $this->getFileContent()['entryPoints'])) { 163 | $keys = array_keys($this->getFileContent()['entryPoints']); 164 | $entryPointKeys = join(', ', array_map(function ($key) { return "'$key'"; }, $keys)); 165 | throw new EntrypointNotFoundException(sprintf("Entry '%s' not present in the entrypoints file. Defined entrypoints are %s", $entryName, $entryPointKeys)); 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/Service/TagRenderer.php: -------------------------------------------------------------------------------- 1 | $globalDefaultAttributes 12 | * @param array $globalScriptAttributes 13 | * @param array $globalLinkAttributes 14 | * @param array $globalPreloadAttributes 15 | * @param 'none'|'link-tag'|'link-header' $preload 16 | */ 17 | public function __construct( 18 | private array $globalDefaultAttributes = [], 19 | private array $globalScriptAttributes = [], 20 | private array $globalLinkAttributes = [], 21 | private array $globalPreloadAttributes = [], 22 | private string $preload = 'link-tag', 23 | ) { 24 | } 25 | 26 | public function createViteClientScript(string $src, string $entryName): Tag 27 | { 28 | return $this->createInternalScriptTag( 29 | [ 30 | 'type' => 'module', 31 | 'src' => $src, 32 | 'crossorigin' => true, 33 | ], 34 | '', 35 | $entryName 36 | ); 37 | } 38 | 39 | public function createReactRefreshScript(string $devServerUrl): Tag 40 | { 41 | return $this->createInternalScriptTag( 42 | ['type' => 'module'], 43 | InlineContent::getReactRefreshInlineCode($devServerUrl) 44 | ); 45 | } 46 | 47 | public function createSafariNoModuleScript(): Tag 48 | { 49 | return $this->createInternalScriptTag( 50 | ['nomodule' => true], 51 | InlineContent::SAFARI10_NO_MODULE_FIX_INLINE_CODE 52 | ); 53 | } 54 | 55 | public function createDynamicFallbackScript(): Tag 56 | { 57 | return $this->createInternalScriptTag( 58 | ['type' => 'module'], 59 | InlineContent::DYNAMIC_FALLBACK_INLINE_CODE 60 | ); 61 | } 62 | 63 | public function createDetectModernBrowserScript(): Tag 64 | { 65 | return $this->createInternalScriptTag( 66 | ['type' => 'module'], 67 | InlineContent::DETECT_MODERN_BROWSER_INLINE_CODE 68 | ); 69 | } 70 | 71 | /** @param array $attributes */ 72 | public function createInternalScriptTag(array $attributes = [], string $content = '', string $origin = ''): Tag 73 | { 74 | $tag = new Tag( 75 | Tag::SCRIPT_TAG, 76 | $attributes, 77 | $content, 78 | $origin, 79 | $this->preload, 80 | true, 81 | ); 82 | 83 | return $tag; 84 | } 85 | 86 | /** @param array $attributes */ 87 | public function createScriptTag(array $attributes = [], string $content = '', string $origin = '', bool $internal = false): Tag 88 | { 89 | $tag = new Tag( 90 | Tag::SCRIPT_TAG, 91 | array_merge( 92 | $this->globalDefaultAttributes, 93 | $this->globalScriptAttributes, 94 | $attributes 95 | ), 96 | $content, 97 | $origin, 98 | $this->preload, 99 | $internal 100 | ); 101 | 102 | return $tag; 103 | } 104 | 105 | /** @param array $extraAttributes */ 106 | public function createLinkStylesheetTag(string $fileName, array $extraAttributes = [], string $origin = ''): Tag 107 | { 108 | $attributes = [ 109 | 'rel' => 'stylesheet', 110 | 'href' => $fileName, 111 | ]; 112 | 113 | $tag = new Tag( 114 | Tag::LINK_TAG, 115 | array_merge( 116 | $this->globalDefaultAttributes, 117 | $this->globalLinkAttributes, 118 | $attributes, 119 | $extraAttributes 120 | ), 121 | '', 122 | $origin, 123 | $this->preload 124 | ); 125 | 126 | return $tag; 127 | } 128 | 129 | /** @param array $extraAttributes */ 130 | public function createModulePreloadLinkTag(string $fileName, array $extraAttributes = [], string $origin = ''): Tag 131 | { 132 | $attributes = [ 133 | 'rel' => 'modulepreload', 134 | 'href' => $fileName, 135 | ]; 136 | 137 | $tag = new Tag( 138 | Tag::LINK_TAG, 139 | array_merge( 140 | $this->globalDefaultAttributes, 141 | $this->globalPreloadAttributes, 142 | $attributes, 143 | $extraAttributes 144 | ), 145 | '', 146 | $origin, 147 | $this->preload 148 | ); 149 | 150 | return $tag; 151 | } 152 | 153 | public static function generateTag(Tag $tag): string 154 | { 155 | return $tag->isLinkTag() ? sprintf( 156 | '<%s %s>', 157 | $tag->getTagName(), 158 | self::convertArrayToAttributes($tag) 159 | ) : sprintf( 160 | '<%s %s>%s', 161 | $tag->getTagName(), 162 | self::convertArrayToAttributes($tag), 163 | $tag->getContent(), 164 | $tag->getTagName() 165 | ); 166 | } 167 | 168 | private static function convertArrayToAttributes(Tag $tag): string 169 | { 170 | $validAttributes = $tag->getValidAttributes(); 171 | 172 | return implode(' ', array_map( 173 | function ($key, $value) { 174 | if (true === $value) { 175 | return sprintf('%s', $key); 176 | } else { 177 | return sprintf('%s="%s"', $key, htmlentities($value)); 178 | } 179 | }, 180 | array_keys($validAttributes), 181 | $validAttributes 182 | )); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /docs/symfony-vite.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 88 | 89 | 90 | 91 | 99 | 104 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 16 | 17 | $rootNode 18 | ->children() 19 | ->scalarNode('public_directory') 20 | ->defaultValue('public') 21 | ->end() 22 | ->scalarNode('build_directory') 23 | ->info('we only need build_directory to locate entrypoints.json file, it\'s the "base" vite config parameter without slashes.') 24 | ->defaultValue('build') 25 | ->end() 26 | ->scalarNode('proxy_origin') 27 | ->info('Allows to use different origin for asset proxy, eg. http://host.docker.internal:5173') 28 | ->defaultValue(null) 29 | ->end() 30 | ->booleanNode('absolute_url') 31 | ->info('Prepend the rendered link and script tags with an absolute URL.') 32 | ->defaultValue(false) 33 | ->end() 34 | ->scalarNode('throw_on_missing_entry') 35 | ->info('Throw exception when entry is not present in the entrypoints file') 36 | ->defaultValue(false) 37 | ->end() 38 | ->scalarNode('throw_on_missing_asset') 39 | ->info('Throw exception when asset is not present in the manifest file') 40 | ->defaultValue(true) 41 | ->end() 42 | ->booleanNode('cache') 43 | ->info('Enable caching of the entry point file(s)') 44 | ->defaultFalse() 45 | ->end() 46 | ->enumNode('preload') 47 | ->values(['none', 'link-tag', 'link-header']) 48 | ->info('preload all rendered script and link tags automatically via the http2 Link header. (symfony/web-link is required) Instead will be used.') 49 | ->defaultValue('link-tag') 50 | ->end() 51 | ->enumNode('crossorigin') 52 | ->defaultTrue() 53 | ->values([false, true, 'anonymous', 'use-credentials']) 54 | ->info('crossorigin value, can be false, true (default), anonymous (same as true) or use-credentials') 55 | ->end() 56 | ->arrayNode('script_attributes') 57 | ->info('Key/value pair of attributes to render on all script tags') 58 | ->example('{ defer: true, referrerpolicy: "origin" }') 59 | ->normalizeKeys(false) 60 | ->scalarPrototype() 61 | ->end() 62 | ->end() 63 | ->arrayNode('link_attributes') 64 | ->info('Key/value pair of attributes to render on all stylesheet link tags') 65 | ->example('{ referrerpolicy: "origin" }') 66 | ->normalizeKeys(false) 67 | ->scalarPrototype() 68 | ->end() 69 | ->end() 70 | ->arrayNode('preload_attributes') 71 | ->info('Key/value pair of attributes to render on all modulepreload link tags') 72 | ->example('{ referrerpolicy: "origin" }') 73 | ->normalizeKeys(false) 74 | ->scalarPrototype() 75 | ->end() 76 | ->end() 77 | ->scalarNode('default_build') 78 | ->defaultValue(null) 79 | ->setDeprecated('pentatrion/vite-bundle', '6.0.0', 'The "%node%" option is deprecated. Use "default_config" instead.') 80 | ->end() 81 | ->arrayNode('builds') 82 | ->setDeprecated('pentatrion/vite-bundle', '6.0.0', 'The "%node%" option is deprecated. Use "configs" instead.') 83 | ->useAttributeAsKey('name') 84 | ->arrayPrototype() 85 | ->children() 86 | ->scalarNode('build_directory') 87 | ->defaultValue('build') 88 | ->end() 89 | ->arrayNode('script_attributes') 90 | ->info('Key/value pair of attributes to render on all script tags') 91 | ->example('{ defer: true, referrerpolicy: "origin" }') 92 | ->normalizeKeys(false) 93 | ->scalarPrototype() 94 | ->end() 95 | ->end() 96 | ->arrayNode('link_attributes') 97 | ->info('Key/value pair of attributes to render on all CSS link tags') 98 | ->example('{ referrerpolicy: "origin" }') 99 | ->normalizeKeys(false) 100 | ->scalarPrototype() 101 | ->end() 102 | ->end() 103 | ->arrayNode('preload_attributes') 104 | ->info('Key/value pair of attributes to render on all modulepreload link tags') 105 | ->example('{ referrerpolicy: "origin" }') 106 | ->normalizeKeys(false) 107 | ->scalarPrototype() 108 | ->end() 109 | ->end() 110 | ->end() 111 | ->end() 112 | ->end() 113 | ->scalarNode('default_config') 114 | ->defaultValue(null) 115 | ->end() 116 | ->arrayNode('configs') 117 | ->useAttributeAsKey('name') 118 | ->arrayPrototype() 119 | ->children() 120 | ->scalarNode('build_directory') 121 | ->defaultValue('build') 122 | ->end() 123 | ->arrayNode('script_attributes') 124 | ->info('Key/value pair of attributes to render on all script tags') 125 | ->example('{ defer: true, referrerpolicy: "origin" }') 126 | ->normalizeKeys(false) 127 | ->scalarPrototype() 128 | ->end() 129 | ->end() 130 | ->arrayNode('link_attributes') 131 | ->info('Key/value pair of attributes to render on all CSS link tags') 132 | ->example('{ referrerpolicy: "origin" }') 133 | ->normalizeKeys(false) 134 | ->scalarPrototype() 135 | ->end() 136 | ->end() 137 | ->arrayNode('preload_attributes') 138 | ->info('Key/value pair of attributes to render on all modulepreload link tags') 139 | ->example('{ referrerpolicy: "origin" }') 140 | ->normalizeKeys(false) 141 | ->scalarPrototype() 142 | ->end() 143 | ->end() 144 | ->end() 145 | ->end() 146 | ->end() 147 | ->end() 148 | ; 149 | 150 | return $treeBuilder; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/Resources/views/Profiler/info.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Vite Dev Server Infos 9 | 139 | 140 | 141 |
142 |
143 | 160 |
161 | {% for viteConfig in viteConfigs %} 162 | {% if loop.length > 1 %} 163 |

{{ viteConfig.configName }}

164 | {% endif %} 165 | {% for groupKey, groupValues in viteConfig.content %} 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | {% for key, value in groupValues %} 174 | 175 | 176 | 177 | 178 | {% endfor %} 179 | 180 |
{{ groupKey }}
{{ key }}{{ value | symfonyvite_stringify }}
181 | {% else %} 182 |

Your Vite Dev server is not running.

183 | {% endfor %} 184 |
185 | {% endfor %} 186 |
187 | 188 | 189 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # `pentatrion/vite-bundle` / `vite-plugin-symfony` Changelog 2 | 3 | ## v8.2.3 4 | 5 | - Add support for Symfony 8 ([@skmedix](https://github.com/skmedix)) 6 | 7 | ## v8.2.2 8 | 9 | - vite-plugin-symfony: Support use modernPolyfills option in @vitejs/plugin-legacy ([@twodogwang](https://github.com/twodogwang)) 10 | 11 | ## v8.2.1 12 | 13 | - fix #83 asset twig method not using entrypoints baseUrl ([@micheh](https://github.com/micheh)) 14 | 15 | ## v8.2.0 16 | 17 | - add vite 7 support 18 | 19 | ## v8.1.1 20 | 21 | - vite-plugin-symfony fix #75 allow users to override server.watch.ignored ([@robinsimonklein](https://github.com/robinsimonklein)) 22 | 23 | ## v8.1.0 24 | 25 | stimulus with svelte : update to svelte 5 ([@faldor20](https://github.com/faldor20)) 26 | 27 | ## v8.0.2 28 | 29 | - vite-bundle fix #62 can't use environment variables for default_config 30 | 31 | ## v8.0.1 32 | 33 | - vite-plugin-symfony fix #60 move postinstall hook to pre-dev. 34 | 35 | ## v8.0.0 36 | 37 | - stimulus fix hmr option from `VitePluginSymfonyStimulusOptions` 38 | - stimulus fix hmr with lazy loaded controllers 39 | - stimulus prevent hmr when controller is not already registered (#56) 40 | - stimulus add `controllersDir` option to prevent analyse Stimulus meta for other files. 41 | 42 | ## v7.1.0 43 | 44 | - allow Vite 6 as peer dependency ([@skmedix](https://github.com/skmedix)) 45 | 46 | ## v7.0.5 47 | 48 | - add origin to internal tags ([@seggewiss](https://github.com/seggewiss)) 49 | 50 | ## v7.0.4 51 | 52 | - fix use `proxy_origin` in Debugger if configured (@andyexeter) 53 | 54 | ## v7.0.3 55 | 56 | - stimulus fix import.meta regex to support comments 57 | 58 | ## v7.0.2 59 | 60 | - stimulus plugin check module entrypoint inside controllers.json 61 | - fix vite-plugin-symfony partial options TypeScript type. 62 | 63 | ## v7.0.1 64 | 65 | - fix Symfony try to register twice `TypeExtension`. 66 | 67 | ## v7.0.0 68 | 69 | - new Profiler 70 | - change crossorin default value 71 | - better `PreloadAssetsEventListener` 72 | - stimulus refactorisation 73 | 74 | ## v6.5.3 75 | 76 | - fix vite-plugin-symfony tsup export when package is ESM. 77 | 78 | ## v6.5.2 79 | 80 | - fix dummy-non-existing-folder to be created when used with vitest UI. 81 | 82 | ## v6.5.1 83 | 84 | - fix overriding types from '@hotwired/stimulus' 85 | 86 | ## v6.5.0 87 | 88 | - move v6.4.7 to 6.5.0 : flex recipes accept only minor version number (not patch). 89 | 90 | ## v6.4.7 91 | 92 | - vite-bundle : prepare v7 flex recipe add pentatrion_vite.yaml route file into install directory 93 | 94 | ## v6.4.6 95 | 96 | - vite-bundle : add throw_on_missing_asset option 97 | 98 | ## v6.4.5 99 | 100 | - vite-bundle : fix Crossorigin attribute needs adding to Link headers (@andyexeter) 101 | - vite-bundle : Skip devServer lookup if proxy is defined (@Blackskyliner) 102 | - vite-bundle : fix typo in error message when outDir is outside project root (@acran) 103 | 104 | ## v6.4.4 105 | 106 | - vite-plugin-symfony : fix typo in error message when outDir is outside project root (@acran) 107 | - vite-plugin-symfony : revert emptying `outDir` in dev mode (thanks @nlemoine) 108 | 109 | ## v6.4.3 110 | 111 | - vite-bundle : fix deprecation warning with `configs` key in multiple config. 112 | 113 | ## v6.4.2 114 | 115 | - doc add https tip with symfony cli certificate. (@nlemoine) 116 | - fixed symfony/ux-react inability to load tsx components (@vladcos) 117 | 118 | ## v6.4.1 119 | 120 | - fix import.meta in cjs env 121 | - vite-plugin-symfony : fix Displaying the statuses of Stimulus controllers in production https://github.com/lhapaipai/vite-plugin-symfony/issues/38 122 | 123 | ## v6.4.0 124 | 125 | - vite-plugin-symfony : add exposedEnvVars option 126 | - vite-plugin-symfony : fix enforcePluginOrderingPosition https://github.com/lhapaipai/vite-bundle/issues/80 127 | ## v6.3.6 128 | 129 | - fix crossorigin attribute to Link header for scripts with type=module (@andyexeter) 130 | 131 | ## v6.3.5 132 | 133 | - fix vite-plugin-symfony support having externals dependencies. 134 | - increase vite-bundle php minimum compatibility to 8.0 135 | no major version because the bundle was unusable with php 7.4 because of mixed type. 136 | 137 | ## v6.3.4 138 | 139 | - Use Request::getUriForPath to build absolute URLs (@andyexeter) 140 | - Formatting fix in vite printUrls output (@andyexeter) 141 | 142 | ## v6.3.3 143 | 144 | - Fix dark mode issue with background 145 | - Fix worker mode (kernel.reset) 146 | 147 | ## v6.3.2 148 | 149 | - Moving package manager to pnpm 150 | 151 | ## v6.3.1 152 | 153 | - Fix React/Vue/Svelte dependencies with Stimulus helper (@santos-pierre) 154 | - vite-plugin-symfony Update dependencies 155 | 156 | ## v6.3.0 157 | 158 | - stimulus HMR 159 | - fix bug : stimulus restart vite dev server when controllers.json is updated 160 | - split vite-plugin-symfony into 2 plugins `vite-plugin-symfony-entrypoints` and `vite-plugin-symfony-stimulus`. 161 | - add new tests to vite-plugin-symfony 162 | - doc : add mermaid charts 163 | 164 | ## v6.2.0 165 | 166 | - fix #77 support Vite 5.x 167 | 168 | ## v6.1.3 169 | 170 | - fix #34 set warning when setting a build directory outside of your project 171 | 172 | ## v6.1.2 173 | 174 | - stimulus lazy controllers enhancement 175 | - Fix : prevent virtual controllers.json prebundling 176 | - Fix : Change dependency to the non-internal ServiceLocator class (@NanoSector) 177 | - Fix : Carelessly setting the outDir folder leads to recursive deletion (@Huppys) 178 | 179 | ## v6.1.0 180 | 181 | - add Stimulus and Symfony UX Integration 182 | 183 | ## v6.0.1 184 | 185 | - add `enforceServerOriginAfterListening` 186 | 187 | ## v6.0.0 188 | 189 | - make services privates. 190 | - add tests for EntrypointRenderer, EntrypointsLookup and TagRenderer. 191 | - add preload option (symfony/web-link) 192 | - add cache option 193 | - add crossorigin option 194 | - add preload_attributes option 195 | - change default_build/builds to default_config/configs 196 | - fix baseUrl to files #67 197 | - refactor RenderAssetTagEvent 198 | 199 | ## v5.0.1 200 | 201 | - remove deprecated options 202 | - fix `absolute_url` error in `shouldUseAbsoluteURL`. 203 | 204 | ## v5.0.0 205 | 206 | - change `entrypoints.json` property `isProd` to `isBuild` because you can be in dev env and want to build your js files. 207 | 208 | ## v4.3.2 209 | 210 | - fix #26 TypeError when no root option (@andyexeter) 211 | 212 | ## v4.3.1 213 | 214 | - add vendor, var and public to ignored directory for file watcher. 215 | 216 | ## v4.3.0 217 | 218 | - add `absolute_url` bundle option. 219 | - add `absolute_url` twig option. (@drazik) 220 | 221 | ## v4.2.0 222 | 223 | - add enforcePluginOrderingPosition option 224 | - fix Integrity hash issue 225 | - add `vite_mode` twig function 226 | 227 | ## v4.1.0 228 | 229 | - add `originOverride` (@elliason) 230 | - deprecate `viteDevServerHostname` 231 | 232 | ## v4.0.2 233 | 234 | - fix #24 normalized path 235 | 236 | ## v4.0.1 237 | 238 | - fix conditional imports generate modulepreloads for everything 239 | 240 | ## v4.0.0 241 | 242 | - add `sriAlgorithm` 243 | - fix react refresh when vite client is returned 244 | - add CDN feature 245 | 246 | ## v3.3.2 247 | 248 | - fix #16 entrypoints outside vite root directory 249 | 250 | ## v3.3.1 251 | 252 | - fix circular reference with imports. 253 | - deprecate `public_dir` / `base` 254 | - add `public_directory` / `build_directory` 255 | 256 | ## v3.3.0 257 | 258 | - add tests 259 | - versionning synchronization between pentatrion/vite-bundle and vite-plugin-symfony 260 | 261 | --- 262 | 263 | before version 3.3 the versions of ViteBundle and vite-plugin-symfony were not synchronized 264 | 265 | 266 | # `pentatrion/vite-bundle` Changelog 267 | 268 | ## v3.2.0 269 | 270 | - add throw_on_missing_entry option (@Magiczne) 271 | 272 | ## v3.1.4 273 | 274 | - add proxy_origin option (@FluffyDiscord) 275 | 276 | ## v3.1.0 277 | 278 | - allow vite multiple configuration files 279 | 280 | ## v3.0.0 281 | 282 | - Add vite 4 compatibility 283 | 284 | ## v2.2.1 285 | 286 | - 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. 287 | 288 | ## v2.2.0 289 | 290 | - add extra attributes to script/link tags 291 | 292 | ## v2.1.1 293 | 294 | - update documentation, update with vite-plugin-symfony v0.6.0 295 | 296 | ## v2.1.0 297 | 298 | - add CSS Entrypoints management to prevent FOUC. 299 | 300 | ## v1.1.4 301 | 302 | - add EntrypointsLookup / EntrypointsRenderer as a service. 303 | 304 | ## v1.1.0 305 | 306 | - Add public_dir conf 307 | 308 | ## v1.0.2 309 | 310 | - fix vite.config path error with windows 311 | 312 | ## v1.0.1 313 | 314 | - fix exception when entrypoints.json is missing 315 | 316 | ## v1.0.0 317 | 318 | - Twig functions refer to named entry points not js file 319 | - Add vite-plugin-symfony 320 | 321 | ## v0.2.0 322 | 323 | Add proxy Controller 324 | 325 | 326 | --- 327 | 328 | # `vite-plugin-symfony` changelog 329 | 330 | ## v0.6.3 331 | 332 | - takes into account vite legacy plugin. 333 | 334 | ## v0.6.2 335 | 336 | - add `viteDevServerHost` plugin option 337 | 338 | ## v0.6.1 339 | 340 | - remove `strictPort: true` 341 | 342 | ## v0.6.0 343 | 344 | - add `publicDirectory`, `buildDirectory`, `refresh`, `verbose` plugin option 345 | - add `dev-server-404.html` page 346 | 347 | ## v0.5.2 348 | 349 | - add `servePublic` plugin option 350 | -------------------------------------------------------------------------------- /src/DependencyInjection/PentatrionViteExtension.php: -------------------------------------------------------------------------------- 1 | , 28 | * link_attributes: array, 29 | * preload_attributes: array, 30 | * default_config: null|string, 31 | * configs: array, 32 | * default_build: null|string, 33 | * builds: array 34 | * } 35 | * @phpstan-type ExtraConfig array{ 36 | * build_directory: string, 37 | * script_attributes: array, 38 | * link_attributes: array, 39 | * preload_attributes: array 40 | * } 41 | * @phpstan-type ResolvedConfig array{ 42 | * base: string, 43 | * script_attributes: array, 44 | * link_attributes: array, 45 | * preload_attributes: array 46 | * } 47 | * @phpstan-type ViteConfigs array 48 | */ 49 | class PentatrionViteExtension extends Extension 50 | { 51 | public function load(array $bundleConfigs, ContainerBuilder $container): void 52 | { 53 | $loader = new YamlFileLoader($container, new FileLocator(\dirname(__DIR__).'/Resources/config')); 54 | $loader->load('services.yaml'); 55 | 56 | $configuration = new Configuration(); 57 | /** @var BundleConfig $bundleConfig */ 58 | $bundleConfig = $this->processConfiguration( 59 | $configuration, 60 | $bundleConfigs 61 | ); 62 | 63 | /* @phpstan-ignore-next-line can be possible with deprecations */ 64 | if (isset($bundleConfig['builds']) && !isset($bundleConfig['configs'])) { 65 | $bundleConfig['configs'] = $bundleConfig['builds']; 66 | } 67 | if (isset($bundleConfig['default_build']) && !isset($bundleConfig['default_config'])) { 68 | $bundleConfig['default_config'] = $bundleConfig['default_build']; 69 | } 70 | 71 | $defaultAttributes = []; 72 | 73 | if (false !== $bundleConfig['crossorigin']) { 74 | $defaultAttributes['crossorigin'] = $bundleConfig['crossorigin']; 75 | } 76 | 77 | $container->setParameter('pentatrion_vite.preload', $bundleConfig['preload']); 78 | $container->setParameter('pentatrion_vite.public_directory', self::preparePublicDirectory($bundleConfig['public_directory'])); 79 | $container->setParameter('pentatrion_vite.absolute_url', $bundleConfig['absolute_url']); 80 | $container->setParameter('pentatrion_vite.proxy_origin', $bundleConfig['proxy_origin']); 81 | $container->setParameter('pentatrion_vite.throw_on_missing_entry', $bundleConfig['throw_on_missing_entry']); 82 | $container->setParameter('pentatrion_vite.throw_on_missing_asset', $bundleConfig['throw_on_missing_asset']); 83 | $container->setParameter('pentatrion_vite.crossorigin', $bundleConfig['crossorigin']); 84 | 85 | if ( 86 | count($bundleConfig['configs']) > 0) { 87 | $defaultConfigName = $container->resolveEnvPlaceholders($bundleConfig['default_config'], true); 88 | if (!is_string($defaultConfigName) || !isset($bundleConfig['configs'][$defaultConfigName])) { 89 | throw new \Exception('Invalid default_config, choose between : '.join(', ', array_keys($bundleConfig['configs']))); 90 | } 91 | $lookupFactories = []; 92 | $tagRendererFactories = []; 93 | /** @var array $configs */ 94 | $configs = []; 95 | 96 | foreach ($bundleConfig['configs'] as $configName => $config) { 97 | if (!preg_match('/^[0-9a-zA-Z_]+$/', $configName)) { 98 | throw new \Exception('Invalid config name, you should use only a-z A-Z and _ characters.'); 99 | } 100 | 101 | $configs[$configName] = $configPrepared = self::prepareConfig($config); 102 | $lookupFactories[$configName] = $this->entrypointsLookupFactory( 103 | $container, 104 | $configName 105 | ); 106 | $tagRendererFactories[$configName] = $this->tagRendererFactory( 107 | $container, 108 | $defaultAttributes, 109 | $configName, 110 | $configPrepared, 111 | $bundleConfig['preload'] 112 | ); 113 | } 114 | } else { 115 | $defaultConfigName = '_default'; 116 | $configs[$defaultConfigName] = $configPrepared = self::prepareConfig($bundleConfig); 117 | 118 | $lookupFactories = [ 119 | '_default' => $this->entrypointsLookupFactory( 120 | $container, 121 | $defaultConfigName 122 | ), 123 | ]; 124 | $tagRendererFactories = [ 125 | '_default' => $this->tagRendererFactory( 126 | $container, 127 | $defaultAttributes, 128 | $defaultConfigName, 129 | $configPrepared, 130 | $bundleConfig['preload'] 131 | ), 132 | ]; 133 | } 134 | 135 | if ('link-header' === $bundleConfig['preload']) { 136 | if (!class_exists(AddLinkHeaderListener::class)) { 137 | throw new \LogicException('To use the "preload" option, the WebLink component must be installed. Try running "composer require symfony/web-link".'); 138 | } 139 | } else { 140 | $container->removeDefinition('pentatrion_vite.preload_assets_event_listener'); 141 | } 142 | 143 | $container->setParameter('pentatrion_vite.default_config', $defaultConfigName); 144 | $container->setParameter('pentatrion_vite.configs', $configs); 145 | 146 | $container->getDefinition('pentatrion_vite.entrypoints_lookup_collection') 147 | ->addArgument(ServiceLocatorTagPass::register($container, $lookupFactories)) 148 | ->addArgument($defaultConfigName); 149 | 150 | $container->getDefinition('pentatrion_vite.tag_renderer_collection') 151 | ->addArgument(ServiceLocatorTagPass::register($container, $tagRendererFactories)) 152 | ->addArgument($defaultConfigName); 153 | 154 | if ($bundleConfig['cache']) { 155 | $container->getDefinition('pentatrion_vite.file_accessor') 156 | ->replaceArgument(2, new Reference('pentatrion_vite.cache')); 157 | } 158 | } 159 | 160 | private function entrypointsLookupFactory( 161 | ContainerBuilder $container, 162 | string $configName, 163 | ): Reference { 164 | $id = $this->getServiceId('entrypoints_lookup', $configName); 165 | $arguments = [ 166 | new Reference('pentatrion_vite.file_accessor'), 167 | $configName, 168 | '%pentatrion_vite.throw_on_missing_entry%', 169 | ]; 170 | $definition = new Definition(EntrypointsLookup::class, $arguments); 171 | $container->setDefinition($id, $definition); 172 | 173 | return new Reference($id); 174 | } 175 | 176 | /** 177 | * @param array $defaultAttributes 178 | * @param ResolvedConfig $config 179 | */ 180 | private function tagRendererFactory( 181 | ContainerBuilder $container, 182 | array $defaultAttributes, 183 | string $configName, 184 | array $config, 185 | string $preload, 186 | ): Reference { 187 | $id = $this->getServiceId('tag_renderer', $configName); 188 | $arguments = [ 189 | $defaultAttributes, 190 | $config['script_attributes'], 191 | $config['link_attributes'], 192 | $config['preload_attributes'], 193 | $preload, 194 | ]; 195 | $definition = new Definition(TagRenderer::class, $arguments); 196 | $container->setDefinition($id, $definition); 197 | 198 | return new Reference($id); 199 | } 200 | 201 | private function getServiceId(string $prefix, string $configName): string 202 | { 203 | return sprintf('pentatrion_vite.%s[%s]', $prefix, $configName); 204 | } 205 | 206 | /** 207 | * @param BundleConfig|ExtraConfig $config 208 | * 209 | * @return ResolvedConfig 210 | */ 211 | public static function prepareConfig(array $config): array 212 | { 213 | $base = '/'.trim($config['build_directory'], '/').'/'; 214 | 215 | return [ 216 | 'base' => $base, 217 | 'script_attributes' => $config['script_attributes'], 218 | 'link_attributes' => $config['link_attributes'], 219 | 'preload_attributes' => $config['preload_attributes'], 220 | ]; 221 | } 222 | 223 | public static function preparePublicDirectory(string $publicDir): string 224 | { 225 | return '/'.trim($publicDir, '/'); 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/Service/EntrypointRenderer.php: -------------------------------------------------------------------------------- 1 | true]). 21 | * 22 | * @var array 23 | */ 24 | private array $returnedViteClients = []; 25 | 26 | /** @var array */ 27 | private array $returnedReactRefresh = []; 28 | 29 | /** @var array */ 30 | private array $returnedViteLegacyScripts = []; 31 | 32 | /** 33 | * ex: [ 34 | * "http://127.0.0.1:5173/build/assets/app.js" => $tag 35 | * ]. 36 | * 37 | * @var array 38 | */ 39 | private array $renderedTags = []; 40 | 41 | public function __construct( 42 | private EntrypointsLookupCollection $entrypointsLookupCollection, 43 | private TagRendererCollection $tagRendererCollection, 44 | private string $defaultConfigName = '_default', 45 | private bool $useAbsoluteUrl = false, 46 | private ?RequestStack $requestStack = null, 47 | private ?EventDispatcherInterface $eventDispatcher = null, 48 | ) { 49 | } 50 | 51 | private function getEntrypointsLookup(?string $configName = null): EntrypointsLookup 52 | { 53 | return $this->entrypointsLookupCollection->getEntrypointsLookup($configName); 54 | } 55 | 56 | private function getTagRenderer(?string $configName = null): TagRenderer 57 | { 58 | return $this->tagRendererCollection->getTagRenderer($configName); 59 | } 60 | 61 | private function completeURL(string $path, bool $useAbsoluteUrl = false): string 62 | { 63 | if (str_starts_with($path, 'http') || false === $useAbsoluteUrl || null === $this->requestStack || null === $this->requestStack->getCurrentRequest()) { 64 | return $path; 65 | } 66 | 67 | return $this->requestStack->getCurrentRequest()->getUriForPath($path); 68 | } 69 | 70 | /** 71 | * @param ViteEntryScriptTagsOptions|ViteEntryLinkTagsOptions $options 72 | */ 73 | private function shouldUseAbsoluteURL(array $options, ?string $configName = null): bool 74 | { 75 | $viteServer = $this->getEntrypointsLookup($configName)->getViteServer(); 76 | 77 | return is_null($viteServer) && ($this->useAbsoluteUrl || (isset($options['absolute_url']) && true === $options['absolute_url'])); 78 | } 79 | 80 | public function getMode(?string $configName = null): ?string 81 | { 82 | $entrypointsLookup = $this->getEntrypointsLookup($configName); 83 | 84 | if (!$entrypointsLookup->hasFile()) { 85 | return null; 86 | } 87 | 88 | return $entrypointsLookup->isBuild() ? 'build' : 'dev'; 89 | } 90 | 91 | public function reset(): void 92 | { 93 | $this->returnedViteClients = []; 94 | $this->returnedReactRefresh = []; 95 | $this->returnedViteLegacyScripts = []; 96 | $this->renderedTags = []; 97 | } 98 | 99 | /** 100 | * @return array 101 | */ 102 | public function getRenderedTags(): array 103 | { 104 | return array_values($this->renderedTags); 105 | } 106 | 107 | /** 108 | * @param ViteEntryScriptTagsOptions $options 109 | * 110 | * @phpstan-return ($toString is true ? string : array) 111 | */ 112 | public function renderScripts( 113 | string $entryName, 114 | array $options = [], 115 | ?string $configName = null, 116 | bool $toString = true, 117 | ): string|array { 118 | $configName = $configName ?? $this->defaultConfigName; 119 | $entrypointsLookup = $this->getEntrypointsLookup($configName); 120 | $tagRenderer = $this->getTagRenderer($configName); 121 | 122 | if (!$entrypointsLookup->hasFile()) { 123 | return ''; 124 | } 125 | 126 | $useAbsoluteUrl = $this->shouldUseAbsoluteURL($options, $configName); 127 | 128 | $tags = []; 129 | $viteServer = $entrypointsLookup->getViteServer(); 130 | $isBuild = $entrypointsLookup->isBuild(); 131 | $base = $entrypointsLookup->getBase(); 132 | 133 | if (!is_null($viteServer)) { 134 | // vite server is active 135 | if (!isset($this->returnedViteClients[$configName])) { 136 | $tags[] = $tagRenderer->createViteClientScript($viteServer.$base.'@vite/client', $entryName); 137 | 138 | $this->returnedViteClients[$configName] = true; 139 | } 140 | 141 | if ( 142 | !isset($this->returnedReactRefresh[$configName]) 143 | && isset($options['dependency']) && 'react' === $options['dependency'] 144 | ) { 145 | $tags[] = $tagRenderer->createReactRefreshScript($viteServer.$base); 146 | 147 | $this->returnedReactRefresh[$configName] = true; 148 | } 149 | } elseif ( 150 | $entrypointsLookup->isLegacyPluginEnabled() 151 | && !isset($this->returnedViteLegacyScripts[$configName]) 152 | ) { 153 | if ($entrypointsLookup->hasModernPolyfillsEntry()) { 154 | foreach ($entrypointsLookup->getJSFiles('polyfills') as $url) { 155 | // normally only one js file 156 | $tags[] = $tagRenderer->createScriptTag( 157 | [ 158 | 'crossorigin' => true, 159 | 'src' => $this->completeURL($url, $useAbsoluteUrl), 160 | ], 161 | '', 162 | $entryName, 163 | true, 164 | ); 165 | } 166 | } 167 | 168 | /* legacy section when vite server is inactive */ 169 | $tags[] = $tagRenderer->createDetectModernBrowserScript(); 170 | $tags[] = $tagRenderer->createDynamicFallbackScript(); 171 | $tags[] = $tagRenderer->createSafariNoModuleScript(); 172 | 173 | foreach ($entrypointsLookup->getJSFiles('polyfills-legacy') as $url) { 174 | // normally only one js file 175 | $tags[] = $tagRenderer->createScriptTag( 176 | [ 177 | 'nomodule' => true, 178 | 'crossorigin' => true, 179 | 'src' => $this->completeURL($url, $useAbsoluteUrl), 180 | 'id' => 'vite-legacy-polyfill', 181 | ], 182 | '', 183 | $entryName, 184 | true, 185 | ); 186 | } 187 | 188 | $this->returnedViteLegacyScripts[$configName] = true; 189 | } 190 | 191 | /* normal js scripts */ 192 | foreach ($entrypointsLookup->getJSFiles($entryName) as $url) { 193 | if (!isset($this->renderedTags[$url])) { 194 | $tag = $tagRenderer->createScriptTag( 195 | array_merge( 196 | [ 197 | 'type' => 'module', 198 | 'src' => $this->completeURL($url, $useAbsoluteUrl), 199 | 'integrity' => $entrypointsLookup->getFileHash($url), 200 | ], 201 | $options['attr'] ?? [] 202 | ), 203 | '', 204 | $entryName 205 | ); 206 | 207 | $tags[] = $tag; 208 | 209 | $this->renderedTags[$url] = $tag; 210 | } 211 | } 212 | 213 | /* legacy js scripts */ 214 | if ($entrypointsLookup->hasLegacy($entryName)) { 215 | $id = self::pascalToKebab("vite-legacy-entry-$entryName"); 216 | 217 | $url = $entrypointsLookup->getLegacyJSFile($entryName); 218 | if (!isset($this->renderedTags[$url])) { 219 | $tag = $tagRenderer->createScriptTag( 220 | [ 221 | 'nomodule' => true, 222 | 'data-src' => $this->completeURL($url, $useAbsoluteUrl), 223 | 'id' => $id, 224 | 'crossorigin' => true, 225 | 'class' => 'vite-legacy-entry', 226 | 'integrity' => $entrypointsLookup->getFileHash($url), 227 | ], 228 | InlineContent::getSystemJSInlineCode($id), 229 | $entryName 230 | ); 231 | 232 | $tags[] = $tag; 233 | 234 | $this->renderedTags[$url] = $tag; 235 | } 236 | } 237 | 238 | return $this->renderTags($tags, $isBuild, $toString); 239 | } 240 | 241 | /** 242 | * @param ViteEntryLinkTagsOptions $options 243 | * 244 | * @phpstan-return ($toString is true ? string : array) 245 | */ 246 | public function renderLinks( 247 | string $entryName, 248 | array $options = [], 249 | ?string $configName = null, 250 | bool $toString = true, 251 | ): string|array { 252 | $configName = $configName ?? $this->defaultConfigName; 253 | $entrypointsLookup = $this->getEntrypointsLookup($configName); 254 | $tagRenderer = $this->getTagRenderer($configName); 255 | 256 | if (!$entrypointsLookup->hasFile()) { 257 | return ''; 258 | } 259 | 260 | $useAbsoluteUrl = $this->shouldUseAbsoluteURL($options, $configName); 261 | $isBuild = $entrypointsLookup->isBuild(); 262 | 263 | $tags = []; 264 | 265 | foreach ($entrypointsLookup->getCSSFiles($entryName) as $url) { 266 | if (!isset($this->renderedTags[$url])) { 267 | $tag = $tagRenderer->createLinkStylesheetTag( 268 | $this->completeURL($url, $useAbsoluteUrl), 269 | array_merge(['integrity' => $entrypointsLookup->getFileHash($url)], $options['attr'] ?? []), 270 | $entryName 271 | ); 272 | 273 | $tags[] = $tag; 274 | 275 | $this->renderedTags[$url] = $tag; 276 | } 277 | } 278 | 279 | if ($isBuild) { 280 | foreach ($entrypointsLookup->getJavascriptDependencies($entryName) as $url) { 281 | if (!isset($this->renderedTags[$url])) { 282 | $tag = $tagRenderer->createModulePreloadLinkTag( 283 | $this->completeURL($url, $useAbsoluteUrl), 284 | ['integrity' => $entrypointsLookup->getFileHash($url)], 285 | $entryName 286 | ); 287 | 288 | $tags[] = $tag; 289 | 290 | $this->renderedTags[$url] = $tag; 291 | } 292 | } 293 | } 294 | 295 | if ($isBuild && isset($options['preloadDynamicImports']) && true === $options['preloadDynamicImports']) { 296 | foreach ($entrypointsLookup->getJavascriptDynamicDependencies($entryName) as $url) { 297 | if (!isset($this->renderedTags[$url])) { 298 | $tag = $tagRenderer->createModulePreloadLinkTag( 299 | $this->completeURL($url, $useAbsoluteUrl), 300 | ['integrity' => $entrypointsLookup->getFileHash($url)], 301 | $entryName 302 | ); 303 | 304 | $tags[] = $tag; 305 | 306 | $this->renderedTags[$url] = $tag; 307 | } 308 | } 309 | } 310 | 311 | return $this->renderTags($tags, $isBuild, $toString); 312 | } 313 | 314 | /** 315 | * @param array $tags 316 | * 317 | * @phpstan-return ($toString is true ? string : array) 318 | */ 319 | public function renderTags(array $tags, bool $isBuild, bool $toString): string|array 320 | { 321 | if (null !== $this->eventDispatcher) { 322 | foreach ($tags as $tag) { 323 | $this->eventDispatcher->dispatch(new RenderAssetTagEvent($isBuild, $tag)); 324 | } 325 | } 326 | 327 | $tags = array_filter($tags, function (Tag $tag) { 328 | return $tag->isRenderAsTag(); 329 | }); 330 | 331 | return $toString 332 | ? implode('', array_map(function ($tagEvent) { 333 | return TagRenderer::generateTag($tagEvent); 334 | }, $tags)) 335 | : $tags; 336 | } 337 | 338 | public static function pascalToKebab(string $str): string 339 | { 340 | return strtolower((string) preg_replace('/[A-Z]/', '-\\0', lcfirst($str))); 341 | } 342 | } 343 | --------------------------------------------------------------------------------