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 .= '' . $stack[$i] . '>';
}
return $before . $link . $closing;
}
}