From 825ef9ed232c532763504626253c2532322d85a8 Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 17 Feb 2026 09:04:27 +0100 Subject: [PATCH 01/29] wip --- packages/view/src/Elements/ElementFactory.php | 3 +++ .../src/Elements/ViewComponentElement.php | 13 ++++++++- .../ElementFactoryInitializer.php | 2 ++ .../view/src/Parser/TempestViewCompiler.php | 1 + .../src/Renderers/TempestViewRenderer.php | 4 +-- .../Framework/Testing/View/ViewTester.php | 4 +-- tests/Integration/View/ViewComponentTest.php | 27 +++++++++++++++++++ 7 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/view/src/Elements/ElementFactory.php b/packages/view/src/Elements/ElementFactory.php index 4d63a88fa..f4c9d6d11 100644 --- a/packages/view/src/Elements/ElementFactory.php +++ b/packages/view/src/Elements/ElementFactory.php @@ -11,6 +11,7 @@ use Tempest\View\Parser\Token; use Tempest\View\Parser\TokenType; use Tempest\View\Slot; +use Tempest\View\ViewCache; use Tempest\View\ViewConfig; final class ElementFactory @@ -22,6 +23,7 @@ final class ElementFactory public function __construct( private readonly ViewConfig $viewConfig, private readonly Environment $environment, + private readonly ViewCache $viewCache, ) {} public function setViewCompiler(TempestViewCompiler $compiler): self @@ -97,6 +99,7 @@ private function makeElement(Token $token, ?Element $parent): ?Element token: $token, environment: $this->environment, compiler: $this->compiler, + viewCache: $this->viewCache, viewComponent: $viewComponentClass, attributes: $attributes, ); diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index dbe953dd5..f9ba4fb09 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -14,6 +14,7 @@ use Tempest\View\Parser\TempestViewParser; use Tempest\View\Parser\Token; use Tempest\View\Slot; +use Tempest\View\ViewCache; use Tempest\View\ViewComponent; use Tempest\View\WithToken; @@ -36,6 +37,7 @@ public function __construct( public readonly Token $token, private readonly Environment $environment, private readonly TempestViewCompiler $compiler, + private readonly ViewCache $viewCache, private readonly ViewComponent $viewComponent, array $attributes, ) { @@ -162,7 +164,16 @@ public function compile(): string }, ); - return $this->compiler->compile($compiled->toString()); + $compiled = $this->compiler->compile($compiled->toString()); + + return $compiled; + + $cachePath = $this->viewCache->getCachedViewPath( + $this->viewComponent->file, + fn () => $compiled, + ); + + return sprintf('', $cachePath); } private function getSlotElement(string $name): SlotElement|CollectionElement|null diff --git a/packages/view/src/Initializers/ElementFactoryInitializer.php b/packages/view/src/Initializers/ElementFactoryInitializer.php index f6eb55b6a..a6b7fd58d 100644 --- a/packages/view/src/Initializers/ElementFactoryInitializer.php +++ b/packages/view/src/Initializers/ElementFactoryInitializer.php @@ -7,6 +7,7 @@ use Tempest\Container\Singleton; use Tempest\Core\Environment; use Tempest\View\Elements\ElementFactory; +use Tempest\View\ViewCache; use Tempest\View\ViewConfig; final class ElementFactoryInitializer implements Initializer @@ -17,6 +18,7 @@ public function initialize(Container $container): ElementFactory return new ElementFactory( viewConfig: $container->get(ViewConfig::class), environment: $container->get(Environment::class), + viewCache: $container->get(ViewCache::class), ); } } diff --git a/packages/view/src/Parser/TempestViewCompiler.php b/packages/view/src/Parser/TempestViewCompiler.php index b9aca25e1..0cc21cab3 100644 --- a/packages/view/src/Parser/TempestViewCompiler.php +++ b/packages/view/src/Parser/TempestViewCompiler.php @@ -15,6 +15,7 @@ use Tempest\View\ShouldBeRemoved; use Tempest\View\View; +use Tempest\View\ViewCache; use function Tempest\Support\arr; use function Tempest\Support\path; use function Tempest\Support\str; diff --git a/packages/view/src/Renderers/TempestViewRenderer.php b/packages/view/src/Renderers/TempestViewRenderer.php index 26429037e..fe21ef1c7 100644 --- a/packages/view/src/Renderers/TempestViewRenderer.php +++ b/packages/view/src/Renderers/TempestViewRenderer.php @@ -38,10 +38,12 @@ public static function make( Environment $environment = Environment::PRODUCTION, ): self { $viewConfig ??= new ViewConfig(); + $viewCache ??= ViewCache::create(enabled: false); $elementFactory = new ElementFactory( $viewConfig, $environment, + $viewCache, ); $compiler = new TempestViewCompiler( @@ -51,8 +53,6 @@ public static function make( $elementFactory->setViewCompiler($compiler); - $viewCache ??= ViewCache::create(enabled: false); - return new self( compiler: $compiler, viewCache: $viewCache, diff --git a/src/Tempest/Framework/Testing/View/ViewTester.php b/src/Tempest/Framework/Testing/View/ViewTester.php index 246ba1b1d..665961bb9 100644 --- a/src/Tempest/Framework/Testing/View/ViewTester.php +++ b/src/Tempest/Framework/Testing/View/ViewTester.php @@ -37,12 +37,12 @@ public function render(string|View $view, mixed ...$params): string /** * Registers a view component for testing purposes. */ - public function registerViewComponent(string $name, string $html, string $file = '', bool $isVendor = false): self + public function registerViewComponent(string $name, string $html, ?string $file = null, bool $isVendor = false): self { $viewComponent = new ViewComponent( name: $name, contents: $html, - file: $file, + file: $file ?? ($name . '.view.php'), isVendorComponent: $isVendor, ); diff --git a/tests/Integration/View/ViewComponentTest.php b/tests/Integration/View/ViewComponentTest.php index 6ef4dbeb3..c9a8b7d7e 100644 --- a/tests/Integration/View/ViewComponentTest.php +++ b/tests/Integration/View/ViewComponentTest.php @@ -1028,4 +1028,31 @@ public function test_fallthrough_attribute_without_value(): void $this->assertSnippetsMatch('', $this->view->render('')); $this->assertSnippetsMatch('
hi
', $this->view->render('')); } + + #[Test] + public function performance(): void + { + $this->view->registerViewComponent('x-a', 'hi'); + + $start = microtime(true); + + $html = $this->view->render( + <<<'HTML' + + + {{ $item }} + + + HTML, + items: \Tempest\Support\Arr\range(1, 10000), + ); + + $end = microtime(true); + $time = $end - $start; + + + // Include: 0.10852599143982 + // Combined in one file: 0.0068130493164062 + ld($time); + } } From ce3d13ab9423b010d2b7a01be153ebd7a40b4029 Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 17 Feb 2026 09:18:10 +0100 Subject: [PATCH 02/29] wip --- packages/view/src/Elements/ViewComponentElement.php | 2 +- tests/Integration/View/ViewComponentTest.php | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index f9ba4fb09..30c7478c0 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -166,7 +166,7 @@ public function compile(): string $compiled = $this->compiler->compile($compiled->toString()); - return $compiled; +// return $compiled; $cachePath = $this->viewCache->getCachedViewPath( $this->viewComponent->file, diff --git a/tests/Integration/View/ViewComponentTest.php b/tests/Integration/View/ViewComponentTest.php index c9a8b7d7e..2fed2d67f 100644 --- a/tests/Integration/View/ViewComponentTest.php +++ b/tests/Integration/View/ViewComponentTest.php @@ -1029,8 +1029,7 @@ public function test_fallthrough_attribute_without_value(): void $this->assertSnippetsMatch('
hi
', $this->view->render('')); } - #[Test] - public function performance(): void + public function test_performance(): void { $this->view->registerViewComponent('x-a', 'hi'); From 2ad96e7ed82a522523ad6ad7ce58111ac017ff23 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 17 Feb 2026 09:28:51 +0100 Subject: [PATCH 03/29] wip --- .../src/Elements/ViewComponentElement.php | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index 30c7478c0..6dfe312f5 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -105,31 +105,13 @@ public function compile(): string $compiled = $compiled ->prepend( sprintf( - '', + '', $this->dataAttributes->isNotEmpty() ? ', ' . $this->dataAttributes->map(fn (string $_value, string $key) => "\${$key}")->implode(', ') : '', $this->expressionAttributes->isNotEmpty() ? ', ' . $this->expressionAttributes->map(fn (string $_value, string $key) => "\${$key}")->implode(', ') : '', $this->scopedVariables->isNotEmpty() ? ', ' . $this->scopedVariables->map(fn (string $name) => "\${$name}")->implode(', ') : '', ), ) - ->append( - sprintf( - 'currentView?->data ?? []) %s %s %s) ?>', - $this->exportAttributesArray(), - ViewObjectExporter::export($slots), - $this->scopedVariables->isNotEmpty() - ? $this->scopedVariables->map(fn (string $name) => "'{$name}' => \${$name}")->implode(', ') - : '', - $this->dataAttributes->isNotEmpty() - ? ', ' . $this->dataAttributes->map(fn (mixed $value, string $key) => "{$key}: " . ViewObjectExporter::exportValue($value))->implode(', ') - : '', - $this->expressionAttributes->isNotEmpty() - ? ', ' . $this->expressionAttributes->map(fn (mixed $value, string $key) => "{$key}: " . $value)->implode(', ') - : '', - $this->scopedVariables->isNotEmpty() - ? ', ' . $this->scopedVariables->map(fn (string $name) => "{$name}: \${$name}")->implode(', ') - : '', - ), - ); + ->append('replaceRegex( regex: '/[\w-]+)")?((\s*\/>)|>(?(.|\n)*?)<\/x-slot>)/', @@ -166,14 +148,34 @@ public function compile(): string $compiled = $this->compiler->compile($compiled->toString()); -// return $compiled; + $cacheKey = sprintf('%s:%s', $this->viewComponent->file, hash('xxh64', $compiled)); $cachePath = $this->viewCache->getCachedViewPath( - $this->viewComponent->file, + $cacheKey, fn () => $compiled, ); - return sprintf('', $cachePath); + $componentVariable = sprintf('$__tempestComponent_%s', hash('xxh64', $cacheKey)); + + return sprintf( + 'currentView?->data ?? []) %6$s %7$s %8$s); ?>', + $componentVariable, + var_export($cachePath, true), + $this->exportAttributesArray(), + ViewObjectExporter::export($slots), + $this->scopedVariables->isNotEmpty() + ? $this->scopedVariables->map(fn (string $name) => "'{$name}' => \${$name}")->implode(', ') + : '', + $this->dataAttributes->isNotEmpty() + ? ', ' . $this->dataAttributes->map(fn (mixed $value, string $key) => "{$key}: " . ViewObjectExporter::exportValue($value))->implode(', ') + : '', + $this->expressionAttributes->isNotEmpty() + ? ', ' . $this->expressionAttributes->map(fn (mixed $value, string $key) => "{$key}: " . $value)->implode(', ') + : '', + $this->scopedVariables->isNotEmpty() + ? ', ' . $this->scopedVariables->map(fn (string $name) => "{$name}: \${$name}")->implode(', ') + : '', + ); } private function getSlotElement(string $name): SlotElement|CollectionElement|null From e42223d6ed4128f1270e2275e0e8ec199ac60200 Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 17 Feb 2026 09:40:13 +0100 Subject: [PATCH 04/29] wip --- packages/view/src/Elements/ViewComponentElement.php | 2 +- tests/Integration/View/ViewComponentTest.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index 6dfe312f5..995327292 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -155,7 +155,7 @@ public function compile(): string fn () => $compiled, ); - $componentVariable = sprintf('$__tempestComponent_%s', hash('xxh64', $cacheKey)); + $componentVariable = sprintf('$__tempestComponent_%s', hash('xxh64', $cacheKey . ':' . spl_object_id($this))); return sprintf( 'currentView?->data ?? []) %6$s %7$s %8$s); ?>', diff --git a/tests/Integration/View/ViewComponentTest.php b/tests/Integration/View/ViewComponentTest.php index 2fed2d67f..3a1784831 100644 --- a/tests/Integration/View/ViewComponentTest.php +++ b/tests/Integration/View/ViewComponentTest.php @@ -1049,7 +1049,6 @@ public function test_performance(): void $end = microtime(true); $time = $end - $start; - // Include: 0.10852599143982 // Combined in one file: 0.0068130493164062 ld($time); From d0b983a88806d7921aa376ecc4ffc7c7d771828c Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 17 Feb 2026 09:49:58 +0100 Subject: [PATCH 05/29] wip --- packages/view/src/Elements/ViewComponentElement.php | 5 +---- packages/view/src/Renderers/TempestViewRenderer.php | 10 ++++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index 995327292..a8bde81f0 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -155,11 +155,8 @@ public function compile(): string fn () => $compiled, ); - $componentVariable = sprintf('$__tempestComponent_%s', hash('xxh64', $cacheKey . ':' . spl_object_id($this))); - return sprintf( - 'currentView?->data ?? []) %6$s %7$s %8$s); ?>', - $componentVariable, + 'includeViewComponent(%1$s)(attributes: %2$s, slots: %3$s, scopedVariables: [%4$s] + ($scopedVariables ?? $this->currentView?->data ?? []) %5$s %6$s %7$s); ?>', var_export($cachePath, true), $this->exportAttributesArray(), ViewObjectExporter::export($slots), diff --git a/packages/view/src/Renderers/TempestViewRenderer.php b/packages/view/src/Renderers/TempestViewRenderer.php index fe21ef1c7..fca5404ee 100644 --- a/packages/view/src/Renderers/TempestViewRenderer.php +++ b/packages/view/src/Renderers/TempestViewRenderer.php @@ -4,6 +4,7 @@ namespace Tempest\View\Renderers; +use Closure; use Stringable; use Tempest\Container\Container; use Tempest\Core\Environment; @@ -25,6 +26,9 @@ final class TempestViewRenderer implements ViewRenderer { private ?View $currentView = null; + /** @var array */ + private array $includedViewComponents = []; + public function __construct( private readonly TempestViewCompiler $compiler, private readonly ViewCache $viewCache, @@ -87,6 +91,12 @@ public function render(string|View $view): string return $this->renderCompiled($view, $path); } + public function includeViewComponent(string $path): Closure + { + /** @var Closure */ + return $this->includedViewComponents[$path] ??= include $path; + } + private function processView(View $view): View { foreach ($this->viewConfig->viewProcessors as $viewProcessorClass) { From 30e6a57041dbda9966dfb0a5b22c6f71c9b9be7f Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 17 Feb 2026 11:41:32 +0100 Subject: [PATCH 06/29] wip --- .../src/Exceptions/DevelopmentException.php | 18 +- packages/view/src/CompiledView.php | 17 ++ .../src/Elements/ViewComponentElement.php | 21 +- .../src/Exceptions/ViewCompilationFailed.php | 10 +- .../view/src/Parser/TempestViewCompiler.php | 248 +++++++++++++++++- packages/view/src/Parser/TempestViewLexer.php | 69 +++-- packages/view/src/Parser/Token.php | 2 + .../src/Renderers/TempestViewRenderer.php | 72 ++++- packages/view/src/ViewCache.php | 48 ++++ .../standalone-error-component-usage.view.php | 1 + .../standalone-error-slot-usage.view.php | 3 + .../tests/Fixtures/standalone-error.view.php | 3 + .../x-standalone-error-component.view.php | 3 + .../view/tests/StandaloneViewRendererTest.php | 56 ++++ 14 files changed, 531 insertions(+), 40 deletions(-) create mode 100644 packages/view/src/CompiledView.php create mode 100644 packages/view/tests/Fixtures/standalone-error-component-usage.view.php create mode 100644 packages/view/tests/Fixtures/standalone-error-slot-usage.view.php create mode 100644 packages/view/tests/Fixtures/standalone-error.view.php create mode 100644 packages/view/tests/Fixtures/x-standalone-error-component.view.php diff --git a/packages/router/src/Exceptions/DevelopmentException.php b/packages/router/src/Exceptions/DevelopmentException.php index 8ffa2d6da..41511cfce 100644 --- a/packages/router/src/Exceptions/DevelopmentException.php +++ b/packages/router/src/Exceptions/DevelopmentException.php @@ -90,8 +90,18 @@ private function enhanceStacktraceForViewCompilation(ViewCompilationFailed $exce return $stacktrace; } - $lines = explode("\n", $exception->content); - $errorLine = $previous->getLine(); + $hasSourceLocation = $exception->sourcePath && $exception->sourceLine && Filesystem\is_file($exception->sourcePath); + + $errorPath = $hasSourceLocation + ? $exception->sourcePath + : $exception->path; + + $errorLine = $exception->sourceLine ?? $previous->getLine(); + + $lines = $hasSourceLocation + ? explode("\n", Filesystem\read_file($exception->sourcePath)) + : explode("\n", $exception->content); + $contextLines = 5; $startLine = max(1, $errorLine - $contextLines); $endLine = min(count($lines), $errorLine + $contextLines); @@ -111,8 +121,8 @@ function: 'renderCompiled', lines: $snippetLines, highlightedLine: $errorLine, ), - absoluteFile: $exception->path, - relativeFile: to_relative_path(root_path(), $exception->path), + absoluteFile: $errorPath, + relativeFile: to_relative_path(root_path(), $errorPath), arguments: [], index: 1, )); diff --git a/packages/view/src/CompiledView.php b/packages/view/src/CompiledView.php new file mode 100644 index 000000000..34210f268 --- /dev/null +++ b/packages/view/src/CompiledView.php @@ -0,0 +1,17 @@ + $lineMap + */ + public function __construct( + public string $content, + public ?string $sourcePath, + public array $lineMap, + ) {} +} diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index a8bde81f0..992548922 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -142,19 +142,32 @@ public function compile(): string return $default; } - return $compiled; + if ($slotElement === null) { + return $compiled; + } + + $slotElements = $slotElement instanceof CollectionElement + ? $slotElement->getElements() + : $slotElement->getChildren(); + + return $this->compiler->compileFragment($slotElements); }, ); - $compiled = $this->compiler->compile($compiled->toString()); + $compiledView = $this->compiler->compileWithSourceMap( + $compiled->toString(), + sourcePath: $this->viewComponent->file, + ); - $cacheKey = sprintf('%s:%s', $this->viewComponent->file, hash('xxh64', $compiled)); + $cacheKey = sprintf('%s:%s', $this->viewComponent->file, hash('xxh64', $compiledView->content)); $cachePath = $this->viewCache->getCachedViewPath( $cacheKey, - fn () => $compiled, + fn () => $compiledView->content, ); + $this->viewCache->saveSourceMap($cachePath, $compiledView->sourcePath, $compiledView->lineMap); + return sprintf( 'includeViewComponent(%1$s)(attributes: %2$s, slots: %3$s, scopedVariables: [%4$s] + ($scopedVariables ?? $this->currentView?->data ?? []) %5$s %6$s %7$s); ?>', var_export($cachePath, true), diff --git a/packages/view/src/Exceptions/ViewCompilationFailed.php b/packages/view/src/Exceptions/ViewCompilationFailed.php index 7cfeb24c2..1dd4bbef7 100644 --- a/packages/view/src/Exceptions/ViewCompilationFailed.php +++ b/packages/view/src/Exceptions/ViewCompilationFailed.php @@ -14,9 +14,15 @@ public function __construct( private(set) string $path, private(set) string $content, Throwable $previous, + private(set) ?string $sourcePath = null, + private(set) ?int $sourceLine = null, ) { + $sourceLocation = $sourcePath && $sourceLine + ? sprintf(' in %s:%d', $sourcePath, $sourceLine) + : ''; + parent::__construct( - message: sprintf('View could not be compiled: %s.', lcfirst($previous->getMessage())), + message: sprintf('View could not be compiled%s: %s.', $sourceLocation, lcfirst($previous->getMessage())), previous: $previous, ); } @@ -25,6 +31,8 @@ public function context(): array { return [ 'path' => $this->path, + 'sourcePath' => $this->sourcePath, + 'sourceLine' => $this->sourceLine, ]; } } diff --git a/packages/view/src/Parser/TempestViewCompiler.php b/packages/view/src/Parser/TempestViewCompiler.php index 0cc21cab3..599a6a910 100644 --- a/packages/view/src/Parser/TempestViewCompiler.php +++ b/packages/view/src/Parser/TempestViewCompiler.php @@ -8,14 +8,16 @@ use Tempest\Support\Filesystem; use Tempest\View\Attribute; use Tempest\View\Attributes\AttributeFactory; +use Tempest\View\CompiledView; use Tempest\View\Element; use Tempest\View\Elements\ElementFactory; use Tempest\View\Exceptions\ViewNotFound; use Tempest\View\Exceptions\XmlDeclarationCouldNotBeParsed; use Tempest\View\ShouldBeRemoved; use Tempest\View\View; +use Tempest\View\WithToken; +use Tempest\View\WrapsElement; -use Tempest\View\ViewCache; use function Tempest\Support\arr; use function Tempest\Support\path; use function Tempest\Support\str; @@ -28,6 +30,10 @@ '?>', ]; + private const string SOURCE_PATH_MARKER = '__tempest_source_path='; + + private const string SOURCE_LINE_MARKER = '__tempest_source_line='; + public function __construct( private ElementFactory $elementFactory, private AttributeFactory $attributeFactory, @@ -36,11 +42,17 @@ public function __construct( ) {} public function compile(string|View $view): string + { + return $this->compileWithSourceMap($view)->content; + } + + public function compileWithSourceMap(string|View $view, ?string $sourcePath = null): CompiledView { $this->elementFactory->setViewCompiler($this); // 1. Retrieve template - $template = $this->retrieveTemplate($view); + [$template, $resolvedSourcePath] = $this->retrieveTemplate($view); + $sourcePath ??= $resolvedSourcePath; // Check for XML declarations when short_open_tag is enabled if (ini_get('short_open_tag') && str_contains($template, needle: 'removeComments($template); // 3. Parse AST - $ast = $this->parseAst($template); + $ast = $this->parseAst($template, $sourcePath); // 4. Map to elements $elements = $this->mapToElements($ast); @@ -63,9 +75,19 @@ public function compile(string|View $view): string $compiled = $this->compileElements($elements); // 7. Cleanup compiled PHP - $cleaned = $this->cleanupCompiled($compiled); + [$cleaned, $lineMap] = $this->cleanupCompiled($compiled, $sourcePath); - return $cleaned; + return new CompiledView( + content: $cleaned, + sourcePath: $sourcePath, + lineMap: $lineMap, + ); + } + + /** @param Element[] $elements */ + public function compileFragment(array $elements): string + { + return $this->compileElements($elements); } private function removeComments(string $template): string @@ -75,12 +97,13 @@ private function removeComments(string $template): string ->toString(); } - private function retrieveTemplate(string|View $view): string + /** @return array{string, string|null} */ + private function retrieveTemplate(string|View $view): array { $path = $view instanceof View ? $view->path : $view; if (! str_ends_with($path, '.php')) { - return $path; + return [$path, null]; } $searchPathOptions = [ @@ -108,12 +131,12 @@ private function retrieveTemplate(string|View $view): string throw new ViewNotFound($path); } - return Filesystem\read_file($searchPath); + return [Filesystem\read_file($searchPath), $searchPath]; } - private function parseAst(string $template): TempestViewAst + private function parseAst(string $template, ?string $sourcePath = null): TempestViewAst { - $tokens = new TempestViewLexer($template)->lex(); + $tokens = new TempestViewLexer($template, $sourcePath)->lex(); return new TempestViewParser($tokens)->parse(); } @@ -189,8 +212,21 @@ private function applyAttributes(array $elements): array private function compileElements(array $elements): string { $compiled = arr(); + $sourcePath = null; foreach ($elements as $element) { + if ($sourceLocation = $this->resolveSourceLocation($element)) { + if ($sourceLocation['sourcePath'] !== $sourcePath) { + $sourcePath = $sourceLocation['sourcePath']; + + if ($sourcePath !== null) { + $compiled[] = self::sourcePathMarker($sourcePath); + } + } + + $compiled[] = self::sourceLineMarker($sourceLocation['sourceLine']); + } + $compiled[] = $element->compile(); } @@ -199,7 +235,43 @@ private function compileElements(array $elements): string ->toString(); } - private function cleanupCompiled(string $compiled): string + private static function sourcePathMarker(string $sourcePath): string + { + return sprintf('', self::SOURCE_PATH_MARKER, base64_encode($sourcePath)); + } + + private static function sourceLineMarker(int $sourceLine): string + { + return sprintf('', self::SOURCE_LINE_MARKER, $sourceLine); + } + + /** @return array{sourcePath: string|null, sourceLine: int}|null */ + private function resolveSourceLocation(Element $element): ?array + { + if ($element instanceof WithToken) { + return [ + 'sourcePath' => $element->token->sourcePath, + 'sourceLine' => $element->token->line, + ]; + } + + if ($element instanceof WrapsElement) { + return $this->resolveSourceLocation($element->getWrappingElement()); + } + + foreach ($element->getChildren() as $child) { + if ($sourceLocation = $this->resolveSourceLocation($child)) { + return $sourceLocation; + } + } + + return null; + } + + /** + * @return array{string, array} + */ + private function cleanupCompiled(string $compiled, ?string $sourcePath): array { // Remove strict type declarations $compiled = str($compiled)->replace('declare(strict_types=1);', ''); @@ -232,6 +304,158 @@ private function cleanupCompiled(string $compiled): string // Remove empty PHP blocks $compiled = $compiled->replaceRegex('/<\?php\s*\?>/', ''); - return $compiled->toString(); + return $this->extractSourceMap($compiled->toString(), $sourcePath); + } + + /** + * @return array{string, array} + */ + private function extractSourceMap(string $compiled, ?string $sourcePath): array + { + $compiledLines = explode("\n", $compiled); + + $cleanedLines = []; + $lineMap = []; + $currentSourcePath = $sourcePath; + $sourceLine = null; + $compiledLine = 0; + + foreach ($compiledLines as $line) { + if (preg_match(sprintf('/^\s*<\?php \/\*%s(?[a-zA-Z0-9\+\/=]+)\*\/ \?>\s*$/', self::SOURCE_PATH_MARKER), $line, $matches) === 1) { + $decodedPath = base64_decode($matches['path'], true); + $currentSourcePath = $decodedPath === false ? null : $decodedPath; + continue; + } + + if (preg_match(sprintf('/^\s*<\?php \/\*%s(?\d+)\*\/ \?>\s*$/', self::SOURCE_LINE_MARKER), $line, $matches) === 1) { + $sourceLine = $currentSourcePath !== null + ? (int) $matches['line'] + : null; + + continue; + } + + $compiledLine++; + $cleanedLines[] = $line; + + if ($sourceLine === null || $currentSourcePath === null) { + continue; + } + + $lineMap[$compiledLine] = [ + 'sourcePath' => $currentSourcePath, + 'sourceLine' => $sourceLine, + ]; + + $sourceLine++; + } + + return [ + implode("\n", $cleanedLines), + $this->compressLineMap($lineMap), + ]; + } + + /** + * @param array $lineMap + * @return array + */ + private function compressLineMap(array $lineMap): array + { + if ($lineMap === []) { + return []; + } + + ksort($lineMap); + + $entries = []; + $currentRange = null; + + foreach ($lineMap as $compiledLine => $sourceLocation) { + $lineMapping = $this->createLineMapping($compiledLine, $sourceLocation); + + if ($currentRange === null) { + $currentRange = $this->startLineMapRange($lineMapping); + continue; + } + + if ($this->canExtendLineMapRange($currentRange, $lineMapping)) { + $currentRange = $this->extendLineMapRange($currentRange, $lineMapping); + continue; + } + + $entries[] = $this->createLineMapEntry($currentRange); + $currentRange = $this->startLineMapRange($lineMapping); + } + + $entries[] = $this->createLineMapEntry($currentRange); + + return $entries; + } + + /** + * @param array{sourcePath: string, sourceLine: int} $sourceLocation + * @return array{compiledLine: int, sourcePath: string, sourceLine: int} + */ + private function createLineMapping(int $compiledLine, array $sourceLocation): array + { + return [ + 'compiledLine' => $compiledLine, + 'sourcePath' => $sourceLocation['sourcePath'], + 'sourceLine' => $sourceLocation['sourceLine'], + ]; + } + + /** + * @param array{compiledLine: int, sourcePath: string, sourceLine: int} $lineMapping + * @return array{compiledStartLine: int, compiledEndLine: int, sourcePath: string, sourceStartLine: int, sourceEndLine: int} + */ + private function startLineMapRange(array $lineMapping): array + { + return [ + 'compiledStartLine' => $lineMapping['compiledLine'], + 'compiledEndLine' => $lineMapping['compiledLine'], + 'sourcePath' => $lineMapping['sourcePath'], + 'sourceStartLine' => $lineMapping['sourceLine'], + 'sourceEndLine' => $lineMapping['sourceLine'], + ]; + } + + /** + * @param array{compiledStartLine: int, compiledEndLine: int, sourcePath: string, sourceStartLine: int, sourceEndLine: int} $range + * @param array{compiledLine: int, sourcePath: string, sourceLine: int} $lineMapping + */ + private function canExtendLineMapRange(array $range, array $lineMapping): bool + { + return $lineMapping['compiledLine'] === $range['compiledEndLine'] + 1 + && $lineMapping['sourcePath'] === $range['sourcePath'] + && $lineMapping['sourceLine'] === $range['sourceEndLine'] + 1; + } + + /** + * @param array{compiledStartLine: int, compiledEndLine: int, sourcePath: string, sourceStartLine: int, sourceEndLine: int} $range + * @param array{compiledLine: int, sourcePath: string, sourceLine: int} $lineMapping + * @return array{compiledStartLine: int, compiledEndLine: int, sourcePath: string, sourceStartLine: int, sourceEndLine: int} + */ + private function extendLineMapRange(array $range, array $lineMapping): array + { + $range['compiledEndLine'] = $lineMapping['compiledLine']; + $range['sourceEndLine'] = $lineMapping['sourceLine']; + + return $range; + } + + /** + * @param array{compiledStartLine: int, compiledEndLine: int, sourcePath: string, sourceStartLine: int, sourceEndLine: int} $range + * @return array{compiledStartLine: int, compiledEndLine: int, sourcePath: string, sourceStartLine: int} + */ + private function createLineMapEntry(array $range): array + { + return [ + 'compiledStartLine' => $range['compiledStartLine'], + 'compiledEndLine' => $range['compiledEndLine'], + 'sourcePath' => $range['sourcePath'], + 'sourceStartLine' => $range['sourceStartLine'], + ]; } } diff --git a/packages/view/src/Parser/TempestViewLexer.php b/packages/view/src/Parser/TempestViewLexer.php index e6d7eced8..1f5c8931d 100644 --- a/packages/view/src/Parser/TempestViewLexer.php +++ b/packages/view/src/Parser/TempestViewLexer.php @@ -8,10 +8,13 @@ final class TempestViewLexer private int $position = 0; + private int $line = 1; + private ?string $current; public function __construct( private readonly string $html, + private readonly ?string $sourcePath = null, ) { $this->current = $this->html[$this->position] ?? null; } @@ -70,6 +73,7 @@ private function consume(int $length = 1): string { $buffer = substr($this->html, $this->position, $length); $this->position += $length; + $this->line += substr_count($buffer, "\n"); $this->current = $this->html[$this->position] ?? null; return $buffer; @@ -94,20 +98,31 @@ private function consumeIncluding(string $search): string return $this->consumeUntil($search) . $this->consume(strlen($search)); } + private function makeToken(string $content, TokenType $type, int $line): Token + { + return new Token( + content: $content, + type: $type, + line: $line, + sourcePath: $this->sourcePath, + ); + } + private function lexTag(): array { + $tagLine = $this->line; $tag = $this->consumeWhile('consumeIncluding('>'); - $tokens[] = new Token($tag, TokenType::CLOSING_TAG); + $tokens[] = $this->makeToken($tag, TokenType::CLOSING_TAG, $tagLine); } elseif ($this->seekIgnoringWhitespace() === '/' || str_ends_with($tag, '/')) { $tag .= $this->consumeIncluding('>'); - $tokens[] = new Token($tag, TokenType::SELF_CLOSING_TAG); + $tokens[] = $this->makeToken($tag, TokenType::SELF_CLOSING_TAG, $tagLine); } else { - $tokens[] = new Token($tag, TokenType::OPEN_TAG_START); + $tokens[] = $this->makeToken($tag, TokenType::OPEN_TAG_START, $tagLine); while ($this->seek() !== null && $this->seekIgnoringWhitespace() !== '>' && $this->seekIgnoringWhitespace() !== '/') { if ($this->seekIgnoringWhitespace(2) === 'line; $attributeName = $this->consumeWhile(self::WHITESPACE); $attributeName .= $this->consumeUntil(self::WHITESPACE . '=/>'); @@ -125,9 +141,10 @@ private function lexTag(): array $attributeName .= $this->consume(); } - $tokens[] = new Token( + $tokens[] = $this->makeToken( content: $attributeName, type: TokenType::ATTRIBUTE_NAME, + line: $attributeLine, ); if ($hasValue) { @@ -135,25 +152,33 @@ private function lexTag(): array ? '\'' : '"'; + $attributeValueLine = $this->line; $attributeValue = $this->consumeIncluding($quote); $attributeValue .= $this->consumeIncluding($quote); - $tokens[] = new Token( + $tokens[] = $this->makeToken( content: $attributeValue, type: TokenType::ATTRIBUTE_VALUE, + line: $attributeValueLine, ); } } if ($this->seekIgnoringWhitespace() === '>') { - $tokens[] = new Token( + $openTagEndLine = $this->line; + + $tokens[] = $this->makeToken( content: $this->consumeIncluding('>'), type: TokenType::OPEN_TAG_END, + line: $openTagEndLine, ); } elseif ($this->seekIgnoringWhitespace() === '/') { - $tokens[] = new Token( + $selfClosingTagEndLine = $this->line; + + $tokens[] = $this->makeToken( content: $this->consumeIncluding('>'), type: TokenType::SELF_CLOSING_TAG_END, + line: $selfClosingTagEndLine, ); } } @@ -163,6 +188,7 @@ private function lexTag(): array private function lexXml(): Token { + $line = $this->line; $buffer = ''; while ($this->seek(2) !== '?>' && $this->current !== null) { @@ -171,11 +197,12 @@ private function lexXml(): Token $buffer .= $this->consume(2); - return new Token($buffer, TokenType::XML); + return $this->makeToken($buffer, TokenType::XML, $line); } private function lexPhp(): Token { + $line = $this->line; $buffer = ''; while ($this->seek(2) !== '?>' && $this->current !== null) { @@ -184,18 +211,20 @@ private function lexPhp(): Token $buffer .= $this->consume(2); - return new Token($buffer, TokenType::PHP); + return $this->makeToken($buffer, TokenType::PHP, $line); } private function lexContent(): Token { + $line = $this->line; $buffer = $this->consumeUntil('<'); - return new Token($buffer, TokenType::CONTENT); + return $this->makeToken($buffer, TokenType::CONTENT, $line); } private function lexComment(): Token { + $line = $this->line; $buffer = ''; while ($this->seek(3) !== '-->' && $this->current !== null) { @@ -204,37 +233,45 @@ private function lexComment(): Token $buffer .= $this->consume(3); - return new Token($buffer, TokenType::COMMENT); + return $this->makeToken($buffer, TokenType::COMMENT, $line); } private function lexDoctype(): Token { + $line = $this->line; $buffer = $this->consumeIncluding('>'); - return new Token($buffer, TokenType::DOCTYPE); + return $this->makeToken($buffer, TokenType::DOCTYPE, $line); } private function lexWhitespace(): Token { + $line = $this->line; $buffer = $this->consumeWhile(self::WHITESPACE); - return new Token($buffer, TokenType::WHITESPACE); + return $this->makeToken($buffer, TokenType::WHITESPACE, $line); } private function lexCharacterData(): array { + $characterDataOpenLine = $this->line; + $tokens = [ - new Token($this->consumeIncluding('makeToken($this->consumeIncluding('line; + while ($this->seek(3) !== ']]>' && $this->current !== null) { $buffer .= $this->consume(); } - $tokens[] = new Token($buffer, TokenType::CONTENT); - $tokens[] = new Token($this->consume(3), TokenType::CHARACTER_DATA_CLOSE); + $tokens[] = $this->makeToken($buffer, TokenType::CONTENT, $contentLine); + + $characterDataCloseLine = $this->line; + $tokens[] = $this->makeToken($this->consume(3), TokenType::CHARACTER_DATA_CLOSE, $characterDataCloseLine); return $tokens; } diff --git a/packages/view/src/Parser/Token.php b/packages/view/src/Parser/Token.php index 939dadd56..007196160 100644 --- a/packages/view/src/Parser/Token.php +++ b/packages/view/src/Parser/Token.php @@ -29,6 +29,8 @@ final class Token public function __construct( public readonly string $content, public readonly TokenType $type, + public readonly int $line = 1, + public readonly ?string $sourcePath = null, ) { $this->tag = (match ($this->type) { TokenType::OPEN_TAG_START => str($this->content) diff --git a/packages/view/src/Renderers/TempestViewRenderer.php b/packages/view/src/Renderers/TempestViewRenderer.php index fca5404ee..0f0b714bc 100644 --- a/packages/view/src/Renderers/TempestViewRenderer.php +++ b/packages/view/src/Renderers/TempestViewRenderer.php @@ -81,11 +81,21 @@ public function render(string|View $view): string $this->validateView($view); + $compiledView = null; + $path = $this->viewCache->getCachedViewPath( path: $view->path, - compiledView: fn () => $this->compiler->compile($view), + compiledView: function () use (&$compiledView, $view): string { + $compiledView = $this->compiler->compileWithSourceMap($view); + + return $compiledView->content; + }, ); + if ($compiledView !== null) { + $this->viewCache->saveSourceMap($path, $compiledView->sourcePath, $compiledView->lineMap); + } + $view = $this->processView($view); return $this->renderCompiled($view, $path); @@ -129,9 +139,14 @@ private function renderCompiled(View $_view, string $_path): string } catch (Throwable $throwable) { ob_end_clean(); // clean buffer before rendering exception + $compiledPath = $throwable->getFile(); + $sourceLocation = $this->resolveSourceLocation($compiledPath, $throwable->getLine()); + throw new ViewCompilationFailed( - path: $_path, - content: Filesystem\read_file($_path), + path: $compiledPath, + content: Filesystem\is_file($compiledPath) ? Filesystem\read_file($compiledPath) : '', + sourcePath: $sourceLocation['path'] ?? null, + sourceLine: $sourceLocation['line'] ?? null, previous: $throwable, ); } @@ -162,4 +177,55 @@ private function validateView(View $view): void throw new ViewVariableWasReserved('slots'); } } + + /** @return array{path: string, line: int}|null */ + private function resolveSourceLocation(string $compiledPath, int $compiledLine): ?array + { + $sourceMap = $this->viewCache->getSourceMap($compiledPath); + + if ($sourceMap === null) { + return null; + } + + $sourceLocation = $this->resolveSourceLine( + $compiledLine, + $sourceMap['sourcePath'], + $sourceMap['lineMap'], + ); + + if ($sourceLocation === null) { + return null; + } + + return [ + 'path' => $sourceLocation['path'], + 'line' => $sourceLocation['line'], + ]; + } + + /** + * @param array $lineMap + * @return array{path: string, line: int}|null + */ + private function resolveSourceLine(int $compiledLine, ?string $defaultSourcePath, array $lineMap): ?array + { + foreach ($lineMap as $entry) { + if ($compiledLine < $entry['compiledStartLine'] || $compiledLine > $entry['compiledEndLine']) { + continue; + } + + $sourcePath = $entry['sourcePath'] ?? $defaultSourcePath; + + if (! is_string($sourcePath)) { + return null; + } + + return [ + 'path' => $sourcePath, + 'line' => $entry['sourceStartLine'] + ($compiledLine - $entry['compiledStartLine']), + ]; + } + + return null; + } } diff --git a/packages/view/src/ViewCache.php b/packages/view/src/ViewCache.php index fe0db3e46..83199b864 100644 --- a/packages/view/src/ViewCache.php +++ b/packages/view/src/ViewCache.php @@ -5,6 +5,7 @@ namespace Tempest\View; use Closure; +use Tempest\Support\Filesystem; use Throwable; use function Tempest\internal_storage_path; @@ -49,6 +50,53 @@ public function getCachedViewPath(string $path, Closure $compiledView): string return path($this->pool->directory, $cacheItem->getKey() . '.php')->toString(); } + /** + * @param array $lineMap + */ + public function saveSourceMap(string $compiledViewPath, ?string $sourcePath, array $lineMap): void + { + $sourceMapPath = $this->getSourceMapPath($compiledViewPath); + + $sourceMap = [ + 'sourcePath' => $sourcePath, + 'lineMap' => $lineMap, + ]; + + Filesystem\write_file( + $sourceMapPath, + sprintf("}|null + */ + public function getSourceMap(string $compiledViewPath): ?array + { + $sourceMapPath = $this->getSourceMapPath($compiledViewPath); + + if (! Filesystem\is_file($sourceMapPath)) { + return null; + } + + $sourceMap = include $sourceMapPath; + + if (! is_array($sourceMap)) { + return null; + } + + return $sourceMap; + } + + private function getSourceMapPath(string $compiledViewPath): string + { + if (str_ends_with($compiledViewPath, '.php')) { + return substr($compiledViewPath, offset: 0, length: -4) . '.map.php'; + } + + return $compiledViewPath . '.map.php'; + } + private static function getCachePath(): string { try { diff --git a/packages/view/tests/Fixtures/standalone-error-component-usage.view.php b/packages/view/tests/Fixtures/standalone-error-component-usage.view.php new file mode 100644 index 000000000..9161191c8 --- /dev/null +++ b/packages/view/tests/Fixtures/standalone-error-component-usage.view.php @@ -0,0 +1 @@ + diff --git a/packages/view/tests/Fixtures/standalone-error-slot-usage.view.php b/packages/view/tests/Fixtures/standalone-error-slot-usage.view.php new file mode 100644 index 000000000..8bcb38da5 --- /dev/null +++ b/packages/view/tests/Fixtures/standalone-error-slot-usage.view.php @@ -0,0 +1,3 @@ + + + diff --git a/packages/view/tests/Fixtures/standalone-error.view.php b/packages/view/tests/Fixtures/standalone-error.view.php new file mode 100644 index 000000000..6e284bb52 --- /dev/null +++ b/packages/view/tests/Fixtures/standalone-error.view.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/packages/view/tests/Fixtures/x-standalone-error-component.view.php b/packages/view/tests/Fixtures/x-standalone-error-component.view.php new file mode 100644 index 000000000..b6619a8fa --- /dev/null +++ b/packages/view/tests/Fixtures/x-standalone-error-component.view.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/packages/view/tests/StandaloneViewRendererTest.php b/packages/view/tests/StandaloneViewRendererTest.php index dc39898f4..7f05162f8 100644 --- a/packages/view/tests/StandaloneViewRendererTest.php +++ b/packages/view/tests/StandaloneViewRendererTest.php @@ -3,6 +3,8 @@ namespace Tempest\View\Tests; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\Test; +use Tempest\View\Exceptions\ViewCompilationFailed; use Tempest\View\Exceptions\ViewComponentPathWasInvalid; use Tempest\View\Exceptions\ViewComponentPathWasNotFound; use Tempest\View\Exceptions\XmlDeclarationCouldNotBeParsed; @@ -119,6 +121,60 @@ public function test_xml_declaration_with_short_open_tag(): void $renderer->render(''); } + #[Test] + public function test_maps_source_path_and_line_for_view_errors(): void + { + $renderer = TempestViewRenderer::make(); + + try { + $renderer->render(view(__DIR__ . '/Fixtures/standalone-error.view.php')); + $this->fail('Expected a view compilation exception.'); + } catch (ViewCompilationFailed $exception) { + $this->assertSame(__DIR__ . '/Fixtures/standalone-error.view.php', $exception->sourcePath); + $this->assertSame(2, $exception->sourceLine); + } + } + + #[Test] + public function test_maps_source_path_and_line_for_component_errors(): void + { + $viewConfig = new ViewConfig()->addViewComponents( + __DIR__ . '/Fixtures/x-standalone-error-component.view.php', + ); + + $renderer = TempestViewRenderer::make( + viewConfig: $viewConfig, + ); + + try { + $renderer->render(view(__DIR__ . '/Fixtures/standalone-error-component-usage.view.php')); + $this->fail('Expected a view compilation exception.'); + } catch (ViewCompilationFailed $exception) { + $this->assertSame(__DIR__ . '/Fixtures/x-standalone-error-component.view.php', $exception->sourcePath); + $this->assertSame(2, $exception->sourceLine); + } + } + + #[Test] + public function test_maps_source_path_and_line_for_slot_content_errors(): void + { + $viewConfig = new ViewConfig()->addViewComponents( + __DIR__ . '/Fixtures/x-standalone-base.view.php', + ); + + $renderer = TempestViewRenderer::make( + viewConfig: $viewConfig, + ); + + try { + $renderer->render(view(__DIR__ . '/Fixtures/standalone-error-slot-usage.view.php')); + $this->fail('Expected a view compilation exception.'); + } catch (ViewCompilationFailed $exception) { + $this->assertSame(__DIR__ . '/Fixtures/standalone-error-slot-usage.view.php', $exception->sourcePath); + $this->assertSame(2, $exception->sourceLine); + } + } + protected function assertSnippetsMatch(string $expected, string $actual): void { $expected = str_replace([PHP_EOL, ' '], '', $expected); From 726acb8fbcf9a329df488ec9b6e3e4329f545a6d Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 17 Feb 2026 11:46:19 +0100 Subject: [PATCH 07/29] wip --- packages/view/src/Exceptions/ViewCompilationFailed.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/view/src/Exceptions/ViewCompilationFailed.php b/packages/view/src/Exceptions/ViewCompilationFailed.php index 1dd4bbef7..9acf8ccc8 100644 --- a/packages/view/src/Exceptions/ViewCompilationFailed.php +++ b/packages/view/src/Exceptions/ViewCompilationFailed.php @@ -17,14 +17,13 @@ public function __construct( private(set) ?string $sourcePath = null, private(set) ?int $sourceLine = null, ) { - $sourceLocation = $sourcePath && $sourceLine - ? sprintf(' in %s:%d', $sourcePath, $sourceLine) - : ''; - parent::__construct( - message: sprintf('View could not be compiled%s: %s.', $sourceLocation, lcfirst($previous->getMessage())), + message: sprintf('View could not be compiled: %s', lcfirst($previous->getMessage())), previous: $previous, ); + + $this->file = $this->sourcePath ?? $this->file; + $this->line = $this->sourceLine ?? $this->line; } public function context(): array From f3c317ec5e6109866621a53d46039114e3dd95c8 Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 17 Feb 2026 11:47:09 +0100 Subject: [PATCH 08/29] wip --- packages/view/src/Exceptions/ViewCompilationFailed.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/view/src/Exceptions/ViewCompilationFailed.php b/packages/view/src/Exceptions/ViewCompilationFailed.php index 9acf8ccc8..dc94be63f 100644 --- a/packages/view/src/Exceptions/ViewCompilationFailed.php +++ b/packages/view/src/Exceptions/ViewCompilationFailed.php @@ -18,7 +18,7 @@ public function __construct( private(set) ?int $sourceLine = null, ) { parent::__construct( - message: sprintf('View could not be compiled: %s', lcfirst($previous->getMessage())), + message: sprintf($previous->getMessage()), previous: $previous, ); From 48df9b9690cfed7012285262644055f15bb3dfc2 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 17 Feb 2026 12:31:11 +0100 Subject: [PATCH 09/29] wip --- .../src/Elements/ViewComponentElement.php | 18 ++--- .../view/src/Parser/TempestViewCompiler.php | 72 ++++++++++++++++++- .../src/Renderers/TempestViewRenderer.php | 34 +++++++-- 3 files changed, 110 insertions(+), 14 deletions(-) diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index 992548922..84fbb8c42 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -135,22 +135,22 @@ public function compile(): string $slotElement = $this->getSlotElement($slot->name); - $compiled = $slotElement?->compile() ?? ''; - - // There's no default slot content, but there's a default value in the view component - if (trim($compiled) === '') { - return $default; - } - if ($slotElement === null) { - return $compiled; + return $default; } $slotElements = $slotElement instanceof CollectionElement ? $slotElement->getElements() : $slotElement->getChildren(); - return $this->compiler->compileFragment($slotElements); + $compiled = $this->compiler->compileFragment($slotElements); + + // There's no default slot content, but there's a default value in the view component + if (trim($compiled) === '') { + return $default; + } + + return $compiled; }, ); diff --git a/packages/view/src/Parser/TempestViewCompiler.php b/packages/view/src/Parser/TempestViewCompiler.php index 599a6a910..9b0ea40fe 100644 --- a/packages/view/src/Parser/TempestViewCompiler.php +++ b/packages/view/src/Parser/TempestViewCompiler.php @@ -87,7 +87,16 @@ public function compileWithSourceMap(string|View $view, ?string $sourcePath = nu /** @param Element[] $elements */ public function compileFragment(array $elements): string { - return $this->compileElements($elements); + $imports = $this->collectImportsForElements($elements); + + if ($imports === []) { + return $this->compileElements($elements); + } + + return implode(PHP_EOL, [ + ...$imports, + $this->compileElements($elements), + ]); } private function removeComments(string $template): string @@ -141,6 +150,67 @@ private function parseAst(string $template, ?string $sourcePath = null): Tempest return new TempestViewParser($tokens)->parse(); } + /** @param Element[] $elements */ + private function collectImportsForElements(array $elements): array + { + $imports = []; + + foreach ($this->collectSourcePathsForElements($elements) as $sourcePath) { + foreach ($this->extractImportsFromSourcePath($sourcePath) as $import) { + $imports[$import] = $import; + } + } + + return array_values($imports); + } + + /** @param Element[] $elements */ + private function collectSourcePathsForElements(array $elements): array + { + $sourcePaths = []; + + foreach ($elements as $element) { + $this->collectSourcePathsForElement($element, $sourcePaths); + } + + return array_keys($sourcePaths); + } + + /** @param array $sourcePaths */ + private function collectSourcePathsForElement(Element $element, array &$sourcePaths): void + { + if ($element instanceof WithToken && is_string($element->token->sourcePath)) { + $sourcePaths[$element->token->sourcePath] = true; + } + + if ($element instanceof WrapsElement) { + $this->collectSourcePathsForElement($element->getWrappingElement(), $sourcePaths); + } + + foreach ($element->getChildren() as $child) { + $this->collectSourcePathsForElement($child, $sourcePaths); + } + } + + /** @return string[] */ + private function extractImportsFromSourcePath(string $sourcePath): array + { + static $imports = []; + + if (isset($imports[$sourcePath])) { + return $imports[$sourcePath]; + } + + if (! Filesystem\is_file($sourcePath)) { + return $imports[$sourcePath] = []; + } + + // TODO: this is not ideal, could use a little more love + preg_match_all('/^\s*use (function )?.*;/m', Filesystem\read_file($sourcePath), $matches); + + return $imports[$sourcePath] = array_values(array_unique($matches[0])); + } + /** * @return Element[] */ diff --git a/packages/view/src/Renderers/TempestViewRenderer.php b/packages/view/src/Renderers/TempestViewRenderer.php index 0f0b714bc..016dd3d0b 100644 --- a/packages/view/src/Renderers/TempestViewRenderer.php +++ b/packages/view/src/Renderers/TempestViewRenderer.php @@ -139,12 +139,11 @@ private function renderCompiled(View $_view, string $_path): string } catch (Throwable $throwable) { ob_end_clean(); // clean buffer before rendering exception - $compiledPath = $throwable->getFile(); - $sourceLocation = $this->resolveSourceLocation($compiledPath, $throwable->getLine()); + $sourceLocation = $this->resolveSourceLocationFromThrowable($throwable, $_path); throw new ViewCompilationFailed( - path: $compiledPath, - content: Filesystem\is_file($compiledPath) ? Filesystem\read_file($compiledPath) : '', + path: $_path, + content: Filesystem\is_file($_path) ? Filesystem\read_file($_path) : '', sourcePath: $sourceLocation['path'] ?? null, sourceLine: $sourceLocation['line'] ?? null, previous: $throwable, @@ -203,6 +202,33 @@ private function resolveSourceLocation(string $compiledPath, int $compiledLine): ]; } + /** @return array{path: string, line: int}|null */ + private function resolveSourceLocationFromThrowable(Throwable $throwable, string $compiledPath): ?array + { + $sourceLocation = $this->resolveSourceLocation($throwable->getFile(), $throwable->getLine()); + + if ($sourceLocation !== null) { + return $sourceLocation; + } + + foreach ($throwable->getTrace() as $frame) { + $framePath = $frame['file'] ?? null; + $frameLine = $frame['line'] ?? null; + + if (! is_string($framePath) || ! is_int($frameLine)) { + continue; + } + + $sourceLocation = $this->resolveSourceLocation($framePath, $frameLine); + + if ($sourceLocation !== null) { + return $sourceLocation; + } + } + + return $this->resolveSourceLocation($compiledPath, 1); + } + /** * @param array $lineMap * @return array{path: string, line: int}|null From 805b02ef03edaa8bbfbbabf1330f53ae078d8d77 Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 17 Feb 2026 12:43:24 +0100 Subject: [PATCH 10/29] Use PHP_EOL --- packages/router/src/Exceptions/DevelopmentException.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/router/src/Exceptions/DevelopmentException.php b/packages/router/src/Exceptions/DevelopmentException.php index 41511cfce..07e41d1fc 100644 --- a/packages/router/src/Exceptions/DevelopmentException.php +++ b/packages/router/src/Exceptions/DevelopmentException.php @@ -99,8 +99,8 @@ private function enhanceStacktraceForViewCompilation(ViewCompilationFailed $exce $errorLine = $exception->sourceLine ?? $previous->getLine(); $lines = $hasSourceLocation - ? explode("\n", Filesystem\read_file($exception->sourcePath)) - : explode("\n", $exception->content); + ? explode(PHP_EOL, Filesystem\read_file($exception->sourcePath)) + : explode(PHP_EOL, $exception->content); $contextLines = 5; $startLine = max(1, $errorLine - $contextLines); From 41fda702eb20d61c9eb493d06218fba2d728c62b Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 17 Feb 2026 12:45:22 +0100 Subject: [PATCH 11/29] Prevent uninitialized variable --- packages/view/src/Parser/TempestViewCompiler.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/view/src/Parser/TempestViewCompiler.php b/packages/view/src/Parser/TempestViewCompiler.php index 9b0ea40fe..a8b358a46 100644 --- a/packages/view/src/Parser/TempestViewCompiler.php +++ b/packages/view/src/Parser/TempestViewCompiler.php @@ -130,13 +130,15 @@ private function retrieveTemplate(string|View $view): array ->toArray(), ]; + $searchPath = null; + foreach ($searchPathOptions as $searchPath) { if (Filesystem\is_file($searchPath)) { break; } } - if (! Filesystem\is_file($searchPath)) { + if (! $searchPath || ! Filesystem\is_file($searchPath)) { throw new ViewNotFound($path); } @@ -399,7 +401,7 @@ private function extractSourceMap(string $compiled, ?string $sourcePath): array if (preg_match(sprintf('/^\s*<\?php \/\*%s(?\d+)\*\/ \?>\s*$/', self::SOURCE_LINE_MARKER), $line, $matches) === 1) { $sourceLine = $currentSourcePath !== null - ? (int) $matches['line'] + ? (int)$matches['line'] : null; continue; From a8989b58178a951d1e3c4839abbaf0a26bbf3793 Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 17 Feb 2026 14:16:07 +0100 Subject: [PATCH 12/29] Add RootElement and HasImports --- packages/view/src/Elements/ElementFactory.php | 67 +++++------- packages/view/src/Elements/IsElement.php | 7 +- packages/view/src/Elements/PhpElement.php | 32 ++++++ packages/view/src/Elements/RootElement.php | 36 +++++++ .../src/Elements/ViewComponentElement.php | 22 ++-- packages/view/src/HasImports.php | 8 ++ .../view/src/Parser/TempestViewCompiler.php | 101 ++++-------------- .../src/Renderers/TempestViewRenderer.php | 2 +- tests/Integration/View/ViewComponentTest.php | 31 ++---- 9 files changed, 155 insertions(+), 151 deletions(-) create mode 100644 packages/view/src/Elements/PhpElement.php create mode 100644 packages/view/src/Elements/RootElement.php create mode 100644 packages/view/src/HasImports.php diff --git a/packages/view/src/Elements/ElementFactory.php b/packages/view/src/Elements/ElementFactory.php index f4c9d6d11..039a2a13b 100644 --- a/packages/view/src/Elements/ElementFactory.php +++ b/packages/view/src/Elements/ElementFactory.php @@ -42,15 +42,7 @@ public function withIsHtml(bool $isHtml): self return $clone; } - public function make(Token $token): ?Element - { - return $this->makeElement( - token: $token, - parent: null, - ); - } - - private function makeElement(Token $token, ?Element $parent): ?Element + public function make(Token $token, Element $parent): ?Element { if ( $token->type === TokenType::OPEN_TAG_END @@ -61,6 +53,12 @@ private function makeElement(Token $token, ?Element $parent): ?Element return null; } + $attributes = $token->htmlAttributes; + + foreach ($token->phpAttributes as $index => $content) { + $attributes[] = new PhpAttribute((string) $index, $content); + } + if ($token->type === TokenType::CONTENT) { $text = $token->compile(); @@ -68,33 +66,28 @@ private function makeElement(Token $token, ?Element $parent): ?Element return null; } - return new TextElement(text: $text); - } - - if ($token->type === TokenType::WHITESPACE) { - return new WhitespaceElement($token->content); - } - - if (! $token->tag || $token->type === TokenType::COMMENT || $token->type === TokenType::PHP) { - return new RawElement(token: $token, tag: null, content: $token->compile()); - } - - $attributes = $token->htmlAttributes; - - foreach ($token->phpAttributes as $index => $content) { - $attributes[] = new PhpAttribute((string) $index, $content); - } - - if ($token->tag === 'code' || $token->tag === 'pre') { - return new RawElement( + $element = new TextElement(text: $text); + } elseif ($token->type === TokenType::WHITESPACE) { + $element = new WhitespaceElement($token->content); + } elseif ($token->type !== TokenType::PHP && (! $token->tag || $token->type === TokenType::COMMENT)) { + $element = new RawElement( + token: $token, + tag: null, + content: $token->compile() + ); + } elseif ($token->tag === 'code' || $token->tag === 'pre') { + $element = new RawElement( token: $token, tag: $token->tag, content: $token->compileChildren(), attributes: $attributes, ); - } - - if ($viewComponentClass = $this->viewConfig->viewComponents[$token->tag] ?? null) { + } elseif ($token->type === TokenType::PHP) { + $element = new PhpElement( + token: $token, + content: $token->compile(), + ); + } elseif ($viewComponentClass = $this->viewConfig->viewComponents[$token->tag] ?? null) { $element = new ViewComponentElement( token: $token, environment: $this->environment, @@ -123,23 +116,15 @@ private function makeElement(Token $token, ?Element $parent): ?Element ); } - $children = []; + $element->setParent($parent); foreach ($token->children as $child) { - $childElement = $this->clone()->makeElement( + $this->clone()->make( token: $child, parent: $parent, ); - - if ($childElement === null) { - continue; - } - - $children[] = $childElement; } - $element->setChildren($children); - return $element; } diff --git a/packages/view/src/Elements/IsElement.php b/packages/view/src/Elements/IsElement.php index 7aa66e030..f1756b752 100644 --- a/packages/view/src/Elements/IsElement.php +++ b/packages/view/src/Elements/IsElement.php @@ -121,6 +121,8 @@ public function setParent(?Element $parent): self { $this->parent = $parent; + $this->parent->setChildren([...$this->parent->getChildren(), $this]); + return $this; } @@ -141,10 +143,7 @@ public function setChildren(array $children): self $previous = null; foreach ($children as $child) { - $child - ->setParent($this) - ->setPrevious($previous); - + $child->setPrevious($previous); $previous = $child; } diff --git a/packages/view/src/Elements/PhpElement.php b/packages/view/src/Elements/PhpElement.php new file mode 100644 index 000000000..7dfe681fa --- /dev/null +++ b/packages/view/src/Elements/PhpElement.php @@ -0,0 +1,32 @@ +content; + } + + public function getImports(): array + { + preg_match_all('/^\s*use .*;/m', $this->content, $matches); + + return $matches[0] ?? []; + } +} diff --git a/packages/view/src/Elements/RootElement.php b/packages/view/src/Elements/RootElement.php new file mode 100644 index 000000000..9182efe2e --- /dev/null +++ b/packages/view/src/Elements/RootElement.php @@ -0,0 +1,36 @@ +children as $element) { + $compiled[] = $element->compile(); + } + + return implode($compiled); + } + + public function getImports(): array + { + $imports = []; + + foreach ($this->children as $child) { + if ($child instanceof PhpElement) { + $imports = [...$imports, ...$child->getImports()]; + } + } + + return $imports; + } +} \ No newline at end of file diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index 84fbb8c42..b6fd416c2 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -10,6 +10,7 @@ use Tempest\Support\Str\MutableString; use Tempest\View\Element; use Tempest\View\Export\ViewObjectExporter; +use Tempest\View\HasImports; use Tempest\View\Parser\TempestViewCompiler; use Tempest\View\Parser\TempestViewParser; use Tempest\View\Parser\Token; @@ -21,7 +22,7 @@ use function Tempest\Support\arr; use function Tempest\Support\str; -final class ViewComponentElement implements Element, WithToken +final class ViewComponentElement implements Element, WithToken, HasImports { use IsElement; @@ -139,11 +140,7 @@ public function compile(): string return $default; } - $slotElements = $slotElement instanceof CollectionElement - ? $slotElement->getElements() - : $slotElement->getChildren(); - - $compiled = $this->compiler->compileFragment($slotElements); + $compiled = $this->compiler->compileElement($slotElement); // There's no default slot content, but there's a default value in the view component if (trim($compiled) === '') { @@ -154,6 +151,8 @@ public function compile(): string }, ); + $compiled = $compiled->prepend(implode(PHP_EOL, $this->getImports())); + $compiledView = $this->compiler->compileWithSourceMap( $compiled->toString(), sourcePath: $this->viewComponent->file, @@ -281,4 +280,15 @@ private function exportAttributesArray(): string return sprintf('new \%s([%s])', ImmutableArray::class, implode(', ', $entries)); } + + public function getImports(): array + { + $imports = []; + + if ($this->parent instanceof HasImports) { + $imports = [...$imports, ...$this->parent->getImports()]; + } + + return $imports; + } } diff --git a/packages/view/src/HasImports.php b/packages/view/src/HasImports.php new file mode 100644 index 000000000..768e497ea --- /dev/null +++ b/packages/view/src/HasImports.php @@ -0,0 +1,8 @@ +parseAst($template, $sourcePath); // 4. Map to elements - $elements = $this->mapToElements($ast); + $rootElement = $this->mapToElements($ast); // 5. Apply attributes - $elements = $this->applyAttributes($elements); + $rootElement = $this->applyAttributes($rootElement); // 6. Compile to PHP - $compiled = $this->compileElements($elements); + $compiled = $this->compileElement($rootElement); // 7. Cleanup compiled PHP [$cleaned, $lineMap] = $this->cleanupCompiled($compiled, $sourcePath); @@ -84,21 +85,6 @@ public function compileWithSourceMap(string|View $view, ?string $sourcePath = nu ); } - /** @param Element[] $elements */ - public function compileFragment(array $elements): string - { - $imports = $this->collectImportsForElements($elements); - - if ($imports === []) { - return $this->compileElements($elements); - } - - return implode(PHP_EOL, [ - ...$imports, - $this->compileElements($elements), - ]); - } - private function removeComments(string $template): string { return str($template) @@ -152,20 +138,6 @@ private function parseAst(string $template, ?string $sourcePath = null): Tempest return new TempestViewParser($tokens)->parse(); } - /** @param Element[] $elements */ - private function collectImportsForElements(array $elements): array - { - $imports = []; - - foreach ($this->collectSourcePathsForElements($elements) as $sourcePath) { - foreach ($this->extractImportsFromSourcePath($sourcePath) as $import) { - $imports[$import] = $import; - } - } - - return array_values($imports); - } - /** @param Element[] $elements */ private function collectSourcePathsForElements(array $elements): array { @@ -194,66 +166,36 @@ private function collectSourcePathsForElement(Element $element, array &$sourcePa } } - /** @return string[] */ - private function extractImportsFromSourcePath(string $sourcePath): array - { - static $imports = []; - - if (isset($imports[$sourcePath])) { - return $imports[$sourcePath]; - } - - if (! Filesystem\is_file($sourcePath)) { - return $imports[$sourcePath] = []; - } - - // TODO: this is not ideal, could use a little more love - preg_match_all('/^\s*use (function )?.*;/m', Filesystem\read_file($sourcePath), $matches); - - return $imports[$sourcePath] = array_values(array_unique($matches[0])); - } - /** * @return Element[] */ - private function mapToElements(TempestViewAst $ast): array + private function mapToElements(TempestViewAst $ast): RootElement { - $elements = []; - $elementFactory = $this->elementFactory->withIsHtml($ast->isHtml); - foreach ($ast as $token) { - $element = $elementFactory->make($token); - - if ($element === null) { - continue; - } + $rootElement = new RootElement(); - $elements[] = $element; + foreach ($ast as $token) { + $elementFactory->make($token, $rootElement); } - return $elements; + return $rootElement; } - /** - * @param Element[] $elements - * @return Element[] - */ - private function applyAttributes(array $elements): array + private function applyAttributes(Element $parentElement): Element { $appliedElements = []; $previous = null; - foreach ($elements as $element) { - $children = $this->applyAttributes($element->getChildren()); - $element->setChildren($children); + foreach ($parentElement->getChildren() as $childElement) { + $this->applyAttributes($childElement); - $element->setPrevious($previous); + $childElement->setPrevious($previous); $shouldBeRemoved = false; - foreach ($element->getAttributes() as $name => $value) { + foreach ($childElement->getAttributes() as $name => $value) { // TODO: possibly refactor attribute construction to ElementFactory? if ($value instanceof Attribute) { $attribute = $value; @@ -261,7 +203,7 @@ private function applyAttributes(array $elements): array $attribute = $this->attributeFactory->make($name); } - $element = $attribute->apply($element); + $childElement = $attribute->apply($childElement); if ($shouldBeRemoved === false && $attribute instanceof ShouldBeRemoved) { $shouldBeRemoved = true; @@ -272,21 +214,22 @@ private function applyAttributes(array $elements): array continue; } - $appliedElements[] = $element; + $appliedElements[] = $childElement; - $previous = $element; + $previous = $childElement; } - return $appliedElements; + $parentElement->setChildren($appliedElements); + + return $parentElement; } - /** @param \Tempest\View\Element[] $elements */ - private function compileElements(array $elements): string + public function compileElement(Element $rootElement): string { $compiled = arr(); $sourcePath = null; - foreach ($elements as $element) { + foreach ($rootElement->getChildren() as $element) { if ($sourceLocation = $this->resolveSourceLocation($element)) { if ($sourceLocation['sourcePath'] !== $sourcePath) { $sourcePath = $sourceLocation['sourcePath']; diff --git a/packages/view/src/Renderers/TempestViewRenderer.php b/packages/view/src/Renderers/TempestViewRenderer.php index 016dd3d0b..abd83f9f7 100644 --- a/packages/view/src/Renderers/TempestViewRenderer.php +++ b/packages/view/src/Renderers/TempestViewRenderer.php @@ -144,9 +144,9 @@ private function renderCompiled(View $_view, string $_path): string throw new ViewCompilationFailed( path: $_path, content: Filesystem\is_file($_path) ? Filesystem\read_file($_path) : '', + previous: $throwable, sourcePath: $sourceLocation['path'] ?? null, sourceLine: $sourceLocation['line'] ?? null, - previous: $throwable, ); } diff --git a/tests/Integration/View/ViewComponentTest.php b/tests/Integration/View/ViewComponentTest.php index 3a1784831..1136342e7 100644 --- a/tests/Integration/View/ViewComponentTest.php +++ b/tests/Integration/View/ViewComponentTest.php @@ -1029,28 +1029,19 @@ public function test_fallthrough_attribute_without_value(): void $this->assertSnippetsMatch('
hi
', $this->view->render('')); } - public function test_performance(): void + public function test_imports_in_slots(): void { - $this->view->registerViewComponent('x-a', 'hi'); + $this->view->registerViewComponent('x-test', '
'); - $start = microtime(true); - - $html = $this->view->render( - <<<'HTML' - - - {{ $item }} - - - HTML, - items: \Tempest\Support\Arr\range(1, 10000), - ); - - $end = microtime(true); - $time = $end - $start; + $html = $this->view->render(<<<'HTML' + + + {{ uri(HomeController::class) }} + HTML); - // Include: 0.10852599143982 - // Combined in one file: 0.0068130493164062 - ld($time); + ld($html); } } From 01b11d939d66478ec86f2c1858fdd6abd1ca91dd Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 17 Feb 2026 14:31:03 +0100 Subject: [PATCH 13/29] Use $element instead of $parent --- packages/view/src/Elements/ElementFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/view/src/Elements/ElementFactory.php b/packages/view/src/Elements/ElementFactory.php index 039a2a13b..5c5668714 100644 --- a/packages/view/src/Elements/ElementFactory.php +++ b/packages/view/src/Elements/ElementFactory.php @@ -121,7 +121,7 @@ public function make(Token $token, Element $parent): ?Element foreach ($token->children as $child) { $this->clone()->make( token: $child, - parent: $parent, + parent: $element, ); } From e854cbc26760a8d5c10ac30f5c719be6165d5e5d Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 17 Feb 2026 14:34:24 +0100 Subject: [PATCH 14/29] Correctly compile slots --- packages/view/src/Elements/ViewComponentElement.php | 2 +- packages/view/src/Parser/TempestViewCompiler.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index b6fd416c2..9ef44ede7 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -140,7 +140,7 @@ public function compile(): string return $default; } - $compiled = $this->compiler->compileElement($slotElement); + $compiled = $slotElement->compile() ?? ''; // There's no default slot content, but there's a default value in the view component if (trim($compiled) === '') { diff --git a/packages/view/src/Parser/TempestViewCompiler.php b/packages/view/src/Parser/TempestViewCompiler.php index cf02dab5d..00ec0f3d4 100644 --- a/packages/view/src/Parser/TempestViewCompiler.php +++ b/packages/view/src/Parser/TempestViewCompiler.php @@ -224,7 +224,7 @@ private function applyAttributes(Element $parentElement): Element return $parentElement; } - public function compileElement(Element $rootElement): string + private function compileElement(Element $rootElement): string { $compiled = arr(); $sourcePath = null; From f2ec21b2d0b45f5af5b30476c28d13cf4196d1d0 Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 17 Feb 2026 14:37:39 +0100 Subject: [PATCH 15/29] =?UTF-8?q?wip=20=E2=80=94=20fix=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Integration/View/ElementFactoryTest.php | 5 ++++- tests/Integration/View/ViewComponentTest.php | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Integration/View/ElementFactoryTest.php b/tests/Integration/View/ElementFactoryTest.php index 529a2ca46..0ba7ff6ef 100644 --- a/tests/Integration/View/ElementFactoryTest.php +++ b/tests/Integration/View/ElementFactoryTest.php @@ -7,6 +7,7 @@ use Tempest\View\Element; use Tempest\View\Elements\ElementFactory; use Tempest\View\Elements\GenericElement; +use Tempest\View\Elements\RootElement; use Tempest\View\Elements\TextElement; use Tempest\View\Elements\WhitespaceElement; use Tempest\View\Parser\TempestViewParser; @@ -38,7 +39,9 @@ public function test_parental_relations(): void $elementFactory = $this->container->get(ElementFactory::class); - $a = $elementFactory->make(iterator_to_array($ast)[0]); + $root = new RootElement(); + $elementFactory->make(iterator_to_array($ast)[0], $root); + $a = $root->getChildren()[0]; $this->assertInstanceOf(GenericElement::class, $a); $this->assertCount(1, $this->withoutWhitespace($a->getChildren())); diff --git a/tests/Integration/View/ViewComponentTest.php b/tests/Integration/View/ViewComponentTest.php index 1136342e7..fd25e6971 100644 --- a/tests/Integration/View/ViewComponentTest.php +++ b/tests/Integration/View/ViewComponentTest.php @@ -1042,6 +1042,5 @@ public function test_imports_in_slots(): void {{ uri(HomeController::class) }} HTML); - ld($html); } } From 924eea8b2697a5546a5b77f2df339282865228a5 Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 17 Feb 2026 14:41:24 +0100 Subject: [PATCH 16/29] =?UTF-8?q?wip=20=E2=80=94=20fix=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Integration/View/ElementFactoryTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/View/ElementFactoryTest.php b/tests/Integration/View/ElementFactoryTest.php index 0ba7ff6ef..c9f1aa76a 100644 --- a/tests/Integration/View/ElementFactoryTest.php +++ b/tests/Integration/View/ElementFactoryTest.php @@ -45,7 +45,7 @@ public function test_parental_relations(): void $this->assertInstanceOf(GenericElement::class, $a); $this->assertCount(1, $this->withoutWhitespace($a->getChildren())); - $this->assertNull($a->getParent()); + $this->assertInstanceOf(RootElement::class, $a->getParent()); $b = $this->withoutWhitespace($a->getChildren())[0]; $this->assertInstanceOf(GenericElement::class, $b); From 17598b12860651b2ca24927cd130d3ae7e6ed2b0 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 17 Feb 2026 14:39:57 +0100 Subject: [PATCH 17/29] Revert "Correctly compile slots" This reverts commit e5a00a2259561311472af55c6c48b2c949f1d860. --- packages/view/src/Elements/ViewComponentElement.php | 2 +- packages/view/src/Parser/TempestViewCompiler.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index 9ef44ede7..b6fd416c2 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -140,7 +140,7 @@ public function compile(): string return $default; } - $compiled = $slotElement->compile() ?? ''; + $compiled = $this->compiler->compileElement($slotElement); // There's no default slot content, but there's a default value in the view component if (trim($compiled) === '') { diff --git a/packages/view/src/Parser/TempestViewCompiler.php b/packages/view/src/Parser/TempestViewCompiler.php index 00ec0f3d4..cf02dab5d 100644 --- a/packages/view/src/Parser/TempestViewCompiler.php +++ b/packages/view/src/Parser/TempestViewCompiler.php @@ -224,7 +224,7 @@ private function applyAttributes(Element $parentElement): Element return $parentElement; } - private function compileElement(Element $rootElement): string + public function compileElement(Element $rootElement): string { $compiled = arr(); $sourcePath = null; From 8e9fc904d95a87484fcf84e114deb1628a42d4b3 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 17 Feb 2026 14:41:10 +0100 Subject: [PATCH 18/29] wip --- packages/view/src/Elements/CollectionElement.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/view/src/Elements/CollectionElement.php b/packages/view/src/Elements/CollectionElement.php index d2106965f..33e6986dc 100644 --- a/packages/view/src/Elements/CollectionElement.php +++ b/packages/view/src/Elements/CollectionElement.php @@ -10,21 +10,17 @@ final class CollectionElement implements Element { use IsElement; - public function __construct( - private readonly array $elements, - ) {} - - /** @return \Tempest\View\Element[] */ - public function getElements(): array + /** @param Element[] $elements */ + public function __construct(array $elements) { - return $this->elements; + $this->setChildren($elements); } public function compile(): string { $compiled = []; - foreach ($this->elements as $element) { + foreach ($this->getChildren() as $element) { $compiled[] = $element->compile(); } From 21d69abb4622c63277c13390d922a1ce61246672 Mon Sep 17 00:00:00 2001 From: brendt Date: Tue, 17 Feb 2026 14:56:59 +0100 Subject: [PATCH 19/29] QA --- packages/view/src/Elements/ElementFactory.php | 2 +- packages/view/src/Elements/RootElement.php | 3 +-- packages/view/src/HasImports.php | 2 +- packages/view/src/Parser/TempestViewCompiler.php | 10 +++++++--- .../view/tests/StandaloneViewRendererTest.php | 16 +++++++++------- .../Framework/Testing/View/ViewTester.php | 2 +- .../QueryStatements/AlterTableStatementTest.php | 1 - tests/Integration/View/ViewComponentTest.php | 3 +-- 8 files changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/view/src/Elements/ElementFactory.php b/packages/view/src/Elements/ElementFactory.php index 5c5668714..2ee353519 100644 --- a/packages/view/src/Elements/ElementFactory.php +++ b/packages/view/src/Elements/ElementFactory.php @@ -73,7 +73,7 @@ public function make(Token $token, Element $parent): ?Element $element = new RawElement( token: $token, tag: null, - content: $token->compile() + content: $token->compile(), ); } elseif ($token->tag === 'code' || $token->tag === 'pre') { $element = new RawElement( diff --git a/packages/view/src/Elements/RootElement.php b/packages/view/src/Elements/RootElement.php index 9182efe2e..4aedf481f 100644 --- a/packages/view/src/Elements/RootElement.php +++ b/packages/view/src/Elements/RootElement.php @@ -2,7 +2,6 @@ namespace Tempest\View\Elements; -use Tempest\View\Attributes\PhpAttribute; use Tempest\View\Element; use Tempest\View\HasImports; @@ -33,4 +32,4 @@ public function getImports(): array return $imports; } -} \ No newline at end of file +} diff --git a/packages/view/src/HasImports.php b/packages/view/src/HasImports.php index 768e497ea..2e2b22de8 100644 --- a/packages/view/src/HasImports.php +++ b/packages/view/src/HasImports.php @@ -5,4 +5,4 @@ interface HasImports { public function getImports(): array; -} \ No newline at end of file +} diff --git a/packages/view/src/Parser/TempestViewCompiler.php b/packages/view/src/Parser/TempestViewCompiler.php index cf02dab5d..1a4924b35 100644 --- a/packages/view/src/Parser/TempestViewCompiler.php +++ b/packages/view/src/Parser/TempestViewCompiler.php @@ -344,7 +344,7 @@ private function extractSourceMap(string $compiled, ?string $sourcePath): array if (preg_match(sprintf('/^\s*<\?php \/\*%s(?\d+)\*\/ \?>\s*$/', self::SOURCE_LINE_MARKER), $line, $matches) === 1) { $sourceLine = $currentSourcePath !== null - ? (int)$matches['line'] + ? (int) $matches['line'] : null; continue; @@ -442,9 +442,13 @@ private function startLineMapRange(array $lineMapping): array */ private function canExtendLineMapRange(array $range, array $lineMapping): bool { - return $lineMapping['compiledLine'] === $range['compiledEndLine'] + 1 + return ( + $lineMapping['compiledLine'] + === ($range['compiledEndLine'] + 1) && $lineMapping['sourcePath'] === $range['sourcePath'] - && $lineMapping['sourceLine'] === $range['sourceEndLine'] + 1; + && $lineMapping['sourceLine'] + === ($range['sourceEndLine'] + 1) + ); } /** diff --git a/packages/view/tests/StandaloneViewRendererTest.php b/packages/view/tests/StandaloneViewRendererTest.php index 7f05162f8..f93ebab36 100644 --- a/packages/view/tests/StandaloneViewRendererTest.php +++ b/packages/view/tests/StandaloneViewRendererTest.php @@ -2,8 +2,8 @@ namespace Tempest\View\Tests; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; use Tempest\View\Exceptions\ViewCompilationFailed; use Tempest\View\Exceptions\ViewComponentPathWasInvalid; use Tempest\View\Exceptions\ViewComponentPathWasNotFound; @@ -142,9 +142,10 @@ public function test_maps_source_path_and_line_for_component_errors(): void __DIR__ . '/Fixtures/x-standalone-error-component.view.php', ); - $renderer = TempestViewRenderer::make( - viewConfig: $viewConfig, - ); + $renderer = + TempestViewRenderer::make( + viewConfig: $viewConfig, + ); try { $renderer->render(view(__DIR__ . '/Fixtures/standalone-error-component-usage.view.php')); @@ -162,9 +163,10 @@ public function test_maps_source_path_and_line_for_slot_content_errors(): void __DIR__ . '/Fixtures/x-standalone-base.view.php', ); - $renderer = TempestViewRenderer::make( - viewConfig: $viewConfig, - ); + $renderer = + TempestViewRenderer::make( + viewConfig: $viewConfig, + ); try { $renderer->render(view(__DIR__ . '/Fixtures/standalone-error-slot-usage.view.php')); diff --git a/src/Tempest/Framework/Testing/View/ViewTester.php b/src/Tempest/Framework/Testing/View/ViewTester.php index 665961bb9..6b383d90a 100644 --- a/src/Tempest/Framework/Testing/View/ViewTester.php +++ b/src/Tempest/Framework/Testing/View/ViewTester.php @@ -42,7 +42,7 @@ public function registerViewComponent(string $name, string $html, ?string $file $viewComponent = new ViewComponent( name: $name, contents: $html, - file: $file ?? ($name . '.view.php'), + file: $file ?? $name . '.view.php', isVendorComponent: $isVendor, ); diff --git a/tests/Integration/Database/QueryStatements/AlterTableStatementTest.php b/tests/Integration/Database/QueryStatements/AlterTableStatementTest.php index 4bf52ccad..0d731e18a 100644 --- a/tests/Integration/Database/QueryStatements/AlterTableStatementTest.php +++ b/tests/Integration/Database/QueryStatements/AlterTableStatementTest.php @@ -61,7 +61,6 @@ public function test_it_can_alter_a_table_definition(): void MigrationModel::get(new PrimaryKey(3))->name, ); - /** @var User $user */ $user = User::create( name: 'Test', email: 'test@example.com', diff --git a/tests/Integration/View/ViewComponentTest.php b/tests/Integration/View/ViewComponentTest.php index fd25e6971..b52bd972a 100644 --- a/tests/Integration/View/ViewComponentTest.php +++ b/tests/Integration/View/ViewComponentTest.php @@ -1038,9 +1038,8 @@ public function test_imports_in_slots(): void use Tests\Tempest\Fixtures\Modules\Home\HomeController; use function \Tempest\Router\uri; ?> - + {{ uri(HomeController::class) }} HTML); - } } From 43b49e17361be9cac90c247962b0ff7275f735b9 Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 18 Feb 2026 09:10:58 +0100 Subject: [PATCH 20/29] Improved exceptions --- .../src/Elements/ViewComponentElement.php | 6 + .../src/Exceptions/ViewCompilationFailed.php | 8 +- .../Fixtures/missing-import-view.view.php | 3 + .../View/Fixtures/x-missing-import.view.php | 1 + tests/Integration/View/ViewComponentTest.php | 197 +++++++++++++----- 5 files changed, 160 insertions(+), 55 deletions(-) create mode 100644 tests/Integration/View/Fixtures/missing-import-view.view.php create mode 100644 tests/Integration/View/Fixtures/x-missing-import.view.php diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index b6fd416c2..aeaff926e 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -289,6 +289,12 @@ public function getImports(): array $imports = [...$imports, ...$this->parent->getImports()]; } + foreach ($this->getChildren() as $child) { + if ($child instanceof PhpElement) { + $imports = [...$imports, ...$child->getImports()]; + } + } + return $imports; } } diff --git a/packages/view/src/Exceptions/ViewCompilationFailed.php b/packages/view/src/Exceptions/ViewCompilationFailed.php index dc94be63f..0b5273e5b 100644 --- a/packages/view/src/Exceptions/ViewCompilationFailed.php +++ b/packages/view/src/Exceptions/ViewCompilationFailed.php @@ -11,11 +11,11 @@ final class ViewCompilationFailed extends Exception implements ProvidesContext { public function __construct( - private(set) string $path, - private(set) string $content, + private(set) readonly string $path, + private(set) readonly string $content, Throwable $previous, - private(set) ?string $sourcePath = null, - private(set) ?int $sourceLine = null, + private(set) readonly ?string $sourcePath = null, + private(set) readonly ?int $sourceLine = null, ) { parent::__construct( message: sprintf($previous->getMessage()), diff --git a/tests/Integration/View/Fixtures/missing-import-view.view.php b/tests/Integration/View/Fixtures/missing-import-view.view.php new file mode 100644 index 000000000..1809cd36f --- /dev/null +++ b/tests/Integration/View/Fixtures/missing-import-view.view.php @@ -0,0 +1,3 @@ + + {{ uri(HomeController::class) }} + \ No newline at end of file diff --git a/tests/Integration/View/Fixtures/x-missing-import.view.php b/tests/Integration/View/Fixtures/x-missing-import.view.php new file mode 100644 index 000000000..9fbb13462 --- /dev/null +++ b/tests/Integration/View/Fixtures/x-missing-import.view.php @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/tests/Integration/View/ViewComponentTest.php b/tests/Integration/View/ViewComponentTest.php index b52bd972a..a0a792fa2 100644 --- a/tests/Integration/View/ViewComponentTest.php +++ b/tests/Integration/View/ViewComponentTest.php @@ -14,6 +14,7 @@ use Tempest\Validation\Rules\IsBetween; use Tempest\Validation\Validator; use Tempest\View\Exceptions\DataAttributeWasInvalid; +use Tempest\View\Exceptions\ViewCompilationFailed; use Tempest\View\Exceptions\ViewVariableWasReserved; use Tempest\View\ViewCache; use Tests\Tempest\Fixtures\Views\Chapter; @@ -75,14 +76,16 @@ public function test_view_can_access_dynamic_slots(): void
{{ $slot->language }}
{!! $slot->content !!}
- HTML); + HTML, + ); $html = $this->view->render(<<<'HTML_WRAP' PHP Body HTML Body - HTML_WRAP); + HTML_WRAP, + ); $this->assertSnippetsMatch(<<<'HTML_WRAP'
slot-php
PHP
PHP
PHP Body
@@ -97,7 +100,8 @@ public function test_dynamic_slots_are_cleaned_up(): void
{{ $slot->name }}
- HTML); + HTML, + ); $html = $this->view->render(<<<'HTML' @@ -109,7 +113,8 @@ public function test_dynamic_slots_are_cleaned_up(): void
slots still here
slots are cleared
- HTML); + HTML, + ); $this->assertStringContainsString('
internal slots still here
', $html); $this->assertStringContainsString('
slots are cleared
', $html); @@ -120,7 +125,8 @@ public function test_dynamic_slots_include_the_default_slot(): void $this->view->registerViewComponent('x-test', <<<'HTML'
{{ $slots['default']->name }}
{{ $slots['default']->content }}
- HTML); + HTML, + ); $html = $this->view->render('Hello'); @@ -140,13 +146,15 @@ public function test_slots_with_nested_view_components(): void
A{{ $slot->name }}
- HTML); + HTML, + ); $this->view->registerViewComponent('x-b', <<<'HTML'
B{{ $slot->name }}
- HTML); + HTML, + ); $html = $this->view->render(<<<'HTML' @@ -158,7 +166,8 @@ public function test_slots_with_nested_view_components(): void - HTML); + HTML, + ); $this->assertStringContainsString('
B1
', $html); $this->assertStringContainsString('
B2
', $html); @@ -179,7 +188,8 @@ public function test_scope_does_not_leak_data(): void $html = $this->view->render(<<<'HTML' - HTML); + HTML, + ); $this->assertStringContainsString('', $html); $this->assertStringContainsString('view->registerViewComponent('x-test', <<<'HTML'
- HTML); + HTML, + ); $this->assertSnippetsMatch( expected: '
a
b
', @@ -319,7 +330,8 @@ public function test_with_passed_php_data(): void $rendered = $this->view->render( view(<< - HTML), + HTML, + ), ); $this->assertSnippetsMatch( @@ -406,7 +418,8 @@ public function test_view_component_with_camelcase_attribute(): void { $this->view->registerViewComponent('x-test', <<<'HTML' {{ $metaType ?? 'nothing' }} - HTML); + HTML, + ); $this->assertSame('test', $this->view->render('')); $this->assertSame('test', $this->view->render('')); @@ -480,13 +493,15 @@ public function test_full_html_document_as_component(): void - HTML); + HTML, + ); $html = $this->view->render(<<<'HTML' Hello World - HTML); + HTML, + ); $this->assertStringContainsString(<<<'HTML' @@ -512,12 +527,14 @@ public function test_empty_slots_are_commented_out(): void - HTML); + HTML, + ); $html = $this->view->render(<<<'HTML' - HTML); + HTML, + ); $this->assertSnippetsMatch(<<<'HTML' @@ -536,12 +553,14 @@ public function test_empty_slots_are_removed_in_production(): void - HTML); + HTML, + ); $html = $this->view->render(<<<'HTML' - HTML); + HTML, + ); $this->assertSnippetsMatch(<<<'HTML' @@ -552,7 +571,8 @@ public function test_custom_components_in_head(): void { $this->view->registerViewComponent('x-custom-link', <<<'HTML' - HTML); + HTML, + ); $html = $this->view->render(<<<'HTML' @@ -561,7 +581,8 @@ public function test_custom_components_in_head(): void - HTML); + HTML, + ); $this->assertSnippetsMatch(<<<'HTML' @@ -573,7 +594,8 @@ public function test_head_injection(): void { $this->view->registerViewComponent('x-custom-link', <<<'HTML' - HTML); + HTML, + ); $html = $this->view->render(<<<'HTML' @@ -586,7 +608,8 @@ public function test_head_injection(): void b - HTML); + HTML, + ); $this->assertSnippetsMatch(<<<'HTML' @@ -600,11 +623,13 @@ public function test_attributes_variable_in_view_component(): void { $this->view->registerViewComponent('x-test', <<<'HTML'
- HTML); + HTML, + ); $html = $this->view->render(<<<'HTML' - HTML); + HTML, + ); $this->assertSnippetsMatch(<<<'HTML'
@@ -615,11 +640,13 @@ public function test_fallthrough_attributes(): void { $this->view->registerViewComponent('x-test', <<<'HTML'
- HTML); + HTML, + ); $html = $this->view->render(<<<'HTML' - HTML); + HTML, + ); $this->assertSnippetsMatch(<<<'HTML'
@@ -630,11 +657,13 @@ public function test_merged_fallthrough_attributes(): void { $this->view->registerViewComponent('x-test', <<<'HTML'
- HTML); + HTML, + ); $html = $this->view->render(<<<'HTML' - HTML); + HTML, + ); $this->assertSnippetsMatch(<<<'HTML'
@@ -645,11 +674,13 @@ public function test_fallthrough_attributes_with_other_attributes(): void { $this->view->registerViewComponent('x-test', <<<'HTML'
- HTML); + HTML, + ); $html = $this->view->render(<<<'HTML' - HTML); + HTML, + ); $this->assertSnippetsMatch(<<<'HTML'
@@ -667,7 +698,8 @@ public function test_array_attribute(): void { $html = $this->view->render(<<<'HTML'
- HTML); + HTML, + ); $this->assertSnippetsMatch(<<<'HTML'
@@ -678,11 +710,13 @@ public function test_merge_class(): void { $this->view->registerViewComponent('x-test', <<<'HTML'
- HTML); + HTML, + ); $html = $this->view->render(<<<'HTML' - HTML); + HTML, + ); $this->assertSnippetsMatch(<<<'HTML'
@@ -693,11 +727,13 @@ public function test_merge_class_from_template_to_component(): void { $this->view->registerViewComponent('x-test', <<<'HTML'
- HTML); + HTML, + ); $html = $this->view->render(<<<'HTML' - HTML); + HTML, + ); $this->assertSnippetsMatch(<<<'HTML'
@@ -715,14 +751,16 @@ public function test_does_not_duplicate_br(): void - HTML); + HTML, + ); $html = $this->view->render(<<<'HTML'

- HTML); + HTML, + ); $this->assertSnippetsMatch(<<<'HTML' @@ -734,7 +772,8 @@ public function test_renders_minified_html_with_void_elements(): void { $html = $this->view->render(<<<'HTML' - HTML); + HTML, + ); $this->assertSnippetsMatch(<<<'HTML' @@ -750,7 +789,8 @@ public function test_multiple_instances_of_custom_component_using_slots(): void - HTML); + HTML, + ); $html = $this->view->render(<<<'HTML' @@ -758,7 +798,8 @@ public function test_multiple_instances_of_custom_component_using_slots(): void
- HTML); + HTML, + ); $this->assertSnippetsMatch(<<<'HTML'
FOO-BAR @@ -773,7 +814,8 @@ public function test_slots_with_hyphens(): void
- HTML); + HTML, + ); $html = $this->view->render(<<<'HTML' @@ -781,7 +823,8 @@ public function test_slots_with_hyphens(): void Hi - HTML); + HTML, + ); $this->assertSnippetsMatch(<<<'HTML'
@@ -811,7 +854,8 @@ public function test_nested_table_components(): void - HTML); + HTML, + ); $this->assertSnippetsMatch(<<<'HTML' @@ -835,7 +879,8 @@ public function test_dynamic_view_component_with_string_name(): void $html = $this->view->render(<<<'HTML' - HTML); + HTML, + ); $this->assertSame('
test
', $html); } @@ -898,7 +943,8 @@ public function test_nested_slots(): void hi - HTML); + HTML, + ); $this->assertSnippetsMatch('hi', $html); } @@ -912,13 +958,15 @@ public function test_nested_slots_with_escaping(): void use \Tempest\Core\Environment; ?> {{ get(Environment::class)->value }} - HTML); + HTML, + ); $html = $this->view->render(<<<'HTML' - HTML); + HTML, + ); $this->assertSnippetsMatch('testing', $html); } @@ -931,7 +979,8 @@ public function test_repeated_local_var_across_view_components(): void - HTML); + HTML, + ); $this->assertSnippetsMatch('
a
@@ -955,7 +1004,8 @@ public function test_default_slot_value(): void Default Default A Default B - HTML); + HTML, + ); $this->assertSnippetsMatch( <<<'HTML' @@ -1029,7 +1079,7 @@ public function test_fallthrough_attribute_without_value(): void $this->assertSnippetsMatch('
hi
', $this->view->render('')); } - public function test_imports_in_slots(): void + public function test_imports_in_slots_from_root_node(): void { $this->view->registerViewComponent('x-test', '
'); @@ -1040,6 +1090,51 @@ public function test_imports_in_slots(): void ?> {{ uri(HomeController::class) }} - HTML); + HTML, + ); + + $this->assertSame('
/
', $html); + } + + public function test_combined_imports_from_root_node_and_view_component(): void + { + $this->view->registerViewComponent('x-parent', <<<'HTML' +
+ HTML, + ); + + $this->view->registerViewComponent('x-child', <<<'HTML' + +
+ HTML, + ); + + $html = $this->view->render(<<<'HTML' + + + + {{ uri(HomeController::class) }} + + HTML, + ); + + $this->assertSnippetsMatch('
/
', $html); + } + + public function test_exception_for_missing_imports(): void + { + $this->assertException( + ViewCompilationFailed::class, + function () { + $this->view->render(view(__DIR__ . '/Fixtures/missing-import-view.view.php')); + }, + function (ViewCompilationFailed $exception) { + $this->assertStringContainsString('missing-import-view.view.php', $exception->getFile()); + $this->assertSame(2, $exception->getLine()); + }); } } From 841f83b02db6a77a2616538b085691910e4aabb0 Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 18 Feb 2026 09:50:35 +0100 Subject: [PATCH 21/29] Fix nested slot imports --- .../src/Elements/ViewComponentElement.php | 2 +- tests/Integration/View/ViewComponentTest.php | 31 ++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index aeaff926e..d2aaba85d 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -151,7 +151,7 @@ public function compile(): string }, ); - $compiled = $compiled->prepend(implode(PHP_EOL, $this->getImports())); + $compiled = $compiled->prepend('getImports()) . PHP_EOL . '?>' . PHP_EOL); $compiledView = $this->compiler->compileWithSourceMap( $compiled->toString(), diff --git a/tests/Integration/View/ViewComponentTest.php b/tests/Integration/View/ViewComponentTest.php index a0a792fa2..c5fb149e2 100644 --- a/tests/Integration/View/ViewComponentTest.php +++ b/tests/Integration/View/ViewComponentTest.php @@ -1073,7 +1073,7 @@ public function test_capture_outer_scope_view_component_variables(): void public function test_fallthrough_attribute_without_value(): void { - $this->view->registerViewComponent('x-test', '
hi
'); + $this->view->registerViewComponent('x-test', '
hi
'); $this->assertSnippetsMatch('', $this->view->render('')); $this->assertSnippetsMatch('
hi
', $this->view->render('')); @@ -1104,15 +1104,15 @@ public function test_combined_imports_from_root_node_and_view_component(): void ); $this->view->registerViewComponent('x-child', <<<'HTML' -
HTML, ); $html = $this->view->render(<<<'HTML' - @@ -1137,4 +1137,27 @@ function (ViewCompilationFailed $exception) { $this->assertSame(2, $exception->getLine()); }); } + + public function test_imports_with_nested_view_components(): void + { + $this->view->registerViewComponent('x-card', <<<'HTML' +
+ HTML); + + $this->view->registerViewComponent('x-footer', <<<'HTML' + + HTML); + + $html = $this->view->render(<<<'HTML' + + + {{ uri(HomeController::class) }} + + HTML); + + $this->assertSnippetsMatch('
/
', $html); + } } From 557c523217dadedb784d61eb70609eb7a032a350 Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 18 Feb 2026 11:52:58 +0100 Subject: [PATCH 22/29] Nested imports for all elements --- packages/view/src/Element.php | 5 +++++ packages/view/src/Elements/GenericElement.php | 1 + packages/view/src/Elements/IsElement.php | 10 +++++++++ packages/view/src/Elements/PhpElement.php | 3 +-- packages/view/src/Elements/RootElement.php | 3 +-- .../src/Elements/ViewComponentElement.php | 4 ++-- packages/view/src/HasImports.php | 8 ------- tests/Integration/View/ViewComponentTest.php | 22 +++++++++++++++++++ 8 files changed, 42 insertions(+), 14 deletions(-) delete mode 100644 packages/view/src/HasImports.php diff --git a/packages/view/src/Element.php b/packages/view/src/Element.php index a6b3031cf..a97a2564e 100644 --- a/packages/view/src/Element.php +++ b/packages/view/src/Element.php @@ -42,4 +42,9 @@ public function getChildren(): array; * @return T|null */ public function unwrap(string $elementClass): ?Element; + + /** + * @return string[] An array of import statements + */ + public function getImports(): array; } diff --git a/packages/view/src/Elements/GenericElement.php b/packages/view/src/Elements/GenericElement.php index b0266a9b7..efc40378e 100644 --- a/packages/view/src/Elements/GenericElement.php +++ b/packages/view/src/Elements/GenericElement.php @@ -5,6 +5,7 @@ namespace Tempest\View\Elements; use Tempest\View\Element; +use Tempest\View\HasImports; use Tempest\View\Parser\Token; use Tempest\View\WithToken; diff --git a/packages/view/src/Elements/IsElement.php b/packages/view/src/Elements/IsElement.php index f1756b752..8f1e18700 100644 --- a/packages/view/src/Elements/IsElement.php +++ b/packages/view/src/Elements/IsElement.php @@ -5,6 +5,7 @@ namespace Tempest\View\Elements; use Tempest\View\Element; +use Tempest\View\HasImports; use Tempest\View\View; use Tempest\View\WrapsElement; @@ -169,4 +170,13 @@ public function unwrap(string $elementClass): ?Element return null; } + + public function getImports(): array + { + if ($this->parent) { + return $this->parent->getImports(); + } + + return []; + } } diff --git a/packages/view/src/Elements/PhpElement.php b/packages/view/src/Elements/PhpElement.php index 7dfe681fa..70cb5762e 100644 --- a/packages/view/src/Elements/PhpElement.php +++ b/packages/view/src/Elements/PhpElement.php @@ -5,11 +5,10 @@ namespace Tempest\View\Elements; use Tempest\View\Element; -use Tempest\View\HasImports; use Tempest\View\Parser\Token; use Tempest\View\WithToken; -final class PhpElement implements Element, WithToken, HasImports +final class PhpElement implements Element, WithToken { use IsElement; diff --git a/packages/view/src/Elements/RootElement.php b/packages/view/src/Elements/RootElement.php index 4aedf481f..77d1b10c4 100644 --- a/packages/view/src/Elements/RootElement.php +++ b/packages/view/src/Elements/RootElement.php @@ -3,9 +3,8 @@ namespace Tempest\View\Elements; use Tempest\View\Element; -use Tempest\View\HasImports; -final class RootElement implements Element, HasImports +final class RootElement implements Element { use IsElement; diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index d2aaba85d..f13b08ce8 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -22,7 +22,7 @@ use function Tempest\Support\arr; use function Tempest\Support\str; -final class ViewComponentElement implements Element, WithToken, HasImports +final class ViewComponentElement implements Element, WithToken { use IsElement; @@ -285,7 +285,7 @@ public function getImports(): array { $imports = []; - if ($this->parent instanceof HasImports) { + if ($this->parent) { $imports = [...$imports, ...$this->parent->getImports()]; } diff --git a/packages/view/src/HasImports.php b/packages/view/src/HasImports.php deleted file mode 100644 index 2e2b22de8..000000000 --- a/packages/view/src/HasImports.php +++ /dev/null @@ -1,8 +0,0 @@ -assertSnippetsMatch('
/
', $html); } + + public function test_imports_in_nested_html_elements(): void + { + $this->view->registerViewComponent('x-a', '
">'); + $this->view->registerViewComponent('x-b', '
">'); + + $html = $this->view->render(<<<'HTML' + + +
+ + {{ uri(HomeController::class) }} + +
+
+ HTML); + + $this->assertSnippetsMatch('
/
">
">', $html); + } } From 86afb0a7547f524f55db49ee29e39d127549feb4 Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 18 Feb 2026 11:54:22 +0100 Subject: [PATCH 23/29] QA --- packages/view/src/Elements/GenericElement.php | 1 - packages/view/src/Elements/IsElement.php | 1 - .../src/Elements/ViewComponentElement.php | 1 - .../Fixtures/missing-import-view.view.php | 2 +- .../View/Fixtures/x-missing-import.view.php | 2 +- tests/Integration/View/ViewComponentTest.php | 166 ++++++------------ 6 files changed, 59 insertions(+), 114 deletions(-) diff --git a/packages/view/src/Elements/GenericElement.php b/packages/view/src/Elements/GenericElement.php index efc40378e..b0266a9b7 100644 --- a/packages/view/src/Elements/GenericElement.php +++ b/packages/view/src/Elements/GenericElement.php @@ -5,7 +5,6 @@ namespace Tempest\View\Elements; use Tempest\View\Element; -use Tempest\View\HasImports; use Tempest\View\Parser\Token; use Tempest\View\WithToken; diff --git a/packages/view/src/Elements/IsElement.php b/packages/view/src/Elements/IsElement.php index 8f1e18700..415566d21 100644 --- a/packages/view/src/Elements/IsElement.php +++ b/packages/view/src/Elements/IsElement.php @@ -5,7 +5,6 @@ namespace Tempest\View\Elements; use Tempest\View\Element; -use Tempest\View\HasImports; use Tempest\View\View; use Tempest\View\WrapsElement; diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index f13b08ce8..8abf0e1df 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -10,7 +10,6 @@ use Tempest\Support\Str\MutableString; use Tempest\View\Element; use Tempest\View\Export\ViewObjectExporter; -use Tempest\View\HasImports; use Tempest\View\Parser\TempestViewCompiler; use Tempest\View\Parser\TempestViewParser; use Tempest\View\Parser\Token; diff --git a/tests/Integration/View/Fixtures/missing-import-view.view.php b/tests/Integration/View/Fixtures/missing-import-view.view.php index 1809cd36f..109de38cd 100644 --- a/tests/Integration/View/Fixtures/missing-import-view.view.php +++ b/tests/Integration/View/Fixtures/missing-import-view.view.php @@ -1,3 +1,3 @@ {{ uri(HomeController::class) }} - \ No newline at end of file + diff --git a/tests/Integration/View/Fixtures/x-missing-import.view.php b/tests/Integration/View/Fixtures/x-missing-import.view.php index 9fbb13462..d16798d96 100644 --- a/tests/Integration/View/Fixtures/x-missing-import.view.php +++ b/tests/Integration/View/Fixtures/x-missing-import.view.php @@ -1 +1 @@ -
\ No newline at end of file +
diff --git a/tests/Integration/View/ViewComponentTest.php b/tests/Integration/View/ViewComponentTest.php index d2c5bff34..cc5dd93e8 100644 --- a/tests/Integration/View/ViewComponentTest.php +++ b/tests/Integration/View/ViewComponentTest.php @@ -76,16 +76,14 @@ public function test_view_can_access_dynamic_slots(): void
{{ $slot->language }}
{!! $slot->content !!}
- HTML, - ); + HTML); $html = $this->view->render(<<<'HTML_WRAP' PHP Body HTML Body - HTML_WRAP, - ); + HTML_WRAP); $this->assertSnippetsMatch(<<<'HTML_WRAP'
slot-php
PHP
PHP
PHP Body
@@ -100,8 +98,7 @@ public function test_dynamic_slots_are_cleaned_up(): void
{{ $slot->name }}
- HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' @@ -113,8 +110,7 @@ public function test_dynamic_slots_are_cleaned_up(): void
slots still here
slots are cleared
- HTML, - ); + HTML); $this->assertStringContainsString('
internal slots still here
', $html); $this->assertStringContainsString('
slots are cleared
', $html); @@ -125,8 +121,7 @@ public function test_dynamic_slots_include_the_default_slot(): void $this->view->registerViewComponent('x-test', <<<'HTML'
{{ $slots['default']->name }}
{{ $slots['default']->content }}
- HTML, - ); + HTML); $html = $this->view->render('Hello'); @@ -146,15 +141,13 @@ public function test_slots_with_nested_view_components(): void
A{{ $slot->name }}
- HTML, - ); + HTML); $this->view->registerViewComponent('x-b', <<<'HTML'
B{{ $slot->name }}
- HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' @@ -166,8 +159,7 @@ public function test_slots_with_nested_view_components(): void - HTML, - ); + HTML); $this->assertStringContainsString('
B1
', $html); $this->assertStringContainsString('
B2
', $html); @@ -188,8 +180,7 @@ public function test_scope_does_not_leak_data(): void $html = $this->view->render(<<<'HTML' - HTML, - ); + HTML); $this->assertStringContainsString('', $html); $this->assertStringContainsString('view->registerViewComponent('x-test', <<<'HTML'
- HTML, - ); + HTML); $this->assertSnippetsMatch( expected: '
a
b
', @@ -330,8 +320,7 @@ public function test_with_passed_php_data(): void $rendered = $this->view->render( view(<< - HTML, - ), + HTML), ); $this->assertSnippetsMatch( @@ -418,8 +407,7 @@ public function test_view_component_with_camelcase_attribute(): void { $this->view->registerViewComponent('x-test', <<<'HTML' {{ $metaType ?? 'nothing' }} - HTML, - ); + HTML); $this->assertSame('test', $this->view->render('')); $this->assertSame('test', $this->view->render('')); @@ -493,15 +481,13 @@ public function test_full_html_document_as_component(): void - HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' Hello World - HTML, - ); + HTML); $this->assertStringContainsString(<<<'HTML' @@ -527,14 +513,12 @@ public function test_empty_slots_are_commented_out(): void - HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' - HTML, - ); + HTML); $this->assertSnippetsMatch(<<<'HTML' @@ -553,14 +537,12 @@ public function test_empty_slots_are_removed_in_production(): void - HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' - HTML, - ); + HTML); $this->assertSnippetsMatch(<<<'HTML' @@ -571,8 +553,7 @@ public function test_custom_components_in_head(): void { $this->view->registerViewComponent('x-custom-link', <<<'HTML' - HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' @@ -581,8 +562,7 @@ public function test_custom_components_in_head(): void - HTML, - ); + HTML); $this->assertSnippetsMatch(<<<'HTML' @@ -594,8 +574,7 @@ public function test_head_injection(): void { $this->view->registerViewComponent('x-custom-link', <<<'HTML' - HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' @@ -608,8 +587,7 @@ public function test_head_injection(): void b - HTML, - ); + HTML); $this->assertSnippetsMatch(<<<'HTML' @@ -623,13 +601,11 @@ public function test_attributes_variable_in_view_component(): void { $this->view->registerViewComponent('x-test', <<<'HTML'
- HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' - HTML, - ); + HTML); $this->assertSnippetsMatch(<<<'HTML'
@@ -640,13 +616,11 @@ public function test_fallthrough_attributes(): void { $this->view->registerViewComponent('x-test', <<<'HTML'
- HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' - HTML, - ); + HTML); $this->assertSnippetsMatch(<<<'HTML'
@@ -657,13 +631,11 @@ public function test_merged_fallthrough_attributes(): void { $this->view->registerViewComponent('x-test', <<<'HTML'
- HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' - HTML, - ); + HTML); $this->assertSnippetsMatch(<<<'HTML'
@@ -674,13 +646,11 @@ public function test_fallthrough_attributes_with_other_attributes(): void { $this->view->registerViewComponent('x-test', <<<'HTML'
- HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' - HTML, - ); + HTML); $this->assertSnippetsMatch(<<<'HTML'
@@ -698,8 +668,7 @@ public function test_array_attribute(): void { $html = $this->view->render(<<<'HTML'
- HTML, - ); + HTML); $this->assertSnippetsMatch(<<<'HTML'
@@ -710,13 +679,11 @@ public function test_merge_class(): void { $this->view->registerViewComponent('x-test', <<<'HTML'
- HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' - HTML, - ); + HTML); $this->assertSnippetsMatch(<<<'HTML'
@@ -727,13 +694,11 @@ public function test_merge_class_from_template_to_component(): void { $this->view->registerViewComponent('x-test', <<<'HTML'
- HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' - HTML, - ); + HTML); $this->assertSnippetsMatch(<<<'HTML'
@@ -751,16 +716,14 @@ public function test_does_not_duplicate_br(): void - HTML, - ); + HTML); $html = $this->view->render(<<<'HTML'

- HTML, - ); + HTML); $this->assertSnippetsMatch(<<<'HTML' @@ -772,8 +735,7 @@ public function test_renders_minified_html_with_void_elements(): void { $html = $this->view->render(<<<'HTML' - HTML, - ); + HTML); $this->assertSnippetsMatch(<<<'HTML' @@ -789,8 +751,7 @@ public function test_multiple_instances_of_custom_component_using_slots(): void - HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' @@ -798,8 +759,7 @@ public function test_multiple_instances_of_custom_component_using_slots(): void
- HTML, - ); + HTML); $this->assertSnippetsMatch(<<<'HTML'
FOO-BAR @@ -814,8 +774,7 @@ public function test_slots_with_hyphens(): void
- HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' @@ -823,8 +782,7 @@ public function test_slots_with_hyphens(): void Hi - HTML, - ); + HTML); $this->assertSnippetsMatch(<<<'HTML'
@@ -854,8 +812,7 @@ public function test_nested_table_components(): void
- HTML, - ); + HTML); $this->assertSnippetsMatch(<<<'HTML' @@ -879,8 +836,7 @@ public function test_dynamic_view_component_with_string_name(): void $html = $this->view->render(<<<'HTML' - HTML, - ); + HTML); $this->assertSame('
test
', $html); } @@ -943,8 +899,7 @@ public function test_nested_slots(): void hi - HTML, - ); + HTML); $this->assertSnippetsMatch('hi', $html); } @@ -958,15 +913,13 @@ public function test_nested_slots_with_escaping(): void use \Tempest\Core\Environment; ?> {{ get(Environment::class)->value }} - HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' - HTML, - ); + HTML); $this->assertSnippetsMatch('testing', $html); } @@ -979,8 +932,7 @@ public function test_repeated_local_var_across_view_components(): void - HTML, - ); + HTML); $this->assertSnippetsMatch('
a
@@ -1004,8 +956,7 @@ public function test_default_slot_value(): void Default Default A Default B - HTML, - ); + HTML); $this->assertSnippetsMatch( <<<'HTML' @@ -1090,8 +1041,7 @@ public function test_imports_in_slots_from_root_node(): void ?> {{ uri(HomeController::class) }} - HTML, - ); + HTML); $this->assertSame('
/
', $html); } @@ -1100,16 +1050,14 @@ public function test_combined_imports_from_root_node_and_view_component(): void { $this->view->registerViewComponent('x-parent', <<<'HTML'
- HTML, - ); + HTML); $this->view->registerViewComponent('x-child', <<<'HTML'
- HTML, - ); + HTML); $html = $this->view->render(<<<'HTML' {{ uri(HomeController::class) }} - HTML, - ); + HTML); $this->assertSnippetsMatch('
/
', $html); } @@ -1129,13 +1076,14 @@ public function test_exception_for_missing_imports(): void { $this->assertException( ViewCompilationFailed::class, - function () { + function (): void { $this->view->render(view(__DIR__ . '/Fixtures/missing-import-view.view.php')); }, - function (ViewCompilationFailed $exception) { + function (ViewCompilationFailed $exception): void { $this->assertStringContainsString('missing-import-view.view.php', $exception->getFile()); $this->assertSame(2, $exception->getLine()); - }); + }, + ); } public function test_imports_with_nested_view_components(): void From 3e4c0da41a471a472520f5f9250a7c0c7394dbd4 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 18 Feb 2026 19:10:55 +0100 Subject: [PATCH 24/29] fix(router): deduplicate stacktraces --- .../router/src/Exceptions/DevelopmentException.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/router/src/Exceptions/DevelopmentException.php b/packages/router/src/Exceptions/DevelopmentException.php index 07e41d1fc..9cb242712 100644 --- a/packages/router/src/Exceptions/DevelopmentException.php +++ b/packages/router/src/Exceptions/DevelopmentException.php @@ -98,6 +98,18 @@ private function enhanceStacktraceForViewCompilation(ViewCompilationFailed $exce $errorLine = $exception->sourceLine ?? $previous->getLine(); + $firstFrame = $stacktrace->frames[0] ?? null; + + if ( + $firstFrame instanceof Frame + && $firstFrame->absoluteFile === $errorPath + && $firstFrame->line === $errorLine + && $firstFrame->class === TempestViewRenderer::class + && $firstFrame->function === 'renderCompiled' + ) { + return $stacktrace; + } + $lines = $hasSourceLocation ? explode(PHP_EOL, Filesystem\read_file($exception->sourcePath)) : explode(PHP_EOL, $exception->content); From ac3c999893ce0c89f29a8841d235c8696c86d0a7 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 18 Feb 2026 19:19:25 +0100 Subject: [PATCH 25/29] wip - add test for duplicate stacktraces --- .../Exceptions/HtmlExceptionRendererTest.php | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/Integration/Http/Exceptions/HtmlExceptionRendererTest.php b/tests/Integration/Http/Exceptions/HtmlExceptionRendererTest.php index 509393b7d..10f0a03f9 100644 --- a/tests/Integration/Http/Exceptions/HtmlExceptionRendererTest.php +++ b/tests/Integration/Http/Exceptions/HtmlExceptionRendererTest.php @@ -21,11 +21,15 @@ use Tempest\Validation\Exceptions\ValidationFailed; use Tempest\Validation\FailingRule; use Tempest\Validation\Rules\IsNotNull; +use Tempest\View\Exceptions\ViewCompilationFailed; use Tempest\View\GenericView; +use Tempest\View\Renderers\TempestViewRenderer; use Tempest\View\View; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use Tests\Tempest\Integration\Http\Fixtures\ExceptionThatConvertsToRedirectResponse; +use function Tempest\View\view; + final class HtmlExceptionRendererTest extends FrameworkIntegrationTestCase { private HtmlExceptionRenderer $renderer { @@ -135,6 +139,38 @@ public function does_not_render_development_exception_for_not_found_in_local(): $this->assertSame(Status::NOT_FOUND, $response->status); } + #[Test] + public function does_not_duplicate_view_compilation_frames_in_stacktrace(): void + { + $this->container->singleton(Environment::class, Environment::LOCAL); + $this->container->singleton(GenericRequest::class, new GenericRequest( + method: Method::GET, + uri: '/', + )); + + $viewRenderer = TempestViewRenderer::make(); + + try { + $viewRenderer->render(view(__DIR__ . '/../../../../packages/view/tests/Fixtures/standalone-error.view.php')); + $this->fail('Expected a view compilation exception.'); + } catch (ViewCompilationFailed $exception) { + $response = $this->renderer->render($exception); + } + + $this->assertInstanceOf(DevelopmentException::class, $response); + $this->assertInstanceOf(GenericView::class, $response->body); + + $hydration = json_decode($response->body->data['hydration'], associative: true, flags: JSON_THROW_ON_ERROR); + $stacktrace = json_decode($hydration['stacktrace'], associative: true, flags: JSON_THROW_ON_ERROR); + + $renderCompiledFrames = array_values(array_filter( + $stacktrace['applicationFrames'], + fn (array $frame): bool => ($frame['class'] ?? null) === TempestViewRenderer::class && ($frame['function'] ?? null) === 'renderCompiled', + )); + + $this->assertCount(1, $renderCompiledFrames); + } + #[Test] public function converts_request_failed_string_bodies_to_proper_responses(): void { From 6d951ad65575b657afb6334f14c6f4a01ac87a6c Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 18 Feb 2026 19:21:40 +0100 Subject: [PATCH 26/29] add temporary error handler to avoid throwing raw ErrorException --- .../view/src/Renderers/TempestViewRenderer.php | 12 ++++++++++++ .../standalone-undefined-variable.view.php | 3 +++ packages/view/tests/StandaloneViewRendererTest.php | 14 ++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 packages/view/tests/Fixtures/standalone-undefined-variable.view.php diff --git a/packages/view/src/Renderers/TempestViewRenderer.php b/packages/view/src/Renderers/TempestViewRenderer.php index abd83f9f7..54340940c 100644 --- a/packages/view/src/Renderers/TempestViewRenderer.php +++ b/packages/view/src/Renderers/TempestViewRenderer.php @@ -5,6 +5,7 @@ namespace Tempest\View\Renderers; use Closure; +use ErrorException; use Stringable; use Tempest\Container\Container; use Tempest\Core\Environment; @@ -134,6 +135,15 @@ private function renderCompiled(View $_view, string $_path): string extract($_data, flags: EXTR_SKIP); + set_error_handler(static function (int $code, string $message, string $filename, int $line): bool { + throw new ErrorException( + message: $message, + code: $code, + filename: $filename, + line: $line, + ); + }); + try { include $_path; } catch (Throwable $throwable) { @@ -148,6 +158,8 @@ private function renderCompiled(View $_view, string $_path): string sourcePath: $sourceLocation['path'] ?? null, sourceLine: $sourceLocation['line'] ?? null, ); + } finally { + restore_error_handler(); } $this->currentView = null; diff --git a/packages/view/tests/Fixtures/standalone-undefined-variable.view.php b/packages/view/tests/Fixtures/standalone-undefined-variable.view.php new file mode 100644 index 000000000..e904d14db --- /dev/null +++ b/packages/view/tests/Fixtures/standalone-undefined-variable.view.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/packages/view/tests/StandaloneViewRendererTest.php b/packages/view/tests/StandaloneViewRendererTest.php index f93ebab36..857e67992 100644 --- a/packages/view/tests/StandaloneViewRendererTest.php +++ b/packages/view/tests/StandaloneViewRendererTest.php @@ -135,6 +135,20 @@ public function test_maps_source_path_and_line_for_view_errors(): void } } + #[Test] + public function test_maps_source_path_and_line_for_undefined_variable_errors(): void + { + $renderer = TempestViewRenderer::make(); + + try { + $renderer->render(view(__DIR__ . '/Fixtures/standalone-undefined-variable.view.php')); + $this->fail('Expected a view compilation exception.'); + } catch (ViewCompilationFailed $exception) { + $this->assertSame(__DIR__ . '/Fixtures/standalone-undefined-variable.view.php', $exception->sourcePath); + $this->assertSame(2, $exception->sourceLine); + } + } + #[Test] public function test_maps_source_path_and_line_for_component_errors(): void { From f162dcfdf0e9c1fadb441aae3617480c98d5b981 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 18 Feb 2026 20:59:58 +0100 Subject: [PATCH 27/29] wip - fix source line shifting compared to sourcemap --- packages/view/src/Elements/RootElement.php | 29 +++++++++++++-- .../src/Elements/ViewComponentElement.php | 3 +- .../view/src/Parser/TempestViewCompiler.php | 36 +++++++++++++++---- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/packages/view/src/Elements/RootElement.php b/packages/view/src/Elements/RootElement.php index 77d1b10c4..ec54f92a3 100644 --- a/packages/view/src/Elements/RootElement.php +++ b/packages/view/src/Elements/RootElement.php @@ -1,4 +1,5 @@ mergeImports($imports, $this->inheritedImports); + foreach ($this->children as $child) { if ($child instanceof PhpElement) { - $imports = [...$imports, ...$child->getImports()]; + $this->mergeImports($imports, $child->getImports()); } } - return $imports; + return array_values($imports); + } + + public function setInheritedImports(array $imports): self + { + $this->inheritedImports = $imports; + + return $this; + } + + private function mergeImports(array &$imports, array $candidates): void + { + foreach ($candidates as $import) { + $import = trim($import); + + if ($import === '') { + continue; + } + + $imports[$import] = $import; + } } } diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index 8abf0e1df..4f42d0627 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -150,11 +150,10 @@ public function compile(): string }, ); - $compiled = $compiled->prepend('getImports()) . PHP_EOL . '?>' . PHP_EOL); - $compiledView = $this->compiler->compileWithSourceMap( $compiled->toString(), sourcePath: $this->viewComponent->file, + prependImports: $this->getImports(), ); $cacheKey = sprintf('%s:%s', $this->viewComponent->file, hash('xxh64', $compiledView->content)); diff --git a/packages/view/src/Parser/TempestViewCompiler.php b/packages/view/src/Parser/TempestViewCompiler.php index 1a4924b35..4e41b1811 100644 --- a/packages/view/src/Parser/TempestViewCompiler.php +++ b/packages/view/src/Parser/TempestViewCompiler.php @@ -47,10 +47,12 @@ public function compile(string|View $view): string return $this->compileWithSourceMap($view)->content; } - public function compileWithSourceMap(string|View $view, ?string $sourcePath = null): CompiledView + public function compileWithSourceMap(string|View $view, ?string $sourcePath = null, array $prependImports = []): CompiledView { $this->elementFactory->setViewCompiler($this); + $prependImports = $this->normalizeImports($prependImports); + // 1. Retrieve template [$template, $resolvedSourcePath] = $this->retrieveTemplate($view); $sourcePath ??= $resolvedSourcePath; @@ -69,6 +71,10 @@ public function compileWithSourceMap(string|View $view, ?string $sourcePath = nu // 4. Map to elements $rootElement = $this->mapToElements($ast); + if ($prependImports !== []) { + $rootElement->setInheritedImports($prependImports); + } + // 5. Apply attributes $rootElement = $this->applyAttributes($rootElement); @@ -76,7 +82,7 @@ public function compileWithSourceMap(string|View $view, ?string $sourcePath = nu $compiled = $this->compileElement($rootElement); // 7. Cleanup compiled PHP - [$cleaned, $lineMap] = $this->cleanupCompiled($compiled, $sourcePath); + [$cleaned, $lineMap] = $this->cleanupCompiled($compiled, $sourcePath, $rootElement->getImports()); return new CompiledView( content: $cleaned, @@ -166,9 +172,6 @@ private function collectSourcePathsForElement(Element $element, array &$sourcePa } } - /** - * @return Element[] - */ private function mapToElements(TempestViewAst $ast): RootElement { $elementFactory = $this->elementFactory->withIsHtml($ast->isHtml); @@ -286,7 +289,7 @@ private function resolveSourceLocation(Element $element): ?array /** * @return array{string, array} */ - private function cleanupCompiled(string $compiled, ?string $sourcePath): array + private function cleanupCompiled(string $compiled, ?string $sourcePath, array $importsToPrepend = []): array { // Remove strict type declarations $compiled = str($compiled)->replace('declare(strict_types=1);', ''); @@ -294,6 +297,10 @@ private function cleanupCompiled(string $compiled, ?string $sourcePath): array // Cleanup and bundle imports $imports = arr(); + foreach ($this->normalizeImports($importsToPrepend) as $import) { + $imports[$import] = $import; + } + $compiled = $compiled->replaceRegex("/^\s*use (function )?.*;/m", function (array $matches) use (&$imports) { // The import contains escaped slashes, meaning it's a var_exported string; we can ignore those if (str_contains($matches[0], '\\\\')) { @@ -322,6 +329,23 @@ private function cleanupCompiled(string $compiled, ?string $sourcePath): array return $this->extractSourceMap($compiled->toString(), $sourcePath); } + private function normalizeImports(array $imports): array + { + $normalized = []; + + foreach ($imports as $import) { + $import = trim($import); + + if ($import === '') { + continue; + } + + $normalized[$import] = $import; + } + + return array_values($normalized); + } + /** * @return array{string, array} */ From 542418457b10f411ded204d88d6c15da309615cb Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 18 Feb 2026 21:14:40 +0100 Subject: [PATCH 28/29] wip - add regression tests --- .../Exceptions/HtmlExceptionRendererTest.php | 36 -------- ...acktrace-component-imported-usage.view.php | 5 ++ .../stacktrace-standalone-error.view.php | 3 + .../x-stacktrace-error-component.view.php | 3 + tests/Integration/View/ViewComponentTest.php | 84 +++++++++++++++++++ 5 files changed, 95 insertions(+), 36 deletions(-) create mode 100644 tests/Integration/View/Fixtures/stacktrace-component-imported-usage.view.php create mode 100644 tests/Integration/View/Fixtures/stacktrace-standalone-error.view.php create mode 100644 tests/Integration/View/Fixtures/x-stacktrace-error-component.view.php diff --git a/tests/Integration/Http/Exceptions/HtmlExceptionRendererTest.php b/tests/Integration/Http/Exceptions/HtmlExceptionRendererTest.php index 10f0a03f9..509393b7d 100644 --- a/tests/Integration/Http/Exceptions/HtmlExceptionRendererTest.php +++ b/tests/Integration/Http/Exceptions/HtmlExceptionRendererTest.php @@ -21,15 +21,11 @@ use Tempest\Validation\Exceptions\ValidationFailed; use Tempest\Validation\FailingRule; use Tempest\Validation\Rules\IsNotNull; -use Tempest\View\Exceptions\ViewCompilationFailed; use Tempest\View\GenericView; -use Tempest\View\Renderers\TempestViewRenderer; use Tempest\View\View; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use Tests\Tempest\Integration\Http\Fixtures\ExceptionThatConvertsToRedirectResponse; -use function Tempest\View\view; - final class HtmlExceptionRendererTest extends FrameworkIntegrationTestCase { private HtmlExceptionRenderer $renderer { @@ -139,38 +135,6 @@ public function does_not_render_development_exception_for_not_found_in_local(): $this->assertSame(Status::NOT_FOUND, $response->status); } - #[Test] - public function does_not_duplicate_view_compilation_frames_in_stacktrace(): void - { - $this->container->singleton(Environment::class, Environment::LOCAL); - $this->container->singleton(GenericRequest::class, new GenericRequest( - method: Method::GET, - uri: '/', - )); - - $viewRenderer = TempestViewRenderer::make(); - - try { - $viewRenderer->render(view(__DIR__ . '/../../../../packages/view/tests/Fixtures/standalone-error.view.php')); - $this->fail('Expected a view compilation exception.'); - } catch (ViewCompilationFailed $exception) { - $response = $this->renderer->render($exception); - } - - $this->assertInstanceOf(DevelopmentException::class, $response); - $this->assertInstanceOf(GenericView::class, $response->body); - - $hydration = json_decode($response->body->data['hydration'], associative: true, flags: JSON_THROW_ON_ERROR); - $stacktrace = json_decode($hydration['stacktrace'], associative: true, flags: JSON_THROW_ON_ERROR); - - $renderCompiledFrames = array_values(array_filter( - $stacktrace['applicationFrames'], - fn (array $frame): bool => ($frame['class'] ?? null) === TempestViewRenderer::class && ($frame['function'] ?? null) === 'renderCompiled', - )); - - $this->assertCount(1, $renderCompiledFrames); - } - #[Test] public function converts_request_failed_string_bodies_to_proper_responses(): void { diff --git a/tests/Integration/View/Fixtures/stacktrace-component-imported-usage.view.php b/tests/Integration/View/Fixtures/stacktrace-component-imported-usage.view.php new file mode 100644 index 000000000..3b9295ce3 --- /dev/null +++ b/tests/Integration/View/Fixtures/stacktrace-component-imported-usage.view.php @@ -0,0 +1,5 @@ + + + diff --git a/tests/Integration/View/Fixtures/stacktrace-standalone-error.view.php b/tests/Integration/View/Fixtures/stacktrace-standalone-error.view.php new file mode 100644 index 000000000..39e68ade4 --- /dev/null +++ b/tests/Integration/View/Fixtures/stacktrace-standalone-error.view.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/tests/Integration/View/Fixtures/x-stacktrace-error-component.view.php b/tests/Integration/View/Fixtures/x-stacktrace-error-component.view.php new file mode 100644 index 000000000..d0b4f2b05 --- /dev/null +++ b/tests/Integration/View/Fixtures/x-stacktrace-error-component.view.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/tests/Integration/View/ViewComponentTest.php b/tests/Integration/View/ViewComponentTest.php index cc5dd93e8..5b1a47103 100644 --- a/tests/Integration/View/ViewComponentTest.php +++ b/tests/Integration/View/ViewComponentTest.php @@ -8,7 +8,11 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Tempest\Core\Environment; +use Tempest\Http\GenericRequest; +use Tempest\Http\Method; use Tempest\Http\Session\FormSession; +use Tempest\Router\Exceptions\DevelopmentException; +use Tempest\Router\Exceptions\HtmlExceptionRenderer; use Tempest\Validation\FailingRule; use Tempest\Validation\Rules\IsAlphaNumeric; use Tempest\Validation\Rules\IsBetween; @@ -16,7 +20,10 @@ use Tempest\View\Exceptions\DataAttributeWasInvalid; use Tempest\View\Exceptions\ViewCompilationFailed; use Tempest\View\Exceptions\ViewVariableWasReserved; +use Tempest\View\GenericView; +use Tempest\View\Renderers\TempestViewRenderer; use Tempest\View\ViewCache; +use Tempest\View\ViewConfig; use Tests\Tempest\Fixtures\Views\Chapter; use Tests\Tempest\Fixtures\Views\DocsView; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -1086,6 +1093,83 @@ function (ViewCompilationFailed $exception): void { ); } + #[Test] + public function does_not_duplicate_view_compilation_frames_in_stacktrace(): void + { + $this->container->singleton(Environment::class, Environment::LOCAL); + $this->container->singleton(GenericRequest::class, new GenericRequest( + method: Method::GET, + uri: '/', + )); + + $viewRenderer = TempestViewRenderer::make(); + $exceptionRenderer = $this->container->get(HtmlExceptionRenderer::class); + + $this->assertException( + ViewCompilationFailed::class, + function () use ($viewRenderer): void { + $viewRenderer->render(view(__DIR__ . '/Fixtures/stacktrace-standalone-error.view.php')); + }, + function (ViewCompilationFailed $exception) use ($exceptionRenderer): void { + $response = $exceptionRenderer->render($exception); + + $this->assertInstanceOf(DevelopmentException::class, $response); + $this->assertInstanceOf(GenericView::class, $response->body); + + $hydration = json_decode($response->body->data['hydration'], associative: true, flags: JSON_THROW_ON_ERROR); + $stacktrace = json_decode($hydration['stacktrace'], associative: true, flags: JSON_THROW_ON_ERROR); + + $renderCompiledFrames = array_values(array_filter( + $stacktrace['applicationFrames'], + fn (array $frame): bool => ($frame['class'] ?? null) === TempestViewRenderer::class && ($frame['function'] ?? null) === 'renderCompiled', + )); + + $this->assertCount(1, $renderCompiledFrames); + }, + ); + } + + #[Test] + public function maps_component_source_line_in_view_compilation_stacktrace(): void + { + $this->container->singleton(Environment::class, Environment::LOCAL); + $this->container->singleton(GenericRequest::class, new GenericRequest( + method: Method::GET, + uri: '/', + )); + + $this->container->get(ViewConfig::class)->addViewComponents( + __DIR__ . '/Fixtures/x-stacktrace-error-component.view.php', + ); + + $exceptionRenderer = $this->container->get(HtmlExceptionRenderer::class); + + $this->assertException( + ViewCompilationFailed::class, + function (): void { + $this->view->render(view(__DIR__ . '/Fixtures/stacktrace-component-imported-usage.view.php')); + }, + function (ViewCompilationFailed $exception) use ($exceptionRenderer): void { + $response = $exceptionRenderer->render($exception); + + $this->assertInstanceOf(DevelopmentException::class, $response); + $this->assertInstanceOf(GenericView::class, $response->body); + + $hydration = json_decode($response->body->data['hydration'], associative: true, flags: JSON_THROW_ON_ERROR); + $stacktrace = json_decode($hydration['stacktrace'], associative: true, flags: JSON_THROW_ON_ERROR); + + $renderCompiledFrames = array_values(array_filter( + $stacktrace['applicationFrames'], + fn (array $frame): bool => ($frame['class'] ?? null) === TempestViewRenderer::class && ($frame['function'] ?? null) === 'renderCompiled', + )); + + $this->assertCount(1, $renderCompiledFrames); + $this->assertSame('tests/Integration/View/Fixtures/x-stacktrace-error-component.view.php', $renderCompiledFrames[0]['relativeFile']); + $this->assertSame(2, $renderCompiledFrames[0]['line']); + }, + ); + } + public function test_imports_with_nested_view_components(): void { $this->view->registerViewComponent('x-card', <<<'HTML' From bcc2cdfe1e520defa9701e00e6755196c6e11307 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 18 Feb 2026 23:08:04 +0100 Subject: [PATCH 29/29] QA --- packages/view/src/Elements/PhpElement.php | 2 +- .../view/src/Parser/TempestViewCompiler.php | 28 ------------------- phpstan-baseline.neon | 14 ++++++++++ ...acktrace-component-imported-usage.view.php | 3 +- 4 files changed, 16 insertions(+), 31 deletions(-) diff --git a/packages/view/src/Elements/PhpElement.php b/packages/view/src/Elements/PhpElement.php index 70cb5762e..a75cb3577 100644 --- a/packages/view/src/Elements/PhpElement.php +++ b/packages/view/src/Elements/PhpElement.php @@ -26,6 +26,6 @@ public function getImports(): array { preg_match_all('/^\s*use .*;/m', $this->content, $matches); - return $matches[0] ?? []; + return $matches[0]; } } diff --git a/packages/view/src/Parser/TempestViewCompiler.php b/packages/view/src/Parser/TempestViewCompiler.php index 4e41b1811..0a7b21d70 100644 --- a/packages/view/src/Parser/TempestViewCompiler.php +++ b/packages/view/src/Parser/TempestViewCompiler.php @@ -144,34 +144,6 @@ private function parseAst(string $template, ?string $sourcePath = null): Tempest return new TempestViewParser($tokens)->parse(); } - /** @param Element[] $elements */ - private function collectSourcePathsForElements(array $elements): array - { - $sourcePaths = []; - - foreach ($elements as $element) { - $this->collectSourcePathsForElement($element, $sourcePaths); - } - - return array_keys($sourcePaths); - } - - /** @param array $sourcePaths */ - private function collectSourcePathsForElement(Element $element, array &$sourcePaths): void - { - if ($element instanceof WithToken && is_string($element->token->sourcePath)) { - $sourcePaths[$element->token->sourcePath] = true; - } - - if ($element instanceof WrapsElement) { - $this->collectSourcePathsForElement($element->getWrappingElement(), $sourcePaths); - } - - foreach ($element->getChildren() as $child) { - $this->collectSourcePathsForElement($child, $sourcePaths); - } - } - private function mapToElements(TempestViewAst $ast): RootElement { $elementFactory = $this->elementFactory->withIsHtml($ast->isHtml); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 9858fc847..1d997881e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -110,3 +110,17 @@ parameters: path: tests/Integration/Database/GroupedWhereMethodsTest.php count: 1 # Intentional: assertion keeps explicit null-check semantics in grouped-where integration test + - + identifier: function.notFound + paths: + - packages/view/tests/Fixtures/standalone-error-slot-usage.view.php + - packages/view/tests/Fixtures/standalone-error.view.php + - packages/view/tests/Fixtures/x-standalone-error-component.view.php + - tests/Integration/View/Fixtures/stacktrace-standalone-error.view.php + - tests/Integration/View/Fixtures/x-stacktrace-error-component.view.php + # Intentional: these fixtures validate source-path stack traces for view compilation errors + - + identifier: variable.undefined + path: packages/view/tests/Fixtures/standalone-undefined-variable.view.php + count: 1 + # Intentional: this fixture validates undefined variable source mapping in view compilation errors diff --git a/tests/Integration/View/Fixtures/stacktrace-component-imported-usage.view.php b/tests/Integration/View/Fixtures/stacktrace-component-imported-usage.view.php index 3b9295ce3..6ae9fd82f 100644 --- a/tests/Integration/View/Fixtures/stacktrace-component-imported-usage.view.php +++ b/tests/Integration/View/Fixtures/stacktrace-component-imported-usage.view.php @@ -1,5 +1,4 @@ +use function Tempest\View\view; // @mago-expect lint:no-redundant-use ?>