From cd2072e4090d2afcbb98f4e461a1a2c32ee9389a Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Tue, 23 Jun 2026 14:22:59 -0400 Subject: [PATCH 01/30] update mikecao copyright --- composer.json | 2 +- flight/template/View.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 70a09ca..6a03654 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ { "name": "Mike Cao", "email": "mike@mikecao.com", - "homepage": "http://www.mikecao.com/", + "homepage": "https://mikecao.com", "role": "Original Developer" }, { diff --git a/flight/template/View.php b/flight/template/View.php index f995d36..e14b0f4 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -9,8 +9,8 @@ namespace flight\template; * methods for managing view data and inserts the data into * view templates upon rendering. * - * @license MIT, http://flightphp.com/license - * @copyright Copyright (c) 2011, Mike Cao + * @license MIT, https://docs.flightphp.com/license + * @copyright 2011 [Mike Cao](https://mikecao.com) */ class View { From a3423b4cdf4aabf0d73f927f4ada88ee013a29a8 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Tue, 23 Jun 2026 14:25:34 -0400 Subject: [PATCH 02/30] update license docblock --- flight/template/View.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flight/template/View.php b/flight/template/View.php index e14b0f4..3366097 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -9,7 +9,7 @@ namespace flight\template; * methods for managing view data and inserts the data into * view templates upon rendering. * - * @license MIT, https://docs.flightphp.com/license + * @license [MIT](https://docs.flightphp.com/license) * @copyright 2011 [Mike Cao](https://mikecao.com) */ class View From 98e20c6ad18c10cc16419d70805171fbd56e5fb1 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Tue, 23 Jun 2026 18:31:37 -0400 Subject: [PATCH 03/30] import Exception --- flight/template/View.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flight/template/View.php b/flight/template/View.php index 3366097..957acbf 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace flight\template; +use Exception; + /** * The View class represents output to be displayed. It provides * methods for managing view data and inserts the data into @@ -105,7 +107,7 @@ class View * @param string $file Template file * @param ?array $templateData Template data * - * @throws \Exception If template not found + * @throws Exception If template not found */ public function render(string $file, ?array $templateData = null): void { @@ -113,7 +115,7 @@ class View if (!\file_exists($this->template)) { $normalized_path = self::normalizePath($this->template); - throw new \Exception("Template file not found: {$normalized_path}."); + throw new Exception("Template file not found: $normalized_path."); } \extract($this->vars); From f047d22fab1a82c761a55977663f65610b21a7a0 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Tue, 23 Jun 2026 18:34:31 -0400 Subject: [PATCH 04/30] simplify docblocks --- flight/template/View.php | 45 +++++++++++----------------------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/flight/template/View.php b/flight/template/View.php index 957acbf..5e8b516 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -16,37 +16,28 @@ use Exception; */ class View { - /** Location of view templates. */ + /** Location of view templates */ public string $path; - /** File extension. */ + /** File extension */ public string $extension = '.php'; public bool $preserveVars = true; - /** - * View variables. - * - * @var array $vars - */ + /** @var array View variables */ protected array $vars = []; /** Template file. */ private string $template; - /** - * Constructor. - * - * @param string $path Path to templates directory - */ + /** @param string $path Path to templates directory */ public function __construct(string $path = '.') { $this->path = $path; } /** - * Gets a template variable. - * + * Gets a template variable * @return mixed Variable value or `null` if doesn't exists */ public function get(string $key) @@ -76,8 +67,7 @@ class View } /** - * Checks if a template variable is set. - * + * Checks if a template variable is set * @return bool If key exists */ public function has(string $key): bool @@ -86,8 +76,7 @@ class View } /** - * Unsets a template variable. If no key is passed in, clear all variables. - * + * Unsets a template variable. If no key is passed in, clear all variables * @return $this */ public function clear(?string $key = null): self @@ -102,11 +91,9 @@ class View } /** - * Renders a template. - * + * Renders a template * @param string $file Template file * @param ?array $templateData Template data - * * @throws Exception If template not found */ public function render(string $file, ?array $templateData = null): void @@ -132,11 +119,9 @@ class View } /** - * Gets the output of a template. - * + * Gets the output of a template * @param string $file Template file * @param ?array $data Template data - * * @return string Output of template */ public function fetch(string $file, ?array $data = null): string @@ -149,10 +134,8 @@ class View } /** - * Checks if a template file exists. - * + * Checks if a template file exists * @param string $file Template file - * * @return bool Template file exists */ public function exists(string $file): bool @@ -161,10 +144,8 @@ class View } /** - * Gets the full path to a template file. - * + * Gets the full path to a template file * @param string $file Template file - * * @return string Template file location */ public function getTemplate(string $file): string @@ -185,10 +166,8 @@ class View } /** - * Displays escaped output. - * + * Displays escaped output * @param string $str String to escape - * * @return string Escaped string */ public function e(string $str): string From 7d29fdf7195ee70966dcd1f3d4a2399cbcd5fafd Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Tue, 23 Jun 2026 18:35:32 -0400 Subject: [PATCH 05/30] use $this instead of self --- flight/template/View.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/flight/template/View.php b/flight/template/View.php index 5e8b516..51ed7c7 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -50,10 +50,9 @@ class View * * @param string|iterable $key * @param mixed $value Value - * - * @return self + * @return $this */ - public function set($key, $value = null): self + public function set($key, $value = null) { if (\is_iterable($key)) { foreach ($key as $k => $v) { @@ -79,7 +78,7 @@ class View * Unsets a template variable. If no key is passed in, clear all variables * @return $this */ - public function clear(?string $key = null): self + public function clear(?string $key = null) { if ($key === null) { $this->vars = []; From d28939f2044fb83eb5fe6ad9664e017dc946c900 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Tue, 23 Jun 2026 18:36:12 -0400 Subject: [PATCH 06/30] replace iterable for arrays --- flight/template/View.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/flight/template/View.php b/flight/template/View.php index 51ed7c7..c5c5788 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -46,15 +46,14 @@ class View } /** - * Sets a template variable. - * - * @param string|iterable $key + * Sets a template variable + * @param string|array $key * @param mixed $value Value * @return $this */ public function set($key, $value = null) { - if (\is_iterable($key)) { + if (is_array($key)) { foreach ($key as $k => $v) { $this->vars[$k] = $v; } From 206eff4f3df9a19d578e1c4840458d733f8c137a Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Tue, 23 Jun 2026 18:37:37 -0400 Subject: [PATCH 07/30] simplify render --- flight/template/View.php | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/flight/template/View.php b/flight/template/View.php index c5c5788..598a395 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -27,9 +27,6 @@ class View /** @var array View variables */ protected array $vars = []; - /** Template file. */ - private string $template; - /** @param string $path Path to templates directory */ public function __construct(string $path = '.') { @@ -96,24 +93,25 @@ class View */ public function render(string $file, ?array $templateData = null): void { - $this->template = $this->getTemplate($file); + $template = $this->getTemplate($file); + + if (!$this->exists($file)) { + $normalized_path = $this::normalizePath($template); - if (!\file_exists($this->template)) { - $normalized_path = self::normalizePath($this->template); throw new Exception("Template file not found: $normalized_path."); } - \extract($this->vars); + extract($this->vars); - if (\is_array($templateData) === true) { - \extract($templateData); + if (is_array($templateData)) { + extract($templateData); - if ($this->preserveVars === true) { - $this->vars = \array_merge($this->vars, $templateData); + if ($this->preserveVars) { + $this->vars += $templateData; } } - include $this->template; + include $template; } /** From 2321b0e079bbc310592c900d48096a5e39632749 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Tue, 23 Jun 2026 18:38:10 -0400 Subject: [PATCH 08/30] simplify getTemplate --- flight/template/View.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/flight/template/View.php b/flight/template/View.php index 598a395..92a2d1b 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -146,15 +146,13 @@ class View */ public function getTemplate(string $file): string { - $ext = $this->extension; - - if (!empty($ext) && (\substr($file, -1 * \strlen($ext)) != $ext)) { - $file .= $ext; + if (!empty($this->extension) && (substr($file, -1 * strlen($this->extension)) !== $this->extension)) { + $file .= $this->extension; } - $is_windows = \strtoupper(\substr(PHP_OS, 0, 3)) === 'WIN'; + $is_windows = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; - if ((\substr($file, 0, 1) === '/') || ($is_windows && \substr($file, 1, 1) === ':')) { + if ((substr($file, 0, 1) === '/') || ($is_windows && substr($file, 1, 1) === ':')) { return $file; } From abdf84589968594e598fd5adaa69ec5e56edb1ad Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Tue, 23 Jun 2026 18:38:34 -0400 Subject: [PATCH 09/30] remove \ from global function calls --- flight/template/View.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/flight/template/View.php b/flight/template/View.php index 92a2d1b..63f2a8d 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -122,11 +122,11 @@ class View */ public function fetch(string $file, ?array $data = null): string { - \ob_start(); + ob_start(); $this->render($file, $data); - return \ob_get_clean(); + return ob_get_clean(); } /** @@ -136,7 +136,7 @@ class View */ public function exists(string $file): bool { - return \file_exists($this->getTemplate($file)); + return file_exists($this->getTemplate($file)); } /** @@ -166,13 +166,16 @@ class View */ public function e(string $str): string { - $value = \htmlentities($str); + $value = htmlentities($str); echo $value; + return $value; } - protected static function normalizePath(string $path, string $separator = DIRECTORY_SEPARATOR): string - { - return \str_replace(['\\', '/'], $separator, $path); + protected static function normalizePath( + string $path, + string $separator = DIRECTORY_SEPARATOR + ): string { + return str_replace(['\\', '/'], $separator, $path); } } From 6b506636f05d3027c6262a09a50afe3afe537270 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Tue, 23 Jun 2026 18:56:18 -0400 Subject: [PATCH 10/30] always normalize paths --- flight/template/View.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/flight/template/View.php b/flight/template/View.php index 63f2a8d..26c3f2c 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -96,9 +96,7 @@ class View $template = $this->getTemplate($file); if (!$this->exists($file)) { - $normalized_path = $this::normalizePath($template); - - throw new Exception("Template file not found: $normalized_path."); + throw new Exception("Template file not found: $template."); } extract($this->vars); @@ -156,7 +154,7 @@ class View return $file; } - return $this->path . DIRECTORY_SEPARATOR . $file; + return $this::normalizePath("$this->path/$file"); } /** From b1c56b1a6e8d4c99a761b1749e254dc8dcdcd363 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Tue, 23 Jun 2026 19:28:10 -0400 Subject: [PATCH 11/30] remove final_newlines to all tests/views/* --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index d3de648..d6b66b8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,5 +11,5 @@ insert_final_newline = true [*.md] indent_size = 2 -[tests/views/*.php] +[tests/views/*] insert_final_newline = false From ee9699e8ad9bd92764c3fdd149af34164fce0ea6 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Tue, 23 Jun 2026 19:29:27 -0400 Subject: [PATCH 12/30] simplify view instantiation --- tests/ViewTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/ViewTest.php b/tests/ViewTest.php index e57afd0..6a03320 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -14,8 +14,7 @@ class ViewTest extends TestCase protected function setUp(): void { - $this->view = new View(); - $this->view->path = __DIR__ . '/views'; + $this->view = new View(__DIR__ . '/views'); } // Set template variables From 6afd221c76b310871003f080037290f9c0e821b3 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Tue, 23 Jun 2026 19:30:18 -0400 Subject: [PATCH 13/30] remove ViewTest methods comments --- tests/ViewTest.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/ViewTest.php b/tests/ViewTest.php index 6a03320..2ead7fe 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -17,7 +17,6 @@ class ViewTest extends TestCase $this->view = new View(__DIR__ . '/views'); } - // Set template variables public function testVariables(): void { $this->view->set('test', 123); @@ -48,14 +47,12 @@ class ViewTest extends TestCase $this->assertNull($this->view->get('foo')); } - // Check if template files exist public function testTemplateExists(): void { $this->assertTrue($this->view->exists('hello.php')); $this->assertTrue(!$this->view->exists('unknown.php')); } - // Render a template public function testRender(): void { $this->view->render('hello', ['name' => 'Bob']); @@ -77,7 +74,6 @@ class ViewTest extends TestCase $this->view->render('badfile'); } - // Fetch template output public function testFetch(): void { $output = $this->view->fetch('hello', ['name' => 'Bob']); @@ -85,7 +81,6 @@ class ViewTest extends TestCase $this->assertEquals('Hello, Bob!', $output); } - // Default extension public function testTemplateWithExtension(): void { $this->view->set('name', 'Bob'); @@ -95,7 +90,6 @@ class ViewTest extends TestCase $this->expectOutputString('Hello, Bob!'); } - // Custom extension public function testTemplateWithCustomExtension(): void { $this->view->set('name', 'Bob'); From a6dbd321ae44dabda47278d83eee6a1f0c7ce07f Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Tue, 23 Jun 2026 19:30:39 -0400 Subject: [PATCH 14/30] use short echo --- tests/views/hello.php | 2 +- tests/views/world.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/views/hello.php b/tests/views/hello.php index 86a9b80..8551a87 100644 --- a/tests/views/hello.php +++ b/tests/views/hello.php @@ -1 +1 @@ -Hello, ! \ No newline at end of file +Hello, ! \ No newline at end of file diff --git a/tests/views/world.html b/tests/views/world.html index f4c856e..d274ca3 100644 --- a/tests/views/world.html +++ b/tests/views/world.html @@ -1 +1 @@ -Hello world, ! +Hello world, ! \ No newline at end of file From 576801967cb345d32cf33289f44ae7a6262b1579 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Tue, 23 Jun 2026 19:30:51 -0400 Subject: [PATCH 15/30] simplify ViewTest --- tests/ViewTest.php | 135 +++++++++++++++++++++++---------------------- 1 file changed, 68 insertions(+), 67 deletions(-) diff --git a/tests/ViewTest.php b/tests/ViewTest.php index 2ead7fe..91e7b85 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -21,55 +21,52 @@ class ViewTest extends TestCase { $this->view->set('test', 123); - $this->assertEquals(123, $this->view->get('test')); - - $this->assertTrue($this->view->has('test')); - $this->assertTrue(!$this->view->has('unknown')); + self::assertSame(123, $this->view->get('test')); + self::assertTrue($this->view->has('test')); + self::assertFalse($this->view->has('unknown')); $this->view->clear('test'); - $this->assertNull($this->view->get('test')); + self::assertNull($this->view->get('test')); } public function testMultipleVariables(): void { - $this->view->set([ - 'test' => 123, - 'foo' => 'bar' - ]); + $this->view->set(['test' => 123, 'foo' => 'bar']); - $this->assertEquals(123, $this->view->get('test')); - $this->assertEquals('bar', $this->view->get('foo')); + self::assertSame(123, $this->view->get('test')); + self::assertSame('bar', $this->view->get('foo')); $this->view->clear(); - $this->assertNull($this->view->get('test')); - $this->assertNull($this->view->get('foo')); + self::assertNull($this->view->get('test')); + self::assertNull($this->view->get('foo')); } public function testTemplateExists(): void { - $this->assertTrue($this->view->exists('hello.php')); - $this->assertTrue(!$this->view->exists('unknown.php')); + self::assertTrue($this->view->exists('hello.php')); + self::assertFalse($this->view->exists('unknown.php')); } public function testRender(): void { $this->view->render('hello', ['name' => 'Bob']); - $this->expectOutputString('Hello, Bob!'); + self::expectOutputString('Hello, Bob!'); } public function testRenderBadFilePath(): void { - $this->expectException(Exception::class); $exception_message = sprintf( 'Template file not found: %s%sviews%sbadfile.php', __DIR__, DIRECTORY_SEPARATOR, - DIRECTORY_SEPARATOR + DIRECTORY_SEPARATOR, ); - $this->expectExceptionMessage($exception_message); + + self::expectException(Exception::class); + self::expectExceptionMessage($exception_message); $this->view->render('badfile'); } @@ -78,30 +75,22 @@ class ViewTest extends TestCase { $output = $this->view->fetch('hello', ['name' => 'Bob']); - $this->assertEquals('Hello, Bob!', $output); + self::assertSame('Hello, Bob!', $output); } public function testTemplateWithExtension(): void { - $this->view->set('name', 'Bob'); + $this->view->render('hello.php', ['name' => 'Bob']); - $this->view->render('hello.php'); - - $this->expectOutputString('Hello, Bob!'); + self::expectOutputString('Hello, Bob!'); } public function testTemplateWithCustomExtension(): void { - $this->view->set('name', 'Bob'); - $this->view->extension = '.html'; - - ob_start(); - $this->view->render('world'); - $html = ob_get_clean(); - $html = str_replace(["\r\n", "\n"], '', $html); - echo $html; + self::expectOutputString('Hello world, Bob!'); - $this->expectOutputString("Hello world, Bob!"); + $this->view->extension = '.html'; + $this->view->render('world', ['name' => 'Bob']); } public function testGetTemplateAbsolutePath(): void @@ -109,48 +98,64 @@ class ViewTest extends TestCase $tmpfile = tmpfile(); $this->view->extension = ''; $file_path = stream_get_meta_data($tmpfile)['uri']; - $this->assertEquals($file_path, $this->view->getTemplate($file_path)); + + self::assertSame($file_path, $this->view->getTemplate($file_path)); } public function testE(): void { - $this->expectOutputString('<script>'); + $expectedString = '<script>'; + + self::expectOutputString($expectedString); + $result = $this->view->e(' + html; + } + + ob_end_clean(); + + break; + default: + $view = ob_get_clean(); + } + preg_match('/[a-z-]+)\s*\/>/', $view, $matches); if ($matches) { diff --git a/tests/ViewTest.php b/tests/ViewTest.php index d71e166..b649a56 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -239,24 +239,82 @@ class ViewTest extends TestCase { $view = new View(__DIR__ . '/views'); $view->preserveVars = false; - $actual = $view->fetch($page); + $actual = $view->fetch("pages/$page"); - self::assertSame(self::removeLineEndings($expected), self::removeLineEndings($actual)); + self::assertSame( + self::removeIndentation(self::removeLineEndings($expected)), + self::removeIndentation(self::removeLineEndings($actual)), + ); } public static function pagesDataProvider(): array { return [ - ['pages/page-with-component-with-old-syntax', <<<'html' - - value - - html], - ['pages/page-with-component-with-new-syntax', <<<'html' - - value - - html], + [ + 'page-with-component-with-old-syntax', + <<<'html' + my-component + html, + ], + [ + 'page-with-component-with-new-syntax', + <<<'html' + my-component + html, + ], + [ + 'page-with-component-with-subcomponent', + <<<'html' +
+ my-component-with-subcomponent + subcomponent +
+ html, + ], + [ + 'page-with-multiple-components', + <<<'html' +
    +
  • my-component
  • +
  • my-component
  • +
+ html, + ], + [ + 'page-with-functional-component', + <<<'html' + my-functional-component + html, + ], + [ + 'page-with-class-component', + <<<'html' + my-class-component + html, + ], + [ + 'page-with-class-component-with-styles', + <<<'html' + + my-class-component-with-styles + + + + html, + ], + [ + 'page-with-class-component-with-scripts', + <<<'html' + my-class-component-with-scripts + + + html, + ], + ]; } @@ -264,4 +322,9 @@ class ViewTest extends TestCase { return str_replace(["\r", "\n"], '', $subject); } + + private static function removeIndentation(string $subject): string + { + return str_replace(' ', '', $subject); + } } diff --git a/tests/views/components/my-class-component-with-scripts.php b/tests/views/components/my-class-component-with-scripts.php new file mode 100644 index 0000000..280307e --- /dev/null +++ b/tests/views/components/my-class-component-with-scripts.php @@ -0,0 +1,24 @@ + + my-class-component-with-styles + + html; + } + + #[Override] + public function css(): string + { + return <<<'css' + .my-class-component-with-styles { + color: red; + } + css; + } +}; \ No newline at end of file diff --git a/tests/views/components/my-class-component.php b/tests/views/components/my-class-component.php new file mode 100644 index 0000000..be86579 --- /dev/null +++ b/tests/views/components/my-class-component.php @@ -0,0 +1,16 @@ + + my-component-with-subcomponent + + \ No newline at end of file diff --git a/tests/views/components/my-component.php b/tests/views/components/my-component.php index 6d4e150..e3ecf45 100644 --- a/tests/views/components/my-component.php +++ b/tests/views/components/my-component.php @@ -1 +1 @@ -value +my-component \ No newline at end of file diff --git a/tests/views/components/my-functional-component.php b/tests/views/components/my-functional-component.php new file mode 100644 index 0000000..0bff949 --- /dev/null +++ b/tests/views/components/my-functional-component.php @@ -0,0 +1,7 @@ + <<<'html' +my-functional-component +html; \ No newline at end of file diff --git a/tests/views/components/subcomponent.php b/tests/views/components/subcomponent.php new file mode 100644 index 0000000..8aca66b --- /dev/null +++ b/tests/views/components/subcomponent.php @@ -0,0 +1 @@ +subcomponent \ No newline at end of file diff --git a/tests/views/pages/page-with-class-component-with-scripts.php b/tests/views/pages/page-with-class-component-with-scripts.php new file mode 100644 index 0000000..0fcd56f --- /dev/null +++ b/tests/views/pages/page-with-class-component-with-scripts.php @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/views/pages/page-with-class-component-with-styles.php b/tests/views/pages/page-with-class-component-with-styles.php new file mode 100644 index 0000000..3ca6a35 --- /dev/null +++ b/tests/views/pages/page-with-class-component-with-styles.php @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/views/pages/page-with-class-component.php b/tests/views/pages/page-with-class-component.php new file mode 100644 index 0000000..76a7423 --- /dev/null +++ b/tests/views/pages/page-with-class-component.php @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/views/pages/page-with-component-with-new-syntax.php b/tests/views/pages/page-with-component-with-new-syntax.php index f02d75a..31ebaab 100644 --- a/tests/views/pages/page-with-component-with-new-syntax.php +++ b/tests/views/pages/page-with-component-with-new-syntax.php @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/tests/views/pages/page-with-component-with-old-syntax.php b/tests/views/pages/page-with-component-with-old-syntax.php index ff5cf50..1aa9702 100644 --- a/tests/views/pages/page-with-component-with-old-syntax.php +++ b/tests/views/pages/page-with-component-with-old-syntax.php @@ -1,3 +1 @@ - - render('components/my-component') ?> - +render('components/my-component') ?> \ No newline at end of file diff --git a/tests/views/pages/page-with-component-with-subcomponent.php b/tests/views/pages/page-with-component-with-subcomponent.php new file mode 100644 index 0000000..d032809 --- /dev/null +++ b/tests/views/pages/page-with-component-with-subcomponent.php @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/views/pages/page-with-functional-component.php b/tests/views/pages/page-with-functional-component.php new file mode 100644 index 0000000..07fcec2 --- /dev/null +++ b/tests/views/pages/page-with-functional-component.php @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/views/pages/page-with-multiple-components.php b/tests/views/pages/page-with-multiple-components.php new file mode 100644 index 0000000..f7836b4 --- /dev/null +++ b/tests/views/pages/page-with-multiple-components.php @@ -0,0 +1,7 @@ +
    + +
  • + +
  • + +
