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 +358 -177 2.6.24 → 2.7.2 View file →
@@ -33,8 +33,23 @@
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 + /**
37 52 * Meta key for the OTTO focus keyword (read-only, written by OTTO)
38 53 */
39 54 const META_OTTO_KEYWORDS = '_metasync_otto_keywords';
40 55
@@ -82,9 +97,9 @@
82 97
83 98 // Enqueue block editor assets
84 99 add_action('enqueue_block_editor_assets', array($this, 'enqueue_block_editor_assets'));
85 100
86 - // 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)
87 102 add_action('rest_api_init', array($this, 'register_rest_save_hooks'));
88 103
89 104 // Clean up primary category meta when category is unchecked (all post types)
90 105 add_action('save_post', array($this, 'cleanup_primary_category_meta'), 10, 1);
@@ -93,18 +108,24 @@
93 108 // Custom values ALWAYS take priority over OTTO suggestions
94 109 add_action('wp_head', array($this, 'output_seo_meta_description'), 2);
95 110 add_filter('pre_get_document_title', array($this, 'filter_document_title'), 100);
96 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);
97 119 }
98 120
99 121 /**
100 - * Register REST API save/cleanup hooks for all public post types
122 + * Register REST API cleanup hooks for all public post types
101 123 */
102 124 public function register_rest_save_hooks() {
103 125 $post_types = get_post_types(array('public' => true), 'names');
104 126 unset($post_types['attachment']);
105 127 foreach ($post_types as $post_type) {
106 - add_filter("rest_pre_insert_{$post_type}", array($this, 'track_meta_before_save'), 10, 2);
107 128 add_action("rest_after_insert_{$post_type}", array($this, 'cleanup_empty_seo_meta'), 10, 3);
108 129 }
109 130 }
110 131
@@ -119,66 +140,33 @@
119 140 return ($otto_enabled === 'true' || $otto_enabled === true);
120 141 }
121 142
122 143 /**
123 - * Track meta state before REST API save
124 - * Stores which SEO meta keys existed before the save
125 - *
126 - * @param stdClass $prepared_post Prepared post object
127 - * @param WP_REST_Request $request Request object
128 - * @return stdClass Unmodified prepared post object
129 - */
130 - public function track_meta_before_save($prepared_post, $request) {
131 - $post_id = $request->get_param('id');
132 - if (!$post_id) {
133 - return $prepared_post;
134 - }
135 -
136 - // Track which meta keys existed before this save
137 - $meta_existed = array(
138 - self::META_SEO_TITLE => metadata_exists('post', $post_id, self::META_SEO_TITLE),
139 - self::META_DESCRIPTION => metadata_exists('post', $post_id, self::META_DESCRIPTION),
140 - );
141 -
142 - // Store in transient for use in cleanup
143 - set_transient('metasync_seo_meta_existed_' . $post_id, $meta_existed, 60);
144 -
145 - return $prepared_post;
146 - }
147 -
148 - /**
149 - * Clean up empty SEO meta values after REST API save
150 - * This prevents blanking fields that are showing OTTO fallback values
151 - *
152 - * Deletes meta keys that are empty AND didn't exist before the save
153 - * (meaning they were showing OTTO fallback, not user-edited values)
154 - *
155 - * @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)
156 153 * @param WP_REST_Request $_request Request object (unused)
157 154 * @param bool $_creating True when creating a post, false when updating (unused)
158 155 */
159 156 public function cleanup_empty_seo_meta($post, $_request, $_creating) {
160 - $post_id = $post->ID;
161 -
162 - // Get the before-save state
163 - $meta_existed = get_transient('metasync_seo_meta_existed_' . $post_id);
164 - if ($meta_existed === false) {
165 - // 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) {
166 159 return;
167 160 }
168 161
169 - // Delete transient
170 - delete_transient('metasync_seo_meta_existed_' . $post_id);
171 -
172 - // Check each of our SEO meta keys
173 - foreach ($meta_existed as $meta_key => $existed_before) {
174 - // Get the current value
162 + foreach (array(self::META_SEO_TITLE, self::META_DESCRIPTION) as $meta_key) {
175 163 $meta_value = get_post_meta($post_id, $meta_key, true);
176 164
177 - // Only delete if:
178 - // 1. Value is now empty
179 - // 2. It didn't exist before the save (was showing OTTO fallback)
180 - 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 === '') {
181 169 delete_post_meta($post_id, $meta_key);
182 170 }
183 171 }
184 172 }
@@ -206,80 +194,131 @@
206 194 }
207 195
208 196 $conflict_handler = Metasync_SEO_Conflict_Handler::get_instance();
209 197
210 - // og:title / twitter:title replacement.
198 + // og:title / twitter:title replacement (WP-609).
211 199 //
212 - // On a Yoast/AIOSEO page the conflict handler suppresses that plugin's
213 - // og:title when MetaSync has a title, but Metasync_OpenGraph::output_opengraph_tags()
214 - // bails whenever an SEO plugin is active — leaving the page with no og:title at all.
215 - // Emit the same title the suppression keyed off, so the title path is symmetric with
216 - // the description path below. og_title_needs_replacement() is deliberately narrow: it
217 - // is false on MetaSync-only sites (the consolidated emitter still renders og:title
218 - // there), on synced posts (the plugin renders its own), and when the OG toggle is off.
219 - //
220 - // Emitted BEFORE the description defer gate below: that gate is about the plugin
221 - // owning the *description*, which does not imply it renders the title. A post whose
222 - // sync timestamp names a plugin that is not the active one still has its og:title
223 - // suppressed, so returning early there would leave the page with none.
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.
224 208 if ($conflict_handler->og_title_needs_replacement()) {
225 209 $title = $conflict_handler->get_metasync_title();
226 210 if (!empty($title)) {
227 211 $title_escaped = esc_attr($title);
228 - echo '<meta property="og:title" content="' . $title_escaped . '" data-metasync-seo="custom" />' . "\n";
229 - echo '<meta name="twitter:title" content="' . $title_escaped . '" data-metasync-seo="custom" />' . "\n";
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
230 214 }
231 215 }
232 216
233 - // When synced to an active SEO plugin, that plugin outputs
234 - // the description from its native storage — skip MetaSync's own tag.
235 - // But only defer if the plugin's field is actually populated.
236 - // A stale/partial sync (timestamp present, plugin field empty) would
237 - // otherwise drop the description entirely — so fall through and emit
238 - // MetaSync's own custom tag as the fallback.
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.
239 227 $defer_meta_description = false;
240 228 if ($conflict_handler->has_active_seo_plugin()) {
241 - $sync_ts = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
242 - if (!empty($sync_ts) && $conflict_handler->active_plugin_has_description($post_id)) {
229 + if ($conflict_handler->metasync_has_description()
230 + || $conflict_handler->active_plugin_has_description($post_id)) {
243 231 $defer_meta_description = true;
244 232 }
245 233 }
246 234
247 - // The Open Graph tags need their own decision. The check above asks whether
248 - // the plugin holds a META description, which says nothing about whether it
249 - // renders an og:description — Yoast renders one either way, falling back to
250 - // its meta description and then the excerpt. Deferring the plain tag while
251 - // still emitting ours produced two conflicting og:description tags.
252 - $defer_og_description = $conflict_handler->third_party_owns_og_description();
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();
253 241
254 242 if ($defer_meta_description && $defer_og_description) {
255 243 return;
256 244 }
257 245
258 - // Get the custom SEO description (takes priority)
259 - $description = get_post_meta($post_id, self::META_DESCRIPTION, true);
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'];
260 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 +
261 288 // Output meta description tags if custom value exists
262 289 // This will be output BEFORE OTTO processes the page, and since we're using
263 290 // a high priority filter, it will override OTTO's meta description
264 291 if (!empty($description)) {
265 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 + }
266 300 // Standard meta description
267 301 if (!$defer_meta_description) {
268 - echo '<meta name="description" content="' . $description_escaped . '" data-metasync-seo="custom" />' . "\n";
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
269 303 }
270 304
271 - // og:description / twitter:description are Open Graph & social tags, gated by
272 - // the per-post "Enable Open Graph & Social Media Tags" toggle. Only an explicit
273 - // '0' opt-out disables them (unset/empty counts as enabled), mirroring
274 - // Metasync_OpenGraph::will_emit(). When disabled, MetaSync emits no OG/Twitter
275 - // description so it neither overrides a third-party plugin's tag nor leaves
276 - // one sourced from our database.
277 - if (!$defer_og_description && get_post_meta($post_id, '_metasync_og_enabled', true) !== '0') {
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)) {
278 317 // Open Graph description
279 - echo '<meta property="og:description" content="' . $description_escaped . '" data-metasync-seo="custom" />' . "\n";
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
280 319 // Twitter description
281 - echo '<meta name="twitter:description" content="' . $description_escaped . '" data-metasync-seo="custom" />' . "\n";
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
282 321 }
283 322 }
284 323 }
285 324
@@ -305,19 +344,87 @@
305 344 if (!$post_id) {
306 345 return $title;
307 346 }
308 347
309 - // Get the custom SEO title (takes priority)
310 - $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);
311 358
312 - // Return custom SEO title if set
313 359 return !empty($seo_title) ? $seo_title : $title;
314 360 }
315 361
316 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 + /**
317 421 * Filter document title parts (for themes using wp_get_document_title)
318 - * Custom values ALWAYS take priority over OTTO
319 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 + *
320 427 * @param array $title_parts Title parts array
321 428 * @return array Modified title parts
322 429 */
323 430 public function filter_document_title_parts($title_parts) {
@@ -335,12 +442,11 @@
335 442 if (!$post_id) {
336 443 return $title_parts;
337 444 }
338 445
339 - // Get the custom SEO title (takes priority)
340 - $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);
341 447
342 - // Replace title part if custom SEO title is set
448 + // Replace title part if MetaSync holds a title for this post
343 449 if (!empty($seo_title)) {
344 450 $title_parts['title'] = $seo_title;
345 451 // Remove tagline and site for clean SEO title
346 452 unset($title_parts['tagline']);
@@ -459,8 +565,25 @@
459 565 return current_user_can('edit_post', $object_id);
460 566 },
461 567 ));
462 568
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 +
463 586 // Register OTTO focus keyword for REST API (read-only display in sidebar)
464 587 register_post_meta($post_type, self::META_OTTO_KEYWORDS, array(
465 588 'show_in_rest' => true,
466 589 'single' => true,
@@ -487,28 +610,34 @@
487 610 return current_user_can('edit_post', $object_id);
488 611 },
489 612 ));
490 613
491 - // Register hreflang / language alternates meta (JSON-encoded array)
492 - register_post_meta($post_type, self::META_HREFLANG, array(
493 - 'show_in_rest' => true,
494 - 'single' => true,
495 - 'type' => 'string',
496 - 'sanitize_callback' => function($value) {
497 - $decoded = json_decode($value, true);
498 - if (!is_array($decoded)) {
499 - return '[]';
500 - }
501 - return wp_json_encode($decoded);
502 - },
503 - 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
504 - if (empty($object_id)) {
505 - $pt_obj = get_post_type_object($post_type);
506 - return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
507 - }
508 - return current_user_can('edit_post', $object_id);
509 - },
510 - ));
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 + }
511 640
512 641 // Register plugin sync timestamp meta
513 642 register_post_meta($post_type, self::META_PLUGIN_SYNC_TS, array(
514 643 'show_in_rest' => true,
@@ -564,13 +693,67 @@
564 693 }
565 694 }
566 695
567 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 + /**
568 749 * Build a read-only list of WPML translation entries for a post.
569 750 *
570 - * Returns an array of {lang, url} objects by querying the
571 - * `icl_translations` table. Used to seed the "Language Alternates"
572 - * 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.
573 756 *
574 757 * @param int $post_id Post ID.
575 758 * @return array
576 759 */
@@ -578,46 +761,20 @@
578 761 if (!defined('ICL_SITEPRESS_VERSION') || $post_id <= 0) {
579 762 return array();
580 763 }
581 764
582 - global $wpdb;
583 - $post = get_post($post_id);
584 - if (!$post) {
585 - return array();
586 - }
765 + $hreflang = new Metasync_Hreflang_Output();
766 + $entries = $hreflang->get_wpml_entries($post_id);
587 767
588 - $element_type = 'post_' . $post->post_type;
589 - $table = $wpdb->prefix . 'icl_translations';
590 -
591 - $trid = $wpdb->get_var($wpdb->prepare(
592 - "SELECT trid FROM {$table} WHERE element_id = %d AND element_type = %s LIMIT 1",
593 - $post_id,
594 - $element_type
595 - ));
596 - if (empty($trid)) {
597 - return array();
598 - }
599 -
600 - $rows = $wpdb->get_results($wpdb->prepare(
601 - "SELECT language_code, element_id FROM {$table} WHERE trid = %d",
602 - $trid
603 - ));
604 - if (empty($rows)) {
605 - return array();
606 - }
607 -
608 - $entries = array();
609 - foreach ($rows as $row) {
610 - $permalink = get_permalink((int) $row->element_id);
611 - if (empty($permalink)) {
612 - continue;
613 - }
614 - $entries[] = array(
615 - 'lang' => (string) $row->language_code,
616 - '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'] : '',
617 774 );
618 775 }
619 - return $entries;
776 + return $out;
620 777 }
621 778
622 779 /**
623 780 * Enqueue block editor assets
@@ -680,19 +837,12 @@
680 837 if (class_exists('Metasync') && method_exists('Metasync', 'get_whitelabel_otto_name')) {
681 838 $otto_name = Metasync::get_whitelabel_otto_name();
682 839 }
683 840
684 - // Get current post ID and check if meta keys exist in database
685 - $post_id = isset($_GET['post']) ? intval($_GET['post']) : 0;
686 - $has_seo_title = false;
687 - $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
688 844
689 - if ($post_id > 0) {
690 - // Check if meta keys exist in database (even if value is empty)
691 - $has_seo_title = metadata_exists('post', $post_id, self::META_SEO_TITLE);
692 - $has_seo_description = metadata_exists('post', $post_id, self::META_DESCRIPTION);
693 - }
694 -
695 845 // LPS / custom-HTML pages bake their own SEO — suppress the editable sidebar
696 846 // panels and surface a read-only notice instead.
697 847 // otto_pixel.php (where metasync_is_custom_or_lps_page lives) is only
698 848 // required conditionally, so the function_exists guard is needed at runtime.
@@ -698,9 +848,20 @@
698 848 // required conditionally, so the function_exists guard is needed at runtime.
699 849 $is_lps_page = function_exists('metasync_is_custom_or_lps_page') && $post_id > 0 && metasync_is_custom_or_lps_page($post_id);
700 850
701 851 // Auto-detected WPML entries for the "Language Alternates" panel.
702 - $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();
703 864
704 865 // Link Suggestions configuration
705 866 $link_suggestions_config = array(
706 867 'restUrl' => rest_url('metasync/v1/link-suggestions'),
@@ -761,8 +922,12 @@
761 922 'breadcrumbTitle' => '_metasync_breadcrumb_title',
762 923 // OTTO keys for fallback (read-only, used to prefill if manual fields are empty)
763 924 'ottoTitle' => '_metasync_otto_title',
764 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,
765 930 // OTTO focus keyword (read-only display only)
766 931 'ottoKeywords' => self::META_OTTO_KEYWORDS,
767 932 // OTTO disabled per-post flag
768 933 'ottoDisabled' => '_metasync_otto_disabled',
@@ -780,14 +945,23 @@
780 945 'yoast' => defined('WPSEO_VERSION'),
781 946 'rankmath' => defined('RANK_MATH_VERSION'),
782 947 'aioseo' => defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO'),
783 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 + ),
784 958 'wpmlEntries' => $wpml_entries,
785 - 'hasMetaKeys' => array(
786 - // Whether the manual meta keys exist in database (PHP check)
787 - 'seoTitle' => $has_seo_title,
788 - 'metaDescription' => $has_seo_description,
789 - ),
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' : '',
790 964 'otto' => array(
791 965 'globalEnabled' => self::is_otto_enabled_globally(),
792 966 'name' => $otto_name,
793 967 ),
@@ -811,10 +985,8 @@
811 985 'seoTitleLabel' => __('SEO Title', 'metasync'),
812 986 'seoTitleHelp' => __('The title that appears in search engine results. Optimal length: 50-60 characters.', 'metasync'),
813 987 'metaDescriptionLabel' => __('Meta Description', 'metasync'),
814 988 'metaDescriptionHelp' => __('A brief description for search engine results. Optimal length: 120-160 characters.', 'metasync'),
815 - 'urlSlugLabel' => __('URL Slug', 'metasync'),
816 - 'urlSlugHelp' => __('The URL-friendly version of the post name. Use lowercase letters, numbers, and hyphens only.', 'metasync'),
817 989 'serpPreviewTitle' => __('SERP Preview', 'metasync'),
818 990 'serpPreviewHelp' => __('Preview how your page will appear in Google search results.', 'metasync'),
819 991 'serpDesktop' => __('Desktop', 'metasync'),
820 992 'serpMobile' => __('Mobile', 'metasync'),
@@ -820,11 +992,17 @@
820 992 'serpMobile' => __('Mobile', 'metasync'),
821 993 'characters' => __('characters', 'metasync'),
822 994 'primaryCategoryNote' => __('Assign 2+ categories to enable this option.', 'metasync'),
823 995 'ottoPrefillHelp' => sprintf(
996 + /* translators: %s: OTTO name (whitelabel). */
824 997 __('Pre-filled from %s. Edit to customize.', 'metasync'),
825 998 $otto_name
826 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 + ),
827 1005 // Focus Keyword: read-only field surfacing the OTTO keyword
828 1006 'focusKeywordLabel' => __('Focus Keyword', 'metasync'),
829 1007 'focusKeywordHelp' => sprintf(
830 1008 /* translators: %s: OTTO name (whitelabel) */
@@ -837,9 +1015,10 @@
837 1015 'primaryCategoryLabel' => __('Primary Category', 'metasync'),
838 1016 'primaryCategoryHelp' => __('Select which category appears in the breadcrumb path when this post belongs to multiple categories.', 'metasync'),
839 1017 /* translators: %s: OTTO name (whitelabel) */
840 1018 'ottoOverrideNotice' => sprintf(
841 - __('%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'),
842 1021 $otto_name,
843 1022 $otto_name
844 1023 ),
845 1024 // Language Alternates (hreflang) panel strings
@@ -849,8 +1028,10 @@
849 1028 'regionLabel' => __('Region', 'metasync'),
850 1029 'urlLabel' => __('URL', 'metasync'),
851 1030 'editManually' => __('Edit Manually', 'metasync'),
852 1031 'wpmlAutoPopulated' => __('Auto-populated from WPML. Click Edit Manually to override.', 'metasync'),
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'),
853 1034 // Advanced Robots Directives
854 1035 'robotsAdvancedTitle' => __('Advanced Robots Directives', 'metasync'),
855 1036 'nofollowLabel' => __('Nofollow', 'metasync'),
856 1037 'nofollowHelp' => __('Prevent search engines from following links on this page.', 'metasync'),