From 9be59e9cdcbcf89a545724a779a62a63df07e6c1 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 11 Mar 2026 18:15:31 +0100 Subject: [PATCH 1/3] feat: add namespace support for vite extension --- docs/content/extensions/vite.dj | 56 +- .../Vite/Runtime/ViteAssetResolver.php | 248 ++++++--- src/Extension/Vite/ViteConfig.php | 40 ++ src/Extension/Vite/ViteExtension.php | 34 +- tests/Integration/ViteIntegrationTest.php | 241 +++++++++ .../Vite/Runtime/ViteAssetResolverTest.php | 503 +++++++++--------- tests/Unit/Extension/Vite/ViteConfigTest.php | 46 ++ .../Unit/Extension/Vite/ViteExtensionTest.php | 39 ++ 8 files changed, 884 insertions(+), 323 deletions(-) create mode 100644 src/Extension/Vite/ViteConfig.php create mode 100644 tests/Unit/Extension/Vite/ViteConfigTest.php diff --git a/docs/content/extensions/vite.dj b/docs/content/extensions/vite.dj index 71ff3fc..a33d132 100644 --- a/docs/content/extensions/vite.dj +++ b/docs/content/extensions/vite.dj @@ -60,6 +60,49 @@ In this form, `src` is used as the directive expression and produces the same ou - `dev`: Always emits dev server tags. - `prod`: Always resolves assets from `manifest.json`. +## Namespaces + +For multi-build setups — such as a plugin or theme with its own Vite build — you can register named namespaces. Each namespace is an independent `ViteConfig` with its own `assetBaseUrl`, `manifestPath`, and optionally a separate `devServerUrl`. + +Register namespaces when creating the extension: + +```php +use Sugar\Core\Engine; +use Sugar\Extension\Vite\ViteConfig; +use Sugar\Extension\Vite\ViteExtension; + +$engine = Engine::builder() + ->withExtension(new ViteExtension( + assetBaseUrl: '/build/', + manifestPath: ROOT . '/webroot/build/.vite/manifest.json', + namespaces: [ + 'theme' => new ViteConfig( + assetBaseUrl: '/theme/build/', + manifestPath: ROOT . '/plugins/Theme/webroot/build/.vite/manifest.json', + devServerUrl: 'http://localhost:5174', + defaultEntry: 'resources/js/theme.ts', + ), + ], + )) + ->build(); +``` + +Reference a namespace entry in templates using the `@name/` prefix: + +```sugar + +``` + +Multiple entries from different namespaces can be combined in one directive: + +```sugar + +``` + +Each namespace resolves its assets independently: its own manifest in production, its own dev server URL in development, and its own `@vite/client` injection tracking. + +When `devServerUrl` is omitted from a `ViteConfig`, the namespace falls back to the root extension `devServerUrl`. + ## Configuration options - `mode`: `auto`, `dev`, or `prod`. @@ -68,7 +111,18 @@ In this form, `src` is used as the directive expression and produces the same ou - `devServerUrl`: Vite development server origin. - `injectClient`: Automatically inject `@vite/client` in development mode. - `defaultEntry`: Entry used when `s:vite` is used as a boolean directive. +- `namespaces`: Named namespace configurations as an array of `ViteConfig` objects, keyed by namespace name. + +### `ViteConfig` options + +`ViteConfig` is used to configure a named namespace. All options mirror the top-level extension options and apply only to that namespace. + +- `assetBaseUrl`: Public URL prefix for built assets (required). +- `manifestPath`: Absolute filesystem path to the Vite `manifest.json`. +- `devServerUrl`: Dev server origin for this namespace. Falls back to the root `devServerUrl` when omitted. +- `injectClient`: Whether to inject `@vite/client` for this namespace in development mode. Defaults to `true`. +- `defaultEntry`: Default entry used when `s:vite` is boolean for this namespace. ## Production recommendation -Set `manifestPath` and `assetBaseUrl` explicitly in production. +Set `manifestPath` and `assetBaseUrl` explicitly in production. This applies equally to each registered namespace. diff --git a/src/Extension/Vite/Runtime/ViteAssetResolver.php b/src/Extension/Vite/Runtime/ViteAssetResolver.php index 9ff50b1..09635c2 100644 --- a/src/Extension/Vite/Runtime/ViteAssetResolver.php +++ b/src/Extension/Vite/Runtime/ViteAssetResolver.php @@ -5,6 +5,7 @@ use Sugar\Core\Escape\Escaper; use Sugar\Core\Exception\TemplateRuntimeException; +use Sugar\Extension\Vite\ViteConfig; /** * Resolves and renders Vite asset tags at runtime. @@ -12,40 +13,40 @@ * In development mode this emits `@vite/client` and entry module scripts. * In production mode this resolves entries from `manifest.json` and emits * stylesheet and module script tags. + * + * Named namespace configs (e.g. `@theme`) allow multi-build setups where each + * namespace has its own manifest, base URL, and optional dev server. */ final class ViteAssetResolver { /** - * @var array|null + * @var array> */ - private ?array $manifest = null; + private array $manifests = []; /** * @var array */ private array $emittedTags = []; - private bool $clientInjected = false; + /** + * @var array + */ + private array $injectedClients = []; /** * @param string $mode Resolver mode: `auto`, `dev`, or `prod` * @param bool $debug Whether engine debug mode is enabled - * @param string|null $manifestPath Absolute path to Vite manifest file for production mode - * @param string $assetBaseUrl Public URL base for emitted manifest assets - * @param string $devServerUrl Vite dev server origin - * @param bool $injectClient Whether to inject `@vite/client` in development mode - * @param string|null $defaultEntry Optional default entry used when specification is boolean + * @param \Sugar\Extension\Vite\ViteConfig $default Configuration for the default (unnamed) namespace + * @param array $namespaces Named namespace configurations keyed by namespace name */ public function __construct( private readonly string $mode, private readonly bool $debug, - private readonly ?string $manifestPath, - private readonly string $assetBaseUrl, - private readonly string $devServerUrl, - private readonly bool $injectClient, - private readonly ?string $defaultEntry, + private readonly ViteConfig $default, + private readonly array $namespaces = [], ) { - if (trim($this->assetBaseUrl) === '') { + if (trim($this->default->assetBaseUrl) === '') { throw new TemplateRuntimeException('Vite assetBaseUrl must be configured and non-empty.'); } } @@ -89,15 +90,94 @@ private function isDevMode(): bool } /** - * Normalize directive specification into entry list. + * Parse a `@namespace/path` entry into its namespace name and bare path. + * + * Returns `[null, $entry]` for entries without a namespace prefix. + * + * @return array{0: string|null, 1: string} + */ + private function parseEntry(string $entry): array + { + if (!str_starts_with($entry, '@')) { + return [null, $entry]; + } + + $slashPos = strpos($entry, '/'); + if ($slashPos === false) { + return [null, $entry]; + } + + return [substr($entry, 1, $slashPos - 1), substr($entry, $slashPos + 1)]; + } + + /** + * Resolve the ViteConfig for a given namespace name. + * + * Falls back to the default config when namespace is null. + * + * @param string|null $namespace Namespace name or null for the default + * @throws \Sugar\Core\Exception\TemplateRuntimeException When the namespace is not registered + */ + private function resolveConfig(?string $namespace): ViteConfig + { + if ($namespace === null) { + return $this->default; + } + + if (!isset($this->namespaces[$namespace])) { + throw new TemplateRuntimeException(sprintf( + 'Vite namespace "@%s" is not registered. Registered namespaces: %s.', + $namespace, + $this->namespaces === [] ? '(none)' : implode(', ', array_map( + static fn(string $n): string => '@' . $n, + array_keys($this->namespaces), + )), + )); + } + + return $this->namespaces[$namespace]; + } + + /** + * Resolve the effective dev server URL for a config, falling back to the default. + */ + private function resolveDevServerUrl(ViteConfig $config): string + { + return $config->devServerUrl ?? $this->default->devServerUrl ?? 'http://localhost:5173'; + } + + /** + * Normalize directive specification into a list of `[namespace, path]` tuples. * * @param mixed $spec Entry specification - * @return array + * @return array */ private function normalizeEntries(mixed $spec): array + { + $raw = $this->normalizeRawEntries($spec); + $result = []; + + foreach ($raw as $entry) { + [$namespace, $path] = $this->parseEntry($entry); + + if ($path !== '') { + $result[] = [$namespace, $path]; + } + } + + return $result; + } + + /** + * Normalize directive specification into a flat list of raw entry strings. + * + * @param mixed $spec Entry specification + * @return array + */ + private function normalizeRawEntries(mixed $spec): array { if ($spec === null || $spec === true) { - return $this->defaultEntry === null ? [] : [$this->defaultEntry]; + return $this->default->defaultEntry !== null ? [$this->default->defaultEntry] : []; } if (is_string($spec)) { @@ -114,61 +194,64 @@ private function normalizeEntries(mixed $spec): array } if (array_key_exists('entries', $spec) && is_array($spec['entries'])) { - $entries = []; - foreach ($spec['entries'] as $entry) { - if (!is_string($entry)) { - continue; - } - - $normalized = trim($entry); - if ($normalized !== '') { - $entries[] = $normalized; - } - } - - return $entries; + return $this->collectStringEntries($spec['entries']); } - $entries = []; - foreach ($spec as $entry) { - if (!is_string($entry)) { - continue; - } + return $this->collectStringEntries($spec); + } - $normalized = trim($entry); - if ($normalized !== '') { - $entries[] = $normalized; - } + throw new TemplateRuntimeException('s:vite expects a string, list, or options array expression.'); + } + + /** + * Collect non-empty string entries from a list. + * + * @param array $items + * @return array + */ + private function collectStringEntries(array $items): array + { + $entries = []; + foreach ($items as $item) { + if (!is_string($item)) { + continue; } - return $entries; + $normalized = trim($item); + if ($normalized !== '') { + $entries[] = $normalized; + } } - throw new TemplateRuntimeException('s:vite expects a string, list, or options array expression.'); + return $entries; } /** * Render development mode tags for entries. * - * @param array $entries Entry paths + * @param array $entries Parsed namespace+path tuples * @return string Rendered HTML tags */ private function renderDevelopmentTags(array $entries): string { $tags = []; - if ($this->injectClient && !$this->clientInjected) { - $clientUrl = $this->normalizeDevUrl('@vite/client'); - $tag = ''; - $deduped = $this->emitTag($tag); - if ($deduped !== null) { - $tags[] = $deduped; - $this->clientInjected = true; + foreach ($entries as [$namespace, $path]) { + $config = $this->resolveConfig($namespace); + $devServerUrl = $this->resolveDevServerUrl($config); + $configKey = $namespace ?? ''; + + if ($config->injectClient && !isset($this->injectedClients[$configKey])) { + $clientUrl = $this->normalizeDevUrl('@vite/client', $devServerUrl); + $tag = ''; + $deduped = $this->emitTag($tag); + if ($deduped !== null) { + $tags[] = $deduped; + $this->injectedClients[$configKey] = true; + } } - } - foreach ($entries as $entry) { - $entryUrl = $this->normalizeDevUrl($entry); + $entryUrl = $this->normalizeDevUrl($path, $devServerUrl); $tag = ''; $deduped = $this->emitTag($tag); if ($deduped !== null) { @@ -182,25 +265,27 @@ private function renderDevelopmentTags(array $entries): string /** * Render production mode tags for entries. * - * @param array $entries Entry paths + * @param array $entries Parsed namespace+path tuples * @return string Rendered HTML tags */ private function renderProductionTags(array $entries): string { - $manifest = $this->loadManifest(); $tags = []; - foreach ($entries as $entry) { - $entryKey = $this->resolveManifestEntryKey($entry, $manifest); + foreach ($entries as [$namespace, $path]) { + $config = $this->resolveConfig($namespace); + $manifest = $this->loadManifest($namespace, $config); + + $entryKey = $this->resolveManifestEntryKey($path, $manifest); if ($entryKey === null) { - throw new TemplateRuntimeException(sprintf('Vite manifest entry "%s" was not found.', $entry)); + throw new TemplateRuntimeException(sprintf('Vite manifest entry "%s" was not found.', $path)); } $visited = []; $cssFiles = $this->collectCssFiles($entryKey, $manifest, $visited); foreach ($cssFiles as $cssFile) { - $href = $this->normalizeBuildUrl($cssFile); + $href = $this->normalizeBuildUrl($cssFile, $config->assetBaseUrl); $tag = ''; $deduped = $this->emitTag($tag); if ($deduped !== null) { @@ -210,11 +295,11 @@ private function renderProductionTags(array $entries): string $entryMeta = $manifest[$entryKey]; if (!is_array($entryMeta) || !isset($entryMeta['file']) || !is_string($entryMeta['file'])) { - throw new TemplateRuntimeException(sprintf('Invalid Vite manifest entry for "%s".', $entry)); + throw new TemplateRuntimeException(sprintf('Invalid Vite manifest entry for "%s".', $path)); } $entryFile = $entryMeta['file']; - $src = $this->normalizeBuildUrl($entryFile); + $src = $this->normalizeBuildUrl($entryFile, $config->assetBaseUrl); if ($this->isCssAssetPath($entryFile)) { $styleTag = ''; @@ -306,32 +391,38 @@ private function collectCssFiles(string $entryKey, array $manifest, array &$visi } /** - * Load and decode the Vite manifest. + * Load and decode the Vite manifest for the given config, caching per namespace. * + * @param string|null $namespace Namespace key used for caching + * @param \Sugar\Extension\Vite\ViteConfig $config Config to load the manifest from * @return array */ - private function loadManifest(): array + private function loadManifest(?string $namespace, ViteConfig $config): array { - if (is_array($this->manifest)) { - return $this->manifest; + $cacheKey = $namespace ?? ''; + + if (isset($this->manifests[$cacheKey])) { + return $this->manifests[$cacheKey]; } - if ($this->manifestPath === null || trim($this->manifestPath) === '') { + $manifestPath = $config->manifestPath; + + if ($manifestPath === null || trim($manifestPath) === '') { throw new TemplateRuntimeException('Vite manifest path is required in production mode.'); } - if (!is_file($this->manifestPath)) { + if (!is_file($manifestPath)) { throw new TemplateRuntimeException(sprintf( 'Vite manifest file was not found at "%s".', - $this->manifestPath, + $manifestPath, )); } - $manifestJson = file_get_contents($this->manifestPath); + $manifestJson = file_get_contents($manifestPath); if (!is_string($manifestJson) || $manifestJson === '') { throw new TemplateRuntimeException(sprintf( 'Vite manifest file "%s" is empty or unreadable.', - $this->manifestPath, + $manifestPath, )); } @@ -339,7 +430,7 @@ private function loadManifest(): array if (!is_array($decoded)) { throw new TemplateRuntimeException(sprintf( 'Vite manifest file "%s" contains invalid JSON.', - $this->manifestPath, + $manifestPath, )); } @@ -352,29 +443,28 @@ private function loadManifest(): array $manifest[$key] = $value; } - $this->manifest = $manifest; + $this->manifests[$cacheKey] = $manifest; - return $this->manifest; + return $this->manifests[$cacheKey]; } /** - * Normalize an entry path against the dev server URL. + * Normalize an entry path against the given dev server URL. */ - private function normalizeDevUrl(string $entryPath): string + private function normalizeDevUrl(string $entryPath, string $devServerUrl): string { - $base = rtrim($this->devServerUrl, '/'); + $base = rtrim($devServerUrl, '/'); $path = ltrim($entryPath, '/'); return $base . '/' . $path; } /** - * Normalize a built file path against the configured build base URL. + * Normalize a built file path against the given build base URL. */ - private function normalizeBuildUrl(string $filePath): string + private function normalizeBuildUrl(string $filePath, string $assetBaseUrl): string { - $base = rtrim($this->normalizePublicPath($this->assetBaseUrl), '/'); - + $base = rtrim($this->normalizePublicPath($assetBaseUrl), '/'); $path = ltrim($filePath, '/'); return $base . '/' . $path; diff --git a/src/Extension/Vite/ViteConfig.php b/src/Extension/Vite/ViteConfig.php new file mode 100644 index 0000000..da2131a --- /dev/null +++ b/src/Extension/Vite/ViteConfig.php @@ -0,0 +1,40 @@ + new ViteConfig( + * assetBaseUrl: '/theme/build/', + * manifestPath: ROOT . '/plugins/Theme/webroot/build/.vite/manifest.json', + * devServerUrl: 'http://localhost:5174', + * ), + * ], + * ) + * ``` */ final readonly class ViteExtension implements ExtensionInterface { @@ -24,6 +43,7 @@ * @param string $devServerUrl Vite dev server origin (e.g. `http://localhost:5173`) * @param bool $injectClient Whether to inject `@vite/client` in development mode * @param string|null $defaultEntry Optional default entry used when `s:vite` is boolean + * @param array $namespaces Named namespace configurations keyed by namespace name */ public function __construct( private string $assetBaseUrl, @@ -32,6 +52,7 @@ public function __construct( private string $devServerUrl = 'http://localhost:5173', private bool $injectClient = true, private ?string $defaultEntry = null, + private array $namespaces = [], ) { } @@ -46,15 +67,20 @@ public function register(RegistrationContext $context): void $context->protectedRuntimeService( ViteAssetResolver::class, function (RuntimeContext $runtimeContext) use ($debug): ViteAssetResolver { - return new ViteAssetResolver( - mode: $this->mode, - debug: $debug, - manifestPath: $this->manifestPath, + $default = new ViteConfig( assetBaseUrl: $this->assetBaseUrl, + manifestPath: $this->manifestPath, devServerUrl: $this->devServerUrl, injectClient: $this->injectClient, defaultEntry: $this->defaultEntry, ); + + return new ViteAssetResolver( + mode: $this->mode, + debug: $debug, + default: $default, + namespaces: $this->namespaces, + ); }, ); } diff --git a/tests/Integration/ViteIntegrationTest.php b/tests/Integration/ViteIntegrationTest.php index 8a5fe0b..ff7d837 100644 --- a/tests/Integration/ViteIntegrationTest.php +++ b/tests/Integration/ViteIntegrationTest.php @@ -5,7 +5,9 @@ use PHPUnit\Framework\TestCase; use Sugar\Core\Engine; +use Sugar\Core\Exception\TemplateRuntimeException; use Sugar\Core\Loader\StringTemplateLoader; +use Sugar\Extension\Vite\ViteConfig; use Sugar\Extension\Vite\ViteExtension; /** @@ -199,6 +201,245 @@ public function testRendersDevelopmentTagsFromElementClaimingSyntax(): void $this->assertStringContainsString('http://localhost:5173/resources/js/app.ts', $output); } + // ---------------------------------------------------------------- + // Namespace tests + // ---------------------------------------------------------------- + + /** + * Verify @namespace/path entries in dev mode route to the namespace-specific dev server. + */ + public function testRendersNamespacedDevelopmentTagsUsingSeparateDevServer(): void + { + $loader = new StringTemplateLoader(templates: [ + 'ns-dev-page' => '', + ]); + + $engine = Engine::builder() + ->withTemplateLoader($loader) + ->withExtension(new ViteExtension( + assetBaseUrl: '/build/', + mode: 'dev', + devServerUrl: 'http://localhost:5173', + namespaces: [ + 'theme' => new ViteConfig( + assetBaseUrl: '/theme/build/', + devServerUrl: 'http://localhost:5174', + ), + ], + )) + ->build(); + + $output = $engine->render('ns-dev-page'); + + $this->assertStringContainsString('http://localhost:5174/@vite/client', $output); + $this->assertStringContainsString('http://localhost:5174/resources/js/theme.ts', $output); + $this->assertStringNotContainsString('http://localhost:5173', $output); + } + + /** + * Verify @namespace/path entries in dev mode fall back to root dev server when none configured. + */ + public function testNamespacedDevModeUsesRootDevServerWhenNotOverridden(): void + { + $loader = new StringTemplateLoader(templates: [ + 'ns-dev-fallback-page' => '', + ]); + + $engine = Engine::builder() + ->withTemplateLoader($loader) + ->withExtension(new ViteExtension( + assetBaseUrl: '/build/', + mode: 'dev', + devServerUrl: 'http://localhost:5173', + namespaces: [ + 'theme' => new ViteConfig(assetBaseUrl: '/theme/build/'), + ], + )) + ->build(); + + $output = $engine->render('ns-dev-fallback-page'); + + $this->assertStringContainsString('http://localhost:5173/resources/js/theme.ts', $output); + } + + /** + * Verify @namespace/path entries in prod mode resolve from the namespace-specific manifest. + */ + public function testRendersNamespacedProductionAssetsFromSeparateManifest(): void + { + $defaultManifest = $this->createManifestFile([ + 'resources/js/app.ts' => ['file' => 'assets/app-abc.js'], + ]); + $themeManifest = $this->createManifestFile([ + 'resources/js/theme.ts' => [ + 'file' => 'assets/theme-xyz.js', + 'css' => ['assets/theme-xyz.css'], + ], + ]); + $this->manifestPath = $defaultManifest; + + $loader = new StringTemplateLoader(templates: [ + 'ns-prod-page' => '', + ]); + + $engine = Engine::builder() + ->withTemplateLoader($loader) + ->withExtension(new ViteExtension( + assetBaseUrl: '/build/', + mode: 'prod', + manifestPath: $defaultManifest, + namespaces: [ + 'theme' => new ViteConfig( + assetBaseUrl: '/theme/build/', + manifestPath: $themeManifest, + ), + ], + )) + ->build(); + + $output = $engine->render('ns-prod-page'); + + $this->assertStringContainsString('/build/assets/app-abc.js', $output); + $this->assertStringContainsString('/theme/build/assets/theme-xyz.css', $output); + $this->assertStringContainsString('/theme/build/assets/theme-xyz.js', $output); + + unlink($themeManifest); + } + + /** + * Verify that default entries from the root config still work with namespace entries mixed in. + */ + public function testDefaultEntryIsUnaffectedByNamespaceEntries(): void + { + $manifest = $this->createManifestFile([ + 'resources/js/app.ts' => ['file' => 'assets/app-abc.js'], + ]); + $this->manifestPath = $manifest; + + $loader = new StringTemplateLoader(templates: [ + 'ns-default-entry-page' => '', + ]); + + $engine = Engine::builder() + ->withTemplateLoader($loader) + ->withExtension(new ViteExtension( + assetBaseUrl: '/build/', + mode: 'prod', + manifestPath: $manifest, + defaultEntry: 'resources/js/app.ts', + namespaces: [ + 'theme' => new ViteConfig(assetBaseUrl: '/theme/build/'), + ], + )) + ->build(); + + $output = $engine->render('ns-default-entry-page'); + + $this->assertStringContainsString('/build/assets/app-abc.js', $output); + } + + /** + * Verify that @namespace/path with a namespace-scoped defaultEntry resolves correctly in prod. + */ + public function testNamespacedDefaultEntryIsUsedWhenSpecIsTrue(): void + { + $themeManifest = $this->createManifestFile([ + 'resources/js/theme.ts' => ['file' => 'assets/theme-xyz.js'], + ]); + + $loader = new StringTemplateLoader(templates: [ + 'ns-default-ns-entry-page' => '', + ]); + + $rootManifest = $this->createManifestFile([ + 'resources/js/app.ts' => ['file' => 'assets/app-abc.js'], + ]); + $this->manifestPath = $rootManifest; + + $engine = Engine::builder() + ->withTemplateLoader($loader) + ->withExtension(new ViteExtension( + assetBaseUrl: '/build/', + mode: 'prod', + manifestPath: $rootManifest, + defaultEntry: 'resources/js/app.ts', + namespaces: [ + 'theme' => new ViteConfig( + assetBaseUrl: '/theme/build/', + manifestPath: $themeManifest, + ), + ], + )) + ->build(); + + $output = $engine->render('ns-default-ns-entry-page'); + + $this->assertStringContainsString('/build/assets/app-abc.js', $output); + $this->assertStringContainsString('/theme/build/assets/theme-xyz.js', $output); + + unlink($themeManifest); + } + + /** + * Verify that an unregistered namespace throws a descriptive exception. + */ + public function testUnregisteredNamespaceThrowsException(): void + { + $loader = new StringTemplateLoader(templates: [ + 'ns-unknown-page' => '', + ]); + + $engine = Engine::builder() + ->withTemplateLoader($loader) + ->withExtension(new ViteExtension( + assetBaseUrl: '/build/', + mode: 'dev', + namespaces: [ + 'theme' => new ViteConfig(assetBaseUrl: '/theme/build/'), + ], + )) + ->build(); + + $this->expectException(TemplateRuntimeException::class); + $this->expectExceptionMessageMatches('/@unknown/'); + $this->expectExceptionMessageMatches('/@theme/'); + + $engine->render('ns-unknown-page'); + } + + /** + * Verify that namespaced entries in dev mode are each deduplicated independently. + */ + public function testNamespacedEntriesAreDeduplicatedIndependently(): void + { + $loader = new StringTemplateLoader(templates: [ + 'ns-dedupe-page' => implode('', [ + '', + '', + ]), + ]); + + $engine = Engine::builder() + ->withTemplateLoader($loader) + ->withExtension(new ViteExtension( + assetBaseUrl: '/build/', + mode: 'dev', + devServerUrl: 'http://localhost:5173', + namespaces: [ + 'theme' => new ViteConfig( + assetBaseUrl: '/theme/build/', + devServerUrl: 'http://localhost:5174', + ), + ], + )) + ->build(); + + $output = $engine->render('ns-dedupe-page'); + + $this->assertSame(1, substr_count($output, 'http://localhost:5174/@vite/client')); + $this->assertSame(1, substr_count($output, 'resources/js/theme.ts')); + } + /** * Create a temporary Vite manifest JSON file for tests. * diff --git a/tests/Unit/Extension/Vite/Runtime/ViteAssetResolverTest.php b/tests/Unit/Extension/Vite/Runtime/ViteAssetResolverTest.php index 30ec4af..690858b 100644 --- a/tests/Unit/Extension/Vite/Runtime/ViteAssetResolverTest.php +++ b/tests/Unit/Extension/Vite/Runtime/ViteAssetResolverTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Sugar\Core\Exception\TemplateRuntimeException; use Sugar\Extension\Vite\Runtime\ViteAssetResolver; +use Sugar\Extension\Vite\ViteConfig; /** * Tests ViteAssetResolver runtime behavior. @@ -21,20 +22,41 @@ protected function tearDown(): void } } + /** + * Build a ViteAssetResolver using the default (flat) config for brevity in tests. + * + * @param array $namespaces + */ + private function makeResolver( + string $mode = 'dev', + bool $debug = true, + ?string $manifestPath = null, + string $assetBaseUrl = '/build/', + string $devServerUrl = 'http://localhost:5173', + bool $injectClient = true, + ?string $defaultEntry = null, + array $namespaces = [], + ): ViteAssetResolver { + return new ViteAssetResolver( + mode: $mode, + debug: $debug, + default: new ViteConfig( + assetBaseUrl: $assetBaseUrl, + manifestPath: $manifestPath, + devServerUrl: $devServerUrl, + injectClient: $injectClient, + defaultEntry: $defaultEntry, + ), + namespaces: $namespaces, + ); + } + /** * Verify development output injects client and deduplicates repeated entries. */ public function testDevelopmentModeInjectsClientAndDeduplicates(): void { - $resolver = new ViteAssetResolver( - mode: 'dev', - debug: true, - manifestPath: null, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(injectClient: true); $first = $resolver->render('resources/js/app.ts'); $second = $resolver->render('resources/js/app.ts'); @@ -56,15 +78,7 @@ public function testProductionModeRendersManifestAssets(): void ], ]); - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false, manifestPath: $this->manifestPath); $output = $resolver->render('resources/js/app.ts'); @@ -86,15 +100,7 @@ public function testProductionModeCssEntryRendersStylesheetTag(): void ], ]); - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false, manifestPath: $this->manifestPath); $output = $resolver->render('resources/assets/css/app.css'); @@ -107,15 +113,7 @@ public function testProductionModeCssEntryRendersStylesheetTag(): void */ public function testProductionModeWithoutManifestPathThrowsException(): void { - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: null, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false); $this->expectException(TemplateRuntimeException::class); $this->expectExceptionMessage('Vite manifest path is required in production mode.'); @@ -131,15 +129,7 @@ public function testProductionModeWithoutConfiguredAssetBaseUrlThrowsException() $this->expectException(TemplateRuntimeException::class); $this->expectExceptionMessage('Vite assetBaseUrl must be configured and non-empty.'); - new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $this->makeResolver(mode: 'prod', debug: false, assetBaseUrl: ''); } /** @@ -147,15 +137,7 @@ public function testProductionModeWithoutConfiguredAssetBaseUrlThrowsException() */ public function testBooleanSpecificationUsesDefaultEntry(): void { - $resolver = new ViteAssetResolver( - mode: 'dev', - debug: true, - manifestPath: null, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: false, - defaultEntry: 'resources/js/default.ts', - ); + $resolver = $this->makeResolver(injectClient: false, defaultEntry: 'resources/js/default.ts'); $output = $resolver->render(true); @@ -167,15 +149,7 @@ public function testBooleanSpecificationUsesDefaultEntry(): void */ public function testNullSpecificationWithoutDefaultEntryReturnsEmptyString(): void { - $resolver = new ViteAssetResolver( - mode: 'dev', - debug: true, - manifestPath: null, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: false, - defaultEntry: null, - ); + $resolver = $this->makeResolver(injectClient: false); $output = $resolver->render(null); @@ -187,15 +161,7 @@ public function testNullSpecificationWithoutDefaultEntryReturnsEmptyString(): vo */ public function testEntryOptionArrayRendersNormalizedEntry(): void { - $resolver = new ViteAssetResolver( - mode: 'dev', - debug: true, - manifestPath: null, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: false, - defaultEntry: null, - ); + $resolver = $this->makeResolver(injectClient: false); $output = $resolver->render(['entry' => ' resources/js/app.ts ']); @@ -207,15 +173,7 @@ public function testEntryOptionArrayRendersNormalizedEntry(): void */ public function testEntriesOptionArrayFiltersNonStringValues(): void { - $resolver = new ViteAssetResolver( - mode: 'dev', - debug: true, - manifestPath: null, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: false, - defaultEntry: null, - ); + $resolver = $this->makeResolver(injectClient: false); $output = $resolver->render([ 'entries' => [' resources/js/a.ts ', 42, '', 'resources/js/b.ts'], @@ -231,15 +189,7 @@ public function testEntriesOptionArrayFiltersNonStringValues(): void */ public function testListSpecificationFiltersInvalidEntries(): void { - $resolver = new ViteAssetResolver( - mode: 'dev', - debug: true, - manifestPath: null, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: false, - defaultEntry: null, - ); + $resolver = $this->makeResolver(injectClient: false); $output = $resolver->render([' resources/js/a.ts ', null, '', 'resources/js/b.ts']); @@ -252,15 +202,7 @@ public function testListSpecificationFiltersInvalidEntries(): void */ public function testUnsupportedSpecificationTypeThrowsException(): void { - $resolver = new ViteAssetResolver( - mode: 'dev', - debug: true, - manifestPath: null, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: false, - defaultEntry: null, - ); + $resolver = $this->makeResolver(injectClient: false); $this->expectException(TemplateRuntimeException::class); $this->expectExceptionMessage('s:vite expects a string, list, or options array expression.'); @@ -279,15 +221,7 @@ public function testAutoModeUsesProductionWhenDebugDisabled(): void ], ]); - $resolver = new ViteAssetResolver( - mode: 'auto', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'auto', debug: false, manifestPath: $this->manifestPath); $output = $resolver->render('resources/js/app.ts'); @@ -306,15 +240,7 @@ public function testProductionModeResolvesLeadingSlashEntryNames(): void ], ]); - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false, manifestPath: $this->manifestPath); $output = $resolver->render('/resources/js/app.ts'); @@ -339,15 +265,7 @@ public function testProductionModeCollectsCssFromImportsAndDeduplicates(): void ], ]); - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false, manifestPath: $this->manifestPath); $output = $resolver->render('resources/js/app.ts'); @@ -366,15 +284,7 @@ public function testProductionModeMissingManifestEntryThrowsException(): void ], ]); - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false, manifestPath: $this->manifestPath); $this->expectException(TemplateRuntimeException::class); $this->expectExceptionMessage('Vite manifest entry "resources/js/app.ts" was not found.'); @@ -393,15 +303,7 @@ public function testProductionModeInvalidManifestEntryThrowsException(): void ], ]); - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false, manifestPath: $this->manifestPath); $this->expectException(TemplateRuntimeException::class); $this->expectExceptionMessage('Invalid Vite manifest entry for "resources/js/app.ts".'); @@ -417,15 +319,7 @@ public function testProductionModeInvalidManifestJsonThrowsException(): void $this->manifestPath = sys_get_temp_dir() . '/sugar-vite-invalid-' . uniqid('', true) . '.json'; file_put_contents($this->manifestPath, '{invalid-json'); - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false, manifestPath: $this->manifestPath); $this->expectException(TemplateRuntimeException::class); $this->expectExceptionMessage('contains invalid JSON'); @@ -438,14 +332,10 @@ public function testProductionModeInvalidManifestJsonThrowsException(): void */ public function testProductionModeWithMissingManifestFileThrowsException(): void { - $resolver = new ViteAssetResolver( + $resolver = $this->makeResolver( mode: 'prod', debug: false, manifestPath: sys_get_temp_dir() . '/sugar-vite-missing-' . uniqid('', true) . '.json', - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, ); $this->expectException(TemplateRuntimeException::class); @@ -464,15 +354,7 @@ public function testProductionModeIgnoresNonStringManifestKeys(): void ['file' => 'assets/app.js'], ], JSON_THROW_ON_ERROR)); - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false, manifestPath: $this->manifestPath); $this->expectException(TemplateRuntimeException::class); $this->expectExceptionMessage('Vite manifest entry "resources/js/app.ts" was not found.'); @@ -492,15 +374,7 @@ public function testProductionModeCssAssetWithQueryStringRendersStylesheetTag(): ], ]); - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false, manifestPath: $this->manifestPath); $output = $resolver->render('resources/assets/css/app.css'); @@ -519,15 +393,7 @@ public function testProductionModeReusesLoadedManifestAcrossRenders(): void ], ]); - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false, manifestPath: $this->manifestPath); $first = $resolver->render('resources/js/app.ts'); $second = $resolver->render('resources/js/app.ts'); @@ -550,15 +416,7 @@ public function testProductionModeCollectCssHandlesCyclesAndNonArrayImports(): v 'dep.ts' => 'invalid-meta', ]); - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false, manifestPath: $this->manifestPath); $output = $resolver->render('resources/js/app.ts'); @@ -574,15 +432,7 @@ public function testProductionModeWithEmptyManifestFileThrowsException(): void $this->manifestPath = sys_get_temp_dir() . '/sugar-vite-empty-' . uniqid('', true) . '.json'; file_put_contents($this->manifestPath, ''); - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false, manifestPath: $this->manifestPath); $this->expectException(TemplateRuntimeException::class); $this->expectExceptionMessage('is empty or unreadable'); @@ -601,15 +451,7 @@ public function testProductionModeSlashOnlyBuildBaseNormalizesToRoot(): void ], ]); - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '///', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false, manifestPath: $this->manifestPath, assetBaseUrl: '///'); $output = $resolver->render('resources/js/app.ts'); @@ -627,15 +469,7 @@ public function testProductionModeSingleSlashBuildBaseNormalizesToRoot(): void ], ]); - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false, manifestPath: $this->manifestPath, assetBaseUrl: '/'); $output = $resolver->render('resources/js/app.ts'); @@ -654,15 +488,7 @@ public function testProductionModeUsesExplicitAssetBaseUrlWhenConfigured(): void ], ]); - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: '/assets/build', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false, manifestPath: $this->manifestPath, assetBaseUrl: '/assets/build'); $output = $resolver->render('resources/assets/js/site.js'); @@ -682,15 +508,7 @@ public function testProductionModeSupportsAbsoluteAssetBaseUrl(): void ], ]); - $resolver = new ViteAssetResolver( - mode: 'prod', - debug: false, - manifestPath: $this->manifestPath, - assetBaseUrl: 'https://cdn.example.com/build/', - devServerUrl: 'http://localhost:5173', - injectClient: true, - defaultEntry: null, - ); + $resolver = $this->makeResolver(mode: 'prod', debug: false, manifestPath: $this->manifestPath, assetBaseUrl: 'https://cdn.example.com/build/'); $output = $resolver->render('resources/assets/js/site.js'); @@ -709,4 +527,211 @@ private function createManifestFile(array $manifest): string return $path; } + + // ---------------------------------------------------------------- + // Namespace tests + // ---------------------------------------------------------------- + + /** + * Verify @namespace/path dev entries route to the namespace-specific dev server URL. + */ + public function testNamespacedEntryDevModeRoutesToNamespaceDevServer(): void + { + $resolver = $this->makeResolver( + mode: 'dev', + devServerUrl: 'http://localhost:5173', + namespaces: [ + 'theme' => new ViteConfig( + assetBaseUrl: '/theme/build/', + devServerUrl: 'http://localhost:5174', + ), + ], + ); + + $output = $resolver->render('@theme/resources/js/theme.ts'); + + $this->assertStringContainsString('http://localhost:5174/@vite/client', $output); + $this->assertStringContainsString('http://localhost:5174/resources/js/theme.ts', $output); + $this->assertStringNotContainsString('http://localhost:5173', $output); + } + + /** + * Verify @namespace/path dev entries fall back to root dev server when namespace has none. + */ + public function testNamespacedEntryDevModeFallsBackToRootDevServer(): void + { + $resolver = $this->makeResolver( + mode: 'dev', + devServerUrl: 'http://localhost:5173', + namespaces: [ + 'theme' => new ViteConfig(assetBaseUrl: '/theme/build/'), + ], + ); + + $output = $resolver->render('@theme/resources/js/theme.ts'); + + $this->assertStringContainsString('http://localhost:5173/resources/js/theme.ts', $output); + } + + /** + * Verify @namespace/path prod entries resolve from the namespace manifest and base URL. + */ + public function testNamespacedEntryProdModeUsesNamespaceManifestAndBaseUrl(): void + { + $themeManifest = $this->createManifestFile([ + 'resources/js/theme.ts' => [ + 'file' => 'assets/theme-xyz.js', + 'css' => ['assets/theme-xyz.css'], + ], + ]); + + $resolver = $this->makeResolver( + mode: 'prod', + debug: false, + namespaces: [ + 'theme' => new ViteConfig( + assetBaseUrl: '/theme/build/', + manifestPath: $themeManifest, + ), + ], + ); + + $output = $resolver->render('@theme/resources/js/theme.ts'); + + $this->assertStringContainsString('/theme/build/assets/theme-xyz.css', $output); + $this->assertStringContainsString('/theme/build/assets/theme-xyz.js', $output); + $this->assertStringNotContainsString('src="/build/assets/', $output); + + unlink($themeManifest); + } + + /** + * Verify separate namespace manifests are cached independently from each other. + */ + public function testNamespaceManifestsAreCachedIndependently(): void + { + $defaultManifest = $this->createManifestFile([ + 'resources/js/app.ts' => ['file' => 'assets/app-abc.js'], + ]); + $themeManifest = $this->createManifestFile([ + 'resources/js/theme.ts' => ['file' => 'assets/theme-xyz.js'], + ]); + $this->manifestPath = $defaultManifest; + + $resolver = $this->makeResolver( + mode: 'prod', + debug: false, + manifestPath: $defaultManifest, + namespaces: [ + 'theme' => new ViteConfig( + assetBaseUrl: '/theme/build/', + manifestPath: $themeManifest, + ), + ], + ); + + $defaultOutput = $resolver->render('resources/js/app.ts'); + $themeOutput = $resolver->render('@theme/resources/js/theme.ts'); + + $this->assertStringContainsString('/build/assets/app-abc.js', $defaultOutput); + $this->assertStringContainsString('/theme/build/assets/theme-xyz.js', $themeOutput); + + unlink($themeManifest); + } + + /** + * Verify an unregistered namespace throws a descriptive exception listing known namespaces. + */ + public function testUnregisteredNamespaceThrowsDescriptiveException(): void + { + $resolver = $this->makeResolver( + mode: 'dev', + namespaces: [ + 'theme' => new ViteConfig(assetBaseUrl: '/theme/build/'), + ], + ); + + $this->expectException(TemplateRuntimeException::class); + $this->expectExceptionMessageMatches('/@unknown/'); + $this->expectExceptionMessageMatches('/@theme/'); + + $resolver->render('@unknown/resources/js/app.ts'); + } + + /** + * Verify the error message for an unregistered namespace states "(none)" when no namespaces exist. + */ + public function testUnregisteredNamespaceWithNoNamespacesRegisteredMentionsNone(): void + { + $resolver = $this->makeResolver(mode: 'dev'); + + $this->expectException(TemplateRuntimeException::class); + $this->expectExceptionMessageMatches('/\(none\)/'); + + $resolver->render('@theme/resources/js/app.ts'); + } + + /** + * Verify per-namespace @vite/client injection is tracked independently. + */ + public function testNamespacedClientInjectionTrackedPerNamespace(): void + { + $resolver = $this->makeResolver( + mode: 'dev', + devServerUrl: 'http://localhost:5173', + injectClient: true, + namespaces: [ + 'theme' => new ViteConfig( + assetBaseUrl: '/theme/build/', + devServerUrl: 'http://localhost:5174', + injectClient: true, + ), + ], + ); + + $defaultOutput = $resolver->render('resources/js/app.ts'); + $themeOutput = $resolver->render('@theme/resources/js/theme.ts'); + + $this->assertSame(1, substr_count($defaultOutput . $themeOutput, 'http://localhost:5173/@vite/client')); + $this->assertSame(1, substr_count($defaultOutput . $themeOutput, 'http://localhost:5174/@vite/client')); + } + + /** + * Verify namespaced entries are deduplicated independently from default entries. + */ + public function testNamespacedEntriesDeduplicatedIndependently(): void + { + $resolver = $this->makeResolver( + mode: 'dev', + injectClient: false, + namespaces: [ + 'theme' => new ViteConfig( + assetBaseUrl: '/theme/build/', + injectClient: false, + ), + ], + ); + + $first = $resolver->render('@theme/resources/js/theme.ts'); + $second = $resolver->render('@theme/resources/js/theme.ts'); + + $this->assertStringContainsString('resources/js/theme.ts', $first); + $this->assertSame('', $second); + } + + /** + * Verify ViteConfig defaultEntry is used for the namespace when spec has no path. + */ + public function testNamespaceDefaultEntryIsUsedInDevMode(): void + { + $resolver = $this->makeResolver( + mode: 'dev', + injectClient: false, + defaultEntry: 'resources/js/app.ts', + ); + + $output = $resolver->render(true); + + $this->assertStringContainsString('resources/js/app.ts', $output); + } } diff --git a/tests/Unit/Extension/Vite/ViteConfigTest.php b/tests/Unit/Extension/Vite/ViteConfigTest.php new file mode 100644 index 0000000..dc006b8 --- /dev/null +++ b/tests/Unit/Extension/Vite/ViteConfigTest.php @@ -0,0 +1,46 @@ +assertSame('/build/', $config->assetBaseUrl); + $this->assertSame('/var/www/build/.vite/manifest.json', $config->manifestPath); + $this->assertSame('http://localhost:5174', $config->devServerUrl); + $this->assertFalse($config->injectClient); + $this->assertSame('resources/js/app.ts', $config->defaultEntry); + } + + /** + * Verify optional properties default to null/true when not specified. + */ + public function testOptionalPropertiesHaveCorrectDefaults(): void + { + $config = new ViteConfig(assetBaseUrl: '/build/'); + + $this->assertNull($config->manifestPath); + $this->assertNull($config->devServerUrl); + $this->assertTrue($config->injectClient); + $this->assertNull($config->defaultEntry); + } +} diff --git a/tests/Unit/Extension/Vite/ViteExtensionTest.php b/tests/Unit/Extension/Vite/ViteExtensionTest.php index bedd736..af6aaaa 100644 --- a/tests/Unit/Extension/Vite/ViteExtensionTest.php +++ b/tests/Unit/Extension/Vite/ViteExtensionTest.php @@ -15,6 +15,7 @@ use Sugar\Core\Parser\Parser; use Sugar\Extension\Vite\Directive\ViteDirective; use Sugar\Extension\Vite\Runtime\ViteAssetResolver; +use Sugar\Extension\Vite\ViteConfig; use Sugar\Extension\Vite\ViteExtension; /** @@ -66,6 +67,44 @@ public function testResolverServiceClosureMaterializesResolver(): void $this->assertInstanceOf(ViteAssetResolver::class, $resolver); } + /** + * Verify namespace configs passed to ViteExtension result in a resolver with namespace support. + */ + public function testNamespaceConfigsArePassedToResolver(): void + { + $context = $this->createRegistrationContext(); + $extension = new ViteExtension( + assetBaseUrl: '/build/', + mode: 'dev', + namespaces: [ + 'theme' => new ViteConfig( + assetBaseUrl: '/theme/build/', + devServerUrl: 'http://localhost:5174', + ), + ], + ); + + $extension->register($context); + + $services = $context->getRuntimeServices(); + $this->assertArrayHasKey(ViteAssetResolver::class, $services); + $this->assertInstanceOf(Closure::class, $services[ViteAssetResolver::class]); + + $resolverFactory = $services[ViteAssetResolver::class]; + + $runtimeContext = new RuntimeContext( + compiler: $this->createStub(CompilerInterface::class), + tracker: null, + ); + + $resolver = $resolverFactory($runtimeContext); + $this->assertInstanceOf(ViteAssetResolver::class, $resolver); + + // Rendering a namespaced entry must use the namespace's dev server URL. + $output = $resolver->render('@theme/resources/js/theme.ts'); + $this->assertStringContainsString('http://localhost:5174/resources/js/theme.ts', $output); + } + /** * Create a registration context fixture for extension tests. */ From 7860550c1d27a496c40d87da25624dc8a6908729 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 11 Mar 2026 19:41:22 +0100 Subject: [PATCH 2/3] handle copilot comments --- docs/content/extensions/vite.dj | 8 +- .../Vite/Runtime/ViteAssetResolver.php | 32 ++++- .../Vite/Runtime/ViteAssetResolverTest.php | 116 +++++++++++++++++- 3 files changed, 150 insertions(+), 6 deletions(-) diff --git a/docs/content/extensions/vite.dj b/docs/content/extensions/vite.dj index a33d132..a84b274 100644 --- a/docs/content/extensions/vite.dj +++ b/docs/content/extensions/vite.dj @@ -93,6 +93,12 @@ Reference a namespace entry in templates using the `@name/` prefix: ``` +When a namespace has a `defaultEntry` configured, you can use the bare `@name` shorthand to load it without specifying an explicit path: + +```sugar + +``` + Multiple entries from different namespaces can be combined in one directive: ```sugar @@ -121,7 +127,7 @@ When `devServerUrl` is omitted from a `ViteConfig`, the namespace falls back to - `manifestPath`: Absolute filesystem path to the Vite `manifest.json`. - `devServerUrl`: Dev server origin for this namespace. Falls back to the root `devServerUrl` when omitted. - `injectClient`: Whether to inject `@vite/client` for this namespace in development mode. Defaults to `true`. -- `defaultEntry`: Default entry used when `s:vite` is boolean for this namespace. +- `defaultEntry`: Default entry resolved when the namespace is referenced without an explicit path (e.g. `'@theme'` or `'@theme/'`). Throws at render time if the shorthand is used and `defaultEntry` is not configured. ## Production recommendation diff --git a/src/Extension/Vite/Runtime/ViteAssetResolver.php b/src/Extension/Vite/Runtime/ViteAssetResolver.php index 09635c2..4669dbb 100644 --- a/src/Extension/Vite/Runtime/ViteAssetResolver.php +++ b/src/Extension/Vite/Runtime/ViteAssetResolver.php @@ -104,7 +104,8 @@ private function parseEntry(string $entry): array $slashPos = strpos($entry, '/'); if ($slashPos === false) { - return [null, $entry]; + // "@namespace" with no slash — treat as namespace shorthand with empty path. + return [substr($entry, 1), '']; } return [substr($entry, 1, $slashPos - 1), substr($entry, $slashPos + 1)]; @@ -135,7 +136,16 @@ private function resolveConfig(?string $namespace): ViteConfig )); } - return $this->namespaces[$namespace]; + $config = $this->namespaces[$namespace]; + + if (trim($config->assetBaseUrl) === '') { + throw new TemplateRuntimeException(sprintf( + 'Vite namespace "@%s" has an empty assetBaseUrl; a non-empty assetBaseUrl is required.', + $namespace, + )); + } + + return $config; } /** @@ -162,7 +172,22 @@ private function normalizeEntries(mixed $spec): array if ($path !== '') { $result[] = [$namespace, $path]; + continue; + } + + // Empty path — resolve via the (namespace) config's configured defaultEntry. + $config = $this->resolveConfig($namespace); + if ($config->defaultEntry === null || trim($config->defaultEntry) === '') { + $nsLabel = $namespace === null + ? 'default namespace' + : sprintf('namespace "@%s"', $namespace); + throw new TemplateRuntimeException(sprintf( + 'Vite %s was requested with an empty entry path, but no defaultEntry is configured.', + $nsLabel, + )); } + + $result[] = [$namespace, $config->defaultEntry]; } return $result; @@ -247,8 +272,9 @@ private function renderDevelopmentTags(array $entries): string $deduped = $this->emitTag($tag); if ($deduped !== null) { $tags[] = $deduped; - $this->injectedClients[$configKey] = true; } + + $this->injectedClients[$configKey] = true; } $entryUrl = $this->normalizeDevUrl($path, $devServerUrl); diff --git a/tests/Unit/Extension/Vite/Runtime/ViteAssetResolverTest.php b/tests/Unit/Extension/Vite/Runtime/ViteAssetResolverTest.php index 690858b..4f5d43f 100644 --- a/tests/Unit/Extension/Vite/Runtime/ViteAssetResolverTest.php +++ b/tests/Unit/Extension/Vite/Runtime/ViteAssetResolverTest.php @@ -720,9 +720,9 @@ public function testNamespacedEntriesDeduplicatedIndependently(): void } /** - * Verify ViteConfig defaultEntry is used for the namespace when spec has no path. + * Verify the root defaultEntry is used when spec is boolean true. */ - public function testNamespaceDefaultEntryIsUsedInDevMode(): void + public function testDefaultEntryIsUsedInDevMode(): void { $resolver = $this->makeResolver( mode: 'dev', @@ -734,4 +734,116 @@ public function testNamespaceDefaultEntryIsUsedInDevMode(): void $this->assertStringContainsString('resources/js/app.ts', $output); } + + /** + * Verify "@namespace" shorthand (no trailing slash) resolves via the namespace defaultEntry. + */ + public function testNamespaceShorthandUsesNamespaceDefaultEntry(): void + { + $resolver = $this->makeResolver( + mode: 'dev', + injectClient: false, + namespaces: [ + 'theme' => new ViteConfig( + assetBaseUrl: '/theme/build/', + devServerUrl: 'http://localhost:5174', + injectClient: false, + defaultEntry: 'resources/js/theme.ts', + ), + ], + ); + + $output = $resolver->render('@theme'); + + $this->assertStringContainsString('http://localhost:5174/resources/js/theme.ts', $output); + } + + /** + * Verify "@namespace/" (empty path after slash) also resolves via the namespace defaultEntry. + */ + public function testNamespaceEmptyPathUsesNamespaceDefaultEntry(): void + { + $resolver = $this->makeResolver( + mode: 'dev', + injectClient: false, + namespaces: [ + 'theme' => new ViteConfig( + assetBaseUrl: '/theme/build/', + devServerUrl: 'http://localhost:5174', + injectClient: false, + defaultEntry: 'resources/js/theme.ts', + ), + ], + ); + + $output = $resolver->render('@theme/'); + + $this->assertStringContainsString('http://localhost:5174/resources/js/theme.ts', $output); + } + + /** + * Verify "@namespace" shorthand throws when no defaultEntry is configured for the namespace. + */ + public function testNamespaceShorthandWithoutDefaultEntryThrows(): void + { + $resolver = $this->makeResolver( + mode: 'dev', + namespaces: [ + 'theme' => new ViteConfig(assetBaseUrl: '/theme/build/'), + ], + ); + + $this->expectException(TemplateRuntimeException::class); + $this->expectExceptionMessageMatches('/namespace "@theme"/i'); + $this->expectExceptionMessageMatches('/no defaultEntry is configured/i'); + + $resolver->render('@theme'); + } + + /** + * Verify a namespace with an empty assetBaseUrl throws a descriptive exception. + */ + public function testNamespaceEmptyAssetBaseUrlThrowsException(): void + { + $resolver = $this->makeResolver( + mode: 'dev', + namespaces: [ + 'theme' => new ViteConfig(assetBaseUrl: ''), + ], + ); + + $this->expectException(TemplateRuntimeException::class); + $this->expectExceptionMessageMatches('/namespace "@theme"/i'); + $this->expectExceptionMessageMatches('/empty assetBaseUrl/i'); + + $resolver->render('@theme/resources/js/theme.ts'); + } + + /** + * Verify @vite/client is marked as injected even when its tag is deduped by emitTag(). + */ + public function testClientInjectionTrackedEvenWhenTagIsDeduped(): void + { + $resolver = $this->makeResolver( + mode: 'dev', + devServerUrl: 'http://localhost:5173', + injectClient: true, + namespaces: [ + 'alias' => new ViteConfig( + assetBaseUrl: '/build/', + devServerUrl: 'http://localhost:5173', + injectClient: true, + ), + ], + ); + + // Renders the client for the default namespace (and deduplicates it for "alias" since same URL). + $resolver->render('resources/js/app.ts'); + // The "alias" namespace shares the same dev server URL; the client tag is deduped but should + // still be considered injected, so no duplicate attempt is made on subsequent renders. + $secondOutput = $resolver->render('@alias/resources/js/other.ts'); + + // The second render should NOT contain @vite/client again. + $this->assertStringNotContainsString('@vite/client', $secondOutput); + } } From fc1122032008e0eb8d9ee2a0f5a3726acf877338 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 11 Mar 2026 19:49:28 +0100 Subject: [PATCH 3/3] copilit feedback pt2 --- src/Extension/Vite/Runtime/ViteAssetResolver.php | 6 ++++-- src/Extension/Vite/ViteConfig.php | 4 +++- src/Extension/Vite/ViteExtension.php | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Extension/Vite/Runtime/ViteAssetResolver.php b/src/Extension/Vite/Runtime/ViteAssetResolver.php index 4669dbb..156df0a 100644 --- a/src/Extension/Vite/Runtime/ViteAssetResolver.php +++ b/src/Extension/Vite/Runtime/ViteAssetResolver.php @@ -187,7 +187,7 @@ private function normalizeEntries(mixed $spec): array )); } - $result[] = [$namespace, $config->defaultEntry]; + $result[] = [$namespace, trim($config->defaultEntry)]; } return $result; @@ -202,7 +202,9 @@ private function normalizeEntries(mixed $spec): array private function normalizeRawEntries(mixed $spec): array { if ($spec === null || $spec === true) { - return $this->default->defaultEntry !== null ? [$this->default->defaultEntry] : []; + $defaultEntry = $this->default->defaultEntry !== null ? trim($this->default->defaultEntry) : null; + + return $defaultEntry !== null && $defaultEntry !== '' ? [$defaultEntry] : []; } if (is_string($spec)) { diff --git a/src/Extension/Vite/ViteConfig.php b/src/Extension/Vite/ViteConfig.php index da2131a..01f416a 100644 --- a/src/Extension/Vite/ViteConfig.php +++ b/src/Extension/Vite/ViteConfig.php @@ -27,7 +27,9 @@ * @param string|null $manifestPath Absolute path to the Vite manifest file used in production mode * @param string|null $devServerUrl Vite dev server origin; when null the resolver falls back to the root config value * @param bool $injectClient Whether to inject `@vite/client` in development mode - * @param string|null $defaultEntry Default entry used when directive spec is boolean true or null + * @param string|null $defaultEntry Default entry path for this config. For the root/default config it is used when + * the directive spec is boolean true or null. For namespace configs it is resolved when the namespace is referenced + * without an explicit path (e.g. `'@theme'` or `'@theme/'`). */ public function __construct( public string $assetBaseUrl, diff --git a/src/Extension/Vite/ViteExtension.php b/src/Extension/Vite/ViteExtension.php index 7fcda1c..e0982ae 100644 --- a/src/Extension/Vite/ViteExtension.php +++ b/src/Extension/Vite/ViteExtension.php @@ -31,7 +31,7 @@ * devServerUrl: 'http://localhost:5174', * ), * ], - * ) + * ); * ``` */ final readonly class ViteExtension implements ExtensionInterface