diff --git a/src/Illuminate/Foundation/Vite.php b/src/Illuminate/Foundation/Vite.php
index 7e641ab7b60..6b96e3ab8ee 100644
--- a/src/Illuminate/Foundation/Vite.php
+++ b/src/Illuminate/Foundation/Vite.php
@@ -3,11 +3,117 @@
namespace Illuminate\Foundation;
use Exception;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Collection;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
class Vite
{
+ /**
+ * The Content Security Policy nonce to apply to all generated tags.
+ *
+ * @var string|null
+ */
+ protected static $nonce;
+
+ /**
+ * The key to check for integrity hashes within the manifest.
+ *
+ * @var string|bool
+ */
+ protected static $integrityKey = 'integrity';
+
+ /**
+ * The script tag attributes resolvers.
+ *
+ * @var array
+ */
+ protected static $scriptTagAttributesResolvers = [];
+
+ /**
+ * The style tag attributes resolvers.
+ *
+ * @var array
+ */
+ protected static $styleTagAttributesResolvers = [];
+
+ /**
+ * Generate or set a Content Security Policy nonce to apply to all generated tags.
+ *
+ * @param ?string $nonce
+ * @return string
+ */
+ public static function useCspNonce($nonce = null)
+ {
+ return static::$nonce = $nonce ?? Str::random(40);
+ }
+
+ /**
+ * Get the Content Security Policy nonce applied to all generated tags.
+ *
+ * @return string|null
+ */
+ public static function cspNonce()
+ {
+ return static::$nonce;
+ }
+
+ /**
+ * Clear the Content Security Policy nonce.
+ *
+ * @return void
+ */
+ public static function withoutCspNonce()
+ {
+ static::$nonce = null;
+ }
+
+ /**
+ * Use the given key to detect integrity hashes in the manifest.
+ *
+ * @param bool|string $key
+ * @return void
+ */
+ public static function useIntegrityKey($key)
+ {
+ static::$integrityKey = $key;
+ }
+
+ /**
+ * Use the given callback to resolve attributes for script tags.
+ *
+ * @param callable $callback
+ * return void
+ */
+ public static function useAttributesForScriptTag($callback)
+ {
+ static::$scriptTagAttributesResolvers[] = $callback;
+ }
+
+ /**
+ * Use the given callback to resolve attributes for script tags.
+ *
+ * @param callable $callback
+ * return void
+ */
+ public static function useAttributesForStyleTag($callback)
+ {
+ static::$styleTagAttributesResolvers[] = $callback;
+ }
+
+ /**
+ * Clear the attribute resolvers.
+ *
+ * @return void
+ */
+ public static function withoutAttributeResolvers()
+ {
+ static::$styleTagAttributesResolvers = [];
+
+ static::$scriptTagAttributesResolvers = [];
+ }
+
/**
* Generate Vite tags for an entrypoint.
*
@@ -29,6 +135,7 @@ public function __invoke($entrypoints, $buildDirectory = 'build')
return new HtmlString(
$entrypoints
+ // TODO: for HMR, should we just pass through an empty array for the chunk?
->map(fn ($entrypoint) => $this->makeTag("{$url}/{$entrypoint}"))
->prepend($this->makeScriptTag("{$url}/@vite/client"))
->join('')
@@ -54,21 +161,27 @@ public function __invoke($entrypoints, $buildDirectory = 'build')
throw new Exception("Unable to locate file in Vite manifest: {$entrypoint}.");
}
- $tags->push($this->makeTag(asset("{$buildDirectory}/{$manifest[$entrypoint]['file']}")));
+ $tags->push($this->makeTagForChunk(
+ asset("{$buildDirectory}/{$manifest[$entrypoint]['file']}"),
+ $manifest[$entrypoint],
+ $manifest
+ ));
- if (isset($manifest[$entrypoint]['css'])) {
- foreach ($manifest[$entrypoint]['css'] as $css) {
- $tags->push($this->makeStylesheetTag(asset("{$buildDirectory}/{$css}")));
- }
+ foreach ($manifest[$entrypoint]['css'] ?? [] as $css) {
+ $tags->push($this->makeTagForChunk(
+ asset("{$buildDirectory}/{$css}"),
+ $manifest[$entrypoint],
+ $manifest
+ ));
}
- if (isset($manifest[$entrypoint]['imports'])) {
- foreach ($manifest[$entrypoint]['imports'] as $import) {
- if (isset($manifest[$import]['css'])) {
- foreach ($manifest[$import]['css'] as $css) {
- $tags->push($this->makeStylesheetTag(asset("{$buildDirectory}/{$css}")));
- }
- }
+ foreach ($manifest[$entrypoint]['imports'] ?? [] as $import) {
+ foreach ($manifest[$import]['css'] ?? [] as $css) {
+ $tags->push(
+ $this->makeTagForChunk(asset("{$buildDirectory}/{$css}"),
+ Collection::make($manifest)->firstWhere('file', $css),
+ $manifest
+ ));
}
}
}
@@ -108,7 +221,80 @@ public function reactRefresh()
}
/**
- * Generate an appropriate tag for the given URL.
+ * Make tag for the given chunk.
+ *
+ * @param string $url
+ * @param array $chunk
+ * @param array $manifest
+ * @return string
+ */
+ protected function makeTagForChunk($url, $chunk, $manifest)
+ {
+ if (
+ static::$nonce === null
+ && ! array_key_exists(static::$integrityKey, $chunk)
+ && static::$scriptTagAttributesResolvers === []
+ && static::$styleTagAttributesResolvers === []) {
+ return $this->makeTag($url);
+ }
+
+ if ($this->isCssPath($url)) {
+ return $this->makeStylesheetTagWithAttributes(
+ $url,
+ $this->resolveStyleTagAttributes($url, $chunk, $manifest)
+ );
+ }
+
+ return $this->makeScriptTagWithAttributes(
+ $url,
+ $this->resolveScriptTagAttributes($url, $chunk, $manifest)
+ );
+ }
+
+ /**
+ * Resolve the attributes for the chunks generated script tag.
+ *
+ * @param string $url
+ * @param array $chunk
+ * @param array $manifest
+ * @return array
+ */
+ protected function resolveScriptTagAttributes($url, $chunk, $manifest)
+ {
+ $attributes = static::$integrityKey
+ ? ['integrity' => $chunk[static::$integrityKey] ?? false]
+ : [];
+
+ foreach (static::$scriptTagAttributesResolvers as $resolver) {
+ $attributes = array_merge($attributes, $resolver($chunk, $manifest, $url));
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Resolve the attributes for the chunks generated style tag.
+ *
+ * @param string $url
+ * @param array $chunk
+ * @param array $manifest
+ * @return array
+ */
+ protected function resolveStyleTagAttributes($url, $chunk, $manifest)
+ {
+ $attributes = static::$integrityKey
+ ? ['integrity' => $chunk[static::$integrityKey] ?? false]
+ : [];
+
+ foreach (static::$styleTagAttributesResolvers as $resolver) {
+ $attributes = array_merge($attributes, $resolver($chunk, $manifest, $url));
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Generate an appropriate tag for the given URL in HMR mode.
*
* @param string $url
* @return string
@@ -130,18 +316,54 @@ protected function makeTag($url)
*/
protected function makeScriptTag($url)
{
- return sprintf('', $url);
+ return $this->makeScriptTagWithAttributes($url, []);
}
/**
- * Generate a stylesheet tag for the given URL.
+ * Generate a stylesheet tag for the given URL in HMR mode.
*
* @param string $url
* @return string
*/
protected function makeStylesheetTag($url)
{
- return sprintf('', $url);
+ return $this->makeStylesheetTagWithAttributes($url, []);
+ }
+
+ /**
+ * Generate a script tag with attributes for the given URL.
+ *
+ * @param string $url
+ * @param array $attributes
+ * @return string
+ */
+ protected function makeScriptTagWithAttributes($url, $attributes)
+ {
+ $attributes = $this->parseAttributes(array_merge([
+ 'type' => 'module',
+ 'src' => $url,
+ 'nonce' => static::$nonce ?? false,
+ ], $attributes));
+
+ return '';
+ }
+
+ /**
+ * Generate a link tag with attributes for the given URL.
+ *
+ * @param string $url
+ * @param array $attributes
+ * @return string
+ */
+ protected function makeStylesheetTagWithAttributes($url, $attributes)
+ {
+ $attributes = $this->parseAttributes(array_merge([
+ 'rel' => 'stylesheet',
+ 'href' => $url,
+ 'nonce' => static::$nonce ?? false,
+ ], $attributes));
+
+ return '';
}
/**
@@ -154,4 +376,20 @@ protected function isCssPath($path)
{
return preg_match('/\.(css|less|sass|scss|styl|stylus|pcss|postcss)$/', $path) === 1;
}
+
+ /**
+ * Parse the attributes into key="value" strings.
+ *
+ * @param array $attributes
+ * @return array
+ */
+ protected function parseAttributes($attributes)
+ {
+ return Collection::make($attributes)
+ ->reject(fn ($value, $key) => $value === false)
+ ->flatMap(fn ($value, $key) => $value === true ? [$key] : [$key => $value])
+ ->map(fn ($value, $key) => is_int($key) ? $value : $key.'="'.$value.'"')
+ ->values()
+ ->all();
+ }
}
diff --git a/src/Illuminate/Support/Facades/Facade.php b/src/Illuminate/Support/Facades/Facade.php
index d17e16c7c04..aead786f987 100755
--- a/src/Illuminate/Support/Facades/Facade.php
+++ b/src/Illuminate/Support/Facades/Facade.php
@@ -4,6 +4,7 @@
use Closure;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Foundation\Vite;
use Illuminate\Support\Arr;
use Illuminate\Support\Js;
use Illuminate\Support\Str;
@@ -293,6 +294,7 @@ public static function defaultAliases()
'URL' => URL::class,
'Validator' => Validator::class,
'View' => View::class,
+ 'Vite' => Vite::class,
]);
}
diff --git a/tests/Foundation/FoundationViteTest.php b/tests/Foundation/FoundationViteTest.php
index 4a9c5b61c91..3604c4af131 100644
--- a/tests/Foundation/FoundationViteTest.php
+++ b/tests/Foundation/FoundationViteTest.php
@@ -4,6 +4,7 @@
use Illuminate\Foundation\Vite;
use Illuminate\Routing\UrlGenerator;
+use Illuminate\Support\Str;
use Mockery as m;
use PHPUnit\Framework\TestCase;
@@ -101,15 +102,229 @@ public function testViteHotModuleReplacementWithJsAndCss()
);
}
- protected function makeViteManifest()
+ public function testItCanGenerateCspNonceWithHotFile()
+ {
+ Str::createRandomStringsUsing(fn ($length) => "random-string-with-length:{$length}");
+ $this->makeViteHotFile();
+
+ $nonce = Vite::useCspNonce();
+ $result = (new Vite)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame('random-string-with-length:40', $nonce);
+ $this->assertSame('random-string-with-length:40', Vite::cspNonce());
+ $this->assertSame(
+ ''
+ .''
+ .'',
+ $result->toHtml()
+ );
+
+ Str::createRandomStringsNormally();
+ Vite::withoutCspNonce();
+ }
+
+ public function testItCanGenerateCspNonceWithManifest()
+ {
+ Str::createRandomStringsUsing(fn ($length) => "random-string-with-length:{$length}");
+ $this->makeViteManifest();
+
+ $nonce = Vite::useCspNonce();
+ $result = (new Vite)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame('random-string-with-length:40', $nonce);
+ $this->assertSame('random-string-with-length:40', Vite::cspNonce());
+ $this->assertSame(
+ ''
+ .'',
+ $result->toHtml()
+ );
+
+ Str::createRandomStringsNormally();
+ Vite::withoutCspNonce();
+ }
+
+ public function testItCanSpecifyCspNonceWithHotFile()
+ {
+ $this->makeViteHotFile();
+
+ $nonce = Vite::useCspNonce('expected-nonce');
+ $result = (new Vite)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame('expected-nonce', $nonce);
+ $this->assertSame('expected-nonce', Vite::cspNonce());
+ $this->assertSame(
+ ''
+ .''
+ .'',
+ $result->toHtml()
+ );
+
+ Vite::withoutCspNonce();
+ }
+
+ public function testItCanSpecifyCspNonceWithManifest()
+ {
+ $this->makeViteManifest();
+
+ $nonce = Vite::useCspNonce('expected-nonce');
+ $result = (new Vite)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame('expected-nonce', $nonce);
+ $this->assertSame('expected-nonce', Vite::cspNonce());
+ $this->assertSame(
+ ''
+ .'',
+ $result->toHtml()
+ );
+
+ Vite::withoutCspNonce();
+ }
+
+ public function testItCanInjectIntegrityWhenPresentInManifest()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'file' => 'assets/app.versioned.js',
+ 'integrity' => 'expected-app.js-integrity'
+ ],
+ 'resources/css/app.css' => [
+ 'file' => 'assets/app.versioned.css',
+ 'integrity' => 'expected-app.css-integrity'
+ ],
+ ], $buildDir);
+
+ $result = (new Vite)(['resources/css/app.css', 'resources/js/app.js'], $buildDir);
+
+ $this->assertSame(
+ ''
+ .'',
+ $result->toHtml()
+ );
+
+ unlink(public_path("{$buildDir}/manifest.json"));
+ rmdir(public_path($buildDir));
+ }
+
+ public function testItCanInjectIntegrityWhenPresentInManifestForImportedCss()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ "resources/js/app.js" => [
+ "file" => "assets/app.versioned.js",
+ "imports" => [
+ "_import.versioned.js"
+ ],
+ "integrity" => "expected-app.js-integrity"
+ ],
+ "_import.versioned.js" => [
+ "file" => "assets/import.versioned.js",
+ "css" => [
+ "assets/imported-css.versioned.css"
+ ],
+ "integrity" => "expected-import.js-integrity"
+ ],
+ "imported-css.css" => [
+ "file" => "assets/imported-css.versioned.css",
+ "integrity" => "expected-imported-css.css-integrity"
+ ]
+ ], $buildDir);
+
+ $result = (new Vite)('resources/js/app.js', $buildDir);
+
+ $this->assertSame(
+ ''
+ .'',
+ $result->toHtml()
+ );
+
+ unlink(public_path("{$buildDir}/manifest.json"));
+ rmdir(public_path($buildDir));
+ }
+
+ public function testItCanSpecifyIntegrityKey()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'file' => 'assets/app.versioned.js',
+ 'different-integrity-key' => 'expected-app.js-integrity'
+ ],
+ 'resources/css/app.css' => [
+ 'file' => 'assets/app.versioned.css',
+ 'different-integrity-key' => 'expected-app.css-integrity'
+ ],
+ ], $buildDir);
+ Vite::useIntegrityKey('different-integrity-key');
+
+ $result = (new Vite)(['resources/css/app.css', 'resources/js/app.js'], $buildDir);
+
+ $this->assertSame(
+ ''
+ .'',
+ $result->toHtml()
+ );
+
+ unlink(public_path("{$buildDir}/manifest.json"));
+ rmdir(public_path($buildDir));
+ Vite::useIntegrityKey('integrity');
+ }
+
+ public function testItCanSpecifyArbitraryAttributesPerScriptTag()
+ {
+ $this->makeViteManifest();
+ Vite::useAttributesForScriptTag(function ($chunk, $manifest, $url) {
+ return [
+ 'crossorigin',
+ 'data-persistent-across-pages' => 'YES',
+ 'remove-me' => false,
+ 'keep-me' => true,
+ ];
+ });
+
+ $result = (new Vite)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame(
+ ''
+ .'',
+ $result->toHtml()
+ );
+
+ Vite::withoutAttributeResolvers();
+ }
+
+ public function testItCanSpecifyArbitraryAttributesPerStyleTag()
+ {
+ $this->makeViteManifest();
+ Vite::useAttributesForStyleTag(function ($chunk, $manifest, $url) {
+ return [
+ 'crossorigin',
+ 'data-persistent-across-pages' => 'YES',
+ 'remove-me' => false,
+ 'keep-me' => true,
+ ];
+ });
+
+ $result = (new Vite)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame(
+ ''
+ .'',
+ $result->toHtml()
+ );
+
+ Vite::withoutAttributeResolvers();
+ }
+
+ protected function makeViteManifest($contents = null, $path = 'build')
{
app()->singleton('path.public', fn () => __DIR__);
- if (! file_exists(public_path('build'))) {
- mkdir(public_path('build'));
+ if (! file_exists(public_path($path))) {
+ mkdir(public_path($path));
}
- $manifest = json_encode([
+ $manifest = json_encode($contents ?? [
'resources/js/app.js' => [
'file' => 'assets/app.versioned.js',
],
@@ -119,6 +334,9 @@ protected function makeViteManifest()
'assets/imported-css.versioned.css',
],
],
+ 'resources/css/imported-css.css' => [
+ 'file' => 'assets/imported-css.versioned.css',
+ ],
'resources/js/app-with-shared-css.js' => [
'file' => 'assets/app-with-shared-css.versioned.js',
'imports' => [
@@ -133,9 +351,12 @@ protected function makeViteManifest()
'assets/shared-css.versioned.css',
],
],
+ 'resources/css/shared-css' => [
+ 'file' => 'assets/shared-css.versioned.css',
+ ]
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- file_put_contents(public_path('build/manifest.json'), $manifest);
+ file_put_contents(public_path("{$path}/manifest.json"), $manifest);
}
protected function cleanViteManifest()