diff --git a/docs/content/extensions/vite.dj b/docs/content/extensions/vite.dj
index 71ff3fc..a84b274 100644
--- a/docs/content/extensions/vite.dj
+++ b/docs/content/extensions/vite.dj
@@ -60,6 +60,55 @@ 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
+
+```
+
+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
+
+```
+
+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 +117,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 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
-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..156df0a 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,121 @@ 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) {
+ // "@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)];
+ }
+
+ /**
+ * 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),
+ )),
+ ));
+ }
+
+ $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;
+ }
+
+ /**
+ * 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];
+ 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, trim($config->defaultEntry)];
+ }
+
+ 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];
+ $defaultEntry = $this->default->defaultEntry !== null ? trim($this->default->defaultEntry) : null;
+
+ return $defaultEntry !== null && $defaultEntry !== '' ? [$defaultEntry] : [];
}
if (is_string($spec)) {
@@ -114,61 +221,65 @@ 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 +293,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 +323,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 +419,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 +458,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 +471,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..01f416a
--- /dev/null
+++ b/src/Extension/Vite/ViteConfig.php
@@ -0,0 +1,42 @@
+ 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..4f5d43f 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,323 @@ 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 the root defaultEntry is used when spec is boolean true.
+ */
+ public function testDefaultEntryIsUsedInDevMode(): void
+ {
+ $resolver = $this->makeResolver(
+ mode: 'dev',
+ injectClient: false,
+ defaultEntry: 'resources/js/app.ts',
+ );
+
+ $output = $resolver->render(true);
+
+ $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);
+ }
}
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.
*/