PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-opengraph.php +1137 -73 2.6.5 → 2.7.1 View file →
@@ -31,31 +31,568 @@
31 31 */
32 32 private $version;
33 33
34 34 /**
35 + * Most recently constructed instance, for cross-class reuse of the value
36 + * resolvers (e.g. the OTTO buffer needs the same default OG values the meta
37 + * box pre-fills, to tell an auto-filled default from a user-customized value).
38 + *
39 + * @var Metasync_OpenGraph|null
40 + */
41 + private static $instance;
42 +
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 + /**
35 53 * Meta box ID
36 54 */
37 55 const META_BOX_ID = 'metasync_opengraph_meta_box';
38 56
39 57 /**
40 - * Supported post types
58 + * The social title/description keys the meta box pre-fills from the post
59 + * title/excerpt and persists on save. These are the keys that can capture the
60 + * "Auto Draft" placeholder WordPress gives a brand-new, still-untitled post.
41 61 */
42 - private $supported_post_types = ['post', 'page'];
62 + const AUTO_DRAFT_PRONE_KEYS = [
63 + '_metasync_og_title',
64 + '_metasync_og_description',
65 + '_metasync_twitter_title',
66 + '_metasync_twitter_description',
67 + ];
43 68
44 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 + /**
97 + * Site option holding every "Auto Draft" placeholder variant this install
98 + * has actually seen, keyed by nothing, de-duplicated, capped. Core fills a
99 + * new post's title with __( 'Auto Draft' ) in the *creating admin user's*
100 + * locale, and that translation is not loaded on front-end requests, so no
101 + * render-time literal/translation list can be complete. The registry is the
102 + * locale-free source of truth: captured verbatim at creation time in admin,
103 + * where the translation does resolve, and re-read anywhere.
104 + */
105 + const AUTO_DRAFT_PLACEHOLDER_OPTION = 'metasync_auto_draft_placeholders';
106 +
107 + /**
108 + * Upper bound on registered placeholder variants. Locales change rarely;
109 + * the cap keeps the site option tiny no matter how many editors come and
110 + * go with different locales.
111 + */
112 + const AUTO_DRAFT_PLACEHOLDER_CAP = 20;
113 +
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 + /**
126 + * Per-post flag marking "this post was saved while its title was still the
127 + * untitled placeholder". Stores the exact placeholder string that was
128 + * recognized at save time, so the render path can suppress by state (flag
129 + * present + title still equals it) rather than by string matching alone —
130 + * a genuinely-titled post never carries the flag.
131 + */
132 + const UNTITLED_FLAG_META = '_metasync_untitled_placeholder';
133 +
134 + /**
45 135 * Initialize the class and set its properties.
46 136 */
47 137 public function __construct($plugin_name, $version) {
48 138 $this->plugin_name = $plugin_name;
49 139 $this->version = $version;
140 + self::$instance = $this;
50 141 }
51 142
52 143 /**
144 + * Get the most recently constructed instance (null if none yet).
145 + *
146 + * @return Metasync_OpenGraph|null
147 + */
148 + public static function get_instance() {
149 + return self::$instance;
150 + }
151 +
152 + /**
153 + * Whether a value is the placeholder title WordPress assigns to a brand-new,
154 + * still-untitled post.
155 + *
156 + * Core creates the row with a *translated* title —
157 + * wp-admin/includes/post.php: 'post_title' => __( 'Auto Draft' ) — so matching
158 + * only the English literal misses every localized site. Compare against the
159 + * literal and the current locale's translation, so both an English install and
160 + * a translated one are covered, as is a value stored before a locale switch.
161 + * A third arm checks the captured-variant registry, because the translation
162 + * only resolves in admin: on front-end requests __( 'Auto Draft' ) stays
163 + * English even on a localized site, and a stored de_DE placeholder would
164 + * otherwise pass every check above.
165 + *
166 + * Exact match after trim, never fuzzy: an editor who legitimately titles a post
167 + * "Auto Draft" on purpose is a caller-side concern, and only the four social
168 + * keys in AUTO_DRAFT_PRONE_KEYS are ever routed through here.
169 + *
170 + * @param mixed $value
171 + * @return bool
172 + */
173 + public static function is_auto_draft_title($value) {
174 + if (!is_string($value)) {
175 + return false;
176 + }
177 + $value = trim($value);
178 + if ($value === '') {
179 + return false;
180 + }
181 + # 'default' text domain, matching the __() call core itself uses.
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
183 + return true;
184 + }
185 + # Registry arm: a variant captured in another locale's admin request.
186 + # Without it the guard above is English-only on every non-admin request,
187 + # because core never loads the 'default' MO files out there.
188 + return in_array($value, self::known_auto_draft_placeholders(), true);
189 + }
190 +
191 + /**
192 + * Every placeholder variant known to this install: the English literal plus
193 + * whatever has been captured into the site option so far.
194 + *
195 + * @return string[]
196 + */
197 + public static function known_auto_draft_placeholders() {
198 + $stored = get_option(self::AUTO_DRAFT_PLACEHOLDER_OPTION, []);
199 + if (!is_array($stored)) {
200 + $stored = [];
201 + }
202 + $variants = ['Auto Draft'];
203 + foreach ($stored as $variant) {
204 + if (is_string($variant) && trim($variant) !== '' && !in_array(trim($variant), $variants, true)) {
205 + $variants[] = trim($variant);
206 + }
207 + }
208 + return $variants;
209 + }
210 +
211 + /**
212 + * Capture a placeholder variant into the site option. Called from the
213 + * admin save path, where the creating request's own __( 'Auto Draft' )
214 + * has already verified the title *is* core's placeholder — so no
215 + * translation is needed here, only de-duplication and capping.
216 + *
217 + * @param mixed $title Verbatim post title recognized as the placeholder.
218 + * @return void
219 + */
220 + public static function remember_auto_draft_placeholder($title) {
221 + $title = is_string($title) ? trim($title) : '';
222 + if ($title === '' || $title === 'Auto Draft') {
223 + return;
224 + }
225 + $known = self::known_auto_draft_placeholders();
226 + if (in_array($title, $known, true)) {
227 + return;
228 + }
229 + # Persist captured variants only: the English literal is seeded
230 + # code-side by known_auto_draft_placeholders(), so storing it would be
231 + # redundant state.
232 + $captured = array_values(array_diff($known, ['Auto Draft']));
233 + $captured[] = $title;
234 + update_option(self::AUTO_DRAFT_PLACEHOLDER_OPTION, array_slice($captured, -self::AUTO_DRAFT_PLACEHOLDER_CAP), false);
235 + }
236 +
237 + /**
238 + * admin_init callback: register the placeholder variant the current admin
239 + * request's translation resolves to. remember_auto_draft_placeholder()
240 + * de-duplicates, so repeated requests only cost one cached option read.
241 + *
242 + * @return void
243 + */
244 + public static function seed_auto_draft_placeholder() {
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
246 + }
247 +
248 + /**
249 + * Read a persisted social meta value, collapsing the "Auto Draft" placeholder
250 + * to an empty string.
251 + *
252 + * Every consumer resolves these fields through a "first non-empty wins" chain
253 + * (persisted key -> OTTO staging key -> real post title). A stored placeholder
254 + * is truthy, so the chain would keep it; returning '' lets the chain fall
255 + * through to the real title. That fixes rows already polluted before this fix
256 + * shipped, at render time, without a DB migration.
257 + *
258 + * Public and static because the three consumers that actually emit or forward
259 + * these values live in different classes: this emitter,
260 + * Otto_html_class::apply_metabox_og_precedence(), and Metasync_Plugin_Sync's
261 + * mirror into Yoast/RankMath/AIOSEO storage. Follows the same
262 + * sanitize-at-the-source pattern as Metasync_Canonical_Sanitizer::sanitize().
263 + *
264 + * @param mixed $value Raw stored meta value.
265 + * @return string
266 + */
267 + public static function strip_auto_draft_title($value) {
268 + if (self::is_auto_draft_title($value)) {
269 + return '';
270 + }
271 + return is_scalar($value) ? (string) $value : '';
272 + }
273 +
274 + /**
275 + * Read one of the AUTO_DRAFT_PRONE_KEYS for a post with the placeholder
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.
280 + *
281 + * @param int $post_id
282 + * @param string $key
283 + * @return string
284 + */
285 + public static function get_social_meta($post_id, $key) {
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 + );
295 + }
296 +
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 + /**
471 + * A post's title for social use, suppressing only WordPress's own placeholder.
472 + *
473 + * Distinct from strip_auto_draft_title(), which is for *stored* meta values:
474 + * there the string is all we have, since a legacy row carries no clue about the
475 + * status it was written under. A live post gives us both signals, so require
476 + * both — the post is still an auto-draft AND its title is the placeholder. An
477 + * editor who genuinely titles a published post "Auto Draft" (an article about
478 + * the placeholder itself, say) keeps it as their og:title.
479 + *
480 + * @param WP_Post|mixed $post
481 + * @return string
482 + */
483 + public static function social_post_title($post) {
484 + if (!$post instanceof WP_Post) {
485 + return '';
486 + }
487 + if ($post->post_status === 'auto-draft' && self::is_auto_draft_title($post->post_title)) {
488 + return '';
489 + }
490 + # State-based arm: the post was saved while still untitled (flag set at
491 + # save time, where translations resolve) and its title is still that
492 + # flagged placeholder. This is what catches a placeholder-titled post
493 + # that went on to be published — the status check above no longer
494 + # matches it, and on a localized site the string checks cannot either.
495 + $flagged = get_post_meta($post->ID, self::UNTITLED_FLAG_META, true);
496 + if (is_string($flagged) && trim($flagged) !== '' && trim((string) $post->post_title) === trim($flagged)) {
497 + return '';
498 + }
499 + return (string) $post->post_title;
500 + }
501 +
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 + /**
520 + * The default OG values the meta box pre-fills for a post: post title,
521 + * generated excerpt, and featured image. Computed with the same helpers the
522 + * meta box/emitter use, so a caller can compare a stored _metasync_og_* value
523 + * against the default and tell whether the user genuinely customized it (the
524 + * meta box persists these defaults on save, so a non-empty value alone does
525 + * not prove user intent).
526 + *
527 + * The title is returned empty on a still-untitled post rather than as the
528 + * "Auto Draft" placeholder, so the meta box pre-fills nothing and callers
529 + * comparing a stored value against this default don't read the placeholder as
530 + * a deliberate override.
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 + *
542 + * @param int $post_id
543 + * @return array{title:string,description:string,image:string}
544 + */
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 +
556 + $post = get_post($post_id);
557 + if (!$post instanceof WP_Post) {
558 + return ['title' => '', 'description' => '', 'image' => ''];
559 + }
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] = [
569 + 'title' => self::social_post_title($post),
570 + 'description' => (string) $this->get_post_excerpt($post),
571 + 'image' => (string) $this->get_featured_image_url($post->ID),
572 + ];
573 + }
574 +
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 + /**
53 587 * Register all hooks for this class
54 588 */
55 589 public function init() {
56 590 # Admin hooks
57 591 add_action('add_meta_boxes', [$this, 'add_meta_box']);
592 + # Runs before save_meta_box_data so the untitled flag it maintains is
593 + # already current when the meta box save guard consults it.
594 + add_action('save_post', [$this, 'track_untitled_post'], 5, 2);
58 595 add_action('save_post', [$this, 'save_meta_box_data']);
59 596 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
60 597
61 598 # Alternative script loading for post edit screens
@@ -82,11 +619,95 @@
82 619 add_action('wp_ajax_metasync_og_preview', [$this, 'ajax_generate_preview']);
83 620
84 621 # Register cross-plugin dedup filters (Yoast / Rank Math) when their plugins are active
85 622 $this->register_dedup_filters();
623 +
624 + # Keep the localized "Auto Draft" placeholder out of SEO-plugin titles
625 + # for posts published while still untitled.
626 + $this->register_untitled_title_filters();
627 +
628 + # Seed the placeholder registry with this admin user's locale variant,
629 + # so localized values are recognized everywhere even before any new
630 + # post is created on a localized install.
631 + if (is_admin()) {
632 + add_action('admin_init', [self::class, 'seed_auto_draft_placeholder']);
633 + }
634 +
635 + # Shared predicate so the legacy emitter (Metasync_Seo_Output::hook_metasync_metatags)
636 + # can suppress its own OG/Twitter blocks whenever this class will emit for the post
637 + add_filter('metasync_opengraph_will_emit', [$this, 'will_emit']);
86 638 }
87 639
88 640 /**
641 + * Shared predicate: returns true when output_opengraph_tags() will emit
642 + * the consolidated OG/Twitter block for the current request.
643 + *
644 + * Mirrors the early-return guards in output_opengraph_tags() so this
645 + * canonical emitter and the legacy emitter stay mutually exclusive —
646 + * exactly one fires per page, and neither stays silent on a
647 + * MetaSync-only site.
648 + *
649 + * @param bool $default Filter default (ignored; the real answer is computed).
650 + * @return bool
651 + */
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 +
660 + if (!is_singular($this->get_supported_post_types())) {
661 + return false;
662 + }
663 +
664 + global $post;
665 + if (!$post instanceof WP_Post) {
666 + return false;
667 + }
668 +
669 + # Only an explicit '0' opt-out disables output; unset/empty counts as enabled
670 + $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true);
671 + if ($og_enabled === '0') {
672 + return false;
673 + }
674 +
675 + # OTTO active with persisted OG data owns the page (legacy emitter suppresses too)
676 + if ($this->otto_owns_og($post->ID)) {
677 + return false;
678 + }
679 +
680 + # Third-party SEO plugin active: yield entirely, legacy emitter keeps its original behavior
681 + if (apply_filters('metasync_opengraph_check_conflicts', true) && $this->has_seo_plugin_conflicts()) {
682 + return false;
683 + }
684 +
685 + return true;
686 + }
687 +
688 + /**
689 + * Whether OTTO owns this page's Open Graph / Twitter output.
690 + *
691 + * When OTTO is enabled and has persisted OG data for the post, OTTO's
692 + * dynamically-injected tags (and the buffer-level dedup) take precedence, so
693 + * the per-post OG meta box values are not emitted on the frontend. Shared by
694 + * the frontend emitter (to suppress duplicate output) and the admin meta box
695 + * (to warn the user their values won't apply on an OTTO-managed page).
696 + *
697 + * @param int $post_id
698 + * @return bool
699 + */
700 + private function otto_owns_og($post_id) {
701 + if (!class_exists('Metasync_Otto_Config') || !Metasync_Otto_Config::is_otto_enabled()) {
702 + return false;
703 + }
704 + $otto_og_title = get_post_meta($post_id, '_metasync_otto_og_title', true);
705 + $otto_og_desc = get_post_meta($post_id, '_metasync_otto_og_description', true);
706 + return !empty($otto_og_title) || !empty($otto_og_desc);
707 + }
708 +
709 + /**
89 710 * Add the Open Graph meta box to post and page editors
90 711 */
91 712 public function add_meta_box() {
92 713 # Don't show meta box if user's role doesn't have plugin access
@@ -106,8 +727,16 @@
106 727 if (!empty($general_settings['disable_social_opengraph_metabox'])) {
107 728 return;
108 729 }
109 730
731 + # LPS / custom-HTML pages bake their own OG/social tags into their HTML bundle,
732 + # served before wp_head — so this box does nothing on them. Hide it; the SEO
733 + # read-only notice covers the messaging.
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
735 + if (function_exists('metasync_is_custom_or_lps_page') && $lps_post_id > 0 && metasync_is_custom_or_lps_page($lps_post_id)) {
736 + return;
737 + }
738 +
110 739 # Get supported post types (allow filtering)
111 740 $post_types = $this->get_supported_post_types();
112 741 $plugin_name = Metasync::get_effective_plugin_name();
113 742
@@ -113,8 +742,9 @@
113 742
114 743 foreach ($post_types as $post_type) {
115 744 add_meta_box(
116 745 self::META_BOX_ID,
746 + /* translators: %s: effective plugin name (whitelabel-aware). */
117 747 sprintf(esc_html__('Social Media & Open Graph by %s', 'metasync'), $plugin_name),
118 748 [$this, 'render_meta_box'],
119 749 $post_type,
120 750 'normal',
@@ -129,12 +759,16 @@
129 759 public function render_meta_box($post) {
130 760 # Add nonce for security
131 761 wp_nonce_field('metasync_opengraph_nonce', 'metasync_opengraph_nonce');
132 762
133 - # Get existing values
763 + # Get existing values. The four social title/description keys are read through
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.
134 768 $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true);
135 - $og_title = get_post_meta($post->ID, '_metasync_og_title', true);
136 - $og_description = get_post_meta($post->ID, '_metasync_og_description', true);
769 + $og_title = self::get_social_meta($post->ID, '_metasync_og_title');
770 + $og_description = self::get_social_meta($post->ID, '_metasync_og_description');
137 771 $og_image = get_post_meta($post->ID, '_metasync_og_image', true);
138 772 $og_url = get_post_meta($post->ID, '_metasync_og_url', true);
139 773 $og_type = get_post_meta($post->ID, '_metasync_og_type', true);
140 774
@@ -140,10 +774,10 @@
140 774
141 775 # Twitter Card fields
142 776 $twitter_card = get_post_meta($post->ID, '_metasync_twitter_card', true);
143 777 $twitter_site = get_post_meta($post->ID, '_metasync_twitter_site', true);
144 - $twitter_title = get_post_meta($post->ID, '_metasync_twitter_title', true);
145 - $twitter_description = get_post_meta($post->ID, '_metasync_twitter_description', true);
778 + $twitter_title = self::get_social_meta($post->ID, '_metasync_twitter_title');
779 + $twitter_description = self::get_social_meta($post->ID, '_metasync_twitter_description');
146 780 $twitter_image = get_post_meta($post->ID, '_metasync_twitter_image', true);
147 781 $twitter_image_alt = get_post_meta($post->ID, '_metasync_twitter_image_alt', true);
148 782
149 783 # Twitter App Card fields
@@ -165,14 +799,36 @@
165 799 if ($og_enabled === '') {
166 800 # For new posts, default to enabled
167 801 $og_enabled = '1';
168 802 }
169 - if (empty($og_title)) {
170 - $og_title = $post->post_title;
171 - }
172 - if (empty($og_description)) {
173 - $og_description = $this->get_post_excerpt($post);
174 - }
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;
175 831 if (empty($og_url)) {
176 832 $og_url = $this->get_canonical_url($post);
177 833 }
178 834 if (empty($og_type)) {
@@ -180,23 +836,20 @@
180 836 }
181 837 if (empty($og_image)) {
182 838 $og_image = $this->get_featured_image_url($post->ID);
183 839 }
184 -
840 +
185 841 # Twitter defaults
186 842 if (empty($twitter_card)) {
187 843 $twitter_card = 'summary_large_image';
188 844 }
189 - if (empty($twitter_title)) {
190 - $twitter_title = $og_title;
191 - }
192 - if (empty($twitter_description)) {
193 - $twitter_description = $og_description;
194 - }
195 845 if (empty($twitter_image)) {
196 846 $twitter_image = $og_image;
197 847 }
198 848
849 + # Whether OTTO is managing this page's OG output (values below won't apply on the frontend)
850 + $otto_owns_og = $this->otto_owns_og($post->ID);
851 +
199 852 # Include the meta box template
200 853 include plugin_dir_path(__FILE__) . '../admin/partials/metasync-opengraph-meta-box.php';
201 854 }
202 855
@@ -203,10 +856,20 @@
203 856 /**
204 857 * Save meta box data
205 858 */
206 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 +
207 870 # Check if nonce is valid
208 - if (!isset($_POST['metasync_opengraph_nonce']) ||
871 + if (!isset($_POST['metasync_opengraph_nonce']) ||
209 872 !wp_verify_nonce($_POST['metasync_opengraph_nonce'], 'metasync_opengraph_nonce')) {
210 873 return;
211 874 }
212 875
@@ -271,14 +934,156 @@
271 934
272 935 foreach ($all_fields as $field => $sanitize_callback) {
273 936 if (isset($_POST[$field])) {
274 937 $value = call_user_func($sanitize_callback, $_POST[$field]);
275 - update_post_meta($post_id, $field, $value);
938 +
939 + # The meta box pre-fills empty social title/description fields from the
940 + # post title, and on a brand-new post that title is the "Auto Draft"
941 + # placeholder. Persisting it ships "Auto Draft" as the post's social
942 + # title and description forever, so store empty instead and let the
943 + # render-time fallback chain resolve the real title once one is set.
944 + if (in_array($field, self::AUTO_DRAFT_PRONE_KEYS, true)
945 + && $this->is_auto_draft_prefill_echo($post_id, $value)
946 + ) {
947 + $value = '';
948 + }
949 +
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 + }
276 987 }
277 988 }
278 989 }
279 990
280 991 /**
992 + * Whether a submitted social field value is the meta box echoing back a
993 + * placeholder pre-fill rather than something the editor typed.
994 + *
995 + * Two cases, both narrow on purpose so genuinely typed text is never discarded:
996 + * - the value is the "Auto Draft" placeholder itself; or
997 + * - the post is still an auto-draft and the value is just its title echoed
998 + * back, which is what the pre-fill submits when the editor never touched
999 + * the field.
1000 + *
1001 + * @param int $post_id
1002 + * @param mixed $value Sanitized submitted value.
1003 + * @return bool
1004 + */
1005 + /**
1006 + * save_post tracker maintaining the untitled state this class suppresses by.
1007 + *
1008 + * Two jobs, both anchored at save time — the only place the "Auto Draft"
1009 + * translation reliably resolves — so the render path never has to guess:
1010 + *
1011 + * 1. Capture: when core creates an auto-draft, its title *is*
1012 + * __( 'Auto Draft' ) in the creating request's locale (admin requests
1013 + * carry the creating user's locale). Verified against that same __()
1014 + * call here, then remembered verbatim, so every locale variant this
1015 + * install ever uses is known to all contexts.
1016 + * 2. Flag: mark posts saved while still placeholder-titled (recognized via
1017 + * literal + translation + the registry above, so REST/CLI saves — where
1018 + * the translation stays English — still recognize a registry hit), and
1019 + * clear the mark as soon as a real title arrives.
1020 + *
1021 + * @param int $post_id
1022 + * @param WP_Post|mixed $post
1023 + * @return void
1024 + */
1025 + public function track_untitled_post($post_id, $post) {
1026 + if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
1027 + return;
1028 + }
1029 + if (!$post instanceof WP_Post) {
1030 + $post = get_post($post_id);
1031 + }
1032 + if (!$post instanceof WP_Post || $post->post_type === 'revision' || $post->post_status === 'trash') {
1033 + return;
1034 + }
1035 + if (!in_array($post->post_type, $this->get_supported_post_types(), true)) {
1036 + return;
1037 + }
1038 +
1039 + $title = trim((string) $post->post_title);
1040 +
1041 + # Capture: an auto-draft's title is core's placeholder in whatever
1042 + # locale resolved for the saving request — admin requests carry the
1043 + # creating user's locale; on untranslated requests (front end, REST,
1044 + # CLI) the equality below degenerates to the English literal, which
1045 + # remember_auto_draft_placeholder() already skips. Verified against
1046 + # __( 'Auto Draft' ), never trusted as a bare string, so a builder's
1047 + # custom-seeded auto-draft title is never captured.
1048 + if ($post->post_status === '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
1050 + self::remember_auto_draft_placeholder($title);
1051 + }
1052 +
1053 + # Flag maintenance: recognition is context-independent thanks to the
1054 + # registry (an untranslated REST/CLI save still matches a captured
1055 + # variant), so the flag can never go stale in either direction.
1056 + if (self::is_auto_draft_title($title)) {
1057 + update_post_meta($post_id, self::UNTITLED_FLAG_META, $title);
1058 + } else {
1059 + delete_post_meta($post_id, self::UNTITLED_FLAG_META);
1060 + }
1061 + }
1062 +
1063 + private function is_auto_draft_prefill_echo($post_id, $value) {
1064 + if (self::is_auto_draft_title($value)) {
1065 + return true;
1066 + }
1067 + if (!is_string($value) || trim($value) === '') {
1068 + return false;
1069 + }
1070 + $post = get_post($post_id);
1071 + if (!$post instanceof WP_Post || trim($value) !== trim((string) $post->post_title)) {
1072 + return false;
1073 + }
1074 + if (get_post_status($post_id) === 'auto-draft') {
1075 + return true;
1076 + }
1077 + # State arm: the submitted value echoes a title that was flagged as the
1078 + # placeholder at save time. Covers a value the string checks above
1079 + # missed — e.g. a localized placeholder stored under an admin user
1080 + # whose locale differs from the one that created the post.
1081 + $flagged = get_post_meta($post_id, self::UNTITLED_FLAG_META, true);
1082 + return is_string($flagged) && trim($flagged) !== '' && trim($flagged) === trim($value);
1083 + }
1084 +
1085 + /**
281 1086 * Enqueue admin scripts and styles
282 1087 */
283 1088 public function enqueue_admin_scripts($hook) {
284 1089 global $post_type;
@@ -284,9 +1089,9 @@
284 1089 global $post_type;
285 1090
286 1091 # Only load on post edit screens for supported post types
287 1092 if (!in_array($hook, ['post.php', 'post-new.php']) ||
288 - !in_array($post_type, $this->supported_post_types)) {
1093 + !in_array($post_type, $this->get_supported_post_types())) {
289 1094 return;
290 1095 }
291 1096
292 1097 wp_enqueue_media();
@@ -331,9 +1136,9 @@
331 1136 */
332 1137 public function force_enqueue_scripts() {
333 1138 global $post_type;
334 1139
335 - if (!in_array($post_type, $this->supported_post_types)) {
1140 + if (!in_array($post_type, $this->get_supported_post_types())) {
336 1141 return;
337 1142 }
338 1143
339 1144 # Check if already enqueued
@@ -379,12 +1184,17 @@
379 1184 /**
380 1185 * Output Open Graph and Twitter Card tags in wp_head
381 1186 */
382 1187 public function output_opengraph_tags() {
383 - if (!is_singular($this->supported_post_types)) {
1188 + # Social Media & Open Graph switched off — emit nothing.
1189 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
384 1190 return;
385 1191 }
386 1192
1193 + if (!is_singular($this->get_supported_post_types())) {
1194 + return;
1195 + }
1196 +
387 1197 global $post;
388 1198
389 1199 # Ensure post is a valid object
390 1200 if (!$post instanceof WP_Post) {
@@ -390,24 +1200,22 @@
390 1200 if (!$post instanceof WP_Post) {
391 1201 return;
392 1202 }
393 1203
394 - # Check if Open Graph is enabled for this post
395 - $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true);
396 - if (empty($og_enabled) || $og_enabled !== '1') {
1204 + # Check if Open Graph is enabled for this post.
1205 + # Only an explicit '0' opt-out suppresses output; unset/empty counts as enabled
1206 + # so a MetaSync-only site gets one consolidated set whether or not the meta
1207 + # box was ever saved. Must stay in sync with will_emit().
1208 + if (self::is_social_output_disabled($post->ID)) {
397 1209 return;
398 1210 }
399 1211
400 - # When OTTO is active AND has OG data for this post, skip legacy OG output.
401 - # For cases where OTTO's pixel injects OG tags dynamically (without
402 - # persisting to _metasync_otto_og_* meta), the buffer-level dedup in
403 - # Otto_html_class::deduplicate_og_twitter_tags() handles cleanup.
404 - if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_otto_enabled()) {
405 - $otto_og_title = get_post_meta($post->ID, '_metasync_otto_og_title', true);
406 - $otto_og_desc = get_post_meta($post->ID, '_metasync_otto_og_description', true);
407 - if (!empty($otto_og_title) || !empty($otto_og_desc)) {
408 - return;
409 - }
1212 + # When OTTO owns this page's OG (enabled + persisted OG data), skip legacy
1213 + # OG output. For cases where OTTO's pixel injects OG tags dynamically
1214 + # (without persisting to _metasync_otto_og_* meta), the buffer-level dedup
1215 + # in Otto_html_class::deduplicate_og_twitter_tags() handles cleanup.
1216 + if ($this->otto_owns_og($post->ID)) {
1217 + return;
410 1218 }
411 1219
412 1220 # Check for conflicts with other SEO plugins (allow override via filter)
413 1221 if (apply_filters('metasync_opengraph_check_conflicts', true) && $this->has_seo_plugin_conflicts()) {
@@ -413,29 +1221,73 @@
413 1221 if (apply_filters('metasync_opengraph_check_conflicts', true) && $this->has_seo_plugin_conflicts()) {
414 1222 return;
415 1223 }
416 1224
417 - # Get Open Graph data — check persisted key first, fall back to OTTO staging key, then post default
418 - $og_title = get_post_meta($post->ID, '_metasync_og_title', true)
419 - ?: get_post_meta($post->ID, '_metasync_otto_og_title', true)
420 - ?: $post->post_title;
421 - $og_description = get_post_meta($post->ID, '_metasync_og_description', true)
422 - ?: get_post_meta($post->ID, '_metasync_otto_og_description', 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)
1236 + ?: self::social_post_title($post);
1237 + $og_description = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION)
423 1238 ?: $this->get_post_excerpt($post);
424 - $og_image = get_post_meta($post->ID, '_metasync_og_image', true) ?: $this->get_featured_image_url($post->ID);
425 - $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 + }
426 1270 $og_type = get_post_meta($post->ID, '_metasync_og_type', true) ?: 'article';
427 1271
428 1272 # Get Twitter Card data — check persisted key first, fall back to OTTO staging key
429 1273 $twitter_card = get_post_meta($post->ID, '_metasync_twitter_card', true) ?: 'summary_large_image';
430 1274 $twitter_site = get_post_meta($post->ID, '_metasync_twitter_site', true);
431 - $twitter_title = get_post_meta($post->ID, '_metasync_twitter_title', true)
432 - ?: get_post_meta($post->ID, '_metasync_otto_twitter_title', true)
1275 +
1276 + # Fall back to the site-wide Twitter username (Social Meta settings) so the
1277 + # twitter:site / twitter:creator tags the legacy emitter produced are not lost
1278 + # now that this emitter is the single canonical OG/Twitter source
1279 + $twitter_username = Metasync::get_option('social_meta')['twitter_username'] ?? '';
1280 + if (empty($twitter_site) && !empty($twitter_username)) {
1281 + $twitter_site = '@' . $twitter_username;
1282 + }
1283 + $twitter_creator = !empty($twitter_username) ? '@' . $twitter_username : '';
1284 + $twitter_title = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_TWITTER_TITLE)
433 1285 ?: $og_title;
434 - $twitter_description = get_post_meta($post->ID, '_metasync_twitter_description', true)
435 - ?: 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)
436 1287 ?: $og_description;
437 - $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;
438 1290 $twitter_image_alt = get_post_meta($post->ID, '_metasync_twitter_image_alt', true);
439 1291
440 1292 # Resolve OG image attachment ID once for reuse (twitter:image:alt fallback + og:image dimensions)
441 1293 $og_image_attachment_id = 0;
@@ -474,8 +1326,9 @@
474 1326 $twitter_player_height = get_post_meta($post->ID, '_metasync_twitter_player_height', true);
475 1327
476 1328 # Output Open Graph tags
477 1329 echo "\n<!-- MetaSync Open Graph Tags -->\n";
1330 + echo '<meta property="og:locale" content="' . esc_attr(get_locale()) . '">' . "\n";
478 1331 if ($og_title) {
479 1332 echo '<meta property="og:title" content="' . esc_attr($og_title) . '">' . "\n";
480 1333 }
481 1334 if ($og_description) {
@@ -504,8 +1357,15 @@
504 1357 }
505 1358 if ($og_type) {
506 1359 echo '<meta property="og:type" content="' . esc_attr($og_type) . '">' . "\n";
507 1360 }
1361 + $site_name = get_bloginfo('name');
1362 + if ($site_name) {
1363 + echo '<meta property="og:site_name" content="' . esc_attr($site_name) . '">' . "\n";
1364 + }
1365 + if (!empty($post->post_modified)) {
1366 + echo '<meta property="og:updated_time" content="' . esc_attr($post->post_modified) . '">' . "\n";
1367 + }
508 1368
509 1369 # Output Twitter Card tags
510 1370 echo "<!-- MetaSync Twitter Card Tags -->\n";
511 1371 if ($twitter_card) {
@@ -513,8 +1373,11 @@
513 1373 }
514 1374 if ($twitter_site) {
515 1375 echo '<meta name="twitter:site" content="' . esc_attr($twitter_site) . '">' . "\n";
516 1376 }
1377 + if ($twitter_creator) {
1378 + echo '<meta name="twitter:creator" content="' . esc_attr($twitter_creator) . '">' . "\n";
1379 + }
517 1380 if ($twitter_title) {
518 1381 echo '<meta name="twitter:title" content="' . esc_attr($twitter_title) . '">' . "\n";
519 1382 }
520 1383 if ($twitter_description) {
@@ -623,8 +1486,14 @@
623 1486 * complete article metadata while other SEO plugins handle og:title/description.
624 1487 * Cross-plugin dedup is handled via register_dedup_filters() instead.
625 1488 */
626 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 +
627 1496 if (!is_singular()) {
628 1497 return;
629 1498 }
630 1499
@@ -632,8 +1501,12 @@
632 1501 if (!$post instanceof WP_Post) {
633 1502 return;
634 1503 }
635 1504
1505 + if (self::is_social_output_disabled($post->ID)) {
1506 + return;
1507 + }
1508 +
636 1509 $og_type = get_post_meta($post->ID, '_metasync_og_type', true) ?: 'article';
637 1510 if ($og_type !== 'article') {
638 1511 return;
639 1512 }
@@ -739,16 +1612,27 @@
739 1612 $yoast_active = is_plugin_active('wordpress-seo/wp-seo.php')
740 1613 || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php');
741 1614 $rank_math_active = is_plugin_active('seo-by-rank-math/rank-math.php');
742 1615
743 - $article_timestamps_enabled = ($settings['article_timestamps'] ?? 'true') !== 'false';
744 - $article_author_enabled = ($settings['article_author'] ?? 'true') !== 'false';
745 - $article_section_enabled = ($settings['article_section'] ?? 'true') !== 'false';
746 - $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);
747 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 +
748 1628 # Yoast: remove individual presenters based on which MetaSync features are enabled
749 1629 if ($yoast_active && ($article_timestamps_enabled || $article_author_enabled)) {
750 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 + }
751 1635 foreach ( $presenters as $key => $presenter ) {
752 1636 if ( $article_timestamps_enabled && (
753 1637 $presenter instanceof \Yoast\WP\SEO\Presenters\Open_Graph\Article_Published_Time_Presenter ||
754 1638 $presenter instanceof \Yoast\WP\SEO\Presenters\Open_Graph\Article_Modified_Time_Presenter
@@ -768,25 +1652,98 @@
768 1652 # Rank Math's tag() method passes content through rank_math/opengraph/facebook/{property}
769 1653 # where {property} is the OG property with colons replaced by underscores.
770 1654 # Returning false causes tag() to skip output (empty($content) check).
771 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 + };
772 1660 if ($article_timestamps_enabled) {
773 - add_filter('rank_math/opengraph/facebook/article_published_time', '__return_false', 999);
774 - 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);
775 1663 }
776 1664 if ($article_tags_enabled) {
777 - add_filter('rank_math/opengraph/facebook/article_tag', '__return_false', 999);
1665 + add_filter('rank_math/opengraph/facebook/article_tag', $keep_third_party, 999);
778 1666 }
779 1667 if ($article_author_enabled) {
780 - add_filter('rank_math/opengraph/facebook/article_author', '__return_false', 999);
1668 + add_filter('rank_math/opengraph/facebook/article_author', $keep_third_party, 999);
781 1669 }
782 1670 if ($article_section_enabled) {
783 - add_filter('rank_math/opengraph/facebook/article_section', '__return_false', 999);
1671 + add_filter('rank_math/opengraph/facebook/article_section', $keep_third_party, 999);
784 1672 }
785 1673 }
786 1674 }
787 1675
788 1676 /**
1677 + * Hook the untitled-placeholder suppression into the title pipelines of
1678 + * the SEO plugins that can own the <title>/og:title/twitter:title output
1679 + * when MetaSync's own emitter stands down due to a conflict.
1680 + *
1681 + * Rank Math funnels <title>, og:title and twitter:title through one filter
1682 + * (rank_math/frontend/title wraps Paper::get_title()), so one hook covers
1683 + * all three. Yoast builds them from separate presenters, so it needs the
1684 + * title filter plus the og/twitter title filters.
1685 + *
1686 + * The hooks are registered unconditionally: each only fires while its
1687 + * plugin is actually generating a title, and the callback no-ops unless
1688 + * the current post carries the untitled flag.
1689 + *
1690 + * @return void
1691 + */
1692 + private function register_untitled_title_filters() {
1693 + add_filter('rank_math/frontend/title', [$this, 'strip_untitled_from_seo_title'], 99);
1694 + add_filter('wpseo_title', [$this, 'strip_untitled_from_seo_title'], 99);
1695 + add_filter('wpseo_opengraph_title', [$this, 'strip_untitled_from_seo_title'], 99);
1696 + add_filter('wpseo_twitter_title', [$this, 'strip_untitled_from_seo_title'], 99);
1697 + }
1698 +
1699 + /**
1700 + * Remove the flagged placeholder from an SEO plugin's assembled title.
1701 + *
1702 + * The incoming value is the finished template output ("placeholder -
1703 + * Site name"), so the placeholder segment is cut out and any dangling
1704 + * separator tidied. An empty result (title was the placeholder alone)
1705 + * cannot be returned as-is: both plugins treat an empty value as "do
1706 + * nothing" and re-use their own generated title, so the site name is the
1707 + * replacement of last resort.
1708 + *
1709 + * @param mixed $title Assembled title from the SEO plugin.
1710 + * @return string
1711 + */
1712 + public function strip_untitled_from_seo_title($title) {
1713 + if (!is_string($title) || trim($title) === '') {
1714 + return $title;
1715 + }
1716 + $post = get_post();
1717 + if (!$post instanceof WP_Post) {
1718 + return $title;
1719 + }
1720 + $flagged = get_post_meta($post->ID, self::UNTITLED_FLAG_META, true);
1721 + if (!is_string($flagged) || trim($flagged) === '') {
1722 + return $title;
1723 + }
1724 + # Same state check as social_post_title(): only while the live title
1725 + # still *is* the flagged placeholder.
1726 + if (trim((string) $post->post_title) !== trim($flagged)) {
1727 + return $title;
1728 + }
1729 + if (strpos($title, $flagged) === false) {
1730 + return $title;
1731 + }
1732 + $remainder = trim(str_replace($flagged, '', $title));
1733 + # Tidy the separator(s) that flanked the removed segment.
1734 + $remainder = trim((string) preg_replace('/^[\s\-–—|·»•]+|[\s\-–—|·»•]+$/u', '', $remainder));
1735 + if ($remainder === '') {
1736 + $remainder = trim((string) get_bloginfo('name'));
1737 + }
1738 + if ($remainder === '') {
1739 + # A space, not '': an empty return is a no-op for these filters.
1740 + $remainder = ' ';
1741 + }
1742 + return $remainder;
1743 + }
1744 +
1745 + /**
789 1746 * AJAX handler for generating social media preview
790 1747 */
791 1748 public function ajax_generate_preview() {
792 1749
@@ -829,9 +1786,9 @@
829 1786 private function generate_preview_html($title, $description, $image, $url, $twitter_title = '', $twitter_description = '', $twitter_image = '') {
830 1787 # Parse domain from URL
831 1788 $domain = '';
832 1789 if (!empty($url)) {
833 - $parsed = parse_url($url);
1790 + $parsed = wp_parse_url($url);
834 1791 $domain = $parsed['host'] ?? '';
835 1792 }
836 1793
837 1794 # Fallback to site URL if no domain found
@@ -836,9 +1793,9 @@
836 1793
837 1794 # Fallback to site URL if no domain found
838 1795 if (empty($domain)) {
839 1796 $site_url = get_site_url();
840 - $parsed = parse_url($site_url);
1797 + $parsed = wp_parse_url($site_url);
841 1798 $domain = $parsed['host'] ?? 'your-site.com';
842 1799 }
843 1800
844 1801 # Provide fallbacks for empty values
@@ -998,15 +1955,27 @@
998 1955
999 1956 # Generate excerpt from content
1000 1957 $content = $post->post_content;
1001 1958
1002 - # Process shortcodes to get the actual rendered content (what users see on frontend)
1003 - # This converts page builder shortcodes into their actual HTML output
1004 - $content = do_shortcode($content);
1959 + # Do NOT run do_shortcode()/apply_filters('the_content') here.
1960 + # This method runs on wp_head (priority 5, before the body renders) to build
1961 + # og:description. On page-builder pages (Elementor, etc.) the_content fully
1962 + # renders the page — including widgets like Elementor Loop Grid — which makes
1963 + # the builder mark those widgets' per-request inline CSS as "already printed".
1964 + # When the real widget renders later in the body, the builder's dedup then
1965 + # OMITS its inline <style> (e.g. <style id="loop-NNNN"> carrying the loop
1966 + # card's flex/width vars), collapsing the layout (stacked cards, full-width
1967 + # images). We only need plain text for a meta description, so strip instead of
1968 + # render — matching how Metasync_Seo_Output builds its description safely.
1969 + $content = strip_shortcodes($content);
1005 1970
1006 - # Apply WordPress content filters (same filters used on the frontend)
1007 - # This ensures we get the exact same content as displayed on the page
1008 - $content = apply_filters('the_content', $content);
1971 + # Page builders (Divi, Elementor, WPBakery) store content as shortcodes.
1972 + # do_shortcode() only renders shortcodes whose handlers are registered, and when
1973 + # this runs server-side (REST/cron/CLI) or before the builder loads the [et_pb_*]
1974 + # tags are never expanded. strip_shortcodes() only removes *registered* shortcodes
1975 + # too, so any leftover shortcode-style tags are removed by pattern below — otherwise
1976 + # raw builder markup leaks into the og:description.
1977 + $content = $this->strip_shortcode_markup($content);
1009 1978
1010 1979 # Remove HTML tags to get clean text
1011 1980 $content = wp_strip_all_tags($content);
1012 1981
@@ -1013,13 +1982,20 @@
1013 1982 # Remove extra whitespace, line breaks, and special characters
1014 1983 $content = preg_replace('/\s+/', ' ', $content);
1015 1984 $content = trim($content);
1016 1985
1017 - # If content is still empty or too short, fallback to post title
1986 + # If content is still empty or too short, fallback to post title. A brand-new
1987 + # post has no content and its title is the "Auto Draft" placeholder, which
1988 + # must not become the og:/twitter:description — strip it so the caller's
1989 + # fallback chain resolves to something real instead.
1018 1990 if (empty($content) || strlen($content) < 20) {
1019 - $content = $post->post_title;
1991 + $content = self::social_post_title($post);
1020 1992 }
1021 1993
1994 + if ($content === '') {
1995 + return '';
1996 + }
1997 +
1022 1998 # Generate excerpt
1023 1999 $excerpt = wp_trim_words($content, 30, '...');
1024 2000
1025 2001 return $excerpt;
@@ -1025,8 +2001,31 @@
1025 2001 return $excerpt;
1026 2002 }
1027 2003
1028 2004 /**
2005 + * Strip shortcode markup from a string.
2006 + *
2007 + * Removes registered shortcodes via strip_shortcodes(), then strips any
2008 + * leftover shortcode-style tags (e.g. unregistered page-builder tags such
2009 + * as [et_pb_section ...] / [/et_pb_section]) by pattern. The pattern is
2010 + * anchored to a leading letter so legitimate bracketed prose like
2011 + * "[2026 Guide]" is preserved.
2012 + *
2013 + * @param string $content
2014 + * @return string
2015 + */
2016 + private function strip_shortcode_markup($content) {
2017 + if (empty($content) || !is_string($content)) {
2018 + return (string) $content;
2019 + }
2020 +
2021 + $content = strip_shortcodes($content);
2022 + $content = preg_replace('/\[\/?[a-zA-Z][^\]]*\]/', '', $content);
2023 +
2024 + return $content;
2025 + }
2026 +
2027 + /**
1029 2028 * Get featured image URL
1030 2029 */
1031 2030 private function get_featured_image_url($post_id) {
1032 2031 $thumbnail_id = get_post_thumbnail_id($post_id);
@@ -1040,9 +2039,11 @@
1040 2039 /**
1041 2040 * Get supported post types
1042 2041 */
1043 2042 public function get_supported_post_types() {
1044 - return apply_filters('metasync_opengraph_post_types', $this->supported_post_types);
2043 + $post_types = array_values(get_post_types(['public' => true], 'names'));
2044 + $post_types = array_diff($post_types, ['attachment']);
2045 + return apply_filters('metasync_opengraph_post_types', $post_types);
1045 2046 }
1046 2047
1047 2048 /**
1048 2049 * Add debug menu for testing
@@ -1176,8 +2177,62 @@
1176 2177 return $permalink;
1177 2178 }
1178 2179
1179 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 + /**
1180 2235 * Update OpenGraph URL when post is saved
1181 2236 */
1182 2237 public function update_opengraph_url($post_id) {
1183 2238 # Only update for supported post types
@@ -1317,11 +2372,20 @@
1317 2372 /**
1318 2373 * Check if post slug changed via edit slug functionality
1319 2374 */
1320 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 +
1321 2385 # Get the post ID from the request
1322 2386 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
1323 - if (!$post_id) {
2387 + if (!$post_id || !current_user_can('edit_post', $post_id)) {
1324 2388 return;
1325 2389 }
1326 2390
1327 2391 # Only check for supported post types
@@ -1354,9 +2418,9 @@
1354 2418 # This means the og:url was set to the post permalink (not a custom URL)
1355 2419 if ($current_og_url !== $current_permalink && strpos($current_og_url, '?p=') === false) {
1356 2420 # Check if the og:url was the old permalink by comparing with a generated old permalink
1357 2421 $old_post = clone $post;
1358 - $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;
1359 2423
1360 2424 # If the og:url doesn't match the current permalink, it might be the old one
1361 2425 # We'll update it to the new permalink
1362 2426 if ($current_og_url !== $current_permalink) {