diff --git a/README.md b/README.md
index 98c8f21..6e9cc63 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@ A [Vite.js](https://vitejs.dev/) integration for CakePHP 5.0+ applications. Seam
- [Plugin Assets](#plugin-assets)
- [Multiple Entry Points](#multiple-entry-points)
- [Custom Attributes](#custom-attributes)
+ - [Inline Output](#inline-output)
- [Preloading Assets](#preloading-assets)
- [Caching](#caching)
- [Multiple Configurations](#multiple-configurations)
@@ -487,6 +488,52 @@ $this->Vite->script([
= $this->fetch('custom_scripts') ?>
```
+### Inline Output
+
+By default, `script()` and `css()` append tags to view blocks for rendering in layouts.
+Set `block => false` to return tags as a string for inline output:
+
+```php
+Vite->script(['files' => ['src/main.js'], 'block' => false]);
+echo $this->Vite->css(['files' => ['src/style.css'], 'block' => false]);
+?>
+```
+
+This is useful when you need to render assets directly in elements or partials:
+
+```php
+
+= $this->Vite->css(['files' => ['src/component.css'], 'block' => false]) ?>
+
+
+
+```
+
+**Vite Client Deduplication:**
+
+In development mode, the `@vite/client` script is automatically deduplicated. Multiple `script()` calls (e.g., in layout, elements, components) will only output the Vite client once:
+
+```php
+// Layout
+Vite->script(['files' => ['src/app.js']]); ?>
+
+// Element
+Vite->script(['files' => ['src/element.js']]); ?>
+```
+
+**Disable Dependent CSS Injection:**
+
+In production mode, `script()` automatically injects dependent CSS from JS entries. Use `cssBlock => false` to disable this:
+
+```php
+Vite->script(['files' => ['src/main.js'], 'cssBlock' => false]);
+?>
+```
+
### Preloading Assets
CakeVite supports `modulepreload` to improve load times for applications with code splitting. Preloading hints to the browser which modules will be needed soon, allowing parallel downloads.
diff --git a/src/Service/AssetService.php b/src/Service/AssetService.php
index 1523262..27ba1a4 100644
--- a/src/Service/AssetService.php
+++ b/src/Service/AssetService.php
@@ -74,12 +74,14 @@ private function generateDevelopmentScriptTags(ViteConfig $config, array $option
{
$tags = [];
- // Add Vite client
- $tags[] = new AssetTag(
- url: $config->devServerUrl . '/@vite/client',
- type: AssetType::Script,
- attributes: ['type' => 'module'],
- );
+ // Add Vite client (skip if already rendered to avoid duplicates)
+ if (empty($options['skipViteClient'])) {
+ $tags[] = new AssetTag(
+ url: $config->devServerUrl . '/@vite/client',
+ type: AssetType::Script,
+ attributes: ['type' => 'module'],
+ );
+ }
// Add script entries
$entries = $options['files'] ?? $options['devEntries'] ?? $config->scriptEntries;
diff --git a/src/View/Helper/ViteHelper.php b/src/View/Helper/ViteHelper.php
index b8c25ab..2eae3ac 100644
--- a/src/View/Helper/ViteHelper.php
+++ b/src/View/Helper/ViteHelper.php
@@ -44,6 +44,11 @@ class ViteHelper extends Helper
*/
private ?Environment $cachedEnvironment = null;
+ /**
+ * Track if @vite/client has been rendered (optimization #3: deduplicate vite client)
+ */
+ private bool $viteClientRendered = false;
+
/**
* Check if currently in development mode
*
@@ -77,10 +82,14 @@ public function isDev(array|ViteConfig|null $config = null): bool
*
* Backwards compatible with ViteScriptsHelper::script()
*
+ * When `block` option is `false`, returns the tags as a string for inline output.
+ * Otherwise appends to the specified view block and returns null.
+ *
* @param array|string $options Options or file shorthand
* @param \CakeVite\ValueObject\ViteConfig|array|null $config Configuration
+ * @return string|null Returns tag string when block is false, null otherwise
*/
- public function script(array|string $options = [], array|ViteConfig|null $config = null): void
+ public function script(array|string $options = [], array|ViteConfig|null $config = null): ?string
{
$options = $this->normalizeOptions($options);
$config = $this->extractConfigFromOptions($options, $config);
@@ -95,26 +104,46 @@ public function script(array|string $options = [], array|ViteConfig|null $config
$block = $options['block'] ?? $config->scriptBlock;
$cssBlock = $options['cssBlock'] ?? $config->cssBlock;
+ $inline = $block === false;
+ // Pass flag to skip vite client if already rendered (deduplication)
+ $options['skipViteClient'] = $this->viteClientRendered;
$tags = $this->getAssetService()->generateScriptTags($config, $options);
+ // Mark vite client as rendered after first dev mode script() call
+ if ($this->isDev($config) && !$this->viteClientRendered) {
+ $this->viteClientRendered = true;
+ }
+
+ $output = '';
+
// Render tags (preload tags as link elements, script tags as script elements)
foreach ($tags as $tag) {
if ($tag->isPreload) {
// Render preload tags as
$preloadType = $tag->preloadType ?? 'modulepreload';
$linkTag = $this->buildPreloadLinkTag($preloadType, $tag->url, $tag->attributes);
- $this->getView()->append($block, $linkTag);
+ if ($inline) {
+ $output .= $linkTag;
+ } else {
+ $this->getView()->append($block, $linkTag);
+ }
} else {
// Render regular script tags
- $this->Html->script($tag->url, array_merge(['block' => $block], $tag->attributes));
+ // When block is false, Html::script() returns the tag string
+ $scriptTag = $this->Html->script($tag->url, array_merge(['block' => $block], $tag->attributes));
+ if ($inline && $scriptTag !== null) {
+ $output .= $scriptTag;
+ }
}
}
// Add dependent CSS if in production
if (!$this->isDev($config)) {
- $this->addDependentCss($config, $options, $cssBlock);
+ $output .= $this->addDependentCss($config, $options, $cssBlock, $inline);
}
+
+ return $inline ? $output : null;
}
/**
@@ -122,10 +151,14 @@ public function script(array|string $options = [], array|ViteConfig|null $config
*
* Backwards compatible with ViteScriptsHelper::css()
*
+ * When `block` option is `false`, returns the tags as a string for inline output.
+ * Otherwise appends to the specified view block and returns null.
+ *
* @param array|string $options Options or file shorthand
* @param \CakeVite\ValueObject\ViteConfig|array|null $config Configuration
+ * @return string|null Returns tag string when block is false, null otherwise
*/
- public function css(array|string $options = [], array|ViteConfig|null $config = null): void
+ public function css(array|string $options = [], array|ViteConfig|null $config = null): ?string
{
$options = $this->normalizeOptions($options);
$config = $this->extractConfigFromOptions($options, $config);
@@ -133,12 +166,21 @@ public function css(array|string $options = [], array|ViteConfig|null $config =
$config = $this->resolveConfig($config);
$block = $options['block'] ?? $config->cssBlock;
+ $inline = $block === false;
$tags = $this->getAssetService()->generateStyleTags($config, $options);
+ $output = '';
+
foreach ($tags as $tag) {
- $this->Html->css($tag->url, array_merge(['block' => $block], $tag->attributes));
+ // When block is false, Html::css() returns the tag string
+ $cssTag = $this->Html->css($tag->url, array_merge(['block' => $block], $tag->attributes));
+ if ($inline && $cssTag !== null) {
+ $output .= $cssTag;
+ }
}
+
+ return $inline ? $output : null;
}
/**
@@ -146,24 +188,28 @@ public function css(array|string $options = [], array|ViteConfig|null $config =
*
* Backwards compatible with ViteScriptsHelper::pluginScript()
*
+ * When `block` option is `false`, returns the tags as a string for inline output.
+ * Otherwise appends to the specified view block and returns null.
+ *
* @param string $pluginName Plugin name
* @param bool $devMode Development mode flag
* @param array $options Options
* @param \CakeVite\ValueObject\ViteConfig|array|null $config Configuration
+ * @return string|null Returns tag string when block is false, null otherwise
*/
public function pluginScript(
string $pluginName,
bool $devMode = false,
array $options = [],
array|ViteConfig|null $config = null,
- ): void {
+ ): ?string {
$config = $this->resolveConfig($config);
$config = $config->merge([
'plugin' => $pluginName,
'forceProductionMode' => !$devMode,
]);
- $this->script($options, $config);
+ return $this->script($options, $config);
}
/**
@@ -171,24 +217,28 @@ public function pluginScript(
*
* Backwards compatible with ViteScriptsHelper::pluginCss()
*
+ * When `block` option is `false`, returns the tags as a string for inline output.
+ * Otherwise appends to the specified view block and returns null.
+ *
* @param string $pluginName Plugin name
* @param bool $devMode Development mode flag
* @param array $options Options
* @param \CakeVite\ValueObject\ViteConfig|array|null $config Configuration
+ * @return string|null Returns tag string when block is false, null otherwise
*/
public function pluginCss(
string $pluginName,
bool $devMode = false,
array $options = [],
array|ViteConfig|null $config = null,
- ): void {
+ ): ?string {
$config = $this->resolveConfig($config);
$config = $config->merge([
'plugin' => $pluginName,
'forceProductionMode' => !$devMode,
]);
- $this->css($options, $config);
+ return $this->css($options, $config);
}
/**
@@ -270,12 +320,26 @@ private function extractConfigFromOptions(
/**
* Add dependent CSS from JS entries
*
+ * When cssBlock is false and inline mode is disabled, dependent CSS injection is skipped.
+ * This allows users to explicitly disable automatic CSS injection.
+ *
* @param \CakeVite\ValueObject\ViteConfig $config Configuration
* @param array $options Options
- * @param string $cssBlock CSS block name
+ * @param string|false $cssBlock CSS block name or false to disable/inline
+ * @param bool $inline Whether to return inline (when script block is false)
+ * @return string Returns CSS tags when inline is true, empty string otherwise
*/
- private function addDependentCss(ViteConfig $config, array $options, string $cssBlock): void
- {
+ private function addDependentCss(
+ ViteConfig $config,
+ array $options,
+ string|false $cssBlock,
+ bool $inline = false,
+ ): string {
+ // Skip dependent CSS injection if cssBlock is false and not in inline mode
+ if ($cssBlock === false && !$inline) {
+ return '';
+ }
+
$manifestService = new ManifestService();
$manifest = $manifestService->load($config);
@@ -287,11 +351,20 @@ private function addDependentCss(ViteConfig $config, array $options, string $css
$entries = $manifest->filterEntries();
$pluginPrefix = $config->pluginName ? $config->pluginName . '.' : '';
+ $output = '';
+
foreach ($entries as $entry) {
foreach ($entry->getDependentCssUrls() as $cssUrl) {
- $this->Html->css($pluginPrefix . $cssUrl, ['block' => $cssBlock]);
+ // When inline, pass block => false to get the tag string; otherwise use the block name
+ $blockOption = $inline ? false : $cssBlock;
+ $cssTag = $this->Html->css($pluginPrefix . $cssUrl, ['block' => $blockOption]);
+ if ($inline && $cssTag !== null) {
+ $output .= $cssTag;
+ }
}
}
+
+ return $output;
}
/**
diff --git a/tests/TestCase/View/Helper/ViteHelperTest.php b/tests/TestCase/View/Helper/ViteHelperTest.php
index f9c806e..199018a 100644
--- a/tests/TestCase/View/Helper/ViteHelperTest.php
+++ b/tests/TestCase/View/Helper/ViteHelperTest.php
@@ -727,4 +727,321 @@ public function testBuildPreloadLinkTagEscapesSpecialCharacters(): void
$this->assertStringNotContainsString('