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/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/Middleware/ShareErrorsFromSession.php b/src/core/src/View/Middleware/ShareErrorsFromSession.php deleted file mode 100644 index f92a2596f..000000000 --- a/src/core/src/View/Middleware/ShareErrorsFromSession.php +++ /dev/null @@ -1,32 +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/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index a8788c999..d45bb43dd 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -600,6 +600,10 @@ protected function withErrors(Request $request, mixed $provider, string $key = ' if ($flashInputs) { $session->flashInput($flashInputs); } + + // Because an exception is thrown and the reset logic of `StartSession` middleware is not executed, + // We need to save the session here manually. + $session->save(); } /** diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php index bf92860ea..be68297bb 100644 --- a/src/foundation/src/helpers.php +++ b/src/foundation/src/helpers.php @@ -7,8 +7,6 @@ use Hyperf\Contract\Arrayable; use Hyperf\HttpMessage\Cookie\Cookie; use Hyperf\Stringable\Stringable; -use Hyperf\ViewEngine\Contract\FactoryInterface; -use Hyperf\ViewEngine\Contract\ViewInterface; use Hypervel\Auth\Contracts\Factory as AuthFactoryContract; use Hypervel\Auth\Contracts\Gate; use Hypervel\Auth\Contracts\Guard; @@ -33,6 +31,8 @@ use Hypervel\Translation\Contracts\Translator as TranslatorContract; use Hypervel\Validation\Contracts\Factory as ValidatorFactoryContract; use Hypervel\Validation\Contracts\Validator as ValidatorContract; +use Hypervel\View\Contracts\Factory as FactoryContract; +use Hypervel\View\Contracts\View as ViewContract; use Psr\Http\Message\ResponseInterface; use Psr\Log\LoggerInterface; @@ -770,9 +770,9 @@ function __(?string $key = null, array $replace = [], ?string $locale = null): a * @param null|string $view * @param array $mergeData */ - function view($view = null, array|Arrayable $data = [], $mergeData = []): FactoryInterface|ViewInterface + function view($view = null, array|Arrayable $data = [], $mergeData = []): FactoryContract|ViewContract { - $factory = app(FactoryInterface::class); + $factory = app(FactoryContract::class); if (func_num_args() === 0) { return $factory; diff --git a/src/http/src/CoreMiddleware.php b/src/http/src/CoreMiddleware.php index 48269c6bd..f2c572e6c 100644 --- a/src/http/src/CoreMiddleware.php +++ b/src/http/src/CoreMiddleware.php @@ -17,6 +17,7 @@ use Hyperf\HttpServer\Router\DispatcherFactory; use Hyperf\Server\Exception\ServerException; use Hyperf\View\RenderInterface; +use Hyperf\ViewEngine\Contract\Htmlable; use Hyperf\ViewEngine\Contract\Renderable; use Hyperf\ViewEngine\Contract\ViewInterface; use Hypervel\Context\ResponseContext; @@ -65,6 +66,12 @@ protected function transferToResponse($response, ServerRequestInterface $request ->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/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/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/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..24e4f32b3 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; - /** * @method static void compile(string|null $path = null) * @method static string getPath() * @method static void setPath(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, callable $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(string|callable $class, callable|null $handler = null) * @method static string compileEchos(string $value) + * @method static string applyEchoHandler(string $value) * * @see \Hypervel\View\Compilers\BladeCompiler */ @@ -48,6 +55,6 @@ class Blade extends Facade { protected static function getFacadeAccessor() { - return CompilerInterface::class; + return 'blade.compiler'; } } diff --git a/src/support/src/Facades/View.php b/src/support/src/Facades/View.php index bbe8ea9df..e6e4a534c 100644 --- a/src/support/src/Facades/View.php +++ b/src/support/src/Facades/View.php @@ -4,70 +4,76 @@ namespace Hypervel\Support\Facades; -use Hyperf\ViewEngine\Contract\FactoryInterface; - /** - * @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|null $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, string|array $hints) + * @method static \Hypervel\View\Factory prependNamespace(string $namespace, string|array $hints) + * @method static \Hypervel\View\Factory replaceNamespace(string $namespace, string|array $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 macro(string $name, object|callable $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|null 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, 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 yieldContent(string $section, string $default = '') * @method static string parentPlaceholder(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(\Countable|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 +84,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 'view'; } } diff --git a/src/support/src/MessageBag.php b/src/support/src/MessageBag.php index 6654b4ef9..5d0745d45 100644 --- a/src/support/src/MessageBag.php +++ b/src/support/src/MessageBag.php @@ -5,7 +5,16 @@ namespace Hypervel\Support; use Hyperf\Support\MessageBag as HyperfMessageBag; +use Hypervel\Support\Contracts\MessageBag as ContractsMessageBag; +use Hypervel\Support\Contracts\MessageProvider; -class MessageBag extends HyperfMessageBag +class MessageBag extends HyperfMessageBag implements ContractsMessageBag, MessageProvider { + /** + * Get the messages for the instance. + */ + public function getMessageBag(): ContractsMessageBag + { + return $this; + } } 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/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..7369a5e07 --- /dev/null +++ b/src/view/src/Compilers/BladeCompiler.php @@ -0,0 +1,891 @@ +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..cf1b13249 --- /dev/null +++ b/src/view/src/Compilers/Compiler.php @@ -0,0 +1,73 @@ +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) { + 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, + $viewFactory->exists($guess = $path['prefixHash'] . $delimiter . $formattedComponent . '.index') => $guess, + $viewFactory->exists($guess = $path['prefixHash'] . $delimiter . $formattedComponent . '.' . Str::afterLast($formattedComponent, '.')) => $guess, + 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() + ->make(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..2cf9eeb75 --- /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('view'); + } + + 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..233632a81 --- /dev/null +++ b/src/view/src/Concerns/ManagesEvents.php @@ -0,0 +1,156 @@ +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; + } + if (is_string($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->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->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) { + static::$compiler = new ComponentTagCompiler( + Container::getInstance()->make('blade.compiler')->getClassComponentAliases(), + Container::getInstance()->make('blade.compiler')->getClassComponentNamespaces(), + Container::getInstance()->make('blade.compiler') + ); + } + + 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/view/src/View.php b/src/view/src/View.php new file mode 100755 index 000000000..4de163733 --- /dev/null +++ b/src/view/src/View.php @@ -0,0 +1,408 @@ +data = $data instanceof Arrayable ? $data->toArray() : (array) $data; + } + + /** + * Get the evaluated contents of a given fragment. + */ + public function fragment(string $fragment): HtmlString + { + $content = $this->render(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->render(fn () => $this->factory->getFragments())))->implode(''); + } + + /** + * Get the string contents of the view. + * + * @throws Throwable + */ + public function render(?callable $callback = null): string + { + 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(); + $this->compatibleWithHyperfView(); + } + + /** + * Register the view environment. + */ + protected function registerFactory(): void + { + $this->app->bind('view', 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['view.engine.resolver']; + + $finder = $app['view.finder']; + + $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; + }); + + $this->app->bind(FactoryContract::class, function ($app) { + return $app['view']; + }); + } + + /** + * 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('view.finder', function ($app) { + return new FileViewFinder($app['files'], $app['config']['view.paths']); + }); + } + + /** + * Register the Blade compiler implementation. + */ + protected function registerBladeCompiler(): void + { + $this->app->bind('blade.compiler', 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('view.engine.resolver', 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('blade.compiler'), + $app->get('files'), + ); + }); + } + + /** + * Make compatible with Hyperf View component. + */ + protected function compatibleWithHyperfView(): void + { + if (! class_exists(HyperfFactoryInterface::class)) { + return; + } + + $this->app->beforeResolving(HyperfFactoryInterface::class, function (string $abstract, array $params, Application $app) { + $config = $app->get('config'); + $viewConfig = $config->get('view', []); + + $customHyperfViewConfig = Arr::only($viewConfig, [ + 'engine', + 'mode', + 'config', + 'event', + 'components', + ]); + $hyperfViewConfig = $customHyperfViewConfig + [ + 'engine' => HyperfViewEngine::class, + 'mode' => Mode::SYNC, + 'config' => [ + 'view_path' => base_path('resources/views'), + 'cache_path' => storage_path('framework/views'), + ], + 'event' => ['enable' => false], + 'components' => [], + ]; + + $config->set('view', array_merge($hyperfViewConfig, $viewConfig)); + }); + } +} diff --git a/tests/Foundation/FoundationExceptionHandlerTest.php b/tests/Foundation/FoundationExceptionHandlerTest.php index 60f8a3203..4618b10a2 100644 --- a/tests/Foundation/FoundationExceptionHandlerTest.php +++ b/tests/Foundation/FoundationExceptionHandlerTest.php @@ -35,6 +35,7 @@ use Hypervel\Tests\TestCase; use Hypervel\Validation\ValidationException; use Hypervel\Validation\Validator; +use Hypervel\View\Contracts\Factory as FactoryContract; use InvalidArgumentException; use Mockery as m; use OutOfRangeException; @@ -344,6 +345,7 @@ public function testValidateFailed() $session->shouldReceive('get')->with('errors', m::type(ViewErrorBag::class))->andReturn(new MessageBag(['error' => 'My custom validation exception'])); $session->shouldReceive('flash')->with('errors', m::type(ViewErrorBag::class))->once(); $session->shouldReceive('flashInput')->with(['foo' => 'bar'])->once(); + $session->shouldReceive('save')->once(); Context::set(SessionInterface::class, $session); $this->container->instance(SessionContract::class, $session); @@ -385,10 +387,10 @@ 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); $handler = new class($this->container) extends Handler { public function getErrorView($e) @@ -402,11 +404,11 @@ 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); $handler = new class($this->container) extends Handler { public function getErrorView($e) @@ -420,11 +422,11 @@ 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); $handler = new class($this->container) extends Handler { public function getErrorView($e) @@ -441,10 +443,10 @@ 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); + $viewFactory = m::mock(FactoryContract::class); $viewFactory->shouldReceive('exists')->once()->with('errors::404')->andReturn(true); - $this->container->instance(FactoryInterface::class, $viewFactory); + $this->container->instance(FactoryContract::class, $viewFactory); $renderer = m::mock(RenderInterface::class); $renderer->shouldReceive('render')->once()->withAnyArgs()->andThrow(new Exception('Rendering this view throws an exception')); diff --git a/tests/Session/SessionStoreTest.php b/tests/Session/SessionStoreTest.php index d07872a3d..8ab6f3377 100644 --- a/tests/Session/SessionStoreTest.php +++ b/tests/Session/SessionStoreTest.php @@ -6,9 +6,9 @@ use Hyperf\Context\Context; use Hyperf\Stringable\Str; -use Hyperf\Support\MessageBag; -use Hyperf\ViewEngine\ViewErrorBag; use Hypervel\Session\Store; +use Hypervel\Support\MessageBag; +use Hypervel\Support\ViewErrorBag; use Hypervel\Tests\TestCase; use Mockery as m; use SessionHandlerInterface; diff --git a/tests/View/Blade/AbstractBladeTestCase.php b/tests/View/Blade/AbstractBladeTestCase.php new file mode 100644 index 000000000..a0cafae9b --- /dev/null +++ b/tests/View/Blade/AbstractBladeTestCase.php @@ -0,0 +1,42 @@ +compiler = new BladeCompiler($this->getFiles(), __DIR__); + } + + protected function tearDown(): void + { + Component::flushCache(); + Component::forgetComponentsResolver(); + Component::forgetFactory(); + + m::close(); + + parent::tearDown(); + } + + protected function getFiles() + { + return m::mock(Filesystem::class); + } +} diff --git a/tests/View/Blade/BladeAppendTest.php b/tests/View/Blade/BladeAppendTest.php new file mode 100644 index 000000000..d34eb1f45 --- /dev/null +++ b/tests/View/Blade/BladeAppendTest.php @@ -0,0 +1,17 @@ +assertSame('appendSection(); ?>', $this->compiler->compileString('@append')); + } +} diff --git a/tests/View/Blade/BladeBoolTest.php b/tests/View/Blade/BladeBoolTest.php new file mode 100644 index 000000000..c22dbb0bc --- /dev/null +++ b/tests/View/Blade/BladeBoolTest.php @@ -0,0 +1,73 @@ +}"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + // For Javascript object{'isBool' : false} + $string = "{'isBool' : @bool(false)}"; + $expected = "{'isBool' : }"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + // For Alpine.js x-show attribute + $string = ""; + $expected = "' />"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + // For Alpine.js x-show attribute + $string = ""; + $expected = "' />"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testCompileBool(): void + { + $someViewVarTruthy = 123; + $compiled = $this->compiler->compileString('@bool($someViewVarTruthy)'); + + ob_start(); + eval(substr($compiled, 6, -3)); + $this->assertEquals('true', ob_get_clean()); + + $someViewVarFalsey = '0'; + $compiled = $this->compiler->compileString('@bool($someViewVarFalsey)'); + + ob_start(); + eval(substr($compiled, 6, -3)); + $this->assertEquals('false', ob_get_clean()); + + $anotherSomeViewVarTruthy = new SomeClass(); + $compiled = $this->compiler->compileString('@bool($anotherSomeViewVarTruthy)'); + + ob_start(); + eval(substr($compiled, 6, -3)); + $this->assertEquals('true', ob_get_clean()); + + $anotherSomeViewVarFalsey = null; + $compiled = $this->compiler->compileString('@bool($anotherSomeViewVarFalsey)'); + + ob_start(); + eval(substr($compiled, 6, -3)); + $this->assertEquals('false', ob_get_clean()); + } +} + +class SomeClass +{ + public function someMethod() + { + } +} diff --git a/tests/View/Blade/BladeBreakStatementsTest.php b/tests/View/Blade/BladeBreakStatementsTest.php new file mode 100644 index 000000000..739f47291 --- /dev/null +++ b/tests/View/Blade/BladeBreakStatementsTest.php @@ -0,0 +1,77 @@ + +test + +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testBreakStatementsWithExpressionAreCompiled() + { + $string = '@for ($i = 0; $i < 10; $i++) +test +@break(TRUE) +@endfor'; + $expected = ' +test + +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testBreakStatementsWithArgumentAreCompiled() + { + $string = '@for ($i = 0; $i < 10; $i++) +test +@break(2) +@endfor'; + $expected = ' +test + +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testBreakStatementsWithSpacedArgumentAreCompiled() + { + $string = '@for ($i = 0; $i < 10; $i++) +test +@break( 2 ) +@endfor'; + $expected = ' +test + +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testBreakStatementsWithFaultyArgumentAreCompiled() + { + $string = '@for ($i = 0; $i < 10; $i++) +test +@break(-2) +@endfor'; + $expected = ' +test + +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeCanStatementsTest.php b/tests/View/Blade/BladeCanStatementsTest.php new file mode 100644 index 000000000..69961a860 --- /dev/null +++ b/tests/View/Blade/BladeCanStatementsTest.php @@ -0,0 +1,27 @@ +check(\'update\', [$post])): ?> +breeze +check(\'delete\', [$post])): ?> +sneeze +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeCananyStatementsTest.php b/tests/View/Blade/BladeCananyStatementsTest.php new file mode 100644 index 000000000..aae18ffc3 --- /dev/null +++ b/tests/View/Blade/BladeCananyStatementsTest.php @@ -0,0 +1,27 @@ +any([\'create\', \'update\'], [$post])): ?> +breeze +any([\'delete\', \'approve\'], [$post])): ?> +sneeze +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeCannotStatementsTest.php b/tests/View/Blade/BladeCannotStatementsTest.php new file mode 100644 index 000000000..cf0e49b82 --- /dev/null +++ b/tests/View/Blade/BladeCannotStatementsTest.php @@ -0,0 +1,27 @@ +denies(\'update\', [$post])): ?> +breeze +denies(\'delete\', [$post])): ?> +sneeze +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeCheckedStatementsTest.php b/tests/View/Blade/BladeCheckedStatementsTest.php new file mode 100644 index 000000000..efd99439c --- /dev/null +++ b/tests/View/Blade/BladeCheckedStatementsTest.php @@ -0,0 +1,52 @@ +'; + $expected = "/>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testCheckedStatementsAreCompiled() + { + $string = ''; + $expected = "/>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testDisabledStatementsAreCompiled() + { + $string = ''; + $expected = ""; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testRequiredStatementsAreCompiled() + { + $string = ''; + $expected = "/>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testReadonlyStatementsAreCompiled() + { + $string = ''; + $expected = "/>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeClassTest.php b/tests/View/Blade/BladeClassTest.php new file mode 100644 index 000000000..f12176ec7 --- /dev/null +++ b/tests/View/Blade/BladeClassTest.php @@ -0,0 +1,20 @@ + true, 'mr-2' => false])>"; + $expected = " true, 'mr-2' => false]); ?>\">"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeCommentsTest.php b/tests/View/Blade/BladeCommentsTest.php new file mode 100644 index 000000000..f2b7ab7bd --- /dev/null +++ b/tests/View/Blade/BladeCommentsTest.php @@ -0,0 +1,33 @@ +assertEmpty($this->compiler->compileString($string)); + + $string = '{{-- +this is a comment +--}}'; + $this->assertEmpty($this->compiler->compileString($string)); + + $string = sprintf('{{-- this is an %s long comment --}}', str_repeat('extremely ', 1000)); + $this->assertEmpty($this->compiler->compileString($string)); + } + + public function testBladeCodeInsideCommentsIsNotCompiled() + { + $string = '{{-- @foreach() --}}'; + + $this->assertEmpty($this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeComponentFirstTest.php b/tests/View/Blade/BladeComponentFirstTest.php new file mode 100644 index 000000000..1d7cc1e7d --- /dev/null +++ b/tests/View/Blade/BladeComponentFirstTest.php @@ -0,0 +1,18 @@ +assertSame('startComponentFirst(["one", "two"]); ?>', $this->compiler->compileString('@componentFirst(["one", "two"])')); + $this->assertSame('startComponentFirst(["one", "two"], ["foo" => "bar"]); ?>', $this->compiler->compileString('@componentFirst(["one", "two"], ["foo" => "bar"])')); + } +} diff --git a/tests/View/Blade/BladeComponentTagCompilerTest.php b/tests/View/Blade/BladeComponentTagCompilerTest.php new file mode 100644 index 000000000..508989004 --- /dev/null +++ b/tests/View/Blade/BladeComponentTagCompilerTest.php @@ -0,0 +1,963 @@ +mockViewFactory(); + $result = $this->compiler()->compileSlots(' +'); + + $this->assertSame( + "@slot('foo', null, []) \n" . ' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); + } + + public function testInlineSlotsCanBeCompiled() + { + $this->mockViewFactory(); + $result = $this->compiler()->compileSlots(' +'); + + $this->assertSame( + "@slot('foo', null, []) \n" . ' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); + } + + public function testDynamicSlotsCanBeCompiled() + { + $this->mockViewFactory(); + $result = $this->compiler()->compileSlots(' +'); + + $this->assertSame( + "@slot(\$foo, null, []) \n" . ' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); + } + + public function testDynamicSlotsCanBeCompiledWithKeyOfObjects() + { + $this->mockViewFactory(); + $result = $this->compiler()->compileSlots(' +'); + + $this->assertSame( + "@slot(\$foo->name, null, []) \n" . ' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); + } + + public function testSlotsWithAttributesCanBeCompiled() + { + $this->mockViewFactory(); + $result = $this->compiler()->compileSlots(' +'); + + $this->assertSame( + "@slot('foo', null, ['class' => 'font-bold']) \n" . ' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); + } + + public function testInlineSlotsWithAttributesCanBeCompiled() + { + $this->mockViewFactory(); + $result = $this->compiler()->compileSlots(' +'); + + $this->assertSame( + "@slot('foo', null, ['class' => 'font-bold']) \n" . ' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); + } + + public function testSlotsWithDynamicAttributesCanBeCompiled() + { + $this->mockViewFactory(); + $result = $this->compiler()->compileSlots(' +'); + + $this->assertSame( + "@slot('foo', null, ['class' => \\Hypervel\\View\\Compilers\\BladeCompiler::sanitizeComponentAttribute(\$classes)]) \n" . ' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); + } + + public function testSlotsWithClassDirectiveCanBeCompiled() + { + $this->mockViewFactory(); + $result = $this->compiler()->compileSlots(' +'); + + $this->assertSame( + "@slot('foo', null, ['class' => \\Hypervel\\View\\Compilers\\BladeCompiler::sanitizeComponentAttribute(\\Hypervel\\Support\\Arr::toCssClasses(\$classes))]) \n" . ' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); + } + + public function testSlotsWithStyleDirectiveCanBeCompiled() + { + $this->mockViewFactory(); + $result = $this->compiler()->compileSlots(' +'); + + $this->assertSame( + "@slot('foo', null, ['style' => \\Hypervel\\View\\Compilers\\BladeCompiler::sanitizeComponentAttribute(\\Hypervel\\Support\\Arr::toCssStyles(\$styles))]) \n" . ' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); + } + + public function testBasicComponentParsing() + { + $this->mockViewFactory(); + + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('
'); + + $this->assertSame("
##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestAlertComponent', 'alert', []) + +except(\\Hypervel\\Tests\\View\\Blade\\TestAlertComponent::ignoredParameterNames()); ?> + +withAttributes(['type' => 'foo','limit' => '5','@click' => 'foo','wire:click' => 'changePlan(\\''.e(\$plan).'\\')','required' => true,'x-intersect.margin.-50%.0px' => 'visibleSection = \\'profile\\'']); ?>\n" +. "@endComponentClass##END-COMPONENT-CLASS####BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestAlertComponent', 'alert', []) + +except(\\Hypervel\\Tests\\View\\Blade\\TestAlertComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?>\n" +. '@endComponentClass##END-COMPONENT-CLASS##
', trim($result)); + } + + public function testNestedDefaultComponentParsing() + { + $this->mockViewFactory(); + + $result = $this->compiler()->compileTags('
'); + + $this->assertSame("
##BEGIN-COMPONENT-CLASS##@component('App\\View\\Components\\Card\\Card', 'card', []) + +except(\\App\\View\\Components\\Card\\Card::ignoredParameterNames()); ?> + +withAttributes([]); ?>\n" + . '@endComponentClass##END-COMPONENT-CLASS##
', trim($result)); + } + + public function testBasicComponentWithEmptyAttributesParsing() + { + $this->mockViewFactory(); + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('
'); + + $this->assertSame("
##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestAlertComponent', 'alert', []) + +except(\\Hypervel\\Tests\\View\\Blade\\TestAlertComponent::ignoredParameterNames()); ?> + +withAttributes(['type' => '','limit' => '','@click' => '','required' => true]); ?>\n" +. '@endComponentClass##END-COMPONENT-CLASS##
', trim($result)); + } + + public function testDataCamelCasing() + { + $this->mockViewFactory(); + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestProfileComponent', 'profile', ['userId' => '1']) + +except(\\Hypervel\\Tests\\View\\Blade\\TestProfileComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testColonData() + { + $this->mockViewFactory(); + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestProfileComponent', 'profile', ['userId' => 1]) + +except(\\Hypervel\\Tests\\View\\Blade\\TestProfileComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testColonDataShortSyntax() + { + $this->mockViewFactory(); + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestProfileComponent', 'profile', ['userId' => \$userId]) + +except(\\Hypervel\\Tests\\View\\Blade\\TestProfileComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testColonDataWithStaticClassProperty() + { + $this->mockViewFactory(); + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestProfileComponent', 'profile', ['userId' => User::\$id]) + +except(\\Hypervel\\Tests\\View\\Blade\\TestProfileComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testColonDataWithStaticClassPropertyAndMultipleAttributes() + { + $this->mockViewFactory(); + $result = $this->compiler(['input' => TestInputComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestInputComponent', 'input', ['label' => Input::\$label,'name' => \$name,'value' => 'Joe']) + +except(\\Hypervel\\Tests\\View\\Blade\\TestInputComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + + $result = $this->compiler(['input' => TestInputComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestInputComponent', 'input', ['value' => 'Joe','name' => \$name,'label' => Input::\$label]) + +except(\\Hypervel\\Tests\\View\\Blade\\TestInputComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testSelfClosingComponentWithColonDataShortSyntax() + { + $this->mockViewFactory(); + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestProfileComponent', 'profile', ['userId' => \$userId]) + +except(\\Hypervel\\Tests\\View\\Blade\\TestProfileComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?>\n" +. '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testSelfClosingComponentWithColonDataAndStaticClassPropertyShortSyntax() + { + $this->mockViewFactory(); + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestProfileComponent', 'profile', ['userId' => User::\$id]) + +except(\\Hypervel\\Tests\\View\\Blade\\TestProfileComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?>\n" +. '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testSelfClosingComponentWithColonDataMultipleAttributesAndStaticClassPropertyShortSyntax() + { + $this->mockViewFactory(); + $result = $this->compiler(['input' => TestInputComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestInputComponent', 'input', ['label' => Input::\$label,'value' => 'Joe','name' => \$name]) + +except(\\Hypervel\\Tests\\View\\Blade\\TestInputComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?>\n" +. '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + + $result = $this->compiler(['input' => TestInputComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestInputComponent', 'input', ['name' => \$name,'label' => Input::\$label,'value' => 'Joe']) + +except(\\Hypervel\\Tests\\View\\Blade\\TestInputComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?>\n" +. '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testEscapedColonAttribute() + { + $this->mockViewFactory(); + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestProfileComponent', 'profile', ['userId' => 1]) + +except(\\Hypervel\\Tests\\View\\Blade\\TestProfileComponent::ignoredParameterNames()); ?> + +withAttributes([':title' => 'user.name']); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testColonAttributesIsEscapedIfStrings() + { + $this->mockViewFactory(); + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestProfileComponent', 'profile', []) + +except(\\Hypervel\\Tests\\View\\Blade\\TestProfileComponent::ignoredParameterNames()); ?> + +withAttributes(['src' => \\Hypervel\\View\\Compilers\\BladeCompiler::sanitizeComponentAttribute('foo')]); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testClassDirective() + { + $this->mockViewFactory(); + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags('true])>'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestProfileComponent', 'profile', []) + +except(\\Hypervel\\Tests\\View\\Blade\\TestProfileComponent::ignoredParameterNames()); ?> + +withAttributes(['class' => \\Hypervel\\View\\Compilers\\BladeCompiler::sanitizeComponentAttribute(\\Hypervel\\Support\\Arr::toCssClasses(['bar'=>true]))]); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testStyleDirective() + { + $this->mockViewFactory(); + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags('true])>'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestProfileComponent', 'profile', []) + +except(\\Hypervel\\Tests\\View\\Blade\\TestProfileComponent::ignoredParameterNames()); ?> + +withAttributes(['style' => \\Hypervel\\View\\Compilers\\BladeCompiler::sanitizeComponentAttribute(\\Hypervel\\Support\\Arr::toCssStyles(['bar'=>true]))]); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testColonNestedComponentParsing() + { + $this->mockViewFactory(); + $result = $this->compiler(['foo:alert' => TestAlertComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestAlertComponent', 'foo:alert', []) + +except(\\Hypervel\\Tests\\View\\Blade\\TestAlertComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testColonStartingNestedComponentParsing() + { + $this->mockViewFactory(); + $result = $this->compiler(['foo:alert' => TestAlertComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestAlertComponent', 'foo:alert', []) + +except(\\Hypervel\\Tests\\View\\Blade\\TestAlertComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testSelfClosingComponentsCanBeCompiled() + { + $this->mockViewFactory(); + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('
'); + + $this->assertSame("
##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestAlertComponent', 'alert', []) + +except(\\Hypervel\\Tests\\View\\Blade\\TestAlertComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?>\n" +. '@endComponentClass##END-COMPONENT-CLASS##
', trim($result)); + } + + public function testClassNamesCanBeGuessed() + { + $this->mockViewFactory(); + + $result = $this->compiler()->guessClassName('alert'); + + $this->assertSame('App\View\Components\Alert', trim($result)); + } + + public function testClassNamesCanBeGuessedWithNamespaces() + { + $this->mockViewFactory(); + + $result = $this->compiler()->guessClassName('base.alert'); + + $this->assertSame('App\View\Components\Base\Alert', trim($result)); + } + + public function testComponentsCanBeCompiledWithHyphenAttributes() + { + $this->mockViewFactory(); + + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestAlertComponent', 'alert', []) + +except(\\Hypervel\\Tests\\View\\Blade\\TestAlertComponent::ignoredParameterNames()); ?> + +withAttributes(['class' => 'bar','wire:model' => 'foo','x-on:click' => 'bar','@click' => 'baz']); ?>\n" +. '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testSelfClosingComponentsCanBeCompiledWithDataAndAttributes() + { + $this->mockViewFactory(); + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestAlertComponent', 'alert', ['title' => 'foo']) + +except(\\Hypervel\\Tests\\View\\Blade\\TestAlertComponent::ignoredParameterNames()); ?> + +withAttributes(['class' => 'bar','wire:model' => 'foo']); ?>\n" +. '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testComponentCanReceiveAttributeBag() + { + $this->mockViewFactory(); + + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestProfileComponent', 'profile', []) + +except(\\Hypervel\\Tests\\View\\Blade\\TestProfileComponent::ignoredParameterNames()); ?> + +withAttributes(['class' => 'bar','attributes' => \\Hypervel\\View\\Compilers\\BladeCompiler::sanitizeComponentAttribute(\$attributes),'wire:model' => 'foo']); ?> @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testSelfClosingComponentCanReceiveAttributeBag() + { + $this->mockViewFactory(); + + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('
merge([\'class\' => \'test\']) }} wire:model="foo" />
'); + + $this->assertSame("
##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestAlertComponent', 'alert', ['title' => 'foo']) + +except(\\Hypervel\\Tests\\View\\Blade\\TestAlertComponent::ignoredParameterNames()); ?> + +withAttributes(['class' => 'bar','attributes' => \\Hypervel\\View\\Compilers\\BladeCompiler::sanitizeComponentAttribute(\$attributes->merge(['class' => 'test'])),'wire:model' => 'foo']); ?>\n" + . '@endComponentClass##END-COMPONENT-CLASS##
', trim($result)); + } + + public function testComponentsCanHaveAttachedWord() + { + $this->mockViewFactory(); + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags('Words'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestProfileComponent', 'profile', []) + +except(\\Hypervel\\Tests\\View\\Blade\\TestProfileComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?> @endComponentClass##END-COMPONENT-CLASS##Words", trim($result)); + } + + public function testSelfClosingComponentsCanHaveAttachedWord() + { + $this->mockViewFactory(); + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('Words'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestAlertComponent', 'alert', []) + +except(\\Hypervel\\Tests\\View\\Blade\\TestAlertComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?>\n" +. '@endComponentClass##END-COMPONENT-CLASS##Words', trim($result)); + } + + public function testSelfClosingComponentsCanBeCompiledWithBoundData() + { + $this->mockViewFactory(); + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestAlertComponent', 'alert', ['title' => \$title]) + +except(\\Hypervel\\Tests\\View\\Blade\\TestAlertComponent::ignoredParameterNames()); ?> + +withAttributes(['class' => 'bar']); ?>\n" +. '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testPairedComponentTags() + { + $this->mockViewFactory(); + $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags(' +'); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\Tests\\View\\Blade\\TestAlertComponent', 'alert', []) + +except(\\Hypervel\\Tests\\View\\Blade\\TestAlertComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?> + @endComponentClass##END-COMPONENT-CLASS##", trim($result)); + } + + public function testClasslessComponents() + { + $this->mockViewFactory(); + + $result = $this->compiler()->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\View\\AnonymousComponent', 'anonymous-component', ['view' => 'components.anonymous-component','data' => ['name' => 'Taylor','age' => 31,'wire:model' => 'foo']]) + +except(\\Hypervel\\View\\AnonymousComponent::ignoredParameterNames()); ?> + +withAttributes(['name' => \\Hypervel\\View\\Compilers\\BladeCompiler::sanitizeComponentAttribute('Taylor'),'age' => 31,'wire:model' => 'foo']); ?>\n" +. '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testClasslessComponentsWithIndexView() + { + $this->mockViewFactory(false, true); + + $result = $this->compiler()->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\View\\AnonymousComponent', 'anonymous-component', ['view' => 'components.anonymous-component.index','data' => ['name' => 'Taylor','age' => 31,'wire:model' => 'foo']]) + +except(\\Hypervel\\View\\AnonymousComponent::ignoredParameterNames()); ?> + +withAttributes(['name' => \\Hypervel\\View\\Compilers\\BladeCompiler::sanitizeComponentAttribute('Taylor'),'age' => 31,'wire:model' => 'foo']); ?>\n" +. '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testClasslessComponentsWithComponentView() + { + $this->mockViewFactory(false, false, true); + + $result = $this->compiler()->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\View\\AnonymousComponent', 'anonymous-component', ['view' => 'components.anonymous-component.anonymous-component','data' => ['name' => 'Taylor','age' => 31,'wire:model' => 'foo']]) + +except(\\Hypervel\\View\\AnonymousComponent::ignoredParameterNames()); ?> + +withAttributes(['name' => \\Hypervel\\View\\Compilers\\BladeCompiler::sanitizeComponentAttribute('Taylor'),'age' => 31,'wire:model' => 'foo']); ?>\n" + . '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testPackagesClasslessComponents() + { + $this->mockViewFactory(); + + $result = $this->compiler()->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\View\\AnonymousComponent', 'package::anonymous-component', ['view' => 'package::components.anonymous-component','data' => ['name' => 'Taylor','age' => 31,'wire:model' => 'foo']]) + +except(\\Hypervel\\View\\AnonymousComponent::ignoredParameterNames()); ?> + +withAttributes(['name' => \\Hypervel\\View\\Compilers\\BladeCompiler::sanitizeComponentAttribute('Taylor'),'age' => 31,'wire:model' => 'foo']); ?>\n" +. '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testClasslessComponentsWithAnonymousComponentNamespace() + { + $this->mockViewFactory(function ($arg) { + // In our test, we'll do as if the 'public.frontend.anonymous-component' + // view exists and not the others. + return $arg === 'public.frontend.anonymous-component'; + }); + + $blade = m::mock(BladeCompiler::class)->makePartial(); + + $blade->shouldReceive('getAnonymousComponentNamespaces')->once()->andReturn([ + 'frontend' => 'public.frontend', + ]); + + $compiler = $this->compiler([], [], $blade); + + $result = $compiler->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\View\\AnonymousComponent', 'frontend::anonymous-component', ['view' => 'public.frontend.anonymous-component','data' => ['name' => 'Taylor','age' => 31,'wire:model' => 'foo']]) + +except(\\Hypervel\\View\\AnonymousComponent::ignoredParameterNames()); ?> + +withAttributes(['name' => \\Hypervel\\View\\Compilers\\BladeCompiler::sanitizeComponentAttribute('Taylor'),'age' => 31,'wire:model' => 'foo']); ?>\n" + . '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testClasslessComponentsWithAnonymousComponentNamespaceWithIndexView() + { + $this->mockViewFactory(function (string $viewNameBeingCheckedForExistence) { + // In our test, we'll do as if the 'public.frontend.anonymous-component' + // view exists and not the others. + return $viewNameBeingCheckedForExistence === 'admin.auth.components.anonymous-component.index'; + }); + + $blade = m::mock(BladeCompiler::class)->makePartial(); + + $blade->shouldReceive('getAnonymousComponentNamespaces')->once()->andReturn([ + 'admin.auth' => 'admin.auth.components', + ]); + + $compiler = $this->compiler([], [], $blade); + + $result = $compiler->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\View\\AnonymousComponent', 'admin.auth::anonymous-component', ['view' => 'admin.auth.components.anonymous-component.index','data' => ['name' => 'Taylor','age' => 31,'wire:model' => 'foo']]) + +except(\\Hypervel\\View\\AnonymousComponent::ignoredParameterNames()); ?> + +withAttributes(['name' => \\Hypervel\\View\\Compilers\\BladeCompiler::sanitizeComponentAttribute('Taylor'),'age' => 31,'wire:model' => 'foo']); ?>\n" + . '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testClasslessComponentsWithAnonymousComponentNamespaceWithComponentView() + { + $this->mockViewFactory(function (string $viewNameBeingCheckedForExistence) { + // In our test, we'll do as if the 'public.frontend.anonymous-component' + // view exists and not the others. + return $viewNameBeingCheckedForExistence === 'admin.auth.components.anonymous-component.anonymous-component'; + }); + + $blade = m::mock(BladeCompiler::class)->makePartial(); + + $blade->shouldReceive('getAnonymousComponentNamespaces')->once()->andReturn([ + 'admin.auth' => 'admin.auth.components', + ]); + + $compiler = $this->compiler([], [], $blade); + + $result = $compiler->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\View\\AnonymousComponent', 'admin.auth::anonymous-component', ['view' => 'admin.auth.components.anonymous-component.anonymous-component','data' => ['name' => 'Taylor','age' => 31,'wire:model' => 'foo']]) + +except(\\Hypervel\\View\\AnonymousComponent::ignoredParameterNames()); ?> + +withAttributes(['name' => \\Hypervel\\View\\Compilers\\BladeCompiler::sanitizeComponentAttribute('Taylor'),'age' => 31,'wire:model' => 'foo']); ?>\n" + . '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testClasslessComponentsWithAnonymousComponentPath() + { + $this->mockViewFactory(function ($arg) { + return $arg === md5('test-directory') . '::panel.index'; + }); + + $blade = m::mock(BladeCompiler::class)->makePartial(); + + $blade->shouldReceive('getAnonymousComponentPaths')->once()->andReturn([ + ['path' => 'test-directory', 'prefix' => null, 'prefixHash' => md5('test-directory')], + ]); + + $compiler = $this->compiler([], [], $blade); + + $result = $compiler->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\View\\AnonymousComponent', 'panel', ['view' => '" . md5('test-directory') . "::panel.index','data' => []]) + +except(\\Hypervel\\View\\AnonymousComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?>\n" + . '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testClasslessComponentsWithAnonymousComponentPathComponentName() + { + $this->mockViewFactory(function ($arg) { + return $arg === md5('test-directory') . '::panel.panel'; + }); + + $blade = m::mock(BladeCompiler::class)->makePartial(); + + $blade->shouldReceive('getAnonymousComponentPaths')->once()->andReturn([ + ['path' => 'test-directory', 'prefix' => null, 'prefixHash' => md5('test-directory')], + ]); + + $compiler = $this->compiler([], [], $blade); + + $result = $compiler->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\View\\AnonymousComponent', 'panel', ['view' => '" . md5('test-directory') . "::panel.panel','data' => []]) + +except(\\Hypervel\\View\\AnonymousComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?>\n" + . '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testClasslessIndexComponentsWithAnonymousComponentPath() + { + $this->mockViewFactory(function ($arg) { + return $arg === md5('test-directory') . '::panel'; + }); + + $blade = m::mock(BladeCompiler::class)->makePartial(); + + $blade->shouldReceive('getAnonymousComponentPaths')->once()->andReturn([ + ['path' => 'test-directory', 'prefix' => null, 'prefixHash' => md5('test-directory')], + ]); + + $compiler = $this->compiler([], [], $blade); + + $result = $compiler->compileTags(''); + + $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Hypervel\\View\\AnonymousComponent', 'panel', ['view' => '" . md5('test-directory') . "::panel','data' => []]) + +except(\\Hypervel\\View\\AnonymousComponent::ignoredParameterNames()); ?> + +withAttributes([]); ?>\n" + . '@endComponentClass##END-COMPONENT-CLASS##', trim($result)); + } + + public function testAttributeSanitization() + { + $this->mockViewFactory(); + $class = new class implements Stringable { + public function __toString() + { + return ''; + } + }; + + $model = new class extends Model { + public function getEventDispatcher(): ?EventDispatcherInterface + { + return null; + } + }; + + $this->assertEquals(e(''), BladeCompiler::sanitizeComponentAttribute('')); + $this->assertEquals(e('1'), BladeCompiler::sanitizeComponentAttribute('1')); + $this->assertEquals(1, BladeCompiler::sanitizeComponentAttribute(1)); + $this->assertEquals(e(''), BladeCompiler::sanitizeComponentAttribute($class)); + $this->assertSame($model, BladeCompiler::sanitizeComponentAttribute($model)); + } + + public function testItThrowsAnExceptionForNonExistingAliases() + { + $this->mockViewFactory(false); + + $this->expectException(InvalidArgumentException::class); + + $this->compiler(['alert' => 'foo.bar'])->compileTags(''); + } + + public function testItThrowsAnExceptionForNonExistingClass() + { + $this->mockViewFactory(false); + + $this->expectException(InvalidArgumentException::class); + + $this->compiler()->compileTags(''); + } + + public function testAttributesTreatedAsPropsAreRemovedFromFinalAttributes() + { + $factory = m::mock(Factory::class); + $factory->shouldReceive('exists')->never(); + + $container = $this->getMockBuilder(Application::class) + ->setConstructorArgs([ + new DefinitionSource([ + Factory::class => fn () => $factory, + ]), + 'bath_path', + ]) + ->onlyMethods(['getNamespace']) + ->getMock(); + $container->method('getNamespace')->willReturn('App\\'); + $container->alias(Factory::class, 'view'); + + ApplicationContext::setContainer($container); + + $attributes = new ComponentAttributeBag(['userId' => 'bar', 'other' => 'ok']); + + $component = m::mock(TestProfileComponent::class); + $component->shouldReceive('withName')->with('profile')->once(); + $component->shouldReceive('shouldRender')->once()->andReturn(true); + $component->shouldReceive('resolveView')->once()->andReturn(''); + $component->shouldReceive('data')->once()->andReturn([]); + $component->shouldReceive('withAttributes')->with(['attributes' => new ComponentAttributeBag(['other' => 'ok'])])->once(); + + Component::resolveComponentsUsing(fn () => $component); + + $__env = m::mock(\Hypervel\View\Factory::class); + $__env->shouldReceive('startComponent')->once(); + $__env->shouldReceive('renderComponent')->once(); + + $template = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); + $template = $this->compiler->compileString($template); + + ob_start(); + eval(" ?> {$template} assertSame($attributes->get('userId'), 'bar'); + $this->assertSame($attributes->get('other'), 'ok'); + } + + public function testOriginalAttributesAreRestoredAfterRenderingChildComponentWithProps() + { + $factory = m::mock(Factory::class); + $factory->shouldReceive('exists')->never(); + + $container = $this->getMockBuilder(Application::class) + ->setConstructorArgs([ + new DefinitionSource([ + Factory::class => fn () => $factory, + ]), + 'bath_path', + ]) + ->onlyMethods(['getNamespace']) + ->getMock(); + $container->method('getNamespace')->willReturn('App\\'); + $container->alias(Factory::class, 'view'); + + ApplicationContext::setContainer($container); + + $attributes = new ComponentAttributeBag(['userId' => 'bar', 'other' => 'ok']); + + $containerComponent = m::mock(TestContainerComponent::class); + $containerComponent->shouldReceive('withName')->with('container')->once(); + $containerComponent->shouldReceive('shouldRender')->once()->andReturn(true); + $containerComponent->shouldReceive('resolveView')->once()->andReturn(''); + $containerComponent->shouldReceive('data')->once()->andReturn([]); + $containerComponent->shouldReceive('withAttributes')->once(); + + $profileComponent = m::mock(TestProfileComponent::class); + $profileComponent->shouldReceive('withName')->with('profile')->once(); + $profileComponent->shouldReceive('shouldRender')->once()->andReturn(true); + $profileComponent->shouldReceive('resolveView')->once()->andReturn(''); + $profileComponent->shouldReceive('data')->once()->andReturn([]); + $profileComponent->shouldReceive('withAttributes')->with(['attributes' => new ComponentAttributeBag(['other' => 'ok'])])->once(); + + Component::resolveComponentsUsing(fn ($component) => match ($component) { + TestContainerComponent::class => $containerComponent, + TestProfileComponent::class => $profileComponent, + }); + + $__env = m::mock(\Hypervel\View\Factory::class); + $__env->shouldReceive('startComponent')->twice(); + $__env->shouldReceive('renderComponent')->twice(); + + $template = $this->compiler([ + 'container' => TestContainerComponent::class, + 'profile' => TestProfileComponent::class, + ])->compileTags(''); + $template = $this->compiler->compileString($template); + + ob_start(); + eval(" ?> {$template} assertSame($attributes->get('userId'), 'bar'); + $this->assertSame($attributes->get('other'), 'ok'); + } + + protected function mockViewFactory(...$exists) + { + $exists = $exists ?: [true]; + $factory = m::mock(Factory::class); + if ($exists[0] instanceof Closure) { + $factory->shouldReceive('exists')->andReturnUsing($exists[0]); + } else { + $factory->shouldReceive('exists')->andReturn(...$exists); + } + + $container = $this->getMockBuilder(Application::class) + ->setConstructorArgs([ + new DefinitionSource([ + Factory::class => fn () => $factory, + ]), + 'bath_path', + ]) + ->onlyMethods(['getNamespace']) + ->getMock(); + $container->method('getNamespace')->willReturn('App\\'); + $container->alias(Factory::class, 'view'); + + ApplicationContext::setContainer($container); + } + + protected function compiler(array $aliases = [], array $namespaces = [], ?BladeCompiler $blade = null) + { + return new ComponentTagCompiler( + $aliases, + $namespaces, + $blade + ); + } +} + +class TestAlertComponent extends Component +{ + public $title; + + public function __construct($title = 'foo', $userId = 1) + { + $this->title = $title; + } + + public function render(): View|Htmlable|Closure|string + { + return 'alert'; + } +} + +class TestProfileComponent extends Component +{ + public $userId; + + public function __construct($userId = 'foo') + { + $this->userId = $userId; + } + + public function render(): View|Htmlable|Closure|string + { + return 'profile'; + } +} + +class TestInputComponent extends Component +{ + public $userId; + + public function __construct( + protected $name, + protected $label, + protected $value, + ) { + } + + public function render(): View|Htmlable|Closure|string + { + return 'input'; + } +} + +class TestContainerComponent extends Component +{ + public function render(): View|Htmlable|Closure|string + { + return 'container'; + } +} + +namespace App\View\Components\Card; + +use Closure; +use Hypervel\Support\Contracts\Htmlable; +use Hypervel\View\Component; +use Hypervel\View\Contracts\View; + +class Card extends Component +{ + public function render(): View|Htmlable|Closure|string + { + return 'card'; + } +} diff --git a/tests/View/Blade/BladeComponentsTest.php b/tests/View/Blade/BladeComponentsTest.php new file mode 100644 index 000000000..89be67c12 --- /dev/null +++ b/tests/View/Blade/BladeComponentsTest.php @@ -0,0 +1,91 @@ +assertSame('startComponent(\'foo\', ["foo" => "bar"]); ?>', $this->compiler->compileString('@component(\'foo\', ["foo" => "bar"])')); + $this->assertSame('startComponent(\'foo\'); ?>', $this->compiler->compileString('@component(\'foo\')')); + } + + public function testClassComponentsAreCompiled() + { + $this->assertSame(str_replace("\r\n", "\n", ' + + "bar"] + (isset($attributes) && $attributes instanceof Hypervel\View\ComponentAttributeBag ? $attributes->all() : [])); ?> +withName(\'test\'); ?> +shouldRender()): ?> +startComponent($component->resolveView(), $component->data()); ?>'), $this->compiler->compileString('@component(\'Hypervel\Tests\View\Blade\ComponentStub::class\', \'test\', ["foo" => "bar"])')); + } + + public function testEndComponentsAreCompiled() + { + $this->compiler->newComponentHash('foo'); + + $this->assertSame('renderComponent(); ?>', $this->compiler->compileString('@endcomponent')); + } + + public function testEndComponentClassesAreCompiled() + { + $this->compiler->newComponentHash('foo'); + + $this->assertSame(str_replace("\r\n", "\n", 'renderComponent(); ?> + + + + + + + + +'), $this->compiler->compileString('@endcomponentClass')); + } + + public function testSlotsAreCompiled() + { + $this->assertSame('slot(\'foo\', null, ["foo" => "bar"]); ?>', $this->compiler->compileString('@slot(\'foo\', null, ["foo" => "bar"])')); + $this->assertSame('slot(\'foo\'); ?>', $this->compiler->compileString('@slot(\'foo\')')); + } + + public function testEndSlotsAreCompiled() + { + $this->assertSame('endSlot(); ?>', $this->compiler->compileString('@endslot')); + } + + public function testPropsAreExtractedFromParentAttributesCorrectlyForClassComponents() + { + $component = m::mock(ComponentStub::class); + $component->shouldReceive('withName', 'test'); + $component->shouldReceive('shouldRender')->andReturn(false); + + Component::resolveComponentsUsing(fn () => $component); + + $template = $this->compiler->compileString('@component(\'Hypervel\Tests\View\Blade\ComponentStub::class\', \'test\', ["foo" => "bar"])'); + + ob_start(); + eval(" ?> {$template} +test + +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testContinueStatementsWithExpressionAreCompiled() + { + $string = '@for ($i = 0; $i < 10; $i++) +test +@continue(TRUE) +@endfor'; + $expected = ' +test + +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testContinueStatementsWithArgumentAreCompiled() + { + $string = '@for ($i = 0; $i < 10; $i++) +test +@continue(2) +@endfor'; + $expected = ' +test + +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testContinueStatementsWithSpacedArgumentAreCompiled() + { + $string = '@for ($i = 0; $i < 10; $i++) +test +@continue( 2 ) +@endfor'; + $expected = ' +test + +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testContinueStatementsWithFaultyArgumentAreCompiled() + { + $string = '@for ($i = 0; $i < 10; $i++) +test +@continue(-2) +@endfor'; + $expected = ' +test + +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeCustomTest.php b/tests/View/Blade/BladeCustomTest.php new file mode 100644 index 000000000..de905ed05 --- /dev/null +++ b/tests/View/Blade/BladeCustomTest.php @@ -0,0 +1,233 @@ +assertSame(' ', $this->compiler->compileString("@if(\$test) @endif")); + } + + public function testMixingYieldAndEcho() + { + $this->assertSame('yieldContent(\'title\'); ?> - ', $this->compiler->compileString("@yield('title') - {{Config::get('site.title')}}")); + } + + public function testCustomExtensionsAreCompiled() + { + $this->compiler->extend(function ($value) { + return str_replace('foo', 'bar', $value); + }); + $this->assertSame('bar', $this->compiler->compileString('foo')); + } + + public function testCustomStatements() + { + $this->assertCount(0, $this->compiler->getCustomDirectives()); + $this->compiler->directive('customControl', function ($expression) { + return ""; + }); + $this->assertCount(1, $this->compiler->getCustomDirectives()); + + $string = '@if($foo) +@customControl(10, $foo, \'bar\') +@endif'; + $expected = ' + +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testCustomShortStatements() + { + $this->compiler->directive('customControl', function ($expression) { + return ''; + }); + + $string = '@customControl'; + $expected = ''; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testValidCustomNames() + { + $this->assertNull($this->compiler->directive('custom', function () { + })); + $this->assertNull($this->compiler->directive('custom_custom', function () { + })); + $this->assertNull($this->compiler->directive('customCustom', function () { + })); + $this->assertNull($this->compiler->directive('custom::custom', function () { + })); + } + + public function testInvalidCustomNames() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The directive name [custom-custom] is not valid.'); + $this->compiler->directive('custom-custom', function () { + }); + } + + public function testInvalidCustomNames2() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The directive name [custom:custom] is not valid.'); + $this->compiler->directive('custom:custom', function () { + }); + } + + public function testCustomExtensionOverwritesCore() + { + $this->compiler->directive('foreach', function ($expression) { + return ''; + }); + + $string = '@foreach'; + $expected = ''; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testCustomConditions() + { + $this->compiler->if('custom', function ($user) { + return true; + }); + + $string = '@custom($user) +@endcustom'; + $expected = ' +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testCustomIfElseConditions() + { + $this->compiler->if('custom', function ($anything) { + return true; + }); + + $string = '@custom($user) +@elsecustom($product) +@else +@endcustom'; + $expected = ' + + +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testCustomUnlessConditions() + { + $this->compiler->if('custom', function ($anything) { + return true; + }); + + $string = '@unlesscustom($user) +@endcustom'; + $expected = ' +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testCustomConditionsAccepts0AsArgument() + { + $this->compiler->if('custom', function ($number) { + return true; + }); + + $string = '@custom(0) +@elsecustom(0) +@endcustom'; + $expected = ' + +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testCustomComponents() + { + $this->compiler->aliasComponent('app.components.alert', 'alert'); + + $string = '@alert +@endalert'; + $expected = 'startComponent(\'app.components.alert\'); ?> +renderComponent(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testCustomComponentsWithSlots() + { + $this->compiler->aliasComponent('app.components.alert', 'alert'); + + $string = '@alert([\'type\' => \'danger\']) +@endalert'; + $expected = 'startComponent(\'app.components.alert\', [\'type\' => \'danger\']); ?> +renderComponent(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testCustomComponentsWithExistingDirective() + { + $this->compiler->aliasComponent('app.components.foreach', 'foreach'); + + $string = '@foreach +@endforeach'; + $expected = 'startComponent(\'app.components.foreach\'); ?> +renderComponent(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testCustomIncludes() + { + $this->compiler->include('app.includes.input', 'input'); + + $string = '@input'; + $expected = 'make(\'app.includes.input\', [], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testCustomIncludesWithData() + { + $this->compiler->include('app.includes.input', 'input'); + + $string = '@input([\'type\' => \'email\'])'; + $expected = 'make(\'app.includes.input\', [\'type\' => \'email\'], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testCustomIncludesDefaultAlias() + { + $this->compiler->include('app.includes.input'); + + $string = '@input'; + $expected = 'make(\'app.includes.input\', [], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testCustomIncludesWithExistingDirective() + { + $this->compiler->include('app.includes.foreach'); + + $string = '@foreach'; + $expected = 'make(\'app.includes.foreach\', [], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testUnescapedNonRegisteredDirective() + { + $string = '@media only screen and (min-width:480px) {'; + $expected = '@media only screen and (min-width:480px) {'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeEchoHandlerTest.php b/tests/View/Blade/BladeEchoHandlerTest.php new file mode 100644 index 000000000..b10cc4110 --- /dev/null +++ b/tests/View/Blade/BladeEchoHandlerTest.php @@ -0,0 +1,151 @@ +compiler->stringable(function (Fluent $object) { + return 'Hello World'; + }); + } + + public function testBladeHandlerCanInterceptRegularEchos() + { + $this->assertSame( + "applyEchoHandler(\$exampleObject)); ?>", + $this->compiler->compileString('{{$exampleObject}}') + ); + } + + public function testBladeHandlerCanInterceptRawEchos() + { + $this->assertSame( + "applyEchoHandler(\$exampleObject); ?>", + $this->compiler->compileString('{!!$exampleObject!!}') + ); + } + + public function testBladeHandlerCanInterceptEscapedEchos() + { + $this->assertSame( + "applyEchoHandler(\$exampleObject)); ?>", + $this->compiler->compileString('{{{$exampleObject}}}') + ); + } + + public function testWhitespaceIsPreservedCorrectly() + { + $this->assertSame( + "applyEchoHandler(\$exampleObject)); ?>\n\n", + $this->compiler->compileString("{{\$exampleObject}}\n") + ); + } + + #[DataProvider('handlerLogicDataProvider')] + public function testHandlerLogicWorksCorrectly($blade) + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('The fluent object has been successfully handled!'); + + $this->compiler->stringable(Fluent::class, function ($object) { + throw new Exception('The fluent object has been successfully handled!'); + }); + + $app = $this->createApplication(); + $app->instance('blade.compiler', $this->compiler); + + $exampleObject = new Fluent(); + + eval((new Stringable($this->compiler->compileString($blade)))->remove([''])); + } + + public static function handlerLogicDataProvider() + { + return [ + ['{{$exampleObject}}'], + ['{{$exampleObject;}}'], + ['{{{$exampleObject;}}}'], + ['{!!$exampleObject;!!}'], + ]; + } + + protected function createApplication() + { + $container = new Application( + new DefinitionSource([]), + 'bath_path', + ); + ApplicationContext::setContainer($container); + + return $container; + } + + #[DataProvider('handlerWorksWithIterableDataProvider')] + public function testHandlerWorksWithIterables($blade, $closure, $expectedOutput) + { + $this->compiler->stringable('iterable', $closure); + + $app = $this->createApplication(); + $app->instance('blade.compiler', $this->compiler); + + ob_start(); + eval((new Stringable($this->compiler->compileString($blade)))->remove([''])); + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertSame($expectedOutput, $output); + } + + public static function handlerWorksWithIterableDataProvider() + { + return [ + [ + '{{[1,"two",3]}}', + fn (iterable $arr) => implode(', ', $arr), + '1, two, 3', + ], + ]; + } + + #[DataProvider('nonStringableDataProvider')] + public function testHandlerWorksWithNonStringables($blade, $expectedOutput) + { + $app = $this->createApplication(); + $app->instance('blade.compiler', $this->compiler); + + ob_start(); + eval((new Stringable($this->compiler->compileString($blade)))->remove([''])); + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertSame($expectedOutput, $output); + } + + public static function nonStringableDataProvider() + { + return [ + ['{{"foo" . "bar"}}', 'foobar'], + ['{{ 1 + 2 }}{{ "test"; }}', '3test'], + ['@php($test = "hi"){{ $test }}', 'hi'], + ['{!! " " !!}', ' '], + ]; + } +} diff --git a/tests/View/Blade/BladeEchoTest.php b/tests/View/Blade/BladeEchoTest.php new file mode 100644 index 000000000..751e14923 --- /dev/null +++ b/tests/View/Blade/BladeEchoTest.php @@ -0,0 +1,90 @@ +assertSame('', $this->compiler->compileString('{!!$name!!}')); + $this->assertSame('', $this->compiler->compileString('{!! $name !!}')); + $this->assertSame('', $this->compiler->compileString('{!! + $name + !!}')); + + $this->assertSame('', $this->compiler->compileString('{{{$name}}}')); + $this->assertSame('', $this->compiler->compileString('{{$name}}')); + $this->assertSame('', $this->compiler->compileString('{{ $name }}')); + $this->assertSame('', $this->compiler->compileString('{{ + $name + }}')); + $this->assertSame("\n\n", $this->compiler->compileString("{{ \$name }}\n")); + $this->assertSame("\r\n\r\n", $this->compiler->compileString("{{ \$name }}\r\n")); + $this->assertSame("\n\n", $this->compiler->compileString("{{ \$name }}\n")); + $this->assertSame("\r\n\r\n", $this->compiler->compileString("{{ \$name }}\r\n")); + + $this->assertSame( + '', + $this->compiler->compileString('{{ "Hello world or foo" }}') + ); + $this->assertSame( + '', + $this->compiler->compileString('{{"Hello world or foo"}}') + ); + $this->assertSame('', $this->compiler->compileString('{{$foo + $or + $baz}}')); + $this->assertSame('', $this->compiler->compileString('{{ + "Hello world or foo" + }}')); + + $this->assertSame( + '', + $this->compiler->compileString('{{ \'Hello world or foo\' }}') + ); + $this->assertSame( + '', + $this->compiler->compileString('{{\'Hello world or foo\'}}') + ); + $this->assertSame('', $this->compiler->compileString('{{ + \'Hello world or foo\' + }}')); + + $this->assertSame( + '', + $this->compiler->compileString('{{ myfunc(\'foo or bar\') }}') + ); + $this->assertSame( + '', + $this->compiler->compileString('{{ myfunc("foo or bar") }}') + ); + $this->assertSame( + '', + $this->compiler->compileString('{{ myfunc("$name or \'foo\'") }}') + ); + } + + public function testEscapedWithAtEchosAreCompiled() + { + $this->assertSame('{{$name}}', $this->compiler->compileString('@{{$name}}')); + $this->assertSame('{{ $name }}', $this->compiler->compileString('@{{ $name }}')); + $this->assertSame( + '{{ + $name + }}', + $this->compiler->compileString('@{{ + $name + }}') + ); + $this->assertSame( + '{{ $name }} + ', + $this->compiler->compileString('@{{ $name }} + ') + ); + } +} diff --git a/tests/View/Blade/BladeElseAuthStatementsTest.php b/tests/View/Blade/BladeElseAuthStatementsTest.php new file mode 100644 index 000000000..d6a572f67 --- /dev/null +++ b/tests/View/Blade/BladeElseAuthStatementsTest.php @@ -0,0 +1,42 @@ +guard("api")->check()): ?> +breeze +guard("standard")->check()): ?> +wheeze +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testPlainElseAuthStatementsAreCompiled() + { + $string = '@auth("api") +breeze +@elseauth +wheeze +@endauth'; + $expected = 'guard("api")->check()): ?> +breeze +guard()->check()): ?> +wheeze +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeElseGuestStatementsTest.php b/tests/View/Blade/BladeElseGuestStatementsTest.php new file mode 100644 index 000000000..c1d881756 --- /dev/null +++ b/tests/View/Blade/BladeElseGuestStatementsTest.php @@ -0,0 +1,27 @@ +guard("api")->guest()): ?> +breeze +guard("standard")->guest()): ?> +wheeze +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeElseIfStatementsTest.php b/tests/View/Blade/BladeElseIfStatementsTest.php new file mode 100644 index 000000000..5e8e90b10 --- /dev/null +++ b/tests/View/Blade/BladeElseIfStatementsTest.php @@ -0,0 +1,27 @@ + +breeze + +boom +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeElseStatementsTest.php b/tests/View/Blade/BladeElseStatementsTest.php new file mode 100644 index 000000000..4fc8d1360 --- /dev/null +++ b/tests/View/Blade/BladeElseStatementsTest.php @@ -0,0 +1,42 @@ + +breeze + +boom +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testElseIfStatementsAreCompiled() + { + $string = '@if(name(foo(bar))) +breeze +@elseif(boom(breeze)) +boom +@endif'; + $expected = ' +breeze + +boom +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeEndSectionsTest.php b/tests/View/Blade/BladeEndSectionsTest.php new file mode 100644 index 000000000..6b58031f4 --- /dev/null +++ b/tests/View/Blade/BladeEndSectionsTest.php @@ -0,0 +1,17 @@ +assertSame('stopSection(); ?>', $this->compiler->compileString('@endsection')); + } +} diff --git a/tests/View/Blade/BladeEnvironmentStatementsTest.php b/tests/View/Blade/BladeEnvironmentStatementsTest.php new file mode 100644 index 000000000..1a475de40 --- /dev/null +++ b/tests/View/Blade/BladeEnvironmentStatementsTest.php @@ -0,0 +1,72 @@ +environment('staging')): ?> +breeze + +boom +"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testEnvStatementsWithMultipleStringParamsAreCompiled() + { + $string = "@env('staging', 'production') +breeze +@else +boom +@endenv"; + $expected = "environment('staging', 'production')): ?> +breeze + +boom +"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testEnvStatementsWithArrayParamAreCompiled() + { + $string = "@env(['staging', 'production']) +breeze +@else +boom +@endenv"; + $expected = "environment(['staging', 'production'])): ?> +breeze + +boom +"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testProductionStatementsAreCompiled() + { + $string = '@production +breeze +@else +boom +@endproduction'; + $expected = "environment('production')): ?> +breeze + +boom +"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeErrorTest.php b/tests/View/Blade/BladeErrorTest.php new file mode 100644 index 000000000..8f4ed11f6 --- /dev/null +++ b/tests/View/Blade/BladeErrorTest.php @@ -0,0 +1,53 @@ +{{ $message }} +@enderror'; + $expected = ' +getBag($__errorArgs[1] ?? \'default\'); +if ($__bag->has($__errorArgs[0])) : +if (isset($message)) { $__messageOriginal = $message; } +$message = $__bag->first($__errorArgs[0]); ?> + +'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testErrorsWithBagsAreCompiled() + { + $string = ' +@error(\'email\', \'customBag\') + {{ $message }} +@enderror'; + $expected = ' +getBag($__errorArgs[1] ?? \'default\'); +if ($__bag->has($__errorArgs[0])) : +if (isset($message)) { $__messageOriginal = $message; } +$message = $__bag->first($__errorArgs[0]); ?> + +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeEscapedTest.php b/tests/View/Blade/BladeEscapedTest.php new file mode 100644 index 000000000..6194504ec --- /dev/null +++ b/tests/View/Blade/BladeEscapedTest.php @@ -0,0 +1,42 @@ +assertSame('@foreach', $this->compiler->compileString('@@foreach')); + $this->assertSame('@verbatim @continue @endverbatim', $this->compiler->compileString('@@verbatim @@continue @@endverbatim')); + $this->assertSame('@foreach($i as $x)', $this->compiler->compileString('@@foreach($i as $x)')); + $this->assertSame('@continue @break', $this->compiler->compileString('@@continue @@break')); + $this->assertSame('@foreach( + $i as $x + )', $this->compiler->compileString('@@foreach( + $i as $x + )')); + } + + public function testNestedEscapes() + { + $template = ' +@foreach($cols as $col) + @@foreach($issues as $issue_45915) + 👋 سلام 👋 + @@endforeach +@endforeach'; + $compiled = ' +addLoop($__currentLoopData); foreach($__currentLoopData as $col): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> + @foreach($issues as $issue_45915) + 👋 سلام 👋 + @endforeach +popLoop(); $loop = $__env->getLastLoop(); ?>'; + $this->assertSame($compiled, $this->compiler->compileString($template)); + } +} diff --git a/tests/View/Blade/BladeExpressionTest.php b/tests/View/Blade/BladeExpressionTest.php new file mode 100644 index 000000000..1e9185563 --- /dev/null +++ b/tests/View/Blade/BladeExpressionTest.php @@ -0,0 +1,24 @@ +assertSame('get(foo(bar(baz(qux(breeze()))))); ?> space () get(foo(bar)); ?>', $this->compiler->compileString('@lang(foo(bar(baz(qux(breeze()))))) space () @lang(foo(bar))')); + } + + public function testExpressionWithinHTML() + { + $this->assertSame('>', $this->compiler->compileString('')); + $this->assertSame('>', $this->compiler->compileString('')); + $this->assertSame(' get(\'foo\'); ?>>', $this->compiler->compileString('')); + } +} diff --git a/tests/View/Blade/BladeExtendsTest.php b/tests/View/Blade/BladeExtendsTest.php new file mode 100644 index 000000000..c2e07c1a3 --- /dev/null +++ b/tests/View/Blade/BladeExtendsTest.php @@ -0,0 +1,49 @@ +make(\'foo\', array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = '@extends(name(foo))' . "\n" . 'test'; + $expected = "test\n" . 'make(name(foo), array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testSequentialCompileStringCalls() + { + $string = '@extends(\'foo\') +test'; + $expected = "test\n" . 'make(\'foo\', array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + // use the same compiler instance to compile another template with @extends directive + $string = "@extends(name(foo))\ntest"; + $expected = "test\n" . 'make(name(foo), array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testExtendsFirstAreCompiled() + { + $string = '@extendsFirst([\'foo\', \'milwad\']) +test'; + $expected = "test\n" . 'first([\'foo\', \'milwad\'], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = '@extendsFirst([name(foo), name(milwad)])' . "\n" . 'test'; + $expected = "test\n" . 'first([name(foo), name(milwad)], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeForStatementsTest.php b/tests/View/Blade/BladeForStatementsTest.php new file mode 100644 index 000000000..eb7d7bd2f --- /dev/null +++ b/tests/View/Blade/BladeForStatementsTest.php @@ -0,0 +1,38 @@ + +test +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testNestedForStatementsAreCompiled() + { + $string = '@for ($i = 0; $i < 10; $i++) +@for ($j = 0; $j < 20; $j++) +test +@endfor +@endfor'; + $expected = ' + +test + +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeForeachStatementsTest.php b/tests/View/Blade/BladeForeachStatementsTest.php new file mode 100644 index 000000000..95a67aa3d --- /dev/null +++ b/tests/View/Blade/BladeForeachStatementsTest.php @@ -0,0 +1,127 @@ +getUsers() as $user) +test +@endforeach'; + $expected = 'getUsers(); $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $user): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> +test +popLoop(); $loop = $__env->getLastLoop(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testForeachStatementsAreCompileWithUppercaseSyntax() + { + $string = '@foreach ($this->getUsers() AS $user) +test +@endforeach'; + $expected = 'getUsers(); $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $user): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> +test +popLoop(); $loop = $__env->getLastLoop(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testForeachStatementsAreCompileWithMultipleLine() + { + $string = '@foreach ([ +foo, +bar, +] as $label) +test +@endforeach'; + $expected = 'addLoop($__currentLoopData); foreach($__currentLoopData as $label): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> +test +popLoop(); $loop = $__env->getLastLoop(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testNestedForeachStatementsAreCompiled() + { + $string = '@foreach ($this->getUsers() as $user) +user info +@foreach ($user->tags as $tag) +tag info +@endforeach +@endforeach'; + $expected = 'getUsers(); $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $user): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> +user info +tags; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $tag): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> +tag info +popLoop(); $loop = $__env->getLastLoop(); ?> +popLoop(); $loop = $__env->getLastLoop(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testLoopContentHolderIsExtractedFromForeachStatements() + { + $string = '@foreach ($some_uSers1 as $user)'; + $expected = 'addLoop($__currentLoopData); foreach($__currentLoopData as $user): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = '@foreach ($users->get() as $user)'; + $expected = 'get(); $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $user): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = '@foreach (range(1, 4) as $user)'; + $expected = 'addLoop($__currentLoopData); foreach($__currentLoopData as $user): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = '@foreach ( $users as $user)'; + $expected = 'addLoop($__currentLoopData); foreach($__currentLoopData as $user): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = '@foreach ($tasks as $task)'; + $expected = 'addLoop($__currentLoopData); foreach($__currentLoopData as $task): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = "@foreach(resolve('App\\\\DataProviders\\\\'.\$provider)->data() as \$key => \$value) + +@endforeach"; + $expected = "data(); \$__env->addLoop(\$__currentLoopData); foreach(\$__currentLoopData as \$key => \$value): \$__env->incrementLoopIndices(); \$loop = \$__env->getLastLoop(); ?> + > +popLoop(); \$loop = \$__env->getLastLoop(); ?>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + #[DataProvider('invalidForeachStatementsDataProvider')] + public function testForeachStatementsThrowHumanizedMessageWhenInvalidStatement($initialStatement) + { + $this->expectException(ViewCompilationException::class); + $this->expectExceptionMessage('Malformed @foreach statement.'); + $string = "{$initialStatement} +test +@endforeach"; + $this->compiler->compileString($string); + } + + public static function invalidForeachStatementsDataProvider() + { + return [ + ['@foreach'], + ['@foreach()'], + ['@foreach ()'], + ['@foreach($test)'], + ['@foreach($test as)'], + ['@foreach(as)'], + ['@foreach ( as )'], + ]; + } +} diff --git a/tests/View/Blade/BladeForelseStatementsTest.php b/tests/View/Blade/BladeForelseStatementsTest.php new file mode 100644 index 000000000..9d10a841d --- /dev/null +++ b/tests/View/Blade/BladeForelseStatementsTest.php @@ -0,0 +1,115 @@ +getUsers() as $user) +breeze +@empty +empty +@endforelse'; + $expected = 'getUsers(); $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $user): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?> +breeze +popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?> +empty +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testForelseStatementsAreCompiledWithUppercaseSyntax() + { + $string = '@forelse ($this->getUsers() AS $user) +breeze +@empty +empty +@endforelse'; + $expected = 'getUsers(); $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $user): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?> +breeze +popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?> +empty +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testForelseStatementsAreCompiledWithMultipleLine() + { + $string = '@forelse ([ +foo, +bar, +] as $label) +breeze +@empty +empty +@endforelse'; + $expected = 'addLoop($__currentLoopData); foreach($__currentLoopData as $label): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?> +breeze +popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?> +empty +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testNestedForelseStatementsAreCompiled() + { + $string = '@forelse ($this->getUsers() as $user) +@forelse ($user->tags as $tag) +breeze +@empty +tag empty +@endforelse +@empty +empty +@endforelse'; + $expected = 'getUsers(); $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $user): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?> +tags; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $tag): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_2 = false; ?> +breeze +popLoop(); $loop = $__env->getLastLoop(); if ($__empty_2): ?> +tag empty + +popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?> +empty +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + #[DataProvider('invalidForelseStatementsDataProvider')] + public function testForelseStatementsThrowHumanizedMessageWhenInvalidStatement($initialStatement) + { + $this->expectException(ViewCompilationException::class); + $this->expectExceptionMessage('Malformed @forelse statement.'); + $string = "{$initialStatement} +breeze +@empty +tag empty +@endforelse"; + $this->compiler->compileString($string); + } + + public static function invalidForelseStatementsDataProvider() + { + return [ + ['@forelse'], + ['@forelse()'], + ['@forelse ()'], + ['@forelse($test)'], + ['@forelse($test as)'], + ['@forelse(as)'], + ['@forelse ( as )'], + ]; + } +} diff --git a/tests/View/Blade/BladeFragmentTest.php b/tests/View/Blade/BladeFragmentTest.php new file mode 100644 index 000000000..fcddf1b9d --- /dev/null +++ b/tests/View/Blade/BladeFragmentTest.php @@ -0,0 +1,23 @@ +assertSame('startFragment(\'foo\'); ?>', $this->compiler->compileString('@fragment(\'foo\')')); + $this->assertSame('startFragment(name(foo)); ?>', $this->compiler->compileString('@fragment(name(foo))')); + } + + public function testEndFragmentsAreCompiled() + { + $this->assertSame('stopFragment(); ?>', $this->compiler->compileString('@endfragment')); + } +} diff --git a/tests/View/Blade/BladeHasSectionTest.php b/tests/View/Blade/BladeHasSectionTest.php new file mode 100644 index 000000000..2b3169b59 --- /dev/null +++ b/tests/View/Blade/BladeHasSectionTest.php @@ -0,0 +1,23 @@ +yieldContent("section")))): ?> +breeze +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeHelpersTest.php b/tests/View/Blade/BladeHelpersTest.php new file mode 100644 index 000000000..f9459cf44 --- /dev/null +++ b/tests/View/Blade/BladeHelpersTest.php @@ -0,0 +1,26 @@ +assertSame('', $this->compiler->compileString('@csrf')); + $this->assertSame('', $this->compiler->compileString("@method('patch')")); + $this->assertSame('', $this->compiler->compileString('@dd($var1)')); + $this->assertSame('', $this->compiler->compileString('@dd($var1, $var2)')); + $this->assertSame('', $this->compiler->compileString('@dump($var1, $var2)')); + $this->assertSame('', $this->compiler->compileString('@vite')); + $this->assertSame('', $this->compiler->compileString('@vite()')); + $this->assertSame('', $this->compiler->compileString('@vite(\'resources/js/app.js\')')); + $this->assertSame('', $this->compiler->compileString('@vite([\'resources/js/app.js\'])')); + $this->assertSame('reactRefresh(); ?>', $this->compiler->compileString('@viteReactRefresh')); + } +} diff --git a/tests/View/Blade/BladeIfAuthStatementsTest.php b/tests/View/Blade/BladeIfAuthStatementsTest.php new file mode 100644 index 000000000..5de26d64c --- /dev/null +++ b/tests/View/Blade/BladeIfAuthStatementsTest.php @@ -0,0 +1,34 @@ +guard("api")->check()): ?> +breeze +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testPlainIfStatementsAreCompiled() + { + $string = '@auth +breeze +@endauth'; + $expected = 'guard()->check()): ?> +breeze +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeIfEmptyStatementsTest.php b/tests/View/Blade/BladeIfEmptyStatementsTest.php new file mode 100644 index 000000000..a637977d2 --- /dev/null +++ b/tests/View/Blade/BladeIfEmptyStatementsTest.php @@ -0,0 +1,23 @@ + +breeze +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeIfGuestStatementsTest.php b/tests/View/Blade/BladeIfGuestStatementsTest.php new file mode 100644 index 000000000..1b1b30ae7 --- /dev/null +++ b/tests/View/Blade/BladeIfGuestStatementsTest.php @@ -0,0 +1,23 @@ +guard("api")->guest()): ?> +breeze +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeIfIssetStatementsTest.php b/tests/View/Blade/BladeIfIssetStatementsTest.php new file mode 100644 index 000000000..9fa6ba23d --- /dev/null +++ b/tests/View/Blade/BladeIfIssetStatementsTest.php @@ -0,0 +1,23 @@ + +breeze +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeIfStatementsTest.php b/tests/View/Blade/BladeIfStatementsTest.php new file mode 100644 index 000000000..d4cd0a3fb --- /dev/null +++ b/tests/View/Blade/BladeIfStatementsTest.php @@ -0,0 +1,62 @@ + +breeze +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testSwitchstatementsAreCompiled() + { + $string = '@switch(true) +@case(1) +foo + +@case(2) +bar +@endswitch + +foo + +@switch(true) +@case(1) +foo + +@case(2) +bar +@endswitch'; + $expected = ' +foo + + +bar + + +foo + + +foo + + +bar +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeIncludesTest.php b/tests/View/Blade/BladeIncludesTest.php new file mode 100644 index 000000000..9699039d7 --- /dev/null +++ b/tests/View/Blade/BladeIncludesTest.php @@ -0,0 +1,55 @@ +assertSame('renderEach(\'foo\', \'bar\'); ?>', $this->compiler->compileString('@each(\'foo\', \'bar\')')); + $this->assertSame('renderEach(\'foo\', \'(bar))\'); ?>', $this->compiler->compileString('@each(\'foo\', \'(bar))\')')); + $this->assertSame('renderEach(name(foo)); ?>', $this->compiler->compileString('@each(name(foo))')); + } + + public function testIncludesAreCompiled() + { + $this->assertSame('make(\'foo\', array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>', $this->compiler->compileString('@include(\'foo\')')); + $this->assertSame('make(\'foo\', [\'((\'], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>', $this->compiler->compileString('@include(\'foo\', [\'((\'])')); + $this->assertSame('make(\'foo\', [\'((a)\' => \'((a)\'], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>', $this->compiler->compileString('@include(\'foo\', [\'((a)\' => \'((a)\'])')); + $this->assertSame('make(name(foo), array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>', $this->compiler->compileString('@include(name(foo))')); + } + + public function testIncludeIfsAreCompiled() + { + $this->assertSame('exists(\'foo\')) echo $__env->make(\'foo\', array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>', $this->compiler->compileString('@includeIf(\'foo\')')); + $this->assertSame('exists(name(foo))) echo $__env->make(name(foo), array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>', $this->compiler->compileString('@includeIf(name(foo))')); + } + + public function testIncludeWhensAreCompiled() + { + $this->assertSame('renderWhen(true, \'foo\', ["foo" => "bar"], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1])); ?>', $this->compiler->compileString('@includeWhen(true, \'foo\', ["foo" => "bar"])')); + $this->assertSame('renderWhen(true, \'foo\', array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1])); ?>', $this->compiler->compileString('@includeWhen(true, \'foo\')')); + } + + public function testIncludeUnlessesAreCompiled() + { + $this->assertSame('renderUnless(true, \'foo\', ["foo" => "bar"], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1])); ?>', $this->compiler->compileString('@includeUnless(true, \'foo\', ["foo" => "bar"])')); + $this->assertSame('renderUnless(true, \'foo\', ["foo" => "bar_))-))>"], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1])); ?>', $this->compiler->compileString('@includeUnless(true, \'foo\', ["foo" => "bar_))-))>"])')); + $this->assertSame('renderUnless($undefined ?? true, \'foo\', array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1])); ?>', $this->compiler->compileString('@includeUnless($undefined ?? true, \'foo\')')); + } + + public function testIncludeFirstsAreCompiled() + { + $this->assertSame('first(["one", "two"], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>', $this->compiler->compileString('@includeFirst(["one", "two"])')); + $this->assertSame('first(["one", "two"], ["foo" => "bar"], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>', $this->compiler->compileString('@includeFirst(["one", "two"], ["foo" => "bar"])')); + $this->assertSame('first(["issue", "#45424)"], ["foo()" => "bar)-))"], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>', $this->compiler->compileString('@includeFirst(["issue", "#45424)"], ["foo()" => "bar)-))"])')); + $this->assertSame('first(["issue", "#45424)"], ["foo" => "bar(-(("], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>', $this->compiler->compileString('@includeFirst(["issue", "#45424)"], ["foo" => "bar(-(("])')); + $this->assertSame('first(["issue", "#45424)"], [(string) "foo()" => "bar(-(("], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>', $this->compiler->compileString('@includeFirst(["issue", "#45424)"], [(string) "foo()" => "bar(-(("])')); + } +} diff --git a/tests/View/Blade/BladeInjectTest.php b/tests/View/Blade/BladeInjectTest.php new file mode 100644 index 000000000..6086532eb --- /dev/null +++ b/tests/View/Blade/BladeInjectTest.php @@ -0,0 +1,40 @@ + bar"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testDependenciesInjectedAsStringsAreCompiledWhenInjectedWithDoubleQuotes() + { + $string = 'Foo @inject("baz", "SomeNamespace\SomeClass") bar'; + $expected = 'Foo bar'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testDependenciesAreCompiled() + { + $string = "Foo @inject('baz', SomeNamespace\\SomeClass::class) bar"; + $expected = 'Foo bar'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testDependenciesAreCompiledWithDoubleQuotes() + { + $string = 'Foo @inject("baz", SomeNamespace\SomeClass::class) bar'; + $expected = 'Foo bar'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeJsTest.php b/tests/View/Blade/BladeJsTest.php new file mode 100644 index 000000000..3e819e1b4 --- /dev/null +++ b/tests/View/Blade/BladeJsTest.php @@ -0,0 +1,36 @@ +'; + $expected = '
'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testJsonFlagsCanBeSet() + { + $string = '
'; + $expected = '
'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testEncodingDepthCanBeSet() + { + $string = '
'; + $expected = '
'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeJsonTest.php b/tests/View/Blade/BladeJsonTest.php new file mode 100644 index 000000000..079c0bd28 --- /dev/null +++ b/tests/View/Blade/BladeJsonTest.php @@ -0,0 +1,28 @@ +;'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testEncodingOptionsCanBeOverwritten() + { + $string = 'var foo = @json($var, JSON_HEX_TAG);'; + $expected = 'var foo = ;'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeLangTest.php b/tests/View/Blade/BladeLangTest.php new file mode 100644 index 000000000..0410335ca --- /dev/null +++ b/tests/View/Blade/BladeLangTest.php @@ -0,0 +1,25 @@ +get(function_call('foo(blah)')); ?> bar"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testLanguageAndChoicesAreCompiled() + { + $this->assertSame('get(\'foo\'); ?>', $this->compiler->compileString("@lang('foo')")); + $this->assertSame('choice(\'foo\', 1); ?>', $this->compiler->compileString("@choice('foo', 1)")); + } +} diff --git a/tests/View/Blade/BladeOverwriteSectionTest.php b/tests/View/Blade/BladeOverwriteSectionTest.php new file mode 100644 index 000000000..820f6bb0f --- /dev/null +++ b/tests/View/Blade/BladeOverwriteSectionTest.php @@ -0,0 +1,17 @@ +assertSame('stopSection(true); ?>', $this->compiler->compileString('@overwrite')); + } +} diff --git a/tests/View/Blade/BladePhpStatementsTest.php b/tests/View/Blade/BladePhpStatementsTest.php new file mode 100644 index 000000000..4eb3f0674 --- /dev/null +++ b/tests/View/Blade/BladePhpStatementsTest.php @@ -0,0 +1,187 @@ +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testStringWithParenthesisWithEndPHP() + { + $string = "@php(\$data = ['related_to' => 'issue#45388'];) {{ \$data }} @endphp"; + $expected = " 'issue#45388'];) {{ \$data }} ?>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testPhpStatementsWithoutExpressionAreIgnored() + { + $string = '@php'; + $expected = '@php'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = '{{ "Ignore: @php" }}'; + $expected = ''; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testPhpStatementsDontParseBladeCode() + { + $string = '@php echo "{{ This is a blade tag }}" @endphp'; + $expected = ''; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testVerbatimAndPhpStatementsDontGetMixedUp() + { + $string = "@verbatim {{ Hello, I'm not blade! }}" + . "\n@php echo 'And I'm not PHP!' @endphp" + . "\n@endverbatim {{ 'I am Blade' }}" + . "\n@php echo 'I am PHP {{ not Blade }}' @endphp"; + + $expected = " {{ Hello, I'm not blade! }}" + . "\n@php echo 'And I'm not PHP!' @endphp" + . "\n " + . "\n\n"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testStringWithOpeningParenthesisCanBeCompiled() + { + $string = "@php(\$data = ['single' => ':(('])"; + $expected = " ':((']); ?>"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = "@php(\$data = ['single' => (string)':(('])"; + $expected = " (string)':((']); ?>"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = "@php(\$data = ['single' => '(()(('])"; + $expected = " '(()((']); ?>"; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testStringWithParenthesisCanBeCompiled() + { + $string = "@php(\$data = ['single' => ')'])"; + $expected = " ')']); ?>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = "@php(\$data = ['(multiple)-))' => '((-))'])"; + $expected = " '((-))']); ?>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = "@php(\$data = [(int)'(multiple)-))' => (bool)'((casty))'])"; + $expected = " (bool)'((casty))']); ?>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $this->assertSame('renderEach(\'foo\', \'b)a)r\'); ?>', $this->compiler->compileString('@each(\'foo\', \'b)a)r\')')); + $this->assertSame('make(\'test_for\', [\'issue))\' => \'(issue#45424))\'], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>', $this->compiler->compileString('@include(\'test_for\', [\'issue))\' => \'(issue#45424))\'])')); + $this->assertSame('( make(\'test_for\', [\'not_too_much))\' => \'(issue#45424))\'], array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?>))', $this->compiler->compileString('( @include(\'test_for\', [\'not_too_much))\' => \'(issue#45424))\'])))')); + } + + public function testStringWithEmptyStringDataValue() + { + $string = "@php(\$data = ['test' => ''])"; + + $expected = " '']); ?>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = "@php(\$data = ['test' => \"\"])"; + + $expected = " \"\"]); ?>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testStringWithEscapingDataValue() + { + $string = "@php(\$data = ['test' => 'won\\'t break'])"; + + $expected = " 'won\\'t break']); ?>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = "@php(\$data = ['test' => \"\\\"escaped\\\"\"])"; + + $expected = " \"\\\"escaped\\\"\"]); ?>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testUnclosedParenthesisForBladeTags() + { + $string = ""; + $expected = "\"(['(']>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = ""; + $expected = "\"(['']>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = ""; + $expected = ""; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = ""; + $expected = ""; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testNestedTagCalls() + { + $string = " @empty(\$v)])>"; + $expected = ''; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = " @empty(\$v)])>"; + $expected = ''; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = " @empty(\$v), 't' => @empty(\$v1)])>"; + $expected = ''; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = " @empty(\$v), 't' => @empty(\$v1)])>"; + $expected = ''; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = " @empty(\$v), 't' => @empty(\$v1), 'r' => @empty(\$v2)])>"; + $expected = ''; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = " @empty(\$v), 't))' => @empty(\$v1), 'r' => @empty(\$v2)])>"; + $expected = ''; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = " @empty(\$v), 't' => @empty(\$v1), 'r' => @empty(\$v2), 'l' => 'l'])> @empty(\$v)])>"; + $expected = ''; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testItDoesNotCompileInvalidSyntax() + { + $template = " ()])>"; + $this->assertEquals($template, $this->compiler->compileString($template)); + } +} diff --git a/tests/View/Blade/BladePrependTest.php b/tests/View/Blade/BladePrependTest.php new file mode 100644 index 000000000..451df97e6 --- /dev/null +++ b/tests/View/Blade/BladePrependTest.php @@ -0,0 +1,63 @@ +startPrepend(\'foo\'); ?> +bar +stopPrepend(); ?>'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testPrependOnceIsCompiled() + { + $string = '@prependOnce(\'foo\', \'bar\') +test +@endPrependOnce'; + + $expected = 'hasRenderedOnce(\'bar\')): $__env->markAsRenderedOnce(\'bar\'); +$__env->startPrepend(\'foo\'); ?> +test +stopPrepend(); endif; ?>'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testPrependOnceIsCompiledWhenIdIsMissing() + { + $uuid = Mockery::mock(UuidInterface::class); + $uuid->shouldReceive('__toString')->andReturn('e60e8f77-9ac3-4f71-9f8e-a044ef481d7f'); + $factory = Mockery::mock(UuidFactoryInterface::class); + $factory->shouldReceive('uuid4')->andReturn($uuid); + Uuid::setFactory($factory); + + $string = '@prependOnce(\'foo\') +test +@endPrependOnce'; + + $expected = 'hasRenderedOnce(\'e60e8f77-9ac3-4f71-9f8e-a044ef481d7f\')): $__env->markAsRenderedOnce(\'e60e8f77-9ac3-4f71-9f8e-a044ef481d7f\'); +$__env->startPrepend(\'foo\'); ?> +test +stopPrepend(); endif; ?>'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladePropsTest.php b/tests/View/Blade/BladePropsTest.php new file mode 100644 index 000000000..ae1f99966 --- /dev/null +++ b/tests/View/Blade/BladePropsTest.php @@ -0,0 +1,69 @@ +assertSame(' true, \'two\' => \'string\'])); + +foreach ($attributes->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(([\'one\' => true, \'two\' => \'string\']), \'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); ?>', $this->compiler->compileString('@props([\'one\' => true, \'two\' => \'string\'])')); + } + + public function testPropsAreExtractedFromParentAttributesCorrectly() + { + $test1 = $test2 = $test4 = null; + + $attributes = new ComponentAttributeBag(['test1' => 'value1', 'test2' => 'value2', 'test3' => 'value3']); + + $template = $this->compiler->compileString('@props([\'test1\' => \'default\', \'test2\', \'test4\' => \'default\'])'); + + ob_start(); + eval(" ?> {$template} assertSame($test1, 'value1'); + $this->assertSame($test2, 'value2'); + $this->assertFalse(isset($test3)); + $this->assertSame($test4, 'default'); + + $this->assertNull($attributes->get('test1')); + $this->assertNull($attributes->get('test2')); + $this->assertSame($attributes->get('test3'), 'value3'); + } +} diff --git a/tests/View/Blade/BladePushTest.php b/tests/View/Blade/BladePushTest.php new file mode 100644 index 000000000..1077be5a0 --- /dev/null +++ b/tests/View/Blade/BladePushTest.php @@ -0,0 +1,105 @@ +startPush(\'foo\'); ?> +test +stopPush(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testPushIsCompiledWithParenthesis() + { + $string = '@push(\'foo):))\') +test +@endpush'; + $expected = 'startPush(\'foo):))\'); ?> +test +stopPush(); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testPushOnceIsCompiled() + { + $string = '@pushOnce(\'foo\', \'bar\') +test +@endPushOnce'; + + $expected = 'hasRenderedOnce(\'bar\')): $__env->markAsRenderedOnce(\'bar\'); +$__env->startPush(\'foo\'); ?> +test +stopPush(); endif; ?>'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testPushOnceIsCompiledWhenIdIsMissing() + { + $uuid = Mockery::mock(UuidInterface::class); + $uuid->shouldReceive('__toString')->andReturn('e60e8f77-9ac3-4f71-9f8e-a044ef481d7f'); + $factory = Mockery::mock(UuidFactoryInterface::class); + $factory->shouldReceive('uuid4')->andReturn($uuid); + Uuid::setFactory($factory); + + $string = '@pushOnce(\'foo\') +test +@endPushOnce'; + + $expected = 'hasRenderedOnce(\'e60e8f77-9ac3-4f71-9f8e-a044ef481d7f\')): $__env->markAsRenderedOnce(\'e60e8f77-9ac3-4f71-9f8e-a044ef481d7f\'); +$__env->startPush(\'foo\'); ?> +test +stopPush(); endif; ?>'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testPushIfIsCompiled() + { + $string = '@pushIf(true, \'foo\') +test +@endPushIf'; + $expected = 'startPush( \'foo\'); ?> +test +stopPush(); endif; ?>'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testPushIfElseIsCompiled() + { + $string = '@pushIf(true, \'stack\') +if +@elsePushIf(false, \'stack\') +elseif +@elsePush(\'stack\') +else +@endPushIf'; + $expected = 'startPush( \'stack\'); ?> +if +stopPush(); elseif(false): $__env->startPush( \'stack\'); ?> +elseif +stopPush(); else: $__env->startPush(\'stack\'); ?> +else +stopPush(); endif; ?>'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeSectionMissingTest.php b/tests/View/Blade/BladeSectionMissingTest.php new file mode 100644 index 000000000..330bd677b --- /dev/null +++ b/tests/View/Blade/BladeSectionMissingTest.php @@ -0,0 +1,23 @@ +yieldContent("section")))): ?> +breeze +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeSectionTest.php b/tests/View/Blade/BladeSectionTest.php new file mode 100644 index 000000000..c30a60c84 --- /dev/null +++ b/tests/View/Blade/BladeSectionTest.php @@ -0,0 +1,19 @@ +assertSame('startSection(\'foo\'); ?>', $this->compiler->compileString('@section(\'foo\')')); + $this->assertSame('startSection(\'issue#18317 :))\'); ?>', $this->compiler->compileString('@section(\'issue#18317 :))\')')); + $this->assertSame('startSection(name(foo)); ?>', $this->compiler->compileString('@section(name(foo))')); + } +} diff --git a/tests/View/Blade/BladeSessionTest.php b/tests/View/Blade/BladeSessionTest.php new file mode 100644 index 000000000..38cbc15c0 --- /dev/null +++ b/tests/View/Blade/BladeSessionTest.php @@ -0,0 +1,33 @@ +{{ $value }} +@endsession'; + $expected = ' +has($__sessionArgs[0])) : +if (isset($value)) { $__sessionPrevious[] = $value; } +$value = session()->get($__sessionArgs[0]); ?> + +'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeShowTest.php b/tests/View/Blade/BladeShowTest.php new file mode 100644 index 000000000..b695a581a --- /dev/null +++ b/tests/View/Blade/BladeShowTest.php @@ -0,0 +1,17 @@ +assertSame('yieldSection(); ?>', $this->compiler->compileString('@show')); + } +} diff --git a/tests/View/Blade/BladeStackTest.php b/tests/View/Blade/BladeStackTest.php new file mode 100644 index 000000000..a076f0626 --- /dev/null +++ b/tests/View/Blade/BladeStackTest.php @@ -0,0 +1,23 @@ +yieldPushContent(\'foo\'); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = '@stack(\'foo))\')'; + $expected = 'yieldPushContent(\'foo))\'); ?>'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeStopSectionTest.php b/tests/View/Blade/BladeStopSectionTest.php new file mode 100644 index 000000000..4509f9f16 --- /dev/null +++ b/tests/View/Blade/BladeStopSectionTest.php @@ -0,0 +1,17 @@ +assertSame('stopSection(); ?>', $this->compiler->compileString('@stop')); + } +} diff --git a/tests/View/Blade/BladeStyleTest.php b/tests/View/Blade/BladeStyleTest.php new file mode 100644 index 000000000..bd23c1a46 --- /dev/null +++ b/tests/View/Blade/BladeStyleTest.php @@ -0,0 +1,20 @@ + true, 'margin-top: 10px' => false])>"; + $expected = " true, 'margin-top: 10px' => false]) ?>\">"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeUnlessStatementsTest.php b/tests/View/Blade/BladeUnlessStatementsTest.php new file mode 100644 index 000000000..87855a130 --- /dev/null +++ b/tests/View/Blade/BladeUnlessStatementsTest.php @@ -0,0 +1,23 @@ + +breeze +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeUnsetStatementsTest.php b/tests/View/Blade/BladeUnsetStatementsTest.php new file mode 100644 index 000000000..fc8aa7754 --- /dev/null +++ b/tests/View/Blade/BladeUnsetStatementsTest.php @@ -0,0 +1,23 @@ +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + + $string = '@unset ($unset)))'; + $expected = '))'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeUseTest.php b/tests/View/Blade/BladeUseTest.php new file mode 100644 index 000000000..30c080a58 --- /dev/null +++ b/tests/View/Blade/BladeUseTest.php @@ -0,0 +1,40 @@ + bar'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testUseStatementsWithoutAsAreCompiled() + { + $string = "Foo @use('SomeNamespace\\SomeClass') bar"; + $expected = 'Foo bar'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testUseStatementsWithBackslashAtBeginningAreCompiled() + { + $string = "Foo @use('\\SomeNamespace\\SomeClass') bar"; + $expected = 'Foo bar'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testUseStatementsWithBackslashAtBeginningAndAliasedAreCompiled() + { + $string = "Foo @use('\\SomeNamespace\\SomeClass', 'Foo') bar"; + $expected = 'Foo bar'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeVerbatimTest.php b/tests/View/Blade/BladeVerbatimTest.php new file mode 100644 index 000000000..73f7072f9 --- /dev/null +++ b/tests/View/Blade/BladeVerbatimTest.php @@ -0,0 +1,110 @@ +assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testVerbatimBlocksWithMultipleLinesAreCompiled() + { + $string = 'Some text +@verbatim + {{ $a }} + @if($b) + {{ $b }} + @endif +@endverbatim'; + $expected = 'Some text + + {{ $a }} + @if($b) + {{ $b }} + @endif +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testMultipleVerbatimBlocksAreCompiled() + { + $string = '@verbatim {{ $a }} @endverbatim {{ $b }} @verbatim {{ $c }} @endverbatim'; + $expected = ' {{ $a }} {{ $c }} '; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testRawBlocksAreRenderedInTheRightOrder() + { + $string = '@php echo "#1"; @endphp @verbatim {{ #2 }} @endverbatim @verbatim {{ #3 }} @endverbatim @php echo "#4"; @endphp'; + + $expected = ' {{ #2 }} {{ #3 }} '; + + $this->assertSame($expected, $this->compiler->compileString($string)); + } + + public function testMultilineTemplatesWithRawBlocksAreRenderedInTheRightOrder() + { + $string = '{{ $first }} +@php + echo $second; +@endphp +@if ($conditional) + {{ $third }} +@endif +@include("users") +@verbatim + {{ $fourth }} @include("test") +@endverbatim +@php echo $fifth; @endphp'; + + $expected = ' + + + + + + +make("users", array_diff_key(get_defined_vars(), [\'__data\' => 1, \'__path\' => 1]))->render(); ?> + + {{ $fourth }} @include("test") + +'; + + $this->assertSame($expected, $this->compiler->compileString($string)); + } + + public function testRawBlocksDontGetMixedUpWhenSomeAreRemovedByBladeComments() + { + $string = '{{-- @verbatim Block #1 @endverbatim --}} @php "Block #2" @endphp'; + $expected = ' '; + + $this->assertSame($expected, $this->compiler->compileString($string)); + } + + public function testNewlinesAreInsertedCorrectlyAfterEcho() + { + $string = "test @verbatim\nhello world\n@endverbatim"; + $expected = "test \nhello world\n"; + $this->assertSame($expected, $this->compiler->compileString($string)); + + $string = "{{ 1 }}\nhello world\n"; + $expected = "\n\nhello world\n"; + $this->assertSame($expected, $this->compiler->compileString($string)); + + $string = "{{ 1 }}@verbatim\nhello world\n@endverbatim"; + $expected = "\n\nhello world\n"; + $this->assertSame($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeWhileStatementsTest.php b/tests/View/Blade/BladeWhileStatementsTest.php new file mode 100644 index 000000000..68fe74898 --- /dev/null +++ b/tests/View/Blade/BladeWhileStatementsTest.php @@ -0,0 +1,38 @@ + +test +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } + + public function testNestedWhileStatementsAreCompiled() + { + $string = '@while ($foo) +@while ($bar) +test +@endwhile +@endwhile'; + $expected = ' + +test + +'; + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeYieldTest.php b/tests/View/Blade/BladeYieldTest.php new file mode 100644 index 000000000..7991e8fd0 --- /dev/null +++ b/tests/View/Blade/BladeYieldTest.php @@ -0,0 +1,19 @@ +assertSame('yieldContent(\'foo\'); ?>', $this->compiler->compileString('@yield(\'foo\')')); + $this->assertSame('yieldContent(\'foo\', \'bar\'); ?>', $this->compiler->compileString('@yield(\'foo\', \'bar\')')); + $this->assertSame('yieldContent(name(foo)); ?>', $this->compiler->compileString('@yield(name(foo))')); + } +} diff --git a/tests/View/ComponentTest.php b/tests/View/ComponentTest.php new file mode 100644 index 000000000..7b95f9319 --- /dev/null +++ b/tests/View/ComponentTest.php @@ -0,0 +1,499 @@ +viewFactory = m::mock(Factory::class); + $this->config = m::mock(Config::class); + + $container = new Container( + new DefinitionSource([ + 'config' => fn () => $this->config, + 'view' => fn () => $this->viewFactory, + FactoryContract::class => fn () => $this->viewFactory, + ]) + ); + + ApplicationContext::setContainer($container); + } + + protected function tearDown(): void + { + m::close(); + + Component::flushCache(); + Component::forgetFactory(); + + parent::tearDown(); + } + + public function testInlineViewsGetCreated() + { + $this->config->shouldReceive('get')->once()->with('view.compiled')->andReturn('/tmp'); + $this->viewFactory->shouldReceive('exists')->once()->andReturn(false); + $this->viewFactory->shouldReceive('addNamespace')->once()->with('__components', '/tmp'); + + $component = new TestInlineViewComponent(); + $this->assertSame('__components::57b7a54afa0eb51fd9b88eec031c9e9e', $component->resolveView()); + } + + public function testRegularViewsGetReturnedUsingViewHelper() + { + $view = m::mock(View::class); + $this->viewFactory->shouldReceive('make')->once()->with('alert', [], [])->andReturn($view); + + $component = new TestRegularViewComponentUsingViewHelper(); + + $this->assertSame($view, $component->resolveView()); + } + + public function testRenderingStringClosureFromComponent() + { + $this->config->shouldReceive('get')->once()->with('view.compiled')->andReturn('/tmp'); + $this->viewFactory->shouldReceive('exists')->once()->andReturn(false); + $this->viewFactory->shouldReceive('addNamespace')->once()->with('__components', '/tmp'); + + $component = new class extends Component { + protected $title; + + public function __construct($title = 'World') + { + $this->title = $title; + } + + public function render(): ViewContract|Htmlable|Closure|string + { + return function (array $data) { + return "

Hello {$this->title}

"; + }; + } + }; + + $closure = $component->resolveView(); + + $viewPath = $closure([]); + + $this->viewFactory->shouldReceive('make')->with($viewPath, [], [])->andReturn('

Hello World

'); + + $this->assertInstanceOf(Closure::class, $closure); + $this->assertSame('__components::9cc08f5001b343c093ee1a396da820dc', $viewPath); + + $hash = str_replace('__components::', '', $viewPath); + $this->assertSame('

Hello World

', file_get_contents("/tmp/{$hash}.blade.php")); + } + + public function testRegularViewsGetReturnedUsingViewMethod() + { + $view = m::mock(View::class); + $this->viewFactory->shouldReceive('make')->once()->with('alert', [], [])->andReturn($view); + + $component = new TestRegularViewComponentUsingViewMethod(); + + $this->assertSame($view, $component->resolveView()); + } + + public function testRegularViewNamesGetReturned() + { + $this->viewFactory->shouldReceive('exists')->once()->andReturn(true); + $this->viewFactory->shouldReceive('addNamespace')->never(); + + $component = new TestRegularViewNameViewComponent(); + + $this->assertSame('alert', $component->resolveView()); + } + + public function testHtmlableGetReturned() + { + $component = new TestHtmlableReturningViewComponent(); + + $view = $component->resolveView(); + + $this->assertInstanceOf(Htmlable::class, $view); + $this->assertSame('

Hello foo

', $view->toHtml()); + } + + public function testResolveWithUnresolvableDependency() + { + $this->expectException(InvalidDefinitionException::class); + + TestInlineViewComponentWhereRenderDependsOnProps::resolve([]); + } + + public function testResolveDependenciesWithoutContainer() + { + $component = TestInlineViewComponentWhereRenderDependsOnProps::resolve(['content' => 'foo']); + $this->assertSame('foo', $component->render()); + + $component = new class extends Component { + public $content; + + public function __construct($a = null, $b = null) + { + $this->content = $a . $b; + } + + public function render(): ViewContract|Htmlable|Closure|string + { + return $this->content; + } + }; + + $component = $component::resolve(['a' => 'a', 'b' => 'b']); + $this->assertSame('ab', $component->render()); + } + + public function testResolveDependenciesWithContainerIfNecessary() + { + $component = TestInlineViewComponentWithContainerDependencies::resolve([]); + $this->assertSame($this->viewFactory, $component->dependency); + + $component = TestInlineViewComponentWithContainerDependenciesAndProps::resolve(['content' => 'foo']); + $this->assertSame($this->viewFactory, $component->dependency); + $this->assertSame('foo', $component->render()); + } + + public function testResolveComponentsUsing() + { + $component = new TestInlineViewComponent(); + + Component::resolveComponentsUsing(function ($class, $data) use ($component) { + $this->assertSame(Component::class, $class, 'It takes the component class name as the first parameter.'); + $this->assertSame(['foo' => 'bar'], $data, 'It takes the given data as the second parameter.'); + + return $component; + }); + + $this->assertSame($component, Component::resolve(['foo' => 'bar'])); + } + + public function testBladeViewCacheWithRegularViewNameViewComponent() + { + $component = new TestRegularViewNameViewComponent(); + + $this->viewFactory->shouldReceive('exists')->twice()->andReturn(true); + + $this->assertSame('alert', $component->resolveView()); + $this->assertSame('alert', $component->resolveView()); + $this->assertSame('alert', $component->resolveView()); + $this->assertSame('alert', $component->resolveView()); + + $cache = (fn () => $component::$bladeViewCache)->call($component); + $this->assertSame([$component::class . '::alert' => 'alert'], $cache); + + $component::flushCache(); + + $cache = (fn () => $component::$bladeViewCache)->call($component); + $this->assertSame([], $cache); + + $this->assertSame('alert', $component->resolveView()); + $this->assertSame('alert', $component->resolveView()); + $this->assertSame('alert', $component->resolveView()); + $this->assertSame('alert', $component->resolveView()); + } + + public function testBladeViewCacheWithInlineViewComponent() + { + $component = new TestInlineViewComponent(); + + $this->viewFactory->shouldReceive('exists')->twice()->andReturn(false); + + $this->config->shouldReceive('get')->twice()->with('view.compiled')->andReturn('/tmp'); + + $this->viewFactory->shouldReceive('addNamespace') + ->with('__components', '/tmp') + ->twice(); + + $compiledViewName = '__components::57b7a54afa0eb51fd9b88eec031c9e9e'; + $contents = '::Hello {{ $title }}'; + $cacheKey = $component::class . $contents; + + $this->assertSame($compiledViewName, $component->resolveView()); + $this->assertSame($compiledViewName, $component->resolveView()); + $this->assertSame($compiledViewName, $component->resolveView()); + $this->assertSame($compiledViewName, $component->resolveView()); + + $cache = (fn () => $component::$bladeViewCache)->call($component); + $this->assertSame([$cacheKey => $compiledViewName], $cache); + + $component::flushCache(); + + $cache = (fn () => $component::$bladeViewCache)->call($component); + $this->assertSame([], $cache); + + $this->assertSame($compiledViewName, $component->resolveView()); + $this->assertSame($compiledViewName, $component->resolveView()); + $this->assertSame($compiledViewName, $component->resolveView()); + $this->assertSame($compiledViewName, $component->resolveView()); + } + + public function testBladeViewCacheWithInlineViewComponentWhereRenderDependsOnProps() + { + $componentA = new TestInlineViewComponentWhereRenderDependsOnProps('A'); + $componentB = new TestInlineViewComponentWhereRenderDependsOnProps('B'); + + $this->viewFactory->shouldReceive('exists')->twice()->andReturn(false); + + $this->config->shouldReceive('get')->twice()->with('view.compiled')->andReturn('/tmp'); + + $this->viewFactory->shouldReceive('addNamespace') + ->with('__components', '/tmp') + ->twice(); + + $compiledViewNameA = '__components::9b0498cbe3839becd0d496e05c553485'; + $compiledViewNameB = '__components::9d1b9bc4078a3e7274d3766ca02423f3'; + $cacheAKey = $componentA::class . '::A'; + $cacheBKey = $componentB::class . '::B'; + + $this->assertSame($compiledViewNameA, $componentA->resolveView()); + $this->assertSame($compiledViewNameA, $componentA->resolveView()); + $this->assertSame($compiledViewNameB, $componentB->resolveView()); + $this->assertSame($compiledViewNameB, $componentB->resolveView()); + + $cacheA = (fn () => $componentA::$bladeViewCache)->call($componentA); + $cacheB = (fn () => $componentB::$bladeViewCache)->call($componentB); + $this->assertSame($cacheA, $cacheB); + $this->assertSame([ + $cacheAKey => $compiledViewNameA, + $cacheBKey => $compiledViewNameB, + ], $cacheA); + + $componentA::flushCache(); + + $cacheA = (fn () => $componentA::$bladeViewCache)->call($componentA); + $cacheB = (fn () => $componentB::$bladeViewCache)->call($componentB); + $this->assertSame($cacheA, $cacheB); + $this->assertSame([], $cacheA); + } + + public function testFactoryGetsSharedBetweenComponents() + { + $regular = new TestRegularViewNameViewComponent(); + $inline = new TestInlineViewComponent(); + + $getFactory = fn ($component) => (fn () => $component->factory())->call($component); + + $this->assertSame($this->viewFactory, $getFactory($regular)); + + $this->assertSame($this->viewFactory, $getFactory($inline)); + } + + public function testComponentSlotIsEmpty() + { + $slot = new ComponentSlot(); + + $this->assertTrue((bool) $slot->isEmpty()); + } + + public function testComponentSlotSanitizedEmpty() + { + // default sanitizer should remove all html tags + $slot = new ComponentSlot(''); + + $linebreakingSlot = new ComponentSlot("\n \t"); + + $moreComplexSlot = new ComponentSlot(''); + + $this->assertFalse((bool) $slot->hasActualContent()); + $this->assertFalse((bool) $linebreakingSlot->hasActualContent('trim')); + $this->assertFalse((bool) $moreComplexSlot->hasActualContent()); + } + + public function testComponentSlotSanitizedNotEmpty() + { + // default sanitizer should remove all html tags + $slot = new ComponentSlot('not empty'); + + $linebreakingSlot = new ComponentSlot("\ntest \t"); + + $moreComplexSlot = new ComponentSlot('beforeafter'); + + $this->assertTrue((bool) $slot->hasActualContent()); + $this->assertTrue((bool) $linebreakingSlot->hasActualContent('trim')); + $this->assertTrue((bool) $moreComplexSlot->hasActualContent()); + } + + public function testComponentSlotIsNotEmpty() + { + $slot = new ComponentSlot('test'); + + $anotherSlot = new ComponentSlot('test'); + + $moreComplexSlot = new ComponentSlot('test'); + + $this->assertTrue((bool) $slot->hasActualContent()); + $this->assertTrue((bool) $anotherSlot->hasActualContent()); + $this->assertTrue((bool) $moreComplexSlot->hasActualContent()); + } +} + +class TestInlineViewComponent extends Component +{ + public $title; + + public function __construct($title = 'foo') + { + $this->title = $title; + } + + public function render(): ViewContract|Htmlable|Closure|string + { + return 'Hello {{ $title }}'; + } +} + +class TestInlineViewComponentWithContainerDependencies extends Component +{ + public $dependency; + + public function __construct(FactoryContract $dependency) + { + $this->dependency = $dependency; + } + + public function render(): ViewContract|Htmlable|Closure|string + { + return ''; + } +} + +class TestInlineViewComponentWithContainerDependenciesAndProps extends Component +{ + public $content; + + public $dependency; + + public function __construct(FactoryContract $dependency, $content) + { + $this->content = $content; + $this->dependency = $dependency; + } + + public function render(): ViewContract|Htmlable|Closure|string + { + return $this->content; + } +} + +class TestInlineViewComponentWithoutDependencies extends Component +{ + public function render(): ViewContract|Htmlable|Closure|string + { + return 'alert'; + } +} + +class TestInlineViewComponentWhereRenderDependsOnProps extends Component +{ + public $content; + + public function __construct($content) + { + $this->content = $content; + } + + public function render(): ViewContract|Htmlable|Closure|string + { + return $this->content; + } +} + +class TestRegularViewComponentUsingViewHelper extends Component +{ + public $title; + + public function __construct($title = 'foo') + { + $this->title = $title; + } + + public function render(): ViewContract|Htmlable|Closure|string + { + return view('alert'); + } +} + +class TestRegularViewComponentUsingViewMethod extends Component +{ + public $title; + + public function __construct($title = 'foo') + { + $this->title = $title; + } + + public function render(): ViewContract|Htmlable|Closure|string + { + return $this->view('alert'); + } +} + +class TestRegularViewNameViewComponent extends Component +{ + public $title; + + public function __construct($title = 'foo') + { + $this->title = $title; + } + + public function render(): ViewContract|Htmlable|Closure|string + { + return 'alert'; + } +} + +class TestHtmlableReturningViewComponent extends Component +{ + protected $title; + + public function __construct($title = 'foo') + { + $this->title = $title; + } + + public function render(): ViewContract|Htmlable|Closure|string + { + return new HtmlString("

Hello {$this->title}

"); + } +} diff --git a/tests/View/ViewBladeCompilerTest.php b/tests/View/ViewBladeCompilerTest.php new file mode 100644 index 000000000..85f4d99b3 --- /dev/null +++ b/tests/View/ViewBladeCompilerTest.php @@ -0,0 +1,220 @@ +getFiles(), __DIR__); + $files->shouldReceive('exists')->once()->with(__DIR__ . '/' . hash('xxh128', 'v2foo') . '.php')->andReturn(false); + $this->assertTrue($compiler->isExpired('foo')); + } + + public function testIsExpiredReturnsTrueWhenModificationTimesWarrant() + { + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + $files->shouldReceive('exists')->once()->with(__DIR__ . '/' . hash('xxh128', 'v2foo') . '.php')->andReturn(true); + $files->shouldReceive('lastModified')->once()->with('foo')->andReturn(100); + $files->shouldReceive('lastModified')->once()->with(__DIR__ . '/' . hash('xxh128', 'v2foo') . '.php')->andReturn(0); + $this->assertTrue($compiler->isExpired('foo')); + } + + public function testIsExpiredReturnsFalseWhenUseCacheIsTrueAndNoFileModification() + { + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + $files->shouldReceive('exists')->once()->with(__DIR__ . '/' . hash('xxh128', 'v2foo') . '.php')->andReturn(true); + $files->shouldReceive('lastModified')->once()->with('foo')->andReturn(0); + $files->shouldReceive('lastModified')->once()->with(__DIR__ . '/' . hash('xxh128', 'v2foo') . '.php')->andReturn(100); + $this->assertFalse($compiler->isExpired('foo')); + } + + public function testIsExpiredReturnsTrueWhenUseCacheIsFalse() + { + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__, $basePath = '', $useCache = false); + $this->assertTrue($compiler->isExpired('foo')); + } + + public function testCompilePathIsProperlyCreated() + { + $compiler = new BladeCompiler($this->getFiles(), __DIR__); + $this->assertEquals(__DIR__ . '/' . hash('xxh128', 'v2foo') . '.php', $compiler->getCompiledPath('foo')); + } + + public function testCompileCompilesFileAndReturnsContents() + { + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + $files->shouldReceive('get')->once()->with('foo')->andReturn('Hello World'); + $files->shouldReceive('exists')->once()->with(__DIR__)->andReturn(true); + $files->shouldReceive('put')->once()->with(__DIR__ . '/' . hash('xxh128', 'v2foo') . '.php', 'Hello World'); + $compiler->compile('foo'); + } + + public function testCompileCompilesFileAndReturnsContentsCreatingDirectory() + { + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + $files->shouldReceive('get')->once()->with('foo')->andReturn('Hello World'); + $files->shouldReceive('exists')->once()->with(__DIR__)->andReturn(false); + $files->shouldReceive('makeDirectory')->once()->with(__DIR__, 0777, true, true); + $files->shouldReceive('put')->once()->with(__DIR__ . '/' . hash('xxh128', 'v2foo') . '.php', 'Hello World'); + $compiler->compile('foo'); + } + + public function testRawTagsCanBeSetToLegacyValues() + { + $compiler = new BladeCompiler($this->getFiles(), __DIR__); + $compiler->setEchoFormat('%s'); + + $this->assertSame('', $compiler->compileString('{{{ $name }}}')); + $this->assertSame('', $compiler->compileString('{{ $name }}')); + $this->assertSame('', $compiler->compileString('{{ + $name + }}')); + } + + /** + * @param string $content + * @param string $compiled + */ + #[DataProvider('appendViewPathDataProvider')] + public function testIncludePathToTemplate($content, $compiled) + { + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + $files->shouldReceive('get')->once()->with('foo')->andReturn($content); + $files->shouldReceive('exists')->once()->with(__DIR__)->andReturn(true); + $files->shouldReceive('put')->once()->with(__DIR__ . '/' . hash('xxh128', 'v2foo') . '.php', $compiled); + + $compiler->compile('foo'); + } + + /** + * @return array + */ + public static function appendViewPathDataProvider() + { + return [ + 'No PHP blocks' => [ + 'Hello World', + 'Hello World', + ], + 'Single PHP block without closing ?>' => [ + '', + ], + 'Ending PHP block.' => [ + 'Hello world', + 'Hello world', + ], + 'Ending PHP block without closing ?>' => [ + 'Hello world', + ], + 'PHP block between content.' => [ + 'Hello worldHi There', + 'Hello worldHi There', + ], + 'Multiple PHP blocks.' => [ + 'Hello worldHi ThereHello Again', + 'Hello worldHi ThereHello Again', + ], + 'Multiple PHP blocks without closing ?>' => [ + 'Hello worldHi ThereHi There', + ], + 'Short open echo tag' => [ + 'Hello world', + ], + 'Echo XML declaration' => [ + '\';', + '\'; ?>', + ], + ]; + } + + public function testShouldStartFromStrictTypesDeclaration() + { + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + $strictTypeDecl = "assertSame(substr( + $compiler->compileString("getFiles(), __DIR__); + + $compiler->component('App\Foo\Bar'); + $this->assertEquals(['bar' => 'App\Foo\Bar'], $compiler->getClassComponentAliases()); + + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + + $compiler->component('App\Foo\Bar', null, 'prefix'); + $this->assertEquals(['prefix-bar' => 'App\Foo\Bar'], $compiler->getClassComponentAliases()); + + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + + $compiler->component('App\View\Components\Forms\Input'); + $this->assertEquals(['forms:input' => 'App\View\Components\Forms\Input'], $compiler->getClassComponentAliases()); + + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + + $compiler->component('App\View\Components\Forms\Input', null, 'prefix'); + $this->assertEquals(['prefix-forms:input' => 'App\View\Components\Forms\Input'], $compiler->getClassComponentAliases()); + } + + public function testAnonymousComponentNamespacesCanBeStored() + { + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + + $compiler->anonymousComponentNamespace(' public/frontend ', 'frontend'); + $this->assertEquals(['frontend' => 'public.frontend'], $compiler->getAnonymousComponentNamespaces()); + + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + + $compiler->anonymousComponentNamespace('public/frontend/', 'frontend'); + $this->assertEquals(['frontend' => 'public.frontend'], $compiler->getAnonymousComponentNamespaces()); + + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + + $compiler->anonymousComponentNamespace('/admin/components', 'admin'); + $this->assertEquals(['admin' => 'admin.components'], $compiler->getAnonymousComponentNamespaces()); + + // Test directory is automatically inferred from the prefix if not given. + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + + $compiler->anonymousComponentNamespace('frontend'); + $this->assertEquals(['frontend' => 'frontend'], $compiler->getAnonymousComponentNamespaces()); + + // Test that the prefix can also contain dots. + $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + + $compiler->anonymousComponentNamespace('frontend/auth', 'frontend.auth'); + $this->assertEquals(['frontend.auth' => 'frontend.auth'], $compiler->getAnonymousComponentNamespaces()); + } + + protected function getFiles() + { + return m::mock(Filesystem::class); + } +} diff --git a/tests/View/ViewCompilerEngineTest.php b/tests/View/ViewCompilerEngineTest.php new file mode 100755 index 000000000..eddd086f5 --- /dev/null +++ b/tests/View/ViewCompilerEngineTest.php @@ -0,0 +1,303 @@ +getEngine(); + $engine->getCompiler()->shouldReceive('getCompiledPath')->with(__DIR__ . '/fixtures/foo.php')->andReturn(__DIR__ . '/fixtures/basic.php'); + $engine->getCompiler()->shouldReceive('isExpired')->once()->with(__DIR__ . '/fixtures/foo.php')->andReturn(true); + $engine->getCompiler()->shouldReceive('compile')->once()->with(__DIR__ . '/fixtures/foo.php'); + $results = $engine->get(__DIR__ . '/fixtures/foo.php'); + + $this->assertSame('Hello World +', $results); + } + + public function testViewsAreNotRecompiledIfTheyAreNotExpired() + { + $engine = $this->getEngine(); + $engine->getCompiler()->shouldReceive('getCompiledPath')->with(__DIR__ . '/fixtures/foo.php')->andReturn(__DIR__ . '/fixtures/basic.php'); + $engine->getCompiler()->shouldReceive('isExpired')->once()->andReturn(false); + $engine->getCompiler()->shouldReceive('compile')->never(); + $results = $engine->get(__DIR__ . '/fixtures/foo.php'); + + $this->assertSame('Hello World +', $results); + } + + public function testRegularExceptionsAreReThrownAsViewExceptions() + { + $engine = $this->getEngine(); + $engine->getCompiler()->shouldReceive('getCompiledPath')->with(__DIR__ . '/fixtures/foo.php')->andReturn(__DIR__ . '/fixtures/regular-exception.php'); + $engine->getCompiler()->shouldReceive('isExpired')->once()->andReturn(false); + + $this->expectException(ViewException::class); + $this->expectExceptionMessage('regular exception message'); + + $engine->get(__DIR__ . '/fixtures/foo.php'); + } + + public function testHttpExceptionsAreNotReThrownAsViewExceptions() + { + $engine = $this->getEngine(); + $engine->getCompiler()->shouldReceive('getCompiledPath')->with(__DIR__ . '/fixtures/foo.php')->andReturn(__DIR__ . '/fixtures/http-exception.php'); + $engine->getCompiler()->shouldReceive('isExpired')->once()->andReturn(false); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('http exception message'); + + $engine->get(__DIR__ . '/fixtures/foo.php'); + } + + public function testThatViewsAreNotAskTwiceIfTheyAreExpired() + { + $engine = $this->getEngine(); + $engine->getCompiler()->shouldReceive('getCompiledPath')->with(__DIR__ . '/fixtures/foo.php')->andReturn(__DIR__ . '/fixtures/basic.php'); + $engine->getCompiler()->shouldReceive('isExpired')->twice()->andReturn(false); + $engine->getCompiler()->shouldReceive('compile')->never(); + + $engine->get(__DIR__ . '/fixtures/foo.php'); + $engine->get(__DIR__ . '/fixtures/foo.php'); + } + + public function testViewsAreRecompiledWhenCompiledViewIsMissingViaFileNotFoundException() + { + $compiled = __DIR__ . '/fixtures/basic.php'; + $path = __DIR__ . '/fixtures/foo.php'; + + $files = m::mock(Filesystem::class); + $engine = $this->getEngine($files); + + $files->shouldReceive('getRequire') + ->once() + ->with($compiled, []) + ->andReturn('compiled-content'); + + $files->shouldReceive('getRequire') + ->once() + ->with($compiled, []) + ->andThrow(new FileNotFoundException( + "File does not exist at path {$path}." + )); + + $files->shouldReceive('getRequire') + ->once() + ->with($compiled, []) + ->andReturn('compiled-content'); + + $engine->getCompiler() + ->shouldReceive('getCompiledPath') + ->times(3) + ->with($path) + ->andReturn($compiled); + + $engine->getCompiler() + ->shouldReceive('isExpired') + ->twice() + ->andReturn(true); + + $engine->getCompiler() + ->shouldReceive('compile') + ->times(3) + ->with($path); + + $engine->get($path); + $engine->get($path); + } + + public function testViewsAreRecompiledWhenCompiledViewIsMissingViaRequireException() + { + $compiled = __DIR__ . '/fixtures/basic.php'; + $path = __DIR__ . '/fixtures/foo.php'; + + $files = m::mock(Filesystem::class); + $engine = $this->getEngine($files); + + $files->shouldReceive('getRequire') + ->once() + ->with($compiled, []) + ->andReturn('compiled-content'); + + $files->shouldReceive('getRequire') + ->once() + ->with($compiled, []) + ->andThrow(new ErrorException( + "require({$path}): Failed to open stream: No such file or directory", + )); + + $files->shouldReceive('getRequire') + ->once() + ->with($compiled, []) + ->andReturn('compiled-content'); + + $engine->getCompiler() + ->shouldReceive('getCompiledPath') + ->times(3) + ->with($path) + ->andReturn($compiled); + + $engine->getCompiler() + ->shouldReceive('isExpired') + ->twice() + ->andReturn(true); + + $engine->getCompiler() + ->shouldReceive('compile') + ->times(3) + ->with($path); + + $engine->get($path); + $engine->get($path); + } + + public function testViewsAreRecompiledJustOnceWhenCompiledViewIsMissing() + { + $compiled = __DIR__ . '/fixtures/basic.php'; + $path = __DIR__ . '/fixtures/foo.php'; + + $files = m::mock(Filesystem::class); + $engine = $this->getEngine($files); + + $files->shouldReceive('getRequire') + ->once() + ->with($compiled, []) + ->andReturn('compiled-content'); + + $files->shouldReceive('getRequire') + ->once() + ->with($compiled, []) + ->andThrow(new FileNotFoundException( + "File does not exist at path {$path}." + )); + + $files->shouldReceive('getRequire') + ->once() + ->with($compiled, []) + ->andThrow(new FileNotFoundException( + "File does not exist at path {$path}." + )); + + $engine->getCompiler() + ->shouldReceive('getCompiledPath') + ->times(3) + ->with($path) + ->andReturn($compiled); + + $engine->getCompiler() + ->shouldReceive('isExpired') + ->twice() + ->andReturn(true); + + $engine->getCompiler() + ->shouldReceive('compile') + ->times(3) + ->with($path); + + $engine->get($path); + + $this->expectException(ViewException::class); + $this->expectExceptionMessage("File does not exist at path {$path}."); + $engine->get($path); + } + + public function testViewsAreNotRecompiledOnRegularViewException() + { + $compiled = __DIR__ . '/fixtures/basic.php'; + $path = __DIR__ . '/fixtures/foo.php'; + + $files = m::mock(Filesystem::class); + $engine = $this->getEngine($files); + + $files->shouldReceive('getRequire') + ->once() + ->with($compiled, []) + ->andThrow(new Exception( + 'Just an regular error...' + )); + + $engine->getCompiler() + ->shouldReceive('isExpired') + ->once() + ->andReturn(false); + + $engine->getCompiler() + ->shouldReceive('compile') + ->never(); + + $engine->getCompiler() + ->shouldReceive('getCompiledPath') + ->once() + ->with($path) + ->andReturn($compiled); + + $this->expectException(ViewException::class); + $this->expectExceptionMessage('Just an regular error...'); + $engine->get($path); + } + + public function testViewsAreNotRecompiledIfTheyWereJustCompiled() + { + $compiled = __DIR__ . '/fixtures/basic.php'; + $path = __DIR__ . '/fixtures/foo.php'; + + $files = m::mock(Filesystem::class); + $engine = $this->getEngine($files); + + $files->shouldReceive('getRequire') + ->twice() + ->with($compiled, []) + ->andThrow(new FileNotFoundException( + "File does not exist at path {$path}." + )); + + $engine->getCompiler() + ->shouldReceive('isExpired') + ->once() + ->andReturn(true); + + $engine->getCompiler() + ->shouldReceive('compile') + ->twice() + ->with($path); + + $engine->getCompiler() + ->shouldReceive('getCompiledPath') + ->twice() + ->with($path) + ->andReturn($compiled); + + $this->expectException(ViewException::class); + $this->expectExceptionMessage("File does not exist at path {$path}."); + $engine->get($path); + } + + protected function getEngine($filesystem = null) + { + return new CompilerEngine(m::mock(CompilerInterface::class), $filesystem ?: new Filesystem()); + } +} diff --git a/tests/View/ViewComponentAttributeBagTest.php b/tests/View/ViewComponentAttributeBagTest.php new file mode 100644 index 000000000..81e2ce84b --- /dev/null +++ b/tests/View/ViewComponentAttributeBagTest.php @@ -0,0 +1,162 @@ + 'font-bold', 'name' => 'test']); + + $this->assertSame('class="font-bold"', (string) $bag->whereStartsWith('class')); + $this->assertSame('font-bold', (string) $bag->whereStartsWith('class')->first()); + $this->assertSame('name="test"', (string) $bag->whereDoesntStartWith('class')); + $this->assertSame('test', (string) $bag->whereDoesntStartWith('class')->first()); + $this->assertSame('class="mt-4 font-bold" name="test"', (string) $bag->merge(['class' => 'mt-4'])); + $this->assertSame('class="mt-4 font-bold" name="test"', (string) $bag->merge(['class' => 'mt-4', 'name' => 'foo'])); + $this->assertSame('class="mt-4 font-bold" id="bar" name="test"', (string) $bag->merge(['class' => 'mt-4', 'id' => 'bar'])); + $this->assertSame('class="mt-4 font-bold" name="test"', (string) $bag(['class' => 'mt-4'])); + $this->assertSame('class="mt-4 font-bold"', (string) $bag->only('class')->merge(['class' => 'mt-4'])); + $this->assertSame('name="test" class="font-bold"', (string) $bag->merge(['name' => 'default'])); + $this->assertSame('class="font-bold" name="test"', (string) $bag->merge([])); + $this->assertSame('class="mt-4 font-bold"', (string) $bag->merge(['class' => 'mt-4'])->only('class')); + $this->assertSame('class="mt-4 font-bold"', (string) $bag->only('class')(['class' => 'mt-4'])); + $this->assertSame('font-bold', $bag->get('class')); + $this->assertSame('bar', $bag->get('foo', 'bar')); + $this->assertSame('font-bold', $bag['class']); + $this->assertSame('class="mt-4 font-bold" name="test"', (string) $bag->class('mt-4')); + $this->assertSame('class="mt-4 font-bold" name="test"', (string) $bag->class(['mt-4'])); + $this->assertSame('class="mt-4 ml-2 font-bold" name="test"', (string) $bag->class(['mt-4', 'ml-2' => true, 'mr-2' => false])); + + $bag = new ComponentAttributeBag(['class' => 'font-bold', 'name' => 'test', 'style' => 'margin-top: 10px']); + $this->assertSame('class="mt-4 ml-2 font-bold" style="margin-top: 10px;" name="test"', (string) $bag->class(['mt-4', 'ml-2' => true, 'mr-2' => false])); + $this->assertSame('style="margin-top: 4px; margin-left: 10px; margin-top: 10px;" class="font-bold" name="test"', (string) $bag->style(['margin-top: 4px', 'margin-left: 10px;'])); + + $bag = new ComponentAttributeBag(['class' => 'font-bold', 'name' => 'test', 'style' => 'margin-top: 10px; font-weight: bold']); + $this->assertSame('class="mt-4 ml-2 font-bold" style="margin-top: 10px; font-weight: bold;" name="test"', (string) $bag->class(['mt-4', 'ml-2' => true, 'mr-2' => false])); + $this->assertSame('style="margin-top: 4px; margin-left: 10px; margin-top: 10px; font-weight: bold;" class="font-bold" name="test"', (string) $bag->style(['margin-top: 4px', 'margin-left: 10px;'])); + + $bag = new ComponentAttributeBag([]); + + $this->assertSame('class="mt-4"', (string) $bag->merge(['class' => 'mt-4'])); + + $bag = new ComponentAttributeBag([ + 'test-string' => 'ok', + 'test-null' => null, + 'test-false' => false, + 'test-true' => true, + 'test-0' => 0, + 'test-0-string' => '0', + 'test-empty-string' => '', + ]); + + $this->assertSame('test-string="ok" test-true="test-true" test-0="0" test-0-string="0" test-empty-string=""', (string) $bag); + $this->assertSame('test-string="ok" test-true="test-true" test-0="0" test-0-string="0" test-empty-string=""', (string) $bag->merge()); + + $bag = (new ComponentAttributeBag()) + ->merge([ + 'test-escaped' => '', + ]); + + $this->assertSame('test-escaped="<tag attr="attr">"', (string) $bag); + + $bag = (new ComponentAttributeBag()) + ->merge([ + 'test-string' => 'ok', + 'test-null' => null, + 'test-false' => false, + 'test-true' => true, + 'test-0' => 0, + 'test-0-string' => '0', + 'test-empty-string' => '', + ]); + + $this->assertSame('test-string="ok" test-true="test-true" test-0="0" test-0-string="0" test-empty-string=""', (string) $bag); + + $bag = (new ComponentAttributeBag()) + ->merge([ + 'test-extract-1' => 'extracted-1', + 'test-extract-2' => 'extracted-2', + 'test-discard-1' => 'discarded-1', + 'test-discard-2' => 'discarded-2', + ]); + + $this->assertSame('test-extract-1="extracted-1" test-extract-2="extracted-2"', (string) $bag->exceptProps([ + 'test-discard-1', + 'test-discard-2' => 'defaultValue', + ])); + + $bag = (new ComponentAttributeBag()) + ->merge([ + 'test-extract-1' => 'extracted-1', + 'test-extract-2' => 'extracted-2', + 'test-discard-1' => 'discarded-1', + 'test-discard-2' => 'discarded-2', + ]); + + $this->assertSame('test-extract-1="extracted-1" test-extract-2="extracted-2"', (string) $bag->onlyProps([ + 'test-extract-1', + 'test-extract-2' => 'defaultValue', + ])); + } + + public function testItMakesAnExceptionForAlpineXdata() + { + $bag = new ComponentAttributeBag([ + 'required' => true, + 'x-data' => true, + ]); + + $this->assertSame('required="required" x-data=""', (string) $bag); + } + + public function testItMakesAnExceptionForLivewireWireAttributes() + { + $bag = new ComponentAttributeBag([ + 'wire:loading' => true, + 'wire:loading.remove' => true, + 'wire:poll' => true, + ]); + + $this->assertSame('wire:loading="" wire:loading.remove="" wire:poll=""', (string) $bag); + } + + public function testAttributeExistence() + { + $bag = new ComponentAttributeBag(['name' => 'test']); + + $this->assertTrue((bool) $bag->has('name')); + $this->assertTrue((bool) $bag->has(['name'])); + $this->assertTrue((bool) $bag->hasAny(['class', 'name'])); + $this->assertTrue((bool) $bag->hasAny('class', 'name')); + $this->assertFalse((bool) $bag->missing('name')); + $this->assertFalse((bool) $bag->has('class')); + $this->assertFalse((bool) $bag->has(['class'])); + $this->assertFalse((bool) $bag->has(['name', 'class'])); + $this->assertFalse((bool) $bag->has('name', 'class')); + $this->assertTrue((bool) $bag->missing('class')); + } + + public function testAttributeIsEmpty() + { + $bag = new ComponentAttributeBag([]); + + $this->assertTrue((bool) $bag->isEmpty()); + } + + public function testAttributeIsNotEmpty() + { + $bag = new ComponentAttributeBag(['name' => 'test']); + + $this->assertTrue((bool) $bag->isNotEmpty()); + } +} diff --git a/tests/View/ViewComponentTest.php b/tests/View/ViewComponentTest.php new file mode 100644 index 000000000..4d980ac43 --- /dev/null +++ b/tests/View/ViewComponentTest.php @@ -0,0 +1,266 @@ +data(); + + $this->assertEquals(10, $variables['votes']); + $this->assertSame('world', $variables['hello']()); + $this->assertSame('taylor', $variables['hello']('taylor')); + } + + public function testIgnoredMethodsAreNotExposedToViewData() + { + $component = new class extends Component { + protected array $except = ['goodbye']; + + public function render(): ViewContract|Htmlable|Closure|string + { + return 'test'; + } + + public function hello() + { + return 'hello world'; + } + + public function goodbye() + { + return 'goodbye'; + } + }; + + $data = $component->data(); + + $this->assertArrayHasKey('hello', $data); + $this->assertArrayNotHasKey('goodbye', $data); + + $reflectionMethod = new ReflectionMethod($component, 'ignoredMethods'); + + $ignoredMethods = $reflectionMethod->invoke($component); + + foreach ($ignoredMethods as $method) { + $this->assertArrayNotHasKey($method, $data); + } + } + + public function testAttributeParentInheritance(): void + { + $component = new TestViewComponent(); + $attributes = new ComponentAttributeBag(['class' => 'bar', 'type' => 'button']); + + $component->withAttributes(['class' => 'foo', 'attributes' => $attributes]); + + $this->assertSame('class="foo bar" type="button"', (string) $component->attributes); + + // Test overriding parent class attributes + $component->withAttributes(['class' => 'override', 'type' => 'submit']); + $this->assertSame('class="override" type="submit"', (string) $component->attributes); + } + + public function testSlotAttributeParentInheritance(): void + { + $attributes = new ComponentAttributeBag(['class' => 'bar', 'type' => 'button']); + + $slot = new ComponentSlot('test', [ + 'class' => 'foo', + 'attributes' => $attributes, + ]); + + $this->assertSame('class="foo bar" type="button"', (string) $slot->attributes); + + // Test overriding parent class attributes + $slot->withAttributes(['class' => 'override', 'type' => 'submit']); + $this->assertSame('class="override" type="submit"', (string) $slot->attributes); + } + + public function testPublicMethodsWithNoArgsAreConvertedToStringableCallablesInvokedAndNotCached() + { + $component = new TestSampleViewComponent(); + + $this->assertEquals(0, $component->counter); + $this->assertEquals(0, TestSampleViewComponent::$publicStaticCounter); + $variables = $component->data(); + $this->assertEquals(0, $component->counter); + $this->assertEquals(0, TestSampleViewComponent::$publicStaticCounter); + + $this->assertSame('noArgs val', $variables['noArgs']()); + $this->assertSame('noArgs val', (string) $variables['noArgs']); + $this->assertEquals(0, $variables['counter']); + + // make sure non-public members are not invoked nor counted. + $this->assertEquals(2, $component->counter); + $this->assertArrayHasKey('publicHello', $variables); + $this->assertArrayNotHasKey('protectedHello', $variables); + $this->assertArrayNotHasKey('privateHello', $variables); + + $this->assertArrayNotHasKey('publicStaticCounter', $variables); + $this->assertArrayNotHasKey('protectedCounter', $variables); + $this->assertArrayNotHasKey('privateCounter', $variables); + + // test each time we invoke data(), the non-argument methods aren't invoked + $this->assertEquals(2, $component->counter); + $component->data(); + $this->assertEquals(2, $component->counter); + $component->data(); + $this->assertEquals(2, $component->counter); + } + + public function testItIgnoresExceptedMethodsAndProperties() + { + $component = new TestExceptedViewComponent(); + $variables = $component->data(); + + // Ignored methods (with no args) are not invoked behind the scenes. + $this->assertSame('Otwell', $component->taylor); + + $this->assertArrayNotHasKey('hello', $variables); + $this->assertArrayNotHasKey('hello2', $variables); + $this->assertArrayNotHasKey('taylor', $variables); + } + + public function testMethodsOverridePropertyValues() + { + $component = new TestHelloPropertyHelloMethodComponent(); + $variables = $component->data(); + $this->assertArrayHasKey('hello', $variables); + $this->assertSame('world', $variables['hello']()); + + // protected methods do not override public properties. + $this->assertArrayHasKey('world', $variables); + $this->assertSame('world property', $variables['world']); + } +} + +class TestViewComponent extends Component +{ + public $votes = 10; + + public function render(): ViewContract|Htmlable|Closure|string + { + return 'test'; + } + + public function hello($string = 'world') + { + return $string; + } +} + +class TestSampleViewComponent extends Component +{ + public $counter = 0; + + public static $publicStaticCounter = 0; + + protected $protectedCounter = 0; + + private $privateCounter = 0; + + public function render(): ViewContract|Htmlable|Closure|string + { + return 'test'; + } + + public function publicHello($string = 'world') + { + $this->counter = 100; + + return $string; + } + + public function noArgs() + { + ++$this->counter; + + return 'noArgs val'; + } + + protected function protectedHello() + { + ++$this->counter; + } + + private function privateHello() + { + ++$this->counter; + } +} + +class TestExceptedViewComponent extends Component +{ + protected array $except = ['hello', 'hello2', 'taylor']; + + public $taylor = 'Otwell'; + + public function hello($string = 'world') + { + return $string; + } + + public function hello2() + { + return $this->taylor = ''; + } + + public function render(): ViewContract|Htmlable|Closure|string + { + return 'test'; + } +} + +class TestHelloPropertyHelloMethodComponent extends Component +{ + public function render(): ViewContract|Htmlable|Closure|string + { + return 'test'; + } + + public $hello = 'hello property'; + + public $world = 'world property'; + + public function hello($string = 'world') + { + return $string; + } + + protected function world($string = 'world') + { + return $string; + } +} + +class TestDefaultAttributesComponent extends Component +{ + public function __construct() + { + $this->withAttributes(['class' => 'text-red-500']); + } + + public function render(): ViewContract|Htmlable|Closure|string + { + return $this->attributes->get('id'); + } +} diff --git a/tests/View/ViewEngineResolverTest.php b/tests/View/ViewEngineResolverTest.php new file mode 100755 index 000000000..7678d345f --- /dev/null +++ b/tests/View/ViewEngineResolverTest.php @@ -0,0 +1,44 @@ +register('foo', function () { + return new FakeEngine(); + }); + $result = $resolver->resolve('foo'); + + $this->assertTrue($result === $resolver->resolve('foo')); + } + + public function testResolverThrowsExceptionOnUnknownEngine() + { + $this->expectException(InvalidArgumentException::class); + + $resolver = new EngineResolver(); + $resolver->resolve('foo'); + } +} + +class FakeEngine implements Engine +{ + public function get(string $path, array $data = []): string + { + return ''; + } +} diff --git a/tests/View/ViewFactoryTest.php b/tests/View/ViewFactoryTest.php new file mode 100755 index 000000000..080cd4284 --- /dev/null +++ b/tests/View/ViewFactoryTest.php @@ -0,0 +1,1097 @@ +getFactory(); + $factory->getFinder()->shouldReceive('find')->once()->with('view')->andReturn('path.php'); + $factory->getEngineResolver()->shouldReceive('resolve')->once()->with('php')->andReturn($engine = m::mock(Engine::class)); + $factory->getFinder()->shouldReceive('addExtension')->once()->with('php'); + $factory->setDispatcher($this->createEventDispatcher()); + $factory->creator('view', function ($eventName, $view) { + $_SERVER['__test.view'] = $view; + }); + $factory->addExtension('php', 'php'); + $view = $factory->make('view', ['foo' => 'bar'], ['baz' => 'boom']); + + $this->assertSame($engine, $view->getEngine()); + $this->assertSame($_SERVER['__test.view'], $view); + + unset($_SERVER['__test.view']); + } + + private function createEventDispatcher() + { + return new EventDispatcher( + new ListenerProvider(), + null, + $this->getApplication() + ); + } + + public function testExistsPassesAndFailsViews() + { + $factory = $this->getFactory(); + $factory->getFinder()->shouldReceive('find')->once()->with('foo')->andThrow(InvalidArgumentException::class); + $factory->getFinder()->shouldReceive('find')->once()->with('bar')->andReturn('path.php'); + + $this->assertFalse($factory->exists('foo')); + $this->assertTrue($factory->exists('bar')); + } + + public function testRenderingOnceChecks() + { + $factory = $this->getFactory(); + $this->assertFalse($factory->hasRenderedOnce('foo')); + $factory->markAsRenderedOnce('foo'); + $this->assertTrue($factory->hasRenderedOnce('foo')); + $factory->flushState(); + $this->assertFalse($factory->hasRenderedOnce('foo')); + } + + public function testFirstCreatesNewViewInstanceWithProperPath() + { + unset($_SERVER['__test.view']); + + $factory = $this->getFactory(); + $factory->getFinder()->shouldReceive('find')->twice()->with('view')->andReturn('path.php'); + $factory->getFinder()->shouldReceive('find')->once()->with('bar')->andThrow(InvalidArgumentException::class); + $factory->getEngineResolver()->shouldReceive('resolve')->once()->with('php')->andReturn($engine = m::mock(Engine::class)); + $factory->getFinder()->shouldReceive('addExtension')->once()->with('php'); + $factory->setDispatcher($this->createEventDispatcher()); + $factory->creator('view', function ($eventName, $view) { + $_SERVER['__test.view'] = $view; + }); + $factory->addExtension('php', 'php'); + $view = $factory->first(['bar', 'view'], ['foo' => 'bar'], ['baz' => 'boom']); + + $this->assertInstanceOf(ViewContract::class, $view); + $this->assertSame($engine, $view->getEngine()); + $this->assertSame($_SERVER['__test.view'], $view); + + unset($_SERVER['__test.view']); + } + + public function testFirstThrowsInvalidArgumentExceptionIfNoneFound() + { + $this->expectException(InvalidArgumentException::class); + + $factory = $this->getFactory(); + $factory->getFinder()->shouldReceive('find')->once()->with('view')->andThrow(InvalidArgumentException::class); + $factory->getFinder()->shouldReceive('find')->once()->with('bar')->andThrow(InvalidArgumentException::class); + $factory->first(['bar', 'view'], ['foo' => 'bar'], ['baz' => 'boom']); + } + + public function testRenderEachCreatesViewForEachItemInArray() + { + $factory = m::mock(Factory::class . '[make]', $this->getFactoryArgs()); + $factory->shouldReceive('make')->once()->with('foo', ['key' => 'bar', 'value' => 'baz'])->andReturn($mockView1 = m::mock(ViewContract::class)); + $factory->shouldReceive('make')->once()->with('foo', ['key' => 'breeze', 'value' => 'boom'])->andReturn($mockView2 = m::mock(ViewContract::class)); + $mockView1->shouldReceive('render')->once()->andReturn('dayle'); + $mockView2->shouldReceive('render')->once()->andReturn('rees'); + + $result = $factory->renderEach('foo', ['bar' => 'baz', 'breeze' => 'boom'], 'value'); + + $this->assertSame('daylerees', $result); + } + + public function testEmptyViewsCanBeReturnedFromRenderEach() + { + $factory = m::mock(Factory::class . '[make]', $this->getFactoryArgs()); + $factory->shouldReceive('make')->once()->with('foo')->andReturn($mockView = m::mock(ViewContract::class)); + $mockView->shouldReceive('render')->once()->andReturn('empty'); + + $this->assertSame('empty', $factory->renderEach('view', [], 'iterator', 'foo')); + } + + public function testRawStringsMayBeReturnedFromRenderEach() + { + $this->assertSame('foo', $this->getFactory()->renderEach('foo', [], 'item', 'raw|foo')); + } + + public function testEnvironmentAddsExtensionWithCustomResolver() + { + $factory = $this->getFactory(); + + $resolver = function () { + }; + + $factory->getFinder()->shouldReceive('addExtension')->once()->with('foo'); + $factory->getEngineResolver()->shouldReceive('register')->once()->with('bar', $resolver); + $factory->getFinder()->shouldReceive('find')->once()->with('view')->andReturn('path.foo'); + $factory->getEngineResolver()->shouldReceive('resolve')->once()->with('bar')->andReturn($engine = m::mock(Engine::class)); + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(false); + + $factory->addExtension('foo', 'bar', $resolver); + + $view = $factory->make('view', ['data']); + $this->assertSame($engine, $view->getEngine()); + } + + public function testAddingExtensionPrependsNotAppends() + { + $factory = $this->getFactory(); + $factory->getFinder()->shouldReceive('addExtension')->once()->with('foo'); + + $factory->addExtension('foo', 'bar'); + + $extensions = $factory->getExtensions(); + $this->assertSame('bar', reset($extensions)); + $this->assertSame('foo', key($extensions)); + } + + public function testPrependedExtensionOverridesExistingExtensions() + { + $factory = $this->getFactory(); + $factory->getFinder()->shouldReceive('addExtension')->once()->with('foo'); + $factory->getFinder()->shouldReceive('addExtension')->once()->with('baz'); + + $factory->addExtension('foo', 'bar'); + $factory->addExtension('baz', 'bar'); + + $extensions = $factory->getExtensions(); + $this->assertSame('bar', reset($extensions)); + $this->assertSame('baz', key($extensions)); + } + + public function testCallCreatorsDoesDispatchEventsWhenIsNecessary() + { + $factory = $this->getFactory(); + + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('creating: name', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(true); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('creating: name', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->once()->andReturn('name'); + + $factory->creator('name', fn () => true); + + $factory->callCreator($view); + } + + public function testCallCreatorsDoesDispatchEventsWhenIsNecessaryUsingNamespacedWildcards() + { + $factory = $this->getFactory(); + + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('creating: namespaced::*', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(true); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('creating: namespaced::my-package-view', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->once()->andReturn('namespaced::my-package-view'); + + $factory->creator('namespaced::*', fn () => true); + + $factory->callCreator($view); + } + + public function testCallCreatorsDoesDispatchEventsWhenIsNecessaryUsingNamespacedNestedWildcards() + { + $factory = $this->getFactory(); + + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('creating: namespaced::*', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('creating: welcome', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(true); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('creating: namespaced::my-package-view', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->once()->andReturn('namespaced::my-package-view'); + + $factory->creator(['namespaced::*', 'welcome'], fn () => true); + + $factory->callCreator($view); + } + + public function testCallCreatorsDoesDispatchEventsWhenIsNecessaryUsingWildcards() + { + $factory = $this->getFactory(); + + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('creating: *', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(true); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('creating: name', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->once()->andReturn('name'); + + $factory->creator('*', fn () => true); + + $factory->callCreator($view); + } + + public function testCallCreatorsDoesDispatchEventsWhenIsNecessaryUsingNormalizedNames() + { + $factory = $this->getFactory(); + + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('creating: components.button', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(true); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('creating: components/button', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name') + ->once() + ->andReturn('components/button'); + + $factory->creator('components.button', fn () => true); + + $factory->callCreator($view); + } + + public function testCallComposerDoesDispatchEventsWhenIsNecessary() + { + $factory = $this->getFactory(); + + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('composing: name', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(true); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('composing: name', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->once()->andReturn('name'); + + $factory->composer('name', fn () => true); + + $factory->callComposer($view); + } + + public function testCallComposerDoesDispatchEventsWhenIsNecessaryAndUsingTheArrayFormat() + { + $factory = $this->getFactory(); + + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('composing: name', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(true); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('composing: name', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->once()->andReturn('name'); + + $factory->composer(['name'], fn () => true); + + $factory->callComposer($view); + } + + public function testCallComposersDoesDispatchEventsWhenIsNecessaryUsingNamespacedWildcards() + { + $factory = $this->getFactory(); + + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('composing: namespaced::*', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(true); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('composing: namespaced::my-package-view', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->once()->andReturn('namespaced::my-package-view'); + + $factory->composer('namespaced::*', fn () => true); + + $factory->callComposer($view); + } + + public function testCallComposersDoesDispatchEventsWhenIsNecessaryUsingNamespacedNestedWildcards() + { + $factory = $this->getFactory(); + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('composing: namespaced::*', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('composing: welcome', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(true); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('composing: namespaced::my-package-view', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->once()->andReturn('namespaced::my-package-view'); + + $factory->composer(['namespaced::*', 'welcome'], fn () => true); + + $factory->callComposer($view); + } + + public function testCallComposersDoesDispatchEventsWhenIsNecessaryUsingWildcards() + { + $factory = $this->getFactory(); + + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(true); + + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('composing: *', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('composing: name', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->once()->andReturn('name'); + + $factory->composer('*', fn () => true); + + $factory->callComposer($view); + } + + public function testCallComposersDoesDispatchEventsWhenIsNecessaryUsingNormalizedNames() + { + $factory = $this->getFactory(); + + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(true); + + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('composing: components.button', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('composing: components/button', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->once()->andReturn('components/button'); + + $factory->composer('components.button', fn () => true); + + $factory->callComposer($view); + } + + public function testComposersAreProperlyRegistered() + { + $factory = $this->getFactory(); + $factory->getDispatcher()->shouldReceive('listen')->once()->with('composing: foo', m::type(Closure::class)); + $callback = $factory->composer('foo', function () { + return 'bar'; + }); + $callback = $callback[0]; + + $this->assertSame('bar', $callback()); + } + + public function testComposersCanBeMassRegistered() + { + $factory = $this->getFactory(); + $factory->getDispatcher()->shouldReceive('listen')->once()->with('composing: bar', m::type(Closure::class)); + $factory->getDispatcher()->shouldReceive('listen')->once()->with('composing: qux', m::type(Closure::class)); + $factory->getDispatcher()->shouldReceive('listen')->once()->with('composing: foo', m::type(Closure::class)); + $composers = $factory->composers([ + 'foo' => 'bar', + 'baz@baz' => ['qux', 'foo'], + ]); + + $this->assertCount(3, $composers); + $reflections = [ + new ReflectionFunction($composers[0]), + new ReflectionFunction($composers[1]), + ]; + $this->assertEquals(['class' => 'foo', 'method' => 'compose'], $reflections[0]->getStaticVariables()); + $this->assertEquals(['class' => 'baz', 'method' => 'baz'], $reflections[1]->getStaticVariables()); + } + + public function testClassCallbacks() + { + $factory = $this->getFactory(); + $factory->getDispatcher()->shouldReceive('listen')->once()->with('composing: foo', m::type(Closure::class)); + $factory->setContainer($container = m::mock(Container::class)); + $container->shouldReceive('make')->once()->with('FooComposer')->andReturn($composer = m::mock(stdClass::class)); + $composer->shouldReceive('compose')->once()->with('view')->andReturn('composed'); + $callback = $factory->composer('foo', 'FooComposer'); + $callback = $callback[0]; + + $this->assertSame('composed', $callback('view')); + } + + public function testClassCallbacksWithMethods() + { + $factory = $this->getFactory(); + $factory->getDispatcher()->shouldReceive('listen')->once()->with('composing: foo', m::type(Closure::class)); + $factory->setContainer($container = m::mock(Container::class)); + $container->shouldReceive('make')->once()->with('FooComposer')->andReturn($composer = m::mock(stdClass::class)); + $composer->shouldReceive('doComposer')->once()->with('view')->andReturn('composed'); + $callback = $factory->composer('foo', 'FooComposer@doComposer'); + $callback = $callback[0]; + + $this->assertSame('composed', $callback('view')); + } + + public function testCallComposerCallsProperEvent() + { + $factory = $this->getFactory(); + $view = m::mock(View::class); + $dispatcher = m::mock(DispatcherContract::class); + $factory->setDispatcher($dispatcher); + + $dispatcher->shouldReceive('listen', m::any())->once(); + + $view->shouldReceive('name')->once()->andReturn('name'); + + $factory->composer('name', fn () => true); + + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(true); + $factory->getDispatcher()->shouldReceive('dispatch')->once()->with('composing: name', [$view]); + + $factory->callComposer($view); + } + + public function testComposersAreRegisteredWithSlashAndDot() + { + $factory = $this->getFactory(); + $factory->getDispatcher()->shouldReceive('listen')->with('composing: foo.bar', m::any())->twice(); + $factory->composer('foo.bar', ''); + $factory->composer('foo/bar', ''); + } + + public function testRenderCountHandling() + { + $factory = $this->getFactory(); + $factory->incrementRender(); + $this->assertFalse($factory->doneRendering()); + $factory->decrementRender(); + $this->assertTrue($factory->doneRendering()); + } + + public function testYieldDefault() + { + $factory = $this->getFactory(); + $this->assertSame('hi', $factory->yieldContent('foo', 'hi')); + } + + public function testYieldDefaultIsEscaped() + { + $factory = $this->getFactory(); + $this->assertSame('<p>hi</p>', $factory->yieldContent('foo', '

hi

')); + } + + public function testYieldDefaultViewIsNotEscapedTwice() + { + $factory = $this->getFactory(); + $view = m::mock(View::class); + $view->shouldReceive('render')->once()->andReturn('

hi

<p>already escaped</p>'); + $this->assertSame('

hi

<p>already escaped</p>', $factory->yieldContent('foo', $view)); + } + + public function testBasicFragmentHandling() + { + $factory = $this->getFactory(); + $factory->startFragment('foo'); + echo 'hi'; + $this->assertSame('hi', $factory->stopFragment()); + } + + public function testBasicSectionHandling() + { + $factory = $this->getFactory(); + $factory->startSection('foo'); + echo 'hi'; + $factory->stopSection(); + $this->assertSame('hi', $factory->yieldContent('foo')); + } + + public function testBasicSectionDefault() + { + $factory = $this->getFactory(); + $factory->startSection('foo', 'hi'); + $this->assertSame('hi', $factory->yieldContent('foo')); + $factory->flushSections(); + } + + public function testBasicSectionDefaultIsEscaped() + { + $factory = $this->getFactory(); + $factory->startSection('foo', '

hi

'); + $this->assertSame('<p>hi</p>', $factory->yieldContent('foo')); + $factory->flushSections(); + } + + public function testBasicSectionDefaultViewIsNotEscapedTwice() + { + $factory = $this->getFactory(); + $view = m::mock(View::class); + $view->shouldReceive('render')->once()->andReturn('

hi

<p>already escaped</p>'); + $factory->startSection('foo', $view); + $this->assertSame('

hi

<p>already escaped</p>', $factory->yieldContent('foo')); + $factory->flushSections(); + } + + public function testSectionExtending() + { + $factory = $this->getFactory(); + $placeholder = $factory->getParentPlaceholder('foo'); + $factory->startSection('foo'); + echo 'hi ' . $placeholder; + $factory->stopSection(); + $factory->startSection('foo'); + echo 'there'; + $factory->stopSection(); + $this->assertSame('hi there', $factory->yieldContent('foo')); + $factory->flushSections(); + } + + public function testSectionMultipleExtending() + { + $factory = $this->getFactory(); + $placeholder = $factory->getParentPlaceholder('foo'); + $factory->startSection('foo'); + echo 'hello ' . $placeholder . ' nice to see you ' . $placeholder; + $factory->stopSection(); + $factory->startSection('foo'); + echo 'my ' . $placeholder; + $factory->stopSection(); + $factory->startSection('foo'); + echo 'friend'; + $factory->stopSection(); + $this->assertSame('hello my friend nice to see you my friend', $factory->yieldContent('foo')); + $factory->flushSections(); + } + + public function testComponentHandling() + { + $factory = $this->getFactory(); + $factory->getFinder()->shouldReceive('find')->andReturn(__DIR__ . '/fixtures/component.php'); + $factory->getEngineResolver()->shouldReceive('resolve')->andReturn(new PhpEngine(new Filesystem())); + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(false); + $factory->startComponent('component', ['name' => 'Taylor']); + $factory->slot('title'); + $factory->slot('website', 'laravel.com', []); + echo 'title
'; + $factory->endSlot(); + echo 'component'; + $contents = $factory->renderComponent(); + $this->assertSame('title
component Taylor laravel.com', $contents); + } + + public function testComponentHandlingUsingViewObject() + { + $factory = $this->getFactory(); + $factory->getFinder()->shouldReceive('find')->andReturn(__DIR__ . '/fixtures/component.php'); + $factory->getEngineResolver()->shouldReceive('resolve')->andReturn(new PhpEngine(new Filesystem())); + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(false); + $factory->startComponent($factory->make('component'), ['name' => 'Taylor']); + $factory->slot('title'); + $factory->slot('website', 'laravel.com', []); + echo 'title
'; + $factory->endSlot(); + echo 'component'; + $contents = $factory->renderComponent(); + $this->assertSame('title
component Taylor laravel.com', $contents); + } + + public function testComponentHandlingUsingClosure() + { + $factory = $this->getFactory(); + $factory->getFinder()->shouldReceive('find')->andReturn(__DIR__ . '/fixtures/component.php'); + $factory->getEngineResolver()->shouldReceive('resolve')->andReturn(new PhpEngine(new Filesystem())); + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(false); + $factory->startComponent(function ($data) use ($factory) { + $this->assertArrayHasKey('name', $data); + $this->assertSame($data['name'], 'Taylor'); + + return $factory->make('component'); + }, ['name' => 'Taylor']); + $factory->slot('title'); + $factory->slot('website', 'laravel.com', []); + echo 'title
'; + $factory->endSlot(); + echo 'component'; + $contents = $factory->renderComponent(); + $this->assertSame('title
component Taylor laravel.com', $contents); + } + + public function testComponentHandlingUsingHtmlable() + { + $factory = $this->getFactory(); + $factory->startComponent(new HtmlString('laravel.com')); + $contents = $factory->renderComponent(); + $this->assertSame('laravel.com', $contents); + } + + public function testTranslation() + { + $translator = m::mock(stdClass::class); + $translator->shouldReceive('get')->with('Foo', ['name' => 'taylor'])->andReturn('Bar'); + $container = m::mock(Container::class); + $container->shouldReceive('make')->with('translator')->andReturn($translator); + $factory = $this->getFactory(); + $factory->setContainer($container); + $factory->startTranslation(['name' => 'taylor']); + echo 'Foo'; + $string = $factory->renderTranslation(); + + $this->assertSame('Bar', $string); + } + + public function testSingleStackPush() + { + $factory = $this->getFactory(); + $factory->startPush('foo'); + echo 'hi'; + $factory->stopPush(); + $this->assertSame('hi', $factory->yieldPushContent('foo')); + $factory->flushStacks(); + } + + public function testMultipleStackPush() + { + $factory = $this->getFactory(); + $factory->startPush('foo'); + echo 'hi'; + $factory->stopPush(); + $factory->startPush('foo'); + echo ', Hello!'; + $factory->stopPush(); + $this->assertSame('hi, Hello!', $factory->yieldPushContent('foo')); + $factory->flushStacks(); + } + + public function testSingleStackPrepend() + { + $factory = $this->getFactory(); + $factory->startPrepend('foo'); + echo 'hi'; + $factory->stopPrepend(); + $this->assertSame('hi', $factory->yieldPushContent('foo')); + $factory->flushStacks(); + } + + public function testMultipleStackPrepend() + { + $factory = $this->getFactory(); + $factory->startPrepend('foo'); + echo ', Hello!'; + $factory->stopPrepend(); + $factory->startPrepend('foo'); + echo 'hi'; + $factory->stopPrepend(); + $this->assertSame('hi, Hello!', $factory->yieldPushContent('foo')); + $factory->flushStacks(); + } + + public function testSessionAppending() + { + $factory = $this->getFactory(); + $factory->startSection('foo'); + echo 'hi'; + $factory->appendSection(); + $factory->startSection('foo'); + echo 'there'; + $factory->appendSection(); + $this->assertSame('hithere', $factory->yieldContent('foo')); + $factory->flushSections(); + } + + public function testYieldSectionStopsAndYields() + { + $factory = $this->getFactory(); + $factory->startSection('foo'); + echo 'hi'; + $this->assertSame('hi', $factory->yieldSection()); + $factory->flushSections(); + } + + public function testInjectStartsSectionWithContent() + { + $factory = $this->getFactory(); + $factory->inject('foo', 'hi'); + $this->assertSame('hi', $factory->yieldContent('foo')); + $factory->flushSections(); + } + + public function testEmptyStringIsReturnedForNonSections() + { + $factory = $this->getFactory(); + $this->assertEmpty($factory->yieldContent('foo')); + } + + public function testSectionFlushing() + { + $factory = $this->getFactory(); + $factory->startSection('foo'); + echo 'hi'; + $factory->stopSection(); + + $this->assertCount(1, $factory->getSections()); + + $factory->flushSections(); + + $this->assertCount(0, $factory->getSections()); + } + + public function testHasSection() + { + $factory = $this->getFactory(); + $factory->startSection('foo'); + echo 'hi'; + $factory->stopSection(); + + $this->assertTrue($factory->hasSection('foo')); + $this->assertFalse($factory->hasSection('bar')); + } + + public function testSectionMissing() + { + $factory = $this->getFactory(); + $factory->startSection('foo'); + echo 'hello world'; + $factory->stopSection(); + + $this->assertTrue($factory->sectionMissing('bar')); + $this->assertFalse($factory->sectionMissing('foo')); + } + + public function testGetSection() + { + $factory = $this->getFactory(); + $factory->startSection('foo'); + echo 'hi'; + $factory->stopSection(); + + $this->assertSame('hi', $factory->getSection('foo')); + $this->assertNull($factory->getSection('bar')); + $this->assertSame('default', $factory->getSection('bar', 'default')); + } + + public function testMakeWithSlashAndDot() + { + $factory = $this->getFactory(); + $factory->getFinder()->shouldReceive('find')->twice()->with('foo.bar')->andReturn('path.php'); + $factory->getEngineResolver()->shouldReceive('resolve')->twice()->with('php')->andReturn(m::mock(Engine::class)); + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(false); + $factory->make('foo/bar'); + $factory->make('foo.bar'); + } + + public function testNamespacedViewNamesAreNormalizedProperly() + { + $factory = $this->getFactory(); + $factory->getFinder()->shouldReceive('find')->twice()->with('vendor/package::foo.bar')->andReturn('path.php'); + $factory->getEngineResolver()->shouldReceive('resolve')->twice()->with('php')->andReturn(m::mock(Engine::class)); + $factory->getDispatcher()->shouldReceive('hasListeners')->andReturn(false); + $factory->make('vendor/package::foo/bar'); + $factory->make('vendor/package::foo.bar'); + } + + public function testExceptionIsThrownForUnknownExtension() + { + $this->expectException(InvalidArgumentException::class); + + $factory = $this->getFactory(); + $factory->getFinder()->shouldReceive('find')->once()->with('view')->andReturn('view.foo'); + $factory->make('view'); + } + + public function testExceptionsInSectionsAreThrown() + { + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('section exception message'); + + $engine = new CompilerEngine(m::mock(CompilerInterface::class), new Filesystem()); + $engine->getCompiler()->shouldReceive('getCompiledPath')->andReturnUsing(function ($path) { + return $path; + }); + $engine->getCompiler()->shouldReceive('isExpired')->twice()->andReturn(false); + $factory = $this->getFactory(); + $factory->getEngineResolver()->shouldReceive('resolve')->twice()->andReturn($engine); + $factory->getFinder()->shouldReceive('find')->once()->with('layout')->andReturn(__DIR__ . '/fixtures/section-exception-layout.php'); + $factory->getFinder()->shouldReceive('find')->once()->with('view')->andReturn(__DIR__ . '/fixtures/section-exception.php'); + $factory->getDispatcher()->shouldReceive('hasListeners')->times(4); // 2 "creating" + 2 "composing"... + + $factory->make('view')->render(); + } + + public function testExtraStopSectionCallThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot end a section without first starting one.'); + + $factory = $this->getFactory(); + $factory->startSection('foo'); + $factory->stopSection(); + + $factory->stopSection(); + } + + public function testExtraAppendSectionCallThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot end a section without first starting one.'); + + $factory = $this->getFactory(); + $factory->startSection('foo'); + $factory->stopSection(); + + $factory->appendSection(); + } + + public function testAddingLoops() + { + $factory = $this->getFactory(); + + $factory->addLoop([1, 2, 3]); + + $expectedLoop = [ + 'iteration' => 0, + 'index' => 0, + 'remaining' => 3, + 'count' => 3, + 'first' => true, + 'last' => false, + 'odd' => false, + 'even' => true, + 'depth' => 1, + 'parent' => null, + ]; + + $this->assertEquals([$expectedLoop], $factory->getLoopStack()); + + $factory->addLoop([1, 2, 3, 4]); + + $secondExpectedLoop = [ + 'iteration' => 0, + 'index' => 0, + 'remaining' => 4, + 'count' => 4, + 'first' => true, + 'last' => false, + 'odd' => false, + 'even' => true, + 'depth' => 2, + 'parent' => (object) $expectedLoop, + ]; + $this->assertEquals([$expectedLoop, $secondExpectedLoop], $factory->getLoopStack()); + + $factory->popLoop(); + + $this->assertEquals([$expectedLoop], $factory->getLoopStack()); + + $factory->popLoop(); + } + + public function testAddingLoopDoesNotCloseGenerator() + { + $factory = $this->getFactory(); + + $data = (new class { + public function generate() + { + for ($count = 0; $count < 3; ++$count) { + yield ['a', 'b']; + } + } + })->generate(); + + $factory->addLoop($data); + + foreach ($data as $chunk) { + $this->assertEquals(['a', 'b'], $chunk); + } + + $factory->popLoop(); + } + + public function testAddingLazyCollection() + { + $factory = $this->getFactory(); + + $factory->addLoop(new LazyCollection(function () { + $this->fail('LazyCollection\'s generator should not have been called'); + })); + + $expectedLoop = [ + 'iteration' => 0, + 'index' => 0, + 'remaining' => null, + 'count' => null, + 'first' => true, + 'last' => null, + 'odd' => false, + 'even' => true, + 'depth' => 1, + 'parent' => null, + ]; + + $this->assertEquals([$expectedLoop], $factory->getLoopStack()); + + $factory->popLoop(); + } + + public function testIncrementingLoopIndices() + { + $factory = $this->getFactory(); + + $factory->addLoop([1, 2, 3, 4]); + + $factory->incrementLoopIndices(); + + $this->assertEquals(1, $factory->getLoopStack()[0]['iteration']); + $this->assertEquals(0, $factory->getLoopStack()[0]['index']); + $this->assertEquals(3, $factory->getLoopStack()[0]['remaining']); + $this->assertTrue($factory->getLoopStack()[0]['odd']); + $this->assertFalse($factory->getLoopStack()[0]['even']); + + $factory->incrementLoopIndices(); + + $this->assertEquals(2, $factory->getLoopStack()[0]['iteration']); + $this->assertEquals(1, $factory->getLoopStack()[0]['index']); + $this->assertEquals(2, $factory->getLoopStack()[0]['remaining']); + $this->assertFalse($factory->getLoopStack()[0]['odd']); + $this->assertTrue($factory->getLoopStack()[0]['even']); + + $factory->popLoop(); + } + + public function testReachingEndOfLoop() + { + $factory = $this->getFactory(); + + $factory->addLoop([1, 2]); + + $factory->incrementLoopIndices(); + + $factory->incrementLoopIndices(); + + $this->assertTrue($factory->getLoopStack()[0]['last']); + } + + public function testMacro() + { + $factory = $this->getFactory(); + $factory->macro('getFoo', function () { + return 'Hello World'; + }); + $this->assertSame('Hello World', $factory->getFoo()); + } + + protected function getFactory() + { + return new Factory( + m::mock(EngineResolver::class), + m::mock(ViewFinderInterface::class), + m::mock(DispatcherContract::class) + ); + } + + protected function getFactoryArgs() + { + return [ + m::mock(EngineResolver::class), + m::mock(ViewFinderInterface::class), + m::mock(DispatcherContract::class), + ]; + } +} diff --git a/tests/View/ViewFileViewFinderTest.php b/tests/View/ViewFileViewFinderTest.php new file mode 100755 index 000000000..7adb7f4a3 --- /dev/null +++ b/tests/View/ViewFileViewFinderTest.php @@ -0,0 +1,176 @@ +getFinder(); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo.blade.php')->andReturn(true); + + $this->assertEquals(__DIR__ . '/foo.blade.php', $finder->find('foo')); + } + + public function testCascadingFileLoading() + { + $finder = $this->getFinder(); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo.blade.php')->andReturn(false); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo.php')->andReturn(true); + + $this->assertEquals(__DIR__ . '/foo.php', $finder->find('foo')); + } + + public function testDirectoryCascadingFileLoading() + { + $finder = $this->getFinder(); + $finder->addLocation(__DIR__ . '/nested'); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo.blade.php')->andReturn(false); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo.php')->andReturn(false); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo.css')->andReturn(false); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo.html')->andReturn(false); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/nested/foo.blade.php')->andReturn(true); + + $this->assertEquals(__DIR__ . '/nested/foo.blade.php', $finder->find('foo')); + } + + public function testNamespacedBasicFileLoading() + { + $finder = $this->getFinder(); + $finder->addNamespace('foo', __DIR__ . '/foo'); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo/bar/baz.blade.php')->andReturn(true); + + $this->assertEquals(__DIR__ . '/foo/bar/baz.blade.php', $finder->find('foo::bar.baz')); + } + + public function testCascadingNamespacedFileLoading() + { + $finder = $this->getFinder(); + $finder->addNamespace('foo', __DIR__ . '/foo'); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo/bar/baz.blade.php')->andReturn(false); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo/bar/baz.php')->andReturn(true); + + $this->assertEquals(__DIR__ . '/foo/bar/baz.php', $finder->find('foo::bar.baz')); + } + + public function testDirectoryCascadingNamespacedFileLoading() + { + $finder = $this->getFinder(); + $finder->addNamespace('foo', [__DIR__ . '/foo', __DIR__ . '/bar']); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo/bar/baz.blade.php')->andReturn(false); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo/bar/baz.php')->andReturn(false); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo/bar/baz.css')->andReturn(false); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo/bar/baz.html')->andReturn(false); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/bar/bar/baz.blade.php')->andReturn(true); + + $this->assertEquals(__DIR__ . '/bar/bar/baz.blade.php', $finder->find('foo::bar.baz')); + } + + public function testExceptionThrownWhenViewNotFound() + { + $this->expectException(InvalidArgumentException::class); + + $finder = $this->getFinder(); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo.blade.php')->andReturn(false); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo.php')->andReturn(false); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo.css')->andReturn(false); + $finder->getFilesystem()->shouldReceive('exists')->once()->with(__DIR__ . '/foo.html')->andReturn(false); + + $finder->find('foo'); + } + + public function testExceptionThrownOnInvalidViewName() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No hint path defined for [name].'); + + $finder = $this->getFinder(); + $finder->find('name::'); + } + + public function testExceptionThrownWhenNoHintPathIsRegistered() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No hint path defined for [name].'); + + $finder = $this->getFinder(); + $finder->find('name::foo'); + } + + public function testAddingExtensionPrependsNotAppends() + { + $finder = $this->getFinder(); + $finder->addExtension('baz'); + $extensions = $finder->getExtensions(); + $this->assertSame('baz', reset($extensions)); + } + + public function testAddingExtensionsReplacesOldOnes() + { + $finder = $this->getFinder(); + $finder->addExtension('baz'); + $finder->addExtension('baz'); + + $this->assertCount(5, $finder->getExtensions()); + } + + public function testPassingViewWithHintReturnsTrue() + { + $finder = $this->getFinder(); + + $this->assertTrue($finder->hasHintInformation('hint::foo.bar')); + } + + public function testPassingViewWithoutHintReturnsFalse() + { + $finder = $this->getFinder(); + + $this->assertFalse($finder->hasHintInformation('foo.bar')); + } + + public function testPassingViewWithFalseHintReturnsFalse() + { + $finder = $this->getFinder(); + + $this->assertFalse($finder->hasHintInformation('::foo.bar')); + } + + #[DataProvider('pathsProvider')] + public function testNormalizedPaths($originalPath, $exceptedPath) + { + $finder = $this->getFinder(); + $finder->prependLocation($originalPath); + $normalizedPath = $finder->getPaths()[0]; + $this->assertSame($exceptedPath, $normalizedPath); + } + + public static function pathsProvider() + { + return [ + ['incorrect_path', 'incorrect_path'], + ]; + } + + protected function getFinder() + { + return new FileViewFinder(m::mock(Filesystem::class), [__DIR__]); + } +} diff --git a/tests/View/ViewPhpEngineTest.php b/tests/View/ViewPhpEngineTest.php new file mode 100755 index 000000000..3cc566fc8 --- /dev/null +++ b/tests/View/ViewPhpEngineTest.php @@ -0,0 +1,22 @@ +assertSame('Hello World' . PHP_EOL, $engine->get(__DIR__ . '/fixtures/basic.php')); + } +} diff --git a/tests/View/ViewTest.php b/tests/View/ViewTest.php new file mode 100755 index 000000000..30f9e11f8 --- /dev/null +++ b/tests/View/ViewTest.php @@ -0,0 +1,259 @@ +getView(); + $view->with('foo', 'bar'); + $view->with(['baz' => 'boom']); + $this->assertEquals(['foo' => 'bar', 'baz' => 'boom'], $view->getData()); + + $view = $this->getView(); + $view->withFoo('bar')->withBaz('boom'); + $this->assertEquals(['foo' => 'bar', 'baz' => 'boom'], $view->getData()); + } + + public function testRenderProperlyRendersView() + { + $view = $this->getView(['foo' => 'bar']); + $view->getFactory()->shouldReceive('incrementRender')->once()->ordered(); + $view->getFactory()->shouldReceive('callComposer')->once()->ordered()->with($view); + $view->getFactory()->shouldReceive('getShared')->once()->andReturn(['shared' => 'foo']); + $view->getEngine()->shouldReceive('get')->once()->with('path', ['foo' => 'bar', 'shared' => 'foo'])->andReturn('contents'); + $view->getFactory()->shouldReceive('decrementRender')->once()->ordered(); + $view->getFactory()->shouldReceive('flushStateIfDoneRendering')->once(); + + $callback = function (View $rendered, $contents) use ($view) { + $this->assertEquals($view, $rendered); + $this->assertSame('contents', $contents); + }; + + $this->assertSame('contents', $view->render($callback)); + } + + public function testRenderHandlingCallbackReturnValues() + { + $view = $this->getView(); + $view->getFactory()->shouldReceive('incrementRender'); + $view->getFactory()->shouldReceive('callComposer'); + $view->getFactory()->shouldReceive('getShared')->andReturn(['shared' => 'foo']); + $view->getEngine()->shouldReceive('get')->andReturn('contents'); + $view->getFactory()->shouldReceive('decrementRender'); + $view->getFactory()->shouldReceive('flushStateIfDoneRendering'); + + $this->assertSame('new contents', $view->render(function () { + return 'new contents'; + })); + + $this->assertEmpty($view->render(function () { + return ''; + })); + + $this->assertSame('contents', $view->render(function () { + })); + } + + public function testRenderSectionsReturnsEnvironmentSections() + { + $view = $this->getMockBuilder(View::class) + ->setConstructorArgs([ + $factory = m::mock(Factory::class), + m::mock(Engine::class), + 'view', + 'path', + [], + ]) + ->onlyMethods(['renderContents']) + ->getMock(); + + $factory->shouldReceive('getSections')->with()->once()->andReturn($sections = ['foo' => 'bar']); + $factory->shouldReceive('flushStateIfDoneRendering')->with()->once(); + + $this->assertEquals($sections, $view->renderSections()); + } + + public function testSectionsAreNotFlushedWhenNotDoneRendering() + { + $view = $this->getView(['foo' => 'bar']); + $view->getFactory()->shouldReceive('incrementRender')->twice(); + $view->getFactory()->shouldReceive('callComposer')->twice()->with($view); + $view->getFactory()->shouldReceive('getShared')->twice()->andReturn(['shared' => 'foo']); + $view->getEngine()->shouldReceive('get')->twice()->with('path', ['foo' => 'bar', 'shared' => 'foo'])->andReturn('contents'); + $view->getFactory()->shouldReceive('decrementRender')->twice(); + $view->getFactory()->shouldReceive('flushStateIfDoneRendering')->twice(); + + $this->assertSame('contents', $view->render()); + $this->assertSame('contents', (string) $view); + } + + public function testViewNestBindsASubView() + { + $view = $this->getView(); + $view->getFactory()->shouldReceive('make')->once()->with('foo', ['data']); + $result = $view->nest('key', 'foo', ['data']); + + $this->assertInstanceOf(View::class, $result); + } + + public function testViewAcceptsArrayableImplementations() + { + $arrayable = m::mock(Arrayable::class); + $arrayable->shouldReceive('toArray')->once()->andReturn(['foo' => 'bar', 'baz' => ['qux', 'corge']]); + + $view = $this->getView($arrayable); + + $this->assertSame('bar', $view->foo); + $this->assertEquals(['qux', 'corge'], $view->baz); + } + + public function testViewGettersSetters() + { + $view = $this->getView(['foo' => 'bar']); + $this->assertSame('view', $view->name()); + $this->assertSame('path', $view->getPath()); + $data = $view->getData(); + $this->assertSame('bar', $data['foo']); + $view->setPath('newPath'); + $this->assertSame('newPath', $view->getPath()); + } + + public function testViewArrayAccess() + { + $view = $this->getView(['foo' => 'bar']); + $this->assertInstanceOf(ArrayAccess::class, $view); + $this->assertTrue($view->offsetExists('foo')); + $this->assertSame('bar', $view->offsetGet('foo')); + $view->offsetSet('foo', 'baz'); + $this->assertSame('baz', $view->offsetGet('foo')); + $view->offsetUnset('foo'); + $this->assertFalse($view->offsetExists('foo')); + } + + public function testViewConstructedWithObjectData() + { + $view = $this->getView(new DataObjectStub()); + $this->assertInstanceOf(ArrayAccess::class, $view); + $this->assertTrue($view->offsetExists('foo')); + $this->assertSame('bar', $view->offsetGet('foo')); + $view->offsetSet('foo', 'baz'); + $this->assertSame('baz', $view->offsetGet('foo')); + $view->offsetUnset('foo'); + $this->assertFalse($view->offsetExists('foo')); + } + + public function testViewMagicMethods() + { + $view = $this->getView(['foo' => 'bar']); + $this->assertTrue(isset($view->foo)); + $this->assertSame('bar', $view->foo); + $view->foo = 'baz'; + $this->assertSame('baz', $view->foo); + $this->assertEquals($view['foo'], $view->foo); + unset($view->foo); + $this->assertFalse(isset($view->foo)); + $this->assertFalse($view->offsetExists('foo')); + } + + public function testViewBadMethod() + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Method Hypervel\View\View::badMethodCall does not exist.'); + + $view = $this->getView(); + $view->badMethodCall(); + } + + public function testViewGatherDataWithRenderable() + { + $view = $this->getView(); + $view->getFactory()->shouldReceive('incrementRender')->once()->ordered(); + $view->getFactory()->shouldReceive('callComposer')->once()->ordered()->with($view); + $view->getFactory()->shouldReceive('getShared')->once()->andReturn(['shared' => 'foo']); + $view->getEngine()->shouldReceive('get')->once()->andReturn('contents'); + $view->getFactory()->shouldReceive('decrementRender')->once()->ordered(); + $view->getFactory()->shouldReceive('flushStateIfDoneRendering')->once(); + + $view->renderable = m::mock(Renderable::class); + $view->renderable->shouldReceive('render')->once()->andReturn('text'); + $this->assertSame('contents', $view->render()); + } + + public function testViewRenderSections() + { + $view = $this->getView(); + $view->getFactory()->shouldReceive('incrementRender')->once()->ordered(); + $view->getFactory()->shouldReceive('callComposer')->once()->ordered()->with($view); + $view->getFactory()->shouldReceive('getShared')->once()->andReturn(['shared' => 'foo']); + $view->getEngine()->shouldReceive('get')->once()->andReturn('contents'); + $view->getFactory()->shouldReceive('decrementRender')->once()->ordered(); + $view->getFactory()->shouldReceive('flushStateIfDoneRendering')->once(); + + $view->getFactory()->shouldReceive('getSections')->once()->andReturn(['foo', 'bar']); + $sections = $view->renderSections(); + $this->assertSame('foo', $sections[0]); + $this->assertSame('bar', $sections[1]); + } + + public function testWithErrors() + { + $view = $this->getView(); + $errors = ['foo' => 'bar', 'qu' => 'ux']; + $this->assertSame($view, $view->withErrors($errors)); + $this->assertInstanceOf(ViewErrorBag::class, $view->errors); + $foo = $view->errors->get('foo'); + $this->assertSame('bar', $foo[0]); + $qu = $view->errors->get('qu'); + $this->assertSame('ux', $qu[0]); + $data = ['foo' => 'baz']; + $this->assertSame($view, $view->withErrors(new MessageBag($data))); + $foo = $view->errors->get('foo'); + $this->assertSame('baz', $foo[0]); + $foo = $view->errors->getBag('default')->get('foo'); + $this->assertSame('baz', $foo[0]); + $this->assertSame($view, $view->withErrors(new MessageBag($data), 'login')); + $foo = $view->errors->getBag('login')->get('foo'); + $this->assertSame('baz', $foo[0]); + } + + protected function getView($data = []) + { + return new View( + m::mock(Factory::class), + m::mock(Engine::class), + 'view', + 'path', + $data + ); + } +} + +class DataObjectStub +{ + public $foo = 'bar'; +} diff --git a/tests/View/fixtures/basic.php b/tests/View/fixtures/basic.php new file mode 100755 index 000000000..57abe576b --- /dev/null +++ b/tests/View/fixtures/basic.php @@ -0,0 +1,2 @@ + +Hello World diff --git a/tests/View/fixtures/component.php b/tests/View/fixtures/component.php new file mode 100644 index 000000000..4af443db8 --- /dev/null +++ b/tests/View/fixtures/component.php @@ -0,0 +1 @@ + diff --git a/tests/View/fixtures/http-exception.php b/tests/View/fixtures/http-exception.php new file mode 100644 index 000000000..7c4e205f0 --- /dev/null +++ b/tests/View/fixtures/http-exception.php @@ -0,0 +1,7 @@ + diff --git a/tests/View/fixtures/regular-exception.php b/tests/View/fixtures/regular-exception.php new file mode 100644 index 000000000..c5713b525 --- /dev/null +++ b/tests/View/fixtures/regular-exception.php @@ -0,0 +1,5 @@ +yieldContent('content'); diff --git a/tests/View/fixtures/section-exception.php b/tests/View/fixtures/section-exception.php new file mode 100644 index 000000000..0c7a1203b --- /dev/null +++ b/tests/View/fixtures/section-exception.php @@ -0,0 +1,4 @@ +make('layout', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> +startSection('content'); ?> + +stopSection(); ?>