` or a form's `popup_id` in the saved * documents of the current post, the header/footer/location templates and the printed popups) — printed * with `data-zx-popup-auto="0"` so only the link / form opens them. * The "logged in / out" rule is evaluated here (the popup is not printed). When anything is printed the * frontend bundle and the popup entry (`Assets::ENTRY_POPUP`) are enqueued; the popups themselves are printed * hidden on `wp_footer` (priority 7, before the footer scripts). Never in the editor or on a template URL. * * Hooks: filter `zadex/popups/ids` (array id => auto, array $context) changes the list; * `kses_allowed_protocols` gets `#zx-popup` so esc_url() keeps `#zx-popup:open:12` links. */ final class Popups { public const TYPE = 'popup'; public const PROTOCOL = '#zx-popup'; private Assets $assets; private ?SiteSettings $site; private ?TemplateRenderer $templates; /** @var array|null id => auto (armed triggers) */ private ?array $queue = null; public function __construct(Assets $assets, ?SiteSettings $site = null, ?TemplateRenderer $templates = null) { $this->assets = $assets; $this->site = $site; $this->templates = $templates; } public function register(): void { add_filter('kses_allowed_protocols', [self::class, 'allow_protocol']); // wp_allowed_protocols() only applies its filter before `wp_loaded`: make sure it ran with ours. add_action('init', [self::class, 'prime_protocols'], 0); add_action('wp_enqueue_scripts', [$this, 'prepare'], 21); add_action('wp_footer', [$this, 'print_popups'], 7); } /** @param mixed $protocols */ public static function allow_protocol($protocols): array { $protocols = is_array($protocols) ? $protocols : []; if (!in_array(self::PROTOCOL, $protocols, true)) { $protocols[] = self::PROTOCOL; } return $protocols; } public static function prime_protocols(): void { if (function_exists('wp_allowed_protocols')) { wp_allowed_protocols(); } } /** Popup ids linked from a saved document (raw `_zadex_data` JSON or any string). @return int[] */ public static function referenced_ids(string $json): array { if ($json === '' || (strpos($json, 'zx-popup:') === false && strpos($json, 'popup_id') === false)) { return []; } $ids = []; if (preg_match_all('/#zx-popup:(?:open|toggle):(\d+)/', $json, $m)) { $ids = array_merge($ids, $m[1]); } if (preg_match_all('/"popup_id"\s*:\s*"?(\d+)/', $json, $m)) { $ids = array_merge($ids, $m[1]); } return array_values(array_unique(array_filter(array_map('intval', $ids), static fn(int $i): bool => $i > 0))); } /** Whether the request may print popups (never in the editor, wp-admin, feeds or on a template's own URL). */ public static function applies(): bool { if (is_admin() || EditorLoader::mode() !== '') { return false; } if (function_exists('is_feed') && is_feed()) { return false; } return !(function_exists('is_singular') && is_singular(TemplatePost::POST_TYPE)); } /** * Popups for this request: id => auto (true = conditions match, triggers armed; false = linked only). * * @param int[] $matching Resolver::matching('popup') * @param int[] $documents ids of the documents rendered on the request (post, header, footer, location) * @return array */ public static function collect(array $matching, array $documents, bool $logged_in, ?callable $settings = null): array { $settings = $settings ?? static fn(int $id): array => Renderer::settings_for($id); $out = []; foreach ($matching as $id) { $out[(int) $id] = true; } // Links (one level into the printed popups too, so a popup can open another one). $scan = array_merge($documents, array_keys($out)); $seen = []; for ($i = 0; $i < count($scan) && $i < 50; $i++) { $doc = (int) $scan[$i]; if (isset($seen[$doc])) { continue; } $seen[$doc] = true; foreach (self::referenced_ids((string) get_post_meta($doc, Document::META_DATA, true)) as $ref) { if (isset($out[$ref]) || !self::is_published_popup($ref)) { continue; } $out[$ref] = false; $scan[] = $ref; } } foreach (array_keys($out) as $id) { if (!Settings::login_allows($settings($id), $logged_in)) { unset($out[$id]); } } return $out; } public static function is_published_popup(int $id): bool { return $id > 0 && TemplatePost::is_template($id) && TemplatePost::type($id) === self::TYPE && get_post_status($id) === 'publish'; } /** wp_enqueue_scripts: decides which popups print and enqueues their assets. */ public function prepare(): void { $this->queue = []; if (!self::applies()) { return; } $documents = []; $queried = function_exists('is_singular') && is_singular() ? (int) get_queried_object_id() : 0; if ($queried > 0) { $documents[] = $queried; } foreach (['header', 'footer', \Zadex\Theme\Locations::location()] as $location) { if (is_string($location) && $location !== '') { $tpl = Resolver::for_location($location); if ($tpl) { $documents[] = $tpl; } } } $breakpoints = $this->breakpoints(); $queue = self::collect(Resolver::matching(self::TYPE), $documents, is_user_logged_in(), static fn(int $id): array => Renderer::settings_for($id, $breakpoints)); $filtered = apply_filters('zadex/popups/ids', $queue, ['post_id' => $queried]); $this->queue = is_array($filtered) ? $filtered : $queue; if ($this->queue) { $this->assets->enqueue_entry(Assets::ENTRY_FRONTEND, 'zadex-frontend'); $this->assets->enqueue_entry(Assets::ENTRY_POPUP, 'zadex-popup'); // The popups' CSS goes to through the style queue (printed hidden in the footer later). foreach (array_keys($this->queue) as $id) { \Zadex\Frontend\Styles::document_tree((int) $id); } } } /** @return array */ public function queue(): array { return $this->queue ?? []; } /** wp_footer: prints the hidden popups. */ public function print_popups(): void { if ($this->queue === null) { $this->prepare(); } if (!$this->queue || !self::applies()) { return; } \Zadex\Frontend\Kses::begin(); echo wp_kses($this->render_all($this->queue), \Zadex\Frontend\Kses::allowed_html()); \Zadex\Frontend\Kses::end(); } /** @param array $queue */ public function render_all(array $queue): string { $renderer = new Renderer($this->templates ?? ThemeBuilder::instance()->renderer()); $context = function_exists('is_singular') && is_singular() ? (int) get_queried_object_id() : 0; $breakpoints = $this->breakpoints(); $html = ''; foreach ($queue as $id => $auto) { $html .= $renderer->render((int) $id, Renderer::settings_for((int) $id, $breakpoints), $context > 0 ? $context : null, (bool) $auto); } return $html; } /** @return array{tablet: int, mobile: int} */ private function breakpoints(): array { $out = ['tablet' => 1024, 'mobile' => 767]; if ($this->site) { $bps = $this->site->breakpoints(); foreach (['tablet', 'mobile'] as $key) { if (isset($bps[$key]['value']) && (int) $bps[$key]['value'] > 0) { $out[$key] = (int) $bps[$key]['value']; } } } return $out; } }