From d00b9f6a11573281a1e7f69b2e08b718b9a3038b Mon Sep 17 00:00:00 2001 From: Eric Pfeiffer Date: Sat, 3 Aug 2024 21:29:37 -0500 Subject: [PATCH 1/8] Adds a `mix` Twig filter for Laravel Mix theme assets. --- modules/cms/twig/Extension.php | 2 ++ modules/system/classes/asset/Mix.php | 53 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 modules/system/classes/asset/Mix.php diff --git a/modules/cms/twig/Extension.php b/modules/cms/twig/Extension.php index 01ed17438e..20c3dc9004 100644 --- a/modules/cms/twig/Extension.php +++ b/modules/cms/twig/Extension.php @@ -3,6 +3,7 @@ use Block; use Cms\Classes\Controller; use Event; +use System\Classes\Asset\Mix; use System\Classes\Asset\Vite; use Twig\Extension\AbstractExtension as TwigExtension; use Twig\TwigFilter as TwigSimpleFilter; @@ -68,6 +69,7 @@ public function getFilters(): array return [ new TwigSimpleFilter('page', [$this, 'pageFilter'], $options), new TwigSimpleFilter('theme', [$this, 'themeFilter'], $options), + new TwigSimpleFilter('mix', [Mix::class, 'mix'], $options), ]; } diff --git a/modules/system/classes/asset/Mix.php b/modules/system/classes/asset/Mix.php new file mode 100644 index 0000000000..8c8edb170a --- /dev/null +++ b/modules/system/classes/asset/Mix.php @@ -0,0 +1,53 @@ +getPath($theme->getDirName()); + + $path = Str::start($path, '/'); + + if ($manifestDirectory && ! str_starts_with($manifestDirectory, '/')) { + $manifestDirectory = "/$manifestDirectory"; + } else { + $manifestDirectory = Str::start($theme->getConfigValue('mix_manifest_path', '/'), '/'); + } + + $manifestPath = $themePath.rtrim($manifestDirectory, '/').'/mix-manifest.json'; + + if (! isset($manifests[$manifestPath])) { + if (! is_file($manifestPath)) { + throw new \Exception('The Mix manifest does not exist.'); + } + + $manifests[$manifestPath] = json_decode(file_get_contents($manifestPath), true); + } + + $manifest = $manifests[$manifestPath]; + + if (! isset($manifest[$path])) { + $exception = new \Exception("Unable to locate Mix file: $path"); + + if (! app('config')->get('app.debug')) { + report($exception); + + return $path; + } else { + throw $exception; + } + } + + return new HtmlString($theme->assetUrl(ltrim($manifest[$path], '/'))); + } +} From 4f89e7982afdd995cb6c5de3d4d150bdf724ff36 Mon Sep 17 00:00:00 2001 From: Eric Pfeiffer Date: Sun, 18 Aug 2024 18:25:04 -0500 Subject: [PATCH 2/8] Started adding working tests for `Mix` helper class. --- modules/system/classes/asset/Mix.php | 4 +- .../system/tests/classes/asset/MixTest.php | 94 +++++++++++++++++++ .../tests/fixtures/npm/package-mixtest.json | 11 +++ .../themes/mixtest/assets/src/css/theme.css | 3 + .../themes/mixtest/assets/src/js/theme.js | 1 + .../fixtures/themes/mixtest/winter.mix.js | 13 +++ 6 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 modules/system/tests/classes/asset/MixTest.php create mode 100644 modules/system/tests/fixtures/npm/package-mixtest.json create mode 100644 modules/system/tests/fixtures/themes/mixtest/assets/src/css/theme.css create mode 100644 modules/system/tests/fixtures/themes/mixtest/assets/src/js/theme.js create mode 100644 modules/system/tests/fixtures/themes/mixtest/winter.mix.js diff --git a/modules/system/classes/asset/Mix.php b/modules/system/classes/asset/Mix.php index 8c8edb170a..d1538cfcec 100644 --- a/modules/system/classes/asset/Mix.php +++ b/modules/system/classes/asset/Mix.php @@ -14,8 +14,6 @@ public static function mix(string $path, string $manifestDirectory = ''): HtmlSt $theme = Theme::getActiveTheme(); - $themePath = $theme->getPath($theme->getDirName()); - $path = Str::start($path, '/'); if ($manifestDirectory && ! str_starts_with($manifestDirectory, '/')) { @@ -24,7 +22,7 @@ public static function mix(string $path, string $manifestDirectory = ''): HtmlSt $manifestDirectory = Str::start($theme->getConfigValue('mix_manifest_path', '/'), '/'); } - $manifestPath = $themePath.rtrim($manifestDirectory, '/').'/mix-manifest.json'; + $manifestPath = $theme->getPath($theme->getDirName() . '/' . $manifestDirectory . '/mix-manifest.json'); if (! isset($manifests[$manifestPath])) { if (! is_file($manifestPath)) { diff --git a/modules/system/tests/classes/asset/MixTest.php b/modules/system/tests/classes/asset/MixTest.php new file mode 100644 index 0000000000..cfbe5e9913 --- /dev/null +++ b/modules/system/tests/classes/asset/MixTest.php @@ -0,0 +1,94 @@ +markTestSkipped('This test requires node_modules to be installed'); + } + + if (!is_file(base_path('node_modules/.bin/mix'))) { + $this->markTestSkipped('This test requires the mix package to be installed'); + } + + $this->originalThemesPath = Config::get('cms.themesPath'); + Config::set('cms.themesPath', '/modules/system/tests/fixtures/themes'); + + $this->originalThemesPathLocal = Config::get('cms.themesPathLocal'); + Config::set('cms.themesPathLocal', base_path('modules/system/tests/fixtures/themes')); + $this->app->setThemesPath(Config::get('cms.themesPathLocal')); + + $this->themePath = base_path('modules/system/tests/fixtures/themes/mixtest'); + + Config::set('cms.activeTheme', 'mixtest'); + + Event::flush('cms.theme.getActiveTheme'); + Theme::resetCache(); + } + + protected function tearDown(): void + { + File::deleteDirectory('modules/system/tests/fixtures/themes/mixtest/assets/dist'); + File::delete('modules/system/tests/fixtures/themes/mixtest/mix-manifest.json'); + + Config::set('cms.themesPath', $this->originalThemesPath); + + Config::set('cms.themesPathLocal', $this->originalThemesPathLocal); + $this->app->setThemesPath($this->originalThemesPathLocal); + + parent::tearDown(); + } + + public function testGeneratesVersionedUrl(): void + { + $this->artisan('mix:compile', [ + 'theme-mixtest', + '--manifest' => 'modules/system/tests/fixtures/npm/package-mixtest.json', + '--disable-tty' => true, + ])->assertExitCode(0); + + $theme = Theme::getActiveTheme(); + + $this->assertFileExists($theme->getPath($theme->getDirName() . '/mix-manifest.json')); + + $manifest = json_decode(file_get_contents($theme->getPath($theme->getDirName() . '/mix-manifest.json')), true); + + foreach ($manifest as $key => $value) { + $mixAssetUrl = Mix::mix($key); + + $mixQueryParams = parse_url($mixAssetUrl, PHP_URL_QUERY); + parse_str($mixQueryParams, $mixResult); + + $manifestQueryParams = parse_url($value, PHP_URL_QUERY); + parse_str($manifestQueryParams, $manifestResult); + + $this->assertArrayHasKey('id', $mixResult); + $this->assertEquals($manifestResult['id'], $mixResult['id']); + + $this->assertNotFalse(filter_var($mixAssetUrl, FILTER_VALIDATE_URL), 'Mix asset URL did not return a valid URL.'); + } + } + + public function testThemeCanOverrideMixManifestPath(): void + { + + } +} diff --git a/modules/system/tests/fixtures/npm/package-mixtest.json b/modules/system/tests/fixtures/npm/package-mixtest.json new file mode 100644 index 0000000000..e38736ed5c --- /dev/null +++ b/modules/system/tests/fixtures/npm/package-mixtest.json @@ -0,0 +1,11 @@ +{ + "type": "module", + "workspaces": { + "packages": [ + "modules/system/tests/fixtures/themes/mixtest" + ] + }, + "devDependencies": { + "laravel-mix": "^6.0.41" + } +} diff --git a/modules/system/tests/fixtures/themes/mixtest/assets/src/css/theme.css b/modules/system/tests/fixtures/themes/mixtest/assets/src/css/theme.css new file mode 100644 index 0000000000..d224431f16 --- /dev/null +++ b/modules/system/tests/fixtures/themes/mixtest/assets/src/css/theme.css @@ -0,0 +1,3 @@ +h1 { + color: red; +} diff --git a/modules/system/tests/fixtures/themes/mixtest/assets/src/js/theme.js b/modules/system/tests/fixtures/themes/mixtest/assets/src/js/theme.js new file mode 100644 index 0000000000..ae17ea5dbd --- /dev/null +++ b/modules/system/tests/fixtures/themes/mixtest/assets/src/js/theme.js @@ -0,0 +1 @@ +window.alert(`hello world`); diff --git a/modules/system/tests/fixtures/themes/mixtest/winter.mix.js b/modules/system/tests/fixtures/themes/mixtest/winter.mix.js new file mode 100644 index 0000000000..d9ed8cb36a --- /dev/null +++ b/modules/system/tests/fixtures/themes/mixtest/winter.mix.js @@ -0,0 +1,13 @@ +const mix = require("laravel-mix"); + +mix.setPublicPath(__dirname) + .options({ + manifest: true, + }) + .version() + + // Render Tailwind style + .postCss("assets/src/css/theme.css", "assets/dist/css/theme.css") + + // Compile JS + .js("assets/src/js/theme.js", "assets/dist/js/theme.js"); From 24caf18712462bd76fb24bbb66de05a5fcb0e537 Mon Sep 17 00:00:00 2001 From: Eric Pfeiffer Date: Wed, 21 Aug 2024 20:15:51 -0500 Subject: [PATCH 3/8] Generate asset URLs directly rather than relying on the Theme's `assetUrl()` function. --- modules/system/classes/asset/Mix.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/system/classes/asset/Mix.php b/modules/system/classes/asset/Mix.php index d1538cfcec..39573aae20 100644 --- a/modules/system/classes/asset/Mix.php +++ b/modules/system/classes/asset/Mix.php @@ -4,6 +4,8 @@ use Cms\Classes\Theme; use Illuminate\Support\HtmlString; +use Winter\Storm\Support\Facades\Config; +use Winter\Storm\Support\Facades\Url; use Winter\Storm\Support\Str; class Mix @@ -46,6 +48,8 @@ public static function mix(string $path, string $manifestDirectory = ''): HtmlSt } } - return new HtmlString($theme->assetUrl(ltrim($manifest[$path], '/'))); + $url = Config::get('cms.themesPath', '/themes') . '/' . $theme->getDirName() . $manifest[$path]; + + return new HtmlString(Url::asset($url)); } } From 5692e7134647724f1bf3cfc3d96cbbee2c8b9ef1 Mon Sep 17 00:00:00 2001 From: Eric Pfeiffer Date: Wed, 21 Aug 2024 20:18:16 -0500 Subject: [PATCH 4/8] Finish creating Mix class tests. Also fixes code formatting errors. --- .../system/tests/classes/asset/MixTest.php | 54 +++++++++++++++---- .../themes/mixtest/assets/src/js/theme.js | 2 +- .../tests/fixtures/themes/mixtest/theme.yaml | 1 + .../mixtest/winter.mix-manifest-override.js | 13 +++++ .../fixtures/themes/mixtest/winter.mix.js | 6 +-- 5 files changed, 61 insertions(+), 15 deletions(-) create mode 100644 modules/system/tests/fixtures/themes/mixtest/theme.yaml create mode 100644 modules/system/tests/fixtures/themes/mixtest/winter.mix-manifest-override.js diff --git a/modules/system/tests/classes/asset/MixTest.php b/modules/system/tests/classes/asset/MixTest.php index cfbe5e9913..5c0ea1ef12 100644 --- a/modules/system/tests/classes/asset/MixTest.php +++ b/modules/system/tests/classes/asset/MixTest.php @@ -8,6 +8,7 @@ use Winter\Storm\Support\Facades\Config; use Winter\Storm\Support\Facades\Event; use Winter\Storm\Support\Facades\File; +use Winter\Storm\Support\Facades\Url; class MixTest extends TestCase { @@ -57,7 +58,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testGeneratesVersionedUrl(): void + public function testGeneratesAssetUrls(): void { $this->artisan('mix:compile', [ 'theme-mixtest', @@ -74,21 +75,52 @@ public function testGeneratesVersionedUrl(): void foreach ($manifest as $key => $value) { $mixAssetUrl = Mix::mix($key); - $mixQueryParams = parse_url($mixAssetUrl, PHP_URL_QUERY); - parse_str($mixQueryParams, $mixResult); - - $manifestQueryParams = parse_url($value, PHP_URL_QUERY); - parse_str($manifestQueryParams, $manifestResult); - - $this->assertArrayHasKey('id', $mixResult); - $this->assertEquals($manifestResult['id'], $mixResult['id']); - - $this->assertNotFalse(filter_var($mixAssetUrl, FILTER_VALIDATE_URL), 'Mix asset URL did not return a valid URL.'); + $this->assertStringStartsWith( + Url::asset(Config::get('cms.themesPath', '/themes') . '/' . $theme->getDirName()), + $mixAssetUrl + ); } } public function testThemeCanOverrideMixManifestPath(): void { + $theme = Theme::getActiveTheme(); + Event::listen('cms.theme.extendConfig', function ($dirName, &$config) { + $config['mix_manifest_path'] = 'assets/dist'; + }); + + rename( + $theme->getPath($theme->getDirName() . '/winter.mix.js'), + $theme->getPath($theme->getDirName() . '/winter.mix.js.bak') + ); + + copy( + $theme->getPath($theme->getDirName() . '/winter.mix-manifest-override.js'), + $theme->getPath($theme->getDirName() . '/winter.mix.js') + ); + + try { + $this->artisan('mix:compile', [ + 'theme-mixtest', + '--manifest' => 'modules/system/tests/fixtures/npm/package-mixtest.json', + '--disable-tty' => true, + ])->assertExitCode(0); + + $this->assertFileExists($theme->getPath($theme->getDirName() . '/assets/dist/mix-manifest.json')); + + $manifest = json_decode(file_get_contents($theme->getPath($theme->getDirName() . '/assets/dist/mix-manifest.json')), true); + + foreach ($manifest as $key => $value) { + $this->assertStringContainsString($key, (string) Mix::mix($key)); + } + } catch (\Exception $e) { + throw $e; + } finally { + rename( + $theme->getPath($theme->getDirName() . '/winter.mix.js.bak'), + $theme->getPath($theme->getDirName() . '/winter.mix.js') + ); + } } } diff --git a/modules/system/tests/fixtures/themes/mixtest/assets/src/js/theme.js b/modules/system/tests/fixtures/themes/mixtest/assets/src/js/theme.js index ae17ea5dbd..55fb7c1a0a 100644 --- a/modules/system/tests/fixtures/themes/mixtest/assets/src/js/theme.js +++ b/modules/system/tests/fixtures/themes/mixtest/assets/src/js/theme.js @@ -1 +1 @@ -window.alert(`hello world`); +window.alert('hello world'); diff --git a/modules/system/tests/fixtures/themes/mixtest/theme.yaml b/modules/system/tests/fixtures/themes/mixtest/theme.yaml new file mode 100644 index 0000000000..cfd8ec5e95 --- /dev/null +++ b/modules/system/tests/fixtures/themes/mixtest/theme.yaml @@ -0,0 +1 @@ +name: 'Mix Test' diff --git a/modules/system/tests/fixtures/themes/mixtest/winter.mix-manifest-override.js b/modules/system/tests/fixtures/themes/mixtest/winter.mix-manifest-override.js new file mode 100644 index 0000000000..89bb436123 --- /dev/null +++ b/modules/system/tests/fixtures/themes/mixtest/winter.mix-manifest-override.js @@ -0,0 +1,13 @@ +const mix = require('laravel-mix'); + +mix.setPublicPath(__dirname) + .options({ + manifest: 'assets/dist/mix-manifest.json', + }) + .version() + + // Render Tailwind style + .postCss('assets/src/css/theme.css', 'assets/dist/css/theme.css') + + // Compile JS + .js('assets/src/js/theme.js', 'assets/dist/js/theme.js'); diff --git a/modules/system/tests/fixtures/themes/mixtest/winter.mix.js b/modules/system/tests/fixtures/themes/mixtest/winter.mix.js index d9ed8cb36a..7affd67545 100644 --- a/modules/system/tests/fixtures/themes/mixtest/winter.mix.js +++ b/modules/system/tests/fixtures/themes/mixtest/winter.mix.js @@ -1,4 +1,4 @@ -const mix = require("laravel-mix"); +const mix = require('laravel-mix'); mix.setPublicPath(__dirname) .options({ @@ -7,7 +7,7 @@ mix.setPublicPath(__dirname) .version() // Render Tailwind style - .postCss("assets/src/css/theme.css", "assets/dist/css/theme.css") + .postCss('assets/src/css/theme.css', 'assets/dist/css/theme.css') // Compile JS - .js("assets/src/js/theme.js", "assets/dist/js/theme.js"); + .js('assets/src/js/theme.js', 'assets/dist/js/theme.js'); From ead018903811045a739c934b35ff8aebd5eca900 Mon Sep 17 00:00:00 2001 From: Eric Pfeiffer Date: Wed, 21 Aug 2024 21:11:25 -0500 Subject: [PATCH 5/8] Add test for Laravel Mix Twig filter. --- .../tests/fixtures/npm/package-mixtheme.json | 11 +++ .../themes/mixtest/assets/src/css/theme.css | 3 + .../fixtures/themes/mixtest/winter.mix.js | 10 +++ modules/cms/tests/twig/MixFilterTest.php | 73 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 modules/cms/tests/fixtures/npm/package-mixtheme.json create mode 100644 modules/cms/tests/fixtures/themes/mixtest/assets/src/css/theme.css create mode 100644 modules/cms/tests/fixtures/themes/mixtest/winter.mix.js create mode 100644 modules/cms/tests/twig/MixFilterTest.php diff --git a/modules/cms/tests/fixtures/npm/package-mixtheme.json b/modules/cms/tests/fixtures/npm/package-mixtheme.json new file mode 100644 index 0000000000..0a1353fc94 --- /dev/null +++ b/modules/cms/tests/fixtures/npm/package-mixtheme.json @@ -0,0 +1,11 @@ +{ + "type": "module", + "workspaces": { + "packages": [ + "modules/cms/tests/fixtures/themes/mixtest" + ] + }, + "devDependencies": { + "laravel-mix": "^6.0.41" + } +} diff --git a/modules/cms/tests/fixtures/themes/mixtest/assets/src/css/theme.css b/modules/cms/tests/fixtures/themes/mixtest/assets/src/css/theme.css new file mode 100644 index 0000000000..d224431f16 --- /dev/null +++ b/modules/cms/tests/fixtures/themes/mixtest/assets/src/css/theme.css @@ -0,0 +1,3 @@ +h1 { + color: red; +} diff --git a/modules/cms/tests/fixtures/themes/mixtest/winter.mix.js b/modules/cms/tests/fixtures/themes/mixtest/winter.mix.js new file mode 100644 index 0000000000..280c474e4b --- /dev/null +++ b/modules/cms/tests/fixtures/themes/mixtest/winter.mix.js @@ -0,0 +1,10 @@ +const mix = require('laravel-mix'); + +mix.setPublicPath(__dirname) + .options({ + manifest: true, + }) + .version() + + // Render Tailwind style + .postCss('assets/src/css/theme.css', 'assets/dist/css/theme.css') diff --git a/modules/cms/tests/twig/MixFilterTest.php b/modules/cms/tests/twig/MixFilterTest.php new file mode 100644 index 0000000000..918c6df6fa --- /dev/null +++ b/modules/cms/tests/twig/MixFilterTest.php @@ -0,0 +1,73 @@ +markTestSkipped('This test requires node_modules to be installed'); + } + + if (!is_file(base_path('node_modules/.bin/mix'))) { + $this->markTestSkipped('This test requires the mix package to be installed'); + } + + $this->originalThemesPath = Config::get('cms.themesPath'); + Config::set('cms.themesPath', '/modules/cms/tests/fixtures/themes'); + + $this->themePath = base_path('modules/cms/tests/fixtures/themes/mixtest'); + + Config::set('cms.activeTheme', 'mixtest'); + + Event::flush('cms.theme.getActiveTheme'); + Theme::resetCache(); + } + + protected function tearDown(): void + { + File::deleteDirectory('modules/cms/tests/fixtures/themes/mixtest/assets/dist'); + File::delete('modules/cms/tests/fixtures/themes/mixtest/mix-manifest.json'); + + Config::set('cms.themesPath', $this->originalThemesPath); + + parent::tearDown(); + } + + public function testGeneratesAssetUrl(): void + { + $theme = Theme::getActiveTheme(); + + $this->artisan('mix:compile', [ + 'theme-mixtest', + '--manifest' => 'modules/cms/tests/fixtures/npm/package-mixtheme.json', + '--disable-tty' => true, + ])->assertExitCode(0); + + $this->assertFileExists($theme->getPath($theme->getDirName() . '/mix-manifest.json')); + + $controller = Controller::getController() ?: new Controller(); + + $extension = new Extension(); + $extension->setController($controller); + + $this->app->make('twig.environment') + ->addExtension($extension); + + $contents = Twig::parse("{{ 'assets/dist/css/theme.css' | mix }}"); + + $this->assertStringContainsString('/assets/dist/css/theme.css?id=', $contents); + } +} From 5fb257f71e7d4e5626644c89cdd62d413a23f07a Mon Sep 17 00:00:00 2001 From: Eric Pfeiffer Date: Wed, 21 Aug 2024 21:28:08 -0500 Subject: [PATCH 6/8] Add additional tests for Mix class. --- .../system/tests/classes/asset/MixTest.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/modules/system/tests/classes/asset/MixTest.php b/modules/system/tests/classes/asset/MixTest.php index 5c0ea1ef12..9b88038388 100644 --- a/modules/system/tests/classes/asset/MixTest.php +++ b/modules/system/tests/classes/asset/MixTest.php @@ -58,6 +58,14 @@ protected function tearDown(): void parent::tearDown(); } + public function testThrowsExceptionWhenMixManifestIsMissing(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('The Mix manifest does not exist'); + + Mix::mix('assets/dist/foo.css'); + } + public function testGeneratesAssetUrls(): void { $this->artisan('mix:compile', [ @@ -123,4 +131,31 @@ public function testThemeCanOverrideMixManifestPath(): void ); } } + + public function testThrowsAnExceptionForInvalidMixFileWhenDebugIsEnabled() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Unable to locate Mix file: /assets/dist/foo.css'); + + $this->artisan('mix:compile', [ + 'theme-mixtest', + '--manifest' => 'modules/system/tests/fixtures/npm/package-mixtest.json', + '--disable-tty' => true, + ])->assertExitCode(0); + + Mix::mix('assets/dist/foo.css'); + } + + public function testDoesNotThrowAnExceptionForInvalidMixFileWhenDebugIsDisabled(): void + { + Config::set('app.debug', false); + + $this->artisan('mix:compile', [ + 'theme-mixtest', + '--manifest' => 'modules/system/tests/fixtures/npm/package-mixtest.json', + '--disable-tty' => true, + ])->assertExitCode(0); + + $this->assertEquals('/assets/dist/foo.css', Mix::mix('assets/dist/foo.css')); + } } From 3100bbcffb02c874df38f59ca390f5a37c4a1808 Mon Sep 17 00:00:00 2001 From: Eric Pfeiffer Date: Thu, 12 Sep 2024 20:35:07 -0500 Subject: [PATCH 7/8] Fix code styling. --- modules/system/classes/asset/Mix.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/modules/system/classes/asset/Mix.php b/modules/system/classes/asset/Mix.php index 39573aae20..d79884b213 100644 --- a/modules/system/classes/asset/Mix.php +++ b/modules/system/classes/asset/Mix.php @@ -3,6 +3,7 @@ namespace System\Classes\Asset; use Cms\Classes\Theme; +use Exception; use Illuminate\Support\HtmlString; use Winter\Storm\Support\Facades\Config; use Winter\Storm\Support\Facades\Url; @@ -18,7 +19,7 @@ public static function mix(string $path, string $manifestDirectory = ''): HtmlSt $path = Str::start($path, '/'); - if ($manifestDirectory && ! str_starts_with($manifestDirectory, '/')) { + if ($manifestDirectory && !str_starts_with($manifestDirectory, '/')) { $manifestDirectory = "/$manifestDirectory"; } else { $manifestDirectory = Str::start($theme->getConfigValue('mix_manifest_path', '/'), '/'); @@ -26,9 +27,9 @@ public static function mix(string $path, string $manifestDirectory = ''): HtmlSt $manifestPath = $theme->getPath($theme->getDirName() . '/' . $manifestDirectory . '/mix-manifest.json'); - if (! isset($manifests[$manifestPath])) { - if (! is_file($manifestPath)) { - throw new \Exception('The Mix manifest does not exist.'); + if (!isset($manifests[$manifestPath])) { + if (!is_file($manifestPath)) { + throw new Exception('The Mix manifest does not exist.'); } $manifests[$manifestPath] = json_decode(file_get_contents($manifestPath), true); @@ -36,10 +37,10 @@ public static function mix(string $path, string $manifestDirectory = ''): HtmlSt $manifest = $manifests[$manifestPath]; - if (! isset($manifest[$path])) { - $exception = new \Exception("Unable to locate Mix file: $path"); + if (!isset($manifest[$path])) { + $exception = new Exception("Unable to locate Mix file: $path"); - if (! app('config')->get('app.debug')) { + if (!app('config')->get('app.debug')) { report($exception); return $path; From 54137f60d4b9e7c6c3f0825898886880c95b5b9c Mon Sep 17 00:00:00 2001 From: Eric Pfeiffer Date: Mon, 17 Feb 2025 16:39:59 -0600 Subject: [PATCH 8/8] Update Laravel Mix to have similar functionality as Vite. --- ...{MixFilterTest.php => MixFunctionTest.php} | 17 +- modules/cms/twig/Extension.php | 7 +- modules/system/classes/asset/Mix.php | 232 ++++++++++++++++-- .../system/tests/classes/asset/MixTest.php | 91 +++---- 4 files changed, 273 insertions(+), 74 deletions(-) rename modules/cms/tests/twig/{MixFilterTest.php => MixFunctionTest.php} (75%) diff --git a/modules/cms/tests/twig/MixFilterTest.php b/modules/cms/tests/twig/MixFunctionTest.php similarity index 75% rename from modules/cms/tests/twig/MixFilterTest.php rename to modules/cms/tests/twig/MixFunctionTest.php index 918c6df6fa..9108b8d89c 100644 --- a/modules/cms/tests/twig/MixFilterTest.php +++ b/modules/cms/tests/twig/MixFunctionTest.php @@ -11,8 +11,10 @@ use Winter\Storm\Support\Facades\File; use Winter\Storm\Support\Facades\Twig; -class MixFilterTest extends TestCase +class MixFunctionTest extends TestCase { + protected string $themeName = 'mixtest'; + protected function setUp(): void { parent::setUp(); @@ -28,9 +30,9 @@ protected function setUp(): void $this->originalThemesPath = Config::get('cms.themesPath'); Config::set('cms.themesPath', '/modules/cms/tests/fixtures/themes'); - $this->themePath = base_path('modules/cms/tests/fixtures/themes/mixtest'); + $this->themePath = base_path("modules/cms/tests/fixtures/themes/$this->themeName"); - Config::set('cms.activeTheme', 'mixtest'); + Config::set('cms.activeTheme', $this->themeName); Event::flush('cms.theme.getActiveTheme'); Theme::resetCache(); @@ -38,8 +40,8 @@ protected function setUp(): void protected function tearDown(): void { - File::deleteDirectory('modules/cms/tests/fixtures/themes/mixtest/assets/dist'); - File::delete('modules/cms/tests/fixtures/themes/mixtest/mix-manifest.json'); + File::deleteDirectory("modules/cms/tests/fixtures/themes/$this->themeName/assets/dist"); + File::delete("modules/cms/tests/fixtures/themes/$this->themeName/mix-manifest.json"); Config::set('cms.themesPath', $this->originalThemesPath); @@ -49,9 +51,10 @@ protected function tearDown(): void public function testGeneratesAssetUrl(): void { $theme = Theme::getActiveTheme(); + $packageName = "theme-$this->themeName"; $this->artisan('mix:compile', [ - 'theme-mixtest', + $packageName, '--manifest' => 'modules/cms/tests/fixtures/npm/package-mixtheme.json', '--disable-tty' => true, ])->assertExitCode(0); @@ -66,7 +69,7 @@ public function testGeneratesAssetUrl(): void $this->app->make('twig.environment') ->addExtension($extension); - $contents = Twig::parse("{{ 'assets/dist/css/theme.css' | mix }}"); + $contents = Twig::parse("{{ mix(['assets/dist/css/theme.css'], '$packageName') }}"); $this->assertStringContainsString('/assets/dist/css/theme.css?id=', $contents); } diff --git a/modules/cms/twig/Extension.php b/modules/cms/twig/Extension.php index 20c3dc9004..0ad3c5fd45 100644 --- a/modules/cms/twig/Extension.php +++ b/modules/cms/twig/Extension.php @@ -54,6 +54,7 @@ public function getFunctions(): array new TwigSimpleFunction('component', [$this, 'componentFunction'], $options), new TwigSimpleFunction('placeholder', [$this, 'placeholderFunction'], ['is_safe' => ['html']]), new TwigSimpleFunction('vite', [$this, 'viteFunction'], $options), + new TwigSimpleFunction('mix', [$this, 'mixFunction'], $options), ]; } @@ -69,7 +70,6 @@ public function getFilters(): array return [ new TwigSimpleFilter('page', [$this, 'pageFilter'], $options), new TwigSimpleFilter('theme', [$this, 'themeFilter'], $options), - new TwigSimpleFilter('mix', [Mix::class, 'mix'], $options), ]; } @@ -178,6 +178,11 @@ public function viteFunction(array $entrypoints, string $package): \Illuminate\S return Vite::tags($entrypoints, $package); } + public function mixFunction(array|string $paths, string $package, ?string $manifestPath = null): \Illuminate\Support\HtmlString|string + { + return Mix::tags($paths, $package, $manifestPath); + } + /** * Opens a layout block. */ diff --git a/modules/system/classes/asset/Mix.php b/modules/system/classes/asset/Mix.php index d79884b213..62c22c2cb8 100644 --- a/modules/system/classes/asset/Mix.php +++ b/modules/system/classes/asset/Mix.php @@ -2,55 +2,239 @@ namespace System\Classes\Asset; -use Cms\Classes\Theme; use Exception; +use Illuminate\Support\Facades\App; use Illuminate\Support\HtmlString; -use Winter\Storm\Support\Facades\Config; +use InvalidArgumentException; +use Winter\Storm\Exception\SystemException; +use Winter\Storm\Support\Collection; use Winter\Storm\Support\Facades\Url; use Winter\Storm\Support\Str; class Mix { - public static function mix(string $path, string $manifestDirectory = ''): HtmlString|string + /** + * The preloaded assets. + * + * @var array + */ + protected array $preloadedAssets = []; + + /** + * The cached manifest files. + * + * @var array + */ + protected static array $manifests = []; + + /** + * Get the preloaded assets. + * + * @return array + */ + public function preloadedAssets(): array + { + return $this->preloadedAssets; + } + + /** + * Generate Mix tags for an entrypoint(s). + * + * @param array|string $entrypoints The list of entry points for Mix + * @param string|null $package The package name of the plugin or theme + * @param string|null $manifestPath + * @return HtmlString|string + * @throws SystemException + */ + public function __invoke(array|string $entrypoints, ?string $package = null, ?string $manifestPath = null): HtmlString|string { - static $manifests = []; + if (!$package) { + throw new InvalidArgumentException('A package must be passed'); + } - $theme = Theme::getActiveTheme(); + // Normalise the package name + $package = strtolower($package); - $path = Str::start($path, '/'); + $manifestPath ??= 'mix-manifest.json'; - if ($manifestDirectory && !str_starts_with($manifestDirectory, '/')) { - $manifestDirectory = "/$manifestDirectory"; - } else { - $manifestDirectory = Str::start($theme->getConfigValue('mix_manifest_path', '/'), '/'); + if (!($compilableAssetPackage = PackageManager::instance()->getPackages('mix')[$package] ?? null)) { + throw new SystemException('Unable to resolve package: ' . $package); } - $manifestPath = $theme->getPath($theme->getDirName() . '/' . $manifestDirectory . '/mix-manifest.json'); + $manifestPath = public_path($compilableAssetPackage['path'] . Str::start($manifestPath, '/')); - if (!isset($manifests[$manifestPath])) { + if (!isset(static::$manifests[$manifestPath])) { if (!is_file($manifestPath)) { - throw new Exception('The Mix manifest does not exist.'); + throw new Exception("The Mix manifest does not exist."); } - $manifests[$manifestPath] = json_decode(file_get_contents($manifestPath), true); + static::$manifests[$manifestPath] = json_decode(file_get_contents($manifestPath), true); } - $manifest = $manifests[$manifestPath]; + $manifest = static::$manifests[$manifestPath]; - if (!isset($manifest[$path])) { - $exception = new Exception("Unable to locate Mix file: $path"); + $entrypoints = collect($entrypoints) + ->map(fn($path) => Str::start($path, '/')); - if (!app('config')->get('app.debug')) { - report($exception); + $tags = collect(); + $preloads = collect(); - return $path; - } else { - throw $exception; + foreach ($entrypoints as $entrypoint) { + if (!isset($manifest[$entrypoint])) { + throw new Exception("Unable to locate file in Mix manifest: $entrypoint"); } + + $preloads->push([ + $entrypoint, + Url::asset($compilableAssetPackage['path'] . $manifest[$entrypoint]), + ]); + + $tags->push($this->makeTagForEntrypoint($entrypoint, Url::asset($compilableAssetPackage['path'] . $manifest[$entrypoint]))); + } + + [$stylesheets, $scripts] = $tags->unique()->partition(fn($tag) => str_starts_with($tag, 'unique() + ->sortByDesc(fn($args) => $this->isCssPath($args[0])) + ->map(fn($args) => $this->makePreloadTagForEntrypoint(...$args)); + + return new HtmlString($preloads->join('') . $stylesheets->join('') . $scripts->join('')); + } + + /** + * Helper method to generate Mix tags for an entrypoint(s). + * + * @param array|string $entrypoints The list of entry points for Mix + * @param string $package The package name of the plugin or theme + * @param string|null $manifestPath The relative path to the mix-manifest.json file from the package path + * @return HtmlString|string + * + * @throws SystemException + */ + public static function tags(array|string $entrypoints, string $package, ?string $manifestPath = null): HtmlString|string + { + return App::make(static::class)($entrypoints, $package, $manifestPath); + } + + /** + * Make a preload tag for the given entrypoint. + * + * @param $src + * @param $url + * @return string + */ + protected function makePreloadTagForEntrypoint($src, $url): string + { + $attributes = $this->resolvePreloadTagAttributes($src, $url); + + $this->preloadedAssets[$url] = $this->parseAttributes( + Collection::make($attributes)->forget('href')->all() + ); + + return 'parseAttributes($attributes)) . ' />'; + } + + /** + * Make tag for the given entrypoint. + * + * @param $src + * @param $url + * @return string + */ + protected function makeTagForEntrypoint($src, $url): string + { + return $this->makeTag($src, $url); + } + + /** + * Generate an appropriate tag for the given URL. + * + * @param string $src + * @param string $url + * @return string + */ + protected function makeTag(string $src, string $url): string + { + if ($this->isCssPath($src)) { + return $this->makeStylesheetTagWithAttributes($url, []); } - $url = Config::get('cms.themesPath', '/themes') . '/' . $theme->getDirName() . $manifest[$path]; + return $this->makeScriptTagWithAttributes($url, []); + } - return new HtmlString(Url::asset($url)); + /** + * Generate a link tag with attributes for the given URL. + * + * @param string $url + * @param array $attributes + * @return string + */ + protected function makeStylesheetTagWithAttributes(string $url, array $attributes): string + { + $attributes = $this->parseAttributes(array_merge([ + 'rel' => 'stylesheet', + 'href' => $url, + ], $attributes)); + + return ''; + } + + /** + * Generate a script tag with attributes for the given URL. + * + * @param string $url + * @param array $attributes + * @return string + */ + protected function makeScriptTagWithAttributes(string $url, array $attributes): string + { + $attributes = $this->parseAttributes(array_merge([ + 'src' => $url, + ], $attributes)); + + return ''; + } + + /** + * Determines whether the given path is a CSS file. + * + * @param string $path + * @return bool + */ + protected function isCssPath(string $path): bool + { + return Str::endsWith($path, '.css'); + } + + /** + * Resolve the attributes for the entrypoints generated preload tag. + * + * @param string $src + * @param string $url + * @return array + */ + protected function resolvePreloadTagAttributes(string $src, string $url): array + { + return [ + 'rel' => 'preload', + 'as' => $this->isCssPath($src) ? 'style' : 'script', + 'href' => $url, + ]; + } + + /** + * Parse the attributes into key="value" strings. + * + * @param array $attributes + * @return array + */ + protected function parseAttributes(array $attributes): array + { + return Collection::make($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(); } } diff --git a/modules/system/tests/classes/asset/MixTest.php b/modules/system/tests/classes/asset/MixTest.php index 9b88038388..e174f1a540 100644 --- a/modules/system/tests/classes/asset/MixTest.php +++ b/modules/system/tests/classes/asset/MixTest.php @@ -3,7 +3,9 @@ namespace System\Tests\Classes\Asset; use Cms\Classes\Theme; +use Exception; use System\Classes\Asset\Mix; +use System\Classes\Asset\PackageManager; use System\Tests\Bootstrap\TestCase; use Winter\Storm\Support\Facades\Config; use Winter\Storm\Support\Facades\Event; @@ -60,13 +62,13 @@ protected function tearDown(): void public function testThrowsExceptionWhenMixManifestIsMissing(): void { - $this->expectException(\Exception::class); + $this->expectException(Exception::class); $this->expectExceptionMessage('The Mix manifest does not exist'); - Mix::mix('assets/dist/foo.css'); + app(Mix::class)(['assets/dist/foo.css'], 'theme-mixtest'); } - public function testGeneratesAssetUrls(): void + public function testMixWithJsOnly(): void { $this->artisan('mix:compile', [ 'theme-mixtest', @@ -74,38 +76,56 @@ public function testGeneratesAssetUrls(): void '--disable-tty' => true, ])->assertExitCode(0); - $theme = Theme::getActiveTheme(); + $package = PackageManager::instance()->getPackage('theme-mixtest')[0]; - $this->assertFileExists($theme->getPath($theme->getDirName() . '/mix-manifest.json')); + $manifest = json_decode(file_get_contents($package['path'].'/mix-manifest.json'), true); - $manifest = json_decode(file_get_contents($theme->getPath($theme->getDirName() . '/mix-manifest.json')), true); + $mixFileUrl = collect($manifest)->firstWhere(fn ($value, $key) => $key === '/assets/dist/js/theme.js'); + $mixFileUrl = Url::asset($package['path'] . $mixFileUrl); - foreach ($manifest as $key => $value) { - $mixAssetUrl = Mix::mix($key); + $result = app(Mix::class)(['assets/dist/js/theme.js'], 'theme-mixtest'); - $this->assertStringStartsWith( - Url::asset(Config::get('cms.themesPath', '/themes') . '/' . $theme->getDirName()), - $mixAssetUrl - ); - } + $this->assertStringEndsWith('', $result->toHtml()); } - public function testThemeCanOverrideMixManifestPath(): void + public function testMixWithCssAndJs(): void { - $theme = Theme::getActiveTheme(); + $this->artisan('mix:compile', [ + 'theme-mixtest', + '--manifest' => 'modules/system/tests/fixtures/npm/package-mixtest.json', + '--disable-tty' => true, + ])->assertExitCode(0); + + $package = PackageManager::instance()->getPackage('theme-mixtest')[0]; + + $manifest = collect(json_decode(file_get_contents($package['path'].'/mix-manifest.json'), true)) + ->map(fn ($value, $key) => Url::asset($package['path'].$value)); + + $result = app(Mix::class)(['assets/dist/css/theme.css', 'assets/dist/js/theme.js'], 'theme-mixtest'); + + $this->assertStringEndsWith( + '' + .'', + $result->toHtml() + ); + } + public function testThemeCanOverrideMixManifestPath(): void + { Event::listen('cms.theme.extendConfig', function ($dirName, &$config) { $config['mix_manifest_path'] = 'assets/dist'; }); + $package = PackageManager::instance()->getPackage('theme-mixtest')[0]; + rename( - $theme->getPath($theme->getDirName() . '/winter.mix.js'), - $theme->getPath($theme->getDirName() . '/winter.mix.js.bak') + $package['path'] . '/winter.mix.js', + $package['path'] . '/winter.mix.js.bak' ); copy( - $theme->getPath($theme->getDirName() . '/winter.mix-manifest-override.js'), - $theme->getPath($theme->getDirName() . '/winter.mix.js') + $package['path'] . '/winter.mix-manifest-override.js', + $package['path'] . '/winter.mix.js' ); try { @@ -115,40 +135,27 @@ public function testThemeCanOverrideMixManifestPath(): void '--disable-tty' => true, ])->assertExitCode(0); - $this->assertFileExists($theme->getPath($theme->getDirName() . '/assets/dist/mix-manifest.json')); + $this->assertFileExists($package['path'] . '/assets/dist/mix-manifest.json'); - $manifest = json_decode(file_get_contents($theme->getPath($theme->getDirName() . '/assets/dist/mix-manifest.json')), true); + $manifest = json_decode(file_get_contents($package['path'] . '/assets/dist/mix-manifest.json'), true); foreach ($manifest as $key => $value) { - $this->assertStringContainsString($key, (string) Mix::mix($key)); + $this->assertStringContainsString($key, (string) app(Mix::class)($key, 'theme-mixtest', 'assets/dist/mix-manifest.json')); } - } catch (\Exception $e) { + } catch (Exception $e) { throw $e; } finally { rename( - $theme->getPath($theme->getDirName() . '/winter.mix.js.bak'), - $theme->getPath($theme->getDirName() . '/winter.mix.js') + $package['path'] . '/winter.mix.js.bak', + $package['path'] . '/winter.mix.js' ); } } - public function testThrowsAnExceptionForInvalidMixFileWhenDebugIsEnabled() - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Unable to locate Mix file: /assets/dist/foo.css'); - - $this->artisan('mix:compile', [ - 'theme-mixtest', - '--manifest' => 'modules/system/tests/fixtures/npm/package-mixtest.json', - '--disable-tty' => true, - ])->assertExitCode(0); - - Mix::mix('assets/dist/foo.css'); - } - - public function testDoesNotThrowAnExceptionForInvalidMixFileWhenDebugIsDisabled(): void + public function testThrowsAnExceptionForInvalidMixFile() { - Config::set('app.debug', false); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Unable to locate file in Mix manifest: /assets/dist/foo.css'); $this->artisan('mix:compile', [ 'theme-mixtest', @@ -156,6 +163,6 @@ public function testDoesNotThrowAnExceptionForInvalidMixFileWhenDebugIsDisabled( '--disable-tty' => true, ])->assertExitCode(0); - $this->assertEquals('/assets/dist/foo.css', Mix::mix('assets/dist/foo.css')); + app(Mix::class)('assets/dist/foo.css', 'theme-mixtest'); } }