| @@ -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 |
| @@ -362,8 +649,15 @@ | ||
| 362 | 649 | * @param bool $default Filter default (ignored; the real answer is computed). |
| 363 | 650 | * @return bool |
| 364 | 651 | */ |
| 365 | 652 | public function will_emit($default = false) { |
| 653 | + # Feature switched off, so this emitter stays silent. The legacy emitter | |
| 654 | + # checks the same switch independently, so reporting false here cannot | |
| 655 | + # hand it the work. | |
| 656 | + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) { | |
| 657 | + return false; | |
| 658 | + } | |
| 659 | + | |
| 366 | 660 | if (!is_singular($this->get_supported_post_types())) { |
| 367 | 661 | return false; |
| 368 | 662 | } |
| 369 | 663 | |
| @@ -436,9 +730,9 @@ | ||
| 436 | 730 | |
| 437 | 731 | # LPS / custom-HTML pages bake their own OG/social tags into their HTML bundle, |
| 438 | 732 | # served before wp_head — so this box does nothing on them. Hide it; the SEO |
| 439 | 733 | # read-only notice covers the messaging. |
| 440 | - $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 | |
| 441 | 735 | if (function_exists('metasync_is_custom_or_lps_page') && $lps_post_id > 0 && metasync_is_custom_or_lps_page($lps_post_id)) { |
| 442 | 736 | return; |
| 443 | 737 | } |
| 444 | 738 | |
| @@ -448,8 +742,9 @@ | ||
| 448 | 742 | |
| 449 | 743 | foreach ($post_types as $post_type) { |
| 450 | 744 | add_meta_box( |
| 451 | 745 | self::META_BOX_ID, |
| 746 | + /* translators: %s: effective plugin name (whitelabel-aware). */ | |
| 452 | 747 | sprintf(esc_html__('Social Media & Open Graph by %s', 'metasync'), $plugin_name), |
| 453 | 748 | [$this, 'render_meta_box'], |
| 454 | 749 | $post_type, |
| 455 | 750 | 'normal', |
| @@ -465,10 +760,12 @@ | ||
| 465 | 760 | # Add nonce for security |
| 466 | 761 | wp_nonce_field('metasync_opengraph_nonce', 'metasync_opengraph_nonce'); |
| 467 | 762 | |
| 468 | 763 | # Get existing values. The four social title/description keys are read through |
| 469 | - # get_social_meta() so a row polluted with "Auto Draft" before this fix shipped | |
| 470 | - # 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. | |
| 471 | 768 | $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true); |
| 472 | 769 | $og_title = self::get_social_meta($post->ID, '_metasync_og_title'); |
| 473 | 770 | $og_description = self::get_social_meta($post->ID, '_metasync_og_description'); |
| 474 | 771 | $og_image = get_post_meta($post->ID, '_metasync_og_image', true); |
| @@ -502,16 +799,36 @@ | ||
| 502 | 799 | if ($og_enabled === '') { |
| 503 | 800 | # For new posts, default to enabled |
| 504 | 801 | $og_enabled = '1'; |
| 505 | 802 | } |
| 506 | - if (empty($og_title)) { | |
| 507 | - # Never pre-fill WordPress's own "Auto Draft" placeholder: it would be | |
| 508 | - # submitted back as a real value and persist as the social title forever. | |
| 509 | - $og_title = self::social_post_title($post); | |
| 510 | - } | |
| 511 | - if (empty($og_description)) { | |
| 512 | - $og_description = $this->get_post_excerpt($post); | |
| 513 | - } | |
| 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; | |
| 514 | 831 | if (empty($og_url)) { |
| 515 | 832 | $og_url = $this->get_canonical_url($post); |
| 516 | 833 | } |
| 517 | 834 | if (empty($og_type)) { |
| @@ -519,19 +836,13 @@ | ||
| 519 | 836 | } |
| 520 | 837 | if (empty($og_image)) { |
| 521 | 838 | $og_image = $this->get_featured_image_url($post->ID); |
| 522 | 839 | } |
| 523 | - | |
| 840 | + | |
| 524 | 841 | # Twitter defaults |
| 525 | 842 | if (empty($twitter_card)) { |
| 526 | 843 | $twitter_card = 'summary_large_image'; |
| 527 | 844 | } |
| 528 | - if (empty($twitter_title)) { | |
| 529 | - $twitter_title = $og_title; | |
| 530 | - } | |
| 531 | - if (empty($twitter_description)) { | |
| 532 | - $twitter_description = $og_description; | |
| 533 | - } | |
| 534 | 845 | if (empty($twitter_image)) { |
| 535 | 846 | $twitter_image = $og_image; |
| 536 | 847 | } |
| 537 | 848 | |
| @@ -545,10 +856,20 @@ | ||
| 545 | 856 | /** |
| 546 | 857 | * Save meta box data |
| 547 | 858 | */ |
| 548 | 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 | + | |
| 549 | 870 | # Check if nonce is valid |
| 550 | - if (!isset($_POST['metasync_opengraph_nonce']) || | |
| 871 | + if (!isset($_POST['metasync_opengraph_nonce']) || | |
| 551 | 872 | !wp_verify_nonce($_POST['metasync_opengraph_nonce'], 'metasync_opengraph_nonce')) { |
| 552 | 873 | return; |
| 553 | 874 | } |
| 554 | 875 | |
| @@ -625,9 +946,45 @@ | ||
| 625 | 946 | ) { |
| 626 | 947 | $value = ''; |
| 627 | 948 | } |
| 628 | 949 | |
| 629 | - 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 | + } | |
| 630 | 987 | } |
| 631 | 988 | } |
| 632 | 989 | } |
| 633 | 990 | |
| @@ -688,9 +1045,9 @@ | ||
| 688 | 1045 | # remember_auto_draft_placeholder() already skips. Verified against |
| 689 | 1046 | # __( 'Auto Draft' ), never trusted as a bare string, so a builder's |
| 690 | 1047 | # custom-seeded auto-draft title is never captured. |
| 691 | 1048 | if ($post->post_status === 'auto-draft' |
| 692 | - && $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 | |
| 693 | 1050 | self::remember_auto_draft_placeholder($title); |
| 694 | 1051 | } |
| 695 | 1052 | |
| 696 | 1053 | # Flag maintenance: recognition is context-independent thanks to the |
| @@ -827,8 +1184,13 @@ | ||
| 827 | 1184 | /** |
| 828 | 1185 | * Output Open Graph and Twitter Card tags in wp_head |
| 829 | 1186 | */ |
| 830 | 1187 | public function output_opengraph_tags() { |
| 1188 | + # Social Media & Open Graph switched off — emit nothing. | |
| 1189 | + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) { | |
| 1190 | + return; | |
| 1191 | + } | |
| 1192 | + | |
| 831 | 1193 | if (!is_singular($this->get_supported_post_types())) { |
| 832 | 1194 | return; |
| 833 | 1195 | } |
| 834 | 1196 | |
| @@ -842,10 +1204,9 @@ | ||
| 842 | 1204 | # Check if Open Graph is enabled for this post. |
| 843 | 1205 | # Only an explicit '0' opt-out suppresses output; unset/empty counts as enabled |
| 844 | 1206 | # so a MetaSync-only site gets one consolidated set whether or not the meta |
| 845 | 1207 | # box was ever saved. Must stay in sync with will_emit(). |
| 846 | - $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true); | |
| 847 | - if ($og_enabled === '0') { | |
| 1208 | + if (self::is_social_output_disabled($post->ID)) { | |
| 848 | 1209 | return; |
| 849 | 1210 | } |
| 850 | 1211 | |
| 851 | 1212 | # When OTTO owns this page's OG (enabled + persisted OG data), skip legacy |
| @@ -860,19 +1221,53 @@ | ||
| 860 | 1221 | if (apply_filters('metasync_opengraph_check_conflicts', true) && $this->has_seo_plugin_conflicts()) { |
| 861 | 1222 | return; |
| 862 | 1223 | } |
| 863 | 1224 | |
| 864 | - # Get Open Graph data — check persisted key first, fall back to OTTO staging key, then post default. | |
| 865 | - # get_social_meta() collapses a stored "Auto Draft" placeholder to '' so the chain | |
| 866 | - # falls through to the real title on rows polluted before this fix shipped. | |
| 867 | - $og_title = self::get_social_meta($post->ID, '_metasync_og_title') | |
| 868 | - ?: get_post_meta($post->ID, '_metasync_otto_og_title', true) | |
| 1225 | + # Open Graph data. The tier order — what the customer set, then OTTO, then | |
| 1226 | + # a value brought in from another SEO plugin — comes from | |
| 1227 | + # Metasync_Seo_Precedence so this emitter and the conflict handler cannot | |
| 1228 | + # disagree about which value the page should carry. The resolver collapses a | |
| 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. | |
| 1235 | + $og_title = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_TITLE) | |
| 869 | 1236 | ?: self::social_post_title($post); |
| 870 | - $og_description = self::get_social_meta($post->ID, '_metasync_og_description') | |
| 871 | - ?: get_post_meta($post->ID, '_metasync_otto_og_description', true) | |
| 1237 | + $og_description = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION) | |
| 872 | 1238 | ?: $this->get_post_excerpt($post); |
| 873 | - $og_image = get_post_meta($post->ID, '_metasync_og_image', true) ?: $this->get_featured_image_url($post->ID); | |
| 874 | - $og_url = get_post_meta($post->ID, '_metasync_og_url', true) ?: $this->get_canonical_url($post); | |
| 1239 | + $og_image = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_IMAGE) | |
| 1240 | + ?: $this->get_featured_image_url($post->ID); | |
| 1241 | + $canonical_override = ''; | |
| 1242 | + if (class_exists('Metasync_Headless_Config') && Metasync_Headless_Config::is_active()) { | |
| 1243 | + # The same two-key chain the canonical tag itself resolves, so the | |
| 1244 | + # two cannot disagree. meta_canonical is not a legacy alias: the | |
| 1245 | + # REST sync and the classic-editor Canonical box both still write | |
| 1246 | + # it, and the GraphQL surface reads both keys as well. | |
| 1247 | + foreach (array('_metasync_canonical_url', 'meta_canonical') as $canonical_key) { | |
| 1248 | + $canonical_override = Metasync_Canonical_Sanitizer::sanitize( | |
| 1249 | + get_post_meta($post->ID, $canonical_key, true) | |
| 1250 | + ); | |
| 1251 | + if ($canonical_override !== '') { | |
| 1252 | + break; | |
| 1253 | + } | |
| 1254 | + } | |
| 1255 | + } | |
| 1256 | + | |
| 1257 | + # A validated canonical override is the author's final answer. It may | |
| 1258 | + # deliberately name another host, so it bypasses headless rehosting. | |
| 1259 | + if ($canonical_override !== '') { | |
| 1260 | + $og_url = esc_url($canonical_override); | |
| 1261 | + } else { | |
| 1262 | + $og_url = get_post_meta($post->ID, '_metasync_og_url', true) ?: $this->get_canonical_url($post); | |
| 1263 | + | |
| 1264 | + # On a headless site the rendered page is not the public page, so | |
| 1265 | + # an own-host og:url must name the frontend. Applied at the single | |
| 1266 | + # emission source so update_opengraph_url() continues to persist | |
| 1267 | + # the WordPress URL used outside headless mode. | |
| 1268 | + $og_url = $this->maybe_build_headless_url($og_url, $post->post_type); | |
| 1269 | + } | |
| 875 | 1270 | $og_type = get_post_meta($post->ID, '_metasync_og_type', true) ?: 'article'; |
| 876 | 1271 | |
| 877 | 1272 | # Get Twitter Card data — check persisted key first, fall back to OTTO staging key |
| 878 | 1273 | $twitter_card = get_post_meta($post->ID, '_metasync_twitter_card', true) ?: 'summary_large_image'; |
| @@ -885,15 +1280,14 @@ | ||
| 885 | 1280 | if (empty($twitter_site) && !empty($twitter_username)) { |
| 886 | 1281 | $twitter_site = '@' . $twitter_username; |
| 887 | 1282 | } |
| 888 | 1283 | $twitter_creator = !empty($twitter_username) ? '@' . $twitter_username : ''; |
| 889 | - $twitter_title = self::get_social_meta($post->ID, '_metasync_twitter_title') | |
| 890 | - ?: get_post_meta($post->ID, '_metasync_otto_twitter_title', true) | |
| 1284 | + $twitter_title = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_TWITTER_TITLE) | |
| 891 | 1285 | ?: $og_title; |
| 892 | - $twitter_description = self::get_social_meta($post->ID, '_metasync_twitter_description') | |
| 893 | - ?: get_post_meta($post->ID, '_metasync_otto_twitter_description', true) | |
| 1286 | + $twitter_description = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_TWITTER_DESCRIPTION) | |
| 894 | 1287 | ?: $og_description; |
| 895 | - $twitter_image = get_post_meta($post->ID, '_metasync_twitter_image', true) ?: $og_image; | |
| 1288 | + $twitter_image = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_TWITTER_IMAGE) | |
| 1289 | + ?: $og_image; | |
| 896 | 1290 | $twitter_image_alt = get_post_meta($post->ID, '_metasync_twitter_image_alt', true); |
| 897 | 1291 | |
| 898 | 1292 | # Resolve OG image attachment ID once for reuse (twitter:image:alt fallback + og:image dimensions) |
| 899 | 1293 | $og_image_attachment_id = 0; |
| @@ -1092,8 +1486,14 @@ | ||
| 1092 | 1486 | * complete article metadata while other SEO plugins handle og:title/description. |
| 1093 | 1487 | * Cross-plugin dedup is handled via register_dedup_filters() instead. |
| 1094 | 1488 | */ |
| 1095 | 1489 | public function output_article_tags() { |
| 1490 | + # article:* tags are part of the same Open Graph block, so they follow | |
| 1491 | + # the same switch as output_opengraph_tags(). | |
| 1492 | + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) { | |
| 1493 | + return; | |
| 1494 | + } | |
| 1495 | + | |
| 1096 | 1496 | if (!is_singular()) { |
| 1097 | 1497 | return; |
| 1098 | 1498 | } |
| 1099 | 1499 | |
| @@ -1101,8 +1501,12 @@ | ||
| 1101 | 1501 | if (!$post instanceof WP_Post) { |
| 1102 | 1502 | return; |
| 1103 | 1503 | } |
| 1104 | 1504 | |
| 1505 | + if (self::is_social_output_disabled($post->ID)) { | |
| 1506 | + return; | |
| 1507 | + } | |
| 1508 | + | |
| 1105 | 1509 | $og_type = get_post_meta($post->ID, '_metasync_og_type', true) ?: 'article'; |
| 1106 | 1510 | if ($og_type !== 'article') { |
| 1107 | 1511 | return; |
| 1108 | 1512 | } |
| @@ -1208,16 +1612,27 @@ | ||
| 1208 | 1612 | $yoast_active = is_plugin_active('wordpress-seo/wp-seo.php') |
| 1209 | 1613 | || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php'); |
| 1210 | 1614 | $rank_math_active = is_plugin_active('seo-by-rank-math/rank-math.php'); |
| 1211 | 1615 | |
| 1212 | - $article_timestamps_enabled = ($settings['article_timestamps'] ?? 'true') !== 'false'; | |
| 1213 | - $article_author_enabled = ($settings['article_author'] ?? 'true') !== 'false'; | |
| 1214 | - $article_section_enabled = ($settings['article_section'] ?? 'true') !== 'false'; | |
| 1215 | - $article_tags_enabled = ($settings['article_tags'] ?? 'true') !== 'false'; | |
| 1616 | + # The article:* tags these filters suppress are emitted by | |
| 1617 | + # output_article_tags(), which stands down when the Social Media & Open | |
| 1618 | + # Graph feature is switched off. Treat every article feature as disabled | |
| 1619 | + # in that case so the third party keeps rendering its own tags — pulling | |
| 1620 | + # ours without releasing theirs would leave the page with none. | |
| 1621 | + $social_enabled = Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::SOCIAL_OG); | |
| 1216 | 1622 | |
| 1623 | + $article_timestamps_enabled = $social_enabled && ($settings['article_timestamps'] ?? 'true') !== 'false'; | |
| 1624 | + $article_author_enabled = $social_enabled && ($settings['article_author'] ?? 'true') !== 'false'; | |
| 1625 | + $article_section_enabled = $social_enabled && ($settings['article_section'] ?? 'true') !== 'false'; | |
| 1626 | + $article_tags_enabled = $social_enabled && ($settings['article_tags'] ?? 'true') !== 'false'; | |
| 1627 | + | |
| 1217 | 1628 | # Yoast: remove individual presenters based on which MetaSync features are enabled |
| 1218 | 1629 | if ($yoast_active && ($article_timestamps_enabled || $article_author_enabled)) { |
| 1219 | 1630 | add_filter('wpseo_frontend_presenters', function( $presenters ) use ( $article_timestamps_enabled, $article_author_enabled ) { |
| 1631 | + $post_id = function_exists('get_queried_object_id') ? (int) get_queried_object_id() : 0; | |
| 1632 | + if (self::is_social_output_disabled($post_id)) { | |
| 1633 | + return $presenters; | |
| 1634 | + } | |
| 1220 | 1635 | foreach ( $presenters as $key => $presenter ) { |
| 1221 | 1636 | if ( $article_timestamps_enabled && ( |
| 1222 | 1637 | $presenter instanceof \Yoast\WP\SEO\Presenters\Open_Graph\Article_Published_Time_Presenter || |
| 1223 | 1638 | $presenter instanceof \Yoast\WP\SEO\Presenters\Open_Graph\Article_Modified_Time_Presenter |
| @@ -1237,20 +1652,24 @@ | ||
| 1237 | 1652 | # Rank Math's tag() method passes content through rank_math/opengraph/facebook/{property} |
| 1238 | 1653 | # where {property} is the OG property with colons replaced by underscores. |
| 1239 | 1654 | # Returning false causes tag() to skip output (empty($content) check). |
| 1240 | 1655 | if ($rank_math_active) { |
| 1656 | + $keep_third_party = function ($value) { | |
| 1657 | + $post_id = function_exists('get_queried_object_id') ? (int) get_queried_object_id() : 0; | |
| 1658 | + return self::is_social_output_disabled($post_id) ? $value : false; | |
| 1659 | + }; | |
| 1241 | 1660 | if ($article_timestamps_enabled) { |
| 1242 | - add_filter('rank_math/opengraph/facebook/article_published_time', '__return_false', 999); | |
| 1243 | - add_filter('rank_math/opengraph/facebook/article_modified_time', '__return_false', 999); | |
| 1661 | + add_filter('rank_math/opengraph/facebook/article_published_time', $keep_third_party, 999); | |
| 1662 | + add_filter('rank_math/opengraph/facebook/article_modified_time', $keep_third_party, 999); | |
| 1244 | 1663 | } |
| 1245 | 1664 | if ($article_tags_enabled) { |
| 1246 | - add_filter('rank_math/opengraph/facebook/article_tag', '__return_false', 999); | |
| 1665 | + add_filter('rank_math/opengraph/facebook/article_tag', $keep_third_party, 999); | |
| 1247 | 1666 | } |
| 1248 | 1667 | if ($article_author_enabled) { |
| 1249 | - add_filter('rank_math/opengraph/facebook/article_author', '__return_false', 999); | |
| 1668 | + add_filter('rank_math/opengraph/facebook/article_author', $keep_third_party, 999); | |
| 1250 | 1669 | } |
| 1251 | 1670 | if ($article_section_enabled) { |
| 1252 | - add_filter('rank_math/opengraph/facebook/article_section', '__return_false', 999); | |
| 1671 | + add_filter('rank_math/opengraph/facebook/article_section', $keep_third_party, 999); | |
| 1253 | 1672 | } |
| 1254 | 1673 | } |
| 1255 | 1674 | } |
| 1256 | 1675 | |
| @@ -1367,9 +1786,9 @@ | ||
| 1367 | 1786 | private function generate_preview_html($title, $description, $image, $url, $twitter_title = '', $twitter_description = '', $twitter_image = '') { |
| 1368 | 1787 | # Parse domain from URL |
| 1369 | 1788 | $domain = ''; |
| 1370 | 1789 | if (!empty($url)) { |
| 1371 | - $parsed = parse_url($url); | |
| 1790 | + $parsed = wp_parse_url($url); | |
| 1372 | 1791 | $domain = $parsed['host'] ?? ''; |
| 1373 | 1792 | } |
| 1374 | 1793 | |
| 1375 | 1794 | # Fallback to site URL if no domain found |
| @@ -1374,9 +1793,9 @@ | ||
| 1374 | 1793 | |
| 1375 | 1794 | # Fallback to site URL if no domain found |
| 1376 | 1795 | if (empty($domain)) { |
| 1377 | 1796 | $site_url = get_site_url(); |
| 1378 | - $parsed = parse_url($site_url); | |
| 1797 | + $parsed = wp_parse_url($site_url); | |
| 1379 | 1798 | $domain = $parsed['host'] ?? 'your-site.com'; |
| 1380 | 1799 | } |
| 1381 | 1800 | |
| 1382 | 1801 | # Provide fallbacks for empty values |
| @@ -1758,8 +2177,62 @@ | ||
| 1758 | 2177 | return $permalink; |
| 1759 | 2178 | } |
| 1760 | 2179 | |
| 1761 | 2180 | /** |
| 2181 | + * Rehost a URL onto the public frontend when headless mode is active. | |
| 2182 | + * | |
| 2183 | + * Unlike the derived-URL callers elsewhere, the value handed here can be a | |
| 2184 | + * stored one an editor typed, which may legitimately point at a third-party | |
| 2185 | + * host. Only a URL on this site's own host is rewritten; anything else is | |
| 2186 | + * returned untouched. A builder failure is returned as an empty string so | |
| 2187 | + * the emitter cannot advertise the WordPress host on an active headless site. | |
| 2188 | + * | |
| 2189 | + * @param mixed $url URL to consider rehosting. | |
| 2190 | + * @param string $post_type Post type, for the frontend path prefix. | |
| 2191 | + * @return mixed Original URL when inactive or foreign; public URL or empty | |
| 2192 | + * string for an own-host URL while active. | |
| 2193 | + */ | |
| 2194 | + private function maybe_build_headless_url($url, $post_type = '') { | |
| 2195 | + if (!class_exists('Metasync_Headless_Config') || !Metasync_Headless_Config::is_active() || !$this->is_own_host_url($url)) { | |
| 2196 | + return $url; | |
| 2197 | + } | |
| 2198 | + | |
| 2199 | + return Metasync_Headless_Url_Builder::from_wp_url($url, $post_type); | |
| 2200 | + } | |
| 2201 | + | |
| 2202 | + /** | |
| 2203 | + * Whether a URL addresses this WordPress site rather than a foreign host. | |
| 2204 | + * | |
| 2205 | + * Accepts only HTTP(S) URLs on the exact site host, protocol-relative URLs | |
| 2206 | + * on that host, and genuine site-relative paths beginning with one slash. | |
| 2207 | + * | |
| 2208 | + * @param mixed $url URL to test. | |
| 2209 | + * @return bool | |
| 2210 | + */ | |
| 2211 | + private function is_own_host_url($url) { | |
| 2212 | + if (!is_string($url) || $url === '' || preg_match('/\s/', $url)) { | |
| 2213 | + return false; | |
| 2214 | + } | |
| 2215 | + | |
| 2216 | + if ($url[0] === '/' && (!isset($url[1]) || $url[1] !== '/')) { | |
| 2217 | + return true; | |
| 2218 | + } | |
| 2219 | + | |
| 2220 | + $is_protocol_relative = strpos($url, '//') === 0; | |
| 2221 | + $scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME)); | |
| 2222 | + if (!$is_protocol_relative && !in_array($scheme, array('http', 'https'), true)) { | |
| 2223 | + return false; | |
| 2224 | + } | |
| 2225 | + | |
| 2226 | + $url_host = wp_parse_url($url, PHP_URL_HOST); | |
| 2227 | + $site_host = wp_parse_url(home_url('/'), PHP_URL_HOST); | |
| 2228 | + | |
| 2229 | + return !empty($url_host) | |
| 2230 | + && !empty($site_host) | |
| 2231 | + && strcasecmp((string) $url_host, (string) $site_host) === 0; | |
| 2232 | + } | |
| 2233 | + | |
| 2234 | + /** | |
| 1762 | 2235 | * Update OpenGraph URL when post is saved |
| 1763 | 2236 | */ |
| 1764 | 2237 | public function update_opengraph_url($post_id) { |
| 1765 | 2238 | # Only update for supported post types |
| @@ -1899,11 +2372,20 @@ | ||
| 1899 | 2372 | /** |
| 1900 | 2373 | * Check if post slug changed via edit slug functionality |
| 1901 | 2374 | */ |
| 1902 | 2375 | public function check_slug_change() { |
| 2376 | + # Fires on the same admin-ajax request as core's sample-permalink | |
| 2377 | + # handler, but at priority 5 — before core validates the nonce and | |
| 2378 | + # capability at its own priority-10 callback. Verify them ourselves | |
| 2379 | + # before any post-meta write. Return (not die) so core still serves | |
| 2380 | + # the preview request normally. | |
| 2381 | + if (!check_ajax_referer('samplepermalink', 'samplepermalinknonce', false)) { | |
| 2382 | + return; | |
| 2383 | + } | |
| 2384 | + | |
| 1903 | 2385 | # Get the post ID from the request |
| 1904 | 2386 | $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0; |
| 1905 | - if (!$post_id) { | |
| 2387 | + if (!$post_id || !current_user_can('edit_post', $post_id)) { | |
| 1906 | 2388 | return; |
| 1907 | 2389 | } |
| 1908 | 2390 | |
| 1909 | 2391 | # Only check for supported post types |
| @@ -1936,9 +2418,9 @@ | ||
| 1936 | 2418 | # This means the og:url was set to the post permalink (not a custom URL) |
| 1937 | 2419 | if ($current_og_url !== $current_permalink && strpos($current_og_url, '?p=') === false) { |
| 1938 | 2420 | # Check if the og:url was the old permalink by comparing with a generated old permalink |
| 1939 | 2421 | $old_post = clone $post; |
| 1940 | - $old_slug = isset($_POST['new_slug']) ? sanitize_title($_POST['new_slug']) : $post->post_name; | |
| 2422 | + $old_slug = isset($_POST['new_slug']) ? sanitize_title(wp_unslash($_POST['new_slug'])) : $post->post_name; | |
| 1941 | 2423 | |
| 1942 | 2424 | # If the og:url doesn't match the current permalink, it might be the old one |
| 1943 | 2425 | # We'll update it to the new permalink |
| 1944 | 2426 | if ($current_og_url !== $current_permalink) { |