From dc092e3fa55f45c1e1b8df3cc7d000527d3a27fa Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Wed, 29 Apr 2026 13:00:44 +0530 Subject: [PATCH 1/7] Add Component based rendering with priority --- inc/Framework/ComponentLoader.php | 165 ++++++++++++ inc/helpers/custom-functions.php | 22 +- src/Components/Button/Button.php | 43 +++ src/Components/Card/Card.php | 63 +++++ .../php/inc/Framework/ComponentLoaderTest.php | 247 ++++++++++++++++++ 5 files changed, 539 insertions(+), 1 deletion(-) create mode 100644 inc/Framework/ComponentLoader.php create mode 100644 src/Components/Button/Button.php create mode 100644 src/Components/Card/Card.php create mode 100644 tests/php/inc/Framework/ComponentLoaderTest.php diff --git a/inc/Framework/ComponentLoader.php b/inc/Framework/ComponentLoader.php new file mode 100644 index 00000000..fb5faa68 --- /dev/null +++ b/inc/Framework/ComponentLoader.php @@ -0,0 +1,165 @@ + directory path. + * @param string $name Component name being resolved. + * @param array $options Options passed to render(). + */ + $paths = apply_filters( + 'elementary_theme_component_paths', + [ + 'theme' => ELEMENTARY_THEME_TEMP_DIR . '/src/Components', + ], + $name, + $options + ); + + // Order sources based on priority. + $order = self::get_source_order( $priority, $paths ); + + foreach ( $order as $source ) { + + if ( empty( $paths[ $source ] ) ) { + continue; + } + + $file = trailingslashit( $paths[ $source ] ) . $name . '/' . $name . '.php'; + + if ( file_exists( $file ) && is_readable( $file ) ) { + return $file; + } + } + + return false; + } + + /** + * Get the resolution priority. + * + * @param array $options Options array potentially containing 'priority'. + * + * @return string 'theme' or 'plugin'. + */ + private static function get_priority( array $options ): string { + + if ( ! empty( $options['priority'] ) && in_array( $options['priority'], [ 'theme', 'plugin' ], true ) ) { + return $options['priority']; + } + + /** + * Filters the default component resolution priority. + * + * @since 1.0.0 + * + * @param string $priority Default priority. Accepts 'theme' or 'plugin'. + */ + $default = apply_filters( 'elementary_theme_component_default_priority', 'theme' ); + + if ( in_array( $default, [ 'theme', 'plugin' ], true ) ) { + return $default; + } + + return 'theme'; + } + + /** + * Get the source resolution order based on priority. + * + * @param string $priority 'theme' or 'plugin'. + * @param array $paths Registered paths keyed by source. + * + * @return array Ordered list of source keys to check. + */ + private static function get_source_order( string $priority, array $paths ): array { + + $sources = array_keys( $paths ); + + if ( 'plugin' === $priority ) { + // Move 'plugin' to front if it exists. + $key = array_search( 'plugin', $sources, true ); + + if ( false !== $key ) { + unset( $sources[ $key ] ); + array_unshift( $sources, 'plugin' ); + } + } else { + // Move 'theme' to front if it exists. + $key = array_search( 'theme', $sources, true ); + + if ( false !== $key ) { + unset( $sources[ $key ] ); + array_unshift( $sources, 'theme' ); + } + } + + return array_values( $sources ); + } +} diff --git a/inc/helpers/custom-functions.php b/inc/helpers/custom-functions.php index 98e7996f..a973f7bb 100644 --- a/inc/helpers/custom-functions.php +++ b/inc/helpers/custom-functions.php @@ -7,4 +7,24 @@ declare( strict_types = 1 ); -// Define custom functions here. +use rtCamp\Theme\Elementary\Framework\ComponentLoader; + +if ( ! function_exists( 'elementary_theme_component' ) ) { + + /** + * Render a component by name. + * + * Global convenience wrapper for ComponentLoader::render(). + * + * @since 1.0.0 + * + * @param string $name Component name (e.g. 'Button', 'Card'). + * @param array $args Arguments to pass to the component. + * @param array $options Optional. Resolution options. See ComponentLoader::render(). + * + * @return void + */ + function elementary_theme_component( string $name, array $args = [], array $options = [] ): void { + ComponentLoader::render( $name, $args, $options ); + } +} diff --git a/src/Components/Button/Button.php b/src/Components/Button/Button.php new file mode 100644 index 00000000..4b0f682e --- /dev/null +++ b/src/Components/Button/Button.php @@ -0,0 +1,43 @@ +%s', + esc_url( $url ), + esc_attr( $css_class ), + esc_html( $label ) + ); +} else { + printf( + '', + esc_attr( $css_class ), + esc_html( $label ) + ); +} diff --git a/src/Components/Card/Card.php b/src/Components/Card/Card.php new file mode 100644 index 00000000..8e0cd48e --- /dev/null +++ b/src/Components/Card/Card.php @@ -0,0 +1,63 @@ + +
+ +
+ <?php echo esc_attr( $title ); ?> +
+ + +
+

