widgets = $widgets; $this->editor = $editor; $this->api = new ComponentsController($widgets); } public function register(): void { add_action('init', [ComponentPost::class, 'register']); add_filter('zadex/editor/post_types', [$this, 'editable_post_types']); add_filter('zadex/editor/config', [$this, 'editor_config'], 10, 2); add_action('zadex/document/saved', [$this, 'document_saved'], 10, 2); add_filter('zadex/sanitize/element', [$this, 'sanitize_element'], 10, 3); add_action('template_redirect', [$this, 'guard_single'], 0); add_filter('template_include', [$this, 'template_include'], 1000); $this->api->register(); if (is_admin()) { (new ComponentsPage($this->editor))->register(); } } public function editable_post_types($types): array { $types = is_array($types) ? $types : []; $types[] = ComponentPost::POST_TYPE; return array_values(array_unique($types)); } public function editor_config(array $config, int $post_id): array { if (!ComponentPost::is_component($post_id)) { return $config; } $config['component'] = ['id' => $post_id, 'kind' => ComponentPost::kind($post_id), 'title' => (string) ($config['postTitle'] ?? '')]; $config['exitUrl'] = ComponentsPage::url(); // The editor edits the exposed properties as the page setting `component_props` (undoable, saved with the document). $page = is_array($config['pageSettings'] ?? null) ? $config['pageSettings'] : []; $has_envelope = isset($page['settings']) && is_array($page['settings']); $inner = $has_envelope ? $page['settings'] : $page; $inner[ComponentPost::PAGE_KEY] = ComponentPost::props($post_id); $config['pageSettings'] = $has_envelope ? array_merge($page, ['settings' => $inner]) : $inner; return $config; } /** Usage index for every saved document; props + kind for components. */ public function document_saved($post_id, $data = null): void { $post_id = (int) $post_id; $elements = is_array($data['elements'] ?? null) ? $data['elements'] : (Document::get_data($post_id)['elements'] ?? []); $elements = is_array($elements) ? $elements : []; Usage::index($post_id, $elements); if (ComponentPost::is_component($post_id)) { self::sync($post_id, $elements, Document::page_setting($post_id, ComponentPost::PAGE_KEY, null), $this->widgets); } } /** * Component meta after a save: props (those whose element still exists; null = keep), kind, usage index. * Called by the `zadex/document/saved` hook and by the REST controller. */ public static function sync(int $post_id, array $elements, $props, WidgetRegistry $widgets): void { Usage::index($post_id, $elements); if ($props !== null) { $ids = self::element_ids($elements); ComponentPost::set_props($post_id, array_values(array_filter(ComponentPost::sanitize_props($props), static fn(array $p): bool => isset($ids[$p['elementId']])))); } $kind = ComponentPost::kind_for($elements, $widgets); update_post_meta($post_id, ComponentPost::META_KIND, $kind); ComponentPost::reset_cache($post_id); do_action('zadex/components/saved', $post_id, $kind); } /** * Instance overrides: the generic clean of the `overrides` setting is replaced by each value sanitized with the * control it overrides (a richtext keeps its markup, a URL is checked like a URL). Unknown props keep the * generic clean. */ public function sanitize_element($clean, $raw, $sanitizer) { if (!is_array($clean) || ($clean['type'] ?? '') !== 'component' || !$sanitizer instanceof Sanitizer) { return $clean; } $id = (int) ($clean['settings']['component_id'] ?? 0); $clean['settings']['component_id'] = max(0, $id); $raw_overrides = is_array($raw['settings']['overrides'] ?? null) ? $raw['settings']['overrides'] : []; $overrides = is_array($clean['settings']['overrides'] ?? null) ? $clean['settings']['overrides'] : []; if ($id > 0 && $raw_overrides && ComponentPost::exists($id)) { $tree = ComponentPost::tree($id); foreach (ComponentPost::props($id) as $prop) { if (!array_key_exists($prop['id'], $raw_overrides)) { continue; } $el = self::find($tree, $prop['elementId']); $controls = $el ? $this->widgets->controls((string) ($el['type'] ?? '')) : []; if (!isset($controls[$prop['control']])) { continue; } $value = $sanitizer->sanitize_settings([$prop['control'] => $raw_overrides[$prop['id']]], $controls); $overrides[$prop['id']] = $value[$prop['control']] ?? null; } } $clean['settings']['overrides'] = $overrides ? $overrides : new \stdClass(); $clean['children'] = []; return $clean; } /** True when one of `$ids` (or a component they place, transitively) is `$host`. */ public static function contains(array $ids, int $host, array $seen = []): bool { foreach ($ids as $id) { $id = (int) $id; if ($id === $host) { return true; } if (isset($seen[$id]) || !ComponentPost::exists($id)) { continue; } $seen[$id] = true; if (self::contains(Usage::component_ids(ComponentPost::tree($id)), $host, $seen)) { return true; } } return false; } /** @return array */ private static function element_ids(array $elements, int $depth = 0): array { $out = []; if ($depth > 60) { return $out; } foreach ($elements as $el) { if (is_array($el) && is_string($el['id'] ?? null)) { $out[$el['id']] = true; if (!empty($el['children']) && is_array($el['children'])) { $out += self::element_ids($el['children'], $depth + 1); } } } return $out; } private static function find(array $elements, string $id, int $depth = 0): ?array { if ($depth > 60) { return null; } foreach ($elements as $el) { if (!is_array($el)) { continue; } if (($el['id'] ?? '') === $id) { return $el; } if (!empty($el['children']) && is_array($el['children'])) { $hit = self::find($el['children'], $id, $depth + 1); if ($hit !== null) { return $hit; } } } return null; } /** Visitors never see a component on its own URL (editors can: preview / view). */ public function guard_single(): void { if (!is_singular(ComponentPost::POST_TYPE) || EditorLoader::mode() !== '') { return; } if (current_user_can('edit_post', (int) get_queried_object_id())) { nocache_headers(); return; } global $wp_query; $wp_query->set_404(); status_header(404); nocache_headers(); } /** Singular `zadex_component` requests (editor canvas and editors' view) use the component preview page. */ public function template_include($template) { if (!is_singular(ComponentPost::POST_TYPE)) { return $template; } add_filter('wp_robots', static fn(array $r): array => array_merge($r, ['noindex' => true, 'nofollow' => true])); return __DIR__ . '/views/component-preview.php'; } }