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 +378 -158 2.6.21 → 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 }
@@ -185,9 +173,10 @@
185 173
186 174 /**
187 175 * Output meta description tags from sidebar field
188 176 * Custom values ALWAYS take priority over OTTO
189 - * 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
190 179 */
191 180 public function output_seo_meta_description() {
192 181 // Skip for admin, feeds, etc.
193 182 if (is_admin() || is_feed() || is_robots()) {
@@ -203,44 +192,133 @@
203 192 if (!$post_id) {
204 193 return;
205 194 }
206 195
207 - // When synced to an active SEO plugin, that plugin outputs
208 - // the description from its native storage — skip MetaSync's own tag.
209 - // But only defer if the plugin's field is actually populated.
210 - // A stale/partial sync (timestamp present, plugin field empty) would
211 - // otherwise drop the description entirely — so fall through and emit
212 - // MetaSync's own custom tag as the fallback.
213 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;
214 228 if ($conflict_handler->has_active_seo_plugin()) {
215 - $sync_ts = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
216 - if (!empty($sync_ts) && $conflict_handler->active_plugin_has_description($post_id)) {
217 - return;
229 + if ($conflict_handler->metasync_has_description()
230 + || $conflict_handler->active_plugin_has_description($post_id)) {
231 + $defer_meta_description = true;
218 232 }
219 233 }
220 234
221 - // Get the custom SEO description (takes priority)
222 - $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();
223 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 +
224 288 // Output meta description tags if custom value exists
225 289 // This will be output BEFORE OTTO processes the page, and since we're using
226 290 // a high priority filter, it will override OTTO's meta description
227 291 if (!empty($description)) {
228 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 + }
229 300 // Standard meta description
230 - echo '<meta name="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 + }
231 304
232 - // og:description / twitter:description are Open Graph & social tags, gated by
233 - // the per-post "Enable Open Graph & Social Media Tags" toggle. Only an explicit
234 - // '0' opt-out disables them (unset/empty counts as enabled), mirroring
235 - // Metasync_OpenGraph::will_emit(). When disabled, MetaSync emits no OG/Twitter
236 - // description so it neither overrides a third-party plugin's tag nor leaves
237 - // one sourced from our database.
238 - if (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)) {
239 317 // Open Graph description
240 - 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
241 319 // Twitter description
242 - 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
243 321 }
244 322 }
245 323 }
246 324
@@ -266,19 +344,87 @@
266 344 if (!$post_id) {
267 345 return $title;
268 346 }
269 347
270 - // Get the custom SEO title (takes priority)
271 - $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);
272 358
273 - // Return custom SEO title if set
274 359 return !empty($seo_title) ? $seo_title : $title;
275 360 }
276 361
277 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 + /**
278 421 * Filter document title parts (for themes using wp_get_document_title)
279 - * Custom values ALWAYS take priority over OTTO
280 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 + *
281 427 * @param array $title_parts Title parts array
282 428 * @return array Modified title parts
283 429 */
284 430 public function filter_document_title_parts($title_parts) {
@@ -296,12 +442,11 @@
296 442 if (!$post_id) {
297 443 return $title_parts;
298 444 }
299 445
300 - // Get the custom SEO title (takes priority)
301 - $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);
302 447
303 - // Replace title part if custom SEO title is set
448 + // Replace title part if MetaSync holds a title for this post
304 449 if (!empty($seo_title)) {
305 450 $title_parts['title'] = $seo_title;
306 451 // Remove tagline and site for clean SEO title
307 452 unset($title_parts['tagline']);
@@ -420,8 +565,25 @@
420 565 return current_user_can('edit_post', $object_id);
421 566 },
422 567 ));
423 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 +
424 586 // Register OTTO focus keyword for REST API (read-only display in sidebar)
425 587 register_post_meta($post_type, self::META_OTTO_KEYWORDS, array(
426 588 'show_in_rest' => true,
427 589 'single' => true,
@@ -448,28 +610,34 @@
448 610 return current_user_can('edit_post', $object_id);
449 611 },
450 612 ));
451 613
452 - // Register hreflang / language alternates meta (JSON-encoded array)
453 - register_post_meta($post_type, self::META_HREFLANG, array(
454 - 'show_in_rest' => true,
455 - 'single' => true,
456 - 'type' => 'string',
457 - 'sanitize_callback' => function($value) {
458 - $decoded = json_decode($value, true);
459 - if (!is_array($decoded)) {
460 - return '[]';
461 - }
462 - return wp_json_encode($decoded);
463 - },
464 - 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
465 - if (empty($object_id)) {
466 - $pt_obj = get_post_type_object($post_type);
467 - return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
468 - }
469 - return current_user_can('edit_post', $object_id);
470 - },
471 - ));
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 + }
472 640
473 641 // Register plugin sync timestamp meta
474 642 register_post_meta($post_type, self::META_PLUGIN_SYNC_TS, array(
475 643 'show_in_rest' => true,
@@ -525,13 +693,67 @@
525 693 }
526 694 }
527 695
528 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 + /**
529 749 * Build a read-only list of WPML translation entries for a post.
530 750 *
531 - * Returns an array of {lang, url} objects by querying the
532 - * `icl_translations` table. Used to seed the "Language Alternates"
533 - * 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.
534 756 *
535 757 * @param int $post_id Post ID.
536 758 * @return array
537 759 */
@@ -539,46 +761,20 @@
539 761 if (!defined('ICL_SITEPRESS_VERSION') || $post_id <= 0) {
540 762 return array();
541 763 }
542 764
543 - global $wpdb;
544 - $post = get_post($post_id);
545 - if (!$post) {
546 - return array();
547 - }
765 + $hreflang = new Metasync_Hreflang_Output();
766 + $entries = $hreflang->get_wpml_entries($post_id);
548 767
549 - $element_type = 'post_' . $post->post_type;
550 - $table = $wpdb->prefix . 'icl_translations';
551 -
552 - $trid = $wpdb->get_var($wpdb->prepare(
553 - "SELECT trid FROM {$table} WHERE element_id = %d AND element_type = %s LIMIT 1",
554 - $post_id,
555 - $element_type
556 - ));
557 - if (empty($trid)) {
558 - return array();
559 - }
560 -
561 - $rows = $wpdb->get_results($wpdb->prepare(
562 - "SELECT language_code, element_id FROM {$table} WHERE trid = %d",
563 - $trid
564 - ));
565 - if (empty($rows)) {
566 - return array();
567 - }
568 -
569 - $entries = array();
570 - foreach ($rows as $row) {
571 - $permalink = get_permalink((int) $row->element_id);
572 - if (empty($permalink)) {
573 - continue;
574 - }
575 - $entries[] = array(
576 - 'lang' => (string) $row->language_code,
577 - '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'] : '',
578 774 );
579 775 }
580 - return $entries;
776 + return $out;
581 777 }
582 778
583 779 /**
584 780 * Enqueue block editor assets
@@ -641,19 +837,12 @@
641 837 if (class_exists('Metasync') && method_exists('Metasync', 'get_whitelabel_otto_name')) {
642 838 $otto_name = Metasync::get_whitelabel_otto_name();
643 839 }
644 840
645 - // Get current post ID and check if meta keys exist in database
646 - $post_id = isset($_GET['post']) ? intval($_GET['post']) : 0;
647 - $has_seo_title = false;
648 - $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
649 844
650 - if ($post_id > 0) {
651 - // Check if meta keys exist in database (even if value is empty)
652 - $has_seo_title = metadata_exists('post', $post_id, self::META_SEO_TITLE);
653 - $has_seo_description = metadata_exists('post', $post_id, self::META_DESCRIPTION);
654 - }
655 -
656 845 // LPS / custom-HTML pages bake their own SEO — suppress the editable sidebar
657 846 // panels and surface a read-only notice instead.
658 847 // otto_pixel.php (where metasync_is_custom_or_lps_page lives) is only
659 848 // required conditionally, so the function_exists guard is needed at runtime.
@@ -659,9 +848,20 @@
659 848 // required conditionally, so the function_exists guard is needed at runtime.
660 849 $is_lps_page = function_exists('metasync_is_custom_or_lps_page') && $post_id > 0 && metasync_is_custom_or_lps_page($post_id);
661 850
662 851 // Auto-detected WPML entries for the "Language Alternates" panel.
663 - $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();
664 864
665 865 // Link Suggestions configuration
666 866 $link_suggestions_config = array(
667 867 'restUrl' => rest_url('metasync/v1/link-suggestions'),
@@ -722,8 +922,12 @@
722 922 'breadcrumbTitle' => '_metasync_breadcrumb_title',
723 923 // OTTO keys for fallback (read-only, used to prefill if manual fields are empty)
724 924 'ottoTitle' => '_metasync_otto_title',
725 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,
726 930 // OTTO focus keyword (read-only display only)
727 931 'ottoKeywords' => self::META_OTTO_KEYWORDS,
728 932 // OTTO disabled per-post flag
729 933 'ottoDisabled' => '_metasync_otto_disabled',
@@ -741,14 +945,23 @@
741 945 'yoast' => defined('WPSEO_VERSION'),
742 946 'rankmath' => defined('RANK_MATH_VERSION'),
743 947 'aioseo' => defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO'),
744 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 + ),
745 958 'wpmlEntries' => $wpml_entries,
746 - 'hasMetaKeys' => array(
747 - // Whether the manual meta keys exist in database (PHP check)
748 - 'seoTitle' => $has_seo_title,
749 - 'metaDescription' => $has_seo_description,
750 - ),
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' : '',
751 964 'otto' => array(
752 965 'globalEnabled' => self::is_otto_enabled_globally(),
753 966 'name' => $otto_name,
754 967 ),
@@ -772,10 +985,8 @@
772 985 'seoTitleLabel' => __('SEO Title', 'metasync'),
773 986 'seoTitleHelp' => __('The title that appears in search engine results. Optimal length: 50-60 characters.', 'metasync'),
774 987 'metaDescriptionLabel' => __('Meta Description', 'metasync'),
775 988 'metaDescriptionHelp' => __('A brief description for search engine results. Optimal length: 120-160 characters.', 'metasync'),
776 - 'urlSlugLabel' => __('URL Slug', 'metasync'),
777 - 'urlSlugHelp' => __('The URL-friendly version of the post name. Use lowercase letters, numbers, and hyphens only.', 'metasync'),
778 989 'serpPreviewTitle' => __('SERP Preview', 'metasync'),
779 990 'serpPreviewHelp' => __('Preview how your page will appear in Google search results.', 'metasync'),
780 991 'serpDesktop' => __('Desktop', 'metasync'),
781 992 'serpMobile' => __('Mobile', 'metasync'),
@@ -781,11 +992,17 @@
781 992 'serpMobile' => __('Mobile', 'metasync'),
782 993 'characters' => __('characters', 'metasync'),
783 994 'primaryCategoryNote' => __('Assign 2+ categories to enable this option.', 'metasync'),
784 995 'ottoPrefillHelp' => sprintf(
996 + /* translators: %s: OTTO name (whitelabel). */
785 997 __('Pre-filled from %s. Edit to customize.', 'metasync'),
786 998 $otto_name
787 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 + ),
788 1005 // Focus Keyword: read-only field surfacing the OTTO keyword
789 1006 'focusKeywordLabel' => __('Focus Keyword', 'metasync'),
790 1007 'focusKeywordHelp' => sprintf(
791 1008 /* translators: %s: OTTO name (whitelabel) */
@@ -798,9 +1015,10 @@
798 1015 'primaryCategoryLabel' => __('Primary Category', 'metasync'),
799 1016 'primaryCategoryHelp' => __('Select which category appears in the breadcrumb path when this post belongs to multiple categories.', 'metasync'),
800 1017 /* translators: %s: OTTO name (whitelabel) */
801 1018 'ottoOverrideNotice' => sprintf(
802 - __('%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'),
803 1021 $otto_name,
804 1022 $otto_name
805 1023 ),
806 1024 // Language Alternates (hreflang) panel strings
@@ -810,8 +1028,10 @@
810 1028 'regionLabel' => __('Region', 'metasync'),
811 1029 'urlLabel' => __('URL', 'metasync'),
812 1030 'editManually' => __('Edit Manually', 'metasync'),
813 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'),
814 1034 // Advanced Robots Directives
815 1035 'robotsAdvancedTitle' => __('Advanced Robots Directives', 'metasync'),
816 1036 'nofollowLabel' => __('Nofollow', 'metasync'),
817 1037 'nofollowHelp' => __('Prevent search engines from following links on this page.', 'metasync'),