['title' => true], 'acronym' => ['title' => true], 'b' => [], 'code' => [], 'em' => [], 'i' => [], 'strong' => []]; private static ?string $token = null; private static ?Overlay $overlay = null; /** @var array}>|null */ private static ?array $changes = null; public function register(): void { // After the theme has registered its patterns (init, priority 10), so // a staged template part expands them as a request from the chat // does; still before the main query, which the_posts needs. add_action('init', [$this, 'hookFrontEnd'], 100); } /** Validates a token the browser sent (a header on the assistant's routes, the cookie on the site). */ public static function activate(string $token): bool { self::$token = null; self::$overlay = null; self::$changes = null; if ($token === '' || !preg_match(self::TOKEN, $token) || !is_user_logged_in()) { return false; } $stored = get_transient(self::key($token)); if (!is_array($stored) || (int) ($stored['userId'] ?? 0) !== get_current_user_id()) { return false; } self::$token = $token; self::$changes = is_array($stored['changes'] ?? null) ? $stored['changes'] : []; return true; } public static function active(): bool { return self::$token !== null; } public static function token(): ?string { return self::$token; } /** @return array}> */ public static function changes(): array { return self::$changes ?? []; } /** The site as the staged changes leave it, replayed from the stored list. */ public static function overlay(): Overlay { if (self::$overlay === null) { self::$overlay = self::replay(self::changes()); } return self::$overlay; } /** A post as the preview shows it, or as WordPress holds it when no preview is active. */ public static function post(int $id): ?object { return self::active() ? self::overlay()->post($id) : get_post($id); } public static function option(string $name, mixed $default = false): mixed { return self::active() ? self::overlay()->option($name, $default) : get_option($name, $default); } /** @return array */ public static function menuItems(int $menuId): array { $items = array_values(array_filter((array) wp_get_nav_menu_items($menuId), 'is_object')); return self::active() ? self::overlay()->applyToMenu($menuId, $items) : $items; } /** * Replaces the staged list with what the browser holds (the browser is the * source of truth: a reload re-sends it, a closed tab never does). Every * change is checked like a run would check it, then replayed so a change * that no longer fits the site is reported rather than kept. * * @param array $changes * @return array{ok:bool,token?:string,changes?:array>,error?:string} */ public static function sync(string $token, array $changes): array { if ($token !== '' && !preg_match(self::TOKEN, $token)) { return ['error' => __('That preview is not valid.', 'sarcio'), 'ok' => false]; } if ($token !== '') { $stored = get_transient(self::key($token)); if (is_array($stored) && (int) ($stored['userId'] ?? 0) !== get_current_user_id()) { return ['error' => __('That preview belongs to someone else.', 'sarcio'), 'ok' => false]; } } $token = $token !== '' ? $token : wp_generate_password(32, false); $clean = []; $overlay = new Overlay(); $results = []; foreach (array_slice($changes, 0, self::MAX_CHANGES) as $change) { if (!is_array($change) || !is_string($change['ability'] ?? null) || !is_string($change['id'] ?? null)) { continue; } $args = is_array($change['args'] ?? null) ? $change['args'] : []; $staged = Catalog::stage($change['ability'], $args, $overlay); $results[] = ['id' => $change['id'], 'ok' => $staged['ok']] + ($staged['ok'] ? [] : ['error' => $staged['error'] ?? '']); if ($staged['ok']) { $clean[] = ['ability' => $change['ability'], 'args' => $args, 'id' => mb_substr($change['id'], 0, 40)]; } } self::store($token, $clean); self::$token = $token; self::$changes = $clean; self::$overlay = $overlay; self::setCookie(self::COOKIE, $token); return ['changes' => $results, 'ok' => true, 'token' => $token]; } /** * Adds one change on top of the active preview. * * @param array $args * @return array{ok:bool,summary?:string,before?:string,after?:string,error?:string} */ public static function add(string $id, string $ability, array $args): array { if (!self::active() || self::$token === null) { return ['error' => __('There is no preview to add to.', 'sarcio'), 'ok' => false]; } if (count(self::changes()) >= self::MAX_CHANGES) { return ['error' => __('The preview holds as many changes as it can. Apply or discard it first.', 'sarcio'), 'ok' => false]; } $overlay = self::overlay(); $staged = Catalog::stage($ability, $args, $overlay); if (!$staged['ok']) { return $staged; } self::$changes[] = ['ability' => $ability, 'args' => $args, 'id' => mb_substr($id, 0, 40)]; self::store(self::$token, self::$changes); return $staged; } /** * Writes every staged change for real, in order, as the signed-in user, * then drops the preview. A change that no longer fits is reported and the * rest still run. * * @return array{ok:bool,results:array>} */ public static function apply(): array { $changes = self::changes(); $token = self::$token; self::$token = null; self::$overlay = null; self::$changes = null; $results = []; foreach ($changes as $change) { $result = Catalog::run($change['ability'], $change['args']); $one = ['id' => $change['id'], 'ok' => $result['ok']]; if ($result['ok']) { $one['changeId'] = (int) ($result['changeId'] ?? 0); } else { $one['error'] = (string) ($result['error'] ?? ''); } $results[] = $one; } if ($token !== null) { delete_transient(self::key($token)); } self::clearCookie(); return ['ok' => true, 'results' => $results]; } public static function clear(string $token): void { if ($token !== '' && preg_match(self::TOKEN, $token)) { $stored = get_transient(self::key($token)); if (is_array($stored) && (int) ($stored['userId'] ?? 0) === get_current_user_id()) { delete_transient(self::key($token)); } } self::$token = null; self::$overlay = null; self::$changes = null; self::clearCookie(); } /** The front end, for the one browser holding the cookie: the site rendered through the overlay. */ public function hookFrontEnd(): void { if (is_admin() || wp_doing_cron() || self::isRestRequest()) { return; } $token = isset($_COOKIE[self::COOKIE]) ? sanitize_text_field(wp_unslash((string) $_COOKIE[self::COOKIE])) : ''; if ($token === '') { Review::hookFrontEnd(); return; } if (!self::activate($token)) { self::clearCookie(); return; } if (self::framed()) { add_filter('show_admin_bar', '__return_false'); } $overlay = self::overlay(); if ($overlay->empty()) { return; } self::switchView(); $live = self::showingLive($token); if (!$live) { self::hookOverlay($overlay); Review::hookOwnerBar(); } $framed = self::framed(); add_action('wp_body_open', fn () => $this->banner($live, $framed)); if (!$framed) { add_action('wp_enqueue_scripts', [self::class, 'enqueueBarStyle']); } } public static function framed(): bool { $dest = isset($_SERVER['HTTP_SEC_FETCH_DEST']) ? sanitize_key(wp_unslash((string) $_SERVER['HTTP_SEC_FETCH_DEST'])) : ''; return $dest === 'iframe'; } public static function setView(bool $live): bool { if (self::$token === null) { return false; } if ($live) { $key = self::viewKey(self::$token); self::setCookie(self::VIEW_COOKIE, $key); $_COOKIE[self::VIEW_COOKIE] = $key; } else { self::expireCookie(self::VIEW_COOKIE); unset($_COOKIE[self::VIEW_COOKIE]); } return true; } /** Whether this browser asked to see the live site while keeping its preview. */ public static function showingLive(string $token): bool { $view = isset($_COOKIE[self::VIEW_COOKIE]) ? sanitize_text_field(wp_unslash((string) $_COOKIE[self::VIEW_COOKIE])) : ''; return $view !== '' && hash_equals(self::viewKey($token), $view); } /** The bar's switch: a form posted back to the page, answered with the same page in the other view. */ private static function switchView(): void { $method = isset($_SERVER['REQUEST_METHOD']) ? sanitize_key(wp_unslash((string) $_SERVER['REQUEST_METHOD'])) : ''; if ($method !== 'post' || !isset($_POST[self::VIEW_FIELD], $_POST[self::VIEW_NONCE])) { return; } if (!wp_verify_nonce(sanitize_text_field(wp_unslash((string) $_POST[self::VIEW_NONCE])), self::VIEW_ACTION)) { return; } self::setView(sanitize_key(wp_unslash((string) $_POST[self::VIEW_FIELD])) === 'live'); wp_safe_redirect(add_query_arg([]), 303); exit; } private static function viewKey(string $token): string { return wp_hash($token . '|live', 'nonce'); } public static function enqueueBarStyle(): void { wp_register_style('sarcio-preview-bar', false, [], SARCIO_WP_VERSION); wp_enqueue_style('sarcio-preview-bar'); wp_add_inline_style('sarcio-preview-bar', '.sarcio-assistant-preview-bar button:focus-visible,.sarcio-assistant-preview-bar a:focus-visible{outline:2px solid #fff;outline-offset:2px}.sarcio-assistant-preview-bar button:hover{opacity:.9}'); } public static function hookOverlay(Overlay $overlay): void { if ($overlay->posts !== []) { // Blocks that read a post by id (a navigation's menu, a template // part's override) go through get_post(), which no filter covers, // so the staged copy is put in the object cache for this request, // with the group made non-persistent first so a shared cache never // holds a previewed post. if (function_exists('wp_cache_add_non_persistent_groups')) { wp_cache_add_non_persistent_groups(['posts']); } foreach (array_keys($overlay->posts) as $postId) { $post = get_post($postId); if ($post !== null && function_exists('wp_cache_set')) { wp_cache_set($postId, self::escapedPost($overlay, $post), 'posts'); } } add_filter('the_posts', static fn ($posts) => is_array($posts) ? array_map(static fn ($post) => is_object($post) ? self::escapedPost($overlay, $post) : $post, $posts) : $posts, 10); add_filter('the_title', static function ($title, $id = 0) use ($overlay) { $fields = $overlay->posts[(int) $id] ?? null; return $fields !== null && array_key_exists('post_title', $fields) ? wp_kses((string) $fields['post_title'], self::TITLE_TAGS) : $title; }, 1, 2); add_filter('the_content', static function ($content) use ($overlay) { $fields = $overlay->posts[(int) get_the_ID()] ?? null; return $fields !== null && array_key_exists('post_content', $fields) ? wp_kses_post((string) $fields['post_content']) : $content; }, 1); add_filter('get_the_excerpt', static function ($excerpt, $post = null) use ($overlay) { $id = is_object($post) ? (int) $post->ID : (int) get_the_ID(); $fields = $overlay->posts[$id] ?? null; return $fields !== null && array_key_exists('post_excerpt', $fields) ? wp_kses_post((string) $fields['post_excerpt']) : $excerpt; }, 1, 2); } foreach (array_keys($overlay->options) as $name) { add_filter('pre_option_' . $name, static fn () => self::escapedOption($overlay->options[$name]), 10); } if ($overlay->globalStyles !== null) { if (function_exists('wp_cache_add_non_persistent_groups')) { wp_cache_add_non_persistent_groups(['theme_json']); } add_filter('wp_theme_json_data_user', static function ($data) use ($overlay) { if (!is_object($data) || !method_exists($data, 'update_with') || !method_exists($data, 'get_data')) { return $data; } $raw = (array) $data->get_data(); $raw['settings'] = $overlay->globalStyles['settings']; $raw['styles'] = $overlay->globalStyles['styles']; $raw['version'] = $raw['version'] ?? (class_exists('WP_Theme_JSON') ? \WP_Theme_JSON::LATEST_SCHEMA : 3); return $data->update_with($raw); }, 10); } if ($overlay->menus !== []) { add_filter('wp_get_nav_menu_items', static function ($items, $menu = null) use ($overlay) { $menuId = is_object($menu) && isset($menu->term_id) ? (int) $menu->term_id : 0; return is_array($items) && $menuId > 0 ? self::escapedMenuItems($overlay, $menuId, $overlay->applyToMenu($menuId, $items)) : $items; }, 10, 2); } if ($overlay->templateParts !== []) { add_filter('get_block_file_template', static function ($template, $id = '') use ($overlay) { $slug = is_string($id) && str_contains($id, '//') ? substr($id, strpos($id, '//') + 2) : ''; if ($slug !== '' && isset($overlay->templateParts[$slug]) && is_object($template)) { $template = clone $template; $template->content = wp_kses_post($overlay->templateParts[$slug]); } return $template; }, 10, 2); } if ($overlay->products !== [] || $overlay->meta !== []) { add_filter('get_post_metadata', static function ($value, $objectId, $metaKey, $single) use ($overlay) { if (array_key_exists((string) $metaKey, $overlay->meta[(int) $objectId] ?? [])) { $staged = $overlay->meta[(int) $objectId][(string) $metaKey]; return $single ? $staged : [$staged]; } $staged = $overlay->productMeta((int) $objectId, (string) $metaKey); if ($staged === null) { return $value; } return $single ? $staged : [$staged]; }, 10, 4); } if ($overlay->extensions !== []) { Registry::render($overlay->extensions); do_action('sarcio_assistant_overlay', $overlay); } } private static function escapedPost(Overlay $overlay, object $post): object { $fields = $overlay->posts[(int) ($post->ID ?? 0)] ?? null; if ($fields === null) { return $post; } $copy = $overlay->applyToPost($post); foreach (['post_content' => 'post', 'post_excerpt' => 'post', 'post_title' => 'title'] as $field => $kind) { if (array_key_exists($field, $fields) && is_string($copy->$field ?? null)) { $copy->$field = $kind === 'title' ? wp_kses($copy->$field, self::TITLE_TAGS) : wp_kses_post($copy->$field); } } return $copy; } /** * @param array $items * @return array */ private static function escapedMenuItems(Overlay $overlay, int $menuId, array $items): array { $staged = array_map('intval', array_keys($overlay->menus[$menuId]['updated'] ?? [])); foreach ($items as $index => $item) { $id = (int) ($item->ID ?? 0); if ($id >= 0 && !in_array($id, $staged, true)) { continue; } $item = clone $item; if (isset($item->title)) { $item->title = wp_kses((string) $item->title, self::TITLE_TAGS); } if (isset($item->url)) { $item->url = esc_url_raw((string) $item->url); } if (isset($item->attr_title)) { $item->attr_title = sanitize_text_field((string) $item->attr_title); } $items[$index] = $item; } return $items; } private static function escapedOption(mixed $value): mixed { if (is_string($value)) { return wp_kses_post($value); } return is_array($value) ? map_deep($value, static fn ($item) => is_string($item) ? wp_kses_post($item) : $item) : $value; } public function banner(bool $live = false, bool $framed = false): void { $count = count(self::changes()); $color = sanitize_hex_color(\Sarcio\WordPress\Brand::color()); $color = is_string($color) && $color !== '' ? $color : \Sarcio\WordPress\Brand::DEFAULT_COLOR; echo '
'; echo '' . esc_html(\Sarcio\WordPress\Brand::screen(__('preview', 'sarcio'))) . ' '; echo '' . esc_html(sprintf( /* translators: %d: how many changes are in the preview. */ _n('%d change from the assistant, visible only to you. Nothing is live until you apply it.', '%d changes from the assistant, visible only to you. Nothing is live until you apply it.', $count, 'sarcio'), $count, )) . ''; echo '
'; echo '' . esc_html($live ? __('Showing the live site', 'sarcio') : __('Showing your changes', 'sarcio')) . ''; wp_nonce_field(self::VIEW_ACTION, self::VIEW_NONCE, false); echo '
'; echo '' . esc_html__('Back to the assistant', 'sarcio') . ''; echo '
'; } /** * REST_REQUEST is only defined once the request is parsed, after init, so * the assistant's own routes are told apart by their path: with the * overlay hooked, applying the preview would read the staged copy of a * part or post and fail to find the text it was asked to change. */ private static function isRestRequest(): bool { if (defined('REST_REQUEST') && REST_REQUEST) { return true; } if (function_exists('wp_doing_ajax') && wp_doing_ajax()) { return true; } $uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash((string) $_SERVER['REQUEST_URI'])) : ''; $prefix = function_exists('rest_get_url_prefix') ? rest_get_url_prefix() : 'wp-json'; return str_contains($uri, '/' . $prefix . '/') || str_contains($uri, 'rest_route='); } /** The site as a list of staged changes leaves it, for a review link's snapshot. */ public static function overlayFor(array $changes): Overlay { return self::replay($changes); } /** @param array}> $changes */ private static function replay(array $changes): Overlay { $overlay = new Overlay(); foreach ($changes as $change) { Catalog::stage($change['ability'], $change['args'], $overlay); } return $overlay; } /** @param array}> $changes */ private static function store(string $token, array $changes): void { set_transient(self::key($token), ['changes' => $changes, 'userId' => get_current_user_id()], self::TTL_SEC); } private static function key(string $token): string { return 'sarcio_stage_' . md5($token); } private static function setCookie(string $name, string $value): void { if (headers_sent()) { return; } setcookie($name, $value, self::cookieOptions(time() + self::TTL_SEC)); } private static function clearCookie(): void { self::expireCookie(self::COOKIE); self::expireCookie(self::VIEW_COOKIE); } private static function expireCookie(string $name): void { if (headers_sent() || !isset($_COOKIE[$name])) { return; } setcookie($name, '', self::cookieOptions(time() - 3600)); } /** @return array{expires:int,httponly:bool,path:string,samesite:string,secure:bool} */ private static function cookieOptions(int $expires): array { return [ 'expires' => $expires, 'httponly' => true, 'path' => defined('COOKIEPATH') && COOKIEPATH !== '' ? COOKIEPATH : '/', 'samesite' => 'Lax', 'secure' => is_ssl(), ]; } }