diff --git a/inc/Framework/ComponentLoader.php b/inc/Framework/ComponentLoader.php new file mode 100644 index 00000000..0d727171 --- /dev/null +++ b/inc/Framework/ComponentLoader.php @@ -0,0 +1,261 @@ + 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 ); + + 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 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 ); + } +} diff --git a/inc/Framework/Traits/AssetLoaderTrait.php b/inc/Framework/Traits/AssetLoaderTrait.php index 59d59034..46f616d4 100644 --- a/inc/Framework/Traits/AssetLoaderTrait.php +++ b/inc/Framework/Traits/AssetLoaderTrait.php @@ -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' => [], 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..a8d65e27 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/*