+ + +

+ + + +
+ $title, + 'url' => $url, + 'class' => 'elementary-card__button', + ] + ); + ?> +
+ +
+
+assertTrue( class_exists( ComponentLoader::class ) ); + } + + /** + * Test render outputs component HTML for a known component. + */ + public function test_render_outputs_button_component(): void { + ob_start(); + ComponentLoader::render( 'Button', [ 'label' => 'Test Button' ] ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'Test Button', $output ); + $this->assertStringContainsString( 'assertStringContainsString( 'elementary-button', $output ); + } + + /** + * Test render outputs nothing for a missing component. + */ + public function test_render_missing_component_outputs_nothing(): void { + ob_start(); + ComponentLoader::render( 'NonExistentComponent', [ 'label' => 'Test' ] ); + $output = ob_get_clean(); + + $this->assertEmpty( $output ); + } + + /** + * Test Button component renders a link when url is provided. + */ + public function test_button_with_url_renders_link(): void { + ob_start(); + ComponentLoader::render( + 'Button', + [ + 'label' => 'Click Me', + 'url' => 'https://example.com', + ] + ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'assertStringContainsString( 'Click Me', $output ); + } + + /** + * Test Button component renders nothing when label is empty. + */ + public function test_button_empty_label_renders_nothing(): void { + ob_start(); + ComponentLoader::render( 'Button', [] ); + $output = ob_get_clean(); + + $this->assertEmpty( $output ); + } + + /** + * Test Card component renders with title and description. + */ + public function test_card_renders_with_content(): void { + ob_start(); + ComponentLoader::render( + 'Card', + [ + 'title' => 'Test Card', + 'description' => 'A test description.', + ] + ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'elementary-card', $output ); + $this->assertStringContainsString( 'Test Card', $output ); + $this->assertStringContainsString( 'A test description.', $output ); + } + + /** + * Test Card component renders Button when url is provided. + */ + public function test_card_with_url_renders_button(): void { + ob_start(); + ComponentLoader::render( + 'Card', + [ + 'title' => 'Linked Card', + 'url' => 'https://example.com', + ] + ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'elementary-card__button', $output ); + $this->assertStringContainsString( 'https://example.com', $output ); + } + + /** + * Test the elementary_theme_component_paths filter is applied. + */ + public function test_component_paths_filter_is_applied(): void { + $filter_called = false; + + $callback = function ( $paths ) use ( &$filter_called ) { + $filter_called = true; + return $paths; + }; + + add_filter( 'elementary_theme_component_paths', $callback ); + + ob_start(); + ComponentLoader::render( 'Button', [ 'label' => 'Test' ] ); + ob_end_clean(); + + remove_filter( 'elementary_theme_component_paths', $callback ); + + $this->assertTrue( $filter_called ); + } + + /** + * Test the elementary_theme_component_default_priority filter is applied. + */ + public function test_default_priority_filter_is_applied(): void { + $filter_called = false; + + $callback = function ( $priority ) use ( &$filter_called ) { + $filter_called = true; + return $priority; + }; + + add_filter( 'elementary_theme_component_default_priority', $callback ); + + ob_start(); + ComponentLoader::render( 'Button', [ 'label' => 'Test' ] ); + ob_end_clean(); + + remove_filter( 'elementary_theme_component_default_priority', $callback ); + + $this->assertTrue( $filter_called ); + } + + /** + * Test that priority option 'plugin' is accepted. + */ + public function test_plugin_priority_resolves_correctly(): void { + // With only theme paths registered and priority='plugin', it should + // still fall back to the theme path and render the component. + ob_start(); + ComponentLoader::render( 'Button', [ 'label' => 'Fallback' ], [ 'priority' => 'plugin' ] ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'Fallback', $output ); + } + + /** + * Test that invalid priority falls back to theme. + */ + public function test_invalid_priority_falls_back_to_theme(): void { + ob_start(); + ComponentLoader::render( 'Button', [ 'label' => 'Valid' ], [ 'priority' => 'invalid' ] ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'Valid', $output ); + } + + /** + * Test that plugin paths are checked first when priority is 'plugin'. + */ + public function test_plugin_priority_checks_plugin_path_first(): void { + + // Create a temporary plugin component directory with a custom Button. + $tmp_dir = sys_get_temp_dir() . '/elementary-test-plugin-components'; + + if ( ! is_dir( $tmp_dir . '/Button' ) ) { + mkdir( $tmp_dir . '/Button', 0755, true ); // phpcs:ignore + } + + file_put_contents( // phpcs:ignore + $tmp_dir . '/Button/Button.php', + ' 'Test' ], [ 'priority' => 'plugin' ] ); + $plugin_output = ob_get_clean(); + + // With priority='theme', the theme Button should be used. + ob_start(); + ComponentLoader::render( 'Button', [ 'label' => 'Test' ], [ 'priority' => 'theme' ] ); + $theme_output = ob_get_clean(); + + remove_filter( 'elementary_theme_component_paths', $callback ); + + // Clean up. + unlink( $tmp_dir . '/Button/Button.php' ); // phpcs:ignore + rmdir( $tmp_dir . '/Button' ); // phpcs:ignore + rmdir( $tmp_dir ); // phpcs:ignore + + $this->assertStringContainsString( 'plugin-button', $plugin_output ); + $this->assertStringContainsString( 'elementary-button', $theme_output ); + } + + /** + * Test that the global wrapper function exists. + */ + public function test_global_wrapper_function_exists(): void { + $this->assertTrue( function_exists( 'elementary_theme_component' ) ); + } + + /** + * Test that the global wrapper delegates to ComponentLoader. + */ + public function test_global_wrapper_renders_component(): void { + ob_start(); + elementary_theme_component( 'Button', [ 'label' => 'Global Test' ] ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'Global Test', $output ); + } +} From fd4b542a6e4fb03eb5f3df366b20ef05022f9906 Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Wed, 29 Apr 2026 13:14:50 +0530 Subject: [PATCH 2/7] Add output buffered get static method --- inc/Framework/ComponentLoader.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/inc/Framework/ComponentLoader.php b/inc/Framework/ComponentLoader.php index fb5faa68..c882ced7 100644 --- a/inc/Framework/ComponentLoader.php +++ b/inc/Framework/ComponentLoader.php @@ -46,6 +46,30 @@ public static function render( string $name, array $args = [], array $options = require $file; } + /** + * Get the rendered HTML of a component as a string. + * + * Uses output buffering to capture the component output instead of + * sending it directly to the browser. + * + * @param string $name Component name (e.g. 'Button', 'Card'). + * @param array $args Arguments to pass to the component. + * @param array $options { + * Optional. Resolution options. + * + * @type string $priority Resolution priority: 'theme' or 'plugin'. Default determined by filter. + * } + * + * @return string Rendered component HTML, or empty string if not found. + */ + public static function get( string $name, array $args = [], array $options = [] ): string { + + ob_start(); + self::render( $name, $args, $options ); + + return (string) ob_get_clean(); + } + /** * Resolve the component file path. * From c60aa58a6d78fc377aeebdebadd381ebd2e4e59d Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Wed, 29 Apr 2026 13:16:27 +0530 Subject: [PATCH 3/7] Add convenience wrapper for get method --- inc/helpers/custom-functions.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/inc/helpers/custom-functions.php b/inc/helpers/custom-functions.php index a973f7bb..0952dfcc 100644 --- a/inc/helpers/custom-functions.php +++ b/inc/helpers/custom-functions.php @@ -28,3 +28,23 @@ function elementary_theme_component( string $name, array $args = [], array $opti ComponentLoader::render( $name, $args, $options ); } } + +if ( ! function_exists( 'elementary_theme_get_component' ) ) { + + /** + * Get the rendered HTML of a component as a string. + * + * Global convenience wrapper for ComponentLoader::get(). + * + * @since 1.0.0 + * + * @param string $name Component name (e.g. 'Button', 'Card'). + * @param array $args Arguments to pass to the component. + * @param array $options Optional. Resolution options. See ComponentLoader::get(). + * + * @return string Rendered component HTML. + */ + function elementary_theme_get_component( string $name, array $args = [], array $options = [] ): string { + return ComponentLoader::get( $name, $args, $options ); + } +} From 4512e3a47d973e5b5833849cc4bcb967af49a405 Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Thu, 30 Apr 2026 12:18:43 +0530 Subject: [PATCH 4/7] Fix global variable prefix phpcs error --- phpcs.xml.dist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index bb96a577..7a52901f 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -100,6 +100,8 @@ tests/bootstrap.php + + src/Components/* From 223b9ab264c6e2e8f25223e6891a04491be44be6 Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Thu, 30 Apr 2026 12:21:50 +0530 Subject: [PATCH 5/7] Fix WP global phpcs errors --- phpcs.xml.dist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 7a52901f..a8d65e27 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -114,6 +114,8 @@ tests/* + + src/Components/*