diff --git a/modules/cms/tests/classes/TwigExtensionTest.php b/modules/cms/tests/classes/TwigExtensionTest.php
index 686ab96c74..b62673c30d 100644
--- a/modules/cms/tests/classes/TwigExtensionTest.php
+++ b/modules/cms/tests/classes/TwigExtensionTest.php
@@ -5,11 +5,31 @@
use Cms\Twig\Extension;
use Cms\Classes\Controller;
+use System\Classes\Asset\PackageManager;
use System\Tests\Bootstrap\TestCase;
use Winter\Storm\Exception\SystemException;
+use Winter\Storm\Support\Facades\File;
class TwigExtensionTest extends TestCase
{
+ private const VITE_FIXTURE_PACKAGE = 'theme-assettest';
+ private const VITE_FIXTURE_THEME_PATH = '/modules/system/tests/fixtures/themes/assettest';
+ private const VITE_HOT_URL = 'http://localhost:5173';
+
+ public function tearDown(): void
+ {
+ $hotFile = base_path(self::VITE_FIXTURE_THEME_PATH . '/assets/dist/hot');
+ if (File::exists($hotFile)) {
+ File::delete($hotFile);
+ }
+ $distDir = base_path(self::VITE_FIXTURE_THEME_PATH . '/assets/dist');
+ if (File::isDirectory($distDir) && count(File::files($distDir)) === 0 && count(File::directories($distDir)) === 0) {
+ File::deleteDirectory($distDir);
+ }
+
+ parent::tearDown();
+ }
+
public function testPartialFunction()
{
$extension = new Extension;
@@ -35,4 +55,111 @@ public function testContentFunction()
$this->expectExceptionMessageMatches('/is\snot\sfound/');
$this->assertFalse($extension->contentFunction('invalid-content-file', [], true));
}
+
+ public function testStylesTagEmitsCssAndViteCss(): void
+ {
+ [$extension, $controller] = $this->buildExtensionWithViteAssets([
+ 'assets/css/theme.css',
+ 'assets/javascript/theme.js',
+ ]);
+ $controller->addCss('plain.css');
+
+ // This is exactly the call StylesNode::compile() writes into the compiled `{% styles %}` tag
+ $output = (string) $extension->assetsFunction('css');
+
+ $this->assertStringContainsString('plain.css', $output);
+ $this->assertStringContainsString('assets/css/theme.css', $output);
+ $this->assertStringNotContainsString('assets/javascript/theme.js', $output);
+ }
+
+ public function testScriptsTagEmitsJsAndViteJs(): void
+ {
+ [$extension, $controller] = $this->buildExtensionWithViteAssets([
+ 'assets/css/theme.css',
+ 'assets/javascript/theme.js',
+ ]);
+ $controller->addJs('plain.js');
+
+ // Mirrors what ScriptsNode::compile() emits for the `{% scripts %}` tag
+ $output = (string) $extension->assetsFunction('js');
+
+ $this->assertStringContainsString('plain.js', $output);
+ $this->assertStringContainsString('assets/javascript/theme.js', $output);
+ $this->assertStringNotContainsString('assets/css/theme.css', $output);
+ }
+
+ public function testStylesAndScriptsTagsDoNotCrossLeak(): void
+ {
+ // Reverse entrypoint order to confirm filtering doesn't depend on array order
+ [$extension] = $this->buildExtensionWithViteAssets([
+ 'assets/javascript/theme.js',
+ 'assets/css/theme.css',
+ ]);
+
+ $stylesOutput = (string) $extension->assetsFunction('css');
+ $scriptsOutput = (string) $extension->assetsFunction('js');
+
+ $this->assertStringContainsString('assets/css/theme.css', $stylesOutput);
+ $this->assertStringNotContainsString('assets/javascript/theme.js', $stylesOutput);
+
+ $this->assertStringContainsString('assets/javascript/theme.js', $scriptsOutput);
+ $this->assertStringNotContainsString('assets/css/theme.css', $scriptsOutput);
+ }
+
+ public function testStylesTagOmitsViteWhenNoCssEntrypoints(): void
+ {
+ // JS-only vite registration; the styles tag must contribute no vite output for it
+ [$extension] = $this->buildExtensionWithViteAssets([
+ 'assets/javascript/theme.js',
+ ]);
+
+ $output = (string) $extension->assetsFunction('css');
+
+ $this->assertStringNotContainsString('@vite/client', $output);
+ $this->assertStringNotContainsString('assets/javascript/theme.js', $output);
+ }
+
+ /**
+ * Boots a Cms\Twig\Extension wired up to a fresh Controller that has the
+ * given vite entrypoints registered against the assettest fixture package.
+ * The fixture's hot file is written so Vite::tags() emits deterministic
+ * dev-server tags (no manifest required).
+ *
+ * @return array{0: Extension, 1: Controller}
+ */
+ private function buildExtensionWithViteAssets(array $entrypoints): array
+ {
+ $themePath = base_path(self::VITE_FIXTURE_THEME_PATH);
+ if (!File::isDirectory($themePath)) {
+ $this->markTestSkipped('Vite test fixture is missing at ' . self::VITE_FIXTURE_THEME_PATH);
+ }
+
+ // PackageManager's lazy init() touches Theme::all() and Halcyon model events, which
+ // can blow up when prior tests in the full suite have left datasource/plugin state in
+ // an inconsistent shape. Skip gracefully — same pattern as the existing
+ // ViteInstallTest — so this test still asserts something useful in isolation.
+ try {
+ $packageManager = PackageManager::instance();
+ } catch (\Throwable $e) {
+ $this->markTestSkipped('PackageManager could not initialise in this environment: ' . $e->getMessage());
+ }
+
+ // registerPackage silently no-ops when re-registering the same name + config.
+ $packageManager->registerPackage(
+ self::VITE_FIXTURE_PACKAGE,
+ $themePath . '/vite.config.mjs',
+ 'vite'
+ );
+
+ File::ensureDirectoryExists($themePath . '/assets/dist');
+ File::put($themePath . '/assets/dist/hot', self::VITE_HOT_URL);
+
+ $controller = new Controller();
+ $controller->addVite($entrypoints, self::VITE_FIXTURE_PACKAGE);
+
+ $extension = new Extension();
+ $extension->setController($controller);
+
+ return [$extension, $controller];
+ }
}
diff --git a/modules/cms/twig/StylesNode.php b/modules/cms/twig/StylesNode.php
index bd6e0fc08b..35a442c2f5 100644
--- a/modules/cms/twig/StylesNode.php
+++ b/modules/cms/twig/StylesNode.php
@@ -26,7 +26,6 @@ public function compile(TwigCompiler $compiler)
$compiler
->addDebugInfo($this)
->write("echo \$this->env->getExtension('Cms\Twig\Extension')->assetsFunction('css');\n")
- ->write("echo \$this->env->getExtension('Cms\Twig\Extension')->assetsFunction('vite');\n")
->write("echo \$this->env->getExtension('Cms\Twig\Extension')->displayBlock('styles');\n")
;
}
diff --git a/modules/system/tests/traits/AssetMakerTest.php b/modules/system/tests/traits/AssetMakerTest.php
index ca461941d9..061c5d175b 100644
--- a/modules/system/tests/traits/AssetMakerTest.php
+++ b/modules/system/tests/traits/AssetMakerTest.php
@@ -2,10 +2,12 @@
namespace System\Tests\Traits;
+use System\Classes\Asset\PackageManager;
use System\Tests\Bootstrap\TestCase;
use System\Traits\AssetMaker;
use System\Traits\EventEmitter;
use System\Traits\ViewMaker;
+use Winter\Storm\Support\Facades\File;
use Winter\Storm\Support\Facades\Url;
class AssetMakerStub
@@ -19,12 +21,64 @@ class AssetMakerTest extends TestCase
{
private AssetMakerStub $stub;
+ private const VITE_FIXTURE_PACKAGE = 'theme-assettest';
+ private const VITE_FIXTURE_THEME_PATH = '/modules/system/tests/fixtures/themes/assettest';
+ private const VITE_HOT_URL = 'http://localhost:5173';
+
public function setUp() : void
{
$this->createApplication();
$this->stub = new AssetMakerStub();
}
+ public function tearDown(): void
+ {
+ // Remove any vite hot file that a vite-related test wrote into the fixture
+ $hotFile = base_path(self::VITE_FIXTURE_THEME_PATH . '/assets/dist/hot');
+ if (File::exists($hotFile)) {
+ File::delete($hotFile);
+ }
+ $distDir = base_path(self::VITE_FIXTURE_THEME_PATH . '/assets/dist');
+ if (File::isDirectory($distDir) && count(File::files($distDir)) === 0 && count(File::directories($distDir)) === 0) {
+ File::deleteDirectory($distDir);
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * Registers the assettest fixture as a vite package and writes a hot file so that
+ * Vite::tags() emits deterministic dev-server tags without needing a built manifest.
+ */
+ private function setUpViteFixture(): void
+ {
+ $themePath = base_path(self::VITE_FIXTURE_THEME_PATH);
+ if (!File::isDirectory($themePath)) {
+ $this->markTestSkipped('Vite test fixture is missing at ' . self::VITE_FIXTURE_THEME_PATH);
+ }
+
+ // PackageManager's lazy init() call iterates Theme::all(), which fires Halcyon model
+ // events. In the full system suite, prior tests can leave plugin/datasource state in a
+ // way that makes that initial iteration throw — same pattern as the existing
+ // ViteInstallTest. Skip gracefully when that happens so this test is meaningful only
+ // in a clean environment (and always when run in isolation).
+ try {
+ $packageManager = PackageManager::instance();
+ } catch (\Throwable $e) {
+ $this->markTestSkipped('PackageManager could not initialise in this environment: ' . $e->getMessage());
+ }
+
+ // registerPackage silently no-ops when re-registering the same name + config.
+ $packageManager->registerPackage(
+ self::VITE_FIXTURE_PACKAGE,
+ $themePath . '/vite.config.mjs',
+ 'vite'
+ );
+
+ File::ensureDirectoryExists($themePath . '/assets/dist');
+ File::put($themePath . '/assets/dist/hot', self::VITE_HOT_URL);
+ }
+
//
// Tests
//
@@ -118,4 +172,115 @@ public function testAssetOrdering(): void
$hostUrl . 'myThird.css',
], $assets['css']);
}
+
+ public function testGetAssetType(): void
+ {
+ $cases = [
+ 'foo.js' => 'js',
+ 'assets/javascript/theme.js' => 'js',
+ 'foo.css' => 'css',
+ 'assets/css/theme.css' => 'css',
+ 'foo.txt' => null,
+ 'foo' => null,
+ 'js' => null,
+ 'foo.JS' => 'js',
+ 'foo.CSS' => 'css',
+ ];
+
+ foreach ($cases as $input => $expected) {
+ $this->assertSame(
+ $expected,
+ $this->callProtectedMethod($this->stub, 'getAssetType', [$input]),
+ "getAssetType('$input') did not return the expected type"
+ );
+ }
+ }
+
+ public function testMakeAssetsViteCssOnly(): void
+ {
+ $this->setUpViteFixture();
+
+ $this->stub->addCss('plain.css');
+ $this->stub->addVite(
+ ['assets/css/theme.css', 'assets/javascript/theme.js'],
+ self::VITE_FIXTURE_PACKAGE
+ );
+
+ $output = $this->stub->makeAssets('css');
+
+ $this->assertNotNull($output);
+ // Plain CSS still emitted
+ $this->assertStringContainsString('plain.css', $output);
+ // Vite CSS entrypoint emitted
+ $this->assertStringContainsString('assets/css/theme.css', $output);
+ // Vite JS entrypoint must NOT leak into the css output
+ $this->assertStringNotContainsString('assets/javascript/theme.js', $output);
+ }
+
+ public function testMakeAssetsViteJsOnly(): void
+ {
+ $this->setUpViteFixture();
+
+ $this->stub->addJs('plain.js');
+ $this->stub->addVite(
+ ['assets/css/theme.css', 'assets/javascript/theme.js'],
+ self::VITE_FIXTURE_PACKAGE
+ );
+
+ $output = $this->stub->makeAssets('js');
+
+ $this->assertNotNull($output);
+ $this->assertStringContainsString('plain.js', $output);
+ $this->assertStringContainsString('assets/javascript/theme.js', $output);
+ $this->assertStringNotContainsString('assets/css/theme.css', $output);
+ }
+
+ public function testMakeAssetsViteTypeUnfiltered(): void
+ {
+ $this->setUpViteFixture();
+
+ $this->stub->addVite(
+ ['assets/css/theme.css', 'assets/javascript/theme.js'],
+ self::VITE_FIXTURE_PACKAGE
+ );
+
+ $output = $this->stub->makeAssets('vite');
+
+ $this->assertNotNull($output);
+ // Legacy 'vite' type must still emit both entrypoints unfiltered
+ $this->assertStringContainsString('assets/css/theme.css', $output);
+ $this->assertStringContainsString('assets/javascript/theme.js', $output);
+ }
+
+ public function testMakeAssetsAllTypesSplitsViteAcrossGroups(): void
+ {
+ $this->setUpViteFixture();
+
+ $this->stub->addVite(
+ ['assets/css/theme.css', 'assets/javascript/theme.js'],
+ self::VITE_FIXTURE_PACKAGE
+ );
+
+ $output = $this->stub->makeAssets();
+
+ $this->assertNotNull($output);
+ // Each entrypoint should appear exactly once: in its corresponding group, not in the
+ // standalone vite block (which must NOT fire when $type is null).
+ $this->assertSame(1, substr_count($output, 'assets/css/theme.css'));
+ $this->assertSame(1, substr_count($output, 'assets/javascript/theme.js'));
+ }
+
+ public function testMakeAssetsViteSkipsAssetWithNoMatchingEntrypoints(): void
+ {
+ $this->setUpViteFixture();
+
+ // JS-only entrypoint registered as a vite asset
+ $this->stub->addVite(['assets/javascript/theme.js'], self::VITE_FIXTURE_PACKAGE);
+
+ $output = $this->stub->makeAssets('css');
+
+ // No css entrypoints to filter to, so the vite asset must contribute nothing
+ // and not produce an empty Vite::tags() call (which would still emit @vite/client).
+ $this->assertEmpty($output);
+ }
}
diff --git a/modules/system/traits/AssetMaker.php b/modules/system/traits/AssetMaker.php
index faf2c66962..4a82ca1a1f 100644
--- a/modules/system/traits/AssetMaker.php
+++ b/modules/system/traits/AssetMaker.php
@@ -73,6 +73,17 @@ public function makeAssets(?string $type = null): ?string
$result .= '' . PHP_EOL;
}
+
+ foreach ($this->assets['vite'] as $asset) {
+ $asset['attributes']['entrypoints'] = array_filter(
+ $asset['attributes']['entrypoints'],
+ fn ($entrypoint) => $this->getAssetType($entrypoint) === 'css'
+ );
+
+ if ($asset['attributes']['entrypoints']) {
+ $result .= Vite::tags($asset['attributes']['entrypoints'], $asset['path']);
+ }
+ }
}
if ($type == null || $type == 'rss') {
@@ -102,9 +113,20 @@ public function makeAssets(?string $type = null): ?string
$result .= '' . PHP_EOL;
}
+
+ foreach ($this->assets['vite'] as $asset) {
+ $asset['attributes']['entrypoints'] = array_filter(
+ $asset['attributes']['entrypoints'],
+ fn ($entrypoint) => $this->getAssetType($entrypoint) === 'js'
+ );
+
+ if ($asset['attributes']['entrypoints']) {
+ $result .= Vite::tags($asset['attributes']['entrypoints'], $asset['path']);
+ }
+ }
}
- if ($type == null || $type == 'vite') {
+ if ($type == 'vite') {
foreach ($this->assets['vite'] as $asset) {
$result .= Vite::tags($asset['attributes']['entrypoints'], $asset['path']);
}
@@ -458,4 +480,16 @@ public function orderAssets(array $assets): array
return $sortedAssets;
}
+
+ protected function getAssetType(string $asset): ?string
+ {
+ $path = strtolower(parse_url($asset, PHP_URL_PATH) ?? $asset);
+ $ext = pathinfo($path, PATHINFO_EXTENSION);
+
+ return match ($ext) {
+ 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'vue', 'svelte' => 'js',
+ 'css', 'scss', 'sass', 'less', 'styl', 'stylus', 'pcss', 'postcss' => 'css',
+ default => null,
+ };
+ }
}