diff --git a/inc/Framework/ComponentLoader.php b/inc/Framework/ComponentLoader.php new file mode 100644 index 00000000..43d1bcb4 --- /dev/null +++ b/inc/Framework/ComponentLoader.php @@ -0,0 +1,564 @@ +> + */ + private static array $component_data_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 string $priority Deprecated. Ignored; components always resolve from child/parent theme before plugin paths. + * @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 ) { + 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 string $priority Deprecated. Ignored; components always resolve from child/parent theme before plugin paths. + * @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; + } + + $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; + } + + $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 { + $normalized_file = ltrim( str_replace( '\\', '/', $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 : []; + + 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/inc/helpers/custom-functions.php b/inc/helpers/custom-functions.php index 98e7996f..0952dfcc 100644 --- a/inc/helpers/custom-functions.php +++ b/inc/helpers/custom-functions.php @@ -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 ); + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist index bb96a577..45ff914e 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -100,6 +100,8 @@ tests/bootstrap.php + + src/components/* @@ -112,6 +114,8 @@ tests/* + + src/components/*