diff --git a/src/Illuminate/Foundation/Vite.php b/src/Illuminate/Foundation/Vite.php index 44233cc4b16e..7a137e10db8a 100644 --- a/src/Illuminate/Foundation/Vite.php +++ b/src/Illuminate/Foundation/Vite.php @@ -37,6 +37,13 @@ class Vite */ protected $styleTagAttributesResolvers = []; + /** + * The cached manifest files. + * + * @var array + */ + protected static $manifests = []; + /** * Get the Content Security Policy nonce applied to all generated tags. * @@ -116,49 +123,33 @@ public function useStyleTagAttributes($attributes) */ public function __invoke($entrypoints, $buildDirectory = 'build') { - static $manifests = []; - $entrypoints = collect($entrypoints); $buildDirectory = Str::start($buildDirectory, '/'); - if (is_file(public_path('/hot'))) { - $url = rtrim(file_get_contents(public_path('/hot'))); - + if ($this->isRunningHot()) { return new HtmlString( $entrypoints ->prepend('@vite/client') - ->map(fn ($entrypoint) => $this->makeTagForChunk($entrypoint, "{$url}/{$entrypoint}", null, null)) + ->map(fn ($entrypoint) => $this->makeTagForChunk($entrypoint, $this->hotAsset($entrypoint), null, null)) ->join('') ); } - $manifestPath = public_path($buildDirectory.'/manifest.json'); - - if (! isset($manifests[$manifestPath])) { - if (! is_file($manifestPath)) { - throw new Exception("Vite manifest not found at: {$manifestPath}"); - } - - $manifests[$manifestPath] = json_decode(file_get_contents($manifestPath), true); - } - - $manifest = $manifests[$manifestPath]; + $manifest = $this->manifest($buildDirectory); $tags = collect(); foreach ($entrypoints as $entrypoint) { - if (! isset($manifest[$entrypoint])) { - throw new Exception("Unable to locate file in Vite manifest: {$entrypoint}."); - } + $chunk = $this->chunk($manifest, $entrypoint); $tags->push($this->makeTagForChunk( $entrypoint, - asset("{$buildDirectory}/{$manifest[$entrypoint]['file']}"), - $manifest[$entrypoint], + asset("{$buildDirectory}/{$chunk['file']}"), + $chunk, $manifest )); - foreach ($manifest[$entrypoint]['css'] ?? [] as $css) { + foreach ($chunk['css'] ?? [] as $css) { $partialManifest = Collection::make($manifest)->where('file', $css); $tags->push($this->makeTagForChunk( @@ -169,7 +160,7 @@ public function __invoke($entrypoints, $buildDirectory = 'build') )); } - foreach ($manifest[$entrypoint]['imports'] ?? [] as $import) { + foreach ($chunk['imports'] ?? [] as $import) { foreach ($manifest[$import]['css'] ?? [] as $css) { $partialManifest = Collection::make($manifest)->where('file', $css); @@ -378,25 +369,102 @@ protected function parseAttributes($attributes) */ public function reactRefresh() { - if (! is_file(public_path('/hot'))) { + if (! $this->isRunningHot()) { return; } - $url = rtrim(file_get_contents(public_path('/hot'))); - return new HtmlString( sprintf( <<<'HTML' HTML, - $url + $this->hotAsset('@react-refresh') ) ); } + + /** + * Get the path to a given asset when running in HMR mode. + * + * @return string + */ + protected function hotAsset($asset) + { + return rtrim(file_get_contents(public_path('/hot'))).'/'.$asset; + } + + /** + * Get the URL for an asset. + * + * @param string $asset + * @param string|null $buildDirectory + * @return string + */ + public function asset($asset, $buildDirectory = 'build') + { + if ($this->isRunningHot()) { + return $this->hotAsset($asset); + } + + $chunk = $this->chunk($this->manifest($buildDirectory), $asset); + + return asset($buildDirectory.'/'.$chunk['file']); + } + + /** + * Get the the manifest file for the given build directory. + * + * @param string $buildDirectory + * @return array + * + * @throws \Exception + */ + protected function manifest($buildDirectory) + { + $path = public_path($buildDirectory.'/manifest.json'); + + if (! isset(static::$manifests[$path])) { + if (! is_file($path)) { + throw new Exception("Vite manifest not found at: {$path}"); + } + + static::$manifests[$path] = json_decode(file_get_contents($path), true); + } + + return static::$manifests[$path]; + } + + /** + * Get the chunk for the given entry point / asset. + * + * @param array $manifest + * @param string $file + * @return array + * + * @throws \Exception + */ + protected function chunk($manifest, $file) + { + if (! isset($manifest[$file])) { + throw new Exception("Unable to locate file in Vite manifest: {$file}."); + } + + return $manifest[$file]; + } + + /** + * Determine if the HMR server is running. + * + * @return bool + */ + protected function isRunningHot() + { + return is_file(public_path('/hot')); + } } diff --git a/src/Illuminate/Support/Facades/Vite.php b/src/Illuminate/Support/Facades/Vite.php index 610dc823016d..25afa063747b 100644 --- a/src/Illuminate/Support/Facades/Vite.php +++ b/src/Illuminate/Support/Facades/Vite.php @@ -5,6 +5,7 @@ /** * @method static string useCspNonce(?string $nonce = null) * @method static string|null cspNonce() + * @method static string asset(string $asset, string|null $buildDirectory) * @method static \Illuminte\Foundation\Vite useIntegrityKey(string|false $key) * @method static \Illuminte\Foundation\Vite useScriptTagAttributes(callable|array $callback) * @method static \Illuminte\Foundation\Vite useStyleTagAttributes(callable|array $callback) diff --git a/tests/Foundation/FoundationViteTest.php b/tests/Foundation/FoundationViteTest.php index 8bfc20eb9d27..873f22954959 100644 --- a/tests/Foundation/FoundationViteTest.php +++ b/tests/Foundation/FoundationViteTest.php @@ -2,35 +2,25 @@ namespace Illuminate\Tests\Foundation; +use Exception; use Illuminate\Foundation\Vite; -use Illuminate\Routing\UrlGenerator; -use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\Vite as ViteFacade; use Illuminate\Support\Str; -use Mockery as m; -use PHPUnit\Framework\TestCase; +use Orchestra\Testbench\TestCase; class FoundationViteTest extends TestCase { protected function setUp(): void { - app()->instance('url', tap( - m::mock(UrlGenerator::class), - fn ($url) => $url - ->shouldReceive('asset') - ->andReturnUsing(fn ($value) => "https://example.com{$value}") - )); - - app()->singleton(Vite::class); - Facade::setFacadeApplication(app()); + parent::setUp(); + + app('config')->set('app.asset_url', 'https://example.com'); } protected function tearDown(): void { $this->cleanViteManifest(); $this->cleanViteHotFile(); - Facade::clearResolvedInstances(); - m::close(); } public function testViteWithJsOnly() @@ -513,6 +503,42 @@ public function testItCanOverrideAllAttributes() ); } + 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(Exception::class); + $this->expectExceptionMessage('Vite manifest not found at: '.public_path('build/manifest.json')); + + ViteFacade::asset('resources/js/app.js'); + } + + public function testItThrowsWhenUnableToFindAssetChunkInBuildMode() + { + $this->makeViteManifest(); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Unable to locate file in Vite manifest: resources/js/missing.js'); + + ViteFacade::asset('resources/js/missing.js'); + } + protected function makeViteManifest($contents = null, $path = 'build') { app()->singleton('path.public', fn () => __DIR__);