10000||($schema>1&&!is_callable($definition['migrate']??null))||(isset($definition['validate'])&&!is_callable($definition['validate']))){ return new \WP_Error('digibl_module_schema','Use an integer schema version and a migration callback for versions above 1.'); } $fields=$definition['fields']??[]; if(!is_array($fields)||!array_is_list($fields)||count($fields)>40){return new \WP_Error('digibl_module_fields','Use a list of at most 40 fields.');} $clean=[];$keys=[]; foreach($fields as $field){ if(!is_array($field)||!preg_match('/^[a-z][a-zA-Z0-9_]{0,47}$/D',(string)($field['key']??''))||isset($keys[$field['key']])||!in_array($field['type']??'',['text','textarea','rich','url','image','number','color','select'],true)||!array_key_exists('default',$field)){ return new \WP_Error('digibl_module_field','Invalid or duplicate module field.'); } $keys[$field['key']]=true; $f=['key'=>$field['key'],'type'=>$field['type'],'label'=>sanitize_text_field($field['label']??$field['key']),'default'=>$field['default']]; if($f['type']==='number'){ if(!is_numeric($field['min']??null)||!is_numeric($field['max']??null)||!is_finite((float)$field['min'])||!is_finite((float)$field['max'])||$field['min']>$field['max']){return new \WP_Error('digibl_module_range','Number fields require finite min/max bounds.');} $f['min']=(float)$field['min'];$f['max']=(float)$field['max']; } if($f['type']==='select'){ $options=$field['options']??[]; if(!is_array($options)||!array_is_list($options)||!$options||count($options)>100||count(array_filter($options,'is_string'))!==count($options)||!in_array($f['default'],$options,true)){ return new \WP_Error('digibl_module_options','Select fields require string options and a matching default.'); } $f['options']=array_values(array_unique($options)); } $f['default']=Document::clean_field($f['default'],$f);$clean[]=$f; } $assets=[]; foreach(['styles','scripts'] as $kind){ $list=$definition[$kind]??[]; if(!is_array($list)||count($list)>10||count(array_filter($list,static fn($v)=>is_string($v)&&preg_match('/^[a-zA-Z0-9_-]{1,100}$/D',$v)))!==count($list)){ return new \WP_Error('digibl_module_assets','Assets must be registered WordPress handles.'); } $assets[$kind]=array_values(array_unique($list)); } self::$registry[$name]=[ 'catalog'=>['label'=>sanitize_text_field($definition['label']??$name),'group'=>sanitize_text_field($definition['group']??'Extensions'),'icon'=>sanitize_text_field($definition['icon']??'◇'),'container'=>false,'fields'=>$clean,'schemaVersion'=>$schema,'supportsComponents'=>($definition['supports_components']??false)===true], 'render'=>$definition['render'],'styles'=>$assets['styles'],'scripts'=>$assets['scripts'], 'migrate'=>$definition['migrate']??null,'validate'=>$definition['validate']??null, ]; return true; } public static function catalog(): array {return array_map(static fn($item)=>$item['catalog'],self::$registry);} public static function has(string $type): bool {return isset(self::$registry[$type]);} public static function component_types(): array {return array_keys(array_filter(self::$registry,static fn($item)=>$item['catalog']['supportsComponents']));} /** Pure migration of field data. Callbacks must never write to storage. */ public static function migrate(array $node): array { $item=self::$registry[$node['type']];$from=$node['moduleVersion']??1;$to=$item['catalog']['schemaVersion']; if(!is_int($from)||$from<1||$from>$to){throw new \InvalidArgumentException('This module needs a compatible extension version. The document was not changed.');} if($from<$to){ try{ $result=call_user_func($item['migrate'],['props'=>$node['props']??[],'bindings'=>$node['bindings']??[],'slot'=>$node['slot']??[]],$from,$to); if(!is_array($result)||!is_array($result['props']??null)){throw new \UnexpectedValueException('Migration must return field data.');} $node=array_replace($node,array_intersect_key($result,array_flip(['props','bindings','slot']))); if(strlen((string)wp_json_encode($node))>750000){throw new \UnexpectedValueException('Migration result exceeds document limits.');} }catch(\Throwable $error){ do_action('digibl_module_migration_error',$node['type'],$error); throw new \InvalidArgumentException('An extension could not migrate its fields. Existing data was preserved.'); } } $node['moduleVersion']=$to;return $node; } public static function validate(string $type,array $props): void { $validator=self::$registry[$type]['validate']??null;if(!$validator){return;} try{$result=call_user_func($validator,$props);if($result!==true){throw new \UnexpectedValueException('Module validation failed.');}} catch(\Throwable $error){do_action('digibl_module_validation_error',$type,$error);throw new \InvalidArgumentException('An extension rejected its field values. Correct its settings before saving.');} } public static function render(array $node,array $context): string { $item=self::$registry[$node['type']]??null;if(!$item){return '';} try{ self::validate($node['type'],$node['props']); $html=call_user_func($item['render'],$node['props'],$context+['node_id'=>$node['id'],'api_version'=>self::API_VERSION]); if(!is_string($html)){throw new \UnexpectedValueException('Module renderer must return HTML.');} // Scripts belong in registered assets; document values stay non-executable. $html=wp_kses_post($html); if(empty($context['preview'])&&empty($context['fallback'])){ foreach($item['scripts'] as $handle){wp_enqueue_script($handle);} } if(empty($context['fallback'])&&!empty($item['styles'])){foreach($item['styles'] as $handle){wp_enqueue_style($handle);}ob_start();wp_print_styles($item['styles']);$html=ob_get_clean().$html;} return $html; }catch(\Throwable $error){ do_action('digibl_module_render_error',$node['type'],$error); return !empty($context['preview'])?'