diff --git a/README.md b/README.md index 5b7c4aa..f555b45 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ reloading. In production mode, the Helper loads the bundled files. `@vitejs/plugin-legacy` is supported, which will insert `nomodule`-tags for older browsers, e.g. older iOS devices, which do not support js modules. -> This readme is for **version 1.x.** If you are migrating from 0.x and something is unclear, read the Migration guide +> This readme is for **version 3.x.** If you are migrating from 0.x and something is unclear, read the Migration guide > under `/docs`. Feel free to open an issue if you run into problems. ## Installation @@ -23,6 +23,7 @@ You can install this plugin into your CakePHP application using [composer](https | ^4.2 | 0.x | master | ^7.4 | | ^4.2 | 1.x | master | ^8.0 | | ^5.0 | 2.x | cake5 | ^8.1 | +| ^5.0 | 3.x | cake5 | ^8.1 | The recommended way to install the plugin is: @@ -62,13 +63,13 @@ These are the default view blocks in CakePHP. In your php-template or in layout you can import javascript files with: ```php -ViteScripts->script($options) ?> +ViteScripts->script('resources/main.ts') ?> ``` -… or by using this shortcut for a single entrypoint: +… or multiple files ```php -ViteScripts->script('webroot_src/main.ts') ?> +ViteScripts->script(['resources/main.ts', 'resources/main2.ts', 'resources/main3.ts']) ?> ``` If you imported CSS files inside your JavaScript files, this method automatically @@ -79,13 +80,13 @@ appends your css tags to the css view block. In your php-template you can import css files with: ```php -ViteScripts->css($options) ?> +ViteScripts->css('resources/style.css') ?> ``` -… or by using this shortcut for a single entrypoint: +… or multiple files ```php -ViteScripts->css('webroot_src/style.css') ?> +ViteScripts->css(['resources/style.css', 'resources/style2.css', 'resources/style3.css']) ?> ``` ## Configuration @@ -93,81 +94,116 @@ In your php-template you can import css files with: The plugin comes with some default configuration. You may need to change it depending on your setup. Or you might not need any config at all. -You can override some of these config settings through the `$options` of the helper methods. Or you can pass -your own instance of `ViteHelperConfig` to a helper method as a second parameter. +The default configuration is: ```php 'ViteHelper' => [ - 'build' => [ - 'outDirectory' => false, // output directory of build assets. string (e.g. 'dist') or false. - 'manifest' => WWW_ROOT . 'manifest.json', // absolute path to manifest - ], + 'plugin' => false, // or string 'MyPlugin' to serve plugin build assets + 'render_mode' => \ViteHelper\Enum\RenderMode::AUTO, // how/when the styles and scripts should be added to view blocks. available options AUTO, MANUAL + 'environment' => \ViteHelper\Enum\Environment::PRODUCTION, // available options PRODUCTION, DEVELOPMENT, FROM_DETECTOR 'development' => [ - 'scriptEntries' => ['someFolder/myScriptEntry.ts'], // relative to project root - 'styleEntries' => ['someFolder/myStyleEntry.scss'], // relative to project root. Unnecessary when using css-in-js. - 'hostNeedles' => ['.test', '.local'], // to check if the app is running locally 'url' => 'http://localhost:3000', // url of the vite dev server ], - 'forceProductionMode' => false, // or true to always serve build assets - 'plugin' => false, // or string 'MyPlugin' to serve plugin build assets - 'productionHint' => 'vprod', // can be a true-ish cookie or url-param to serve build assets without changing the forceProductionMode config - 'viewBlocks' => [ - 'css' => 'css', // name of the css view block - 'script' => 'script', // name of the script view block + 'build' => [ + 'outDirectory' => 'build', // output directory of build assets. string (e.g. 'dist') or false. + 'manifest' => WWW_ROOT . 'build' . DS . '.vite' . DS . 'manifest.json', // absolute path to manifest ], ], ``` -You can override the defaults in your `app.php`, `app_local.php`, or `app_vite.php`. +You can override the defaults in your `app.php`, `app_local.php`, or `app_vite.php`, also you can override in +`AppView.php` when you are loading the helper. -See the plugin's [app_vite.php](https://github.com/brandcom/cakephp-vite/blob/master/config/app_vite.php) for reference. +```php +$this->loadHelper('ViteHelper.ViteScripts', [ + // ...your config goes here +]); +``` + +## Environment -Example: +The plugin MUST accurately determine whether you are in development or production mode. You must explicitly set in the +config that you are in either `\ViteHelper\Enum\Environment::PRODUCTION` or `\ViteHelper\Enum\Environment::DEVELOPMENT`. +To enhance the flexibility of the plugin, you can utilize `\ViteHelper\Enum\Environment::FROM_DETECTOR`. This setting +will employ a [detector](https://book.cakephp.org/5/en/controllers/request-response.html#Cake\Http\ServerRequest::is) +to automatically detect the environment. ```php -return [ - 'ViteHelper' => [ - 'forceProductionMode' => 1, - 'development' => [ - 'hostNeedles' => ['.dev'], // if you don't use one of the defaults - 'url' => 'https://192.168.0.88:3000', - ], - ], -]; +$this->request->addDetector( + \ViteHelper\View\Helper\ViteScriptsHelper::VITESCRIPT_DETECTOR_NAME, + function ($serverRequest) { + // your logic goes here + // return true for prod, false for dev + } +); ``` ## Helper method usage with options -You can pass an `$options` array to override config or to completely skip the necessity to have a ViteHelper config. - -The options are mostly the same for `::script()` and `::css()`. +The options are the same for `::script()` and `::css()`. ### Example ```php -$this->ViteScripts->script([ +$this->ViteScripts->script( + // files for the block + files: ['resource/file1.js', 'resource/file2.js'], // can be also a string + + // filter for environment + // default: null + // in case of null the file(s) will be rendered both on prod and dev + // possible values: \ViteHelper\Enum\Environment::PRODUCTION, \ViteHelper\Enum\Environment::DEVELOPMENT, null + environment: null, + + // name of the view block to render the scripts in + // default null + // on null uses `css` for style, `script` for javascript files + block: null, + + // plugin prefix + // default null + // on null uses the plugin used in default config + plugin: null, +); +``` - // this would append both the scripts and the css to a block named 'myCustomBlock' - // don't forget to use the block through $this->fetch('myCustomBlock') - 'block' => 'myCustomBlock', - 'cssBlock' => 'myCustomBlock', // for ::script() only – if you use css imports inside js. +## Performance - // files that are entry files during development and that should be served during production - 'files' => [ - 'webroot_src/main.ts', - ], +In production, it's possible that the manifest.json file is too large. If the default render mode is set to `AUTO`, +every `::script()` and `::css()` call automatically and instantly adds the HTML tag to the block. You can disable this +feature by setting render mode `MANUAL`. - // "devEntries" is like "files". If you set "files", it will override both "devEntries" and "prodFilters" - 'devEntries' => ['webroot_src/main.ts'] - - // "prodFilter" filters the entry files. Useful for code-splitting if you don't use dynamic imports - 'prodFilter' => 'webroot_src/main.ts' // as string if there's only one option - 'prodFilter' => 'main.ts' // also works - only looks for parts of the string - 'prodFilter' => ['main.ts'] // as array - same as above with multiple files - 'prodFilter' => function (ManifestRecord $record) { /* do something with the record and return true or false */ } +```php +$this->loadHelper('ViteHelper.ViteScripts', [ + 'render_mode' => \ViteHelper\Enum\RenderMode::MANUAL ]); ``` +In your php-layout, right before the `viewBlocks` you should manually call the `::render()` method or dispatch +the `Vite.render` event. + +```php +$this->ViteScripts->script('resource/myscript1.js'); +$this->ViteScripts->script('resource/myscript2.js'); +// ... + a lot of script +$this->ViteScripts->script('resource/myscriptN.js'); +$this->ViteScripts->render(); +fetch('css') ?> +``` +or + +```php +$this->ViteScripts->script('resource/myscript1.js'); +$this->ViteScripts->script('resource/myscript2.js'); +// ... + a lot of script +$this->ViteScripts->script('resource/myscriptN.js'); +// dispatch 'Vite.render' event +$this->getEventManager()->dispatch('Vite.render'); +fetch('css') ?> +``` + +// TODO pluginScript should be removed, devEntries and prodFilter makes no-sense anymore + **Note:** You need to set `devEntries` when running the dev server. They have to either be set in the config or through the helper method. In contrast, you only need `files` or `prodFilter` if you are interested in php-side code-splitting and don't use dynamic imports in js. @@ -215,9 +251,9 @@ yarn add -D @vitejs/plugin-legacy ### Configuration After installing, you will need to refactor the files a bit to make sense of it in a php project. The default config of -this plugin assumes that you put your js, ts, scss etc. in `/webroot_src`. +this plugin assumes that you put your js, ts, scss etc. in `/resources`. -The build files will end up in `/webroot/assets` by default. Your `vite.config.js or *.ts` file for vite stays in the +The build files will end up in `/webroot/build` by default. Your `vite.config.js or *.ts` file for vite stays in the project root. > Wanted: Examples for vite/plugin configs and directory structures. Feel free to contribute with a PR to show how your diff --git a/composer.json b/composer.json index 7b61171..65fb081 100644 --- a/composer.json +++ b/composer.json @@ -33,8 +33,8 @@ }, "scripts": { "test": "phpunit", - "cs-check": "phpcs --colors --parallel=16 -p src/", - "cs-fix": "phpcbf --colors --parallel=16 -p src/", + "cs-check": "phpcs --colors -p src/", + "cs-fix": "phpcbf --colors -p src/", "stan": "phpstan analyse", "stan-setup": "cp composer.json composer.backup && composer require --dev phpstan/phpstan:^1.7.0 && mv composer.backup composer.json", "lowest-setup": "composer update --prefer-lowest --prefer-stable --prefer-dist --no-interaction && cp composer.json composer.backup && composer require --dev dereuromark/composer-prefer-lowest && mv composer.backup composer.json" diff --git a/config/app_vite.php b/config/app_vite.php index 5312d69..572b7f3 100644 --- a/config/app_vite.php +++ b/config/app_vite.php @@ -1,25 +1,16 @@ [ + 'plugin' => false, + 'render_mode' => \ViteHelper\Enum\RenderMode::AUTO, + 'environment' => \ViteHelper\Enum\Environment::PRODUCTION, 'build' => [ - 'outDirectory' => ConfigDefaults::BUILD_OUT_DIRECTORY, - 'manifest' => ConfigDefaults::BUILD_MANIFEST, + 'outDirectory' => 'build', + 'manifest' => WWW_ROOT . 'build' . DS . '.vite' . DS . 'manifest.json', ], 'development' => [ - 'scriptEntries' => ConfigDefaults::DEVELOPMENT_SCRIPT_ENTRIES, - 'styleEntries' => ConfigDefaults::DEVELOPMENT_STYLE_ENTRIES, - 'hostNeedles' => ConfigDefaults::DEVELOPMENT_HOST_NEEDLES, - 'url' => ConfigDefaults::DEVELOPMENT_URL, - ], - 'forceProductionMode' => ConfigDefaults::FORCE_PRODUCTION_MODE, - 'plugin' => false, - 'productionHint' => ConfigDefaults::PRODUCTION_HINT, - 'viewBlocks' => [ - 'css' => ConfigDefaults::VIEW_BLOCK_CSS, - 'script' => ConfigDefaults::VIEW_BLOCK_SCRIPT, + 'url' => 'http://localhost:3000', ], ], ]; diff --git a/etc/vite.config.ts.example b/etc/vite.config.ts.example index 710f644..9a13047 100644 --- a/etc/vite.config.ts.example +++ b/etc/vite.config.ts.example @@ -5,6 +5,8 @@ import basicSsl from '@vitejs/plugin-basic-ssl' // https://vitejs.dev/config/ export default defineConfig({ + root: '', + publicDir: 'resources/assets', plugins: [ basicSsl(), vue(), @@ -26,23 +28,28 @@ export default defineConfig({ protocol: 'wss', }, watch: { - ignored: [/bin/, /config/, /plugins/, /resources/, /tests/, /vendor/, /logs/, /tmp/], - depth: 5, + ignored: [/bin/, /config/, /plugins/, /tests/, /vendor/, /logs/, /tmp/], + depth: 15, } }, build: { emptyOutDir: true, outDir: './webroot/build', - assetsDir: 'assets', + assetsDir: './assets', manifest: true, rollupOptions: { input: [ - './webroot_src/js/main.js', - './webroot_src/js/timetables.js', - './webroot_src/scss/style.scss', + './resources/js/main.js', + './resources/js/timetables.js', + './resources/scss/style.scss', ], output: { entryFileNames: '[name].[hash].min.js', + manualChunks(id) { + if (id.includes('node_modules')) { + return id.toString().split('node_modules/')[1].split('/')[0].toString(); + } + } } } }, diff --git a/src/Enum/Environment.php b/src/Enum/Environment.php new file mode 100644 index 0000000..04b8e20 --- /dev/null +++ b/src/Enum/Environment.php @@ -0,0 +1,14 @@ +type = $type; + } + + /** + * returns the record type + * + * @return \ViteHelper\Enum\RecordType + */ + public function getType(): RecordType + { + return $this->type; + } +} diff --git a/src/Model/Entity/ScriptRecord.php b/src/Model/Entity/ScriptRecord.php new file mode 100644 index 0000000..b3f22cc --- /dev/null +++ b/src/Model/Entity/ScriptRecord.php @@ -0,0 +1,33 @@ +key = $key; - $this->chunk = $chunk; - $this->config = $config; + public function __construct( + private readonly string $key, + private readonly stdClass $chunk, + private readonly string|bool $outDirectory + ) { } /** @@ -207,6 +206,44 @@ public function isModuleEntryScript(): bool return $this->isEntry() && $this->isJavascript() && !$this->isLegacy() && !$this->isPolyfill(); } + /** + * Adds a key value to metadata (if exists overwrites) + * + * @param string $key + * @param string $value + * @return void + */ + public function addMetadata(string $key, string $value): void + { + $this->metadata[$key] = $value; + } + + /** + * Replaces the metadata array + * + * @param array $metadata + * @return void + */ + public function setMetadata(array $metadata): void + { + $this->metadata = $metadata; + } + + /** + * Returns a metadata value + * + * @param string|null $key + * @return mixed + */ + public function getMetadata(?string $key = null): mixed + { + if (is_null($key)) { + return $this->metadata; + } + + return array_key_exists($key, $this->metadata) ? $this->metadata[$key] : null; + } + /** * Enables users to set build.outDirectory in app_vite.php to false, * so that the outDir equals the webroot. @@ -216,14 +253,15 @@ public function isModuleEntryScript(): bool */ private function getLinkFromOutDirectory(string $assetLink): string { - $outDirectory = $this->config->read('build.outDirectory'); + $outDirectory = $this->outDirectory; if (empty($outDirectory) && $outDirectory !== false) { - $outDirectory = ConfigDefaults::BUILD_OUT_DIRECTORY; + // TODO Needs to be verified + $outDirectory = false; } $outDirectory = ltrim((string)$outDirectory, DS); - $outDirectory = $outDirectory ? DS . $outDirectory : ''; + $outDirectory = $outDirectory ? '/' . $outDirectory : ''; - return $outDirectory . DS . $assetLink; + return $outDirectory . '/' . $assetLink; } } diff --git a/src/Utilities/ViteHelperConfig.php b/src/Utilities/ViteHelperConfig.php deleted file mode 100644 index 81ae6d8..0000000 --- a/src/Utilities/ViteHelperConfig.php +++ /dev/null @@ -1,59 +0,0 @@ -config = is_array($config) ? $config : (array)Configure::read($config); - } - - /** - * @param array|string|null $config config array or key - * @return self - */ - public static function create(array|string|null $config = null): self - { - return new self($config); - } - - /** - * @param string $path path to config - * @param mixed $default default value - * @return mixed - */ - public function read(string $path, mixed $default = null): mixed - { - return Hash::get($this->config, $path, $default); - } - - /** - * Merge two configs - * - * @param \ViteHelper\Utilities\ViteHelperConfig $config - * @return self - */ - public function merge(ViteHelperConfig $config): self - { - return self::create(array_merge( - $this->config, - $config->config, - )); - } -} diff --git a/src/Utilities/ViteManifest.php b/src/Utilities/ViteManifest.php index c9f08a9..b08d2a8 100644 --- a/src/Utilities/ViteManifest.php +++ b/src/Utilities/ViteManifest.php @@ -3,7 +3,6 @@ namespace ViteHelper\Utilities; -use Cake\Core\Plugin; use ViteHelper\Exception\ManifestNotFoundException; /** @@ -14,19 +13,15 @@ class ViteManifest /** * Returns the manifest records as a Collection * - * @param \ViteHelper\Utilities\ViteHelperConfig $config plugin config instance + * @param string $manifestPath + * @param string $outDirectory * @return \ViteHelper\Utilities\ManifestRecords<\ViteHelper\Utilities\ManifestRecord> + * @throws \JsonException * @throws \ViteHelper\Exception\ManifestNotFoundException * @internal */ - public static function getRecords(ViteHelperConfig $config): ManifestRecords + public static function getRecords(string $manifestPath, string $outDirectory): ManifestRecords { - if ($config->read('plugin') && $config->read('build.manifest') === null) { - $manifestPath = static::getPluginManifestPath($config->read('plugin')); - } else { - $manifestPath = $config->read('build.manifest', ConfigDefaults::BUILD_MANIFEST); - } - if (!is_readable($manifestPath)) { throw new ManifestNotFoundException( "No valid manifest.json found at path {$manifestPath}. Did you build your js?", @@ -52,7 +47,7 @@ public static function getRecords(ViteHelperConfig $config): ManifestRecords $manifestArray = []; foreach (get_object_vars($manifest) as $property => $value) { - $manifestArray[$property] = new ManifestRecord($property, $value, $config); + $manifestArray[$property] = new ManifestRecord($property, $value, $outDirectory); } /** @@ -73,15 +68,4 @@ public static function getRecords(ViteHelperConfig $config): ManifestRecords return new ManifestRecords($manifestArray, $manifestPath); } - - /** - * Get the default location of a plugin's vite manifest.json - * - * @param string $pluginName e.g. "MyPlugin" - * @return string filesystem path to the Plugin's manifest.json - */ - protected static function getPluginManifestPath(string $pluginName): string - { - return Plugin::path($pluginName) . 'webroot' . DS . 'manifest.json'; - } } diff --git a/src/View/Helper/ViteScriptsHelper.php b/src/View/Helper/ViteScriptsHelper.php index aa91420..52f52db 100644 --- a/src/View/Helper/ViteScriptsHelper.php +++ b/src/View/Helper/ViteScriptsHelper.php @@ -3,15 +3,18 @@ namespace ViteHelper\View\Helper; -use Cake\Collection\CollectionInterface; +use Cake\Core\Configure; +use Cake\Core\Plugin; use Cake\Utility\Text; use Cake\View\Helper; +use JsonException; +use ViteHelper\Enum\Environment; +use ViteHelper\Enum\RenderMode; use ViteHelper\Exception\ConfigurationException; -use ViteHelper\Exception\InvalidArgumentException; -use ViteHelper\Utilities\ConfigDefaults; +use ViteHelper\Exception\ManifestNotFoundException; +use ViteHelper\Model\Entity\ScriptRecord; +use ViteHelper\Model\Entity\StyleRecord; use ViteHelper\Utilities\ManifestRecord; -use ViteHelper\Utilities\ManifestRecords; -use ViteHelper\Utilities\ViteHelperConfig; use ViteHelper\Utilities\ViteManifest; /** @@ -21,165 +24,296 @@ */ class ViteScriptsHelper extends Helper { + public const VITESCRIPT_DETECTOR_NAME = 'vite_in_production'; + public array $helpers = ['Html']; + protected array $entries; + + protected array $_defaultConfig = [ + 'plugin' => false, + 'render_mode' => RenderMode::AUTO, + 'environment' => Environment::PRODUCTION, + 'build' => [ + 'outDirectory' => 'build', + 'manifest' => WWW_ROOT . 'build' . DS . '.vite' . DS . 'manifest.json', + ], + 'viewBlocks' => [ + 'css' => 'css', + 'script' => 'script', + ], + 'development' => [ + 'url' => 'http://localhost:3000', + ], + ]; + /** - * Check if the app is currently in development state. - * - * Production mode can be forced in config through `forceProductionMode`, - * or by setting a cookie or a url-parameter. - * - * Otherwise, it will look for a hint that the app - * is in development mode through the `developmentHostNeedles` - * - * @param \ViteHelper\Utilities\ViteHelperConfig|string|null $config config key or instance to use - * @return bool + * @inheritDoc */ - public function isDev(ViteHelperConfig|string|null $config = null): bool + public function initialize(array $config): void { - $config = $this->createConfig($config); - if ($config->read('forceProductionMode', ConfigDefaults::FORCE_PRODUCTION_MODE)) { - return false; + parent::initialize($config); + $this->setConfig(Configure::read('ViteHelper')); + $this->setConfig($config); + $env = $this->getConfig('environment', 'prod'); + if (is_string($env)) { + $env = Environment::from($env); } - $productionHint = $config->read('productionHint', ConfigDefaults::PRODUCTION_HINT); - $hasCookieOrQuery = $this->getView()->getRequest()->getCookie($productionHint) || $this->getView()->getRequest()->getQuery($productionHint); - if ($hasCookieOrQuery) { - return false; + if (!($env instanceof Environment)) { + throw new ConfigurationException('Invalid environment config!'); } - $needles = $config->read('development.hostNeedles', ConfigDefaults::DEVELOPMENT_HOST_NEEDLES); - foreach ($needles as $needle) { - if (str_contains((string)$this->getView()->getRequest()->host(), $needle)) { - return true; - } + if ($env === Environment::FROM_DETECTOR) { + $this->setConfig( + 'environment', + $this->getView()->getRequest()->is(self::VITESCRIPT_DETECTOR_NAME) ? + Environment::PRODUCTION : Environment::DEVELOPMENT + ); } - return false; + if ($env === Environment::DEVELOPMENT) { + $this->Html->script( + $this->getConfig('development.url') + . '/@vite/client', + [ + 'type' => 'module', + 'block' => $this->getConfig('viewBlocks.css'), + ] + ); + } + $this->getView()->getEventManager()->on('Vite.render', [$this, 'render']); } /** - * Adds scripts to the script view block - * - * Options: - * * block (string): name of the view block to render the scripts in - * * files (string[]): files to serve in development and production - overrides prodFilter and devEntries - * * prodFilter (string, array, callable): to filter manifest entries in production mode - * * devEntries (string[]): entry files in development mode - * * other options are rendered as attributes to the html tag + * Adds styles and scripts to the blocks * - * @param array|string $options file entrypoint or script options - * @param \ViteHelper\Utilities\ViteHelperConfig|string|null $config config key or instance to use * @return void - * @throws \ViteHelper\Exception\ConfigurationException - * @throws \ViteHelper\Exception\ManifestNotFoundException|\ViteHelper\Exception\InvalidArgumentException */ - public function script(array|string $options = [], ViteHelperConfig|string|null $config = null): void + public function render(): void { - $config = $this->createConfig($config); - if (is_string($options)) { - $options = ['files' => [$options]]; + if ($this->getConfig('environment', Environment::PRODUCTION) === Environment::DEVELOPMENT) { + $this->outputDevelopmentScripts(); + $this->outputDevelopmentStyles(); + } else { + $this->outputProductionScripts(); + $this->outputProductionStyles(); } - $options['block'] = $options['block'] ?? $config->read('viewBlocks.script', ConfigDefaults::VIEW_BLOCK_SCRIPT); - $options['cssBlock'] = $options['cssBlock'] ?? $config->read('viewBlocks.css', ConfigDefaults::VIEW_BLOCK_CSS); - $options = $this->updateOptionsForFiltersAndEntries($options); - - if ($this->isDev($config)) { - $this->devScript($options, $config); + } - return; + /** + * Adds scripts to the script view block + * + * @param array|string $files files to serve + * @param \ViteHelper\Enum\Environment|string|null $environment the files will be served only in this environment, null on both + * @param string|null $block name of the view block to render the scripts in + * @param string|null $plugin + * @param array $elementOptions options to the html tag + * @return void + */ + public function script( + array|string $files = [], + Environment|string|null $environment = null, + ?string $block = null, + ?string $plugin = null, + array $elementOptions = [], + ): void { + if (is_string($environment)) { + $environment = Environment::tryFrom($environment); } + $elementOptions['block'] = $block ?? $this->getConfig('viewBlocks.script'); + $files = (array)$files; + foreach ($files as $file) { + switch ($environment) { + case Environment::DEVELOPMENT: + $this->entries[] = new ScriptRecord( + $file, + Environment::DEVELOPMENT, + $block, + $plugin, + $elementOptions, + ); + break; + case Environment::PRODUCTION: + $this->entries[] = new ScriptRecord( + $file, + Environment::PRODUCTION, + $block, + $plugin, + $elementOptions, + ); + break; + default: + $this->entries[] = new ScriptRecord( + $file, + Environment::DEVELOPMENT, + $block, + $plugin, + $elementOptions, + ); + $this->entries[] = new ScriptRecord( + $file, + Environment::PRODUCTION, + $block, + $plugin, + $elementOptions, + ); + break; + } + } + if ($this->getConfig('render_mode') === RenderMode::AUTO) { + $this->render(); + } + } - $this->productionScript($options, $config); + /** + * Adds style to the css view block + * + * @param array|string $files files to serve + * @param \ViteHelper\Enum\Environment|string|null $environment the files will be served only in this environment, null on both + * @param string|null $block name of the view block to render the scripts in + * @param string|null $plugin + * @param array $elementOptions options to the html tag + * @return void + */ + public function css( + array|string $files = [], + Environment|string|null $environment = null, + ?string $block = null, + ?string $plugin = null, + array $elementOptions = [], + ): void { + if (is_string($environment)) { + $environment = Environment::tryFrom($environment); + } + $elementOptions['block'] = $block ?? $this->getConfig('viewBlocks.css'); + $files = (array)$files; + foreach ($files as $file) { + switch ($environment) { + case Environment::DEVELOPMENT: + $this->entries[] = new StyleRecord( + $file, + Environment::DEVELOPMENT, + $block, + $plugin, + $elementOptions, + ); + break; + case Environment::PRODUCTION: + $this->entries[] = new StyleRecord( + $file, + Environment::PRODUCTION, + $block, + $plugin, + $elementOptions, + ); + break; + default: + $this->entries[] = new StyleRecord( + $file, + Environment::DEVELOPMENT, + $block, + $plugin, + $elementOptions, + ); + $this->entries[] = new StyleRecord( + $file, + Environment::PRODUCTION, + $block, + $plugin, + $elementOptions, + ); + break; + } + } + if ($this->getConfig('render_mode') === RenderMode::AUTO) { + $this->render(); + } } /** - * Convenience method to render a plugin's scripts + * Appends development script tags to configured block * - * @param string $pluginName e.g. MyPlugin - * @param bool $devMode set to true during development - * @param array $options helper options - * @param \ViteHelper\Utilities\ViteHelperConfig|string|null $config config key or instance to use * @return void - * @throws \ViteHelper\Exception\ConfigurationException - * @throws \ViteHelper\Exception\InvalidArgumentException - * @throws \ViteHelper\Exception\ManifestNotFoundException */ - public function pluginScript(string $pluginName, bool $devMode = false, array $options = [], ViteHelperConfig|string|null $config = null): void + private function outputDevelopmentScripts(): void { - $config = $this->createConfig($config); - $config = $config->merge(ViteHelperConfig::create([ - 'plugin' => $pluginName, - 'forceProductionMode' => !$devMode, - ])); + $files = array_filter($this->entries, function ($record) { + return $record instanceof ScriptRecord && + !$record->is_rendered && + $record->environment === Environment::DEVELOPMENT; + }); - $this->script($options, $config); + /** @var \ViteHelper\Model\Entity\ScriptRecord $record */ + foreach ($files as $record) { + $record->is_rendered = true; + $record->elementOptions['type'] = 'module'; + $this->Html->script(Text::insert(':host/:file', [ + 'host' => $this->getConfig('development.url'), + 'file' => ltrim($record->file, DS), + ]), $record->elementOptions); + } } /** - * @param array $options passed to script tag - * @param \ViteHelper\Utilities\ViteHelperConfig $config config instance + * Appends development style tags to configured block + * * @return void - * @throws \ViteHelper\Exception\ConfigurationException */ - private function devScript(array $options, ViteHelperConfig $config): void + private function outputDevelopmentStyles(): void { - $this->Html->script( - $config->read('development.url', ConfigDefaults::DEVELOPMENT_URL) - . '/@vite/client', - [ - 'type' => 'module', - 'block' => $options['cssBlock'], - ] - ); - - $files = $this->getFilesForDevelopment($options, $config, 'scriptEntries'); - - unset($options['cssBlock']); - unset($options['prodFilter']); - unset($options['devEntries']); - $options['type'] = 'module'; + $files = array_filter($this->entries, function ($record) { + return $record instanceof StyleRecord && + !$record->is_rendered && + $record->environment === Environment::DEVELOPMENT; + }); - foreach ($files as $file) { - $this->Html->script(Text::insert(':host/:file', [ - 'host' => $config->read('development.url', ConfigDefaults::DEVELOPMENT_URL), - 'file' => ltrim($file, DS), - ]), $options); + /** @var \ViteHelper\Model\Entity\StyleRecord $record */ + foreach ($files as $record) { + $record->is_rendered = true; + $this->Html->css(Text::insert(':host/:file', [ + 'host' => $this->getConfig('development.url'), + 'file' => ltrim($record->file, '/'), + ]), $record->elementOptions); } } /** - * @param array $options will be passed to script tag - * @param \ViteHelper\Utilities\ViteHelperConfig $config config instance + * Appends production script tags to configured block + * * @return void - * @throws \ViteHelper\Exception\ManifestNotFoundException - * @throws \ViteHelper\Exception\InvalidArgumentException */ - private function productionScript(array $options, ViteHelperConfig $config): void + private function outputProductionScripts(): void { - $pluginPrefix = $config->read('plugin'); - $pluginPrefix = $pluginPrefix ? $pluginPrefix . '.' : null; + $files = array_filter($this->entries, function ($record) { + return $record instanceof ScriptRecord && + !$record->is_rendered && + $record->environment === Environment::PRODUCTION; + }); - $records = $this->getFilteredRecords(ViteManifest::getRecords($config), $options); - $cssBlock = $options['cssBlock']; - unset($options['prodFilter']); - unset($options['cssBlock']); - unset($options['devEntries']); + $records = $this->getManifestRecords($files); + $pluginPrefix = $this->getConfig('plugin'); + $pluginPrefix = $pluginPrefix ? $pluginPrefix . '.' : null; + /** @var \ViteHelper\Utilities\ManifestRecord $record */ foreach ($records as $record) { if (!$record->isEntryScript()) { continue; } - unset($options['type']); - unset($options['nomodule']); + $options = $record->getMetadata(); if ($record->isModuleEntryScript()) { - $options['type'] = 'module'; + $options['options']['type'] = 'module'; } else { - $options['nomodule'] = 'nomodule'; + $options['options']['nomodule'] = 'nomodule'; } - $this->Html->script($pluginPrefix . $record->getFileUrl(), $options); + $recordPluginPrefix = $pluginPrefix; + if (isset($options['plugin'])) { + $recordPluginPrefix = $options['plugin'] . '.'; + unset($options['plugin']); + } + $this->Html->script($recordPluginPrefix . $record->getFileUrl(), $options['options']); // the js files has css dependency ? $cssFiles = $record->getCss(); @@ -188,195 +322,87 @@ private function productionScript(array $options, ViteHelperConfig $config): voi } foreach ($cssFiles as $cssFile) { - $this->Html->css($pluginPrefix . $cssFile, [ - 'block' => $cssBlock, + $this->Html->css($recordPluginPrefix . $cssFile, [ + 'block' => $this->getConfig('viewBlocks.css'), ]); } + unset($recordPluginPrefix); } + + array_map(fn ($file) => $file->is_rendered = true, $files); } /** - * Adds CSS tags to the configured block + * Appends production style tags to configured block * - * Note: This method might be unnecessary if you import your css in javascript. - * - * Options: - * * block (string): name of the view block to render the html tags in - * * files (string[]): files to serve in development and production - overrides prodFilter and devEntries - * * prodFilter (string, array, callable): to filter manifest entries in production mode - * * devEntries (string[]): entry files in development mode - * * other options are rendered as attributes to the html tag - * - * @param array|string $options file entrypoint or css options - * @param \ViteHelper\Utilities\ViteHelperConfig|string|null $config config key or instance to use * @return void - * @throws \ViteHelper\Exception\ManifestNotFoundException - * @throws \ViteHelper\Exception\ConfigurationException - * @throws \ViteHelper\Exception\InvalidArgumentException */ - public function css(array|string $options = [], ViteHelperConfig|string|null $config = null): void + private function outputProductionStyles(): void { - $config = $this->createConfig($config); - if (is_string($options)) { - $options = ['files' => [$options]]; - } - // TODO the default should be css. This is a bug but might break in production. - // So this should be replaced in a major release. - $options['block'] = $options['block'] ?? $config->read('viewBlocks.css', ConfigDefaults::VIEW_BLOCK_SCRIPT); - $options = $this->updateOptionsForFiltersAndEntries($options); - - if ($this->isDev($config)) { - $files = $this->getFilesForDevelopment($options, $config, 'styleEntries'); - unset($options['devEntries']); - foreach ($files as $file) { - $this->Html->css(Text::insert(':host/:file', [ - 'host' => $config->read('development.url', ConfigDefaults::DEVELOPMENT_URL), - 'file' => ltrim($file, '/'), - ]), $options); - } + $pluginPrefix = $this->getConfig('plugin'); + $pluginPrefix = $pluginPrefix ? $pluginPrefix . '.' : null; + $files = array_filter($this->entries, function ($record) { + return $record instanceof StyleRecord && + !$record->is_rendered && + $record->environment === Environment::PRODUCTION; + }); - return; - } + $records = $this->getManifestRecords($files); - $pluginPrefix = $config->read('plugin'); - $pluginPrefix = $pluginPrefix ? $pluginPrefix . '.' : null; - $records = $this->getFilteredRecords(ViteManifest::getRecords($config), $options); - unset($options['prodFilter']); - unset($options['devEntries']); foreach ($records as $record) { if (!$record->isEntry() || !$record->isStylesheet() || $record->isLegacy()) { continue; } + $options = $record->getMetadata(); + $recordPluginPrefix = $pluginPrefix; + if (isset($options['plugin'])) { + $recordPluginPrefix = $options['plugin'] . '.'; + unset($options['plugin']); + } - $this->Html->css($pluginPrefix . $record->getFileUrl(), $options); - } - } - - /** - * Convenience method to render a plugin's styles - * - * @param string $pluginName e.g. MyPlugin - * @param bool $devMode set to true during development - * @param array $options helper options - * @param \ViteHelper\Utilities\ViteHelperConfig|string|null $config config key or instance to use - * @return void - * @throws \ViteHelper\Exception\ConfigurationException - * @throws \ViteHelper\Exception\InvalidArgumentException - * @throws \ViteHelper\Exception\ManifestNotFoundException - */ - public function pluginCss(string $pluginName, bool $devMode = false, array $options = [], ViteHelperConfig|string|null $config = null): void - { - $config = $this->createConfig($config); - $config = $config->merge(ViteHelperConfig::create([ - 'plugin' => $pluginName, - 'forceProductionMode' => !$devMode, - ])); - - $this->css($options, $config); - } - - /** - * @param array $options entries can be passed through `devEntries` - * @param \ViteHelper\Utilities\ViteHelperConfig $config config instance - * @param string $configOption key of the config - * @return array - * @throws \ViteHelper\Exception\ConfigurationException - */ - private function getFilesForDevelopment(array $options, ViteHelperConfig $config, string $configOption): array - { - $files = $options['devEntries'] ?: $config->read('development.' . $configOption, ConfigDefaults::DEVELOPMENT_SCRIPT_ENTRIES); - - if (empty($files)) { - throw new ConfigurationException( - 'There are no valid entry points for the dev server. ' - . 'Be sure to set the ViteHelper.development.' . $configOption . ' config or pass entries to the helper.' - ); - } - - if (!array_is_list($files)) { - throw new ConfigurationException(sprintf( - 'Expected entryPoints to be a List (array with int-keys) with at least one entry, but got %s.', - gettype($files) === 'array' ? 'a relational array' : gettype($files), - )); + $this->Html->css($pluginPrefix . $record->getFileUrl(), $options['options']); + unset($recordPluginPrefix); } - return $files; + array_map(fn ($file) => $file->is_rendered = true, $files); } /** - * Filter records from vite manifest for production + * Returns manifest records with the correct metadata * - * @param \ViteHelper\Utilities\ManifestRecords $records records to filter - * @param array $options method looks at the `prodFilter`key - * @return \ViteHelper\Utilities\ManifestRecords|\Cake\Collection\CollectionInterface - * @throws \ViteHelper\Exception\InvalidArgumentException + * @param array $files + * @return iterable */ - private function getFilteredRecords(ManifestRecords $records, array $options): ManifestRecords|CollectionInterface + private function getManifestRecords(iterable $files): iterable { - $filter = $options['prodFilter']; - if (empty($filter)) { - return $records; + if ($this->getConfig('plugin') && $this->getConfig('build.manifest') === null) { + $manifestPath = Plugin::path($this->getConfig('plugin')) . 'webroot' . DS . 'manifest.json'; + } else { + $manifestPath = $this->getConfig('build.manifest'); } - if (is_callable($filter)) { - return $records->filter($filter); - } - - if (is_string($filter)) { - $filter = (array)$filter; - } - - if (!is_array($filter)) { - throw new InvalidArgumentException('$options["prodFilter"] must be empty or of type string, array, or callable.'); - } - - return $records->filter(function (ManifestRecord $record) use ($filter) { - foreach ($filter as $property => $file) { - $property = is_string($property) ? $property : 'src'; - if ($record->match($file, $property)) { - return true; + try { + // TODO: Cache ?! + $records = ViteManifest::getRecords($manifestPath, $this->getConfig('build.outDirectory')); + $records = $records->filter(function (ManifestRecord $record) use ($files) { + /** @var \ViteHelper\Model\Entity\StyleRecord|\ViteHelper\Model\Entity\ScriptRecord $file */ + foreach ($files as $file) { + if ($record->match($file->file, 'src')) { + $record->setMetadata([ + 'options' => $file->elementOptions, + 'plugin' => $file->plugin, + ]); + + return $record; + } } - } - - return false; - }); - } - /** - * @param array $options options with `prodFilter`, `devEntries`, or `files` key - * @return array - */ - private function updateOptionsForFiltersAndEntries(array $options): array - { - $options['prodFilter'] = $options['prodFilter'] ?? null; - $options['devEntries'] = $options['devEntries'] ?? null; - $files = $options['files'] ?? null; - if ($files) { - if (!empty($options['devEntries'])) { - trigger_error('"devEntries" passed to ViteHelper will be overridden by "files".'); - } - if (!empty($options['prodFilter'])) { - trigger_error('"prodFilter" passed to ViteHelper will be overridden by "files".'); - } - $options['devEntries'] = $files; - $options['prodFilter'] = $files; - } - - return $options; - } - - /** - * Helper method to create a new config or the defined config - * - * @param \ViteHelper\Utilities\ViteHelperConfig|string|null $config can be a config key, a config instance or null for the default - * @return \ViteHelper\Utilities\ViteHelperConfig - */ - private function createConfig(ViteHelperConfig|string|null $config): ViteHelperConfig - { - if ($config instanceof ViteHelperConfig) { - return $config; + return false; + }); + } catch (ManifestNotFoundException | JsonException $e) { + $records = []; } - return ViteHelperConfig::create($config); + return $records; } }