Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- feat: added a framework-agnostic Vite facade with explicit development and production configuration.
- docs: add class-level PHPDoc for the framework-neutral Vite APIs.
- refactor: remove unreachable renderer and manifest resolver branches, retain filesystem race protection, and reach `100%` code coverage without reflection-based tests.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
"phpstan/phpstan": "^2.2",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0.3",
"phpunit/phpunit": "^12.5"
"phpunit/phpunit": "^12.5",
"xepozz/internal-mocker": "^1.4"
},
"autoload": {
"psr-4": {
Expand Down
4 changes: 4 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
failOnWarning="true"
stopOnFailure="false"
>
<extensions>
<bootstrap class="PHPForge\Vite\Tests\Support\InternalMockerExtension"/>
</extensions>

<testsuites>
<testsuite name="PHPForge-Vite">
<directory>tests</directory>
Expand Down
13 changes: 7 additions & 6 deletions src/Asset/AssetCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,18 @@
/**
* Immutable, insertion-ordered collection of deduplicated neutral assets.
*
* @implements IteratorAggregate<int, AssetInterface>
* @implements IteratorAggregate<int, InlineModule|ModulePreload|ModuleScript|Stylesheet>
*/
final readonly class AssetCollection implements Countable, IteratorAggregate
{
/**
* @var list<AssetInterface> Accepted assets in insertion order, with per-type duplicates removed.
* @var list<InlineModule|ModulePreload|ModuleScript|Stylesheet> Accepted assets in insertion order, with per-type
* duplicates removed.
*/
private array $assets;

/**
* @param iterable<AssetInterface> $assets Assets to collect.
* @param iterable<mixed> $assets Assets to validate and collect.
*
* @throws ConfigurationException if a value does not implement {@see AssetInterface}, or implements it without
* being one of the four supported asset types.
Expand Down Expand Up @@ -57,7 +58,7 @@ public function __construct(iterable $assets = [])
/**
* Returns every collected asset in insertion order, regardless of type.
*
* @return list<AssetInterface> The collected assets.
* @return list<InlineModule|ModulePreload|ModuleScript|Stylesheet> The collected assets.
*/
public function all(): array
{
Expand Down Expand Up @@ -93,7 +94,7 @@ public function count(): int
/**
* Iterates over the collected assets in insertion order.
*
* @return Traversable<int, AssetInterface> Iterator over the collected assets.
* @return Traversable<int, InlineModule|ModulePreload|ModuleScript|Stylesheet> Iterator over the collected assets.
*/
public function getIterator(): Traversable
{
Expand Down Expand Up @@ -198,7 +199,7 @@ public function stylesheets(): array
*
* @return InlineModule|ModulePreload|ModuleScript|Stylesheet The narrowed asset.
*/
private function requireAsset(mixed $asset): AssetInterface
private function requireAsset(mixed $asset): InlineModule|ModulePreload|ModuleScript|Stylesheet
{
if (!$asset instanceof AssetInterface) {
throw new ConfigurationException(
Expand Down
4 changes: 2 additions & 2 deletions src/Configuration/DevelopmentConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
/**
* @param string $devServerUrl Absolute HTTP(S) URL of the running Vite development server.
* @param bool $includeViteClient Whether the `@vite/client` module script is emitted.
* @param list<InlineModuleProviderInterface> $inlineModuleProviders Providers of application-owned inline
* modules that must run before the Vite client.
* @param list<mixed> $inlineModuleProviders Providers of application-owned inline modules to validate and emit
* before the Vite client.
*
* @throws ConfigurationException if the development-server URL is not an absolute HTTP(S) URL, or if a provider
* does not implement {@see InlineModuleProviderInterface}.
Expand Down
14 changes: 0 additions & 14 deletions src/Exception/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,6 @@ enum Message: string
*/
case HTML_ATTRIBUTE_VALUE_INVALID = 'The HTML attribute "%s" has an unsupported value.';

/**
* An inline module cannot be rendered safely.
*
* Format: "Unable to render the inline module source."
*/
case INLINE_MODULE_RENDER_FAILED = 'Unable to render the inline module source.';

/**
* An inline module has no source.
*
Expand Down Expand Up @@ -273,13 +266,6 @@ enum Message: string
*/
case MANIFEST_REFERENCE_MISSING = 'The Vite manifest entry "%s" in "%s" references missing "%s" chunk "%s".';

/**
* A resolved manifest import is missing unexpectedly.
*
* Format: "The Vite manifest entry \"%s\" in \"%s\" references missing chunk \"%s\"."
*/
case MANIFEST_RESOLVER_REFERENCE_MISSING = 'The Vite manifest entry "%s" in "%s" references missing chunk "%s".';

/**
* A manifest root is not a JSON object.
*
Expand Down
21 changes: 10 additions & 11 deletions src/Html/HtmlRenderOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,19 @@
final readonly class HtmlRenderOptions
{
/**
* @var (Closure(AssetInterface): array<string, bool|float|int|string|null>)|null Per-asset attribute callback,
* or `null` when only the static per-type attributes apply.
* @var (Closure(AssetInterface): mixed)|null Per-asset attribute callback, or `null` when only the static per-type
* attributes apply.
*/
private Closure|null $attributeProvider;

/**
* @param string|null $nonce CSP nonce applied to every generated tag, or `null` to emit none.
* @param array<string, bool|float|int|string|null> $moduleScriptAttributes Extra attributes for module scripts.
* @param array<string, bool|float|int|string|null> $stylesheetAttributes Extra attributes for stylesheets.
* @param array<string, bool|float|int|string|null> $modulePreloadAttributes Extra attributes for preload hints.
* @param array<string, bool|float|int|string|null> $inlineModuleAttributes Extra attributes for inline modules.
* @param (callable(AssetInterface): array<string, bool|float|int|string|null>)|null $attributeProvider Callback
* returning per-asset attributes that override the per-type ones, or `null` to apply none.
* @param array<array-key, mixed> $moduleScriptAttributes Extra attributes for module scripts.
* @param array<array-key, mixed> $stylesheetAttributes Extra attributes for stylesheets.
* @param array<array-key, mixed> $modulePreloadAttributes Extra attributes for preload hints.
* @param array<array-key, mixed> $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.
Expand Down Expand Up @@ -95,10 +95,9 @@ public function attributesFor(AssetInterface $asset): array
/**
* Invokes the attribute provider without narrowing its result.
*
* The return type stays `mixed` so the caller can reject a provider that breaks its declared contract at
* runtime, which static analysis alone cannot guarantee.
* The return type stays `mixed` so the caller can validate the provider result at runtime.
*
* @param Closure(AssetInterface): array<string, bool|float|int|string|null> $provider Configured callback.
* @param Closure(AssetInterface): mixed $provider Configured callback.
* @param AssetInterface $asset Asset passed to the callback.
*
* @return mixed Whatever the provider returned.
Expand Down
59 changes: 20 additions & 39 deletions src/Html/HtmlRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
use function is_int;
use function is_string;
use function preg_match;
use function preg_replace;
use function str_ireplace;
use function str_starts_with;
use function strtolower;

Expand Down Expand Up @@ -47,8 +47,8 @@ final class HtmlRenderer
* @param AssetCollection $assets Resolved assets to render.
* @param HtmlRenderOptions|null $options Per-render policy, or `null` to apply the defaults.
*
* @throws HtmlRenderingException if an asset type is unsupported, an inline module cannot be neutralized, or a
* custom attribute is malformed, reserved, duplicated, or carries an unsupported value.
* @throws HtmlRenderingException if a custom attribute is malformed, reserved, duplicated, or carries an
* unsupported value.
*
* @return string The rendered tags joined by the configured separator.
*/
Expand Down Expand Up @@ -110,58 +110,39 @@ private function attributesFor(AssetInterface $asset, HtmlRenderOptions $options
* Module scripts and inline modules become `<script type="module">` elements, stylesheets and preload hints
* become `<link>` elements. Inline source has its `</script` sequences neutralized before being embedded.
*
* @param AssetInterface $asset Asset to render.
* @param InlineModule|ModulePreload|ModuleScript|Stylesheet $asset Asset to render.
* @param HtmlRenderOptions $options Per-render policy applied to the tag.
*
* @throws HtmlRenderingException if the inline source cannot be neutralized, or the asset type is unsupported.
* @throws HtmlRenderingException if the asset's custom attributes cannot be rendered.
*
* @return string The rendered tag.
*/
private function renderAsset(AssetInterface $asset, HtmlRenderOptions $options): string
{
if ($asset instanceof ModuleScript) {
return Script::tag()
private function renderAsset(
InlineModule|ModulePreload|ModuleScript|Stylesheet $asset,
HtmlRenderOptions $options,
): string {
return match (true) {
$asset instanceof ModuleScript => Script::tag()
->attributes($this->attributesFor($asset, $options))
->type('module')
->src($asset->url)
->render();
}

if ($asset instanceof Stylesheet) {
return Link::tag()
->render(),
$asset instanceof Stylesheet => Link::tag()
->attributes($this->attributesFor($asset, $options))
->rel('stylesheet')
->href($asset->url)
->render();
}

if ($asset instanceof ModulePreload) {
return Link::tag()
->render(),
$asset instanceof ModulePreload => Link::tag()
->attributes($this->attributesFor($asset, $options))
->rel('modulepreload')
->href($asset->url)
->render();
}

if ($asset instanceof InlineModule) {
$source = preg_replace('~</script~i', '<\\/script', $asset->source);

if ($source === null) {
throw new HtmlRenderingException(
Message::INLINE_MODULE_RENDER_FAILED->getMessage(),
);
}

return Script::tag()
->render(),
$asset instanceof InlineModule => Script::tag()
->attributes($this->attributesFor($asset, $options))
->type('module')
->html($source)
->render();
}

throw new HtmlRenderingException(
Message::ASSET_IMPLEMENTATION_UNSUPPORTED->getMessage(),
);
->html(str_ireplace('</script', '<\\/script', $asset->source))
->render(),
};
}

/**
Expand Down
2 changes: 0 additions & 2 deletions src/Manifest/ManifestLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
use function array_is_list;
use function array_key_exists;
use function clearstatcache;
use function file_get_contents;
use function is_array;
use function is_bool;
use function is_file;
Expand All @@ -27,7 +26,6 @@
use function json_decode;
use function property_exists;
use function sprintf;
use function stat;

use const JSON_THROW_ON_ERROR;

Expand Down
14 changes: 2 additions & 12 deletions src/Resolver/ManifestAssetResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,6 @@ private function collectCss(array &$stylesheets, ManifestChunk $chunk): void
* @param ManifestChunk $chunk Chunk whose imports are walked.
* @param array<string, true> $seen Keys already visited, mutated in place to guard against cycles.
*
* @throws InvalidManifestException if an import references a chunk the manifest does not declare.
*
* @return list<ManifestChunk> Imported chunks, dependencies first.
*/
private function importedChunks(Manifest $manifest, ManifestChunk $chunk, array &$seen): array
Expand All @@ -181,17 +179,9 @@ private function importedChunks(Manifest $manifest, ManifestChunk $chunk, array
}

$seen[$reference] = true;
$import = $manifest->get($reference);

if (!$import instanceof ManifestChunk) {
throw new InvalidManifestException(
Message::MANIFEST_RESOLVER_REFERENCE_MISSING->getMessage(
$chunk->key,
$this->configuration->manifestPath,
$reference,
),
);
}
/** @var ManifestChunk $import References are validated when the manifest is loaded. */
$import = $manifest->get($reference);

$chunks = [...$chunks, ...$this->importedChunks($manifest, $import, $seen), $import];
}
Expand Down
2 changes: 1 addition & 1 deletion src/Vite.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
* eagerly but may be empty, since {@see Vite::resolve()} accepts a per-call override.
*
* @param DevelopmentConfiguration|ProductionConfiguration $configuration Configuration selecting the strategy.
* @param list<string> $entrypoints Default entrypoints resolved when no override is supplied.
* @param list<mixed> $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
Expand Down
11 changes: 11 additions & 0 deletions tests/AssetCollectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use PHPForge\Vite\Tests\Fixture\UnsupportedAssetStub;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
use stdClass;

use function iterator_to_array;

Expand Down Expand Up @@ -126,4 +127,14 @@ public function testThrowConfigurationExceptionForUnsupportedAssetImplementation

new AssetCollection([new UnsupportedAssetStub()]);
}

public function testThrowConfigurationExceptionForValueThatIsNotAnAsset(): void
{
$this->expectException(ConfigurationException::class);
$this->expectExceptionMessage(
Message::ASSET_COLLECTION_ITEM_INVALID->getMessage(),
);

new AssetCollection([new stdClass()]);
}
}
9 changes: 2 additions & 7 deletions tests/ConfigurationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
use PHPForge\Vite\Vite;
use PHPUnit\Framework\Attributes\{DataProviderExternal, Group};
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use stdClass;

/**
Expand Down Expand Up @@ -93,9 +92,7 @@ public function testThrowConfigurationExceptionForInvalidInlineModuleProvider():
Message::DEVELOPMENT_INLINE_MODULE_PROVIDER_INVALID->getMessage(),
);

(new ReflectionClass(DevelopmentConfiguration::class))->newInstanceArgs(
['http://localhost:5173', true, [new stdClass()]],
);
new DevelopmentConfiguration('http://localhost:5173', inlineModuleProviders: [new stdClass()]);
}

public function testThrowConfigurationExceptionForNonAbsoluteManifestPath(): void
Expand Down Expand Up @@ -160,8 +157,6 @@ public function testThrowInvalidEntrypointExceptionForNonStringEntrypoint(): voi
Message::ENTRYPOINT_TYPE_INVALID->getMessage(),
);

(new ReflectionClass(Vite::class))->newInstanceArgs(
[new DevelopmentConfiguration('http://localhost:5173'), [123]],
);
new Vite(new DevelopmentConfiguration('http://localhost:5173'), [123]);
}
}
9 changes: 8 additions & 1 deletion tests/Fixture/duplicate-file-manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
{
"_bundle-alias.js": {
"file": "assets/bundle.js"
},
"_vendor.js": {
"file": "assets/vendor.js"
},
"resources/js/app.js": {
"file": "assets/bundle.js",
"isEntry": true
"isEntry": true,
"imports": ["_bundle-alias.js", "_vendor.js"]
},
"resources/js/app-legacy.js": {
"file": "assets/bundle.js",
Expand Down
Loading
Loading