\ No newline at end of file From bc86e7a29803e987c7da025b0e1e60c153fcec98 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Wed, 24 Jun 2026 02:08:11 -0400 Subject: [PATCH 18/30] 3. renders self close class components that extends another class component --- tests/ViewTest.php | 7 ++++++- .../components/another-class-component.php | 19 +++++++++++++++++++ ...t-that-extends-another-class-component.php | 16 ++++++++++++++++ .../my-class-component-with-scripts.php | 4 +--- tests/views/components/my-class-component.php | 4 +--- .../components/my-functional-component.php | 4 +--- ...t-that-extends-another-class-component.php | 1 + 7 files changed, 45 insertions(+), 10 deletions(-) create mode 100644 tests/views/components/another-class-component.php create mode 100644 tests/views/components/my-class-component-that-extends-another-class-component.php create mode 100644 tests/views/pages/page-with-class-component-that-extends-another-class-component.php diff --git a/tests/ViewTest.php b/tests/ViewTest.php index b649a56..a6abff5 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -314,7 +314,12 @@ class ViewTest extends TestCase html, ], - + [ + 'page-with-class-component-that-extends-another-class-component', + <<<'html' + another-class-component extended by my-class-component-that-extends-another-class-component + html, + ], ]; } diff --git a/tests/views/components/another-class-component.php b/tests/views/components/another-class-component.php new file mode 100644 index 0000000..2731373 --- /dev/null +++ b/tests/views/components/another-class-component.php @@ -0,0 +1,19 @@ + <<<'html' -my-functional-component -html; \ No newline at end of file +return fn (): string => 'my-functional-component'; \ No newline at end of file diff --git a/tests/views/pages/page-with-class-component-that-extends-another-class-component.php b/tests/views/pages/page-with-class-component-that-extends-another-class-component.php new file mode 100644 index 0000000..2fef30a --- /dev/null +++ b/tests/views/pages/page-with-class-component-that-extends-another-class-component.php @@ -0,0 +1 @@ + \ No newline at end of file From 538c9d32ae97d2aad222cdd09b229fecbfa101a6 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Wed, 24 Jun 2026 02:44:36 -0400 Subject: [PATCH 19/30] 4. renders self close components with one prop --- flight/template/View.php | 21 +++++++++++++++++-- tests/ViewTest.php | 10 +++++++++ tests/views/components/greeting.php | 1 + .../page-with-component-with-one-prop.php | 5 +++++ 4 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 tests/views/components/greeting.php create mode 100644 tests/views/pages/page-with-component-with-one-prop.php diff --git a/flight/template/View.php b/flight/template/View.php index 06a4416..a7212db 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -157,11 +157,28 @@ class View $view = ob_get_clean(); } - preg_match('/[a-z-]+)\s*\/>/', $view, $matches); + preg_match( + '/[a-z-]+)\s*(?[a-z]+="[a-z-A-Z]+")*\s*\/>/', + $view, + $matches, + ); if ($matches) { $tag = $matches[0]; - $component = $this->fetch("components/{$matches['component']}"); + $component = $matches['component']; + $props = $matches['props'] ?? ''; + + preg_match( + '/^(?[a-z]+)="(?[a-zA-Z]+)"$/', + $props, + $matches, + ); + + $props = $matches + ? [$matches['name'] => $matches['value']] + : []; + + $component = $this->fetch("components/$component", $props); $view = str_replace($tag, $component, $view); } diff --git a/tests/ViewTest.php b/tests/ViewTest.php index a6abff5..3f24028 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -320,6 +320,16 @@ class ViewTest extends TestCase another-class-component extended by my-class-component-that-extends-another-class-component html, ], + [ + 'page-with-component-with-one-prop', + <<<'html' + + +

Hello, James

+ + + html, + ], ]; } diff --git a/tests/views/components/greeting.php b/tests/views/components/greeting.php new file mode 100644 index 0000000..3a8fd49 --- /dev/null +++ b/tests/views/components/greeting.php @@ -0,0 +1 @@ +

