From dc092e3fa55f45c1e1b8df3cc7d000527d3a27fa Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Wed, 29 Apr 2026 13:00:44 +0530 Subject: [PATCH 01/19] 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 02/19] 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 03/19] 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 04/19] 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 05/19] 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/* - src/Components/* + src/components/* @@ -115,7 +115,7 @@ tests/* - src/Components/* + src/components/* - src/components/* + src/components/* diff --git a/src/components/button/button.php b/src/components/button/button.php index 4b0f682e..25dac49c 100644 --- a/src/components/button/button.php +++ b/src/components/button/button.php @@ -16,6 +16,8 @@ * } */ +declare( strict_types = 1 ); + $label = $args['label'] ?? ''; $url = $args['url'] ?? ''; $class = $args['class'] ?? ''; diff --git a/src/components/card/card.php b/src/components/card/card.php index 8e0cd48e..fffb5ebf 100644 --- a/src/components/card/card.php +++ b/src/components/card/card.php @@ -18,7 +18,7 @@ * } */ -use rtCamp\Theme\Elementary\Framework\ComponentLoader; +declare( strict_types = 1 ); $title = $args['title'] ?? ''; $description = $args['description'] ?? ''; @@ -47,13 +47,20 @@
$title, 'url' => $url, 'class' => 'elementary-card__button', - ] + ], + array_intersect_key( + $options, + [ + 'script' => true, + 'style' => true, + ] + ) ); ?>
diff --git a/tests/js/webpack-config.test.js b/tests/js/webpack-config.test.js new file mode 100644 index 00000000..7cec8e2c --- /dev/null +++ b/tests/js/webpack-config.test.js @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +const fs = require( 'fs' ); +const os = require( 'os' ); +const path = require( 'path' ); + +jest.mock( '@wordpress/scripts/config/webpack.config', () => [ + { + module: {}, + optimization: { + minimizer: [], + splitChunks: {}, + }, + output: {}, + plugins: [], + }, + { + output: {}, + }, +] ); + +const { getComponentEntries } = require( '../../webpack.config' ); + +describe( 'webpack component entries', () => { + let tmpDir; + + afterEach( () => { + if ( tmpDir ) { + fs.rmSync( tmpDir, { recursive: true, force: true } ); + tmpDir = undefined; + } + } ); + + it( 'only matches files with the exact component basename', () => { + tmpDir = fs.mkdtempSync( path.join( os.tmpdir(), 'elementary-webpack-components-' ) ); + const buttonDir = path.join( tmpDir, 'button' ); + + fs.mkdirSync( buttonDir ); + fs.writeFileSync( path.join( buttonDir, 'button.js' ), '' ); + fs.writeFileSync( path.join( buttonDir, 'button-extra.js' ), '' ); + fs.writeFileSync( path.join( buttonDir, 'button.test.js' ), '' ); + fs.writeFileSync( path.join( buttonDir, 'button_utils.js' ), '' ); + + expect( getComponentEntries( tmpDir, /\.js$/ ) ).toEqual( { + 'components/button': path.join( buttonDir, 'button.js' ), + } ); + } ); +} ); diff --git a/tests/php/inc/Framework/ComponentLoaderTest.php b/tests/php/inc/Framework/ComponentLoaderTest.php index 5209e501..2fc0b0dd 100644 --- a/tests/php/inc/Framework/ComponentLoaderTest.php +++ b/tests/php/inc/Framework/ComponentLoaderTest.php @@ -63,6 +63,8 @@ public function test_render_outputs_button_component(): void { * Test render outputs nothing for a missing component. */ public function test_render_missing_component_outputs_nothing(): void { + $this->setExpectedIncorrectUsage( ComponentLoader::class . '::render' ); + ob_start(); ComponentLoader::render( 'NonExistentComponent', [ 'label' => 'Test' ] ); $output = ob_get_clean(); @@ -74,6 +76,8 @@ public function test_render_missing_component_outputs_nothing(): void { * Test render rejects unsafe names containing path separators. */ public function test_render_rejects_component_name_with_slash(): void { + $this->setExpectedIncorrectUsage( ComponentLoader::class . '::render' ); + ob_start(); ComponentLoader::render( '../Button', [ 'label' => 'Test' ] ); $output = ob_get_clean(); @@ -85,6 +89,8 @@ public function test_render_rejects_component_name_with_slash(): void { * Test get() rejects unsafe names containing directory traversal tokens. */ public function test_get_rejects_component_name_with_dot_dot(): void { + $this->setExpectedIncorrectUsage( ComponentLoader::class . '::render' ); + $output = ComponentLoader::get( '..' ); $this->assertSame( '', $output ); @@ -105,6 +111,8 @@ public function test_get_returns_markup_without_direct_output(): void { * Test render rejects names containing backslashes. */ public function test_render_rejects_component_name_with_backslash(): void { + $this->setExpectedIncorrectUsage( ComponentLoader::class . '::render' ); + ob_start(); ComponentLoader::render( '..\\Button', [ 'label' => 'Test' ] ); $output = ob_get_clean(); @@ -127,6 +135,8 @@ public function test_render_trims_component_name_before_resolving(): void { * Test render rejects empty names after normalization. */ public function test_render_rejects_empty_component_name(): void { + $this->setExpectedIncorrectUsage( ComponentLoader::class . '::render' ); + ob_start(); ComponentLoader::render( ' ', [ 'label' => 'Test' ] ); $output = ob_get_clean(); @@ -226,6 +236,8 @@ public function test_component_paths_filter_is_applied(): void { * Test non-array component paths filter return is handled safely. */ public function test_component_paths_filter_non_array_return_is_handled(): void { + $this->setExpectedIncorrectUsage( ComponentLoader::class . '::render' ); + $callback = function () { return 'invalid-paths'; }; @@ -264,139 +276,6 @@ public function test_component_paths_filter_malformed_entries_are_ignored(): voi $this->assertStringContainsString( 'Sanitized Paths', $output ); } - /** - * Test the default priority filter no longer overrides theme-first resolution. - */ - public function test_default_priority_filter_is_ignored_by_theme_first_resolution(): void { - $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-priority-filter-' . uniqid( '', true ); - $button_dir = $tmp_dir . '/Button'; - $button_file = $button_dir . '/Button.php'; - - mkdir( $button_dir, 0755, true ); // phpcs:ignore - - file_put_contents( // phpcs:ignore - $button_file, - ' $tmp_dir, - ]; - return $paths; - }; - - $priority_callback = function () { - return 'plugin'; - }; - - add_filter( 'elementary_theme_component_paths', $paths_callback ); - add_filter( 'elementary_theme_component_default_priority', $priority_callback ); - - try { - ob_start(); - ComponentLoader::render( 'Button', [ 'label' => 'Test' ] ); - $output = ob_get_clean(); - - $this->assertStringContainsString( 'Test', $output ); - $this->assertStringContainsString( 'elementary-button', $output ); - $this->assertStringNotContainsString( 'priority-filter-plugin-button', $output ); - } finally { - remove_filter( 'elementary_theme_component_paths', $paths_callback ); - remove_filter( 'elementary_theme_component_default_priority', $priority_callback ); - - if ( is_file( $button_file ) ) { - unlink( $button_file ); // phpcs:ignore - } - - if ( is_dir( $button_dir ) ) { - rmdir( $button_dir ); // phpcs:ignore - } - - if ( is_dir( $tmp_dir ) ) { - rmdir( $tmp_dir ); // phpcs:ignore - } - } - } - - /** - * 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 priority does not override theme components. - */ - public function test_plugin_priority_does_not_override_theme_component(): void { - $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-plugin-components-' . uniqid( '', true ); - $button_dir = $tmp_dir . '/Button'; - $button_file = $button_dir . '/Button.php'; - $plugin_output = ''; - $theme_output = ''; - - mkdir( $button_dir, 0755, true ); // phpcs:ignore - - file_put_contents( // phpcs:ignore - $button_file, - ' $tmp_dir, - ]; - return $paths; - }; - - add_filter( 'elementary_theme_component_paths', $callback ); - - try { - ob_start(); - ComponentLoader::render( 'Button', [ 'label' => 'Test' ], [ 'priority' => 'plugin' ] ); - $plugin_output = ob_get_clean(); - - ob_start(); - ComponentLoader::render( 'Button', [ 'label' => 'Test' ], [ 'priority' => 'theme' ] ); - $theme_output = ob_get_clean(); - } finally { - remove_filter( 'elementary_theme_component_paths', $callback ); - - if ( is_file( $button_file ) ) { - unlink( $button_file ); // phpcs:ignore - } - - if ( is_dir( $button_dir ) ) { - rmdir( $button_dir ); // phpcs:ignore - } - - if ( is_dir( $tmp_dir ) ) { - rmdir( $tmp_dir ); // phpcs:ignore - } - } - - $this->assertStringContainsString( 'elementary-button', $plugin_output ); - $this->assertStringNotContainsString( 'plugin-button', $plugin_output ); - $this->assertStringContainsString( 'elementary-button', $theme_output ); - } /** * Test child theme templates resolve before plugin components. @@ -437,7 +316,7 @@ public function test_child_theme_component_resolves_before_plugin_component(): v add_filter( 'template_directory', $template_callback ); try { - $output = ComponentLoader::get( 'ChildFirst', [], [ 'priority' => 'plugin' ] ); + $output = ComponentLoader::get( 'ChildFirst' ); } finally { remove_filter( 'elementary_theme_component_paths', $paths_callback ); remove_filter( 'stylesheet_directory', $stylesheet_callback ); @@ -499,7 +378,7 @@ public function test_parent_theme_component_resolves_before_plugin_component(): add_filter( 'template_directory', $template_callback ); try { - $output = ComponentLoader::get( 'ParentFirst', [], [ 'priority' => 'plugin' ] ); + $output = ComponentLoader::get( 'ParentFirst' ); } finally { remove_filter( 'elementary_theme_component_paths', $paths_callback ); remove_filter( 'stylesheet_directory', $stylesheet_callback ); @@ -578,222 +457,6 @@ public function test_plugin_component_resolves_when_theme_template_is_absent(): $this->assertStringContainsString( 'plugin-fallback-component', $output ); } - /** - * Test fixture PromoBanner resolves from theme override before plugin-key source. - */ - public function test_fixture_promo_banner_resolves_from_theme_override(): void { - $component_meta = null; - $paths_callback = function ( $paths ) { - $paths['plugin'] = [ - 'php' => ELEMENTARY_THEME_TEMP_DIR . '/plugins-theme/Components', - 'style' => [ - 'dir' => ELEMENTARY_THEME_BUILD_DIR . '/css/plugin-components', - 'url' => ELEMENTARY_THEME_BUILD_URI . '/css/plugin-components', - ], - 'script' => [ - 'dir' => ELEMENTARY_THEME_BUILD_DIR . '/js/plugin-components', - 'url' => ELEMENTARY_THEME_BUILD_URI . '/js/plugin-components', - ], - ]; - return $paths; - }; - $action_callback = function ( $name, $args, $options ) use ( &$component_meta ) { - $component_meta = $options['component'] ?? null; - }; - - add_filter( 'elementary_theme_component_paths', $paths_callback ); - add_action( 'elementary_theme_before_get_component', $action_callback, 10, 3 ); - - try { - $output = ComponentLoader::get( - 'PromoBanner', - [ - 'title' => 'Theme Promo', - 'description' => 'Theme override wins.', - ] - ); - } finally { - remove_filter( 'elementary_theme_component_paths', $paths_callback ); - remove_action( 'elementary_theme_before_get_component', $action_callback ); - } - - $this->assertStringContainsString( 'elementary-promo-banner--theme', $output ); - $this->assertStringContainsString( 'data-component-source="theme"', $output ); - $this->assertIsArray( $component_meta ); - $this->assertSame( 'theme', $component_meta['source'] ); - } - - /** - * Test fixture PromoBanner falls back to plugin-key source when theme path is absent. - */ - public function test_fixture_promo_banner_falls_back_to_plugin_key_source(): void { - $component_meta = null; - $paths_callback = function ( $paths ) { - $paths['theme']['php'] = 'src/MissingComponents'; - $paths['plugin'] = [ - 'php' => ELEMENTARY_THEME_TEMP_DIR . '/plugins-theme/Components', - 'style' => [ - 'dir' => ELEMENTARY_THEME_BUILD_DIR . '/css/plugin-components', - 'url' => ELEMENTARY_THEME_BUILD_URI . '/css/plugin-components', - ], - 'script' => [ - 'dir' => ELEMENTARY_THEME_BUILD_DIR . '/js/plugin-components', - 'url' => ELEMENTARY_THEME_BUILD_URI . '/js/plugin-components', - ], - ]; - return $paths; - }; - $action_callback = function ( $name, $args, $options ) use ( &$component_meta ) { - $component_meta = $options['component'] ?? null; - }; - - add_filter( 'elementary_theme_component_paths', $paths_callback ); - add_action( 'elementary_theme_before_get_component', $action_callback, 10, 3 ); - - try { - $output = ComponentLoader::get( - 'PromoBanner', - [ - 'title' => 'Plugin Promo', - 'description' => 'Plugin fixture wins when theme is absent.', - ] - ); - } finally { - remove_filter( 'elementary_theme_component_paths', $paths_callback ); - remove_action( 'elementary_theme_before_get_component', $action_callback ); - } - - $this->assertStringContainsString( 'plugin-promo-banner', $output ); - $this->assertStringContainsString( 'data-component-source="plugin"', $output ); - $this->assertIsArray( $component_meta ); - $this->assertSame( 'plugin', $component_meta['source'] ); - } - - /** - * Test fixture FeaturePanel resolves from plugin-key source and shared asset cascade. - */ - public function test_fixture_feature_panel_resolves_from_plugin_key_source_with_shared_asset_cascade(): void { - $component_assets = null; - $paths_callback = function ( $paths ) { - $paths['plugin'] = [ - 'php' => ELEMENTARY_THEME_TEMP_DIR . '/plugins-theme/Components', - 'style' => [ - 'dir' => ELEMENTARY_THEME_BUILD_DIR . '/css/plugin-components', - 'url' => ELEMENTARY_THEME_BUILD_URI . '/css/plugin-components', - ], - 'script' => [ - 'dir' => ELEMENTARY_THEME_BUILD_DIR . '/js/plugin-components', - 'url' => ELEMENTARY_THEME_BUILD_URI . '/js/plugin-components', - ], - ]; - return $paths; - }; - $action_callback = function ( $name, $args, $options ) use ( &$component_assets ) { - $component_assets = $options['component']['assets'] ?? null; - }; - - add_filter( 'elementary_theme_component_paths', $paths_callback ); - add_action( 'elementary_theme_before_get_component', $action_callback, 10, 3 ); - - try { - $output = ComponentLoader::get( - 'FeaturePanel', - [ - 'title' => 'Plugin Feature', - 'description' => 'Plugin-key fixture.', - ] - ); - } finally { - remove_filter( 'elementary_theme_component_paths', $paths_callback ); - remove_action( 'elementary_theme_before_get_component', $action_callback ); - } - - $this->assertStringContainsString( 'plugin-feature-panel', $output ); - $this->assertIsArray( $component_assets ); - $this->assertSame( ELEMENTARY_THEME_BUILD_DIR . '/css/plugin-components/featurepanel.css', $component_assets['style']['file'] ); - $this->assertSame( ELEMENTARY_THEME_BUILD_DIR . '/js/plugin-components/featurepanel.js', $component_assets['script']['file'] ); - } - - /** - * Test child theme built assets override parent theme assets. - */ - public function test_child_theme_asset_overrides_are_independent(): void { - $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-child-theme-assets-' . uniqid( '', true ); - $child_root = $tmp_dir . '/child'; - $child_css_dir = $child_root . '/assets/build/css/components'; - $child_js_dir = $child_root . '/assets/build/js/components'; - $component_assets = null; - - foreach ( [ $child_css_dir, $child_js_dir ] as $dir ) { - mkdir( $dir, 0755, true ); // phpcs:ignore - } - - file_put_contents( $child_css_dir . '/featurepanel.css', '.child-feature-panel { color: red; }' ); // phpcs:ignore - file_put_contents( $child_js_dir . '/featurepanel.js', 'window.childFeaturePanel = true;' ); // phpcs:ignore - - $stylesheet_callback = function () use ( $child_root ) { - return $child_root; - }; - $stylesheet_uri_callback = function () { - return 'https://child.example'; - }; - $before_component_callback = function ( $name, $args, $options ) use ( &$component_assets ) { - $component_assets = $options['component']['assets'] ?? null; - }; - - add_filter( 'stylesheet_directory', $stylesheet_callback ); - add_filter( 'stylesheet_directory_uri', $stylesheet_uri_callback ); - add_action( 'elementary_theme_before_get_component', $before_component_callback, 10, 3 ); - - try { - $output = ComponentLoader::get( - 'FeaturePanel', - [ - 'title' => 'Child Asset Feature', - 'description' => 'Child assets should win.', - ] - ); - } finally { - remove_filter( 'stylesheet_directory', $stylesheet_callback ); - remove_filter( 'stylesheet_directory_uri', $stylesheet_uri_callback ); - remove_action( 'elementary_theme_before_get_component', $before_component_callback ); - - foreach ( - [ - $child_css_dir . '/featurepanel.css', - $child_js_dir . '/featurepanel.js', - ] as $file - ) { - if ( is_file( $file ) ) { - unlink( $file ); // phpcs:ignore - } - } - - foreach ( - [ - $child_css_dir, - $child_root . '/assets/build/css', - $child_js_dir, - $child_root . '/assets/build/js', - $child_root . '/assets/build', - $child_root . '/assets', - $child_root, - $tmp_dir, - ] as $dir - ) { - if ( is_dir( $dir ) ) { - rmdir( $dir ); // phpcs:ignore - } - } - } - - $this->assertStringContainsString( 'Child Asset Feature', $output ); - $this->assertIsArray( $component_assets ); - $this->assertSame( $child_css_dir . '/featurepanel.css', $component_assets['style']['file'] ); - $this->assertSame( 'https://child.example/assets/build/css/components/featurepanel.css', $component_assets['style']['url'] ); - $this->assertSame( $child_js_dir . '/featurepanel.js', $component_assets['script']['file'] ); - $this->assertSame( 'https://child.example/assets/build/js/components/featurepanel.js', $component_assets['script']['url'] ); - } /** * Test child theme asset lookup derives from the configured theme asset directory. @@ -893,92 +556,6 @@ public function test_child_theme_asset_lookup_uses_theme_asset_config(): void { $this->assertArrayNotHasKey( 'script', $component_assets ); } - /** - * Test plugin assets backfill missing theme assets for theme-resolved components. - */ - public function test_plugin_asset_backfills_missing_theme_asset(): void { - $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-plugin-asset-backfill-' . uniqid( '', true ); - $parent_root = $tmp_dir . '/parent'; - $child_root = $tmp_dir . '/child'; - $plugin_script_dir = $tmp_dir . '/plugin-js'; - $component_assets = null; - - mkdir( $parent_root, 0755, true ); // phpcs:ignore - mkdir( $child_root, 0755, true ); // phpcs:ignore - mkdir( $plugin_script_dir, 0755, true ); // phpcs:ignore - - file_put_contents( $plugin_script_dir . '/featurepanel.js', 'window.pluginFeaturePanelBackfill = true;' ); // phpcs:ignore - - $template_callback = function () use ( $parent_root ) { - return $parent_root; - }; - $template_uri_callback = function () { - return 'https://parent.example'; - }; - $stylesheet_callback = function () use ( $child_root ) { - return $child_root; - }; - $stylesheet_uri_callback = function () { - return 'https://child.example'; - }; - $paths_callback = function ( $paths ) use ( $plugin_script_dir ) { - $paths['theme']['script'] = 'theme-js'; - $paths['plugin'] = [ - 'php' => ELEMENTARY_THEME_TEMP_DIR . '/plugins-theme/Components', - 'script' => [ - 'dir' => $plugin_script_dir, - 'url' => 'https://plugin.example/js/plugin-components', - ], - ]; - return $paths; - }; - $action_callback = function ( $name, $args, $options ) use ( &$component_assets ) { - $component_assets = $options['component']['assets'] ?? null; - }; - - add_filter( 'template_directory', $template_callback ); - add_filter( 'template_directory_uri', $template_uri_callback ); - add_filter( 'stylesheet_directory', $stylesheet_callback ); - add_filter( 'stylesheet_directory_uri', $stylesheet_uri_callback ); - add_filter( 'elementary_theme_component_paths', $paths_callback ); - add_action( 'elementary_theme_before_get_component', $action_callback, 10, 3 ); - - try { - $output = ComponentLoader::get( - 'FeaturePanel', - [ 'title' => 'Plugin Asset Backfill' ], - [ - 'script' => true, - 'style' => false, - ] - ); - } finally { - remove_filter( 'template_directory', $template_callback ); - remove_filter( 'template_directory_uri', $template_uri_callback ); - remove_filter( 'stylesheet_directory', $stylesheet_callback ); - remove_filter( 'stylesheet_directory_uri', $stylesheet_uri_callback ); - remove_filter( 'elementary_theme_component_paths', $paths_callback ); - remove_action( 'elementary_theme_before_get_component', $action_callback ); - - if ( is_file( $plugin_script_dir . '/featurepanel.js' ) ) { - unlink( $plugin_script_dir . '/featurepanel.js' ); // phpcs:ignore - } - - foreach ( [ $parent_root, $child_root, $plugin_script_dir, $tmp_dir ] as $dir ) { - if ( is_dir( $dir ) ) { - rmdir( $dir ); // phpcs:ignore - } - } - } - - $this->assertStringContainsString( 'Plugin Asset Backfill', $output ); - $this->assertIsArray( $component_assets ); - $this->assertArrayHasKey( 'script', $component_assets ); - $this->assertArrayNotHasKey( 'style', $component_assets ); - $this->assertSame( $plugin_script_dir . '/featurepanel.js', $component_assets['script']['file'] ); - $this->assertSame( 'https://plugin.example/js/plugin-components/featurepanel.js', $component_assets['script']['url'] ); - } - /** * Test PHP-only path configs render without asset config. */ @@ -1005,7 +582,7 @@ public function test_php_only_component_path_config_renders_without_assets(): vo try { ob_start(); - ComponentLoader::render( 'PhpOnly', [ 'label' => 'Test' ], [ 'priority' => 'plugin' ] ); + ComponentLoader::render( 'PhpOnly', [ 'label' => 'Test' ] ); $output = ob_get_clean(); } finally { remove_filter( 'elementary_theme_component_paths', $callback ); @@ -1071,7 +648,7 @@ public function test_malformed_component_asset_metadata_falls_back_safely(): voi try { ob_start(); - ComponentLoader::render( 'MalformedAsset', [ 'label' => 'Test' ], [ 'priority' => 'plugin' ] ); + ComponentLoader::render( 'MalformedAsset', [ 'label' => 'Test' ] ); $output = ob_get_clean(); } finally { remove_filter( 'elementary_theme_component_paths', $callback ); @@ -1092,6 +669,89 @@ public function test_malformed_component_asset_metadata_falls_back_safely(): voi $this->assertStringContainsString( 'malformed-asset-meta-button', $output ); } + /** + * Test component asset metadata files are required only once per request. + */ + public function test_component_asset_metadata_is_cached_between_repeated_renders(): void { + $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-cached-asset-meta-' . uniqid( '', true ); + $component_root = $tmp_dir . '/components'; + $style_root = $tmp_dir . '/css'; + $button_dir = $component_root . '/CachedMeta'; + $button_file = $button_dir . '/CachedMeta.php'; + $button_css_file = $style_root . '/cachedmeta.css'; + $button_asset = $style_root . '/cachedmeta.asset.php'; + + mkdir( $button_dir, 0755, true ); // phpcs:ignore + mkdir( $style_root, 0755, true ); // phpcs:ignore + + file_put_contents( $button_file, ' [], "version" => "test-version" ];' + ); + + $callback = function ( $paths ) use ( $component_root, $style_root ) { + $paths['plugin'] = [ + 'php' => $component_root, + 'style' => [ + 'dir' => $style_root, + 'url' => 'https://example.com/css', + ], + ]; + return $paths; + }; + + add_filter( 'elementary_theme_component_paths', $callback ); + self::reset_component_asset_handles( 'cachedmeta' ); + $require_count = 0; + + $GLOBALS['elementary_test_asset_meta_require_count'] = 0; + + try { + $first_output = ComponentLoader::get( 'CachedMeta', [], [ 'style' => true ] ); + $second_output = ComponentLoader::get( 'CachedMeta', [], [ 'style' => true ] ); + $require_count = $GLOBALS['elementary_test_asset_meta_require_count']; + } finally { + remove_filter( 'elementary_theme_component_paths', $callback ); + self::reset_component_asset_handles( 'cachedmeta' ); + unset( $GLOBALS['elementary_test_asset_meta_require_count'] ); + + foreach ( [ $button_asset, $button_css_file, $button_file ] as $file ) { + if ( is_file( $file ) ) { + unlink( $file ); // phpcs:ignore + } + } + + foreach ( [ $button_dir, $style_root, $component_root, $tmp_dir ] as $dir ) { + if ( is_dir( $dir ) ) { + rmdir( $dir ); // phpcs:ignore + } + } + } + + $this->assertStringContainsString( 'cached-asset-meta-button', $first_output ); + $this->assertStringContainsString( 'cached-asset-meta-button', $second_output ); + $this->assertSame( 1, $require_count ); + } + + /** + * Test Windows-style asset paths are not prefixed with an extra slash. + */ + public function test_component_asset_metadata_path_preserves_windows_drive_prefix(): void { + $method = new ReflectionMethod( ComponentLoader::class, 'get_component_asset_meta' ); + $method->setAccessible( true ); + $method->invoke( null, 'C:\\theme\\assets\\button.js' ); + + $reflection = new ReflectionClass( ComponentLoader::class ); + $property = $reflection->getProperty( 'asset_meta_cache' ); + $property->setAccessible( true ); + $cache = $property->getValue(); + + $this->assertArrayHasKey( 'C:\\theme\\assets\\button.asset.php', $cache ); + $this->assertArrayNotHasKey( '/C:/theme/assets/button.asset.php', $cache ); + } + /** * Test enqueue defaults prevent disabled assets from being collected. */ @@ -1243,7 +903,7 @@ public function test_enqueue_defaults_disable_asset_enqueueing(): void { self::reset_component_asset_handles( 'disabledasset' ); try { - $output = ComponentLoader::get( 'DisabledAsset', [ 'label' => 'No Enqueue' ], [ 'priority' => 'plugin' ] ); + $output = ComponentLoader::get( 'DisabledAsset', [ 'label' => 'No Enqueue' ] ); } finally { remove_filter( 'elementary_theme_component_paths', $paths_callback ); remove_filter( 'elementary_theme_component_enqueue_defaults', $enqueue_callback ); @@ -1321,9 +981,8 @@ public function test_enqueue_options_override_defaults_before_enqueueing_assets( 'OverrideAsset', [ 'label' => 'Script Override' ], [ - 'priority' => 'plugin', - 'script' => true, - 'style' => false, + 'script' => true, + 'style' => false, ] ); @@ -1350,16 +1009,134 @@ public function test_enqueue_options_override_defaults_before_enqueueing_assets( } } + /** + * Test registered-but-dequeued component asset handles are enqueued again. + */ + public function test_registered_dequeued_component_assets_are_enqueued_on_later_render(): void { + $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-reenqueue-asset-' . uniqid( '', true ); + $component_root = $tmp_dir . '/components'; + $style_root = $tmp_dir . '/css'; + $script_root = $tmp_dir . '/js'; + $button_dir = $component_root . '/ReenqueueAsset'; + $button_file = $button_dir . '/ReenqueueAsset.php'; + $button_css = $style_root . '/reenqueueasset.css'; + $button_js = $script_root . '/reenqueueasset.js'; + $style_handle = 'elementary-theme-component-reenqueueasset-style'; + $script_handle = 'elementary-theme-component-reenqueueasset-script'; + + mkdir( $button_dir, 0755, true ); // phpcs:ignore + mkdir( $style_root, 0755, true ); // phpcs:ignore + mkdir( $script_root, 0755, true ); // phpcs:ignore + + file_put_contents( $button_file, ' $component_root, + 'style' => [ + 'dir' => $style_root, + 'url' => 'https://example.com/css', + ], + 'script' => [ + 'dir' => $script_root, + 'url' => 'https://example.com/js', + ], + ]; + return $paths; + }; + + add_filter( 'elementary_theme_component_paths', $paths_callback ); + self::reset_component_asset_handles( 'reenqueueasset' ); + + try { + ComponentLoader::get( + 'ReenqueueAsset', + [], + [ + 'style' => true, + 'script' => true, + ] + ); + + wp_dequeue_style( $style_handle ); + wp_dequeue_script( $script_handle ); + + $this->assertTrue( wp_style_is( $style_handle, 'registered' ) ); + $this->assertTrue( wp_script_is( $script_handle, 'registered' ) ); + $this->assertFalse( wp_style_is( $style_handle, 'enqueued' ) ); + $this->assertFalse( wp_script_is( $script_handle, 'enqueued' ) ); + + $output = ComponentLoader::get( + 'ReenqueueAsset', + [], + [ + 'style' => true, + 'script' => true, + ] + ); + + $this->assertStringContainsString( 'reenqueue-asset-button', $output ); + $this->assertTrue( wp_style_is( $style_handle, 'enqueued' ) ); + $this->assertTrue( wp_script_is( $script_handle, 'enqueued' ) ); + } finally { + remove_filter( 'elementary_theme_component_paths', $paths_callback ); + self::reset_component_asset_handles( 'reenqueueasset' ); + + foreach ( [ $button_js, $button_css, $button_file ] as $file ) { + if ( is_file( $file ) ) { + unlink( $file ); // phpcs:ignore + } + } + + foreach ( [ $button_dir, $style_root, $script_root, $component_root, $tmp_dir ] as $dir ) { + if ( is_dir( $dir ) ) { + rmdir( $dir ); // phpcs:ignore + } + } + } + } + /** * Test nested components inherit disabled enqueue options. */ public function test_nested_components_inherit_disabled_enqueue_options(): void { + $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-nested-disabled-assets-' . uniqid( '', true ); + $component_root = $tmp_dir . '/components'; + $style_root = $tmp_dir . '/css'; + $script_root = $tmp_dir . '/js'; + $button_css = $style_root . '/button.css'; + $button_js = $script_root . '/button.js'; $component_assets = []; + mkdir( $component_root, 0755, true ); // phpcs:ignore + mkdir( $style_root, 0755, true ); // phpcs:ignore + mkdir( $script_root, 0755, true ); // phpcs:ignore + + file_put_contents( $button_css, '.elementary-button { color: inherit; }' ); // phpcs:ignore + file_put_contents( $button_js, 'window.elementaryNestedButton = true;' ); // phpcs:ignore + + $paths_callback = function ( $paths ) use ( $component_root, $style_root, $script_root ) { + $paths['plugin'] = [ + 'php' => $component_root, + 'style' => [ + 'dir' => $style_root, + 'url' => 'https://example.com/css', + ], + 'script' => [ + 'dir' => $script_root, + 'url' => 'https://example.com/js', + ], + ]; + return $paths; + }; + $action_callback = function ( $name, $args, $options ) use ( &$component_assets ) { $component_assets[ $name ] = $options['component']['assets'] ?? null; }; + add_filter( 'elementary_theme_component_paths', $paths_callback ); add_action( 'elementary_theme_before_get_component', $action_callback, 10, 3 ); try { @@ -1375,7 +1152,20 @@ public function test_nested_components_inherit_disabled_enqueue_options(): void ] ); } finally { + remove_filter( 'elementary_theme_component_paths', $paths_callback ); remove_action( 'elementary_theme_before_get_component', $action_callback ); + + foreach ( [ $button_js, $button_css ] as $file ) { + if ( is_file( $file ) ) { + unlink( $file ); // phpcs:ignore + } + } + + foreach ( [ $style_root, $script_root, $component_root, $tmp_dir ] as $dir ) { + if ( is_dir( $dir ) ) { + rmdir( $dir ); // phpcs:ignore + } + } } $this->assertStringContainsString( 'Nested Disabled Assets', $output ); @@ -1432,9 +1222,9 @@ public function test_component_lookup_cache_is_sensitive_to_filtered_paths(): vo add_filter( 'elementary_theme_component_paths', $callback ); try { - $first_output = ComponentLoader::get( 'CacheProbe', [ 'label' => 'Test' ], [ 'priority' => 'plugin' ] ); + $first_output = ComponentLoader::get( 'CacheProbe', [ 'label' => 'Test' ] ); $active_tmp_dir = $second_tmp_dir; - $second_output = ComponentLoader::get( 'CacheProbe', [ 'label' => 'Test' ], [ 'priority' => 'plugin' ] ); + $second_output = ComponentLoader::get( 'CacheProbe', [ 'label' => 'Test' ] ); } finally { remove_filter( 'elementary_theme_component_paths', $callback ); diff --git a/webpack.config.js b/webpack.config.js index 9807e8cc..b075b718 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -117,7 +117,7 @@ const getComponentEntries = ( dir, extFilter ) => { const compName = entry.name; const compDir = path.join( resolvedDir, compName ); fs.readdirSync( compDir ).forEach( ( file ) => { - if ( file.startsWith( compName ) && file.match( extFilter ) ) { + if ( file.match( extFilter ) && path.parse( file ).name === compName ) { const entryName = `components/${ compName.toLowerCase() }`; entries[ entryName ] = path.join( compDir, file ); } @@ -236,4 +236,10 @@ const moduleScripts = { }, }; -module.exports = [ scripts, styles, moduleScripts ]; +const configs = [ scripts, styles, moduleScripts ]; + +Object.defineProperty( configs, 'getComponentEntries', { + value: getComponentEntries, +} ); + +module.exports = configs; From 2931ff54e3339c332de4fa3b251d8457d31d777f Mon Sep 17 00:00:00 2001 From: Bhavik Tank Date: Wed, 27 May 2026 01:35:01 +0530 Subject: [PATCH 17/19] refactor: remove ComponentLoader and its test --- inc/Framework/ComponentLoader.php | 590 -------- .../php/inc/Framework/ComponentLoaderTest.php | 1312 ----------------- 2 files changed, 1902 deletions(-) delete mode 100644 inc/Framework/ComponentLoader.php delete mode 100644 tests/php/inc/Framework/ComponentLoaderTest.php diff --git a/inc/Framework/ComponentLoader.php b/inc/Framework/ComponentLoader.php deleted file mode 100644 index e5f15118..00000000 --- a/inc/Framework/ComponentLoader.php +++ /dev/null @@ -1,590 +0,0 @@ -> - */ - private static array $component_data_cache = []; - - /** - * Component asset metadata cache. - * - * @var array - */ - private static array $asset_meta_cache = []; - - /** - * Render a component by name. - * - * Resolves the component file from child theme, parent theme, or plugin paths, - * then includes it with the provided arguments available in scope. - * - * @param string $name Component name (e.g. 'Button', 'Card'). - * @param array $args Arguments to pass to the component. - * @param array $options { - * Optional. Resolution and asset enqueue options. - * - * @type bool $script Whether to enqueue the component's script. Default determined by filter. - * @type bool $style Whether to enqueue the component's style. Default determined by filter. - * } - * - * @return void - */ - public static function render( string $name, array $args = [], array $options = [] ): void { - - $options = self::get_render_options( $options ); - $component = self::get_component_data( $name, $options ); - - if ( false === $component ) { - _doing_it_wrong( - __METHOD__, - sprintf( - /* translators: %s: Component name. */ - esc_html__( 'Component "%s" could not be resolved.', 'elementary-theme' ), - esc_html( $name ) - ), - '1.0.0' - ); - - return; - } - - $options['component'] = $component; - - do_action( 'elementary_theme_before_get_component', $name, $args, $options ); - - self::require_component_file( (string) $component['file'], $args, $options ); - - self::enqueue_component_assets( $component, $options ); - - do_action( 'elementary_theme_after_get_component', $name, $args, $options ); - } - - /** - * Get normalized render options. - * - * @param array $options Render options. - * - * @return array Render options with enqueue settings resolved. - */ - private static function get_render_options( array $options ): array { - /** - * Filters the default enqueue settings for elementary theme components. - * - * This filter allows developers to modify whether scripts and styles - * should be enqueued by default for the theme component. - * - * @param array $defaults { - * Default enqueue settings. - * - * @type bool $script Whether to enqueue the component's script. Default true. - * @type bool $style Whether to enqueue the component's style. Default true. - * } - */ - $enqueue = apply_filters( - 'elementary_theme_component_enqueue_defaults', - [ - 'script' => true, - 'style' => true, - ] - ); - - if ( ! is_array( $enqueue ) ) { - $enqueue = []; - } - - $enqueue = wp_parse_args( - $options, - $enqueue - ); - - $options['script'] = ! empty( $enqueue['script'] ); - $options['style'] = ! empty( $enqueue['style'] ); - - return $options; - } - - /** - * 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 bool $script Whether to enqueue the component's script. Default determined by filter. - * @type bool $style Whether to enqueue the component's style. 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(); - } - - /** - * Require a component file in render scope. - * - * @param string $file Component file path. - * @param array $args Component arguments. - * @param array $options Component render options. - * - * @return void - */ - private static function require_component_file( string $file, array $args, array $options ): void { - require $file; - } - - /** - * Resolve the component file path. - * - * Checks the theme path first, then the plugin path, and returns the first match. - * Theme path format: {relative_theme_path}/{Name}/{Name}.php. - * Plugin path format: {absolute_source_path}/{Name}/{Name}.php. - * - * @param string $name Component name. - * @param array $options Resolution options. - * - * @return array|false Component metadata on success, false if not found. - */ - private static function get_component_data( string $name, array $options = [] ): array|false { - - $component_name = self::normalize_component_name( $name ); - - if ( false === $component_name ) { - return false; - } - - /** - * Filters the registered component paths. - * - * Supported source keys are 'theme' and 'plugin'. Theme PHP, style, and - * script paths are relative to the theme root. Plugin PHP paths are - * absolute, and plugin assets use absolute dir/url config. - * - * @since 1.0.0 - * - * @param array> $paths Associative array of source => path config. - * @param string $name Component name being resolved. - * @param array $options Options passed to render(). - */ - $paths = apply_filters( - 'elementary_theme_component_paths', - [ - 'theme' => [ - 'php' => 'src/components', - 'style' => 'assets/build/css/components', - 'script' => 'assets/build/js/components', - ], - ], - $component_name, - $options - ); - - if ( empty( $paths ) || ! is_array( $paths ) ) { - return false; - } - - $cache_key = self::get_cache_key( - [ - $component_name, - $paths, - $options['script'] ?? false, - $options['style'] ?? false, - ] - ); - - if ( isset( self::$component_data_cache[ $cache_key ] ) ) { - return self::$component_data_cache[ $cache_key ]; - } - - if ( ! empty( $paths['theme'] ) && is_array( $paths['theme'] ) ) { - $component = self::get_theme_component_data( $component_name, $paths['theme'], $paths, $options ); - - if ( false !== $component ) { - self::$component_data_cache[ $cache_key ] = $component; - - return $component; - } - } - - if ( ! empty( $paths['plugin'] ) && is_array( $paths['plugin'] ) ) { - $component = self::get_plugin_component_data( $component_name, $paths['plugin'], $paths, $options ); - - if ( false !== $component ) { - self::$component_data_cache[ $cache_key ] = $component; - - return $component; - } - } - - return false; - } - - /** - * Resolve theme component data through locate_template(). - * - * @param string $component_name Component name. - * @param array $paths Theme path config. - * @param array $all_paths All filtered path configs. - * @param array $options Component render options. - * - * @return array|false Component metadata on success, false if not found. - */ - private static function get_theme_component_data( string $component_name, array $paths, array $all_paths, array $options ): array|false { - if ( empty( $paths['php'] ) || ! is_string( $paths['php'] ) ) { - return false; - } - - $component_slug = strtolower( $component_name ); - $component_root = trim( $paths['php'], '/\\' ); - $file = locate_template( - [ - $component_root . '/' . $component_slug . '/' . $component_slug . '.php', - $component_root . '/' . $component_name . '/' . $component_name . '.php', - ], - false, - false - ); - - if ( empty( $file ) || ! is_readable( $file ) ) { - return false; - } - - return [ - 'name' => $component_name, - 'source' => 'theme', - 'file' => $file, - 'root' => $paths['php'], - 'paths' => $paths, - 'assets' => self::get_component_assets( $component_name, $all_paths, $options ), - ]; - } - - /** - * Resolve plugin component data from an absolute source path. - * - * @param string $component_name Component name. - * @param array $paths Plugin path config. - * @param array $all_paths All filtered path configs. - * @param array $options Component render options. - * - * @return array|false Component metadata on success, false if not found. - */ - private static function get_plugin_component_data( string $component_name, array $paths, array $all_paths, array $options ): array|false { - if ( empty( $paths['php'] ) || ! is_string( $paths['php'] ) ) { - return false; - } - - $component_slug = strtolower( $component_name ); - $file = trailingslashit( $paths['php'] ) . $component_slug . '/' . $component_slug . '.php'; - - if ( ! is_readable( $file ) ) { - $file = trailingslashit( $paths['php'] ) . $component_name . '/' . $component_name . '.php'; - } - - if ( ! is_readable( $file ) ) { - return false; - } - - return [ - 'name' => $component_name, - 'source' => 'plugin', - 'file' => $file, - 'root' => $paths['php'], - 'paths' => $paths, - 'assets' => self::get_component_assets( $component_name, $all_paths, $options ), - ]; - } - - /** - * Get component asset metadata from child theme, parent theme, then plugin. - * - * @param string $component_name Component name. - * @param array $paths All filtered path configs. - * @param array $options Component render options. - * - * @return array> Asset metadata. - */ - private static function get_component_assets( string $component_name, array $paths, array $options ): array { - if ( empty( $options['style'] ) && empty( $options['script'] ) ) { - return []; - } - - $assets = []; - - foreach ( - [ - 'style' => 'css', - 'script' => 'js', - ] as $asset_type => $extension - ) { - if ( empty( $options[ $asset_type ] ) ) { - continue; - } - - $asset_file_name = strtolower( $component_name ) . '.' . $extension; - - if ( ! empty( $paths['theme'][ $asset_type ] ) && is_string( $paths['theme'][ $asset_type ] ) ) { - $relative_asset_dir = trim( $paths['theme'][ $asset_type ], '/\\' ); - $child_asset_file = trailingslashit( get_stylesheet_directory() ) . $relative_asset_dir . '/' . $asset_file_name; - - if ( is_readable( $child_asset_file ) ) { - $assets[ $asset_type ] = [ - 'file' => $child_asset_file, - 'url' => trailingslashit( get_stylesheet_directory_uri() ) . $relative_asset_dir . '/' . $asset_file_name, - ]; - - continue; - } - - $theme_asset_file = trailingslashit( get_template_directory() ) . $relative_asset_dir . '/' . $asset_file_name; - - if ( is_readable( $theme_asset_file ) ) { - $assets[ $asset_type ] = [ - 'file' => $theme_asset_file, - 'url' => trailingslashit( get_template_directory_uri() ) . $relative_asset_dir . '/' . $asset_file_name, - ]; - - continue; - } - } - - if ( ! empty( $paths['plugin'][ $asset_type ]['dir'] ) && ! empty( $paths['plugin'][ $asset_type ]['url'] ) ) { - $plugin_asset_file = trailingslashit( (string) $paths['plugin'][ $asset_type ]['dir'] ) . $asset_file_name; - - if ( is_readable( $plugin_asset_file ) ) { - $assets[ $asset_type ] = [ - 'file' => $plugin_asset_file, - 'url' => trailingslashit( (string) $paths['plugin'][ $asset_type ]['url'] ) . $asset_file_name, - ]; - } - } - } - - return $assets; - } - - /** - * Create a stable cache key for request-level lookup caches. - * - * @param array $parts Cache key parts. - * - * @return string Cache key. - */ - private static function get_cache_key( array $parts ): string { - $encoded_parts = wp_json_encode( $parts ); - - return md5( is_string( $encoded_parts ) ? $encoded_parts : '' ); - } - - /** - * Enqueue assets for a rendered component. - * - * @param array $component Component metadata. - * @param array $options Component render options. - * - * @return void - */ - private static function enqueue_component_assets( array $component, array $options ): void { - if ( empty( $component['name'] ) || empty( $component['assets'] ) || ! is_array( $component['assets'] ) ) { - return; - } - - $slug = sanitize_key( (string) $component['name'] ); - - if ( - ! empty( $options['style'] ) && - ! empty( $component['assets']['style'] ) && - is_array( $component['assets']['style'] ) - ) { - $handle = 'elementary-theme-component-' . $slug . '-style'; - - if ( self::register_component_style( $handle, $component['assets']['style'] ) ) { - wp_enqueue_style( $handle ); - } - } - - if ( - ! empty( $options['script'] ) && - ! empty( $component['assets']['script'] ) && - is_array( $component['assets']['script'] ) - ) { - $handle = 'elementary-theme-component-' . $slug . '-script'; - - if ( self::register_component_script( $handle, $component['assets']['script'] ) ) { - wp_enqueue_script( $handle ); - } - } - } - - /** - * Register a component script. - * - * @param string $handle Name of the script. Should be unique. - * @param array $asset Component asset metadata. - * @param array $deps Optional. An array of registered script handles this script depends on. Default empty array. - * @param string|bool|null $ver Optional. String specifying script version number, if not set, filetime will be used as version number. - * @param bool $in_footer Optional. Whether to enqueue the script before instead of in the . - * - * @return bool Whether the script has been registered. - */ - private static function register_component_script( string $handle, array $asset, array $deps = [], string|bool|null $ver = false, bool $in_footer = true ): bool { - if ( - empty( $asset['url'] ) || - empty( $asset['file'] ) || - ! file_exists( $asset['file'] ) - ) { - return false; - } - - if ( wp_script_is( $handle, 'registered' ) ) { - return true; - } - - $asset_meta = self::get_component_asset_meta( (string) $asset['file'], $deps, $ver ); - - return wp_register_script( $handle, (string) $asset['url'], $asset_meta['dependencies'], $asset_meta['version'], $in_footer ); - } - - /** - * Register a component stylesheet. - * - * @param string $handle Name of the stylesheet. Should be unique. - * @param array $asset Component asset metadata. - * @param array $deps Optional. An array of registered stylesheet handles this stylesheet depends on. Default empty array. - * @param string|bool|null $ver Optional. String specifying style version number, if not set, filetime will be used as version number. - * @param string $media Optional. The media for which this stylesheet has been defined. - * - * @return bool Whether the style has been registered. - */ - private static function register_component_style( string $handle, array $asset, array $deps = [], string|bool|null $ver = false, string $media = 'all' ): bool { - if ( - empty( $asset['url'] ) || - empty( $asset['file'] ) || - ! file_exists( $asset['file'] ) - ) { - return false; - } - - if ( wp_style_is( $handle, 'registered' ) ) { - return true; - } - - $asset_meta = self::get_component_asset_meta( (string) $asset['file'], $deps, $ver ); - - return wp_register_style( $handle, (string) $asset['url'], $asset_meta['dependencies'], $asset_meta['version'], $media ); - } - - /** - * Get component asset dependencies and version info from a matching .asset.php file. - * - * @param string $file Asset file path. - * @param array $deps Asset dependencies to merge with. - * @param string|bool|null $ver Asset version string. - * - * @return array{dependencies: array, version: string|bool} Asset meta information including dependencies and version. - */ - private static function get_component_asset_meta( string $file, array $deps = [], string|bool|null $ver = false ): array { - $asset_meta_file = preg_replace( '/\.[^\/\\\\.]+$/', '.asset.php', $file ); - $asset_meta_file = ! empty( $asset_meta_file ) ? $asset_meta_file : $file . '.asset.php'; - - if ( ! array_key_exists( $asset_meta_file, self::$asset_meta_cache ) ) { - self::$asset_meta_cache[ $asset_meta_file ] = is_readable( $asset_meta_file ) ? require $asset_meta_file : []; - } - - $asset_meta = self::$asset_meta_cache[ $asset_meta_file ]; - - if ( ! is_array( $asset_meta ) ) { - $asset_meta = []; - } - - $dependencies = $asset_meta['dependencies'] ?? []; - $version = $asset_meta['version'] ?? self::get_component_file_version( $file, $ver ); - - if ( ! is_array( $dependencies ) ) { - $dependencies = []; - } - - $dependencies = array_values( array_filter( $dependencies, 'is_string' ) ); - - return [ - 'dependencies' => array_merge( $deps, $dependencies ), - 'version' => is_string( $version ) || is_bool( $version ) - ? $version - : ( is_int( $version ) ? (string) $version : self::get_component_file_version( $file, $ver ) ), - ]; - } - - /** - * Get component asset file version. - * - * @param string $file File path. - * @param string|bool|null $ver File version. - * - * @return string|bool File version based on file modification time or provided version. - */ - private static function get_component_file_version( string $file, string|bool|null $ver = false ): string|bool { - if ( ! empty( $ver ) ) { - return $ver; - } - - return file_exists( $file ) ? (string) filemtime( $file ) : false; - } - - /** - * Normalize and validate a component name before using it in filesystem paths. - * - * Normalization trims surrounding whitespace. Validation then enforces - * length bounds, blocks traversal and path separators, and allows only - * alphanumeric characters, underscores and dashes. - * - * @param string $name Component name to normalize and validate. - * - * @return string|false Normalized component name, or false when invalid. - */ - private static function normalize_component_name( string $name ): string|false { - $name = trim( $name ); - - if ( - '' === $name || - strlen( $name ) > 128 || - str_contains( $name, '..' ) || - str_contains( $name, '/' ) || - str_contains( $name, '\\' ) || - 1 !== preg_match( '/^[A-Za-z0-9_-]+$/', $name ) - ) { - return false; - } - - return $name; - } - -} diff --git a/tests/php/inc/Framework/ComponentLoaderTest.php b/tests/php/inc/Framework/ComponentLoaderTest.php deleted file mode 100644 index 2fc0b0dd..00000000 --- a/tests/php/inc/Framework/ComponentLoaderTest.php +++ /dev/null @@ -1,1312 +0,0 @@ -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 { - $this->setExpectedIncorrectUsage( ComponentLoader::class . '::render' ); - - ob_start(); - ComponentLoader::render( 'NonExistentComponent', [ 'label' => 'Test' ] ); - $output = ob_get_clean(); - - $this->assertEmpty( $output ); - } - - /** - * Test render rejects unsafe names containing path separators. - */ - public function test_render_rejects_component_name_with_slash(): void { - $this->setExpectedIncorrectUsage( ComponentLoader::class . '::render' ); - - ob_start(); - ComponentLoader::render( '../Button', [ 'label' => 'Test' ] ); - $output = ob_get_clean(); - - $this->assertEmpty( $output ); - } - - /** - * Test get() rejects unsafe names containing directory traversal tokens. - */ - public function test_get_rejects_component_name_with_dot_dot(): void { - $this->setExpectedIncorrectUsage( ComponentLoader::class . '::render' ); - - $output = ComponentLoader::get( '..' ); - - $this->assertSame( '', $output ); - } - - /** - * Test get() returns component HTML and does not echo directly. - */ - public function test_get_returns_markup_without_direct_output(): void { - $this->expectOutputString( '' ); - $markup = ComponentLoader::get( 'Button', [ 'label' => 'Buffered' ] ); - - $this->assertStringContainsString( 'Buffered', $markup ); - $this->assertStringContainsString( 'setExpectedIncorrectUsage( ComponentLoader::class . '::render' ); - - ob_start(); - ComponentLoader::render( '..\\Button', [ 'label' => 'Test' ] ); - $output = ob_get_clean(); - - $this->assertEmpty( $output ); - } - - /** - * Test render accepts names with surrounding whitespace after normalization. - */ - public function test_render_trims_component_name_before_resolving(): void { - ob_start(); - ComponentLoader::render( ' Button ', [ 'label' => 'Trimmed' ] ); - $output = ob_get_clean(); - - $this->assertStringContainsString( 'Trimmed', $output ); - } - - /** - * Test render rejects empty names after normalization. - */ - public function test_render_rejects_empty_component_name(): void { - $this->setExpectedIncorrectUsage( ComponentLoader::class . '::render' ); - - ob_start(); - ComponentLoader::render( ' ', [ '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 non-array component paths filter return is handled safely. - */ - public function test_component_paths_filter_non_array_return_is_handled(): void { - $this->setExpectedIncorrectUsage( ComponentLoader::class . '::render' ); - - $callback = function () { - return 'invalid-paths'; - }; - - add_filter( 'elementary_theme_component_paths', $callback ); - - ob_start(); - ComponentLoader::render( 'Button', [ 'label' => 'Test' ] ); - $output = ob_get_clean(); - - remove_filter( 'elementary_theme_component_paths', $callback ); - - $this->assertEmpty( $output ); - } - - /** - * Test malformed path entries are ignored while valid entries still resolve. - */ - public function test_component_paths_filter_malformed_entries_are_ignored(): void { - $callback = function ( $paths ) { - return [ - 'theme' => $paths['theme'], - 'plugin' => [ 'not-a-string-path' ], - '' => '/tmp', - ]; - }; - - add_filter( 'elementary_theme_component_paths', $callback ); - - ob_start(); - ComponentLoader::render( 'Button', [ 'label' => 'Sanitized Paths' ] ); - $output = ob_get_clean(); - - remove_filter( 'elementary_theme_component_paths', $callback ); - - $this->assertStringContainsString( 'Sanitized Paths', $output ); - } - - - /** - * Test child theme templates resolve before plugin components. - */ - public function test_child_theme_component_resolves_before_plugin_component(): void { - $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-child-theme-component-' . uniqid( '', true ); - $child_root = $tmp_dir . '/child'; - $parent_root = $tmp_dir . '/parent'; - $plugin_root = $tmp_dir . '/plugin'; - $child_component = $child_root . '/components/ChildFirst'; - $plugin_component = $plugin_root . '/ChildFirst'; - $child_file = $child_component . '/ChildFirst.php'; - $plugin_file = $plugin_component . '/ChildFirst.php'; - - mkdir( $child_component, 0755, true ); // phpcs:ignore - mkdir( $parent_root, 0755, true ); // phpcs:ignore - mkdir( $plugin_component, 0755, true ); // phpcs:ignore - - file_put_contents( $child_file, ' $plugin_root, - ]; - return $paths; - }; - $stylesheet_callback = function () use ( $child_root ) { - return $child_root; - }; - $template_callback = function () use ( $parent_root ) { - return $parent_root; - }; - - add_filter( 'elementary_theme_component_paths', $paths_callback ); - add_filter( 'stylesheet_directory', $stylesheet_callback ); - add_filter( 'template_directory', $template_callback ); - - try { - $output = ComponentLoader::get( 'ChildFirst' ); - } finally { - remove_filter( 'elementary_theme_component_paths', $paths_callback ); - remove_filter( 'stylesheet_directory', $stylesheet_callback ); - remove_filter( 'template_directory', $template_callback ); - - foreach ( [ $child_file, $plugin_file ] as $file ) { - if ( is_file( $file ) ) { - unlink( $file ); // phpcs:ignore - } - } - - foreach ( [ $child_component, $plugin_component, $child_root . '/components', $child_root, $parent_root, $plugin_root, $tmp_dir ] as $dir ) { - if ( is_dir( $dir ) ) { - rmdir( $dir ); // phpcs:ignore - } - } - } - - $this->assertStringContainsString( 'child-theme-component', $output ); - $this->assertStringNotContainsString( 'plugin-component', $output ); - } - - /** - * Test parent theme templates resolve when child theme does not provide one. - */ - public function test_parent_theme_component_resolves_before_plugin_component(): void { - $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-parent-theme-component-' . uniqid( '', true ); - $child_root = $tmp_dir . '/child'; - $parent_root = $tmp_dir . '/parent'; - $plugin_root = $tmp_dir . '/plugin'; - $parent_component = $parent_root . '/components/ParentFirst'; - $plugin_component = $plugin_root . '/ParentFirst'; - $parent_file = $parent_component . '/ParentFirst.php'; - $plugin_file = $plugin_component . '/ParentFirst.php'; - - mkdir( $child_root, 0755, true ); // phpcs:ignore - mkdir( $parent_component, 0755, true ); // phpcs:ignore - mkdir( $plugin_component, 0755, true ); // phpcs:ignore - - file_put_contents( $parent_file, ' $plugin_root, - ]; - return $paths; - }; - $stylesheet_callback = function () use ( $child_root ) { - return $child_root; - }; - $template_callback = function () use ( $parent_root ) { - return $parent_root; - }; - - add_filter( 'elementary_theme_component_paths', $paths_callback ); - add_filter( 'stylesheet_directory', $stylesheet_callback ); - add_filter( 'template_directory', $template_callback ); - - try { - $output = ComponentLoader::get( 'ParentFirst' ); - } finally { - remove_filter( 'elementary_theme_component_paths', $paths_callback ); - remove_filter( 'stylesheet_directory', $stylesheet_callback ); - remove_filter( 'template_directory', $template_callback ); - - foreach ( [ $parent_file, $plugin_file ] as $file ) { - if ( is_file( $file ) ) { - unlink( $file ); // phpcs:ignore - } - } - - foreach ( [ $parent_component, $plugin_component, $parent_root . '/components', $child_root, $parent_root, $plugin_root, $tmp_dir ] as $dir ) { - if ( is_dir( $dir ) ) { - rmdir( $dir ); // phpcs:ignore - } - } - } - - $this->assertStringContainsString( 'parent-theme-component', $output ); - $this->assertStringNotContainsString( 'plugin-component', $output ); - } - - /** - * Test plugin components resolve when no child or parent theme template exists. - */ - public function test_plugin_component_resolves_when_theme_template_is_absent(): void { - $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-plugin-fallback-component-' . uniqid( '', true ); - $child_root = $tmp_dir . '/child'; - $parent_root = $tmp_dir . '/parent'; - $plugin_root = $tmp_dir . '/plugin'; - $plugin_component = $plugin_root . '/PluginFallback'; - $plugin_file = $plugin_component . '/PluginFallback.php'; - - mkdir( $child_root, 0755, true ); // phpcs:ignore - mkdir( $parent_root, 0755, true ); // phpcs:ignore - mkdir( $plugin_component, 0755, true ); // phpcs:ignore - - file_put_contents( $plugin_file, ' $plugin_root, - ]; - return $paths; - }; - $stylesheet_callback = function () use ( $child_root ) { - return $child_root; - }; - $template_callback = function () use ( $parent_root ) { - return $parent_root; - }; - - add_filter( 'elementary_theme_component_paths', $paths_callback ); - add_filter( 'stylesheet_directory', $stylesheet_callback ); - add_filter( 'template_directory', $template_callback ); - - try { - $output = ComponentLoader::get( 'PluginFallback' ); - } finally { - remove_filter( 'elementary_theme_component_paths', $paths_callback ); - remove_filter( 'stylesheet_directory', $stylesheet_callback ); - remove_filter( 'template_directory', $template_callback ); - - if ( is_file( $plugin_file ) ) { - unlink( $plugin_file ); // phpcs:ignore - } - - foreach ( [ $plugin_component, $child_root, $parent_root, $plugin_root, $tmp_dir ] as $dir ) { - if ( is_dir( $dir ) ) { - rmdir( $dir ); // phpcs:ignore - } - } - } - - $this->assertStringContainsString( 'plugin-fallback-component', $output ); - } - - - /** - * Test child theme asset lookup derives from the configured theme asset directory. - */ - public function test_child_theme_asset_lookup_uses_theme_asset_config(): void { - $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-child-theme-config-assets-' . uniqid( '', true ); - $parent_root = $tmp_dir . '/parent'; - $child_root = $tmp_dir . '/child'; - $plugin_root = $tmp_dir . '/plugin-components'; - $child_css_dir = $child_root . '/custom-build/styles'; - $component_dir = $plugin_root . '/ConfiguredAsset'; - $component_file = $component_dir . '/ConfiguredAsset.php'; - $child_css_file = $child_css_dir . '/configuredasset.css'; - $component_assets = null; - - foreach ( [ $child_css_dir, $component_dir ] as $dir ) { - mkdir( $dir, 0755, true ); // phpcs:ignore - } - - file_put_contents( $component_file, ' $plugin_root, - ]; - return $paths; - }; - $before_component_callback = function ( $name, $args, $options ) use ( &$component_assets ) { - $component_assets = $options['component']['assets'] ?? null; - }; - - add_filter( 'template_directory', $template_callback ); - add_filter( 'template_directory_uri', $template_uri_callback ); - add_filter( 'stylesheet_directory', $stylesheet_callback ); - add_filter( 'stylesheet_directory_uri', $stylesheet_uri_callback ); - add_filter( 'elementary_theme_component_paths', $paths_callback ); - add_action( 'elementary_theme_before_get_component', $before_component_callback, 10, 3 ); - - try { - $output = ComponentLoader::get( - 'ConfiguredAsset', - [], - [ - 'style' => true, - 'script' => false, - ] - ); - } finally { - remove_filter( 'template_directory', $template_callback ); - remove_filter( 'template_directory_uri', $template_uri_callback ); - remove_filter( 'stylesheet_directory', $stylesheet_callback ); - remove_filter( 'stylesheet_directory_uri', $stylesheet_uri_callback ); - remove_filter( 'elementary_theme_component_paths', $paths_callback ); - remove_action( 'elementary_theme_before_get_component', $before_component_callback ); - - foreach ( [ $child_css_file, $component_file ] as $file ) { - if ( is_file( $file ) ) { - unlink( $file ); // phpcs:ignore - } - } - - foreach ( - [ - $component_dir, - $plugin_root, - $child_css_dir, - $child_root . '/custom-build', - $child_root, - $parent_root, - $tmp_dir, - ] as $dir - ) { - if ( is_dir( $dir ) ) { - rmdir( $dir ); // phpcs:ignore - } - } - } - - $this->assertStringContainsString( 'configured-asset-component', $output ); - $this->assertIsArray( $component_assets ); - $this->assertSame( $child_css_file, $component_assets['style']['file'] ); - $this->assertSame( 'https://child.example/custom-build/styles/configuredasset.css', $component_assets['style']['url'] ); - $this->assertArrayNotHasKey( 'script', $component_assets ); - } - - /** - * Test PHP-only path configs render without asset config. - */ - public function test_php_only_component_path_config_renders_without_assets(): void { - $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-php-only-components-' . uniqid( '', true ); - $button_dir = $tmp_dir . '/PhpOnly'; - $button_file = $button_dir . '/PhpOnly.php'; - - mkdir( $button_dir, 0755, true ); // phpcs:ignore - - file_put_contents( // phpcs:ignore - $button_file, - ' $tmp_dir, - ]; - return $paths; - }; - - add_filter( 'elementary_theme_component_paths', $callback ); - - try { - ob_start(); - ComponentLoader::render( 'PhpOnly', [ 'label' => 'Test' ] ); - $output = ob_get_clean(); - } finally { - remove_filter( 'elementary_theme_component_paths', $callback ); - - if ( is_file( $button_file ) ) { - unlink( $button_file ); // phpcs:ignore - } - - if ( is_dir( $button_dir ) ) { - rmdir( $button_dir ); // phpcs:ignore - } - - if ( is_dir( $tmp_dir ) ) { - rmdir( $tmp_dir ); // phpcs:ignore - } - } - - $this->assertStringContainsString( 'php-only-button', $output ); - } - - /** - * Test malformed asset metadata falls back safely. - */ - public function test_malformed_component_asset_metadata_falls_back_safely(): void { - $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-malformed-asset-meta-' . uniqid( '', true ); - $component_root = $tmp_dir . '/components'; - $style_root = $tmp_dir . '/css'; - $button_dir = $component_root . '/MalformedAsset'; - $button_file = $button_dir . '/MalformedAsset.php'; - $button_css_file = $style_root . '/malformedasset.css'; - $button_asset = $style_root . '/malformedasset.asset.php'; - - mkdir( $button_dir, 0755, true ); // phpcs:ignore - mkdir( $style_root, 0755, true ); // phpcs:ignore - - file_put_contents( // phpcs:ignore - $button_file, - ' "bad-deps", "version" => [ "bad-version" ] ];' - ); - - $callback = function ( $paths ) use ( $component_root, $style_root ) { - $paths['plugin'] = [ - 'php' => $component_root, - 'style' => [ - 'dir' => $style_root, - 'url' => 'https://example.com/css', - ], - ]; - return $paths; - }; - - add_filter( 'elementary_theme_component_paths', $callback ); - - try { - ob_start(); - ComponentLoader::render( 'MalformedAsset', [ 'label' => 'Test' ] ); - $output = ob_get_clean(); - } finally { - remove_filter( 'elementary_theme_component_paths', $callback ); - - foreach ( [ $button_asset, $button_css_file, $button_file ] as $file ) { - if ( is_file( $file ) ) { - unlink( $file ); // phpcs:ignore - } - } - - foreach ( [ $button_dir, $style_root, $component_root, $tmp_dir ] as $dir ) { - if ( is_dir( $dir ) ) { - rmdir( $dir ); // phpcs:ignore - } - } - } - - $this->assertStringContainsString( 'malformed-asset-meta-button', $output ); - } - - /** - * Test component asset metadata files are required only once per request. - */ - public function test_component_asset_metadata_is_cached_between_repeated_renders(): void { - $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-cached-asset-meta-' . uniqid( '', true ); - $component_root = $tmp_dir . '/components'; - $style_root = $tmp_dir . '/css'; - $button_dir = $component_root . '/CachedMeta'; - $button_file = $button_dir . '/CachedMeta.php'; - $button_css_file = $style_root . '/cachedmeta.css'; - $button_asset = $style_root . '/cachedmeta.asset.php'; - - mkdir( $button_dir, 0755, true ); // phpcs:ignore - mkdir( $style_root, 0755, true ); // phpcs:ignore - - file_put_contents( $button_file, ' [], "version" => "test-version" ];' - ); - - $callback = function ( $paths ) use ( $component_root, $style_root ) { - $paths['plugin'] = [ - 'php' => $component_root, - 'style' => [ - 'dir' => $style_root, - 'url' => 'https://example.com/css', - ], - ]; - return $paths; - }; - - add_filter( 'elementary_theme_component_paths', $callback ); - self::reset_component_asset_handles( 'cachedmeta' ); - $require_count = 0; - - $GLOBALS['elementary_test_asset_meta_require_count'] = 0; - - try { - $first_output = ComponentLoader::get( 'CachedMeta', [], [ 'style' => true ] ); - $second_output = ComponentLoader::get( 'CachedMeta', [], [ 'style' => true ] ); - $require_count = $GLOBALS['elementary_test_asset_meta_require_count']; - } finally { - remove_filter( 'elementary_theme_component_paths', $callback ); - self::reset_component_asset_handles( 'cachedmeta' ); - unset( $GLOBALS['elementary_test_asset_meta_require_count'] ); - - foreach ( [ $button_asset, $button_css_file, $button_file ] as $file ) { - if ( is_file( $file ) ) { - unlink( $file ); // phpcs:ignore - } - } - - foreach ( [ $button_dir, $style_root, $component_root, $tmp_dir ] as $dir ) { - if ( is_dir( $dir ) ) { - rmdir( $dir ); // phpcs:ignore - } - } - } - - $this->assertStringContainsString( 'cached-asset-meta-button', $first_output ); - $this->assertStringContainsString( 'cached-asset-meta-button', $second_output ); - $this->assertSame( 1, $require_count ); - } - - /** - * Test Windows-style asset paths are not prefixed with an extra slash. - */ - public function test_component_asset_metadata_path_preserves_windows_drive_prefix(): void { - $method = new ReflectionMethod( ComponentLoader::class, 'get_component_asset_meta' ); - $method->setAccessible( true ); - $method->invoke( null, 'C:\\theme\\assets\\button.js' ); - - $reflection = new ReflectionClass( ComponentLoader::class ); - $property = $reflection->getProperty( 'asset_meta_cache' ); - $property->setAccessible( true ); - $cache = $property->getValue(); - - $this->assertArrayHasKey( 'C:\\theme\\assets\\button.asset.php', $cache ); - $this->assertArrayNotHasKey( '/C:/theme/assets/button.asset.php', $cache ); - } - - /** - * Test enqueue defaults prevent disabled assets from being collected. - */ - public function test_enqueue_defaults_disable_asset_collection(): void { - $component_assets = null; - - $enqueue_callback = function () { - return [ - 'script' => false, - 'style' => false, - ]; - }; - - $action_callback = function ( $name, $args, $options ) use ( &$component_assets ) { - $component_assets = $options['component']['assets'] ?? null; - }; - - add_filter( 'elementary_theme_component_enqueue_defaults', $enqueue_callback ); - add_action( 'elementary_theme_before_get_component', $action_callback, 10, 3 ); - - try { - $output = ComponentLoader::get( 'Button', [ 'label' => 'No Assets' ] ); - } finally { - remove_filter( 'elementary_theme_component_enqueue_defaults', $enqueue_callback ); - remove_action( 'elementary_theme_before_get_component', $action_callback ); - } - - $this->assertStringContainsString( 'No Assets', $output ); - $this->assertSame( [], $component_assets ); - } - - /** - * Test render options override enqueue defaults for asset collection. - */ - public function test_enqueue_options_override_defaults_for_asset_collection(): void { - $component_assets = null; - - $enqueue_callback = function () { - return [ - 'script' => false, - 'style' => false, - ]; - }; - - $action_callback = function ( $name, $args, $options ) use ( &$component_assets ) { - $component_assets = $options['component']['assets'] ?? null; - }; - - add_filter( 'elementary_theme_component_enqueue_defaults', $enqueue_callback ); - add_action( 'elementary_theme_before_get_component', $action_callback, 10, 3 ); - - try { - $output = ComponentLoader::get( - 'Button', - [ 'label' => 'Style Only' ], - [ - 'script' => false, - 'style' => true, - ] - ); - } finally { - remove_filter( 'elementary_theme_component_enqueue_defaults', $enqueue_callback ); - remove_action( 'elementary_theme_before_get_component', $action_callback ); - } - - $this->assertStringContainsString( 'Style Only', $output ); - $this->assertIsArray( $component_assets ); - $this->assertArrayHasKey( 'style', $component_assets ); - $this->assertArrayNotHasKey( 'script', $component_assets ); - } - - /** - * Test script-only enqueue options collect only script assets. - */ - public function test_script_only_enqueue_options_collect_only_script_assets(): void { - $component_assets = null; - - $action_callback = function ( $name, $args, $options ) use ( &$component_assets ) { - $component_assets = $options['component']['assets'] ?? null; - }; - - add_action( 'elementary_theme_before_get_component', $action_callback, 10, 3 ); - - try { - $output = ComponentLoader::get( - 'Button', - [ 'label' => 'Script Only' ], - [ - 'script' => true, - 'style' => false, - ] - ); - } finally { - remove_action( 'elementary_theme_before_get_component', $action_callback ); - } - - $this->assertStringContainsString( 'Script Only', $output ); - $this->assertIsArray( $component_assets ); - $this->assertArrayHasKey( 'script', $component_assets ); - $this->assertArrayNotHasKey( 'style', $component_assets ); - } - - /** - * Test enqueue defaults prevent disabled assets from being enqueued. - */ - public function test_enqueue_defaults_disable_asset_enqueueing(): void { - $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-disabled-asset-enqueue-' . uniqid( '', true ); - $component_root = $tmp_dir . '/components'; - $style_root = $tmp_dir . '/css'; - $script_root = $tmp_dir . '/js'; - $button_dir = $component_root . '/DisabledAsset'; - $button_file = $button_dir . '/DisabledAsset.php'; - $button_css = $style_root . '/disabledasset.css'; - $button_js = $script_root . '/disabledasset.js'; - - mkdir( $button_dir, 0755, true ); // phpcs:ignore - mkdir( $style_root, 0755, true ); // phpcs:ignore - mkdir( $script_root, 0755, true ); // phpcs:ignore - - file_put_contents( $button_file, ' $component_root, - 'style' => [ - 'dir' => $style_root, - 'url' => 'https://example.com/css', - ], - 'script' => [ - 'dir' => $script_root, - 'url' => 'https://example.com/js', - ], - ]; - return $paths; - }; - - $enqueue_callback = function () { - return [ - 'script' => false, - 'style' => false, - ]; - }; - - add_filter( 'elementary_theme_component_paths', $paths_callback ); - add_filter( 'elementary_theme_component_enqueue_defaults', $enqueue_callback ); - - self::reset_component_asset_handles( 'disabledasset' ); - - try { - $output = ComponentLoader::get( 'DisabledAsset', [ 'label' => 'No Enqueue' ] ); - } finally { - remove_filter( 'elementary_theme_component_paths', $paths_callback ); - remove_filter( 'elementary_theme_component_enqueue_defaults', $enqueue_callback ); - - self::reset_component_asset_handles( 'disabledasset' ); - - foreach ( [ $button_js, $button_css, $button_file ] as $file ) { - if ( is_file( $file ) ) { - unlink( $file ); // phpcs:ignore - } - } - - foreach ( [ $button_dir, $style_root, $script_root, $component_root, $tmp_dir ] as $dir ) { - if ( is_dir( $dir ) ) { - rmdir( $dir ); // phpcs:ignore - } - } - } - - $this->assertStringContainsString( 'disabled-asset-enqueue-button', $output ); - $this->assertFalse( wp_style_is( 'elementary-theme-component-disabledasset-style', 'enqueued' ) ); - $this->assertFalse( wp_script_is( 'elementary-theme-component-disabledasset-script', 'enqueued' ) ); - } - - /** - * Test render options override enqueue defaults before assets are enqueued. - */ - public function test_enqueue_options_override_defaults_before_enqueueing_assets(): void { - $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-override-asset-enqueue-' . uniqid( '', true ); - $component_root = $tmp_dir . '/components'; - $style_root = $tmp_dir . '/css'; - $script_root = $tmp_dir . '/js'; - $button_dir = $component_root . '/OverrideAsset'; - $button_file = $button_dir . '/OverrideAsset.php'; - $button_css = $style_root . '/overrideasset.css'; - $button_js = $script_root . '/overrideasset.js'; - - mkdir( $button_dir, 0755, true ); // phpcs:ignore - mkdir( $style_root, 0755, true ); // phpcs:ignore - mkdir( $script_root, 0755, true ); // phpcs:ignore - - file_put_contents( $button_file, ' $component_root, - 'style' => [ - 'dir' => $style_root, - 'url' => 'https://example.com/css', - ], - 'script' => [ - 'dir' => $script_root, - 'url' => 'https://example.com/js', - ], - ]; - return $paths; - }; - - $enqueue_callback = function () { - return [ - 'script' => false, - 'style' => false, - ]; - }; - - add_filter( 'elementary_theme_component_paths', $paths_callback ); - add_filter( 'elementary_theme_component_enqueue_defaults', $enqueue_callback ); - - self::reset_component_asset_handles( 'overrideasset' ); - - try { - $output = ComponentLoader::get( - 'OverrideAsset', - [ 'label' => 'Script Override' ], - [ - 'script' => true, - 'style' => false, - ] - ); - - $this->assertStringContainsString( 'override-asset-enqueue-button', $output ); - $this->assertTrue( wp_script_is( 'elementary-theme-component-overrideasset-script', 'enqueued' ) ); - $this->assertFalse( wp_style_is( 'elementary-theme-component-overrideasset-style', 'enqueued' ) ); - } finally { - remove_filter( 'elementary_theme_component_paths', $paths_callback ); - remove_filter( 'elementary_theme_component_enqueue_defaults', $enqueue_callback ); - - self::reset_component_asset_handles( 'overrideasset' ); - - foreach ( [ $button_js, $button_css, $button_file ] as $file ) { - if ( is_file( $file ) ) { - unlink( $file ); // phpcs:ignore - } - } - - foreach ( [ $button_dir, $style_root, $script_root, $component_root, $tmp_dir ] as $dir ) { - if ( is_dir( $dir ) ) { - rmdir( $dir ); // phpcs:ignore - } - } - } - } - - /** - * Test registered-but-dequeued component asset handles are enqueued again. - */ - public function test_registered_dequeued_component_assets_are_enqueued_on_later_render(): void { - $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-reenqueue-asset-' . uniqid( '', true ); - $component_root = $tmp_dir . '/components'; - $style_root = $tmp_dir . '/css'; - $script_root = $tmp_dir . '/js'; - $button_dir = $component_root . '/ReenqueueAsset'; - $button_file = $button_dir . '/ReenqueueAsset.php'; - $button_css = $style_root . '/reenqueueasset.css'; - $button_js = $script_root . '/reenqueueasset.js'; - $style_handle = 'elementary-theme-component-reenqueueasset-style'; - $script_handle = 'elementary-theme-component-reenqueueasset-script'; - - mkdir( $button_dir, 0755, true ); // phpcs:ignore - mkdir( $style_root, 0755, true ); // phpcs:ignore - mkdir( $script_root, 0755, true ); // phpcs:ignore - - file_put_contents( $button_file, ' $component_root, - 'style' => [ - 'dir' => $style_root, - 'url' => 'https://example.com/css', - ], - 'script' => [ - 'dir' => $script_root, - 'url' => 'https://example.com/js', - ], - ]; - return $paths; - }; - - add_filter( 'elementary_theme_component_paths', $paths_callback ); - self::reset_component_asset_handles( 'reenqueueasset' ); - - try { - ComponentLoader::get( - 'ReenqueueAsset', - [], - [ - 'style' => true, - 'script' => true, - ] - ); - - wp_dequeue_style( $style_handle ); - wp_dequeue_script( $script_handle ); - - $this->assertTrue( wp_style_is( $style_handle, 'registered' ) ); - $this->assertTrue( wp_script_is( $script_handle, 'registered' ) ); - $this->assertFalse( wp_style_is( $style_handle, 'enqueued' ) ); - $this->assertFalse( wp_script_is( $script_handle, 'enqueued' ) ); - - $output = ComponentLoader::get( - 'ReenqueueAsset', - [], - [ - 'style' => true, - 'script' => true, - ] - ); - - $this->assertStringContainsString( 'reenqueue-asset-button', $output ); - $this->assertTrue( wp_style_is( $style_handle, 'enqueued' ) ); - $this->assertTrue( wp_script_is( $script_handle, 'enqueued' ) ); - } finally { - remove_filter( 'elementary_theme_component_paths', $paths_callback ); - self::reset_component_asset_handles( 'reenqueueasset' ); - - foreach ( [ $button_js, $button_css, $button_file ] as $file ) { - if ( is_file( $file ) ) { - unlink( $file ); // phpcs:ignore - } - } - - foreach ( [ $button_dir, $style_root, $script_root, $component_root, $tmp_dir ] as $dir ) { - if ( is_dir( $dir ) ) { - rmdir( $dir ); // phpcs:ignore - } - } - } - } - - /** - * Test nested components inherit disabled enqueue options. - */ - public function test_nested_components_inherit_disabled_enqueue_options(): void { - $tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-nested-disabled-assets-' . uniqid( '', true ); - $component_root = $tmp_dir . '/components'; - $style_root = $tmp_dir . '/css'; - $script_root = $tmp_dir . '/js'; - $button_css = $style_root . '/button.css'; - $button_js = $script_root . '/button.js'; - $component_assets = []; - - mkdir( $component_root, 0755, true ); // phpcs:ignore - mkdir( $style_root, 0755, true ); // phpcs:ignore - mkdir( $script_root, 0755, true ); // phpcs:ignore - - file_put_contents( $button_css, '.elementary-button { color: inherit; }' ); // phpcs:ignore - file_put_contents( $button_js, 'window.elementaryNestedButton = true;' ); // phpcs:ignore - - $paths_callback = function ( $paths ) use ( $component_root, $style_root, $script_root ) { - $paths['plugin'] = [ - 'php' => $component_root, - 'style' => [ - 'dir' => $style_root, - 'url' => 'https://example.com/css', - ], - 'script' => [ - 'dir' => $script_root, - 'url' => 'https://example.com/js', - ], - ]; - return $paths; - }; - - $action_callback = function ( $name, $args, $options ) use ( &$component_assets ) { - $component_assets[ $name ] = $options['component']['assets'] ?? null; - }; - - add_filter( 'elementary_theme_component_paths', $paths_callback ); - add_action( 'elementary_theme_before_get_component', $action_callback, 10, 3 ); - - try { - $output = ComponentLoader::get( - 'Card', - [ - 'title' => 'Nested Disabled Assets', - 'url' => 'https://example.com', - ], - [ - 'script' => false, - 'style' => false, - ] - ); - } finally { - remove_filter( 'elementary_theme_component_paths', $paths_callback ); - remove_action( 'elementary_theme_before_get_component', $action_callback ); - - foreach ( [ $button_js, $button_css ] as $file ) { - if ( is_file( $file ) ) { - unlink( $file ); // phpcs:ignore - } - } - - foreach ( [ $style_root, $script_root, $component_root, $tmp_dir ] as $dir ) { - if ( is_dir( $dir ) ) { - rmdir( $dir ); // phpcs:ignore - } - } - } - - $this->assertStringContainsString( 'Nested Disabled Assets', $output ); - $this->assertSame( [], $component_assets['Card'] ); - $this->assertSame( [], $component_assets['Button'] ); - } - - /** - * Test repeated renders use fresh arguments when lookup data is cached. - */ - public function test_repeated_renders_use_fresh_arguments(): void { - $first_output = ComponentLoader::get( 'Button', [ 'label' => 'First Render' ] ); - $second_output = ComponentLoader::get( 'Button', [ 'label' => 'Second Render' ] ); - - $this->assertStringContainsString( 'First Render', $first_output ); - $this->assertStringNotContainsString( 'Second Render', $first_output ); - $this->assertStringContainsString( 'Second Render', $second_output ); - $this->assertStringNotContainsString( 'First Render', $second_output ); - } - - /** - * Test cached lookup data stays sensitive to filtered path changes. - */ - public function test_component_lookup_cache_is_sensitive_to_filtered_paths(): void { - $first_tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-cache-first-' . uniqid( '', true ); - $second_tmp_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/elementary-test-cache-second-' . uniqid( '', true ); - $active_tmp_dir = $first_tmp_dir; - - $first_button_dir = $first_tmp_dir . '/CacheProbe'; - $second_button_dir = $second_tmp_dir . '/CacheProbe'; - $first_button_file = $first_button_dir . '/CacheProbe.php'; - $second_button_file = $second_button_dir . '/CacheProbe.php'; - - mkdir( $first_button_dir, 0755, true ); // phpcs:ignore - mkdir( $second_button_dir, 0755, true ); // phpcs:ignore - - file_put_contents( // phpcs:ignore - $first_button_file, - ' $active_tmp_dir, - ]; - return $paths; - }; - - add_filter( 'elementary_theme_component_paths', $callback ); - - try { - $first_output = ComponentLoader::get( 'CacheProbe', [ 'label' => 'Test' ] ); - $active_tmp_dir = $second_tmp_dir; - $second_output = ComponentLoader::get( 'CacheProbe', [ 'label' => 'Test' ] ); - } finally { - remove_filter( 'elementary_theme_component_paths', $callback ); - - foreach ( [ $first_button_file, $second_button_file ] as $button_file ) { - if ( is_file( $button_file ) ) { - unlink( $button_file ); // phpcs:ignore - } - } - - foreach ( [ $first_button_dir, $second_button_dir, $first_tmp_dir, $second_tmp_dir ] as $dir ) { - if ( is_dir( $dir ) ) { - rmdir( $dir ); // phpcs:ignore - } - } - } - - $this->assertStringContainsString( 'cache-first-button', $first_output ); - $this->assertStringContainsString( 'cache-second-button', $second_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 ); - } - - /** - * Test the global get wrapper returns markup without direct output. - */ - public function test_global_get_wrapper_returns_markup_without_direct_output(): void { - $this->expectOutputString( '' ); - $markup = elementary_theme_get_component( 'Button', [ 'label' => 'Global Buffered' ] ); - - $this->assertStringContainsString( 'Global Buffered', $markup ); - $this->assertStringContainsString( 'hasProperty( $property_name ) ) { - continue; - } - - $property = $reflection->getProperty( $property_name ); - $property->setAccessible( true ); - $property->setValue( null, [] ); - } - } -} From 861f4ae66bc07d104bf08de98cb90517b94727f1 Mon Sep 17 00:00:00 2001 From: Bhavik Tank Date: Wed, 27 May 2026 01:44:52 +0530 Subject: [PATCH 18/19] fix: update webpack config js --- webpack.config.js | 104 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/webpack.config.js b/webpack.config.js index a2876323..e37e4e10 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -86,6 +86,7 @@ const CSS_FILENAME = '../css/[name].css'; const FRONTEND_AND_ADMIN_DIRS = [ 'frontend', 'admin' ]; const EDITOR_DIRS = [ 'editor' ]; const MODULES_DIR = 'modules'; +const COMPONENTS_DIR = rootPath( 'src', 'components' ); const STYLE_ONLY_IGNORED_PLUGINS = [ 'DependencyExtractionWebpackPlugin', 'RtlCssPlugin', @@ -185,6 +186,44 @@ const readAllFileEntries = ( return entries; }; +/** + * Read component entry files from src/components/{component}/{component}.{ext}. + * + * @param {string} dir Component source directory. + * @param {RegExp} pattern File extension pattern to match. + * @return {Object} Object mapping component entry names to file paths. + */ +const getComponentEntries = ( dir = COMPONENTS_DIR, pattern = /\.(js|s[ac]ss)$/ ) => { + const entries = {}; + + if ( ! fs.existsSync( dir ) ) { + return entries; + } + + fs.readdirSync( dir, { withFileTypes: true } ).forEach( ( entry ) => { + if ( ! entry.isDirectory() || entry.name.startsWith( '_' ) || entry.name.startsWith( '.' ) ) { + return; + } + + const componentFile = path.join( dir, entry.name, entry.name ); + const matchedFile = fs + .readdirSync( path.join( dir, entry.name ), { withFileTypes: true } ) + .find( ( file ) => { + if ( ! file.isFile() || ! pattern.test( file.name ) ) { + return false; + } + + return path.join( dir, entry.name, file.name ).replace( /\.[^/.]+$/, '' ) === componentFile; + } ); + + if ( matchedFile ) { + entries[ `components/${ entry.name }` ] = path.join( dir, entry.name, matchedFile.name ); + } + } ); + + return entries; +}; + class CleanBuildPlugin { static cleaned = false; @@ -257,6 +296,41 @@ class CssAssetRtlPlugin { } } +class CssAssetMetadataPlugin { + /** + * Emit a minimal WordPress asset metadata file next to each component CSS file. + * + * @param {import('webpack').Compiler} compiler Webpack compiler. + */ + apply( compiler ) { + compiler.hooks.compilation.tap( 'CssAssetMetadataPlugin', ( compilation ) => { + compilation.hooks.processAssets.tap( + { + name: 'CssAssetMetadataPlugin', + stage: compilation.PROCESS_ASSETS_STAGE_ADDITIONS, + }, + () => { + for ( const filename of Object.keys( compilation.assets ) ) { + if ( + path.extname( filename ) !== '.css' || + filename.endsWith( '-rtl.css' ) || + ! filename.includes( '/components/' ) + ) { + continue; + } + + const assetFilename = filename.replace( /\.css$/, '.asset.php' ); + + compilation.assets[ assetFilename ] = new webpack.sources.RawSource( + ` array(), 'version' => '${ compilation.hash }');\n`, + ); + } + }, + ); + } ); + } +} + /** * Force MiniCssExtractPlugin to emit CSS into the sibling CSS build directory. * @@ -453,6 +527,31 @@ const scripts = { ], }; +// Component JS entry points from src/components/{component}/{component}.js. +const componentScripts = { + ...sharedNonHotConfig, + entry: () => getComponentEntries( COMPONENTS_DIR, /\.js$/ ), + plugins: [ + ...sharedNonHotConfig.plugins.filter( isNotPlugin( 'RtlCssPlugin' ) ), + ], +}; + +// Component SCSS entry points from src/components/{component}/{component}.scss. +const componentStyles = { + ...sharedNonHotConfig, + entry: () => getComponentEntries( COMPONENTS_DIR, /\.s[ac]ss$/ ), + module: { + ...sharedNonHotConfig.module, + }, + plugins: [ + ...sharedNonHotConfig.plugins.filter( + isNotOneOfPlugins( STYLE_ONLY_IGNORED_PLUGINS ), + ), + new CssAssetMetadataPlugin(), + new CssAssetRtlPlugin(), + ], +}; + // Editor JS entry points keep webpack-dev-server HMR/Fast Refresh. const editorScripts = { ...sharedConfig, @@ -480,4 +579,7 @@ const moduleScripts = { }, }; -module.exports = [ scripts, editorScripts, styles, moduleScripts ]; +const configs = [ scripts, componentScripts, editorScripts, styles, componentStyles, moduleScripts ]; + +module.exports = configs; +module.exports.getComponentEntries = getComponentEntries; From 154dbbfd0239511528f58652d52b56e3ac91d912 Mon Sep 17 00:00:00 2001 From: Bhavik Tank Date: Wed, 27 May 2026 01:59:43 +0530 Subject: [PATCH 19/19] tests: update webpack tests --- tests/js/jest.config.js | 3 --- tests/js/webpack-config.test.js | 6 +----- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/js/jest.config.js b/tests/js/jest.config.js index ed13ea53..09257dd8 100644 --- a/tests/js/jest.config.js +++ b/tests/js/jest.config.js @@ -4,9 +4,6 @@ module.exports = { transform: { '^.+\\.[jt]sx?$': '/node_modules/@wordpress/scripts/config/babel-transform', }, - setupFiles: [ - '/tests/js/setup-globals', - ], preset: '@wordpress/jest-preset-default', testPathIgnorePatterns: [ '/.git', diff --git a/tests/js/webpack-config.test.js b/tests/js/webpack-config.test.js index 7cec8e2c..ac65afd5 100644 --- a/tests/js/webpack-config.test.js +++ b/tests/js/webpack-config.test.js @@ -7,17 +7,13 @@ const path = require( 'path' ); jest.mock( '@wordpress/scripts/config/webpack.config', () => [ { - module: {}, optimization: { minimizer: [], splitChunks: {}, }, - output: {}, plugins: [], }, - { - output: {}, - }, + {}, ] ); const { getComponentEntries } = require( '../../webpack.config' );