PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | admin/class-metasync-seo-sidebar.php +415 -148 2.6.13 → 2.7.2 View file →
@@ -33,8 +33,28 @@
33 33 */
34 34 const META_DESCRIPTION = '_metasync_seo_desc';
35 35
36 36 /**
37 + * Meta keys for values brought in by the external SEO importer.
38 + *
39 + * Deliberately separate from META_SEO_TITLE / META_DESCRIPTION. Those two
40 + * mean "the customer typed this" and therefore outrank OTTO — the Classic
41 + * meta box tells the user as much ("Leave the field blank to use OTTO's
42 + * suggestion"). A bulk import is not a per-post customer decision, so
43 + * writing it there silently opted every imported post out of OTTO.
44 + *
45 + * These keys render only where OTTO has no suggestion. See
46 + * filter_document_title_imported() and output_seo_meta_description().
47 + */
48 + const META_IMPORTED_SEO_TITLE = Metasync_Seo_Precedence::KEY_IMPORTED_TITLE;
49 + const META_IMPORTED_DESCRIPTION = Metasync_Seo_Precedence::KEY_IMPORTED_DESC;
50 +
51 + /**
52 + * Meta key for the OTTO focus keyword (read-only, written by OTTO)
53 + */
54 + const META_OTTO_KEYWORDS = '_metasync_otto_keywords';
55 +
56 + /**
37 57 * Meta key for primary category (used in breadcrumbs and canonical URL)
38 58 */
39 59 const META_PRIMARY_CATEGORY = '_metasync_primary_category';
40 60
@@ -77,9 +97,9 @@
77 97
78 98 // Enqueue block editor assets
79 99 add_action('enqueue_block_editor_assets', array($this, 'enqueue_block_editor_assets'));
80 100
81 - // Track meta state before REST API save, then clean up after (all public post types)
101 + // Clean up emptied SEO meta rows after REST saves (all public post types)
82 102 add_action('rest_api_init', array($this, 'register_rest_save_hooks'));
83 103
84 104 // Clean up primary category meta when category is unchecked (all post types)
85 105 add_action('save_post', array($this, 'cleanup_primary_category_meta'), 10, 1);
@@ -88,18 +108,24 @@
88 108 // Custom values ALWAYS take priority over OTTO suggestions
89 109 add_action('wp_head', array($this, 'output_seo_meta_description'), 2);
90 110 add_filter('pre_get_document_title', array($this, 'filter_document_title'), 100);
91 111 add_filter('document_title_parts', array($this, 'filter_document_title_parts'), 100);
112 +
113 + // Imported values sit BELOW OTTO. Priority 98 runs before OTTO's own
114 + // pre_get_document_title callback (priority 99, see
115 + // otto/metasync-otto-seo-functions.php), so OTTO overwrites this value
116 + // whenever it has a suggestion and leaves it alone when it does not.
117 + // The customer's own title at priority 100 still beats both.
118 + add_filter('pre_get_document_title', array($this, 'filter_document_title_imported'), 98);
92 119 }
93 120
94 121 /**
95 - * Register REST API save/cleanup hooks for all public post types
122 + * Register REST API cleanup hooks for all public post types
96 123 */
97 124 public function register_rest_save_hooks() {
98 125 $post_types = get_post_types(array('public' => true), 'names');
99 126 unset($post_types['attachment']);
100 127 foreach ($post_types as $post_type) {
101 - add_filter("rest_pre_insert_{$post_type}", array($this, 'track_meta_before_save'), 10, 2);
102 128 add_action("rest_after_insert_{$post_type}", array($this, 'cleanup_empty_seo_meta'), 10, 3);
103 129 }
104 130 }
105 131
@@ -114,66 +140,33 @@
114 140 return ($otto_enabled === 'true' || $otto_enabled === true);
115 141 }
116 142
117 143 /**
118 - * Track meta state before REST API save
119 - * Stores which SEO meta keys existed before the save
120 - *
121 - * @param stdClass $prepared_post Prepared post object
122 - * @param WP_REST_Request $request Request object
123 - * @return stdClass Unmodified prepared post object
124 - */
125 - public function track_meta_before_save($prepared_post, $request) {
126 - $post_id = $request->get_param('id');
127 - if (!$post_id) {
128 - return $prepared_post;
129 - }
130 -
131 - // Track which meta keys existed before this save
132 - $meta_existed = array(
133 - self::META_SEO_TITLE => metadata_exists('post', $post_id, self::META_SEO_TITLE),
134 - self::META_DESCRIPTION => metadata_exists('post', $post_id, self::META_DESCRIPTION),
135 - );
136 -
137 - // Store in transient for use in cleanup
138 - set_transient('metasync_seo_meta_existed_' . $post_id, $meta_existed, 60);
139 -
140 - return $prepared_post;
141 - }
142 -
143 - /**
144 - * Clean up empty SEO meta values after REST API save
145 - * This prevents blanking fields that are showing OTTO fallback values
146 - *
147 - * Deletes meta keys that are empty AND didn't exist before the save
148 - * (meaning they were showing OTTO fallback, not user-edited values)
149 - *
150 - * @param WP_Post $post Inserted or updated post object
144 + * Clean up empty SEO meta values after a REST save
145 + *
146 + * The REST API cannot delete a meta key — clearing a sidebar field
147 + * persists an empty string, and that empty row would keep outranking
148 + * OTTO/imported suggestions forever. Mirror the classic metabox save
149 + * path instead: an empty value loses its row so the post falls back to
150 + * the suggestion tiers again.
151 + *
152 + * @param WP_Post|int $post Inserted or updated post object (or its ID)
151 153 * @param WP_REST_Request $_request Request object (unused)
152 154 * @param bool $_creating True when creating a post, false when updating (unused)
153 155 */
154 156 public function cleanup_empty_seo_meta($post, $_request, $_creating) {
155 - $post_id = $post->ID;
156 -
157 - // Get the before-save state
158 - $meta_existed = get_transient('metasync_seo_meta_existed_' . $post_id);
159 - if ($meta_existed === false) {
160 - // Transient expired or not set, can't make safe decision
157 + $post_id = is_object($post) ? (int) $post->ID : (int) $post;
158 + if (!$post_id) {
161 159 return;
162 160 }
163 161
164 - // Delete transient
165 - delete_transient('metasync_seo_meta_existed_' . $post_id);
166 -
167 - // Check each of our SEO meta keys
168 - foreach ($meta_existed as $meta_key => $existed_before) {
169 - // Get the current value
162 + foreach (array(self::META_SEO_TITLE, self::META_DESCRIPTION) as $meta_key) {
170 163 $meta_value = get_post_meta($post_id, $meta_key, true);
171 164
172 - // Only delete if:
173 - // 1. Value is now empty
174 - // 2. It didn't exist before the save (was showing OTTO fallback)
175 - if (empty($meta_value) && !$existed_before) {
165 + // Strict '' check: get_post_meta() returns '' for a missing row
166 + // too, where delete_post_meta() is a harmless no-op. A real
167 + // (non-empty) value the user typed is never dropped.
168 + if ($meta_value === '') {
176 169 delete_post_meta($post_id, $meta_key);
177 170 }
178 171 }
179 172 }
@@ -180,9 +173,10 @@
180 173
181 174 /**
182 175 * Output meta description tags from sidebar field
183 176 * Custom values ALWAYS take priority over OTTO
184 - * Outputs: meta description, og:description, and twitter:description
177 + * Outputs: meta description, og:description, and twitter:description,
178 + * plus og:title / twitter:title when a suppressed third-party tag needs replacing
185 179 */
186 180 public function output_seo_meta_description() {
187 181 // Skip for admin, feeds, etc.
188 182 if (is_admin() || is_feed() || is_robots()) {
@@ -198,32 +192,134 @@
198 192 if (!$post_id) {
199 193 return;
200 194 }
201 195
202 - // WP-196: When synced to an active SEO plugin, that plugin outputs
203 - // the description from its native storage — skip MetaSync's own tag.
204 196 $conflict_handler = Metasync_SEO_Conflict_Handler::get_instance();
197 +
198 + // og:title / twitter:title replacement (WP-609).
199 + //
200 + // Still needed, but for a narrower case than when it was written. The
201 + // conflict-handler filters now hand a third-party plugin MetaSync's OG
202 + // title whenever one is set, so that plugin renders it and nothing is
203 + // required here. What the filters cannot cover is a post holding a page
204 + // title and no OG-specific value: there they pass the plugin's own tag
205 + // through, and without this block the page carries the plugin's og:title
206 + // while <title> carries ours. og_title_needs_replacement() returns false
207 + // once the filters are supplying the tag, so the two never both emit.
208 + if ($conflict_handler->og_title_needs_replacement()) {
209 + $title = $conflict_handler->get_metasync_title();
210 + if (!empty($title)) {
211 + $title_escaped = esc_attr($title);
212 + echo '<meta property="og:title" content="' . $title_escaped . '" data-metasync-seo="custom" />' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value already escaped via esc_attr() above; the sniff cannot track custom escaped variables
213 + echo '<meta name="twitter:title" content="' . $title_escaped . '" data-metasync-seo="custom" />' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value already escaped via esc_attr() above; the sniff cannot track custom escaped variables
214 + }
215 + }
216 +
217 + // With an active third-party SEO plugin, that plugin emits the description
218 + // tag and the conflict handler hands it MetaSync's value when MetaSync
219 + // holds one. Emitting here as well would put two identical description
220 + // tags on the page.
221 + //
222 + // The condition is "will that plugin render a description", not "has a
223 + // sync run": a timestamp only records that a sync happened, and the
224 + // mirrored copy can be edited or emptied in the plugin afterwards. When
225 + // neither side holds anything we fall through, so a stale or partial sync
226 + // cannot drop the description entirely.
227 + $defer_meta_description = false;
205 228 if ($conflict_handler->has_active_seo_plugin()) {
206 - $sync_ts = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
207 - if (!empty($sync_ts)) {
208 - return;
229 + if ($conflict_handler->metasync_has_description()
230 + || $conflict_handler->active_plugin_has_description($post_id)) {
231 + $defer_meta_description = true;
209 232 }
210 233 }
211 234
212 - // Get the custom SEO description (takes priority)
213 - $description = get_post_meta($post_id, self::META_DESCRIPTION, true);
235 + // With a plugin active the conflict handler now supplies og:/twitter:
236 + // description too — falling back to the page description when no
237 + // OG-specific value is set — so that plugin renders exactly one. Emitting
238 + // here as well is always a second tag, never a replacement.
239 + $defer_og_description = $conflict_handler->has_active_seo_plugin()
240 + || $conflict_handler->third_party_owns_og_description();
214 241
242 + if ($defer_meta_description && $defer_og_description) {
243 + return;
244 + }
245 +
246 + // Which stored value wins is decided in one place, so the global
247 + // "SEO Meta Priority" setting reaches this emitter
248 + // without a second copy of the rule. Under the default this is still
249 + // the customer's description; under OTTO priority the chain hands back
250 + // OTTO's value and falls back to the custom one where OTTO has none.
251 + $resolved = Metasync_Seo_Precedence::resolve($post_id, Metasync_Seo_Precedence::FIELD_DESCRIPTION);
252 + $description = $resolved['value'];
253 +
254 + // The marker has to describe what actually won, not which code path
255 + // emitted it: Otto_html_class treats data-metasync-seo="custom" as an
256 + // explicit override that must survive the buffer pass, and a tag
257 + // carrying OTTO's own value must not claim to be one. The resolver
258 + // already names the tier it took the value from, so the marker follows
259 + // that rather than being inferred a second time here.
260 + $is_imported_fallback = $resolved['source'] === Metasync_Seo_Precedence::SOURCE_IMPORTED;
261 + $is_otto_value = Metasync_Seo_Precedence::is_otto_value(
262 + $post_id,
263 + Metasync_Seo_Precedence::FIELD_DESCRIPTION,
264 + $resolved
265 + );
266 +
267 + // Exactly one printer owns each description value. When OTTO's stored
268 + // suggestion is the resolved winner, this emitter stands down and
269 + // metasync_output_otto_meta_description() (wp_head priority 1) prints
270 + // it — that emitter already covers every delivery path this one runs
271 + // on, and both firing ships two identical description tags on pages
272 + // the OTTO SSR buffer dedup never reaches (cold/served-from-cache,
273 + // rate-limited, or Cloudflare-pixel mode). Every other tier — the
274 + // customer's value, the persisted pair, imported fallbacks — is still
275 + // printed here, and the og:title replacement above is unaffected.
276 + // Mirrors the ownership rule in that emitter's stand-down guard.
277 + if ($resolved['key'] === Metasync_Seo_Precedence::KEY_OTTO_DESC) {
278 + return;
279 + }
280 +
281 + // An imported value is migration data rather than a per-post customer
282 + // decision, so it only renders where MetaSync owns the output.
283 + if ($is_imported_fallback && $this->third_party_owns_output()) {
284 + $description = '';
285 + $is_imported_fallback = false;
286 + }
287 +
215 288 // Output meta description tags if custom value exists
216 289 // This will be output BEFORE OTTO processes the page, and since we're using
217 290 // a high priority filter, it will override OTTO's meta description
218 291 if (!empty($description)) {
219 292 $description_escaped = esc_attr($description);
293 + if ($is_imported_fallback) {
294 + $marker = ' data-metasync-seo="imported"';
295 + } elseif ($is_otto_value) {
296 + $marker = ' data-metasync-otto="true"';
297 + } else {
298 + $marker = ' data-metasync-seo="custom"';
299 + }
220 300 // Standard meta description
221 - echo '<meta name="description" content="' . $description_escaped . '" data-metasync-seo="custom" />' . "\n";
222 - // Open Graph description
223 - echo '<meta property="og:description" content="' . $description_escaped . '" data-metasync-seo="custom" />' . "\n";
224 - // Twitter description
225 - echo '<meta name="twitter:description" content="' . $description_escaped . '" data-metasync-seo="custom" />' . "\n";
301 + if (!$defer_meta_description) {
302 + echo '<meta name="description" content="' . $description_escaped . '"' . $marker . ' />' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value already escaped via esc_attr() above; the sniff cannot track custom escaped variables
303 + }
304 +
305 + // og:description / twitter:description are Open Graph & social tags, so they
306 + // answer to the site-wide Social Media & Open Graph switch as well as the
307 + // per-post "Enable Open Graph & Social Media Tags" toggle. For the per-post
308 + // toggle only an explicit '0' opt-out disables them (unset/empty counts as
309 + // enabled), mirroring Metasync_OpenGraph::will_emit(). When either says no,
310 + // MetaSync emits no OG/Twitter description, so it neither overrides a
311 + // third-party plugin's tag nor leaves one sourced from our database.
312 + //
313 + // The plain <meta name="description"> above is deliberately NOT gated here:
314 + // it belongs to the SEO title/description feature, which has its own setting.
315 + if (!$defer_og_description
316 + && !Metasync_OpenGraph::is_social_output_disabled($post_id)) {
317 + // Open Graph description
318 + echo '<meta property="og:description" content="' . $description_escaped . '"' . $marker . ' />' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value already escaped via esc_attr() above; the sniff cannot track custom escaped variables
319 + // Twitter description
320 + echo '<meta name="twitter:description" content="' . $description_escaped . '"' . $marker . ' />' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value already escaped via esc_attr() above; the sniff cannot track custom escaped variables
321 + }
226 322 }
227 323 }
228 324
229 325 /**
@@ -248,19 +344,87 @@
248 344 if (!$post_id) {
249 345 return $title;
250 346 }
251 347
252 - // Get the custom SEO title (takes priority)
253 - $seo_title = get_post_meta($post_id, self::META_SEO_TITLE, true);
348 + // Which stored value wins is decided in one place, so the global
349 + // "SEO Meta Priority" setting reaches this filter
350 + // without a second copy of the rule. Under the default the customer's
351 + // title still wins; under OTTO priority the chain hands back OTTO's
352 + // value and falls back to the custom one where OTTO has none.
353 + //
354 + // The resolver reads the whole chain, so it also answers for a post
355 + // whose title lives on the persisted or imported tier — cases this
356 + // filter used to miss entirely by reading only the sidebar key.
357 + $seo_title = Metasync_Seo_Precedence::value($post_id, Metasync_Seo_Precedence::FIELD_TITLE);
254 358
255 - // Return custom SEO title if set
256 359 return !empty($seo_title) ? $seo_title : $title;
257 360 }
258 361
259 362 /**
363 + * Whether a third-party SEO plugin is the one actually rendering this page.
364 + *
365 + * Mirrors the guard OTTO's own filters use
366 + * (otto/metasync-otto-seo-functions.php): when an active plugin owns output
367 + * and OTTO has nothing live to say, leave that plugin's tags alone.
368 + *
369 + * Gates the *imported* value only. Imported values are migration data with
370 + * no per-post customer intent behind them, so they must never compete with a
371 + * plugin the customer is still using. The description emitter is an echo on
372 + * wp_head rather than a filter return, so without this it would add a second
373 + * <meta name="description"> next to the third-party plugin's own.
374 + *
375 + * @return bool True when MetaSync should stand down for imported values.
376 + */
377 + private function third_party_owns_output() {
378 + if (!class_exists('Metasync_SEO_Conflict_Handler')) {
379 + return false;
380 + }
381 +
382 + $handler = Metasync_SEO_Conflict_Handler::get_instance();
383 +
384 + return $handler->has_active_seo_plugin() && !$handler->otto_has_live_suggestions();
385 + }
386 +
387 + /**
388 + * Supply an imported SEO title as a fallback BELOW OTTO.
389 + *
390 + * Registered at priority 98 so OTTO's own callback (priority 99) runs
391 + * afterwards: OTTO overwrites this whenever it has a suggestion, and
392 + * returns the title untouched when it does not — which is exactly when the
393 + * imported value should show. The customer's own title (priority 100) still
394 + * wins over both.
395 + *
396 + * @param string $title Current title.
397 + * @return string Imported title when it should apply, else unchanged.
398 + */
399 + public function filter_document_title_imported($title) {
400 + if (is_admin() || !is_singular()) {
401 + return $title;
402 + }
403 +
404 + $post_id = get_the_ID();
405 + if (!$post_id) {
406 + return $title;
407 + }
408 +
409 + // Never outrank a third-party SEO plugin that is the one actually
410 + // rendering this page.
411 + if ($this->third_party_owns_output()) {
412 + return $title;
413 + }
414 +
415 + $imported = get_post_meta($post_id, self::META_IMPORTED_SEO_TITLE, true);
416 +
417 + return (!empty($imported) && is_string($imported)) ? $imported : $title;
418 + }
419 +
420 + /**
260 421 * Filter document title parts (for themes using wp_get_document_title)
261 - * Custom values ALWAYS take priority over OTTO
262 422 *
423 + * Which stored value wins is decided by Metasync_Seo_Precedence, so this
424 + * filter follows the global "SEO Meta Priority" setting
425 + * without restating the order.
426 + *
263 427 * @param array $title_parts Title parts array
264 428 * @return array Modified title parts
265 429 */
266 430 public function filter_document_title_parts($title_parts) {
@@ -278,12 +442,11 @@
278 442 if (!$post_id) {
279 443 return $title_parts;
280 444 }
281 445
282 - // Get the custom SEO title (takes priority)
283 - $seo_title = get_post_meta($post_id, self::META_SEO_TITLE, true);
446 + $seo_title = Metasync_Seo_Precedence::value($post_id, Metasync_Seo_Precedence::FIELD_TITLE);
284 447
285 - // Replace title part if custom SEO title is set
448 + // Replace title part if MetaSync holds a title for this post
286 449 if (!empty($seo_title)) {
287 450 $title_parts['title'] = $seo_title;
288 451 // Remove tagline and site for clean SEO title
289 452 unset($title_parts['tagline']);
@@ -402,10 +565,27 @@
402 565 return current_user_can('edit_post', $object_id);
403 566 },
404 567 ));
405 568
406 - // Register OTTO disabled flag for REST API (to check if OTTO is disabled per-post)
407 - register_post_meta($post_type, '_metasync_otto_disabled', array(
569 + // Register imported SEO meta for REST API (read-only fallback shown
570 + // as a placeholder only when OTTO has no suggestion)
571 + foreach (array(self::META_IMPORTED_SEO_TITLE, self::META_IMPORTED_DESCRIPTION) as $imported_key) {
572 + register_post_meta($post_type, $imported_key, array(
573 + 'show_in_rest' => true,
574 + 'single' => true,
575 + 'type' => 'string',
576 + 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
577 + if (empty($object_id)) {
578 + $pt_obj = get_post_type_object($post_type);
579 + return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
580 + }
581 + return current_user_can('edit_post', $object_id);
582 + },
583 + ));
584 + }
585 +
586 + // Register OTTO focus keyword for REST API (read-only display in sidebar)
587 + register_post_meta($post_type, self::META_OTTO_KEYWORDS, array(
408 588 'show_in_rest' => true,
409 589 'single' => true,
410 590 'type' => 'string',
411 591 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
@@ -416,20 +596,13 @@
416 596 return current_user_can('edit_post', $object_id);
417 597 },
418 598 ));
419 599
420 - // Register hreflang / language alternates meta (JSON-encoded array)
421 - register_post_meta($post_type, self::META_HREFLANG, array(
600 + // Register OTTO disabled flag for REST API (to check if OTTO is disabled per-post)
601 + register_post_meta($post_type, '_metasync_otto_disabled', array(
422 602 'show_in_rest' => true,
423 603 'single' => true,
424 604 'type' => 'string',
425 - 'sanitize_callback' => function($value) {
426 - $decoded = json_decode($value, true);
427 - if (!is_array($decoded)) {
428 - return '[]';
429 - }
430 - return wp_json_encode($decoded);
431 - },
432 605 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
433 606 if (empty($object_id)) {
434 607 $pt_obj = get_post_type_object($post_type);
435 608 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
@@ -437,9 +610,36 @@
437 610 return current_user_can('edit_post', $object_id);
438 611 },
439 612 ));
440 613
441 - // Register plugin sync timestamp meta (WP-196)
614 + // Register hreflang / language alternates meta (JSON-encoded array).
615 + // Gated on the Language Alternates feature flag: when the feature is
616 + // disabled the meta is not registered for REST, so the Gutenberg panel
617 + // has nothing to bind to. Saved values are kept regardless.
618 + //
619 + // defined() rather than class_exists(): during the upgrade window a
620 + // stale opcache copy of Metasync_Feature_Flags satisfies
621 + // class_exists() while lacking a newly added constant, and reading
622 + // LANGUAGE_ALTERNATES off it is a fatal. defined() checks the
623 + // constant itself, which is the thing this line actually needs.
624 + // @phpstan-ignore-next-line function.alreadyNarrowedType
625 + if (defined('Metasync_Feature_Flags::LANGUAGE_ALTERNATES') && Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::LANGUAGE_ALTERNATES)) {
626 + register_post_meta($post_type, self::META_HREFLANG, array(
627 + 'show_in_rest' => true,
628 + 'single' => true,
629 + 'type' => 'string',
630 + 'sanitize_callback' => array(__CLASS__, 'sanitize_hreflang_meta'),
631 + 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
632 + if (empty($object_id)) {
633 + $pt_obj = get_post_type_object($post_type);
634 + return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
635 + }
636 + return current_user_can('edit_post', $object_id);
637 + },
638 + ));
639 + }
640 +
641 + // Register plugin sync timestamp meta
442 642 register_post_meta($post_type, self::META_PLUGIN_SYNC_TS, array(
443 643 'show_in_rest' => true,
444 644 'single' => true,
445 645 'type' => 'string',
@@ -451,9 +651,9 @@
451 651 return current_user_can('edit_post', $object_id);
452 652 },
453 653 ));
454 654
455 - // Register advanced robots directives meta (WP-197)
655 + // Register advanced robots directives meta
456 656 register_post_meta($post_type, self::META_ROBOTS_ADVANCED, array(
457 657 'show_in_rest' => true,
458 658 'single' => true,
459 659 'type' => 'string',
@@ -493,13 +693,67 @@
493 693 }
494 694 }
495 695
496 696 /**
697 + * Sanitize the `_metasync_hreflang` meta value (register_post_meta
698 + * sanitize_callback; static so it is directly testable).
699 + *
700 + * The stored value is a JSON array of {lang, region, url} rows. Rows are
701 + * normalised through Metasync_Hreflang_Output::normalize_entry() (so
702 + * `en_US` becomes `en`+`US`, casing is folded, `x-default` drops its
703 + * region), fully blank rows are dropped, and malformed input degrades to
704 + * an empty array. Rows whose language code still fails validation after
705 + * normalisation are KEPT — the emitter skips them, and the editor panel
706 + * warns — so saving never silently erases what the user typed.
707 + *
708 + * @param mixed $value Incoming JSON string.
709 + * @return string JSON array string.
710 + */
711 + public static function sanitize_hreflang_meta($value) {
712 + // A non-string (e.g. a future importer passing an array straight to
713 + // update_post_meta) must degrade to "no alternates" rather than
714 + // fatal inside sanitize_meta on PHP 8.
715 + if (!is_string($value)) {
716 + return '[]';
717 + }
718 +
719 + $decoded = json_decode($value, true);
720 + if (!is_array($decoded)) {
721 + return '[]';
722 + }
723 +
724 + $clean = array();
725 + foreach ($decoded as $entry) {
726 + if (!is_array($entry)) {
727 + continue;
728 + }
729 + $normalized = Metasync_Hreflang_Output::normalize_entry($entry);
730 + if ($normalized['lang'] === '' && $normalized['url'] === '') {
731 + // A stray blank row ("Add alternate" clicked, never filled
732 + // in) must not flip the post into MetaSync-emits mode on
733 + // WPML sites.
734 + continue;
735 + }
736 + $clean[] = array(
737 + 'lang' => $normalized['lang'],
738 + 'region' => $normalized['region'],
739 + 'url' => $normalized['url'],
740 + );
741 + }
742 +
743 + // UNESCAPED_* keeps non-ASCII URLs readable in storage and free of
744 + // \uXXXX escapes that could be corrupted by a later unslash.
745 + return wp_json_encode($clean, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
746 + }
747 +
748 + /**
497 749 * Build a read-only list of WPML translation entries for a post.
498 750 *
499 - * Returns an array of {lang, url} objects by querying the
500 - * `icl_translations` table. Used to seed the "Language Alternates"
501 - * Gutenberg panel with auto-populated values.
751 + * Delegates to the shared implementation in
752 + * Metasync_Hreflang_Output::get_wpml_entries() so the editor panel shows
753 + * exactly what the front end emits — including the synthesised
754 + * x-default row and the region part — instead of a divergent copy of
755 + * the query.
502 756 *
503 757 * @param int $post_id Post ID.
504 758 * @return array
505 759 */
@@ -507,46 +761,20 @@
507 761 if (!defined('ICL_SITEPRESS_VERSION') || $post_id <= 0) {
508 762 return array();
509 763 }
510 764
511 - global $wpdb;
512 - $post = get_post($post_id);
513 - if (!$post) {
514 - return array();
515 - }
765 + $hreflang = new Metasync_Hreflang_Output();
766 + $entries = $hreflang->get_wpml_entries($post_id);
516 767
517 - $element_type = 'post_' . $post->post_type;
518 - $table = $wpdb->prefix . 'icl_translations';
519 -
520 - $trid = $wpdb->get_var($wpdb->prepare(
521 - "SELECT trid FROM {$table} WHERE element_id = %d AND element_type = %s LIMIT 1",
522 - $post_id,
523 - $element_type
524 - ));
525 - if (empty($trid)) {
526 - return array();
527 - }
528 -
529 - $rows = $wpdb->get_results($wpdb->prepare(
530 - "SELECT language_code, element_id FROM {$table} WHERE trid = %d",
531 - $trid
532 - ));
533 - if (empty($rows)) {
534 - return array();
535 - }
536 -
537 - $entries = array();
538 - foreach ($rows as $row) {
539 - $permalink = get_permalink((int) $row->element_id);
540 - if (empty($permalink)) {
541 - continue;
542 - }
543 - $entries[] = array(
544 - 'lang' => (string) $row->language_code,
545 - 'url' => $permalink,
768 + $out = array();
769 + foreach ($entries as $entry) {
770 + $out[] = array(
771 + 'lang' => isset($entry['lang']) ? $entry['lang'] : '',
772 + 'region' => isset($entry['region']) ? $entry['region'] : '',
773 + 'url' => isset($entry['url']) ? $entry['url'] : '',
546 774 );
547 775 }
548 - return $entries;
776 + return $out;
549 777 }
550 778
551 779 /**
552 780 * Enqueue block editor assets
@@ -609,21 +837,31 @@
609 837 if (class_exists('Metasync') && method_exists('Metasync', 'get_whitelabel_otto_name')) {
610 838 $otto_name = Metasync::get_whitelabel_otto_name();
611 839 }
612 840
613 - // Get current post ID and check if meta keys exist in database
614 - $post_id = isset($_GET['post']) ? intval($_GET['post']) : 0;
615 - $has_seo_title = false;
616 - $has_seo_description = false;
841 + // Get current post ID (used for the LPS/custom-page check and WPML
842 + // entries below)
843 + $post_id = isset($_GET['post']) ? intval($_GET['post']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only post ID for editor script data
617 844
618 - if ($post_id > 0) {
619 - // Check if meta keys exist in database (even if value is empty)
620 - $has_seo_title = metadata_exists('post', $post_id, self::META_SEO_TITLE);
621 - $has_seo_description = metadata_exists('post', $post_id, self::META_DESCRIPTION);
622 - }
845 + // LPS / custom-HTML pages bake their own SEO — suppress the editable sidebar
846 + // panels and surface a read-only notice instead.
847 + // otto_pixel.php (where metasync_is_custom_or_lps_page lives) is only
848 + // required conditionally, so the function_exists guard is needed at runtime.
849 + $is_lps_page = function_exists('metasync_is_custom_or_lps_page') && $post_id > 0 && metasync_is_custom_or_lps_page($post_id);
623 850
624 851 // Auto-detected WPML entries for the "Language Alternates" panel.
625 - $wpml_entries = $this->get_wpml_entries_for_post($post_id);
852 + // Gated on the feature flag: when disabled the panel is hidden, so the
853 + // entries are zeroed to avoid seeding data the UI cannot bind to.
854 + //
855 + // defined() rather than class_exists(): see register_post_meta() above —
856 + // a stale opcache copy of the class passes class_exists() but has no
857 + // LANGUAGE_ALTERNATES constant, and reading it is a fatal.
858 + // @phpstan-ignore-next-line function.alreadyNarrowedType
859 + $language_alternates_enabled = defined('Metasync_Feature_Flags::LANGUAGE_ALTERNATES')
860 + && Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::LANGUAGE_ALTERNATES);
861 + $wpml_entries = $language_alternates_enabled
862 + ? $this->get_wpml_entries_for_post($post_id)
863 + : array();
626 864
627 865 // Link Suggestions configuration
628 866 $link_suggestions_config = array(
629 867 'restUrl' => rest_url('metasync/v1/link-suggestions'),
@@ -674,8 +912,9 @@
674 912
675 913 // Localize script with meta keys and settings
676 914 wp_localize_script('metasync-seo-sidebar', 'metasyncSeoSidebar', array(
677 915 'iconUrl' => $icon_url,
916 + 'isCustomOrLpsPage' => $is_lps_page,
678 917 'otherSeoPrimary' => $other_seo_primary,
679 918 'metaKeys' => array(
680 919 'seoTitle' => self::META_SEO_TITLE,
681 920 'metaDescription' => self::META_DESCRIPTION,
@@ -680,12 +919,17 @@
680 919 'seoTitle' => self::META_SEO_TITLE,
681 920 'metaDescription' => self::META_DESCRIPTION,
682 921 // Breadcrumb meta keys
683 922 'breadcrumbTitle' => '_metasync_breadcrumb_title',
684 - 'primaryCategory' => '_metasync_primary_category',
685 923 // OTTO keys for fallback (read-only, used to prefill if manual fields are empty)
686 924 'ottoTitle' => '_metasync_otto_title',
687 925 'ottoDescription' => '_metasync_otto_description',
926 + // Imported keys (read-only). Rank below OTTO — used as the
927 + // placeholder only when OTTO has no suggestion for the post.
928 + 'importedTitle' => self::META_IMPORTED_SEO_TITLE,
929 + 'importedDescription' => self::META_IMPORTED_DESCRIPTION,
930 + // OTTO focus keyword (read-only display only)
931 + 'ottoKeywords' => self::META_OTTO_KEYWORDS,
688 932 // OTTO disabled per-post flag
689 933 'ottoDisabled' => '_metasync_otto_disabled',
690 934 // Primary category
691 935 'primaryCategory' => self::META_PRIMARY_CATEGORY,
@@ -691,11 +935,11 @@
691 935 'primaryCategory' => self::META_PRIMARY_CATEGORY,
692 936 'primaryProductCat' => self::META_PRIMARY_PRODUCT_CAT,
693 937 // Language alternates (hreflang) — JSON-encoded array
694 938 'hreflang' => self::META_HREFLANG,
695 - // Plugin sync timestamp (WP-196)
939 + // Plugin sync timestamp
696 940 'pluginSyncTs' => self::META_PLUGIN_SYNC_TS,
697 - // Advanced robots directives (WP-197)
941 + // Advanced robots directives
698 942 'robotsAdvanced' => self::META_ROBOTS_ADVANCED,
699 943 ),
700 944 'activeSeoPlugins' => array(
701 945 'yoast' => defined('WPSEO_VERSION'),
@@ -701,14 +945,23 @@
701 945 'yoast' => defined('WPSEO_VERSION'),
702 946 'rankmath' => defined('RANK_MATH_VERSION'),
703 947 'aioseo' => defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO'),
704 948 ),
949 + // Breadcrumb Title Override visibility. False only when MetaSync
950 + // breadcrumbs are off AND no Yoast/Rank Math breadcrumb provider is
951 + // active — in that state the override can never reach a trail.
952 + // Stored overrides are never deleted, so this is display-only.
953 + 'breadcrumbs' => array(
954 + 'titleFieldEnabled' => class_exists('Metasync_Breadcrumbs')
955 + ? Metasync_Breadcrumbs::is_breadcrumb_title_meaningful()
956 + : true,
957 + ),
705 958 'wpmlEntries' => $wpml_entries,
706 - 'hasMetaKeys' => array(
707 - // Whether the manual meta keys exist in database (PHP check)
708 - 'seoTitle' => $has_seo_title,
709 - 'metaDescription' => $has_seo_description,
710 - ),
959 + // '1' when enabled, '' when disabled. wp_localize_script() casts
960 + // top-level scalars with (string), so a raw boolean false would
961 + // arrive as "" and could not be distinguished from a missing key
962 + // by a strict JS check. Follows the isCustomOrLpsPage pattern.
963 + 'languageAlternatesEnabled' => $language_alternates_enabled ? '1' : '',
711 964 'otto' => array(
712 965 'globalEnabled' => self::is_otto_enabled_globally(),
713 966 'name' => $otto_name,
714 967 ),
@@ -732,22 +985,31 @@
732 985 'seoTitleLabel' => __('SEO Title', 'metasync'),
733 986 'seoTitleHelp' => __('The title that appears in search engine results. Optimal length: 50-60 characters.', 'metasync'),
734 987 'metaDescriptionLabel' => __('Meta Description', 'metasync'),
735 988 'metaDescriptionHelp' => __('A brief description for search engine results. Optimal length: 120-160 characters.', 'metasync'),
736 - 'urlSlugLabel' => __('URL Slug', 'metasync'),
737 - 'urlSlugHelp' => __('The URL-friendly version of the post name. Use lowercase letters, numbers, and hyphens only.', 'metasync'),
738 989 'serpPreviewTitle' => __('SERP Preview', 'metasync'),
739 990 'serpPreviewHelp' => __('Preview how your page will appear in Google search results.', 'metasync'),
740 991 'serpDesktop' => __('Desktop', 'metasync'),
741 992 'serpMobile' => __('Mobile', 'metasync'),
742 993 'characters' => __('characters', 'metasync'),
743 - 'primaryCategoryLabel' => __('Primary Category', 'metasync'),
744 - 'primaryCategoryHelp' => __('Used in breadcrumbs and canonical URL when multiple categories are assigned.', 'metasync'),
745 994 'primaryCategoryNote' => __('Assign 2+ categories to enable this option.', 'metasync'),
746 995 'ottoPrefillHelp' => sprintf(
996 + /* translators: %s: OTTO name (whitelabel). */
747 997 __('Pre-filled from %s. Edit to customize.', 'metasync'),
748 998 $otto_name
749 999 ),
1000 + 'importedPrefillHelp' => sprintf(
1001 + /* translators: %s: OTTO name (whitelabel) */
1002 + __('Suggestion imported from another SEO plugin. Used only until %s has its own suggestion for this page.', 'metasync'),
1003 + $otto_name
1004 + ),
1005 + // Focus Keyword: read-only field surfacing the OTTO keyword
1006 + 'focusKeywordLabel' => __('Focus Keyword', 'metasync'),
1007 + 'focusKeywordHelp' => sprintf(
1008 + /* translators: %s: OTTO name (whitelabel) */
1009 + __('Managed by %s. Set in Search Atlas — read-only here.', 'metasync'),
1010 + $otto_name
1011 + ),
750 1012 'breadcrumbPanelTitle' => __('Breadcrumbs', 'metasync'),
751 1013 'breadcrumbTitleLabel' => __('Breadcrumb Title Override', 'metasync'),
752 1014 'breadcrumbTitleHelp' => __('Custom label for this page in breadcrumb trails. Leave empty to use the post title.', 'metasync'),
753 1015 'primaryCategoryLabel' => __('Primary Category', 'metasync'),
@@ -753,9 +1015,10 @@
753 1015 'primaryCategoryLabel' => __('Primary Category', 'metasync'),
754 1016 'primaryCategoryHelp' => __('Select which category appears in the breadcrumb path when this post belongs to multiple categories.', 'metasync'),
755 1017 /* translators: %s: OTTO name (whitelabel) */
756 1018 'ottoOverrideNotice' => sprintf(
757 - __('%s is enabled. Any SEO title and description changes from %s will be overwritten by your custom values entered here.', 'metasync'),
1019 + /* translators: 1: OTTO name (whitelabel), 2: OTTO name (whitelabel). */
1020 + __('%1$s is enabled. Any SEO title and description changes from %2$s will be overwritten by your custom values entered here.', 'metasync'),
758 1021 $otto_name,
759 1022 $otto_name
760 1023 ),
761 1024 // Language Alternates (hreflang) panel strings
@@ -765,9 +1028,11 @@
765 1028 'regionLabel' => __('Region', 'metasync'),
766 1029 'urlLabel' => __('URL', 'metasync'),
767 1030 'editManually' => __('Edit Manually', 'metasync'),
768 1031 'wpmlAutoPopulated' => __('Auto-populated from WPML. Click Edit Manually to override.', 'metasync'),
769 - // Advanced Robots Directives (WP-197)
1032 + 'hreflangFormatHelp' => __('Use ISO codes (e.g. en, en-US, x-default — hyphen, not underscore) and absolute URLs (https://…). A self-reference for this page is added automatically when missing.', 'metasync'),
1033 + 'hreflangInvalidRow' => __('This row will not be emitted: the language code must look like "en" or "en-US", and the URL must be absolute (https://…).', 'metasync'),
1034 + // Advanced Robots Directives
770 1035 'robotsAdvancedTitle' => __('Advanced Robots Directives', 'metasync'),
771 1036 'nofollowLabel' => __('Nofollow', 'metasync'),
772 1037 'nofollowHelp' => __('Prevent search engines from following links on this page.', 'metasync'),
773 1038 'noarchiveLabel' => __('Noarchive', 'metasync'),
@@ -779,13 +1044,15 @@
779 1044 'maxSnippetLabel' => __('Max Snippet Length', 'metasync'),
780 1045 'maxSnippetHelp' => __('Maximum character length for text snippets. -1 for unlimited.', 'metasync'),
781 1046 'maxImagePreviewLabel' => __('Max Image Preview', 'metasync'),
782 1047 'maxImagePreviewHelp' => __('Maximum size of image preview in search results.', 'metasync'),
783 - // Plugin Sync Status (WP-196)
1048 + // Plugin Sync Status
784 1049 'syncStatusTitle' => __('Plugin Sync Status', 'metasync'),
785 1050 'syncedTo' => __('Synced to:', 'metasync'),
786 1051 'syncNever' => __('Never synced', 'metasync'),
787 1052 'syncAgo' => __('ago', 'metasync'),
1053 + // LPS / custom-HTML read-only notice
1054 + 'lpsNotice' => __('The SEO for this page is managed by WebStudio.', 'metasync'),
788 1055 ),
789 1056 ));
790 1057 }
791 1058