Hello,

\ No newline at end of file diff --git a/tests/views/pages/page-with-component-with-one-prop.php b/tests/views/pages/page-with-component-with-one-prop.php new file mode 100644 index 0000000..7244435 --- /dev/null +++ b/tests/views/pages/page-with-component-with-one-prop.php @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file From 38d4458e96a75b1b07d246b72f209c15c76dc223 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Wed, 24 Jun 2026 02:48:31 -0400 Subject: [PATCH 20/30] 5. views can repass props --- tests/ViewTest.php | 22 ++++++++++++++++--- .../page-with-component-with-one-prop.php | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/ViewTest.php b/tests/ViewTest.php index 3f24028..bb5c47e 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -235,11 +235,14 @@ class ViewTest extends TestCase } /** @dataProvider pagesDataProvider */ - public function testItRendersComponent(string $page, string $expected): void - { + public function testItRendersComponent( + string $page, + string $expected, + array $props = [] + ): void { $view = new View(__DIR__ . '/views'); $view->preserveVars = false; - $actual = $view->fetch("pages/$page"); + $actual = $view->fetch("pages/$page", $props); self::assertSame( self::removeIndentation(self::removeLineEndings($expected)), @@ -329,7 +332,20 @@ class ViewTest extends TestCase html, + ['name' => 'James'], + ], + [ + 'page-with-component-with-one-prop', + <<<'html' + + +

Hello, Victoria

+ + + html, + ['name' => 'Victoria'], ], + ]; } diff --git a/tests/views/pages/page-with-component-with-one-prop.php b/tests/views/pages/page-with-component-with-one-prop.php index 7244435..9839e1a 100644 --- a/tests/views/pages/page-with-component-with-one-prop.php +++ b/tests/views/pages/page-with-component-with-one-prop.php @@ -1,5 +1,5 @@ - + \ No newline at end of file From 5cdc2e85c0f5425d18d6ac7aae4992427faf36b6 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Wed, 24 Jun 2026 03:17:59 -0400 Subject: [PATCH 21/30] 6. renders self close components with many props --- flight/template/View.php | 22 ++++++++++++++----- tests/ViewTest.php | 12 +++++++++- .../components/greeting-with-two-props.php | 1 + .../page-with-component-with-two-props.php | 5 +++++ 4 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 tests/views/components/greeting-with-two-props.php create mode 100644 tests/views/pages/page-with-component-with-two-props.php diff --git a/flight/template/View.php b/flight/template/View.php index a7212db..d47eeff 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -158,7 +158,7 @@ class View } preg_match( - '/[a-z-]+)\s*(?[a-z]+="[a-z-A-Z]+")*\s*\/>/', + '/[a-z-]+)\s*(?([a-z]+="[a-zA-Z]+"\s*)*)?\s*\/>/', $view, $matches, ); @@ -168,15 +168,25 @@ class View $component = $matches['component']; $props = $matches['props'] ?? ''; - preg_match( - '/^(?[a-z]+)="(?[a-zA-Z]+)"$/', + preg_match_all( + '/(?[a-z]+)="(?[a-zA-Z]+)"/', $props, $matches, ); - $props = $matches - ? [$matches['name'] => $matches['value']] - : []; + $matches = array_filter($matches); + + if ($matches) { + $props = []; + + foreach (array_keys($matches[0]) as $index) { + $name = $matches['name'][$index]; + $value = $matches['value'][$index]; + $props[$name] = $value; + } + } else { + $props = []; + } $component = $this->fetch("components/$component", $props); $view = str_replace($tag, $component, $view); diff --git a/tests/ViewTest.php b/tests/ViewTest.php index bb5c47e..178c22c 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -345,7 +345,17 @@ class ViewTest extends TestCase html, ['name' => 'Victoria'], ], - + [ + 'page-with-component-with-two-props', + <<<'html' + + +

Hello, Astronaut Victoria

+ + + html, + ['name' => 'Victoria', 'occupation' => 'Astronaut'], + ], ]; } diff --git a/tests/views/components/greeting-with-two-props.php b/tests/views/components/greeting-with-two-props.php new file mode 100644 index 0000000..24045d6 --- /dev/null +++ b/tests/views/components/greeting-with-two-props.php @@ -0,0 +1 @@ +

