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 @@
+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 @@
+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, '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, []);
+ }
+
+ 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 '';
+ }
+
+ /**
+ * 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
new file mode 100644
index 0000000000..e174f1a540
--- /dev/null
+++ b/modules/system/tests/classes/asset/MixTest.php
@@ -0,0 +1,168 @@
+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('', $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(
+ ''
+ .'',
+ $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');