Which target boxes were present on the current screen. */ private $present = array(); /** @var array|null Captured Schema meta-box definition (rendered inside the Schema tab). */ private $schema_box = null; public function __construct() { add_action('add_meta_boxes', array($this, 'unify'), 9999, 2); add_action('admin_enqueue_scripts', array($this, 'enqueue_assets')); } /** Load the media library for the OG/Twitter image pickers. */ public function enqueue_assets($hook) { if (in_array($hook, array('post.php', 'post-new.php'), true)) { wp_enqueue_media(); } } /** * True for any request originating from the block editor. * * The block editor renders its meta-box area during the main page load * (is_block_editor() is true there) and re-posts that same form to * post.php?meta-box-loader=1 when saving. Both are treated as block-editor * context so this box never engages in either. */ private function is_block_editor_request() { if (!empty($_REQUEST['meta-box-loader'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag for meta box registration return true; } $screen = get_current_screen(); return $screen && $screen->is_block_editor(); } /** * Runs after MetaSync registers its boxes (default priority). Captures the * target boxes, removes them from display, and registers the unified box. */ public function unify($post_type, $post) { /** Allow sites to opt out of the unified box and keep the classic separate boxes. */ if (!apply_filters('metasync_enable_seo_suite', true, $post_type, $post)) { return; } // Classic editor only. In the block editor the plugin's own SEO sidebar owns // these fields, and the SEO box is registered __back_compat_meta_box so it // never renders there at all. Returning before any remove_meta_box() call // leaves the block editor byte-for-byte as it is without this class. if ($this->is_block_editor_request()) { return; } global $wp_meta_boxes; if (empty($wp_meta_boxes[$post_type])) { return; } $this->present = array(); $this->schema_box = null; foreach ($wp_meta_boxes[$post_type] as $context => $priorities) { foreach ((array) $priorities as $boxes) { if (!is_array($boxes)) { continue; } foreach ($boxes as $box_id => $box) { if (in_array($box_id, $this->target_ids, true) && $box && !empty($box['callback'])) { $this->present[$box_id] = true; if ($box_id === 'metasync-schema-markup') { $this->schema_box = $box; } } } } foreach ($this->target_ids as $id) { remove_meta_box($id, $post_type, $context); } } if (empty($this->present)) { return; } if (empty($wp_meta_boxes[$post_type]['normal']['high']['metasync_seo_suite'])) { $plugin_name = class_exists('Metasync') ? Metasync::get_effective_plugin_name() : 'MetaSync'; add_meta_box( 'metasync_seo_suite', sprintf('SEO Suite by %s', $plugin_name), array($this, 'render'), $post_type, 'normal', 'high' ); } } /** True when the page's front-end output bypasses WordPress SEO (LPS / custom-HTML). */ private function is_managed_page($post_id) { if (function_exists('metasync_is_custom_or_lps_page') && $post_id > 0 && metasync_is_custom_or_lps_page($post_id)) { return true; } // Fallbacks if the helper isn't loaded on this request. if (get_post_meta($post_id, '_metasync_lps_import', true)) { return true; } if (get_post_meta($post_id, '_metasync_is_custom_html_page', true) === '1' && get_post_meta($post_id, '_metasync_raw_html_enabled', true) === '1') { return true; } return false; } private static function v($post_id, $key) { $value = get_post_meta($post_id, $key, true); if (is_array($value)) { return ''; } // Collapse the "Auto Draft" placeholder the meta box pre-fill can persist into // the social title/description keys, so the unified box shows the real pre-fill // instead and re-saving the post clears the stored placeholder. // @phpstan-ignore-next-line function.alreadyNarrowedType if (method_exists('Metasync_OpenGraph', 'strip_auto_draft_title') && defined('Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS') && in_array($key, Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS, true) ) { $value = Metasync_OpenGraph::strip_auto_draft_title($value); } // A social title that merely mirrors the post title is the old pre-fill's // snapshot — stale the moment the post is renamed — not a customization. // Collapsed so the field renders empty (the default stays visible as the // placeholder) and the shared save handler stores nothing for it. // @phpstan-ignore-next-line function.alreadyNarrowedType if (method_exists('Metasync_OpenGraph', 'strip_title_snapshot') && defined('Metasync_OpenGraph::TITLE_DEFAULTED_KEYS') && in_array($key, Metasync_OpenGraph::TITLE_DEFAULTED_KEYS, true) && is_string($value) ) { $value = Metasync_OpenGraph::strip_title_snapshot($post_id, $key, $value); } // A social description that merely mirrors the resolved excerpt is the // same kind of snapshot — stale the moment the excerpt or content // changes — collapsed for the same reason as the title above. // @phpstan-ignore-next-line function.alreadyNarrowedType if (method_exists('Metasync_OpenGraph', 'strip_description_snapshot') && defined('Metasync_OpenGraph::DESCRIPTION_DEFAULTED_KEYS') && in_array($key, Metasync_OpenGraph::DESCRIPTION_DEFAULTED_KEYS, true) && is_string($value) ) { $value = Metasync_OpenGraph::strip_description_snapshot($post_id, $key, $value); } return (string) $value; } private static function checked_attr($cond) { return $cond ? ' checked' : ''; } /** A clean, "pretty" permalink for placeholders/preview (avoids ?p=ID on drafts). */ private static function pretty_permalink($post) { $link = get_permalink($post->ID); if ($link && strpos($link, '?p=') === false && strpos($link, '?page_id=') === false && strpos($link, 'preview=') === false) { return $link; } $slug = $post->post_name ? $post->post_name : sanitize_title(get_the_title($post->ID)); if (!$slug) { $slug = 'your-post'; } return user_trailingslashit(home_url('/' . $slug . '/')); } /** * Render the unified box. */ public function render($post) { $P = $this->present; $id = $post->ID; $managed = $this->is_managed_page($id); $has_seo = isset($P['metasync-seo-meta']) || isset($P['metasync-seo-lps-notice']); $has_crob = isset($P['common-robots-meta']); $has_arob = isset($P['advance-robots-meta']); $has_robots = $has_crob || $has_arob; $has_canon = isset($P['post-canonical-meta']); $has_redir = isset($P['post-redirection-meta']); $has_social = isset($P['metasync_opengraph_meta_box']); $has_schema = isset($P['metasync-schema-markup']); $has_video = isset($P['metasync-video-sitemap-meta']); // Breadcrumb Title is useful while MetaSync breadcrumbs are enabled or // while Yoast/Rank Math owns the rendered breadcrumb trail. $has_breadcrumbs = class_exists('Metasync_Breadcrumbs') ? Metasync_Breadcrumbs::is_breadcrumb_title_meaningful() : true; // The Language Alternates tab follows its own editor-settings switch, // not the Canonical metabox's — disabling one must not hide the other. // The defined() guard matches the REST registration's defence against // an opcache-stale copy of the flags class during an upgrade window. // @phpstan-ignore-next-line function.alreadyNarrowedType $has_lang = defined('Metasync_Feature_Flags::LANGUAGE_ALTERNATES') && Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::LANGUAGE_ALTERNATES); // Current values. $seo_title = self::v($id, '_metasync_seo_title'); $seo_desc = self::v($id, '_metasync_seo_desc'); // The read-only "OTTO keyword" row shows OTTO's suggestion under a lock // icon. Which tier that is — and whether OTTO counts at all, since the // per-post "Disable OTTO" toggle makes its stored suggestion dead data — // is the resolver's call, not this screen's. fallback() drops the // customer tier and honours Disable OTTO, so an OTTO-disabled post stops // showing a dead suggestion here. $kw_fallback = Metasync_Seo_Precedence::fallback($id, Metasync_Seo_Precedence::FIELD_FOCUS_KEYWORD); $otto_kw = $kw_fallback['value']; // Classic-editor fields sharing meta keys with the Gutenberg sidebar, // so values round-trip between editors. The save path lives in // Metasync_Classic_Seo_Fields (the Suite stays presentation-only). $breadcrumb_title = self::v($id, '_metasync_breadcrumb_title'); // hreflang is stored as a JSON string of {lang, region, url} rows. // Decode defensively — malformed, empty, and non-array stored values // must not fatal — the same pattern // Metasync_Hreflang_Output::get_manual_entries() already uses. Values // render as stored, like the Gutenberg panel, which warns about (but // keeps) rows that fail validation. $hreflang_rows = array(); $raw_hreflang = get_post_meta($id, '_metasync_hreflang', true); if (is_string($raw_hreflang) && $raw_hreflang !== '') { $decoded = json_decode($raw_hreflang, true); if (is_array($decoded)) { foreach ($decoded as $entry) { if (!is_array($entry)) { continue; } $hreflang_rows[] = array( 'lang' => isset($entry['lang']) ? (string) $entry['lang'] : '', 'region' => isset($entry['region']) ? (string) $entry['region'] : '', 'url' => isset($entry['url']) ? (string) $entry['url'] : '', ); } } } // The greyed-out value each field shows when left blank: OTTO's // suggestion, or the imported value where OTTO is silent. Which tier // that is — and whether OTTO counts at all, since the per-post "Disable // OTTO" toggle makes its stored suggestion dead data — is // Metasync_Seo_Precedence's call, not this screen's. Without a ghost the // fields look completely empty after an import and the customer // concludes it did nothing. $title_fallback = Metasync_Seo_Precedence::fallback($id, Metasync_Seo_Precedence::FIELD_TITLE); $desc_fallback = Metasync_Seo_Precedence::fallback($id, Metasync_Seo_Precedence::FIELD_DESCRIPTION); $ghost_title = $title_fallback['value']; $ghost_desc = $desc_fallback['value']; $show_imported_note = $title_fallback['source'] === Metasync_Seo_Precedence::SOURCE_IMPORTED || $desc_fallback['source'] === Metasync_Seo_Precedence::SOURCE_IMPORTED; // Robots: post meta first, falling back to the site-wide defaults exactly as // the standalone Common/Advance Robots boxes do (new option spelling first, // then the legacy "mata" spelling). $cr = get_post_meta($id, 'metasync_common_robots', true); if (!$cr && class_exists('Metasync')) { $cr = Metasync::get_option('common_robots_meta') ?? Metasync::get_option('common_robots_mata') ?? ''; } if (!is_array($cr)) { $cr = array(); } $ar = get_post_meta($id, 'metasync_advance_robots', true); if (!$ar && class_exists('Metasync')) { $ar = Metasync::get_option('advance_robots_meta') ?? Metasync::get_option('advance_robots_mata') ?? ''; } if (!is_array($ar)) { $ar = array(); } $ar_snip_e = isset($ar['max-snippet']['enable']) ? $ar['max-snippet']['enable'] : ''; $ar_snip_l = isset($ar['max-snippet']['length']) ? $ar['max-snippet']['length'] : ''; $ar_vid_e = isset($ar['max-video-preview']['enable']) ? $ar['max-video-preview']['enable'] : ''; $ar_vid_l = isset($ar['max-video-preview']['length']) ? $ar['max-video-preview']['length'] : ''; $ar_img_e = isset($ar['max-image-preview']['enable']) ? $ar['max-image-preview']['enable'] : ''; $ar_img_l = isset($ar['max-image-preview']['length']) ? $ar['max-image-preview']['length'] : ''; // Canonical: same sanitise-and-repair path as the standalone Canonical box. // A bare reset() here would re-corrupt nested-array rows to the string "Array". $raw_canonical = get_post_meta($id, 'meta_canonical', true); if (class_exists('Metasync_Canonical_Sanitizer')) { $canon = Metasync_Canonical_Sanitizer::sanitize($raw_canonical); if (is_array($raw_canonical) || Metasync_Canonical_Sanitizer::is_corrupted($raw_canonical)) { if ($canon !== '') { update_post_meta($id, 'meta_canonical', $canon); } else { delete_post_meta($id, 'meta_canonical'); } } elseif ($canon === '' && is_string($raw_canonical) && trim($raw_canonical) !== '') { // Stored value the validator doesn't recognise: show it as-is so a // routine post save doesn't silently wipe it. $canon = trim($raw_canonical); } } else { $canon = is_array($raw_canonical) ? '' : (string) $raw_canonical; } $pretty = self::pretty_permalink($post); $rd = get_post_meta($id, 'metasync_post_redirection_meta', true); if (!is_array($rd)) { $rd = array(); } $rd_en = (isset($rd['enable']) ? $rd['enable'] : '') === 'true'; $rd_type = isset($rd['type']) ? $rd['type'] : '301'; $rd_url = isset($rd['url']) ? $rd['url'] : ''; // The standalone Open Graph box defaults this setting to enabled when // no value has been saved yet. Preserve that behaviour: only an // explicit '0' represents an editor opting out. $og_en_value = get_post_meta($id, '_metasync_og_enabled', true); $og_en = ($og_en_value !== '0'); // Match the standalone Open Graph box exactly: its computed defaults are // real form values (and are therefore persisted on save), not placeholders. $og_defaults = array('title' => '', 'description' => '', 'image' => ''); $opengraph = class_exists('Metasync_OpenGraph') ? Metasync_OpenGraph::get_instance() : null; if ($opengraph) { $og_defaults = $opengraph->get_default_og_values($id); } // Social titles and descriptions render placeholder-only: the default // (the post title, the resolved excerpt) stays visible in the // placeholder but is never a form value, so submitting the page cannot // persist a snapshot of it. Images keep their computed default as a // real value, matching the standalone OG box. $og_title = self::v($id, '_metasync_og_title'); $og_desc = self::v($id, '_metasync_og_description'); $og_image = self::v($id, '_metasync_og_image') ?: $og_defaults['image']; $og_type = self::v($id, '_metasync_og_type') ?: 'article'; $og_url = self::v($id, '_metasync_og_url'); if ($og_url === '') { $og_url = $opengraph ? $opengraph->get_canonical_url($post) : $pretty; } $tw_card = self::v($id, '_metasync_twitter_card') ?: 'summary_large_image'; $tw_site = self::v($id, '_metasync_twitter_site'); $tw_title = self::v($id, '_metasync_twitter_title'); // Placeholder-only like the standalone box: when no twitter title is // stored the placeholder shows the og title if one is set, else the // default (post) title — the exact chain the standalone OG box uses. $tw_title_placeholder = ($og_title !== '') ? $og_title : $og_defaults['title']; // Same chain for the twitter description's placeholder: the og // description if one is set, else the resolved excerpt default. $tw_desc_placeholder = ($og_desc !== '') ? $og_desc : $og_defaults['description']; $tw_desc = self::v($id, '_metasync_twitter_description'); $tw_image = self::v($id, '_metasync_twitter_image') ?: $og_image; $tw_alt = self::v($id, '_metasync_twitter_image_alt'); $tw_app_iphone = self::v($id, '_metasync_twitter_app_id_iphone'); $tw_app_ipad = self::v($id, '_metasync_twitter_app_id_ipad'); $tw_app_gp = self::v($id, '_metasync_twitter_app_id_googleplay'); $tw_app_url_iphone = self::v($id, '_metasync_twitter_app_url_iphone'); $tw_app_url_ipad = self::v($id, '_metasync_twitter_app_url_ipad'); $tw_app_url_gp = self::v($id, '_metasync_twitter_app_url_googleplay'); $tw_app_country = self::v($id, '_metasync_twitter_app_country'); $tw_player = self::v($id, '_metasync_twitter_player'); $tw_player_w = self::v($id, '_metasync_twitter_player_width'); $tw_player_h = self::v($id, '_metasync_twitter_player_height'); $tw_app_disabled = $tw_card === 'app' ? '' : ' disabled'; $tw_player_disabled = $tw_card === 'player' ? '' : ' disabled'; $v_url = self::v($id, '_metasync_video_url'); $v_thumb = self::v($id, '_metasync_video_thumbnail'); $v_title = self::v($id, '_metasync_video_title'); $v_desc = self::v($id, '_metasync_video_description'); $v_dur = self::v($id, '_metasync_video_duration'); $host = strtoupper((string) wp_parse_url(home_url(), PHP_URL_HOST)); $host_lc = strtolower($host); $prev_url_disp = preg_replace('#^https?://#', '', (string) $pretty); $site = get_bloginfo('name'); if ($site === '') { $site = $host; } $avatar = strtoupper(function_exists('mb_substr') ? mb_substr($site, 0, 1) : substr($site, 0, 1)); $handle = sanitize_title($site) ?: 'site'; $this->styles(); ?>