Hello,

\ No newline at end of file diff --git a/tests/views/pages/page-with-component-with-two-props.php b/tests/views/pages/page-with-component-with-two-props.php new file mode 100644 index 0000000..6cb9045 --- /dev/null +++ b/tests/views/pages/page-with-component-with-two-props.php @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file From 8280d4ddaf0291f2273bc8b7d6b2cf4b419ba20c Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Thu, 25 Jun 2026 12:37:56 -0400 Subject: [PATCH 22/30] allow to change the prefix --- flight/template/View.php | 11 +++++++--- tests/ViewTest.php | 21 +++++++++++++++++++ ...page-with-component-with-custom-prefix.php | 1 + 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 tests/views/pages/page-with-component-with-custom-prefix.php diff --git a/flight/template/View.php b/flight/template/View.php index d47eeff..1a3dc01 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -27,10 +27,15 @@ class View /** @var array View variables */ protected array $vars = []; + private string $componentPrefix; + /** @param string $path Path to templates directory */ - public function __construct(string $path = '.') - { + public function __construct( + string $path = ".", + string $componentPrefix = 'f' + ) { $this->path = $path; + $this->componentPrefix = $componentPrefix; } /** @@ -158,7 +163,7 @@ class View } preg_match( - '/[a-z-]+)\s*(?([a-z]+="[a-zA-Z]+"\s*)*)?\s*\/>/', + "/<$this->componentPrefix-(?[a-z-]+)\s*(?([a-z]+=\"[a-zA-Z]+\"\s*)*)?\s*\/>/", $view, $matches, ); diff --git a/tests/ViewTest.php b/tests/ViewTest.php index 178c22c..d85b5f6 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -359,6 +359,27 @@ class ViewTest extends TestCase ]; } + /** @dataProvider prefixesDataProvider */ + public function testRenderComponentsWithDifferentPrefixes(string $prefix): void + { + $view = new View(__DIR__ . '/views', $prefix); + $view->preserveVars = false; + $actual = $view->fetch('pages/page-with-component-with-custom-prefix', compact('prefix')); + $expected = 'my-component'; + + self::assertSame( + self::removeIndentation(self::removeLineEndings($expected)), + self::removeIndentation(self::removeLineEndings($actual)), + ); + } + + public static function prefixesDataProvider(): array + { + return [ + ['x'], + ]; + } + private static function removeLineEndings(string $subject): string { return str_replace(["\r", "\n"], '', $subject); diff --git a/tests/views/pages/page-with-component-with-custom-prefix.php b/tests/views/pages/page-with-component-with-custom-prefix.php new file mode 100644 index 0000000..46b3dce --- /dev/null +++ b/tests/views/pages/page-with-component-with-custom-prefix.php @@ -0,0 +1 @@ +<-my-component /> \ No newline at end of file From 5c4b9b985674ea0ac80fa02ad492d33e512da8b6 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Thu, 25 Jun 2026 13:48:14 -0400 Subject: [PATCH 23/30] allow to change the components path --- flight/template/View.php | 29 ++++++++++++++----- tests/ViewTest.php | 13 +++++++++ .../my-component-from-another-path.php | 1 + .../page-with-component-from-another-path.php | 1 + 4 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 tests/components/my-component-from-another-path.php create mode 100644 tests/views/pages/page-with-component-from-another-path.php diff --git a/flight/template/View.php b/flight/template/View.php index 1a3dc01..067d008 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -28,14 +28,24 @@ class View protected array $vars = []; private string $componentPrefix; + private string $componentsPath; - /** @param string $path Path to templates directory */ + /** + * @param string $path Path to templates directory + * @param string $componentPrefix Prefix for component tags. + * For example, if the prefix is `f`, then a component tag would look like `` + * @param string $componentsPath Path to components directory. + * If is a relative path, it will be relative to the `$path` property. + * **We recomment that you always use absolute paths for explicitness**. + */ public function __construct( string $path = ".", - string $componentPrefix = 'f' + string $componentPrefix = 'f', + string $componentsPath = 'components' ) { $this->path = $path; $this->componentPrefix = $componentPrefix; + $this->componentsPath = $componentsPath; } /** @@ -68,7 +78,7 @@ class View /** * Checks if a template variable is set - * @return bool If key exists + * @return bool If key exists and is not `null` */ public function has(string $key): bool { @@ -193,7 +203,7 @@ class View $props = []; } - $component = $this->fetch("components/$component", $props); + $component = $this->fetch("$this->componentsPath/$component", $props); $view = str_replace($tag, $component, $view); } @@ -217,14 +227,17 @@ class View */ public function getTemplate(string $file): string { - if (!empty($this->extension) && (substr($file, -1 * strlen($this->extension)) !== $this->extension)) { + $fileDoesNotHaveExtension = substr($file, -strlen($this->extension)) !== $this->extension; + + if ($fileDoesNotHaveExtension) { $file .= $this->extension; } - $is_windows = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; + $isLinuxAbsolutePath = $file[0] === '/'; + $isWindowsAbsolutePath = PHP_OS === 'WINNT' && $file[1] === ':'; - if ((substr($file, 0, 1) === '/') || ($is_windows && substr($file, 1, 1) === ':')) { - return $file; + if ($isLinuxAbsolutePath || $isWindowsAbsolutePath) { + return $this::normalizePath($file); } return $this::normalizePath("$this->path/$file"); diff --git a/tests/ViewTest.php b/tests/ViewTest.php index d85b5f6..69f1f61 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -250,6 +250,19 @@ class ViewTest extends TestCase ); } + public function testItRendersComponentFromAnotherPath(): void + { + $view = new View(__DIR__ . '/views', 'f', __DIR__ . '/components'); + $view->preserveVars = false; + $actual = $view->fetch('pages/page-with-component-from-another-path'); + $expected = 'my-component-from-another-path'; + + self::assertSame( + self::removeIndentation(self::removeLineEndings($expected)), + self::removeIndentation(self::removeLineEndings($actual)), + ); + } + public static function pagesDataProvider(): array { return [ diff --git a/tests/components/my-component-from-another-path.php b/tests/components/my-component-from-another-path.php new file mode 100644 index 0000000..b234a7d --- /dev/null +++ b/tests/components/my-component-from-another-path.php @@ -0,0 +1 @@ +my-component-from-another-path diff --git a/tests/views/pages/page-with-component-from-another-path.php b/tests/views/pages/page-with-component-from-another-path.php new file mode 100644 index 0000000..7ab559f --- /dev/null +++ b/tests/views/pages/page-with-component-from-another-path.php @@ -0,0 +1 @@ + \ No newline at end of file From 5076b266b3f1da4b5d10832bf77086485146c2b3 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Thu, 25 Jun 2026 13:54:40 -0400 Subject: [PATCH 24/30] allow custom indentations in tests (> 1 space) --- tests/ViewTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ViewTest.php b/tests/ViewTest.php index 69f1f61..e855fff 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -400,6 +400,6 @@ class ViewTest extends TestCase private static function removeIndentation(string $subject): string { - return str_replace(' ', '', $subject); + return preg_replace('/\s{2,}/', '', $subject); } } From 52ec6b88031b1fc0dc97d4d415b17a1237402035 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Thu, 25 Jun 2026 14:43:25 -0400 Subject: [PATCH 25/30] allow to render different components --- flight/template/View.php | 26 ++++++++++--------- tests/ViewTest.php | 8 ++++++ .../page-with-three-different-components.php | 3 +++ 3 files changed, 25 insertions(+), 12 deletions(-) create mode 100644 tests/views/pages/page-with-three-different-components.php diff --git a/flight/template/View.php b/flight/template/View.php index 067d008..e3f8993 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -172,31 +172,33 @@ class View $view = ob_get_clean(); } - preg_match( + preg_match_all( "/<$this->componentPrefix-(?[a-z-]+)\s*(?([a-z]+=\"[a-zA-Z]+\"\s*)*)?\s*\/>/", $view, - $matches, + $tagsMatches, ); - if ($matches) { - $tag = $matches[0]; - $component = $matches['component']; - $props = $matches['props'] ?? ''; + $tagsMatches = array_filter($tagsMatches); + + foreach ($tagsMatches[0] ?? [] as $tagIndex => $match) { + $tag = $match; + $component = $tagsMatches['component'][$tagIndex]; + $props = $tagsMatches['props'][$tagIndex] ?? ''; preg_match_all( '/(?[a-z]+)="(?[a-zA-Z]+)"/', $props, - $matches, + $propsMatches, ); - $matches = array_filter($matches); + $propsMatches = array_filter($propsMatches); - if ($matches) { + if ($propsMatches) { $props = []; - foreach (array_keys($matches[0]) as $index) { - $name = $matches['name'][$index]; - $value = $matches['value'][$index]; + foreach (array_keys($propsMatches[0]) as $propIndex) { + $name = $propsMatches['name'][$propIndex]; + $value = $propsMatches['value'][$propIndex]; $props[$name] = $value; } } else { diff --git a/tests/ViewTest.php b/tests/ViewTest.php index e855fff..34ee4d1 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -369,6 +369,14 @@ class ViewTest extends TestCase html, ['name' => 'Victoria', 'occupation' => 'Astronaut'], ], + [ + 'page-with-three-different-components', + <<<'html' + my-component + my-functional-component + my-class-component + html, + ], ]; } diff --git a/tests/views/pages/page-with-three-different-components.php b/tests/views/pages/page-with-three-different-components.php new file mode 100644 index 0000000..a17fe68 --- /dev/null +++ b/tests/views/pages/page-with-three-different-components.php @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 8c88b9d94a3eed9ceb8c5ff135a64a9f9858ae60 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Thu, 25 Jun 2026 14:56:39 -0400 Subject: [PATCH 26/30] allow props which values contains spaces and numbers --- flight/template/View.php | 4 ++-- tests/ViewTest.php | 7 +++++++ ...t-with-prop-which-value-contains-spaces-and-numbers.php | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 tests/views/pages/page-with-component-with-prop-which-value-contains-spaces-and-numbers.php diff --git a/flight/template/View.php b/flight/template/View.php index e3f8993..10a55b2 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -173,7 +173,7 @@ class View } preg_match_all( - "/<$this->componentPrefix-(?[a-z-]+)\s*(?([a-z]+=\"[a-zA-Z]+\"\s*)*)?\s*\/>/", + "/<$this->componentPrefix-(?[a-z-]+)\s*(?([a-z]+=\"[^\"]+\"\s*)*)?\s*\/>/", $view, $tagsMatches, ); @@ -186,7 +186,7 @@ class View $props = $tagsMatches['props'][$tagIndex] ?? ''; preg_match_all( - '/(?[a-z]+)="(?[a-zA-Z]+)"/', + '/(?[a-z]+)="(?[^"]+)"/', $props, $propsMatches, ); diff --git a/tests/ViewTest.php b/tests/ViewTest.php index 34ee4d1..39c4a6f 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -377,6 +377,13 @@ class ViewTest extends TestCase my-class-component html, ], + [ + 'page-with-component-with-prop-which-value-contains-spaces-and-numbers', + <<<'html' +

Hello, Constantine 1st the Great

+ html, + ['name' => 'Constantine 1st the Great'], + ], ]; } diff --git a/tests/views/pages/page-with-component-with-prop-which-value-contains-spaces-and-numbers.php b/tests/views/pages/page-with-component-with-prop-which-value-contains-spaces-and-numbers.php new file mode 100644 index 0000000..622b833 --- /dev/null +++ b/tests/views/pages/page-with-component-with-prop-which-value-contains-spaces-and-numbers.php @@ -0,0 +1 @@ + \ No newline at end of file From 7acf6c00b93dd37ce34d6a2bc5018410ca33fb17 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Thu, 25 Jun 2026 16:37:52 -0400 Subject: [PATCH 27/30] fix styles and scripts duplication --- flight/template/View.php | 19 +- tests/ViewTest.php | 229 ++++++++++-------- ...mponents-with-one-style-and-script-tag.php | 5 + 3 files changed, 145 insertions(+), 108 deletions(-) create mode 100644 tests/views/pages/page-two-same-components-with-one-style-and-script-tag.php diff --git a/flight/template/View.php b/flight/template/View.php index 10a55b2..1db48ef 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -149,20 +149,27 @@ class View $css = $component->css(); $js = $component->js(); - if ($css) { + static $styles = []; + static $scripts = []; + + if ($css && !array_key_exists($template, $styles)) { $view .= << $css html; + + $styles[$template] = true; } - if ($js) { + if ($js && !array_key_exists($template, $scripts)) { $view .= << $js html; + + $scripts[$template] = true; } ob_end_clean(); @@ -206,7 +213,13 @@ class View } $component = $this->fetch("$this->componentsPath/$component", $props); - $view = str_replace($tag, $component, $view); + $tagPosition = strpos($view, $tag); + + if ($tagPosition === false) { + continue; + } + + $view = substr_replace($view, $component, $tagPosition, strlen($tag)); } return $view; diff --git a/tests/ViewTest.php b/tests/ViewTest.php index 39c4a6f..520140a 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -266,123 +266,142 @@ class ViewTest extends TestCase public static function pagesDataProvider(): array { return [ + // [ + // 'page-with-component-with-old-syntax', + // <<<'html' + // my-component + // html, + // ], + // [ + // 'page-with-component-with-new-syntax', + // <<<'html' + // my-component + // html, + // ], + // [ + // 'page-with-component-with-subcomponent', + // <<<'html' + //
+ // my-component-with-subcomponent + // subcomponent + //
+ // html, + // ], + // [ + // 'page-with-multiple-components', + // <<<'html' + //
    + //
  • my-component
  • + //
  • my-component
  • + //
+ // html, + // ], + // [ + // 'page-with-functional-component', + // <<<'html' + // my-functional-component + // html, + // ], + // [ + // 'page-with-class-component', + // <<<'html' + // my-class-component + // html, + // ], + // [ + // 'page-with-class-component-with-styles', + // <<<'html' + // + // my-class-component-with-styles + // + + // + // html, + // ], + // [ + // 'page-with-class-component-with-scripts', + // <<<'html' + // my-class-component-with-scripts + + // + // html, + // ], + // [ + // 'page-with-class-component-that-extends-another-class-component', + // <<<'html' + // another-class-component extended by my-class-component-that-extends-another-class-component + // html, + // ], + // [ + // 'page-with-component-with-one-prop', + // <<<'html' + // + // + //

Hello, James

+ // + // + // html, + // ['name' => 'James'], + // ], + // [ + // 'page-with-component-with-one-prop', + // <<<'html' + // + // + //

Hello, Victoria

+ // + // + // html, + // ['name' => 'Victoria'], + // ], + // [ + // 'page-with-component-with-two-props', + // <<<'html' + // + // + //

Hello, Astronaut Victoria

+ // + // + // html, + // ['name' => 'Victoria', 'occupation' => 'Astronaut'], + // ], + // [ + // 'page-with-three-different-components', + // <<<'html' + // my-component + // my-functional-component + // my-class-component + // html, + // ], + // [ + // 'page-with-component-with-prop-which-value-contains-spaces-and-numbers', + // <<<'html' + //

Hello, Constantine 1st the Great

+ // html, + // ['name' => 'Constantine 1st the Great'], + // ], [ - 'page-with-component-with-old-syntax', - <<<'html' - my-component - html, - ], - [ - 'page-with-component-with-new-syntax', - <<<'html' - my-component - html, - ], - [ - 'page-with-component-with-subcomponent', - <<<'html' -
- my-component-with-subcomponent - subcomponent -
- html, - ], - [ - 'page-with-multiple-components', - <<<'html' -
    -
  • my-component
  • -
  • my-component
  • -
- html, - ], - [ - 'page-with-functional-component', - <<<'html' - my-functional-component - html, - ], - [ - 'page-with-class-component', - <<<'html' - my-class-component - html, - ], - [ - 'page-with-class-component-with-styles', + 'page-two-same-components-with-one-style-and-script-tag', <<<'html' my-class-component-with-styles - - html, - ], - [ - 'page-with-class-component-with-scripts', - <<<'html' + + my-class-component-with-styles + my-class-component-with-scripts - - html, - ], - [ - 'page-with-class-component-that-extends-another-class-component', - <<<'html' - another-class-component extended by my-class-component-that-extends-another-class-component - html, - ], - [ - 'page-with-component-with-one-prop', - <<<'html' - - -

Hello, James

- - - html, - ['name' => 'James'], - ], - [ - 'page-with-component-with-one-prop', - <<<'html' - - -

Hello, Victoria

- - - html, - ['name' => 'Victoria'], - ], - [ - 'page-with-component-with-two-props', - <<<'html' - - -

Hello, Astronaut Victoria

- - - html, - ['name' => 'Victoria', 'occupation' => 'Astronaut'], - ], - [ - 'page-with-three-different-components', - <<<'html' - my-component - my-functional-component - my-class-component - html, - ], - [ - 'page-with-component-with-prop-which-value-contains-spaces-and-numbers', - <<<'html' -

Hello, Constantine 1st the Great

- html, - ['name' => 'Constantine 1st the Great'], + my-class-component-with-scripts + html, ], ]; } diff --git a/tests/views/pages/page-two-same-components-with-one-style-and-script-tag.php b/tests/views/pages/page-two-same-components-with-one-style-and-script-tag.php new file mode 100644 index 0000000..8dfdf97 --- /dev/null +++ b/tests/views/pages/page-two-same-components-with-one-style-and-script-tag.php @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file From 8c277dadd6f05f36f2dff719695e911e5f4f647a Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Thu, 25 Jun 2026 16:48:54 -0400 Subject: [PATCH 28/30] Fix component style/script dedupe leakage Use per-render instance state for component style/script dedupe in View::fetch so assets are deduped within a render without leaking across tests or subsequent renders. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- flight/template/View.php | 173 +++++++++++++++------------- tests/ViewTest.php | 236 +++++++++++++++++++-------------------- 2 files changed, 211 insertions(+), 198 deletions(-) diff --git a/flight/template/View.php b/flight/template/View.php index 1db48ef..8983db5 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -29,6 +29,11 @@ class View private string $componentPrefix; private string $componentsPath; + private int $fetchDepth = 0; + /** @var array */ + private array $styles = []; + /** @var array */ + private array $scripts = []; /** * @param string $path Path to templates directory @@ -119,110 +124,118 @@ class View */ public function fetch(string $file, ?array $data = null): string { - $template = $this->getTemplate($file); - - if (!$this->exists($file)) { - throw new Exception("Template file not found: $template."); + if ($this->fetchDepth === 0) { + $this->styles = []; + $this->scripts = []; } - extract($this->vars); + $this->fetchDepth++; - if (is_array($data)) { - extract($data); + try { + $template = $this->getTemplate($file); - if ($this->preserveVars) { - $this->vars += $data; + if (!$this->exists($file)) { + throw new Exception("Template file not found: $template."); } - } - - ob_start(); - $component = include $template; - - switch (true) { - case is_callable($component): - $view = $component(); - ob_end_clean(); - - break; - case $component instanceof Component: - $view = $component->html(); - $css = $component->css(); - $js = $component->js(); - static $styles = []; - static $scripts = []; + extract($this->vars); - if ($css && !array_key_exists($template, $styles)) { - $view .= << - $css - - html; + if (is_array($data)) { + extract($data); - $styles[$template] = true; + if ($this->preserveVars) { + $this->vars += $data; } + } - if ($js && !array_key_exists($template, $scripts)) { - $view .= << - $js - - html; - - $scripts[$template] = true; - } + ob_start(); + $component = include $template; + + switch (true) { + case is_callable($component): + $view = $component(); + ob_end_clean(); + + break; + case $component instanceof Component: + $view = $component->html(); + $css = $component->css(); + $js = $component->js(); + + if ($css && !array_key_exists($template, $this->styles)) { + $view .= << + $css + + html; + + $this->styles[$template] = true; + } + + if ($js && !array_key_exists($template, $this->scripts)) { + $view .= << + $js + + html; + + $this->scripts[$template] = true; + } + + ob_end_clean(); + + break; + default: + $view = ob_get_clean(); + } - ob_end_clean(); + preg_match_all( + "/<$this->componentPrefix-(?[a-z-]+)\s*(?([a-z]+=\"[^\"]+\"\s*)*)?\s*\/>/", + $view, + $tagsMatches, + ); - break; - default: - $view = ob_get_clean(); - } + $tagsMatches = array_filter($tagsMatches); - preg_match_all( - "/<$this->componentPrefix-(?[a-z-]+)\s*(?([a-z]+=\"[^\"]+\"\s*)*)?\s*\/>/", - $view, - $tagsMatches, - ); + foreach ($tagsMatches[0] ?? [] as $tagIndex => $match) { + $tag = $match; + $component = $tagsMatches['component'][$tagIndex]; + $props = $tagsMatches['props'][$tagIndex] ?? ''; - $tagsMatches = array_filter($tagsMatches); + preg_match_all( + '/(?[a-z]+)="(?[^"]+)"/', + $props, + $propsMatches, + ); - foreach ($tagsMatches[0] ?? [] as $tagIndex => $match) { - $tag = $match; - $component = $tagsMatches['component'][$tagIndex]; - $props = $tagsMatches['props'][$tagIndex] ?? ''; + $propsMatches = array_filter($propsMatches); - preg_match_all( - '/(?[a-z]+)="(?[^"]+)"/', - $props, - $propsMatches, - ); + if ($propsMatches) { + $props = []; - $propsMatches = array_filter($propsMatches); + foreach (array_keys($propsMatches[0]) as $propIndex) { + $name = $propsMatches['name'][$propIndex]; + $value = $propsMatches['value'][$propIndex]; + $props[$name] = $value; + } + } else { + $props = []; + } - if ($propsMatches) { - $props = []; + $component = $this->fetch("$this->componentsPath/$component", $props); + $tagPosition = strpos($view, $tag); - foreach (array_keys($propsMatches[0]) as $propIndex) { - $name = $propsMatches['name'][$propIndex]; - $value = $propsMatches['value'][$propIndex]; - $props[$name] = $value; + if ($tagPosition === false) { + continue; } - } else { - $props = []; - } - $component = $this->fetch("$this->componentsPath/$component", $props); - $tagPosition = strpos($view, $tag); - - if ($tagPosition === false) { - continue; + $view = substr_replace($view, $component, $tagPosition, strlen($tag)); } - $view = substr_replace($view, $component, $tagPosition, strlen($tag)); + return $view; + } finally { + $this->fetchDepth--; } - - return $view; } /** diff --git a/tests/ViewTest.php b/tests/ViewTest.php index 520140a..ce37cd6 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -266,124 +266,124 @@ class ViewTest extends TestCase public static function pagesDataProvider(): array { return [ - // [ - // 'page-with-component-with-old-syntax', - // <<<'html' - // my-component - // html, - // ], - // [ - // 'page-with-component-with-new-syntax', - // <<<'html' - // my-component - // html, - // ], - // [ - // 'page-with-component-with-subcomponent', - // <<<'html' - //
- // my-component-with-subcomponent - // subcomponent - //
- // html, - // ], - // [ - // 'page-with-multiple-components', - // <<<'html' - //
    - //
  • my-component
  • - //
  • my-component
  • - //
- // html, - // ], - // [ - // 'page-with-functional-component', - // <<<'html' - // my-functional-component - // html, - // ], - // [ - // 'page-with-class-component', - // <<<'html' - // my-class-component - // html, - // ], - // [ - // 'page-with-class-component-with-styles', - // <<<'html' - // - // my-class-component-with-styles - // - - // - // html, - // ], - // [ - // 'page-with-class-component-with-scripts', - // <<<'html' - // my-class-component-with-scripts - - // - // html, - // ], - // [ - // 'page-with-class-component-that-extends-another-class-component', - // <<<'html' - // another-class-component extended by my-class-component-that-extends-another-class-component - // html, - // ], - // [ - // 'page-with-component-with-one-prop', - // <<<'html' - // - // - //

Hello, James

- // - // - // html, - // ['name' => 'James'], - // ], - // [ - // 'page-with-component-with-one-prop', - // <<<'html' - // - // - //

Hello, Victoria

- // - // - // html, - // ['name' => 'Victoria'], - // ], - // [ - // 'page-with-component-with-two-props', - // <<<'html' - // - // - //

Hello, Astronaut Victoria

- // - // - // html, - // ['name' => 'Victoria', 'occupation' => 'Astronaut'], - // ], - // [ - // 'page-with-three-different-components', - // <<<'html' - // my-component - // my-functional-component - // my-class-component - // html, - // ], - // [ - // 'page-with-component-with-prop-which-value-contains-spaces-and-numbers', - // <<<'html' - //

Hello, Constantine 1st the Great

- // html, - // ['name' => 'Constantine 1st the Great'], - // ], + [ + 'page-with-component-with-old-syntax', + <<<'html' + my-component + html, + ], + [ + 'page-with-component-with-new-syntax', + <<<'html' + my-component + html, + ], + [ + 'page-with-component-with-subcomponent', + <<<'html' +
+ my-component-with-subcomponent + subcomponent +
+ html, + ], + [ + 'page-with-multiple-components', + <<<'html' +
    +
  • my-component
  • +
  • my-component
  • +
+ html, + ], + [ + 'page-with-functional-component', + <<<'html' + my-functional-component + html, + ], + [ + 'page-with-class-component', + <<<'html' + my-class-component + html, + ], + [ + 'page-with-class-component-with-styles', + <<<'html' + + my-class-component-with-styles + + + + html, + ], + [ + 'page-with-class-component-with-scripts', + <<<'html' + my-class-component-with-scripts + + + html, + ], + [ + 'page-with-class-component-that-extends-another-class-component', + <<<'html' + another-class-component extended by my-class-component-that-extends-another-class-component + html, + ], + [ + 'page-with-component-with-one-prop', + <<<'html' + + +

Hello, James

+ + + html, + ['name' => 'James'], + ], + [ + 'page-with-component-with-one-prop', + <<<'html' + + +

Hello, Victoria

+ + + html, + ['name' => 'Victoria'], + ], + [ + 'page-with-component-with-two-props', + <<<'html' + + +

Hello, Astronaut Victoria

+ + + html, + ['name' => 'Victoria', 'occupation' => 'Astronaut'], + ], + [ + 'page-with-three-different-components', + <<<'html' + my-component + my-functional-component + my-class-component + html, + ], + [ + 'page-with-component-with-prop-which-value-contains-spaces-and-numbers', + <<<'html' +

Hello, Constantine 1st the Great

+ html, + ['name' => 'Constantine 1st the Great'], + ], [ 'page-two-same-components-with-one-style-and-script-tag', <<<'html' From dd238319dad41e63de9ae2dedf2cf334bfd6f5c1 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Thu, 25 Jun 2026 17:05:46 -0400 Subject: [PATCH 29/30] Add component prop-passing coverage Extend View component rendering tests to cover functional and class components receiving props via variadic and individual parameters, and update View callable invocation to map template data into callable arguments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- flight/template/View.php | 40 ++++++++++++++++++- tests/ViewTest.php | 21 ++++++++++ .../my-class-component-with-props.php | 22 ++++++++++ ...tional-component-with-individual-props.php | 6 +++ .../my-functional-component-with-props.php | 9 +++++ .../page-with-class-component-with-props.php | 1 + ...tional-component-with-individual-props.php | 1 + ...e-with-functional-component-with-props.php | 1 + 8 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 tests/views/components/my-class-component-with-props.php create mode 100644 tests/views/components/my-functional-component-with-individual-props.php create mode 100644 tests/views/components/my-functional-component-with-props.php create mode 100644 tests/views/pages/page-with-class-component-with-props.php create mode 100644 tests/views/pages/page-with-functional-component-with-individual-props.php create mode 100644 tests/views/pages/page-with-functional-component-with-props.php diff --git a/flight/template/View.php b/flight/template/View.php index 8983db5..453f533 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace flight\template; +use Closure; use Exception; +use ReflectionFunction; /** * The View class represents output to be displayed. It provides @@ -153,7 +155,8 @@ class View switch (true) { case is_callable($component): - $view = $component(); + $arguments = $this->getCallableArguments($component, $data); + $view = $component(...$arguments); ob_end_clean(); break; @@ -290,4 +293,39 @@ class View ): string { return str_replace(['\\', '/'], $separator, $path); } + + /** + * @param callable $component + * @param ?array $data + * @return array + */ + private function getCallableArguments(callable $component, ?array $data): array + { + if (!is_array($data) || !$data) { + return []; + } + + $arguments = []; + $remainingData = $data; + $reflection = new ReflectionFunction(Closure::fromCallable($component)); + + foreach ($reflection->getParameters() as $parameter) { + if ($parameter->isVariadic()) { + foreach ($remainingData as $value) { + $arguments[] = $value; + } + + break; + } + + $name = $parameter->getName(); + + if (array_key_exists($name, $remainingData)) { + $arguments[] = $remainingData[$name]; + unset($remainingData[$name]); + } + } + + return $arguments; + } } diff --git a/tests/ViewTest.php b/tests/ViewTest.php index ce37cd6..78c5d2d 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -308,6 +308,27 @@ class ViewTest extends TestCase my-class-component html, ], + [ + 'page-with-functional-component-with-props', + <<<'html' + functional-component-with-props: Astronaut Victoria + html, + ['name' => 'Victoria', 'occupation' => 'Astronaut'], + ], + [ + 'page-with-functional-component-with-individual-props', + <<<'html' + functional-component-with-individual-props: Astronaut Victoria + html, + ['name' => 'Victoria', 'occupation' => 'Astronaut'], + ], + [ + 'page-with-class-component-with-props', + <<<'html' + class-component-with-props: Astronaut Victoria + html, + ['name' => 'Victoria', 'occupation' => 'Astronaut'], + ], [ 'page-with-class-component-with-styles', <<<'html' diff --git a/tests/views/components/my-class-component-with-props.php b/tests/views/components/my-class-component-with-props.php new file mode 100644 index 0000000..66abcad --- /dev/null +++ b/tests/views/components/my-class-component-with-props.php @@ -0,0 +1,22 @@ +name = $name; + $this->occupation = $occupation; + } + + public function html(): string + { + return "class-component-with-props: $this->occupation $this->name"; + } +}; diff --git a/tests/views/components/my-functional-component-with-individual-props.php b/tests/views/components/my-functional-component-with-individual-props.php new file mode 100644 index 0000000..9f3bdb9 --- /dev/null +++ b/tests/views/components/my-functional-component-with-individual-props.php @@ -0,0 +1,6 @@ + + "functional-component-with-individual-props: $occupation $name"; diff --git a/tests/views/components/my-functional-component-with-props.php b/tests/views/components/my-functional-component-with-props.php new file mode 100644 index 0000000..ef8856e --- /dev/null +++ b/tests/views/components/my-functional-component-with-props.php @@ -0,0 +1,9 @@ + sprintf( + 'functional-component-with-props: %s %s', + $props[1] ?? '', + $props[0] ?? '' +); diff --git a/tests/views/pages/page-with-class-component-with-props.php b/tests/views/pages/page-with-class-component-with-props.php new file mode 100644 index 0000000..a2bd6d9 --- /dev/null +++ b/tests/views/pages/page-with-class-component-with-props.php @@ -0,0 +1 @@ + diff --git a/tests/views/pages/page-with-functional-component-with-individual-props.php b/tests/views/pages/page-with-functional-component-with-individual-props.php new file mode 100644 index 0000000..f77480a --- /dev/null +++ b/tests/views/pages/page-with-functional-component-with-individual-props.php @@ -0,0 +1 @@ + diff --git a/tests/views/pages/page-with-functional-component-with-props.php b/tests/views/pages/page-with-functional-component-with-props.php new file mode 100644 index 0000000..dddef9f --- /dev/null +++ b/tests/views/pages/page-with-functional-component-with-props.php @@ -0,0 +1 @@ + From 48035d6a925e73d486927b48e594e9eba27e03e0 Mon Sep 17 00:00:00 2001 From: fadrian06 Date: Thu, 25 Jun 2026 17:10:58 -0400 Subject: [PATCH 30/30] Support custom style/script tags in class components Allow class components to return full - html; + $view .= $this->renderComponentStyle($css); $this->styles[$template] = true; } if ($js && !array_key_exists($template, $this->scripts)) { - $view .= << - $js - - html; + $view .= $this->renderComponentScript($js); $this->scripts[$template] = true; } @@ -328,4 +320,30 @@ class View return $arguments; } + + private function renderComponentStyle(string $css): string + { + if (preg_match('/^\s*]*>.*<\/style>\s*$/is', $css) === 1) { + return $css; + } + + return << + $css + + html; + } + + private function renderComponentScript(string $js): string + { + if (preg_match('/^\s*]*>.*<\/script>\s*$/is', $js) === 1) { + return $js; + } + + return << + $js + + html; + } } diff --git a/tests/ViewTest.php b/tests/ViewTest.php index 78c5d2d..1357fb7 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -343,6 +343,19 @@ class ViewTest extends TestCase html, ], + [ + 'page-with-class-component-with-custom-style-tag', + <<<'html' + + my-class-component-with-custom-style-tag + + + html, + ], [ 'page-with-class-component-with-scripts', <<<'html' @@ -351,6 +364,15 @@ class ViewTest extends TestCase html, ], + [ + 'page-with-class-component-with-custom-script-tag', + <<<'html' + my-class-component-with-custom-script-tag + + html, + ], [ 'page-with-class-component-that-extends-another-class-component', <<<'html' diff --git a/tests/views/components/my-class-component-with-custom-script-tag.php b/tests/views/components/my-class-component-with-custom-script-tag.php new file mode 100644 index 0000000..87ee8b1 --- /dev/null +++ b/tests/views/components/my-class-component-with-custom-script-tag.php @@ -0,0 +1,22 @@ + + console.log('my-class-component-with-custom-script-tag') + + js; + } +}; diff --git a/tests/views/components/my-class-component-with-custom-style-tag.php b/tests/views/components/my-class-component-with-custom-style-tag.php new file mode 100644 index 0000000..f6c65b8 --- /dev/null +++ b/tests/views/components/my-class-component-with-custom-style-tag.php @@ -0,0 +1,28 @@ + + my-class-component-with-custom-style-tag + + html; + } + + public function css(): string + { + return <<<'css' + + css; + } +}; diff --git a/tests/views/pages/page-with-class-component-with-custom-script-tag.php b/tests/views/pages/page-with-class-component-with-custom-script-tag.php new file mode 100644 index 0000000..bec475e --- /dev/null +++ b/tests/views/pages/page-with-class-component-with-custom-script-tag.php @@ -0,0 +1 @@ + diff --git a/tests/views/pages/page-with-class-component-with-custom-style-tag.php b/tests/views/pages/page-with-class-component-with-custom-style-tag.php new file mode 100644 index 0000000..5f21043 --- /dev/null +++ b/tests/views/pages/page-with-class-component-with-custom-style-tag.php @@ -0,0 +1 @@ +