From a7d0876ed09c0cdca5ea016d734c6c3f2c702307 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 25 Aug 2026 13:37:06 -0400 Subject: [PATCH 1/2] feat!: add `Vite::create()` and replace high-arity render-option and manifest-chunk construction with fluent immutable APIs. --- CHANGELOG.md | 4 +- README.md | 8 +- docs/configuration.md | 18 +- docs/examples.md | 6 +- docs/installation.md | 2 +- docs/manifest.md | 9 +- docs/security.md | 17 +- src/Html/HtmlRenderOptions.php | 240 ++++++++++++++++++--- src/Html/HtmlRenderer.php | 10 +- src/Manifest/ManifestChunk.php | 284 +++++++++++++++++++++++-- src/Manifest/ManifestLoader.php | 26 +-- src/Resolver/ManifestAssetResolver.php | 6 +- src/Vite.php | 20 ++ tests/ConfigurationTest.php | 35 ++- tests/HtmlRendererTest.php | 281 ++++++++++++++++++++++-- tests/ManifestChunkTest.php | 232 +++++++++++++++++++- tests/ManifestLoaderTest.php | 36 +++- 17 files changed, 1108 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37853b3..b7a5c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 0.1.1 Under development +## 0.2.0 Under development - docs: add `Next steps` section with links to installation, usage, configuration, and testing guides. -- docs: update badge in `README.md` to reflect security checks. +- feat!: add `Vite::create()` and replace high-arity render-option and manifest-chunk construction with fluent immutable APIs. ## 0.1.0 August 24, 2026 diff --git a/README.md b/README.md index d808a0f..b9a8378 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ ## Installation ```bash -composer require php-forge/vite:^0.1 +composer require php-forge/vite:^0.2 ``` HTML output is generated with [`ui-awesome/html`](https://github.com/ui-awesome/html) while asset resolution remains @@ -64,7 +64,7 @@ use PHPForge\Vite\Configuration\DevelopmentConfiguration; use PHPForge\Vite\Html\HtmlRenderer; use PHPForge\Vite\Vite; -$vite = new Vite( +$vite = Vite::create( new DevelopmentConfiguration( devServerUrl: 'http://localhost:5173', ), @@ -81,7 +81,7 @@ use PHPForge\Vite\Configuration\ProductionConfiguration; use PHPForge\Vite\Html\HtmlRenderer; use PHPForge\Vite\Vite; -$vite = new Vite( +$vite = Vite::create( new ProductionConfiguration( manifestPath: '/srv/app/public/build/.vite/manifest.json', assetBaseUrl: '/build', @@ -112,7 +112,7 @@ echo (new HtmlRenderer())->render($vite->resolve()); [![Codecov](https://img.shields.io/codecov/c/github/php-forge/vite.svg?style=for-the-badge&logo=codecov&logoColor=white&label=Coverage)](https://codecov.io/gh/php-forge/vite) [![PHPStan Level Max](https://img.shields.io/badge/PHPStan-Level%20Max-4F5D95.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/php-forge/vite/actions/workflows/static.yml) [![Quality](https://img.shields.io/github/actions/workflow/status/php-forge/vite/quality.yml?style=for-the-badge&label=Quality&logo=github)](https://github.com/php-forge/vite/actions/workflows/quality.yml) -[![Dependency Check](https://img.shields.io/github/actions/workflow/status/php-forge/vite/dependency-check.yml?style=for-the-badge&label=Dependency%20Check&logo=github)](https://github.com/php-forge/vite/actions/workflows/dependency-check.yml) +[![StyleCI](https://img.shields.io/badge/StyleCI-Passed-44CC11.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.styleci.io/repos/1342863441?branch=main) ## Social networks diff --git a/docs/configuration.md b/docs/configuration.md index 59be086..099f767 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -49,7 +49,7 @@ strings, fragments, and non-HTTP schemes are rejected. ```php use PHPForge\Vite\Vite; -$vite = new Vite( +$vite = Vite::create( configuration: $configuration, entrypoints: ['resources/js/app.js'], ); @@ -59,6 +59,9 @@ $pageAssets = $vite->resolve('resources/js/admin.js'); $combinedAssets = $vite->resolve(['resources/js/app.js', 'resources/js/admin.js']); ``` +`Vite::create()` is an additive construction shortcut. The public constructor remains available for dependency-injection +containers and accepts the same arguments. + Default entrypoints belong to the facade because they apply equally to development and production. Duplicate entrypoints are removed while preserving the first occurrence. At least one entrypoint must be available when `Vite::resolve()` is called. @@ -97,14 +100,17 @@ use PHPForge\Vite\Html\HtmlRenderOptions; $html = (new HtmlRenderer())->render( $vite->resolve(), - new HtmlRenderOptions( - nonce: $nonce, - moduleScriptAttributes: ['crossorigin' => true], - stylesheetAttributes: ['media' => 'screen'], - ), + HtmlRenderOptions::create() + ->withNonce($nonce) + ->withModuleScriptAttributes(['crossorigin' => true]) + ->withStylesheetAttributes(['media' => 'screen']), ); ``` +`HtmlRenderOptions::create()` starts with the default policy. Use `withNonce()`, `withSeparator()`, the four per-asset +attribute modifiers, and `withAttributeProvider()` to replace individual values. Every modifier returns a new policy and +leaves the original instance unchanged. + `HtmlRenderer` maps the neutral asset objects to `ui-awesome/html` `Script` and `Link` elements. Applications that consume `AssetCollection` directly do not depend on the renderer's markup structure. diff --git a/docs/examples.md b/docs/examples.md index c756f46..b9f5095 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -42,7 +42,7 @@ $configuration = $isDevelopment assetBaseUrl: '/build', ); -$vite = new Vite($configuration, entrypoints: ['resources/js/app.js']); +$vite = Vite::create($configuration, entrypoints: ['resources/js/app.js']); $assets = $vite->resolve(); @@ -122,7 +122,7 @@ use PHPForge\Vite\Vite; use Yiisoft\Aliases\Aliases; static function (Aliases $aliases): Vite { - return new Vite( + return Vite::create( new ProductionConfiguration( manifestPath: $aliases->get('@public/build/.vite/manifest.json'), assetBaseUrl: '/build', @@ -167,7 +167,7 @@ $configuration = new DevelopmentConfiguration( inlineModuleProviders: [new ReactRefreshPreamble()], ); -$vite = new Vite($configuration, entrypoints: ['resources/js/app.jsx']); +$vite = Vite::create($configuration, entrypoints: ['resources/js/app.jsx']); ``` Providers run in their configured order before `@vite/client` and the entrypoint scripts. The application owns the provider diff --git a/docs/installation.md b/docs/installation.md index a914cf0..071a2a3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -14,7 +14,7 @@ library, which includes `ui-awesome/html-helper` transitively. ## Install the PHP package ```bash -composer require php-forge/vite:^0.1 +composer require php-forge/vite:^0.2 ``` ## Configure the consuming project diff --git a/docs/manifest.md b/docs/manifest.md index 1638f13..8dc273c 100644 --- a/docs/manifest.md +++ b/docs/manifest.md @@ -24,6 +24,11 @@ Unknown chunk fields are accepted as forward-compatible input and ignored. Known Every `file`, `css`, and `assets` value must be a safe relative build path. Every static or dynamic reference must identify another manifest entry. +Consumers constructing chunks directly can use `ManifestChunk::create($key, $file)` or its public two-argument constructor, +then replace optional fields with `withSrc()`, `withCss()`, `withAssets()`, `withEntry()`, `withName()`, +`withDynamicEntry()`, `withImports()`, and `withDynamicImports()`. Each modifier returns a new chunk. Optional values are +read through the corresponding typed getters. + ## Initial-page resolution For each requested entrypoint, the resolver: @@ -39,8 +44,8 @@ prevents infinite recursion for malformed circular import graphs and prevents re Selected entrypoint scripts are not also emitted as modulepreload assets. `dynamicImports` are validated but are not placed in the initial page because the browser loads them when the application -executes the corresponding dynamic import. The `assets` field is represented in `ManifestChunk` for consumers inspecting a -manifest, but generic HTML tags cannot be inferred safely from those files and are not emitted automatically. +executes the corresponding dynamic import. The `assets` field is available through `ManifestChunk::assets()` for consumers +inspecting a manifest, but generic HTML tags cannot be inferred safely from those files and are not emitted automatically. ## Failure behavior diff --git a/docs/security.md b/docs/security.md index b304e8b..38ed1c1 100644 --- a/docs/security.md +++ b/docs/security.md @@ -19,13 +19,14 @@ use PHPForge\Vite\Asset\AssetInterface; use PHPForge\Vite\Asset\ModuleScript; use PHPForge\Vite\Html\HtmlRenderOptions; -$options = new HtmlRenderOptions( - moduleScriptAttributes: ['crossorigin' => true], - stylesheetAttributes: ['media' => 'screen'], - attributeProvider: static fn(AssetInterface $asset): array => $asset instanceof ModuleScript - ? ['data-entry' => 'application'] - : [], -); +$options = HtmlRenderOptions::create() + ->withModuleScriptAttributes(['crossorigin' => true]) + ->withStylesheetAttributes(['media' => 'screen']) + ->withAttributeProvider( + static fn(AssetInterface $asset): array => $asset instanceof ModuleScript + ? ['data-entry' => 'application'] + : [], + ); ``` Attribute names must begin with a letter or underscore and may otherwise contain letters, digits, underscores, or hyphens. @@ -52,7 +53,7 @@ header("Content-Security-Policy: script-src 'nonce-{$nonce}' 'strict-dynamic'; o $html = (new HtmlRenderer())->render( $vite->resolve(), - new HtmlRenderOptions(nonce: $nonce), + HtmlRenderOptions::create()->withNonce($nonce), ); ``` diff --git a/src/Html/HtmlRenderOptions.php b/src/Html/HtmlRenderOptions.php index a311b5f..060d513 100644 --- a/src/Html/HtmlRenderOptions.php +++ b/src/Html/HtmlRenderOptions.php @@ -15,43 +15,43 @@ /** * Immutable per-render HTML policy. */ -final readonly class HtmlRenderOptions +final class HtmlRenderOptions { /** * @var (Closure(AssetInterface): mixed)|null Per-asset attribute callback, or `null` when only the static per-type * attributes apply. */ - private Closure|null $attributeProvider; + private Closure|null $attributeProvider = null; /** - * @param string|null $nonce CSP nonce applied to every generated tag, or `null` to emit none. - * @param array $moduleScriptAttributes Extra attributes for module scripts. - * @param array $stylesheetAttributes Extra attributes for stylesheets. - * @param array $modulePreloadAttributes Extra attributes for preload hints. - * @param array $inlineModuleAttributes Extra attributes for inline modules. - * @param (callable(AssetInterface): mixed)|null $attributeProvider Callback returning per-asset attributes that - * override the per-type ones, or `null` to apply none. - * @param string $separator String inserted between two rendered tags. - * - * @throws HtmlRenderingException if the nonce is not a non-empty base64 or base64url value. + * @var array Extra attributes for inline modules. */ - public function __construct( - public string|null $nonce = null, - public array $moduleScriptAttributes = [], - public array $stylesheetAttributes = [], - public array $modulePreloadAttributes = [], - public array $inlineModuleAttributes = [], - callable|null $attributeProvider = null, - public string $separator = "\n", - ) { - if ($nonce !== null && preg_match('/^[A-Za-z0-9+\/_-]+={0,2}$/', $nonce) !== 1) { - throw new HtmlRenderingException( - Message::CSP_NONCE_INVALID->getMessage(), - ); - } + private array $inlineModuleAttributes = []; - $this->attributeProvider = $attributeProvider === null ? null : Closure::fromCallable($attributeProvider); - } + /** + * @var array Extra attributes for preload hints. + */ + private array $modulePreloadAttributes = []; + + /** + * @var array Extra attributes for module scripts. + */ + private array $moduleScriptAttributes = []; + + /** + * CSP nonce applied to every generated tag, or `null` to emit none. + */ + private string|null $nonce = null; + + /** + * String inserted between two rendered tags. + */ + private string $separator = "\n"; + + /** + * @var array Extra attributes for stylesheets. + */ + private array $stylesheetAttributes = []; /** * Returns the attributes configured for the supplied asset. @@ -92,6 +92,190 @@ public function attributesFor(AssetInterface $asset): array return array_replace($attributes, $provided); } + /** + * Creates an HTML render policy with the default settings. + * + * @return HtmlRenderOptions A new default render policy. + */ + public static function create(): self + { + return new self(); + } + + /** + * Returns the attributes configured for inline modules. + * + * @return array Extra attributes for inline modules. + */ + public function inlineModuleAttributes(): array + { + return $this->inlineModuleAttributes; + } + + /** + * Returns the attributes configured for module-preload hints. + * + * @return array Extra attributes for module-preload hints. + */ + public function modulePreloadAttributes(): array + { + return $this->modulePreloadAttributes; + } + + /** + * Returns the attributes configured for module scripts. + * + * @return array Extra attributes for module scripts. + */ + public function moduleScriptAttributes(): array + { + return $this->moduleScriptAttributes; + } + + /** + * Returns the CSP nonce applied to every generated tag. + * + * @return string|null The configured nonce, or `null` when none is emitted. + */ + public function nonce(): string|null + { + return $this->nonce; + } + + /** + * Returns the string inserted between rendered tags. + * + * @return string The configured tag separator. + */ + public function separator(): string + { + return $this->separator; + } + + /** + * Returns the attributes configured for stylesheets. + * + * @return array Extra attributes for stylesheets. + */ + public function stylesheetAttributes(): array + { + return $this->stylesheetAttributes; + } + + /** + * Returns a new policy with the per-asset attribute provider replaced. + * + * @param (callable(AssetInterface): mixed)|null $attributeProvider Callback returning per-asset attributes that + * override the per-type ones, or `null` to apply none. + * + * @return HtmlRenderOptions A new policy containing the supplied provider. + */ + public function withAttributeProvider(callable|null $attributeProvider): self + { + $clone = clone $this; + $clone->attributeProvider = $attributeProvider === null ? null : Closure::fromCallable($attributeProvider); + + return $clone; + } + + /** + * Returns a new policy with the inline-module attributes replaced. + * + * @param array $attributes Extra attributes for inline modules. + * + * @return HtmlRenderOptions A new policy containing the supplied attributes. + */ + public function withInlineModuleAttributes(array $attributes): self + { + $clone = clone $this; + $clone->inlineModuleAttributes = $attributes; + + return $clone; + } + + /** + * Returns a new policy with the module-preload attributes replaced. + * + * @param array $attributes Extra attributes for module-preload hints. + * + * @return HtmlRenderOptions A new policy containing the supplied attributes. + */ + public function withModulePreloadAttributes(array $attributes): self + { + $clone = clone $this; + $clone->modulePreloadAttributes = $attributes; + + return $clone; + } + + /** + * Returns a new policy with the module-script attributes replaced. + * + * @param array $attributes Extra attributes for module scripts. + * + * @return HtmlRenderOptions A new policy containing the supplied attributes. + */ + public function withModuleScriptAttributes(array $attributes): self + { + $clone = clone $this; + $clone->moduleScriptAttributes = $attributes; + + return $clone; + } + + /** + * Returns a new policy with the CSP nonce replaced. + * + * @param string|null $nonce CSP nonce applied to every generated tag, or `null` to emit none. + * + * @throws HtmlRenderingException if the nonce is not a non-empty base64 or base64url value. + * + * @return HtmlRenderOptions A new policy containing the supplied nonce. + */ + public function withNonce(string|null $nonce): self + { + if ($nonce !== null && preg_match('/^[A-Za-z0-9+\/_-]+={0,2}$/', $nonce) !== 1) { + throw new HtmlRenderingException( + Message::CSP_NONCE_INVALID->getMessage(), + ); + } + + $clone = clone $this; + $clone->nonce = $nonce; + + return $clone; + } + + /** + * Returns a new policy with the tag separator replaced. + * + * @param string $separator String inserted between two rendered tags. + * + * @return HtmlRenderOptions A new policy containing the supplied separator. + */ + public function withSeparator(string $separator): self + { + $clone = clone $this; + $clone->separator = $separator; + + return $clone; + } + + /** + * Returns a new policy with the stylesheet attributes replaced. + * + * @param array $attributes Extra attributes for stylesheets. + * + * @return HtmlRenderOptions A new policy containing the supplied attributes. + */ + public function withStylesheetAttributes(array $attributes): self + { + $clone = clone $this; + $clone->stylesheetAttributes = $attributes; + + return $clone; + } + /** * Invokes the attribute provider without narrowing its result. * diff --git a/src/Html/HtmlRenderer.php b/src/Html/HtmlRenderer.php index 4b10ffc..7e1fe70 100644 --- a/src/Html/HtmlRenderer.php +++ b/src/Html/HtmlRenderer.php @@ -54,7 +54,7 @@ final class HtmlRenderer */ public function render(AssetCollection $assets, HtmlRenderOptions|null $options = null): string { - $options ??= new HtmlRenderOptions(); + $options ??= HtmlRenderOptions::create(); $tags = []; @@ -62,7 +62,7 @@ public function render(AssetCollection $assets, HtmlRenderOptions|null $options $tags[] = $this->renderAsset($asset, $options); } - return implode($options->separator, $tags); + return implode($options->separator(), $tags); } /** @@ -82,9 +82,11 @@ public function render(AssetCollection $assets, HtmlRenderOptions|null $options private function attributesFor(AssetInterface $asset, HtmlRenderOptions $options): array { $attributes = []; + + $nonce = $options->nonce(); - if ($options->nonce !== null) { - $attributes['nonce'] = $options->nonce; + if ($nonce !== null) { + $attributes['nonce'] = $nonce; } $seen = []; diff --git a/src/Manifest/ManifestChunk.php b/src/Manifest/ManifestChunk.php index a7c3c0a..e02c9f8 100644 --- a/src/Manifest/ManifestChunk.php +++ b/src/Manifest/ManifestChunk.php @@ -10,32 +10,114 @@ /** * Immutable representation of one Vite manifest chunk. */ -final readonly class ManifestChunk +final class ManifestChunk { + /** + * Static assets referenced by this chunk. + * + * @var list + */ + private array $assets = []; + + /** + * Stylesheets emitted alongside this chunk. + * + * @var list + */ + private array $css = []; + + /** + * Dynamically imported chunk keys. + * + * @var list + */ + private array $dynamicImports = []; + + /** + * Statically imported chunk keys. + * + * @var list + */ + private array $imports = []; + + /** + * Whether the chunk is the target of a dynamic import. + */ + private bool $isDynamicEntry = false; + + /** + * Whether the chunk is a build entrypoint. + */ + private bool $isEntry = false; + + /** + * Chunk name, or `null` when Vite emitted none. + */ + private string|null $name = null; + + /** + * Original source path, or `null` for chunks Vite generated without one. + */ + private string|null $src = null; + /** * @param string $key Manifest key under which the entry is declared. * @param string $file Emitted build file, relative to the asset base URL. - * @param string|null $src Original source path, or `null` for chunks Vite generated without one. - * @param list $css Stylesheets emitted alongside this chunk. - * @param list $assets Static assets referenced by this chunk. - * @param bool $isEntry Whether the chunk is a build entrypoint. - * @param string|null $name Chunk name, or `null` when Vite emitted none. - * @param bool $isDynamicEntry Whether the chunk is the target of a dynamic import. - * @param list $imports Statically imported chunk keys. - * @param list $dynamicImports Dynamically imported chunk keys. */ - public function __construct( - public string $key, - public string $file, - public string|null $src = null, - public array $css = [], - public array $assets = [], - public bool $isEntry = false, - public string|null $name = null, - public bool $isDynamicEntry = false, - public array $imports = [], - public array $dynamicImports = [], - ) {} + public function __construct(public readonly string $key, public readonly string $file) {} + + /** + * Returns the static assets referenced by this chunk. + * + * @return list Static asset paths in manifest order. + */ + public function assets(): array + { + return $this->assets; + } + + /** + * Creates a manifest chunk from its required values. + * + * @param string $key Manifest key under which the entry is declared. + * @param string $file Emitted build file, relative to the asset base URL. + * + * @return ManifestChunk A new manifest chunk with the optional values set to their defaults. + */ + public static function create(string $key, string $file): self + { + return new self($key, $file); + } + + /** + * Returns the stylesheets emitted alongside this chunk. + * + * @return list Stylesheet paths in manifest order. + */ + public function css(): array + { + return $this->css; + } + + /** + * Returns the dynamically imported chunk keys. + * + * @return list Dynamic import references in manifest order. + */ + public function dynamicImports(): array + { + return $this->dynamicImports; + } + + /** + * Returns the statically imported chunk keys. + * + * @return list Static import references in manifest order. + */ + public function imports(): array + { + return $this->imports; + } /** * Detects whether the emitted file is a stylesheet rather than a JavaScript module. @@ -48,4 +130,164 @@ public function isCss(): bool { return str_ends_with(strtolower($this->file), '.css'); } + + /** + * Returns whether the chunk is the target of a dynamic import. + * + * @return bool Whether the chunk is a dynamic entrypoint. + */ + public function isDynamicEntry(): bool + { + return $this->isDynamicEntry; + } + + /** + * Returns whether the chunk is a build entrypoint. + * + * @return bool Whether the chunk is a build entrypoint. + */ + public function isEntry(): bool + { + return $this->isEntry; + } + + /** + * Returns the chunk name emitted by Vite. + * + * @return string|null Chunk name, or `null` when Vite emitted none. + */ + public function name(): string|null + { + return $this->name; + } + + /** + * Returns the original source path emitted by Vite. + * + * @return string|null Original source path, or `null` when Vite emitted none. + */ + public function src(): string|null + { + return $this->src; + } + + /** + * Returns a new chunk with the static asset paths replaced. + * + * @param list $assets Static assets referenced by this chunk. + * + * @return ManifestChunk A new chunk containing the supplied static assets. + */ + public function withAssets(array $assets): self + { + $clone = clone $this; + $clone->assets = $assets; + + return $clone; + } + + /** + * Returns a new chunk with the stylesheet paths replaced. + * + * @param list $css Stylesheets emitted alongside this chunk. + * + * @return ManifestChunk A new chunk containing the supplied stylesheet paths. + */ + public function withCss(array $css): self + { + $clone = clone $this; + $clone->css = $css; + + return $clone; + } + + /** + * Returns a new chunk with its dynamic-entry status replaced. + * + * @param bool $isDynamicEntry Whether the chunk is the target of a dynamic import. + * + * @return ManifestChunk A new chunk with the supplied dynamic-entry status. + */ + public function withDynamicEntry(bool $isDynamicEntry = true): self + { + $clone = clone $this; + $clone->isDynamicEntry = $isDynamicEntry; + + return $clone; + } + + /** + * Returns a new chunk with the dynamic import references replaced. + * + * @param list $dynamicImports Dynamically imported chunk keys. + * + * @return ManifestChunk A new chunk containing the supplied dynamic import references. + */ + public function withDynamicImports(array $dynamicImports): self + { + $clone = clone $this; + $clone->dynamicImports = $dynamicImports; + + return $clone; + } + + /** + * Returns a new chunk with its entrypoint status replaced. + * + * @param bool $isEntry Whether the chunk is a build entrypoint. + * + * @return ManifestChunk A new chunk with the supplied entrypoint status. + */ + public function withEntry(bool $isEntry = true): self + { + $clone = clone $this; + $clone->isEntry = $isEntry; + + return $clone; + } + + /** + * Returns a new chunk with the static import references replaced. + * + * @param list $imports Statically imported chunk keys. + * + * @return ManifestChunk A new chunk containing the supplied static import references. + */ + public function withImports(array $imports): self + { + $clone = clone $this; + $clone->imports = $imports; + + return $clone; + } + + /** + * Returns a new chunk with its name replaced. + * + * @param string|null $name Chunk name, or `null` when Vite emitted none. + * + * @return ManifestChunk A new chunk with the supplied name. + */ + public function withName(string|null $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + /** + * Returns a new chunk with its original source path replaced. + * + * @param string|null $src Original source path, or `null` when Vite emitted none. + * + * @return ManifestChunk A new chunk with the supplied source path. + */ + public function withSrc(string|null $src): self + { + $clone = clone $this; + $clone->src = $src; + + return $clone; + } } diff --git a/src/Manifest/ManifestLoader.php b/src/Manifest/ManifestLoader.php index 8585fe2..c92e434 100644 --- a/src/Manifest/ManifestLoader.php +++ b/src/Manifest/ManifestLoader.php @@ -97,6 +97,7 @@ public function load(string $manifestPath): Manifest } $fingerprint = $this->fingerprint($metadata); + $cached = $this->cache[$manifestPath] ?? null; if ($cached !== null && $cached['fingerprint'] === $fingerprint) { @@ -344,22 +345,21 @@ private function parse(string $manifestPath, stdClass $decoded): Manifest ); } - $chunks[$key] = new ManifestChunk( - key: $key, - file: $file, - src: $this->optionalString($value, 'src', $key, $manifestPath), - css: $this->optionalList($value, 'css', $key, $manifestPath, true), - assets: $this->optionalList($value, 'assets', $key, $manifestPath, true), - isEntry: $this->optionalBool($value, 'isEntry', $key, $manifestPath), - name: $this->optionalString($value, 'name', $key, $manifestPath), - isDynamicEntry: $this->optionalBool($value, 'isDynamicEntry', $key, $manifestPath), - imports: $this->optionalList($value, 'imports', $key, $manifestPath, false), - dynamicImports: $this->optionalList($value, 'dynamicImports', $key, $manifestPath, false), - ); + $chunks[$key] = ManifestChunk::create($key, $file) + ->withSrc($this->optionalString($value, 'src', $key, $manifestPath)) + ->withCss($this->optionalList($value, 'css', $key, $manifestPath, true)) + ->withAssets($this->optionalList($value, 'assets', $key, $manifestPath, true)) + ->withEntry($this->optionalBool($value, 'isEntry', $key, $manifestPath)) + ->withName($this->optionalString($value, 'name', $key, $manifestPath)) + ->withDynamicEntry($this->optionalBool($value, 'isDynamicEntry', $key, $manifestPath)) + ->withImports($this->optionalList($value, 'imports', $key, $manifestPath, false)) + ->withDynamicImports($this->optionalList($value, 'dynamicImports', $key, $manifestPath, false)); } foreach ($chunks as $chunk) { - foreach (['imports' => $chunk->imports, 'dynamicImports' => $chunk->dynamicImports] as $field => $references) { + foreach ( + ['imports' => $chunk->imports(), 'dynamicImports' => $chunk->dynamicImports()] as $field => $references + ) { foreach ($references as $reference) { if (!array_key_exists($reference, $chunks)) { throw new InvalidManifestException( diff --git a/src/Resolver/ManifestAssetResolver.php b/src/Resolver/ManifestAssetResolver.php index 3e1a115..6474c53 100644 --- a/src/Resolver/ManifestAssetResolver.php +++ b/src/Resolver/ManifestAssetResolver.php @@ -68,7 +68,7 @@ public function resolve(array $entrypoints): AssetCollection ); } - if (!$chunk->isEntry) { + if (!$chunk->isEntry()) { throw new InvalidManifestException( Message::MANIFEST_ENTRY_NOT_ENTRYPOINT->getMessage( $entrypoint, @@ -152,7 +152,7 @@ private function assetUrl(string $path): string */ private function collectCss(array &$stylesheets, ManifestChunk $chunk): void { - foreach ($chunk->css as $file) { + foreach ($chunk->css() as $file) { $this->pushStylesheet($stylesheets, $file); } } @@ -173,7 +173,7 @@ private function importedChunks(Manifest $manifest, ManifestChunk $chunk, array { $chunks = []; - foreach ($chunk->imports as $reference) { + foreach ($chunk->imports() as $reference) { if (isset($seen[$reference])) { continue; } diff --git a/src/Vite.php b/src/Vite.php index 480022d..11b7ca1 100644 --- a/src/Vite.php +++ b/src/Vite.php @@ -91,6 +91,26 @@ public function clearManifestCache(): void } } + /** + * Creates a facade using the supplied configuration and default entrypoints. + * + * @param DevelopmentConfiguration|ProductionConfiguration $configuration Configuration selecting the strategy. + * @param list $entrypoints Default entrypoints to validate and resolve when no override is supplied. + * @param ManifestLoader|null $manifestLoader Loader to share across instances, or `null` to create one. + * + * @throws InvalidEntrypointException if a default entrypoint is not a `string`, is empty, or contains a + * backslash or a control character. + * + * @return Vite A new facade using the supplied configuration. + */ + public static function create( + DevelopmentConfiguration|ProductionConfiguration $configuration, + array $entrypoints = [], + ManifestLoader|null $manifestLoader = null, + ): self { + return new self($configuration, $entrypoints, $manifestLoader); + } + /** * Resolves entrypoints into the framework-neutral assets a page must load. * diff --git a/tests/ConfigurationTest.php b/tests/ConfigurationTest.php index 2701ae6..1578b0e 100644 --- a/tests/ConfigurationTest.php +++ b/tests/ConfigurationTest.php @@ -7,6 +7,7 @@ use PHPForge\Vite\Asset\{InlineModule, ModuleScript}; use PHPForge\Vite\Configuration\{DevelopmentConfiguration, ProductionConfiguration}; use PHPForge\Vite\Exception\{ConfigurationException, InvalidEntrypointException, Message}; +use PHPForge\Vite\Manifest\ManifestLoader; use PHPForge\Vite\Tests\Fixture\CapturingInlineModuleProviderStub; use PHPForge\Vite\Tests\Provider\ConfigurationProvider; use PHPForge\Vite\Vite; @@ -15,7 +16,7 @@ use stdClass; /** - * Unit tests for {@see DevelopmentConfiguration} and {@see ProductionConfiguration} input normalization and validation. + * Unit tests for configuration validation and {@see Vite} facade construction. * * {@see ConfigurationProvider} for test case data providers. */ @@ -31,6 +32,38 @@ public function testAssetValueAcceptsCaseInsensitiveHttpScheme(): void ); } + public function testCreateReturnsConfiguredViteFacade(): void + { + $manifestPath = __DIR__ . '/Fixture/manifest.json'; + + $loader = new ManifestLoader(); + + $cachedManifest = $loader->load($manifestPath); + + $vite = Vite::create( + new ProductionConfiguration($manifestPath, '/build'), + ['views/foo.js'], + $loader, + ); + + self::assertSame( + ['/build/assets/foo-BRBmoGS9.js'], + array_map( + static fn(ModuleScript $script): string => $script->url, + $vite->resolve()->moduleScripts(), + ), + 'The factory must forward the configuration and default entrypoints.', + ); + + $vite->clearManifestCache(); + + self::assertNotSame( + $cachedManifest, + $loader->load($manifestPath), + 'The factory must forward a shared manifest loader.', + ); + } + public function testDevelopmentConfigurationNormalizesValues(): void { $firstProvider = new CapturingInlineModuleProviderStub(); diff --git a/tests/HtmlRendererTest.php b/tests/HtmlRendererTest.php index f9ad439..89dc5a0 100644 --- a/tests/HtmlRendererTest.php +++ b/tests/HtmlRendererTest.php @@ -13,13 +13,48 @@ use PHPUnit\Framework\TestCase; /** - * Unit tests for {@see HtmlRenderer} output ordering, escaping, attributes, and CSP nonce support. + * Unit tests for {@see HtmlRenderOptions} policies and {@see HtmlRenderer} output. * * {@see HtmlRendererProvider} for test case data providers. */ #[Group('html')] final class HtmlRendererTest extends TestCase { + public function testFactoryCreatesDefaultOptions(): void + { + $options = HtmlRenderOptions::create(); + + self::assertNull( + $options->nonce(), + 'The default policy must not emit a nonce.', + ); + self::assertSame( + [], + $options->moduleScriptAttributes(), + 'Module scripts must have no custom attributes by default.', + ); + self::assertSame( + [], + $options->stylesheetAttributes(), + 'Stylesheets must have no custom attributes by default.', + ); + self::assertSame( + [], + $options->modulePreloadAttributes(), + 'Module-preload hints must have no custom attributes by default.', + ); + self::assertSame( + [], + $options->inlineModuleAttributes(), + 'Inline modules must have no custom attributes by default.', + ); + self::assertSame( + "\n", + $options->separator(), + 'Rendered tags must be separated by a newline by default.', + ); + } + public function testInlineModuleNeutralizesClosingScriptSequence(): void { $html = (new HtmlRenderer())->render( @@ -50,7 +85,7 @@ public function testNonceIsAppliedToEveryGeneratedTag(): void ); $html = (new HtmlRenderer())->render( $assets, - new HtmlRenderOptions(nonce: 'c2VjdXJlLW5vbmNl'), + HtmlRenderOptions::create()->withNonce('c2VjdXJlLW5vbmNl'), ); self::assertSame( @@ -60,14 +95,213 @@ public function testNonceIsAppliedToEveryGeneratedTag(): void ); } - public function testOptionsRetainConfiguredAttributesWithoutProvider(): void + public function testOptionsModifiersCanResetConfiguredValues(): void { - $options = new HtmlRenderOptions( - moduleScriptAttributes: [ - 'crossorigin' => 'anonymous', + $configured = HtmlRenderOptions::create() + ->withNonce('c2VjdXJlLW5vbmNl') + ->withModuleScriptAttributes(['defer' => true]) + ->withStylesheetAttributes(['media' => 'screen']) + ->withModulePreloadAttributes(['crossorigin' => 'anonymous']) + ->withInlineModuleAttributes(['data-inline' => true]) + ->withAttributeProvider(static fn(): array => ['data-provider' => true]) + ->withSeparator(''); + + $reset = $configured + ->withNonce(null) + ->withModuleScriptAttributes([]) + ->withStylesheetAttributes([]) + ->withModulePreloadAttributes([]) + ->withInlineModuleAttributes([]) + ->withAttributeProvider(null) + ->withSeparator("\n"); + + self::assertNull( + $reset->nonce(), + 'A `null` nonce must clear the configured nonce.', + ); + self::assertSame( + [], + $reset->moduleScriptAttributes(), + 'An empty array must clear module-script attributes.', + ); + self::assertSame( + [], + $reset->stylesheetAttributes(), + 'An empty array must clear stylesheet attributes.', + ); + self::assertSame( + [], + $reset->modulePreloadAttributes(), + 'An empty array must clear module-preload attributes.', + ); + self::assertSame( + [], + $reset->inlineModuleAttributes(), + 'An empty array must clear inline-module attributes.', + ); + self::assertSame( + [], + $reset->attributesFor(new ModuleScript('/app.js')), + 'A `null` provider must clear the configured attribute provider.', + ); + self::assertSame( + "\n", + $reset->separator(), + 'The separator must be replaceable with its default value.', + ); + + self::assertSame( + 'c2VjdXJlLW5vbmNl', + $configured->nonce(), + 'Resetting a derived policy must not clear the source nonce.', + ); + self::assertSame( + [ 'defer' => true, + 'data-provider' => true, ], + $configured->attributesFor(new ModuleScript('/app.js')), + 'Resetting a derived policy must not clear the source attributes or provider.', + ); + self::assertSame( + '', + $configured->separator(), + 'Resetting a derived policy must not change the source separator.', + ); + } + + public function testOptionsModifiersReturnNewConfiguredInstances(): void + { + $options = HtmlRenderOptions::create(); + + $attributeProvider = static fn(AssetInterface $asset): array => [ + 'data-asset' => $asset::class, + ]; + + $withAttributeProvider = $options->withAttributeProvider($attributeProvider); + $withInlineModuleAttributes = $options->withInlineModuleAttributes(['data-inline' => true]); + $withModulePreloadAttributes = $options->withModulePreloadAttributes(['crossorigin' => 'anonymous']); + $withModuleScriptAttributes = $options->withModuleScriptAttributes(['defer' => true]); + $withNonce = $options->withNonce('c2VjdXJlLW5vbmNl'); + $withSeparator = $options->withSeparator(''); + $withStylesheetAttributes = $options->withStylesheetAttributes(['media' => 'screen']); + + self::assertNotSame( + $options, + $withAttributeProvider, + 'Configuring an attribute provider must return a new policy.', + ); + self::assertSame( + ['data-asset' => ModuleScript::class], + $withAttributeProvider->attributesFor(new ModuleScript('/app.js')), + 'The configured attribute provider must receive the asset.', + ); + self::assertNotSame( + $options, + $withInlineModuleAttributes, + 'Configuring inline-module attributes must return a new policy.', + ); + self::assertSame( + ['data-inline' => true], + $withInlineModuleAttributes->inlineModuleAttributes(), + 'The configured inline-module attributes must be retained.', ); + self::assertSame( + ['data-inline' => true], + $withInlineModuleAttributes->attributesFor(new InlineModule('window.ready = true;')), + 'The configured inline-module attributes must be selected for inline modules.', + ); + self::assertNotSame( + $options, + $withModulePreloadAttributes, + 'Configuring module-preload attributes must return a new policy.', + ); + self::assertSame( + ['crossorigin' => 'anonymous'], + $withModulePreloadAttributes->modulePreloadAttributes(), + 'The configured module-preload attributes must be retained.', + ); + self::assertSame( + ['crossorigin' => 'anonymous'], + $withModulePreloadAttributes->attributesFor(new ModulePreload('/vendor.js')), + 'The configured module-preload attributes must be selected for preload hints.', + ); + self::assertNotSame( + $options, + $withModuleScriptAttributes, + 'Configuring module-script attributes must return a new policy.', + ); + self::assertSame( + ['defer' => true], + $withModuleScriptAttributes->moduleScriptAttributes(), + 'The configured module-script attributes must be retained.', + ); + self::assertSame( + ['defer' => true], + $withModuleScriptAttributes->attributesFor(new ModuleScript('/app.js')), + 'The configured module-script attributes must be selected for module scripts.', + ); + self::assertNotSame( + $options, + $withNonce, + 'Configuring a nonce must return a new policy.', + ); + self::assertSame( + 'c2VjdXJlLW5vbmNl', + $withNonce->nonce(), + 'The configured nonce must be retained.', + ); + self::assertNotSame( + $options, + $withSeparator, + 'Configuring a separator must return a new policy.', + ); + self::assertSame( + '', + $withSeparator->separator(), + 'The configured separator must be retained.', + ); + self::assertNotSame( + $options, + $withStylesheetAttributes, + 'Configuring stylesheet attributes must return a new policy.', + ); + self::assertSame( + ['media' => 'screen'], + $withStylesheetAttributes->stylesheetAttributes(), + 'The configured stylesheet attributes must be retained.', + ); + self::assertSame( + ['media' => 'screen'], + $withStylesheetAttributes->attributesFor(new Stylesheet('/app.css')), + 'The configured stylesheet attributes must be selected for stylesheets.', + ); + + self::assertNull( + $options->nonce(), + 'Configuring derived policies must not change the original nonce.', + ); + self::assertSame( + [], + $options->attributesFor(new ModuleScript('/app.js')), + 'Configuring derived policies must not change the original attributes.', + ); + self::assertSame( + "\n", + $options->separator(), + 'Configuring a derived policy must not change the original separator.', + ); + } + + public function testOptionsRetainConfiguredAttributesWithoutProvider(): void + { + $options = HtmlRenderOptions::create() + ->withModuleScriptAttributes( + [ + 'crossorigin' => 'anonymous', + 'defer' => true, + ], + ); self::assertSame( [ @@ -82,17 +316,20 @@ public function testOptionsRetainConfiguredAttributesWithoutProvider(): void public function testRendererEscapesUrlsAndCustomAttributes(): void { $script = new ModuleScript('/app.js?x=1&name="quoted"&tag='); - $options = new HtmlRenderOptions( - moduleScriptAttributes: [ - 'crossorigin' => 'anonymous', - 'defer' => true, - 'data-disabled' => false, - 'data-empty' => null, - ], - attributeProvider: static fn(AssetInterface $asset): array => [ - 'data-kind' => $asset instanceof ModuleScript ? 'module&script' : 'asset', - ], - ); + $options = HtmlRenderOptions::create() + ->withModuleScriptAttributes( + [ + 'crossorigin' => 'anonymous', + 'defer' => true, + 'data-disabled' => false, + 'data-empty' => null, + ], + ) + ->withAttributeProvider( + static fn(AssetInterface $asset): array => [ + 'data-kind' => $asset instanceof ModuleScript ? 'module&script' : 'asset', + ], + ); self::assertSame( <<render($assets, new HtmlRenderOptions(separator: '')); + $html = (new HtmlRenderer())->render($assets, HtmlRenderOptions::create()->withSeparator('')); self::assertSame( <<getMessage(), ); - $options = new HtmlRenderOptions(attributeProvider: static fn(): string => 'invalid'); + $options = HtmlRenderOptions::create()->withAttributeProvider(static fn(): string => 'invalid'); $options->attributesFor(new ModuleScript('/app.js')); } @@ -161,7 +398,7 @@ public function testThrowHtmlRenderingExceptionForInvalidNonce(): void Message::CSP_NONCE_INVALID->getMessage(), ); - new HtmlRenderOptions(nonce: 'invalid nonce'); + HtmlRenderOptions::create()->withNonce('invalid nonce'); } /** @@ -179,7 +416,7 @@ public function testThrowHtmlRenderingExceptionForUnsafeAttribute( $message->getMessage(...$arguments), ); - $options = new HtmlRenderOptions(moduleScriptAttributes: $attributes); + $options = HtmlRenderOptions::create()->withModuleScriptAttributes($attributes); (new HtmlRenderer())->render(new AssetCollection([new ModuleScript('/app.js')]), $options); } @@ -191,6 +428,6 @@ public function testThrowHtmlRenderingExceptionForUnsupportedAssetAttributes(): Message::ASSET_IMPLEMENTATION_UNSUPPORTED->getMessage(), ); - (new HtmlRenderOptions())->attributesFor(new UnsupportedAssetStub()); + HtmlRenderOptions::create()->attributesFor(new UnsupportedAssetStub()); } } diff --git a/tests/ManifestChunkTest.php b/tests/ManifestChunkTest.php index f1b3841..351d5b6 100644 --- a/tests/ManifestChunkTest.php +++ b/tests/ManifestChunkTest.php @@ -9,11 +9,58 @@ use PHPUnit\Framework\TestCase; /** - * Unit tests for {@see ManifestChunk} asset type detection. + * Unit tests for {@see ManifestChunk} construction, immutable modifiers, and asset type detection. */ #[Group('manifest')] final class ManifestChunkTest extends TestCase { + public function testConstructorAndFactoryApplyOptionalDefaults(): void + { + foreach ( + [ + new ManifestChunk('constructor.js', 'assets/constructor.js'), + ManifestChunk::create('factory.js', 'assets/factory.js'), + ] as $chunk + ) { + self::assertNull( + $chunk->src(), + "The source path must default to 'null'.", + ); + self::assertSame( + [], + $chunk->css(), + 'The stylesheet list must default to empty.', + ); + self::assertSame( + [], + $chunk->assets(), + 'The static asset list must default to empty.', + ); + self::assertFalse( + $chunk->isEntry(), + "The entrypoint flag must default to 'false'.", + ); + self::assertNull( + $chunk->name(), + "The chunk name must default to 'null'.", + ); + self::assertFalse( + $chunk->isDynamicEntry(), + "The dynamic entrypoint flag must default to 'false'.", + ); + self::assertSame( + [], + $chunk->imports(), + 'The static import list must default to empty.', + ); + self::assertSame( + [], + $chunk->dynamicImports(), + 'The dynamic import list must default to empty.', + ); + } + } + public function testCssDetectionIsCaseInsensitive(): void { self::assertTrue( @@ -21,4 +68,187 @@ public function testCssDetectionIsCaseInsensitive(): void 'An uppercase CSS extension must be recognized.', ); } + + public function testWithMethodsCanResetEveryOptionalValue(): void + { + $configured = ManifestChunk::create('app.js', 'assets/app.js') + ->withSrc('resources/app.js') + ->withCss(['assets/app.css']) + ->withAssets(['assets/logo.svg']) + ->withEntry() + ->withName('app') + ->withDynamicEntry() + ->withImports(['vendor.js']) + ->withDynamicImports(['lazy.js']); + + $reset = $configured + ->withSrc(null) + ->withCss([]) + ->withAssets([]) + ->withEntry(false) + ->withName(null) + ->withDynamicEntry(false) + ->withImports([]) + ->withDynamicImports([]); + + self::assertNotSame( + $configured, + $reset, + 'Resetting values must return a distinct chunk instance.', + ); + self::assertNull( + $reset->src(), + "The source path must reset to 'null'.", + ); + self::assertSame( + [], + $reset->css(), + 'The stylesheet list must reset to empty.', + ); + self::assertSame( + [], + $reset->assets(), + 'The static asset list must reset to empty.', + ); + self::assertFalse( + $reset->isEntry(), + "The entrypoint flag must reset to 'false'.", + ); + self::assertNull( + $reset->name(), + "The chunk name must reset to 'null'.", + ); + self::assertFalse( + $reset->isDynamicEntry(), + "The dynamic entrypoint flag must reset to 'false'.", + ); + self::assertSame( + [], + $reset->imports(), + 'The static import list must reset to empty.', + ); + self::assertSame( + [], + $reset->dynamicImports(), + 'The dynamic import list must reset to empty.', + ); + self::assertSame( + 'resources/app.js', + $configured->src(), + "Resetting must not mutate the configured source path.", + ); + self::assertTrue( + $configured->isEntry(), + "Resetting must not mutate the configured entrypoint flag.", + ); + self::assertTrue( + $configured->isDynamicEntry(), + "Resetting must not mutate the configured dynamic entrypoint flag.", + ); + } + + public function testWithMethodsReturnConfiguredCopyWithoutMutatingOriginal(): void + { + $original = ManifestChunk::create('app.js', 'assets/app.js'); + + $configured = $original + ->withSrc('resources/app.js') + ->withCss(['assets/app.css']) + ->withAssets(['assets/logo.svg']) + ->withEntry() + ->withName('app') + ->withDynamicEntry() + ->withImports(['vendor.js']) + ->withDynamicImports(['lazy.js']); + + self::assertNotSame( + $original, + $configured, + 'Configuration must return a distinct chunk instance.', + ); + self::assertSame( + 'app.js', + $configured->key, + 'The manifest key must be preserved.', + ); + self::assertSame( + 'assets/app.js', + $configured->file, + 'The emitted file must be preserved.', + ); + self::assertSame( + 'resources/app.js', + $configured->src(), + 'The source path must be replaced.', + ); + self::assertSame( + ['assets/app.css'], + $configured->css(), + 'The stylesheet list must be replaced.', + ); + self::assertSame( + ['assets/logo.svg'], + $configured->assets(), + 'The static asset list must be replaced.', + ); + self::assertTrue( + $configured->isEntry(), + 'The entrypoint flag must be enabled.', + ); + self::assertSame( + 'app', + $configured->name(), + 'The chunk name must be replaced.', + ); + self::assertTrue( + $configured->isDynamicEntry(), + 'The dynamic entrypoint flag must be enabled.', + ); + self::assertSame( + ['vendor.js'], + $configured->imports(), + 'The static import list must be replaced.', + ); + self::assertSame( + ['lazy.js'], + $configured->dynamicImports(), + 'The dynamic import list must be replaced.', + ); + self::assertNull( + $original->src(), + 'The original source path must remain unchanged.', + ); + self::assertSame( + [], + $original->css(), + 'The original stylesheet list must remain unchanged.', + ); + self::assertSame( + [], + $original->assets(), + 'The original static asset list must remain unchanged.', + ); + self::assertFalse( + $original->isEntry(), + 'The original entrypoint flag must remain unchanged.', + ); + self::assertNull( + $original->name(), + 'The original chunk name must remain unchanged.', + ); + self::assertFalse( + $original->isDynamicEntry(), + 'The original dynamic entrypoint flag must remain unchanged.', + ); + self::assertSame( + [], + $original->imports(), + 'The original static import list must remain unchanged.', + ); + self::assertSame( + [], + $original->dynamicImports(), + 'The original dynamic import list must remain unchanged.', + ); + } } diff --git a/tests/ManifestLoaderTest.php b/tests/ManifestLoaderTest.php index 346357e..ef69418 100644 --- a/tests/ManifestLoaderTest.php +++ b/tests/ManifestLoaderTest.php @@ -48,7 +48,9 @@ public function testClearWithoutPathDiscardsEveryCachedManifest(): void $secondPath = $this->temporaryManifest( '{"second.js":{"file":"assets/second.js","isEntry":true}}', ); + $loader = new ManifestLoader(); + $firstManifest = $loader->load($firstPath); $secondManifest = $loader->load($secondPath); @@ -127,6 +129,7 @@ public function testLoaderParsesCurrentOfficialManifestFields(): void $manifest = (new ManifestLoader())->load(__DIR__ . '/Fixture/manifest.json'); $entry = $manifest->get('views/bar.js'); + $dynamic = $manifest->get('baz.js'); $shared = $manifest->get('_shared-B7PI925R.js'); self::assertSame( @@ -152,33 +155,52 @@ public function testLoaderParsesCurrentOfficialManifestFields(): void ); self::assertSame( 'views/bar.js', - $entry->src, + $entry->src(), 'The source path must be parsed.', ); - self::assertTrue($entry->isEntry, 'The entry flag must be `true`.'); - self::assertFalse($entry->isDynamicEntry, 'The dynamic entry flag must default to `false`.'); + self::assertSame( + 'bar', + $entry->name(), + 'The chunk name must be parsed.', + ); + self::assertTrue( + $entry->isEntry(), + "The entry flag must be 'true'.", + ); + self::assertFalse( + $entry->isDynamicEntry(), + "The dynamic entry flag must default to 'false'.", + ); self::assertSame( ['_shared-B7PI925R.js'], - $entry->imports, + $entry->imports(), 'Static imports must be parsed.', ); self::assertSame( ['baz.js'], - $entry->dynamicImports, + $entry->dynamicImports(), 'Dynamic imports must be parsed.', ); + self::assertNotNull( + $dynamic, + 'The dynamic entry chunk must be present.', + ); + self::assertTrue( + $dynamic->isDynamicEntry(), + 'The dynamic entry flag must be parsed.', + ); self::assertNotNull( $shared, 'The imported chunk must be present.', ); self::assertSame( ['assets/shared-ChJ_j-JJ.css'], - $shared->css, + $shared->css(), 'CSS assets must be parsed.', ); self::assertSame( ['assets/logo-BuPIv-2h.svg'], - $shared->assets, + $shared->assets(), 'Static assets must be parsed.', ); } From 80833a6918a2142328deb928958d7710039f8810 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula <42547589+terabytesoftw@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:37:38 -0400 Subject: [PATCH 2/2] Apply fixes from StyleCI (#8) --- src/Asset/AssetCollection.php | 4 ++-- src/Html/HtmlRenderer.php | 2 +- src/Resolver/ManifestAssetResolver.php | 2 +- tests/ManifestChunkTest.php | 6 +++--- tests/ViteProductionTest.php | 1 - 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/Asset/AssetCollection.php b/src/Asset/AssetCollection.php index 4117ef1..f33c7b7 100644 --- a/src/Asset/AssetCollection.php +++ b/src/Asset/AssetCollection.php @@ -76,7 +76,7 @@ public function all(): array * * @return AssetCollection A new collection holding both sequences. */ - public function append(AssetInterface ...$assets): AssetCollection + public function append(AssetInterface ...$assets): self { return new self([...$this->assets, ...$assets]); } @@ -166,7 +166,7 @@ public function moduleScripts(): array * * @return AssetCollection A new collection holding both sequences. */ - public function prepend(AssetInterface ...$assets): AssetCollection + public function prepend(AssetInterface ...$assets): self { return new self([...$assets, ...$this->assets]); } diff --git a/src/Html/HtmlRenderer.php b/src/Html/HtmlRenderer.php index 7e1fe70..8460846 100644 --- a/src/Html/HtmlRenderer.php +++ b/src/Html/HtmlRenderer.php @@ -82,7 +82,7 @@ public function render(AssetCollection $assets, HtmlRenderOptions|null $options private function attributesFor(AssetInterface $asset, HtmlRenderOptions $options): array { $attributes = []; - + $nonce = $options->nonce(); if ($nonce !== null) { diff --git a/src/Resolver/ManifestAssetResolver.php b/src/Resolver/ManifestAssetResolver.php index 6474c53..257994f 100644 --- a/src/Resolver/ManifestAssetResolver.php +++ b/src/Resolver/ManifestAssetResolver.php @@ -196,7 +196,7 @@ private function importedChunks(Manifest $manifest, ManifestChunk $chunk, array * @param array $scripts Module scripts keyed by asset URL. * @param array $preloads Module-preload hints keyed by asset URL. * - * @return iterable Generator yielding stylesheets, then scripts, then + * @return iterable Generator yielding stylesheets, then scripts, then * preloads. */ private function orderedAssets(array $stylesheets, array $scripts, array $preloads): iterable diff --git a/tests/ManifestChunkTest.php b/tests/ManifestChunkTest.php index 351d5b6..b509cef 100644 --- a/tests/ManifestChunkTest.php +++ b/tests/ManifestChunkTest.php @@ -135,15 +135,15 @@ public function testWithMethodsCanResetEveryOptionalValue(): void self::assertSame( 'resources/app.js', $configured->src(), - "Resetting must not mutate the configured source path.", + 'Resetting must not mutate the configured source path.', ); self::assertTrue( $configured->isEntry(), - "Resetting must not mutate the configured entrypoint flag.", + 'Resetting must not mutate the configured entrypoint flag.', ); self::assertTrue( $configured->isDynamicEntry(), - "Resetting must not mutate the configured dynamic entrypoint flag.", + 'Resetting must not mutate the configured dynamic entrypoint flag.', ); } diff --git a/tests/ViteProductionTest.php b/tests/ViteProductionTest.php index b08a84f..b0fc1b7 100644 --- a/tests/ViteProductionTest.php +++ b/tests/ViteProductionTest.php @@ -124,7 +124,6 @@ public function testEntrypointsWithSameOutputFileAreDeduplicated(): void ); } - public function testImportedCssChunkIsRenderedAndNotPreloaded(): void { $assets = $this->vite('css-chunk-import-manifest.json', ['resources/js/app.js'])->resolve();