self::REFRESH_AFTER ) { self::schedule( $key ); } if ( self::GONE === $record['state'] || self::LINE === $record['state'] ) { return $record['state']; } return isset( $record['data'] ) && is_array( $record['data'] ) ? $record['data'] : null; } /** * The start for the editor: fetched again once it is older than EDITOR_FRESH, so a module unpublished, deleted or * blocked in SR Anywhere shows its notice in the editor within minutes, not after REFRESH_AFTER (KC's QA, * 2026-10-15). The fetch also brings the site's copy up to date. * * @param string $key The embed key. * @return array|string|null What get() returns. */ public static function for_editor( $key ) { $record = get_transient( self::transient( $key ) ); if ( is_array( $record ) && self::RETRY !== ( $record['state'] ?? '' ) && time() - (int) ( $record['fetched'] ?? 0 ) < Config::cache_ttl( self::EDITOR_FRESH ) ) { return self::get( $key ); } return self::refresh( $key ); } /** * Fetches a module's start now and keeps it. Called by WP-cron, on saving a post, and when the editor places a module. * * @param string $key The embed key. * @return array|string|null What get() would return afterwards. */ public static function refresh( $key ) { if ( ! Keys::is_embed_key( $key ) || Keys::is_group_key( $key ) || ! Placeable::allows( $key ) ) { return null; } $host = wp_parse_url( home_url(), PHP_URL_HOST ); $url = Config::start_url() . '/i/' . rawurlencode( $key ) . '/start.json?host=' . rawurlencode( (string) $host ); $response = wp_remote_get( $url, array( 'timeout' => 5, 'redirection' => 0, 'headers' => array( 'Accept' => 'application/json' ), ) ); if ( is_wp_error( $response ) ) { return self::failed( $key ); } $status = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 404 === $status && is_array( $body ) && isset( $body['error'] ) && in_array( $body['error'], array( 'blocked', 'off' ), true ) ) { set_transient( self::transient( $key ), array( 'state' => self::GONE, 'reason' => $body['error'], 'fetched' => time(), ), self::KEEP_GONE ); return self::GONE; } // A paused module (a Free account's views are used up until the 1st): its embed line shows the module's still, // and the module itself again from the 1st on. Kept for an hour, like the states above, so the start comes back. if ( 404 === $status && is_array( $body ) && 'paused' === ( $body['error'] ?? '' ) ) { set_transient( self::transient( $key ), array( 'state' => self::LINE, 'reason' => 'paused', 'fetched' => time(), ), self::KEEP_GONE ); return self::LINE; } // A module that plays from its embed line only (a Studio scroll section: plain HTML, no Slider Revolution). if ( 404 === $status && is_array( $body ) && 'native' === ( $body['error'] ?? '' ) ) { set_transient( self::transient( $key ), array( 'state' => self::LINE, 'fetched' => time(), ), self::KEEP_OK ); return self::LINE; } if ( 200 !== $status || ! is_array( $body ) ) { return self::failed( $key ); } $data = self::parse( $body, $key ); if ( null === $data ) { return self::failed( $key ); } set_transient( self::transient( $key ), array( 'state' => 'ok', 'data' => $data, 'fetched' => time(), ), self::KEEP_OK ); return $data; } /** * Why a module shows nothing or only its embed line, for the editor (blocked, off, paused), or an empty string. * * @param string $key The embed key. * @return string */ public static function reason( $key ) { $record = get_transient( self::transient( $key ) ); return is_array( $record ) && in_array( $record['state'] ?? '', array( self::GONE, self::LINE ), true ) ? (string) ( $record['reason'] ?? '' ) : ''; } /** * Fills the cache for every module a saved post holds. * * @param int $post_id The post's id. * @param \WP_Post $post The post. */ public static function warm_for_post( $post_id, $post ) { if ( wp_is_post_revision( $post_id ) || ! $post instanceof \WP_Post ) { return; } self::warm( self::keys_in_content( $post->post_content ) ); } /** * Fetches the starts of placed modules that are allowed and not fresh, at most WARM_PER_POST in one go. * * @param string[] $keys Embed keys. */ public static function warm( array $keys ) { $keys = array_filter( $keys, array( Placeable::class, 'allows' ) ); foreach ( array_slice( array_values( $keys ), 0, self::WARM_PER_POST ) as $key ) { $record = get_transient( self::transient( $key ) ); if ( is_array( $record ) && ! empty( $record['fetched'] ) && time() - (int) $record['fetched'] < HOUR_IN_SECONDS ) { continue; } self::refresh( $key ); } } /** * The module keys in a post's content: its blocks and its `[sr_anywhere]` shortcodes. * * @param string $content The post's content. * @return string[] */ public static function keys_in_content( $content ) { $content = (string) $content; $keys = array(); if ( has_block( 'sr-anywhere/module', $content ) || has_block( 'sr-anywhere/divi-module', $content ) ) { $keys = self::keys_in( parse_blocks( $content ) ); } if ( has_shortcode( $content, 'sr_anywhere' ) ) { $keys = array_merge( $keys, self::keys_in_shortcodes( $content ) ); } return array_values( array_unique( $keys ) ); } /** * The module keys of `[sr_anywhere]` shortcodes, also inside other shortcodes (WPBakery's rows and columns). * * @param string $content Text with shortcodes. * @param int $depth How deep the search has gone. * @return string[] */ private static function keys_in_shortcodes( $content, $depth = 0 ) { $keys = array(); if ( $depth > 10 || ! preg_match_all( '/' . get_shortcode_regex() . '/', $content, $found, PREG_SET_ORDER ) ) { return $keys; } foreach ( $found as $match ) { if ( 'sr_anywhere' === $match[2] ) { $atts = shortcode_parse_atts( $match[3] ); $key = is_array( $atts ) && isset( $atts['key'] ) ? (string) $atts['key'] : ''; if ( Keys::is_embed_key( $key ) && ! Keys::is_group_key( $key ) ) { $keys[] = $key; } } elseif ( '' !== $match[5] ) { $keys = array_merge( $keys, self::keys_in_shortcodes( $match[5], $depth + 1 ) ); } } return $keys; } /** * The module keys in a list of parsed blocks, inner blocks included. * * @param array $blocks Parsed blocks. * @return string[] */ public static function keys_in( array $blocks ) { $keys = array(); foreach ( $blocks as $block ) { $name = $block['blockName'] ?? ''; $key = ''; if ( 'sr-anywhere/module' === $name ) { $key = $block['attrs']['embedKey'] ?? ''; } elseif ( 'sr-anywhere/divi-module' === $name ) { // Divi 5 keeps its modules as blocks $key = $block['attrs']['embed']['innerContent']['desktop']['value'] ?? ''; } if ( Keys::is_embed_key( $key ) && ! Keys::is_group_key( $key ) ) { $keys[] = $key; } if ( ! empty( $block['innerBlocks'] ) ) { $keys = array_merge( $keys, self::keys_in( $block['innerBlocks'] ) ); } } return array_values( array_unique( $keys ) ); } /** * Reads a start.json answer into the values the plugin prints. * * @param array $body The decoded answer. * @param string $key The embed key it was asked for. * @return array|null */ public static function parse( array $body, $key ) { $id = isset( $body['id'] ) && is_string( $body['id'] ) ? $body['id'] : ''; if ( 1 !== preg_match( '/^SR7_[A-Za-z0-9]+_\d+$/', $id ) ) { return null; } $head = isset( $body['head'] ) && is_array( $body['head'] ) ? $body['head'] : array(); $page_js = isset( $head['pageJs'] ) && is_string( $head['pageJs'] ) ? $head['pageJs'] : ''; $sr7_css = isset( $head['sr7Css'] ) && is_string( $head['sr7Css'] ) ? $head['sr7Css'] : ''; $embed = isset( $body['embed'] ) && is_string( $body['embed'] ) ? $body['embed'] : ''; $html = isset( $body['html'] ) && is_string( $body['html'] ) ? $body['html'] : ''; $env_js = isset( $head['env'] ) && is_string( $head['env'] ) ? $head['env'] : ''; if ( ! Config::is_delivery_url( $page_js ) || ! Config::is_delivery_url( $sr7_css ) || ! Config::is_delivery_url( $embed ) ) { return null; } if ( false === strpos( (string) wp_parse_url( $embed, PHP_URL_PATH ), '/i/' . $key . '/embed.js' ) ) { return null; } // The data fields asked for in to-anywhere/2026-10-07-after-the-review.md, when the delivery sends them. $start = isset( $body['start'] ) && is_array( $body['start'] ) ? $body['start'] : self::start_from_html( $html ); $env = isset( $body['env'] ) && is_array( $body['env'] ) ? $body['env'] : self::env_from_script( $env_js ); $markup = isset( $body['markup'] ) && is_string( $body['markup'] ) ? $body['markup'] : self::strip_scripts( $html ); if ( null === $start || null === $env || '' === trim( $markup ) || false === strpos( $markup, $id ) ) { return null; } if ( empty( $env['plugin_url'] ) || ! is_string( $env['plugin_url'] ) || ! Config::is_delivery_url( $env['plugin_url'] ) ) { return null; } $start['id'] = $id; $scene = self::scene_url( $body, $markup, $key ); return array( 'id' => $id, 'key' => $key, 'markup' => $markup, 'start' => $start, 'env' => array( 'plugin_url' => esc_url_raw( $env['plugin_url'] ), 'breakPoints' => array_map( 'intval', isset( $env['breakPoints'] ) && is_array( $env['breakPoints'] ) ? $env['breakPoints'] : array() ), 'fSUVW' => ! empty( $env['fSUVW'] ), ), 'pageJs' => esc_url_raw( $page_js ), 'sr7Css' => esc_url_raw( $sr7_css ), 'embed' => esc_url_raw( $embed ), 'scene' => $scene, 'fullHeight' => ! empty( $start['size']['fullHeight'] ), 'title' => isset( $body['title'] ) && is_string( $body['title'] ) ? sanitize_text_field( $body['title'] ) : '', 'lcp' => self::lcp( $body['lcp'] ?? null ), 'seo' => self::seo( $body['seo'] ?? null, $id ), ); } /** * The picture the module opens with (`lcp`), to preload: only a picture on the delivery. * * @param mixed $lcp The answer's `lcp`. * @return array|null `src`, `alt`, `title`. */ private static function lcp( $lcp ) { if ( ! is_array( $lcp ) || ! isset( $lcp['src'] ) || ! is_string( $lcp['src'] ) || ! Config::is_delivery_url( $lcp['src'] ) ) { return null; } return array( 'src' => esc_url_raw( $lcp['src'] ), 'alt' => sanitize_text_field( (string) ( $lcp['alt'] ?? '' ) ), 'title' => sanitize_text_field( (string) ( $lcp['title'] ?? '' ) ), ); } /** * The first slide's words and background (`seo`), as SR7's Output.php prints them for search engines and pages read * without scripts. Checked: every id belongs to this module (the engine takes the elements by id), the tags are * layer tags, the words are plain text, the background is a picture on the delivery. * * @param mixed $seo The answer's `seo`. * @param string $id The module's id. * @return array|null `slide` (`id`, `key`), `bg` (`id`, `src`, `alt`, `title`) or null, `layers` (`id`, `tag`, `text`, * and on an `a` its `href`, `target`, `rel`). */ private static function seo( $seo, $id ) { $own = static function ( $value ) use ( $id ) { return is_string( $value ) && 0 === strpos( $value, $id . '-' ) && 1 === preg_match( '/^[A-Za-z0-9_-]{1,160}$/', $value ); }; if ( ! is_array( $seo ) || ! isset( $seo['slide'] ) || ! is_array( $seo['slide'] ) || ! $own( $seo['slide']['id'] ?? null ) ) { return null; } $out = array( 'slide' => array( 'id' => $seo['slide']['id'], 'key' => sanitize_text_field( (string) ( $seo['slide']['key'] ?? '' ) ), ), 'bg' => null, 'layers' => array(), ); $bg = $seo['bg'] ?? null; if ( is_array( $bg ) && $own( $bg['id'] ?? null ) && is_string( $bg['src'] ?? null ) && Config::is_delivery_url( $bg['src'] ) ) { $out['bg'] = array( 'id' => $bg['id'], 'src' => esc_url_raw( $bg['src'] ), 'alt' => sanitize_text_field( (string) ( $bg['alt'] ?? '' ) ), 'title' => sanitize_text_field( (string) ( $bg['title'] ?? '' ) ), ); } $layers = isset( $seo['layers'] ) && is_array( $seo['layers'] ) ? array_slice( $seo['layers'], 0, 200 ) : array(); foreach ( $layers as $layer ) { if ( ! is_array( $layer ) || ! $own( $layer['id'] ?? null ) || ! in_array( $layer['tag'] ?? '', self::SEO_TAGS, true ) || ! is_string( $layer['text'] ?? null ) ) { continue; } $text = sanitize_text_field( $layer['text'] ); if ( '' === $text ) { continue; } $entry = array( 'id' => $layer['id'], 'tag' => $layer['tag'], 'text' => $text, ); if ( 'a' === $layer['tag'] ) { $entry['href'] = is_string( $layer['href'] ?? null ) ? esc_url_raw( $layer['href'], array( 'http', 'https', 'mailto', 'tel' ) ) : ''; $entry['target'] = in_array( $layer['target'] ?? '', array( '_blank', '_self', '_parent', '_top' ), true ) ? $layer['target'] : ''; $entry['rel'] = is_string( $layer['rel'] ?? null ) ? implode( ' ', array_filter( array_map( 'sanitize_key', preg_split( '/\s+/', $layer['rel'] ) ) ) ) : ''; } $out['layers'][] = $entry; } return $out; } /** * The module's own scene URL, so an engine already on the page (the classic plugin's) loads it from the delivery and * never asks WordPress for it (the core's answer of 2026-10-07): the `scene` field, else the markup's `data-scene`. * * @param array $body The decoded answer. * @param string $markup The start's markup. * @param string $key The embed key. * @return string The URL, or an empty string. */ private static function scene_url( array $body, $markup, $key ) { $url = isset( $body['scene'] ) && is_string( $body['scene'] ) ? $body['scene'] : ''; if ( '' === $url && 1 === preg_match( '/\sdata-scene="([^"]+)"/', $markup, $m ) ) { $url = html_entity_decode( $m[1], ENT_QUOTES ); } $path = (string) wp_parse_url( $url, PHP_URL_PATH ); if ( '' === $url || ! Config::is_delivery_url( $url ) || '/i/' . $key . '/scene.json' !== $path ) { return ''; } return esc_url_raw( $url ); } /** * The start values inside the delivery's `SR7.PMH` script (stop-gap). * * @param string $html The start's html. * @return array|null */ private static function start_from_html( $html ) { if ( 1 !== preg_match( '/prepareModuleHeight\((\{.*?\})\);\s*SR7\.PMH/s', $html, $m ) ) { return null; } $start = json_decode( $m[1], true ); return is_array( $start ) ? $start : null; } /** * The values of the delivery's `head.env` script (stop-gap). * * @param string $script The env script. * @return array|null */ private static function env_from_script( $script ) { if ( 1 !== preg_match( '/SR7\.E\.plugin_url\s*\?\?=\s*"([^"]+)"/', $script, $url ) ) { return null; } $env = array( 'plugin_url' => $url[1] ); if ( 1 === preg_match( '/SR7\.G\.breakPoints\s*\?\?=\s*(\[[0-9,\s]*\])/', $script, $bp ) ) { $env['breakPoints'] = json_decode( $bp[1], true ); } $env['fSUVW'] = 1 === preg_match( '/SR7\.G\.fSUVW\s*\?\?=\s*true/', $script ); return $env; } /** * The markup without its scripts. * * @param string $html The start's html. * @return string */ private static function strip_scripts( $html ) { return (string) preg_replace( '#]*>.*?#is', '', $html ); } /** * A failed fetch: keeps an older start, or remembers the failure for a while so page views do not ask again at once. * * @param string $key The embed key. * @return array|string|null */ private static function failed( $key ) { $cached = self::get_cached( $key ); if ( null === $cached ) { set_transient( self::transient( $key ), array( 'state' => self::RETRY, 'fetched' => time(), ), self::KEEP_RETRY ); } return $cached; } /** * The cached value, without scheduling anything. * * @param string $key The embed key. * @return array|string|null */ private static function get_cached( $key ) { $record = get_transient( self::transient( $key ) ); if ( ! is_array( $record ) || empty( $record['state'] ) ) { return null; } if ( self::GONE === $record['state'] || self::LINE === $record['state'] ) { return $record['state']; } return isset( $record['data'] ) && is_array( $record['data'] ) ? $record['data'] : null; } /** * Schedules one background refresh of a key. * * @param string $key The embed key. */ private static function schedule( $key ) { if ( ! wp_next_scheduled( self::CRON_HOOK, array( $key ) ) ) { wp_schedule_single_event( time(), self::CRON_HOOK, array( $key ) ); } } /** * The transient's name for a key. * * @param string $key The embed key. * @return string */ private static function transient( $key ) { return 'sranywhere_start_' . $key; } }