full-page editor shell (no theme), boots the Vue app. * ?zadex=preview -> the real theme page where the_content is replaced by the canvas mount point. */ final class EditorLoader { private Assets $assets; private WidgetRegistry $widgets; private Settings $settings; public function __construct(Assets $assets, WidgetRegistry $widgets, Settings $settings) { $this->assets = $assets; $this->widgets = $widgets; $this->settings = $settings; } public function register(): void { add_action('template_redirect', [$this, 'maybe_handle'], 1); } /** * Editor route of the current request (`?zadex=edit|preview`, anything else is ''). It only selects which * screen is rendered; maybe_handle() checks the edit_post capability before either screen is shown, and * every change the editor makes goes through the nonce-checked REST API. */ public static function mode(): string { $mode = isset($_GET['zadex']) ? sanitize_key(wp_unslash($_GET['zadex'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended return in_array($mode, ['edit', 'preview'], true) ? $mode : ''; } /** * Post id of the editor route when WordPress has not resolved a queried object (draft previews: * `?p=ID` / `?page_id=ID`). Callers check edit_post on it before using it. */ public static function requested_post_id(): int { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- routing only, see above. $id = isset($_GET['p']) ? absint(wp_unslash($_GET['p'])) : 0; if ($id <= 0 && isset($_GET['page_id'])) { $id = absint(wp_unslash($_GET['page_id'])); } // phpcs:enable WordPress.Security.NonceVerification.Recommended return $id; } public static function is_preview(): bool { return self::mode() === 'preview'; } public function edit_url(int $post_id): string { return (string) get_preview_post_link($post_id, ['zadex' => 'edit']); } public function preview_url(int $post_id): string { return (string) get_preview_post_link($post_id, ['zadex' => 'preview']); } public function can_edit(int $post_id): bool { if (!$post_id || !current_user_can('edit_post', $post_id)) { return false; } $post_type = get_post_type($post_id); // `zadex/editor/post_types`: post types editable with Zadex besides the enabled ones (theme templates ...). $types = (array) apply_filters('zadex/editor/post_types', $this->settings->post_types()); // `zadex/editor/can_edit` ($can, $post_id): e.g. the role manager's "No access" mode (T170). return $post_type && in_array($post_type, $types, true) && (bool) apply_filters('zadex/editor/can_edit', true, $post_id); } public function maybe_handle(): void { $mode = self::mode(); if ($mode === '') { return; } $post_id = (int) get_queried_object_id(); if (!$post_id) { $post_id = self::requested_post_id(); // access is checked by can_edit() below } if (!$this->can_edit($post_id)) { if (!is_user_logged_in()) { wp_safe_redirect(wp_login_url($this->edit_url($post_id))); exit; } wp_die(esc_html__('You are not allowed to edit this content with Zadex.', 'zadex'), 403); } if ($mode === 'edit') { $this->render_shell($post_id); exit; } $this->setup_preview($post_id); } private function setup_preview(int $post_id): void { show_admin_bar(false); add_filter('the_content', static function (string $content) use ($post_id): string { if (!in_the_loop() || !is_main_query() || get_the_ID() !== $post_id) { return $content; } return '
'; }, 9999); add_filter('body_class', static fn(array $c): array => array_merge($c, ['zadex-preview', 'zadex-page'])); add_action('wp_enqueue_scripts', static function () use ($post_id): void { wp_register_script('zadex-preview', false, [], ZADEX_VERSION, false); wp_add_inline_script('zadex-preview', 'window.ZADEX_PREVIEW = ' . wp_json_encode(['postId' => (int) $post_id], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP) . ';'); wp_enqueue_script('zadex-preview'); wp_register_style('zadex-preview', false, [], ZADEX_VERSION); wp_add_inline_style('zadex-preview', 'html{margin-top:0!important}#zadex-canvas-root{min-height:40vh}'); wp_enqueue_style('zadex-preview'); }, 1); // Never cache or index preview pages. nocache_headers(); add_filter('wp_robots', static fn(array $r): array => array_merge($r, ['noindex' => true, 'nofollow' => true])); } public function config(int $post_id): array { $post = get_post($post_id); $user = wp_get_current_user(); $breakpoints = $this->settings->breakpoints(); $config = [ 'version' => ZADEX_VERSION, 'dev' => $this->assets->is_dev(), 'postId' => $post_id, 'postTitle' => $post ? $post->post_title : '', 'postStatus' => $post ? $post->post_status : 'draft', 'postType' => $post ? $post->post_type : 'page', 'permalink' => get_permalink($post_id), 'previewUrl' => $this->preview_url($post_id), 'exitUrl' => get_edit_post_link($post_id, 'raw') ?: admin_url(), 'loginUrl' => wp_login_url($this->edit_url($post_id)), 'adminUrl' => admin_url(), 'siteUrl' => home_url('/'), 'pluginUrl' => ZADEX_URL, 'restUrl' => esc_url_raw(rest_url('zadex/v1')), 'nonce' => wp_create_nonce('wp_rest'), 'locale' => get_user_locale(), 'isRtl' => is_rtl(), // Editor UI dictionary for t() (languages/zadex-.json); [] = English source strings. 'i18n' => (object) Translations::for_locale((string) get_user_locale()), 'widgets' => $this->widgets->for_editor(), 'commonControls'=> $this->widgets->common_controls(), 'breakpoints' => $breakpoints, 'global' => $this->settings->global(), 'globalCss' => $this->settings->global_css(), 'document' => Document::get_data($post_id), 'pageSettings' => Document::get_page_settings($post_id), // Page template (_wp_page_template) as seen by the Page panel; '' = the theme's default layout. 'template' => Templates::get($post_id), 'user' => [ 'id' => $user->ID, 'name' => $user->display_name, 'manageOptions' => current_user_can('manage_options'), ], 'features' => $this->settings->get('features', []), // Dynamic tags (T114): name, title, group, categories, controls for the picker. 'dynamicTags' => \Zadex\Dynamic\Registry::instance()->for_editor(), // Runtime option lists for `optionsFrom` selects (the Page panel's "Page layout" reads data.templates). 'data' => ['sidebars' => self::sidebar_options(), 'templates' => Templates::options()], ]; return apply_filters('zadex/editor/config', $config, $post_id); } private function render_shell(int $post_id): void { nocache_headers(); header('Content-Type: text/html; charset=' . get_option('blog_charset')); // The shell is not a theme page: drop the emoji/feed/theme hooks that would print into it // (print_emoji_styles is deprecated since WP 6.4 and would raise a notice under WP_DEBUG). remove_action('wp_print_styles', 'print_emoji_styles'); remove_action('wp_head', 'print_emoji_detection_script', 7); remove_action('wp_head', 'wp_print_styles', 8); wp_enqueue_media(['post' => $post_id]); wp_enqueue_script('wp-i18n'); $config = $this->config($post_id); $title = sprintf(/* translators: post title */ __('Editing: %s', 'zadex'), $config['postTitle']); ?> class="zadex-editor-html"> <?php echo esc_html($title); ?> assets->print_entry(Assets::ENTRY_EDITOR); // Icon font so the icon picker shows real glyphs. $this->assets->print_entry(Assets::ENTRY_ICONS); do_action('zadex/editor/head'); ?>
` and `&` are hex-escaped so * a saved document containing a closing script tag in any text value can never end the script early. */ public static function json_for_script(array $data): string { return (string) wp_json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP); } /** Registered widget areas as select options (consumed by controls with `optionsFrom: "sidebars"`). */ public static function sidebar_options(): array { $out = [['value' => '', 'label' => __('— Select —', 'zadex')]]; foreach ((array) ($GLOBALS['wp_registered_sidebars'] ?? []) as $id => $sidebar) { $out[] = ['value' => (string) $id, 'label' => is_array($sidebar) && !empty($sidebar['name']) ? (string) $sidebar['name'] : (string) $id]; } return $out; } /** * Collects all editable items on the current request: * - The singular post/page (if singular and user can edit it) * - The Header template (Theme Builder) * - Location template (single / archive / search / 404) * - The Footer template (Theme Builder) * - Popups (matching/active on this request) * * @return array */ public function collect_editable_items(): array { if (function_exists('is_admin') && is_admin()) { return []; } $items = []; // 1. Current singular post or page if (function_exists('is_singular') && is_singular()) { $post_id = function_exists('get_queried_object_id') ? (int) get_queried_object_id() : 0; if ($post_id > 0 && $this->can_edit($post_id)) { $post = get_post($post_id); $title = $post && !empty($post->post_title) ? $post->post_title : (function_exists('get_the_title') ? (get_the_title($post_id) ?: __('(no title)', 'zadex')) : __('(no title)', 'zadex')); $post_type = get_post_type($post_id); $type_obj = function_exists('get_post_type_object') ? get_post_type_object($post_type) : null; $type_label = $type_obj && isset($type_obj->labels->singular_name) ? $type_obj->labels->singular_name : __('Page', 'zadex'); $items['post'] = [ 'id' => $post_id, 'title' => $title, 'type' => $type_label, 'url' => $this->edit_url($post_id), ]; } } // 2. Header template (Theme Builder) if (class_exists(\Zadex\Theme\Resolver::class)) { $header_id = \Zadex\Theme\Resolver::for_location('header'); if ($header_id && $this->can_edit($header_id)) { $title = function_exists('get_the_title') ? (get_the_title($header_id) ?: __('Header', 'zadex')) : __('Header', 'zadex'); $items['header'] = [ 'id' => $header_id, 'title' => $title, 'type' => __('Header', 'zadex'), 'url' => $this->edit_url($header_id), ]; } } // 3. Location template: single / archive / search / 404 (Theme Builder) if (class_exists(\Zadex\Theme\Locations::class) && class_exists(\Zadex\Theme\Resolver::class)) { $location = \Zadex\Theme\Locations::location(); if (is_string($location) && $location !== '') { foreach (\Zadex\Theme\Locations::candidates($location) as $cand) { $cand_id = \Zadex\Theme\Resolver::for_location($cand); if ($cand_id && $this->can_edit($cand_id)) { if (isset($items['post']) && $items['post']['id'] === $cand_id) { continue; } $labels = [ 'single' => __('Single Post', 'zadex'), 'archive' => __('Archive', 'zadex'), 'search' => __('Search Results', 'zadex'), '404' => __('404 Error', 'zadex'), ]; $type_label = $labels[$cand] ?? (ucfirst($cand) . ' ' . __('Template', 'zadex')); $title = function_exists('get_the_title') ? (get_the_title($cand_id) ?: $type_label) : $type_label; $items['location_' . $cand] = [ 'id' => $cand_id, 'title' => $title, 'type' => $type_label, 'url' => $this->edit_url($cand_id), ]; break; } } } } // 4. Footer template (Theme Builder) if (class_exists(\Zadex\Theme\Resolver::class)) { $footer_id = \Zadex\Theme\Resolver::for_location('footer'); if ($footer_id && $this->can_edit($footer_id)) { $title = function_exists('get_the_title') ? (get_the_title($footer_id) ?: __('Footer', 'zadex')) : __('Footer', 'zadex'); $items['footer'] = [ 'id' => $footer_id, 'title' => $title, 'type' => __('Footer', 'zadex'), 'url' => $this->edit_url($footer_id), ]; } } // 5. Popups active on this page if (class_exists(\Zadex\Theme\Resolver::class)) { $popup_ids = \Zadex\Theme\Resolver::matching('popup'); foreach ($popup_ids as $popup_id) { if ($this->can_edit((int) $popup_id)) { $title = function_exists('get_the_title') ? (get_the_title((int) $popup_id) ?: __('Popup', 'zadex')) : __('Popup', 'zadex'); $items['popup_' . $popup_id] = [ 'id' => (int) $popup_id, 'title' => $title, 'type' => __('Popup', 'zadex'), 'url' => $this->edit_url((int) $popup_id), ]; } } } return (array) apply_filters('zadex/admin_bar/editable_items', $items); } /** * Adds "Edit with Zadex" and a dropdown list of all editable items (page, header, footer, templates, popups) * as shortcuts to the WordPress toolbar. * * @param \WP_Admin_Bar $bar */ public function admin_bar_link($bar): void { if (!is_object($bar) || !method_exists($bar, 'add_node') || (function_exists('is_admin') && is_admin())) { return; } $items = $this->collect_editable_items(); $can_manage_theme = current_user_can('edit_theme_options') || current_user_can('manage_options'); if (empty($items) && !$can_manage_theme) { return; } // Determine primary URL and ID for the main button $primary_url = ''; $primary_id = 0; if (!empty($items['post']['url'])) { $primary_url = $items['post']['url']; $primary_id = (int) $items['post']['id']; } else { foreach ($items as $item) { if (!empty($item['url'])) { $primary_url = $item['url']; $primary_id = (int) $item['id']; break; } } } if ($primary_url === '' && $can_manage_theme) { $primary_url = admin_url('admin.php?page=zadex-theme-builder'); } if ($primary_url === '') { return; } // 1. Top-level node $icon = ''; $bar->add_node([ 'id' => 'zadex-edit', 'title' => $icon . '' . esc_html__('Edit with Zadex', 'zadex') . '', 'href' => $primary_url, 'meta' => [ 'title' => __('Edit with Zadex', 'zadex'), ], ]); // 2. Sub-nodes for each editable item foreach ($items as $key => $item) { if (strcasecmp($item['type'], $item['title']) === 0) { $label = $item['title']; } else { /* translators: 1: kind of editable item (e.g. "Header"), 2: its title. */ $label = sprintf(__('%1$s: %2$s', 'zadex'), $item['type'], $item['title']); } $bar->add_node([ 'id' => 'zadex-edit-' . $key, 'parent' => 'zadex-edit', 'title' => esc_html($label), 'href' => $item['url'], ]); } // 3. Global shortcuts if ($can_manage_theme) { if (!empty($items)) { $bar->add_group([ 'id' => 'zadex-edit-tools-group', 'parent' => 'zadex-edit', 'meta' => ['class' => 'ab-sub-secondary'], ]); } $parent = !empty($items) ? 'zadex-edit-tools-group' : 'zadex-edit'; if ($primary_id > 0) { $bar->add_node([ 'id' => 'zadex-edit-site-settings', 'parent' => $parent, 'title' => esc_html__('Site Settings', 'zadex'), 'href' => add_query_arg(['zx_panel' => 'global'], $this->edit_url($primary_id)), ]); } $bar->add_node([ 'id' => 'zadex-edit-theme-builder', 'parent' => $parent, 'title' => esc_html__('Theme Builder', 'zadex'), 'href' => admin_url('admin.php?page=zadex-theme-builder'), ]); } } }