diff --git a/docs/creating-content/managing-assets.md b/docs/creating-content/managing-assets.md index dc84cc013d5..e4db6fee431 100644 --- a/docs/creating-content/managing-assets.md +++ b/docs/creating-content/managing-assets.md @@ -18,6 +18,57 @@ Some extra component styles are organized into modular files in the HydeFront pa To get you started quickly, all the styles are already compiled and minified into `_media/app.css`, which will be copied to the `_site/media/app.css` directory when you run `php hyde build`. +## Vite + +Hyde uses [Vite](https://vitejs.dev/) to compile assets. Vite is a build tool that aims to provide a faster and more efficient development experience for modern web projects. + +### Why Vite? + +HydePHP integrates Vite to compile assets such as CSS and JavaScript files. This integration ensures that your assets are processed efficiently, enhancing the development workflow by leveraging Vite's rapid build system. + +#### Asset Management + +**Development and Production Modes** + +- **Development Mode**: Use `npm run dev` to start the Vite development HMR server, which provides fast live reloading and efficient compilation during development. +- **Production Mode**: Use `npm run build` for creating optimized, minified asset bundles ready for production deployment. + +**Asset Compilation**: + +- Assets are compiled from the `resources/assets` directory. The primary CSS file, `app.css`, is processed with TailwindCSS and other specified tools like PostCSS. +- Vite automatically processes all scripts and styles, outputting compiled files to the `_media` directory. These are copied to `_site/media` when the static site is built with `php hyde build`. + +>warn Note that the HydePHP Vite integration only supports CSS and JavaScript files, if you try to load other file types, they will not be processed by Vite. + +**Configuration**: +- You can customize Vite's behavior and output paths by modifying the pre-configured `vite.config.js` file in the project root directory. + +### Hot Module Replacement (HMR) + +Vite's HMR feature allows for instant updates to the browser without requiring a full page reload. This **only works** through the realtime compiler when the Vite development server is also running. + +You can start both of these by running `npm run dev` and `php hyde serve` in separate terminals, or using the `--vite` flag with the serve command: + +```bash +php hyde serve --vite +``` + +### Blade Integration + +Hyde effortlessly integrates Vite with Blade views, allowing you to include compiled assets in your templates. The Blade components `hyde::layouts.styles` and `hyde::layouts.scripts` are already set up to load the compiled CSS and JavaScript files. + +You can check if the Vite HMR server is running with `Vite::running()`, and you can include CSS and JavaScript resources with `Vite::asset('path')`, or `Vite::assets([])` to supply an array of paths. + +**Example: Using Vite if the HMR server is enabled, or loading the compiled CSS file if not:** + +```blade +@if(Vite::running()) + {{ Vite::assets(['resources/assets/app.css']) }} +@else + +@endif +``` + ## Additional Information and Answers to Common Questions ### Is NodeJS/NPM Required for Using Hyde? diff --git a/packages/framework/src/Console/Commands/ServeCommand.php b/packages/framework/src/Console/Commands/ServeCommand.php index c998aba44ce..b2b85548d05 100644 --- a/packages/framework/src/Console/Commands/ServeCommand.php +++ b/packages/framework/src/Console/Commands/ServeCommand.php @@ -5,6 +5,7 @@ namespace Hyde\Console\Commands; use Closure; +use Hyde\Facades\Filesystem; use Hyde\Hyde; use Hyde\Facades\Config; use Illuminate\Contracts\Process\InvokedProcess; @@ -112,7 +113,6 @@ protected function getEnvironmentVariables(): array 'HYDE_SERVER_DASHBOARD' => $this->parseEnvironmentOption('dashboard'), 'HYDE_PRETTY_URLS' => $this->parseEnvironmentOption('pretty-urls'), 'HYDE_PLAY_CDN' => $this->parseEnvironmentOption('play-cdn'), - 'HYDE_SERVER_VITE' => $this->option('vite') ? 'enabled' : null, ]); } @@ -204,6 +204,8 @@ protected function runViteProcess(): void ); } + Filesystem::touch('app/storage/framework/cache/vite.hot'); + $this->vite = Process::forever()->start('npm run dev'); } diff --git a/packages/framework/src/Facades/Vite.php b/packages/framework/src/Facades/Vite.php index d93f60f742d..814aff2b07c 100644 --- a/packages/framework/src/Facades/Vite.php +++ b/packages/framework/src/Facades/Vite.php @@ -5,37 +5,75 @@ namespace Hyde\Facades; use Illuminate\Support\HtmlString; +use InvalidArgumentException; /** * Vite facade for handling Vite-related operations. */ class Vite { + protected const CSS_EXTENSIONS = ['css', 'less', 'sass', 'scss', 'styl', 'stylus', 'pcss', 'postcss']; + protected const JS_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx']; + public static function running(): bool { - // Check if Vite was enabled via the serve command - if (env('HYDE_SERVER_VITE') === 'enabled') { - return true; - } - - // Check for Vite hot file return Filesystem::exists('app/storage/framework/cache/vite.hot'); } + public static function asset(string $path): HtmlString + { + return static::assets([$path]); + } + + /** @param array $paths */ public static function assets(array $paths): HtmlString { - $html = sprintf(''); + $html = ''; foreach ($paths as $path) { - if (str_ends_with($path, '.css')) { - $html .= sprintf('', $path); - } - - if (str_ends_with($path, '.js')) { - $html .= sprintf('', $path); - } + $html .= static::formatAssetPath($path); } return new HtmlString($html); } + + /** @throws InvalidArgumentException If the asset type is not supported. */ + protected static function formatAssetPath(string $path): string + { + if (static::isCssPath($path)) { + return static::formatStylesheetLink($path); + } + + if (static::isJsPath($path)) { + return static::formatScriptInclude($path); + } + + // We don't know how to handle other asset types, so we throw an exception to let the user know. + throw new InvalidArgumentException("Unsupported asset type for path: '$path'"); + } + + protected static function isCssPath(string $path): bool + { + return static::checkFileExtensionForPath($path, static::CSS_EXTENSIONS); + } + + protected static function isJsPath(string $path): bool + { + return static::checkFileExtensionForPath($path, static::JS_EXTENSIONS); + } + + protected static function checkFileExtensionForPath(string $path, array $extensions): bool + { + return preg_match('/\.('.implode('|', $extensions).')$/', $path) === 1; + } + + protected static function formatStylesheetLink(string $path): string + { + return sprintf('', $path); + } + + protected static function formatScriptInclude(string $path): string + { + return sprintf('', $path); + } } diff --git a/packages/framework/tests/Feature/Commands/ServeCommandTest.php b/packages/framework/tests/Feature/Commands/ServeCommandTest.php index 1f26744c908..3b34e3f4ce9 100644 --- a/packages/framework/tests/Feature/Commands/ServeCommandTest.php +++ b/packages/framework/tests/Feature/Commands/ServeCommandTest.php @@ -182,6 +182,8 @@ public function testWithFancyOutput() public function testHydeServeCommandWithViteOption() { + $this->cleanUpWhenDone('app/storage/framework/cache/vite.hot'); + $mockViteProcess = mock(InvokedProcess::class); $mockViteProcess->shouldReceive('running') ->once() @@ -202,7 +204,7 @@ public function testHydeServeCommandWithViteOption() Process::shouldReceive('env') ->once() - ->with(['HYDE_SERVER_REQUEST_OUTPUT' => false, 'HYDE_SERVER_VITE' => 'enabled']) + ->with(['HYDE_SERVER_REQUEST_OUTPUT' => false]) ->andReturnSelf(); Process::shouldReceive('start') @@ -224,10 +226,14 @@ public function testHydeServeCommandWithViteOption() ->expectsOutput('server output') ->expectsOutput('vite latest output') ->assertExitCode(0); + + $this->assertFileExists('app/storage/framework/cache/vite.hot'); } public function testHydeServeCommandWithViteOptionButViteNotRunning() { + $this->cleanUpWhenDone('app/storage/framework/cache/vite.hot'); + $mockViteProcess = mock(InvokedProcess::class); $mockViteProcess->shouldReceive('running') ->once() @@ -245,7 +251,7 @@ public function testHydeServeCommandWithViteOptionButViteNotRunning() Process::shouldReceive('env') ->once() - ->with(['HYDE_SERVER_REQUEST_OUTPUT' => false, 'HYDE_SERVER_VITE' => 'enabled']) + ->with(['HYDE_SERVER_REQUEST_OUTPUT' => false]) ->andReturnSelf(); Process::shouldReceive('start') @@ -263,6 +269,8 @@ public function testHydeServeCommandWithViteOptionButViteNotRunning() $this->artisan('serve --no-ansi --vite') ->expectsOutput('Starting the HydeRC server... Use Ctrl+C to stop') ->assertExitCode(0); + + $this->assertFileExists('app/storage/framework/cache/vite.hot'); } public function testHydeServeCommandWithViteOptionThrowsWhenPortIsInUse() diff --git a/packages/framework/tests/Unit/Facades/ViteFacadeTest.php b/packages/framework/tests/Unit/Facades/ViteFacadeTest.php index 2219d319ceb..45adda783c4 100644 --- a/packages/framework/tests/Unit/Facades/ViteFacadeTest.php +++ b/packages/framework/tests/Unit/Facades/ViteFacadeTest.php @@ -1,10 +1,14 @@ cleanUpFilesystem(); } - public function testRunningReturnsTrueWhenEnvironmentVariableIsSet() + public function testRunningReturnsTrueWhenViteHotFileExists() { - putenv('HYDE_SERVER_VITE=enabled'); + $this->file('app/storage/framework/cache/vite.hot'); $this->assertTrue(Vite::running()); + } + + public function testRunningReturnsFalseWhenViteHotFileDoesNotExist() + { + $this->assertFileDoesNotExist('app/storage/framework/cache/vite.hot'); - putenv('HYDE_SERVER_VITE'); + $this->assertFalse(Vite::running()); } - public function testRunningReturnsTrueWhenViteHotFileExists() + public function testItAlwaysImportsClientModule() { - $this->file('app/storage/framework/cache/vite.hot'); + $html = Vite::assets([]); - $this->assertTrue(Vite::running()); + $this->assertStringContainsString('', (string) $html); + + $html = Vite::assets(['foo.js']); + + $this->assertStringContainsString('', (string) $html); } - public function testRunningReturnsFalseWhenViteHotFileDoesNotExist() + public function testAssetMethodThrowsExceptionForUnknownExtensions() { - $this->assertFalse(Vite::running()); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Unsupported asset type for path: 'foo.txt'"); + + Vite::asset('foo.txt'); + } + + public function testAssetsMethodThrowsExceptionForUnknownExtensions() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Unsupported asset type for path: 'foo.txt'"); + + Vite::assets(['foo.txt']); + } + + public function testAssetsMethodReturnsHtmlString() + { + $this->assertInstanceOf(HtmlString::class, Vite::assets([])); + $this->assertInstanceOf(HtmlString::class, Vite::assets(['foo.js'])); + + $this->assertEquals(new HtmlString(''), Vite::assets([])); + $this->assertEquals(new HtmlString(''), Vite::assets(['foo.js'])); } public function testAssetsMethodGeneratesCorrectHtmlForJavaScriptFiles() { $html = Vite::assets(['resources/js/app.js']); - $expected = '' - .''; + $expected = ''; $this->assertSame($expected, (string) $html); } @@ -55,8 +87,7 @@ public function testAssetsMethodGeneratesCorrectHtmlForCssFiles() { $html = Vite::assets(['resources/css/app.css']); - $expected = '' - .''; + $expected = ''; $this->assertSame($expected, (string) $html); } @@ -69,16 +100,91 @@ public function testAssetsMethodGeneratesCorrectHtmlForMultipleFiles() 'resources/js/other.js', ]); - $expected = '' - .'' - .'' - .''; + $expected = ''; $this->assertSame($expected, (string) $html); } - public function testAssetsMethodReturnsHtmlString() + /** + * @dataProvider cssFileExtensionsProvider + */ + public function testAssetsMethodSupportsAllCssFileExtensions(string $extension) + { + $html = Vite::assets(["resources/css/app.$extension"]); + + if ($extension !== 'js') { + $expected = ''; + + $this->assertStringContainsString('stylesheet', (string) $html); + $this->assertSame($expected, (string) $html); + } else { + $this->assertStringNotContainsString('stylesheet', (string) $html); + } + } + + /** + * @dataProvider jsFileExtensionsProvider + */ + public function testAssetsMethodSupportsAllJsFileExtensions(string $extension) + { + $html = Vite::assets(["resources/js/app.$extension"]); + + if ($extension !== 'css') { + $expected = ''; + + $this->assertStringNotContainsString('stylesheet', (string) $html); + $this->assertSame($expected, (string) $html); + } else { + $this->assertStringContainsString('stylesheet', (string) $html); + } + } + + public function testAssetMethodReturnsHtmlString() + { + $this->assertInstanceOf(HtmlString::class, Vite::asset('foo.js')); + } + + public function testAssetMethodGeneratesCorrectHtmlForJavaScriptFile() + { + $html = Vite::asset('resources/js/app.js'); + + $expected = ''; + + $this->assertSame($expected, (string) $html); + } + + public function testAssetMethodGeneratesCorrectHtmlForCssFile() + { + $html = Vite::asset('resources/css/app.css'); + + $expected = ''; + + $this->assertSame($expected, (string) $html); + } + + public static function cssFileExtensionsProvider(): array + { + return [ + ['css'], + ['less'], + ['sass'], + ['scss'], + ['styl'], + ['stylus'], + ['pcss'], + ['postcss'], + ['js'], + ]; + } + + public static function jsFileExtensionsProvider(): array { - $this->assertInstanceOf(\Illuminate\Support\HtmlString::class, Vite::assets([])); + return [ + ['js'], + ['jsx'], + ['ts'], + ['tsx'], + ['css'], + ]; } } diff --git a/packages/framework/tests/Unit/ServeCommandOptionsUnitTest.php b/packages/framework/tests/Unit/ServeCommandOptionsUnitTest.php index 9df671ec114..eeaa62b5a47 100644 --- a/packages/framework/tests/Unit/ServeCommandOptionsUnitTest.php +++ b/packages/framework/tests/Unit/ServeCommandOptionsUnitTest.php @@ -335,18 +335,6 @@ public function testGetOpenCommandForUnknownOS() $this->assertNull($this->getMock()->getOpenCommand('UnknownOS')); } - public function testViteOptionPropagatesToEnvironmentVariables() - { - $command = $this->getMock(['vite' => true]); - $this->assertSame('enabled', $command->getEnvironmentVariables()['HYDE_SERVER_VITE']); - - $command = $this->getMock(['vite' => false]); - $this->assertFalse(isset($command->getEnvironmentVariables()['HYDE_SERVER_VITE'])); - - $command = $this->getMock(); - $this->assertFalse(isset($command->getEnvironmentVariables()['HYDE_SERVER_VITE'])); - } - public function testWithViteArgument() { HydeKernel::setInstance(new HydeKernel()); diff --git a/packages/realtime-compiler/src/ConsoleOutput.php b/packages/realtime-compiler/src/ConsoleOutput.php index fdb1cf0a801..0d1d6a611bf 100644 --- a/packages/realtime-compiler/src/ConsoleOutput.php +++ b/packages/realtime-compiler/src/ConsoleOutput.php @@ -5,6 +5,7 @@ namespace Hyde\RealtimeCompiler; use Closure; +use Hyde\Facades\Vite; use Hyde\Hyde; use Illuminate\Support\Str; use Illuminate\Support\Arr; @@ -35,7 +36,7 @@ public function printStartMessage(string $host, int $port, array $environment = sprintf('Listening on: %s', $url, $url), (config('hyde.server.dashboard.enabled') || Arr::has($environment, 'HYDE_SERVER_DASHBOARD')) && Arr::get($environment, 'HYDE_SERVER_DASHBOARD') === 'enabled' ? sprintf('Live dashboard: %s/dashboard', $url, $url) : null, - Arr::get($environment, 'HYDE_SERVER_VITE') === 'enabled' ? + Vite::running() ? sprintf('Vite HMR server: http://%s:5173', $host, $host) : null, '', ]);