diff --git a/source/php/Component/Config/ComponentConfiguration.php b/source/php/Component/Config/ComponentConfiguration.php new file mode 100644 index 00000000..dec1b8f6 --- /dev/null +++ b/source/php/Component/Config/ComponentConfiguration.php @@ -0,0 +1,156 @@ + + */ + private static array $instances = []; + + private string $slug; + + private string $name; + + private string $identifier; + + private string $view; + + /** + * @var array + */ + private array $defaultParameters = []; + + /** + * @var array + */ + private array $types = []; + + /** + * @var array + */ + private array $description = []; + + /** + * @var array + */ + private array $dependencies = []; + + private bool $inputParametersLocked = false; + + /** + * @param array $config + */ + private function __construct(array $config) + { + $this->slug = $config['slug'] ?? ''; + + if ($this->slug === '') { + throw new InvalidArgumentException('Component configuration must define a slug.'); + } + + $this->name = $config['name'] ?? $this->slug; + $this->identifier = $config['identifier'] ?? $this->slug; + $this->view = $config['view'] ?? ($this->slug . '.blade.php'); + $this->types = (array) ($config['types'] ?? []); + $this->description = (array) ($config['description'] ?? []); + // Prefer 'dependencies'; keep 'dependency' for backward compatibility. + $this->dependencies = (array) ($config['dependencies'] ?? $config['dependency'] ?? []); + + $this->initializeInputParameters((array) ($config['default'] ?? [])); + } + + /** + * @param array $config + */ + public static function getInstance(array $config): self + { + $slug = $config['slug'] ?? ''; + + if ($slug === '') { + throw new InvalidArgumentException('Component configuration must define a slug.'); + } + + if (!isset(self::$instances[$slug])) { + self::$instances[$slug] = new self($config); + } + + return self::$instances[$slug]; + } + + public function getName(): string + { + return $this->name; + } + + public function getSlug(): string + { + return $this->slug; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + public function getView(): string + { + return $this->view; + } + + public function getDefaultParameters(): array + { + return $this->defaultParameters; + } + + public function getTypes(): array + { + return $this->types; + } + + public function getDescription(): array + { + return $this->description; + } + + /** + * Returns dependency metadata for the component. + * Uses the "dependencies" config key; "dependency" is supported for backward compatibility. + * + * @deprecated Use the "dependencies" config key; support for "dependency" will be removed in a future release. + * + * @return array + */ + public function getDependencies(): array + { + return $this->dependencies; + } + + /** + * @param array $parameters + */ + private function initializeInputParameters(array $parameters): void + { + $this->defaultParameters = $parameters; + } + + public function setInputParameters(array $parameters): void + { + if ($this->inputParametersLocked) { + throw new InvalidArgumentException( + sprintf('Input parameters for component "%s" are already locked and cannot be changed.', $this->slug) + ); + } + + $this->defaultParameters = $parameters; + $this->inputParametersLocked = true; + } +} diff --git a/source/php/Component/Config/ComponentConfigurationInterface.php b/source/php/Component/Config/ComponentConfigurationInterface.php new file mode 100644 index 00000000..874678a6 --- /dev/null +++ b/source/php/Component/Config/ComponentConfigurationInterface.php @@ -0,0 +1,69 @@ + + */ + public function getDefaultParameters(): array; + + /** + * Returns the expected data types for input parameters. + * + * @return array + */ + public function getTypes(): array; + + /** + * Returns the description metadata for the component. + * + * @return array + */ + public function getDescription(): array; + + /** + * Returns dependency metadata for the component. + * + * @return array + */ + public function getDependencies(): array; + + /** + * Sets the input parameters for the component. + * + * @param array $parameters + * + * Implementations may choose to lock parameter updates after the first call and can surface warnings + * (E_USER_WARNING) on subsequent calls to preserve singleton state. + */ + public function setInputParameters(array $parameters): void; +} diff --git a/source/php/Register.php b/source/php/Register.php index 55f1f113..37d6eeac 100644 --- a/source/php/Register.php +++ b/source/php/Register.php @@ -2,6 +2,8 @@ namespace ComponentLibrary; +use ComponentLibrary\Component\Config\ComponentConfiguration; +use ComponentLibrary\Component\Config\ComponentConfigurationInterface; use ComponentLibrary\Cache\CacheInterface; use ComponentLibrary\Helper\TagSanitizerInterface; use HelsingborgStad\BladeService\BladeServiceInterface; @@ -11,19 +13,19 @@ class Register { - private static $cache = [ - 'configJson' => [], + private static array $cache = [ + 'config' => [], 'fileExists' => [], 'fileGetContents' => [], 'glob' => [], ]; - public $data; - public $cachePath = ""; - public $viewPaths = []; - public $controllerPaths = []; - private $reservedNames = ["data", "class", "list", "lang"]; - private $controllers = []; + public ?object $data = null; + public string $cachePath = ""; + public array $viewPaths = []; + public array $controllerPaths = []; + private array $reservedNames = ["data", "class", "list", "lang"]; + private array $controllers = []; public function __construct( private BladeServiceInterface $blade, @@ -36,11 +38,11 @@ public function __construct( * Add a new component to the system. * * @param string $slug The unique identifier for the component. - * @param array $defaultArgs The default arguments for the component. + * @param array $defaultParameters The default arguments for the component. * @param string|null $view The optional view name for the component. * @throws \Exception if the provided slug is reserved or invalid. */ - public function add($slug, $defaultArgs, $argsTypes = false, $view = null) + public function add(string $slug, array $defaultParameters, array $parameterTypes = [], ?string $view = null): void { //Create utility data object if (is_null($this->data)) { @@ -57,11 +59,11 @@ public function add($slug, $defaultArgs, $argsTypes = false, $view = null) //Adds to full object $this->data->{$slug} = (object) array( - 'slug' => (string) $slug, - 'args' => (object) $defaultArgs, - 'view' => (string) $slug . DIRECTORY_SEPARATOR . $view, - 'controller' => (string) $slug, - 'argsTypes' => (object) $argsTypes + 'slug' => $slug, + 'args' => (object) $defaultParameters, + 'view' => $slug . DIRECTORY_SEPARATOR . $view, + 'controller' => $slug, + 'argsTypes' => (object) $parameterTypes ); $this->blade->registerComponentDirective( ucfirst($slug) . '.' . $slug, $slug); @@ -73,7 +75,7 @@ public function add($slug, $defaultArgs, $argsTypes = false, $view = null) * * @return string The updated object with controller paths */ - public function addControllerPath($path, $prepend = true): array + public function addControllerPath(string $path, bool $prepend = true): array { //Sanitize path $path = rtrim($path, "/"); @@ -98,7 +100,7 @@ public function addControllerPath($path, $prepend = true): array * * @return string The slugs of all registered components */ - public function registerInternalComponents($path): array + public function registerInternalComponents(string $path): array { //Declare $result = array(); @@ -120,21 +122,21 @@ public function registerInternalComponents($path): array //Register the component $this->add( - $config['slug'], - $config['default'], - $config['types'] ?? (object) [], - $config['view'] ? $config['view'] : $config['slug'] . "blade.php" + $config->getSlug(), + $config->getDefaultParameters(), + $config->getTypes(), + $config->getView() ); //Log - $result[] = $config['slug']; + $result[] = $config->getSlug(); } } return $result; } - public function getEngine() + public function getEngine(): BladeServiceInterface { return $this->blade; } @@ -144,7 +146,7 @@ public function getEngine() * * @return string The view name included filetype */ - private function getViewName($slug, $view = null): string + private function getViewName(string $slug, ?string $view = null): string { if (is_null($view)) { $view = $slug . '.blade.php'; @@ -152,7 +154,7 @@ private function getViewName($slug, $view = null): string return $view; } - public function registerViewComposer(object $component) + public function registerViewComposer(object $component): void { try { $this->blade->registerComponent( @@ -165,6 +167,16 @@ function ($view) use ($component) { $viewData = $this->accessProtected($view, 'data'); + + if (!is_array($viewData) && !is_object($viewData)) { + throw new \UnexpectedValueException( + 'View data must be an array or object, received: ' . gettype($viewData) + ); + } + + if (is_object($viewData)) { + $viewData = get_object_vars($viewData); + } $this->handleTypingsErrors($viewData, $component->argsTypes, $component->slug); // Get controller data @@ -189,24 +201,26 @@ function ($view) use ($component) { /** * Handle typing errors for the given view data and arguments types. * - * @param array|object $viewData The view data to check for typing errors. - * @param array|object|false $argsTypes The expected argument types for each key in the view data. + * @param array $viewData The view data to check for typing errors. + * @param array|object $argsTypes The expected argument types for each key in the view data. * @param string $componentSlug The slug of the component being checked. * @return void */ - public function handleTypingsErrors($viewData, $argsTypes, $componentSlug) { + public function handleTypingsErrors(array $viewData, array|object $argsTypes, string $componentSlug): void + { if ($this->shouldHideTypingsErrors()) { return; } - if (empty((array) $argsTypes) || (empty($viewData) && !is_array($viewData))) { + if (is_array($argsTypes) && empty($argsTypes)) { return; } foreach ($viewData as $key => $value) { - if (is_object($argsTypes) && isset($argsTypes->{$key})) { - $types = explode('|', $argsTypes->{$key}); + $types = $this->resolveArgumentTypes($argsTypes, $key); + + if (!empty($types)) { $valueType = gettype($value); // Check if the value is an object, and get its class name without the namespace @@ -219,7 +233,7 @@ public function handleTypingsErrors($viewData, $argsTypes, $componentSlug) { $valueTypeDisplay = $valueType === 'object' ? $classNameWithoutNamespace : $valueType; $this->triggerError( 'The parameter "' . $key . '" in the ' . $componentSlug . ' component should be of type "' - . $argsTypes->{$key} . '" but was received as type "' . $valueTypeDisplay . '".' + . implode('|', $types) . '" but was received as type "' . $valueTypeDisplay . '".' ); } } elseif ( @@ -233,12 +247,29 @@ public function handleTypingsErrors($viewData, $argsTypes, $componentSlug) { } } + /** + * @param array|object $argsTypes + * @return array + */ + private function resolveArgumentTypes(array|object $argsTypes, string $key): array + { + if (is_object($argsTypes) && isset($argsTypes->{$key})) { + return explode('|', (string) $argsTypes->{$key}); + } + + if (is_array($argsTypes) && isset($argsTypes[$key])) { + return explode('|', (string) $argsTypes[$key]); + } + + return []; + } + /** * Determines whether typing errors should be hidden. * * @return bool Returns true if typing errors should be hidden, false otherwise. */ - private function shouldHideTypingsErrors() + private function shouldHideTypingsErrors(): bool { return (defined('WP_ENVIRONMENT_TYPE') && WP_ENVIRONMENT_TYPE === 'production') || defined('WP_CLI'); } @@ -249,7 +280,7 @@ private function shouldHideTypingsErrors() * @param string $message The error message. * @return void */ - private function triggerError($message = "") { + private function triggerError(string $message = ""): void { trigger_error($message, E_USER_WARNING); } @@ -258,7 +289,7 @@ private function triggerError($message = "") { * * @return string Array of values */ - public function accessProtected($obj, $prop) + public function accessProtected(object $obj, string $prop): mixed { $reflection = new \ReflectionClass($obj); $property = $reflection->getProperty($prop); @@ -269,9 +300,9 @@ public function accessProtected($obj, $prop) /** * Get data from controller * - * @return string Array of controller data + * @return array Array of controller data */ - public function getControllerArgs($data, $controllerName): array + public function getControllerArgs(array $data, string $controllerName): array { //Run controller & fetch data if ($controllerLocation = $this->locateController(ucfirst($controllerName))) { @@ -289,7 +320,7 @@ public function getControllerArgs($data, $controllerName): array * * @return string The expected controller name */ - public function camelCase($viewName): string + public function camelCase(string $viewName): string { return (string)str_replace( " ", @@ -305,7 +336,7 @@ public function camelCase($viewName): string * * @return string Controller path */ - public function locateController($controller) + public function locateController(string $controller): string|false { if (is_array($this->controllerPaths) && !empty($this->controllerPaths)) { foreach ($this->controllerPaths as $path) { @@ -327,7 +358,7 @@ public function locateController($controller) * * @return string Namespace or null */ - public function getNamespace($classPath) + public function getNamespace(string $classPath): ?string { $src = $this->cachedFileGetContents($classPath); if (preg_match('/namespace\s+(.+?);/', $src, $m)) { @@ -341,7 +372,7 @@ public function getNamespace($classPath) * * @return string Simple view name without appended filetype */ - public function cleanViewName($viewName): string + public function cleanViewName(string $viewName): string { return (string) str_replace('.blade.php', '', $viewName); } @@ -356,7 +387,7 @@ public function cleanViewName($viewName): string * @return string The complete path to the configuration file. * @throws \Exception If no configuration file is found in the specified path. */ - private function getConfigFilePath($path) { + private function getConfigFilePath(string $path): string { $configFile = $path . DIRECTORY_SEPARATOR . lcfirst(basename($path)) . "Config.php"; if($this->cachedFileExists($configFile)) { return $configFile; @@ -368,18 +399,19 @@ private function getConfigFilePath($path) { /** * Read and parse a configuration file. * - * This function requires a PHP configuration file that must return an array. + * This function requires a PHP configuration file that must return an array or configuration instance. * Results are cached in a static array to avoid redundant file loads. + * Plain array configurations are automatically wrapped into ComponentConfiguration singleton instances. * * @param string $path The path to the configuration file. - * @return array The configuration array returned by the file. + * @return ComponentConfigurationInterface The configuration object returned by the file. * @throws \Exception If the configuration file is missing or does not return an array. */ - private function readConfigFile(string $path) { + private function readConfigFile(string $path): ComponentConfigurationInterface { $id = md5($path); - if (isset(self::$cache['configJson'][$id])) { - return self::$cache['configJson'][$id]; + if (isset(self::$cache['config'][$id])) { + return self::$cache['config'][$id]; } if (!$this->cachedFileExists($path)) { @@ -388,11 +420,18 @@ private function readConfigFile(string $path) { $config = require $path; - if (!is_array($config)) { - throw new \Exception("Configuration file must return an array at " . $path); + if ($config instanceof ComponentConfigurationInterface) { + $configInstance = $config; + } elseif (is_array($config)) { + $configInstance = ComponentConfiguration::getInstance($config); + } else { + $receivedType = is_object($config) ? get_class($config) : gettype($config); + throw new \Exception( + "Configuration file must return an array or an implementation of ComponentLibrary\\Component\\Config\\ComponentConfigurationInterface at " . $path . ', received: ' . $receivedType + ); } - return self::$cache['configJson'][$id] = $config; + return self::$cache['config'][$id] = $configInstance; } /** @@ -405,7 +444,7 @@ private function readConfigFile(string $path) { * @param string $path The path to the file to check. * @return bool Returns true if the file exists, false otherwise. */ - private function cachedFileExists($path) { + private function cachedFileExists(string $path): bool { $id = md5($path); // Check static cache first @@ -433,7 +472,7 @@ private function cachedFileExists($path) { * @param string $path The path to the file to read. * @return string|false Returns the file contents if successful, false otherwise. */ - private function cachedFileGetContents($path) { + private function cachedFileGetContents(string $path): string|false { $id = md5($path); // Check static cache first @@ -482,7 +521,7 @@ private function cachedFileGetContents($path) { * @param string $path The path to the file to check. * @return bool Returns true if the file exists, false otherwise. */ - private function cachedGlob($path) { + private function cachedGlob(string $path): array|false { $id = md5($path); if(isset(self::$cache['glob'][$id])) { return self::$cache['glob'][$id];