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 | includes/class-metasync-opengraph.php +503 -43 2.6.25 → 2.7.2 View file →
@@ -40,8 +40,17 @@
40 40 */
41 41 private static $instance;
42 42
43 43 /**
44 + * Per-request memo for get_default_og_values(), keyed on post ID. See that
45 + * method for why the defaults are worth memoizing and where the memo is
46 + * dropped.
47 + *
48 + * @var array<int, array{title:string,description:string,image:string}>
49 + */
50 + private static $default_og_values_memo = [];
51 +
52 + /**
44 53 * Meta box ID
45 54 */
46 55 const META_BOX_ID = 'metasync_opengraph_meta_box';
47 56
@@ -57,8 +66,35 @@
57 66 '_metasync_twitter_description',
58 67 ];
59 68
60 69 /**
70 + * The social title keys whose meta box default is the post title itself.
71 + * Unlike the description keys — whose pre-fill (the excerpt) is content the
72 + * editor curates separately — these mirror the title verbatim, so a stored
73 + * value equal to it carries no information of its own: it is the pre-fill
74 + * snapshot, not a customization. Reads collapse such rows to '' so the live
75 + * title (which a rename keeps fresh) applies instead.
76 + */
77 + const TITLE_DEFAULTED_KEYS = [
78 + '_metasync_og_title',
79 + '_metasync_twitter_title',
80 + ];
81 +
82 + /**
83 + * The social description keys whose meta box default is the resolved
84 + * description (the excerpt the emitter would derive). The pre-fill persisted
85 + * that default verbatim on save, so rows exist where these keys hold a copy
86 + * of the excerpt as it was on save day — stale the moment the excerpt or the
87 + * content it is generated from changes. Reads collapse such rows to '' so
88 + * the live default applies instead. A value that differs from the default is
89 + * untouched: only text the editor genuinely typed survives.
90 + */
91 + const DESCRIPTION_DEFAULTED_KEYS = [
92 + '_metasync_og_description',
93 + '_metasync_twitter_description',
94 + ];
95 +
96 + /**
61 97 * Site option holding every "Auto Draft" placeholder variant this install
62 98 * has actually seen, keyed by nothing, de-duplicated, capped. Core fills a
63 99 * new post's title with __( 'Auto Draft' ) in the *creating admin user's*
64 100 * locale, and that translation is not loaded on front-end requests, so no
@@ -75,8 +111,19 @@
75 111 */
76 112 const AUTO_DRAFT_PLACEHOLDER_CAP = 20;
77 113
78 114 /**
115 + * Upper bound on posts held in the get_default_og_values() memo. One
116 + * front-end render touches one or two posts; a back-end loop (an importer,
117 + * a bulk action, a resync walking every post) would otherwise grow the
118 + * memo — and the excerpt-derivation results it caches — without end for
119 + * the life of the request. When full the oldest entry is dropped first:
120 + * insertion order is the only order the memo has, and a dropped post
121 + * simply re-derives on its next read.
122 + */
123 + const DEFAULT_OG_VALUES_MEMO_CAP = 10;
124 +
125 + /**
79 126 * Per-post flag marking "this post was saved while its title was still the
80 127 * untitled placeholder". Stores the exact placeholder string that was
81 128 * recognized at save time, so the render path can suppress by state (flag
82 129 * present + title still equals it) rather than by string matching alone —
@@ -131,9 +178,9 @@
131 178 if ($value === '') {
132 179 return false;
133 180 }
134 181 # 'default' text domain, matching the __() call core itself uses.
135 - if ($value === 'Auto Draft' || $value === trim(__('Auto Draft'))) {
182 + if ($value === 'Auto Draft' || $value === trim(__('Auto Draft', 'default'))) { // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- resolves core's own 'Auto Draft' placeholder, which only exists in core's 'default' domain; the plugin text domain cannot reproduce it
136 183 return true;
137 184 }
138 185 # Registry arm: a variant captured in another locale's admin request.
139 186 # Without it the guard above is English-only on every non-admin request,
@@ -194,9 +241,9 @@
194 241 *
195 242 * @return void
196 243 */
197 244 public static function seed_auto_draft_placeholder() {
198 - self::remember_auto_draft_placeholder(__('Auto Draft'));
245 + self::remember_auto_draft_placeholder(__('Auto Draft', 'default')); // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- resolves core's own 'Auto Draft' placeholder, which only exists in core's 'default' domain; the plugin text domain cannot reproduce it
199 246 }
200 247
201 248 /**
202 249 * Read a persisted social meta value, collapsing the "Auto Draft" placeholder
@@ -225,9 +272,12 @@
225 272 }
226 273
227 274 /**
228 275 * Read one of the AUTO_DRAFT_PRONE_KEYS for a post with the placeholder
229 - * collapsed to ''.
276 + * collapsed to '' — and, for the title keys, a stored snapshot of the
277 + * title default the pre-fill showed (the post title, or the og title for
278 + * the twitter twin), and for the description keys a stored snapshot of
279 + * the resolved description, collapsed too.
230 280 *
231 281 * @param int $post_id
232 282 * @param string $key
233 283 * @return string
@@ -232,12 +282,193 @@
232 282 * @param string $key
233 283 * @return string
234 284 */
235 285 public static function get_social_meta($post_id, $key) {
236 - return self::strip_auto_draft_title(get_post_meta($post_id, $key, true));
286 + return self::strip_description_snapshot(
287 + $post_id,
288 + $key,
289 + self::strip_title_snapshot(
290 + $post_id,
291 + $key,
292 + self::strip_auto_draft_title(get_post_meta($post_id, $key, true))
293 + )
294 + );
237 295 }
238 296
239 297 /**
298 + * Collapse a stored social title that merely mirrors the post's own title.
299 + *
300 + * The meta box used to pre-fill Title from the post title as a real value
301 + * and persist it on save, so rows exist where `_metasync_og_title` (and the
302 + * twitter twin) hold a verbatim copy of the title as it was on save day.
303 + * Renaming the post then left that snapshot stale with nothing to tell it
304 + * apart from a typed title. Collapsing it here — at the read, the same
305 + * place the "Auto Draft" placeholder collapses — makes such a row read as
306 + * unset, and the render-time fallback to the *live* title keeps the social
307 + * title in step with every rename. A value that differs from the title is
308 + * untouched: only text the editor genuinely typed survives.
309 + *
310 + * The twitter twin compares against what the og title field showed when
311 + * the pre-fill was rendered — a stored og title if one is set, otherwise
312 + * the same live post title — since that is exactly what the pre-fill
313 + * echoed into the twitter field. Without that chain, a twitter row echoed
314 + * from a typed og title would read as a deliberate override and stop
315 + * following the og title it always rendered.
316 + *
317 + * Compared after trim on both sides: stored values pass through
318 + * sanitize_text_field, which trims, so only the post title side can carry
319 + * surrounding whitespace.
320 + *
321 + * Public and static for the same reason strip_auto_draft_title() is: the
322 + * consumers live in different classes (this emitter, the precedence
323 + * resolver, the plugin sync mirror, the SEO suite).
324 + *
325 + * @param int $post_id
326 + * @param string $key One of TITLE_DEFAULTED_KEYS; others pass through.
327 + * @param mixed $value Stored meta value, already placeholder-collapsed.
328 + * @return string
329 + */
330 + public static function strip_title_snapshot($post_id, $key, $value) {
331 + $value = is_scalar($value) ? (string) $value : '';
332 + if ($value === '' || !in_array($key, self::TITLE_DEFAULTED_KEYS, true)) {
333 + return $value;
334 + }
335 +
336 + $default = self::default_social_title($post_id, $key);
337 + if ($default === '') {
338 + return $value;
339 + }
340 +
341 + return trim($value) === trim($default) ? '' : $value;
342 + }
343 +
344 + /**
345 + * The default a title key renders when its own field is blank: the live
346 + * post title for the og key, and for the twitter key whatever the og field
347 + * would show (a stored og title if one is set, otherwise the same live
348 + * title) — the exact chain the meta box fallback used to pre-fill as a
349 + * value and still shows as the twitter field's placeholder.
350 + *
351 + * social_post_title() — not the raw post_title — is what the pre-fill ever
352 + * stored, and it returns '' for a still-untitled post, where there is no
353 + * meaningful default to compare against anyway (such rows are the
354 + * placeholder case strip_auto_draft_title() already handles).
355 + *
356 + * @param int $post_id
357 + * @param string $key
358 + * @return string
359 + */
360 + private static function default_social_title($post_id, $key) {
361 + $title = self::social_post_title(get_post($post_id));
362 + if ($key === '_metasync_og_title') {
363 + return $title;
364 + }
365 +
366 + $og = self::get_social_meta($post_id, '_metasync_og_title');
367 + return $og !== '' ? $og : $title;
368 + }
369 +
370 + /**
371 + * Collapse a stored social description that merely mirrors the default the
372 + * meta box pre-filled.
373 + *
374 + * The meta box pre-filled Description from the resolved description (the
375 + * manual excerpt, or one generated from the content) as a real value and
376 + * persisted it on save, so rows exist where `_metasync_og_description` (and
377 + * the twitter twin) hold a copy of the excerpt as it was on save day.
378 + * Changing the excerpt — or the content the excerpt is generated from — then
379 + * left that snapshot stale with nothing to tell it apart from a typed
380 + * description. Collapsing it here — at the read, the same place the "Auto
381 + * Draft" placeholder and title snapshots collapse — makes such a row read as
382 + * unset, and the render-time fallback to the *live* excerpt keeps the social
383 + * description in step. A value that differs from the default is untouched:
384 + * only text the editor genuinely typed survives.
385 + *
386 + * The twitter twin compares against what the og description field showed
387 + * when the pre-fill was rendered — a stored og description if one is set,
388 + * otherwise the same resolved default — since that is exactly what the
389 + * pre-fill echoed into the twitter field.
390 + *
391 + * Compared after whitespace normalization on both sides: descriptions pass
392 + * through sanitize_textarea_field, which preserves line breaks, and a manual
393 + * excerpt can carry them while the generated default cannot, so a snapshot
394 + * and its default must be compared with runs of whitespace treated as one
395 + * space rather than byte-for-byte.
396 + *
397 + * Public and static for the same reason strip_title_snapshot() is: the
398 + * consumers live in different classes (this emitter, the precedence
399 + * resolver, the plugin sync mirror, the SEO suite).
400 + *
401 + * @param int $post_id
402 + * @param string $key One of DESCRIPTION_DEFAULTED_KEYS; others pass through.
403 + * @param mixed $value Stored meta value, already placeholder-collapsed.
404 + * @return string
405 + */
406 + public static function strip_description_snapshot($post_id, $key, $value) {
407 + $value = is_scalar($value) ? (string) $value : '';
408 + if ($value === '' || !in_array($key, self::DESCRIPTION_DEFAULTED_KEYS, true)) {
409 + return $value;
410 + }
411 +
412 + $default = self::default_social_description($post_id, $key);
413 + if ($default === '') {
414 + return $value;
415 + }
416 +
417 + return self::normalize_description_compare($value) === self::normalize_description_compare($default)
418 + ? ''
419 + : $value;
420 + }
421 +
422 + /**
423 + * The default a description key renders when its own field is blank: the
424 + * resolved description for the og key, and for the twitter key whatever the
425 + * og field would show (a stored og description if one is set, otherwise the
426 + * same resolved default — even when that default is empty) — the exact
427 + * chain the meta box fallback used to pre-fill as a value.
428 + *
429 + * Resolved through the emitter's own instance so it matches what actually
430 + * renders; when no instance is available the default cannot be proven, so ''
431 + * is returned and the caller keeps the stored value rather than risk
432 + * discarding text the editor typed.
433 + *
434 + * @param int $post_id
435 + * @param string $key
436 + * @return string
437 + */
438 + private static function default_social_description($post_id, $key) {
439 + $instance = self::get_instance();
440 + if (!$instance) {
441 + return '';
442 + }
443 +
444 + $default = (string) $instance->get_default_og_values($post_id)['description'];
445 + if ($key === '_metasync_og_description') {
446 + return $default;
447 + }
448 +
449 + # The og chain runs for the twitter twin even when the resolved default
450 + # is empty: the pre-fill echoed the og description into the twitter
451 + # field whenever the og field carried a value, so that — not the empty
452 + # excerpt — is the default a stored twitter echo must match to collapse.
453 + $og = self::get_social_meta($post_id, '_metasync_og_description');
454 + return $og !== '' ? $og : $default;
455 + }
456 +
457 + /**
458 + * Normalize a description for equality comparison: trimmed, with runs of
459 + * whitespace collapsed to a single space. Falls back to a plain trim when
460 + * the value is not valid UTF-8 and the /u pattern therefore fails.
461 + *
462 + * @param string $value
463 + * @return string
464 + */
465 + private static function normalize_description_compare($value) {
466 + $normalized = preg_replace('/\s+/u', ' ', trim((string) $value));
467 + return is_string($normalized) ? $normalized : trim((string) $value);
468 + }
469 +
470 + /**
240 471 * A post's title for social use, suppressing only WordPress's own placeholder.
241 472 *
242 473 * Distinct from strip_auto_draft_title(), which is for *stored* meta values:
243 474 * there the string is all we have, since a legacy row carries no clue about the
@@ -268,8 +499,25 @@
268 499 return (string) $post->post_title;
269 500 }
270 501
271 502 /**
503 + * Whether MetaSync social output is disabled for a post.
504 + *
505 + * The site-wide switch and the per-post switch both disable only MetaSync's
506 + * social output; callers must leave third-party SEO output untouched.
507 + *
508 + * @param int $post_id Post ID.
509 + * @return bool
510 + */
511 + public static function is_social_output_disabled($post_id = 0) {
512 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
513 + return true;
514 + }
515 +
516 + return $post_id > 0 && get_post_meta($post_id, '_metasync_og_enabled', true) === '0';
517 + }
518 +
519 + /**
272 520 * The default OG values the meta box pre-fills for a post: post title,
273 521 * generated excerpt, and featured image. Computed with the same helpers the
274 522 * meta box/emitter use, so a caller can compare a stored _metasync_og_* value
275 523 * against the default and tell whether the user genuinely customized it (the
@@ -280,17 +528,45 @@
280 528 * "Auto Draft" placeholder, so the meta box pre-fills nothing and callers
281 529 * comparing a stored value against this default don't read the placeholder as
282 530 * a deliberate override.
283 531 *
532 + * Memoized per request: the values are pure functions of the post, and one
533 + * front-end render resolves them several times (every collapsed description
534 + * read asks for the default, and the OTTO precedence walk asks again) — on
535 + * page-builder content the excerpt pass alone re-runs the whole
536 + * shortcode-strip pipeline each time. The save handler drops the memo
537 + * before its comparisons, since save_post fires after the post row write
538 + * and the comparison must see the new title/excerpt. Capped at
539 + * DEFAULT_OG_VALUES_MEMO_CAP posts for loops that walk many posts in one
540 + * request.
541 + *
284 542 * @param int $post_id
285 543 * @return array{title:string,description:string,image:string}
286 544 */
287 545 public function get_default_og_values($post_id) {
546 + $post_id = (int) $post_id;
547 + // No real post: get_post(0) answers with the global post, which would
548 + // then be memoized under key 0 and served for every later bogus id.
549 + if ($post_id <= 0) {
550 + return ['title' => '', 'description' => '', 'image' => ''];
551 + }
552 + if (isset(self::$default_og_values_memo[$post_id])) {
553 + return self::$default_og_values_memo[$post_id];
554 + }
555 +
288 556 $post = get_post($post_id);
289 557 if (!$post instanceof WP_Post) {
290 558 return ['title' => '', 'description' => '', 'image' => ''];
291 559 }
292 - return [
560 +
561 + if (count(self::$default_og_values_memo) >= self::DEFAULT_OG_VALUES_MEMO_CAP) {
562 + // unset() on the first key, NOT array_shift(): the memo is keyed by
563 + // post id, and array_shift() reindexes integer keys from zero —
564 + // after one eviction the survivors would live under 0..8 and any
565 + // post with a low id would be served another post's defaults.
566 + unset(self::$default_og_values_memo[array_key_first(self::$default_og_values_memo)]);
567 + }
568 + return self::$default_og_values_memo[$post_id] = [
293 569 'title' => self::social_post_title($post),
294 570 'description' => (string) $this->get_post_excerpt($post),
295 571 'image' => (string) $this->get_featured_image_url($post->ID),
296 572 ];
@@ -296,8 +572,19 @@
296 572 ];
297 573 }
298 574
299 575 /**
576 + * Drop the get_default_og_values() memo. Called on the save path, where the
577 + * just-written post row must be re-read rather than served from a memo
578 + * populated earlier in the request; also the seam long-lived processes
579 + * (tests, CLI) use to simulate a fresh request. Mirrors
580 + * Metasync_Otto_Config::clear_cache().
581 + */
582 + public static function clear_default_og_values_memo() {
583 + self::$default_og_values_memo = [];
584 + }
585 +
586 + /**
300 587 * Register all hooks for this class
301 588 */
302 589 public function init() {
303 590 # Admin hooks
@@ -443,9 +730,9 @@
443 730
444 731 # LPS / custom-HTML pages bake their own OG/social tags into their HTML bundle,
445 732 # served before wp_head — so this box does nothing on them. Hide it; the SEO
446 733 # read-only notice covers the messaging.
447 - $lps_post_id = isset($_GET['post']) ? intval($_GET['post']) : (isset($_POST['post_ID']) ? intval($_POST['post_ID']) : 0);
734 + $lps_post_id = isset($_GET['post']) ? intval($_GET['post']) : (isset($_POST['post_ID']) ? intval($_POST['post_ID']) : 0); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended -- read-only post ID to pick meta boxes
448 735 if (function_exists('metasync_is_custom_or_lps_page') && $lps_post_id > 0 && metasync_is_custom_or_lps_page($lps_post_id)) {
449 736 return;
450 737 }
451 738
@@ -455,8 +742,9 @@
455 742
456 743 foreach ($post_types as $post_type) {
457 744 add_meta_box(
458 745 self::META_BOX_ID,
746 + /* translators: %s: effective plugin name (whitelabel-aware). */
459 747 sprintf(esc_html__('Social Media & Open Graph by %s', 'metasync'), $plugin_name),
460 748 [$this, 'render_meta_box'],
461 749 $post_type,
462 750 'normal',
@@ -472,10 +760,12 @@
472 760 # Add nonce for security
473 761 wp_nonce_field('metasync_opengraph_nonce', 'metasync_opengraph_nonce');
474 762
475 763 # Get existing values. The four social title/description keys are read through
476 - # get_social_meta() so a row polluted with "Auto Draft" before this fix shipped
477 - # shows the real pre-fill in the editor — and re-saving the post clears it.
764 + # get_social_meta(), which collapses the "Auto Draft" placeholder, a stored
765 + # snapshot of the post title, and a stored snapshot of the resolved
766 + # description to '' — so a polluted legacy row shows an empty field (the
767 + # default lives in the placeholder) and re-saving clears it.
478 768 $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true);
479 769 $og_title = self::get_social_meta($post->ID, '_metasync_og_title');
480 770 $og_description = self::get_social_meta($post->ID, '_metasync_og_description');
481 771 $og_image = get_post_meta($post->ID, '_metasync_og_image', true);
@@ -509,16 +799,36 @@
509 799 if ($og_enabled === '') {
510 800 # For new posts, default to enabled
511 801 $og_enabled = '1';
512 802 }
513 - if (empty($og_title)) {
514 - # Never pre-fill WordPress's own "Auto Draft" placeholder: it would be
515 - # submitted back as a real value and persist as the social title forever.
516 - $og_title = self::social_post_title($post);
517 - }
518 - if (empty($og_description)) {
519 - $og_description = $this->get_post_excerpt($post);
520 - }
803 + # The post title is the default social title, but it is shown as a
804 + # placeholder, never as the field's value. Submitting a page carries
805 + # every value attribute to the save handler, so pre-filling the title
806 + # persisted a verbatim snapshot of it — one a later rename left stale
807 + # (and indistinguishable from a typed title). An empty field plus a
808 + # placeholder keeps the default visible while storing nothing, and the
809 + # render-time fallback to the live title tracks renames on its own.
810 + # social_post_title() suppresses WordPress's own "Auto Draft"
811 + # placeholder, so a brand-new post shows no misleading hint either.
812 + $title_placeholder = self::social_post_title($post);
813 + # What twitter:title actually renders when its own field is blank: the
814 + # OG title if one is set, otherwise the same live post title. Shown as
815 + # the twitter field's placeholder for the same reason as above.
816 + $twitter_title_placeholder = ($og_title !== '') ? $og_title : $title_placeholder;
817 + # The resolved description (the manual excerpt, or one generated from
818 + # the content) is the default social description, and like the title it
819 + # is shown as a placeholder, never as the field's value. Pre-filling it
820 + # persisted a snapshot of the excerpt as of save day — one a later
821 + # excerpt or content edit left stale, and indistinguishable from a
822 + # typed description. An empty field plus a placeholder keeps the
823 + # default visible while storing nothing, and the render-time fallback
824 + # to the live excerpt tracks edits on its own.
825 + $description_placeholder = $this->get_post_excerpt($post);
826 + # What twitter:description actually renders when its own field is
827 + # blank: the OG description if one is set, otherwise the same resolved
828 + # default. Shown as the twitter field's placeholder for the same
829 + # reason as above.
830 + $twitter_description_placeholder = ($og_description !== '') ? $og_description : $description_placeholder;
521 831 if (empty($og_url)) {
522 832 $og_url = $this->get_canonical_url($post);
523 833 }
524 834 if (empty($og_type)) {
@@ -526,19 +836,13 @@
526 836 }
527 837 if (empty($og_image)) {
528 838 $og_image = $this->get_featured_image_url($post->ID);
529 839 }
530 -
840 +
531 841 # Twitter defaults
532 842 if (empty($twitter_card)) {
533 843 $twitter_card = 'summary_large_image';
534 844 }
535 - if (empty($twitter_title)) {
536 - $twitter_title = $og_title;
537 - }
538 - if (empty($twitter_description)) {
539 - $twitter_description = $og_description;
540 - }
541 845 if (empty($twitter_image)) {
542 846 $twitter_image = $og_image;
543 847 }
544 848
@@ -552,10 +856,20 @@
552 856 /**
553 857 * Save meta box data
554 858 */
555 859 public function save_meta_box_data($post_id) {
860 + # Drop the defaults memo before anything below can bail. This handler is
861 + # hooked to save_post, so it runs for EVERY save of the post — including
862 + # ones initiated by another meta box, a bulk action, or a sync flow that
863 + # carries no OG nonce and would have returned early above the old flush
864 + # position. Without this clear, a memo seeded earlier in the same request
865 + # (a metabox render, a sync read) survives the post-row write and serves
866 + # the pre-save title/excerpt to the snapshot comparisons in later
867 + # save_post subscribers — the Yoast re-sync on shutdown among them.
868 + self::clear_default_og_values_memo();
869 +
556 870 # Check if nonce is valid
557 - if (!isset($_POST['metasync_opengraph_nonce']) ||
871 + if (!isset($_POST['metasync_opengraph_nonce']) ||
558 872 !wp_verify_nonce($_POST['metasync_opengraph_nonce'], 'metasync_opengraph_nonce')) {
559 873 return;
560 874 }
561 875
@@ -632,9 +946,45 @@
632 946 ) {
633 947 $value = '';
634 948 }
635 949
636 - update_post_meta($post_id, $field, $value);
950 + # A social title identical to the post's title is the default, not a
951 + # customization — whether the editor left an older pre-filled form
952 + # untouched or typed the title back verbatim. Store empty so the
953 + # render-time fallback to the live title applies, which is what keeps
954 + # a rename from leaving a stale snapshot behind. Text that differs
955 + # from the title passes through untouched. save_post fires after the
956 + # post row is written, so the comparison is against the *new* title.
957 + if (in_array($field, self::TITLE_DEFAULTED_KEYS, true)) {
958 + $value = self::strip_title_snapshot($post_id, $field, $value);
959 + }
960 +
961 + # A social description identical to its rendered default — the
962 + # resolved excerpt the older pre-filled form carried, or the
963 + # same text typed back verbatim — is the default, not a
964 + # customization. Store empty so the render-time fallback to the
965 + # live excerpt applies, which is what keeps an excerpt or
966 + # content edit from leaving a stale snapshot behind. Text that
967 + # differs from the default passes through untouched. save_post
968 + # fires after the post row is written, so the comparison is
969 + # against the *new* excerpt and content.
970 + if (in_array($field, self::DESCRIPTION_DEFAULTED_KEYS, true)) {
971 + $value = self::strip_description_snapshot($post_id, $field, $value);
972 + }
973 +
974 + # An empty value means "no value" for every field in this box,
975 + # and must not be stored as one: update_post_meta(..., '') keeps
976 + # a row with an empty meta_value in the table (visible only to
977 + # metadata_exists, EXISTS queries and exports, which would then
978 + # report a customization where the field is merely blank). The
979 + # plain read answers '' for a missing row too, so deleting is
980 + # behaviour-preserving for every reader. Same pattern the
981 + # untitled-flag write below has always used.
982 + if ($value === '') {
983 + delete_post_meta($post_id, $field);
984 + } else {
985 + update_post_meta($post_id, $field, $value);
986 + }
637 987 }
638 988 }
639 989 }
640 990
@@ -695,9 +1045,9 @@
695 1045 # remember_auto_draft_placeholder() already skips. Verified against
696 1046 # __( 'Auto Draft' ), never trusted as a bare string, so a builder's
697 1047 # custom-seeded auto-draft title is never captured.
698 1048 if ($post->post_status === 'auto-draft'
699 - && $title !== '' && $title === trim(__('Auto Draft'))) {
1049 + && $title !== '' && $title === trim(__('Auto Draft', 'default'))) { // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- resolves core's own 'Auto Draft' placeholder, which only exists in core's 'default' domain; the plugin text domain cannot reproduce it
700 1050 self::remember_auto_draft_placeholder($title);
701 1051 }
702 1052
703 1053 # Flag maintenance: recognition is context-independent thanks to the
@@ -854,10 +1204,9 @@
854 1204 # Check if Open Graph is enabled for this post.
855 1205 # Only an explicit '0' opt-out suppresses output; unset/empty counts as enabled
856 1206 # so a MetaSync-only site gets one consolidated set whether or not the meta
857 1207 # box was ever saved. Must stay in sync with will_emit().
858 - $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true);
859 - if ($og_enabled === '0') {
1208 + if (self::is_social_output_disabled($post->ID)) {
860 1209 return;
861 1210 }
862 1211
863 1212 # When OTTO owns this page's OG (enabled + persisted OG data), skip legacy
@@ -876,12 +1225,14 @@
876 1225 # Open Graph data. The tier order — what the customer set, then OTTO, then
877 1226 # a value brought in from another SEO plugin — comes from
878 1227 # Metasync_Seo_Precedence so this emitter and the conflict handler cannot
879 1228 # disagree about which value the page should carry. The resolver collapses a
880 - # stored "Auto Draft" placeholder to '' as it walks the chain, so a row
881 - # polluted by the meta box pre-fill falls through to OTTO's tier rather than
882 - # outranking it. The literal fallbacks below the chain (post title, excerpt,
883 - # featured image) stay here: they are derived at render time, not stored.
1229 + # stored "Auto Draft" placeholder, a snapshot of the post title, and a
1230 + # snapshot of the resolved description to '' as it walks the chain, so a
1231 + # row polluted by the meta box pre-fill falls through to the live
1232 + # fallback (or OTTO's tier) rather than outranking it. The literal
1233 + # fallbacks below the chain (post title, excerpt, featured image) stay
1234 + # here: they are derived at render time, not stored.
884 1235 $og_title = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_TITLE)
885 1236 ?: self::social_post_title($post);
886 1237 $og_description = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION)
887 1238 ?: $this->get_post_excerpt($post);
@@ -886,9 +1237,44 @@
886 1237 $og_description = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION)
887 1238 ?: $this->get_post_excerpt($post);
888 1239 $og_image = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_IMAGE)
889 1240 ?: $this->get_featured_image_url($post->ID);
890 - $og_url = get_post_meta($post->ID, '_metasync_og_url', true) ?: $this->get_canonical_url($post);
1241 + $canonical_override = '';
1242 + if (class_exists('Metasync_Headless_Config') && Metasync_Headless_Config::is_active()) {
1243 + # The two manual keys of the canonical chain, in the same order the
1244 + # canonical tag resolves them. OTTO's volatile staging tier is
1245 + # deliberately left out: headless rehosts derived WordPress URLs,
1246 + # and an OTTO suggestion is not the author's answer about where
1247 + # content lives — a staged cross-domain canonical must not flow
1248 + # into headless og:url. The two can therefore disagree with the
1249 + # WordPress-render canonical tag only when OTTO is staging a
1250 + # canonical and no manual value exists; that is the documented
1251 + # scope split. meta_canonical is not a legacy alias: the REST sync
1252 + # and the classic-editor Canonical box both still write it, and
1253 + # the GraphQL surface reads both keys as well.
1254 + foreach (array('_metasync_canonical_url', 'meta_canonical') as $canonical_key) {
1255 + $canonical_override = Metasync_Canonical_Sanitizer::sanitize(
1256 + get_post_meta($post->ID, $canonical_key, true)
1257 + );
1258 + if ($canonical_override !== '') {
1259 + break;
1260 + }
1261 + }
1262 + }
1263 +
1264 + # A validated canonical override is the author's final answer. It may
1265 + # deliberately name another host, so it bypasses headless rehosting.
1266 + if ($canonical_override !== '') {
1267 + $og_url = esc_url($canonical_override);
1268 + } else {
1269 + $og_url = get_post_meta($post->ID, '_metasync_og_url', true) ?: $this->get_canonical_url($post);
1270 +
1271 + # On a headless site the rendered page is not the public page, so
1272 + # an own-host og:url must name the frontend. Applied at the single
1273 + # emission source so update_opengraph_url() continues to persist
1274 + # the WordPress URL used outside headless mode.
1275 + $og_url = $this->maybe_build_headless_url($og_url, $post->post_type);
1276 + }
891 1277 $og_type = get_post_meta($post->ID, '_metasync_og_type', true) ?: 'article';
892 1278
893 1279 # Get Twitter Card data — check persisted key first, fall back to OTTO staging key
894 1280 $twitter_card = get_post_meta($post->ID, '_metasync_twitter_card', true) ?: 'summary_large_image';
@@ -1122,8 +1508,12 @@
1122 1508 if (!$post instanceof WP_Post) {
1123 1509 return;
1124 1510 }
1125 1511
1512 + if (self::is_social_output_disabled($post->ID)) {
1513 + return;
1514 + }
1515 +
1126 1516 $og_type = get_post_meta($post->ID, '_metasync_og_type', true) ?: 'article';
1127 1517 if ($og_type !== 'article') {
1128 1518 return;
1129 1519 }
@@ -1244,8 +1634,12 @@
1244 1634
1245 1635 # Yoast: remove individual presenters based on which MetaSync features are enabled
1246 1636 if ($yoast_active && ($article_timestamps_enabled || $article_author_enabled)) {
1247 1637 add_filter('wpseo_frontend_presenters', function( $presenters ) use ( $article_timestamps_enabled, $article_author_enabled ) {
1638 + $post_id = function_exists('get_queried_object_id') ? (int) get_queried_object_id() : 0;
1639 + if (self::is_social_output_disabled($post_id)) {
1640 + return $presenters;
1641 + }
1248 1642 foreach ( $presenters as $key => $presenter ) {
1249 1643 if ( $article_timestamps_enabled && (
1250 1644 $presenter instanceof \Yoast\WP\SEO\Presenters\Open_Graph\Article_Published_Time_Presenter ||
1251 1645 $presenter instanceof \Yoast\WP\SEO\Presenters\Open_Graph\Article_Modified_Time_Presenter
@@ -1265,20 +1659,24 @@
1265 1659 # Rank Math's tag() method passes content through rank_math/opengraph/facebook/{property}
1266 1660 # where {property} is the OG property with colons replaced by underscores.
1267 1661 # Returning false causes tag() to skip output (empty($content) check).
1268 1662 if ($rank_math_active) {
1663 + $keep_third_party = function ($value) {
1664 + $post_id = function_exists('get_queried_object_id') ? (int) get_queried_object_id() : 0;
1665 + return self::is_social_output_disabled($post_id) ? $value : false;
1666 + };
1269 1667 if ($article_timestamps_enabled) {
1270 - add_filter('rank_math/opengraph/facebook/article_published_time', '__return_false', 999);
1271 - add_filter('rank_math/opengraph/facebook/article_modified_time', '__return_false', 999);
1668 + add_filter('rank_math/opengraph/facebook/article_published_time', $keep_third_party, 999);
1669 + add_filter('rank_math/opengraph/facebook/article_modified_time', $keep_third_party, 999);
1272 1670 }
1273 1671 if ($article_tags_enabled) {
1274 - add_filter('rank_math/opengraph/facebook/article_tag', '__return_false', 999);
1672 + add_filter('rank_math/opengraph/facebook/article_tag', $keep_third_party, 999);
1275 1673 }
1276 1674 if ($article_author_enabled) {
1277 - add_filter('rank_math/opengraph/facebook/article_author', '__return_false', 999);
1675 + add_filter('rank_math/opengraph/facebook/article_author', $keep_third_party, 999);
1278 1676 }
1279 1677 if ($article_section_enabled) {
1280 - add_filter('rank_math/opengraph/facebook/article_section', '__return_false', 999);
1678 + add_filter('rank_math/opengraph/facebook/article_section', $keep_third_party, 999);
1281 1679 }
1282 1680 }
1283 1681 }
1284 1682
@@ -1378,9 +1776,8 @@
1378 1776 $preview_html = $this->generate_preview_html($title, $description, $image, $url, $twitter_title, $twitter_description, $twitter_image);
1379 1777
1380 1778 if (empty($preview_html)) {
1381 1779 wp_send_json_error(['message' => 'Failed to generate preview HTML']);
1382 - return;
1383 1780 }
1384 1781
1385 1782 wp_send_json_success(['preview' => $preview_html]);
1386 1783
@@ -1395,9 +1792,9 @@
1395 1792 private function generate_preview_html($title, $description, $image, $url, $twitter_title = '', $twitter_description = '', $twitter_image = '') {
1396 1793 # Parse domain from URL
1397 1794 $domain = '';
1398 1795 if (!empty($url)) {
1399 - $parsed = parse_url($url);
1796 + $parsed = wp_parse_url($url);
1400 1797 $domain = $parsed['host'] ?? '';
1401 1798 }
1402 1799
1403 1800 # Fallback to site URL if no domain found
@@ -1402,9 +1799,9 @@
1402 1799
1403 1800 # Fallback to site URL if no domain found
1404 1801 if (empty($domain)) {
1405 1802 $site_url = get_site_url();
1406 - $parsed = parse_url($site_url);
1803 + $parsed = wp_parse_url($site_url);
1407 1804 $domain = $parsed['host'] ?? 'your-site.com';
1408 1805 }
1409 1806
1410 1807 # Provide fallbacks for empty values
@@ -1786,8 +2183,62 @@
1786 2183 return $permalink;
1787 2184 }
1788 2185
1789 2186 /**
2187 + * Rehost a URL onto the public frontend when headless mode is active.
2188 + *
2189 + * Unlike the derived-URL callers elsewhere, the value handed here can be a
2190 + * stored one an editor typed, which may legitimately point at a third-party
2191 + * host. Only a URL on this site's own host is rewritten; anything else is
2192 + * returned untouched. A builder failure is returned as an empty string so
2193 + * the emitter cannot advertise the WordPress host on an active headless site.
2194 + *
2195 + * @param mixed $url URL to consider rehosting.
2196 + * @param string $post_type Post type, for the frontend path prefix.
2197 + * @return mixed Original URL when inactive or foreign; public URL or empty
2198 + * string for an own-host URL while active.
2199 + */
2200 + private function maybe_build_headless_url($url, $post_type = '') {
2201 + if (!class_exists('Metasync_Headless_Config') || !Metasync_Headless_Config::is_active() || !$this->is_own_host_url($url)) {
2202 + return $url;
2203 + }
2204 +
2205 + return Metasync_Headless_Url_Builder::from_wp_url($url, $post_type);
2206 + }
2207 +
2208 + /**
2209 + * Whether a URL addresses this WordPress site rather than a foreign host.
2210 + *
2211 + * Accepts only HTTP(S) URLs on the exact site host, protocol-relative URLs
2212 + * on that host, and genuine site-relative paths beginning with one slash.
2213 + *
2214 + * @param mixed $url URL to test.
2215 + * @return bool
2216 + */
2217 + private function is_own_host_url($url) {
2218 + if (!is_string($url) || $url === '' || preg_match('/\s/', $url)) {
2219 + return false;
2220 + }
2221 +
2222 + if ($url[0] === '/' && (!isset($url[1]) || $url[1] !== '/')) {
2223 + return true;
2224 + }
2225 +
2226 + $is_protocol_relative = strpos($url, '//') === 0;
2227 + $scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME));
2228 + if (!$is_protocol_relative && !in_array($scheme, array('http', 'https'), true)) {
2229 + return false;
2230 + }
2231 +
2232 + $url_host = wp_parse_url($url, PHP_URL_HOST);
2233 + $site_host = wp_parse_url(home_url('/'), PHP_URL_HOST);
2234 +
2235 + return !empty($url_host)
2236 + && !empty($site_host)
2237 + && strcasecmp((string) $url_host, (string) $site_host) === 0;
2238 + }
2239 +
2240 + /**
1790 2241 * Update OpenGraph URL when post is saved
1791 2242 */
1792 2243 public function update_opengraph_url($post_id) {
1793 2244 # Only update for supported post types
@@ -1927,11 +2378,20 @@
1927 2378 /**
1928 2379 * Check if post slug changed via edit slug functionality
1929 2380 */
1930 2381 public function check_slug_change() {
2382 + # Fires on the same admin-ajax request as core's sample-permalink
2383 + # handler, but at priority 5 — before core validates the nonce and
2384 + # capability at its own priority-10 callback. Verify them ourselves
2385 + # before any post-meta write. Return (not die) so core still serves
2386 + # the preview request normally.
2387 + if (!check_ajax_referer('samplepermalink', 'samplepermalinknonce', false)) {
2388 + return;
2389 + }
2390 +
1931 2391 # Get the post ID from the request
1932 2392 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
1933 - if (!$post_id) {
2393 + if (!$post_id || !current_user_can('edit_post', $post_id)) {
1934 2394 return;
1935 2395 }
1936 2396
1937 2397 # Only check for supported post types
@@ -1964,9 +2424,9 @@
1964 2424 # This means the og:url was set to the post permalink (not a custom URL)
1965 2425 if ($current_og_url !== $current_permalink && strpos($current_og_url, '?p=') === false) {
1966 2426 # Check if the og:url was the old permalink by comparing with a generated old permalink
1967 2427 $old_post = clone $post;
1968 - $old_slug = isset($_POST['new_slug']) ? sanitize_title($_POST['new_slug']) : $post->post_name;
2428 + $old_slug = isset($_POST['new_slug']) ? sanitize_title(wp_unslash($_POST['new_slug'])) : $post->post_name;
1969 2429
1970 2430 # If the og:url doesn't match the current permalink, it might be the old one
1971 2431 # We'll update it to the new permalink
1972 2432 if ($current_og_url !== $current_permalink) {