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">
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'),
]);
}
}
}