Skip to content

Refactor: Component based PHP rendering#673

Open
abhishekxix wants to merge 21 commits into
theme-elementary-v2from
refactor/component-based-php-rendering
Open

Refactor: Component based PHP rendering#673
abhishekxix wants to merge 21 commits into
theme-elementary-v2from
refactor/component-based-php-rendering

Conversation

@abhishekxix
Copy link
Copy Markdown
Member

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)

  • Static render() method echoes component HTML; static get() returns it as a string via output buffering.
  • Resolves component files using the pattern {source_path}/{Name}/{Name}.php.
  • Resolution priority ('theme' or 'plugin') is configurable per call site via $options['priority'], or project-wide via the elementary_theme_component_default_priority filter (defaults to 'theme').
  • Component source paths are collected via the elementary_theme_component_paths filter. Theme path (src/Components/) is registered by default; plugins register their own path through the filter.
priority Order checked
'theme' (default) theme → plugin → other sources
'plugin' plugin → theme → other sources

Global wrappers (inc/helpers/custom-functions.php)

  • elementary_theme_component() → delegates to ComponentLoader::render()
  • elementary_theme_get_component() → delegates to ComponentLoader::get()
  • Both guarded with 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 rendering Button internally.

Components are render-only PHP partials. They receive a $args array and output HTML — no business logic, no data fetching.

Checklist

  • ComponentLoader class exists in inc/Framework/ComponentLoader.php
  • ComponentLoader::render() collects paths via elementary_theme_component_paths filter and resolves by priority
  • priority option accepted ('theme' | 'plugin'); defaults to 'theme'
  • elementary_theme_component_default_priority filter allows project-wide override
  • Resolution works correctly in all three project configurations: theme-only, plugin-only, both
  • Global elementary_theme_component() wrapper exists with function_exists() guard; delegates entirely to ComponentLoader::render()
  • Components are render-only — no business logic, no data fetching inside component files
  • Existing partials migrated to the new component structure as PoC (Button + Card)
  • No regressions in existing templates

Screenshots

N/A — no visual changes. This is an infrastructure/API addition.

To-do

  • Component library build-out (TASK-007)
  • Scaffold CLI for generating new components (TASK-009)

Fixes/Covers issue

Fixes #637

@abhishekxix abhishekxix self-assigned this Apr 29, 2026
@abhishekxix abhishekxix requested a review from aryanjasala May 4, 2026 09:04
@abhishekxix abhishekxix marked this pull request as ready for review May 4, 2026 09:04
@aryanjasala aryanjasala requested review from Adi-ty and Copilot May 6, 2026 08:03
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ComponentLoader with render() (echo) and get() (return string) APIs plus filter-based path + priority resolution.
  • Added global convenience wrappers (elementary_theme_component(), elementary_theme_get_component()) and PoC Button + Card components under src/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.

Comment thread inc/Framework/ComponentLoader.php Outdated
Comment on lines +118 to +122
$file = trailingslashit( $paths[ $source ] ) . $name . '/' . $name . '.php';

if ( file_exists( $file ) && is_readable( $file ) ) {
return $file;
}
Comment thread inc/Framework/ComponentLoader.php Outdated
Comment on lines +100 to +111
$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 );

Comment on lines +190 to +225
// 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

Comment on lines +237 to +241
/**
* Test that the global wrapper delegates to ComponentLoader.
*/
public function test_global_wrapper_renders_component(): void {
ob_start();
Copy link
Copy Markdown

@Adi-ty Adi-ty left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Copilot's review points on directory traversal validation in get_component_file() and missing test coverage for get() / elementary_theme_get_component() can be addressed.
  • CI is flagging an alignment issue in AssetLoaderTrait.php line 90 — equals sign spacing needs to match the surrounding assignments.

Comment thread inc/helpers/custom-functions.php
/**
* Test the elementary_theme_component_default_priority filter is applied.
*/
public function test_default_priority_filter_is_applied(): void {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@abhishekxix abhishekxix requested a review from Adi-ty May 7, 2026 08:01
Base automatically changed from refactor/frontend-source-reorganization to theme-elementary-v2 May 13, 2026 21:40
@aryanjasala aryanjasala requested a review from bhavz-10 May 13, 2026 22:06
@aryanjasala aryanjasala assigned Adi-ty and unassigned abhishekxix May 14, 2026
@Adi-ty
Copy link
Copy Markdown

Adi-ty commented May 14, 2026

@aryanjasala I have resolved the merge conflicts. This PR is ready for review.

Copy link
Copy Markdown
Member

@aryanjasala aryanjasala left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should move the Component main loader to https://github.com/rtCamp/wp-framework now and use it here in theme

Comment thread inc/Framework/ComponentLoader.php Outdated
) {
$handle = 'elementary-theme-component-' . $slug . '-style';

if ( self::register_component_style( $handle, $component['assets']['style'] ) ) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Asset validation failed (URL/file empty or missing) → correctly skip enqueue
  2. 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_stylewp_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.

Comment thread inc/Framework/ComponentLoader.php Outdated
* @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 ), '/' );
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$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 );

Comment thread inc/Framework/ComponentLoader.php Outdated
$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 : [];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread phpcs.xml.dist Outdated
</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>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This <exclude-pattern> has extra indentation (double tab) compared to its sibling on line 103.

@@ -0,0 +1,43 @@
<?php
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

declare( strict_types = 1 );

Comment thread src/components/card/card.php Outdated
* }
*/

use rtCamp\Theme\Elementary\Framework\ComponentLoader;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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', ...)

Comment thread inc/Framework/ComponentLoader.php Outdated
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread inc/Framework/ComponentLoader.php Outdated
* @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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should remove these deprecated comments as this is currently v1, and there was no version before this where this was supported.

Comment thread inc/Framework/ComponentLoader.php Outdated
Comment on lines +52 to +54
if ( false === $component ) {
return;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should throw a RuntimeException or atleast doing_it_wrong if a component doesn't exist, else this would just silently fail

@bhavz-10 bhavz-10 self-assigned this May 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants