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 6bf8ccd60..b59f1f568 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(<<