PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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 +203 -178 2.6.25 → 2.7.1 View file →
@@ -97,9 +97,9 @@
97 97
98 98 // Enqueue block editor assets
99 99 add_action('enqueue_block_editor_assets', array($this, 'enqueue_block_editor_assets'));
100 100
101 - // 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)
102 102 add_action('rest_api_init', array($this, 'register_rest_save_hooks'));
103 103
104 104 // Clean up primary category meta when category is unchecked (all post types)
105 105 add_action('save_post', array($this, 'cleanup_primary_category_meta'), 10, 1);
@@ -118,15 +118,14 @@
118 118 add_filter('pre_get_document_title', array($this, 'filter_document_title_imported'), 98);
119 119 }
120 120
121 121 /**
122 - * Register REST API save/cleanup hooks for all public post types
122 + * Register REST API cleanup hooks for all public post types
123 123 */
124 124 public function register_rest_save_hooks() {
125 125 $post_types = get_post_types(array('public' => true), 'names');
126 126 unset($post_types['attachment']);
127 127 foreach ($post_types as $post_type) {
128 - add_filter("rest_pre_insert_{$post_type}", array($this, 'track_meta_before_save'), 10, 2);
129 128 add_action("rest_after_insert_{$post_type}", array($this, 'cleanup_empty_seo_meta'), 10, 3);
130 129 }
131 130 }
132 131
@@ -141,66 +140,33 @@
141 140 return ($otto_enabled === 'true' || $otto_enabled === true);
142 141 }
143 142
144 143 /**
145 - * Track meta state before REST API save
146 - * Stores which SEO meta keys existed before the save
147 - *
148 - * @param stdClass $prepared_post Prepared post object
149 - * @param WP_REST_Request $request Request object
150 - * @return stdClass Unmodified prepared post object
151 - */
152 - public function track_meta_before_save($prepared_post, $request) {
153 - $post_id = $request->get_param('id');
154 - if (!$post_id) {
155 - return $prepared_post;
156 - }
157 -
158 - // Track which meta keys existed before this save
159 - $meta_existed = array(
160 - self::META_SEO_TITLE => metadata_exists('post', $post_id, self::META_SEO_TITLE),
161 - self::META_DESCRIPTION => metadata_exists('post', $post_id, self::META_DESCRIPTION),
162 - );
163 -
164 - // Store in transient for use in cleanup
165 - set_transient('metasync_seo_meta_existed_' . $post_id, $meta_existed, 60);
166 -
167 - return $prepared_post;
168 - }
169 -
170 - /**
171 - * Clean up empty SEO meta values after REST API save
172 - * This prevents blanking fields that are showing OTTO fallback values
173 - *
174 - * Deletes meta keys that are empty AND didn't exist before the save
175 - * (meaning they were showing OTTO fallback, not user-edited values)
176 - *
177 - * @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)
178 153 * @param WP_REST_Request $_request Request object (unused)
179 154 * @param bool $_creating True when creating a post, false when updating (unused)
180 155 */
181 156 public function cleanup_empty_seo_meta($post, $_request, $_creating) {
182 - $post_id = $post->ID;
183 -
184 - // Get the before-save state
185 - $meta_existed = get_transient('metasync_seo_meta_existed_' . $post_id);
186 - if ($meta_existed === false) {
187 - // 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) {
188 159 return;
189 160 }
190 161
191 - // Delete transient
192 - delete_transient('metasync_seo_meta_existed_' . $post_id);
193 -
194 - // Check each of our SEO meta keys
195 - foreach ($meta_existed as $meta_key => $existed_before) {
196 - // Get the current value
162 + foreach (array(self::META_SEO_TITLE, self::META_DESCRIPTION) as $meta_key) {
197 163 $meta_value = get_post_meta($post_id, $meta_key, true);
198 164
199 - // Only delete if:
200 - // 1. Value is now empty
201 - // 2. It didn't exist before the save (was showing OTTO fallback)
202 - 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 === '') {
203 169 delete_post_meta($post_id, $meta_key);
204 170 }
205 171 }
206 172 }
@@ -276,37 +242,62 @@
276 242 if ($defer_meta_description && $defer_og_description) {
277 243 return;
278 244 }
279 245
280 - // Get the custom SEO description (takes priority)
281 - $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 Title & Description 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'];
282 253
283 - // Fall back to an imported description ONLY when the customer has not
284 - // set one and OTTO has no suggestion for this post. Imported values are
285 - // migration data, not a per-post customer decision, so they must never
286 - // displace OTTO. Emitted without the data-metasync-seo="custom" marker
287 - // so Otto_html_class::force_custom_meta() does not mistake it for an
288 - // explicit override.
289 - $is_imported_fallback = false;
290 - if (empty($description)) {
291 - $otto_description = $this->otto_disabled_for_post($post_id)
292 - ? ''
293 - : get_post_meta($post_id, '_metasync_otto_description', true);
294 - if (empty($otto_description)) {
295 - $imported = get_post_meta($post_id, self::META_IMPORTED_DESCRIPTION, true);
296 - if (!empty($imported) && is_string($imported) && !$this->third_party_owns_output()) {
297 - $description = $imported;
298 - $is_imported_fallback = true;
299 - }
300 - }
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;
301 279 }
302 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 +
303 288 // Output meta description tags if custom value exists
304 289 // This will be output BEFORE OTTO processes the page, and since we're using
305 290 // a high priority filter, it will override OTTO's meta description
306 291 if (!empty($description)) {
307 292 $description_escaped = esc_attr($description);
308 - $marker = $is_imported_fallback ? ' data-metasync-seo="imported"' : ' data-metasync-seo="custom"';
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 + }
309 300 // Standard meta description
310 301 if (!$defer_meta_description) {
311 302 echo '<meta name="description" content="' . $description_escaped . '"' . $marker . ' />' . "\n";
312 303 }
@@ -321,10 +312,9 @@
321 312 //
322 313 // The plain <meta name="description"> above is deliberately NOT gated here:
323 314 // it belongs to the SEO title/description feature, which has its own setting.
324 315 if (!$defer_og_description
325 - && Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::SOCIAL_OG)
326 - && get_post_meta($post_id, '_metasync_og_enabled', true) !== '0') {
316 + && !Metasync_OpenGraph::is_social_output_disabled($post_id)) {
327 317 // Open Graph description
328 318 echo '<meta property="og:description" content="' . $description_escaped . '"' . $marker . ' />' . "\n";
329 319 // Twitter description
330 320 echo '<meta name="twitter:description" content="' . $description_escaped . '"' . $marker . ' />' . "\n";
@@ -354,35 +344,23 @@
354 344 if (!$post_id) {
355 345 return $title;
356 346 }
357 347
358 - // Get the custom SEO title (takes priority)
359 - $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 Title & Description 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);
360 358
361 - // Return custom SEO title if set
362 359 return !empty($seo_title) ? $seo_title : $title;
363 360 }
364 361
365 362 /**
366 - * Whether the per-post "Disable OTTO" toggle is set for this post.
367 - *
368 - * OTTO stands down completely for such a post, so its persisted suggestion is
369 - * dead data. Treating that dead value as "OTTO has something to say" would
370 - * block the imported fallback and leave the page with no description at all —
371 - * OTTO suppressed, imported suppressed, nothing emitted.
372 - *
373 - * @param int $post_id Post ID.
374 - * @return bool True when OTTO is switched off for this post.
375 - */
376 - private function otto_disabled_for_post($post_id) {
377 - if (!class_exists('Metasync_Otto_Frontend_Toolbar')) {
378 - return false;
379 - }
380 -
381 - return (bool) Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id);
382 - }
383 -
384 - /**
385 363 * Whether a third-party SEO plugin is the one actually rendering this page.
386 364 *
387 365 * Mirrors the guard OTTO's own filters use
388 366 * (otto/metasync-otto-seo-functions.php): when an active plugin owns output
@@ -440,10 +418,13 @@
440 418 }
441 419
442 420 /**
443 421 * Filter document title parts (for themes using wp_get_document_title)
444 - * Custom values ALWAYS take priority over OTTO
445 422 *
423 + * Which stored value wins is decided by Metasync_Seo_Precedence, so this
424 + * filter follows the global "SEO Title & Description Priority" setting
425 + * without restating the order.
426 + *
446 427 * @param array $title_parts Title parts array
447 428 * @return array Modified title parts
448 429 */
449 430 public function filter_document_title_parts($title_parts) {
@@ -461,12 +442,11 @@
461 442 if (!$post_id) {
462 443 return $title_parts;
463 444 }
464 445
465 - // Get the custom SEO title (takes priority)
466 - $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);
467 447
468 - // Replace title part if custom SEO title is set
448 + // Replace title part if MetaSync holds a title for this post
469 449 if (!empty($seo_title)) {
470 450 $title_parts['title'] = $seo_title;
471 451 // Remove tagline and site for clean SEO title
472 452 unset($title_parts['tagline']);
@@ -630,28 +610,34 @@
630 610 return current_user_can('edit_post', $object_id);
631 611 },
632 612 ));
633 613
634 - // Register hreflang / language alternates meta (JSON-encoded array)
635 - register_post_meta($post_type, self::META_HREFLANG, array(
636 - 'show_in_rest' => true,
637 - 'single' => true,
638 - 'type' => 'string',
639 - 'sanitize_callback' => function($value) {
640 - $decoded = json_decode($value, true);
641 - if (!is_array($decoded)) {
642 - return '[]';
643 - }
644 - return wp_json_encode($decoded);
645 - },
646 - 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
647 - if (empty($object_id)) {
648 - $pt_obj = get_post_type_object($post_type);
649 - return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
650 - }
651 - return current_user_can('edit_post', $object_id);
652 - },
653 - ));
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 + }
654 640
655 641 // Register plugin sync timestamp meta
656 642 register_post_meta($post_type, self::META_PLUGIN_SYNC_TS, array(
657 643 'show_in_rest' => true,
@@ -707,13 +693,67 @@
707 693 }
708 694 }
709 695
710 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 + /**
711 749 * Build a read-only list of WPML translation entries for a post.
712 750 *
713 - * Returns an array of {lang, url} objects by querying the
714 - * `icl_translations` table. Used to seed the "Language Alternates"
715 - * 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.
716 756 *
717 757 * @param int $post_id Post ID.
718 758 * @return array
719 759 */
@@ -721,46 +761,20 @@
721 761 if (!defined('ICL_SITEPRESS_VERSION') || $post_id <= 0) {
722 762 return array();
723 763 }
724 764
725 - global $wpdb;
726 - $post = get_post($post_id);
727 - if (!$post) {
728 - return array();
729 - }
765 + $hreflang = new Metasync_Hreflang_Output();
766 + $entries = $hreflang->get_wpml_entries($post_id);
730 767
731 - $element_type = 'post_' . $post->post_type;
732 - $table = $wpdb->prefix . 'icl_translations';
733 -
734 - $trid = $wpdb->get_var($wpdb->prepare(
735 - "SELECT trid FROM {$table} WHERE element_id = %d AND element_type = %s LIMIT 1",
736 - $post_id,
737 - $element_type
738 - ));
739 - if (empty($trid)) {
740 - return array();
741 - }
742 -
743 - $rows = $wpdb->get_results($wpdb->prepare(
744 - "SELECT language_code, element_id FROM {$table} WHERE trid = %d",
745 - $trid
746 - ));
747 - if (empty($rows)) {
748 - return array();
749 - }
750 -
751 - $entries = array();
752 - foreach ($rows as $row) {
753 - $permalink = get_permalink((int) $row->element_id);
754 - if (empty($permalink)) {
755 - continue;
756 - }
757 - $entries[] = array(
758 - 'lang' => (string) $row->language_code,
759 - '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'] : '',
760 774 );
761 775 }
762 - return $entries;
776 + return $out;
763 777 }
764 778
765 779 /**
766 780 * Enqueue block editor assets
@@ -823,19 +837,12 @@
823 837 if (class_exists('Metasync') && method_exists('Metasync', 'get_whitelabel_otto_name')) {
824 838 $otto_name = Metasync::get_whitelabel_otto_name();
825 839 }
826 840
827 - // Get current post ID and check if meta keys exist in database
828 - $post_id = isset($_GET['post']) ? intval($_GET['post']) : 0;
829 - $has_seo_title = false;
830 - $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
831 844
832 - if ($post_id > 0) {
833 - // Check if meta keys exist in database (even if value is empty)
834 - $has_seo_title = metadata_exists('post', $post_id, self::META_SEO_TITLE);
835 - $has_seo_description = metadata_exists('post', $post_id, self::META_DESCRIPTION);
836 - }
837 -
838 845 // LPS / custom-HTML pages bake their own SEO — suppress the editable sidebar
839 846 // panels and surface a read-only notice instead.
840 847 // otto_pixel.php (where metasync_is_custom_or_lps_page lives) is only
841 848 // required conditionally, so the function_exists guard is needed at runtime.
@@ -841,9 +848,20 @@
841 848 // required conditionally, so the function_exists guard is needed at runtime.
842 849 $is_lps_page = function_exists('metasync_is_custom_or_lps_page') && $post_id > 0 && metasync_is_custom_or_lps_page($post_id);
843 850
844 851 // Auto-detected WPML entries for the "Language Alternates" panel.
845 - $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();
846 864
847 865 // Link Suggestions configuration
848 866 $link_suggestions_config = array(
849 867 'restUrl' => rest_url('metasync/v1/link-suggestions'),
@@ -928,13 +946,13 @@
928 946 'rankmath' => defined('RANK_MATH_VERSION'),
929 947 'aioseo' => defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO'),
930 948 ),
931 949 'wpmlEntries' => $wpml_entries,
932 - 'hasMetaKeys' => array(
933 - // Whether the manual meta keys exist in database (PHP check)
934 - 'seoTitle' => $has_seo_title,
935 - 'metaDescription' => $has_seo_description,
936 - ),
950 + // '1' when enabled, '' when disabled. wp_localize_script() casts
951 + // top-level scalars with (string), so a raw boolean false would
952 + // arrive as "" and could not be distinguished from a missing key
953 + // by a strict JS check. Follows the isCustomOrLpsPage pattern.
954 + 'languageAlternatesEnabled' => $language_alternates_enabled ? '1' : '',
937 955 'otto' => array(
938 956 'globalEnabled' => self::is_otto_enabled_globally(),
939 957 'name' => $otto_name,
940 958 ),
@@ -958,10 +976,8 @@
958 976 'seoTitleLabel' => __('SEO Title', 'metasync'),
959 977 'seoTitleHelp' => __('The title that appears in search engine results. Optimal length: 50-60 characters.', 'metasync'),
960 978 'metaDescriptionLabel' => __('Meta Description', 'metasync'),
961 979 'metaDescriptionHelp' => __('A brief description for search engine results. Optimal length: 120-160 characters.', 'metasync'),
962 - 'urlSlugLabel' => __('URL Slug', 'metasync'),
963 - 'urlSlugHelp' => __('The URL-friendly version of the post name. Use lowercase letters, numbers, and hyphens only.', 'metasync'),
964 980 'serpPreviewTitle' => __('SERP Preview', 'metasync'),
965 981 'serpPreviewHelp' => __('Preview how your page will appear in Google search results.', 'metasync'),
966 982 'serpDesktop' => __('Desktop', 'metasync'),
967 983 'serpMobile' => __('Mobile', 'metasync'),
@@ -967,11 +983,17 @@
967 983 'serpMobile' => __('Mobile', 'metasync'),
968 984 'characters' => __('characters', 'metasync'),
969 985 'primaryCategoryNote' => __('Assign 2+ categories to enable this option.', 'metasync'),
970 986 'ottoPrefillHelp' => sprintf(
987 + /* translators: %s: OTTO name (whitelabel). */
971 988 __('Pre-filled from %s. Edit to customize.', 'metasync'),
972 989 $otto_name
973 990 ),
991 + 'importedPrefillHelp' => sprintf(
992 + /* translators: %s: OTTO name (whitelabel) */
993 + __('Suggestion imported from another SEO plugin. Used only until %s has its own suggestion for this page.', 'metasync'),
994 + $otto_name
995 + ),
974 996 // Focus Keyword: read-only field surfacing the OTTO keyword
975 997 'focusKeywordLabel' => __('Focus Keyword', 'metasync'),
976 998 'focusKeywordHelp' => sprintf(
977 999 /* translators: %s: OTTO name (whitelabel) */
@@ -984,9 +1006,10 @@
984 1006 'primaryCategoryLabel' => __('Primary Category', 'metasync'),
985 1007 'primaryCategoryHelp' => __('Select which category appears in the breadcrumb path when this post belongs to multiple categories.', 'metasync'),
986 1008 /* translators: %s: OTTO name (whitelabel) */
987 1009 'ottoOverrideNotice' => sprintf(
988 - __('%s is enabled. Any SEO title and description changes from %s will be overwritten by your custom values entered here.', 'metasync'),
1010 + /* translators: 1: OTTO name (whitelabel), 2: OTTO name (whitelabel). */
1011 + __('%1$s is enabled. Any SEO title and description changes from %2$s will be overwritten by your custom values entered here.', 'metasync'),
989 1012 $otto_name,
990 1013 $otto_name
991 1014 ),
992 1015 // Language Alternates (hreflang) panel strings
@@ -996,8 +1019,10 @@
996 1019 'regionLabel' => __('Region', 'metasync'),
997 1020 'urlLabel' => __('URL', 'metasync'),
998 1021 'editManually' => __('Edit Manually', 'metasync'),
999 1022 'wpmlAutoPopulated' => __('Auto-populated from WPML. Click Edit Manually to override.', 'metasync'),
1023 + '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'),
1024 + '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'),
1000 1025 // Advanced Robots Directives
1001 1026 'robotsAdvancedTitle' => __('Advanced Robots Directives', 'metasync'),
1002 1027 'nofollowLabel' => __('Nofollow', 'metasync'),
1003 1028 'nofollowHelp' => __('Prevent search engines from following links on this page.', 'metasync'),