diff --git a/packages/debug/src/Debug.php b/packages/debug/src/Debug.php index 5a6f957c0..9ba975793 100644 --- a/packages/debug/src/Debug.php +++ b/packages/debug/src/Debug.php @@ -4,7 +4,6 @@ namespace Tempest\Debug; -use Exception; use Symfony\Component\VarDumper\Cloner\VarCloner; use Symfony\Component\VarDumper\Dumper\CliDumper; use Symfony\Component\VarDumper\VarDumper; @@ -12,6 +11,7 @@ use Tempest\EventBus\EventBus; use Tempest\Highlight\Themes\TerminalStyle; use Tempest\Support\Filesystem; +use Throwable; final readonly class Debug { @@ -27,7 +27,7 @@ public static function resolve(): self config: GenericContainer::instance()->get(DebugConfig::class), eventBus: GenericContainer::instance()->get(EventBus::class), ); - } catch (Exception) { + } catch (Throwable) { return new self(); } } diff --git a/packages/view/src/Elements/ElementFactory.php b/packages/view/src/Elements/ElementFactory.php index bf8e9dcf1..4d63a88fa 100644 --- a/packages/view/src/Elements/ElementFactory.php +++ b/packages/view/src/Elements/ElementFactory.php @@ -69,6 +69,10 @@ private function makeElement(Token $token, ?Element $parent): ?Element 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()); } diff --git a/packages/view/src/Elements/IsElement.php b/packages/view/src/Elements/IsElement.php index 839b7944a..7aa66e030 100644 --- a/packages/view/src/Elements/IsElement.php +++ b/packages/view/src/Elements/IsElement.php @@ -110,6 +110,10 @@ public function setPrevious(?Element $previous): self public function getPrevious(): ?Element { + if ($this->previous instanceof WhitespaceElement) { + return $this->previous->getPrevious(); + } + return $this->previous; } diff --git a/packages/view/src/Elements/WhitespaceElement.php b/packages/view/src/Elements/WhitespaceElement.php new file mode 100644 index 000000000..148e409e8 --- /dev/null +++ b/packages/view/src/Elements/WhitespaceElement.php @@ -0,0 +1,21 @@ +content; + } +} diff --git a/packages/view/src/Parser/TempestViewLexer.php b/packages/view/src/Parser/TempestViewLexer.php index 658cdb476..9b38a66c3 100644 --- a/packages/view/src/Parser/TempestViewLexer.php +++ b/packages/view/src/Parser/TempestViewLexer.php @@ -35,6 +35,8 @@ public function lex(): TokenCollection $tokens = [...$tokens, ...$this->lexCharacterData()]; } elseif ($this->comesNext('<')) { $tokens = [...$tokens, ...$this->lexTag()]; + } elseif ($this->comesNext(' ') || $this->comesNext(PHP_EOL)) { + $tokens[] = $this->lexWhitespace(); } else { $tokens[] = $this->lexContent(); } @@ -214,6 +216,23 @@ private function lexDoctype(): Token return new Token($buffer, TokenType::DOCTYPE); } + private function lexWhitespace(): Token + { + $buffer = ''; + + while ($this->current !== null) { + $seek = $this->seek(); + + if ($seek !== ' ' && $seek !== PHP_EOL) { + break; + } + + $buffer .= $this->consume(); + } + + return new Token($buffer, TokenType::WHITESPACE); + } + private function lexCharacterData(): array { $tokens = [ diff --git a/packages/view/src/Parser/TokenType.php b/packages/view/src/Parser/TokenType.php index 0a433b548..2938ea76f 100644 --- a/packages/view/src/Parser/TokenType.php +++ b/packages/view/src/Parser/TokenType.php @@ -18,4 +18,5 @@ enum TokenType case DOCTYPE; case CHARACTER_DATA_OPEN; case CHARACTER_DATA_CLOSE; + case WHITESPACE; } diff --git a/packages/view/tests/FallthroughAttributesTest.php b/packages/view/tests/FallthroughAttributesTest.php index ba301baaf..89a6f8475 100644 --- a/packages/view/tests/FallthroughAttributesTest.php +++ b/packages/view/tests/FallthroughAttributesTest.php @@ -28,11 +28,11 @@ public function render(): void view(__DIR__ . '/Fixtures/fallthrough.view.php'), ); - $this->assertEquals(<<<'HTML' + $this->assertEquals(str_replace([' ', PHP_EOL], '', <<<'HTML'
- HTML, $html); + HTML), str_replace([' ', PHP_EOL], '', $html)); } } diff --git a/packages/view/tests/TempestViewLexerTest.php b/packages/view/tests/TempestViewLexerTest.php index 9d533d535..0ba62e288 100644 --- a/packages/view/tests/TempestViewLexerTest.php +++ b/packages/view/tests/TempestViewLexerTest.php @@ -173,13 +173,40 @@ class=', TokenType::ATTRIBUTE_NAME), new Token("\n>", TokenType::OPEN_TAG_END), new Token(' -', TokenType::CONTENT), +', TokenType::WHITESPACE), new Token('', TokenType::CLOSING_TAG), ], actual: $tokens, ); } + public function test_whitespace(): void + { + $html = <<<'HTML' +

Test Test

+ HTML; + + $tokens = new TempestViewLexer($html)->lex(); + + $this->assertTokens( + expected: [ + new Token('', TokenType::OPEN_TAG_END), + new Token('', TokenType::OPEN_TAG_END), + new Token('Test', TokenType::CONTENT), + new Token('', TokenType::CLOSING_TAG), + new Token(' ', TokenType::WHITESPACE), + new Token('', TokenType::OPEN_TAG_END), + new Token('Test', TokenType::CONTENT), + new Token('', TokenType::CLOSING_TAG), + new Token('

', TokenType::CLOSING_TAG), + ], + actual: $tokens, + ); + } + public function test_lexer_with_falsy_values(): void { $html = <<<'HTML' diff --git a/tests/Integration/Testing/Http/HttpRouterTesterIntegrationTest.php b/tests/Integration/Testing/Http/HttpRouterTesterIntegrationTest.php index 6359e7d43..739ee93da 100644 --- a/tests/Integration/Testing/Http/HttpRouterTesterIntegrationTest.php +++ b/tests/Integration/Testing/Http/HttpRouterTesterIntegrationTest.php @@ -210,7 +210,7 @@ public function query(): void #[Test] public function raw_body_string(): void { - $this->registerRoute([TestController::class, 'handleRawBody']); + $this->http->registerRoute([TestController::class, 'handleRawBody']); $response = $this->http ->post('/raw-body', body: 'ok') diff --git a/tests/Integration/View/ElementFactoryTest.php b/tests/Integration/View/ElementFactoryTest.php index 5020c5a3f..529a2ca46 100644 --- a/tests/Integration/View/ElementFactoryTest.php +++ b/tests/Integration/View/ElementFactoryTest.php @@ -4,12 +4,16 @@ namespace Tests\Tempest\Integration\View; +use Tempest\View\Element; use Tempest\View\Elements\ElementFactory; use Tempest\View\Elements\GenericElement; use Tempest\View\Elements\TextElement; +use Tempest\View\Elements\WhitespaceElement; use Tempest\View\Parser\TempestViewParser; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; +use function Tempest\Support\arr; + /** * @internal */ @@ -37,33 +41,41 @@ public function test_parental_relations(): void $a = $elementFactory->make(iterator_to_array($ast)[0]); $this->assertInstanceOf(GenericElement::class, $a); - $this->assertCount(1, $a->getChildren()); + $this->assertCount(1, $this->withoutWhitespace($a->getChildren())); $this->assertNull($a->getParent()); - $b = $a->getChildren()[0]; + $b = $this->withoutWhitespace($a->getChildren())[0]; $this->assertInstanceOf(GenericElement::class, $b); - $this->assertCount(3, $b->getChildren()); + $this->assertCount(3, $this->withoutWhitespace($b->getChildren())); $this->assertSame($b->getParent(), $a); - $c = $b->getChildren()[0]; + $c = $this->withoutWhitespace($b->getChildren())[0]; $this->assertInstanceOf(GenericElement::class, $c); - $this->assertCount(1, $c->getChildren()); + $this->assertCount(1, $this->withoutWhitespace($c->getChildren())); $this->assertSame($c->getParent(), $b); - $text = $c->getChildren()[0]; + $text = $this->withoutWhitespace($c->getChildren())[0]; $this->assertInstanceOf(TextElement::class, $text); $this->assertSame($text->getParent(), $c); - $d = $b->getChildren()[1]; + $d = $this->withoutWhitespace($b->getChildren())[1]; $this->assertInstanceOf(GenericElement::class, $d); - $this->assertCount(0, $d->getChildren()); + $this->assertCount(0, $this->withoutWhitespace($d->getChildren())); $this->assertSame($d->getParent(), $b); $this->assertSame($d->getPrevious(), $c); - $e = $b->getChildren()[2]; + $e = $this->withoutWhitespace($b->getChildren())[2]; $this->assertInstanceOf(GenericElement::class, $e); - $this->assertCount(0, $e->getChildren()); + $this->assertCount(0, $this->withoutWhitespace($e->getChildren())); $this->assertSame($e->getParent(), $b); $this->assertSame($e->getPrevious(), $d); } + + private function withoutWhitespace(array $elements): array + { + return arr($elements) + ->filter(fn (Element $element) => ! $element instanceof WhitespaceElement) + ->values() + ->toArray(); + } } diff --git a/tests/Integration/View/TempestViewRendererTest.php b/tests/Integration/View/TempestViewRendererTest.php index 8aad3af5f..134326e4c 100644 --- a/tests/Integration/View/TempestViewRendererTest.php +++ b/tests/Integration/View/TempestViewRendererTest.php @@ -292,7 +292,7 @@ public function test_default_slot(): void public function test_implicit_default_slot(): void { - $this->assertStringEqualsStringIgnoringLineEndings( + $this->assertSnippetsMatch( <<<'HTML'
@@ -891,4 +891,43 @@ public function test_discovery_locations_are_passed_to_compiler(): void $this->assertSnippetsMatch('
Hi
', $html); } + + public function test_whitespace_between_inline_elements_is_preserved(): void + { + /** @var TempestViewRenderer $renderer */ + $renderer = $this->get(TempestViewRenderer::class); + + $this->assertSame( + '

Test Test

', + $renderer->render('

Test Test

'), + ); + } + + public function test_whitespace_introduced_by_line_breaks_is_preserved(): void + { + /** @var TempestViewRenderer $renderer */ + $renderer = $this->get(TempestViewRenderer::class); + + $this->assertSame( + '

Test +Test

', + $renderer->render('

Test +Test

'), + ); + } + + public function test_whitespace_with_blank_lines_between_inline_elements_is_preserved(): void + { + /** @var TempestViewRenderer $renderer */ + $renderer = $this->get(TempestViewRenderer::class); + + $this->assertSame( + '

Test + +Test

', + $renderer->render('

Test + +Test

'), + ); + } } diff --git a/tests/Integration/View/ViewComponentTest.php b/tests/Integration/View/ViewComponentTest.php index e5d15536b..38c528d03 100644 --- a/tests/Integration/View/ViewComponentTest.php +++ b/tests/Integration/View/ViewComponentTest.php @@ -471,14 +471,14 @@ public static function view_components(): Generator public function test_full_html_document_as_component(): void { $this->view->registerViewComponent('x-layout', <<<'HTML' - - - Tempest View - - - - - + + + Tempest View + + + + + HTML); $html = $this->view->render(<<<'HTML' @@ -487,9 +487,16 @@ public function test_full_html_document_as_component(): void HTML); - $this->assertStringContainsString('Tempest View', $html); + $this->assertStringContainsString(<<<'HTML' + + + Tempest View + + + HTML, $html); $this->assertStringContainsString('Hello World', $html); - $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); } public function test_empty_slots_are_commented_out(): void