$status]);} private static function response($data){$r=new \WP_REST_Response($data);$r->header('Cache-Control','no-store, private');return $r;} public function __construct(){add_action('rest_api_init',[$this,'routes']);} public function routes(): void { $read=static fn()=>PostTypes::can_edit(); register_rest_route('digiter-builder/v1','/components',[ ['methods'=>'GET','permission_callback'=>$read,'callback'=>[$this,'catalog']], ['methods'=>'POST','permission_callback'=>static fn()=>current_user_can('manage_options'),'callback'=>[$this,'create']], ]); register_rest_route('digiter-builder/v1','/components/(?P\d+)',['methods'=>'GET','permission_callback'=>$read,'callback'=>function($r){$id=(int)$r->get_url_params()['id'];$doc=self::published($id);return is_wp_error($doc)?$doc:self::response(self::describe($id,$doc));}]); register_rest_route('digiter-builder/v1','/components/(?P\d+)/usage',['methods'=>'GET,POST','permission_callback'=>static fn()=>current_user_can('manage_options'),'callback'=>[$this,'usage']]); register_rest_route('digiter-builder/v1','/components/(?P\d+)/resolve',['methods'=>'POST','permission_callback'=>$read,'callback'=>function($r){ $id=(int)$r->get_url_params()['id'];$doc=self::published($id);if(is_wp_error($doc)){return $doc;} if(!hash_equals((string)$doc['settings']['component']['key'],(string)$r->get_param('sourceKey'))||!hash_equals(self::revision($doc),(string)$r->get_param('revision'))){return self::error('The component changed. Refresh it before detaching.',409);} try{$values=self::values($r->get_param('values')??[]);}catch(\InvalidArgumentException $e){return self::error($e->getMessage());} $variant=$r->get_param('variant');if(!is_string($variant)){return self::error('Choose a component variant.');} return self::response(['nodes'=>self::apply($doc,$values,$variant),'revision'=>self::revision($doc)]); }]); } public static function map(array $nodes): array {$map=[];$scan=function($list)use(&$scan,&$map){foreach($list as $n){$map[$n['id']]=$n;$scan($n['children']??[]);}};$scan($nodes);return $map;} /** Explicit, bounded dependency inspection. It never rewrites instances. */ public function usage($r){ global $wpdb; $id=(int)$r->get_url_params()['id'];$master=self::published($id);if(is_wp_error($master)){return $master;} $after=max(0,(int)$r->get_param('after'));$types=PostTypes::all(); $slots=implode(',',array_fill(0,count($types),'%s')); $like='%'.$wpdb->esc_like('"source":'.$id).'%'; $args=array_merge([$after],$types,[$like]); $ids=$wpdb->get_col($wpdb->prepare("SELECT DISTINCT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} m ON m.post_id=p.ID WHERE p.ID>%d AND p.post_type IN ($slots) AND p.post_status IN ('publish','private','draft','pending','future') AND m.meta_key IN ('_digibl_published','_digibl_draft') AND m.meta_value LIKE %s ORDER BY p.ID ASC LIMIT 101",$args)); $more=count($ids)>100;$ids=array_slice($ids,0,100);$items=[];$definition=$master['settings']['component'];$fields=[];$candidate=$master; if($r->get_method()==='POST'){$candidate=Document::clean($r->get_param('doc'));if(is_wp_error($candidate)){return $candidate;}if(($candidate['settings']['component']['key']??'')!==$definition['key']){return self::error('The component identity changed. Reload its master.',409);}} foreach($candidate['settings']['component']['fields'] as $field){$fields[$field['key']]=$field['kind'];} foreach($ids as $postId){ $postId=(int)$postId;if(!current_user_can('edit_post',$postId)){continue;}$states=[];$incompatible=[]; foreach(['published'=>'_digibl_published','draft'=>'_digibl_draft'] as $state=>$key){ $raw=(string)get_post_meta($postId,$key,true);if(strlen($raw)>800000){continue;}$data=json_decode($raw,true);$doc=$state==='draft'?($data['doc']??[]):$data;$count=0; $scan=static function($nodes,int $depth=0)use(&$scan,&$count,&$incompatible,$id,$definition,$fields){ if(!is_array($nodes)||$depth>8){return;} foreach($nodes as $node){if(!is_array($node)){continue;}$p=$node['props']??[]; if(($node['type']??'')==='component'&&(int)($p['source']??0)===$id&&($p['sourceKey']??'')===$definition['key']){ $count++;foreach(($p['values']??[]) as $value){if(is_array($value)&&isset($value['key'])&&($fields[$value['key']]??null)!==($value['kind']??'')){$incompatible[(string)$value['key']]=true;}} }$scan($node['children']??[],$depth+1); } };$scan($doc['nodes']??[]);if($count){$states[$state]=$count;} } if($states){$items[]=['id'=>$postId,'title'=>get_the_title($postId),'status'=>get_post_status($postId),'states'=>$states,'incompatibleFields'=>array_keys($incompatible),'editUrl'=>(new PluginLinks())->url($postId)];} } return self::response(['items'=>$items,'next'=>$more?(int)end($ids):null,'revision'=>self::revision($master)]); } public static function eligible(array $nodes): array { $out=[];foreach(self::map($nodes) as $n){if(!in_array($n['type'],self::types(),true)){continue;}foreach(Document::modules()[$n['type']]['fields'] as $f){if(in_array($f['type'],self::KINDS,true)&&(!in_array($n['type'],['section','column'],true)||!in_array($f['type'],['number','select','color'],true))&&!in_array($f['key'],['html','code'],true)&&empty($f['hidden'])){$out[]=['node'=>$n['id'],'property'=>$f['key'],'kind'=>$f['type'],'label'=>($n['style']['label']?:Document::modules()[$n['type']]['label']).' / '.$f['label'],'default'=>$n['props'][$f['key']],'constraints'=>array_intersect_key($f,array_flip(['min','max','options']))];}}}return $out; } public static function definition($raw,array $nodes): ?array { if($raw===null){return null;} if(!is_array($raw)||($raw['version']??null)!==1||count($nodes)!==1||$nodes[0]['type']==='column'){throw new \InvalidArgumentException('A component needs one root element and a supported definition.');} $map=self::map($nodes);if(count($map)>100){throw new \InvalidArgumentException('A component supports at most 100 elements.');} foreach($map as $n){if(!in_array($n['type'],self::types(),true)){throw new \InvalidArgumentException('This component contains an unsupported interactive, nested or external module.');}} $available=[];foreach(self::eligible($nodes) as $field){$available[$field['node'].'.'.$field['property']]=$field;} $fields=$raw['fields']??[];$variants=$raw['variants']??[]; if(!is_array($fields)||!array_is_list($fields)||count($fields)>40||!is_array($variants)||!array_is_list($variants)||count($variants)>8){throw new \InvalidArgumentException('Use up to 40 component fields and eight variants.');} $identity=$raw['key']??'';if(!is_string($identity)||($identity!==''&&!preg_match('/^[a-f0-9-]{36}$/D',$identity))){throw new \InvalidArgumentException('Invalid component identity.');} $out=['version'=>1,'key'=>$identity,'fields'=>[],'variants'=>[]];$keys=[];$targets=[]; foreach($fields as $f){ if(!is_array($f)||!self::key($f['key']??null)||!is_string($f['node']??null)||!is_string($f['property']??null)){throw new \InvalidArgumentException('Invalid component field key.');} $key=$f['key'];$target=($f['node']??'').'.'.($f['property']??'');$field=$available[$target]??null; if(!$field||isset($keys[$key])||isset($targets[$target])){throw new \InvalidArgumentException('Component fields must target unique existing content fields. Update Fields and variants after removing an element.');} $keys[$key]=true;$targets[$target]=true;$out['fields'][]=['key'=>$key,'node'=>$field['node'],'property'=>$field['property'],'kind'=>$field['kind'],'label'=>Document::text($f['label']??$field['label'],120)?:$field['label']]; } $keys=[];foreach($variants as $v){if(!is_array($v)||!self::key($v['key']??null)||isset($keys[$v['key']])){throw new \InvalidArgumentException('Variant keys must be valid and unique.');}$keys[$v['key']]=true; $style=$v['style']??[];if(!is_array($style)){throw new \InvalidArgumentException('Invalid variant styles.');}$clean=[]; foreach($style as $k=>$value){if(in_array($k,['background','textColor','borderColor'],true)){$clean[$k]=Document::color($value);}elseif(in_array($k,['radius','space','paddingTop','paddingRight','paddingBottom','paddingLeft','fontDesktop','fontTablet','fontMobile'],true)){$clean[$k]=Document::number($value,0,$k==='radius'?100:(str_starts_with($k,'font')?120:200));}else{throw new \InvalidArgumentException('Unsupported variant style.');}} $out['variants'][]=['key'=>$v['key'],'label'=>Document::text($v['label']??'',100)?:$v['key'],'style'=>$clean]; }return $out; } private static function key($key): bool {return is_string($key)&&preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/D',$key);} public static function values($raw): array { if(!is_array($raw)||!array_is_list($raw)||count($raw)>40){throw new \InvalidArgumentException('Invalid component field values.');}$out=[];$keys=[]; foreach($raw as $v){if(!is_array($v)||!self::key($v['key']??null)||isset($keys[$v['key']])||!in_array($v['kind']??null,self::KINDS,true)||!array_key_exists('value',$v)){throw new \InvalidArgumentException('Invalid or duplicate component field value.');} $keys[$v['key']]=true;$kind=$v['kind'];if($kind==='image'?!is_array($v['value']):($kind==='number'?(!is_int($v['value'])&&!is_float($v['value'])):!is_string($v['value']))){throw new \InvalidArgumentException('Component field value has the wrong type.');} $value=$kind==='number'?Document::number($v['value'],-1000000,1000000):($kind==='select'?Document::text($v['value'],500):Document::clean_field($v['value'],['type'=>$kind,'key'=>'componentValue']));$out[]=['key'=>$v['key'],'kind'=>$kind,'value'=>$value]; }return $out; } public static function published(int $id){ $post=get_post($id);if(!$post||$post->post_type!=='digibl_template'||$post->post_status!=='publish'||$post->post_password!==''){return self::error('The published component is unavailable.',404);} $doc=Document::clean(json_decode(get_post_meta($id,'_digibl_published',true),true));if(is_wp_error($doc)||empty($doc['settings']['component'])){return self::error('Choose a published component.',404);}return $doc; } public static function revision(array $doc): string {return hash('sha256',wp_json_encode($doc));} public static function describe(int $id,array $doc): array { $available=[];foreach(self::eligible($doc['nodes']) as $f){$available[$f['node'].'.'.$f['property']]=$f;} $fields=[];foreach($doc['settings']['component']['fields'] as $f){$fields[]=array_merge($f,['default'=>$available[$f['node'].'.'.$f['property']]['default'],'constraints'=>$available[$f['node'].'.'.$f['property']]['constraints']]);} return ['id'=>$id,'key'=>$doc['settings']['component']['key'],'title'=>$doc['title'],'revision'=>self::revision($doc),'fields'=>$fields,'variants'=>$doc['settings']['component']['variants'],'editUrl'=>current_user_can('edit_post',$id)?(new PluginLinks())->url($id):'']; } public function catalog(){ $posts=get_posts(['post_type'=>'digibl_template','post_status'=>'publish','numberposts'=>200,'orderby'=>'title','order'=>'ASC','meta_key'=>'_digibl_published']);$out=[]; foreach($posts as $p){$d=self::published($p->ID);if(!is_wp_error($d)){$out[]=self::describe($p->ID,$d);}}return self::response($out); } public function create($r){ $doc=Document::clean($r->get_param('doc'));if(is_wp_error($doc)){return $doc;}if(empty($doc['settings']['component'])||!$doc['title']){return self::error('Enter a name and define the component fields.');} $doc['settings']['component']['key']=wp_generate_uuid4();$id=NativeKit::create_document($doc);if(is_wp_error($id)){return $id;}update_post_meta($id,'_digibl_template_kind','component');return self::response(['id'=>$id,'editUrl'=>(new PluginLinks())->url($id)]); } public static function apply(array $doc,array $values,string $variant): array { $overrides=[];foreach($values as $v){$overrides[$v['key']]=$v;}$targets=[]; foreach($doc['settings']['component']['fields'] as $f){$v=$overrides[$f['key']]??null;if($v&&$v['kind']===$f['kind']){$targets[$f['node']][$f['property']]=['kind'=>$f['kind'],'value'=>$v['value']];}} $nodes=$doc['nodes'];$scan=function(&$list)use(&$scan,$targets){foreach($list as &$n){foreach($targets[$n['id']]??[] as $key=>$override){$value=$override['value'];foreach(Document::modules()[$n['type']]['fields'] as $definition){if($definition['key']===$key){$value=Document::clean_field($value,$definition);break;}}$n['props'][$key]=$override['kind']==='image'?array_merge($n['props'][$key],array_intersect_key($value,array_flip(['id','url','alt','caption']))):$value;unset($n['bindings'][$key]);}$scan($n['children']);}};$scan($nodes); foreach($doc['settings']['component']['variants'] as $v){if($v['key']===$variant){$nodes[0]['style']=array_merge($nodes[0]['style'],$v['style']);break;}}return $nodes; } public static function instance(array $node): array { $doc=self::published((int)$node['props']['source']);if(is_wp_error($doc)||empty($node['props']['sourceKey'])||!hash_equals($doc['settings']['component']['key'],$node['props']['sourceKey'])){return [];} $nodes=self::apply($doc,$node['props']['values'],(string)$node['props']['variant']);$prefix='c'.substr(hash('sha256',$node['id'].'|'.$node['props']['source']),0,16); return Document::scope_nodes($nodes,$prefix,24,false); } }