Refactor: Component based PHP rendering#673
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a component-based PHP rendering mechanism for the theme, centered around a single ComponentLoader API that resolves and renders component partials from configurable source paths with a controllable priority order (theme vs plugin).
Changes:
- Added
ComponentLoaderwithrender()(echo) andget()(return string) APIs plus filter-based path + priority resolution. - Added global convenience wrappers (
elementary_theme_component(),elementary_theme_get_component()) and PoCButton+Cardcomponents undersrc/Components/. - Added PHPUnit coverage for core resolution behavior and adjusted PHPCS rules to accommodate component partials.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
inc/Framework/ComponentLoader.php |
New resolver/renderer for PHP component partials with filter-driven paths and priority handling. |
inc/helpers/custom-functions.php |
Adds thin global wrapper functions delegating to ComponentLoader. |
src/Components/Button/Button.php |
PoC render-only Button component (link or button). |
src/Components/Card/Card.php |
PoC render-only Card component demonstrating composition by rendering Button. |
tests/php/inc/Framework/ComponentLoaderTest.php |
Adds PHPUnit tests for component rendering, filters, and priority behavior. |
phpcs.xml.dist |
Excludes component partials from specific sniffs that don’t work well with required partial scoping. |
inc/Framework/Traits/AssetLoaderTrait.php |
Small refactor to make asset meta target fallback explicit. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| $file = trailingslashit( $paths[ $source ] ) . $name . '/' . $name . '.php'; | ||
|
|
||
| if ( file_exists( $file ) && is_readable( $file ) ) { | ||
| return $file; | ||
| } |
| $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 ); | ||
|
|
| // 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', | ||
| '<?php echo "plugin-button";' | ||
| ); | ||
|
|
||
| $callback = function ( $paths ) use ( $tmp_dir ) { | ||
| $paths['plugin'] = $tmp_dir; | ||
| return $paths; | ||
| }; | ||
|
|
||
| add_filter( 'elementary_theme_component_paths', $callback ); | ||
|
|
||
| // With priority='plugin', the plugin Button should be used. | ||
| ob_start(); | ||
| ComponentLoader::render( 'Button', [ 'label' => '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 | ||
|
|
| /** | ||
| * Test that the global wrapper delegates to ComponentLoader. | ||
| */ | ||
| public function test_global_wrapper_renders_component(): void { | ||
| ob_start(); |
Adi-ty
left a comment
There was a problem hiding this comment.
- Copilot's review points on directory traversal validation in
get_component_file()and missing test coverage forget()/elementary_theme_get_component()can be addressed. - CI is flagging an alignment issue in
AssetLoaderTrait.phpline 90 — equals sign spacing needs to match the surrounding assignments.
| /** | ||
| * Test the elementary_theme_component_default_priority filter is applied. | ||
| */ | ||
| public function test_default_priority_filter_is_applied(): void { |
There was a problem hiding this comment.
test_default_priority_filter_is_applied only asserts the filter fires — it doesn't verify that returning 'plugin' actually changes resolution order. A regression where the return value is silently ignored would pass this test.
|
@aryanjasala I have resolved the merge conflicts. This PR is ready for review. |
this is added to the component loade and not to the asset because it is alongside the component so needs to be added there
aryanjasala
left a comment
There was a problem hiding this comment.
We should move the Component main loader to https://github.com/rtCamp/wp-framework now and use it here in theme
| ) { | ||
| $handle = 'elementary-theme-component-' . $slug . '-style'; | ||
|
|
||
| if ( self::register_component_style( $handle, $component['assets']['style'] ) ) { |
There was a problem hiding this comment.
Enqueue silently skipped when handle is already registered
register_component_style calls wp_register_style(...) and returns its bool. wp_register_style returns false for two distinct reasons:
- Asset validation failed (URL/file empty or missing) → correctly skip enqueue
- Handle is already registered (e.g., from a previous render of the same component) → should still call
wp_enqueue_style
The current code conflates both cases. The practical failure scenario: something calls wp_dequeue_style($handle) between two renders of the same component. The second render calls register_component_style → wp_register_style returns false (already registered) → wp_enqueue_style is never called → the style is permanently missing from the page.
Recommended fix — separate validation from enqueueing:
if ( self::register_component_style( $handle, $component['assets']['style'] ) || wp_style_is( $handle, 'registered' ) ) {
wp_enqueue_style( $handle );
}Or even simpler: let register_component_style return true even when already registered (validation passed), and only return false for actual validation failures.
| * @return array{dependencies: array<string>, 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 { | ||
| $normalized_file = ltrim( str_replace( '\\', '/', $file ), '/' ); |
There was a problem hiding this comment.
$normalized_file = ltrim( str_replace( '\\', '/', $file ), '/' );
$asset_meta_target = preg_replace( '/\.[^\/.]$/', '', $normalized_file );
$asset_meta_file = '/' . $asset_meta_target . '.asset.php';Stripping the leading / and re-adding it is fragile. On Windows, C:/path/file.js becomes /C:/path/file.asset.php which is invalid. Consider using extension replacement directly:
$asset_meta_file = preg_replace( '/\.[^\/.]$/', '.asset.php', $file );| $asset_meta_target = preg_replace( '/\.[^\/.]+$/', '', $normalized_file ); | ||
| $asset_meta_target = ! empty( $asset_meta_target ) ? $asset_meta_target : $normalized_file; | ||
| $asset_meta_file = '/' . $asset_meta_target . '.asset.php'; | ||
| $asset_meta = is_readable( $asset_meta_file ) ? require $asset_meta_file : []; |
There was a problem hiding this comment.
Every call to get_component_asset_meta() does require $asset_meta_file without caching. If the same component is rendered with different $args (common for Button), the .asset.php file is re-read and re-executed each time.
The .asset.php content is deterministic (build-time generated). Cache by file path:
static $cache = [];
if ( ! isset( $cache[ $asset_meta_file ] ) ) {
$cache[ $asset_meta_file ] = is_readable( $asset_meta_file ) ? require $asset_meta_file : [];
}
$asset_meta = $cache[ $asset_meta_file ];Note: the test helper reset_component_loader_caches() already resets an asset_meta_cache property (test line 1512), suggesting this was planned but never implemented.
| ComponentLoader::render( | ||
| 'Button', | ||
| [ | ||
| 'label' => $title, |
There was a problem hiding this comment.
Nested component does not forward $options
Card renders Button with no $options, so Button always uses default enqueue settings regardless of what was passed to Card. A caller doing ComponentLoader::render('Card', $args, ['script' => false]) expects no scripts — but Button's script is enqueued anyway.
Forward relevant options:
ComponentLoader::render( 'Button', $button_args, array_intersect_key( $options, array_flip( [ 'script', 'style' ] ) ) );Note: test_nested_components_inherit_disabled_enqueue_options passes today NOT because options are forwarded, but because assets/build/ is gitignored and doesn't exist in CI, so get_component_assets() returns [] regardless of options. The test gives false confidence — see also comment on that test.
| </properties> | ||
| <exclude-pattern>tests/bootstrap.php</exclude-pattern> | ||
| <!-- Component partials are always require'd inside ComponentLoader::render(), so variables are method-scoped at runtime. --> | ||
| <exclude-pattern>src/components/*</exclude-pattern> |
There was a problem hiding this comment.
This <exclude-pattern> has extra indentation (double tab) compared to its sibling on line 103.
| @@ -0,0 +1,43 @@ | |||
| <?php | |||
There was a problem hiding this comment.
declare( strict_types = 1 );| * } | ||
| */ | ||
|
|
||
| use rtCamp\Theme\Elementary\Framework\ComponentLoader; |
There was a problem hiding this comment.
use ComponentLoader in a component partial creates loader coupling
Component partials are meant to be pure render files — they should not import the class that executes them. This use statement creates a conceptual dependency: the component knows about (and references) its own loader, which is unexpected for a "dumb" template.
If Card needs to render Button, it can call the global wrapper elementary_theme_component('Button', ...)
There was a problem hiding this comment.
With the new refactor, we might want to make this class a shared instance, and during initiation we might want to set the default component directory in a variable, so that it doesn't need to be added again and again later - as this would be inside framework now and framework would be added via composer.
| * @param array<string, mixed> $options { | ||
| * Optional. Resolution and asset enqueue options. | ||
| * | ||
| * @type string $priority Deprecated. Ignored; components always resolve from child/parent theme before plugin paths. |
There was a problem hiding this comment.
We should remove these deprecated comments as this is currently v1, and there was no version before this where this was supported.
| if ( false === $component ) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
We should throw a RuntimeException or atleast doing_it_wrong if a component doesn't exist, else this would just silently fail
…ary into refactor/component-based-php-rendering
Description
Introduces a component-based PHP rendering system that allows theme and plugin components to be resolved and rendered through a single API with configurable priority.
Technical Details
New class:
ComponentLoader(inc/Framework/ComponentLoader.php)render()method echoes component HTML; staticget()returns it as a string via output buffering.{source_path}/{Name}/{Name}.php.'theme'or'plugin') is configurable per call site via$options['priority'], or project-wide via theelementary_theme_component_default_priorityfilter (defaults to'theme').elementary_theme_component_pathsfilter. Theme path (src/Components/) is registered by default; plugins register their own path through the filter.priority'theme'(default)'plugin'Global wrappers (
inc/helpers/custom-functions.php)elementary_theme_component()→ delegates toComponentLoader::render()elementary_theme_get_component()→ delegates toComponentLoader::get()function_exists().PoC components (
src/Components/)Button/Button.php— renders<a>or<button>based on args.Card/Card.php— renders a card; demonstrates composability by renderingButtoninternally.Components are render-only PHP partials. They receive a
$argsarray and output HTML — no business logic, no data fetching.Checklist
ComponentLoaderclass exists ininc/Framework/ComponentLoader.phpComponentLoader::render()collects paths viaelementary_theme_component_pathsfilter and resolves by prioritypriorityoption accepted ('theme'|'plugin'); defaults to'theme'elementary_theme_component_default_priorityfilter allows project-wide overrideelementary_theme_component()wrapper exists withfunction_exists()guard; delegates entirely toComponentLoader::render()Screenshots
N/A — no visual changes. This is an infrastructure/API addition.
To-do
Fixes/Covers issue
Fixes #637