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(); ?>
>
M
SEO SuiteOn-page SEO for this post
>
' . esc_html($icon) . ' ' . esc_html($label) . '
'; $first = false; }; if ($has_seo) { $tab('seo', '🔍', 'SEO'); } if ($has_robots) { $tab('robots', '🤖', 'Robots'); } if ($has_canon) { $tab('canonical', '🔗', 'Canonical'); } if ($has_lang) { $tab('languages', '🌐', 'Languages'); } if ($has_redir) { $tab('redirect', '↪️', 'Redirection'); } if ($has_social) { $tab('social', '📢', 'Social & OG'); } if ($has_schema) { $tab('schema', '📐', 'Schema'); } if ($has_video) { $tab('video', '🎬', 'Video'); } ?>

🔍 Search Appearance

Set the SEO Title and Meta Description. Leave blank to use the OTTO suggestion.

The greyed-out value below was imported from another SEO plugin. It is used only until OTTO has a suggestion for this page.

SEO Title0/60
Meta Description0/160
Breadcrumb Title?
Overrides the breadcrumb label for this post. Leave blank to use the post title.
Focus KeywordManaged by OTTO
🔒
Set in Search Atlas — shown here for reference. Read-only.
Preview

🤖 Robots Meta

Crawler directives for this post. These are independent flags — combine as needed.

Common directives
array('Index', 'Allow search engines to index this page'), 'noindex' => array('No Index', 'Keep this page out of results'), 'nofollow' => array('No Follow', "Don't follow links on this page"), 'noarchive' => array('No Archive', 'No cached copy'), 'noimageindex' => array('No Image Index', 'Exclude images from image search'), 'nosnippet' => array('No Snippet', 'No text snippet in results'), ); foreach ($common as $key => $c): ?>
Advanced limits — tick a limit to apply it
Max snippet length in characters.
Max preview duration in seconds.

🔗 Canonical URL

Point search engines to the preferred version of this content.

Canonical URL
Leave blank to use this post's own URL.

🌐 Language Alternates

Tell search engines about translated or regional versions of this post.

Language
Region
URL
Use ISO codes (e.g. en, en-US, x-default) and absolute URLs. A self-reference is added automatically when missing.

↪️ Redirection

Send visitors of this post somewhere else.

Enable redirectionSend visitors of this post to another URL
Type
Destination URL

📢 Social & Open Graph

How this post looks when shared on social platforms.

Enable Open Graph & Social Media TagsAdds Open Graph + Twitter Card tags for better sharing
Open Graph
Title (og:title)
Description (og:description)
Image (og:image)
Recommended 1200×630px.
Type (og:type)
URL (og:url)
post_status === 'auto-draft' ? ' disabled' : ''; ?>>
Twitter Card
Card Type
Twitter Site
Twitter Title
Twitter Description
Twitter Image
Image Alt
App Card
iPhone App ID
>
iPad App ID
>
Google Play App ID
>
iPhone Custom URL
>
iPad Custom URL
>
Google Play Custom URL
>
App Store Country
Required if your app is not available in the US App Store.
Player Card
Player URL
>
Width (px)
>
Height (px)
>
Social preview
2 hours ago · 🌐
No image selected
@ · now
No image selected
Promoted
No image selected
· 1 min read

📐 Schema Markup

schema_box['callback'])) { call_user_func($this->schema_box['callback'], $post, $this->schema_box); } else { echo '

Schema builder unavailable on this screen.

'; } ?>

🎬 Video Sitemap

Override auto-detected video data. Leave blank to use auto-detection.

Video URL
Thumbnail URL
Video Title
Video Description
Duration (seconds)
Changes are saved when you update the post.
🔒

SEO managed by WebStudio

This page's content is served as pre-built HTML before WordPress renders its <head>, so these SEO Suite settings won't apply. SEO for this page is managed by WebStudio (or OTTO, if enabled) to keep everything in sync.

Editing is disabled here to avoid conflicting values.