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 +=== + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](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('