Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
261 changes: 261 additions & 0 deletions inc/Framework/ComponentLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
<?php
/**
* Component loader for resolving and rendering PHP component partials.
*
* Resolves components from theme or plugin paths with configurable priority.
* Components are render-only PHP files that receive data as arguments and output HTML.
*
* @package rtCamp\Theme\Elementary
*/

declare( strict_types = 1 );

namespace rtCamp\Theme\Elementary\Framework;

/**
* Class ComponentLoader
*
* @since 1.0.0
*/
class ComponentLoader {

/**
* Render a component by name.
*
* Resolves the component file based on registered paths and priority,
* 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 options.
*
* @type string $priority Resolution priority: 'theme' or 'plugin'. Default determined by filter.
* }
*
* @return void
*/
public static function render( string $name, array $args = [], array $options = [] ): void {

$file = self::get_component_file( $name, $options );

if ( false === $file ) {
return;
}

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.
*
* Checks registered paths in priority order and returns the first match.
* Path format: {source_path}/{Name}/{Name}.php
*
* @param string $name Component name.
* @param array $options Resolution options.
*
* @return string|false Full file path on success, false if not found.
*/
private static function get_component_file( string $name, array $options = [] ): string|false {

$component_name = self::normalize_component_name( $name );

if ( false === $component_name ) {
return false;
}

$priority = self::get_priority( $options );

/**
* Filters the registered component paths.
*
* Each entry is keyed by source type ('theme', 'plugin') and maps
* to a directory path where components are stored.
*
* @since 1.0.0
*
* @param array $paths Associative array of source => 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',
],
$component_name,
$options
);

$paths = self::sanitize_component_paths( $paths );

if ( empty( $paths ) ) {
return false;
}

// Order sources based on priority.
$order = self::get_source_order( $priority, $paths );

Comment on lines +106 to +123
foreach ( $order as $source ) {

if ( empty( $paths[ $source ] ) ) {
continue;
}

$file = trailingslashit( $paths[ $source ] ) . $component_name . '/' . $component_name . '.php';

if ( file_exists( $file ) && is_readable( $file ) ) {
return $file;
}
}

return false;
}

/**
* Sanitize component path mappings returned by filters.
*
* @param mixed $paths Potentially filtered paths value.
*
* @return array<string, string> Valid source => path mappings.
*/
private static function sanitize_component_paths( mixed $paths ): array {
if ( ! is_array( $paths ) ) {
return [];
}

$sanitized_paths = [];

foreach ( $paths as $source => $path ) {
if ( ! is_string( $source ) || ! is_string( $path ) ) {
continue;
}

$source = trim( $source );
$path = trim( $path );

if ( '' === $source || '' === $path ) {
continue;
}

$sanitized_paths[ $source ] = $path;
}

return $sanitized_paths;
}

/**
* 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;
}

/**
* 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 );
}
}
5 changes: 3 additions & 2 deletions inc/Framework/Traits/AssetLoaderTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,10 @@ private function register_style( string $handle, string $file, array $deps = [],
*/
private function get_asset_meta( string $file, array $deps = [], string|bool|null $ver = false ): array {
$normalized_file = ltrim( str_replace( '\\', '/', $file ), '/' );
$asset_meta_target = preg_replace( '/\.[^\/.]+$/', '', $normalized_file ) ?: $normalized_file;
$asset_meta_target = preg_replace( '/\.[^\/.]+$/', '', $normalized_file );
$asset_meta_target = $asset_meta_target ? $asset_meta_target : $normalized_file;
$asset_meta_file = sprintf( '%s/%s.asset.php', untrailingslashit( ELEMENTARY_THEME_BUILD_DIR ), $asset_meta_target );
$asset_meta = is_readable( $asset_meta_file )
$asset_meta = is_readable( $asset_meta_file )
? require $asset_meta_file
: [
'dependencies' => [],
Expand Down
42 changes: 41 additions & 1 deletion inc/helpers/custom-functions.php
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Noticed the spec and upcoming TASK-007 / TASK-009 refer to rtcamp_* naming, while this PR introduces elementary_theme_*. Not a major issue, but worth deciding on a direction early so we can update TASK-007 accordingly if needed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@Adi-ty, I have mentioned the same in the task for this PR as well. elementary_theme_* was kept to make it compatible with the auto rename thing that we have going on in theme setup.

Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,44 @@

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

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 );
}
}
4 changes: 4 additions & 0 deletions phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@
</property>
</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>
</rule>

<rule ref="WordPress-Docs">
Expand All @@ -112,6 +114,8 @@

<rule ref="WordPress.WP.GlobalVariablesOverride.Prohibited">
<exclude-pattern>tests/*</exclude-pattern>
<!-- Component partials are always require'd inside ComponentLoader::render(), so variables are method-scoped at runtime. -->
<exclude-pattern>src/Components/*</exclude-pattern>
</rule>

<!--
Expand Down
43 changes: 43 additions & 0 deletions src/Components/Button/Button.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php
/**
* Button component.
*
* A render-only component that outputs a button or link element.
*
* @package rtCamp\Theme\Elementary
*
* @param array $args {
* Component arguments.
*
* @type string $label Button label text. Required.
* @type string $url URL for link buttons. Optional.
* @type string $class Additional CSS classes. Optional.
* @type string $tag HTML tag: 'a' or 'button'. Optional. Defaults to 'a' when $url is set, 'button' otherwise.
* }
*/

$label = $args['label'] ?? '';
$url = $args['url'] ?? '';
$class = $args['class'] ?? '';
$tag = $args['tag'] ?? ( ! empty( $url ) ? 'a' : 'button' );

if ( empty( $label ) ) {
return;
}

$css_class = trim( 'elementary-button ' . $class );

if ( 'a' === $tag && ! empty( $url ) ) {
printf(
'<a href="%s" class="%s">%s</a>',
esc_url( $url ),
esc_attr( $css_class ),
esc_html( $label )
);
} else {
printf(
'<button type="button" class="%s">%s</button>',
esc_attr( $css_class ),
esc_html( $label )
);
}
Loading