`. On that * one request, `maybe_enable_preview()` filters `option_darkify` to return * the transient's merged values, so Darkify's normal frontend code renders * the page exactly as it would once the settings were saved. * * Because the override is (a) read-only, (b) scoped to a single nonce-verified * request from a `manage_options` user, and (c) applied only to that user's own * transient, it changes nothing persistent and is invisible to every other * visitor and request — full backward compatibility. * * @package darkify * @subpackage darkify/src/Admin/Rest * @author ThemeAtelier */ namespace ThemeAtelier\Darkify\Admin\Rest; use WP_REST_Server; use WP_REST_Request; use WP_REST_Response; if (! defined('ABSPATH')) { die; } class PreviewRest extends AbstractRestController { /** The option key the preview drives (Darkify stores everything here). */ const OPTION_KEY = 'darkify'; /** Query var the iframe carries to request a preview render. */ const PREVIEW_QUERY_VAR = 'darkify_preview'; /** Nonce action guarding the preview render. */ const PREVIEW_NONCE_ACTION = 'darkify_preview'; /** Transient lifetime — long enough for an editing session, short enough to * self-clean. Refreshed on every preview POST. */ const PREVIEW_TTL = HOUR_IN_SECONDS; public function __construct() { parent::__construct(); // Runs on EVERY request (this controller is constructed in the plugin // boot, not only in wp-admin), so it can catch the frontend iframe // request and swap in the preview values before Darkify's frontend // reads the option. \add_action('init', [$this, 'maybe_enable_preview']); } public function register_routes(): void { \register_rest_route(self::NS, '/preview', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'store_preview'], 'permission_callback' => [$this, 'can_manage'], ]); } /** Per-user transient key so one admin's preview never leaks into another's. */ private function transient_key(int $user_id): string { return 'darkify_admin_preview_' . $user_id; } /** * POST /preview — sanitize + merge the unsaved values over the saved option * and stash them in the current user's transient. Returns the URL the iframe * should load. Never persists to the real option. */ public function store_preview(WP_REST_Request $request): WP_REST_Response { $user_id = \get_current_user_id(); if (! $user_id) { return new WP_REST_Response(['message' => \__('Not allowed.', 'darkify')], 403); } $incoming = $request->get_param('values'); $incoming = \is_array($incoming) ? $incoming : []; $sections = $this->get_registered_sections(self::OPTION_KEY); $type_map = $this->collect_field_types($sections); $sanitized = $this->sanitize_values($incoming, $type_map); // Free plugin: the preview renders with Pro-locked values stripped too, // so it always shows what the free frontend would actually do. $sanitized = $this->strip_pro_keys($sanitized, $sections); $existing = \get_option(self::OPTION_KEY, []); $existing = \is_array($existing) ? $existing : []; $merged = \array_merge($existing, $sanitized); \set_transient($this->transient_key($user_id), $merged, self::PREVIEW_TTL); return \rest_ensure_response([ 'ok' => true, 'url' => $this->preview_url(), ]); } /** The homepage URL carrying the preview nonce. */ public function preview_url(): string { return \add_query_arg( self::PREVIEW_QUERY_VAR, \wp_create_nonce(self::PREVIEW_NONCE_ACTION), \home_url('/') ); } /** * On a valid preview request, make Darkify's frontend read the unsaved * preview values instead of the saved option — for this request only. */ public function maybe_enable_preview(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified on the next line. $token = isset($_GET[self::PREVIEW_QUERY_VAR]) ? \sanitize_text_field(\wp_unslash($_GET[self::PREVIEW_QUERY_VAR])) : ''; if ($token === '' || ! \wp_verify_nonce($token, self::PREVIEW_NONCE_ACTION)) { return; } if (! \is_user_logged_in() || ! \current_user_can('manage_options')) { return; } $preview = \get_transient($this->transient_key(\get_current_user_id())); if (! \is_array($preview)) { return; } // The preview renders the frontend with the admin's *unsaved* settings // and NO forced mode: it uses the exact same light/dark logic a real // visitor would (this browser's stored toggle state, then the unsaved // `enable_default_dark_mode` / OS-aware / time-based settings). So it // starts in whatever mode the site actually would, and the admin can // flip the in-preview switch just like on the front end — the switch // icon then stays in sync because dark mode is never force-applied // out-of-band. \add_filter('option_' . self::OPTION_KEY, function () use ($preview) { return $preview; }, 99); // Tell the frontend engine this exact page load IS the admin's own Live // Preview iframe — not a real third-party iframe embed on someone's page. // This makes it behave like the TOP-LEVEL front end: the "Frontend Iframe // Dark Mode" setting (which governs iframes NESTED WITHIN a page) can't // suppress the engine on the previewed page itself. It does NOT force any // mode — the light/dark state is still whatever the front end would show. // See Frontend::darkify_early_iframe_guard() and the // `darkify_is_admin_live_preview` JS flag in client_main.js's // `_dkf_iframe_disabled` guard. \add_filter('darkify_is_admin_live_preview', '__return_true'); // Keep the preview context when browsing INSIDE the iframe. Without // this, clicking any link inside the preview navigates to a plain URL // with no token, so this whole method no-ops for that page: it silently // reverts to the SAVED settings, and — because a tokenless page in an // iframe fails client_main.js's `_dkf_iframe_disabled` guard whenever // "Frontend Iframe Dark Mode" is off — the engine switches itself off // entirely and the page renders light. Carrying the token forward keeps // the previewed page a preview. \add_action('wp_head', [$this, 'print_preview_link_persistence'], 1); // A cleaner canvas: no admin bar overlapping the previewed page. \add_filter('show_admin_bar', '__return_false'); // Strip other plugins' floating/overlay UI (chat widgets, cookie and // consent banners, popups, announcement bars, …) from the preview only // — see print_preview_overlay_filter() for why this is safe to do // unconditionally for "any installed plugin" without touching the real // frontend or the page's own layout/content. \add_action('wp_head', [$this, 'print_preview_overlay_filter'], 1); // Mark the response so it is never cached by page caches / CDNs. if (! \headers_sent()) { \nocache_headers(); } } /** * Carry the preview token across link clicks inside the preview iframe. * * Only ever registered from maybe_enable_preview(), so it prints on preview * requests alone — a real visitor's page never sees it. * * A delegated listener rather than rewriting every `href`: it costs one * handler regardless of page size and it also covers links a theme or * plugin injects later. It defers to the page in every case where the click * wasn't a plain same-tab navigation to another page of this site — * modified clicks (new tab), `target`ed links, external hosts, in-page * anchors, and anything a script already handled (`defaultPrevented`) are * left completely alone. */ public function print_preview_link_persistence(): void { $var = \wp_json_encode(self::PREVIEW_QUERY_VAR); $token = \wp_json_encode(\wp_create_nonce(self::PREVIEW_NONCE_ACTION)); ?> ` so it works even though this prints in `` before * `` exists) to catch widgets that inject themselves late. * * Both explicitly exempt Darkify's own floating switch (`.darkify_switch`, * `#darkify_switch_*` — see Darkify::switcher_wrapper()) and the admin * bar, and neither touches in-flow page content, so the theme's real * layout renders untouched. */ public function print_preview_overlay_filter(): void { ?>