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
metasync / includes / class-metasync-headless-graphql.php

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

1,553 lines 59.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WPGraphQL delivery for the headless SEO payload.
4 *
5 * Registers one read-only field on every node type WPGraphQL exposes that this
6 * plugin can describe, returning the object Metasync_Headless_Seo_Surface
7 * resolved. No resolution logic lives here — this file is transport.
8 *
9 * **Where each entry point is attached, and why.**
10 *
11 * The surface has six entry points and WPGraphQL has a node for four of them.
12 * Wherever a node exists the field goes on the node, because a node is an
13 * address the frontend already has: it fetches a page with `nodeByUri(uri: …)`
14 * and reads the SEO off whatever came back, in the same request, without
15 * knowing which WordPress object type is behind the route. Attaching to the
16 * node also means WPGraphQL has already decided the caller may see that object
17 * before this field is ever reached.
18 *
19 * - a post type's `graphql_single_name` -> for_post()
20 * - a taxonomy's `graphql_single_name` -> for_term()
21 * - `User` -> for_author()
22 * - `ContentType` -> for_post_type_archive()
23 *
24 * The front page and the blog index are the two that genuinely have no node of
25 * their own — with `show_on_front = posts` there is no object at all — so they
26 * get root fields on `RootQuery`:
27 *
28 * - `metasyncSeoForHomePage` -> for_home_page()
29 * - `metasyncSeoForPostsPage` -> for_posts_page()
30 *
31 * Both take **no arguments**, which is the point rather than a convenience.
32 * There is no id, name or slug a caller can hand a root field here, so no root
33 * field can be pointed at an object the node path would have refused; every
34 * caller-supplied identifier still arrives as a node WPGraphQL access-checked
35 * first. See register_root_fields() for the alternatives that were rejected.
36 *
37 * **The field must never be able to break a page.** On a headless site the
38 * frontend fetches content and SEO in a single GraphQL request, so a resolver
39 * that throws does not degrade the SEO — it fails the whole content query and
40 * the page renders as an error. Worse, a frontend using persisted (locked) query
41 * documents cannot be quietly patched afterwards; every stored operation would
42 * have to be rebuilt and redeployed. So:
43 *
44 * - the field and every child field are nullable;
45 * - the resolver catches Throwable and returns null;
46 * - registration itself is wrapped, so a malformed schema call cannot take the
47 * whole schema down with it.
48 *
49 * Failing closed to null is the deliberate choice over an error a caller could
50 * detect. A frontend that gets null renders the page with whatever SEO it can
51 * derive itself; a frontend that gets an error renders nothing.
52 *
53 * The field is named `metasyncSeo` rather than `seo`. A headless install of the
54 * kind this targets is very likely already serving a `seo` field from another
55 * plugin, and colliding with it would break the site rather than extend it. The
56 * root fields carry the same prefix for the same reason.
57 *
58 * Only render-facing values are exposed. No deployment revision, no internal
59 * ids, no configuration — a headless GraphQL endpoint frequently has no auth
60 * gate in front of it, so everything here should be safe to read anonymously.
61 * Everything on the type is already public on the rendered page.
62 *
63 * @package Metasync
64 * @subpackage Metasync/includes
65 */
66
67 if (!defined('ABSPATH')) {
68 exit; # Exit if accessed directly
69 }
70
71 require_once dirname(__DIR__) . '/headless-refresh-history/class-metasync-headless-refresh-history-database.php';
72
73 class Metasync_Headless_Graphql
74 {
75 /**
76 * GraphQL object type name for the payload.
77 */
78 const TYPE_NAME = 'MetaSyncSeo';
79
80 /**
81 * Field name added to each node type.
82 */
83 const FIELD_NAME = 'metasyncSeo';
84
85 /**
86 * WPGraphQL's root query type, where the two archive-shaped entry points
87 * that have no node of their own are attached.
88 */
89 const ROOT_TYPE_NAME = 'RootQuery';
90
91 /**
92 * WPGraphQL's author node. Fixed rather than derived: unlike post types and
93 * taxonomies, users are not a registry with a configurable GraphQL name.
94 */
95 const USER_TYPE_NAME = 'User';
96
97 /**
98 * WPGraphQL's post-type node — the node `nodeByUri` returns for a post type
99 * archive, and the one it returns for `/` on a site that shows posts there.
100 */
101 const CONTENT_TYPE_NAME = 'ContentType';
102
103 /**
104 * Root field for the site front page.
105 */
106 const HOME_FIELD_NAME = 'metasyncSeoForHomePage';
107
108 /**
109 * Root field for the blog index, when a static page holds it.
110 */
111 const POSTS_PAGE_FIELD_NAME = 'metasyncSeoForPostsPage';
112
113 /**
114 * Most objects one GraphQL request may refresh from OTTO synchronously.
115 *
116 * A request asking for more than this many objects makes no live call at
117 * all — not "the first five and then stop". Which is why the count cannot
118 * be taken as resolvers fire: by the time the sixth object arrived, five
119 * calls would already have gone out. See settle() for how the whole
120 * request is counted before any of them runs.
121 */
122 const MAX_SYNC_REFRESH_OBJECTS = 5;
123
124 /**
125 * How many consecutive quiet turns prove a request has stopped growing.
126 *
127 * One is not enough. A lazy loader can be sitting in the deferred queue
128 * behind us, so a single turn where nothing new arrived may simply be the
129 * turn before that loader runs and introduces a whole new level of
130 * objects. Two consecutive quiet turns mean we have been all the way round
131 * the queue without the count moving.
132 */
133 const SETTLE_QUIET_TURNS = 2;
134
135 /**
136 * Every object this request asked to publish, keyed so that the same
137 * object named twice — through a node and a connection, or through an
138 * alias — counts once.
139 *
140 * @var array<string,bool>
141 */
142 private static $requested = array();
143
144 /**
145 * The request's verdict: null until resolution settles, then whether the
146 * request is small enough to refresh at all.
147 *
148 * Decided once, by whichever waiter first sees a settled request, and read
149 * by every other waiter. Deciding per waiter would let the answer depend
150 * on the order the client happened to write its selection set.
151 *
152 * @var bool|null
153 */
154 private static $verdict = null;
155
156 /**
157 * Live refreshes this request has started, across every pass.
158 *
159 * @var int
160 */
161 private static $refresh_calls = 0;
162
163 /**
164 * Objects already attempted this request, so a second selection of the
165 * same object neither queues nor calls again.
166 *
167 * @var array<string,bool>
168 */
169 private static $attempted = array();
170
171 /**
172 * What a refreshed object published, keyed the same way.
173 *
174 * Without this, a query naming one object twice would publish the
175 * refreshed values for the first selection and the values captured before
176 * the refresh for the second — two answers for one object in one response.
177 * Only refreshed objects are recorded; everything else already publishes
178 * what the resolver read.
179 *
180 * @var array<string,array>
181 */
182 private static $published = array();
183
184 /**
185 * Monotonic start times for per-object history durations.
186 *
187 * @var array<string,float>
188 */
189 private static $history_started = array();
190
191 /**
192 * Objects already written to refresh history in this GraphQL request.
193 *
194 * @var array<string,bool>
195 */
196 private static $history_recorded = array();
197
198 /**
199 * Number of deferred settling callbacks waiting in the executor queue.
200 *
201 * @var int
202 */
203 private static $pending_rechecks = 0;
204
205 /** @var int|null Current WPGraphQL request context identity. */
206 private static $request_context_id = null;
207
208 /** @var array<int,array> Saved outer contexts for nested GraphQL calls. */
209 private static $request_context_stack = array();
210
211 /**
212 * Hook registration into WPGraphQL's schema build.
213 *
214 * Safe to call unconditionally: `graphql_register_types` only ever fires
215 * when WPGraphQL is active, so on a site without it this adds a callback
216 * that is never invoked and changes nothing.
217 *
218 * @return void
219 */
220 public static function init()
221 {
222 # Gated here as well as inside register(), so a non-headless site does not
223 # even carry the hook. The ticket's rule is that with the mode off
224 # execution follows exactly the path it does today, and an extra callback
225 # on a WPGraphQL schema build is an observable difference even when it
226 # returns immediately. The option read this costs is memoised and the
227 # plugin already reads that option several times per request.
228 #
229 # The class_exists leg is the update-window case: this file and the
230 # config class are separate files in the same auto-update, and WordPress
231 # copies them one at a time. A request landing between the two copies
232 # used to fatal here on every front-end load — the classmap cannot
233 # autoload a file that is not on disk yet. Degrade to the no-headless
234 # path instead; the site keeps serving until the update finishes.
235 if (!class_exists('Metasync_Headless_Config') || !Metasync_Headless_Config::is_enabled()) {
236 return;
237 }
238
239 add_action('graphql_register_types', array(__CLASS__, 'register'));
240 add_action('graphql_before_execute', array(__CLASS__, 'begin_request_context'), 10, 1);
241 # reset_request_state() takes no arguments; graphql_execute passes six,
242 # so accept none and let WordPress hand the callback an empty arg list.
243 add_action('graphql_execute', array(__CLASS__, 'reset_request_state'), 10, 0);
244 add_action('graphql_after_execute', array(__CLASS__, 'end_request_context'), 10, 2);
245 }
246
247 /**
248 * Forget everything the previous GraphQL request recorded about refreshes.
249 *
250 * @return void
251 */
252 public static function reset_request_state()
253 {
254 self::$requested = array();
255 self::$verdict = null;
256 self::$refresh_calls = 0;
257 self::$attempted = array();
258 self::$published = array();
259 self::$history_started = array();
260 self::$history_recorded = array();
261 self::$pending_rechecks = 0;
262 }
263
264 public static function begin_request_context($request)
265 {
266 self::$request_context_stack[] = array(
267 'requested' => self::$requested,
268 'verdict' => self::$verdict,
269 'refresh_calls' => self::$refresh_calls,
270 'attempted' => self::$attempted,
271 'published' => self::$published,
272 'history_started' => self::$history_started,
273 'history_recorded' => self::$history_recorded,
274 'pending_rechecks' => self::$pending_rechecks,
275 );
276 self::reset_request_state();
277 self::$request_context_id = is_object($request) ? spl_object_id($request) : null;
278 }
279
280 public static function end_request_context($response, $request)
281 {
282 $context = array_pop(self::$request_context_stack);
283 if ($context === null) {
284 self::reset_request_state();
285 return;
286 }
287
288 self::$requested = $context['requested'];
289 self::$verdict = $context['verdict'];
290 self::$refresh_calls = $context['refresh_calls'];
291 self::$attempted = $context['attempted'];
292 self::$published = $context['published'];
293 self::$history_started = $context['history_started'];
294 self::$history_recorded = $context['history_recorded'];
295 self::$pending_rechecks = $context['pending_rechecks'];
296 self::$request_context_id = null;
297 }
298
299 /**
300 * Register the type and its fields.
301 *
302 * The headless check happens here rather than at hook time so switching the
303 * mode takes effect on the next request instead of needing a reload. With
304 * the mode off nothing is registered and the schema is byte-identical to a
305 * site without this plugin. The class_exists leg mirrors init(): during an
306 * auto-update the file set on disk can be mixed, and a callback that only
307 * exists to be inert must survive landing in one.
308 *
309 * @return void
310 */
311 public static function register()
312 {
313 if (!class_exists('Metasync_Headless_Config') || !Metasync_Headless_Config::is_enabled()) {
314 return;
315 }
316
317 if (!function_exists('register_graphql_object_type') || !function_exists('register_graphql_field')) {
318 return;
319 }
320
321 try {
322 self::register_type();
323 static::register_fields();
324 add_filter('graphql_connection_nodes', array(__CLASS__, 'prime_post_connection_nodes'), 10, 2);
325 } catch (Throwable $e) {
326 # A failed registration must not take down the whole schema — that
327 # would break every query on the site, not just this field.
328 #
329 # Logged as an error rather than a warning because the consequence is
330 # worse than a failed resolve: the field is absent from the schema, so
331 # a persisted query asking for it fails validation with an unknown
332 # field and takes the whole request with it. A resolve failure only
333 # yields a null. This one needs to be noticed.
334 self::log_failure('registration', $e, true);
335 }
336 }
337
338 /**
339 * The payload type.
340 *
341 * Every field is nullable, and the descriptions are written for the frontend
342 * developer reading them in a schema explorer rather than for us.
343 *
344 * @return void
345 */
346 private static function register_type()
347 {
348 register_graphql_object_type(self::TYPE_NAME, array(
349 'description' => 'SEO values resolved by Search Atlas for this object, ready to render.',
350 'fields' => array(
351 'title' => array(
352 'type' => 'String',
353 'description' => 'Document title.',
354 ),
355 'description' => array(
356 'type' => 'String',
357 'description' => 'Meta description.',
358 ),
359 'keywords' => array(
360 'type' => 'String',
361 'description' => 'Focus keyword or keywords.',
362 ),
363 'canonical' => array(
364 'type' => 'String',
365 'description' => 'Canonical URL, on the public frontend domain.',
366 ),
367 'robots' => array(
368 'type' => 'String',
369 'description' => 'Robots directives, ready for a meta robots tag.',
370 ),
371 'ogTitle' => array(
372 'type' => 'String',
373 'description' => 'og:title.',
374 ),
375 'ogDescription' => array(
376 'type' => 'String',
377 'description' => 'og:description.',
378 ),
379 'ogImage' => array(
380 'type' => 'String',
381 'description' => 'og:image URL.',
382 ),
383 'ogUrl' => array(
384 'type' => 'String',
385 'description' => 'og:url. Always the same value as canonical.',
386 ),
387 'ogType' => array(
388 'type' => 'String',
389 'description' => 'og:type.',
390 ),
391 'ogSiteName' => array(
392 'type' => 'String',
393 'description' => 'og:site_name.',
394 ),
395 'ogLocale' => array(
396 'type' => 'String',
397 'description' => 'og:locale.',
398 ),
399 'twitterCard' => array(
400 'type' => 'String',
401 'description' => 'twitter:card type.',
402 ),
403 'twitterTitle' => array(
404 'type' => 'String',
405 'description' => 'twitter:title.',
406 ),
407 'twitterDescription' => array(
408 'type' => 'String',
409 'description' => 'twitter:description.',
410 ),
411 'twitterImage' => array(
412 'type' => 'String',
413 'description' => 'twitter:image URL.',
414 ),
415 'schema' => array(
416 'type' => 'String',
417 'description' => 'JSON-LD for this object, serialised. Render verbatim inside a application/ld+json script tag; do not re-serialise it.',
418 ),
419 'publicUrl' => array(
420 'type' => 'String',
421 'description' => 'URL this object is served from on the public frontend.',
422 ),
423 ),
424 ));
425 }
426
427 /**
428 * Add the field everywhere it belongs.
429 *
430 * The node types are collected into one map before anything is registered,
431 * because a GraphQL type name may only be offered the field once: asking
432 * WPGraphQL to add the same field to the same type twice raises. Two post
433 * types, or two taxonomies, or a post type and a taxonomy can all share a
434 * `graphql_single_name` — an install like that has other problems, but this
435 * must not be one of them.
436 *
437 * The map is built with `+=` so the first claim on a name wins and the
438 * registries are consulted in a fixed order. That makes this the single place
439 * de-duplication happens; a second guard further down would look like
440 * belt-and-braces but would mean neither could be tested, since either one
441 * alone would satisfy the assertion.
442 *
443 * @return void
444 */
445 protected static function register_fields()
446 {
447 $node_fields = array();
448
449 foreach (self::graphql_post_types() as $graphql_single_name) {
450 $node_fields += array($graphql_single_name => array(
451 'resolve' => array(__CLASS__, 'resolve'),
452 'description' => 'SEO values resolved by Search Atlas for this object.',
453 ));
454 }
455
456 # Terms are the type this feature exists for, and the registry is read
457 # the same way the post types are — show_in_graphql and
458 # graphql_single_name — so a custom taxonomy is covered without a code
459 # change here. On a headless build the custom taxonomies are usually the
460 # ones carrying the site's navigation.
461 foreach (self::graphql_taxonomies() as $graphql_single_name) {
462 $node_fields += array($graphql_single_name => array(
463 'resolve' => array(__CLASS__, 'resolve_term'),
464 'description' => 'SEO values resolved by Search Atlas for this term archive.',
465 ));
466 }
467
468 # `User` is the node nodeByUri() returns for an author archive, so the
469 # author payload goes there rather than on a root field taking an id.
470 $node_fields += array(self::USER_TYPE_NAME => array(
471 'resolve' => array(__CLASS__, 'resolve_author'),
472 'description' => 'SEO values resolved by Search Atlas for this author archive.',
473 ));
474
475 # `ContentType` is the node nodeByUri() returns for a post type archive.
476 $node_fields += array(self::CONTENT_TYPE_NAME => array(
477 'resolve' => array(__CLASS__, 'resolve_content_type'),
478 'description' => 'SEO values resolved by Search Atlas for the archive this post type is served at.',
479 ));
480
481 foreach ($node_fields as $type_name => $spec) {
482 if ($type_name === '') {
483 continue;
484 }
485
486 self::register_field($type_name, self::FIELD_NAME, $spec['resolve'], $spec['description']);
487 }
488
489 self::register_root_fields();
490 }
491
492 /**
493 * The two entry points with no node, as root fields.
494 *
495 * Neither takes an argument, and that is the whole design. The rejected
496 * alternatives all did:
497 *
498 * - `metasyncSeoForAuthor(id: ID!)` and `metasyncSeoForArchive(postType:
499 * String!)`. Both describe objects WPGraphQL already exposes as nodes it
500 * access-checks, so a root field taking an identifier would add a second
501 * way in that this plugin would have to keep as strict as WPGraphQL's own
502 * — for ever, on both sides. The resolvers do refuse non-public objects,
503 * so the two happen to agree today; agreeing by coincidence is not a
504 * property worth depending on when the endpoint may have no auth gate.
505 * - one root field with a discriminator, say `metasyncSeo(type: HOME |
506 * ARCHIVE | AUTHOR, id: ID)`. GraphQL cannot express "this argument is
507 * required only for that value of that other argument", so validation
508 * would move into the resolver — where the only legal outcome is null,
509 * because this field may never raise. A caller who passed the wrong
510 * combination would get a silent null indistinguishable from "no data".
511 * - a URI-resolving field of our own. The frontend does fetch by URI, but
512 * `nodeByUri` already does that routing and it is a large, version-
513 * sensitive re-implementation of WordPress's rewrite rules. Putting the
514 * field on the nodes it returns reaches the same place in the same
515 * request without owning any of it.
516 *
517 * @return void
518 */
519 private static function register_root_fields()
520 {
521 self::register_field(
522 self::ROOT_TYPE_NAME,
523 self::HOME_FIELD_NAME,
524 array(__CLASS__, 'resolve_home_page'),
525 'SEO values resolved by Search Atlas for the site front page. Takes no arguments: there is exactly one front page, and on a site that shows posts there it is not backed by any node.'
526 );
527
528 self::register_field(
529 self::ROOT_TYPE_NAME,
530 self::POSTS_PAGE_FIELD_NAME,
531 array(__CLASS__, 'resolve_posts_page'),
532 'SEO values resolved by Search Atlas for the blog index when a static page holds it. Null when the blog index is the front page; metasyncSeoForHomePage describes it in that case.'
533 );
534 }
535
536 /**
537 * Register one field, containing any failure to that field alone.
538 *
539 * register_graphql_field() raises when the type does not exist or the field
540 * name is already taken, and there are now several calls where there was
541 * one. Left to register()'s single catch, the first bad name would abort the
542 * loop and cost every field after it — and an *absent* field is the
543 * expensive failure, not a failed resolve: a persisted query naming a field
544 * the schema does not have fails validation and takes the whole request with
545 * it, where a failed resolve only yields a null. So each registration is
546 * isolated here, and register()'s catch stays as the backstop for everything
547 * outside these calls.
548 *
549 * @param string $type_name
550 * @param string $field_name
551 * @param callable $resolver
552 * @param string $description
553 * @return void
554 */
555 private static function register_field($type_name, $field_name, $resolver, $description)
556 {
557 try {
558 register_graphql_field($type_name, $field_name, array(
559 'type' => self::TYPE_NAME,
560 'description' => $description,
561 'resolve' => $resolver,
562 ));
563 } catch (Throwable $e) {
564 self::log_failure('registration of ' . $field_name . ' on ' . $type_name, $e, true);
565 }
566 }
567
568 /**
569 * The post type objects WPGraphQL exposes.
570 *
571 * The seam is this raw read rather than the derived name list, so the
572 * validation and de-duplication below stay real logic that tests actually
573 * execute. Overriding the derived list instead would leave both untested.
574 *
575 * @return array
576 */
577 protected static function read_post_type_objects()
578 {
579 return get_post_types(array('show_in_graphql' => true), 'objects');
580 }
581
582 /**
583 * The taxonomy objects WPGraphQL exposes.
584 *
585 * A seam for the same reason as the post types: the filtering below is real
586 * logic, and a test that replaced the derived names would prove nothing
587 * about it.
588 *
589 * @return array
590 */
591 protected static function read_taxonomy_objects()
592 {
593 return get_taxonomies(array('show_in_graphql' => true), 'objects');
594 }
595
596 /**
597 * GraphQL type names of every post type exposed to GraphQL.
598 *
599 * @return string[]
600 */
601 private static function graphql_post_types()
602 {
603 return self::graphql_type_names(self::read_registry('read_post_type_objects'));
604 }
605
606 /**
607 * GraphQL type names of every taxonomy exposed to GraphQL.
608 *
609 * @return string[]
610 */
611 private static function graphql_taxonomies()
612 {
613 return self::graphql_type_names(self::read_registry('read_taxonomy_objects'));
614 }
615
616 /**
617 * Read one registry seam, treating a failure as an empty registry.
618 *
619 * Same reasoning as register_field(): a pass that cannot even read its
620 * registry must cost only its own fields. Left to register()'s single catch,
621 * a broken get_taxonomies() would abort before the root fields were reached
622 * — and those depend on no registry at all, so there is no reason for them
623 * to share its fate.
624 *
625 * @param string $seam Name of the protected read method to call.
626 * @return mixed Whatever the seam returned, or an empty array on failure.
627 */
628 private static function read_registry($seam)
629 {
630 try {
631 # Called through static::class so a subclass substituting the seam is
632 # honoured, exactly as a direct static:: call would be.
633 return call_user_func(array(static::class, $seam));
634 } catch (Throwable $e) {
635 self::log_failure('registry read via ' . $seam, $e, true);
636
637 return array();
638 }
639 }
640
641 /**
642 * Pull usable `graphql_single_name` values out of a registry read.
643 *
644 * Names are returned as they were found, duplicates and all. De-duplication
645 * belongs to register_fields(), which is the only place that can see across
646 * both registries and the fixed names; doing it here as well would mean
647 * neither guard could be tested, because either alone would satisfy the
648 * assertion.
649 *
650 * Shared by both registries because the property, the validation and the
651 * failure mode are identical; the two registries differ in which function
652 * reads them, which is what the seams above are for.
653 *
654 * @param mixed $objects Registry objects, as WordPress returns them.
655 * @return string[]
656 */
657 private static function graphql_type_names($objects)
658 {
659 if (!is_array($objects) && !($objects instanceof Traversable)) {
660 return array();
661 }
662
663 $names = array();
664
665 foreach ($objects as $object) {
666 if (!is_object($object) || empty($object->graphql_single_name)) {
667 continue;
668 }
669
670 if (!is_string($object->graphql_single_name)) {
671 continue;
672 }
673
674 $names[] = $object->graphql_single_name;
675 }
676
677 return $names;
678 }
679
680 /**
681 * Resolve the field on a post node.
682 *
683 * Returns null rather than raising for anything unexpected. A GraphQL error
684 * here would fail the entire query the frontend used to fetch its content,
685 * not just this field.
686 *
687 * @param mixed $source Node model WPGraphQL is resolving.
688 * @return array|null
689 */
690 public static function resolve($source)
691 {
692 try {
693 $post_id = self::database_id_from_source($source);
694 if ($post_id <= 0) {
695 return null;
696 }
697
698 $payload = static::payload_for_post($post_id);
699
700 return self::answer('post', $post_id, null, $payload, static function () use ($post_id) {
701 return static::payload_for_post($post_id);
702 });
703 } catch (Throwable $e) {
704 self::log_failure('resolve', $e);
705
706 return null;
707 }
708 }
709
710 /**
711 * Resolve the field on a term node.
712 *
713 * @param mixed $source Node model WPGraphQL is resolving.
714 * @return array|null
715 */
716 public static function resolve_term($source)
717 {
718 try {
719 # `term_id` is accepted alongside the model's databaseId because a
720 # filtered install can hand a resolver a plain WP_Term, and the
721 # taxonomy does not have to be passed: for_term() looks the term up
722 # by id and reads the taxonomy off what it finds.
723 $term_id = self::database_id_from_source($source, array('databaseId', 'term_id', 'ID', 'id'));
724 if ($term_id <= 0) {
725 return null;
726 }
727
728 $payload = static::payload_for_term($term_id);
729
730 # The taxonomy is read off the payload rather than off the node: the
731 # surface already looked the term up to build this, and it is the
732 # only place the taxonomy is known for certain.
733 $taxonomy = $payload instanceof Metasync_Headless_Seo_Data
734 ? (string) $payload->get('object_sub_type')
735 : '';
736
737 return self::answer('term', $term_id, $taxonomy, $payload, static function () use ($term_id) {
738 return static::payload_for_term($term_id);
739 });
740 } catch (Throwable $e) {
741 self::log_failure('resolve_term', $e);
742
743 return null;
744 }
745 }
746
747 /**
748 * Resolve the field on a user node.
749 *
750 * @param mixed $source Node model WPGraphQL is resolving.
751 * @return array|null
752 */
753 public static function resolve_author($source)
754 {
755 try {
756 # `userId` is WPGraphQL's older name for the same value and is still
757 # present on the model, deprecated; reading both means an install
758 # pinned to an older release resolves rather than silently nulling.
759 $user_id = self::database_id_from_source($source, array('databaseId', 'userId', 'ID', 'id'));
760 if ($user_id <= 0) {
761 return null;
762 }
763
764 return self::shape_payload(static::payload_for_author($user_id));
765 } catch (Throwable $e) {
766 self::log_failure('resolve_author', $e);
767
768 return null;
769 }
770 }
771
772 /**
773 * Resolve the field on a post-type node.
774 *
775 * @param mixed $source Node model WPGraphQL is resolving.
776 * @return array|null
777 */
778 public static function resolve_content_type($source)
779 {
780 try {
781 $post_type = self::post_type_from_source($source);
782 if ($post_type === '') {
783 return null;
784 }
785
786 # `post` has no archive route of its own. WordPress serves its
787 # listing as the blog index, and get_post_type_archive_link('post')
788 # returns that address rather than an archive of its own — which is
789 # exactly why for_post_type_archive() refuses `post`.
790 #
791 # Routing it here is what stops the homepage being unreachable by
792 # the path the frontend actually uses: on a site showing posts on
793 # the front, `nodeByUri(uri: "/")` returns this very node, and
794 # leaving it null would mean the customer's URI-driven query gets no
795 # SEO for the site's most important page.
796 #
797 # Asked in this order, and neither call needs a config read here:
798 # for_posts_page() answers only when a *distinct* static page holds
799 # the index, and otherwise the index is the front page, which
800 # for_home_page() owns. Each entry point already makes that decision
801 # for itself, so this stays routing rather than a second copy of it.
802 if ($post_type === 'post') {
803 # One call, because the choice between the posts page and the
804 # front page needs the reason behind a null and this layer
805 # cannot see it. Asking for_posts_page() first and falling back
806 # on null conflated "the front page is the blog index" with
807 # "the assigned posts page is a draft", and answered the second
808 # with the front page's own title and canonical.
809 return self::shape_payload(static::payload_for_blog_index());
810 }
811
812 return self::shape_payload(static::payload_for_post_type_archive($post_type));
813 } catch (Throwable $e) {
814 self::log_failure('resolve_content_type', $e);
815
816 return null;
817 }
818 }
819
820 /**
821 * Resolve the front page root field.
822 *
823 * @return array|null
824 */
825 public static function resolve_home_page()
826 {
827 try {
828 return self::shape_payload(static::payload_for_home_page());
829 } catch (Throwable $e) {
830 self::log_failure('resolve_home_page', $e);
831
832 return null;
833 }
834 }
835
836 /**
837 * Resolve the blog index root field.
838 *
839 * @return array|null
840 */
841 public static function resolve_posts_page()
842 {
843 try {
844 return self::shape_payload(static::payload_for_posts_page());
845 } catch (Throwable $e) {
846 self::log_failure('resolve_posts_page', $e);
847
848 return null;
849 }
850 }
851
852 /**
853 * Shape a resolved payload, or null when there is not one.
854 *
855 * Every resolver funnels through here so "the surface refused" and "the
856 * surface returned something unexpected" become the same null in one place
857 * rather than six.
858 *
859 * @param mixed $data
860 * @return array|null
861 */
862 private static function shape_payload($data)
863 {
864 if (!($data instanceof Metasync_Headless_Seo_Data)) {
865 return null;
866 }
867
868 return self::shape($data);
869 }
870
871 /* -----------------------------------------------------------------
872 * Request-driven refresh
873 *
874 * Stored values are what GraphQL publishes. This block is only ever
875 * allowed to make them *newer* — never slower to arrive than the
876 * configured timeout, never absent because a refresh went wrong, and
877 * never accompanied by a reason it went wrong. Anything that fails,
878 * is blocked, or is simply not permitted yields the same answer the
879 * resolver would have given without it.
880 * ----------------------------------------------------------------- */
881
882 /**
883 * Publish one object's SEO, refreshing it first when that is permitted.
884 *
885 * @param string $type 'post' or 'term'.
886 * @param int $id WordPress object ID.
887 * @param string|null $taxonomy Taxonomy name, terms only.
888 * @param mixed $payload Stored payload as already resolved.
889 * @param callable $reread Re-resolves the payload after a write.
890 * @return array|\GraphQL\Deferred|null
891 */
892 private static function answer($type, $id, $taxonomy, $payload, callable $reread)
893 {
894 if (!($payload instanceof Metasync_Headless_Seo_Data)) {
895 # Nothing stored to publish, so nothing to refresh either — a
896 # refresh writes over stored values, it does not invent them.
897 return null;
898 }
899
900 $stored = self::shape($payload);
901
902 # Asked before anything is queued, so a site with the live path off
903 # carries none of the bookkeeping below.
904 if (!static::live_refresh_available()) {
905 return $stored;
906 }
907
908 $key = $type . ':' . (int) $id . (($taxonomy !== null && $taxonomy !== '') ? ':' . $taxonomy : '');
909
910 if (isset(self::$attempted[$key])) {
911 # Already handled once in this request. A query naming the same
912 # object twice — through a node and through a connection, say —
913 # gets that answer again rather than a second call. Note it is the
914 # *published* answer, not the one just re-read: one object must not
915 # appear with two different titles in one response.
916 return self::$published[$key] ?? $stored;
917 }
918
919 self::$requested[$key] = true;
920
921 return static::defer(static function () use ($key, $type, $id, $taxonomy, $stored, $reread) {
922 return self::settle($key, $type, $id, $taxonomy, $stored, $reread, -1, 0);
923 });
924 }
925
926 /**
927 * Wait for the request to stop growing, then refresh if it is small enough.
928 *
929 * The rule is about the request, not about a pass: more than the ceiling
930 * of objects and *none* of them is refreshed. That means the count has to
931 * be final before the first call goes out, and under lazy resolution it is
932 * not final when the first deferred callback runs.
933 *
934 * graphql-php keeps one FIFO queue of deferred callbacks, and completing
935 * one can resolve fields that queue more. A connection is the ordinary
936 * case: the parent's SEO field and the child loader are queued together,
937 * so when the SEO callback runs, the children it will pull in have not
938 * been counted yet. Answering there would refresh three objects out of a
939 * query that turns out to name nine, and would do it or not depending on
940 * whether the client wrote the connection before or after the SEO field.
941 *
942 * So a waiter that finds the count still moving hands its turn back to the
943 * queue and looks again. When the count has held still for
944 * SETTLE_QUIET_TURNS turns, resolution has produced everything it is going
945 * to and the request can be judged. Costs about two extra turns per object
946 * — no network, no database, just the queue that was going to drain anyway.
947 *
948 * @param string $key
949 * @param string $type
950 * @param int $id
951 * @param string|null $taxonomy
952 * @param array $stored
953 * @param callable $reread
954 * @param int $seen Object count at this waiter's previous turn.
955 * @param int $quiet Consecutive turns the count has not moved.
956 * @return array|\GraphQL\Deferred
957 */
958 private static function settle($key, $type, $id, $taxonomy, $stored, callable $reread, $seen, $quiet)
959 {
960 if (self::$verdict === null) {
961 $total = count(self::$requested);
962
963 if ($total !== $seen) {
964 # Still arriving. Nothing can be decided yet, by us or anyone.
965 return static::defer(static function () use ($key, $type, $id, $taxonomy, $stored, $reread, $total) {
966 return self::settle($key, $type, $id, $taxonomy, $stored, $reread, $total, 0);
967 });
968 }
969
970 if ($quiet < self::SETTLE_QUIET_TURNS - 1
971 && (!static::queue_is_empty() || self::$pending_rechecks > 0)) {
972 # Held still, but there is still work queued that could bring
973 # more objects. Take another turn before trusting the count.
974 $next = $quiet + 1;
975
976 return static::defer(static function () use ($key, $type, $id, $taxonomy, $stored, $reread, $total, $next) {
977 return self::settle($key, $type, $id, $taxonomy, $stored, $reread, $total, $next);
978 });
979 }
980
981 # Settled. Judge the request once, for every object in it, so the
982 # answer cannot depend on selection order.
983 self::$verdict = ($total <= self::MAX_SYNC_REFRESH_OBJECTS);
984 }
985
986 return self::refresh_then_publish($key, $type, $id, $taxonomy, $stored, $reread);
987 }
988
989 /**
990 * Run the refresh this object queued, then publish whatever is stored.
991 *
992 * @param string $key
993 * @param string $type
994 * @param int $id
995 * @param string|null $taxonomy
996 * @param array $stored
997 * @param callable $reread
998 * @return array
999 */
1000 private static function refresh_then_publish($key, $type, $id, $taxonomy, $stored, callable $reread)
1001 {
1002 try {
1003 if (isset(self::$attempted[$key])) {
1004 # Claimed by an earlier callback in this same request. answer()
1005 # cannot catch this case: at resolve time no callback has run
1006 # yet, so both selections of the object look unclaimed.
1007 return self::$published[$key] ?? $stored;
1008 }
1009
1010 if (!self::$verdict || self::$refresh_calls >= self::MAX_SYNC_REFRESH_OBJECTS) {
1011 # Either the request named more objects than may be refreshed
1012 # synchronously, or the budget is already spent. Both publish
1013 # what is stored.
1014 self::$attempted[$key] = true;
1015 self::record_history(
1016 '__request__',
1017 'request',
1018 0,
1019 !self::$verdict ? 'request_too_large' : 'budget_exhausted',
1020 count(self::$requested)
1021 );
1022
1023 return $stored;
1024 }
1025
1026 # Marked before the call, not after: if the call throws, this object
1027 # must still not be retried later in the same request.
1028 self::$attempted[$key] = true;
1029 self::$history_started[$key] = microtime(true);
1030
1031 # Charged before the call, not after. A throw must still cost the
1032 # budget, or a request whose refreshes all fail could attempt an
1033 # unbounded number of them.
1034 self::$refresh_calls++;
1035
1036 $outcome = static::refresh_one($type, (int) $id, $taxonomy);
1037 self::record_history($key, $type, $id, $outcome, count(self::$requested));
1038
1039 if ($outcome === Metasync_Headless_Refresh_Job::OUTCOME_FRESH
1040 || $outcome === Metasync_Headless_Refresh_Job::OUTCOME_DISABLED
1041 || $outcome === Metasync_Headless_Refresh_Job::OUTCOME_NO_URL
1042 || $outcome === Metasync_Headless_Refresh_Job::OUTCOME_BREAKER_OPEN
1043 || $outcome === Metasync_Headless_Refresh_Job::OUTCOME_LOCKED
1044 || $outcome === Metasync_Headless_Refresh_Job::OUTCOME_RATE_LIMITED) {
1045 # None of these reached the network: each returns before the
1046 # fetch. Refund the charge, or a run of cooldown hits would
1047 # crowd out the objects that would actually refresh.
1048 self::$refresh_calls--;
1049 }
1050
1051 if ($outcome !== Metasync_Headless_Refresh_Job::OUTCOME_REFRESHED) {
1052 # Fresh, empty, unchanged, locked, rate-limited, breaker-open,
1053 # failed: in every one of those the stored values are still the
1054 # right answer, and which one it was is ours to know, not the
1055 # client's. They are deliberately indistinguishable from here.
1056 return $stored;
1057 }
1058
1059 $refreshed = $reread();
1060 $answer = $refreshed instanceof Metasync_Headless_Seo_Data ? self::shape($refreshed) : $stored;
1061
1062 self::$published[$key] = $answer;
1063
1064 return $answer;
1065 } catch (Throwable $e) {
1066 # This runs from graphql-php's deferred queue, outside the
1067 # resolver's own try. Without this catch a refresh failure would
1068 # reach the executor and fail the whole content query — the one
1069 # thing this class must never do.
1070 #
1071 # static:: rather than self:: because this is the one failure path
1072 # a subclass has to be able to observe: it is the only one that
1073 # does not surface as a null the caller can see.
1074 static::log_failure('refresh of ' . $type . ' ' . (int) $id, $e);
1075
1076 return $stored;
1077 }
1078 }
1079
1080 /**
1081 * Persist one bounded, privacy-safe refresh history row.
1082 *
1083 * @param string $key
1084 * @param string $type
1085 * @param int $id
1086 * @param string $outcome
1087 * @param int $object_count
1088 * @return void
1089 */
1090 private static function record_history($key, $type, $id, $outcome, $object_count)
1091 {
1092 if ($outcome === Metasync_Headless_Refresh_Job::OUTCOME_FRESH
1093 || isset(self::$history_recorded[$key])) {
1094 return;
1095 }
1096
1097 self::$history_recorded[$key] = true;
1098 $started = self::$history_started[$key] ?? microtime(true);
1099 $duration = (int) round((microtime(true) - $started) * 1000);
1100 $reason_map = array(
1101 Metasync_Headless_Refresh_Job::OUTCOME_FRESH => 'timestamp_fresh',
1102 Metasync_Headless_Refresh_Job::OUTCOME_REFRESHED => 'live_success',
1103 Metasync_Headless_Refresh_Job::OUTCOME_UNCHANGED => 'live_unchanged',
1104 Metasync_Headless_Refresh_Job::OUTCOME_EMPTY => 'empty_response',
1105 Metasync_Headless_Refresh_Job::OUTCOME_NO_URL => 'no_frontend_url',
1106 Metasync_Headless_Refresh_Job::OUTCOME_LOCKED => 'refresh_locked',
1107 Metasync_Headless_Refresh_Job::OUTCOME_RATE_LIMITED => 'rate_limited',
1108 Metasync_Headless_Refresh_Job::OUTCOME_BREAKER_OPEN => 'breaker_open',
1109 Metasync_Headless_Refresh_Job::OUTCOME_FAILED => 'transport_failed',
1110 Metasync_Headless_Refresh_Job::OUTCOME_DISABLED => 'refresh_disabled',
1111 'request_too_large' => 'request_too_large',
1112 'budget_exhausted' => 'budget_exhausted',
1113 );
1114 $reason = $reason_map[$outcome] ?? 'refresh_outcome';
1115
1116 Metasync_Headless_Refresh_History::record(
1117 $type,
1118 (int) $id,
1119 $outcome,
1120 $reason,
1121 $duration,
1122 (int) $object_count
1123 );
1124 }
1125
1126 /**
1127 * Has graphql-php run out of deferred work?
1128 *
1129 * A seam because the transport tests drive settling without the real
1130 * must still be able to answer the question.
1131 *
1132 * @return bool
1133 */
1134 protected static function queue_is_empty()
1135 {
1136 if (!class_exists('GraphQL\Executor\Promise\Adapter\SyncPromiseQueue')) {
1137 # Without the queue we cannot tell whether more work is pending, so
1138 # we assume it is: the extra turn is cheap and the alternative is
1139 # judging a request that has not finished arriving.
1140 return false;
1141 }
1142
1143 return \GraphQL\Executor\Promise\Adapter\SyncPromiseQueue::isEmpty();
1144 }
1145
1146 /**
1147 * Is the request-driven refresh path usable at all?
1148 *
1149 * Deferral is part of the answer, not an optimisation: without it the
1150 * object count cannot be known before the first call, and a path that
1151 * cannot enforce the ceiling must not run. A site whose GraphQL cannot
1152 * defer serves stored values, which is what it does today.
1153 *
1154 * @return bool
1155 */
1156 protected static function live_refresh_available()
1157 {
1158 if (!class_exists('Metasync_Headless_Refresh_Job') || !class_exists('GraphQL\Deferred')) {
1159 return false;
1160 }
1161
1162 return (bool) apply_filters('metasync_headless_graphql_live_refresh', true);
1163 }
1164
1165 /**
1166 * Wrap a callback so graphql-php runs it after the current pass is counted.
1167 *
1168 * @param callable $callback
1169 * @return \GraphQL\Deferred
1170 */
1171 protected static function defer(callable $callback)
1172 {
1173 self::$pending_rechecks++;
1174
1175 return new \GraphQL\Deferred(function () use ($callback) {
1176 self::$pending_rechecks = max(0, self::$pending_rechecks - 1);
1177 return $callback();
1178 });
1179 }
1180
1181 /**
1182 * Refresh one object, the single point where transport calls the job.
1183 *
1184 * A seam for the same reason as the resolution seams above: the transport
1185 * tests drive counting, deferral, deduplication and fallback, and none of
1186 * those need a live OTTO round trip to be real.
1187 *
1188 * @param string $type
1189 * @param int $id
1190 * @param string|null $taxonomy
1191 * @return string One of the job's OUTCOME_* constants.
1192 */
1193 protected static function refresh_one($type, $id, $taxonomy)
1194 {
1195 return Metasync_Headless_Refresh_Job::refresh_single_object($type, (int) $id, $taxonomy, 2);
1196 }
1197
1198 /* -----------------------------------------------------------------
1199 * Resolution seams
1200 *
1201 * The single points where transport meets resolution, one per entry
1202 * point. Kept separate so a REST route can share them, so a bulk
1203 * resolver can be substituted for a listing query without touching
1204 * the shaping below, and so the transport tests can drive registration,
1205 * gating, routing and fail-closed behaviour without dragging the whole
1206 * resolver — and the ambient WordPress stubs — in behind them.
1207 * ----------------------------------------------------------------- */
1208
1209 /**
1210 * The resolved payload for one post.
1211 *
1212 * @param int $post_id
1213 * @return Metasync_Headless_Seo_Data|null
1214 */
1215 protected static function payload_for_post($post_id)
1216 {
1217 return Metasync_Headless_Seo_Surface::for_post($post_id);
1218 }
1219
1220 /**
1221 * Resolve a page of post payloads in one pass.
1222 *
1223 * @param int[] $post_ids
1224 * @return array
1225 */
1226 protected static function payload_for_posts(array $post_ids)
1227 {
1228 return Metasync_Headless_Seo_Surface::for_posts($post_ids);
1229 }
1230
1231 /**
1232 * Resolve a post connection's SEO payloads after WPGraphQL has loaded its
1233 * nodes, so the per-node field resolver can reuse the request memo.
1234 *
1235 * WPGraphQL exposes connection IDs/nodes filters, but no field-specific
1236 * batch resolver. This nodes filter is the practical boundary: it sees the
1237 * complete connection page and runs only when metasyncSeo was requested.
1238 *
1239 * @param mixed $nodes Whatever the filter chain passes; guarded at runtime
1240 * because a third-party filter is free to hand back
1241 * something other than the node array.
1242 * @param mixed $resolver
1243 * @return array
1244 */
1245 public static function prime_post_connection_nodes($nodes, $resolver)
1246 {
1247 if (!is_array($nodes) || !is_object($resolver)) {
1248 return $nodes;
1249 }
1250
1251 try {
1252 if (!method_exists($resolver, 'get_loader_name') || $resolver->get_loader_name() !== 'post') {
1253 return $nodes;
1254 }
1255
1256 if (!self::connection_requests_seo($resolver)) {
1257 return $nodes;
1258 }
1259
1260 $ids = array();
1261 foreach ($nodes as $node) {
1262 $id = self::database_id_from_source($node);
1263 if ($id > 0) {
1264 $ids[] = $id;
1265 }
1266 }
1267
1268 if (!empty($ids)) {
1269 static::payload_for_posts($ids);
1270 }
1271 } catch (Throwable $e) {
1272 self::log_failure('connection priming', $e);
1273 }
1274
1275 return $nodes;
1276 }
1277
1278 /**
1279 * @param mixed $resolver
1280 * @return bool
1281 */
1282 private static function connection_requests_seo($resolver)
1283 {
1284 if (!method_exists($resolver, 'get_info')) {
1285 return false;
1286 }
1287
1288 $info = $resolver->get_info();
1289 if (!is_object($info) || !method_exists($info, 'getFieldSelection')) {
1290 return false;
1291 }
1292
1293 return self::selection_contains_field($info->getFieldSelection(5), self::FIELD_NAME);
1294 }
1295
1296 /**
1297 * @param mixed $selection
1298 * @param string $field
1299 * @return bool
1300 */
1301 private static function selection_contains_field($selection, $field)
1302 {
1303 if (!is_array($selection)) {
1304 return false;
1305 }
1306
1307 foreach ($selection as $name => $children) {
1308 if ((string) $name === $field || self::selection_contains_field($children, $field)) {
1309 return true;
1310 }
1311 }
1312
1313 return false;
1314 }
1315
1316 /**
1317 * The resolved payload for one term.
1318 *
1319 * @param int $term_id
1320 * @return Metasync_Headless_Seo_Data|null
1321 */
1322 protected static function payload_for_term($term_id)
1323 {
1324 return Metasync_Headless_Seo_Surface::for_term($term_id);
1325 }
1326
1327 /**
1328 * The resolved payload for one author archive.
1329 *
1330 * @param int $user_id
1331 * @return Metasync_Headless_Seo_Data|null
1332 */
1333 protected static function payload_for_author($user_id)
1334 {
1335 return Metasync_Headless_Seo_Surface::for_author($user_id);
1336 }
1337
1338 /**
1339 * The resolved payload for one post type archive.
1340 *
1341 * @param string $post_type
1342 * @return Metasync_Headless_Seo_Data|null
1343 */
1344 protected static function payload_for_post_type_archive($post_type)
1345 {
1346 return Metasync_Headless_Seo_Surface::for_post_type_archive($post_type);
1347 }
1348
1349 /**
1350 * The resolved payload for the front page.
1351 *
1352 * @return Metasync_Headless_Seo_Data|null
1353 */
1354 protected static function payload_for_home_page()
1355 {
1356 return Metasync_Headless_Seo_Surface::for_home_page();
1357 }
1358
1359 /**
1360 * The resolved payload for the blog index.
1361 *
1362 * @return Metasync_Headless_Seo_Data|null
1363 */
1364 /**
1365 * The resolved payload for whatever serves as the blog index.
1366 *
1367 * @return Metasync_Headless_Seo_Data|null
1368 */
1369 protected static function payload_for_blog_index()
1370 {
1371 return Metasync_Headless_Seo_Surface::for_blog_index();
1372 }
1373
1374 protected static function payload_for_posts_page()
1375 {
1376 return Metasync_Headless_Seo_Surface::for_posts_page();
1377 }
1378
1379 /**
1380 * Map the payload onto the GraphQL field names.
1381 *
1382 * Written out rather than derived from the payload keys so adding a field to
1383 * the resolver cannot silently publish it: anything new has to be added here
1384 * and to the type deliberately. On an endpoint that may have no auth gate,
1385 * that is the property worth having.
1386 *
1387 * @param Metasync_Headless_Seo_Data $data
1388 * @return array
1389 */
1390 private static function shape(Metasync_Headless_Seo_Data $data)
1391 {
1392 $schema = $data->get_schema();
1393
1394 return array(
1395 'title' => $data->get_title(),
1396 'description' => $data->get_description(),
1397 'keywords' => $data->get_keywords(),
1398 'canonical' => $data->get_canonical(),
1399 'robots' => $data->get_robots(),
1400
1401 'ogTitle' => $data->get_og_title(),
1402 'ogDescription' => $data->get_og_description(),
1403 'ogImage' => $data->get_og_image(),
1404 'ogUrl' => $data->get_og_url(),
1405 'ogType' => (string) $data->get('og_type'),
1406 'ogSiteName' => (string) $data->get('og_site_name'),
1407 'ogLocale' => (string) $data->get('og_locale'),
1408
1409 'twitterCard' => (string) $data->get('twitter_card'),
1410 'twitterTitle' => $data->get_twitter_title(),
1411 'twitterDescription' => $data->get_twitter_description(),
1412 'twitterImage' => $data->get_twitter_image(),
1413
1414 # Null rather than "[]" when there is no graph: a frontend testing
1415 # truthiness before writing a script tag should not have to know that
1416 # an empty JSON array means "nothing to render".
1417 'schema' => $schema === array() ? null : wp_json_encode($schema),
1418
1419 'publicUrl' => $data->get_public_url(),
1420 );
1421 }
1422
1423 /**
1424 * Pull a database ID out of whatever WPGraphQL hands the resolver.
1425 *
1426 * The model exposes `databaseId`; older shapes and plain WordPress objects
1427 * expose `ID`. Both are accepted rather than assuming one, since guessing
1428 * wrong means the field silently resolves to null on every request — the
1429 * failure mode that looks exactly like "there is no data".
1430 *
1431 * The property list is a parameter because each node type spells it
1432 * differently, and because a name that means one thing on one model can mean
1433 * something else on another: `id` on a WPGraphQL model is the relay global
1434 * id, a base64 string, so it is read last and only survives the is_numeric()
1435 * test on a plain object that happens to use it.
1436 *
1437 * @param mixed $source
1438 * @param string[] $properties Property names to try, in order.
1439 * @return int
1440 */
1441 private static function database_id_from_source($source, $properties = array('databaseId', 'ID', 'id'))
1442 {
1443 if (is_numeric($source)) {
1444 return (int) $source;
1445 }
1446
1447 if (!is_object($source)) {
1448 return 0;
1449 }
1450
1451 foreach ($properties as $property) {
1452 if (!isset($source->$property)) {
1453 continue;
1454 }
1455
1456 $value = $source->$property;
1457 if (is_numeric($value)) {
1458 return (int) $value;
1459 }
1460 }
1461
1462 return 0;
1463 }
1464
1465 /**
1466 * Pull a post type name out of a ContentType node.
1467 *
1468 * `name` is the WordPress post type key on WPGraphQL's model — not the
1469 * GraphQL type name, which is `graphqlSingleName`. `post_type` is accepted
1470 * as well so a plain registered post type object also works.
1471 *
1472 * A plain string is accepted too, so this reads the same way as the id
1473 * extraction above: whatever came in, either it names a post type or it does
1474 * not.
1475 *
1476 * @param mixed $source
1477 * @return string
1478 */
1479 private static function post_type_from_source($source)
1480 {
1481 if (is_string($source)) {
1482 return trim($source);
1483 }
1484
1485 if (!is_object($source)) {
1486 return '';
1487 }
1488
1489 foreach (array('name', 'post_type') as $property) {
1490 if (!isset($source->$property)) {
1491 continue;
1492 }
1493
1494 $value = $source->$property;
1495 if (is_string($value) && trim($value) !== '') {
1496 return trim($value);
1497 }
1498 }
1499
1500 return '';
1501 }
1502
1503 /**
1504 * Record a failure without surfacing it to the caller.
1505 *
1506 * The whole point of this class is that the frontend never sees an error, so
1507 * a failure that is invisible to us as well would be undiagnosable.
1508 *
1509 * Logging is itself wrapped, and that is not belt-and-braces. This runs
1510 * inside a catch block, and a throw from inside a catch is not caught by the
1511 * try it belongs to — so a logger that fails for any reason (an unwritable
1512 * log path, a missing constant, a full disk) would send the original
1513 * exception straight on to WPGraphQL and fail the entire content query. The
1514 * one thing this class must never do.
1515 *
1516 * The severity arrives as a boolean rather than as a logger constant for the
1517 * same reason. A caller writing `Metasync_Error_Logger::SEVERITY_ERROR` in
1518 * its argument list resolves that class *before* entering this method, so on
1519 * an install where the class is somehow absent the constant lookup throws
1520 * from inside the caller's catch block — the exact shape this method exists
1521 * to prevent, reintroduced by the call to it. Passing a bool means every
1522 * mention of the class stays inside the try below.
1523 *
1524 * @param string $stage Where it happened.
1525 * @param Throwable $e
1526 * @param bool $is_error Log as an error rather than a warning.
1527 * @return void
1528 */
1529 protected static function log_failure($stage, $e, $is_error = false)
1530 {
1531 try {
1532 if (!class_exists('Metasync_Error_Logger')) {
1533 return;
1534 }
1535
1536 Metasync_Error_Logger::log(
1537 Metasync_Error_Logger::CATEGORY_HEADLESS_DELIVERY,
1538 $is_error ? Metasync_Error_Logger::SEVERITY_ERROR : Metasync_Error_Logger::SEVERITY_WARNING,
1539 'Headless GraphQL SEO field failed during ' . $stage,
1540 array(
1541 'stage' => $stage,
1542 'exception' => $e->getMessage(),
1543 )
1544 );
1545 } catch (Throwable $ignored) {
1546 # Nothing left to do: reporting the reporting failure would have the
1547 # same problem. Losing a log line is survivable; breaking the page
1548 # the visitor asked for is not.
1549 return;
1550 }
1551 }
1552 }
1553