renderer = $renderer; $this->assets = $assets; } public function register(): void { add_filter('the_content', [$this, 'filter_content'], 9); add_action('wp_enqueue_scripts', [$this, 'enqueue'], 20); // After every bundle is enqueued, so the document CSS prints after the widget base CSS. add_action('wp_enqueue_scripts', [$this, 'enqueue_global_css'], 30); add_action('wp_enqueue_scripts', [$this, 'enqueue_document_styles'], 30); // Runs after the content rendered, before WP prints footer scripts/styles (priority 10/20). add_action('wp_footer', [$this, 'enqueue_widget_assets'], 5); add_filter('body_class', [$this, 'body_class']); } public function filter_content(string $content): string { $post = get_post(); if (!$post || !Document::is_builder($post->ID) || post_password_required($post)) { return $content; } if (\Zadex\Editor\EditorLoader::is_preview()) { return $content; // EditorLoader handles preview mode. } $html = $this->render_post($post->ID); self::skip_wpautop(); // Protected third-party fragments (shortcodes, widget areas ...) are restored after escaping. return Kses::restore(wp_kses($html, Kses::allowed_html())); } /** * Like core's do_blocks(): the rendered document must not go through wpautop (priority 10), which would wrap the * inline parts of widgets (links around blocks, "Read more" links …) in stray

tags. Core restores the hook * for the next the_content call (_restore_wpautop_hook). Called after rendering, so nested the_content calls * (post content widgets) keep wpautop. */ private static function skip_wpautop(): void { if (!function_exists('doing_filter') || !doing_filter('the_content')) { return; } $priority = has_filter('the_content', 'wpautop'); if ($priority === false) { return; } remove_filter('the_content', 'wpautop', $priority); if (function_exists('_restore_wpautop_hook')) { add_filter('the_content', '_restore_wpautop_hook', $priority + 1); } } public function render_post(int $post_id): string { $data = Document::get_data($post_id); $this->rendered_posts[] = $post_id; // Element CSS + page-level CSS (scroll snap ...) go to the style queue (in when enqueued early). Styles::document($post_id); $html = '

'; $this->renderer->read_more_at = null; $body = $this->renderer->render_document($data['elements'] ?? []); if ($this->renderer->read_more_at !== null && function_exists('is_singular') && !is_singular()) { $body = self::truncate_at_read_more($body, $this->renderer->read_more_at, (string) get_permalink($post_id), $this->renderer->read_more_text); } $html .= $body; $html .= '
'; return $html; } public function enqueue(): void { $post = get_post(); $is_builder = $post && Document::is_builder($post->ID); $is_preview = \Zadex\Editor\EditorLoader::is_preview(); if (!$is_builder && !$is_preview) { return; } $this->assets->enqueue_entry(Assets::ENTRY_FRONTEND, 'zadex-frontend'); } /** * `wp_enqueue_scripts` (after the frontend bundle): queues the CSS of the Zadex documents the request will render * (queried post or the builder posts of the main query, plus what their trees reference) so it prints in . * The editor canvas generates the edited document's CSS itself. */ public function enqueue_document_styles(): void { if (\Zadex\Editor\EditorLoader::mode() !== '') { return; } $ids = []; if (function_exists('is_singular') && is_singular()) { $ids[] = (int) get_queried_object_id(); } elseif (isset($GLOBALS['wp_query']) && is_object($GLOBALS['wp_query']) && is_array($GLOBALS['wp_query']->posts ?? null)) { foreach (array_slice($GLOBALS['wp_query']->posts, 0, 50) as $p) { $ids[] = is_object($p) ? (int) $p->ID : (int) $p; } } foreach ($ids as $id) { if ($id > 0 && Document::is_builder($id) && !post_password_required($id)) { Styles::document_tree($id, $this->renderer); } } } /** Site-wide design tokens (global colours / fonts as CSS variables), on every front-end page. */ public function enqueue_global_css(): void { Styles::add('zadex-global', Plugin::instance()->settings->global_css()); } /** * Conditionally loads extra asset entries declared by rendered widgets (manifest key `assets`). * The icon font is always loaded in preview mode so the editor canvas can show any icon. */ public function enqueue_widget_assets(): void { $is_preview = \Zadex\Editor\EditorLoader::is_preview(); if ($is_preview || $this->uses_asset('icons')) { $this->assets->enqueue_entry(Assets::ENTRY_ICONS, 'zadex-icons'); } // Motion effects / sticky runtime (T160-T162): only when a rendered element carries its data attributes. // The editor canvas imports it itself in preview mode. if (!$is_preview && $this->renderer->uses_feature('motion')) { $bp = \Zadex\Plugin::instance()->settings->breakpoints(); wp_register_script('zadex-breakpoints', false, [], ZADEX_VERSION, true); wp_add_inline_script( 'zadex-breakpoints', 'window.zadexBreakpoints=' . wp_json_encode(['mobile' => (int) ($bp['mobile']['value'] ?? 767), 'tablet' => (int) ($bp['tablet']['value'] ?? 1024)]) . ';' ); wp_enqueue_script('zadex-breakpoints'); $this->assets->enqueue_entry(Assets::ENTRY_MOTION, 'zadex-motion'); } } /** Whether any widget rendered on this request declared the given asset entry. */ public function uses_asset(string $asset): bool { return in_array($asset, $this->renderer->used_assets(), true); } public function body_class(array $classes): array { $post = get_post(); if ($post && Document::is_builder($post->ID)) { $classes[] = 'zadex-page'; $classes[] = 'zadex-page-' . $post->ID; } // "Hide page title" (Page panel): the rule lives in frontend.css (src/frontend/base.css) and only hides // the theme's title markup outside the Zadex document. Also applied in preview mode so the canvas matches. $is_preview = \Zadex\Editor\EditorLoader::is_preview(); if ($post && ($is_preview || Document::is_builder($post->ID)) && Document::hide_title($post->ID)) { $classes[] = 'zadex-hide-title'; } return $classes; } /** * Cuts the rendered document at the read-more widget (element id), closes the tags that were left open and * appends a "Read more" link. Used on archive / non-singular contexts, like WordPress' boundary. */ public static function truncate_at_read_more(string $html, string $element_id, string $url, string $text = ''): string { $marker = strpos($html, 'data-zx-id="' . $element_id . '"'); if ($marker === false) { return $html; } $start = strrpos(substr($html, 0, $marker), '<'); $before = substr($html, 0, $start === false ? $marker : $start); // Close the tags still open at the cut (tiny balancer: enough for the markup widgets produce). $void = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']; $stack = []; if (preg_match_all('/<(\/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*?(\/?)>/', $before, $m, PREG_SET_ORDER)) { foreach ($m as $tag) { $name = strtolower($tag[2]); if ($tag[1] === '/') { for ($i = count($stack) - 1; $i >= 0; $i--) { if ($stack[$i] === $name) { array_splice($stack, $i); break; } } } elseif ($tag[3] !== '/' && !in_array($name, $void, true)) { $stack[] = $name; } } } $text = trim($text) !== '' ? trim($text) : __('Read more', 'zadex'); $link = '' . esc_html($text) . ''; $closing = ''; for ($i = count($stack) - 1; $i >= 0; $i--) { $closing .= ''; } return $before . $link . $closing; } }