per-request cache of component trees */ private static array $trees = []; public static function register(): void { register_post_type(self::POST_TYPE, [ 'labels' => [ 'name' => __('Components', 'zadex'), 'singular_name' => __('Component', 'zadex'), ], 'public' => false, 'publicly_queryable' => true, 'exclude_from_search' => true, 'show_ui' => false, 'show_in_menu' => false, 'show_in_nav_menus' => false, 'show_in_rest' => false, 'has_archive' => false, 'rewrite' => false, 'query_var' => false, 'hierarchical' => false, 'supports' => ['title', 'revisions', 'author'], 'capability_type' => 'post', 'map_meta_cap' => true, ]); } public static function is_component(int $post_id): bool { return $post_id > 0 && get_post_type($post_id) === self::POST_TYPE; } /** A component that is not trashed. */ public static function exists(int $post_id): bool { $post = $post_id > 0 ? get_post($post_id) : null; return $post !== null && (string) ($post->post_type ?? '') === self::POST_TYPE && (string) ($post->post_status ?? '') !== 'trash'; } /** Rendered on the live site only when published; editor previews show drafts too. */ public static function renderable(int $post_id, bool $editor = false): bool { if (!self::exists($post_id)) { return false; } $status = (string) (get_post($post_id)->post_status ?? ''); return $editor ? true : in_array($status, ['publish', 'private'], true); } public static function sanitize_kind($kind): string { return is_string($kind) && in_array($kind, self::KINDS, true) ? $kind : 'component'; } public static function kind(int $post_id): string { return self::sanitize_kind(get_post_meta($post_id, self::META_KIND, true)); } /** Global widget = one root element that is a widget (no children, not a container). */ public static function kind_for(array $elements, WidgetRegistry $registry): string { if (count($elements) !== 1 || !is_array($elements[0] ?? null)) { return 'component'; } $root = $elements[0]; $manifest = $registry->get((string) ($root['type'] ?? '')); $container = !empty($manifest['isContainer']); return !$container && empty($root['children']) ? 'widget' : 'component'; } /** * Valid, unique props (the same rules as `sanitizeProps()` in src/editor/core/components.ts). * * @return array */ public static function sanitize_props($input): array { if (is_string($input) && $input !== '') { $input = json_decode($input, true); } if (!is_array($input)) { return []; } $out = []; $seen = []; foreach ($input as $raw) { if (!is_array($raw)) { continue; } $id = is_string($raw['id'] ?? null) ? (string) preg_replace('/[^a-zA-Z0-9_-]/', '', $raw['id']) : ''; $element = is_string($raw['elementId'] ?? null) && preg_match('/^[a-z][a-z0-9]{2,15}$/', $raw['elementId']) ? $raw['elementId'] : ''; $control = is_string($raw['control'] ?? null) && preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $raw['control']) ? $raw['control'] : ''; if ($id === '' || $element === '' || $control === '' || isset($seen[$id])) { continue; } $seen[$id] = true; $label = is_string($raw['label'] ?? null) ? trim(sanitize_text_field($raw['label'])) : ''; $out[] = [ 'id' => $id, 'label' => $label !== '' ? mb_substr($label, 0, 80) : $control, 'elementId' => $element, 'control' => $control, ]; if (count($out) >= self::MAX_PROPS) { break; } } return $out; } /** @return array */ public static function props(int $post_id): array { return self::sanitize_props(get_post_meta($post_id, self::META_PROPS, true)); } public static function set_props(int $post_id, $props): array { $clean = self::sanitize_props($props); update_post_meta($post_id, self::META_PROPS, wp_slash((string) wp_json_encode($clean, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))); return $clean; } /** @return array the component's elements (cached per request) */ public static function tree(int $post_id): array { if (!isset(self::$trees[$post_id])) { $data = Document::get_data($post_id); self::$trees[$post_id] = is_array($data['elements'] ?? null) ? $data['elements'] : []; } return self::$trees[$post_id]; } public static function reset_cache(?int $post_id = null): void { if ($post_id === null) { self::$trees = []; } else { unset(self::$trees[$post_id]); } } /** @return array */ public static function summary($post): array { $id = (int) $post->ID; return [ 'id' => $id, 'title' => (string) $post->post_title, 'kind' => self::kind($id), 'usage' => Usage::count($id), 'modified' => (string) ($post->post_modified_gmt ?? ''), 'status' => (string) ($post->post_status ?? ''), ]; } /** Summary + elements, CSS and props (GET /components/{id}). */ public static function full(int $post_id): ?array { if (!self::exists($post_id)) { return null; } $item = self::summary(get_post($post_id)); $item['elements'] = self::tree($post_id); $item['css'] = Document::get_css($post_id); $item['props'] = self::props($post_id); return $item; } /** @return \WP_Post[] every component, newest first */ public static function all(): array { return get_posts([ 'post_type' => self::POST_TYPE, 'post_status' => ['publish', 'draft', 'pending', 'private', 'future'], 'numberposts' => 500, 'orderby' => 'modified', 'order' => 'DESC', ]); } }