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/MixFunctionTest.php b/modules/cms/tests/twig/MixFunctionTest.php new file mode 100644 index 0000000000..9108b8d89c --- /dev/null +++ b/modules/cms/tests/twig/MixFunctionTest.php @@ -0,0 +1,76 @@ +<?php + +namespace Cms\Tests\Twig; + +use Cms\Classes\Controller; +use Cms\Classes\Theme; +use Cms\Twig\Extension; +use System\Tests\Bootstrap\TestCase; +use Winter\Storm\Support\Facades\Config; +use Winter\Storm\Support\Facades\Event; +use Winter\Storm\Support\Facades\File; +use Winter\Storm\Support\Facades\Twig; + +class MixFunctionTest extends TestCase +{ + protected string $themeName = 'mixtest'; + + protected function setUp(): void + { + parent::setUp(); + + if (!is_dir(base_path('node_modules'))) { + $this->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/$this->themeName"); + + Config::set('cms.activeTheme', $this->themeName); + + Event::flush('cms.theme.getActiveTheme'); + Theme::resetCache(); + } + + protected function tearDown(): void + { + 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); + + parent::tearDown(); + } + + public function testGeneratesAssetUrl(): void + { + $theme = Theme::getActiveTheme(); + $packageName = "theme-$this->themeName"; + + $this->artisan('mix:compile', [ + $packageName, + '--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("{{ 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 01ed17438e..0ad3c5fd45 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; @@ -53,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), ]; } @@ -176,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 new file mode 100644 index 0000000000..62c22c2cb8 --- /dev/null +++ b/modules/system/classes/asset/Mix.php @@ -0,0 +1,240 @@ +<?php + +namespace System\Classes\Asset; + +use Exception; +use Illuminate\Support\Facades\App; +use Illuminate\Support\HtmlString; +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 +{ + /** + * 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 + { + if (!$package) { + throw new InvalidArgumentException('A package must be passed'); + } + + // Normalise the package name + $package = strtolower($package); + + $manifestPath ??= 'mix-manifest.json'; + + if (!($compilableAssetPackage = PackageManager::instance()->getPackages('mix')[$package] ?? null)) { + throw new SystemException('Unable to resolve package: ' . $package); + } + + $manifestPath = public_path($compilableAssetPackage['path'] . Str::start($manifestPath, '/')); + + if (!isset(static::$manifests[$manifestPath])) { + if (!is_file($manifestPath)) { + throw new Exception("The Mix manifest does not exist."); + } + + static::$manifests[$manifestPath] = json_decode(file_get_contents($manifestPath), true); + } + + $manifest = static::$manifests[$manifestPath]; + + $entrypoints = collect($entrypoints) + ->map(fn($path) => Str::start($path, '/')); + + $tags = collect(); + $preloads = collect(); + + 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, '<link')); + + $preloads = $preloads->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 '<link ' . implode(' ', $this->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, []); + } + + return $this->makeScriptTagWithAttributes($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 '<link ' . implode(' ', $attributes) . ' />'; + } + + /** + * 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 '<script ' . implode(' ', $attributes) . '></script>'; + } + + /** + * 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 new file mode 100644 index 0000000000..e174f1a540 --- /dev/null +++ b/modules/system/tests/classes/asset/MixTest.php @@ -0,0 +1,168 @@ +<?php + +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; +use Winter\Storm\Support\Facades\File; +use Winter\Storm\Support\Facades\Url; + +class MixTest extends TestCase +{ + protected string $themePath; + + protected string $originalThemesPath = ''; + + protected string $originalThemesPathLocal = ''; + + protected function setUp(): void + { + parent::setUp(); + + if (!is_dir(base_path('node_modules'))) { + $this->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 testThrowsExceptionWhenMixManifestIsMissing(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('The Mix manifest does not exist'); + + app(Mix::class)(['assets/dist/foo.css'], 'theme-mixtest'); + } + + public function testMixWithJsOnly(): void + { + $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 = json_decode(file_get_contents($package['path'].'/mix-manifest.json'), true); + + $mixFileUrl = collect($manifest)->firstWhere(fn ($value, $key) => $key === '/assets/dist/js/theme.js'); + $mixFileUrl = Url::asset($package['path'] . $mixFileUrl); + + $result = app(Mix::class)(['assets/dist/js/theme.js'], 'theme-mixtest'); + + $this->assertStringEndsWith('<script src="'.$mixFileUrl.'"></script>', $result->toHtml()); + } + + public function testMixWithCssAndJs(): void + { + $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( + '<link rel="stylesheet" href="'.$manifest['/assets/dist/css/theme.css'].'" />' + .'<script src="'.$manifest['/assets/dist/js/theme.js'].'"></script>', + $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( + $package['path'] . '/winter.mix.js', + $package['path'] . '/winter.mix.js.bak' + ); + + copy( + $package['path'] . '/winter.mix-manifest-override.js', + $package['path'] . '/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($package['path'] . '/assets/dist/mix-manifest.json'); + + $manifest = json_decode(file_get_contents($package['path'] . '/assets/dist/mix-manifest.json'), true); + + foreach ($manifest as $key => $value) { + $this->assertStringContainsString($key, (string) app(Mix::class)($key, 'theme-mixtest', 'assets/dist/mix-manifest.json')); + } + } catch (Exception $e) { + throw $e; + } finally { + rename( + $package['path'] . '/winter.mix.js.bak', + $package['path'] . '/winter.mix.js' + ); + } + } + + public function testThrowsAnExceptionForInvalidMixFile() + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Unable to locate file in Mix manifest: /assets/dist/foo.css'); + + $this->artisan('mix:compile', [ + 'theme-mixtest', + '--manifest' => 'modules/system/tests/fixtures/npm/package-mixtest.json', + '--disable-tty' => true, + ])->assertExitCode(0); + + app(Mix::class)('assets/dist/foo.css', 'theme-mixtest'); + } +} 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..55fb7c1a0a --- /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/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 new file mode 100644 index 0000000000..7affd67545 --- /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');