diff --git a/composer.json b/composer.json
index 6535b1eba..1212e1e8d 100644
--- a/composer.json
+++ b/composer.json
@@ -66,7 +66,8 @@
"Hypervel\\Translation\\": "src/translation/src/",
"Hypervel\\Validation\\": "src/validation/src/",
"Hypervel\\Permission\\": "src/permission/src/",
- "Hypervel\\Sentry\\": "src/sentry/src/"
+ "Hypervel\\Sentry\\": "src/sentry/src/",
+ "Hypervel\\View\\": "src/view/src/"
},
"files": [
"src/auth/src/Functions.php",
@@ -182,7 +183,8 @@
"hypervel/translation": "self.version",
"hypervel/validation": "self.version",
"hypervel/permission": "self.version",
- "hypervel/sentry": "self.version"
+ "hypervel/sentry": "self.version",
+ "hypervel/view": "self.version"
},
"suggest": {
"hyperf/redis": "Required to use redis driver. (^3.1).",
@@ -261,7 +263,8 @@
"providers": [
"Hypervel\\Notifications\\NotificationServiceProvider",
"Hypervel\\Telescope\\TelescopeServiceProvider",
- "Hypervel\\Sentry\\SentryServiceProvider"
+ "Hypervel\\Sentry\\SentryServiceProvider",
+ "Hypervel\\View\\ViewServiceProvider"
]
},
"branch-alias": {
diff --git a/src/core/src/ConfigProvider.php b/src/core/src/ConfigProvider.php
index ef6e22c82..5c5315980 100644
--- a/src/core/src/ConfigProvider.php
+++ b/src/core/src/ConfigProvider.php
@@ -16,12 +16,10 @@
use Hyperf\Database\Commands\Migrations\StatusCommand;
use Hyperf\Database\Migrations\MigrationCreator as HyperfMigrationCreator;
use Hyperf\Database\Model\Factory as HyperfDatabaseFactory;
-use Hyperf\ViewEngine\Compiler\CompilerInterface;
use Hypervel\Database\Console\SeedCommand;
use Hypervel\Database\Eloquent\Factories\LegacyFactoryInvoker as DatabaseFactoryInvoker;
use Hypervel\Database\Migrations\MigrationCreator;
use Hypervel\Database\TransactionListener;
-use Hypervel\View\CompilerFactory;
class ConfigProvider
{
@@ -31,7 +29,6 @@ public function __invoke(): array
'dependencies' => [
HyperfDatabaseFactory::class => DatabaseFactoryInvoker::class,
HyperfMigrationCreator::class => MigrationCreator::class,
- CompilerInterface::class => CompilerFactory::class,
],
'listeners' => [
TransactionListener::class,
diff --git a/src/core/src/Context/RequestContext.php b/src/core/src/Context/RequestContext.php
index 5a3b7319c..94d332dea 100644
--- a/src/core/src/Context/RequestContext.php
+++ b/src/core/src/Context/RequestContext.php
@@ -5,7 +5,12 @@
namespace Hypervel\Context;
use Hyperf\Context\RequestContext as HyperfRequestContext;
+use Psr\Http\Message\ServerRequestInterface;
class RequestContext extends HyperfRequestContext
{
+ public static function destroy(?int $coroutineId = null): void
+ {
+ Context::destroy(ServerRequestInterface::class, $coroutineId);
+ }
}
diff --git a/src/core/src/View/CompilerFactory.php b/src/core/src/View/CompilerFactory.php
deleted file mode 100644
index ab13af113..000000000
--- a/src/core/src/View/CompilerFactory.php
+++ /dev/null
@@ -1,30 +0,0 @@
-get(Filesystem::class),
- Blade::config('config.cache_path')
- );
-
- // register view components
- foreach ((array) Blade::config('components', []) as $alias => $class) {
- $blade->component($class, $alias);
- }
-
- $blade->setComponentAutoload((array) Blade::config('autoload', ['classes' => [], 'components' => []]));
-
- return $blade;
- }
-}
diff --git a/src/core/src/View/Compilers/BladeCompiler.php b/src/core/src/View/Compilers/BladeCompiler.php
deleted file mode 100644
index 3305ae2db..000000000
--- a/src/core/src/View/Compilers/BladeCompiler.php
+++ /dev/null
@@ -1,35 +0,0 @@
-compilesComponentTags) {
- return $value;
- }
-
- return (new ComponentTagCompiler(
- $this->classComponentAliases,
- $this->classComponentNamespaces,
- $this,
- $this->getComponentAutoload() ?: []
- ))->compile($value);
- }
-}
diff --git a/src/core/src/View/Compilers/Concerns/CompilesHelpers.php b/src/core/src/View/Compilers/Concerns/CompilesHelpers.php
deleted file mode 100644
index 4b01e6ac7..000000000
--- a/src/core/src/View/Compilers/Concerns/CompilesHelpers.php
+++ /dev/null
@@ -1,24 +0,0 @@
-';
- }
-
- /**
- * Compile the method statements into valid PHP.
- */
- protected function compileMethod(string $method): string
- {
- return "";
- }
-}
diff --git a/src/core/src/View/Events/ViewRendered.php b/src/core/src/View/Events/ViewRendered.php
deleted file mode 100644
index 3157eb371..000000000
--- a/src/core/src/View/Events/ViewRendered.php
+++ /dev/null
@@ -1,15 +0,0 @@
-session->get('errors') ?: new ViewErrorBag();
-
- $this->view->share('errors', $errors);
-
- return $handler->handle($request);
- }
-}
diff --git a/src/devtool/src/Generator/ComponentCommand.php b/src/devtool/src/Generator/ComponentCommand.php
index c92e5fd16..46ed71f4c 100644
--- a/src/devtool/src/Generator/ComponentCommand.php
+++ b/src/devtool/src/Generator/ComponentCommand.php
@@ -5,6 +5,7 @@
namespace Hypervel\Devtool\Generator;
use Hyperf\Devtool\Generator\GeneratorCommand;
+use Hypervel\Support\Str;
class ComponentCommand extends GeneratorCommand
{
@@ -27,7 +28,7 @@ protected function getStub(): string
protected function getDefaultNamespace(): string
{
- return $this->getConfig()['namespace'] ?? 'App\View\Component';
+ return $this->getConfig()['namespace'] ?? 'App\View\Components';
}
protected function buildClass(string $name): string
@@ -37,7 +38,12 @@ protected function buildClass(string $name): string
protected function replaceView(string $stub, string $name): string
{
- $view = lcfirst(str_replace($this->getNamespace($name) . '\\', '', $name));
+ $view = str_replace($this->getDefaultNamespace() . '\\', '', $name);
+ $view = array_map(
+ fn ($part) => Str::snake($part),
+ explode('\\', $view)
+ );
+ $view = implode('.', $view);
return str_replace(
['%VIEW%'],
diff --git a/src/devtool/src/Generator/stubs/view-component.stub b/src/devtool/src/Generator/stubs/view-component.stub
index 99be65e94..b4eb26d71 100644
--- a/src/devtool/src/Generator/stubs/view-component.stub
+++ b/src/devtool/src/Generator/stubs/view-component.stub
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace %NAMESPACE%;
use Closure;
-use Hyperf\ViewEngine\Component\Component;
-use Hyperf\ViewEngine\Contract\ViewInterface;
+use Hypervel\View\Component;
+use Hypervel\View\Contracts\View as ViewContract;
use Hypervel\Support\Facades\View;
class %CLASS% extends Component
@@ -22,7 +22,7 @@ class %CLASS% extends Component
/**
* Get the view / contents that represent the component.
*/
- public function render(): ViewInterface|Closure|string
+ public function render(): ViewContract|Closure|string
{
return %VIEW%;
}
diff --git a/src/filesystem/src/Filesystem.php b/src/filesystem/src/Filesystem.php
index f078128ee..68150abd1 100644
--- a/src/filesystem/src/Filesystem.php
+++ b/src/filesystem/src/Filesystem.php
@@ -5,6 +5,7 @@
namespace Hypervel\Filesystem;
use Hyperf\Support\Filesystem\Filesystem as HyperfFilesystem;
+use Hypervel\Http\Exceptions\FileNotFoundException;
class Filesystem extends HyperfFilesystem
{
@@ -17,4 +18,25 @@ public function ensureDirectoryExists(string $path, int $mode = 0755, bool $recu
$this->makeDirectory($path, $mode, $recursive);
}
}
+
+ /**
+ * Get the returned value of a file.
+ *
+ * @throws FileNotFoundException
+ */
+ public function getRequire(string $path, array $data = [[]])
+ {
+ if ($this->isFile($path)) {
+ $__path = $path;
+ $__data = $data;
+
+ return (static function () use ($__path, $__data) {
+ extract($__data, EXTR_SKIP);
+
+ return require $__path;
+ })();
+ }
+
+ throw new FileNotFoundException("File does not exist at path {$path}.");
+ }
}
diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php
index f07b7e70f..eb7d64e27 100644
--- a/src/foundation/src/Application.php
+++ b/src/foundation/src/Application.php
@@ -626,8 +626,8 @@ protected function registerCoreContainerAliases(): void
'url',
\Hypervel\Router\UrlGenerator::class,
],
- \Hyperf\ViewEngine\Contract\FactoryInterface::class => ['view'],
- \Hyperf\ViewEngine\Compiler\CompilerInterface::class => ['blade.compiler'],
+ \Hypervel\View\Contracts\Factory::class => ['view'],
+ \Hypervel\View\Compilers\CompilerInterface::class => ['blade.compiler'],
\Hypervel\Session\Contracts\Factory::class => [
'session',
\Hypervel\Session\SessionManager::class,
diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php
index 2a3e8ef46..fffd570f6 100644
--- a/src/foundation/src/Exceptions/Handler.php
+++ b/src/foundation/src/Exceptions/Handler.php
@@ -8,16 +8,12 @@
use Exception;
use Hyperf\Collection\Arr;
use Hyperf\Context\Context;
-use Hyperf\Contract\MessageBag as MessageBagContract;
-use Hyperf\Contract\MessageProvider;
use Hyperf\Contract\SessionInterface;
use Hyperf\Database\Model\ModelNotFoundException;
use Hyperf\ExceptionHandler\ExceptionHandler;
use Hyperf\HttpMessage\Base\Response as BaseResponse;
use Hyperf\HttpMessage\Exception\HttpException as HyperfHttpException;
use Hyperf\HttpMessage\Upload\UploadedFile;
-use Hyperf\Support\MessageBag;
-use Hyperf\ViewEngine\ViewErrorBag;
use Hypervel\Auth\Access\AuthorizationException;
use Hypervel\Auth\AuthenticationException;
use Hypervel\Foundation\Contracts\Application as Container;
@@ -33,10 +29,14 @@
use Hypervel\Router\Contracts\UrlGenerator as UrlGeneratorContract;
use Hypervel\Session\Contracts\Session as SessionContract;
use Hypervel\Session\TokenMismatchException;
+use Hypervel\Support\Contracts\MessageBag as MessageBagContract;
+use Hypervel\Support\Contracts\MessageProvider;
use Hypervel\Support\Contracts\Responsable;
use Hypervel\Support\Facades\Auth;
+use Hypervel\Support\MessageBag;
use Hypervel\Support\Reflector;
use Hypervel\Support\Traits\ReflectsClosures;
+use Hypervel\Support\ViewErrorBag;
use Hypervel\Validation\ValidationException;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
@@ -461,7 +461,7 @@ protected function finalizeRenderedResponse(Request $request, ResponseInterface
if ($callbacks = $this->afterResponseCallbacks()) {
foreach ($callbacks as $callback) {
- $response = $callback($response, $e, $request);
+ $response = $callback($response, $e, $request) ?: $response;
}
}
diff --git a/src/foundation/src/Http/Kernel.php b/src/foundation/src/Http/Kernel.php
index 383a2121d..55bf91ae6 100644
--- a/src/foundation/src/Http/Kernel.php
+++ b/src/foundation/src/Http/Kernel.php
@@ -5,8 +5,10 @@
namespace Hypervel\Foundation\Http;
use Hyperf\Context\RequestContext;
+use Hyperf\Contract\ConfigInterface;
use Hyperf\Coordinator\Constants;
use Hyperf\Coordinator\CoordinatorManager;
+use Hyperf\Engine\Http\WritableConnection;
use Hyperf\HttpMessage\Server\Request;
use Hyperf\HttpMessage\Server\Response;
use Hyperf\HttpMessage\Upload\UploadedFile as HyperfUploadedFile;
@@ -15,12 +17,14 @@
use Hyperf\HttpServer\Event\RequestTerminated;
use Hyperf\HttpServer\Server as HyperfServer;
use Hyperf\Support\SafeCaller;
+use Hypervel\Context\ResponseContext;
use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract;
use Hypervel\Foundation\Exceptions\Handler as ExceptionHandler;
use Hypervel\Foundation\Http\Contracts\MiddlewareContract;
use Hypervel\Foundation\Http\Traits\HasMiddleware;
use Hypervel\Http\UploadedFile;
use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
use Throwable;
use function Hyperf\Coroutine\defer;
@@ -162,4 +166,40 @@ protected function getResponseForException(Throwable $throwable): Response
return (new Response())->withStatus(400);
});
}
+
+ /**
+ * Initialize PSR-7 Request and Response objects.
+ * @param mixed $request swoole request or psr server request
+ * @param mixed $response swoole response or swow connection
+ */
+ protected function initRequestAndResponse($request, $response): array
+ {
+ ResponseContext::set($psr7Response = new Response());
+
+ $psr7Response->setConnection(new WritableConnection($response));
+
+ $psr7Request = $request instanceof ServerRequestInterface
+ ? $request
+ : Request::loadFromSwooleRequest($request);
+
+ if ($this->enableHttpMethodParameterOverride()) {
+ $this->overrideHttpMethod($psr7Request);
+ }
+
+ RequestContext::set($psr7Request);
+
+ return [$psr7Request, $psr7Response];
+ }
+
+ protected function enableHttpMethodParameterOverride(): bool
+ {
+ return $this->container->get(ConfigInterface::class)->get('view.enable_override_http_method', false);
+ }
+
+ protected function overrideHttpMethod($psr7Request): void
+ {
+ if ($psr7Request->getMethod() === 'POST' && $method = $psr7Request->getParsedBody()['_method'] ?? null) {
+ $psr7Request->setMethod(strtoupper($method));
+ }
+ }
}
diff --git a/src/foundation/src/Testing/Http/TestResponse.php b/src/foundation/src/Testing/Http/TestResponse.php
index 3377c0480..166c9bce1 100644
--- a/src/foundation/src/Testing/Http/TestResponse.php
+++ b/src/foundation/src/Testing/Http/TestResponse.php
@@ -10,10 +10,10 @@
use Hyperf\Context\ApplicationContext;
use Hyperf\Contract\MessageBag;
use Hyperf\Testing\Http\TestResponse as HyperfTestResponse;
-use Hyperf\ViewEngine\ViewErrorBag;
use Hypervel\Cookie\Cookie;
use Hypervel\Foundation\Testing\TestResponseAssert as PHPUnit;
use Hypervel\Session\Contracts\Session as SessionContract;
+use Hypervel\Support\ViewErrorBag;
use Psr\Http\Message\ResponseInterface;
use RuntimeException;
diff --git a/src/foundation/src/Vite.php b/src/foundation/src/Vite.php
new file mode 100644
index 000000000..bcf2fc44d
--- /dev/null
+++ b/src/foundation/src/Vite.php
@@ -0,0 +1,887 @@
+integrityKey = $key;
+
+ return $this;
+ }
+
+ /**
+ * Set the Vite entry points.
+ */
+ public function withEntryPoints(array $entryPoints): static
+ {
+ Context::set(static::ENTRY_POINTS_CONTEXT_KEY, $entryPoints);
+
+ return $this;
+ }
+
+ /**
+ * Merge additional Vite entry points with the current set.
+ */
+ public function mergeEntryPoints(array $entryPoints): static
+ {
+ $currentEntryPoints = Context::get(static::ENTRY_POINTS_CONTEXT_KEY, []);
+
+ return $this->withEntryPoints(array_unique([
+ ...$currentEntryPoints,
+ ...$entryPoints,
+ ]));
+ }
+
+ /**
+ * Set the filename for the manifest file.
+ */
+ public function useManifestFilename(string $filename): static
+ {
+ $this->manifestFilename = $filename;
+
+ return $this;
+ }
+
+ /**
+ * Resolve asset paths using the provided resolver.
+ */
+ public function createAssetPathsUsing(?callable $resolver): static
+ {
+ $this->assetPathResolver = $resolver;
+
+ return $this;
+ }
+
+ /**
+ * Get the Vite "hot" file path.
+ */
+ public function hotFile(): string
+ {
+ return $this->hotFile ?? public_path('/hot');
+ }
+
+ /**
+ * Set the Vite "hot" file path.
+ */
+ public function useHotFile(string $path): static
+ {
+ $this->hotFile = $path;
+
+ return $this;
+ }
+
+ /**
+ * Set the Vite build directory.
+ */
+ public function useBuildDirectory(string $path): static
+ {
+ $this->buildDirectory = $path;
+
+ return $this;
+ }
+
+ /**
+ * Use the given callback to resolve attributes for script tags.
+ *
+ * @param array|(callable(string, string, ?array, ?array): array) $attributes
+ */
+ public function useScriptTagAttributes(callable|array $attributes): static
+ {
+ if (! is_callable($attributes)) {
+ $attributes = fn () => $attributes;
+ }
+
+ $this->scriptTagAttributesResolvers[] = $attributes;
+
+ return $this;
+ }
+
+ /**
+ * Use the given callback to resolve attributes for style tags.
+ *
+ * @param array|(callable(string, string, ?array, ?array): array) $attributes
+ */
+ public function useStyleTagAttributes(callable|array $attributes): static
+ {
+ if (! is_callable($attributes)) {
+ $attributes = fn () => $attributes;
+ }
+
+ $this->styleTagAttributesResolvers[] = $attributes;
+
+ return $this;
+ }
+
+ /**
+ * Use the given callback to resolve attributes for preload tags.
+ *
+ * @param array|(callable(string, string, ?array, ?array): (array|false))|false $attributes
+ */
+ public function usePreloadTagAttributes(callable|array|false $attributes): static
+ {
+ if (! is_callable($attributes)) {
+ $attributes = fn () => $attributes;
+ }
+
+ $this->preloadTagAttributesResolvers[] = $attributes;
+
+ return $this;
+ }
+
+ /**
+ * Eagerly prefetch assets.
+ */
+ public function prefetch(?int $concurrency = null, string $event = 'load'): static
+ {
+ $this->prefetchEvent = $event;
+
+ return $concurrency === null
+ ? $this->usePrefetchStrategy('aggressive')
+ : $this->usePrefetchStrategy('waterfall', ['concurrency' => $concurrency]);
+ }
+
+ /**
+ * Use the "waterfall" prefetching strategy.
+ */
+ public function useWaterfallPrefetching(?int $concurrency = null): static
+ {
+ return $this->usePrefetchStrategy('waterfall', [
+ 'concurrency' => $concurrency ?? $this->prefetchConcurrently,
+ ]);
+ }
+
+ /**
+ * Use the "aggressive" prefetching strategy.
+ */
+ public function useAggressivePrefetching(): static
+ {
+ return $this->usePrefetchStrategy('aggressive');
+ }
+
+ /**
+ * Set the prefetching strategy.
+ *
+ * @throws Exception
+ */
+ public function usePrefetchStrategy(?string $strategy, array $config = []): static
+ {
+ $this->prefetchStrategy = $strategy;
+
+ if ($strategy === 'waterfall') {
+ $this->prefetchConcurrently = $config['concurrency'] ?? $this->prefetchConcurrently;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Generate Vite tags for an entrypoint.
+ */
+ public function __invoke(string|array $entrypoints, ?string $buildDirectory = null): HtmlString
+ {
+ $entrypoints = new Collection($entrypoints);
+ $buildDirectory ??= $this->buildDirectory;
+
+ if ($this->isRunningHot()) {
+ return new HtmlString(
+ $entrypoints
+ ->prepend('@vite/client')
+ ->map(fn ($entrypoint) => $this->makeTagForChunk($entrypoint, $this->hotAsset($entrypoint), null, null))
+ ->join('')
+ );
+ }
+
+ $manifest = $this->manifest($buildDirectory);
+
+ $tags = new Collection();
+ $preloads = new Collection();
+
+ foreach ($entrypoints as $entrypoint) {
+ $chunk = $this->chunk($manifest, $entrypoint);
+
+ $preloads->push([
+ $chunk['src'],
+ $this->assetPath("{$buildDirectory}/{$chunk['file']}"),
+ $chunk,
+ $manifest,
+ ]);
+
+ foreach ($chunk['imports'] ?? [] as $import) {
+ $preloads->push([
+ $import,
+ $this->assetPath("{$buildDirectory}/{$manifest[$import]['file']}"),
+ $manifest[$import],
+ $manifest,
+ ]);
+
+ foreach ($manifest[$import]['css'] ?? [] as $css) {
+ $partialManifest = (new Collection($manifest))->where('file', $css);
+
+ $preloads->push([
+ $partialManifest->keys()->first(),
+ $this->assetPath("{$buildDirectory}/{$css}"),
+ $partialManifest->first(),
+ $manifest,
+ ]);
+
+ $tags->push($this->makeTagForChunk(
+ $partialManifest->keys()->first(),
+ $this->assetPath("{$buildDirectory}/{$css}"),
+ $partialManifest->first(),
+ $manifest
+ ));
+ }
+ }
+
+ $tags->push($this->makeTagForChunk(
+ $entrypoint,
+ $this->assetPath("{$buildDirectory}/{$chunk['file']}"),
+ $chunk,
+ $manifest
+ ));
+
+ foreach ($chunk['css'] ?? [] as $css) {
+ $partialManifest = (new Collection($manifest))->where('file', $css);
+
+ $preloads->push([
+ $partialManifest->keys()->first(),
+ $this->assetPath("{$buildDirectory}/{$css}"),
+ $partialManifest->first(),
+ $manifest,
+ ]);
+
+ $tags->push($this->makeTagForChunk(
+ $partialManifest->keys()->first(),
+ $this->assetPath("{$buildDirectory}/{$css}"),
+ $partialManifest->first(),
+ $manifest
+ ));
+ }
+ }
+
+ [$stylesheets, $scripts] = $tags->unique()->partition(fn ($tag) => str_starts_with($tag, 'unique()
+ ->sortByDesc(fn ($args) => $this->isCssPath($args[1]))
+ ->map(fn ($args) => $this->makePreloadTagForChunk(...$args));
+
+ $base = $preloads->join('') . $stylesheets->join('') . $scripts->join('');
+
+ /* @phpstan-ignore booleanOr.rightAlwaysFalse */
+ if ($this->prefetchStrategy === null || $this->isRunningHot()) {
+ return new HtmlString($base);
+ }
+
+ $discoveredImports = [];
+
+ return (new Collection($entrypoints))
+ ->flatMap(fn ($entrypoint) => (new Collection($manifest[$entrypoint]['dynamicImports'] ?? []))
+ ->map(fn ($import) => $manifest[$import])
+ ->filter(fn ($chunk) => str_ends_with($chunk['file'], '.js') || str_ends_with($chunk['file'], '.css'))
+ ->flatMap($f = function ($chunk) use (&$f, $manifest, &$discoveredImports) {
+ return (new Collection([...$chunk['imports'] ?? [], ...$chunk['dynamicImports'] ?? []]))
+ ->reject(function ($import) use (&$discoveredImports) {
+ if (isset($discoveredImports[$import])) {
+ return true;
+ }
+
+ $discoveredImports[$import] = true;
+
+ return false;
+ })
+ ->reduce(
+ fn ($chunks, $import) => $chunks->merge(
+ $f($manifest[$import])
+ ),
+ new Collection([$chunk])
+ )
+ ->merge((new Collection($chunk['css'] ?? []))->map(
+ fn ($css) => (new Collection($manifest))->first(fn ($chunk) => $chunk['file'] === $css) ?? [
+ 'file' => $css,
+ ],
+ ));
+ })
+ ->map(function ($chunk) use ($buildDirectory, $manifest) {
+ return (new Collection([
+ ...$this->resolvePreloadTagAttributes(
+ $chunk['src'] ?? null,
+ $url = $this->assetPath("{$buildDirectory}/{$chunk['file']}"),
+ $chunk,
+ $manifest,
+ ),
+ 'rel' => 'prefetch',
+ 'fetchpriority' => 'low',
+ 'href' => $url,
+ ]))->reject(
+ fn ($value) => in_array($value, [null, false], true)
+ )->mapWithKeys(fn ($value, $key) => [
+ $key = (is_int($key) ? $value : $key) => $value === true ? $key : $value,
+ ])->all();
+ })
+ ->reject(function ($attributes) {
+ $preloadedAssets = $this->preloadedAssets();
+ return isset($preloadedAssets[$attributes['href']]);
+ }))
+ ->unique('href')
+ ->values()
+ ->pipe(fn ($assets) => with(Js::from($assets), fn ($assets) => match ($this->prefetchStrategy) {
+ 'waterfall' => new HtmlString($base . <<nonceAttribute()}>
+ window.addEventListener('{$this->prefetchEvent}', () => window.setTimeout(() => {
+ const makeLink = (asset) => {
+ const link = document.createElement('link')
+
+ Object.keys(asset).forEach((attribute) => {
+ link.setAttribute(attribute, asset[attribute])
+ })
+
+ return link
+ }
+
+ const loadNext = (assets, count) => window.setTimeout(() => {
+ if (count > assets.length) {
+ count = assets.length
+
+ if (count === 0) {
+ return
+ }
+ }
+
+ const fragment = new DocumentFragment
+
+ while (count > 0) {
+ const link = makeLink(assets.shift())
+ fragment.append(link)
+ count--
+
+ if (assets.length) {
+ link.onload = () => loadNext(assets, 1)
+ link.onerror = () => loadNext(assets, 1)
+ }
+ }
+
+ document.head.append(fragment)
+ })
+
+ loadNext({$assets}, {$this->prefetchConcurrently})
+ }))
+
+ HTML),
+ 'aggressive' => new HtmlString($base . <<nonceAttribute()}>
+ window.addEventListener('{$this->prefetchEvent}', () => window.setTimeout(() => {
+ const makeLink = (asset) => {
+ const link = document.createElement('link')
+
+ Object.keys(asset).forEach((attribute) => {
+ link.setAttribute(attribute, asset[attribute])
+ })
+
+ return link
+ }
+
+ const fragment = new DocumentFragment;
+ {$assets}.forEach((asset) => fragment.append(makeLink(asset)))
+ document.head.append(fragment)
+ }))
+
+ HTML),
+ default => throw new RuntimeException('Unknown prefetch strategy: ' . $this->prefetchStrategy),
+ }));
+ }
+
+ /**
+ * Make tag for the given chunk.
+ */
+ protected function makeTagForChunk(?string $src, string $url, ?array $chunk, ?array $manifest): string
+ {
+ if (
+ $this->cspNonce() === null
+ && $this->integrityKey !== false
+ && ! array_key_exists($this->integrityKey, $chunk ?? [])
+ && $this->scriptTagAttributesResolvers === []
+ && $this->styleTagAttributesResolvers === []) {
+ return $this->makeTag($url);
+ }
+
+ if ($this->isCssPath($url)) {
+ return $this->makeStylesheetTagWithAttributes(
+ $url,
+ $this->resolveStylesheetTagAttributes($src, $url, $chunk, $manifest)
+ );
+ }
+
+ return $this->makeScriptTagWithAttributes(
+ $url,
+ $this->resolveScriptTagAttributes($src, $url, $chunk, $manifest)
+ );
+ }
+
+ /**
+ * Make a preload tag for the given chunk.
+ */
+ protected function makePreloadTagForChunk(?string $src, string $url, ?array $chunk, array $manifest): string
+ {
+ $attributes = $this->resolvePreloadTagAttributes($src, $url, $chunk, $manifest);
+
+ if ($attributes === false) {
+ return '';
+ }
+
+ $preloadedAssets = $this->preloadedAssets();
+ $preloadedAssets[$url] = $this->parseAttributes(
+ (new Collection($attributes))->forget('href')->all()
+ );
+ $this->setPreloadedAssets($preloadedAssets);
+
+ return 'parseAttributes($attributes)) . ' />';
+ }
+
+ /**
+ * Resolve the attributes for the chunks generated script tag.
+ */
+ protected function resolveScriptTagAttributes(?string $src, string $url, ?array $chunk, ?array $manifest): array
+ {
+ $attributes = $this->integrityKey !== false
+ ? ['integrity' => $chunk[$this->integrityKey] ?? false]
+ : [];
+
+ foreach ($this->scriptTagAttributesResolvers as $resolver) {
+ $attributes = array_merge($attributes, $resolver($src, $url, $chunk, $manifest));
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Resolve the attributes for the chunks generated stylesheet tag.
+ */
+ protected function resolveStylesheetTagAttributes(?string $src, string $url, ?array $chunk, ?array $manifest): array
+ {
+ $attributes = $this->integrityKey !== false
+ ? ['integrity' => $chunk[$this->integrityKey] ?? false]
+ : [];
+
+ foreach ($this->styleTagAttributesResolvers as $resolver) {
+ $attributes = array_merge($attributes, $resolver($src, $url, $chunk, $manifest));
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Resolve the attributes for the chunks generated preload tag.
+ */
+ protected function resolvePreloadTagAttributes(?string $src, string $url, ?array $chunk, array $manifest): array|false
+ {
+ $attributes = $this->isCssPath($url) ? [
+ 'rel' => 'preload',
+ 'as' => 'style',
+ 'href' => $url,
+ 'nonce' => $this->cspNonce() ?? false,
+ 'crossorigin' => $this->resolveStylesheetTagAttributes($src, $url, $chunk, $manifest)['crossorigin'] ?? false,
+ ] : [
+ 'rel' => 'modulepreload',
+ 'href' => $url,
+ 'nonce' => $this->cspNonce() ?? false,
+ 'crossorigin' => $this->resolveScriptTagAttributes($src, $url, $chunk, $manifest)['crossorigin'] ?? false,
+ ];
+
+ $attributes = $this->integrityKey !== false
+ ? array_merge($attributes, ['integrity' => $chunk[$this->integrityKey] ?? false])
+ : $attributes;
+
+ foreach ($this->preloadTagAttributesResolvers as $resolver) {
+ if (false === ($resolvedAttributes = $resolver($src, $url, $chunk, $manifest))) {
+ return false;
+ }
+
+ $attributes = array_merge($attributes, $resolvedAttributes);
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Generate an appropriate tag for the given URL in HMR mode.
+ *
+ * @deprecated will be removed in a future Laravel version
+ */
+ protected function makeTag(string $url): string
+ {
+ if ($this->isCssPath($url)) {
+ return $this->makeStylesheetTag($url);
+ }
+
+ return $this->makeScriptTag($url);
+ }
+
+ /**
+ * Generate a script tag for the given URL.
+ *
+ * @deprecated will be removed in a future Laravel version
+ */
+ protected function makeScriptTag(string $url): string
+ {
+ return $this->makeScriptTagWithAttributes($url, []);
+ }
+
+ /**
+ * Generate a stylesheet tag for the given URL in HMR mode.
+ *
+ * @deprecated will be removed in a future Laravel version
+ */
+ protected function makeStylesheetTag(string $url): string
+ {
+ return $this->makeStylesheetTagWithAttributes($url, []);
+ }
+
+ /**
+ * Generate a script tag with attributes for the given URL.
+ */
+ protected function makeScriptTagWithAttributes(string $url, array $attributes): string
+ {
+ $attributes = $this->parseAttributes(array_merge([
+ 'type' => 'module',
+ 'src' => $url,
+ 'nonce' => $this->cspNonce() ?? false,
+ ], $attributes));
+
+ return '';
+ }
+
+ /**
+ * Generate a link tag with attributes for the given URL.
+ */
+ protected function makeStylesheetTagWithAttributes(string $url, array $attributes): string
+ {
+ $attributes = $this->parseAttributes(array_merge([
+ 'rel' => 'stylesheet',
+ 'href' => $url,
+ 'nonce' => $this->cspNonce() ?? false,
+ ], $attributes));
+
+ return '';
+ }
+
+ /**
+ * Determine whether the given path is a CSS file.
+ */
+ protected function isCssPath(string $path): bool
+ {
+ return preg_match('/\.(css|less|sass|scss|styl|stylus|pcss|postcss)(\?[^\.]*)?$/', $path) === 1;
+ }
+
+ /**
+ * Parse the attributes into key="value" strings.
+ */
+ protected function parseAttributes(array $attributes): array
+ {
+ return (new Collection($attributes))
+ ->reject(fn ($value, $key) => in_array($value, [false, null], true))
+ ->flatMap(fn ($value, $key) => $value === true ? [$key] : [$key => $value])
+ ->map(fn ($value, $key) => is_int($key) ? $value : $key . '="' . $value . '"')
+ ->values()
+ ->all();
+ }
+
+ /**
+ * Generate React refresh runtime script.
+ */
+ public function reactRefresh(): ?HtmlString
+ {
+ if (! $this->isRunningHot()) {
+ return null;
+ }
+
+ $attributes = $this->parseAttributes([
+ 'nonce' => $this->cspNonce(),
+ ]);
+
+ return new HtmlString(
+ sprintf(
+ <<<'HTML'
+
+ HTML,
+ implode(' ', $attributes),
+ $this->hotAsset('@react-refresh')
+ )
+ );
+ }
+
+ /**
+ * Get the path to a given asset when running in HMR mode.
+ */
+ protected function hotAsset(string $asset): string
+ {
+ return rtrim(file_get_contents($this->hotFile())) . '/' . $asset;
+ }
+
+ /**
+ * Get the URL for an asset.
+ */
+ public function asset(string $asset, ?string $buildDirectory = null): string
+ {
+ $buildDirectory ??= $this->buildDirectory;
+
+ if ($this->isRunningHot()) {
+ return $this->hotAsset($asset);
+ }
+
+ $chunk = $this->chunk($this->manifest($buildDirectory), $asset);
+
+ return $this->assetPath($buildDirectory . '/' . $chunk['file']);
+ }
+
+ /**
+ * Get the content of a given asset.
+ *
+ * @throws ViteException
+ */
+ public function content(string $asset, ?string $buildDirectory = null): string
+ {
+ $buildDirectory ??= $this->buildDirectory;
+
+ $chunk = $this->chunk($this->manifest($buildDirectory), $asset);
+
+ $path = public_path($buildDirectory . '/' . $chunk['file']);
+
+ if (! is_file($path) || ! file_exists($path)) {
+ throw new ViteException("Unable to locate file from Vite manifest: {$path}.");
+ }
+
+ return file_get_contents($path);
+ }
+
+ /**
+ * Generate an asset path for the application.
+ */
+ protected function assetPath(string $path, ?bool $secure = null): string
+ {
+ return ($this->assetPathResolver ?? asset(...))($path, $secure);
+ }
+
+ /**
+ * Get the manifest file for the given build directory.
+ *
+ * @throws ViteException
+ */
+ protected function manifest(string $buildDirectory): array
+ {
+ $path = $this->manifestPath($buildDirectory);
+
+ if (! isset(static::$manifests[$path])) {
+ if (! is_file($path)) {
+ throw new ViteException("Vite manifest not found at: {$path}");
+ }
+
+ static::$manifests[$path] = json_decode(file_get_contents($path), true);
+ }
+
+ return static::$manifests[$path];
+ }
+
+ /**
+ * Get the path to the manifest file for the given build directory.
+ */
+ protected function manifestPath(string $buildDirectory): string
+ {
+ return public_path($buildDirectory . '/' . $this->manifestFilename);
+ }
+
+ /**
+ * Get a unique hash representing the current manifest, or null if there is no manifest.
+ */
+ public function manifestHash(?string $buildDirectory = null): ?string
+ {
+ $buildDirectory ??= $this->buildDirectory;
+
+ if ($this->isRunningHot()) {
+ return null;
+ }
+
+ if (! is_file($path = $this->manifestPath($buildDirectory))) {
+ return null;
+ }
+
+ return md5_file($path) ?: null;
+ }
+
+ /**
+ * Get the chunk for the given entry point / asset.
+ *
+ * @throws ViteException
+ */
+ protected function chunk(array $manifest, string $file): array
+ {
+ if (! isset($manifest[$file])) {
+ throw new ViteException("Unable to locate file in Vite manifest: {$file}.");
+ }
+
+ return $manifest[$file];
+ }
+
+ /**
+ * Get the nonce attribute for the prefetch script tags.
+ */
+ protected function nonceAttribute(): HtmlString
+ {
+ if ($this->cspNonce() === null) {
+ return new HtmlString('');
+ }
+
+ return new HtmlString(' nonce="' . $this->cspNonce() . '"');
+ }
+
+ /**
+ * Determine if the HMR server is running.
+ */
+ public function isRunningHot(): bool
+ {
+ return is_file($this->hotFile());
+ }
+
+ /**
+ * Get the Vite tag content as a string of HTML.
+ */
+ public function toHtml(): string
+ {
+ $entryPoints = Context::get(static::ENTRY_POINTS_CONTEXT_KEY, []);
+
+ return $this->__invoke($entryPoints)->toHtml();
+ }
+}
diff --git a/src/foundation/src/ViteException.php b/src/foundation/src/ViteException.php
new file mode 100644
index 000000000..b67c7b682
--- /dev/null
+++ b/src/foundation/src/ViteException.php
@@ -0,0 +1,11 @@
+container->get(ConfigInterface::class)->get('view.event.enable', false)) {
- $this->container->get(EventDispatcherInterface::class)
- ->dispatch(new ViewRendered($response));
- }
- }
-
return $this->response()
- ->setHeader('Content-Type', $this->container->get(RenderInterface::class)->getContentType())
+ ->addHeader('content-type', 'text/html')
->setBody(new SwooleStream($response->render()));
}
+ if ($response instanceof Htmlable) {
+ return $this->response()
+ ->addHeader('content-type', 'text/html')
+ ->setBody(new SwooleStream((string) $response));
+ }
+
if (is_string($response)) {
return $this->response()->addHeader('content-type', 'text/plain')->setBody(new SwooleStream($response));
}
diff --git a/src/http/src/Middleware/AddLinkHeadersForPreloadedAssets.php b/src/http/src/Middleware/AddLinkHeadersForPreloadedAssets.php
new file mode 100644
index 000000000..674e09f3d
--- /dev/null
+++ b/src/http/src/Middleware/AddLinkHeadersForPreloadedAssets.php
@@ -0,0 +1,31 @@
+map(fn ($attributes, $url) => "<{$url}>; " . implode('; ', $attributes))
+ ->join(', ');
+ $response = $response->withHeader('Link', $preloaded);
+ }
+
+ return $response;
+ }
+}
diff --git a/src/http/src/Response.php b/src/http/src/Response.php
index ad31bfe2f..3c0f0c0a9 100644
--- a/src/http/src/Response.php
+++ b/src/http/src/Response.php
@@ -15,11 +15,11 @@
use Hyperf\HttpMessage\Stream\SwooleStream;
use Hyperf\HttpServer\Response as HyperfResponse;
use Hyperf\Support\Filesystem\Filesystem;
-use Hyperf\View\RenderInterface;
use Hypervel\Http\Contracts\ResponseContract;
use Hypervel\Http\Exceptions\FileNotFoundException;
use Hypervel\Support\Collection;
use Hypervel\Support\MimeTypeExtensionGuesser;
+use Hypervel\View\Contracts\Factory as FactoryContract;
use Psr\Http\Message\ResponseInterface;
use RuntimeException;
@@ -201,15 +201,14 @@ public function noContent(int $status = 204, array $headers = []): ResponseInter
*/
public function view(string $view, array $data = [], int $status = 200, array $headers = []): ResponseInterface
{
- $response = ApplicationContext::getContainer()
- ->get(RenderInterface::class)
- ->render($view, $data);
+ $content = ApplicationContext::getContainer()
+ ->get(FactoryContract::class)
+ ->make($view, $data)
+ ->render();
- foreach ($headers as $name => $value) {
- $response = $response->withAddedHeader($name, $value);
- }
+ $headers['Content-Type'] = 'text/html';
- return $response->withStatus($status);
+ return $this->make($content, $status, $headers);
}
/**
diff --git a/src/mail/src/Compiler/ComponentTagCompiler.php b/src/mail/src/Compiler/ComponentTagCompiler.php
deleted file mode 100644
index 4af64932d..000000000
--- a/src/mail/src/Compiler/ComponentTagCompiler.php
+++ /dev/null
@@ -1,118 +0,0 @@
-\\w+(?:-\\w+)*))?
- (?:\\s+name=(?(\"[^\"]+\"|\\\\'[^\\\\']+\\\\'|[^\\s>]+)))?
- (?:\\s+\\:name=(?(\"[^\"]+\"|\\\\'[^\\\\']+\\\\'|[^\\s>]+)))?
- (?
- (?:
- \\s+
- (?:
- (?:
- @(?:class)(\\( (?: (?>[^()]+) | (?-1) )* \\))
- )
- |
- (?:
- @(?:style)(\\( (?: (?>[^()]+) | (?-1) )* \\))
- )
- |
- (?:
- \\{\\{\\s*\\\$attributes(?:[^}]+?)?\\s*\\}\\}
- )
- |
- (?:
- [\\w\\-:.@]+
- (
- =
- (?:
- \\\"[^\\\"]*\\\"
- |
- \\'[^\\']*\\'
- |
- [^\\'\\\"=<>]+
- )
- )?
- )
- )
- )*
- \\s*
- )
- (?
- /x";
-
- $value = preg_replace_callback($pattern, function ($matches) {
- $name = $this->stripQuotes($matches['inlineName'] ?: $matches['name'] ?: $matches['boundName']);
-
- if (Str::contains($name, '-') && ! empty($matches['inlineName'])) {
- $name = Str::camel($name);
- }
-
- if (! empty($matches['inlineName']) || ! empty($matches['name'])) {
- $name = "'{$name}'";
- }
-
- return " @slot({$name}) ";
- }, $value);
-
- return preg_replace('/<\/\s*x[\-\:]slot[^>]*>/', ' @endslot', $value);
- }
-
- /**
- * Get the component class for a given component alias.
- */
- public function componentClass(string $component): string
- {
- $viewFactory = Blade::container()->get(FactoryInterface::class);
-
- if (isset($this->aliases[$component])) {
- if (class_exists($alias = $this->aliases[$component])) {
- return $alias;
- }
-
- if ($viewFactory->exists($alias)) {
- return $alias;
- }
-
- throw new InvalidArgumentException(
- "Unable to locate class or view [{$alias}] for component [{$component}]."
- );
- }
-
- if ($class = $this->findClassByComponent($component)) {
- return $class;
- }
-
- if ($view = $this->guessComponentFromAutoload($viewFactory, $component)) {
- return $view;
- }
-
- if (str_starts_with($component, 'mail::')) {
- return $component;
- }
-
- throw new InvalidArgumentException(
- "Unable to locate a class or view for component [{$component}]."
- );
- }
-}
diff --git a/src/mail/src/MailManager.php b/src/mail/src/MailManager.php
index 564969b17..af14cd782 100644
--- a/src/mail/src/MailManager.php
+++ b/src/mail/src/MailManager.php
@@ -10,7 +10,6 @@
use Hyperf\Collection\Arr;
use Hyperf\Contract\ConfigInterface;
use Hyperf\Stringable\Str;
-use Hyperf\ViewEngine\Contract\FactoryInterface;
use Hypervel\Log\LogManager;
use Hypervel\Mail\Contracts\Factory as FactoryContract;
use Hypervel\Mail\Contracts\Mailer as MailerContract;
@@ -21,6 +20,7 @@
use Hypervel\ObjectPool\Traits\HasPoolProxy;
use Hypervel\Queue\Contracts\Factory as QueueFactory;
use Hypervel\Support\ConfigurationUrlParser;
+use Hypervel\View\Contracts\Factory as ViewFactoryContract;
use InvalidArgumentException;
use Psr\Container\ContainerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
@@ -128,7 +128,7 @@ protected function resolve(string $name): MailerContract
// for maximum testability on said classes instead of passing Closures.
$mailer = new Mailer(
$name,
- $this->app->get(FactoryInterface::class),
+ $this->app->get(ViewFactoryContract::class),
$this->createSymfonyTransport($config, $hasPool ? $name : null),
$this->app->get(EventDispatcherInterface::class)
);
diff --git a/src/mail/src/Mailer.php b/src/mail/src/Mailer.php
index 663c072be..586db1fcc 100644
--- a/src/mail/src/Mailer.php
+++ b/src/mail/src/Mailer.php
@@ -8,7 +8,6 @@
use DateInterval;
use DateTimeInterface;
use Hyperf\Macroable\Macroable;
-use Hyperf\ViewEngine\Contract\FactoryInterface;
use Hypervel\Mail\Contracts\Mailable;
use Hypervel\Mail\Contracts\Mailable as MailableContract;
use Hypervel\Mail\Contracts\Mailer as MailerContract;
@@ -20,6 +19,7 @@
use Hypervel\Queue\Contracts\ShouldQueue;
use Hypervel\Support\Contracts\Htmlable;
use Hypervel\Support\HtmlString;
+use Hypervel\View\Contracts\Factory as FactoryContract;
use InvalidArgumentException;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Mailer\Envelope;
@@ -63,13 +63,13 @@ class Mailer implements MailerContract, MailQueueContract
* Create a new Mailer instance.
*
* @param string $name the name that is configured for the mailer
- * @param FactoryInterface $views the view factory instance
+ * @param FactoryContract $views the view factory instance
* @param TransportInterface $transport the Symfony Transport instance
* @param null|EventDispatcherInterface $events the event dispatcher instance
*/
public function __construct(
protected string $name,
- protected FactoryInterface $views,
+ protected FactoryContract $views,
protected TransportInterface $transport,
protected ?EventDispatcherInterface $events = null
) {
@@ -489,7 +489,7 @@ public function getSymfonyTransport(): TransportInterface
/**
* Get the view factory instance.
*/
- public function getViewFactory(): FactoryInterface
+ public function getViewFactory(): FactoryContract
{
return $this->views;
}
diff --git a/src/mail/src/Markdown.php b/src/mail/src/Markdown.php
index d0a59f6d3..803277cb5 100644
--- a/src/mail/src/Markdown.php
+++ b/src/mail/src/Markdown.php
@@ -5,8 +5,8 @@
namespace Hypervel\Mail;
use Hyperf\Stringable\Str;
-use Hyperf\ViewEngine\Contract\FactoryInterface;
use Hypervel\Support\HtmlString;
+use Hypervel\View\Contracts\Factory as FactoryContract;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Table\TableExtension;
@@ -29,7 +29,7 @@ class Markdown
* Create a new Markdown renderer instance.
*/
public function __construct(
- protected FactoryInterface $view,
+ protected FactoryContract $view,
array $options = []
) {
$this->theme = $options['theme'] ?? 'default';
diff --git a/src/mail/src/MarkdownFactory.php b/src/mail/src/MarkdownFactory.php
index c466148d5..cc8ac71b1 100644
--- a/src/mail/src/MarkdownFactory.php
+++ b/src/mail/src/MarkdownFactory.php
@@ -5,12 +5,12 @@
namespace Hypervel\Mail;
use Hyperf\Contract\ConfigInterface;
-use Hyperf\ViewEngine\Contract\FactoryInterface;
+use Hypervel\View\Contracts\Factory as FactoryContract;
class MarkdownFactory
{
public function __construct(
- protected FactoryInterface $factory,
+ protected FactoryContract $factory,
protected ConfigInterface $config,
) {
}
diff --git a/src/session/src/Middleware/StartSession.php b/src/session/src/Middleware/StartSession.php
index af0d74ff5..60d299566 100644
--- a/src/session/src/Middleware/StartSession.php
+++ b/src/session/src/Middleware/StartSession.php
@@ -12,12 +12,14 @@
use Hyperf\HttpServer\Router\Dispatched;
use Hypervel\Cache\Contracts\Factory as CacheFactoryContract;
use Hypervel\Cookie\Cookie;
+use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract;
use Hypervel\Session\Contracts\Session;
use Hypervel\Session\SessionManager;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
+use Throwable;
class StartSession implements MiddlewareInterface
{
@@ -27,11 +29,13 @@ class StartSession implements MiddlewareInterface
* @param SessionManager $manager the session manager
* @param CacheFactoryContract $cache the cache factory
* @param Request $request Hyperf's request proxy
+ * @param ExceptionHandlerContract $exceptionHandler the exception handler
*/
public function __construct(
protected SessionManager $manager,
protected CacheFactoryContract $cache,
- protected Request $request
+ protected Request $request,
+ protected ExceptionHandlerContract $exceptionHandler,
) {
}
@@ -97,26 +101,34 @@ protected function handleRequestWhileBlocking(ServerRequestInterface $request, S
*/
protected function handleStatefulRequest(ServerRequestInterface $request, Session $session, RequestHandlerInterface $handler): mixed
{
- // If a session driver has been configured, we will need to start the session here
- // so that the data is ready for an application. Note that the Hypervel sessions
- // do not make use of PHP "native" sessions in any way since they are crappy.
- Context::set(SessionInterface::class, $session);
- $session->start();
+ try {
+ // If a session driver has been configured, we will need to start the session here
+ // so that the data is ready for an application. Note that the Hypervel sessions
+ // do not make use of PHP "native" sessions in any way since they are crappy.
+ Context::set(SessionInterface::class, $session);
+ $session->start();
+
+ $this->collectGarbage($session);
- $this->collectGarbage($session);
+ $response = $handler->handle($request);
- $response = $handler->handle($request);
+ $this->storeCurrentUrl($session);
- $this->storeCurrentUrl($session);
+ $response = $this->addCookieToResponse($response, $session);
- $response = $this->addCookieToResponse($response, $session);
+ // Again, if the session has been configured we will need to close out the session
+ // so that the attributes may be persisted to some storage medium. We will also
+ // add the session identifier cookie to the application response headers now.
+ $this->saveSession();
- // Again, if the session has been configured we will need to close out the session
- // so that the attributes may be persisted to some storage medium. We will also
- // add the session identifier cookie to the application response headers now.
- $this->saveSession();
+ return $response;
+ } catch (Throwable $e) {
+ $this->exceptionHandler->afterResponse(
+ fn () => $this->saveSession()
+ );
- return $response;
+ throw $e;
+ }
}
/**
diff --git a/src/session/src/Store.php b/src/session/src/Store.php
index 10c7107e7..bc77c63b6 100644
--- a/src/session/src/Store.php
+++ b/src/session/src/Store.php
@@ -10,9 +10,9 @@
use Hyperf\Context\Context;
use Hyperf\Macroable\Macroable;
use Hyperf\Stringable\Str;
-use Hyperf\Support\MessageBag;
-use Hyperf\ViewEngine\ViewErrorBag;
use Hypervel\Session\Contracts\Session;
+use Hypervel\Support\MessageBag;
+use Hypervel\Support\ViewErrorBag;
use SessionHandlerInterface;
use stdClass;
diff --git a/src/support/src/Contracts/Htmlable.php b/src/support/src/Contracts/Htmlable.php
index 41bda883b..a691ef389 100644
--- a/src/support/src/Contracts/Htmlable.php
+++ b/src/support/src/Contracts/Htmlable.php
@@ -4,8 +4,10 @@
namespace Hypervel\Support\Contracts;
-use Hyperf\ViewEngine\Contract\Htmlable as HyperfHtmlable;
-
-interface Htmlable extends HyperfHtmlable
+interface Htmlable
{
+ /**
+ * Get content as a string of HTML.
+ */
+ public function toHtml(): string;
}
diff --git a/src/support/src/Contracts/MessageProvider.php b/src/support/src/Contracts/MessageProvider.php
index d2eda22e0..e9e4327c6 100644
--- a/src/support/src/Contracts/MessageProvider.php
+++ b/src/support/src/Contracts/MessageProvider.php
@@ -8,4 +8,8 @@
interface MessageProvider extends HyperfMessageProvider
{
+ /**
+ * Get the messages for the instance.
+ */
+ public function getMessageBag(): MessageBag;
}
diff --git a/src/support/src/Contracts/Renderable.php b/src/support/src/Contracts/Renderable.php
index 11fa059bc..151b0f775 100644
--- a/src/support/src/Contracts/Renderable.php
+++ b/src/support/src/Contracts/Renderable.php
@@ -4,9 +4,7 @@
namespace Hypervel\Support\Contracts;
-use Hyperf\ViewEngine\Contract\Renderable as BaseRenderable;
-
-interface Renderable extends BaseRenderable
+interface Renderable
{
/**
* Get the evaluated contents of the object.
diff --git a/src/support/src/DefaultProviders.php b/src/support/src/DefaultProviders.php
index 7064a06d5..eb1a9797c 100644
--- a/src/support/src/DefaultProviders.php
+++ b/src/support/src/DefaultProviders.php
@@ -19,6 +19,7 @@ public function __construct(?array $providers = null)
$this->providers = $providers ?: [
\Hypervel\Foundation\Providers\FoundationServiceProvider::class,
\Hypervel\Foundation\Providers\FormRequestServiceProvider::class,
+ \Hypervel\View\ViewServiceProvider::class,
];
}
diff --git a/src/support/src/Facades/Blade.php b/src/support/src/Facades/Blade.php
index 15ee8208a..59ab067c3 100644
--- a/src/support/src/Facades/Blade.php
+++ b/src/support/src/Facades/Blade.php
@@ -4,31 +4,36 @@
namespace Hypervel\Support\Facades;
-use Hyperf\ViewEngine\Compiler\CompilerInterface;
+use Hypervel\View\Compilers\CompilerInterface;
/**
- * @method static void compile(string|null $path = null)
- * @method static string getPath()
- * @method static void setPath(string $path)
+ * @method static void compile(string $path)
* @method static string compileString(string $value)
+ * @method static string render(string $string, array $data = [], bool $deleteCachedView = false)
+ * @method static string renderComponent(\Hypervel\View\Component $component)
* @method static string stripParentheses(string $expression)
* @method static void extend(callable $compiler)
* @method static array getExtensions()
* @method static void if(string $name, callable $callback)
- * @method static bool check(string $name, array ...$parameters)
+ * @method static bool check(string $name, mixed ...$parameters)
* @method static void component(string $class, string|null $alias = null, string $prefix = '')
* @method static void components(array $components, string $prefix = '')
* @method static array getClassComponentAliases()
+ * @method static void anonymousComponentPath(string $path, string|null $prefix = null)
+ * @method static void anonymousComponentNamespace(string $directory, string|null $prefix = null)
* @method static void componentNamespace(string $namespace, string $prefix)
+ * @method static array getAnonymousComponentPaths()
+ * @method static array getAnonymousComponentNamespaces()
* @method static array getClassComponentNamespaces()
- * @method static array getComponentAutoload()
- * @method static void setComponentAutoload(array $config)
* @method static void aliasComponent(string $path, string|null $alias = null)
* @method static void include(string $path, string|null $alias = null)
* @method static void aliasInclude(string $path, string|null $alias = null)
- * @method static void directive(string $name, callable $handler)
+ * @method static void bindDirective(string $name, callable $handler)
+ * @method static void directive(string $name, \Closure $handler, bool $bind = false)
* @method static array getCustomDirectives()
+ * @method static \Hypervel\View\Compilers\BladeCompiler prepareStringsForCompilationUsing(callable $callback)
* @method static void precompiler(callable $precompiler)
+ * @method static string usingEchoFormat(string $format, callable $callback)
* @method static void setEchoFormat(string $format)
* @method static void withDoubleEncoding()
* @method static void withoutDoubleEncoding()
@@ -40,7 +45,9 @@
* @method static string compileEndComponentClass()
* @method static mixed sanitizeComponentAttribute(mixed $value)
* @method static string compileEndOnce()
+ * @method static void stringable(callable|string $class, callable|null $handler = null)
* @method static string compileEchos(string $value)
+ * @method static mixed applyEchoHandler(mixed $value)
*
* @see \Hypervel\View\Compilers\BladeCompiler
*/
diff --git a/src/support/src/Facades/View.php b/src/support/src/Facades/View.php
index bbe8ea9df..29549a111 100644
--- a/src/support/src/Facades/View.php
+++ b/src/support/src/Facades/View.php
@@ -4,70 +4,78 @@
namespace Hypervel\Support\Facades;
-use Hyperf\ViewEngine\Contract\FactoryInterface;
+use Hypervel\View\Contracts\Factory as FactoryContract;
/**
- * @method static \Hyperf\ViewEngine\Contract\ViewInterface file(string $path, array|\Hyperf\Contract\Arrayable $data = [], array $mergeData = [])
- * @method static \Hyperf\ViewEngine\Contract\ViewInterface make(string $view, array|\Hyperf\Contract\Arrayable $data = [], array $mergeData = [])
- * @method static \Hyperf\ViewEngine\Contract\ViewInterface first(array $views, \Hyperf\Contract\Arrayable|array $data = [], array $mergeData = [])
- * @method static string renderWhen(bool $condition, string $view, \Hyperf\Contract\Arrayable|array $data = [], array $mergeData = [])
- * @method static string renderUnless(bool $condition, string $view, \Hyperf\Contract\Arrayable|array $data = [], array $mergeData = [])
+ * @method static \Hypervel\View\Contracts\View file(string $path, \Hypervel\Support\Contracts\Arrayable|array $data = [], array $mergeData = [])
+ * @method static \Hypervel\View\Contracts\View make(string $view, \Hypervel\Support\Contracts\Arrayable|array $data = [], array $mergeData = [])
+ * @method static \Hypervel\View\Contracts\View first(array $views, \Hypervel\Support\Contracts\Arrayable|array $data = [], array $mergeData = [])
+ * @method static string renderWhen(bool $condition, string $view, \Hypervel\Support\Contracts\Arrayable|array $data = [], array $mergeData = [])
+ * @method static string renderUnless(bool $condition, string $view, \Hypervel\Support\Contracts\Arrayable|array $data = [], array $mergeData = [])
* @method static string renderEach(string $view, array $data, string $iterator, string $empty = 'raw|')
* @method static bool exists(string $view)
- * @method static \Hyperf\ViewEngine\Contract\EngineInterface getEngineFromPath(string $path)
- * @method static mixed share(array|string $key, null|mixed $value = null)
+ * @method static \Hypervel\View\Contracts\Engine getEngineFromPath(string $path)
+ * @method static mixed share(array|string $key, mixed $value = null)
* @method static void incrementRender()
* @method static void decrementRender()
* @method static bool doneRendering()
* @method static bool hasRenderedOnce(string $id)
* @method static void markAsRenderedOnce(string $id)
* @method static void addLocation(string $location)
- * @method static \Hyperf\ViewEngine\Factory addNamespace(string $namespace, array|string $hints)
- * @method static \Hyperf\ViewEngine\Factory prependNamespace(string $namespace, array|string $hints)
- * @method static \Hyperf\ViewEngine\Factory replaceNamespace(string $namespace, array|string $hints)
+ * @method static void prependLocation(string $location)
+ * @method static \Hypervel\View\Factory addNamespace(string $namespace, array|string $hints)
+ * @method static \Hypervel\View\Factory prependNamespace(string $namespace, array|string $hints)
+ * @method static \Hypervel\View\Factory replaceNamespace(string $namespace, array|string $hints)
* @method static void addExtension(string $extension, string $engine, \Closure|null $resolver = null)
* @method static void flushState()
* @method static void flushStateIfDoneRendering()
* @method static array getExtensions()
- * @method static \Hyperf\ViewEngine\Contract\EngineResolverInterface getEngineResolver()
- * @method static \Hyperf\ViewEngine\Contract\FinderInterface getFinder()
- * @method static void setFinder(\Hyperf\ViewEngine\Contract\FinderInterface $finder)
+ * @method static \Hypervel\View\Engines\EngineResolver getEngineResolver()
+ * @method static \Hypervel\View\ViewFinderInterface getFinder()
+ * @method static void setFinder(\Hypervel\View\ViewFinderInterface $finder)
* @method static void flushFinderCache()
- * @method static \Psr\EventDispatcher\EventDispatcherInterface getDispatcher()
- * @method static void setDispatcher(\Psr\EventDispatcher\EventDispatcherInterface $events)
- * @method static \Psr\Container\ContainerInterface getContainer()
- * @method static void setContainer(\Psr\Container\ContainerInterface $container)
+ * @method static \Hypervel\Event\Contracts\Dispatcher getDispatcher()
+ * @method static void setDispatcher(\Hypervel\Event\Contracts\Dispatcher $events)
+ * @method static \Hypervel\Container\Contracts\Container getContainer()
+ * @method static void setContainer(\Hypervel\Container\Contracts\Container $container)
* @method static mixed shared(string $key, mixed $default = null)
* @method static array getShared()
* @method static void macro(string $name, callable|object $macro)
* @method static void mixin(object $mixin, bool $replace = true)
* @method static bool hasMacro(string $name)
- * @method static void startComponent(\Closure|\Hyperf\ViewEngine\Contract\Htmlable|string|\Hyperf\ViewEngine\View $view, array $data = [])
+ * @method static void flushMacros()
+ * @method static void startComponent(\Hypervel\View\Contracts\View|\Hypervel\Support\Contracts\Htmlable|\Closure|string $view, array $data = [])
* @method static void startComponentFirst(array $names, array $data = [])
* @method static string renderComponent()
- * @method static void slot(string $name, null|string $content = null)
+ * @method static mixed getConsumableComponentData(string $key, mixed $default = null)
+ * @method static void slot(string $name, string|null $content = null, array $attributes = [])
* @method static void endSlot()
* @method static array creator(array|string $views, \Closure|string $callback)
* @method static array composers(array $composers)
* @method static array composer(array|string $views, \Closure|string $callback)
- * @method static void callComposer(\Hyperf\ViewEngine\Contract\ViewInterface $view)
- * @method static void callCreator(\Hyperf\ViewEngine\Contract\ViewInterface $view)
- * @method static void startSection(string $section, null|string|\Hyperf\ViewEngine\Contract\ViewInterface $content = null)
+ * @method static void callComposer(\Hypervel\View\Contracts\View $view)
+ * @method static void callCreator(\Hypervel\View\Contracts\View $view)
+ * @method static void startFragment(string $fragment)
+ * @method static string stopFragment()
+ * @method static mixed getFragment(string $name, string|null $default = null)
+ * @method static array getFragments()
+ * @method static void flushFragments()
+ * @method static void startSection(string $section, \Hypervel\View\Contracts\View|string|null $content = null)
* @method static void inject(string $section, string $content)
* @method static string yieldSection()
* @method static string stopSection(bool $overwrite = false)
* @method static string appendSection()
- * @method static string yieldContent(string $section, \Hyperf\ViewEngine\Contract\ViewInterface|string $default = '')
- * @method static string parentPlaceholder(string $section = '')
+ * @method static string yieldContent(string $section, \Hypervel\View\Contracts\View|string $default = '')
+ * @method static string getParentPlaceholder(string $section = '')
* @method static bool hasSection(string $name)
* @method static bool sectionMissing(string $name)
- * @method static mixed getSection(string $name, null|string $default = null)
+ * @method static mixed getSection(string $name, string|null $default = null)
* @method static array getSections()
* @method static void flushSections()
- * @method static void addLoop(null|array|\Countable $data)
+ * @method static void addLoop(\Closure|\Generator|\Hypervel\Support\LazyCollection|array $data)
* @method static void incrementLoopIndices()
* @method static void popLoop()
- * @method static null|\stdClass|void getLastLoop()
+ * @method static \stdClass|null getLastLoop()
* @method static array getLoopStack()
* @method static void startPush(string $section, string $content = '')
* @method static string stopPush()
@@ -78,12 +86,12 @@
* @method static void startTranslation(array $replacements = [])
* @method static string renderTranslation()
*
- * @see \Hyperf\ViewEngine\Factory
+ * @see \Hypervel\View\Factory
*/
class View extends Facade
{
protected static function getFacadeAccessor()
{
- return FactoryInterface::class;
+ return FactoryContract::class;
}
}
diff --git a/src/support/src/Facades/Vite.php b/src/support/src/Facades/Vite.php
new file mode 100644
index 000000000..78ea2abd4
--- /dev/null
+++ b/src/support/src/Facades/Vite.php
@@ -0,0 +1,13 @@
+callAfterResolving(ViewFactoryContract::class, function ($view) use ($path, $namespace) {
- $viewPath = $this->app->get(ConfigInterface::class)
- ->get('view.config.view_path', null);
-
- if (is_dir($appPath = $viewPath . '/vendor/' . $namespace)) {
- $view->addNamespace($namespace, $appPath);
- }
-
$view->addNamespace($namespace, $path);
});
}
@@ -134,7 +127,7 @@ protected function loadViewsFrom(array|string $path, string $namespace): void
*/
protected function loadViewComponentsAs(string $prefix, array $components): void
{
- $this->callAfterResolving(BladeCompiler::class, function ($blade) use ($prefix, $components) {
+ $this->callAfterResolving(CompilerInterface::class, function ($blade) use ($prefix, $components) {
foreach ($components as $alias => $component) {
$blade->component($component, is_string($alias) ? $alias : null, $prefix);
}
diff --git a/src/support/src/Str.php b/src/support/src/Str.php
index a4f3a7145..c94af70d8 100644
--- a/src/support/src/Str.php
+++ b/src/support/src/Str.php
@@ -13,6 +13,13 @@
class Str extends BaseStr
{
+ /**
+ * The callback that should be used to generate random strings.
+ *
+ * @param ?callable $randomStringFactory
+ */
+ protected static $randomStringFactory;
+
/**
* Get a string from a BackedEnum, Stringable, or scalar value.
*
@@ -128,4 +135,32 @@ public static function isUuid($value, $version = null): bool
return $fields->getVersion() === $version;
}
+
+ /**
+ * Generate a more truly "random" alpha-numeric string.
+ */
+ public static function random(int $length = 16): string
+ {
+ if (is_callable(static::$randomStringFactory)) {
+ return call_user_func(static::$randomStringFactory, $length);
+ }
+
+ return parent::random($length);
+ }
+
+ /**
+ * Set the callable that will be used to generate random strings.
+ */
+ public static function createRandomStringsUsing(?callable $factory = null): void
+ {
+ static::$randomStringFactory = $factory;
+ }
+
+ /**
+ * Indicate that random strings should be created normally and not using a custom factory.
+ */
+ public static function createRandomStringsNormally(): void
+ {
+ static::$randomStringFactory = null;
+ }
}
diff --git a/src/support/src/ViewErrorBag.php b/src/support/src/ViewErrorBag.php
new file mode 100644
index 000000000..eda81ab74
--- /dev/null
+++ b/src/support/src/ViewErrorBag.php
@@ -0,0 +1,108 @@
+bags[$key]);
+ }
+
+ /**
+ * Get a MessageBag instance from the bags.
+ */
+ public function getBag(string $key)
+ {
+ return Arr::get($this->bags, $key) ?: new MessageBag();
+ }
+
+ /**
+ * Get all the bags.
+ */
+ public function getBags(): array
+ {
+ return $this->bags;
+ }
+
+ /**
+ * Add a new MessageBag instance to the bags.
+ */
+ public function put(string $key, MessageBagContract $bag): static
+ {
+ $this->bags[$key] = $bag;
+
+ return $this;
+ }
+
+ /**
+ * Determine if the default message bag has any messages.
+ */
+ public function any(): bool
+ {
+ return $this->count() > 0;
+ }
+
+ /**
+ * Get the number of messages in the default bag.
+ */
+ public function count(): int
+ {
+ return $this->getBag('default')->count();
+ }
+
+ /**
+ * Dynamically call methods on the default bag.
+ */
+ public function __call(string $method, array $parameters): mixed
+ {
+ return $this->getBag('default')->{$method}(...$parameters);
+ }
+
+ /**
+ * Dynamically access a view error bag.
+ *
+ * @param string $key
+ * @return MessageBagContract
+ */
+ public function __get($key)
+ {
+ return $this->getBag($key);
+ }
+
+ /**
+ * Dynamically set a view error bag.
+ *
+ * @param string $key
+ * @param MessageBagContract $value
+ */
+ public function __set($key, $value)
+ {
+ $this->put($key, $value);
+ }
+
+ /**
+ * Convert the default bag to its string representation.
+ */
+ public function __toString(): string
+ {
+ return (string) $this->getBag('default');
+ }
+}
diff --git a/src/support/src/helpers.php b/src/support/src/helpers.php
index 1545b5b3c..9362764f0 100644
--- a/src/support/src/helpers.php
+++ b/src/support/src/helpers.php
@@ -53,7 +53,7 @@ function environment(mixed ...$environments): bool|Environment
/**
* Encode HTML special characters in a string.
*/
- function e(BackedEnum|DeferringDisplayableValue|float|Htmlable|int|string|null $value, bool $doubleEncode = true): string
+ function e(BackedEnum|DeferringDisplayableValue|Stringable|float|Htmlable|int|string|null $value, bool $doubleEncode = true): string
{
if ($value instanceof DeferringDisplayableValue) {
$value = $value->resolveDisplayableValue();
@@ -67,7 +67,7 @@ function e(BackedEnum|DeferringDisplayableValue|float|Htmlable|int|string|null $
$value = $value->value;
}
- return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', $doubleEncode);
+ return htmlspecialchars((string) ($value ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', $doubleEncode);
}
}
diff --git a/src/telescope/src/Http/Controllers/HomeController.php b/src/telescope/src/Http/Controllers/HomeController.php
index 6cb2c316b..3350a45ef 100644
--- a/src/telescope/src/Http/Controllers/HomeController.php
+++ b/src/telescope/src/Http/Controllers/HomeController.php
@@ -4,15 +4,15 @@
namespace Hypervel\Telescope\Http\Controllers;
-use Hyperf\ViewEngine\Contract\ViewInterface;
use Hypervel\Telescope\Telescope;
+use Hypervel\View\Contracts\View;
class HomeController
{
/**
* Display the Telescope view.
*/
- public function index(): ViewInterface
+ public function index(): View
{
return view('telescope::layout', [
'cssFile' => Telescope::$useDarkTheme ? 'app-dark.css' : 'app.css',
diff --git a/src/telescope/src/Watchers/ViewWatcher.php b/src/telescope/src/Watchers/ViewWatcher.php
index c324149a7..9833a5dcc 100644
--- a/src/telescope/src/Watchers/ViewWatcher.php
+++ b/src/telescope/src/Watchers/ViewWatcher.php
@@ -7,11 +7,10 @@
use Hyperf\Collection\Collection;
use Hyperf\Contract\ConfigInterface;
use Hyperf\Stringable\Str;
-use Hyperf\ViewEngine\Contract\ViewInterface;
use Hypervel\Telescope\IncomingEntry;
use Hypervel\Telescope\Telescope;
use Hypervel\Telescope\Watchers\Traits\FormatsClosure;
-use Hypervel\View\Events\ViewRendered;
+use Hypervel\View\Contracts\View as ViewContract;
use Psr\Container\ContainerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
@@ -28,22 +27,22 @@ public function register(ContainerInterface $app): void
->set('view.event.enable', true);
$app->get(EventDispatcherInterface::class)
- ->listen(ViewRendered::class, [$this, 'recordAction']);
+ ->listen($this->options['events'] ?? 'composing:*', [$this, 'recordAction']);
}
/**
* Record an action.
*/
- public function recordAction(ViewRendered $event): void
+ public function recordAction(string $event, ViewContract $view): void
{
if (! Telescope::isRecording()) {
return;
}
Telescope::recordView(IncomingEntry::make(array_filter([
- 'name' => $event->view->name(),
- 'path' => $this->extractPath($event->view),
- 'data' => $this->extractKeysFromData($event->view),
+ 'name' => $view->name(),
+ 'path' => $this->extractPath($view),
+ 'data' => $this->extractKeysFromData($view),
'composers' => [],
])));
}
@@ -51,9 +50,8 @@ public function recordAction(ViewRendered $event): void
/**
* Extract the path from the given view.
*/
- protected function extractPath(ViewInterface $view): string
+ protected function extractPath(ViewContract $view): string
{
- /** @var \Hyperf\ViewEngine\View $view */
$path = $view->getPath();
if (Str::startsWith($path, base_path())) {
@@ -66,7 +64,7 @@ protected function extractPath(ViewInterface $view): string
/**
* Extract the keys from the given view in array form.
*/
- protected function extractKeysFromData(ViewInterface $view): array
+ protected function extractKeysFromData(ViewContract $view): array
{
return Collection::make($view->getData())->filter(function ($value, $key) {
return ! in_array($key, ['app', '__env', 'obLevel', 'errors']);
diff --git a/src/testbench/workbench/config/view.php b/src/testbench/workbench/config/view.php
new file mode 100644
index 000000000..6b0ebb116
--- /dev/null
+++ b/src/testbench/workbench/config/view.php
@@ -0,0 +1,33 @@
+ [
+ BASE_PATH . '/resources/views',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Compiled View Path
+ |--------------------------------------------------------------------------
+ |
+ | This option determines where all the compiled Blade templates will be
+ | stored for your application. Typically, this is within the storage
+ | directory. However, as usual, you are free to change this value.
+ |
+ */
+
+ 'compiled' => BASE_PATH . '/runtime/cache/views',
+];
diff --git a/src/view/README.md b/src/view/README.md
new file mode 100644
index 000000000..d67ed0829
--- /dev/null
+++ b/src/view/README.md
@@ -0,0 +1,4 @@
+View for Hypervel
+===
+
+[](https://deepwiki.com/hypervel/view)
diff --git a/src/view/composer.json b/src/view/composer.json
new file mode 100644
index 000000000..8dc0fe24c
--- /dev/null
+++ b/src/view/composer.json
@@ -0,0 +1,50 @@
+{
+ "name": "hypervel/view",
+ "description": "The view package for Hypervel.",
+ "license": "MIT",
+ "keywords": [
+ "php",
+ "hyperf",
+ "swoole",
+ "view",
+ "hypervel"
+ ],
+ "support": {
+ "issues": "https://github.com/hypervel/components/issues",
+ "source": "https://github.com/hypervel/components"
+ },
+ "authors": [
+ {
+ "name": "Albert Chen",
+ "email": "albert@hypervel.org"
+ }
+ ],
+ "require": {
+ "php": "^8.2",
+ "ext-tokenizer": "*",
+ "hyperf/view": "~3.1.0",
+ "hypervel/container": "^0.3",
+ "hypervel/event": "^0.3",
+ "hypervel/filesystem": "^0.3",
+ "hypervel/support": "^0.3"
+ },
+ "autoload": {
+ "psr-4": {
+ "Hypervel\\View\\": "src/"
+ }
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-main": "0.3-dev"
+ },
+ "hypervel": {
+ "providers": [
+ "Hypervel\\View\\ViewServiceProvider"
+ ]
+ }
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "minimum-stability": "dev"
+}
diff --git a/src/view/src/AnonymousComponent.php b/src/view/src/AnonymousComponent.php
new file mode 100644
index 000000000..0d06d91f5
--- /dev/null
+++ b/src/view/src/AnonymousComponent.php
@@ -0,0 +1,40 @@
+view;
+ }
+
+ /**
+ * Get the data that should be supplied to the view.
+ */
+ public function data(): array
+ {
+ $this->attributes = $this->attributes ?: $this->newAttributeBag();
+
+ return array_merge(
+ ($this->data['attributes'] ?? null)?->getAttributes() ?: [],
+ $this->attributes->getAttributes(),
+ $this->data,
+ ['attributes' => $this->attributes]
+ );
+ }
+}
diff --git a/src/view/src/AppendableAttributeValue.php b/src/view/src/AppendableAttributeValue.php
new file mode 100644
index 000000000..ff1c2471b
--- /dev/null
+++ b/src/view/src/AppendableAttributeValue.php
@@ -0,0 +1,26 @@
+value;
+ }
+}
diff --git a/src/view/src/Compilers/BladeCompiler.php b/src/view/src/Compilers/BladeCompiler.php
new file mode 100644
index 000000000..1a281a83e
--- /dev/null
+++ b/src/view/src/Compilers/BladeCompiler.php
@@ -0,0 +1,890 @@
+compileString($this->files->get($path));
+
+ $contents = $this->appendFilePath($contents, $path);
+
+ $this->ensureCompiledDirectoryExists(
+ $compiledPath = $this->getCompiledPath($path)
+ );
+
+ $this->files->put($compiledPath, $contents);
+ }
+
+ /**
+ * Append the file path to the compiled string.
+ */
+ protected function appendFilePath(string $contents, string $path): string
+ {
+ $tokens = $this->getOpenAndClosingPhpTokens($contents);
+
+ if ($tokens->isNotEmpty() && $tokens->last() !== T_CLOSE_TAG) {
+ $contents .= ' ?>';
+ }
+
+ return $contents . "";
+ }
+
+ /**
+ * Get the open and closing PHP tag tokens from the given string.
+ */
+ protected function getOpenAndClosingPhpTokens(string $contents): Collection
+ {
+ return (new Collection(token_get_all($contents)))
+ ->pluck('0')
+ ->filter(function ($token) {
+ return in_array($token, [T_OPEN_TAG, T_OPEN_TAG_WITH_ECHO, T_CLOSE_TAG]);
+ });
+ }
+
+ /**
+ * Compile the given Blade template contents.
+ */
+ public function compileString(string $value): string
+ {
+ Context::set(static::FOOTER_CONTEXT_KEY, []);
+ $result = '';
+
+ foreach ($this->prepareStringsForCompilationUsing as $callback) {
+ $value = $callback($value);
+ }
+
+ $value = $this->storeUncompiledBlocks($value);
+
+ // First we will compile the Blade component tags. This is a precompile style
+ // step which compiles the component Blade tags into @component directives
+ // that may be used by Blade. Then we should call any other precompilers.
+ $value = $this->compileComponentTags(
+ $this->compileComments($value)
+ );
+
+ foreach ($this->precompilers as $precompiler) {
+ $value = $precompiler($value);
+ }
+
+ // Here we will loop through all of the tokens returned by the Zend lexer and
+ // parse each one into the corresponding valid PHP. We will then have this
+ // template as the correctly rendered PHP that can be rendered natively.
+ foreach (token_get_all($value) as $token) {
+ $result .= is_array($token) ? $this->parseToken($token) : $token;
+ }
+
+ if (! empty(Context::get(static::RAW_BLOCKS_CONTEXT_KEY))) {
+ $result = $this->restoreRawContent($result);
+ }
+
+ // If there are any footer lines that need to get added to a template we will
+ // add them here at the end of the template. This gets used mainly for the
+ // template inheritance via the extends keyword that should be appended.
+ $footer = Context::get(static::FOOTER_CONTEXT_KEY, []);
+ if (count($footer) > 0) {
+ $result = $this->addFooters($result, $footer);
+ }
+
+ if (! empty($this->echoHandlers)) {
+ $result = $this->addBladeCompilerVariable($result);
+ }
+
+ return str_replace(
+ ['##BEGIN-COMPONENT-CLASS##', '##END-COMPONENT-CLASS##'],
+ '',
+ $result
+ );
+ }
+
+ /**
+ * Evaluate and render a Blade string to HTML.
+ */
+ public static function render(string $string, array $data = [], bool $deleteCachedView = false): string
+ {
+ $component = new class($string) extends Component {
+ protected $template;
+
+ public function __construct($template)
+ {
+ $this->template = $template;
+ }
+
+ public function render(): View|Htmlable|Closure|string
+ {
+ return $this->template;
+ }
+ };
+
+ $view = Container::getInstance()
+ ->make(ViewFactory::class)
+ ->make($component->resolveView(), $data);
+
+ return tap($view->render(), function () use ($view, $deleteCachedView) {
+ if ($deleteCachedView) {
+ @unlink($view->getPath());
+ }
+ });
+ }
+
+ /**
+ * Render a component instance to HTML.
+ */
+ public static function renderComponent(Component $component): string
+ {
+ $data = $component->data();
+
+ $view = value($component->resolveView(), $data);
+
+ if ($view instanceof View) {
+ return $view->with($data)->render();
+ }
+ if ($view instanceof Htmlable) {
+ return $view->toHtml();
+ }
+ return Container::getInstance()
+ ->make(ViewFactory::class)
+ ->make($view, $data)
+ ->render();
+ }
+
+ /**
+ * Store the blocks that do not receive compilation.
+ */
+ protected function storeUncompiledBlocks(string $value): string
+ {
+ if (str_contains($value, '@verbatim')) {
+ $value = $this->storeVerbatimBlocks($value);
+ }
+
+ if (str_contains($value, '@php')) {
+ $value = $this->storePhpBlocks($value);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Store the verbatim blocks and replace them with a temporary placeholder.
+ */
+ protected function storeVerbatimBlocks(string $value): string
+ {
+ return preg_replace_callback('/(?storeRawBlock($matches[2]);
+ }, $value);
+ }
+
+ /**
+ * Store the PHP blocks and replace them with a temporary placeholder.
+ */
+ protected function storePhpBlocks(string $value): string
+ {
+ return preg_replace_callback('/(?storeRawBlock("");
+ }, $value);
+ }
+
+ /**
+ * Store a raw block and return a unique raw placeholder.
+ */
+ protected function storeRawBlock(string $value): string
+ {
+ return $this->getRawPlaceholder(
+ $this->pushRawBlock($value) - 1
+ );
+ }
+
+ /**
+ * Temporarily store the raw block found in the template.
+ *
+ * @return int the number of raw blocks in the stack after pushing the new one
+ */
+ protected function pushRawBlock(string $value): int
+ {
+ $stack = Context::get(static::RAW_BLOCKS_CONTEXT_KEY, []);
+ $stack[] = $value;
+ Context::set(static::RAW_BLOCKS_CONTEXT_KEY, $stack);
+
+ return count($stack);
+ }
+
+ /**
+ * Compile the component tags.
+ */
+ protected function compileComponentTags(string $value): string
+ {
+ if (! $this->compilesComponentTags) {
+ return $value;
+ }
+
+ return (new ComponentTagCompiler(
+ $this->classComponentAliases,
+ $this->classComponentNamespaces,
+ $this
+ ))->compile($value);
+ }
+
+ /**
+ * Replace the raw placeholders with the original code stored in the raw blocks.
+ */
+ protected function restoreRawContent(string $result): string
+ {
+ $rawBlocks = Context::get(static::RAW_BLOCKS_CONTEXT_KEY);
+
+ $result = preg_replace_callback('/' . $this->getRawPlaceholder('(\d+)') . '/', function ($matches) use ($rawBlocks) {
+ return $rawBlocks[$matches[1]];
+ }, $result);
+
+ $rawBlocks = Context::set(static::RAW_BLOCKS_CONTEXT_KEY, []);
+
+ return $result;
+ }
+
+ /**
+ * Get a placeholder to temporarily mark the position of raw blocks.
+ */
+ protected function getRawPlaceholder(int|string $replace): string
+ {
+ return str_replace('#', (string) $replace, '@__raw_block_#__@');
+ }
+
+ /**
+ * Add the stored footers onto the given content.
+ */
+ protected function addFooters(string $result, array $footer): string
+ {
+ return ltrim($result, "\n")
+ . "\n" . implode("\n", array_reverse($footer));
+ }
+
+ /**
+ * Parse the tokens from the template.
+ */
+ protected function parseToken(array $token): string
+ {
+ [$id, $content] = $token;
+
+ if ($id == T_INLINE_HTML) {
+ foreach ($this->compilers as $type) {
+ $content = $this->{"compile{$type}"}($content);
+ }
+ }
+
+ return $content;
+ }
+
+ /**
+ * Execute the user defined extensions.
+ */
+ protected function compileExtensions(string $value): string
+ {
+ foreach ($this->extensions as $compiler) {
+ $value = $compiler($value, $this);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Compile Blade statements that start with "@".
+ */
+ protected function compileStatements(string $template): string
+ {
+ preg_match_all('/\B@(@?\w+(?:::\w+)?)([ \t]*)(\( ( [\S\s]*? ) \))?/x', $template, $matches);
+
+ $offset = 0;
+
+ for ($i = 0; isset($matches[0][$i]); ++$i) {
+ $match = [
+ $matches[0][$i],
+ $matches[1][$i],
+ $matches[2][$i],
+ $matches[3][$i] ?: null,
+ $matches[4][$i] ?: null,
+ ];
+
+ // Here we check to see if we have properly found the closing parenthesis by
+ // regex pattern or not, and will recursively continue on to the next ")"
+ // then check again until the tokenizer confirms we find the right one.
+ while (isset($match[4])
+ && Str::endsWith($match[0], ')')
+ && ! $this->hasEvenNumberOfParentheses($match[0])) {
+ if (($after = Str::after($template, $match[0])) === $template) {
+ break;
+ }
+
+ $rest = Str::before($after, ')');
+
+ if (isset($matches[0][$i + 1]) && Str::contains($rest . ')', $matches[0][$i + 1])) {
+ unset($matches[0][$i + 1]);
+ ++$i;
+ }
+
+ $match[0] = $match[0] . $rest . ')';
+ $match[3] = $match[3] . $rest . ')';
+ $match[4] = $match[4] . $rest;
+ }
+
+ [$template, $offset] = $this->replaceFirstStatement(
+ $match[0],
+ $this->compileStatement($match),
+ $template,
+ $offset
+ );
+ }
+
+ return $template;
+ }
+
+ /**
+ * Replace the first match for a statement compilation operation.
+ */
+ protected function replaceFirstStatement(string $search, string $replace, string $subject, int $offset): array
+ {
+ $search = (string) $search;
+
+ if ($search === '') {
+ return [$subject, 0];
+ }
+
+ $position = strpos($subject, $search, $offset);
+
+ if ($position !== false) {
+ return [
+ substr_replace($subject, $replace, $position, strlen($search)),
+ $position + strlen($replace),
+ ];
+ }
+
+ return [$subject, 0];
+ }
+
+ /**
+ * Determine if the given expression has the same number of opening and closing parentheses.
+ */
+ protected function hasEvenNumberOfParentheses(string $expression): bool
+ {
+ $tokens = token_get_all('customDirectives[$match[1]])) {
+ $match[0] = $this->callCustomDirective($match[1], Arr::get($match, 3));
+ } elseif (method_exists($this, $method = 'compile' . ucfirst($match[1]))) {
+ $match[0] = $this->{$method}(Arr::get($match, 3));
+ } else {
+ return $match[0];
+ }
+
+ return isset($match[3]) ? $match[0] : $match[0] . $match[2];
+ }
+
+ /**
+ * Call the given directive with the given value.
+ */
+ protected function callCustomDirective(string $name, ?string $value): string
+ {
+ $value ??= '';
+
+ if (str_starts_with($value, '(') && str_ends_with($value, ')')) {
+ $value = Str::substr($value, 1, -1);
+ }
+
+ return call_user_func($this->customDirectives[$name], trim($value));
+ }
+
+ /**
+ * Strip the parentheses from the given expression.
+ */
+ public function stripParentheses(string $expression): string
+ {
+ if (Str::startsWith($expression, '(')) {
+ $expression = substr($expression, 1, -1);
+ }
+
+ return $expression;
+ }
+
+ /**
+ * Register a custom Blade compiler.
+ */
+ public function extend(callable $compiler): void
+ {
+ $this->extensions[] = $compiler;
+ }
+
+ /**
+ * Get the extensions used by the compiler.
+ */
+ public function getExtensions(): array
+ {
+ return $this->extensions;
+ }
+
+ /**
+ * Register an "if" statement directive.
+ */
+ public function if(string $name, callable $callback): void
+ {
+ $this->conditions[$name] = $callback;
+
+ $this->directive($name, function ($expression) use ($name) {
+ return $expression !== ''
+ ? ""
+ : "";
+ });
+
+ $this->directive('unless' . $name, function ($expression) use ($name) {
+ return $expression !== ''
+ ? ""
+ : "";
+ });
+
+ $this->directive('else' . $name, function ($expression) use ($name) {
+ return $expression !== ''
+ ? ""
+ : "";
+ });
+
+ $this->directive('end' . $name, function () {
+ return '';
+ });
+ }
+
+ /**
+ * Check the result of a condition.
+ */
+ public function check(string $name, mixed ...$parameters): bool
+ {
+ return call_user_func($this->conditions[$name], ...$parameters);
+ }
+
+ /**
+ * Register a class-based component alias directive.
+ */
+ public function component(string $class, ?string $alias = null, string $prefix = ''): void
+ {
+ if (! is_null($alias) && str_contains($alias, '\\')) {
+ [$class, $alias] = [$alias, $class];
+ }
+
+ if (is_null($alias)) {
+ $alias = str_contains($class, '\View\Components\\')
+ ? (new Collection(explode('\\', Str::after($class, '\View\Components\\'))))->map(function ($segment) {
+ return Str::kebab($segment);
+ })->implode(':')
+ : Str::kebab(class_basename($class));
+ }
+
+ if (! empty($prefix)) {
+ $alias = $prefix . '-' . $alias;
+ }
+
+ $this->classComponentAliases[$alias] = $class;
+ }
+
+ /**
+ * Register an array of class-based components.
+ */
+ public function components(array $components, string $prefix = ''): void
+ {
+ foreach ($components as $key => $value) {
+ if (is_numeric($key)) {
+ $this->component($value, null, $prefix);
+ } else {
+ $this->component($key, $value, $prefix);
+ }
+ }
+ }
+
+ /**
+ * Get the registered class component aliases.
+ */
+ public function getClassComponentAliases(): array
+ {
+ return $this->classComponentAliases;
+ }
+
+ /**
+ * Register a new anonymous component path.
+ */
+ public function anonymousComponentPath(string $path, ?string $prefix = null): void
+ {
+ $prefixHash = md5($prefix ?: $path);
+
+ $this->anonymousComponentPaths[] = [
+ 'path' => $path,
+ 'prefix' => $prefix,
+ 'prefixHash' => $prefixHash,
+ ];
+
+ Container::getInstance()
+ ->make(ViewFactory::class)
+ ->addNamespace($prefixHash, $path);
+ }
+
+ /**
+ * Register an anonymous component namespace.
+ */
+ public function anonymousComponentNamespace(string $directory, ?string $prefix = null): void
+ {
+ $prefix ??= $directory;
+
+ $this->anonymousComponentNamespaces[$prefix] = (new Stringable($directory))
+ ->replace('/', '.')
+ ->trim('. ')
+ ->toString();
+ }
+
+ /**
+ * Register a class-based component namespace.
+ */
+ public function componentNamespace(string $namespace, string $prefix): void
+ {
+ $this->classComponentNamespaces[$prefix] = $namespace;
+ }
+
+ /**
+ * Get the registered anonymous component paths.
+ */
+ public function getAnonymousComponentPaths(): array
+ {
+ return $this->anonymousComponentPaths;
+ }
+
+ /**
+ * Get the registered anonymous component namespaces.
+ */
+ public function getAnonymousComponentNamespaces(): array
+ {
+ return $this->anonymousComponentNamespaces;
+ }
+
+ /**
+ * Get the registered class component namespaces.
+ */
+ public function getClassComponentNamespaces(): array
+ {
+ return $this->classComponentNamespaces;
+ }
+
+ /**
+ * Register a component alias directive.
+ */
+ public function aliasComponent(string $path, ?string $alias = null): void
+ {
+ $alias = $alias ?: Arr::last(explode('.', $path));
+
+ $this->directive($alias, function ($expression) use ($path) {
+ return $expression
+ ? "startComponent('{$path}', {$expression}); ?>"
+ : "startComponent('{$path}'); ?>";
+ });
+
+ $this->directive('end' . $alias, function ($expression) {
+ return 'renderComponent(); ?>';
+ });
+ }
+
+ /**
+ * Register an include alias directive.
+ */
+ public function include(string $path, ?string $alias = null): void
+ {
+ $this->aliasInclude($path, $alias);
+ }
+
+ /**
+ * Register an include alias directive.
+ */
+ public function aliasInclude(string $path, ?string $alias = null): void
+ {
+ $alias = $alias ?: Arr::last(explode('.', $path));
+
+ $this->directive($alias, function ($expression) use ($path) {
+ $expression = $this->stripParentheses($expression) ?: '[]';
+
+ return "make('{$path}', {$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?>";
+ });
+ }
+
+ /**
+ * Register a handler for custom directives, binding the handler to the compiler.
+ *
+ * @throws InvalidArgumentException
+ */
+ public function bindDirective(string $name, callable $handler): void
+ {
+ $this->directive($name, $handler, bind: true);
+ }
+
+ /**
+ * Register a handler for custom directives.
+ *
+ * @throws InvalidArgumentException
+ */
+ public function directive(string $name, Closure $handler, bool $bind = false): void
+ {
+ if (! preg_match('/^\w+(?:::\w+)?$/x', $name)) {
+ throw new InvalidArgumentException("The directive name [{$name}] is not valid. Directive names must only contain alphanumeric characters and underscores.");
+ }
+
+ $this->customDirectives[$name] = $bind ? $handler->bindTo($this, BladeCompiler::class) : $handler;
+ }
+
+ /**
+ * Get the list of custom directives.
+ */
+ public function getCustomDirectives(): array
+ {
+ return $this->customDirectives;
+ }
+
+ /**
+ * Indicate that the following callable should be used to prepare strings for compilation.
+ */
+ public function prepareStringsForCompilationUsing(callable $callback): static
+ {
+ $this->prepareStringsForCompilationUsing[] = $callback;
+
+ return $this;
+ }
+
+ /**
+ * Register a new precompiler.
+ */
+ public function precompiler(callable $precompiler): void
+ {
+ $this->precompilers[] = $precompiler;
+ }
+
+ /**
+ * Execute the given callback using a custom echo format.
+ */
+ public function usingEchoFormat(string $format, callable $callback): string
+ {
+ $originalEchoFormat = $this->getEchoFormat();
+
+ $this->setEchoFormat($format);
+
+ try {
+ $output = call_user_func($callback);
+ } finally {
+ $this->setEchoFormat($originalEchoFormat);
+ }
+
+ return $output;
+ }
+
+ /**
+ * Set the echo format to be used by the compiler.
+ */
+ public function setEchoFormat(string $format): void
+ {
+ Context::set(static::ECHO_FORMAT_CONTEXT_KEY, $format);
+ }
+
+ /**
+ * Get the echo format to be used by the compiler.
+ */
+ protected function getEchoFormat(): string
+ {
+ return Context::get(static::ECHO_FORMAT_CONTEXT_KEY, 'e(%s)');
+ }
+
+ /**
+ * Set the "echo" format to double encode entities.
+ */
+ public function withDoubleEncoding(): void
+ {
+ $this->setEchoFormat('e(%s, true)');
+ }
+
+ /**
+ * Set the "echo" format to not double encode entities.
+ */
+ public function withoutDoubleEncoding(): void
+ {
+ $this->setEchoFormat('e(%s, false)');
+ }
+
+ /**
+ * Indicate that component tags should not be compiled.
+ */
+ public function withoutComponentTags(): void
+ {
+ $this->compilesComponentTags = false;
+ }
+
+ protected function pushFooter($footer)
+ {
+ $stack = Context::get(static::FOOTER_CONTEXT_KEY, []);
+ $stack[] = $footer;
+ Context::set(static::FOOTER_CONTEXT_KEY, $stack);
+ }
+}
diff --git a/src/view/src/Compilers/Compiler.php b/src/view/src/Compilers/Compiler.php
new file mode 100755
index 000000000..292d8f2b3
--- /dev/null
+++ b/src/view/src/Compilers/Compiler.php
@@ -0,0 +1,75 @@
+cachePath . '/' . hash('xxh128', 'v2' . Str::after($path, $this->basePath)) . '.' . $this->compiledExtension;
+ }
+
+ /**
+ * Determine if the view at the given path is expired.
+ *
+ * @throws ErrorException
+ */
+ public function isExpired(string $path): bool
+ {
+ if (! $this->shouldCache) {
+ return true;
+ }
+
+ $compiled = $this->getCompiledPath($path);
+
+ // If the compiled file doesn't exist we will indicate that the view is expired
+ // so that it can be re-compiled. Else, we will verify the last modification
+ // of the views is less than the modification times of the compiled views.
+ if (! $this->files->exists($compiled)) {
+ return true;
+ }
+
+ try {
+ return $this->files->lastModified($path) >= $this->files->lastModified($compiled);
+ } catch (ErrorException $exception) {
+ // The compiled file might have been deleted between the initial check and lastModified() call
+ // @phpstan-ignore booleanNot.alwaysFalse
+ if (! $this->files->exists($compiled)) {
+ return true;
+ }
+
+ throw $exception;
+ }
+ }
+
+ /**
+ * Create the compiled file directory if necessary.
+ */
+ protected function ensureCompiledDirectoryExists(string $path): void
+ {
+ if (! $this->files->exists(dirname($path))) {
+ $this->files->makeDirectory(dirname($path), 0777, true, true);
+ }
+ }
+}
diff --git a/src/view/src/Compilers/CompilerInterface.php b/src/view/src/Compilers/CompilerInterface.php
new file mode 100755
index 000000000..3e9fc9b30
--- /dev/null
+++ b/src/view/src/Compilers/CompilerInterface.php
@@ -0,0 +1,23 @@
+blade = $blade ?: new BladeCompiler(new Filesystem(), sys_get_temp_dir());
+ }
+
+ /**
+ * Compile the component and slot tags within the given string.
+ */
+ public function compile(string $value): string
+ {
+ $value = $this->compileSlots($value);
+
+ return $this->compileTags($value);
+ }
+
+ /**
+ * Compile the tags within the given string.
+ *
+ * @throws InvalidArgumentException
+ */
+ public function compileTags(string $value): string
+ {
+ $value = $this->compileSelfClosingTags($value);
+ $value = $this->compileOpeningTags($value);
+ return $this->compileClosingTags($value);
+ }
+
+ /**
+ * Compile the opening tags within the given string.
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function compileOpeningTags(string $value): string
+ {
+ $pattern = "/
+ <
+ \\s*
+ x[-\\:]([\\w\\-\\:\\.]*)
+ (?
+ (?:
+ \\s+
+ (?:
+ (?:
+ @(?:class)(\\( (?: (?>[^()]+) | (?-1) )* \\))
+ )
+ |
+ (?:
+ @(?:style)(\\( (?: (?>[^()]+) | (?-1) )* \\))
+ )
+ |
+ (?:
+ \\{\\{\\s*\\\$attributes(?:[^}]+?)?\\s*\\}\\}
+ )
+ |
+ (?:
+ (\\:\\\$)(\\w+)
+ )
+ |
+ (?:
+ [\\w\\-:.@%]+
+ (
+ =
+ (?:
+ \\\"[^\\\"]*\\\"
+ |
+ \\'[^\\']*\\'
+ |
+ [^\\'\\\"=<>]+
+ )
+ )?
+ )
+ )
+ )*
+ \\s*
+ )
+ (?
+ /x";
+
+ return preg_replace_callback($pattern, function (array $matches) {
+ $this->clearBoundAttributes();
+
+ $attributes = $this->getAttributesFromAttributeString($matches['attributes']);
+
+ return $this->componentString($matches[1], $attributes);
+ }, $value);
+ }
+
+ /**
+ * Clear the bound attributes for the current component.
+ */
+ protected function clearBoundAttributes(): void
+ {
+ Context::set(self::BOUND_ATTRIBUTES_CONTEXT_KEY, []);
+ }
+
+ /**
+ * Set a bound attribute for the current component.
+ * @param mixed $attribute
+ */
+ protected function setBoundAttribute($attribute): void
+ {
+ $boundAttributes = Context::get(self::BOUND_ATTRIBUTES_CONTEXT_KEY, []);
+ $boundAttributes[$attribute] = true;
+ Context::set(self::BOUND_ATTRIBUTES_CONTEXT_KEY, $boundAttributes);
+ }
+
+ /**
+ * Get the bound attributes for the current component.
+ */
+ protected function getBoundAttributes(): array
+ {
+ return Context::get(self::BOUND_ATTRIBUTES_CONTEXT_KEY, []);
+ }
+
+ /**
+ * Compile the self-closing tags within the given string.
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function compileSelfClosingTags(string $value): string
+ {
+ $pattern = "/
+ <
+ \\s*
+ x[-\\:]([\\w\\-\\:\\.]*)
+ \\s*
+ (?
+ (?:
+ \\s+
+ (?:
+ (?:
+ @(?:class)(\\( (?: (?>[^()]+) | (?-1) )* \\))
+ )
+ |
+ (?:
+ @(?:style)(\\( (?: (?>[^()]+) | (?-1) )* \\))
+ )
+ |
+ (?:
+ \\{\\{\\s*\\\$attributes(?:[^}]+?)?\\s*\\}\\}
+ )
+ |
+ (?:
+ (\\:\\\$)(\\w+)
+ )
+ |
+ (?:
+ [\\w\\-:.@%]+
+ (
+ =
+ (?:
+ \\\"[^\\\"]*\\\"
+ |
+ \\'[^\\']*\\'
+ |
+ [^\\'\\\"=<>]+
+ )
+ )?
+ )
+ )
+ )*
+ \\s*
+ )
+ \\/>
+ /x";
+
+ return preg_replace_callback($pattern, function (array $matches) {
+ $this->clearBoundAttributes();
+
+ $attributes = $this->getAttributesFromAttributeString($matches['attributes']);
+
+ return $this->componentString($matches[1], $attributes) . "\n@endComponentClass##END-COMPONENT-CLASS##";
+ }, $value);
+ }
+
+ /**
+ * Compile the Blade component string for the given component and attributes.
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function componentString(string $component, array $attributes): string
+ {
+ $class = $this->componentClass($component);
+
+ [$data, $attributes] = $this->partitionDataAndAttributes($class, $attributes);
+
+ $data = $data->mapWithKeys(function ($value, $key) {
+ return [Str::camel($key) => $value];
+ });
+
+ // If the component doesn't exist as a class, we'll assume it's a class-less
+ // component and pass the component as a view parameter to the data so it
+ // can be accessed within the component and we can render out the view.
+ if (! class_exists($class)) {
+ $view = Str::startsWith($component, 'mail::')
+ ? "\$__env->getContainer()->make(Hypervel\\View\\Factory::class)->make('{$component}')"
+ : "'{$class}'";
+
+ $parameters = [
+ 'view' => $view,
+ 'data' => '[' . $this->attributesToString($data->all(), $escapeBound = false) . ']',
+ ];
+
+ $class = AnonymousComponent::class;
+ } else {
+ $parameters = $data->all();
+ }
+
+ return "##BEGIN-COMPONENT-CLASS##@component('{$class}', '{$component}', [" . $this->attributesToString($parameters, $escapeBound = false) . '])
+
+except(\\' . $class . '::ignoredParameterNames()); ?>
+
+withAttributes([' . $this->attributesToString($attributes->all(), $escapeAttributes = $class !== DynamicComponent::class) . ']); ?>';
+ }
+
+ /**
+ * Get the component class for a given component alias.
+ *
+ * @throws InvalidArgumentException
+ */
+ public function componentClass(string $component): string
+ {
+ $viewFactory = Container::getInstance()->get(Factory::class);
+
+ if (isset($this->aliases[$component])) {
+ if (class_exists($alias = $this->aliases[$component])) {
+ return $alias;
+ }
+
+ if ($viewFactory->exists($alias)) {
+ return $alias;
+ }
+
+ throw new InvalidArgumentException(
+ "Unable to locate class or view [{$alias}] for component [{$component}]."
+ );
+ }
+
+ if ($class = $this->findClassByComponent($component)) {
+ return $class;
+ }
+
+ if (class_exists($class = $this->guessClassName($component))) {
+ return $class;
+ }
+
+ if (class_exists($class = $class . '\\' . Str::afterLast($class, '\\'))) {
+ return $class;
+ }
+
+ if (! is_null($guess = $this->guessAnonymousComponentUsingNamespaces($viewFactory, $component))
+ || ! is_null($guess = $this->guessAnonymousComponentUsingPaths($viewFactory, $component))) {
+ return $guess;
+ }
+
+ if (Str::startsWith($component, 'mail::')) {
+ return $component;
+ }
+
+ throw new InvalidArgumentException(
+ "Unable to locate a class or view for component [{$component}]."
+ );
+ }
+
+ /**
+ * Attempt to find an anonymous component using the registered anonymous component paths.
+ */
+ protected function guessAnonymousComponentUsingPaths(Factory $viewFactory, string $component): ?string
+ {
+ $delimiter = ViewFinderInterface::HINT_PATH_DELIMITER;
+
+ foreach ($this->blade->getAnonymousComponentPaths() as $path) {
+ try {
+ if (str_contains($component, $delimiter)
+ && ! str_starts_with($component, $path['prefix'] . $delimiter)) {
+ continue;
+ }
+
+ $formattedComponent = str_starts_with($component, $path['prefix'] . $delimiter)
+ ? Str::after($component, $delimiter)
+ : $component;
+
+ if (! is_null($guess = match (true) {
+ $viewFactory->exists($guess = $path['prefixHash'] . $delimiter . $formattedComponent) => $guess, // @phpstan-ignore variable.undefined
+ $viewFactory->exists($guess = $path['prefixHash'] . $delimiter . $formattedComponent . '.index') => $guess, // @phpstan-ignore variable.undefined
+ $viewFactory->exists($guess = $path['prefixHash'] . $delimiter . $formattedComponent . '.' . Str::afterLast($formattedComponent, '.')) => $guess, // @phpstan-ignore variable.undefined
+ default => null,
+ })) {
+ return $guess;
+ }
+ } catch (InvalidArgumentException) {
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Attempt to find an anonymous component using the registered anonymous component namespaces.
+ */
+ protected function guessAnonymousComponentUsingNamespaces(Factory $viewFactory, string $component): ?string
+ {
+ return (new Collection($this->blade->getAnonymousComponentNamespaces()))
+ ->filter(function ($directory, $prefix) use ($component) {
+ return Str::startsWith($component, $prefix . '::');
+ })
+ ->prepend('components', $component)
+ ->reduce(function ($carry, $directory, $prefix) use ($component, $viewFactory) {
+ if (! is_null($carry)) {
+ return $carry;
+ }
+
+ $componentName = Str::after($component, $prefix . '::');
+
+ if ($viewFactory->exists($view = $this->guessViewName($componentName, $directory))) {
+ return $view;
+ }
+
+ if ($viewFactory->exists($view = $this->guessViewName($componentName, $directory) . '.index')) {
+ return $view;
+ }
+
+ $lastViewSegment = Str::afterLast(Str::afterLast($componentName, '.'), ':');
+
+ if ($viewFactory->exists($view = $this->guessViewName($componentName, $directory) . '.' . $lastViewSegment)) {
+ return $view;
+ }
+ });
+ }
+
+ /**
+ * Find the class for the given component using the registered namespaces.
+ */
+ public function findClassByComponent(string $component): ?string
+ {
+ $segments = explode('::', $component);
+
+ $prefix = $segments[0];
+
+ if (! isset($this->namespaces[$prefix], $segments[1])) {
+ return null;
+ }
+
+ if (class_exists($class = $this->namespaces[$prefix] . '\\' . $this->formatClassName($segments[1]))) {
+ return $class;
+ }
+
+ return null;
+ }
+
+ /**
+ * Guess the class name for the given component.
+ */
+ public function guessClassName(string $component): string
+ {
+ $namespace = Container::getInstance()
+ ->get(Application::class)
+ ->getNamespace();
+
+ $class = $this->formatClassName($component);
+
+ return $namespace . 'View\Components\\' . $class;
+ }
+
+ /**
+ * Format the class name for the given component.
+ */
+ public function formatClassName(string $component): string
+ {
+ $componentPieces = array_map(function ($componentPiece) {
+ return ucfirst(Str::camel($componentPiece));
+ }, explode('.', $component));
+
+ return implode('\\', $componentPieces);
+ }
+
+ /**
+ * Guess the view name for the given component.
+ */
+ public function guessViewName(string $name, string $prefix = 'components.'): string
+ {
+ if (! Str::endsWith($prefix, '.')) {
+ $prefix .= '.';
+ }
+
+ $delimiter = ViewFinderInterface::HINT_PATH_DELIMITER;
+
+ if (str_contains($name, $delimiter)) {
+ return Str::replaceFirst($delimiter, $delimiter . $prefix, $name);
+ }
+
+ return $prefix . $name;
+ }
+
+ /**
+ * Partition the data and extra attributes from the given array of attributes.
+ */
+ public function partitionDataAndAttributes(string $class, array $attributes): array
+ {
+ // If the class doesn't exist, we'll assume it is a class-less component and
+ // return all of the attributes as both data and attributes since we have
+ // now way to partition them. The user can exclude attributes manually.
+ if (! class_exists($class)) {
+ return [new Collection($attributes), new Collection($attributes)];
+ }
+
+ $constructor = (new ReflectionClass($class))->getConstructor();
+
+ $parameterNames = $constructor
+ ? (new Collection($constructor->getParameters()))->map(fn ($p) => $p->getName())->all()
+ : [];
+
+ return (new Collection($attributes))
+ ->partition(fn ($value, $key) => in_array(Str::camel($key), $parameterNames))
+ ->all();
+ }
+
+ /**
+ * Compile the closing tags within the given string.
+ */
+ protected function compileClosingTags(string $value): string
+ {
+ return preg_replace('/<\/\s*x[-\:][\w\-\:\.]*\s*>/', ' @endComponentClass##END-COMPONENT-CLASS##', $value);
+ }
+
+ /**
+ * Compile the slot tags within the given string.
+ */
+ public function compileSlots(string $value): string
+ {
+ $pattern = "/
+ <
+ \\s*
+ x[\\-\\:]slot
+ (?:\\:(?\\w+(?:-\\w+)*))?
+ (?:\\s+name=(?(\"[^\"]+\"|\\\\'[^\\\\']+\\\\'|[^\\s>]+)))?
+ (?:\\s+\\:name=(?(\"[^\"]+\"|\\\\'[^\\\\']+\\\\'|[^\\s>]+)))?
+ (?
+ (?:
+ \\s+
+ (?:
+ (?:
+ @(?:class)(\\( (?: (?>[^()]+) | (?-1) )* \\))
+ )
+ |
+ (?:
+ @(?:style)(\\( (?: (?>[^()]+) | (?-1) )* \\))
+ )
+ |
+ (?:
+ \\{\\{\\s*\\\$attributes(?:[^}]+?)?\\s*\\}\\}
+ )
+ |
+ (?:
+ [\\w\\-:.@]+
+ (
+ =
+ (?:
+ \\\"[^\\\"]*\\\"
+ |
+ \\'[^\\']*\\'
+ |
+ [^\\'\\\"=<>]+
+ )
+ )?
+ )
+ )
+ )*
+ \\s*
+ )
+ (?
+ /x";
+
+ $value = preg_replace_callback($pattern, function ($matches) {
+ $name = $this->stripQuotes($matches['inlineName'] ?: $matches['name'] ?: $matches['boundName']);
+
+ if (Str::contains($name, '-') && ! empty($matches['inlineName'])) {
+ $name = Str::camel($name);
+ }
+
+ // If the name was given as a simple string, we will wrap it in quotes as if it was bound for convenience...
+ if (! empty($matches['inlineName']) || ! empty($matches['name'])) {
+ $name = "'{$name}'";
+ }
+
+ $this->clearBoundAttributes();
+
+ $attributes = $this->getAttributesFromAttributeString($matches['attributes']);
+
+ // If an inline name was provided and a name or bound name was *also* provided, we will assume the name should be an attribute...
+ if (! empty($matches['inlineName']) && (! empty($matches['name']) || ! empty($matches['boundName']))) {
+ $attributes = ! empty($matches['name'])
+ ? array_merge($attributes, $this->getAttributesFromAttributeString('name=' . $matches['name']))
+ : array_merge($attributes, $this->getAttributesFromAttributeString(':name=' . $matches['boundName']));
+ }
+
+ return " @slot({$name}, null, [" . $this->attributesToString($attributes) . ']) ';
+ }, $value);
+
+ return preg_replace('/<\/\s*x[\-\:]slot[^>]*>/', ' @endslot', $value);
+ }
+
+ /**
+ * Get an array of attributes from the given attribute string.
+ */
+ protected function getAttributesFromAttributeString(string $attributeString): array
+ {
+ $attributeString = $this->parseShortAttributeSyntax($attributeString);
+ $attributeString = $this->parseAttributeBag($attributeString);
+ $attributeString = $this->parseComponentTagClassStatements($attributeString);
+ $attributeString = $this->parseComponentTagStyleStatements($attributeString);
+ $attributeString = $this->parseBindAttributes($attributeString);
+
+ $pattern = '/
+ (?[\w\-:.@%]+)
+ (
+ =
+ (?
+ (
+ \"[^\"]+\"
+ |
+ \\\'[^\\\']+\\\'
+ |
+ [^\s>]+
+ )
+ )
+ )?
+ /x';
+
+ if (! preg_match_all($pattern, $attributeString, $matches, PREG_SET_ORDER)) {
+ return [];
+ }
+
+ return (new Collection($matches))->mapWithKeys(function ($match) {
+ $attribute = $match['attribute'];
+ $value = $match['value'] ?? null;
+
+ if (is_null($value)) {
+ $value = 'true';
+
+ $attribute = Str::start($attribute, 'bind:');
+ }
+
+ $value = $this->stripQuotes($value);
+
+ if (str_starts_with($attribute, 'bind:')) {
+ $attribute = Str::after($attribute, 'bind:');
+
+ $this->setBoundAttribute($attribute);
+ } else {
+ $value = "'" . $this->compileAttributeEchos($value) . "'";
+ }
+
+ if (str_starts_with($attribute, '::')) {
+ $attribute = substr($attribute, 1);
+ }
+
+ return [$attribute => $value];
+ })->toArray();
+ }
+
+ /**
+ * Parses a short attribute syntax like :$foo into a fully-qualified syntax like :foo="$foo".
+ */
+ protected function parseShortAttributeSyntax(string $value): string
+ {
+ $pattern = '/\s\:\$(\w+)/x';
+
+ return preg_replace_callback($pattern, function (array $matches) {
+ return " :{$matches[1]}=\"\${$matches[1]}\"";
+ }, $value);
+ }
+
+ /**
+ * Parse the attribute bag in a given attribute string into its fully-qualified syntax.
+ */
+ protected function parseAttributeBag(string $attributeString): string
+ {
+ $pattern = '/
+ (?:^|\s+) # start of the string or whitespace between attributes
+ \{\{\s*(\$attributes(?:[^}]+?(?[^()]+) | (?2) )* \))/x',
+ function ($match) {
+ if ($match[1] === 'class') {
+ $match[2] = str_replace('"', "'", $match[2]);
+
+ return ":class=\"\\Hypervel\\Support\\Arr::toCssClasses{$match[2]}\"";
+ }
+
+ return $match[0];
+ },
+ $attributeString
+ );
+ }
+
+ /**
+ * Parse @style statements in a given attribute string into their fully-qualified syntax.
+ */
+ protected function parseComponentTagStyleStatements(string $attributeString): string
+ {
+ return preg_replace_callback(
+ '/@(style)(\( ( (?>[^()]+) | (?2) )* \))/x',
+ function ($match) {
+ if ($match[1] === 'style') {
+ $match[2] = str_replace('"', "'", $match[2]);
+
+ return ":style=\"\\Hypervel\\Support\\Arr::toCssStyles{$match[2]}\"";
+ }
+
+ return $match[0];
+ },
+ $attributeString
+ );
+ }
+
+ /**
+ * Parse the "bind" attributes in a given attribute string into their fully-qualified syntax.
+ */
+ protected function parseBindAttributes(string $attributeString): string
+ {
+ $pattern = '/
+ (?:^|\s+) # start of the string or whitespace between attributes
+ :(?!:) # attribute needs to start with a single colon
+ ([\w\-:.@]+) # match the actual attribute name
+ = # only match attributes that have a value
+ /xm';
+
+ return preg_replace($pattern, ' bind:$1=', $attributeString);
+ }
+
+ /**
+ * Compile any Blade echo statements that are present in the attribute string.
+ *
+ * These echo statements need to be converted to string concatenation statements.
+ */
+ protected function compileAttributeEchos(string $attributeString): string
+ {
+ $value = $this->blade->compileEchos($attributeString);
+
+ $value = $this->escapeSingleQuotesOutsideOfPhpBlocks($value);
+
+ $value = str_replace('', '.\'', $value);
+ }
+
+ /**
+ * Escape the single quotes in the given string that are outside of PHP blocks.
+ */
+ protected function escapeSingleQuotesOutsideOfPhpBlocks(string $value): string
+ {
+ return (new Collection(token_get_all($value)))->map(function ($token) {
+ if (! is_array($token)) {
+ return $token;
+ }
+
+ return $token[0] === T_INLINE_HTML
+ ? str_replace("'", "\\'", $token[1])
+ : $token[1];
+ })->implode('');
+ }
+
+ /**
+ * Convert an array of attributes to a string.
+ */
+ protected function attributesToString(array $attributes, bool $escapeBound = true): string
+ {
+ $boundAttributes = $this->getBoundAttributes();
+
+ return (new Collection($attributes))
+ ->map(function (string $value, string $attribute) use ($escapeBound, $boundAttributes) {
+ return $escapeBound && isset($boundAttributes[$attribute]) && $value !== 'true' && ! is_numeric($value)
+ ? "'{$attribute}' => \\Hypervel\\View\\Compilers\\BladeCompiler::sanitizeComponentAttribute({$value})"
+ : "'{$attribute}' => {$value}";
+ })
+ ->implode(',');
+ }
+
+ /**
+ * Strip any quotes from the given string.
+ */
+ public function stripQuotes(string $value): string
+ {
+ return Str::startsWith($value, ['"', '\''])
+ ? substr($value, 1, -1)
+ : $value;
+ }
+}
diff --git a/src/core/src/View/Compilers/Concerns/CompilesAuthorization.php b/src/view/src/Compilers/Concerns/CompilesAuthorizations.php
similarity index 61%
rename from src/core/src/View/Compilers/Concerns/CompilesAuthorization.php
rename to src/view/src/Compilers/Concerns/CompilesAuthorizations.php
index 65faebe54..58a8270c1 100644
--- a/src/core/src/View/Compilers/Concerns/CompilesAuthorization.php
+++ b/src/view/src/Compilers/Concerns/CompilesAuthorizations.php
@@ -4,14 +4,14 @@
namespace Hypervel\View\Compilers\Concerns;
-trait CompilesAuthorization
+trait CompilesAuthorizations
{
/**
* Compile the can statements into valid PHP.
*/
protected function compileCan(string $expression): string
{
- return "check{$expression}): ?>";
+ return "check{$expression}): ?>";
}
/**
@@ -19,7 +19,7 @@ protected function compileCan(string $expression): string
*/
protected function compileCannot(string $expression): string
{
- return "denies{$expression}): ?>";
+ return "denies{$expression}): ?>";
}
/**
@@ -27,7 +27,7 @@ protected function compileCannot(string $expression): string
*/
protected function compileCanany(string $expression): string
{
- return "any{$expression}): ?>";
+ return "any{$expression}): ?>";
}
/**
@@ -35,7 +35,7 @@ protected function compileCanany(string $expression): string
*/
protected function compileElsecan(string $expression): string
{
- return "check{$expression}): ?>";
+ return "check{$expression}): ?>";
}
/**
@@ -43,7 +43,7 @@ protected function compileElsecan(string $expression): string
*/
protected function compileElsecannot(string $expression): string
{
- return "denies{$expression}): ?>";
+ return "denies{$expression}): ?>";
}
/**
@@ -51,35 +51,29 @@ protected function compileElsecannot(string $expression): string
*/
protected function compileElsecanany(string $expression): string
{
- return "any{$expression}): ?>";
+ return "any{$expression}): ?>";
}
/**
* Compile the end-can statements into valid PHP.
- *
- * @return string
*/
- protected function compileEndcan()
+ protected function compileEndcan(): string
{
return '';
}
/**
* Compile the end-cannot statements into valid PHP.
- *
- * @return string
*/
- protected function compileEndcannot()
+ protected function compileEndcannot(): string
{
return '';
}
/**
* Compile the end-canany statements into valid PHP.
- *
- * @return string
*/
- protected function compileEndcanany()
+ protected function compileEndcanany(): string
{
return '';
}
diff --git a/src/view/src/Compilers/Concerns/CompilesClasses.php b/src/view/src/Compilers/Concerns/CompilesClasses.php
new file mode 100644
index 000000000..f2b14436c
--- /dev/null
+++ b/src/view/src/Compilers/Concerns/CompilesClasses.php
@@ -0,0 +1,18 @@
+\"";
+ }
+}
diff --git a/src/view/src/Compilers/Concerns/CompilesComments.php b/src/view/src/Compilers/Concerns/CompilesComments.php
new file mode 100644
index 000000000..6a0bb140d
--- /dev/null
+++ b/src/view/src/Compilers/Concerns/CompilesComments.php
@@ -0,0 +1,18 @@
+contentTags[0], $this->contentTags[1]);
+
+ return preg_replace($pattern, '', $value);
+ }
+}
diff --git a/src/view/src/Compilers/Concerns/CompilesComponents.php b/src/view/src/Compilers/Concerns/CompilesComponents.php
new file mode 100644
index 000000000..cea46bc25
--- /dev/null
+++ b/src/view/src/Compilers/Concerns/CompilesComponents.php
@@ -0,0 +1,206 @@
+startComponent{$expression}; ?>";
+ }
+
+ /**
+ * Get a new component hash for a component name.
+ */
+ public static function newComponentHash(string $component): string
+ {
+ $hash = hash('xxh128', $component);
+
+ Context::override(static::COMPONENT_HASH_STACK_CONTEXT_KEY, function ($stack) use ($hash) {
+ $stack ??= [];
+ $stack[] = $hash;
+ return $stack;
+ });
+
+ return $hash;
+ }
+
+ /**
+ * Compile a class component opening.
+ */
+ public static function compileClassComponentOpening(string $component, string $alias, string $data, string $hash): string
+ {
+ return implode("\n", [
+ '',
+ '',
+ 'all() : [])); ?>',
+ 'withName(' . $alias . '); ?>',
+ 'shouldRender()): ?>',
+ 'startComponent($component->resolveView(), $component->data()); ?>',
+ ]);
+ }
+
+ /**
+ * Compile the end-component statements into valid PHP.
+ */
+ protected function compileEndComponent(): string
+ {
+ return 'renderComponent(); ?>';
+ }
+
+ /**
+ * Compile the end-component statements into valid PHP.
+ */
+ public function compileEndComponentClass(): string
+ {
+ $hash = $this->popComponentHashStack();
+
+ return $this->compileEndComponent() . "\n" . implode("\n", [
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ ]);
+ }
+
+ protected function popComponentHashStack(): string
+ {
+ $stack = Context::get(static::COMPONENT_HASH_STACK_CONTEXT_KEY, []);
+
+ $hash = array_pop($stack);
+
+ Context::set(static::COMPONENT_HASH_STACK_CONTEXT_KEY, $stack);
+
+ return $hash;
+ }
+
+ /**
+ * Compile the slot statements into valid PHP.
+ */
+ protected function compileSlot(string $expression): string
+ {
+ return "slot{$expression}; ?>";
+ }
+
+ /**
+ * Compile the end-slot statements into valid PHP.
+ */
+ protected function compileEndSlot(): string
+ {
+ return 'endSlot(); ?>';
+ }
+
+ /**
+ * Compile the component-first statements into valid PHP.
+ */
+ protected function compileComponentFirst(string $expression): string
+ {
+ return "startComponentFirst{$expression}; ?>";
+ }
+
+ /**
+ * Compile the end-component-first statements into valid PHP.
+ */
+ protected function compileEndComponentFirst(): string
+ {
+ return $this->compileEndComponent();
+ }
+
+ /**
+ * Compile the prop statement into valid PHP.
+ */
+ protected function compileProps(string $expression): string
+ {
+ return "all() as \$__key => \$__value) {
+ if (in_array(\$__key, \$__propNames)) {
+ \$\$__key = \$\$__key ?? \$__value;
+ } else {
+ \$__newAttributes[\$__key] = \$__value;
+ }
+}
+
+\$attributes = new \\Hypervel\\View\\ComponentAttributeBag(\$__newAttributes);
+
+unset(\$__propNames);
+unset(\$__newAttributes);
+
+foreach (array_filter({$expression}, 'is_string', ARRAY_FILTER_USE_KEY) as \$__key => \$__value) {
+ \$\$__key = \$\$__key ?? \$__value;
+}
+
+\$__defined_vars = get_defined_vars();
+
+foreach (\$attributes->all() as \$__key => \$__value) {
+ if (array_key_exists(\$__key, \$__defined_vars)) unset(\$\$__key);
+}
+
+unset(\$__defined_vars); ?>";
+ }
+
+ /**
+ * Compile the aware statement into valid PHP.
+ */
+ protected function compileAware(string $expression): string
+ {
+ return " \$__value) {
+ \$__consumeVariable = is_string(\$__key) ? \$__key : \$__value;
+ \$\$__consumeVariable = is_string(\$__key) ? \$__env->getConsumableComponentData(\$__key, \$__value) : \$__env->getConsumableComponentData(\$__value);
+} ?>";
+ }
+
+ /**
+ * Sanitize the given component attribute value.
+ */
+ public static function sanitizeComponentAttribute(mixed $value): mixed
+ {
+ if ($value instanceof CanBeEscapedWhenCastToString) {
+ return $value->escapeWhenCastingToString();
+ }
+
+ return is_string($value)
+ || (is_object($value) && ! $value instanceof Model && ! $value instanceof ComponentAttributeBag && method_exists($value, '__toString'))
+ ? e($value)
+ : $value;
+ }
+}
diff --git a/src/view/src/Compilers/Concerns/CompilesConditionals.php b/src/view/src/Compilers/Concerns/CompilesConditionals.php
new file mode 100644
index 000000000..9a7becb24
--- /dev/null
+++ b/src/view/src/Compilers/Concerns/CompilesConditionals.php
@@ -0,0 +1,326 @@
+guard{$guard}->check()): ?>";
+ }
+
+ /**
+ * Compile the else-auth statements into valid PHP.
+ */
+ protected function compileElseAuth(?string $guard = null): string
+ {
+ $guard = is_null($guard) ? '()' : $guard;
+
+ return "guard{$guard}->check()): ?>";
+ }
+
+ /**
+ * Compile the end-auth statements into valid PHP.
+ */
+ protected function compileEndAuth(): string
+ {
+ return '';
+ }
+
+ /**
+ * Compile the env statements into valid PHP.
+ */
+ protected function compileEnv(string $environments): string
+ {
+ return "environment{$environments}): ?>";
+ }
+
+ /**
+ * Compile the end-env statements into valid PHP.
+ */
+ protected function compileEndEnv(): string
+ {
+ return '';
+ }
+
+ /**
+ * Compile the production statements into valid PHP.
+ */
+ protected function compileProduction(): string
+ {
+ return "environment('production')): ?>";
+ }
+
+ /**
+ * Compile the end-production statements into valid PHP.
+ */
+ protected function compileEndProduction(): string
+ {
+ return '';
+ }
+
+ /**
+ * Compile the if-guest statements into valid PHP.
+ */
+ protected function compileGuest(?string $guard = null): string
+ {
+ $guard = is_null($guard) ? '()' : $guard;
+
+ return "guard{$guard}->guest()): ?>";
+ }
+
+ /**
+ * Compile the else-guest statements into valid PHP.
+ */
+ protected function compileElseGuest(?string $guard = null): string
+ {
+ $guard = is_null($guard) ? '()' : $guard;
+
+ return "guard{$guard}->guest()): ?>";
+ }
+
+ /**
+ * Compile the end-guest statements into valid PHP.
+ */
+ protected function compileEndGuest(): string
+ {
+ return '';
+ }
+
+ /**
+ * Compile the has-section statements into valid PHP.
+ */
+ protected function compileHasSection(string $expression): string
+ {
+ return "yieldContent{$expression}))): ?>";
+ }
+
+ /**
+ * Compile the section-missing statements into valid PHP.
+ */
+ protected function compileSectionMissing(string $expression): string
+ {
+ return "yieldContent{$expression}))): ?>";
+ }
+
+ /**
+ * Compile the if statements into valid PHP.
+ */
+ protected function compileIf(string $expression): string
+ {
+ return "";
+ }
+
+ /**
+ * Compile the unless statements into valid PHP.
+ */
+ protected function compileUnless(string $expression): string
+ {
+ return "";
+ }
+
+ /**
+ * Compile the else-if statements into valid PHP.
+ */
+ protected function compileElseif(string $expression): string
+ {
+ return "";
+ }
+
+ /**
+ * Compile the else statements into valid PHP.
+ */
+ protected function compileElse(): string
+ {
+ return '';
+ }
+
+ /**
+ * Compile the end-if statements into valid PHP.
+ */
+ protected function compileEndif(): string
+ {
+ return '';
+ }
+
+ /**
+ * Compile the end-unless statements into valid PHP.
+ */
+ protected function compileEndunless(): string
+ {
+ return '';
+ }
+
+ /**
+ * Compile the if-isset statements into valid PHP.
+ */
+ protected function compileIsset(string $expression): string
+ {
+ return "";
+ }
+
+ /**
+ * Compile the end-isset statements into valid PHP.
+ */
+ protected function compileEndIsset(): string
+ {
+ return '';
+ }
+
+ /**
+ * Compile the switch statements into valid PHP.
+ */
+ protected function compileSwitch(string $expression): string
+ {
+ Context::set(static::FIRST_CASE_IN_SWITCH_CONTEXT_KEY, true);
+
+ return "";
+ }
+
+ return "";
+ }
+
+ /**
+ * Compile the default statements in switch case into valid PHP.
+ */
+ protected function compileDefault(): string
+ {
+ return '';
+ }
+
+ /**
+ * Compile the end switch statements into valid PHP.
+ */
+ protected function compileEndSwitch(): string
+ {
+ return '';
+ }
+
+ /**
+ * Compile a once block into valid PHP.
+ */
+ protected function compileOnce(?string $id = null): string
+ {
+ $id = $id ? $this->stripParentheses($id) : "'" . (string) Str::uuid() . "'";
+
+ return 'hasRenderedOnce(' . $id . ')): $__env->markAsRenderedOnce(' . $id . '); ?>';
+ }
+
+ /**
+ * Compile an end-once block into valid PHP.
+ */
+ public function compileEndOnce(): string
+ {
+ return '';
+ }
+
+ /**
+ * Compile a boolean value into a raw true / false value for embedding into HTML attributes or JavaScript.
+ */
+ protected function compileBool(string $condition): string
+ {
+ return "";
+ }
+
+ /**
+ * Compile a checked block into valid PHP.
+ */
+ protected function compileChecked(string $condition): string
+ {
+ return "";
+ }
+
+ /**
+ * Compile a disabled block into valid PHP.
+ */
+ protected function compileDisabled(string $condition): string
+ {
+ return "";
+ }
+
+ /**
+ * Compile a required block into valid PHP.
+ */
+ protected function compileRequired(string $condition): string
+ {
+ return "";
+ }
+
+ /**
+ * Compile a readonly block into valid PHP.
+ */
+ protected function compileReadonly(string $condition): string
+ {
+ return "";
+ }
+
+ /**
+ * Compile a selected block into valid PHP.
+ */
+ protected function compileSelected(string $condition): string
+ {
+ return "";
+ }
+
+ /**
+ * Compile the push statements into valid PHP.
+ */
+ protected function compilePushIf(string $expression): string
+ {
+ $parts = explode(',', $this->stripParentheses($expression), 2);
+
+ return "startPush({$parts[1]}); ?>";
+ }
+
+ /**
+ * Compile the else-if push statements into valid PHP.
+ */
+ protected function compileElsePushIf(string $expression): string
+ {
+ $parts = explode(',', $this->stripParentheses($expression), 2);
+
+ return "stopPush(); elseif({$parts[0]}): \$__env->startPush({$parts[1]}); ?>";
+ }
+
+ /**
+ * Compile the else push statements into valid PHP.
+ */
+ protected function compileElsePush(string $expression): string
+ {
+ return "stopPush(); else: \$__env->startPush{$expression}; ?>";
+ }
+
+ /**
+ * Compile the end-push statements into valid PHP.
+ */
+ protected function compileEndPushIf(): string
+ {
+ return 'stopPush(); endif; ?>';
+ }
+}
diff --git a/src/view/src/Compilers/Concerns/CompilesEchos.php b/src/view/src/Compilers/Concerns/CompilesEchos.php
new file mode 100644
index 000000000..4dd0d2fea
--- /dev/null
+++ b/src/view/src/Compilers/Concerns/CompilesEchos.php
@@ -0,0 +1,148 @@
+
+ */
+ protected array $echoHandlers = [];
+
+ /**
+ * Add a handler to be executed before echoing a given class.
+ */
+ public function stringable(string|callable $class, ?callable $handler = null): void
+ {
+ if ($class instanceof Closure) {
+ [$class, $handler] = [$this->firstClosureParameterType($class), $class];
+ }
+
+ $this->echoHandlers[$class] = $handler;
+ }
+
+ /**
+ * Compile Blade echos into valid PHP.
+ */
+ public function compileEchos(string $value): string
+ {
+ foreach ($this->getEchoMethods() as $method) {
+ $value = $this->{$method}($value);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Get the echo methods in the proper order for compilation.
+ *
+ * @return array
+ */
+ protected function getEchoMethods(): array
+ {
+ return [
+ 'compileRawEchos',
+ 'compileEscapedEchos',
+ 'compileRegularEchos',
+ ];
+ }
+
+ /**
+ * Compile the "raw" echo statements.
+ */
+ protected function compileRawEchos(string $value): string
+ {
+ $pattern = sprintf('/(@)?%s\s*(.+?)\s*%s(\r?\n)?/s', $this->rawTags[0], $this->rawTags[1]);
+
+ $callback = function ($matches) {
+ $whitespace = empty($matches[3]) ? '' : $matches[3] . $matches[3];
+
+ return $matches[1]
+ ? substr($matches[0], 1)
+ : "wrapInEchoHandler($matches[2])}; ?>{$whitespace}";
+ };
+
+ return preg_replace_callback($pattern, $callback, $value);
+ }
+
+ /**
+ * Compile the "regular" echo statements.
+ */
+ protected function compileRegularEchos(string $value): string
+ {
+ $pattern = sprintf('/(@)?%s\s*(.+?)\s*%s(\r?\n)?/s', $this->contentTags[0], $this->contentTags[1]);
+
+ $callback = function ($matches) {
+ $whitespace = empty($matches[3]) ? '' : $matches[3] . $matches[3];
+
+ $wrapped = sprintf($this->getEchoFormat(), $this->wrapInEchoHandler($matches[2]));
+
+ return $matches[1] ? substr($matches[0], 1) : "{$whitespace}";
+ };
+
+ return preg_replace_callback($pattern, $callback, $value);
+ }
+
+ /**
+ * Compile the escaped echo statements.
+ */
+ protected function compileEscapedEchos(string $value): string
+ {
+ $pattern = sprintf('/(@)?%s\s*(.+?)\s*%s(\r?\n)?/s', $this->escapedTags[0], $this->escapedTags[1]);
+
+ $callback = function ($matches) {
+ $whitespace = empty($matches[3]) ? '' : $matches[3] . $matches[3];
+
+ return $matches[1]
+ ? $matches[0]
+ : "wrapInEchoHandler($matches[2])}); ?>{$whitespace}";
+ };
+
+ return preg_replace_callback($pattern, $callback, $value);
+ }
+
+ /**
+ * Add an instance of the blade echo handler to the start of the compiled string.
+ */
+ protected function addBladeCompilerVariable(string $result): string
+ {
+ return "" . $result;
+ }
+
+ /**
+ * Wrap the echoable value in an echo handler if applicable.
+ */
+ protected function wrapInEchoHandler(string $value): string
+ {
+ $value = (new Stringable($value))
+ ->trim()
+ ->when(str_ends_with($value, ';'), function ($str) {
+ return $str->beforeLast(';');
+ });
+
+ return empty($this->echoHandlers) ? (string) $value : '$__bladeCompiler->applyEchoHandler(' . $value . ')';
+ }
+
+ /**
+ * Apply the echo handler for the value if it exists.
+ */
+ public function applyEchoHandler(mixed $value): mixed
+ {
+ if (is_object($value) && isset($this->echoHandlers[get_class($value)])) {
+ return call_user_func($this->echoHandlers[get_class($value)], $value);
+ }
+
+ if (is_iterable($value) && isset($this->echoHandlers['iterable'])) {
+ return call_user_func($this->echoHandlers['iterable'], $value);
+ }
+
+ return $value;
+ }
+}
diff --git a/src/view/src/Compilers/Concerns/CompilesErrors.php b/src/view/src/Compilers/Concerns/CompilesErrors.php
new file mode 100644
index 000000000..5e5bd61e1
--- /dev/null
+++ b/src/view/src/Compilers/Concerns/CompilesErrors.php
@@ -0,0 +1,33 @@
+stripParentheses($expression);
+
+ return 'getBag($__errorArgs[1] ?? \'default\');
+if ($__bag->has($__errorArgs[0])) :
+if (isset($message)) { $__messageOriginal = $message; }
+$message = $__bag->first($__errorArgs[0]); ?>';
+ }
+
+ /**
+ * Compile the enderror statements into valid PHP.
+ */
+ protected function compileEnderror(): string
+ {
+ return '';
+ }
+}
diff --git a/src/view/src/Compilers/Concerns/CompilesFragments.php b/src/view/src/Compilers/Concerns/CompilesFragments.php
new file mode 100644
index 000000000..767c9df90
--- /dev/null
+++ b/src/view/src/Compilers/Concerns/CompilesFragments.php
@@ -0,0 +1,31 @@
+lastFragment = trim($expression, "()'\" ");
+
+ return "startFragment{$expression}; ?>";
+ }
+
+ /**
+ * Compile the end-fragment statements into valid PHP.
+ */
+ protected function compileEndfragment(): string
+ {
+ return 'stopFragment(); ?>';
+ }
+}
diff --git a/src/view/src/Compilers/Concerns/CompilesHelpers.php b/src/view/src/Compilers/Concerns/CompilesHelpers.php
new file mode 100644
index 000000000..94ca2a5c7
--- /dev/null
+++ b/src/view/src/Compilers/Concerns/CompilesHelpers.php
@@ -0,0 +1,64 @@
+';
+ }
+
+ /**
+ * Compile the "dd" statements into valid PHP.
+ */
+ protected function compileDd(string $arguments): string
+ {
+ return "";
+ }
+
+ /**
+ * Compile the "dump" statements into valid PHP.
+ */
+ protected function compileDump(string $arguments): string
+ {
+ return "";
+ }
+
+ /**
+ * Compile the method statements into valid PHP.
+ */
+ protected function compileMethod(string $method): string
+ {
+ return "";
+ }
+
+ /**
+ * Compile the "vite" statements into valid PHP.
+ */
+ protected function compileVite(?string $arguments): string
+ {
+ $arguments ??= '()';
+
+ $class = Vite::class;
+
+ return "";
+ }
+
+ /**
+ * Compile the "viteReactRefresh" statements into valid PHP.
+ */
+ protected function compileViteReactRefresh(): string
+ {
+ $class = Vite::class;
+
+ return "reactRefresh(); ?>";
+ }
+}
diff --git a/src/view/src/Compilers/Concerns/CompilesIncludes.php b/src/view/src/Compilers/Concerns/CompilesIncludes.php
new file mode 100644
index 000000000..b94551fdb
--- /dev/null
+++ b/src/view/src/Compilers/Concerns/CompilesIncludes.php
@@ -0,0 +1,66 @@
+renderEach{$expression}; ?>";
+ }
+
+ /**
+ * Compile the include statements into valid PHP.
+ */
+ protected function compileInclude(string $expression): string
+ {
+ $expression = $this->stripParentheses($expression);
+
+ return "make({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?>";
+ }
+
+ /**
+ * Compile the include-if statements into valid PHP.
+ */
+ protected function compileIncludeIf(string $expression): string
+ {
+ $expression = $this->stripParentheses($expression);
+
+ return "exists({$expression})) echo \$__env->make({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?>";
+ }
+
+ /**
+ * Compile the include-when statements into valid PHP.
+ */
+ protected function compileIncludeWhen(string $expression): string
+ {
+ $expression = $this->stripParentheses($expression);
+
+ return "renderWhen({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>";
+ }
+
+ /**
+ * Compile the include-unless statements into valid PHP.
+ */
+ protected function compileIncludeUnless(string $expression): string
+ {
+ $expression = $this->stripParentheses($expression);
+
+ return "renderUnless({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>";
+ }
+
+ /**
+ * Compile the include-first statements into valid PHP.
+ */
+ protected function compileIncludeFirst(string $expression): string
+ {
+ $expression = $this->stripParentheses($expression);
+
+ return "first({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?>";
+ }
+}
diff --git a/src/core/src/View/Compilers/Concerns/CompilesInjections.php b/src/view/src/Compilers/Concerns/CompilesInjections.php
similarity index 100%
rename from src/core/src/View/Compilers/Concerns/CompilesInjections.php
rename to src/view/src/Compilers/Concerns/CompilesInjections.php
diff --git a/src/core/src/View/Compilers/Concerns/CompilesJs.php b/src/view/src/Compilers/Concerns/CompilesJs.php
similarity index 100%
rename from src/core/src/View/Compilers/Concerns/CompilesJs.php
rename to src/view/src/Compilers/Concerns/CompilesJs.php
diff --git a/src/view/src/Compilers/Concerns/CompilesJson.php b/src/view/src/Compilers/Concerns/CompilesJson.php
new file mode 100644
index 000000000..0df43d4a1
--- /dev/null
+++ b/src/view/src/Compilers/Concerns/CompilesJson.php
@@ -0,0 +1,27 @@
+stripParentheses($expression));
+
+ $options = isset($parts[1]) ? trim($parts[1]) : $this->encodingOptions;
+
+ $depth = isset($parts[2]) ? trim($parts[2]) : 512;
+
+ return "";
+ }
+}
diff --git a/src/view/src/Compilers/Concerns/CompilesLayouts.php b/src/view/src/Compilers/Concerns/CompilesLayouts.php
new file mode 100644
index 000000000..2704d0457
--- /dev/null
+++ b/src/view/src/Compilers/Concerns/CompilesLayouts.php
@@ -0,0 +1,109 @@
+stripParentheses($expression);
+
+ $echo = "make({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?>";
+
+ $this->pushFooter($echo);
+
+ return '';
+ }
+
+ /**
+ * Compile the extends-first statements into valid PHP.
+ */
+ protected function compileExtendsFirst(string $expression): string
+ {
+ $expression = $this->stripParentheses($expression);
+
+ $echo = "first({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?>";
+
+ $this->pushFooter($echo);
+
+ return '';
+ }
+
+ /**
+ * Compile the section statements into valid PHP.
+ */
+ protected function compileSection(string $expression): string
+ {
+ $this->lastSection = trim($expression, "()'\" ");
+
+ return "startSection{$expression}; ?>";
+ }
+
+ /**
+ * Replace the @parent directive to a placeholder.
+ */
+ protected function compileParent(): string
+ {
+ $escapedLastSection = strtr($this->lastSection, ['\\' => '\\\\', "'" => "\\'"]);
+
+ return "getParentPlaceholder('{$escapedLastSection}'); ?>";
+ }
+
+ /**
+ * Compile the yield statements into valid PHP.
+ */
+ protected function compileYield(string $expression): string
+ {
+ return "yieldContent{$expression}; ?>";
+ }
+
+ /**
+ * Compile the show statements into valid PHP.
+ */
+ protected function compileShow(): string
+ {
+ return 'yieldSection(); ?>';
+ }
+
+ /**
+ * Compile the append statements into valid PHP.
+ */
+ protected function compileAppend(): string
+ {
+ return 'appendSection(); ?>';
+ }
+
+ /**
+ * Compile the overwrite statements into valid PHP.
+ */
+ protected function compileOverwrite(): string
+ {
+ return 'stopSection(true); ?>';
+ }
+
+ /**
+ * Compile the stop statements into valid PHP.
+ */
+ protected function compileStop(): string
+ {
+ return 'stopSection(); ?>';
+ }
+
+ /**
+ * Compile the end-section statements into valid PHP.
+ */
+ protected function compileEndsection(): string
+ {
+ return 'stopSection(); ?>';
+ }
+}
diff --git a/src/view/src/Compilers/Concerns/CompilesLoops.php b/src/view/src/Compilers/Concerns/CompilesLoops.php
new file mode 100644
index 000000000..0ffda4412
--- /dev/null
+++ b/src/view/src/Compilers/Concerns/CompilesLoops.php
@@ -0,0 +1,185 @@
+incrementForElseCounter();
+ $empty = '$__empty_' . $this->getForElseCounter();
+
+ preg_match('/\( *(.+) +as +(.+)\)$/is', $expression ?? '', $matches);
+
+ if (count($matches) === 0) {
+ throw new ViewCompilationException('Malformed @forelse statement.');
+ }
+
+ $iteratee = trim($matches[1]);
+
+ $iteration = trim($matches[2]);
+
+ $initLoop = "\$__currentLoopData = {$iteratee}; \$__env->addLoop(\$__currentLoopData);";
+
+ $iterateLoop = '$__env->incrementLoopIndices(); $loop = $__env->getLastLoop();';
+
+ return "";
+ }
+
+ /**
+ * Compile the for-else-empty and empty statements into valid PHP.
+ */
+ protected function compileEmpty(?string $expression): string
+ {
+ if ($expression) {
+ return "";
+ }
+
+ $empty = '$__empty_' . $this->getForElseCounter();
+ $this->decrementForElseCounter();
+
+ return "popLoop(); \$loop = \$__env->getLastLoop(); if ({$empty}): ?>";
+ }
+
+ /**
+ * Compile the end-for-else statements into valid PHP.
+ */
+ protected function compileEndforelse(): string
+ {
+ return '';
+ }
+
+ /**
+ * Compile the end-empty statements into valid PHP.
+ */
+ protected function compileEndEmpty(): string
+ {
+ return '';
+ }
+
+ /**
+ * Compile the for statements into valid PHP.
+ */
+ protected function compileFor(string $expression): string
+ {
+ return "";
+ }
+
+ /**
+ * Compile the for-each statements into valid PHP.
+ *
+ * @throws ViewCompilationException
+ */
+ protected function compileForeach(?string $expression): string
+ {
+ preg_match('/\( *(.+) +as +(.*)\)$/is', $expression ?? '', $matches);
+
+ if (count($matches) === 0) {
+ throw new ViewCompilationException('Malformed @foreach statement.');
+ }
+
+ $iteratee = trim($matches[1]);
+
+ $iteration = trim($matches[2]);
+
+ $initLoop = "\$__currentLoopData = {$iteratee}; \$__env->addLoop(\$__currentLoopData);";
+
+ $iterateLoop = '$__env->incrementLoopIndices(); $loop = $__env->getLastLoop();';
+
+ return "";
+ }
+
+ /**
+ * Compile the break statements into valid PHP.
+ */
+ protected function compileBreak(?string $expression = null): string
+ {
+ if ($expression) {
+ preg_match('/\(\s*(-?\d+)\s*\)$/', $expression, $matches);
+
+ return $matches ? '' : "";
+ }
+
+ return '';
+ }
+
+ /**
+ * Compile the continue statements into valid PHP.
+ */
+ protected function compileContinue(?string $expression): string
+ {
+ if ($expression) {
+ preg_match('/\(\s*(-?\d+)\s*\)$/', $expression, $matches);
+
+ return $matches ? '' : "";
+ }
+
+ return '';
+ }
+
+ /**
+ * Compile the end-for statements into valid PHP.
+ */
+ protected function compileEndfor(): string
+ {
+ return '';
+ }
+
+ /**
+ * Compile the end-for-each statements into valid PHP.
+ */
+ protected function compileEndforeach(): string
+ {
+ return 'popLoop(); $loop = $__env->getLastLoop(); ?>';
+ }
+
+ /**
+ * Compile the while statements into valid PHP.
+ */
+ protected function compileWhile(string $expression): string
+ {
+ return "";
+ }
+
+ /**
+ * Compile the end-while statements into valid PHP.
+ */
+ protected function compileEndwhile(): string
+ {
+ return '';
+ }
+}
diff --git a/src/view/src/Compilers/Concerns/CompilesRawPhp.php b/src/view/src/Compilers/Concerns/CompilesRawPhp.php
new file mode 100644
index 000000000..f756a8b39
--- /dev/null
+++ b/src/view/src/Compilers/Concerns/CompilesRawPhp.php
@@ -0,0 +1,28 @@
+";
+ }
+
+ return '@php';
+ }
+
+ /**
+ * Compile the unset statements into valid PHP.
+ */
+ protected function compileUnset(string $expression): string
+ {
+ return "";
+ }
+}
diff --git a/src/core/src/View/Compilers/Concerns/CompilesSession.php b/src/view/src/Compilers/Concerns/CompilesSessions.php
similarity index 90%
rename from src/core/src/View/Compilers/Concerns/CompilesSession.php
rename to src/view/src/Compilers/Concerns/CompilesSessions.php
index 659a88fce..7f2327da9 100644
--- a/src/core/src/View/Compilers/Concerns/CompilesSession.php
+++ b/src/view/src/Compilers/Concerns/CompilesSessions.php
@@ -4,7 +4,7 @@
namespace Hypervel\View\Compilers\Concerns;
-trait CompilesSession
+trait CompilesSessions
{
/**
* Compile the session statements into valid PHP.
@@ -22,7 +22,7 @@ protected function compileSession(string $expression): string
/**
* Compile the endsession statements into valid PHP.
*/
- protected function compileEndsession(string $expression): string
+ protected function compileEndsession(): string
{
return 'yieldPushContent{$expression}; ?>";
+ }
+
+ /**
+ * Compile the push statements into valid PHP.
+ */
+ protected function compilePush(string $expression): string
+ {
+ return "startPush{$expression}; ?>";
+ }
+
+ /**
+ * Compile the push-once statements into valid PHP.
+ */
+ protected function compilePushOnce(string $expression): string
+ {
+ $parts = explode(',', $this->stripParentheses($expression), 2);
+
+ [$stack, $id] = [$parts[0], $parts[1] ?? ''];
+
+ $id = trim($id) ?: "'" . (string) Str::uuid() . "'";
+
+ return 'hasRenderedOnce(' . $id . ')): $__env->markAsRenderedOnce(' . $id . ');
+$__env->startPush(' . $stack . '); ?>';
+ }
+
+ /**
+ * Compile the end-push statements into valid PHP.
+ */
+ protected function compileEndpush(): string
+ {
+ return 'stopPush(); ?>';
+ }
+
+ /**
+ * Compile the end-push-once statements into valid PHP.
+ */
+ protected function compileEndpushOnce(): string
+ {
+ return 'stopPush(); endif; ?>';
+ }
+
+ /**
+ * Compile the prepend statements into valid PHP.
+ */
+ protected function compilePrepend(string $expression): string
+ {
+ return "startPrepend{$expression}; ?>";
+ }
+
+ /**
+ * Compile the prepend-once statements into valid PHP.
+ */
+ protected function compilePrependOnce(string $expression): string
+ {
+ $parts = explode(',', $this->stripParentheses($expression), 2);
+
+ [$stack, $id] = [$parts[0], $parts[1] ?? ''];
+
+ $id = trim($id) ?: "'" . (string) Str::uuid() . "'";
+
+ return 'hasRenderedOnce(' . $id . ')): $__env->markAsRenderedOnce(' . $id . ');
+$__env->startPrepend(' . $stack . '); ?>';
+ }
+
+ /**
+ * Compile the end-prepend statements into valid PHP.
+ */
+ protected function compileEndprepend(): string
+ {
+ return 'stopPrepend(); ?>';
+ }
+
+ /**
+ * Compile the end-prepend-once statements into valid PHP.
+ */
+ protected function compileEndprependOnce(): string
+ {
+ return 'stopPrepend(); endif; ?>';
+ }
+}
diff --git a/src/view/src/Compilers/Concerns/CompilesStyles.php b/src/view/src/Compilers/Concerns/CompilesStyles.php
new file mode 100644
index 000000000..a7aaa623c
--- /dev/null
+++ b/src/view/src/Compilers/Concerns/CompilesStyles.php
@@ -0,0 +1,18 @@
+\"";
+ }
+}
diff --git a/src/view/src/Compilers/Concerns/CompilesTranslations.php b/src/view/src/Compilers/Concerns/CompilesTranslations.php
new file mode 100644
index 000000000..e4da946d3
--- /dev/null
+++ b/src/view/src/Compilers/Concerns/CompilesTranslations.php
@@ -0,0 +1,39 @@
+startTranslation(); ?>';
+ }
+ if ($expression[1] === '[') {
+ return "startTranslation{$expression}; ?>";
+ }
+
+ return "get{$expression}; ?>";
+ }
+
+ /**
+ * Compile the end-lang statements into valid PHP.
+ */
+ protected function compileEndlang(): string
+ {
+ return 'renderTranslation(); ?>';
+ }
+
+ /**
+ * Compile the choice statements into valid PHP.
+ */
+ protected function compileChoice(string $expression): string
+ {
+ return "choice{$expression}; ?>";
+ }
+}
diff --git a/src/core/src/View/Compilers/Concerns/CompilesUseStatements.php b/src/view/src/Compilers/Concerns/CompilesUseStatements.php
similarity index 100%
rename from src/core/src/View/Compilers/Concerns/CompilesUseStatements.php
rename to src/view/src/Compilers/Concerns/CompilesUseStatements.php
diff --git a/src/view/src/Component.php b/src/view/src/Component.php
new file mode 100644
index 000000000..af6c22784
--- /dev/null
+++ b/src/view/src/Component.php
@@ -0,0 +1,417 @@
+
+ */
+ protected static array $bladeViewCache = [];
+
+ /**
+ * The cache of public property names, keyed by class.
+ */
+ protected static array $propertyCache = [];
+
+ /**
+ * The cache of public method names, keyed by class.
+ */
+ protected static array $methodCache = [];
+
+ /**
+ * The cache of constructor parameters, keyed by class.
+ *
+ * @var array>
+ */
+ protected static array $constructorParametersCache = [];
+
+ /**
+ * The cache of ignored parameter names.
+ */
+ protected static array $ignoredParameterNames = [];
+
+ /**
+ * Get the view / view contents that represent the component.
+ */
+ abstract public function render(): ViewContract|Htmlable|Closure|string;
+
+ /**
+ * Resolve the component instance with the given data.
+ */
+ public static function resolve(array $data): static
+ {
+ if (static::$componentsResolver) {
+ return call_user_func(static::$componentsResolver, static::class, $data);
+ }
+
+ $parameters = static::extractConstructorParameters();
+
+ $dataKeys = array_keys($data);
+
+ if (empty(array_diff($parameters, $dataKeys))) {
+ return new static(...array_intersect_key($data, array_flip($parameters)));
+ }
+
+ return Container::getInstance()->make(static::class, $data);
+ }
+
+ /**
+ * Extract the constructor parameters for the component.
+ */
+ protected static function extractConstructorParameters(): array
+ {
+ if (! isset(static::$constructorParametersCache[static::class])) {
+ $class = new ReflectionClass(static::class);
+
+ $constructor = $class->getConstructor();
+
+ static::$constructorParametersCache[static::class] = $constructor
+ ? (new Collection($constructor->getParameters()))->map(fn ($p) => $p->getName())->all()
+ : [];
+ }
+
+ return static::$constructorParametersCache[static::class];
+ }
+
+ /**
+ * Resolve the Blade view or view file that should be used when rendering the component.
+ */
+ public function resolveView(): ViewContract|Htmlable|Closure|string
+ {
+ $view = $this->render();
+
+ if ($view instanceof ViewContract) {
+ return $view;
+ }
+
+ if ($view instanceof Htmlable) {
+ return $view;
+ }
+
+ $resolver = function ($view) {
+ if ($view instanceof ViewContract) {
+ return $view;
+ }
+
+ return $this->extractBladeViewFromString($view);
+ };
+
+ return $view instanceof Closure ? function (array $data = []) use ($view, $resolver) {
+ return $resolver($view($data));
+ }
+ : $resolver($view);
+ }
+
+ /**
+ * Create a Blade view with the raw component string content.
+ */
+ protected function extractBladeViewFromString(string $contents): string
+ {
+ $key = sprintf('%s::%s', static::class, $contents);
+
+ if (isset(static::$bladeViewCache[$key])) {
+ return static::$bladeViewCache[$key];
+ }
+
+ if ($this->factory()->exists($contents)) {
+ return static::$bladeViewCache[$key] = $contents;
+ }
+
+ return static::$bladeViewCache[$key] = $this->createBladeViewFromString($this->factory(), $contents);
+ }
+
+ /**
+ * Create a Blade view with the raw component string content.
+ */
+ protected function createBladeViewFromString(Factory $factory, string $contents): string
+ {
+ $factory->addNamespace(
+ '__components',
+ $directory = Container::getInstance()['config']->get('view.compiled')
+ );
+
+ if (! is_file($viewFile = $directory . '/' . hash('xxh128', $contents) . '.blade.php')) {
+ if (! is_dir($directory)) {
+ mkdir($directory, 0755, true);
+ }
+
+ file_put_contents($viewFile, $contents);
+ }
+
+ return '__components::' . basename($viewFile, '.blade.php');
+ }
+
+ /**
+ * Get the data that should be supplied to the view.
+ */
+ public function data(): array
+ {
+ $this->attributes = $this->attributes ?: $this->newAttributeBag();
+
+ return array_merge($this->extractPublicProperties(), $this->extractPublicMethods());
+ }
+
+ /**
+ * Extract the public properties for the component.
+ */
+ protected function extractPublicProperties(): array
+ {
+ $class = get_class($this);
+
+ if (! isset(static::$propertyCache[$class])) {
+ $reflection = new ReflectionClass($this);
+
+ static::$propertyCache[$class] = (new Collection($reflection->getProperties(ReflectionProperty::IS_PUBLIC)))
+ ->reject(fn (ReflectionProperty $property) => $property->isStatic())
+ ->reject(fn (ReflectionProperty $property) => $this->shouldIgnore($property->getName()))
+ ->map(fn (ReflectionProperty $property) => $property->getName())
+ ->all();
+ }
+
+ $values = [];
+
+ foreach (static::$propertyCache[$class] as $property) {
+ $values[$property] = $this->{$property};
+ }
+
+ return $values;
+ }
+
+ /**
+ * Extract the public methods for the component.
+ */
+ protected function extractPublicMethods(): array
+ {
+ $class = get_class($this);
+
+ if (! isset(static::$methodCache[$class])) {
+ $reflection = new ReflectionClass($this);
+
+ static::$methodCache[$class] = (new Collection($reflection->getMethods(ReflectionMethod::IS_PUBLIC)))
+ ->reject(fn (ReflectionMethod $method) => $this->shouldIgnore($method->getName()))
+ ->map(fn (ReflectionMethod $method) => $method->getName());
+ }
+
+ $values = [];
+
+ foreach (static::$methodCache[$class] as $method) {
+ $values[$method] = $this->createVariableFromMethod(new ReflectionMethod($this, $method));
+ }
+
+ return $values;
+ }
+
+ /**
+ * Create a callable variable from the given method.
+ */
+ protected function createVariableFromMethod(ReflectionMethod $method): mixed
+ {
+ return $method->getNumberOfParameters() === 0
+ ? $this->createInvokableVariable($method->getName())
+ : Closure::fromCallable([$this, $method->getName()]);
+ }
+
+ /**
+ * Create an invokable, toStringable variable for the given component method.
+ */
+ protected function createInvokableVariable(string $method): InvokableComponentVariable
+ {
+ return new InvokableComponentVariable(function () use ($method) {
+ return $this->{$method}();
+ });
+ }
+
+ /**
+ * Determine if the given property / method should be ignored.
+ */
+ protected function shouldIgnore(string $name): bool
+ {
+ return str_starts_with($name, '__')
+ || in_array($name, $this->ignoredMethods());
+ }
+
+ /**
+ * Get the methods that should be ignored.
+ */
+ protected function ignoredMethods(): array
+ {
+ return array_merge([
+ 'data',
+ 'render',
+ 'resolve',
+ 'resolveView',
+ 'shouldRender',
+ 'view',
+ 'withName',
+ 'withAttributes',
+ 'flushCache',
+ 'forgetFactory',
+ 'forgetComponentsResolver',
+ 'resolveComponentsUsing',
+ ], $this->except);
+ }
+
+ /**
+ * Set the component alias name.
+ */
+ public function withName(string $name): static
+ {
+ $this->componentName = $name;
+
+ return $this;
+ }
+
+ /**
+ * Set the extra attributes that the component should make available.
+ */
+ public function withAttributes(array $attributes): static
+ {
+ $this->attributes = $this->attributes ?: $this->newAttributeBag();
+
+ $this->attributes->setAttributes($attributes);
+
+ return $this;
+ }
+
+ /**
+ * Get a new attribute bag instance.
+ */
+ protected function newAttributeBag(array $attributes = []): ComponentAttributeBag
+ {
+ return new ComponentAttributeBag($attributes);
+ }
+
+ /**
+ * Determine if the component should be rendered.
+ */
+ public function shouldRender(): bool
+ {
+ return true;
+ }
+
+ /**
+ * Get the evaluated view contents for the given view.
+ */
+ public function view(?string $view, Arrayable|array $data = [], array $mergeData = []): ViewContract
+ {
+ return $this->factory()->make($view, $data, $mergeData);
+ }
+
+ /**
+ * Get the view factory instance.
+ */
+ protected function factory(): Factory
+ {
+ if (is_null(static::$factory)) {
+ static::$factory = Container::getInstance()->make(Factory::class);
+ }
+
+ return static::$factory;
+ }
+
+ /**
+ * Get the cached set of anonymous component constructor parameter names to exclude.
+ */
+ public static function ignoredParameterNames(): array
+ {
+ if (! isset(static::$ignoredParameterNames[static::class])) {
+ $constructor = (new ReflectionClass(
+ static::class
+ ))->getConstructor();
+
+ if (! $constructor) {
+ return static::$ignoredParameterNames[static::class] = [];
+ }
+
+ static::$ignoredParameterNames[static::class] = (new Collection($constructor->getParameters()))
+ ->map(fn ($p) => $p->getName())
+ ->all();
+ }
+
+ return static::$ignoredParameterNames[static::class];
+ }
+
+ /**
+ * Flush the component's cached state.
+ */
+ public static function flushCache(): void
+ {
+ static::$bladeViewCache = [];
+ static::$constructorParametersCache = [];
+ static::$methodCache = [];
+ static::$propertyCache = [];
+ }
+
+ /**
+ * Forget the component's factory instance.
+ */
+ public static function forgetFactory(): void
+ {
+ static::$factory = null;
+ }
+
+ /**
+ * Forget the component's resolver callback.
+ *
+ * @internal
+ */
+ public static function forgetComponentsResolver(): void
+ {
+ static::$componentsResolver = null;
+ }
+
+ /**
+ * Set the callback that should be used to resolve components within views.
+ *
+ * @param Closure(string $component, array $data): static $resolver
+ *
+ * @internal
+ */
+ public static function resolveComponentsUsing(Closure $resolver): void
+ {
+ static::$componentsResolver = $resolver;
+ }
+}
diff --git a/src/view/src/ComponentAttributeBag.php b/src/view/src/ComponentAttributeBag.php
new file mode 100644
index 000000000..18d57e13c
--- /dev/null
+++ b/src/view/src/ComponentAttributeBag.php
@@ -0,0 +1,427 @@
+setAttributes($attributes);
+ }
+
+ /**
+ * Get all of the attribute values.
+ */
+ public function all(): array
+ {
+ return $this->attributes;
+ }
+
+ /**
+ * Get the first attribute's value.
+ */
+ public function first(mixed $default = null): mixed
+ {
+ return $this->getIterator()->current() ?? value($default);
+ }
+
+ /**
+ * Get a given attribute from the attribute array.
+ */
+ public function get(string $key, mixed $default = null): mixed
+ {
+ return $this->attributes[$key] ?? value($default);
+ }
+
+ /**
+ * Determine if a given attribute exists in the attribute array.
+ */
+ public function has(array|string $key): bool
+ {
+ $keys = is_array($key) ? $key : func_get_args();
+
+ foreach ($keys as $value) {
+ if (! array_key_exists($value, $this->attributes)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Determine if any of the keys exist in the attribute array.
+ */
+ public function hasAny(array|string $key): bool
+ {
+ if (! count($this->attributes)) {
+ return false;
+ }
+
+ $keys = is_array($key) ? $key : func_get_args();
+
+ foreach ($keys as $value) {
+ if ($this->has($value)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine if a given attribute is missing from the attribute array.
+ */
+ public function missing(string $key): bool
+ {
+ return ! $this->has($key);
+ }
+
+ /**
+ * Only include the given attribute from the attribute array.
+ */
+ public function only(mixed $keys): static
+ {
+ if (is_null($keys)) {
+ $values = $this->attributes;
+ } else {
+ $keys = Arr::wrap($keys);
+
+ $values = Arr::only($this->attributes, $keys);
+ }
+
+ return new static($values);
+ }
+
+ /**
+ * Exclude the given attribute from the attribute array.
+ */
+ public function except(mixed $keys): static
+ {
+ if (is_null($keys)) {
+ $values = $this->attributes;
+ } else {
+ $keys = Arr::wrap($keys);
+
+ $values = Arr::except($this->attributes, $keys);
+ }
+
+ return new static($values);
+ }
+
+ /**
+ * Filter the attributes, returning a bag of attributes that pass the filter.
+ */
+ public function filter(callable $callback): static
+ {
+ return new static((new Collection($this->attributes))->filter($callback)->all());
+ }
+
+ /**
+ * Return a bag of attributes that have keys starting with the given value / pattern.
+ *
+ * @param string|string[] $needles
+ */
+ public function whereStartsWith(string|array $needles): static
+ {
+ return $this->filter(function ($value, $key) use ($needles) {
+ return Str::startsWith($key, $needles);
+ });
+ }
+
+ /**
+ * Return a bag of attributes with keys that do not start with the given value / pattern.
+ *
+ * @param string|string[] $needles
+ */
+ public function whereDoesntStartWith(string|array $needles): static
+ {
+ return $this->filter(function ($value, $key) use ($needles) {
+ return ! Str::startsWith($key, $needles);
+ });
+ }
+
+ /**
+ * Return a bag of attributes that have keys starting with the given value / pattern.
+ *
+ * @param string|string[] $needles
+ */
+ public function thatStartWith(string|array $needles): static
+ {
+ return $this->whereStartsWith($needles);
+ }
+
+ /**
+ * Only include the given attribute from the attribute array.
+ */
+ public function onlyProps(mixed $keys): static
+ {
+ return $this->only(static::extractPropNames($keys));
+ }
+
+ /**
+ * Exclude the given attribute from the attribute array.
+ */
+ public function exceptProps(mixed $keys): static
+ {
+ return $this->except(static::extractPropNames($keys));
+ }
+
+ /**
+ * Conditionally merge classes into the attribute bag.
+ */
+ public function class(mixed $classList): static
+ {
+ $classList = Arr::wrap($classList);
+
+ return $this->merge(['class' => Arr::toCssClasses($classList)]);
+ }
+
+ /**
+ * Conditionally merge styles into the attribute bag.
+ */
+ public function style(mixed $styleList): static
+ {
+ $styleList = Arr::wrap($styleList);
+
+ return $this->merge(['style' => Arr::toCssStyles($styleList)]);
+ }
+
+ /**
+ * Merge additional attributes / values into the attribute bag.
+ */
+ public function merge(array $attributeDefaults = [], bool $escape = true): static
+ {
+ $attributeDefaults = array_map(function ($value) use ($escape) {
+ return $this->shouldEscapeAttributeValue($escape, $value)
+ ? e($value)
+ : $value;
+ }, $attributeDefaults);
+
+ [$appendableAttributes, $nonAppendableAttributes] = (new Collection($this->attributes))
+ ->partition(function ($value, $key) use ($attributeDefaults) {
+ return $key === 'class' || $key === 'style' || (
+ isset($attributeDefaults[$key])
+ && $attributeDefaults[$key] instanceof AppendableAttributeValue
+ );
+ });
+
+ $attributes = $appendableAttributes->mapWithKeys(function ($value, $key) use ($attributeDefaults, $escape) {
+ $defaultsValue = isset($attributeDefaults[$key]) && $attributeDefaults[$key] instanceof AppendableAttributeValue
+ ? $this->resolveAppendableAttributeDefault($attributeDefaults, $key, $escape)
+ : ($attributeDefaults[$key] ?? '');
+
+ if ($key === 'style') {
+ $value = Str::finish($value, ';');
+ }
+
+ return [$key => implode(' ', array_unique(array_filter([$defaultsValue, $value])))];
+ })->merge($nonAppendableAttributes)->all();
+
+ return new static(array_merge($attributeDefaults, $attributes));
+ }
+
+ /**
+ * Determine if the specific attribute value should be escaped.
+ */
+ protected function shouldEscapeAttributeValue(bool $escape, mixed $value): bool
+ {
+ if (! $escape) {
+ return false;
+ }
+
+ return ! is_object($value)
+ && ! is_null($value)
+ && ! is_bool($value);
+ }
+
+ /**
+ * Create a new appendable attribute value.
+ */
+ public function prepends(mixed $value): AppendableAttributeValue
+ {
+ return new AppendableAttributeValue($value);
+ }
+
+ /**
+ * Resolve an appendable attribute value default value.
+ */
+ protected function resolveAppendableAttributeDefault(array $attributeDefaults, string $key, bool $escape): mixed
+ {
+ if ($this->shouldEscapeAttributeValue($escape, $value = $attributeDefaults[$key]->value)) {
+ $value = e($value);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Determine if the attribute bag is empty.
+ */
+ public function isEmpty(): bool
+ {
+ return trim((string) $this) === '';
+ }
+
+ /**
+ * Determine if the attribute bag is not empty.
+ */
+ public function isNotEmpty(): bool
+ {
+ return ! $this->isEmpty();
+ }
+
+ /**
+ * Get all of the raw attributes.
+ */
+ public function getAttributes(): array
+ {
+ return $this->attributes;
+ }
+
+ /**
+ * Set the underlying attributes.
+ */
+ public function setAttributes(array $attributes): void
+ {
+ if (isset($attributes['attributes'])
+ && $attributes['attributes'] instanceof self) {
+ $parentBag = $attributes['attributes'];
+
+ unset($attributes['attributes']);
+
+ $attributes = $parentBag->merge($attributes, $escape = false)->getAttributes();
+ }
+
+ $this->attributes = $attributes;
+ }
+
+ /**
+ * Extract "prop" names from given keys.
+ */
+ public static function extractPropNames(array $keys): array
+ {
+ $props = [];
+
+ foreach ($keys as $key => $default) {
+ $key = is_numeric($key) ? $default : $key;
+
+ $props[] = $key;
+ $props[] = Str::kebab($key);
+ }
+
+ return $props;
+ }
+
+ /**
+ * Get content as a string of HTML.
+ */
+ public function toHtml(): string
+ {
+ return (string) $this;
+ }
+
+ /**
+ * Merge additional attributes / values into the attribute bag.
+ */
+ public function __invoke(array $attributeDefaults = []): HtmlString
+ {
+ return new HtmlString((string) $this->merge($attributeDefaults));
+ }
+
+ /**
+ * Determine if the given offset exists.
+ */
+ public function offsetExists(mixed $offset): bool
+ {
+ return isset($this->attributes[$offset]);
+ }
+
+ /**
+ * Get the value at the given offset.
+ */
+ public function offsetGet(mixed $offset): mixed
+ {
+ return $this->get($offset);
+ }
+
+ /**
+ * Set the value at a given offset.
+ */
+ public function offsetSet(mixed $offset, mixed $value): void
+ {
+ $this->attributes[$offset] = $value;
+ }
+
+ /**
+ * Remove the value at the given offset.
+ */
+ public function offsetUnset(mixed $offset): void
+ {
+ unset($this->attributes[$offset]);
+ }
+
+ /**
+ * Get an iterator for the items.
+ */
+ public function getIterator(): ArrayIterator
+ {
+ return new ArrayIterator($this->attributes);
+ }
+
+ /**
+ * Convert the object into a JSON serializable form.
+ */
+ public function jsonSerialize(): mixed
+ {
+ return $this->attributes;
+ }
+
+ /**
+ * Implode the attributes into a single HTML ready string.
+ */
+ public function __toString(): string
+ {
+ $string = '';
+
+ foreach ($this->attributes as $key => $value) {
+ if ($value === false || is_null($value)) {
+ continue;
+ }
+
+ if ($value === true) {
+ $value = $key === 'x-data' || str_starts_with($key, 'wire:') ? '' : $key;
+ }
+
+ $string .= ' ' . $key . '="' . str_replace('"', '\"', trim((string) $value)) . '"';
+ }
+
+ return trim($string);
+ }
+}
diff --git a/src/view/src/ComponentSlot.php b/src/view/src/ComponentSlot.php
new file mode 100644
index 000000000..4cc83b7ff
--- /dev/null
+++ b/src/view/src/ComponentSlot.php
@@ -0,0 +1,85 @@
+withAttributes($attributes);
+ }
+
+ /**
+ * Set the extra attributes that the slot should make available.
+ */
+ public function withAttributes(array $attributes): static
+ {
+ $this->attributes = new ComponentAttributeBag($attributes);
+
+ return $this;
+ }
+
+ /**
+ * Get the slot's HTML string.
+ */
+ public function toHtml(): string
+ {
+ return $this->contents;
+ }
+
+ /**
+ * Determine if the slot is empty.
+ */
+ public function isEmpty(): bool
+ {
+ return $this->contents === '';
+ }
+
+ /**
+ * Determine if the slot is not empty.
+ */
+ public function isNotEmpty(): bool
+ {
+ return ! $this->isEmpty();
+ }
+
+ /**
+ * Determine if the slot has non-comment content.
+ */
+ public function hasActualContent(callable|string|null $callable = null): bool
+ {
+ if (is_string($callable) && ! function_exists($callable)) {
+ throw new InvalidArgumentException('Callable does not exist.');
+ }
+
+ return filter_var(
+ $this->contents,
+ FILTER_CALLBACK,
+ ['options' => $callable ?? fn ($input) => trim(preg_replace('//', '', $input))]
+ ) !== '';
+ }
+
+ /**
+ * Get the slot's HTML string.
+ */
+ public function __toString(): string
+ {
+ return $this->toHtml();
+ }
+}
diff --git a/src/view/src/Concerns/ManagesComponents.php b/src/view/src/Concerns/ManagesComponents.php
new file mode 100644
index 000000000..21a450d50
--- /dev/null
+++ b/src/view/src/Concerns/ManagesComponents.php
@@ -0,0 +1,259 @@
+pushComponentStack($view);
+
+ $this->appendComponentData($data);
+
+ $this->createSlotContext();
+ }
+ }
+
+ protected function pushComponentStack(View|Htmlable|Closure|string $view): int
+ {
+ $componentStack = Context::get(static::COMPONENT_STACK_CONTEXT_KEY, []);
+ $componentStack[] = $view;
+ Context::set(static::COMPONENT_STACK_CONTEXT_KEY, $componentStack);
+
+ return count($componentStack);
+ }
+
+ protected function popComponentStack(): View|Htmlable|Closure|string
+ {
+ $componentStack = Context::get(static::COMPONENT_STACK_CONTEXT_KEY, []);
+ $view = array_pop($componentStack);
+ Context::set(static::COMPONENT_STACK_CONTEXT_KEY, $componentStack);
+
+ return $view;
+ }
+
+ protected function appendComponentData(array $data): void
+ {
+ $componentData = Context::get(static::COMPONENT_DATA_CONTEXT_KEY, []);
+ $componentData[$this->currentComponent()] = $data;
+ Context::set(static::COMPONENT_DATA_CONTEXT_KEY, $componentData);
+ }
+
+ protected function createSlotContext()
+ {
+ $slots = Context::get(static::SLOTS_CONTEXT_KEY, []);
+ $slots[$this->currentComponent()] = [];
+ Context::set(static::SLOTS_CONTEXT_KEY, $slots);
+ }
+
+ /**
+ * Get the first view that actually exists from the given list, and start a component.
+ */
+ public function startComponentFirst(array $names, array $data = []): void
+ {
+ $name = Arr::first($names, function ($item) {
+ return $this->exists($item);
+ });
+
+ $this->startComponent($name, $data);
+ }
+
+ /**
+ * Render the current component.
+ */
+ public function renderComponent(): string
+ {
+ $view = $this->popComponentStack();
+
+ $previousComponentData = Context::get(static::CURRENT_COMPONENT_DATA_CONTEXT_KEY, []);
+ $data = $this->componentData();
+
+ $currentComponentData = array_merge($previousComponentData, $data);
+ Context::set(static::CURRENT_COMPONENT_DATA_CONTEXT_KEY, $currentComponentData);
+
+ try {
+ $view = value($view, $data);
+
+ if ($view instanceof View) {
+ return $view->with($data)->render();
+ }
+ if ($view instanceof Htmlable) {
+ return $view->toHtml();
+ }
+ return $this->make($view, $data)->render();
+ } finally {
+ Context::set(static::CURRENT_COMPONENT_DATA_CONTEXT_KEY, $previousComponentData);
+ }
+ }
+
+ /**
+ * Get the data for the given component.
+ */
+ protected function componentData(): array
+ {
+ $defaultSlot = new ComponentSlot(trim(ob_get_clean()));
+
+ $componentStack = Context::get(static::COMPONENT_STACK_CONTEXT_KEY, []);
+ $componentData = Context::get(static::COMPONENT_DATA_CONTEXT_KEY, []);
+ $slotsData = Context::get(static::SLOTS_CONTEXT_KEY, []);
+
+ $stackCount = count($componentStack);
+
+ $slots = array_merge([
+ '__default' => $defaultSlot,
+ ], $slotsData[$stackCount] ?? []);
+
+ return array_merge(
+ $componentData[$stackCount] ?? [],
+ ['slot' => $defaultSlot],
+ $slotsData[$stackCount] ?? [],
+ ['__laravel_slots' => $slots]
+ );
+ }
+
+ /**
+ * Get an item from the component data that exists above the current component.
+ */
+ public function getConsumableComponentData(string $key, mixed $default = null): mixed
+ {
+ $currentComponentData = Context::get(static::CURRENT_COMPONENT_DATA_CONTEXT_KEY, []);
+
+ if (array_key_exists($key, $currentComponentData)) {
+ return $currentComponentData[$key];
+ }
+
+ $componentStack = Context::get(static::COMPONENT_STACK_CONTEXT_KEY, []);
+ $currentComponent = count($componentStack);
+
+ if ($currentComponent === 0) {
+ return value($default);
+ }
+
+ $componentData = Context::get(static::COMPONENT_DATA_CONTEXT_KEY, []);
+
+ for ($i = $currentComponent - 1; $i >= 0; --$i) {
+ $data = $componentData[$i] ?? [];
+
+ if (array_key_exists($key, $data)) {
+ return $data[$key];
+ }
+ }
+
+ return value($default);
+ }
+
+ /**
+ * Start the slot rendering process.
+ */
+ public function slot(string $name, ?string $content = null, array $attributes = []): void
+ {
+ if (func_num_args() === 2 || $content !== null) {
+ $this->setSlotData($name, $content);
+ } elseif (ob_start()) {
+ $this->setSlotData($name, '');
+
+ $this->pushSlotStack([$name, $attributes]);
+ }
+ }
+
+ protected function setSlotData(string $name, string|ComponentSlot|null $content): void
+ {
+ $currentComponent = $this->currentComponent();
+
+ $slots = Context::get(static::SLOTS_CONTEXT_KEY, []);
+ $slots[$currentComponent][$name] = $content;
+ Context::set(static::SLOTS_CONTEXT_KEY, $slots);
+ }
+
+ protected function pushSlotStack(array $value): void
+ {
+ $currentComponent = $this->currentComponent();
+
+ $slotStack = Context::get(static::SLOT_STACK_CONTEXT_KEY, []);
+ $slotStack[$currentComponent][] = $value;
+ Context::set(static::SLOT_STACK_CONTEXT_KEY, $slotStack);
+ }
+
+ protected function popSlotStack(): array
+ {
+ $currentComponent = $this->currentComponent();
+
+ $slotStack = Context::get(static::SLOT_STACK_CONTEXT_KEY, []);
+ $value = array_pop($slotStack[$currentComponent]);
+ Context::set(static::SLOT_STACK_CONTEXT_KEY, $slotStack);
+
+ return $value;
+ }
+
+ /**
+ * Save the slot content for rendering.
+ */
+ public function endSlot(): void
+ {
+ $currentSlot = $this->popSlotStack();
+
+ [$currentName, $currentAttributes] = $currentSlot;
+
+ $this->setSlotData($currentName, new ComponentSlot(
+ trim(ob_get_clean()),
+ $currentAttributes
+ ));
+ }
+
+ /**
+ * Get the index for the current component.
+ */
+ protected function currentComponent(): int
+ {
+ $componentStack = Context::get(static::COMPONENT_STACK_CONTEXT_KEY, []);
+ return count($componentStack) - 1;
+ }
+
+ /**
+ * Flush all of the component state.
+ */
+ protected function flushComponents(): void
+ {
+ Context::set(static::COMPONENT_STACK_CONTEXT_KEY, []);
+ Context::set(static::COMPONENT_DATA_CONTEXT_KEY, []);
+ Context::set(static::CURRENT_COMPONENT_DATA_CONTEXT_KEY, []);
+ }
+}
diff --git a/src/view/src/Concerns/ManagesEvents.php b/src/view/src/Concerns/ManagesEvents.php
new file mode 100644
index 000000000..2ae96d83e
--- /dev/null
+++ b/src/view/src/Concerns/ManagesEvents.php
@@ -0,0 +1,160 @@
+addViewEvent($view, $callback, 'creating: ');
+ }
+
+ return $creators;
+ }
+
+ /**
+ * Register multiple view composers via an array.
+ */
+ public function composers(array $composers): array
+ {
+ $registered = [];
+
+ foreach ($composers as $callback => $views) {
+ $registered = array_merge($registered, $this->composer($views, $callback));
+ }
+
+ return $registered;
+ }
+
+ /**
+ * Register a view composer event.
+ */
+ public function composer(array|string $views, Closure|string $callback): array
+ {
+ $composers = [];
+
+ foreach ((array) $views as $view) {
+ $composers[] = $this->addViewEvent($view, $callback);
+ }
+
+ return $composers;
+ }
+
+ /**
+ * Add an event for a given view.
+ */
+ protected function addViewEvent(string $view, Closure|string $callback, string $prefix = 'composing: '): ?Closure
+ {
+ $view = $this->normalizeName($view);
+
+ if ($callback instanceof Closure) {
+ $this->addEventListener($prefix . $view, $callback);
+
+ return $callback;
+ }
+
+ return $this->addClassEvent($view, $callback, $prefix);
+ }
+
+ /**
+ * Register a class based view composer.
+ */
+ protected function addClassEvent(string $view, string $class, string $prefix): Closure
+ {
+ $name = $prefix . $view;
+
+ // When registering a class based view "composer", we will simply resolve the
+ // classes from the application IoC container then call the compose method
+ // on the instance. This allows for convenient, testable view composers.
+ $callback = $this->buildClassEventCallback(
+ $class,
+ $prefix
+ );
+
+ $this->addEventListener($name, $callback);
+
+ return $callback;
+ }
+
+ /**
+ * Build a class based container callback Closure.
+ */
+ protected function buildClassEventCallback(string $class, string $prefix): Closure
+ {
+ [$class, $method] = $this->parseClassEvent($class, $prefix);
+
+ // Once we have the class and method name, we can build the Closure to resolve
+ // the instance out of the IoC container and call the method on it with the
+ // given arguments that are passed to the Closure as the composer's data.
+ return function () use ($class, $method) {
+ return $this->container->make($class)->{$method}(...func_get_args());
+ };
+ }
+
+ /**
+ * Parse a class based composer name.
+ */
+ protected function parseClassEvent(string $class, string $prefix): array
+ {
+ return Str::parseCallback($class, $this->classEventMethodForPrefix($prefix));
+ }
+
+ /**
+ * Determine the class event method based on the given prefix.
+ */
+ protected function classEventMethodForPrefix(string $prefix): string
+ {
+ return str_contains($prefix, 'composing') ? 'compose' : 'create';
+ }
+
+ /**
+ * Add a listener to the event dispatcher.
+ */
+ protected function addEventListener(string $name, Closure $callback): void
+ {
+ if (str_contains($name, '*')) {
+ $callback = function ($name, array $data) use ($callback) {
+ return $callback($data[0]);
+ };
+ }
+
+ $this->events->listen($name, $callback);
+ }
+
+ /**
+ * Call the composer for a given view.
+ */
+ public function callComposer(ViewContract $view): void
+ {
+ if ($this->getContainer()->get(ConfigInterface::class)->get('view.event.enable', false)
+ && $this->events->hasListeners($event = 'composing: ' . $view->name())
+ ) {
+ $this->events->dispatch($event, [$view]);
+ }
+ }
+
+ /**
+ * Call the creator for a given view.
+ */
+ public function callCreator(ViewContract $view): void
+ {
+ if ($this->getContainer()->get(ConfigInterface::class)->get('view.event.enable', false)
+ && $this->events->hasListeners($event = 'creating: ' . $view->name())
+ ) {
+ $this->events->dispatch($event, [$view]);
+ }
+ }
+}
diff --git a/src/view/src/Concerns/ManagesFragments.php b/src/view/src/Concerns/ManagesFragments.php
new file mode 100644
index 000000000..8e8f489fd
--- /dev/null
+++ b/src/view/src/Concerns/ManagesFragments.php
@@ -0,0 +1,88 @@
+pushFragmentStack($fragment);
+ }
+ }
+
+ protected function pushFragmentStack(string $fragment): void
+ {
+ Context::override(self::FRAGMENT_STACK_CONTEXT_KEY, function (?array $stack) use ($fragment) {
+ $stack = $stack ?? [];
+ $stack[] = $fragment;
+ return $stack;
+ });
+ }
+
+ /**
+ * Stop injecting content into a fragment.
+ *
+ * @throws InvalidArgumentException
+ */
+ public function stopFragment(): string
+ {
+ $fragmentStack = Context::get(self::FRAGMENT_STACK_CONTEXT_KEY);
+
+ if (empty($fragmentStack)) {
+ throw new InvalidArgumentException('Cannot end a fragment without first starting one.');
+ }
+
+ $last = array_pop($fragmentStack);
+ Context::set(self::FRAGMENT_STACK_CONTEXT_KEY, $fragmentStack);
+
+ $fragments = Context::get(self::FRAGMENTS_CONTEXT_KEY, []);
+ $fragments[$last] = ob_get_clean();
+ Context::set(self::FRAGMENTS_CONTEXT_KEY, $fragments);
+
+ return $fragments[$last];
+ }
+
+ /**
+ * Get the contents of a fragment.
+ */
+ public function getFragment(string $name, ?string $default = null): mixed
+ {
+ return $this->getFragments()[$name] ?? $default;
+ }
+
+ /**
+ * Get the entire array of rendered fragments.
+ */
+ public function getFragments(): array
+ {
+ return Context::get(self::FRAGMENTS_CONTEXT_KEY, []);
+ }
+
+ /**
+ * Flush all of the fragments.
+ */
+ public function flushFragments(): void
+ {
+ Context::set(self::FRAGMENTS_CONTEXT_KEY, []);
+ Context::set(self::FRAGMENT_STACK_CONTEXT_KEY, []);
+ }
+}
diff --git a/src/view/src/Concerns/ManagesLayouts.php b/src/view/src/Concerns/ManagesLayouts.php
new file mode 100644
index 000000000..76bb18a32
--- /dev/null
+++ b/src/view/src/Concerns/ManagesLayouts.php
@@ -0,0 +1,215 @@
+extendSection($section, $content instanceof View ? $content->render() : e($content));
+ }
+ }
+
+ /**
+ * Inject inline content into a section.
+ */
+ public function inject(string $section, string $content): void
+ {
+ $this->startSection($section, $content);
+ }
+
+ /**
+ * Stop injecting content into a section and return its contents.
+ */
+ public function yieldSection(): string
+ {
+ $sectionStack = Context::get(static::SECTION_STACK_CONTEXT_KEY, []);
+
+ if (empty($sectionStack)) {
+ return '';
+ }
+
+ return $this->yieldContent($this->stopSection());
+ }
+
+ /**
+ * Stop injecting content into a section.
+ *
+ * @throws InvalidArgumentException
+ */
+ public function stopSection(bool $overwrite = false): string
+ {
+ $sectionStack = Context::get(static::SECTION_STACK_CONTEXT_KEY, []);
+
+ if (empty($sectionStack)) {
+ throw new InvalidArgumentException('Cannot end a section without first starting one.');
+ }
+
+ $last = array_pop($sectionStack);
+ Context::set(static::SECTION_STACK_CONTEXT_KEY, $sectionStack);
+
+ if ($overwrite) {
+ $sections = Context::get(static::SECTIONS_CONTEXT_KEY, []);
+ $sections[$last] = ob_get_clean();
+ Context::set(static::SECTIONS_CONTEXT_KEY, $sections);
+ } else {
+ $this->extendSection($last, ob_get_clean());
+ }
+
+ return $last;
+ }
+
+ /**
+ * Stop injecting content into a section and append it.
+ *
+ * @throws InvalidArgumentException
+ */
+ public function appendSection(): string
+ {
+ $sectionStack = Context::get(static::SECTION_STACK_CONTEXT_KEY, []);
+
+ if (empty($sectionStack)) {
+ throw new InvalidArgumentException('Cannot end a section without first starting one.');
+ }
+
+ $last = array_pop($sectionStack);
+ Context::set(static::SECTION_STACK_CONTEXT_KEY, $sectionStack);
+
+ $sections = Context::get(static::SECTIONS_CONTEXT_KEY, []);
+ if (isset($sections[$last])) {
+ $sections[$last] .= ob_get_clean();
+ } else {
+ $sections[$last] = ob_get_clean();
+ }
+ Context::set(static::SECTIONS_CONTEXT_KEY, $sections);
+
+ return $last;
+ }
+
+ /**
+ * Append content to a given section.
+ */
+ protected function extendSection(string $section, string $content): void
+ {
+ $sections = Context::get(static::SECTIONS_CONTEXT_KEY, []);
+
+ if (isset($sections[$section])) {
+ $content = str_replace($this->getParentPlaceholder($section), $content, $sections[$section]);
+ }
+
+ $sections[$section] = $content;
+ Context::set(static::SECTIONS_CONTEXT_KEY, $sections);
+ }
+
+ /**
+ * Get the string contents of a section.
+ */
+ public function yieldContent(string $section, string|View $default = ''): string
+ {
+ $sectionContent = $default instanceof View ? $default->render() : e($default);
+
+ $sections = Context::get(static::SECTIONS_CONTEXT_KEY, []);
+ if (isset($sections[$section])) {
+ $sectionContent = $sections[$section];
+ }
+
+ $sectionContent = str_replace('@@parent', '--parent--holder--', $sectionContent);
+
+ return str_replace(
+ '--parent--holder--',
+ '@parent',
+ str_replace($this->getParentPlaceholder($section), '', $sectionContent)
+ );
+ }
+
+ /**
+ * Get the parent placeholder for the current request.
+ */
+ public function getParentPlaceholder(string $section = ''): string
+ {
+ $parentPlaceholder = Context::get(static::PARENT_PLACEHOLDER_CONTEXT_KEY, []);
+
+ if (! isset($parentPlaceholder[$section])) {
+ $salt = Str::random(40);
+ $parentPlaceholder[$section] = '##parent-placeholder-' . hash('xxh128', $salt . $section) . '##';
+ Context::set(static::PARENT_PLACEHOLDER_CONTEXT_KEY, $parentPlaceholder);
+ }
+
+ return $parentPlaceholder[$section];
+ }
+
+ /**
+ * Check if section exists.
+ */
+ public function hasSection(string $name): bool
+ {
+ $sections = Context::get(static::SECTIONS_CONTEXT_KEY, []);
+ return array_key_exists($name, $sections);
+ }
+
+ /**
+ * Check if section does not exist.
+ */
+ public function sectionMissing(string $name): bool
+ {
+ return ! $this->hasSection($name);
+ }
+
+ /**
+ * Get the contents of a section.
+ */
+ public function getSection(string $name, ?string $default = null): mixed
+ {
+ $sections = Context::get(static::SECTIONS_CONTEXT_KEY, []);
+ return $sections[$name] ?? $default;
+ }
+
+ /**
+ * Get the entire array of sections.
+ */
+ public function getSections(): array
+ {
+ return Context::get(static::SECTIONS_CONTEXT_KEY, []);
+ }
+
+ /**
+ * Flush all of the sections.
+ */
+ public function flushSections(): void
+ {
+ Context::set(static::SECTIONS_CONTEXT_KEY, []);
+ Context::set(static::SECTION_STACK_CONTEXT_KEY, []);
+ }
+}
diff --git a/src/view/src/Concerns/ManagesLoops.php b/src/view/src/Concerns/ManagesLoops.php
new file mode 100644
index 000000000..0668bef36
--- /dev/null
+++ b/src/view/src/Concerns/ManagesLoops.php
@@ -0,0 +1,99 @@
+ 0,
+ 'index' => 0,
+ 'remaining' => $length ?? null,
+ 'count' => $length,
+ 'first' => true,
+ 'last' => isset($length) ? $length == 1 : null,
+ 'odd' => false,
+ 'even' => true,
+ 'depth' => count($loopsStack) + 1,
+ 'parent' => $parent ? (object) $parent : null,
+ ];
+
+ Context::set(static::LOOPS_STACK_CONTEXT_KEY, $loopsStack);
+ }
+
+ /**
+ * Increment the top loop's indices.
+ */
+ public function incrementLoopIndices(): void
+ {
+ $loopsStack = Context::get(static::LOOPS_STACK_CONTEXT_KEY, []);
+ $loop = $loopsStack[$index = count($loopsStack) - 1];
+
+ $loopsStack[$index] = array_merge($loopsStack[$index], [
+ 'iteration' => $loop['iteration'] + 1,
+ 'index' => $loop['iteration'],
+ 'first' => $loop['iteration'] == 0,
+ 'odd' => ! $loop['odd'],
+ 'even' => ! $loop['even'],
+ 'remaining' => isset($loop['count']) ? $loop['remaining'] - 1 : null,
+ 'last' => isset($loop['count']) ? $loop['iteration'] == $loop['count'] - 1 : null,
+ ]);
+
+ Context::set(static::LOOPS_STACK_CONTEXT_KEY, $loopsStack);
+ }
+
+ /**
+ * Pop a loop from the top of the loop stack.
+ */
+ public function popLoop(): void
+ {
+ $loopsStack = Context::get(static::LOOPS_STACK_CONTEXT_KEY, []);
+ array_pop($loopsStack);
+ Context::set(static::LOOPS_STACK_CONTEXT_KEY, $loopsStack);
+ }
+
+ /**
+ * Get an instance of the last loop in the stack.
+ */
+ public function getLastLoop(): ?stdClass
+ {
+ $loopsStack = Context::get(static::LOOPS_STACK_CONTEXT_KEY, []);
+
+ return ! empty($loopsStack)
+ ? (object) end($loopsStack)
+ : null;
+ }
+
+ /**
+ * Get the entire loop stack.
+ */
+ public function getLoopStack(): array
+ {
+ return Context::get(static::LOOPS_STACK_CONTEXT_KEY, []);
+ }
+}
diff --git a/src/view/src/Concerns/ManagesStacks.php b/src/view/src/Concerns/ManagesStacks.php
new file mode 100644
index 000000000..28f17040b
--- /dev/null
+++ b/src/view/src/Concerns/ManagesStacks.php
@@ -0,0 +1,185 @@
+pushStack($section);
+ }
+ } else {
+ $this->extendPush($section, $content);
+ }
+ }
+
+ protected function pushStack(string $section): void
+ {
+ $pushStack = Context::get(static::PUSH_STACK_CONTEXT_KEY, []);
+ $pushStack[] = $section;
+ Context::set(static::PUSH_STACK_CONTEXT_KEY, $pushStack);
+ }
+
+ private function popStack(): string
+ {
+ $pushStack = Context::get(static::PUSH_STACK_CONTEXT_KEY, []);
+ $last = array_pop($pushStack);
+ Context::set(static::PUSH_STACK_CONTEXT_KEY, $pushStack);
+
+ return $last;
+ }
+
+ /**
+ * Stop injecting content into a push section.
+ *
+ * @throws InvalidArgumentException
+ */
+ public function stopPush(): string
+ {
+ $last = $this->popStack();
+
+ if (empty($last)) {
+ throw new InvalidArgumentException('Cannot end a push stack without first starting one.');
+ }
+
+ return tap($last, function ($last) {
+ $this->extendPush($last, ob_get_clean());
+ });
+ }
+
+ /**
+ * Append content to a given push section.
+ */
+ protected function extendPush(string $section, string $content): void
+ {
+ $pushes = Context::get(static::PUSHES_CONTEXT_KEY, []);
+
+ if (! isset($pushes[$section])) {
+ $pushes[$section] = [];
+ }
+
+ $renderCount = $this->getRenderCount();
+
+ if (! isset($pushes[$section][$renderCount])) {
+ $pushes[$section][$renderCount] = $content;
+ } else {
+ $pushes[$section][$renderCount] .= $content;
+ }
+
+ Context::set(static::PUSHES_CONTEXT_KEY, $pushes);
+ }
+
+ /**
+ * Start prepending content into a push section.
+ */
+ public function startPrepend(string $section, string $content = ''): void
+ {
+ if ($content === '') {
+ if (ob_start()) {
+ $this->pushStack($section);
+ }
+ } else {
+ $this->extendPrepend($section, $content);
+ }
+ }
+
+ /**
+ * Stop prepending content into a push section.
+ *
+ * @throws InvalidArgumentException
+ */
+ public function stopPrepend(): string
+ {
+ $last = $this->popStack();
+
+ if (empty($last)) {
+ throw new InvalidArgumentException('Cannot end a prepend operation without first starting one.');
+ }
+
+ return tap($last, function ($last) {
+ $this->extendPrepend($last, ob_get_clean());
+ });
+ }
+
+ /**
+ * Prepend content to a given stack.
+ */
+ protected function extendPrepend(string $section, string $content): void
+ {
+ $prepends = Context::get(static::PREPENDS_CONTEXT_KEY, []);
+
+ if (! isset($prepends[$section])) {
+ $prepends[$section] = [];
+ }
+
+ $renderCount = $this->getRenderCount();
+
+ if (! isset($prepends[$section][$renderCount])) {
+ $prepends[$section][$renderCount] = $content;
+ } else {
+ $prepends[$section][$renderCount] = $content . $prepends[$section][$renderCount];
+ }
+
+ Context::set(static::PREPENDS_CONTEXT_KEY, $prepends);
+ }
+
+ /**
+ * Get the string contents of a push section.
+ */
+ public function yieldPushContent(string $section, string $default = ''): string
+ {
+ $pushes = Context::get(static::PUSHES_CONTEXT_KEY, []);
+ $prepends = Context::get(static::PREPENDS_CONTEXT_KEY, []);
+
+ if (! isset($pushes[$section]) && ! isset($prepends[$section])) {
+ return $default;
+ }
+
+ $output = '';
+
+ if (isset($prepends[$section])) {
+ $output .= implode(array_reverse($prepends[$section]));
+ }
+
+ if (isset($pushes[$section])) {
+ $output .= implode($pushes[$section]);
+ }
+
+ return $output;
+ }
+
+ /**
+ * Flush all of the stacks.
+ */
+ public function flushStacks(): void
+ {
+ Context::set(static::PUSHES_CONTEXT_KEY, []);
+ Context::set(static::PREPENDS_CONTEXT_KEY, []);
+ Context::set(static::PUSH_STACK_CONTEXT_KEY, []);
+ }
+}
diff --git a/src/view/src/Concerns/ManagesTranslations.php b/src/view/src/Concerns/ManagesTranslations.php
new file mode 100644
index 000000000..060c19cce
--- /dev/null
+++ b/src/view/src/Concerns/ManagesTranslations.php
@@ -0,0 +1,36 @@
+container->make('translator')->get(
+ trim(ob_get_clean()),
+ Context::get(static::TRANSLATION_REPLACEMENTS_CONTEXT_KEY, [])
+ );
+ }
+}
diff --git a/src/view/src/Contracts/Engine.php b/src/view/src/Contracts/Engine.php
new file mode 100644
index 000000000..3790136ff
--- /dev/null
+++ b/src/view/src/Contracts/Engine.php
@@ -0,0 +1,13 @@
+getAttributes()))->mapWithKeys(function ($value, $key) { return [Hypervel\Support\Str::camel(str_replace([':', '.'], ' ', $key)) => $value]; })->all(), EXTR_SKIP); ?>
+{{ props }}
+
+{{ slots }}
+{{ defaultSlot }}
+
+EOF;
+
+ return function ($data) use ($template) {
+ $bindings = $this->bindings($class = $this->classForComponent());
+
+ return str_replace(
+ [
+ '{{ component }}',
+ '{{ props }}',
+ '{{ bindings }}',
+ '{{ attributes }}',
+ '{{ slots }}',
+ '{{ defaultSlot }}',
+ ],
+ [
+ $this->component,
+ $this->compileProps($bindings),
+ $this->compileBindings($bindings),
+ class_exists($class) ? '{{ $attributes }}' : '',
+ $this->compileSlots($data['__laravel_slots']),
+ '{{ $slot ?? "" }}',
+ ],
+ $template
+ );
+ };
+ }
+
+ /**
+ * Compile the @props directive for the component.
+ */
+ protected function compileProps(array $bindings): string
+ {
+ if (empty($bindings)) {
+ return '';
+ }
+
+ return '@props([\'' . implode('\',\'', (new Collection($bindings))->map(function ($dataKey) {
+ return Str::camel($dataKey);
+ })->all()) . '\'])';
+ }
+
+ /**
+ * Compile the bindings for the component.
+ */
+ protected function compileBindings(array $bindings): string
+ {
+ return (new Collection($bindings))
+ ->map(fn ($key) => ':' . $key . '="$' . Str::camel(str_replace([':', '.'], ' ', $key)) . '"')
+ ->implode(' ');
+ }
+
+ /**
+ * Compile the slots for the component.
+ */
+ protected function compileSlots(array $slots): string
+ {
+ return (new Collection($slots))
+ ->map(fn ($slot, $name) => $name === '__default' ? null : 'attributes) . '>{{ $' . $name . ' }}')
+ ->filter()
+ ->implode(PHP_EOL);
+ }
+
+ /**
+ * Get the class for the current component.
+ */
+ protected function classForComponent(): string
+ {
+ if (isset(static::$componentClasses[$this->component])) {
+ return static::$componentClasses[$this->component];
+ }
+
+ return static::$componentClasses[$this->component]
+ = $this->compiler()->componentClass($this->component);
+ }
+
+ /**
+ * Get the names of the variables that should be bound to the component.
+ */
+ protected function bindings(string $class): array
+ {
+ [$data, $attributes] = $this->compiler()->partitionDataAndAttributes($class, $this->attributes->getAttributes());
+
+ return array_keys($data->all());
+ }
+
+ /**
+ * Get an instance of the Blade tag compiler.
+ */
+ protected function compiler(): ComponentTagCompiler
+ {
+ if (! static::$compiler) {
+ $bladeCompiler = Container::getInstance()->get(CompilerInterface::class);
+
+ static::$compiler = new ComponentTagCompiler(
+ $bladeCompiler->getClassComponentAliases(),
+ $bladeCompiler->getClassComponentNamespaces(),
+ $bladeCompiler
+ );
+ }
+
+ return static::$compiler;
+ }
+}
diff --git a/src/view/src/Engines/CompilerEngine.php b/src/view/src/Engines/CompilerEngine.php
new file mode 100755
index 000000000..1c56253be
--- /dev/null
+++ b/src/view/src/Engines/CompilerEngine.php
@@ -0,0 +1,125 @@
+pushCompiledPath($path);
+
+ // If this given view has expired, which means it has simply been edited since
+ // it was last compiled, we will re-compile the views so we can evaluate a
+ // fresh copy of the view. We'll pass the compiler the path of the view.
+ if ($this->compiler->isExpired($path)) {
+ $this->compiler->compile($path);
+ }
+
+ // Once we have the path to the compiled file, we will evaluate the paths with
+ // typical PHP just like any other templates. We also keep a stack of views
+ // which have been rendered for right exception messages to be generated.
+
+ try {
+ $results = $this->evaluatePath($this->compiler->getCompiledPath($path), $data);
+ } catch (ViewException $e) {
+ if (! Str::of($e->getMessage())->contains(['No such file or directory', 'File does not exist at path'])) {
+ throw $e;
+ }
+
+ $this->compiler->compile($path);
+
+ $results = $this->evaluatePath($this->compiler->getCompiledPath($path), $data);
+ }
+
+ $this->popCompiledPath();
+
+ return $results;
+ }
+
+ protected function pushCompiledPath(string $path): void
+ {
+ $stack = Context::get(static::COMPILED_PATH_CONTEXT_KEY, []);
+ $stack[] = $path;
+ Context::set(static::COMPILED_PATH_CONTEXT_KEY, $stack);
+ }
+
+ protected function popCompiledPath(): void
+ {
+ $stack = Context::get(static::COMPILED_PATH_CONTEXT_KEY, []);
+ array_pop($stack);
+ Context::set(static::COMPILED_PATH_CONTEXT_KEY, $stack);
+ }
+
+ /**
+ * Handle a view exception.
+ *
+ * @throws Throwable
+ */
+ protected function handleViewException(Throwable $e, int $obLevel): void
+ {
+ if ($e instanceof HttpException
+ || $e instanceof HttpResponseException
+ || $e instanceof MultipleRecordsFoundException
+ || $e instanceof RecordsNotFoundException
+ || $e instanceof ModelNotFoundException
+ ) {
+ parent::handleViewException($e, $obLevel);
+ }
+
+ $e = new ViewException($this->getMessage($e), 0, 1, $e->getFile(), $e->getLine(), $e);
+
+ parent::handleViewException($e, $obLevel);
+ }
+
+ /**
+ * Get the exception message for an exception.
+ */
+ protected function getMessage(Throwable $e): string
+ {
+ $stack = Context::get(static::COMPILED_PATH_CONTEXT_KEY);
+
+ return $e->getMessage() . ' (View: ' . realpath(last($stack)) . ')';
+ }
+
+ /**
+ * Get the compiler implementation.
+ */
+ public function getCompiler(): CompilerInterface
+ {
+ return $this->compiler;
+ }
+}
diff --git a/src/view/src/Engines/Engine.php b/src/view/src/Engines/Engine.php
new file mode 100755
index 000000000..d3769c88e
--- /dev/null
+++ b/src/view/src/Engines/Engine.php
@@ -0,0 +1,21 @@
+lastRendered;
+ }
+}
diff --git a/src/view/src/Engines/EngineResolver.php b/src/view/src/Engines/EngineResolver.php
new file mode 100755
index 000000000..785cc2782
--- /dev/null
+++ b/src/view/src/Engines/EngineResolver.php
@@ -0,0 +1,60 @@
+forget($engine);
+
+ $this->resolvers[$engine] = $resolver;
+ }
+
+ /**
+ * Resolve an engine instance by name.
+ *
+ * @throws InvalidArgumentException
+ */
+ public function resolve(string $engine): Engine
+ {
+ if (isset($this->resolved[$engine])) {
+ return $this->resolved[$engine];
+ }
+
+ if (isset($this->resolvers[$engine])) {
+ return $this->resolved[$engine] = call_user_func($this->resolvers[$engine]);
+ }
+
+ throw new InvalidArgumentException("Engine [{$engine}] not found.");
+ }
+
+ /**
+ * Remove a resolved engine.
+ */
+ public function forget(string $engine): void
+ {
+ unset($this->resolved[$engine]);
+ }
+}
diff --git a/src/view/src/Engines/FileEngine.php b/src/view/src/Engines/FileEngine.php
new file mode 100644
index 000000000..1e328e763
--- /dev/null
+++ b/src/view/src/Engines/FileEngine.php
@@ -0,0 +1,27 @@
+files->get($path);
+ }
+}
diff --git a/src/view/src/Engines/PhpEngine.php b/src/view/src/Engines/PhpEngine.php
new file mode 100755
index 000000000..7a70f2632
--- /dev/null
+++ b/src/view/src/Engines/PhpEngine.php
@@ -0,0 +1,63 @@
+evaluatePath($path, $data);
+ }
+
+ /**
+ * Get the evaluated contents of the view at the given path.
+ */
+ protected function evaluatePath(string $path, array $data): string
+ {
+ $obLevel = ob_get_level();
+
+ ob_start();
+
+ // We'll evaluate the contents of the view inside a try/catch block so we can
+ // flush out any stray output that might get out before an error occurs or
+ // an exception is thrown. This prevents any partial views from leaking.
+ try {
+ $this->files->getRequire($path, $data);
+ } catch (Throwable $e) {
+ $this->handleViewException($e, $obLevel);
+ }
+
+ return ltrim(ob_get_clean());
+ }
+
+ /**
+ * Handle a view exception.
+ *
+ * @throws Throwable
+ */
+ protected function handleViewException(Throwable $e, int $obLevel): void
+ {
+ while (ob_get_level() > $obLevel) {
+ ob_end_clean();
+ }
+
+ throw $e;
+ }
+}
diff --git a/src/view/src/Factory.php b/src/view/src/Factory.php
new file mode 100755
index 000000000..2cff18f3e
--- /dev/null
+++ b/src/view/src/Factory.php
@@ -0,0 +1,507 @@
+ 'blade',
+ 'php' => 'php',
+ 'css' => 'file',
+ 'html' => 'file',
+ ];
+
+ /**
+ * The view composer events.
+ */
+ protected array $composers = [];
+
+ /**
+ * The cached array of engines for paths.
+ */
+ protected array $pathEngineCache = [];
+
+ /**
+ * The cache of normalized names for views.
+ */
+ protected array $normalizedNameCache = [];
+
+ /**
+ * Create a new view factory instance.
+ */
+ public function __construct(
+ protected EngineResolver $engines,
+ protected ViewFinderInterface $finder,
+ protected Dispatcher $events
+ ) {
+ $this->share('__env', $this);
+ }
+
+ /**
+ * Get the evaluated view contents for the given view.
+ */
+ public function file(string $path, Arrayable|array $data = [], array $mergeData = []): ViewContract
+ {
+ $data = array_merge($mergeData, $this->parseData($data));
+
+ return tap($this->viewInstance($path, $path, $data), function ($view) {
+ $this->callCreator($view);
+ });
+ }
+
+ /**
+ * Get the evaluated view contents for the given view.
+ */
+ public function make(string $view, Arrayable|array $data = [], array $mergeData = []): ViewContract
+ {
+ $path = $this->finder->find(
+ $view = $this->normalizeName($view)
+ );
+
+ // Next, we will create the view instance and call the view creator for the view
+ // which can set any data, etc. Then we will return the view instance back to
+ // the caller for rendering or performing other view manipulations on this.
+ $data = array_merge($mergeData, $this->parseData($data));
+
+ return tap($this->viewInstance($view, $path, $data), function ($view) {
+ $this->callCreator($view);
+ });
+ }
+
+ /**
+ * Get the first view that actually exists from the given list.
+ *
+ * @throws InvalidArgumentException
+ */
+ public function first(array $views, Arrayable|array $data = [], array $mergeData = []): ViewContract
+ {
+ $view = Arr::first($views, function ($view) {
+ return $this->exists($view);
+ });
+
+ if (! $view) {
+ throw new InvalidArgumentException('None of the views in the given array exist.');
+ }
+
+ return $this->make($view, $data, $mergeData);
+ }
+
+ /**
+ * Get the rendered content of the view based on a given condition.
+ */
+ public function renderWhen(bool $condition, string $view, Arrayable|array $data = [], array $mergeData = []): string
+ {
+ if (! $condition) {
+ return '';
+ }
+
+ return $this->make($view, $this->parseData($data), $mergeData)->render();
+ }
+
+ /**
+ * Get the rendered content of the view based on the negation of a given condition.
+ */
+ public function renderUnless(bool $condition, string $view, Arrayable|array $data = [], array $mergeData = []): string
+ {
+ return $this->renderWhen(! $condition, $view, $data, $mergeData);
+ }
+
+ /**
+ * Get the rendered contents of a partial from a loop.
+ */
+ public function renderEach(string $view, array $data, string $iterator, string $empty = 'raw|'): string
+ {
+ $result = '';
+
+ // If is actually data in the array, we will loop through the data and append
+ // an instance of the partial view to the final result HTML passing in the
+ // iterated value of this data array, allowing the views to access them.
+ if (count($data) > 0) {
+ foreach ($data as $key => $value) {
+ $result .= $this->make(
+ $view,
+ ['key' => $key, $iterator => $value]
+ )->render();
+ }
+ }
+
+ // If there is no data in the array, we will render the contents of the empty
+ // view. Alternatively, the "empty view" could be a raw string that begins
+ // with "raw|" for convenience and to let this know that it is a string.
+ else {
+ $result = str_starts_with($empty, 'raw|')
+ ? substr($empty, 4)
+ : $this->make($empty)->render();
+ }
+
+ return $result;
+ }
+
+ /**
+ * Normalize a view name.
+ */
+ protected function normalizeName(string $name): string
+ {
+ return $this->normalizedNameCache[$name] ??= ViewName::normalize($name);
+ }
+
+ /**
+ * Parse the given data into a raw array.
+ */
+ protected function parseData(mixed $data): array
+ {
+ return $data instanceof Arrayable ? $data->toArray() : $data;
+ }
+
+ /**
+ * Create a new view instance from the given arguments.
+ */
+ protected function viewInstance(string $view, string $path, Arrayable|array $data): ViewContract
+ {
+ return new View($this, $this->getEngineFromPath($path), $view, $path, $data);
+ }
+
+ /**
+ * Determine if a given view exists.
+ */
+ public function exists(string $view): bool
+ {
+ try {
+ $this->finder->find($view);
+ } catch (InvalidArgumentException) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the appropriate view engine for the given path.
+ *
+ * @throws InvalidArgumentException
+ */
+ public function getEngineFromPath(string $path): Engine
+ {
+ if (isset($this->pathEngineCache[$path])) {
+ return $this->engines->resolve($this->pathEngineCache[$path]);
+ }
+
+ if (! $extension = $this->getExtension($path)) {
+ throw new InvalidArgumentException("Unrecognized extension in file: {$path}.");
+ }
+
+ return $this->engines->resolve(
+ $this->pathEngineCache[$path] = $this->extensions[$extension]
+ );
+ }
+
+ /**
+ * Get the extension used by the view file.
+ */
+ protected function getExtension(string $path): ?string
+ {
+ $extensions = array_keys($this->extensions);
+
+ return Arr::first($extensions, function ($value) use ($path) {
+ return str_ends_with($path, '.' . $value);
+ });
+ }
+
+ /**
+ * Add a piece of shared data to the environment.
+ */
+ public function share(array|string $key, mixed $value = null): mixed
+ {
+ $keys = is_array($key) ? $key : [$key => $value];
+
+ foreach ($keys as $key => $value) {
+ $this->shared[$key] = $value;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Increment the rendering counter.
+ */
+ public function incrementRender(): void
+ {
+ Context::override(self::RENDER_COUNT_CONTEXT_KEY, function ($value) {
+ return ($value ?? 0) + 1;
+ });
+ }
+
+ /**
+ * Decrement the rendering counter.
+ */
+ public function decrementRender(): void
+ {
+ Context::override(self::RENDER_COUNT_CONTEXT_KEY, function ($value) {
+ return ($value ?? 1) - 1;
+ });
+ }
+
+ /**
+ * Get the rendering counter.
+ */
+ protected function getRenderCount(): int
+ {
+ return Context::get(self::RENDER_COUNT_CONTEXT_KEY, 0);
+ }
+
+ /**
+ * Check if there are no active render operations.
+ */
+ public function doneRendering(): bool
+ {
+ return Context::get(self::RENDER_COUNT_CONTEXT_KEY, 0) === 0;
+ }
+
+ /**
+ * Determine if the given once token has been rendered.
+ */
+ public function hasRenderedOnce(string $id): bool
+ {
+ $renderedOnce = Context::get(self::RENDERED_ONCE_CONTEXT_KEY, []);
+
+ return isset($renderedOnce[$id]);
+ }
+
+ /**
+ * Mark the given once token as having been rendered.
+ */
+ public function markAsRenderedOnce(string $id): void
+ {
+ Context::override(self::RENDERED_ONCE_CONTEXT_KEY, function ($value) use ($id) {
+ $value ??= [];
+ $value[$id] = true;
+
+ return $value;
+ });
+ }
+
+ /**
+ * Add a location to the array of view locations.
+ */
+ public function addLocation(string $location): void
+ {
+ $this->finder->addLocation($location);
+ }
+
+ /**
+ * Prepend a location to the array of view locations.
+ */
+ public function prependLocation(string $location): void
+ {
+ $this->finder->prependLocation($location);
+ }
+
+ /**
+ * Add a new namespace to the loader.
+ */
+ public function addNamespace(string $namespace, string|array $hints): static
+ {
+ $this->finder->addNamespace($namespace, $hints);
+
+ return $this;
+ }
+
+ /**
+ * Prepend a new namespace to the loader.
+ */
+ public function prependNamespace(string $namespace, string|array $hints): static
+ {
+ $this->finder->prependNamespace($namespace, $hints);
+
+ return $this;
+ }
+
+ /**
+ * Replace the namespace hints for the given namespace.
+ */
+ public function replaceNamespace(string $namespace, string|array $hints): static
+ {
+ $this->finder->replaceNamespace($namespace, $hints);
+
+ return $this;
+ }
+
+ /**
+ * Register a valid view extension and its engine.
+ */
+ public function addExtension(string $extension, string $engine, ?Closure $resolver = null): void
+ {
+ $this->finder->addExtension($extension);
+
+ if (isset($resolver)) {
+ $this->engines->register($engine, $resolver);
+ }
+
+ unset($this->extensions[$extension]);
+
+ $this->extensions = array_merge([$extension => $engine], $this->extensions);
+
+ $this->pathEngineCache = [];
+ }
+
+ /**
+ * Flush all of the factory state like sections and stacks.
+ */
+ public function flushState(): void
+ {
+ Context::set(self::RENDER_COUNT_CONTEXT_KEY, 0);
+ Context::set(self::RENDERED_ONCE_CONTEXT_KEY, []);
+
+ $this->flushSections();
+ $this->flushStacks();
+ $this->flushComponents();
+ $this->flushFragments();
+ }
+
+ /**
+ * Flush all of the section contents if done rendering.
+ */
+ public function flushStateIfDoneRendering(): void
+ {
+ if ($this->doneRendering()) {
+ $this->flushState();
+ }
+ }
+
+ /**
+ * Get the extension to engine bindings.
+ */
+ public function getExtensions(): array
+ {
+ return $this->extensions;
+ }
+
+ /**
+ * Get the engine resolver instance.
+ */
+ public function getEngineResolver(): EngineResolver
+ {
+ return $this->engines;
+ }
+
+ /**
+ * Get the view finder instance.
+ */
+ public function getFinder(): ViewFinderInterface
+ {
+ return $this->finder;
+ }
+
+ /**
+ * Set the view finder instance.
+ */
+ public function setFinder(ViewFinderInterface $finder): void
+ {
+ $this->finder = $finder;
+ }
+
+ /**
+ * Flush the cache of views located by the finder.
+ */
+ public function flushFinderCache(): void
+ {
+ $this->getFinder()->flush();
+ }
+
+ /**
+ * Get the event dispatcher instance.
+ */
+ public function getDispatcher(): Dispatcher
+ {
+ return $this->events;
+ }
+
+ /**
+ * Set the event dispatcher instance.
+ */
+ public function setDispatcher(Dispatcher $events): void
+ {
+ $this->events = $events;
+ }
+
+ /**
+ * Get the IoC container instance.
+ */
+ public function getContainer(): Container
+ {
+ return $this->container;
+ }
+
+ /**
+ * Set the IoC container instance.
+ */
+ public function setContainer(Container $container): void
+ {
+ $this->container = $container;
+ }
+
+ /**
+ * Get an item from the shared data.
+ */
+ public function shared(string $key, mixed $default = null): mixed
+ {
+ return Arr::get($this->shared, $key, $default);
+ }
+
+ /**
+ * Get all of the shared data for the environment.
+ *
+ * @return array
+ */
+ public function getShared()
+ {
+ return $this->shared;
+ }
+}
diff --git a/src/view/src/FileViewFinder.php b/src/view/src/FileViewFinder.php
new file mode 100755
index 000000000..28d7f2525
--- /dev/null
+++ b/src/view/src/FileViewFinder.php
@@ -0,0 +1,260 @@
+paths = array_map([$this, 'resolvePath'], $paths);
+
+ if (isset($extensions)) {
+ $this->extensions = $extensions;
+ }
+ }
+
+ /**
+ * Get the fully qualified location of the view.
+ */
+ public function find(string $name): string
+ {
+ if (isset($this->views[$name])) {
+ return $this->views[$name];
+ }
+
+ if ($this->hasHintInformation($name = trim($name))) {
+ return $this->views[$name] = $this->findNamespacedView($name);
+ }
+
+ return $this->views[$name] = $this->findInPaths($name, $this->paths);
+ }
+
+ /**
+ * Get the path to a template with a named path.
+ */
+ protected function findNamespacedView(string $name): string
+ {
+ [$namespace, $view] = $this->parseNamespaceSegments($name);
+
+ return $this->findInPaths($view, $this->hints[$namespace]);
+ }
+
+ /**
+ * Get the segments of a template with a named path.
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function parseNamespaceSegments(string $name): array
+ {
+ $segments = explode(static::HINT_PATH_DELIMITER, $name);
+
+ if (count($segments) !== 2) {
+ throw new InvalidArgumentException("View [{$name}] has an invalid name.");
+ }
+
+ if (! isset($this->hints[$segments[0]])) {
+ throw new InvalidArgumentException("No hint path defined for [{$segments[0]}].");
+ }
+
+ return $segments;
+ }
+
+ /**
+ * Find the given view in the list of paths.
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function findInPaths(string $name, array $paths): string
+ {
+ foreach ((array) $paths as $path) {
+ foreach ($this->getPossibleViewFiles($name) as $file) {
+ $viewPath = $path . '/' . $file;
+
+ if (strlen($viewPath) < (PHP_MAXPATHLEN - 1) && $this->files->exists($viewPath)) {
+ return $viewPath;
+ }
+ }
+ }
+
+ throw new InvalidArgumentException("View [{$name}] not found.");
+ }
+
+ /**
+ * Get an array of possible view files.
+ */
+ protected function getPossibleViewFiles(string $name): array
+ {
+ return array_map(fn ($extension) => str_replace('.', '/', $name) . '.' . $extension, $this->extensions);
+ }
+
+ /**
+ * Add a location to the finder.
+ */
+ public function addLocation(string $location): void
+ {
+ $this->paths[] = $this->resolvePath($location);
+ }
+
+ /**
+ * Prepend a location to the finder.
+ */
+ public function prependLocation(string $location): void
+ {
+ array_unshift($this->paths, $this->resolvePath($location));
+ }
+
+ /**
+ * Resolve the path.
+ */
+ protected function resolvePath(string $path): string
+ {
+ return realpath($path) ?: $path;
+ }
+
+ /**
+ * Add a namespace hint to the finder.
+ */
+ public function addNamespace(string $namespace, string|array $hints): void
+ {
+ $hints = (array) $hints;
+
+ if (isset($this->hints[$namespace])) {
+ $hints = array_merge($this->hints[$namespace], $hints);
+ }
+
+ $this->hints[$namespace] = $hints;
+ }
+
+ /**
+ * Prepend a namespace hint to the finder.
+ */
+ public function prependNamespace(string $namespace, string|array $hints): void
+ {
+ $hints = (array) $hints;
+
+ if (isset($this->hints[$namespace])) {
+ $hints = array_merge($hints, $this->hints[$namespace]);
+ }
+
+ $this->hints[$namespace] = $hints;
+ }
+
+ /**
+ * Replace the namespace hints for the given namespace.
+ */
+ public function replaceNamespace(string $namespace, string|array $hints): void
+ {
+ $this->hints[$namespace] = (array) $hints;
+ }
+
+ /**
+ * Register an extension with the view finder.
+ */
+ public function addExtension(string $extension): void
+ {
+ if (($index = array_search($extension, $this->extensions)) !== false) {
+ unset($this->extensions[$index]);
+ }
+
+ array_unshift($this->extensions, $extension);
+ }
+
+ /**
+ * Returns whether or not the view name has any hint information.
+ */
+ public function hasHintInformation(string $name): bool
+ {
+ return strpos($name, static::HINT_PATH_DELIMITER) > 0;
+ }
+
+ /**
+ * Flush the cache of located views.
+ */
+ public function flush(): void
+ {
+ $this->views = [];
+ }
+
+ /**
+ * Get the filesystem instance.
+ */
+ public function getFilesystem(): Filesystem
+ {
+ return $this->files;
+ }
+
+ /**
+ * Set the active view paths.
+ */
+ public function setPaths(array $paths): static
+ {
+ $this->paths = $paths;
+
+ return $this;
+ }
+
+ /**
+ * Get the active view paths.
+ */
+ public function getPaths(): array
+ {
+ return $this->paths;
+ }
+
+ /**
+ * Get the views that have been located.
+ */
+ public function getViews(): array
+ {
+ return $this->views;
+ }
+
+ /**
+ * Get the namespace to file path hints.
+ */
+ public function getHints(): array
+ {
+ return $this->hints;
+ }
+
+ /**
+ * Get registered extensions.
+ */
+ public function getExtensions(): array
+ {
+ return $this->extensions;
+ }
+}
diff --git a/src/view/src/InvokableComponentVariable.php b/src/view/src/InvokableComponentVariable.php
new file mode 100644
index 000000000..e1557c451
--- /dev/null
+++ b/src/view/src/InvokableComponentVariable.php
@@ -0,0 +1,75 @@
+__invoke();
+ }
+
+ /**
+ * Get an iterator instance for the variable.
+ */
+ public function getIterator(): Traversable
+ {
+ $result = $this->__invoke();
+
+ return new ArrayIterator($result instanceof Enumerable ? $result->all() : $result);
+ }
+
+ /**
+ * Dynamically proxy attribute access to the variable.
+ */
+ public function __get(string $key): mixed
+ {
+ return $this->__invoke()->{$key};
+ }
+
+ /**
+ * Dynamically proxy method access to the variable.
+ */
+ public function __call(string $method, array $parameters): mixed
+ {
+ return $this->__invoke()->{$method}(...$parameters);
+ }
+
+ /**
+ * Resolve the variable.
+ */
+ public function __invoke(): mixed
+ {
+ return call_user_func($this->callable);
+ }
+
+ /**
+ * Resolve the variable as a string.
+ */
+ public function __toString(): string
+ {
+ return (string) $this->__invoke();
+ }
+}
diff --git a/src/view/src/LICENSE.md b/src/view/src/LICENSE.md
new file mode 100644
index 000000000..79810c848
--- /dev/null
+++ b/src/view/src/LICENSE.md
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) Taylor Otwell
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/src/view/src/Middleware/ShareErrorsFromSession.php b/src/view/src/Middleware/ShareErrorsFromSession.php
new file mode 100644
index 000000000..41701f51e
--- /dev/null
+++ b/src/view/src/Middleware/ShareErrorsFromSession.php
@@ -0,0 +1,42 @@
+view->share(
+ 'errors',
+ $this->session->get('errors') ?: new ViewErrorBag()
+ );
+
+ // Putting the errors in the view for every view allows the developer to just
+ // assume that some errors are always available, which is convenient since
+ // they don't have to continually run checks for the presence of errors.
+
+ return $next($request);
+ }
+}
diff --git a/src/core/src/View/Middleware/ValidationExceptionHandle.php b/src/view/src/Middleware/ValidationExceptionHandle.php
similarity index 88%
rename from src/core/src/View/Middleware/ValidationExceptionHandle.php
rename to src/view/src/Middleware/ValidationExceptionHandle.php
index 0dfbf8958..a016a4a07 100644
--- a/src/core/src/View/Middleware/ValidationExceptionHandle.php
+++ b/src/view/src/Middleware/ValidationExceptionHandle.php
@@ -4,13 +4,13 @@
namespace Hypervel\View\Middleware;
-use Hyperf\Contract\MessageBag as MessageBagContract;
-use Hyperf\Contract\MessageProvider;
-use Hyperf\Support\MessageBag;
-use Hyperf\ViewEngine\Contract\FactoryInterface;
-use Hyperf\ViewEngine\ViewErrorBag;
use Hypervel\Session\Contracts\Session as SessionContract;
+use Hypervel\Support\Contracts\MessageBag as MessageBagContract;
+use Hypervel\Support\Contracts\MessageProvider;
+use Hypervel\Support\MessageBag;
+use Hypervel\Support\ViewErrorBag;
use Hypervel\Validation\ValidationException;
+use Hypervel\View\Contracts\Factory as FactoryContract;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
@@ -23,7 +23,7 @@ class ValidationExceptionHandle implements MiddlewareInterface
public function __construct(
protected ContainerInterface $container,
protected SessionContract $session,
- protected FactoryInterface $view,
+ protected FactoryContract $view,
protected ResponseInterface $response
) {
}
diff --git a/src/view/src/View.php b/src/view/src/View.php
new file mode 100755
index 000000000..f6603c918
--- /dev/null
+++ b/src/view/src/View.php
@@ -0,0 +1,420 @@
+data = $data instanceof Arrayable ? $data->toArray() : (array) $data;
+ }
+
+ /**
+ * Get the evaluated contents of a given fragment.
+ */
+ public function fragment(string $fragment): HtmlString
+ {
+ $content = $this->doRender(function () use ($fragment) {
+ return $this->factory->getFragment($fragment);
+ });
+
+ return new HtmlString($content);
+ }
+
+ /**
+ * Get the evaluated contents for a given array of fragments or return all fragments.
+ */
+ public function fragments(?array $fragments = null): string
+ {
+ return is_null($fragments)
+ ? $this->allFragments()
+ : (new Collection($fragments))->map(fn ($f) => $this->fragment($f))->implode('');
+ }
+
+ /**
+ * Get the evaluated contents of a given fragment if the given condition is true.
+ */
+ public function fragmentIf(bool $boolean, string $fragment): string|Htmlable
+ {
+ if (value($boolean)) {
+ return $this->fragment($fragment);
+ }
+
+ return $this->render();
+ }
+
+ /**
+ * Get the evaluated contents for a given array of fragments if the given condition is true.
+ */
+ public function fragmentsIf(bool $boolean, ?array $fragments = null): string
+ {
+ if (value($boolean)) {
+ return $this->fragments($fragments);
+ }
+
+ return $this->render();
+ }
+
+ /**
+ * Get all fragments as a single string.
+ */
+ protected function allFragments(): string
+ {
+ return (new Collection($this->doRender(fn () => $this->factory->getFragments())))->implode('');
+ }
+
+ /**
+ * Get the string contents of the view.
+ *
+ * @throws Throwable
+ */
+ public function render(): string
+ {
+ return $this->doRender();
+ }
+
+ /**
+ * @template TValue
+ *
+ * @param null|callable(View, string): TValue $callback
+ * @return string|TValue
+ * @throws Throwable
+ */
+ protected function doRender(?callable $callback = null): mixed
+ {
+ try {
+ $contents = $this->renderContents();
+
+ $response = isset($callback) ? $callback($this, $contents) : null;
+
+ // Once we have the contents of the view, we will flush the sections if we are
+ // done rendering all views so that there is nothing left hanging over when
+ // another view gets rendered in the future by the application developer.
+ $this->factory->flushStateIfDoneRendering();
+
+ return ! is_null($response) ? $response : $contents;
+ } catch (Throwable $e) {
+ $this->factory->flushState();
+
+ throw $e;
+ }
+ }
+
+ /**
+ * Get the contents of the view instance.
+ */
+ protected function renderContents(): string
+ {
+ // We will keep track of the number of views being rendered so we can flush
+ // the section after the complete rendering operation is done. This will
+ // clear out the sections for any separate views that may be rendered.
+ $this->factory->incrementRender();
+
+ $this->factory->callComposer($this);
+
+ $contents = $this->getContents();
+
+ // Once we've finished rendering the view, we'll decrement the render count
+ // so that each section gets flushed out next time a view is created and
+ // no old sections are staying around in the memory of an environment.
+ $this->factory->decrementRender();
+
+ return $contents;
+ }
+
+ /**
+ * Get the evaluated contents of the view.
+ */
+ protected function getContents(): string
+ {
+ return $this->engine->get($this->path, $this->gatherData());
+ }
+
+ /**
+ * Get the data bound to the view instance.
+ */
+ public function gatherData(): array
+ {
+ $data = array_merge($this->factory->getShared(), $this->data);
+
+ foreach ($data as $key => $value) {
+ if ($value instanceof Renderable) {
+ $data[$key] = $value->render();
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Get the sections of the rendered view.
+ *
+ * This function is similar to render. We need to call `renderContents` function first.
+ * Because sections are only populated during the view rendering process.
+ *
+ * @throws Throwable
+ */
+ public function renderSections(): array
+ {
+ try {
+ $this->renderContents();
+
+ $response = $this->factory->getSections();
+
+ // Once we have the contents of the view, we will flush the sections if we are
+ // done rendering all views so that there is nothing left hanging over when
+ // another view gets rendered in the future by the application developer.
+ $this->factory->flushStateIfDoneRendering();
+
+ return $response;
+ } catch (Throwable $e) {
+ $this->factory->flushState();
+
+ throw $e;
+ }
+ }
+
+ /**
+ * Add a piece of data to the view.
+ */
+ public function with(string|array $key, mixed $value = null): static
+ {
+ if (is_array($key)) {
+ $this->data = array_merge($this->data, $key);
+ } else {
+ $this->data[$key] = $value;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add a view instance to the view data.
+ */
+ public function nest(string $key, string $view, array $data = []): static
+ {
+ return $this->with($key, $this->factory->make($view, $data));
+ }
+
+ /**
+ * Add validation errors to the view.
+ */
+ public function withErrors(MessageProvider|array|string $provider, string $bag = 'default'): static
+ {
+ return $this->with('errors', (new ViewErrorBag())->put(
+ $bag,
+ $this->formatErrors($provider)
+ ));
+ }
+
+ /**
+ * Parse the given errors into an appropriate value.
+ */
+ protected function formatErrors(MessageProvider|array|string $provider): MessageBagContract
+ {
+ return $provider instanceof MessageProvider
+ ? $provider->getMessageBag()
+ : new MessageBag((array) $provider);
+ }
+
+ /**
+ * Get the name of the view.
+ */
+ public function name(): string
+ {
+ return $this->getName();
+ }
+
+ /**
+ * Get the name of the view.
+ */
+ public function getName(): string
+ {
+ return $this->view;
+ }
+
+ /**
+ * Get the array of view data.
+ */
+ public function getData(): array
+ {
+ return $this->data;
+ }
+
+ /**
+ * Get the path to the view file.
+ */
+ public function getPath(): string
+ {
+ return $this->path;
+ }
+
+ /**
+ * Set the path to the view.
+ */
+ public function setPath(string $path): void
+ {
+ $this->path = $path;
+ }
+
+ /**
+ * Get the view factory instance.
+ */
+ public function getFactory(): Factory
+ {
+ return $this->factory;
+ }
+
+ /**
+ * Get the view's rendering engine.
+ */
+ public function getEngine(): Engine
+ {
+ return $this->engine;
+ }
+
+ /**
+ * Determine if a piece of data is bound.
+ */
+ public function offsetExists(mixed $key): bool
+ {
+ return array_key_exists($key, $this->data);
+ }
+
+ /**
+ * Get a piece of bound data to the view.
+ */
+ public function offsetGet(mixed $key): mixed
+ {
+ return $this->data[$key];
+ }
+
+ /**
+ * Set a piece of data on the view.
+ */
+ public function offsetSet(mixed $key, mixed $value): void
+ {
+ $this->with($key, $value);
+ }
+
+ /**
+ * Unset a piece of data from the view.
+ */
+ public function offsetUnset(mixed $key): void
+ {
+ unset($this->data[$key]);
+ }
+
+ /**
+ * Get a piece of data from the view.
+ */
+ public function &__get(string $key): mixed
+ {
+ return $this->data[$key];
+ }
+
+ /**
+ * Set a piece of data on the view.
+ */
+ public function __set(string $key, mixed $value): void
+ {
+ $this->with($key, $value);
+ }
+
+ /**
+ * Check if a piece of data is bound to the view.
+ */
+ public function __isset(string $key): bool
+ {
+ return isset($this->data[$key]);
+ }
+
+ /**
+ * Remove a piece of bound data from the view.
+ */
+ public function __unset(string $key): void
+ {
+ unset($this->data[$key]);
+ }
+
+ /**
+ * Dynamically bind parameters to the view.
+ *
+ * @return static
+ *
+ * @throws BadMethodCallException
+ */
+ public function __call(string $method, array $parameters): mixed
+ {
+ if (static::hasMacro($method)) {
+ return $this->macroCall($method, $parameters);
+ }
+
+ if (! str_starts_with($method, 'with')) {
+ throw new BadMethodCallException(sprintf(
+ 'Method %s::%s does not exist.',
+ static::class,
+ $method
+ ));
+ }
+
+ return $this->with(Str::camel(substr($method, 4)), $parameters[0]);
+ }
+
+ /**
+ * Get content as a string of HTML.
+ *
+ * @throws Throwable
+ */
+ public function toHtml(): string
+ {
+ return $this->render();
+ }
+
+ /**
+ * Get the string contents of the view.
+ *
+ * @throws Throwable
+ */
+ public function __toString(): string
+ {
+ return $this->render();
+ }
+}
diff --git a/src/view/src/ViewException.php b/src/view/src/ViewException.php
new file mode 100644
index 000000000..52a110517
--- /dev/null
+++ b/src/view/src/ViewException.php
@@ -0,0 +1,42 @@
+getPrevious();
+
+ if (Reflector::isCallable($reportCallable = [$exception, 'report'])) {
+ return Container::getInstance()->call($reportCallable);
+ }
+
+ return false;
+ }
+
+ /**
+ * Render the exception into an HTTP response.
+ */
+ public function render(RequestInterface $request): ?ResponseInterface
+ {
+ $exception = $this->getPrevious();
+
+ if ($exception && method_exists($exception, 'render')) {
+ return $exception->render($request);
+ }
+
+ return null;
+ }
+}
diff --git a/src/view/src/ViewFinderInterface.php b/src/view/src/ViewFinderInterface.php
new file mode 100755
index 000000000..f2dd98eae
--- /dev/null
+++ b/src/view/src/ViewFinderInterface.php
@@ -0,0 +1,53 @@
+registerFactory();
+ $this->registerViewFinder();
+ $this->registerBladeCompiler();
+ $this->registerEngineResolver();
+ }
+
+ /**
+ * Register the view environment.
+ */
+ protected function registerFactory(): void
+ {
+ $this->app->bind(FactoryContract::class, function ($app) {
+ // Next we need to grab the engine resolver instance that will be used by the
+ // environment. The resolver will be used by an environment to get each of
+ // the various engine implementations such as plain PHP or Blade engine.
+ $resolver = $app->get(EngineResolver::class);
+
+ $finder = $app->get(ViewFinderInterface::class);
+
+ $factory = $this->createFactory($resolver, $finder, $app['events']);
+
+ // We will also set the container instance on this view environment since the
+ // view composers may be classes registered in the container, which allows
+ // for great testable, flexible composers for the application developer.
+ $factory->setContainer($app);
+
+ $factory->share('app', $app);
+
+ return $factory;
+ });
+ }
+
+ /**
+ * Create a new Factory Instance.
+ */
+ protected function createFactory(EngineResolver $resolver, ViewFinderInterface $finder, Dispatcher $events): Factory
+ {
+ return new Factory($resolver, $finder, $events);
+ }
+
+ /**
+ * Register the view finder implementation.
+ */
+ protected function registerViewFinder(): void
+ {
+ $this->app->bind(ViewFinderInterface::class, function ($app) {
+ return new FileViewFinder($app['files'], $app['config']['view.paths']);
+ });
+ }
+
+ /**
+ * Register the Blade compiler implementation.
+ */
+ protected function registerBladeCompiler(): void
+ {
+ $this->app->bind(CompilerInterface::class, function ($app) {
+ return tap(new BladeCompiler(
+ $app['files'],
+ $app['config']['view.compiled'],
+ $app['config']->get('view.relative_hash', false) ? $app->basePath() : '',
+ $app['config']->get('view.cache', true),
+ $app['config']->get('view.compiled_extension', 'php'),
+ ), function ($blade) {
+ $blade->component('dynamic-component', DynamicComponent::class);
+ });
+ });
+ }
+
+ /**
+ * Register the engine resolver instance.
+ */
+ protected function registerEngineResolver(): void
+ {
+ $this->app->bind(EngineResolver::class, function () {
+ $resolver = new EngineResolver();
+
+ // Next, we will register the various view engines with the resolver so that the
+ // environment will resolve the engines needed for various views based on the
+ // extension of view file. We call a method for each of the view's engines.
+ foreach (['file', 'php', 'blade'] as $engine) {
+ $this->{'register' . ucfirst($engine) . 'Engine'}($resolver);
+ }
+
+ return $resolver;
+ });
+ }
+
+ /**
+ * Register the file engine implementation.
+ */
+ protected function registerFileEngine(EngineResolver $resolver): void
+ {
+ $resolver->register('file', function () {
+ return new FileEngine(Container::getInstance()->get('files'));
+ });
+ }
+
+ /**
+ * Register the PHP engine implementation.
+ */
+ protected function registerPhpEngine(EngineResolver $resolver): void
+ {
+ $resolver->register('php', function () {
+ return new PhpEngine(Container::getInstance()->get('files'));
+ });
+ }
+
+ /**
+ * Register the Blade engine implementation.
+ */
+ protected function registerBladeEngine(EngineResolver $resolver): void
+ {
+ $resolver->register('blade', function () {
+ $app = Container::getInstance();
+
+ return new CompilerEngine(
+ $app->get(CompilerInterface::class),
+ $app->get('files'),
+ );
+ });
+ }
+}
diff --git a/tests/Foundation/FoundationExceptionHandlerTest.php b/tests/Foundation/FoundationExceptionHandlerTest.php
index 60f8a3203..be953625e 100644
--- a/tests/Foundation/FoundationExceptionHandlerTest.php
+++ b/tests/Foundation/FoundationExceptionHandlerTest.php
@@ -6,7 +6,6 @@
use Exception;
use Hyperf\Context\Context;
-use Hyperf\Context\RequestContext;
use Hyperf\Context\ResponseContext;
use Hyperf\Contract\ConfigInterface;
use Hyperf\Contract\SessionInterface;
@@ -16,11 +15,9 @@
use Hyperf\HttpMessage\Exception\HttpException;
use Hyperf\HttpMessage\Server\Response as Psr7Response;
use Hyperf\HttpMessage\Uri\Uri;
-use Hyperf\View\RenderInterface;
-use Hyperf\ViewEngine\Contract\FactoryInterface;
-use Hyperf\ViewEngine\ViewErrorBag;
use Hypervel\Config\Repository;
use Hypervel\Context\ApplicationContext;
+use Hypervel\Context\RequestContext;
use Hypervel\Foundation\Exceptions\Handler;
use Hypervel\Http\Contracts\ResponseContract;
use Hypervel\Http\Request;
@@ -29,12 +26,16 @@
use Hypervel\Router\Contracts\UrlGenerator as UrlGeneratorContract;
use Hypervel\Session\Contracts\Session as SessionContract;
use Hypervel\Support\Contracts\Responsable;
+use Hypervel\Support\Facades\Facade;
use Hypervel\Support\Facades\View;
use Hypervel\Support\MessageBag;
+use Hypervel\Support\ViewErrorBag;
use Hypervel\Tests\Foundation\Concerns\HasMockedApplication;
use Hypervel\Tests\TestCase;
use Hypervel\Validation\ValidationException;
use Hypervel\Validation\Validator;
+use Hypervel\View\Contracts\Factory as FactoryContract;
+use Hypervel\View\Contracts\View as ViewContract;
use InvalidArgumentException;
use Mockery as m;
use OutOfRangeException;
@@ -71,7 +72,7 @@ protected function setUp(): void
$this->request = m::mock(Request::class);
$this->container = $this->getApplication([
ConfigInterface::class => fn () => $this->config,
- FactoryInterface::class => fn () => new stdClass(),
+ FactoryContract::class => fn () => new stdClass(),
Request::class => fn () => $this->request,
ServerRequestInterface::class => fn () => m::mock(ServerRequestInterface::class),
ResponseContract::class => fn () => new Response(),
@@ -91,6 +92,7 @@ public function tearDown(): void
parent::tearDown();
Context::destroy('__request.root.uri');
+ Facade::clearResolvedInstances();
}
public function testHandlerReportsExceptionAsContext()
@@ -364,6 +366,8 @@ public function testValidateFailed()
$this->assertSame(302, $response->getStatusCode());
$this->assertSame($redirectTo, $response->getHeaderLine('Location'));
+
+ RequestContext::destroy();
}
public function testModelNotFoundReturns404WithoutReporting()
@@ -385,11 +389,12 @@ public function testModelNotFoundReturns404WithoutReporting()
public function testItReturnsSpecificErrorViewIfExists()
{
- $viewFactory = m::mock(FactoryInterface::class);
+ $viewFactory = m::mock(FactoryContract::class);
$viewFactory->shouldReceive('exists')->with('errors::502')->andReturn(true);
- $this->container->instance(FactoryInterface::class, $viewFactory);
+ $this->container->instance(FactoryContract::class, $viewFactory);
+ View::shouldReceive('replaceNamespace')->once();
$handler = new class($this->container) extends Handler {
public function getErrorView($e)
{
@@ -402,12 +407,13 @@ public function getErrorView($e)
public function testItReturnsFallbackErrorViewIfExists()
{
- $viewFactory = m::mock(FactoryInterface::class);
+ $viewFactory = m::mock(FactoryContract::class);
$viewFactory->shouldReceive('exists')->once()->with('errors::502')->andReturn(false);
$viewFactory->shouldReceive('exists')->once()->with('errors::5xx')->andReturn(true);
- $this->container->instance(FactoryInterface::class, $viewFactory);
+ $this->container->instance(FactoryContract::class, $viewFactory);
+ View::shouldReceive('replaceNamespace')->once();
$handler = new class($this->container) extends Handler {
public function getErrorView($e)
{
@@ -420,12 +426,13 @@ public function getErrorView($e)
public function testItReturnsNullIfNoErrorViewExists()
{
- $viewFactory = m::mock(FactoryInterface::class);
+ $viewFactory = m::mock(FactoryContract::class);
$viewFactory->shouldReceive('exists')->once()->with('errors::404')->andReturn(false);
$viewFactory->shouldReceive('exists')->once()->with('errors::4xx')->andReturn(false);
- $this->container->instance(FactoryInterface::class, $viewFactory);
+ $this->container->instance(FactoryContract::class, $viewFactory);
+ View::shouldReceive('replaceNamespace')->once();
$handler = new class($this->container) extends Handler {
public function getErrorView($e)
{
@@ -441,14 +448,12 @@ public function testItDoesNotCrashIfErrorViewThrowsWhileRenderingAndDebugTrue()
// When debug is true, it is OK to bubble the exception thrown while rendering
// the error view as the debug handler should handle this gracefully.
- $viewFactory = m::mock(FactoryInterface::class);
+ $view = m::mock(ViewContract::class);
+ $view->shouldReceive('render')->once()->withAnyArgs()->andThrow(new Exception('Rendering this view throws an exception'));
+ $viewFactory = m::mock(FactoryContract::class);
$viewFactory->shouldReceive('exists')->once()->with('errors::404')->andReturn(true);
-
- $this->container->instance(FactoryInterface::class, $viewFactory);
-
- $renderer = m::mock(RenderInterface::class);
- $renderer->shouldReceive('render')->once()->withAnyArgs()->andThrow(new Exception('Rendering this view throws an exception'));
- $this->container->instance(RenderInterface::class, $renderer);
+ $viewFactory->shouldReceive('make')->once()->with('errors::404', m::any())->andReturn($view);
+ $this->container->instance(FactoryContract::class, $viewFactory);
$this->config->set('app.debug', true);
$handler = new class($this->container) extends Handler {
diff --git a/tests/Foundation/FoundationViteTest.php b/tests/Foundation/FoundationViteTest.php
new file mode 100644
index 000000000..b61253f97
--- /dev/null
+++ b/tests/Foundation/FoundationViteTest.php
@@ -0,0 +1,1757 @@
+set('app.url', 'https://example.com');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->cleanViteManifest();
+ $this->cleanViteHotFile();
+ Context::set('hypervel.vite.nonce', null);
+ Context::set('hypervel.vite.preloaded_assets', []);
+ Context::destroy('__request.root.uri');
+
+ parent::tearDown();
+ }
+
+ public function testViteWithJsOnly()
+ {
+ $this->makeViteManifest();
+
+ $result = app(Vite::class)('resources/js/app.js');
+
+ $this->assertStringEndsWith('', $result->toHtml());
+ }
+
+ public function testViteWithCssAndJs()
+ {
+ $this->makeViteManifest();
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testViteWithCssImport()
+ {
+ $this->makeViteManifest();
+
+ $result = app(Vite::class)('resources/js/app-with-css-import.js');
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testViteWithSharedCssImport()
+ {
+ $this->makeViteManifest();
+
+ $result = app(Vite::class)(['resources/js/app-with-shared-css.js']);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testViteHotModuleReplacementWithJsOnly()
+ {
+ $this->makeViteHotFile();
+
+ $result = app(Vite::class)('resources/js/app.js');
+
+ $this->assertSame(
+ ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testViteHotModuleReplacementWithJsAndCss()
+ {
+ $this->makeViteHotFile();
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame(
+ ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testItCanGenerateCspNonceWithHotFile()
+ {
+ Str::createRandomStringsUsing(fn ($length) => "random-string-with-length:{$length}");
+ $this->makeViteHotFile();
+
+ $nonce = ViteFacade::useCspNonce();
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame('random-string-with-length:40', $nonce);
+ $this->assertSame('random-string-with-length:40', ViteFacade::cspNonce());
+ $this->assertSame(
+ ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+
+ Str::createRandomStringsNormally();
+ }
+
+ public function testItCanGenerateCspNonceWithManifest()
+ {
+ Str::createRandomStringsUsing(fn ($length) => "random-string-with-length:{$length}");
+ $this->makeViteManifest();
+
+ $nonce = ViteFacade::useCspNonce();
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame('random-string-with-length:40', $nonce);
+ $this->assertSame('random-string-with-length:40', ViteFacade::cspNonce());
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ $result->toHtml()
+ );
+
+ Str::createRandomStringsNormally();
+ }
+
+ public function testItCanSpecifyCspNonceWithHotFile()
+ {
+ $this->makeViteHotFile();
+
+ $nonce = ViteFacade::useCspNonce('expected-nonce');
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame('expected-nonce', $nonce);
+ $this->assertSame('expected-nonce', ViteFacade::cspNonce());
+ $this->assertSame(
+ ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testItCanSpecifyCspNonceWithManifest()
+ {
+ $this->makeViteManifest();
+
+ $nonce = ViteFacade::useCspNonce('expected-nonce');
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame('expected-nonce', $nonce);
+ $this->assertSame('expected-nonce', ViteFacade::cspNonce());
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testReactRefreshWithNoNonce()
+ {
+ $this->makeViteHotFile();
+
+ $result = (string) app(Vite::class)->reactRefresh();
+
+ $this->assertStringNotContainsString('nonce', $result);
+ }
+
+ public function testReactRefreshNonce()
+ {
+ $this->makeViteHotFile();
+
+ $nonce = ViteFacade::useCspNonce('expected-nonce');
+ $result = (string) app(Vite::class)->reactRefresh();
+
+ $this->assertStringContainsString(sprintf('nonce="%s"', $nonce), $result);
+ }
+
+ public function testItCanInjectIntegrityWhenPresentInManifest()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'integrity' => 'expected-app.js-integrity',
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ 'integrity' => 'expected-app.css-integrity',
+ ],
+ ], $buildDir);
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js'], $buildDir);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ $result->toHtml()
+ );
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItCanInjectIntegrityWhenPresentInManifestForCss()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'css' => [
+ 'assets/direct-css-dependency.aabbcc.css',
+ ],
+ '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/direct-css-dependency.aabbcc.css',
+ 'integrity' => 'expected-imported-css.css-integrity',
+ ],
+ ], $buildDir);
+
+ $result = app(Vite::class)('resources/js/app.js', $buildDir);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ $result->toHtml()
+ );
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItCanInjectIntegrityWhenPresentInManifestForImportedCss()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'src' => '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 = app(Vite::class)('resources/js/app.js', $buildDir);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ $result->toHtml()
+ );
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItCanSpecifyIntegrityKey()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'different-integrity-key' => 'expected-app.js-integrity',
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ 'different-integrity-key' => 'expected-app.css-integrity',
+ ],
+ ], $buildDir);
+ ViteFacade::useIntegrityKey('different-integrity-key');
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js'], $buildDir);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ $result->toHtml()
+ );
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItCanSpecifyArbitraryAttributesForScriptTagsWhenBuilt()
+ {
+ $this->makeViteManifest();
+ ViteFacade::useScriptTagAttributes([
+ 'general' => 'attribute',
+ ]);
+ ViteFacade::useScriptTagAttributes(function ($src, $url, $chunk, $manifest) {
+ $this->assertSame('resources/js/app.js', $src);
+ $this->assertSame('https://example.com/build/assets/app.versioned.js', $url);
+ $this->assertSame([
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ ], $chunk);
+ $this->assertSame([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ ],
+ 'resources/js/app-with-css-import.js' => [
+ 'src' => 'resources/js/app-with-css-import.js',
+ 'file' => 'assets/app-with-css-import.versioned.js',
+ 'css' => [
+ 'assets/imported-css.versioned.css',
+ ],
+ ],
+ 'resources/css/imported-css.css' => [
+ 'file' => 'assets/imported-css.versioned.css',
+ ],
+ 'resources/js/app-with-shared-css.js' => [
+ 'src' => 'resources/js/app-with-shared-css.js',
+ 'file' => 'assets/app-with-shared-css.versioned.js',
+ 'imports' => [
+ '_someFile.js',
+ ],
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ],
+ '_someFile.js' => [
+ 'file' => 'assets/someFile.versioned.js',
+ 'css' => [
+ 'assets/shared-css.versioned.css',
+ ],
+ ],
+ 'resources/css/shared-css' => [
+ 'src' => 'resources/css/shared-css',
+ 'file' => 'assets/shared-css.versioned.css',
+ ],
+ ], $manifest);
+
+ return [
+ 'crossorigin',
+ 'data-persistent-across-pages' => 'YES',
+ 'remove-me' => false,
+ 'keep-me' => true,
+ 'null' => null,
+ 'empty-string' => '',
+ 'zero' => 0,
+ ];
+ });
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testItCanSpecifyArbitraryAttributesForStylesheetTagsWhenBuild()
+ {
+ $this->makeViteManifest();
+ ViteFacade::useStyleTagAttributes([
+ 'general' => 'attribute',
+ ]);
+ ViteFacade::useStyleTagAttributes(function ($src, $url, $chunk, $manifest) {
+ $this->assertSame('resources/css/app.css', $src);
+ $this->assertSame('https://example.com/build/assets/app.versioned.css', $url);
+ $this->assertSame([
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ], $chunk);
+ $this->assertSame([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ ],
+ 'resources/js/app-with-css-import.js' => [
+ 'src' => 'resources/js/app-with-css-import.js',
+ 'file' => 'assets/app-with-css-import.versioned.js',
+ 'css' => [
+ 'assets/imported-css.versioned.css',
+ ],
+ ],
+ 'resources/css/imported-css.css' => [
+ 'file' => 'assets/imported-css.versioned.css',
+ ],
+ 'resources/js/app-with-shared-css.js' => [
+ 'src' => 'resources/js/app-with-shared-css.js',
+ 'file' => 'assets/app-with-shared-css.versioned.js',
+ 'imports' => [
+ '_someFile.js',
+ ],
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ],
+ '_someFile.js' => [
+ 'file' => 'assets/someFile.versioned.js',
+ 'css' => [
+ 'assets/shared-css.versioned.css',
+ ],
+ ],
+ 'resources/css/shared-css' => [
+ 'src' => 'resources/css/shared-css',
+ 'file' => 'assets/shared-css.versioned.css',
+ ],
+ ], $manifest);
+
+ return [
+ 'crossorigin',
+ 'data-persistent-across-pages' => 'YES',
+ 'remove-me' => false,
+ 'keep-me' => true,
+ ];
+ });
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testItCanSpecifyArbitraryAttributesForScriptTagsWhenHotModuleReloading()
+ {
+ $this->makeViteHotFile();
+ ViteFacade::useScriptTagAttributes([
+ 'general' => 'attribute',
+ ]);
+ $expectedArguments = [
+ ['src' => '@vite/client', 'url' => 'http://localhost:3000/@vite/client'],
+ ['src' => 'resources/js/app.js', 'url' => 'http://localhost:3000/resources/js/app.js'],
+ ];
+ ViteFacade::useScriptTagAttributes(function ($src, $url, $chunk, $manifest) use (&$expectedArguments) {
+ $args = array_shift($expectedArguments);
+
+ $this->assertSame($args['src'], $src);
+ $this->assertSame($args['url'], $url);
+ $this->assertNull($chunk);
+ $this->assertNull($manifest);
+
+ return [
+ 'crossorigin',
+ 'data-persistent-across-pages' => 'YES',
+ 'remove-me' => false,
+ 'keep-me' => true,
+ ];
+ });
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame(
+ ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testItCanSpecifyArbitraryAttributesForStylesheetTagsWhenHotModuleReloading()
+ {
+ $this->makeViteHotFile();
+ ViteFacade::useStyleTagAttributes([
+ 'general' => 'attribute',
+ ]);
+ ViteFacade::useStyleTagAttributes(function ($src, $url, $chunk, $manifest) {
+ $this->assertSame('resources/css/app.css', $src);
+ $this->assertSame('http://localhost:3000/resources/css/app.css', $url);
+ $this->assertNull($chunk);
+ $this->assertNull($manifest);
+
+ return [
+ 'crossorigin',
+ 'data-persistent-across-pages' => 'YES',
+ 'remove-me' => false,
+ 'keep-me' => true,
+ ];
+ });
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertSame(
+ ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testItCanOverrideAllAttributes()
+ {
+ $this->makeViteManifest();
+ ViteFacade::useStyleTagAttributes([
+ 'rel' => 'expected-rel',
+ 'href' => 'expected-href',
+ ]);
+ ViteFacade::useScriptTagAttributes([
+ 'type' => 'expected-type',
+ 'src' => 'expected-src',
+ ]);
+
+ $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);
+
+ $this->assertStringEndsWith(
+ ''
+ . '',
+ $result->toHtml()
+ );
+ }
+
+ public function testItCanGenerateIndividualAssetUrlInBuildMode()
+ {
+ $this->makeViteManifest();
+
+ $url = ViteFacade::asset('resources/js/app.js');
+
+ $this->assertSame('https://example.com/build/assets/app.versioned.js', $url);
+ }
+
+ public function testItCanGenerateIndividualAssetUrlInHotMode()
+ {
+ $this->makeViteHotFile();
+
+ $url = ViteFacade::asset('resources/js/app.js');
+
+ $this->assertSame('http://localhost:3000/resources/js/app.js', $url);
+ }
+
+ public function testItThrowsWhenUnableToFindAssetManifestInBuildMode()
+ {
+ $this->expectException(ViteException::class);
+ $this->expectExceptionMessage('Vite manifest not found at: ' . public_path('build/manifest.json'));
+
+ ViteFacade::asset('resources/js/app.js');
+ }
+
+ public function testItThrowsDeprecatedExecptionWhenUnableToFindAssetManifestInBuildMode()
+ {
+ $this->expectException(ViteException::class);
+ $this->expectExceptionMessage('Vite manifest not found at: ' . public_path('build/manifest.json'));
+
+ ViteFacade::asset('resources/js/app.js');
+ }
+
+ public function testItThrowsWhenUnableToFindAssetChunkInBuildMode()
+ {
+ $this->makeViteManifest();
+
+ $this->expectException(ViteException::class);
+ $this->expectExceptionMessage('Unable to locate file in Vite manifest: resources/js/missing.js');
+
+ ViteFacade::asset('resources/js/missing.js');
+ }
+
+ public function testItDoesNotReturnHashInDevMode()
+ {
+ $this->makeViteHotFile();
+
+ $this->assertNull(ViteFacade::manifestHash());
+
+ $this->cleanViteHotFile();
+ }
+
+ public function testItGetsHashInBuildMode()
+ {
+ $this->makeViteManifest(['a.js' => ['src' => 'a.js']]);
+
+ $this->assertSame('98ca5a789544599b562c9978f3147a0f', ViteFacade::manifestHash());
+
+ $this->cleanViteManifest();
+ }
+
+ public function testItGetsDifferentHashesForDifferentManifestsInBuildMode()
+ {
+ $this->makeViteManifest(['a.js' => ['src' => 'a.js']]);
+ $this->makeViteManifest(['b.js' => ['src' => 'b.js']], 'admin');
+
+ $this->assertSame('98ca5a789544599b562c9978f3147a0f', ViteFacade::manifestHash());
+ $this->assertSame('928a60835978bae84e5381fbb08a38b2', ViteFacade::manifestHash('admin'));
+
+ $this->cleanViteManifest();
+ $this->cleanViteManifest('admin');
+ }
+
+ public function testViteCanSetEntryPointsWithFluentBuilder()
+ {
+ $this->makeViteManifest();
+
+ $vite = app(Vite::class);
+
+ $this->assertSame('', $vite->toHtml());
+
+ $vite->withEntryPoints(['resources/js/app.js']);
+
+ $this->assertStringEndsWith(
+ '',
+ $vite->toHtml()
+ );
+ }
+
+ public function testViteCanOverrideBuildDirectory()
+ {
+ $this->makeViteManifest(null, 'custom-build');
+
+ $vite = app(Vite::class);
+
+ $vite->withEntryPoints(['resources/js/app.js'])->useBuildDirectory('custom-build');
+
+ $this->assertStringEndsWith(
+ '',
+ $vite->toHtml()
+ );
+
+ $this->cleanViteManifest('custom-build');
+ }
+
+ public function testViteCanOverrideHotFilePath()
+ {
+ $this->makeViteHotFile('cold');
+
+ $vite = app(Vite::class);
+
+ $vite->withEntryPoints(['resources/js/app.js'])->useHotFile('cold');
+
+ $this->assertSame(
+ ''
+ . '',
+ $vite->toHtml()
+ );
+
+ $this->cleanViteHotFile('cold');
+ }
+
+ public function testViteCanAssetPath()
+ {
+ $this->makeViteManifest([
+ 'resources/images/profile.png' => [
+ 'src' => 'resources/images/profile.png',
+ 'file' => 'assets/profile.versioned.png',
+ ],
+ ], $buildDir = Str::random());
+ $vite = app(Vite::class)->useBuildDirectory($buildDir);
+ $this->app['config']->set('app.url', 'https://cdn.app.com');
+
+ // default behaviour...
+ $this->assertSame("https://cdn.app.com/{$buildDir}/assets/profile.versioned.png", $vite->asset('resources/images/profile.png'));
+
+ // custom behaviour
+ $vite->createAssetPathsUsing(function ($path) {
+ return 'https://tenant-cdn.app.com/' . $path;
+ });
+ $this->assertSame("https://tenant-cdn.app.com/{$buildDir}/assets/profile.versioned.png", $vite->asset('resources/images/profile.png'));
+
+ // restore default behaviour...
+ $vite->createAssetPathsUsing(null);
+ $this->assertSame("https://cdn.app.com/{$buildDir}/assets/profile.versioned.png", $vite->asset('resources/images/profile.png'));
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testViteIsMacroable()
+ {
+ $this->makeViteManifest([
+ 'resources/images/profile.png' => [
+ 'src' => 'resources/images/profile.png',
+ 'file' => 'assets/profile.versioned.png',
+ ],
+ ], $buildDir = Str::random());
+ Vite::macro('image', function ($asset, $buildDir = null) {
+ return $this->asset("resources/images/{$asset}", $buildDir);
+ });
+
+ $path = ViteFacade::image('profile.png', $buildDir);
+
+ $this->assertSame("https://example.com/{$buildDir}/assets/profile.versioned.png", $path);
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItGeneratesPreloadDirectivesForJsAndCssImports()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/jetstream-manifest.json'));
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+
+ $result = app(Vite::class)(['resources/js/Pages/Auth/Login.vue'], $buildDir);
+
+ $this->assertSame(
+ ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+ $this->assertSame([
+ 'https://example.com/' . $buildDir . '/assets/app.9842b564.css' => [
+ 'rel="preload"',
+ 'as="style"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/Login.8c52c4a3.js' => [
+ 'rel="modulepreload"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/app.a26d8e4d.js' => [
+ 'rel="modulepreload"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/AuthenticationCard.47ef70cc.js' => [
+ 'rel="modulepreload"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/AuthenticationCardLogo.9999a373.js' => [
+ 'rel="modulepreload"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/Checkbox.33ba23f3.js' => [
+ 'rel="modulepreload"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/TextInput.e2f0248c.js' => [
+ 'rel="modulepreload"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/InputLabel.d245ec4e.js' => [
+ 'rel="modulepreload"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/PrimaryButton.931d2859.js' => [
+ 'rel="modulepreload"',
+ ],
+ 'https://example.com/' . $buildDir . '/assets/_plugin-vue_export-helper.cdc0426e.js' => [
+ 'rel="modulepreload"',
+ ],
+ ], ViteFacade::preloadedAssets());
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItCanSpecifyAttributesForPreloadedAssets()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'imports' => [
+ 'import.js',
+ ],
+ 'css' => [
+ 'assets/app.versioned.css',
+ ],
+ ],
+ 'import.js' => [
+ 'file' => 'assets/import.versioned.js',
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ],
+ ], $buildDir);
+ ViteFacade::usePreloadTagAttributes([
+ 'general' => 'attribute',
+ ]);
+ ViteFacade::usePreloadTagAttributes(function ($src, $url, $chunk, $manifest) use ($buildDir) {
+ $this->assertSame([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'imports' => [
+ 'import.js',
+ ],
+ 'css' => [
+ 'assets/app.versioned.css',
+ ],
+ ],
+ 'import.js' => [
+ 'file' => 'assets/import.versioned.js',
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ],
+ ], $manifest);
+
+ (match ($src) {
+ 'resources/js/app.js' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/app.versioned.js", $url);
+ $this->assertSame([
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'imports' => [
+ 'import.js',
+ ],
+ 'css' => [
+ 'assets/app.versioned.css',
+ ],
+ ], $chunk);
+ },
+ 'import.js' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/import.versioned.js", $url);
+ $this->assertSame([
+ 'file' => 'assets/import.versioned.js',
+ ], $chunk);
+ },
+ 'resources/css/app.css' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/app.versioned.css", $url);
+ $this->assertSame([
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ], $chunk);
+ },
+ })();
+
+ return [
+ 'crossorigin',
+ 'data-persistent-across-pages' => 'YES',
+ 'remove-me' => false,
+ 'keep-me' => true,
+ 'null' => null,
+ 'empty-string' => '',
+ 'zero' => 0,
+ ];
+ });
+
+ $result = app(Vite::class)(['resources/js/app.js'], $buildDir);
+
+ $this->assertSame(
+ ''
+ . ''
+ . ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+
+ $this->assertSame([
+ "https://example.com/{$buildDir}/assets/app.versioned.css" => [
+ 'rel="preload"',
+ 'as="style"',
+ 'general="attribute"',
+ 'crossorigin',
+ 'data-persistent-across-pages="YES"',
+ 'keep-me',
+ 'empty-string=""',
+ 'zero="0"',
+ ],
+ "https://example.com/{$buildDir}/assets/app.versioned.js" => [
+ 'rel="modulepreload"',
+ 'general="attribute"',
+ 'crossorigin',
+ 'data-persistent-across-pages="YES"',
+ 'keep-me',
+ 'empty-string=""',
+ 'zero="0"',
+ ],
+ "https://example.com/{$buildDir}/assets/import.versioned.js" => [
+ 'rel="modulepreload"',
+ 'general="attribute"',
+ 'crossorigin',
+ 'data-persistent-across-pages="YES"',
+ 'keep-me',
+ 'empty-string=""',
+ 'zero="0"',
+ ],
+ ], ViteFacade::preloadedAssets());
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItCanSuppressPreloadTagGeneration()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'imports' => [
+ 'import.js',
+ 'import-nopreload.js',
+ ],
+ 'css' => [
+ 'assets/app.versioned.css',
+ 'assets/app-nopreload.versioned.css',
+ ],
+ ],
+ 'resources/js/app-nopreload.js' => [
+ 'src' => 'resources/js/app-nopreload.js',
+ 'file' => 'assets/app-nopreload.versioned.js',
+ ],
+ 'import.js' => [
+ 'file' => 'assets/import.versioned.js',
+ ],
+ 'import-nopreload.js' => [
+ 'file' => 'assets/import-nopreload.versioned.js',
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ],
+ 'resources/css/app-nopreload.css' => [
+ 'src' => 'resources/css/app-nopreload.css',
+ 'file' => 'assets/app-nopreload.versioned.css',
+ ],
+ ], $buildDir);
+ ViteFacade::usePreloadTagAttributes(function ($src, $url, $chunk, $manifest) use ($buildDir) {
+ $this->assertSame([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'imports' => [
+ 'import.js',
+ 'import-nopreload.js',
+ ],
+ 'css' => [
+ 'assets/app.versioned.css',
+ 'assets/app-nopreload.versioned.css',
+ ],
+ ],
+ 'resources/js/app-nopreload.js' => [
+ 'src' => 'resources/js/app-nopreload.js',
+ 'file' => 'assets/app-nopreload.versioned.js',
+ ],
+ 'import.js' => [
+ 'file' => 'assets/import.versioned.js',
+ ],
+ 'import-nopreload.js' => [
+ 'file' => 'assets/import-nopreload.versioned.js',
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ],
+ 'resources/css/app-nopreload.css' => [
+ 'src' => 'resources/css/app-nopreload.css',
+ 'file' => 'assets/app-nopreload.versioned.css',
+ ],
+ ], $manifest);
+
+ (match ($src) {
+ 'resources/js/app.js' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/app.versioned.js", $url);
+ $this->assertSame([
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'imports' => [
+ 'import.js',
+ 'import-nopreload.js',
+ ],
+ 'css' => [
+ 'assets/app.versioned.css',
+ 'assets/app-nopreload.versioned.css',
+ ],
+ ], $chunk);
+ },
+ 'resources/js/app-nopreload.js' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/app-nopreload.versioned.js", $url);
+ $this->assertSame([
+ 'src' => 'resources/js/app-nopreload.js',
+ 'file' => 'assets/app-nopreload.versioned.js',
+ ], $chunk);
+ },
+ 'import.js' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/import.versioned.js", $url);
+ $this->assertSame([
+ 'file' => 'assets/import.versioned.js',
+ ], $chunk);
+ },
+ 'import-nopreload.js' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/import-nopreload.versioned.js", $url);
+ $this->assertSame([
+ 'file' => 'assets/import-nopreload.versioned.js',
+ ], $chunk);
+ },
+ 'resources/css/app.css' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/app.versioned.css", $url);
+ $this->assertSame([
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ], $chunk);
+ },
+ 'resources/css/app-nopreload.css' => function () use ($url, $chunk, $buildDir) {
+ $this->assertSame("https://example.com/{$buildDir}/assets/app-nopreload.versioned.css", $url);
+ $this->assertSame([
+ 'src' => 'resources/css/app-nopreload.css',
+ 'file' => 'assets/app-nopreload.versioned.css',
+ ], $chunk);
+ },
+ })();
+
+ return Str::contains($src, '-nopreload') ? false : [];
+ });
+
+ $result = app(Vite::class)(['resources/js/app.js', 'resources/js/app-nopreload.js'], $buildDir);
+
+ $this->assertSame(
+ ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+
+ $this->assertSame([
+ "https://example.com/{$buildDir}/assets/app.versioned.css" => [
+ 'rel="preload"',
+ 'as="style"',
+ ],
+ "https://example.com/{$buildDir}/assets/app.versioned.js" => [
+ 'rel="modulepreload"',
+ ],
+ "https://example.com/{$buildDir}/assets/import.versioned.js" => [
+ 'rel="modulepreload"',
+ ],
+ ], ViteFacade::preloadedAssets());
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testPreloadAssetsGetAssetNonce()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'css' => [
+ 'assets/app.versioned.css',
+ ],
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ],
+ ], $buildDir);
+ ViteFacade::useCspNonce('expected-nonce');
+
+ $result = app(Vite::class)(['resources/js/app.js'], $buildDir);
+
+ $this->assertSame(
+ ''
+ . ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+
+ $this->assertSame([
+ "https://example.com/{$buildDir}/assets/app.versioned.css" => [
+ 'rel="preload"',
+ 'as="style"',
+ 'nonce="expected-nonce"',
+ ],
+ "https://example.com/{$buildDir}/assets/app.versioned.js" => [
+ 'rel="modulepreload"',
+ 'nonce="expected-nonce"',
+ ],
+ ], ViteFacade::preloadedAssets());
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testCrossoriginAttributeIsInheritedByPreloadTags()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app.js',
+ 'file' => 'assets/app.versioned.js',
+ 'css' => [
+ 'assets/app.versioned.css',
+ ],
+ ],
+ 'resources/css/app.css' => [
+ 'src' => 'resources/css/app.css',
+ 'file' => 'assets/app.versioned.css',
+ ],
+ ], $buildDir);
+ ViteFacade::useScriptTagAttributes([
+ 'crossorigin' => 'script-crossorigin',
+ ]);
+ ViteFacade::useStyleTagAttributes([
+ 'crossorigin' => 'style-crossorigin',
+ ]);
+
+ $result = app(Vite::class)(['resources/js/app.js'], $buildDir);
+
+ $this->assertSame(
+ ''
+ . ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+
+ $this->assertSame([
+ "https://example.com/{$buildDir}/assets/app.versioned.css" => [
+ 'rel="preload"',
+ 'as="style"',
+ 'crossorigin="style-crossorigin"',
+ ],
+ "https://example.com/{$buildDir}/assets/app.versioned.js" => [
+ 'rel="modulepreload"',
+ 'crossorigin="script-crossorigin"',
+ ],
+ ], ViteFacade::preloadedAssets());
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItCanConfigureTheManifestFilename()
+ {
+ $buildDir = Str::random();
+ app()->setBasePath(__DIR__);
+ if (! file_exists(public_path($buildDir))) {
+ mkdir(public_path($buildDir), recursive: true);
+ }
+ $contents = json_encode([
+ 'resources/js/app.js' => [
+ 'src' => 'resources/js/app-from-custom-manifest.js',
+ 'file' => 'assets/app-from-custom-manifest.versioned.js',
+ ],
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ file_put_contents(public_path("{$buildDir}/custom-manifest.json"), $contents);
+
+ ViteFacade::useManifestFilename('custom-manifest.json');
+
+ $result = app(Vite::class)(['resources/js/app.js'], $buildDir);
+
+ $this->assertSame(
+ ''
+ . '',
+ $result->toHtml()
+ );
+
+ unlink(public_path("{$buildDir}/custom-manifest.json"));
+ rmdir(public_path($buildDir));
+ }
+
+ public function testItOnlyOutputsUniquePreloadTags()
+ {
+ $buildDir = Str::random();
+ $this->makeViteManifest([
+ 'resources/js/app.css' => [
+ 'file' => 'assets/app-versioned.css',
+ 'src' => 'resources/js/app.css',
+ ],
+ 'resources/js/Pages/Welcome.vue' => [
+ 'file' => 'assets/Welcome-versioned.js',
+ 'src' => 'resources/js/Pages/Welcome.vue',
+ 'imports' => [
+ 'resources/js/app.js',
+ ],
+ ],
+ 'resources/js/app.js' => [
+ 'file' => 'assets/app-versioned.js',
+ 'src' => 'resources/js/app.js',
+ 'css' => [
+ 'assets/app-versioned.css',
+ ],
+ ],
+ ], $buildDir);
+
+ $result = app(Vite::class)(['resources/js/app.js', 'resources/js/Pages/Welcome.vue'], $buildDir);
+
+ $this->assertSame(
+ ''
+ . ''
+ . ''
+ . ''
+ . ''
+ . '',
+ $result->toHtml()
+ );
+
+ $this->assertSame([
+ "https://example.com/{$buildDir}/assets/app-versioned.css" => [
+ 'rel="preload"',
+ 'as="style"',
+ ],
+ "https://example.com/{$buildDir}/assets/app-versioned.js" => [
+ 'rel="modulepreload"',
+ ],
+ "https://example.com/{$buildDir}/assets/Welcome-versioned.js" => [
+ 'rel="modulepreload"',
+ ],
+ ], ViteFacade::preloadedAssets());
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItRetrievesAssetContent()
+ {
+ $this->makeViteManifest();
+
+ $this->makeAsset('/app.versioned.js', 'some content');
+
+ $content = ViteFacade::content('resources/js/app.js');
+
+ $this->assertSame('some content', $content);
+
+ $this->cleanAsset('/app.versioned.js');
+
+ $this->cleanViteManifest();
+ }
+
+ public function testItThrowsWhenUnableToFindFileToRetrieveContent()
+ {
+ $this->makeViteManifest();
+
+ $this->expectException(ViteException::class);
+ $this->expectExceptionMessage('Unable to locate file from Vite manifest: ' . public_path('build/assets/app.versioned.js'));
+
+ ViteFacade::content('resources/js/app.js');
+ }
+
+ public function testItCanPrefetchEntrypoint()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/prefetching-manifest.json'));
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+
+ $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->prefetch(concurrency: 3)->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'fetchpriority' => 'low'],
+ ]);
+ $this->assertSame(<<
+
+ HTML, $html);
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItHandlesSpecifyingPageWithAppJs()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/prefetching-manifest.json'));
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+
+ $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js', 'resources/js/Pages/Auth/Login.vue'])->useBuildDirectory($buildDir)->prefetch(concurrency: 3)->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'fetchpriority' => 'low'],
+ ]);
+ $this->assertStringContainsString(<<cleanViteManifest($buildDir);
+ }
+
+ public function testItCanSpecifyWaterfallChunks()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/prefetching-manifest.json'));
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+
+ $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->prefetch(concurrency: 10)->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'fetchpriority' => 'low'],
+ ]);
+ $this->assertStringContainsString(<<cleanViteManifest($buildDir);
+ }
+
+ public function testItCanPrefetchAggressively()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/prefetching-manifest.json'));
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+
+ $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->prefetch()->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'fetchpriority' => 'low'],
+ ]);
+
+ $this->assertSame(<<
+
+ HTML, $html);
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testAddsAttributesToPrefetchTags()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/prefetching-manifest.json'));
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+
+ $html = (string) tap(ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->prefetch(concurrency: 3))->useCspNonce('abc123')->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ]);
+ $this->assertStringContainsString(<<cleanViteManifest($buildDir);
+ }
+
+ public function testItNormalisesAttributes()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/prefetching-manifest.json'));
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+
+ $html = (string) tap(ViteFacade::withEntryPoints(['resources/js/app.js']))->useBuildDirectory($buildDir)->prefetch(concurrency: 3)->usePreloadTagAttributes([
+ 'key' => 'value',
+ 'key-only',
+ 'true-value' => true,
+ 'false-value' => false,
+ 'null-value' => null,
+ ])->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ]);
+
+ $this->assertStringContainsString(<<cleanViteManifest($buildDir);
+ }
+
+ public function testItPrefetchesCss()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/prefetching-manifest.json'));
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+
+ $html = (string) ViteFacade::withEntryPoints(['resources/js/admin.js'])->useBuildDirectory($buildDir)->prefetch(concurrency: 3)->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/admin-runtime-import-CRvLQy6v.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/admin-runtime-import-import-DKMIaPXC.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'style', 'href' => "https://example.com/{$buildDir}/assets/admin-runtime-import-BlmN0T4U.css", 'fetchpriority' => 'low'],
+ ]);
+ $this->assertSame(<<
+
+ HTML, $html);
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testSupportCspNonceInPrefetchScript()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__ . '/fixtures/prefetching-manifest.json'));
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+
+ $html = (string) tap(ViteFacade::withEntryPoints(['resources/js/app.js']))
+ ->useCspNonce('abc123')
+ ->useBuildDirectory($buildDir)
+ ->prefetch()
+ ->toHtml();
+ $this->assertStringContainsString('