From 6341d676222117f172d003353a0f2b5bc668de6d Mon Sep 17 00:00:00 2001
From: DangerD
Date: Fri, 3 Oct 2025 18:05:51 +0200
Subject: [PATCH] Vite Final
---
src/core/src/Context/ApplicationContext.php | 5 +-
.../Compilers/Concerns/CompilesHelpers.php | 14 +
src/foundation/publish/app.php | 4 +
src/foundation/src/Application.php | 1 +
.../Providers/FoundationServiceProvider.php | 6 +-
.../Concerns/InteractsWithContainer.php | 107 +
src/foundation/src/Vite.php | 952 +++++++++
src/foundation/src/ViteException.php | 11 +
.../src/ViteManifestNotFoundException.php | 12 +
.../AddLinkHeadersForPreloadedAssets.php | 37 +
src/support/src/Facades/Facade.php | 7 +-
src/support/src/Facades/Vite.php | 49 +
src/support/src/Js.php | 4 +-
src/support/src/Str.php | 46 +
src/testbench/workbench/config/app.php | 4 +
tests/Foundation/FoundationViteTest.php | 1783 +++++++++++++++++
.../fixtures/jetstream-manifest.json | 405 ++++
.../fixtures/prefetching-manifest.json | 283 +++
tests/Http/Middleware/VitePreloadingTest.php | 165 ++
.../Integration copy/Testing/TestCaseTest.php | 26 +
tests/Support/SupportStrTest.php | 42 +
21 files changed, 3953 insertions(+), 10 deletions(-)
create mode 100644 src/foundation/src/Vite.php
create mode 100644 src/foundation/src/ViteException.php
create mode 100644 src/foundation/src/ViteManifestNotFoundException.php
create mode 100755 src/http/src/Middleware/AddLinkHeadersForPreloadedAssets.php
create mode 100644 src/support/src/Facades/Vite.php
create mode 100644 tests/Foundation/FoundationViteTest.php
create mode 100644 tests/Foundation/fixtures/jetstream-manifest.json
create mode 100644 tests/Foundation/fixtures/prefetching-manifest.json
create mode 100755 tests/Http/Middleware/VitePreloadingTest.php
create mode 100755 tests/Integration copy/Testing/TestCaseTest.php
create mode 100644 tests/Support/SupportStrTest.php
diff --git a/src/core/src/Context/ApplicationContext.php b/src/core/src/Context/ApplicationContext.php
index 45e51f8fd..864b7111d 100644
--- a/src/core/src/Context/ApplicationContext.php
+++ b/src/core/src/Context/ApplicationContext.php
@@ -5,15 +5,16 @@
namespace Hypervel\Context;
use Hyperf\Context\ApplicationContext as HyperfApplicationContext;
-use Hypervel\Container\Contracts\Container as ContainerContract;
+use Psr\Container\ContainerInterface;
use TypeError;
class ApplicationContext extends HyperfApplicationContext
{
/**
+ * @return \Hypervel\Container\Contracts\Container
* @throws TypeError
*/
- public static function getContainer(): ContainerContract
+ public static function getContainer(): ContainerInterface
{
/* @phpstan-ignore-next-line */
return self::$container;
diff --git a/src/core/src/View/Compilers/Concerns/CompilesHelpers.php b/src/core/src/View/Compilers/Concerns/CompilesHelpers.php
index 4b01e6ac7..ee561e0ec 100644
--- a/src/core/src/View/Compilers/Concerns/CompilesHelpers.php
+++ b/src/core/src/View/Compilers/Concerns/CompilesHelpers.php
@@ -4,6 +4,8 @@
namespace Hypervel\View\Compilers\Concerns;
+use Hypervel\Foundation\Vite;
+
trait CompilesHelpers
{
/**
@@ -21,4 +23,16 @@ protected function compileMethod(string $method): string
{
return "";
}
+
+ /**
+ * Compile the "vite" statements into valid PHP.
+ */
+ protected function compileVite(?string $arguments): string
+ {
+ $arguments ??= '()';
+
+ $class = Vite::class;
+
+ return "";
+ }
}
diff --git a/src/foundation/publish/app.php b/src/foundation/publish/app.php
index 0bc9b243d..71356cc81 100644
--- a/src/foundation/publish/app.php
+++ b/src/foundation/publish/app.php
@@ -92,6 +92,10 @@
'url' => env('APP_URL', 'http://localhost'),
+ 'frontend_url' => env('FRONTEND_URL', 'http://localhost:3000'),
+
+ 'asset_url' => env('ASSET_URL', 'https://example.com'),
+
/*
|--------------------------------------------------------------------------
| Application Timezone
diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php
index 72c77a479..ba80d4ab2 100644
--- a/src/foundation/src/Application.php
+++ b/src/foundation/src/Application.php
@@ -653,6 +653,7 @@ protected function registerCoreContainerAliases(): void
\Hypervel\Queue\Failed\FailedJobProviderInterface::class => ['queue.failer'],
\Hypervel\Validation\Contracts\Factory::class => ['validator'],
\Hypervel\Validation\DatabasePresenceVerifierInterface::class => ['validation.presence'],
+ \Hypervel\Foundation\Vite::class => ['vite'],
] as $key => $aliases) {
foreach ($aliases as $alias) {
$this->alias($key, $alias);
diff --git a/src/foundation/src/Providers/FoundationServiceProvider.php b/src/foundation/src/Providers/FoundationServiceProvider.php
index 465fbdbd0..c127d2e96 100644
--- a/src/foundation/src/Providers/FoundationServiceProvider.php
+++ b/src/foundation/src/Providers/FoundationServiceProvider.php
@@ -89,8 +89,10 @@ protected function listenCommandException(): void
protected function isConsoleKernelCall(Throwable $exception): bool
{
foreach ($exception->getTrace() as $trace) {
- if (($trace['class'] ?? null) === ConsoleKernel::class
- && ($trace['function'] ?? null) === 'call') {
+ if (
+ ($trace['class'] ?? null) === ConsoleKernel::class
+ && ($trace['function'] ?? null) === 'call'
+ ) {
return true;
}
}
diff --git a/src/foundation/src/Testing/Concerns/InteractsWithContainer.php b/src/foundation/src/Testing/Concerns/InteractsWithContainer.php
index 79e7c4842..7582817dc 100644
--- a/src/foundation/src/Testing/Concerns/InteractsWithContainer.php
+++ b/src/foundation/src/Testing/Concerns/InteractsWithContainer.php
@@ -11,6 +11,9 @@
use Hypervel\Foundation\Contracts\Application as ApplicationContract;
use Hypervel\Foundation\Testing\DatabaseConnectionResolver;
use Hypervel\Foundation\Testing\Dispatcher\HttpDispatcher as TestingHttpDispatcher;
+use Hypervel\Foundation\Vite;
+use Hypervel\Support\Facades\Facade;
+use Hypervel\Support\HtmlString;
use Mockery;
use Mockery\MockInterface;
@@ -18,6 +21,8 @@ trait InteractsWithContainer
{
protected ?ApplicationContract $app = null;
+ protected $originalVite;
+
/**
* Register an instance of an object in the container.
*
@@ -79,6 +84,108 @@ protected function forgetMock(string $abstract): static
return $this;
}
+ /**
+ * Register an empty handler for Vite in the container.
+ *
+ * @return $this
+ */
+ protected function withoutVite(): static
+ {
+ if ($this->originalVite == null) {
+ $this->originalVite = app(Vite::class);
+ }
+
+ Facade::clearResolvedInstance(Vite::class);
+
+ $this->swap(Vite::class, new class extends Vite {
+ public function __invoke(array|string $entrypoints, ?string $buildDirectory = null): HtmlString
+ {
+ return new HtmlString('');
+ }
+
+ public function __call($method, $parameters): string
+ {
+ return '';
+ }
+
+ public function __toString(): string
+ {
+ return '';
+ }
+
+ public function useIntegrityKey(bool|string $key): static
+ {
+ return $this;
+ }
+
+ public function useBuildDirectory(string $path): static
+ {
+ return $this;
+ }
+
+ public function useHotFile(string $path): static
+ {
+ return $this;
+ }
+
+ public function withEntryPoints(array $entryPoints): static
+ {
+ return $this;
+ }
+
+ public function useScriptTagAttributes(callable|array $attributes): static
+ {
+ return $this;
+ }
+
+ public function useStyleTagAttributes(callable|array $attributes): static
+ {
+ return $this;
+ }
+
+ public function usePreloadTagAttributes(callable|array|false $attributes): static
+ {
+ return $this;
+ }
+
+ public function preloadedAssets(): array
+ {
+ return [];
+ }
+
+ public function reactRefresh(): ?HtmlString
+ {
+ return new HtmlString('');
+ }
+
+ public function content(string $asset, ?string $buildDirectory = null): string
+ {
+ return '';
+ }
+
+ public function asset(string $asset, ?string $buildDirectory = null): string
+ {
+ return '';
+ }
+ });
+
+ return $this;
+ }
+
+ /**
+ * Restore Vite in the container.
+ *
+ * @return $this
+ */
+ protected function withVite(): static
+ {
+ if ($this->originalVite) {
+ $this->app->instance(Vite::class, $this->originalVite);
+ }
+
+ return $this;
+ }
+
protected function flushApplication(): void
{
$this->app = null;
diff --git a/src/foundation/src/Vite.php b/src/foundation/src/Vite.php
new file mode 100644
index 000000000..0901b909e
--- /dev/null
+++ b/src/foundation/src/Vite.php
@@ -0,0 +1,952 @@
+preloadedAssets;
+ }
+
+ /**
+ * Get the Content Security Policy nonce applied to all generated tags.
+ */
+ public function cspNonce(): ?string
+ {
+ return $this->nonce;
+ }
+
+ /**
+ * Generate or set a Content Security Policy nonce to apply to all generated tags.
+ */
+ public function useCspNonce(?string $nonce = null): string
+ {
+ return $this->nonce = $nonce ?? Str::random(40);
+ }
+
+ /**
+ * Use the given key to detect integrity hashes in the manifest.
+ *
+ * @return $this
+ */
+ public function useIntegrityKey(false|string $key): static
+ {
+ $this->integrityKey = $key;
+
+ return $this;
+ }
+
+ /**
+ * Set the Vite entry points.
+ *
+ * @return $this
+ */
+ public function withEntryPoints(array $entryPoints): static
+ {
+ $this->entryPoints = $entryPoints;
+
+ return $this;
+ }
+
+ /**
+ * Merge additional Vite entry points with the current set.
+ *
+ * @return $this
+ */
+ public function mergeEntryPoints(array $entryPoints): static
+ {
+ return $this->withEntryPoints(array_unique([
+ ...$this->entryPoints,
+ ...$entryPoints,
+ ]));
+ }
+
+ /**
+ * Set the filename for the manifest file.
+ *
+ * @return $this
+ */
+ public function useManifestFilename(string $filename): static
+ {
+ $this->manifestFilename = $filename;
+
+ return $this;
+ }
+
+ /**
+ * Resolve asset paths using the provided resolver.
+ *
+ * @return $this
+ */
+ public function createAssetPathsUsing(?callable $resolver): static
+ {
+ $this->assetPathResolver = $resolver;
+
+ return $this;
+ }
+
+ /**
+ * Get the Vite "hot" file path.
+ */
+ public function hotFile(): string
+ {
+ return $this->hotFile ?? public_path('/hot');
+ }
+
+ /**
+ * Set the Vite "hot" file path.
+ *
+ * @return $this
+ */
+ public function useHotFile(string $path): static
+ {
+ $this->hotFile = $path;
+
+ return $this;
+ }
+
+ /**
+ * Set the Vite build directory.
+ *
+ * @return $this
+ */
+ public function useBuildDirectory(string $path): static
+ {
+ $this->buildDirectory = $path;
+
+ return $this;
+ }
+
+ /**
+ * Use the given callback to resolve attributes for script tags.
+ *
+ * @param array|(callable(string, string, ?array, ?array): array) $attributes
+ * @return $this
+ */
+ public function useScriptTagAttributes(array|callable $attributes): static
+ {
+ if (! is_callable($attributes)) {
+ $attributes = fn () => $attributes;
+ }
+
+ $this->scriptTagAttributesResolvers[] = $attributes;
+
+ return $this;
+ }
+
+ /**
+ * Use the given callback to resolve attributes for style tags.
+ *
+ * @param array|(callable(string, string, ?array, ?array): array) $attributes
+ * @return $this
+ */
+ public function useStyleTagAttributes(array|callable $attributes): static
+ {
+ if (! is_callable($attributes)) {
+ $attributes = fn () => $attributes;
+ }
+
+ $this->styleTagAttributesResolvers[] = $attributes;
+
+ return $this;
+ }
+
+ /**
+ * Use the given callback to resolve attributes for preload tags.
+ *
+ * @param array|(callable(string, string, ?array, ?array): (array|false))|false $attributes
+ * @return $this
+ */
+ public function usePreloadTagAttributes(array|callable|false $attributes): static
+ {
+ if (! is_callable($attributes)) {
+ $attributes = fn () => $attributes;
+ }
+
+ $this->preloadTagAttributesResolvers[] = $attributes;
+
+ return $this;
+ }
+
+ /**
+ * Eagerly prefetch assets.
+ *
+ * @return $this
+ */
+ public function prefetch(?int $concurrency = null, string $event = 'load'): static
+ {
+ $this->prefetchEvent = $event;
+
+ return $concurrency === null
+ ? $this->usePrefetchStrategy('aggressive')
+ : $this->usePrefetchStrategy('waterfall', ['concurrency' => $concurrency]);
+ }
+
+ /**
+ * Use the "waterfall" prefetching strategy.
+ *
+ * @return $this
+ */
+ public function useWaterfallPrefetching(?int $concurrency = null): static
+ {
+ return $this->usePrefetchStrategy('waterfall', [
+ 'concurrency' => $concurrency ?? $this->prefetchConcurrently,
+ ]);
+ }
+
+ /**
+ * Use the "aggressive" prefetching strategy.
+ *
+ * @return $this
+ */
+ public function useAggressivePrefetching(): static
+ {
+ return $this->usePrefetchStrategy('aggressive');
+ }
+
+ /**
+ * Set the prefetching strategy.
+ *
+ * @param null|'aggressive'|'waterfall' $strategy
+ * @return $this
+ */
+ public function usePrefetchStrategy(?string $strategy, array $config = []): static
+ {
+ $this->prefetchStrategy = $strategy;
+
+ if ($strategy === 'waterfall') {
+ $this->prefetchConcurrently = $config['concurrency'] ?? $this->prefetchConcurrently;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Generate Vite tags for an entrypoint.
+ *
+ * @param string|string[] $entrypoints
+ *
+ * @throws Exception
+ */
+ public function __invoke(string|array $entrypoints, ?string $buildDirectory = null): HtmlString
+ {
+ $entrypoints = new Collection($entrypoints);
+ $buildDirectory ??= $this->buildDirectory;
+
+ if ($this->isRunningHot()) {
+ return new HtmlString(
+ $entrypoints
+ ->prepend('@vite/client')
+ ->map(fn ($entrypoint) => $this->makeTagForChunk($entrypoint, $this->hotAsset($entrypoint), null, null))
+ ->join('')
+ );
+ }
+
+ $manifest = $this->manifest($buildDirectory);
+
+ $tags = new Collection();
+ $preloads = new Collection();
+
+ foreach ($entrypoints as $entrypoint) {
+ $chunk = $this->chunk($manifest, $entrypoint);
+
+ $preloads->push([
+ $chunk['src'],
+ $this->assetPath("{$buildDirectory}/{$chunk['file']}"),
+ $chunk,
+ $manifest,
+ ]);
+
+ foreach ($chunk['imports'] ?? [] as $import) {
+ $preloads->push([
+ $import,
+ $this->assetPath("{$buildDirectory}/{$manifest[$import]['file']}"),
+ $manifest[$import],
+ $manifest,
+ ]);
+
+ foreach ($manifest[$import]['css'] ?? [] as $css) {
+ $partialManifest = (new Collection($manifest))->where('file', $css);
+
+ $preloads->push([
+ $partialManifest->keys()->first(),
+ $this->assetPath("{$buildDirectory}/{$css}"),
+ $partialManifest->first(),
+ $manifest,
+ ]);
+
+ $tags->push($this->makeTagForChunk(
+ $partialManifest->keys()->first(),
+ $this->assetPath("{$buildDirectory}/{$css}"),
+ $partialManifest->first(),
+ $manifest
+ ));
+ }
+ }
+
+ $tags->push($this->makeTagForChunk(
+ $entrypoint,
+ $this->assetPath("{$buildDirectory}/{$chunk['file']}"),
+ $chunk,
+ $manifest
+ ));
+
+ foreach ($chunk['css'] ?? [] as $css) {
+ $partialManifest = (new Collection($manifest))->where('file', $css);
+
+ $preloads->push([
+ $partialManifest->keys()->first(),
+ $this->assetPath("{$buildDirectory}/{$css}"),
+ $partialManifest->first(),
+ $manifest,
+ ]);
+
+ $tags->push($this->makeTagForChunk(
+ $partialManifest->keys()->first(),
+ $this->assetPath("{$buildDirectory}/{$css}"),
+ $partialManifest->first(),
+ $manifest
+ ));
+ }
+ }
+
+ [$stylesheets, $scripts] = $tags->unique()->partition(fn ($tag) => str_starts_with($tag, 'unique()
+ ->sortByDesc(fn ($args) => $this->isCssPath($args[1]))
+ ->map(fn ($args) => $this->makePreloadTagForChunk(...$args));
+
+ $base = $preloads->join('') . $stylesheets->join('') . $scripts->join('');
+
+ if ($this->prefetchStrategy === null || $this->isRunningHot()) {
+ return new HtmlString($base);
+ }
+
+ $discoveredImports = [];
+
+ return (new Collection($entrypoints))
+ ->flatMap(fn ($entrypoint) => (new Collection($manifest[$entrypoint]['dynamicImports'] ?? []))
+ ->map(fn ($import) => $manifest[$import])
+ ->filter(fn ($chunk) => str_ends_with($chunk['file'], '.js') || str_ends_with($chunk['file'], '.css'))
+ ->flatMap($f = function ($chunk) use (&$f, $manifest, &$discoveredImports) {
+ return (new Collection([...$chunk['imports'] ?? [], ...$chunk['dynamicImports'] ?? []]))
+ ->reject(function ($import) use (&$discoveredImports) {
+ // Skip unexpected types (Closure, object, array, etc.)
+ if (! is_int($import) && ! is_string($import)) {
+ error_log('Skipping invalid import type: ' . gettype($import));
+ return true;
+ }
+
+ if (isset($discoveredImports[$import])) {
+ return true;
+ }
+
+ return ! $discoveredImports[$import] = true;
+ })
+ ->reduce(
+ fn ($chunks, $import) => $chunks->merge(
+ $f($manifest[$import])
+ ),
+ new Collection([$chunk])
+ )
+ ->merge((new Collection($chunk['css'] ?? []))->map(
+ fn ($css) => (new Collection($manifest))->first(fn ($chunk) => $chunk['file'] === $css) ?? [
+ 'file' => $css,
+ ],
+ ));
+ })
+ ->map(function ($chunk) use ($buildDirectory, $manifest) {
+ return (new Collection([
+ ...$this->resolvePreloadTagAttributes(
+ $chunk['src'] ?? null,
+ $url = $this->assetPath("{$buildDirectory}/{$chunk['file']}"),
+ $chunk,
+ $manifest,
+ ),
+ 'rel' => 'prefetch',
+ 'fetchpriority' => 'low',
+ 'href' => $url,
+ ]))->reject(
+ fn ($value) => in_array($value, [null, false], true)
+ )->mapWithKeys(fn ($value, $key) => [
+ $key = (is_int($key) ? $value : $key) => $value === true ? $key : $value,
+ ])->all();
+ })
+ ->reject(fn ($attributes) => isset($this->preloadedAssets[$attributes['href']])))
+ ->unique('href')
+ ->values()
+ ->pipe(fn ($assets) => with(Js::from($assets), fn ($assets) => match ($this->prefetchStrategy) {
+ 'waterfall' => new HtmlString($base . <<nonceAttribute()}>
+ window.addEventListener('{$this->prefetchEvent}', () => window.setTimeout(() => {
+ const makeLink = (asset) => {
+ const link = document.createElement('link')
+
+ Object.keys(asset).forEach((attribute) => {
+ link.setAttribute(attribute, asset[attribute])
+ })
+
+ return link
+ }
+
+ const loadNext = (assets, count) => window.setTimeout(() => {
+ if (count > assets.length) {
+ count = assets.length
+
+ if (count === 0) {
+ return
+ }
+ }
+
+ const fragment = new DocumentFragment
+
+ while (count > 0) {
+ const link = makeLink(assets.shift())
+ fragment.append(link)
+ count--
+
+ if (assets.length) {
+ link.onload = () => loadNext(assets, 1)
+ link.onerror = () => loadNext(assets, 1)
+ }
+ }
+
+ document.head.append(fragment)
+ })
+
+ loadNext({$assets}, {$this->prefetchConcurrently})
+ }))
+
+ HTML),
+ 'aggressive' => new HtmlString($base . <<nonceAttribute()}>
+ window.addEventListener('{$this->prefetchEvent}', () => window.setTimeout(() => {
+ const makeLink = (asset) => {
+ const link = document.createElement('link')
+
+ Object.keys(asset).forEach((attribute) => {
+ link.setAttribute(attribute, asset[attribute])
+ })
+
+ return link
+ }
+
+ const fragment = new DocumentFragment;
+ {$assets}.forEach((asset) => fragment.append(makeLink(asset)))
+ document.head.append(fragment)
+ }))
+
+ HTML),
+ }));
+ }
+
+ /**
+ * Make tag for the given chunk.
+ */
+ protected function makeTagForChunk(?string $src, ?string $url, ?array $chunk, ?array $manifest): string
+ {
+ if (
+ $this->nonce === null
+ && $this->integrityKey !== false
+ && ! array_key_exists($this->integrityKey, $chunk ?? [])
+ && $this->scriptTagAttributesResolvers === []
+ && $this->styleTagAttributesResolvers === []
+ ) {
+ return $this->makeTag($url);
+ }
+
+ if ($this->isCssPath($url)) {
+ return $this->makeStylesheetTagWithAttributes(
+ $url,
+ $this->resolveStylesheetTagAttributes($src, $url, $chunk, $manifest)
+ );
+ }
+
+ return $this->makeScriptTagWithAttributes(
+ $url,
+ $this->resolveScriptTagAttributes($src, $url, $chunk, $manifest)
+ );
+ }
+
+ /**
+ * Make a preload tag for the given chunk.
+ */
+ protected function makePreloadTagForChunk(?string $src, ?string $url, ?array $chunk, ?array $manifest): string
+ {
+ $attributes = $this->resolvePreloadTagAttributes($src, $url, $chunk, $manifest);
+
+ if ($attributes === false) {
+ return '';
+ }
+
+ $this->preloadedAssets[$url] = $this->parseAttributes(
+ (new Collection($attributes))->forget('href')->all()
+ );
+
+ return 'parseAttributes($attributes)) . ' />';
+ }
+
+ /**
+ * Resolve the attributes for the chunks generated script tag.
+ */
+ protected function resolveScriptTagAttributes(?string $src, ?string $url, ?array $chunk, ?array $manifest): array
+ {
+ $attributes = $this->integrityKey !== false
+ ? ['integrity' => $chunk[$this->integrityKey] ?? false]
+ : [];
+
+ foreach ($this->scriptTagAttributesResolvers as $resolver) {
+ $attributes = array_merge($attributes, $resolver($src, $url, $chunk, $manifest));
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Resolve the attributes for the chunks generated stylesheet tag.
+ */
+ protected function resolveStylesheetTagAttributes(?string $src, ?string $url, ?array $chunk, ?array $manifest): array
+ {
+ $attributes = $this->integrityKey !== false
+ ? ['integrity' => $chunk[$this->integrityKey] ?? false]
+ : [];
+
+ foreach ($this->styleTagAttributesResolvers as $resolver) {
+ $attributes = array_merge($attributes, $resolver($src, $url, $chunk, $manifest));
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Resolve the attributes for the chunks generated preload tag.
+ */
+ protected function resolvePreloadTagAttributes(?string $src, ?string $url, ?array $chunk, ?array $manifest): array|false
+ {
+ $attributes = $this->isCssPath($url) ? [
+ 'rel' => 'preload',
+ 'as' => 'style',
+ 'href' => $url,
+ 'nonce' => $this->nonce ?? false,
+ 'crossorigin' => $this->resolveStylesheetTagAttributes($src, $url, $chunk, $manifest)['crossorigin'] ?? false,
+ ] : [
+ 'rel' => 'modulepreload',
+ 'as' => 'script',
+ 'href' => $url,
+ 'nonce' => $this->nonce ?? false,
+ 'crossorigin' => $this->resolveScriptTagAttributes($src, $url, $chunk, $manifest)['crossorigin'] ?? false,
+ ];
+
+ $attributes = $this->integrityKey !== false
+ ? array_merge($attributes, ['integrity' => $chunk[$this->integrityKey] ?? false])
+ : $attributes;
+
+ foreach ($this->preloadTagAttributesResolvers as $resolver) {
+ if (false === ($resolvedAttributes = $resolver($src, $url, $chunk, $manifest))) {
+ return false;
+ }
+
+ $attributes = array_merge($attributes, $resolvedAttributes);
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Generate an appropriate tag for the given URL in HMR mode.
+ *
+ * @deprecated will be removed in a future Laravel version
+ */
+ protected function makeTag(string $url): string
+ {
+ if ($this->isCssPath($url)) {
+ return $this->makeStylesheetTag($url);
+ }
+
+ return $this->makeScriptTag($url);
+ }
+
+ /**
+ * Generate a script tag for the given URL.
+ *
+ * @deprecated will be removed in a future Laravel version
+ */
+ protected function makeScriptTag(string $url): string
+ {
+ return $this->makeScriptTagWithAttributes($url, []);
+ }
+
+ /**
+ * Generate a stylesheet tag for the given URL in HMR mode.
+ *
+ * @deprecated will be removed in a future Laravel version
+ */
+ protected function makeStylesheetTag(string $url): string
+ {
+ return $this->makeStylesheetTagWithAttributes($url, []);
+ }
+
+ /**
+ * Generate a script tag with attributes for the given URL.
+ */
+ protected function makeScriptTagWithAttributes(string $url, array $attributes): string
+ {
+ $attributes = $this->parseAttributes(array_merge([
+ 'type' => 'module',
+ 'src' => $url,
+ 'nonce' => $this->nonce ?? false,
+ ], $attributes));
+
+ return '';
+ }
+
+ /**
+ * Generate a link tag with attributes for the given URL.
+ */
+ protected function makeStylesheetTagWithAttributes(string $url, array $attributes): string
+ {
+ $attributes = $this->parseAttributes(array_merge([
+ 'rel' => 'stylesheet',
+ 'href' => $url,
+ 'nonce' => $this->nonce ?? false,
+ ], $attributes));
+
+ return '';
+ }
+
+ /**
+ * Determine whether the given path is a CSS file.
+ */
+ protected function isCssPath(string $path): bool
+ {
+ return preg_match('/\.(css|less|sass|scss|styl|stylus|pcss|postcss)(\?[^\.]*)?$/', $path) === 1;
+ }
+
+ /**
+ * Parse the attributes into key="value" strings.
+ */
+ protected function parseAttributes(array $attributes): array
+ {
+ return (new Collection($attributes))
+ ->reject(fn ($value, $key) => in_array($value, [false, null], true))
+ ->flatMap(fn ($value, $key) => $value === true ? [$key] : [$key => $value])
+ ->map(fn ($value, $key) => is_int($key) ? $value : $key . '="' . $value . '"')
+ ->values()
+ ->all();
+ }
+
+ /**
+ * Generate React refresh runtime script.
+ */
+ public function reactRefresh(): ?HtmlString
+ {
+ if (! $this->isRunningHot()) {
+ return null;
+ }
+
+ $attributes = $this->parseAttributes([
+ 'nonce' => $this->cspNonce(),
+ ]);
+
+ return new HtmlString(
+ sprintf(
+ <<<'HTML'
+
+ HTML,
+ implode(' ', $attributes),
+ $this->hotAsset('@react-refresh')
+ )
+ );
+ }
+
+ /**
+ * Get the path to a given asset when running in HMR mode.
+ */
+ protected function hotAsset(mixed $asset): string
+ {
+ return rtrim(file_get_contents($this->hotFile())) . '/' . $asset;
+ }
+
+ /**
+ * Get the URL for an asset.
+ */
+ public function asset(string $asset, ?string $buildDirectory = null): string
+ {
+ $buildDirectory ??= $this->buildDirectory;
+
+ if ($this->isRunningHot()) {
+ return $this->hotAsset($asset);
+ }
+
+ $chunk = $this->chunk($this->manifest($buildDirectory), $asset);
+
+ return $this->assetPath($buildDirectory . '/' . $chunk['file']);
+ }
+
+ /**
+ * Get the content of a given asset.
+ *
+ * @throws \Hypervel\Foundation\ViteException
+ */
+ public function content(string $asset, ?string $buildDirectory = null): string
+ {
+ $buildDirectory ??= $this->buildDirectory;
+
+ $chunk = $this->chunk($this->manifest($buildDirectory), $asset);
+
+ $path = public_path($buildDirectory . '/' . $chunk['file']);
+
+ if (! is_file($path) || ! file_exists($path)) {
+ throw new ViteException("Unable to locate file from Vite manifest: {$path}.");
+ }
+
+ return file_get_contents($path);
+ }
+
+ /**
+ * Generate an asset path for the application.
+ */
+ protected function assetPath(string $path, ?bool $secure = null): string
+ {
+ $assetUrl = config('app.asset_url');
+
+ if (! empty($assetUrl)) {
+ $this->assetPathResolver ??= fn ($path) => "{$assetUrl}/{$path}";
+ }
+ return ($this->assetPathResolver ?? asset(...))($path, $secure);
+ }
+
+ /**
+ * Get the manifest file for the given build directory.
+ *
+ * @throws \Hypervel\Foundation\ViteException
+ */
+ protected function manifest(string $buildDirectory): array
+ {
+ $path = $this->manifestPath($buildDirectory);
+
+ if (! isset(static::$manifests[$path])) {
+ if (! is_file($path)) {
+ throw new ViteException("Vite manifest not found at: {$path}");
+ }
+
+ static::$manifests[$path] = json_decode(file_get_contents($path), true);
+ }
+
+ return static::$manifests[$path];
+ }
+
+ /**
+ * Get the path to the manifest file for the given build directory.
+ */
+ protected function manifestPath(string $buildDirectory): string
+ {
+ return public_path($buildDirectory . '/' . $this->manifestFilename);
+ }
+
+ /**
+ * Get a unique hash representing the current manifest, or null if there is no manifest.
+ */
+ public function manifestHash(?string $buildDirectory = null): ?string
+ {
+ $buildDirectory ??= $this->buildDirectory;
+
+ if ($this->isRunningHot()) {
+ return null;
+ }
+
+ if (! is_file($path = $this->manifestPath($buildDirectory))) {
+ return null;
+ }
+
+ return md5_file($path) ?: null;
+ }
+
+ /**
+ * Get the chunk for the given entry point / asset.
+ *
+ * @throws \Hypervel\Foundation\ViteException
+ */
+ protected function chunk(array $manifest, string $file): array
+ {
+ if (! isset($manifest[$file])) {
+ throw new ViteException("Unable to locate file in Vite manifest: {$file}.");
+ }
+
+ return $manifest[$file];
+ }
+
+ /**
+ * Get the nonce attribute for the prefetch script tags.
+ */
+ protected function nonceAttribute(): HtmlString
+ {
+ if ($this->cspNonce() === null) {
+ return new HtmlString('');
+ }
+
+ return new HtmlString(' nonce="' . $this->cspNonce() . '"');
+ }
+
+ /**
+ * Determine if the HMR server is running.
+ */
+ public function isRunningHot(): bool
+ {
+ return is_file($this->hotFile());
+ }
+
+ /**
+ * Get the Vite tag content as a string of HTML.
+ */
+ public function toHtml(): string
+ {
+ return $this->__invoke($this->entryPoints)->toHtml();
+ }
+
+ /**
+ * Flush state.
+ */
+ public function flush(): void
+ {
+ $this->preloadedAssets = [];
+ }
+
+ /**
+ * Get the string representation of the URI.
+ */
+ public function __toString(): string
+ {
+ return implode(',', $this->entryPoints);
+ }
+}
diff --git a/src/foundation/src/ViteException.php b/src/foundation/src/ViteException.php
new file mode 100644
index 000000000..b67c7b682
--- /dev/null
+++ b/src/foundation/src/ViteException.php
@@ -0,0 +1,11 @@
+addHeader('Link', (new Collection(Vite::preloadedAssets()))
+ ->when($limit, fn ($assets, $limit) => $assets->take($limit))
+ ->map(fn ($attributes, $url) => "<{$url}>; " . implode('; ', $attributes))
+ ->join(', '));
+ }
+ });
+ }
+}
diff --git a/src/support/src/Facades/Facade.php b/src/support/src/Facades/Facade.php
index db95e6b97..fa095edae 100644
--- a/src/support/src/Facades/Facade.php
+++ b/src/support/src/Facades/Facade.php
@@ -74,8 +74,8 @@ public static function shouldReceive()
$name = static::getFacadeAccessor();
$mock = static::isMock()
- ? static::$resolvedInstance[$name]
- : static::createFreshMockInstance();
+ ? static::$resolvedInstance[$name]
+ : static::createFreshMockInstance();
return $mock->shouldReceive(...func_get_args());
}
@@ -110,7 +110,7 @@ protected static function isMock(): bool
$name = static::getFacadeAccessor();
return isset(static::$resolvedInstance[$name])
- && static::$resolvedInstance[$name] instanceof LegacyMockInterface;
+ && static::$resolvedInstance[$name] instanceof LegacyMockInterface;
}
/**
@@ -249,6 +249,7 @@ public static function defaultAliases(): Collection
'URL' => URL::class,
'Validator' => Validator::class,
'View' => View::class,
+ 'Vite' => Vite::class,
]);
}
diff --git a/src/support/src/Facades/Vite.php b/src/support/src/Facades/Vite.php
new file mode 100644
index 000000000..11c596f1f
--- /dev/null
+++ b/src/support/src/Facades/Vite.php
@@ -0,0 +1,49 @@
+toHtml();
diff --git a/src/support/src/Str.php b/src/support/src/Str.php
index 39de56375..fcb64f393 100644
--- a/src/support/src/Str.php
+++ b/src/support/src/Str.php
@@ -7,10 +7,18 @@
use Hyperf\Stringable\Str as BaseStr;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Ramsey\Uuid\Rfc4122\FieldsInterface;
+use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidFactory;
class Str extends BaseStr
{
+ /**
+ * The callback that should be used to generate random strings.
+ *
+ * @var null|(callable(int): string)
+ */
+ protected static $randomStringFactory;
+
/**
* Determine if a given string matches a given pattern.
*
@@ -96,4 +104,42 @@ public static function isUuid($value, $version = null): bool
return $fields->getVersion() === $version;
}
+
+ /**
+ * Set the callable that will be used to generate random strings.
+ */
+ public static function createRandomStringsUsing(?callable $factory = null)
+ {
+ static::$randomStringFactory = $factory;
+ }
+
+ /**
+ * Indicate that random strings should be created normally and not using a custom factory.
+ */
+ public static function createRandomStringsNormally()
+ {
+ static::$randomStringFactory = null;
+ }
+
+ /**
+ * Generate a more truly "random" alpha-numeric string.
+ */
+ public static function random(int $length = 16): string
+ {
+ return (static::$randomStringFactory ?? function ($length) {
+ $string = '';
+
+ while (($len = strlen($string)) < $length) {
+ $size = $length - $len;
+
+ $bytesSize = (int) ceil($size / 3) * 3;
+
+ $bytes = random_bytes($bytesSize);
+
+ $string .= substr(str_replace(['/', '+', '='], '', base64_encode($bytes)), 0, $size);
+ }
+
+ return $string;
+ })($length);
+ }
}
diff --git a/src/testbench/workbench/config/app.php b/src/testbench/workbench/config/app.php
index db30fb8f2..dd3ea1893 100644
--- a/src/testbench/workbench/config/app.php
+++ b/src/testbench/workbench/config/app.php
@@ -92,6 +92,10 @@
'url' => env('APP_URL', 'http://localhost'),
+ 'frontend_url' => env('FRONTEND_URL', 'http://localhost:3000'),
+
+ 'asset_url' => env('ASSET_URL', 'https://example.com'),
+
/*
|--------------------------------------------------------------------------
| Application Timezone
diff --git a/tests/Foundation/FoundationViteTest.php b/tests/Foundation/FoundationViteTest.php
new file mode 100644
index 000000000..6c0a7a261
--- /dev/null
+++ b/tests/Foundation/FoundationViteTest.php
@@ -0,0 +1,1783 @@
+set('app.asset_url', 'https://example.com');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->cleanViteManifest();
+ $this->cleanViteHotFile();
+
+ parent::tearDown();
+ }
+
+ public function testViteWithJsOnly()
+ {
+ $this->makeViteManifest();
+
+ $result = app(Vite::class)('resources/js/app.js');
+
+ $this->assertStringEndsWith('', (string) $result->toHtml());
+ }
+
+ public function testViteWithCssAndJs()
+ {
+ $this->makeViteManifest();
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ (string) $result->toHtml()
+ );
+ }
+
+ public function testViteWithCssImport()
+ {
+ $this->makeViteManifest();
+
+ $result = app(Vite::class)('resources/js/app-with-css-import.js');
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ (string) $result->toHtml()
+ );
+ }
+
+ public function testViteWithSharedCssImport()
+ {
+ $this->makeViteManifest();
+
+ $result = app(Vite::class)(['resources/js/app-with-shared-css.js']);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ (string) $result->toHtml()
+ );
+ }
+
+ public function testViteHotModuleReplacementWithJsOnly()
+ {
+ $this->makeViteHotFile();
+
+ $result = app(Vite::class)('resources/js/app.js');
+
+ $this->assertSame(
+ ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testViteHotModuleReplacementWithJsAndCss()
+ {
+ $this->makeViteHotFile();
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame(
+ ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testItCanGenerateCspNonceWithHotFile()
+ {
+ Str::createRandomStringsUsing(function ($length) {
+ return "random-string-with-length:{$length}";
+ });
+ $this->makeViteHotFile();
+
+ $nonce = ViteFacade::useCspNonce();
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame('random-string-with-length:40', $nonce);
+ $this->assertSame('random-string-with-length:40', ViteFacade::cspNonce());
+ $this->assertSame(
+ ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+
+ Str::createRandomStringsNormally();
+ }
+
+ public function testItCanGenerateCspNonceWithManifest()
+ {
+ Str::createRandomStringsUsing(function ($length) {
+ return "random-string-with-length:{$length}";
+ });
+ $this->makeViteManifest();
+
+ $nonce = ViteFacade::useCspNonce();
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame('random-string-with-length:40', $nonce);
+ $this->assertSame('random-string-with-length:40', ViteFacade::cspNonce());
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ (string) $result->toHtml()
+ );
+
+ Str::createRandomStringsNormally();
+ }
+
+ public function testItCanSpecifyCspNonceWithHotFile()
+ {
+ $this->makeViteHotFile();
+
+ $nonce = ViteFacade::useCspNonce('expected-nonce');
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame('expected-nonce', $nonce);
+ $this->assertSame('expected-nonce', ViteFacade::cspNonce());
+ $this->assertSame(
+ ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testItCanSpecifyCspNonceWithManifest()
+ {
+ $this->makeViteManifest();
+
+ $nonce = ViteFacade::useCspNonce('expected-nonce');
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame('expected-nonce', $nonce);
+ $this->assertSame('expected-nonce', ViteFacade::cspNonce());
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ (string) $result->toHtml()
+ );
+ }
+
+ public function testReactRefreshWithNoNonce()
+ {
+ $this->makeViteHotFile();
+
+ $result = app(Vite::class)->reactRefresh();
+
+ $this->assertStringNotContainsString('nonce', (string) $result);
+ }
+
+ public function testReactRefreshNonce()
+ {
+ $this->makeViteHotFile();
+
+ $nonce = ViteFacade::useCspNonce('expected-nonce');
+ $result = app(Vite::class)->reactRefresh();
+
+ $this->assertStringContainsString(sprintf('nonce="%s"', $nonce), (string) $result);
+ }
+
+ public function testItCanInjectIntegrityWhenPresentInManifest()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'integrity' => 'expected-app.js-integrity',
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ 'integrity' => 'expected-app.css-integrity',
+ ],
+ ], $buildDir);
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js'], $buildDir);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ (string) $result->toHtml()
+ );
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItCanInjectIntegrityWhenPresentInManifestForCss()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'css' => [
+ 'assets/direct-css-dependency.aabbcc.css',
+ ],
+ 'integrity' => 'expected-app.js-integrity',
+ ],
+ '_import.versioned.js' => [
+ 'file' => 'assets/import.versioned.js',
+ 'css' => [
+ 'assets/imported-css.versioned.css',
+ ],
+ 'integrity' => 'expected-import.js-integrity',
+ ],
+ 'imported-css.css' => [
+ 'file' => 'assets/direct-css-dependency.aabbcc.css',
+ 'integrity' => 'expected-imported-css.css-integrity',
+ ],
+ ], $buildDir);
+
+ $result = app(Vite::class)('resources/js/app.js', $buildDir);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ (string) $result->toHtml()
+ );
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItCanInjectIntegrityWhenPresentInManifestForImportedCss()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'imports' => [
+ '_import.versioned.js',
+ ],
+ 'integrity' => 'expected-app.js-integrity',
+ ],
+ '_import.versioned.js' => [
+ 'file' => 'assets/import.versioned.js',
+ 'css' => [
+ 'assets/imported-css.versioned.css',
+ ],
+ 'integrity' => 'expected-import.js-integrity',
+ ],
+ 'imported-css.css' => [
+ 'file' => 'assets/imported-css.versioned.css',
+ 'integrity' => 'expected-imported-css.css-integrity',
+ ],
+ ], $buildDir);
+
+ $result = app(Vite::class)('resources/js/app.js', $buildDir);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ (string) $result->toHtml()
+ );
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItCanSpecifyIntegrityKey()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'different-integrity-key' => 'expected-app.js-integrity',
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ 'different-integrity-key' => 'expected-app.css-integrity',
+ ],
+ ], $buildDir);
+ ViteFacade::useIntegrityKey('different-integrity-key');
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js'], $buildDir);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ (string) $result->toHtml()
+ );
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItCanSpecifyArbitraryAttributesForScriptTagsWhenBuilt()
+ {
+ $this->makeViteManifest();
+ ViteFacade::useScriptTagAttributes([
+ 'general' => 'attribute',
+ ]);
+ ViteFacade::useScriptTagAttributes(function ($src, $url, $chunk, $manifest) {
+ $this->assertSame('resources/js/app.js', $src);
+ $this->assertSame('https://example.com/build/assets/app.versioned.js', $url);
+ $this->assertSame([
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ ], $chunk);
+ $this->assertSame([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ ],
+ 'resources/js/app-with-css-import.js' => [
+ 'src' => 'resources/js/app-with-css-import.js',
+ 'file' => 'assets/app-with-css-import.versioned.js',
+ 'css' => [
+ 'assets/imported-css.versioned.css',
+ ],
+ ],
+ 'resources/css/imported-css.css' => [
+ 'file' => 'assets/imported-css.versioned.css',
+ ],
+ 'resources/js/app-with-shared-css.js' => [
+ 'src' => 'resources/js/app-with-shared-css.js',
+ 'file' => 'assets/app-with-shared-css.versioned.js',
+ 'imports' => [
+ '_someFile.js',
+ ],
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ],
+ '_someFile.js' => [
+ 'file' => 'assets/someFile.versioned.js',
+ 'css' => [
+ 'assets/shared-css.versioned.css',
+ ],
+ ],
+ 'resources/css/shared-css' => [
+ 'src' => 'resources/css/shared-css',
+ 'file' => 'assets/shared-css.versioned.css',
+ ],
+ ], $manifest);
+
+ return [
+ 'crossorigin',
+ 'data-persistent-across-pages' => 'YES',
+ 'remove-me' => false,
+ 'keep-me' => true,
+ 'null' => null,
+ 'empty-string' => '',
+ 'zero' => 0,
+ ];
+ });
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ (string) $result->toHtml()
+ );
+ }
+
+ public function testItCanSpecifyArbitraryAttributesForStylesheetTagsWhenBuild()
+ {
+ $this->makeViteManifest();
+ ViteFacade::useStyleTagAttributes([
+ 'general' => 'attribute',
+ ]);
+ ViteFacade::useStyleTagAttributes(function ($src, $url, $chunk, $manifest) {
+ $this->assertSame('resources/css/app.css', $src);
+ $this->assertSame('https://example.com/build/assets/app.versioned.css', $url);
+ $this->assertSame([
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ], $chunk);
+ $this->assertSame([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ ],
+ 'resources/js/app-with-css-import.js' => [
+ 'src' => 'resources/js/app-with-css-import.js',
+ 'file' => 'assets/app-with-css-import.versioned.js',
+ 'css' => [
+ 'assets/imported-css.versioned.css',
+ ],
+ ],
+ 'resources/css/imported-css.css' => [
+ 'file' => 'assets/imported-css.versioned.css',
+ ],
+ 'resources/js/app-with-shared-css.js' => [
+ 'src' => 'resources/js/app-with-shared-css.js',
+ 'file' => 'assets/app-with-shared-css.versioned.js',
+ 'imports' => [
+ '_someFile.js',
+ ],
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ],
+ '_someFile.js' => [
+ 'file' => 'assets/someFile.versioned.js',
+ 'css' => [
+ 'assets/shared-css.versioned.css',
+ ],
+ ],
+ 'resources/css/shared-css' => [
+ 'src' => 'resources/css/shared-css',
+ 'file' => 'assets/shared-css.versioned.css',
+ ],
+ ], $manifest);
+
+ return [
+ 'crossorigin',
+ 'data-persistent-across-pages' => 'YES',
+ 'remove-me' => false,
+ 'keep-me' => true,
+ ];
+ });
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ (string) $result->toHtml()
+ );
+ }
+
+ public function testItCanSpecifyArbitraryAttributesForScriptTagsWhenHotModuleReloading()
+ {
+ $this->makeViteHotFile();
+ ViteFacade::useScriptTagAttributes([
+ 'general' => 'attribute',
+ ]);
+ $expectedArguments = [
+ ['src' => '@vite/client', 'url' => 'http://localhost:3000/@vite/client'],
+ ['src' => 'resources/js/app.js', 'url' => 'http://localhost:3000/resources/js/app.js'],
+ ];
+ ViteFacade::useScriptTagAttributes(function ($src, $url, $chunk, $manifest) use (&$expectedArguments) {
+ $args = array_shift($expectedArguments);
+
+ $this->assertSame($args['src'], $src);
+ $this->assertSame($args['url'], $url);
+ $this->assertNull($chunk);
+ $this->assertNull($manifest);
+
+ return [
+ 'crossorigin',
+ 'data-persistent-across-pages' => 'YES',
+ 'remove-me' => false,
+ 'keep-me' => true,
+ ];
+ });
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame(
+ ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testItCanSpecifyArbitraryAttributesForStylesheetTagsWhenHotModuleReloading()
+ {
+ $this->makeViteHotFile();
+ ViteFacade::useStyleTagAttributes([
+ 'general' => 'attribute',
+ ]);
+ ViteFacade::useStyleTagAttributes(function ($src, $url, $chunk, $manifest) {
+ $this->assertSame('resources/css/app.css', $src);
+ $this->assertSame('http://localhost:3000/resources/css/app.css', $url);
+ $this->assertNull($chunk);
+ $this->assertNull($manifest);
+
+ return [
+ 'crossorigin',
+ 'data-persistent-across-pages' => 'YES',
+ 'remove-me' => false,
+ 'keep-me' => true,
+ ];
+ });
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame(
+ ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testItCanOverrideAllAttributes()
+ {
+ $this->makeViteManifest();
+ ViteFacade::useStyleTagAttributes([
+ 'rel' => 'expected-rel',
+ 'href' => 'expected-href',
+ ]);
+ ViteFacade::useScriptTagAttributes([
+ 'type' => 'expected-type',
+ 'src' => 'expected-src',
+ ]);
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ (string) $result->toHtml()
+ );
+ }
+
+ public function testItCanGenerateIndividualAssetUrlInBuildMode()
+ {
+ $this->makeViteManifest();
+
+ $url = ViteFacade::asset('resources/js/app.js');
+
+ $this->assertSame('https://example.com/build/assets/app.versioned.js', $url);
+ }
+
+ public function testItCanGenerateIndividualAssetUrlInHotMode()
+ {
+ $this->makeViteHotFile();
+
+ $url = ViteFacade::asset('resources/js/app.js');
+
+ $this->assertSame('http://localhost:3000/resources/js/app.js', $url);
+ }
+
+ public function testItThrowsWhenUnableToFindAssetManifestInBuildMode()
+ {
+ $this->expectException(ViteException::class);
+ $this->expectExceptionMessage('Vite manifest not found at: ' . public_path('build/custom-manifest.json'));
+ ViteFacade::useManifestFilename('custom-manifest.json');
+ ViteFacade::asset('resources/js/newApp.js');
+ }
+
+ public function testItThrowsWhenUnableToFindAssetChunkInBuildMode()
+ {
+ $this->makeViteManifest();
+
+ $this->expectException(ViteException::class);
+ $this->expectExceptionMessage('Unable to locate file in Vite manifest: resources/js/missing.js');
+
+ ViteFacade::asset('resources/js/missing.js');
+ }
+
+ public function testItDoesNotReturnHashInDevMode()
+ {
+ $this->makeViteHotFile();
+
+ $this->assertNull(ViteFacade::manifestHash());
+
+ $this->cleanViteHotFile();
+ }
+
+ public function testItGetsHashInBuildMode()
+ {
+ $this->makeViteManifest(['a.js' => ['src' => 'a.js']]);
+
+ $this->assertSame('98ca5a789544599b562c9978f3147a0f', ViteFacade::manifestHash());
+
+ $this->cleanViteManifest();
+ }
+
+ public function testItGetsDifferentHashesForDifferentManifestsInBuildMode()
+ {
+ $this->makeViteManifest(['a.js' => ['src' => 'a.js']]);
+ $this->makeViteManifest(['b.js' => ['src' => 'b.js']], 'admin');
+
+ $this->assertSame('98ca5a789544599b562c9978f3147a0f', ViteFacade::manifestHash());
+ $this->assertSame('928a60835978bae84e5381fbb08a38b2', ViteFacade::manifestHash('admin'));
+
+ $this->cleanViteManifest();
+ $this->cleanViteManifest('admin');
+ }
+
+ public function testViteCanSetEntryPointsWithFluentBuilder()
+ {
+ $this->makeViteManifest();
+
+ $vite = app(Vite::class);
+
+ $this->assertSame('', $vite->toHtml());
+
+ $vite->withEntryPoints(['resources/js/app.js']);
+
+ $this->assertStringEndsWith(
+ '',
+ $vite->toHtml()
+ );
+ }
+
+ public function testViteCanOverrideBuildDirectory()
+ {
+ $this->makeViteManifest(null, 'custom-build');
+
+ $vite = app(Vite::class);
+
+ $vite->withEntryPoints(['resources/js/app.js'])->useBuildDirectory('custom-build');
+
+ $this->assertStringEndsWith(
+ '',
+ (string) $vite->toHtml()
+ );
+
+ $this->cleanViteManifest('custom-build');
+ }
+
+ public function testViteCanOverrideHotFilePath()
+ {
+ $this->makeViteHotFile('cold');
+
+ $vite = app(Vite::class);
+
+ $vite->withEntryPoints(['resources/js/app.js'])->useHotFile('cold');
+
+ $this->assertSame(
+ ''
+ . '',
+ $vite->toHtml()
+ );
+
+ $this->cleanViteHotFile('cold');
+ }
+
+ public function testViteCanAssetPath()
+ {
+ $this->makeViteManifest([
+ 'resources/images/profile.png' => [
+ 'src' => 'resources/images/profile.png',
+ 'file' => 'assets/profile.versioned.png',
+ ],
+ ], $buildDir = Str::random());
+ $vite = app(Vite::class)->useBuildDirectory($buildDir);
+ $this->app['config']->set('app.asset_url', 'https://cdn.app.com');
+
+ // default behaviour...
+ $this->assertSame("https://cdn.app.com/{$buildDir}/assets/profile.versioned.png", $vite->asset('resources/images/profile.png'));
+
+ // custom behaviour
+ $vite->createAssetPathsUsing(function ($path) {
+ return 'https://tenant-cdn.app.com/' . $path;
+ });
+ $this->assertSame("https://tenant-cdn.app.com/{$buildDir}/assets/profile.versioned.png", $vite->asset('resources/images/profile.png'));
+
+ // restore default behaviour...
+ $vite->createAssetPathsUsing(null);
+ $this->assertSame("https://cdn.app.com/{$buildDir}/assets/profile.versioned.png", $vite->asset('resources/images/profile.png'));
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testViteIsMacroable()
+ {
+ $this->makeViteManifest([
+ 'resources/images/profile.png' => [
+ 'src' => 'resources/images/profile.png',
+ 'file' => 'assets/profile.versioned.png',
+ ],
+ ], $buildDir = Str::random());
+ Vite::macro('image', function ($asset, $buildDir = null) {
+ return $this->asset("resources/images/{$asset}", $buildDir);
+ });
+
+ $path = ViteFacade::image('profile.png', $buildDir);
+
+ $this->assertSame("https://example.com/{$buildDir}/assets/profile.versioned.png", $path);
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItGeneratesPreloadDirectivesForJsAndCssImports()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/jetstream-manifest.json'), true);
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+
+ $result = app(Vite::class)(['resources/js/Pages/Auth/Login.vue'], $buildDir);
+
+ $this->assertSame(
+ ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . '',
+ (string) $result
+ );
+ $this->assertSame([
+ 'https://example.com/' . $buildDir . '/assets/app.9842b564.css' => [
+ 'rel="preload"',
+ 'as="style"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/Login.8c52c4a3.js' => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/app.a26d8e4d.js' => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/AuthenticationCard.47ef70cc.js' => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/AuthenticationCardLogo.9999a373.js' => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/Checkbox.33ba23f3.js' => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/TextInput.e2f0248c.js' => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/InputLabel.d245ec4e.js' => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/PrimaryButton.931d2859.js' => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/_plugin-vue_export-helper.cdc0426e.js' => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ ],
+ ], ViteFacade::preloadedAssets());
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItCanSpecifyAttributesForPreloadedAssets()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'imports' => [
+ 'import.js',
+ ],
+ 'css' => [
+ 'assets/app.versioned.css',
+ ],
+ ],
+ 'import.js' => [
+ 'file' => 'assets/import.versioned.js',
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ],
+ ], $buildDir);
+ ViteFacade::usePreloadTagAttributes([
+ 'general' => 'attribute',
+ ]);
+ ViteFacade::usePreloadTagAttributes(function ($src, $url, $chunk, $manifest) use ($buildDir) {
+ $this->assertSame([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'imports' => [
+ 'import.js',
+ ],
+ 'css' => [
+ 'assets/app.versioned.css',
+ ],
+ ],
+ 'import.js' => [
+ 'file' => 'assets/import.versioned.js',
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ],
+ ], $manifest);
+
+ (match ($src) {
+ 'resources/js/app.js' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/app.versioned.js", $url);
+ $this->assertSame([
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'imports' => [
+ 'import.js',
+ ],
+ 'css' => [
+ 'assets/app.versioned.css',
+ ],
+ ], $chunk);
+ },
+ 'import.js' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/import.versioned.js", $url);
+ $this->assertSame([
+ 'file' => 'assets/import.versioned.js',
+ ], $chunk);
+ },
+ 'resources/css/app.css' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/app.versioned.css", $url);
+ $this->assertSame([
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ], $chunk);
+ },
+ })();
+
+ return [
+ 'crossorigin',
+ 'data-persistent-across-pages' => 'YES',
+ 'remove-me' => false,
+ 'keep-me' => true,
+ 'null' => null,
+ 'empty-string' => '',
+ 'zero' => 0,
+ ];
+ });
+
+ $result = app(Vite::class)(['resources/js/app.js'], $buildDir);
+
+ $this->assertSame(
+ ''
+ . ''
+ . ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+
+ $this->assertSame([
+ "https://example.com/{$buildDir}/assets/app.versioned.css" => [
+ 'rel="preload"',
+ 'as="style"',
+ 'general="attribute"',
+ 'crossorigin',
+ 'data-persistent-across-pages="YES"',
+ 'keep-me',
+ 'empty-string=""',
+ 'zero="0"',
+ ],
+ "https://example.com/{$buildDir}/assets/app.versioned.js" => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ 'general="attribute"',
+ 'crossorigin',
+ 'data-persistent-across-pages="YES"',
+ 'keep-me',
+ 'empty-string=""',
+ 'zero="0"',
+ ],
+ "https://example.com/{$buildDir}/assets/import.versioned.js" => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ 'general="attribute"',
+ 'crossorigin',
+ 'data-persistent-across-pages="YES"',
+ 'keep-me',
+ 'empty-string=""',
+ 'zero="0"',
+ ],
+ ], ViteFacade::preloadedAssets());
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItCanSuppressPreloadTagGeneration()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'imports' => [
+ 'import.js',
+ 'import-nopreload.js',
+ ],
+ 'css' => [
+ 'assets/app.versioned.css',
+ 'assets/app-nopreload.versioned.css',
+ ],
+ ],
+ 'resources/js/app-nopreload.js' => [
+ 'src' => 'resources/js/app-nopreload.js',
+ 'file' => 'assets/app-nopreload.versioned.js',
+ ],
+ 'import.js' => [
+ 'file' => 'assets/import.versioned.js',
+ ],
+ 'import-nopreload.js' => [
+ 'file' => 'assets/import-nopreload.versioned.js',
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ],
+ 'resources/css/app-nopreload.css' => [
+ 'src' => 'resources/css/app-nopreload.css',
+ 'file' => 'assets/app-nopreload.versioned.css',
+ ],
+ ], $buildDir);
+ ViteFacade::usePreloadTagAttributes(function ($src, $url, $chunk, $manifest) use ($buildDir) {
+ $this->assertSame([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'imports' => [
+ 'import.js',
+ 'import-nopreload.js',
+ ],
+ 'css' => [
+ 'assets/app.versioned.css',
+ 'assets/app-nopreload.versioned.css',
+ ],
+ ],
+ 'resources/js/app-nopreload.js' => [
+ 'src' => 'resources/js/app-nopreload.js',
+ 'file' => 'assets/app-nopreload.versioned.js',
+ ],
+ 'import.js' => [
+ 'file' => 'assets/import.versioned.js',
+ ],
+ 'import-nopreload.js' => [
+ 'file' => 'assets/import-nopreload.versioned.js',
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ],
+ 'resources/css/app-nopreload.css' => [
+ 'src' => 'resources/css/app-nopreload.css',
+ 'file' => 'assets/app-nopreload.versioned.css',
+ ],
+ ], $manifest);
+
+ (match ($src) {
+ 'resources/js/app.js' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/app.versioned.js", $url);
+ $this->assertSame([
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'imports' => [
+ 'import.js',
+ 'import-nopreload.js',
+ ],
+ 'css' => [
+ 'assets/app.versioned.css',
+ 'assets/app-nopreload.versioned.css',
+ ],
+ ], $chunk);
+ },
+ 'resources/js/app-nopreload.js' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/app-nopreload.versioned.js", $url);
+ $this->assertSame([
+ 'src' => 'resources/js/app-nopreload.js',
+ 'file' => 'assets/app-nopreload.versioned.js',
+ ], $chunk);
+ },
+ 'import.js' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/import.versioned.js", $url);
+ $this->assertSame([
+ 'file' => 'assets/import.versioned.js',
+ ], $chunk);
+ },
+ 'import-nopreload.js' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/import-nopreload.versioned.js", $url);
+ $this->assertSame([
+ 'file' => 'assets/import-nopreload.versioned.js',
+ ], $chunk);
+ },
+ 'resources/css/app.css' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/app.versioned.css", $url);
+ $this->assertSame([
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ], $chunk);
+ },
+ 'resources/css/app-nopreload.css' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/app-nopreload.versioned.css", $url);
+ $this->assertSame([
+ 'src' => 'resources/css/app-nopreload.css',
+ 'file' => 'assets/app-nopreload.versioned.css',
+ ], $chunk);
+ },
+ })();
+
+ return Str::contains($src, '-nopreload') ? false : [];
+ });
+
+ $result = app(Vite::class)(['resources/js/app.js', 'resources/js/app-nopreload.js'], $buildDir);
+
+ $this->assertSame(
+ ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+
+ $this->assertSame([
+ "https://example.com/{$buildDir}/assets/app.versioned.css" => [
+ 'rel="preload"',
+ 'as="style"',
+ ],
+ "https://example.com/{$buildDir}/assets/app.versioned.js" => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ ],
+ "https://example.com/{$buildDir}/assets/import.versioned.js" => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ ],
+ ], ViteFacade::preloadedAssets());
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testPreloadAssetsGetAssetNonce()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'css' => [
+ 'assets/app.versioned.css',
+ ],
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ],
+ ], $buildDir);
+ ViteFacade::useCspNonce('expected-nonce');
+
+ $result = app(Vite::class)(['resources/js/app.js'], $buildDir);
+
+ $this->assertSame(
+ ''
+ . ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+
+ $this->assertSame([
+ "https://example.com/{$buildDir}/assets/app.versioned.css" => [
+ 'rel="preload"',
+ 'as="style"',
+ 'nonce="expected-nonce"',
+ ],
+ "https://example.com/{$buildDir}/assets/app.versioned.js" => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ 'nonce="expected-nonce"',
+ ],
+ ], ViteFacade::preloadedAssets());
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testCrossoriginAttributeIsInheritedByPreloadTags()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'css' => [
+ 'assets/app.versioned.css',
+ ],
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ],
+ ], $buildDir);
+ ViteFacade::useScriptTagAttributes([
+ 'crossorigin' => 'script-crossorigin',
+ ]);
+ ViteFacade::useStyleTagAttributes([
+ 'crossorigin' => 'style-crossorigin',
+ ]);
+
+ $result = app(Vite::class)(['resources/js/app.js'], $buildDir);
+
+ $this->assertSame(
+ ''
+ . ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+
+ $this->assertSame([
+ "https://example.com/{$buildDir}/assets/app.versioned.css" => [
+ 'rel="preload"',
+ 'as="style"',
+ 'crossorigin="style-crossorigin"',
+ ],
+ "https://example.com/{$buildDir}/assets/app.versioned.js" => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ 'crossorigin="script-crossorigin"',
+ ],
+ ], ViteFacade::preloadedAssets());
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItCanConfigureTheManifestFilename()
+ {
+ $buildDir = Str::random();
+ app()->publicPath(__DIR__);
+ if (! file_exists(public_path($buildDir))) {
+ mkdir(public_path($buildDir));
+ }
+ $contents = json_encode([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app-from-custom-manifest.js',
+ 'file' => 'assets/app-from-custom-manifest.versioned.js',
+ ],
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ file_put_contents(public_path("{$buildDir}/custom-manifest.json"), $contents);
+
+ ViteFacade::useManifestFilename('custom-manifest.json');
+
+ $result = app(Vite::class)(['resources/js/app.js'], $buildDir);
+
+ $this->assertSame(
+ ''
+ . '',
+ (string) $result->toHtml()
+ );
+
+ unlink(public_path("{$buildDir}/custom-manifest.json"));
+ rmdir(public_path($buildDir));
+ }
+
+ public function testItOnlyOutputsUniquePreloadTags()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.css' => [
+ 'file' => 'assets/app-versioned.css',
+ 'src' => 'resources/js/app.css',
+ ],
+ 'resources/js/Pages/Welcome.vue' => [
+ 'file' => 'assets/Welcome-versioned.js',
+ 'src' => 'resources/js/Pages/Welcome.vue',
+ 'imports' => [
+ 'resources/js/app.js',
+ ],
+ ],
+ 'resources/js/app.js' => [
+ 'file' => 'assets/app-versioned.js',
+ 'src' => 'resources/js/app.js',
+ 'css' => [
+ 'assets/app-versioned.css',
+ ],
+ ],
+ ], $buildDir);
+
+ $result = app(Vite::class)(['resources/js/app.js', 'resources/js/Pages/Welcome.vue'], $buildDir);
+
+ $this->assertSame(
+ ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+
+ $this->assertSame([
+ "https://example.com/{$buildDir}/assets/app-versioned.css" => [
+ 'rel="preload"',
+ 'as="style"',
+ ],
+ "https://example.com/{$buildDir}/assets/app-versioned.js" => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ ],
+ "https://example.com/{$buildDir}/assets/Welcome-versioned.js" => [
+ 'rel="modulepreload"',
+ 'as="script"',
+ ],
+ ], ViteFacade::preloadedAssets());
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItRetrievesAssetContent()
+ {
+ $this->makeViteManifest();
+
+ $this->makeAsset('/app.versioned.js', 'some content');
+
+ $content = ViteFacade::content('resources/js/app.js');
+
+ $this->assertSame('some content', $content);
+
+ $this->cleanAsset('/app.versioned.js');
+
+ $this->cleanViteManifest();
+ }
+
+ public function testItThrowsWhenUnableToFindFileToRetrieveContent()
+ {
+ $this->makeViteManifest();
+
+ $this->expectException(ViteException::class);
+ $this->expectExceptionMessage('Unable to locate file from Vite manifest: ' . public_path('build/assets/app.versioned.js'));
+
+ ViteFacade::content('resources/js/app.js');
+ }
+
+ public function testItCanPrefetchEntrypoint()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/prefetching-manifest.json'), true);
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+ app()->publicPath(__DIR__);
+
+ $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->prefetch(concurrency: 3)->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'fetchpriority' => 'low'],
+ ]);
+ $this->assertSame(<<
+
+ HTML, $html);
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItHandlesSpecifyingPageWithAppJs()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/prefetching-manifest.json'), true);
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+ app()->publicPath(__DIR__);
+
+ $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js', 'resources/js/Pages/Auth/Login.vue'])->useBuildDirectory($buildDir)->prefetch(concurrency: 3)->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'fetchpriority' => 'low'],
+ ]);
+ $this->assertStringContainsString(<<cleanViteManifest($buildDir);
+ }
+
+ public function testItCanSpecifyWaterfallChunks()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/prefetching-manifest.json'), true);
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+ app()->publicPath(__DIR__);
+
+ $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->prefetch(concurrency: 10)->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'fetchpriority' => 'low'],
+ ]);
+ $this->assertStringContainsString(<<cleanViteManifest($buildDir);
+ }
+
+ public function testItCanPrefetchAggressively()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/prefetching-manifest.json'), true);
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+ app()->publicPath(__DIR__);
+
+ $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->prefetch()->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'fetchpriority' => 'low'],
+ ]);
+
+ $this->assertSame(<<
+
+ HTML, $html);
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testAddsAttributesToPrefetchTags()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/prefetching-manifest.json'), true);
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+ app()->publicPath(__DIR__);
+
+ $html = (string) tap(ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->prefetch(concurrency: 3))->useCspNonce('abc123')->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ]);
+ $this->assertStringContainsString(<<cleanViteManifest($buildDir);
+ }
+
+ public function testItNormalisesAttributes()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/prefetching-manifest.json'), true);
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+ app()->publicPath(__DIR__);
+
+ $html = (string) tap(ViteFacade::withEntryPoints(['resources/js/app.js']))->useBuildDirectory($buildDir)->prefetch(concurrency: 3)->usePreloadTagAttributes([
+ 'key' => 'value',
+ 'key-only',
+ 'true-value' => true,
+ 'false-value' => false,
+ 'null-value' => null,
+ ])->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ]);
+
+ $this->assertStringContainsString(<<cleanViteManifest($buildDir);
+ }
+
+ public function testItPrefetchesCss()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/prefetching-manifest.json'), true);
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+ app()->publicPath(__DIR__);
+
+ $html = (string) ViteFacade::withEntryPoints(['resources/js/admin.js'])->useBuildDirectory($buildDir)->prefetch(concurrency: 3)->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/admin-runtime-import-CRvLQy6v.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'script', 'href' => "https://example.com/{$buildDir}/assets/admin-runtime-import-import-DKMIaPXC.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'style', 'href' => "https://example.com/{$buildDir}/assets/admin-runtime-import-BlmN0T4U.css", 'fetchpriority' => 'low'],
+ ]);
+ $this->assertSame(<<
+
+ HTML, $html);
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testSupportCspNonceInPrefetchScript()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/prefetching-manifest.json'), true);
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+ app()->publicPath(__DIR__);
+
+ $html = (string) tap(ViteFacade::withEntryPoints(['resources/js/app.js']))
+ ->useCspNonce('abc123')
+ ->useBuildDirectory($buildDir)
+ ->prefetch()
+ ->toHtml();
+ $this->assertStringContainsString('