'OR', array( 'key' => '_metasync_is_custom_html_page', 'compare' => 'NOT EXISTS', ), array( 'key' => '_metasync_is_custom_html_page', 'value' => '1', 'compare' => '!=', ), ); } } 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('