*/ private array $used = []; private int $depth = 0; /** Element id of the read-more widget rendered in the current document (Frontend truncates archives there). */ public ?string $read_more_at = null; /** Link text recorded by the read-more widget. */ public string $read_more_text = ''; /** Explicit dynamic-tag context (null = Context::current()). */ private ?\Zadex\Dynamic\Context $context = null; /** Display conditions (T165) are evaluated unless false (the editor's POST /render preview turns them off). */ public bool $apply_conditions = true; /** @var array runtime features used by rendered elements ('motion') */ private array $features = []; public function __construct(WidgetRegistry $registry) { $this->registry = $registry; } /** @param array $elements */ public function render_document(array $elements): string { $html = ''; foreach ($elements as $element) { $html .= $this->render_element($element); } return $html; } public function render_element(array $element): string { $type = isset($element['type']) && is_string($element['type']) ? $element['type'] : ''; if ($type === '' || !$this->registry->has($type) || $this->depth > 60) { return ''; } if (!empty($element['hidden'])) { return ''; } $template = $this->registry->template_path($type); if ($template === null) { return ''; } $settings = $this->settings($element); // Display conditions (T165): the element (and its children) is not rendered at all when they fail. if (!$this->is_visible($element, $settings)) { return ''; } $this->used[$type] = true; $attrs = $this->attrs($element, $settings); $this->depth++; $html = $this->include_template($template, $settings, $attrs, $element); $this->depth--; $this->device_css_once($settings); return (string) apply_filters('zadex/render/element', $html, $element, $settings); } /** Render children of a container. */ public function render_children(array $element): string { $html = ''; foreach ($element['children'] ?? [] as $child) { if (is_array($child)) { $html .= $this->render_element($child); } } return $html; } /** * Base settings merged with manifest defaults. Breakpoint/state keys are kept as-is for widget use. * Dynamic values (`{__dynamic: …}`, T114) are resolved here against context() so render.php only ever sees * plain values; the raw value stays readable in `$element['settings']`. */ public function settings(array $element): array { $type = (string) ($element['type'] ?? ''); $settings = is_array($element['settings'] ?? null) ? $element['settings'] : []; $settings = array_merge($this->registry->defaults($type), $settings); foreach ($settings as $value) { if (is_array($value) && \Zadex\Dynamic\Value::contains($value)) { return \Zadex\Dynamic\Resolver::resolve_settings($settings, $this->registry->controls($type), $this->context()); } } return $settings; } /** * Context dynamic tags render against. Theme templates rendering another post call * `$renderer->set_context(Context::for_post($id))` (and `set_context(null)` afterwards). */ public function set_context(?\Zadex\Dynamic\Context $context): void { $this->context = $context; } /** The context set with set_context() (null = none), so nested renders (loop items) can restore it. */ public function explicit_context(): ?\Zadex\Dynamic\Context { return $this->context; } public function context(): \Zadex\Dynamic\Context { return $this->context ?? \Zadex\Dynamic\Context::current(); } /** The widget registry (component instances sanitize their overrides with the inner widgets' controls). */ public function registry(): WidgetRegistry { return $this->registry; } /** Builds the attribute string placed on the root element. */ public function attrs(array $element, array $settings): string { $out = []; foreach ($this->attrs_array($element, $settings) as $name => $value) { if ($value === '' || $value === null) { $out[] = esc_attr($name); } else { $out[] = esc_attr($name) . '="' . esc_attr((string) $value) . '"'; } } return implode(' ', $out); } /** * The root element's attributes as name => value (unescaped; attrs() escapes them). * * Component instances (T168, Components\InstanceRenderer) render their component's elements with two render-time * keys that saved documents never carry (the Sanitizer drops unknown element keys): * - `_zx_alias` string[]: extra `zx-` classes (the component's own element ids, so the component CSS * generated once per component applies to every instance); * - `_zx_merge` array: the instance's own attributes, merged onto the component's root (classes appended, * the root keeps its data-zx-id / data-zx-type). * * @return array */ public function attrs_array(array $element, array $settings): array { $id = (string) ($element['id'] ?? ''); $type = (string) ($element['type'] ?? ''); $classes = ['zx-' . $type, 'zx-' . $id]; foreach ((array) ($element['_zx_alias'] ?? []) as $alias) { if (is_string($alias) && $alias !== '') { $classes[] = 'zx-' . $alias; } } if (!empty($settings['_css_classes']) && is_string($settings['_css_classes'])) { foreach (preg_split('/\s+/', trim($settings['_css_classes'])) ?: [] as $c) { if ($c !== '') { $classes[] = $c; } } } foreach ((array) ($element['classes'] ?? []) as $global_class) { if (is_string($global_class) && $global_class !== '') { $classes[] = 'zx-cls-' . $global_class; } } // Display conditions: "Device" rules hide with CSS (ElementConditions::device_css()). foreach (ElementConditions::hidden_devices($settings) as $device) { $classes[] = 'zx-cond-hide-' . $device; } // Hover effect preset (src/frontend/effects.css). if (!empty($settings['_hover_effect']) && is_string($settings['_hover_effect']) && preg_match('/^[a-z0-9_-]+$/i', $settings['_hover_effect'])) { $classes[] = 'zx-fx-' . $settings['_hover_effect']; } $classes = apply_filters('zadex/render/classes', array_unique($classes), $element, $settings); $attrs = [ 'class' => implode(' ', array_map('sanitize_html_class', $classes)), 'data-zx-id' => $id, 'data-zx-type' => $type, ]; if (!empty($settings['_css_id']) && is_string($settings['_css_id'])) { $attrs['id'] = sanitize_html_class($settings['_css_id']); } if (!empty($settings['_attributes']) && is_string($settings['_attributes'])) { foreach (preg_split('/\r?\n/', $settings['_attributes']) ?: [] as $line) { if (!str_contains($line, '|')) { continue; } [$name, $value] = array_map('trim', explode('|', $line, 2)); if ($name === '' || !preg_match('/^[a-zA-Z_:][-a-zA-Z0-9_:.]*$/', $name) || preg_match('/^on/i', $name)) { continue; } if (in_array(strtolower($name), ['class', 'id', 'style', 'data-zx-id', 'data-zx-type'], true)) { continue; } if (self::is_unsafe_custom_attr($name, $value)) { continue; } $attrs[$name] = $value; } } $attrs = $this->animation_attrs($attrs, $settings); // Motion effects + sticky (T160-T162): data attributes read by the lazy src/frontend/motion.ts entry. $motion = MotionAttrs::attrs($settings); if ($motion) { $attrs = array_merge($attrs, $motion); $this->features['motion'] = true; } if (!empty($element['_zx_merge']) && is_array($element['_zx_merge'])) { $attrs = self::merge_attrs($attrs, $element['_zx_merge']); } return (array) apply_filters('zadex/render/attrs', $attrs, $element, $settings); } /** * Merges an instance's attributes onto a component root: classes are appended (no duplicates), styles joined, * the root keeps its own data-zx-id / data-zx-type, every other attribute of the instance wins. * * @param array $attrs * @param array $merge * @return array */ public static function merge_attrs(array $attrs, array $merge): array { foreach ($merge as $name => $value) { if (!is_string($name) || in_array($name, ['data-zx-id', 'data-zx-type'], true)) { continue; } if ($name === 'class') { $own = preg_split('/\s+/', (string) ($attrs['class'] ?? '')) ?: []; $add = array_map('sanitize_html_class', preg_split('/\s+/', (string) $value) ?: []); $attrs['class'] = implode(' ', array_values(array_unique(array_filter(array_merge($own, $add), static fn($c): bool => $c !== '')))); continue; } if ($name === 'style' && !empty($attrs['style'])) { $attrs['style'] = rtrim((string) $attrs['style'], '; ') . ';' . (string) $value; continue; } $attrs[$name] = $value; } return $attrs; } /** * Custom attributes (Advanced > Attributes) are printed BEFORE the widget's own attributes, so a duplicate * `href` there would win over the sanitized link. Never let a custom attribute carry a script/data URL, * and never allow the attributes that embed markup or override form targets. */ public static function is_unsafe_custom_attr(string $name, string $value): bool { $lname = strtolower($name); if (in_array($lname, ['srcdoc', 'formaction', 'xlink:href'], true)) { return true; } $probe = strtolower(preg_replace('/[\x00-\x20\x7f]+/', '', html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8')) ?? ''); if (preg_match('/^(javascript|vbscript):/', $probe)) { return true; } $url_attrs = ['href', 'src', 'action', 'poster', 'background', 'cite', 'data', 'longdesc', 'usemap', 'srcset', 'ping', 'manifest', 'codebase', 'dynsrc', 'lowsrc']; return in_array($lname, $url_attrs, true) && str_starts_with($probe, 'data:'); } /** * Entrance animation attributes (T095). The runtime (src/frontend/lib/animations.ts) adds `.zx-animated` * when the element scrolls into view; `data-zx-anim-once="false"` replays it every time. * * @param array $attrs * @param array $settings * @return array */ public function animation_attrs(array $attrs, array $settings): array { $anim = isset($settings['_animation']) && is_string($settings['_animation']) ? trim($settings['_animation']) : ''; if ($anim === '' || $anim === 'none' || !preg_match('/^[a-zA-Z0-9_-]+$/', $anim)) { return $attrs; } $attrs['data-zx-anim'] = $anim; $duration = isset($settings['_animation_duration']) && is_string($settings['_animation_duration']) ? $settings['_animation_duration'] : ''; if (in_array($duration, ['slow', 'fast'], true)) { $attrs['data-zx-anim-duration'] = $duration; } $delay = isset($settings['_animation_delay']) && is_numeric($settings['_animation_delay']) ? (int) round((float) $settings['_animation_delay']) : 0; if ($delay > 0) { $attrs['data-zx-anim-delay'] = (string) $delay; $attrs['style'] = '--zx-anim-delay:' . $delay . 'ms'; } if (array_key_exists('_animation_once', $settings) && $settings['_animation_once'] === false) { $attrs['data-zx-anim-once'] = 'false'; } return $attrs; } /** * Display conditions (T165, ElementConditions). Never applied in the editor (?zadex=edit|preview) or when * `apply_conditions` is off. Filter `zadex/element/visible` ($visible, $element, $settings) decides last. */ public function is_visible(array $element, array $settings): bool { $visible = true; if ($this->apply_conditions && ElementConditions::has_rules($settings) && \Zadex\Editor\EditorLoader::mode() === '') { $visible = ElementConditions::visible($settings, ElementConditions::context($this->context())); } return (bool) apply_filters('zadex/element/visible', $visible, $element, $settings); } /** The `zx-cond-hide-` media queries, queued (Styles) for the first element needing them. */ private function device_css_once(array $settings): void { if (!ElementConditions::hidden_devices($settings)) { return; } Styles::device_css(); } /** Whether a rendered element needs a lazy runtime feature ('motion' = src/frontend/motion.ts). */ public function uses_feature(string $feature): bool { return isset($this->features[$feature]); } /** @return string[] widget types rendered so far (for conditional asset loading) */ public function used_widgets(): array { return array_keys($this->used); } public function reset_used(): void { $this->used = []; } /** * Extra asset entries (manifest key `assets`, e.g. ["icons"]) declared by the widgets rendered so far. * * @return string[] */ public function used_assets(): array { $assets = []; foreach ($this->used_widgets() as $type) { $manifest = $this->registry->get($type); foreach ((array) ($manifest['assets'] ?? []) as $asset) { if (is_string($asset) && $asset !== '') { $assets[$asset] = true; } } } return array_keys($assets); } private function include_template(string $template, array $settings, string $attrs, array $element): string { $renderer = $this; ob_start(); try { (static function () use ($template, $settings, $attrs, $element, $renderer): void { include $template; })(); } catch (\Throwable $e) { ob_end_clean(); if (defined('WP_DEBUG') && WP_DEBUG) { return ''; } return ''; } return (string) ob_get_clean(); } }