PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / includes / class-metasync-headless-seo-surface.php

class-metasync-headless-seo-surface.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at includes/class-metasync-headless-seo-surface.php

2,382 lines 86.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Headless SEO surface — the resolvers that build a payload for one object.
4 *
5 * Mirrors the shape Yoast exposes: an entry point per object type, each
6 * returning one object carrying every field, built in a single pass. Posts,
7 * terms, the homepage, the posts page, post-type archives and authors each have
8 * their own entry point; the bulk resolver follows the same contract.
9 *
10 * Resolution order for every field:
11 *
12 * manual override -> OTTO -> Yoast -> WordPress default
13 *
14 * The override and OTTO keys, and the order between them, are not invented
15 * here — they are the same chains Metasync_Plugin_Sync,
16 * Metasync_Term_Plugin_Sync and Metasync_SEO_Conflict_Handler already walk.
17 * OTTO values are durable meta (`_metasync_otto_*`, including the JSON-LD), so
18 * this reads existing storage rather than introducing any.
19 *
20 * Which rungs exist is a property of the object type, not an oversight. Only
21 * posts and terms have MetaSync and OTTO storage. Only posts, terms and authors
22 * have per-object Yoast storage, and Yoast keeps all three in different places:
23 * post meta, one option row, and user meta respectively. Post-type archives and
24 * a homepage with no page assigned to it have no per-object storage anywhere, so
25 * their chains are short by nature. Each resolver below documents its own.
26 *
27 * Three properties matter more than the field list:
28 *
29 * - **One pass over the object's meta.** The meta table is read once and every
30 * field is resolved from that array, so a payload costs one meta read rather
31 * than one per field. It also guarantees the fields agree with each other:
32 * a title and a canonical resolved in the same pass cannot come from
33 * different sources.
34 * - **Every URL goes through Metasync_Headless_Url_Builder.** Not one field
35 * returns get_permalink(), get_term_link(), get_author_posts_url(),
36 * get_post_type_archive_link() or home_url() directly. Those all name the
37 * WordPress host, and terms, the homepage and archives emitting the backend
38 * host is the defect this feature exists to remove.
39 * - **Nothing throws.** The delivery layer is expected to be nullable and fail
40 * closed, but that is a thin last line of defence. This layer does not lean
41 * on it: an unknown id, a taxonomy that has been unregistered, a deleted
42 * user, a corrupted meta row all produce null or a partial payload rather
43 * than an exception.
44 *
45 * @package Metasync
46 * @subpackage Metasync/includes
47 */
48
49 if (!defined('ABSPATH')) {
50 exit; # Exit if accessed directly
51 }
52
53 class Metasync_Headless_Seo_Surface
54 {
55 /**
56 * Request-scoped memo of Yoast's `wpseo_taxonomy_meta` option.
57 *
58 * One row holds every term's Yoast data, so a request resolving many
59 * terms should read it once rather than once per term.
60 *
61 * @var array|null
62 */
63 private static $yoast_taxonomy_meta = null;
64
65 /**
66 * Request-scoped payload memo, keyed by object type and id.
67 *
68 * @var array<string,Metasync_Headless_Seo_Data|null>
69 */
70 private static $payload_cache = array();
71
72 /**
73 * Build the SEO payload for one post, page or custom post type entry.
74 *
75 * Accepts an object as well as an id, matching get_post(), so a caller that
76 * already holds the post does not pay for a second lookup.
77 *
78 * @param int|string|WP_Post $post Post ID or object.
79 * @return Metasync_Headless_Seo_Data|null Null when there is no such post.
80 */
81 public static function for_post($post)
82 {
83 $post = self::resolve_post($post);
84 if ($post === null) {
85 return null;
86 }
87
88 # Gated here as well as in each transport. The docblock above anticipates
89 # a REST route sharing this entry point, and leaving flag-off correctness
90 # to whichever caller arrives next is the same shape the URL builder
91 # deliberately refuses. Inert by construction, not by discipline.
92 if (!Metasync_Headless_Config::is_enabled()) {
93 return null;
94 }
95
96 $cache_key = self::payload_cache_key('post', (int) $post->ID);
97 if (array_key_exists($cache_key, self::$payload_cache)) {
98 return self::$payload_cache[$cache_key];
99 }
100
101 # This payload is built for a public endpoint, so publication state is a
102 # precondition rather than a detail. See is_publicly_viewable().
103 if (!self::is_publicly_viewable($post)) {
104 self::$payload_cache[$cache_key] = null;
105
106 return null;
107 }
108
109 # One read; every field below resolves out of this array.
110 $meta = self::normalize_meta(static::read_post_meta($post->ID));
111
112 $post_type = (string) $post->post_type;
113 $public_url = Metasync_Headless_Url_Builder::from_wp_url(static::read_permalink($post), $post_type);
114
115 $title = self::resolve_title($meta, $post);
116 $description = self::resolve_description($meta, $post);
117 $canonical = self::resolve_canonical($meta, $public_url, $post_type);
118
119 # OG and Twitter cascade into each other: an unset Twitter title falls
120 # back to the OG title, which falls back to the SEO title. Resolving in
121 # that order means each fallback reads a finished value rather than
122 # re-walking the chain underneath it.
123 $og_title = self::resolve_og_title($meta, $post, $title);
124 $og_description = self::resolve_og_description($meta, $description, $post);
125 $og_image = self::resolve_og_image($meta, $post);
126
127 $payload = new Metasync_Headless_Seo_Data(array(
128 'title' => $title,
129 'description' => $description,
130 'keywords' => self::resolve_keywords($meta),
131 'canonical' => $canonical,
132 'robots' => self::resolve_robots($meta),
133
134 'og_title' => $og_title,
135 'og_description' => $og_description,
136 'og_image' => $og_image,
137 # og:url is the same address as the canonical. Emitting the two
138 # differently is a classic way to tell a crawler two things at once.
139 'og_url' => $canonical,
140 'og_type' => self::resolve_og_type($meta, $post),
141 'og_site_name' => static::read_site_name(),
142 'og_locale' => static::read_site_locale(),
143
144 'twitter_card' => self::resolve_twitter_card($meta),
145 'twitter_title' => self::resolve_twitter_title($meta, $og_title, $post),
146 'twitter_description' => self::resolve_twitter_description($meta, $og_description, $post),
147 'twitter_image' => self::resolve_twitter_image($meta, $og_image),
148
149 'schema' => self::resolve_schema($meta),
150
151 'public_url' => $public_url,
152
153 'object_type' => 'post',
154 'object_id' => (int) $post->ID,
155 'object_sub_type' => $post_type,
156 ));
157
158 self::$payload_cache[$cache_key] = $payload;
159
160 return $payload;
161 }
162
163 /**
164 * Resolve several posts after priming their metadata cache once.
165 *
166 * Each object is isolated so one malformed post cannot discard the rest of
167 * a listing. Results retain the caller's order and use the same payloads as
168 * for_post(), including its request-scoped memoisation.
169 *
170 * @param array $ids Post IDs.
171 * @return array<int,Metasync_Headless_Seo_Data|null>
172 */
173 public static function for_posts(array $ids)
174 {
175 if (!Metasync_Headless_Config::is_enabled()) {
176 return array_fill(0, count($ids), null);
177 }
178
179 $normalised = array();
180 foreach ($ids as $id) {
181 $id = is_numeric($id) ? (int) $id : 0;
182 if ($id > 0) {
183 $normalised[] = $id;
184 } else {
185 $normalised[] = 0;
186 }
187 }
188
189 $prime_ids = array();
190 foreach (array_values(array_unique(array_filter($normalised))) as $id) {
191 if (!array_key_exists(self::payload_cache_key('post', $id), self::$payload_cache)) {
192 $prime_ids[] = $id;
193 }
194 }
195 if (!empty($prime_ids)) {
196 try {
197 static::prime_post_meta($prime_ids);
198 } catch (Throwable $e) {
199 # A cache backend failure must not turn one bad primer into a
200 # failed listing; individual resolution still has its normal
201 # fail-soft boundary below.
202 }
203 }
204
205 $payloads = array();
206 foreach ($normalised as $id) {
207 if ($id <= 0) {
208 $payloads[] = null;
209 continue;
210 }
211
212 try {
213 $payloads[] = static::for_post($id);
214 } catch (Throwable $e) {
215 $payloads[] = null;
216 }
217 }
218
219 return $payloads;
220 }
221
222 /**
223 * Build the SEO payload for one taxonomy term.
224 *
225 * Terms are the type this feature exists for. The rendered site derives a
226 * term canonical from term meta when an operator supplied one, otherwise
227 * from get_term_link(), which names the WordPress host until rehosted.
228 *
229 * Accepts an object as well as an id, matching get_term().
230 *
231 * @param int|string|WP_Term $term Term ID or object.
232 * @return Metasync_Headless_Seo_Data|null Null when there is no such term,
233 * or when it is not public.
234 */
235 public static function for_term($term)
236 {
237 # Checked before anything is looked up. for_post() resolves its argument
238 # first because it may be handed an object it can use as-is; here there
239 # is nothing to gain by doing any work while the mode is off.
240 if (!Metasync_Headless_Config::is_enabled()) {
241 return null;
242 }
243
244 $term = self::resolve_term($term);
245 if ($term === null) {
246 return null;
247 }
248
249 $cache_key = self::payload_cache_key('term', (int) $term->term_id);
250 if (array_key_exists($cache_key, self::$payload_cache)) {
251 return self::$payload_cache[$cache_key];
252 }
253
254 if (!self::is_term_publicly_viewable($term)) {
255 self::$payload_cache[$cache_key] = null;
256
257 return null;
258 }
259
260 # One term-meta read; every MetaSync and OTTO field resolves out of it.
261 $meta = self::normalize_meta(static::read_term_meta((int) $term->term_id));
262
263 # Yoast's term data is NOT in term meta. It lives in a single
264 # `wpseo_taxonomy_meta` option keyed [taxonomy][term_id][key], with the
265 # short `wpseo_` prefix rather than the `_yoast_wpseo_` prefix used for
266 # posts. Reading it as term meta finds nothing, silently — a Yoast rung
267 # that never fires. Metasync_Term_Plugin_Sync writes this same store, so
268 # the key names here are its field map read backwards.
269 $yoast = self::yoast_term_values($term);
270
271 $public_url = Metasync_Headless_Url_Builder::from_wp_url(static::read_term_link($term), '');
272
273 $title = self::resolve_term_title($meta, $yoast, $term);
274 $description = self::resolve_term_description($meta, $yoast, $term);
275 $canonical = self::resolve_term_canonical($meta, $yoast, $public_url, $term);
276
277 $og_title = self::resolve_term_og_title($meta, $yoast, $term, $title);
278 $og_description = self::resolve_term_og_description($meta, $yoast, $term, $description);
279 $og_image = self::resolve_term_og_image($meta, $yoast);
280
281 $payload = new Metasync_Headless_Seo_Data(array(
282 'title' => $title,
283 'description' => $description,
284 'keywords' => self::resolve_keywords($meta),
285 'canonical' => $canonical,
286 'robots' => self::resolve_term_robots($meta, $yoast),
287
288 'og_title' => $og_title,
289 'og_description' => $og_description,
290 'og_image' => $og_image,
291 'og_url' => $canonical,
292 'og_type' => self::resolve_section_og_type($meta, 'website'),
293 'og_site_name' => static::read_site_name(),
294 'og_locale' => static::read_site_locale(),
295
296 'twitter_card' => self::resolve_twitter_card($meta),
297 'twitter_title' => self::resolve_term_twitter_title($meta, $yoast, $term, $og_title),
298 'twitter_description' => self::resolve_term_twitter_description($meta, $yoast, $term, $og_description),
299 'twitter_image' => self::resolve_term_twitter_image($meta, $yoast, $og_image),
300
301 'schema' => self::resolve_schema($meta),
302
303 'public_url' => $public_url,
304
305 'object_type' => 'term',
306 'object_id' => (int) $term->term_id,
307 'object_sub_type' => (string) $term->taxonomy,
308 ));
309
310 self::$payload_cache[$cache_key] = $payload;
311
312 return $payload;
313 }
314
315 /**
316 * Build the SEO payload for the site front page.
317 *
318 * Two different objects wear this name, and WordPress decides which by
319 * `show_on_front`:
320 *
321 * - `page` with a page assigned — the front page is a real post, so it has
322 * the whole post chain: sidebar override, OTTO, Yoast post meta. Only its
323 * address differs, because the frontend serves it at the root rather than
324 * at the page's own path.
325 * - `posts` — there is no object at all. Nothing in this plugin, in OTTO or
326 * in Yoast stores per-object SEO for it, so the payload is built from the
327 * site's own name and tagline.
328 *
329 * @return Metasync_Headless_Seo_Data|null
330 */
331 public static function for_home_page()
332 {
333 if (!Metasync_Headless_Config::is_enabled()) {
334 return null;
335 }
336
337 $front = self::front_page_settings();
338
339 # Never get_permalink() and never home_url(): the front page's address on
340 # the public site is the frontend root, with the configured slash policy
341 # applied. No post type is passed, so a `page` path prefix cannot push
342 # the homepage down to /pages/ — the root is the root.
343 $public_url = Metasync_Headless_Url_Builder::home();
344
345 if ($front['front_page'] > 0) {
346 $page = static::read_post($front['front_page']);
347 if (!($page instanceof WP_Post)) {
348 return null;
349 }
350
351 # An operator who assigned a draft or protected page as the front
352 # page has a broken homepage either way; building a payload out of
353 # its meta would publish the draft's title and description. Refuse
354 # for the same reason for_post() refuses.
355 if (!self::is_publicly_viewable($page)) {
356 return null;
357 }
358
359 return self::payload_for_page($page, 'home', $public_url);
360 }
361
362 return self::payload_for_site($public_url, 'home');
363 }
364
365 /**
366 * Build the SEO payload for the blog index when a static page holds it.
367 *
368 * Deliberately narrow: a posts page exists only when a static page is on the
369 * front and a *different* page is assigned to hold the posts. With
370 * `show_on_front = posts` the blog index and the front page are the same
371 * address, and for_home_page() already describes it — returning a second
372 * payload for it would give one URL two object types.
373 *
374 * The assigned page is a real post, so the full post chain applies. That
375 * also matches what the rendered site already does: its archive canonical
376 * honours `_metasync_canonical_url` on this page's post meta.
377 *
378 * @return Metasync_Headless_Seo_Data|null Null when there is no posts page.
379 */
380 public static function for_posts_page()
381 {
382 if (!Metasync_Headless_Config::is_enabled()) {
383 return null;
384 }
385
386 $front = self::front_page_settings();
387
388 if ($front['front_page'] <= 0 || $front['posts_page'] <= 0) {
389 return null;
390 }
391
392 # A site with both settings pointing at one page has no posts page —
393 # WordPress serves that page as the front page. Answering here as well
394 # would hand the same URL out under two object types.
395 if ($front['posts_page'] === $front['front_page']) {
396 return null;
397 }
398
399 $page = static::read_post($front['posts_page']);
400 if (!($page instanceof WP_Post) || !self::is_publicly_viewable($page)) {
401 return null;
402 }
403
404 # The page's own path, re-hosted. No post type passed: the blog index is
405 # a route of the frontend's own, not a single page served under a
406 # per-post-type prefix.
407 $public_url = Metasync_Headless_Url_Builder::from_wp_url(static::read_permalink($page), '');
408
409 return self::payload_for_page($page, 'posts_page', $public_url);
410 }
411
412 /**
413 * Build the SEO payload for whatever WordPress serves as the blog index.
414 *
415 * This exists because a caller cannot tell, from a null, *why* there is no
416 * posts page. for_posts_page() returns null for several different reasons —
417 * the site shows posts on the front, no page is assigned, the assigned page
418 * is also the front page, or the assigned page exists but is a draft and so
419 * is refused. The first three mean "the front page is the blog index"; the
420 * last does not. A caller that treats them alike answers a request about the
421 * blog index with the front page's title and canonical, which is a payload
422 * for a different object.
423 *
424 * So the decision lives here, where the settings are already read, rather
425 * than in a transport that would have to guess from a null.
426 *
427 * @return Metasync_Headless_Seo_Data|null
428 */
429 public static function for_blog_index()
430 {
431 if (!Metasync_Headless_Config::is_enabled()) {
432 return null;
433 }
434
435 return self::blog_index_is_front_page()
436 ? self::for_home_page()
437 : self::for_posts_page();
438 }
439
440 /**
441 * Does the front page double as the blog index?
442 *
443 * True when the site shows posts on the front, when no separate page is
444 * assigned to hold the index, or when the same page is assigned to both.
445 * False when a distinct page is assigned — whether or not that page turns
446 * out to be publicly viewable, which is the distinction that matters: a
447 * draft posts page means the blog index has no public payload, not that the
448 * front page is standing in for it.
449 *
450 * @return bool
451 */
452 private static function blog_index_is_front_page()
453 {
454 $front = self::front_page_settings();
455
456 if ($front['posts_page'] <= 0) {
457 return true;
458 }
459
460 return $front['posts_page'] === $front['front_page'];
461 }
462 /**
463 * Build the SEO payload for a custom post type's archive.
464 *
465 * The thinnest of the entry points, and honestly so: nothing in this plugin,
466 * in OTTO, or in Yoast's per-object storage holds SEO data for a post-type
467 * archive. There is no id to hang meta off. So the payload is derived from
468 * the registered post type — its plural label and its description — and the
469 * value this entry point actually adds is the URL, which the rendered site
470 * does not emit for a post-type archive at all.
471 *
472 * @param mixed $post_type Post type name.
473 * @return Metasync_Headless_Seo_Data|null
474 */
475 public static function for_post_type_archive($post_type)
476 {
477 if (!Metasync_Headless_Config::is_enabled()) {
478 return null;
479 }
480
481 $post_type = is_string($post_type) ? trim($post_type) : '';
482 if ($post_type === '') {
483 return null;
484 }
485
486 # `post` is excluded on purpose. It has no archive of its own — its
487 # listing is the blog index — and get_post_type_archive_link('post')
488 # special-cases it to return the homepage or the posts page. Without this
489 # guard an install that had filtered has_archive onto `post` would get a
490 # homepage URL back labelled as a post-type archive.
491 if ($post_type === 'post') {
492 return null;
493 }
494
495 $type = static::read_post_type_object($post_type);
496 if (!is_object($type)) {
497 return null;
498 }
499
500 if (!self::is_post_type_archive_publicly_viewable($type)) {
501 return null;
502 }
503
504 $public_url = Metasync_Headless_Url_Builder::from_wp_url(
505 static::read_post_type_archive_link($post_type),
506 ''
507 );
508
509 $title = self::post_type_archive_title($type, $post_type);
510 # Deliberately NOT the registered post type description. That field is
511 # WordPress's own admin-facing summary of what the post type is for —
512 # it is shown in wp-admin and rendered nowhere on the public archive.
513 # Publishing it as the meta description would break this payload's own
514 # rule that everything in it is already visible to a visitor, and on an
515 # endpoint with no auth gate an internal note such as "internal records,
516 # do not expose" would become the page's description. An archive has no
517 # per-object description storage anywhere, so the honest answer is none.
518 $description = '';
519
520 return new Metasync_Headless_Seo_Data(array(
521 'title' => $title,
522 'description' => $description,
523 'keywords' => '',
524 'canonical' => $public_url,
525 # No robots claim. A term delegates to the shared resolver because
526 # `_metasync_robots_index` is real term storage; nothing anywhere
527 # stores robots for a post-type archive, so asserting a serving
528 # default over an object we know nothing about would be inventing a
529 # directive rather than reporting one.
530 'robots' => '',
531
532 'og_title' => $title,
533 'og_description' => $description,
534 'og_image' => '',
535 'og_url' => $public_url,
536 'og_type' => 'website',
537 'og_site_name' => static::read_site_name(),
538 'og_locale' => static::read_site_locale(),
539
540 'twitter_card' => self::resolve_twitter_card(array()),
541 'twitter_title' => $title,
542 'twitter_description' => $description,
543 'twitter_image' => '',
544
545 'schema' => array(),
546
547 'public_url' => $public_url,
548
549 'object_type' => 'post_type_archive',
550 'object_id' => 0,
551 'object_sub_type' => $post_type,
552 ));
553 }
554
555 /**
556 * Build the SEO payload for an author archive.
557 *
558 * The one type where Yoast is the only third-party rung and it lives in user
559 * meta — `wpseo_title` and `wpseo_metadesc`, again without the
560 * `_yoast_wpseo_` prefix the post keys carry. This plugin stores no author
561 * SEO of its own and OTTO does not suggest any, so the chain is genuinely
562 * Yoast then WordPress: the display name and the biography.
563 *
564 * @param int|string|WP_User $user User ID or object.
565 * @return Metasync_Headless_Seo_Data|null
566 */
567 public static function for_author($user)
568 {
569 if (!Metasync_Headless_Config::is_enabled()) {
570 return null;
571 }
572
573 $user = self::resolve_user($user);
574 if ($user === null) {
575 return null;
576 }
577
578 $cache_key = self::payload_cache_key('author', (int) $user->ID);
579 if (array_key_exists($cache_key, self::$payload_cache)) {
580 return self::$payload_cache[$cache_key];
581 }
582
583 if (!self::is_author_publicly_viewable($user)) {
584 self::$payload_cache[$cache_key] = null;
585
586 return null;
587 }
588
589 $meta = self::normalize_meta(static::read_user_meta((int) $user->ID));
590
591 $public_url = Metasync_Headless_Url_Builder::from_wp_url(
592 static::read_author_posts_url((int) $user->ID),
593 ''
594 );
595
596 $title = self::yoast_value($meta, 'wpseo_title', $user);
597 if ($title === '') {
598 $title = self::author_display_name($user);
599 }
600
601 $description = self::yoast_value($meta, 'wpseo_metadesc', $user);
602 if ($description === '') {
603 # The biography, which the plugin's own Person schema node already
604 # publishes for this same archive.
605 $description = self::plain_text_summary(self::first($meta, array('description')));
606 }
607
608 $payload = new Metasync_Headless_Seo_Data(array(
609 'title' => $title,
610 'description' => $description,
611 'keywords' => '',
612 'canonical' => $public_url,
613 # As for a post-type archive: no author robots storage exists, so the
614 # payload makes no claim rather than inventing one.
615 'robots' => '',
616
617 'og_title' => $title,
618 'og_description' => $description,
619 'og_image' => '',
620 'og_url' => $public_url,
621 # An author archive describes a person, which is what og:type
622 # profile is for.
623 'og_type' => 'profile',
624 'og_site_name' => static::read_site_name(),
625 'og_locale' => static::read_site_locale(),
626
627 'twitter_card' => self::resolve_twitter_card(array()),
628 'twitter_title' => $title,
629 'twitter_description' => $description,
630 'twitter_image' => '',
631
632 'schema' => array(),
633
634 'public_url' => $public_url,
635
636 'object_type' => 'author',
637 'object_id' => (int) $user->ID,
638 'object_sub_type' => '',
639 ));
640
641 self::$payload_cache[$cache_key] = $payload;
642
643 return $payload;
644 }
645
646 /* -----------------------------------------------------------------
647 * Payload builders shared by the entry points above
648 * ----------------------------------------------------------------- */
649
650 /**
651 * The payload for an entry point whose object really is a page.
652 *
653 * The front page and the posts page are pages: they have post meta, so the
654 * sidebar override, OTTO and Yoast post chains all apply to them exactly as
655 * they do to any other page, and restating those chains here would be a
656 * second implementation that could drift. What is genuinely different is the
657 * address — the frontend serves these at the root and at the blog index,
658 * not at the page's own prefixed path — and what the payload says it is. So
659 * only those are passed in.
660 *
661 * This is not the post path with a flag on it: the caller has already
662 * decided which object it is describing, established that it exists and is
663 * public, and built the URL. This assembles the fields for it.
664 *
665 * @param WP_Post $page The assigned page.
666 * @param string $object_type What the payload describes: home or posts_page.
667 * @param string $public_url Already-built frontend URL.
668 * @return Metasync_Headless_Seo_Data
669 */
670 private static function payload_for_page($page, $object_type, $public_url)
671 {
672 $meta = self::normalize_meta(static::read_post_meta($page->ID));
673
674 $title = self::resolve_title($meta, $page);
675 $description = self::resolve_description($meta, $page);
676
677 # No post type passed to the canonical resolver either: a Yoast canonical
678 # re-hosted for the front page must land on the root, not under a `page`
679 # path prefix.
680 $canonical = self::resolve_canonical($meta, $public_url, '');
681
682 $og_title = self::resolve_og_title($meta, $page, $title);
683 $og_description = self::resolve_og_description($meta, $description, $page);
684 $og_image = self::resolve_og_image($meta, $page);
685
686 return new Metasync_Headless_Seo_Data(array(
687 'title' => $title,
688 'description' => $description,
689 'keywords' => self::resolve_keywords($meta),
690 'canonical' => $canonical,
691 'robots' => self::resolve_robots($meta),
692
693 'og_title' => $og_title,
694 'og_description' => $og_description,
695 'og_image' => $og_image,
696 'og_url' => $canonical,
697 # Always website, never article: the front page and the blog index
698 # are site sections whatever post type happens to be behind them.
699 'og_type' => self::resolve_section_og_type($meta, 'website'),
700 'og_site_name' => static::read_site_name(),
701 'og_locale' => static::read_site_locale(),
702
703 'twitter_card' => self::resolve_twitter_card($meta),
704 'twitter_title' => self::resolve_twitter_title($meta, $og_title, $page),
705 'twitter_description' => self::resolve_twitter_description($meta, $og_description, $page),
706 'twitter_image' => self::resolve_twitter_image($meta, $og_image),
707
708 'schema' => self::resolve_schema($meta),
709
710 'public_url' => $public_url,
711
712 'object_type' => (string) $object_type,
713 'object_id' => (int) $page->ID,
714 'object_sub_type' => (string) $page->post_type,
715 ));
716 }
717
718 /**
719 * The payload for the front page when no page is assigned to it.
720 *
721 * There is no object, so there is no storage: not a MetaSync key, not an
722 * OTTO suggestion, not a Yoast per-object value. Everything comes from the
723 * site itself, which is also what the rendered site falls back to.
724 *
725 * @param string $public_url Already-built frontend URL.
726 * @param string $object_type What the payload describes.
727 * @return Metasync_Headless_Seo_Data
728 */
729 private static function payload_for_site($public_url, $object_type)
730 {
731 $title = static::read_site_name();
732 $description = static::read_site_tagline();
733
734 return new Metasync_Headless_Seo_Data(array(
735 'title' => $title,
736 'description' => $description,
737 'keywords' => '',
738 'canonical' => $public_url,
739 'robots' => '',
740
741 'og_title' => $title,
742 'og_description' => $description,
743 'og_image' => '',
744 'og_url' => $public_url,
745 'og_type' => 'website',
746 'og_site_name' => $title,
747 'og_locale' => static::read_site_locale(),
748
749 'twitter_card' => self::resolve_twitter_card(array()),
750 'twitter_title' => $title,
751 'twitter_description' => $description,
752 'twitter_image' => '',
753
754 'schema' => array(),
755
756 'public_url' => $public_url,
757
758 'object_type' => (string) $object_type,
759 'object_id' => 0,
760 'object_sub_type' => '',
761 ));
762 }
763
764 /* -----------------------------------------------------------------
765 * Read seams
766 *
767 * Every place this class touches WordPress storage, kept separate so
768 * the bulk resolver can pre-fetch for many objects in one query and
769 * hand the results down rather than having each object repeat a
770 * lookup. Called through static:: so a subclass can substitute them.
771 *
772 * They also keep the tests off the ambient WordPress stubs. Around 47
773 * test files in this suite each declare their own get_post_meta,
774 * get_term_meta, get_term_link, get_author_posts_url and home_url
775 * behind function_exists() guards, so whichever loads first wins for
776 * the whole process. A resolver that called those directly would pass
777 * alone and fail in a full run, on load order alone.
778 * ----------------------------------------------------------------- */
779
780 /**
781 * All post meta for one post, in get_post_meta($id) shape.
782 *
783 * @param int $post_id
784 * @return array
785 */
786 protected static function read_post_meta($post_id)
787 {
788 return get_post_meta($post_id);
789 }
790
791 /**
792 * Prime all post metadata needed by a bulk resolution.
793 *
794 * @param int[] $post_ids
795 * @return void
796 */
797 protected static function prime_post_meta(array $post_ids)
798 {
799 if (function_exists('update_meta_cache')) {
800 update_meta_cache('post', $post_ids);
801 }
802 }
803
804 /**
805 * One post by id.
806 *
807 * @param int $post_id
808 * @return mixed WP_Post, or anything else when there is no such post.
809 */
810 protected static function read_post($post_id)
811 {
812 return get_post($post_id);
813 }
814
815 /**
816 * One term by id.
817 *
818 * Returned as WordPress gives it: a WP_Term, null for an id that does not
819 * exist, or a WP_Error when the taxonomy is not registered. The caller
820 * decides, so neither outcome is flattened away here.
821 *
822 * @param int $term_id
823 * @return mixed
824 */
825 protected static function read_term($term_id)
826 {
827 return get_term($term_id);
828 }
829
830 /**
831 * All term meta for one term, in get_term_meta($id) shape.
832 *
833 * @param int $term_id
834 * @return array
835 */
836 protected static function read_term_meta($term_id)
837 {
838 return get_term_meta($term_id);
839 }
840
841 /**
842 * The WordPress archive link for one term.
843 *
844 * Returned as WordPress gives it, including the WP_Error core produces for a
845 * term it cannot build a link for. The URL builder already treats anything
846 * that is not a usable string as "no URL", so re-checking here would only
847 * duplicate that.
848 *
849 * @param WP_Term $term
850 * @return mixed
851 */
852 protected static function read_term_link($term)
853 {
854 return get_term_link($term);
855 }
856
857 /**
858 * Yoast's whole taxonomy-meta option.
859 *
860 * One option row for every term on the site, which is why it is read behind
861 * a seam and only once per payload: a bulk resolver reads it once for a
862 * hundred terms rather than a hundred times.
863 *
864 * @return mixed
865 */
866 protected static function read_yoast_taxonomy_meta()
867 {
868 # Memoised for the request. This option holds every term's Yoast data
869 # in one row, so a query resolving many terms would otherwise walk the
870 # same structure once per term.
871 if (self::$yoast_taxonomy_meta !== null) {
872 return self::$yoast_taxonomy_meta;
873 }
874
875 self::$yoast_taxonomy_meta = self::read_yoast_taxonomy_meta_uncached();
876
877 return self::$yoast_taxonomy_meta;
878 }
879
880 /**
881 * Drop the request-scoped memo of Yoast's taxonomy option.
882 *
883 * @return void
884 */
885 public static function flush_request_cache()
886 {
887 self::$yoast_taxonomy_meta = null;
888 self::$payload_cache = array();
889 }
890
891 /**
892 * Build a collision-proof request-cache key.
893 *
894 * @param string $object_type
895 * @param int $object_id
896 * @return string
897 */
898 private static function payload_cache_key($object_type, $object_id)
899 {
900 return (string) $object_type . ':' . (int) $object_id;
901 }
902
903 /**
904 * The uncached read, so the memo above is its only caller while a
905 * subclass can still substitute the storage.
906 *
907 * @return array
908 */
909 protected static function read_yoast_taxonomy_meta_uncached()
910 {
911 return get_option('wpseo_taxonomy_meta');
912 }
913
914 /**
915 * The three options that decide what the front page and blog index are.
916 *
917 * @return mixed Array of the three options; a subclass may substitute it.
918 */
919 protected static function read_front_page_options()
920 {
921 return array(
922 'show_on_front' => get_option('show_on_front'),
923 'page_on_front' => get_option('page_on_front'),
924 'page_for_posts' => get_option('page_for_posts'),
925 );
926 }
927
928 /**
929 * The registered post type object, or null.
930 *
931 * @param string $post_type
932 * @return mixed
933 */
934 protected static function read_post_type_object($post_type)
935 {
936 return get_post_type_object($post_type);
937 }
938
939 /**
940 * The WordPress archive link for a post type.
941 *
942 * @param string $post_type
943 * @return mixed
944 */
945 protected static function read_post_type_archive_link($post_type)
946 {
947 return get_post_type_archive_link($post_type);
948 }
949
950 /**
951 * One user by id.
952 *
953 * @param int $user_id
954 * @return mixed WP_User, or false when there is no such user.
955 */
956 protected static function read_user($user_id)
957 {
958 return get_userdata($user_id);
959 }
960
961 /**
962 * All user meta for one user, in get_user_meta($id) shape.
963 *
964 * @param int $user_id
965 * @return array
966 */
967 protected static function read_user_meta($user_id)
968 {
969 return get_user_meta($user_id);
970 }
971
972 /**
973 * The WordPress author archive URL for one user.
974 *
975 * @param int $user_id
976 * @return mixed
977 */
978 protected static function read_author_posts_url($user_id)
979 {
980 return get_author_posts_url($user_id);
981 }
982
983 /**
984 * How many published entries this author has, across every public post type.
985 *
986 * Counted as a superset rather than just `post`: a headless frontend defines
987 * its own author route and may well list a custom post type there, so
988 * counting only `post` would withhold a payload for a page the frontend is
989 * really serving. Attachments are left out because a media item is not
990 * authored content in this sense.
991 *
992 * @param int $user_id
993 * @return int
994 */
995 protected static function read_author_published_count($user_id)
996 {
997 if (!function_exists('count_user_posts')) {
998 # Nothing to count with. Treated as "has content" rather than "has
999 # none", so an unexpected environment withholds nothing.
1000 return 1;
1001 }
1002
1003 $types = array('post');
1004
1005 if (function_exists('get_post_types')) {
1006 $public = get_post_types(array('public' => true), 'names');
1007 if ($public !== array()) {
1008 unset($public['attachment']);
1009 $types = array_values($public);
1010 }
1011 }
1012
1013 if ($types === array()) {
1014 return 0;
1015 }
1016
1017 return (int) count_user_posts($user_id, $types, true);
1018 }
1019
1020 /**
1021 * The site's name, for og:site_name.
1022 *
1023 * @return string
1024 */
1025 protected static function read_site_name()
1026 {
1027 return (string) get_bloginfo('name');
1028 }
1029
1030 /**
1031 * The site's tagline, used as the homepage description when no page holds it.
1032 *
1033 * @return string
1034 */
1035 protected static function read_site_tagline()
1036 {
1037 return (string) get_bloginfo('description');
1038 }
1039
1040 /**
1041 * The site's locale, for og:locale.
1042 *
1043 * @return string
1044 */
1045 protected static function read_site_locale()
1046 {
1047 return (string) get_locale();
1048 }
1049
1050 /**
1051 * Is this taxonomy one the public site serves archives for?
1052 *
1053 * @param string $taxonomy
1054 * @return bool
1055 */
1056 protected static function read_taxonomy_is_viewable($taxonomy)
1057 {
1058 if (function_exists('is_taxonomy_viewable')) {
1059 return (bool) is_taxonomy_viewable($taxonomy);
1060 }
1061
1062 # WordPress < 5.1. Mirrors what core's helper does.
1063 $object = function_exists('get_taxonomy') ? get_taxonomy($taxonomy) : false;
1064 if (!is_object($object)) {
1065 return false;
1066 }
1067
1068 return !empty($object->publicly_queryable) || !empty($object->public);
1069 }
1070
1071
1072 /**
1073 * The WordPress permalink for one post.
1074 *
1075 * Returned as WordPress gives it, including the `false` core produces for a
1076 * post it cannot build a link for. The URL builder already treats anything
1077 * that is not a usable string as "no URL", so re-checking here would only
1078 * duplicate that.
1079 *
1080 * @param WP_Post $post
1081 * @return mixed
1082 */
1083 protected static function read_permalink($post)
1084 {
1085 return get_permalink($post);
1086 }
1087
1088 /**
1089 * The featured image URL for one post, or '' when there is none.
1090 *
1091 * wp_get_attachment_url() is the call media-offload plugins filter, so this
1092 * resolves correctly when the file lives in remote object storage rather
1093 * than on local disk. Deliberately no disk access and no HTTP validation: a
1094 * missing local file is not evidence of a missing attachment.
1095 *
1096 * @param WP_Post $post
1097 * @return string
1098 */
1099 protected static function read_featured_image_url($post)
1100 {
1101 $thumbnail_id = get_post_thumbnail_id($post);
1102 if (!$thumbnail_id) {
1103 return '';
1104 }
1105
1106 $url = wp_get_attachment_url($thumbnail_id);
1107
1108 return is_string($url) ? $url : '';
1109 }
1110
1111 /* -----------------------------------------------------------------
1112 * Field resolution
1113 * ----------------------------------------------------------------- */
1114
1115 /**
1116 * Accept an id or an object and return the post, or null.
1117 *
1118 * @param int|string|WP_Post $post
1119 * @return WP_Post|null
1120 */
1121 private static function resolve_post($post)
1122 {
1123 if ($post instanceof WP_Post) {
1124 return $post;
1125 }
1126
1127 $post_id = is_numeric($post) ? (int) $post : 0;
1128 if ($post_id <= 0) {
1129 return null;
1130 }
1131
1132 $found = static::read_post($post_id);
1133
1134 return $found instanceof WP_Post ? $found : null;
1135 }
1136
1137 /**
1138 * May this object's SEO data be handed to an anonymous caller?
1139 *
1140 * The delivery layers in front of this are public: a headless GraphQL
1141 * endpoint frequently has no auth gate at all. So the check belongs here,
1142 * where every entry point shares it, rather than in each transport.
1143 *
1144 * Refusing outright — rather than returning a payload with the private parts
1145 * blanked — is deliberate. The description falls back to post content, the
1146 * schema graph embeds the title, and robots settings describe an unpublished
1147 * editorial decision; a partial payload for a draft still leaks the draft.
1148 *
1149 * Password-protected posts are refused for the same reason: the password
1150 * gates the content, and the description fallback is built from that content.
1151 *
1152 * @param WP_Post $post
1153 * @return bool
1154 */
1155 private static function is_publicly_viewable($post)
1156 {
1157 # The content behind a password must not leak through a description
1158 # derived from it.
1159 if ((string) $post->post_password !== '') {
1160 return false;
1161 }
1162
1163 # An attachment carries its own (almost always empty) password field, so
1164 # the check above says nothing about the post it belongs to. Core's
1165 # is_attachment_publicly_viewable() consults the parent's *status* but
1166 # not its password, which would leave a media item attached to a
1167 # password-protected post publicly resolvable. Close that here.
1168 if ((string) $post->post_type === 'attachment') {
1169 $parent_id = (int) $post->post_parent;
1170 if ($parent_id > 0) {
1171 $parent = static::read_post($parent_id);
1172 if (!($parent instanceof WP_Post) || !self::is_publicly_viewable($parent)) {
1173 return false;
1174 }
1175 }
1176 }
1177
1178 if (function_exists('is_post_publicly_viewable')) {
1179 return (bool) is_post_publicly_viewable($post);
1180 }
1181
1182 # WordPress < 5.7. Mirrors what core's helper does: the status must be
1183 # publicly viewable and the post type must be viewable at all.
1184 $status = get_post_status_object((string) $post->post_status);
1185 $type = get_post_type_object((string) $post->post_type);
1186
1187 if (!is_object($status) || !is_object($type)) {
1188 return false;
1189 }
1190
1191 return !empty($status->public) && !empty($type->public);
1192 }
1193
1194 /**
1195 * Accept an id or an object and return the term, or null.
1196 *
1197 * get_term() has three failure shapes and they mean different things: null
1198 * for an id that is not there, a WP_Error when the taxonomy is not
1199 * registered, and a plain object on some filtered installs. All three become
1200 * null, because none of them is a term this can describe.
1201 *
1202 * @param int|string|WP_Term $term
1203 * @return WP_Term|null
1204 */
1205 private static function resolve_term($term)
1206 {
1207 if (!($term instanceof WP_Term)) {
1208 $term_id = is_numeric($term) ? (int) $term : 0;
1209 if ($term_id <= 0) {
1210 return null;
1211 }
1212
1213 $term = static::read_term($term_id);
1214 }
1215
1216 if (!($term instanceof WP_Term)) {
1217 return null;
1218 }
1219
1220 # A term object with no taxonomy cannot be placed in the registry, so
1221 # neither its visibility nor its link can be established.
1222 if (self::text($term->taxonomy) === '') {
1223 return null;
1224 }
1225
1226 return $term;
1227 }
1228
1229 /**
1230 * Accept an id or an object and return the user, or null.
1231 *
1232 * @param int|string|WP_User $user
1233 * @return WP_User|null
1234 */
1235 private static function resolve_user($user)
1236 {
1237 if (!($user instanceof WP_User)) {
1238 $user_id = is_numeric($user) ? (int) $user : 0;
1239 if ($user_id <= 0) {
1240 return null;
1241 }
1242
1243 $user = static::read_user($user_id);
1244 }
1245
1246 if (!($user instanceof WP_User) || (int) $user->ID <= 0) {
1247 return null;
1248 }
1249
1250 return $user;
1251 }
1252
1253 /**
1254 * May this term's SEO data be handed to an anonymous caller?
1255 *
1256 * Two conditions, and one deliberate omission.
1257 *
1258 * The taxonomy must still be registered, and it must be one the public site
1259 * serves archives for. A term in an unregistered taxonomy has no route at
1260 * all — get_term_link() falls back to a `?taxonomy=…&term=…` query URL,
1261 * which the URL builder refuses — and a private taxonomy (an internal
1262 * `nav_menu` or `wp_theme` term, say) is not a page anybody can visit. Both
1263 * would otherwise produce a payload for an address that does not exist.
1264 *
1265 * A term with no posts is deliberately NOT refused. Its name and description
1266 * are already public — they appear in term lists, widgets and sitemaps — and
1267 * `count` is a denormalised cache that goes stale routinely: it is not
1268 * updated while term counting is deferred, and it does not count every
1269 * object type attached to the taxonomy. Refusing on a stale zero would blank
1270 * the SEO data of a page the frontend is really serving, and the payload
1271 * would flicker in and out as counts drift. An empty archive is a robots
1272 * question, not a "withhold everything" question.
1273 *
1274 * @param WP_Term $term
1275 * @return bool
1276 */
1277 private static function is_term_publicly_viewable($term)
1278 {
1279 return static::read_taxonomy_is_viewable((string) $term->taxonomy);
1280 }
1281
1282 /**
1283 * May this post type's archive be described publicly?
1284 *
1285 * The post type has to be viewable at all, and it has to actually have an
1286 * archive. `has_archive` false means WordPress registers no archive route,
1287 * so there is no page to describe — and get_post_type_archive_link() returns
1288 * false for it, which would leave a payload with no URL.
1289 *
1290 * @param object $type Post type object.
1291 * @return bool
1292 */
1293 private static function is_post_type_archive_publicly_viewable($type)
1294 {
1295 if (empty($type->has_archive)) {
1296 return false;
1297 }
1298
1299 if (function_exists('is_post_type_viewable')) {
1300 return (bool) is_post_type_viewable($type);
1301 }
1302
1303 # WordPress < 4.4. Mirrors what core's helper does.
1304 return !empty($type->publicly_queryable) || !empty($type->public);
1305 }
1306
1307 /**
1308 * May this author's archive be described publicly?
1309 *
1310 * Refused when the author has published nothing. Unlike a term, an author
1311 * archive's only content is an account — and the slug in its URL is
1312 * routinely the login name. An author with nothing published is very often an
1313 * administrator or a subscriber who should never have had a public URL at
1314 * all, so on an endpoint with no auth gate, listing them would hand out
1315 * account names that appear nowhere else on the site. There is also nothing
1316 * to describe: no posts, so no archive worth a title.
1317 *
1318 * The asymmetry with terms is deliberate. A term is authored content that
1319 * someone created on purpose and that the site already publishes, and its
1320 * count is a stale cache; an author's published count is queried live, so a
1321 * zero here is a fact rather than a drifting cache value.
1322 *
1323 * @param WP_User $user
1324 * @return bool
1325 */
1326 private static function is_author_publicly_viewable($user)
1327 {
1328 return static::read_author_published_count((int) $user->ID) > 0;
1329 }
1330
1331 /**
1332 * What the front page and the blog index are, as ids.
1333 *
1334 * `page_on_front` and `page_for_posts` are both ignored by WordPress unless
1335 * `show_on_front` is `page`, so they are zeroed here rather than left for
1336 * each caller to remember. That is what makes "no static front page" and
1337 * "blog index at the root" one condition instead of two.
1338 *
1339 * @return array{front_page:int,posts_page:int,shows_posts_on_front:bool}
1340 */
1341 private static function front_page_settings()
1342 {
1343 $options = static::read_front_page_options();
1344 if (!is_array($options)) {
1345 $options = array();
1346 }
1347
1348 $show = isset($options['show_on_front']) ? $options['show_on_front'] : '';
1349 $show = is_string($show) ? strtolower(trim($show)) : '';
1350
1351 if ($show !== 'page') {
1352 return array(
1353 'front_page' => 0,
1354 'posts_page' => 0,
1355 'shows_posts_on_front' => true,
1356 );
1357 }
1358
1359 $front = isset($options['page_on_front']) ? $options['page_on_front'] : 0;
1360 $posts = isset($options['page_for_posts']) ? $options['page_for_posts'] : 0;
1361
1362 return array(
1363 'front_page' => is_numeric($front) ? max(0, (int) $front) : 0,
1364 'posts_page' => is_numeric($posts) ? max(0, (int) $posts) : 0,
1365 'shows_posts_on_front' => false,
1366 );
1367 }
1368
1369 /* -----------------------------------------------------------------
1370 * Term field resolution
1371 *
1372 * The same chains as the post fields, over the two stores a term
1373 * actually uses: MetaSync and OTTO values in term meta, Yoast values
1374 * in its `wpseo_taxonomy_meta` option entry. Kept as their own
1375 * resolvers rather than parameterising the post ones, because each
1376 * reads exactly one store and the key names genuinely differ — a
1377 * shared resolver taking "which store, which key" would read as
1378 * configuration rather than as the chain it is.
1379 *
1380 * There is no `_metasync_seo_*` rung: those two keys are the post
1381 * sidebar's, and nothing writes them to term meta. `_metasync_metatitle`
1382 * is the top rung for a term, which is what the MCP taxonomy tool and
1383 * the third-party importer both write.
1384 * ----------------------------------------------------------------- */
1385
1386 /**
1387 * This term's Yoast entry, flattened to key => value.
1388 *
1389 * Yoast strips its own defaults before saving, so a term that has never been
1390 * edited has no entry at all and a term that has been edited carries only the
1391 * fields that differ. Both come back as an array the chains can read.
1392 *
1393 * @param WP_Term $term
1394 * @return array
1395 */
1396 private static function yoast_term_values($term)
1397 {
1398 $option = static::read_yoast_taxonomy_meta();
1399 if (!is_array($option)) {
1400 return array();
1401 }
1402
1403 $taxonomy = (string) $term->taxonomy;
1404 if (!isset($option[$taxonomy]) || !is_array($option[$taxonomy])) {
1405 return array();
1406 }
1407
1408 $term_id = (int) $term->term_id;
1409 if (!isset($option[$taxonomy][$term_id]) || !is_array($option[$taxonomy][$term_id])) {
1410 return array();
1411 }
1412
1413 return $option[$taxonomy][$term_id];
1414 }
1415
1416 /**
1417 * Term SEO title: override -> OTTO -> imported -> Yoast -> the term name.
1418 *
1419 * @param array $meta Raw term meta.
1420 * @param array $yoast This term's Yoast entry.
1421 * @param WP_Term $term
1422 * @return string
1423 */
1424 private static function resolve_term_title($meta, $yoast, $term)
1425 {
1426 $resolved = self::first($meta, array(
1427 '_metasync_metatitle',
1428 '_metasync_otto_title',
1429 '_metasync_imported_seo_title',
1430 ));
1431
1432 if ($resolved === '') {
1433 $resolved = self::yoast_value($yoast, 'wpseo_title', $term);
1434 }
1435
1436 return $resolved !== '' ? $resolved : self::text($term->name);
1437 }
1438
1439 /**
1440 * Term meta description: override -> OTTO -> imported -> Yoast -> the term description.
1441 *
1442 * The term description is editorial rich text on many sites, so it gets the
1443 * same shortcode-stripping, tag-stripping, length-limited treatment a post's
1444 * content fallback gets rather than being emitted as stored.
1445 *
1446 * @param array $meta Raw term meta.
1447 * @param array $yoast This term's Yoast entry.
1448 * @param WP_Term $term
1449 * @return string
1450 */
1451 private static function resolve_term_description($meta, $yoast, $term)
1452 {
1453 $resolved = self::first($meta, array(
1454 '_metasync_metadesc',
1455 '_metasync_otto_description',
1456 '_metasync_imported_seo_desc',
1457 ));
1458
1459 if ($resolved === '') {
1460 $resolved = self::yoast_value($yoast, 'wpseo_desc', $term);
1461 }
1462
1463 if ($resolved !== '') {
1464 return $resolved;
1465 }
1466
1467 return self::plain_text_summary($term->description);
1468 }
1469
1470 /**
1471 * Term canonical: override -> Yoast -> the public URL.
1472 *
1473 * `_metasync_canonical_url` in term meta is not invented here — the MCP
1474 * taxonomy tool and the third-party importer both write it, and
1475 * Metasync_Term_Plugin_Sync mirrors it outward into Yoast, Rank Math and
1476 * AIOSEO. Nothing in the plugin has ever read it back for a term, so this is
1477 * the first path that honours it; the rendered site still derives a term
1478 * canonical from get_term_link() and overwrites the operator's intent.
1479 *
1480 * Same two rules as the post canonical: an override is taken verbatim
1481 * because an absolute URL an operator typed is a deliberate statement, and a
1482 * Yoast canonical keeps only its path so a stale backend host cannot come
1483 * back through it.
1484 *
1485 * @param array $meta Raw term meta.
1486 * @param array $yoast This term's Yoast entry.
1487 * @param string $public_url Already-built frontend URL.
1488 * @param WP_Term $term
1489 * @return string
1490 */
1491 private static function resolve_term_canonical($meta, $yoast, $public_url, $term)
1492 {
1493 $override = self::first($meta, array('_metasync_canonical_url', 'meta_canonical'));
1494 if ($override !== '' && class_exists('Metasync_Canonical_Sanitizer')) {
1495 $override = (string) Metasync_Canonical_Sanitizer::sanitize($override);
1496 }
1497
1498 if ($override !== '') {
1499 return $override;
1500 }
1501
1502 $yoast_canonical = self::yoast_value($yoast, 'wpseo_canonical', $term);
1503 if ($yoast_canonical !== '' && class_exists('Metasync_Canonical_Sanitizer')) {
1504 $yoast_canonical = (string) Metasync_Canonical_Sanitizer::sanitize($yoast_canonical);
1505 }
1506
1507 if ($yoast_canonical !== '') {
1508 # No post type: a term archive is not served under a per-post-type
1509 # path prefix, so re-hosting must not insert one.
1510 $rehosted = Metasync_Headless_Url_Builder::from_wp_url($yoast_canonical, '');
1511 if ($rehosted !== '') {
1512 return $rehosted;
1513 }
1514 }
1515
1516 return $public_url;
1517 }
1518
1519 /**
1520 * Term og:title: override -> OTTO -> Yoast -> the SEO title.
1521 *
1522 * No equal-to-the-name guard here, unlike the post resolver. That guard
1523 * exists because the post OG meta box pre-fills its title from the post
1524 * title and persists the default on save, so a value equal to the post title
1525 * proves nothing. There is no term-side box that does that — the only writers
1526 * are the MCP tool and the importer, and both write only what they were
1527 * given — so a stored value is intent, even when it matches the term name.
1528 *
1529 * @param array $meta Raw term meta.
1530 * @param array $yoast This term's Yoast entry.
1531 * @param WP_Term $term
1532 * @param string $title Already-resolved SEO title.
1533 * @return string
1534 */
1535 private static function resolve_term_og_title($meta, $yoast, $term, $title)
1536 {
1537 $resolved = self::first($meta, array(
1538 '_metasync_og_title',
1539 '_metasync_otto_og_title',
1540 ));
1541
1542 if ($resolved === '') {
1543 $resolved = self::yoast_value($yoast, 'wpseo_opengraph-title', $term);
1544 }
1545
1546 return $resolved !== '' ? $resolved : $title;
1547 }
1548
1549 /**
1550 * Term og:description: override -> OTTO -> Yoast -> the meta description.
1551 *
1552 * @param array $meta Raw term meta.
1553 * @param array $yoast This term's Yoast entry.
1554 * @param WP_Term $term
1555 * @param string $description Already-resolved meta description.
1556 * @return string
1557 */
1558 private static function resolve_term_og_description($meta, $yoast, $term, $description)
1559 {
1560 $resolved = self::first($meta, array(
1561 '_metasync_og_description',
1562 '_metasync_otto_og_description',
1563 ));
1564
1565 if ($resolved === '') {
1566 $resolved = self::yoast_value($yoast, 'wpseo_opengraph-description', $term);
1567 }
1568
1569 return $resolved !== '' ? $resolved : $description;
1570 }
1571
1572 /**
1573 * Term og:image: override -> Yoast.
1574 *
1575 * No OTTO rung and no featured-image fallback: OTTO suggests no social image
1576 * at any level, and a term has no featured image to fall back to.
1577 *
1578 * @param array $meta Raw term meta.
1579 * @param array $yoast This term's Yoast entry.
1580 * @return string
1581 */
1582 private static function resolve_term_og_image($meta, $yoast)
1583 {
1584 $resolved = self::first($meta, array('_metasync_og_image'));
1585
1586 # Not run through yoast_flat_value(): an image URL is not a template, and
1587 # a `%%` in one is part of the path rather than a token to expand.
1588 return $resolved !== '' ? $resolved : self::first($yoast, array('wpseo_opengraph-image'));
1589 }
1590
1591 /**
1592 * Term twitter:title: override -> OTTO -> Yoast -> the OG title.
1593 *
1594 * @param array $meta Raw term meta.
1595 * @param array $yoast This term's Yoast entry.
1596 * @param WP_Term $term
1597 * @param string $og_title Already-resolved OG title.
1598 * @return string
1599 */
1600 private static function resolve_term_twitter_title($meta, $yoast, $term, $og_title)
1601 {
1602 $resolved = self::first($meta, array(
1603 '_metasync_twitter_title',
1604 '_metasync_otto_twitter_title',
1605 ));
1606
1607 if ($resolved === '') {
1608 $resolved = self::yoast_value($yoast, 'wpseo_twitter-title', $term);
1609 }
1610
1611 return $resolved !== '' ? $resolved : $og_title;
1612 }
1613
1614 /**
1615 * Term twitter:description: override -> OTTO -> Yoast -> the OG description.
1616 *
1617 * @param array $meta Raw term meta.
1618 * @param array $yoast This term's Yoast entry.
1619 * @param WP_Term $term
1620 * @param string $og_description Already-resolved OG description.
1621 * @return string
1622 */
1623 private static function resolve_term_twitter_description($meta, $yoast, $term, $og_description)
1624 {
1625 $resolved = self::first($meta, array(
1626 '_metasync_twitter_description',
1627 '_metasync_otto_twitter_description',
1628 ));
1629
1630 if ($resolved === '') {
1631 $resolved = self::yoast_value($yoast, 'wpseo_twitter-description', $term);
1632 }
1633
1634 return $resolved !== '' ? $resolved : $og_description;
1635 }
1636
1637 /**
1638 * Term twitter:image: override -> Yoast -> the OG image.
1639 *
1640 * @param array $meta Raw term meta.
1641 * @param array $yoast This term's Yoast entry.
1642 * @param string $og_image Already-resolved OG image.
1643 * @return string
1644 */
1645 private static function resolve_term_twitter_image($meta, $yoast, $og_image)
1646 {
1647 $resolved = self::first($meta, array('_metasync_twitter_image'));
1648 if ($resolved === '') {
1649 $resolved = self::first($yoast, array('wpseo_twitter-image'));
1650 }
1651
1652 return $resolved !== '' ? $resolved : $og_image;
1653 }
1654
1655 /* -----------------------------------------------------------------
1656 * Fields for the types with no per-object storage
1657 * ----------------------------------------------------------------- */
1658
1659 /**
1660 * og:type for a site section — a term archive, the homepage, the blog index.
1661 *
1662 * An explicit `_metasync_og_type` still wins, matching the post resolver, but
1663 * the derived value is not "article or website" the way it is for a single
1664 * post: none of these is an article.
1665 *
1666 * @param array $meta Raw meta for the object, or an empty array.
1667 * @param string $default Derived type when nothing is stored.
1668 * @return string
1669 */
1670 private static function resolve_section_og_type($meta, $default)
1671 {
1672 $override = self::first($meta, array('_metasync_og_type'));
1673
1674 return $override !== '' ? $override : $default;
1675 }
1676
1677 /**
1678 * The title for a post-type archive.
1679 *
1680 * The registered plural label, which is what WordPress itself puts in the
1681 * document title for an archive. Falls back through the singular label to the
1682 * post type name so this cannot come back empty for a type registered with
1683 * no labels at all.
1684 *
1685 * @param object $type Post type object.
1686 * @param string $post_type Post type name.
1687 * @return string
1688 */
1689 private static function post_type_archive_title($type, $post_type)
1690 {
1691 if (isset($type->labels) && is_object($type->labels)) {
1692 foreach (array('name', 'singular_name') as $label) {
1693 if (isset($type->labels->$label) && is_string($type->labels->$label)) {
1694 $value = trim($type->labels->$label);
1695 if ($value !== '') {
1696 return $value;
1697 }
1698 }
1699 }
1700 }
1701
1702 if (isset($type->label) && is_string($type->label) && trim($type->label) !== '') {
1703 return trim($type->label);
1704 }
1705
1706 return $post_type;
1707 }
1708
1709 /**
1710 * An author's display name.
1711 *
1712 * @param WP_User $user
1713 * @return string
1714 */
1715 private static function author_display_name($user)
1716 {
1717 $name = self::text($user->display_name);
1718 if ($name !== '') {
1719 return $name;
1720 }
1721
1722 return self::text($user->user_nicename);
1723 }
1724
1725 /**
1726 * A trimmed string for anything that can sensibly become one.
1727 *
1728 * Used on properties read straight off a WordPress object. Those are typed
1729 * as strings, but this class is handed objects by callers and by seams a
1730 * subclass may substitute, and a corrupted row can put an array where a
1731 * string belongs — casting that would emit a warning and yield the literal
1732 * "Array".
1733 *
1734 * @param mixed $value
1735 * @return string
1736 */
1737 private static function text($value)
1738 {
1739 if (!is_string($value) && !is_numeric($value)) {
1740 return '';
1741 }
1742
1743 return trim((string) $value);
1744 }
1745
1746 /**
1747 * Force post meta into the shape get_post_meta($id) documents.
1748 *
1749 * Real WordPress always returns `key => [value]`, and the robots resolver
1750 * this class delegates to reads element 0 directly. Coercing here means a
1751 * caller — the bulk resolver pre-fetching rows, say — can hand over a
1752 * flatter array without the delegation quietly resolving to nothing.
1753 *
1754 * @param mixed $meta
1755 * @return array
1756 */
1757 private static function normalize_meta($meta)
1758 {
1759 if (!is_array($meta)) {
1760 return array();
1761 }
1762
1763 $normalized = array();
1764
1765 foreach ($meta as $key => $value) {
1766 $normalized[$key] = is_array($value) ? array_values($value) : array($value);
1767 }
1768
1769 return $normalized;
1770 }
1771
1772 /**
1773 * SEO title: override -> OTTO -> imported -> Yoast -> the post title.
1774 *
1775 * `_metasync_otto_title` ranks above `_metasync_metatitle` to mirror the
1776 * shared chain in Metasync_Seo_Precedence: staging is the fresh OTTO value,
1777 * the persisted key can go stale when OTTO persistence is off.
1778 *
1779 * @param array $meta Raw post meta.
1780 * @param WP_Post $post
1781 * @return string
1782 */
1783 private static function resolve_title($meta, $post)
1784 {
1785 $resolved = self::first($meta, array(
1786 '_metasync_seo_title',
1787 '_metasync_otto_title',
1788 '_metasync_metatitle',
1789 '_metasync_imported_seo_title',
1790 ));
1791
1792 if ($resolved === '') {
1793 $resolved = self::yoast_value($meta, '_yoast_wpseo_title', $post);
1794 }
1795
1796 return $resolved !== '' ? $resolved : (string) $post->post_title;
1797 }
1798
1799 /**
1800 * Meta description: override -> OTTO -> imported -> Yoast -> a trimmed excerpt.
1801 *
1802 * @param array $meta Raw post meta.
1803 * @param WP_Post $post
1804 * @return string
1805 */
1806 private static function resolve_description($meta, $post)
1807 {
1808 $resolved = self::first($meta, array(
1809 '_metasync_seo_desc',
1810 '_metasync_otto_description',
1811 '_metasync_metadesc',
1812 '_metasync_imported_seo_desc',
1813 ));
1814
1815 if ($resolved === '') {
1816 $resolved = self::yoast_value($meta, '_yoast_wpseo_metadesc', $post);
1817 }
1818
1819 return $resolved !== '' ? $resolved : self::excerpt_from_post($post);
1820 }
1821
1822 /**
1823 * Keywords: override -> OTTO -> Yoast focus keyword.
1824 *
1825 * OTTO persists its keyword to both `_metasync_focus_keyword` and
1826 * `_metasync_otto_keywords`, so the first is not strictly an override — but
1827 * it is the one a user edit lands in, so it is read first either way.
1828 *
1829 * @param array $meta Raw post meta.
1830 * @return string
1831 */
1832 private static function resolve_keywords($meta)
1833 {
1834 # Deliberately no Yoast rung. `_yoast_wpseo_focuskw` is an editor's
1835 # internal target keyword and is rendered nowhere on the public page, so
1836 # publishing it would break this feature's own rule that everything in
1837 # the payload is already visible to a visitor — on an endpoint with no
1838 # auth gate, one query would hand over every editor's keyword research.
1839 # The importer copies keywords an operator has actually accepted into
1840 # `_metasync_focus_keyword`, which is the explicit act that should make
1841 # them public.
1842 return self::first($meta, array(
1843 '_metasync_focus_keyword',
1844 '_metasync_otto_keywords',
1845 ));
1846 }
1847
1848 /**
1849 * Canonical: override -> Yoast -> the public URL.
1850 *
1851 * OTTO does not suggest canonicals, so there is no OTTO step. The final
1852 * fallback is the frontend URL rather than get_permalink(): on a headless
1853 * site the permalink names the WordPress host, which is the whole defect
1854 * this feature exists to fix.
1855 *
1856 * An override is taken as written. If an operator has typed an absolute URL
1857 * it is a deliberate statement about where the canonical points, and
1858 * rewriting its host would silently overrule them.
1859 *
1860 * @param array $meta Raw post meta.
1861 * @param string $public_url Already-built frontend URL.
1862 * @param string $post_type Post type, for the prefix rule when re-hosting.
1863 * @return string
1864 */
1865 private static function resolve_canonical($meta, $public_url, $post_type = '')
1866 {
1867 $override = self::first($meta, array('_metasync_canonical_url', 'meta_canonical'));
1868 if ($override !== '' && class_exists('Metasync_Canonical_Sanitizer')) {
1869 # Legacy rows corrupted to the literal "Array" must never be emitted.
1870 $override = (string) Metasync_Canonical_Sanitizer::sanitize($override);
1871 }
1872
1873 if ($override !== '') {
1874 return $override;
1875 }
1876
1877 # A Yoast canonical is not an operator statement about the headless
1878 # topology — it is third-party data, very often a stale absolute URL
1879 # naming the host WordPress served when it was still the public site.
1880 # Returned verbatim it would put the backend host back into canonical and
1881 # og:url, which is the exact defect this feature exists to remove. So
1882 # keep only its path and re-host it on the frontend, and sanitise it the
1883 # same way the operator override is sanitised — the rendered path escapes
1884 # canonicals, and this payload is rendered verbatim by the frontend.
1885 $yoast = self::first($meta, array('_yoast_wpseo_canonical'));
1886 if ($yoast !== '' && class_exists('Metasync_Canonical_Sanitizer')) {
1887 $yoast = (string) Metasync_Canonical_Sanitizer::sanitize($yoast);
1888 }
1889
1890 if ($yoast !== '') {
1891 $rehosted = Metasync_Headless_Url_Builder::from_wp_url($yoast, $post_type);
1892 if ($rehosted !== '') {
1893 return $rehosted;
1894 }
1895 }
1896
1897 return $public_url;
1898 }
1899
1900 /**
1901 * Robots directives for a term.
1902 *
1903 * MetaSync's own term storage is resolved by the shared implementation, as
1904 * for a post. Yoast's term noindex is honoured as the rung beneath it,
1905 * because Yoast keeps that flag in its taxonomy option rather than in term
1906 * meta — so the shared resolver, which only ever sees term meta, cannot
1907 * see it. Without this a term marked noindex in Yoast was reported as
1908 * indexable, and this plugin's own term sync writes that very key.
1909 *
1910 * MetaSync's value wins where it exists, matching the order used
1911 * everywhere else. Serving directives are dropped alongside a noindex for
1912 * the same reason the shared resolver drops them: they describe how to
1913 * present a page that is not being indexed at all.
1914 *
1915 * @param array $meta Raw term meta.
1916 * @param array $yoast Yoast's stored entry for this term.
1917 * @return string
1918 */
1919 private static function resolve_term_robots($meta, $yoast)
1920 {
1921 $resolved = self::resolve_robots($meta);
1922
1923 if (stripos($resolved, 'noindex') !== false) {
1924 return $resolved;
1925 }
1926
1927 $yoast_noindex = isset($yoast['wpseo_noindex']) ? (string) $yoast['wpseo_noindex'] : '';
1928 if (strtolower(trim($yoast_noindex)) !== 'noindex') {
1929 return $resolved;
1930 }
1931
1932 # Yoast says noindex and MetaSync has said nothing. Emit noindex, and
1933 # keep only the directives that still mean something beside it.
1934 $directives = array('noindex');
1935
1936 foreach (explode(',', $resolved) as $directive) {
1937 if (strtolower(trim($directive)) === 'nofollow') {
1938 $directives[] = 'nofollow';
1939 }
1940 }
1941
1942 return implode(', ', array_unique($directives));
1943 }
1944 /**
1945 * Robots directives.
1946 *
1947 * Delegates to the existing resolver rather than restating it: four storage
1948 * formats, a rule that drops serving directives once noindex is present, and
1949 * a max-image-preview default. A second implementation of that would drift
1950 * from the one the rendered site uses.
1951 *
1952 * @param array $meta Raw post meta.
1953 * @return string
1954 */
1955 private static function resolve_robots($meta)
1956 {
1957 if (!class_exists('Metasync_Seo_Output')) {
1958 return '';
1959 }
1960
1961 return (string) Metasync_Seo_Output::resolve_robots_value_raw($meta);
1962 }
1963
1964 /**
1965 * og:title: a customised OG title -> OTTO -> imported -> Yoast -> the SEO title.
1966 *
1967 * The OG meta box pre-fills its title from the post title and persists that
1968 * default on save, so a non-empty `_metasync_og_title` is not on its own
1969 * evidence of intent. Treating it as one would let an auto-filled post title
1970 * quietly outrank a deliberately-set SEO title on every ordinary edit. The
1971 * value counts only when it differs from that default — the same comparison
1972 * Metasync_SEO_Conflict_Handler makes.
1973 *
1974 * @param array $meta Raw post meta.
1975 * @param WP_Post $post
1976 * @param string $title Already-resolved SEO title.
1977 * @return string
1978 */
1979 private static function resolve_og_title($meta, $post, $title)
1980 {
1981 $og_title = self::first($meta, array('_metasync_og_title'));
1982 if ($og_title !== '' && $og_title !== (string) $post->post_title) {
1983 return $og_title;
1984 }
1985
1986 $resolved = self::first($meta, array(
1987 '_metasync_otto_og_title',
1988 '_metasync_imported_og_title',
1989 ));
1990
1991 if ($resolved === '') {
1992 $resolved = self::yoast_value($meta, '_yoast_wpseo_opengraph-title', $post);
1993 }
1994
1995 return $resolved !== '' ? $resolved : $title;
1996 }
1997
1998 /**
1999 * og:description: override -> OTTO -> imported -> Yoast -> the meta description.
2000 *
2001 * @param array $meta Raw post meta.
2002 * @param string $description Already-resolved meta description.
2003 * @return string
2004 */
2005 private static function resolve_og_description($meta, $description, $post = null)
2006 {
2007 $resolved = self::first($meta, array(
2008 '_metasync_og_description',
2009 '_metasync_otto_og_description',
2010 '_metasync_imported_og_desc',
2011 ));
2012
2013 if ($resolved === '') {
2014 $resolved = self::yoast_value($meta, '_yoast_wpseo_opengraph-description', $post);
2015 }
2016
2017 return $resolved !== '' ? $resolved : $description;
2018 }
2019
2020 /**
2021 * og:image: override -> imported -> Yoast -> the featured image.
2022 *
2023 * OTTO does not suggest a social image.
2024 *
2025 * @param array $meta Raw post meta.
2026 * @param WP_Post $post
2027 * @return string
2028 */
2029 private static function resolve_og_image($meta, $post)
2030 {
2031 $resolved = self::first($meta, array(
2032 '_metasync_og_image',
2033 '_metasync_imported_og_image',
2034 '_yoast_wpseo_opengraph-image',
2035 ));
2036 if ($resolved !== '') {
2037 return $resolved;
2038 }
2039
2040 return (string) static::read_featured_image_url($post);
2041 }
2042
2043 /**
2044 * og:type.
2045 *
2046 * @param array $meta Raw post meta.
2047 * @param WP_Post $post
2048 * @return string
2049 */
2050 private static function resolve_og_type($meta, $post)
2051 {
2052 $override = self::first($meta, array('_metasync_og_type'));
2053 if ($override !== '') {
2054 return $override;
2055 }
2056
2057 return $post->post_type === 'page' ? 'website' : 'article';
2058 }
2059
2060 /**
2061 * twitter:card.
2062 *
2063 * @param array $meta Raw post meta.
2064 * @return string
2065 */
2066 private static function resolve_twitter_card($meta)
2067 {
2068 $override = self::first($meta, array('_metasync_twitter_card'));
2069 if ($override !== '') {
2070 return $override;
2071 }
2072
2073 $configured = Metasync::get_option('twitter_card_type');
2074
2075 return is_string($configured) && $configured !== '' ? $configured : 'summary_large_image';
2076 }
2077
2078 /**
2079 * twitter:title: override -> OTTO -> imported -> Yoast -> the OG title.
2080 *
2081 * @param array $meta Raw post meta.
2082 * @param string $og_title Already-resolved OG title.
2083 * @return string
2084 */
2085 private static function resolve_twitter_title($meta, $og_title, $post = null)
2086 {
2087 $resolved = self::first($meta, array(
2088 '_metasync_twitter_title',
2089 '_metasync_otto_twitter_title',
2090 '_metasync_imported_twitter_title',
2091 ));
2092
2093 if ($resolved === '') {
2094 $resolved = self::yoast_value($meta, '_yoast_wpseo_twitter-title', $post);
2095 }
2096
2097 return $resolved !== '' ? $resolved : $og_title;
2098 }
2099
2100 /**
2101 * twitter:description: override -> OTTO -> imported -> Yoast -> the OG description.
2102 *
2103 * @param array $meta Raw post meta.
2104 * @param string $og_description Already-resolved OG description.
2105 * @return string
2106 */
2107 private static function resolve_twitter_description($meta, $og_description, $post = null)
2108 {
2109 $resolved = self::first($meta, array(
2110 '_metasync_twitter_description',
2111 '_metasync_otto_twitter_description',
2112 '_metasync_imported_twitter_desc',
2113 ));
2114
2115 if ($resolved === '') {
2116 $resolved = self::yoast_value($meta, '_yoast_wpseo_twitter-description', $post);
2117 }
2118
2119 return $resolved !== '' ? $resolved : $og_description;
2120 }
2121
2122 /**
2123 * twitter:image: override -> imported -> Yoast -> the OG image.
2124 *
2125 * @param array $meta Raw post meta.
2126 * @param string $og_image Already-resolved OG image.
2127 * @return string
2128 */
2129 private static function resolve_twitter_image($meta, $og_image)
2130 {
2131 $resolved = self::first($meta, array(
2132 '_metasync_twitter_image',
2133 '_metasync_imported_twitter_image',
2134 '_yoast_wpseo_twitter-image',
2135 ));
2136
2137 return $resolved !== '' ? $resolved : $og_image;
2138 }
2139
2140 /**
2141 * The OTTO JSON-LD graph, decoded.
2142 *
2143 * Returned decoded rather than as a string so a delivery layer hands a
2144 * consumer structured data instead of a string it has to parse. A row that
2145 * does not decode yields an empty array: shipping a malformed graph is worse
2146 * than shipping none, because a validator reports it as broken markup rather
2147 * than absent markup.
2148 *
2149 * The value is always wrapped in a list, so a consumer iterates one shape
2150 * whether the stored graph was a single node or several.
2151 *
2152 * @param array $meta Raw post meta.
2153 * @return array
2154 */
2155 private static function resolve_schema($meta)
2156 {
2157 $raw = self::first($meta, array('_metasync_otto_structured_data'));
2158 if ($raw === '') {
2159 return array();
2160 }
2161
2162 $decoded = json_decode($raw, true);
2163 if (!is_array($decoded) || $decoded === array()) {
2164 return array();
2165 }
2166
2167 # A graph stored as a single node arrives as an associative array; one
2168 # stored as several arrives as a list. Normalise to a list.
2169 return isset($decoded[0]) ? array_values($decoded) : array($decoded);
2170 }
2171
2172 /* -----------------------------------------------------------------
2173 * Helpers
2174 * ----------------------------------------------------------------- */
2175
2176 /**
2177 * A Yoast-sourced value, with its template variables dealt with.
2178 *
2179 * Yoast stores templates, not finished strings: `_yoast_wpseo_title` is
2180 * routinely `%%title%% %%sep%% %%sitename%%`, and Yoast expands those at
2181 * render time through wpseo_replace_vars(). Every other consumer in this
2182 * plugin lets Yoast render itself, so this is the first place raw Yoast meta
2183 * would become output — shipped verbatim, a frontend would put the literal
2184 * `%%title%%` in the document title.
2185 *
2186 * Expanded when Yoast is present to do it. When it is not, a value still
2187 * carrying tokens is treated as absent so resolution falls through to the
2188 * next rung, which yields the plain post title rather than a broken one.
2189 *
2190 * `$values` is whichever store the caller read, because Yoast keeps its data
2191 * in three different places: post meta for a post (`key => [value]`), the
2192 * `wpseo_taxonomy_meta` option entry for a term (`key => value`), and user
2193 * meta for an author. first() normalises both shapes, so the chain does not
2194 * have to know which one it is reading.
2195 *
2196 * @param array $values Values from whichever Yoast store applies.
2197 * @param string $key Yoast key.
2198 * @param mixed $context The object Yoast should resolve tokens from.
2199 * @return string
2200 */
2201 private static function yoast_value($values, $key, $context = null)
2202 {
2203 $value = self::first($values, array($key));
2204 if ($value === '') {
2205 return '';
2206 }
2207
2208 if (strpos($value, '%%') === false) {
2209 return $value;
2210 }
2211
2212 # The args are built here, not behind the seam. This choice IS the bug
2213 # that shipped, so it has to sit in code a test actually executes — a
2214 # seam that both decides the args and makes the call would hide it again.
2215 #
2216 # Any object is passed through, not only a WP_Post. Yoast casts what it
2217 # is given to an array and merges it over its own defaults, so a WP_Term
2218 # supplies the `name`, `taxonomy` and `term_id` slots that %%term_title%%
2219 # and %%term_description%% resolve from — and a `$post instanceof
2220 # WP_Post` test would coerce that term to an empty array and reproduce
2221 # the exact same stripped-token failure for every term archive.
2222 $args = is_object($context) ? $context : array();
2223
2224 $expanded = static::expand_yoast_template($value, $args);
2225
2226 if (is_string($expanded) && strpos($expanded, '%%') === false) {
2227 $expanded = trim($expanded);
2228
2229 # Because unresolved tokens are stripped rather than flagged, an
2230 # expansion can collapse to nothing but leftover punctuation — a
2231 # separator with both sides missing. That is not a title, so fall
2232 # through to the next rung instead of publishing it.
2233 if (self::has_renderable_text($expanded)) {
2234 return $expanded;
2235 }
2236 }
2237
2238 # Still templated, or nothing renderable survived: better to fall through
2239 # than to render a token or a stray separator.
2240 return '';
2241 }
2242
2243 /**
2244 * Expand Yoast's template tokens, when Yoast is present to do it.
2245 *
2246 * The post must be handed over. Yoast resolves post-bound tokens
2247 * (%%title%%, %%excerpt%%, %%category%%, %%parent_title%%) out of the $args
2248 * it is given, and from an empty array it resolves none of them — then
2249 * STRIPS them rather than leaving the %% markers behind. So
2250 * `%%title%% %%sep%% %%sitename%%` came back as ' - sitename', with no %%
2251 * left for any guard to notice, and that fragment was published as the
2252 * document title. Passing the post is what makes this match what Yoast
2253 * itself renders on a normal page.
2254 *
2255 * Separated from yoast_value() so both outcomes are testable: PHP cannot
2256 * un-define a function, so a test that declared a wpseo_replace_vars() stub
2257 * would make Yoast permanently "present" for every other test in the run.
2258 *
2259 * Deliberately thin: it decides only whether an expander exists and passes
2260 * the arguments straight through. Everything worth getting wrong — above all
2261 * what gets handed to Yoast — lives in yoast_value() where tests reach it.
2262 *
2263 * @param string $value Template string containing %% tokens.
2264 * @param WP_Post|array $args What Yoast should resolve tokens from.
2265 * @return string|null Expanded string, or null when there is no expander.
2266 */
2267 protected static function expand_yoast_template($value, $args)
2268 {
2269 if (!function_exists('wpseo_replace_vars')) {
2270 return null;
2271 }
2272
2273 return wpseo_replace_vars($value, $args);
2274 }
2275
2276 /**
2277 * Does this string contain anything a reader would recognise as text?
2278 *
2279 * At least one letter or digit. Used to reject an expansion that reduced to
2280 * punctuation, which is what a template leaves behind when its tokens do not
2281 * resolve.
2282 *
2283 * @param string $value
2284 * @return bool
2285 */
2286 private static function has_renderable_text($value)
2287 {
2288 return preg_match('/[\p{L}\p{N}]/u', (string) $value) === 1;
2289 }
2290
2291 /**
2292 * The first non-empty value among a list of meta keys.
2293 *
2294 * get_post_meta($id) returns every key as a list of values, so each lookup
2295 * has to reach into element 0. Centralising that also centralises the
2296 * definition of "empty", which is what the whole fallback order rests on:
2297 * OTTO never emits a blank suggestion, so treating '' as absent is safe and
2298 * makes a rollback — clearing the OTTO value — fall through to Yoast, which
2299 * is the wanted behaviour.
2300 *
2301 * @param array $meta Raw post meta, as returned by get_post_meta($id).
2302 * @param string[] $keys Keys to try, in order.
2303 * @return string The first non-empty value, or ''.
2304 */
2305 private static function first($meta, array $keys)
2306 {
2307 foreach ($keys as $key) {
2308 if (!isset($meta[$key])) {
2309 continue;
2310 }
2311
2312 $value = $meta[$key];
2313 if (is_array($value)) {
2314 $value = isset($value[0]) ? $value[0] : '';
2315 }
2316
2317 if (!is_string($value) && !is_numeric($value)) {
2318 continue;
2319 }
2320
2321 $value = trim((string) $value);
2322 if ($value !== '') {
2323 return $value;
2324 }
2325 }
2326
2327 return '';
2328 }
2329
2330 /**
2331 * A description built from the post itself.
2332 *
2333 * Prefers a hand-written excerpt. Falling back to the content mirrors what
2334 * the rendered site does, including stripping shortcodes before tags rather
2335 * than executing them — running shortcodes here would fire third-party code
2336 * during what should be a read.
2337 *
2338 * @param WP_Post $post
2339 * @return string
2340 */
2341 private static function excerpt_from_post($post)
2342 {
2343 $excerpt = trim((string) $post->post_excerpt);
2344 if ($excerpt !== '') {
2345 return $excerpt;
2346 }
2347
2348 return self::plain_text_summary($post->post_content);
2349 }
2350
2351 /**
2352 * Reduce stored rich text to a short plain-text description.
2353 *
2354 * Shared by the post content fallback, the term description fallback, the
2355 * post-type archive description and the author biography, so all four get the
2356 * same treatment: shortcodes stripped rather than executed — running one here
2357 * would fire third-party code during what is supposed to be a read — then
2358 * tags removed, whitespace collapsed, and the result cut to a length that
2359 * fits a meta description.
2360 *
2361 * @param mixed $text Stored rich text.
2362 * @return string
2363 */
2364 private static function plain_text_summary($text)
2365 {
2366 if (!is_string($text) && !is_numeric($text)) {
2367 return '';
2368 }
2369
2370 $text = (string) $text;
2371 if (trim($text) === '') {
2372 return '';
2373 }
2374
2375 $text = strip_shortcodes($text);
2376 $text = wp_strip_all_tags($text);
2377 $text = preg_replace('/\s+/', ' ', $text);
2378
2379 return wp_trim_words(trim((string) $text), 30, '');
2380 }
2381 }
2382