'AND', array( 'relation' => 'OR', array( 'key' => '_metasync_is_custom_html_page', 'compare' => 'NOT EXISTS', ), array( 'key' => '_metasync_is_custom_html_page', 'value' => '1', 'compare' => '!=', ), ), array( 'relation' => 'OR', array( 'key' => '_metasync_lps_import', 'compare' => 'NOT EXISTS', ), // IN ('', '0') mirrors empty() semantics for stored strings. array( 'key' => '_metasync_lps_import', 'value' => array('', '0'), 'compare' => 'IN', ), ), array( 'relation' => 'OR', array( 'key' => '_metasync_created_via_api', 'compare' => 'NOT EXISTS', ), array( 'key' => '_metasync_created_via_api', 'value' => array('', '0'), 'compare' => 'IN', ), ), ); } } if (!function_exists('metasync_get_custom_page_exclusion_where_sql')) { /** * Build a prepared SQL WHERE fragment that excludes custom/LPS pages. * * Produces three AND-ed NOT EXISTS correlated anti-joins against wp_postmeta. * Unlike WP core's meta_query translation (which generates unanchored * post_id-only LEFT JOINs multiplied across meta rows), this WHERE clause * checks only the 3 specific marker keys and avoids joining postmeta. * * Semantics match metasync_is_custom_or_lps_page() and * metasync_get_custom_page_exclusion_meta_query() exactly: * - Excluded if _metasync_is_custom_html_page = '1' * - Excluded if _metasync_lps_import NOT IN ('', '0') * - Excluded if _metasync_created_via_api NOT IN ('', '0') * * @return string Prepared SQL fragment starting with ' AND ...' */ function metasync_get_custom_page_exclusion_where_sql() { global $wpdb; if (!isset($wpdb) || !is_object($wpdb) || empty($wpdb->postmeta) || empty($wpdb->posts)) { return ''; } $sql = " AND NOT EXISTS (\n" . "\tSELECT 1 FROM {$wpdb->postmeta} AS _ms_ex_html\n" . "\tWHERE _ms_ex_html.post_id = {$wpdb->posts}.ID\n" . "\t\tAND _ms_ex_html.meta_key = %s\n" . "\t\tAND _ms_ex_html.meta_value = %s\n" . ")\n" . "AND NOT EXISTS (\n" . "\tSELECT 1 FROM {$wpdb->postmeta} AS _ms_ex_lps\n" . "\tWHERE _ms_ex_lps.post_id = {$wpdb->posts}.ID\n" . "\t\tAND _ms_ex_lps.meta_key = %s\n" . "\t\tAND _ms_ex_lps.meta_value NOT IN ('', '0')\n" . ")\n" . "AND NOT EXISTS (\n" . "\tSELECT 1 FROM {$wpdb->postmeta} AS _ms_ex_api\n" . "\tWHERE _ms_ex_api.post_id = {$wpdb->posts}.ID\n" . "\t\tAND _ms_ex_api.meta_key = %s\n" . "\t\tAND _ms_ex_api.meta_value NOT IN ('', '0')\n" . ")"; return $wpdb->prepare( $sql, '_metasync_is_custom_html_page', '1', '_metasync_lps_import', '_metasync_created_via_api' ); } } if (!function_exists('metasync_filter_exclude_custom_pages_where')) { /** * WP_Query posts_where filter callback to exclude custom/LPS pages. * * Checks for the sentinel query variable 'metasync_exclude_custom_pages'. * * @param string $where Existing WHERE clause. * @param WP_Query $query Current query. * @return string Modified WHERE clause. */ function metasync_filter_exclude_custom_pages_where($where, $query) { if (empty($query->get('metasync_exclude_custom_pages'))) { return $where; } return $where . metasync_get_custom_page_exclusion_where_sql(); } } if (!function_exists('metasync_get_sitemap_post_type_objects')) { /** * Return the post types that may be selected by a sitemap picker. * * Sitemap settings are shared by the general, news, and video sitemap * screens. Keep their visibility rules in one helper so the three pickers * cannot drift apart when a page builder or WordPress adds an internal * post type. * * @return array Viewable public post type objects. */ function metasync_get_sitemap_post_type_objects(): array { $default_excluded_post_types = [ 'attachment', 'revision', 'nav_menu_item', 'elementor_library', 'elementor-hf', 'e-floating-buttons', 'bricks_template', 'ct_template', 'oxy_user_library', 'brizy-template', 'fusion_template', 'fusion_tb_section', 'ae_global_templates', 'custom_css', 'customize_changeset', 'oembed_cache', 'user_request', 'wp_block', 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation', 'acf-field-group', 'acf-field', 'fl-builder-template', 'fl-theme-layout', 'wpr_mega_menu', 'wpr_templates', ]; /** * Allow integrations to remove additional internal post types from * all sitemap post-type pickers. * * @param string[] $excluded_post_types Post type slugs to exclude. */ /** @var mixed $filtered_excluded_post_types */ $filtered_excluded_post_types = apply_filters( 'metasync_sitemap_excluded_post_types', $default_excluded_post_types ); $excluded_post_types = is_array($filtered_excluded_post_types) ? $filtered_excluded_post_types : $default_excluded_post_types; /** * Allow integrations and tests to adjust the public post-type objects * before the common exclusion and viewability rules are applied. * * @param array $post_types Public post types. */ /** @var mixed $filtered_post_types */ $filtered_post_types = apply_filters( 'metasync_sitemap_post_types', get_post_types(['public' => true], 'objects') ); $post_types = is_array($filtered_post_types) ? $filtered_post_types : []; $filtered = []; foreach ($post_types as $post_type) { if (!is_object($post_type) || empty($post_type->name)) { continue; } if (in_array($post_type->name, $excluded_post_types, true)) { continue; } if (!is_post_type_viewable($post_type)) { continue; } $filtered[] = $post_type; } return $filtered; } } if (!function_exists('metasync_repair_scheme_prefixed_media_id')) { /** * Recover a media-library attachment ID from the corrupted value the legacy * logo sanitizer wrote. * * The legacy save path ran the Local Business logo through * sanitize_url() (= esc_url_raw()), which prepends a scheme to any value * that has none. A stored attachment ID such as "45589" therefore became * "http://45589". That shape is unambiguous — "https?://" followed by * digits only, with no dot and no path, which can never be a resolvable * host — so the digits are exactly the original attachment ID and * recovering them is mechanically safe. * * Values that are not of that shape are returned untouched, so this is * safe to call on every read of a media setting: a real URL (which always * carries a dot in its host) and a plain attachment ID both pass through. * * Used by the schema output, the admin preview, and the one-time database * repair migration, so all three can never disagree. * * @param mixed $value Stored media setting (attachment ID, URL, or corrupted value). * @return string|int The attachment ID when $value was corrupted, $value otherwise. */ function metasync_repair_scheme_prefixed_media_id($value){ if (is_numeric($value) || !is_string($value)) { return $value; } if (!preg_match('/^https?:\/\/(\d+)\/?$/i', trim($value), $matches)) { return $value; } return (int) $matches[1]; } } if (!function_exists('metasync_discard_buffered_output')) { /** * Discard any pending output buffers before emitting a machine-readable body. * * Endpoints that serve XML/plain text (sitemaps, llms.txt, the IndexNow key * file) must start at the very first byte of the response. When another * plugin emits output earlier in the request — most commonly a PHP * Deprecated/Notice/Warning rendered as HTML because display_errors or * WP_DEBUG_DISPLAY is on — that text sits in the output buffer and gets * flushed ahead of the ` 0) { $status = ob_get_status(); $flags = isset($status['flags']) ? (int) $status['flags'] : 0; $needed = PHP_OUTPUT_HANDLER_CLEANABLE | PHP_OUTPUT_HANDLER_REMOVABLE; // Some buffers cannot be discarded at all — zlib.output_compression // and handlers started without the cleanable/removable flags. Check // first, because calling ob_end_clean() on one emits a PHP notice, // which would add to the very corruption this guards against. if (($flags & $needed) !== $needed) { return false; } $level_before = ob_get_level(); ob_end_clean(); // Only keep going while the level is actually falling. Looping on // ob_get_level() alone would spin forever against a buffer that // refuses to close. if (ob_get_level() >= $level_before) { return false; } } return true; } } if (!function_exists('metasync_escape_json_ld_for_script')) { /** * Escape a JSON document string for safe embedding inside a * `` (or any `` element. * * Encodes with wp_json_encode() using the caller's flags, then applies * metasync_escape_json_ld_for_script() so a literal `` inside any * string value cannot break out of the surrounding script block. When no * `` cannot * be fixed with a naive non-greedy `` match — the match * would stop at the injected closer and leave the payload live. Instead, * for each `application/ld+json` opening tag the content is extended past * successive closing tags until it parses as valid JSON: that is the span * the producer meant to be script content, so every `]*application\/ld\+json[^>]*>/i', $html, $opens, PREG_OFFSET_CAPTURE); if (empty($opens[0])) { return $html; } if (!preg_match_all('/<\/script\s*>/i', $html, $closers, PREG_OFFSET_CAPTURE)) { return $html; } $result = $html; $processed_from = PHP_INT_MAX; // Right to left, so escaping a block never invalidates the offsets of // the blocks still to be processed. for ($i = count($opens[0]) - 1; $i >= 0; $i--) { $open = $opens[0][$i]; $content_start = $open[1] + strlen($open[0]); if ($open[1] >= $processed_from) { continue; // already inside an escaped span } foreach ($closers[0] as $closer) { $end = $closer[1]; if ($end <= $content_start) { continue; } $candidate = substr($html, $content_start, $end - $content_start); json_decode($candidate); if (json_last_error() !== JSON_ERROR_NONE) { continue; // keep extending to the next closing tag } $escaped = str_replace('