PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 All 58 releases
thinkrank / includes / admin / importers / class-slim-seo-schema-converter.php

class-slim-seo-schema-converter.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.14.2, at includes/admin/importers/class-slim-seo-schema-converter.php

1,083 lines 40.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Slim SEO Pro schema converter
5 *
6 * Turns Slim SEO Pro's schema builder entries into ThinkRank Pro Custom
7 * Schema entries: static JSON-LD plus display conditions (#886).
8 *
9 * The two models differ in one way that decides everything here. A Slim SEO
10 * schema is a TEMPLATE: field values carry Slim Twig variables
11 * (`{{ post.title }}`, `{{ current.url }}`) that are resolved on every
12 * request, and its location is a set of rule groups or arbitrary PHP. A
13 * ThinkRank Pro entry is fixed JSON-LD shown where its conditions match. So:
14 *
15 * - A per-post schema (`slim_seo_schema` post meta) only ever renders on
16 * its own post, so its variables are resolved against that post now and
17 * the entry is scoped to it (`singular` = post ID).
18 * - A global schema (`slim_seo_schemas` option) converts when its values
19 * are the same on every page it matches — no variables beyond `site.*`
20 * and references to site-level nodes — and its location maps onto
21 * ThinkRank's targets. One scoped to specific posts is rendered per post.
22 * - Anything else (a per-page template applied to a whole post type, a PHP
23 * location, rule groups ThinkRank cannot express) is returned as
24 * preserved: kept in the snapshot, reported, and blocking cleanup.
25 * - A post with schemas of its own shows no global schema in Slim SEO Pro
26 * unless it opts in, so those posts are excluded from every global entry.
27 * - The types ThinkRank's own graph already emits (WebSite, WebPage,
28 * Organization, Article…) are not imported: emitting both would publish
29 * two competing nodes.
30 *
31 * Entries are shaped to pass ThinkRank Pro's Repository on the first try:
32 * every nested object carries an `@type` (references get the referenced
33 * node's type) and URL properties hold absolute http(s) URLs only.
34 *
35 * @package ThinkRank\Admin\Importers
36 * @since 2.13.0
37 */
38
39 declare(strict_types=1);
40
41 namespace ThinkRank\Admin\Importers;
42
43 if (!defined('ABSPATH')) {
44 exit;
45 }
46
47 /**
48 * Slim SEO Pro schema → ThinkRank Pro Custom Schema entries.
49 *
50 * @since 2.13.0
51 */
52 class Slim_SEO_Schema_Converter {
53
54 /**
55 * Schema types ThinkRank's own graph emits on every page it applies to.
56 * Slim SEO Pro's default set is exactly these, so a site that never
57 * touched the builder imports nothing.
58 */
59 public const NATIVE_TYPES = [
60 'WebSite', 'SearchAction', 'WebPage', 'Organization', 'Person',
61 'BreadcrumbList', 'Article', 'BlogPosting', 'NewsArticle',
62 ];
63
64 /**
65 * Slim SEO Pro's default global schemas by reference id, used to resolve
66 * `{{ schemas.<id> }}` when the option was never saved
67 * (SlimSEOPro\Schema\Defaults::get()).
68 */
69 private const DEFAULT_IDS = [
70 'website' => 'WebSite',
71 'searchaction' => 'SearchAction',
72 'webpage' => 'WebPage',
73 'organization' => 'Organization',
74 'breadcrumblist' => 'BreadcrumbList',
75 'article' => 'Article',
76 'person' => 'Person',
77 ];
78
79 /**
80 * Properties ThinkRank Pro's Repository requires to hold http(s) URLs.
81 */
82 private const URL_PROPS = ['url', 'sameAs', 'logo', 'image', 'contentUrl', 'thumbnailUrl'];
83
84 /**
85 * Fields Slim SEO Pro lets the user repeat ("cloneable"), by id: every one
86 * declared `'cloneable' => true` in its schema specs (181 declarations,
87 * 57 ids, Slim SEO Pro 1.11.1). They are stored as id-keyed maps of
88 * clones — `['q1' => [...], 'q2' => [...]]` — and listed only at render
89 * time (SchemaRenderer::render_array_prop()). A field that is one typed
90 * object in another schema type carries its own `@type` and is left alone.
91 */
92 private const CLONEABLE_FIELDS = [
93 'actor', 'additionalType', 'address', 'alumni', 'applicableCountry', 'availableAtOrFrom', 'brand',
94 'businessDays', 'children', 'colleague', 'contactPoint', 'department', 'distribution',
95 'educationalAlignment', 'eligibleRegion', 'employee', 'estimatedSalary', 'founder', 'funder',
96 'hasPOS', 'hasPart', 'hoursAvailable', 'image', 'includedInDataCatalog', 'includesObject',
97 'ineligibleRegion', 'isRelatedTo', 'itemCondition', 'itemListElement', 'knows', 'knowsAbout',
98 'knowsLanguage', 'location', 'mainContentOfPage', 'mainEntity', 'memberOf', 'offers',
99 'openingHoursSpecification', 'owns', 'performer', 'potentialAction', 'previousStartDate',
100 'recipeIngredient', 'recipeInstructions', 'returnPolicyCountry', 'returnPolicySeasonalOverride',
101 'review', 'sameAs', 'sibling', 'sponsor', 'spouse', 'step', 'suggestedAnswer', 'supply', 'tool',
102 'workExample', 'worksFor',
103 ];
104
105 /**
106 * Fields Slim SEO Pro keeps as strings even when numeric
107 * (Factory\Base::convert_numeric_fields_to_string(), abridged to the
108 * ones its builder offers).
109 */
110 private const STRING_FIELDS = [
111 'telephone', 'faxNumber', 'postalCode', 'sku', 'mpn', 'gtin', 'gtin8', 'gtin12',
112 'gtin13', 'gtin14', 'isbn', 'issn', 'identifier', 'productID', 'priceRange',
113 'vatID', 'taxID', 'duns', 'leiCode', 'globalLocationNumber', 'size', 'servingSize',
114 ];
115
116 /**
117 * Guard against a pathological nesting in foreign data.
118 */
119 private const MAX_DEPTH = 32;
120
121 /**
122 * Global schemas, active only, keyed by their Slim SEO key.
123 *
124 * @var array<string,array>
125 */
126 private array $globals;
127
128 /**
129 * Reference id (`{{ schemas.<id> }}`) => schema type, for globals.
130 *
131 * @var array<string,string>
132 */
133 private array $global_ids = [];
134
135 /**
136 * @param mixed $option Raw `slim_seo_schemas` option, or null when never saved
137 */
138 public function __construct($option) {
139 $this->globals = is_array($option) ? $this->active($option) : [];
140
141 if (!is_array($option)) {
142 $this->global_ids = self::DEFAULT_IDS;
143 }
144 foreach ($this->globals as $schema) {
145 $this->global_ids[$this->reference_id($schema)] = (string) ($schema['type'] ?? '');
146 }
147 }
148
149 // -------------------------------------------------------------------------
150 // Per-post schemas
151 // -------------------------------------------------------------------------
152
153 /**
154 * Entries for one post's own schemas.
155 *
156 * @param int $post_id Post ID
157 * @param mixed $meta Raw `slim_seo_schema` post meta
158 * @param array $slim_seo The post's rendered Slim SEO fields (`slim_seo.*` variables)
159 * @return array<int,array> Canonical custom schema entries
160 */
161 public function convert_post(int $post_id, $meta, array $slim_seo = []): array {
162 $schemas = $this->active($this->post_schema_list($meta));
163 if (empty($schemas)) {
164 return [];
165 }
166
167 $context = $this->post_context($post_id, $schemas, $slim_seo);
168 $title = (string) get_the_title($post_id);
169
170 $entries = [];
171 foreach ($schemas as $key => $schema) {
172 $type = (string) ($schema['type'] ?? '');
173 if ($type === '' || in_array($type, self::NATIVE_TYPES, true)) {
174 continue;
175 }
176
177 $json = $this->render_json($schema, $context);
178 if ($json === null) {
179 continue;
180 }
181
182 $entries[] = [
183 'key' => 'post:' . $post_id . ':' . $key,
184 'title' => sprintf('Slim SEO: %s — %s', $this->label($schema), $title !== '' ? $title : '#' . $post_id),
185 'enabled' => true,
186 'json' => $json,
187 'conditions' => ['include' => [['target' => 'singular', 'value' => (string) $post_id]], 'exclude' => []],
188 ];
189 }
190
191 return $entries;
192 }
193
194 /**
195 * The stored per-post value as a key => schema list. The oldest format
196 * was one schema stored directly (Post::get_schemas()).
197 *
198 * @param mixed $meta Raw post meta
199 * @return array<string,array>
200 */
201 private function post_schema_list($meta): array {
202 if (!is_array($meta) || empty($meta)) {
203 return [];
204 }
205 if (isset($meta['type'])) {
206 return ['schema' => $meta];
207 }
208
209 return $meta;
210 }
211
212 // -------------------------------------------------------------------------
213 // Global schemas
214 // -------------------------------------------------------------------------
215
216 /**
217 * Whether a post's stored schemas include at least one active one.
218 *
219 * @param mixed $meta Raw `slim_seo_schema` post meta (unserialized)
220 */
221 public static function has_active_schemas($meta): bool {
222 if (!is_array($meta) || empty($meta)) {
223 return false;
224 }
225 $converter = new self([]);
226
227 return !empty($converter->active($converter->post_schema_list($meta)));
228 }
229
230 /**
231 * Entries for the global schemas, and the ones that cannot be converted.
232 *
233 * @param int[] $suppressed_post_ids Posts that show no global schema: they
234 * have schemas of their own and did not
235 * opt in to the globals as well.
236 * @return array{entries: array<int,array>, preserved: array<int,array>}
237 */
238 public function convert_globals(array $suppressed_post_ids = []): array {
239 $entries = [];
240 $preserved = [];
241 $suppressed_post_ids = array_values(array_unique(array_filter(array_map('intval', $suppressed_post_ids))));
242 $suppressed_rules = array_map(
243 static fn(int $post_id): array => ['target' => 'singular', 'value' => (string) $post_id],
244 $suppressed_post_ids
245 );
246
247 foreach ($this->globals as $key => $schema) {
248 $type = (string) ($schema['type'] ?? '');
249 if ($type === '' || in_array($type, self::NATIVE_TYPES, true)) {
250 continue;
251 }
252 // Slim SEO Pro renders a schema with no location nowhere
253 // (Factory\Base::validate()); there is nothing to carry.
254 if (empty($schema['location']) || !is_array($schema['location'])) {
255 continue;
256 }
257
258 $include = $this->map_location($schema['location']);
259 $exclude = $this->map_location(is_array($schema['exclude'] ?? null) ? $schema['exclude'] : [], true);
260 if ($exclude !== null) {
261 $exclude = array_merge($exclude, $suppressed_rules);
262 }
263
264 if ($include === null || $exclude === null) {
265 $preserved[] = $this->preserve($key, $schema, 'Its location rules have no ThinkRank equivalent (PHP code, an author/date/search archive, a term-based post rule, or several rule groups).');
266 continue;
267 }
268
269 if ($this->is_static($schema)) {
270 $json = $this->render_json($schema, $this->site_context());
271 if ($json !== null) {
272 $entries[] = $this->global_entry($key, $schema, $json, $include, $exclude);
273 }
274 continue;
275 }
276
277 // A per-page template bound to specific posts can be rendered for
278 // each of them; bound to a whole post type, it cannot.
279 $post_ids = $this->only_singular_posts($include);
280 if ($post_ids === null) {
281 $preserved[] = $this->preserve($key, $schema, 'It takes its values from each page it is shown on, and Custom Schema entries are fixed JSON-LD.');
282 continue;
283 }
284
285 foreach ($post_ids as $post_id) {
286 // The post replaces the globals with its own schemas.
287 if (in_array($post_id, $suppressed_post_ids, true)) {
288 continue;
289 }
290 $json = $this->render_json($schema, $this->post_context($post_id, [], []));
291 if ($json === null) {
292 continue;
293 }
294 $entry = $this->global_entry($key . ':' . $post_id, $schema, $json, [['target' => 'singular', 'value' => (string) $post_id]], $exclude);
295 $entry['title'] .= ': ' . (string) get_the_title($post_id);
296 $entries[] = $entry;
297 }
298 }
299
300 return ['entries' => $entries, 'preserved' => $preserved];
301 }
302
303 /**
304 * @param string $key Slim SEO schema key
305 * @param array $schema Schema
306 * @param string $json Rendered JSON-LD
307 * @param array $includes ThinkRank include rules
308 * @param array $exclude ThinkRank exclude rules
309 */
310 private function global_entry(string $key, array $schema, string $json, array $includes, array $exclude): array {
311 return [
312 'key' => 'global:' . $key,
313 'title' => 'Slim SEO: ' . $this->label($schema),
314 'enabled' => true,
315 'json' => $json,
316 'conditions' => ['include' => $includes, 'exclude' => $exclude],
317 ];
318 }
319
320 /**
321 * A preserved (unconverted) schema, as the snapshot keeps it.
322 *
323 * @param string $key Slim SEO schema key
324 * @param array $schema Schema
325 * @param string $reason Why it was not converted
326 */
327 private function preserve(string $key, array $schema, string $reason): array {
328 return [
329 'key' => $key,
330 'type' => (string) ($schema['type'] ?? ''),
331 'label' => $this->label($schema),
332 'reason' => $reason,
333 'schema' => $schema,
334 ];
335 }
336
337 /**
338 * Whether a schema renders the same on every page: no variable beyond
339 * `site.*` and references to site-level nodes. Its `@id` is excluded —
340 * the default `{{ current.url }}#id` is replaced with a site-level id.
341 *
342 * @param array $schema Schema
343 */
344 private function is_static(array $schema): bool {
345 $fields = is_array($schema['fields'] ?? null) ? $schema['fields'] : [];
346 unset($fields['@id'], $fields['_label']);
347 if (($schema['type'] ?? '') === 'CustomJsonLd') {
348 $fields = ['code' => (string) ($fields['code'] ?? '')];
349 }
350
351 $static = true;
352 array_walk_recursive($fields, function ($value) use (&$static): void {
353 if (!$static || !is_string($value) || strpos($value, '{{') === false) {
354 return;
355 }
356 preg_match_all('/\{\{\s*([^}\s]+?)\s*\}\}/', $value, $matches);
357 foreach ($matches[1] as $path) {
358 if (strpos($path, 'site.') === 0) {
359 continue;
360 }
361 if (strpos($path, 'schemas.') === 0 && in_array($this->global_ids[substr($path, 8)] ?? '', ['WebSite', 'Organization'], true)) {
362 continue;
363 }
364 $static = false;
365 }
366 });
367
368 return $static;
369 }
370
371 /**
372 * Post IDs when every include rule names one post, else null.
373 *
374 * @param array $includes ThinkRank include rules
375 * @return int[]|null
376 */
377 private function only_singular_posts(array $includes): ?array {
378 $ids = [];
379 foreach ($includes as $rule) {
380 if (($rule['target'] ?? '') !== 'singular' || (int) ($rule['value'] ?? 0) <= 0) {
381 return null;
382 }
383 $ids[] = (int) $rule['value'];
384 }
385
386 return empty($ids) ? null : $ids;
387 }
388
389 // -------------------------------------------------------------------------
390 // Locations
391 // -------------------------------------------------------------------------
392
393 /**
394 * Slim SEO location → ThinkRank rules, or null when not expressible.
395 *
396 * Slim SEO requires EVERY rule group to match and ANY rule in a group;
397 * ThinkRank OR-s its rules. One group maps exactly, several do not.
398 *
399 * @param array $location Slim SEO location (or exclude) settings
400 * @param bool $is_exclude Whether this is the exclude side (empty = none)
401 * @return array<int,array{target:string,value:string}>|null
402 */
403 private function map_location(array $location, bool $is_exclude = false): ?array {
404 $type = (string) ($location['type'] ?? '');
405
406 if ($type === '') {
407 return $is_exclude ? [] : null;
408 }
409 if ($type === 'site') {
410 return [['target' => 'entire_site', 'value' => '']];
411 }
412 if ($type !== 'singular' && $type !== 'archive') {
413 return null;
414 }
415
416 $groups = is_array($location["{$type}_locations"] ?? null) ? array_values($location["{$type}_locations"]) : [];
417 if (count($groups) !== 1 || !is_array($groups[0]) || empty($groups[0])) {
418 return null;
419 }
420
421 $rules = [];
422 foreach ($groups[0] as $rule) {
423 $mapped = is_array($rule) ? $this->map_rule($type, $rule) : null;
424 if ($mapped === null) {
425 return null;
426 }
427 $rules[] = $mapped;
428 }
429
430 return $rules;
431 }
432
433 /**
434 * One Slim SEO rule (`name` = "{type}:{subtype}", `value` = "all" or an ID).
435 *
436 * @param string $location_type singular | archive
437 * @param array $rule Rule
438 * @return array{target:string,value:string}|null
439 */
440 private function map_rule(string $location_type, array $rule): ?array {
441 $parts = explode(':', (string) ($rule['name'] ?? ''), 2);
442 if (count($parts) !== 2) {
443 return null;
444 }
445 [$type, $subtype] = $parts;
446 $value = (string) ($rule['value'] ?? 'all');
447 $all = $value === 'all' || $value === '';
448
449 if ($location_type === 'singular') {
450 if ($type === 'general') {
451 return ['target' => 'singular', 'value' => '0'];
452 }
453 // "{post_type}:post" picks posts; "{post_type}:{taxonomy}" filters
454 // by term, which ThinkRank has no singular target for.
455 if ($subtype !== 'post') {
456 return null;
457 }
458
459 return $all
460 ? ['target' => 'post_type', 'value' => $type]
461 : ['target' => 'singular', 'value' => (string) (int) $value];
462 }
463
464 if ($type === 'general') {
465 return $subtype === 'all' || $subtype === '' ? ['target' => 'archive', 'value' => 'any'] : null;
466 }
467 if ($subtype === 'archive') {
468 return ['target' => 'archive', 'value' => $type];
469 }
470
471 return ['target' => 'taxonomy', 'value' => $subtype . ':' . ($all ? '0' : (string) (int) $value)];
472 }
473
474 // -------------------------------------------------------------------------
475 // Rendering
476 // -------------------------------------------------------------------------
477
478 /**
479 * Render one schema to a JSON-LD string, or null when nothing is left.
480 *
481 * Mirrors PropParser::parse() (the `@type`, the `@id`, the custom key/value
482 * fields) and SchemaRenderer::render() (variables, Cleaner rules).
483 *
484 * @param array $schema Schema
485 * @param array $context Variable data tree
486 */
487 private function render_json(array $schema, array $context): ?string {
488 $type = (string) ($schema['type'] ?? '');
489
490 if ($type === 'CustomJsonLd') {
491 $nodes = $this->custom_json_ld($schema, $context);
492 } else {
493 $node = $this->clean($this->render_value($this->normalize_clones($this->parse_props($schema, $context)), $context), true);
494 $nodes = $node === [] ? [] : [$node];
495 }
496
497 $nodes = array_values(array_filter($nodes, static fn($n): bool => is_array($n) && !empty($n['@type'])));
498 if (empty($nodes)) {
499 return null;
500 }
501
502 $document = count($nodes) === 1
503 ? ['@context' => 'https://schema.org'] + $nodes[0]
504 : ['@context' => 'https://schema.org', '@graph' => $nodes];
505
506 $json = wp_json_encode($document, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
507
508 return is_string($json) ? $json : null;
509 }
510
511 /**
512 * The builder's props as one node: `@type`, the `@id` and the custom
513 * key/value fields folded in (dotted keys nest).
514 *
515 * @param array $schema Schema
516 * @param array $context Variable data tree
517 */
518 private function parse_props(array $schema, array $context): array {
519 $fields = is_array($schema['fields'] ?? null) ? $schema['fields'] : [];
520 $custom = is_array($fields['custom'] ?? null) ? $fields['custom'] : [];
521 unset($fields['_label'], $fields['custom']);
522
523 $id = '';
524 $extra = [];
525 foreach ($custom as $row) {
526 $key = is_array($row) ? trim((string) ($row['key'] ?? '')) : '';
527 $value = is_array($row) ? ($row['value'] ?? '') : '';
528 if ($key === '' || $value === '' || $value === null) {
529 continue;
530 }
531 if ($key === '@id') {
532 $id = (string) $value;
533 continue;
534 }
535 $extra[$key] = $value;
536 }
537
538 if ($id === '') {
539 $id = (string) ($fields['@id'] ?? '{{ current.url }}#{{ id }}');
540 }
541 $fields['@id'] = str_replace('{{ id }}', $this->reference_id($schema), $id);
542
543 $node = array_merge(['@type' => (string) ($schema['type'] ?? '')], $fields);
544 foreach ($extra as $path => $value) {
545 $this->set_path($node, (string) $path, $value);
546 }
547
548 return $node;
549 }
550
551 /**
552 * Turn Slim SEO Pro's stored clone maps into lists, as its renderer does.
553 *
554 * A clone map is an array, not a list, and has no `@type` of its own, so
555 * left as it is clean() would drop it as an untyped nested object — which
556 * removed every repeatable property and every FAQPage outright (#896).
557 * Only builder fields are touched; Custom JSON-LD carries real JSON.
558 *
559 * @param array $node Node or nested value
560 * @param int $depth Recursion depth
561 * @return array
562 */
563 private function normalize_clones(array $node, int $depth = 0): array {
564 if ($depth > self::MAX_DEPTH) {
565 return $node;
566 }
567
568 foreach ($node as $key => $value) {
569 if (!is_array($value)) {
570 continue;
571 }
572 if (is_string($key) && in_array($key, self::CLONEABLE_FIELDS, true)
573 && !self::is_list($value) && !isset($value['@type']) && !isset($value['@id'])) {
574 $value = array_values($value);
575 }
576 $node[$key] = $this->normalize_clones($value, $depth + 1);
577 }
578
579 return $node;
580 }
581
582 /**
583 * Nodes from a CustomJsonLd schema: the JSON between the first `{` and the
584 * last `}` (users paste whole <script> tags), its `@graph` unwrapped.
585 *
586 * @param array $schema Schema
587 * @param array $context Variable data tree
588 * @return array<int,array>
589 */
590 private function custom_json_ld(array $schema, array $context): array {
591 $code = (string) ($schema['fields']['code'] ?? '');
592 $start = strpos($code, '{');
593 $end = strrpos($code, '}');
594 if ($start === false || $end === false || $end < $start) {
595 return [];
596 }
597
598 $decoded = json_decode(substr($code, $start, $end - $start + 1), true);
599 if (!is_array($decoded)) {
600 return [];
601 }
602 if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
603 $decoded = $decoded['@graph'];
604 }
605 $nodes = array_key_exists('@type', $decoded) || !self::is_list($decoded) ? [$decoded] : $decoded;
606
607 $out = [];
608 foreach ($nodes as $node) {
609 if (!is_array($node)) {
610 continue;
611 }
612 unset($node['@context']);
613 $node = $this->clean($this->render_value($node, $context), true);
614 if ($node !== []) {
615 $out[] = $node;
616 }
617 }
618
619 return $out;
620 }
621
622 /**
623 * Resolve variables through a value, recursively.
624 *
625 * @param mixed $value Prop value
626 * @param array $context Variable data tree
627 * @param string $key Prop key (decides numeric conversion)
628 * @param int $depth Recursion depth
629 * @return mixed
630 */
631 private function render_value($value, array $context, string $key = '', int $depth = 0) {
632 if ($depth > self::MAX_DEPTH) {
633 return null;
634 }
635 if (is_array($value)) {
636 $out = [];
637 foreach ($value as $k => $v) {
638 $out[$k] = $this->render_value($v, $context, is_string($k) ? $k : $key, $depth + 1);
639 }
640
641 return $out;
642 }
643 if (!is_string($value)) {
644 return $value;
645 }
646
647 if (strpos($value, '{{') === false) {
648 return $this->normalize($value, $key);
649 }
650
651 preg_match_all('/\{\{\s*([^}\s]+?)\s*\}\}/', $value, $matches);
652
653 // One variable standing alone may resolve to a list (categories,
654 // images) or to a schema reference; keep its shape.
655 if (count($matches[0]) === 1 && trim($value) === $matches[0][0]) {
656 $resolved = $this->lookup($context, $matches[1][0]);
657 if (is_array($resolved)) {
658 return $resolved;
659 }
660
661 return $this->normalize((string) ($resolved ?? ''), $key);
662 }
663
664 $replacements = [];
665 foreach ($matches[0] as $i => $token) {
666 $resolved = $this->lookup($context, $matches[1][$i]);
667 $replacements[$token] = is_array($resolved)
668 ? implode(', ', array_filter($resolved, 'is_scalar'))
669 : (string) ($resolved ?? '');
670 }
671
672 return $this->normalize(strtr($value, $replacements), $key);
673 }
674
675 /**
676 * Slim SEO Pro's Normalizer: tags out, whitespace collapsed, numeric text
677 * to a number except where the field is a string by nature.
678 *
679 * @param string $text Value
680 * @param string $key Prop key
681 * @return string|int|float
682 */
683 private function normalize(string $text, string $key) {
684 $text = trim((string) preg_replace('/\s+/', ' ', wp_strip_all_tags($text)));
685
686 if ($text !== '' && is_numeric($text) && !in_array($key, self::STRING_FIELDS, true)) {
687 return $text + 0;
688 }
689
690 return $text;
691 }
692
693 /**
694 * Drop what Slim SEO's Cleaner drops, and what ThinkRank Pro's Repository
695 * would reject: empty values, untyped nested objects, URL properties that
696 * are not absolute http(s) URLs, a FAQ without answered questions, a
697 * rating without a value and a count, and a node with nothing but its
698 * `@type` and `@id`.
699 *
700 * @param mixed $node Rendered value
701 * @param bool $is_root Whether this is a top-level node
702 * @return mixed
703 */
704 private function clean($node, bool $is_root = false) {
705 if (!is_array($node)) {
706 return $node;
707 }
708
709 $is_list = self::is_list($node);
710 $out = [];
711 foreach ($node as $key => $value) {
712 if (in_array($key, self::URL_PROPS, true) && !is_array($value)) {
713 $value = $this->is_url($value) ? $value : null;
714 } elseif (in_array($key, self::URL_PROPS, true) && self::is_list($value)) {
715 $value = array_values(array_filter($value, fn($v): bool => is_array($v) || $this->is_url($v)));
716 }
717
718 $value = $this->clean($value);
719
720 if ($value === null || $value === '' || $value === []) {
721 continue;
722 }
723 if (is_array($value) && !self::is_list($value) && empty($value['@type'])) {
724 // ThinkRank Pro requires a type on every nested object.
725 continue;
726 }
727 if (is_array($value) && !self::is_list($value) && $key !== '' && $this->only_identity($value) && empty($value['@id'])) {
728 continue;
729 }
730 if ($key === 'aggregateRating' && is_array($value)
731 && (empty($value['ratingValue']) || (empty($value['reviewCount']) && empty($value['ratingCount'])))) {
732 continue;
733 }
734 $out[$key] = $value;
735 }
736
737 if ($is_list) {
738 // A repeated value whose variable resolved to several values
739 // (`{{ post.categories }}`) is merged into the one list, as Slim
740 // SEO Pro's Arr::flatten() does.
741 $flat = [];
742 foreach ($out as $value) {
743 if (is_array($value) && self::is_list($value) && $value === array_filter($value, 'is_scalar')) {
744 array_push($flat, ...$value);
745 } else {
746 $flat[] = $value;
747 }
748 }
749 $out = $flat;
750 }
751
752 if (!$is_list && ($out['@type'] ?? '') === 'FAQPage') {
753 $questions = array_values(array_filter(
754 is_array($out['mainEntity'] ?? null) ? $out['mainEntity'] : [],
755 static fn($q): bool => is_array($q) && !empty($q['name']) && !empty($q['acceptedAnswer']['text'])
756 ));
757 if (empty($questions)) {
758 return [];
759 }
760 $out['mainEntity'] = $questions;
761 }
762
763 if ($is_root && !$is_list && $this->only_identity($out)) {
764 return [];
765 }
766
767 return $out;
768 }
769
770 /**
771 * array_is_list() without requiring PHP 8.1.
772 *
773 * @param array $value Array
774 */
775 private static function is_list(array $value): bool {
776 return $value === [] || array_keys($value) === range(0, count($value) - 1);
777 }
778
779 /**
780 * Whether a node holds nothing but `@type` / `@id`.
781 *
782 * @param array $node Node
783 */
784 private function only_identity(array $node): bool {
785 return empty(array_diff(array_keys($node), ['@type', '@id']));
786 }
787
788 /**
789 * Whether a value is an absolute http(s) URL.
790 *
791 * @param mixed $value Candidate
792 */
793 private function is_url($value): bool {
794 return \ThinkRank\Core\Url_Validator::is_http_url($value);
795 }
796
797 // -------------------------------------------------------------------------
798 // Data trees
799 // -------------------------------------------------------------------------
800
801 /**
802 * Data for a schema that renders the same everywhere.
803 */
804 private function site_context(): array {
805 return [
806 'site' => $this->site_data(),
807 'current' => ['url' => home_url('/')],
808 'schemas' => $this->references([], home_url('/'), true),
809 ];
810 }
811
812 /**
813 * Data for one post, mirroring SlimSEOPro\Schema\Renderer\Data::collect()
814 * as that post's own page would see it. `user.*` is the visitor and
815 * resolves to nothing, as it does for one who is not logged in.
816 *
817 * @param int $post_id Post ID
818 * @param array $post_schemas The post's own schemas, for references
819 * @param array $slim_seo The post's rendered Slim SEO fields
820 */
821 private function post_context(int $post_id, array $post_schemas, array $slim_seo): array {
822 $post = get_post($post_id);
823 if (!$post) {
824 return $this->site_context();
825 }
826
827 $url = (string) get_permalink($post);
828 $content = trim((string) preg_replace('/\s+/', ' ', wp_strip_all_tags(strip_shortcodes((string) $post->post_content))));
829
830 $tax = [];
831 if (function_exists('get_object_taxonomies')) {
832 foreach ((array) get_object_taxonomies((string) $post->post_type, 'names') as $taxonomy) {
833 $tax[str_replace('-', '_', (string) $taxonomy)] = $this->term_names($post_id, (string) $taxonomy);
834 }
835 }
836
837 $custom_field = [];
838 foreach ((array) get_post_meta($post_id) as $meta_key => $values) {
839 // Any plugin's meta can sit on the post: never instantiate objects
840 // out of it. Arrays are kept so `custom_field.a.b` paths resolve.
841 $raw = is_array($values) ? reset($values) : '';
842 $custom_field[(string) $meta_key] = is_string($raw) && is_serialized($raw)
843 ? Safe_Unserializer::unserialize($raw, '')
844 : $raw;
845 }
846
847 $current_url = trailingslashit(strtok(strtok($url, '#'), '?') ?: $url);
848
849 return [
850 'post' => [
851 'ID' => $post_id,
852 'title' => (string) $post->post_title,
853 'excerpt' => (string) $post->post_excerpt,
854 'content' => $content,
855 'url' => $url,
856 'slug' => (string) ($post->post_name ?? ''),
857 'date' => $this->iso_date((string) ($post->post_date_gmt ?? '')),
858 'modified_date' => $this->iso_date((string) ($post->post_modified_gmt ?? '')),
859 'thumbnail' => function_exists('get_the_post_thumbnail_url') ? (string) get_the_post_thumbnail_url($post_id, 'full') : '',
860 'comment_count' => (int) ($post->comment_count ?? 0),
861 'tags' => $this->term_names($post_id, 'post_tag'),
862 'categories' => $this->term_names($post_id, 'category'),
863 'custom_field' => $custom_field,
864 'tax' => $tax,
865 'word_count' => str_word_count($content),
866 ],
867 'author' => $this->author_data((int) ($post->post_author ?? 0)),
868 'user' => [],
869 'term' => [],
870 'site' => $this->site_data(),
871 'current' => ['url' => $current_url, 'title' => (string) ($slim_seo['title'] ?? $post->post_title)],
872 'slim_seo' => $slim_seo,
873 'schemas' => $this->references($post_schemas, $current_url, false, $url),
874 ];
875 }
876
877 /**
878 * `{{ schemas.<id> }}` targets: a reference carrying the node's `@type`
879 * (ThinkRank Pro requires one on every nested object) and the `@id`
880 * ThinkRank's own graph uses for the nodes it emits, or Slim SEO's
881 * `{current url}#{id}` for the rest.
882 *
883 * @param array $post_schemas The post's own schemas
884 * @param string $current_url The page the reference renders on
885 * @param bool $site_level Whether the entry renders site-wide
886 * @param string $permalink The post's permalink exactly as WordPress
887 * builds it: Schema_Graph keys its page-level
888 * ids off that, trailing slash or not
889 * @return array<string,array{@id:string,@type:string}>
890 */
891 private function references(array $post_schemas, string $current_url, bool $site_level, string $permalink = ''): array {
892 $ids = $this->global_ids;
893 foreach ($this->active($post_schemas) as $schema) {
894 $ids[$this->reference_id($schema)] = (string) ($schema['type'] ?? '');
895 }
896
897 $page = $permalink !== '' ? $permalink : $current_url;
898 $native = [
899 'WebSite' => home_url('/#website'),
900 'Organization' => home_url('/#organization'),
901 'Person' => home_url('/#person'),
902 'WebPage' => $page . '#webpage',
903 'BreadcrumbList' => $page . '#breadcrumb',
904 ];
905
906 $references = [];
907 foreach ($ids as $id => $type) {
908 if ($type === '') {
909 continue;
910 }
911 $references[$id] = [
912 '@type' => $type,
913 '@id' => $native[$type] ?? (($site_level ? home_url('/') : $current_url) . '#' . $id),
914 ];
915 }
916
917 return $references;
918 }
919
920 /**
921 * `site.*` (Renderer\Data::get_site_data()).
922 */
923 private function site_data(): array {
924 return [
925 'title' => (string) get_bloginfo('name'),
926 'description' => (string) get_bloginfo('description'),
927 'url' => home_url('/'),
928 'language' => str_replace('_', '-', (string) get_locale()),
929 'icon' => function_exists('get_site_icon_url') ? (string) get_site_icon_url() : '',
930 ];
931 }
932
933 /**
934 * `author.*` (Renderer\Data::get_user()).
935 *
936 * @param int $user_id User ID
937 */
938 private function author_data(int $user_id): array {
939 $user = $user_id > 0 ? get_userdata($user_id) : false;
940 if (!$user) {
941 return [];
942 }
943
944 return [
945 'ID' => (int) $user->ID,
946 'first_name' => (string) ($user->first_name ?? ''),
947 'last_name' => (string) ($user->last_name ?? ''),
948 'display_name' => (string) ($user->display_name ?? ''),
949 'nickname' => (string) ($user->nickname ?? ''),
950 'url' => (string) ($user->user_url ?? ''),
951 'nicename' => (string) ($user->user_nicename ?? ''),
952 'description' => (string) ($user->description ?? ''),
953 'posts_url' => function_exists('get_author_posts_url') ? (string) get_author_posts_url((int) $user->ID) : '',
954 'avatar' => function_exists('get_avatar_url') ? (string) get_avatar_url((int) $user->ID) : '',
955 ];
956 }
957
958 // -------------------------------------------------------------------------
959 // Helpers
960 // -------------------------------------------------------------------------
961
962 /**
963 * Active schemas only (a missing `active` flag means active), keyed.
964 *
965 * @param array $schemas Schemas
966 * @return array<string,array>
967 */
968 private function active(array $schemas): array {
969 $active = [];
970 foreach ($schemas as $key => $schema) {
971 if (!is_array($schema)) {
972 continue;
973 }
974 $flag = $schema['active'] ?? true;
975 if ($flag === true || $flag === 'true' || $flag === 1 || $flag === '1') {
976 $active[(string) $key] = $schema;
977 }
978 }
979
980 return $active;
981 }
982
983 /**
984 * The id other schemas reference this one by: its label, or its type,
985 * sanitised the way PropParser::sanitize_id() does it.
986 *
987 * @param array $schema Schema
988 */
989 private function reference_id(array $schema): string {
990 $label = (string) ($schema['fields']['_label'] ?? ($schema['type'] ?? ''));
991 $id = sanitize_title($label);
992 $id = (string) preg_replace('/[^a-z0-9_]/', '_', $id);
993 $id = (string) preg_replace('/[ _]{2,}/', '_', $id);
994 $id = trim($id, '_');
995 $id = (string) preg_replace('/^\d+/', '', $id);
996
997 return trim($id, '_');
998 }
999
1000 /**
1001 * A schema's display name: its label, else its type.
1002 *
1003 * @param array $schema Schema
1004 */
1005 private function label(array $schema): string {
1006 $label = trim((string) ($schema['fields']['_label'] ?? ''));
1007 if ($label !== '') {
1008 return $label;
1009 }
1010
1011 $type = (string) ($schema['type'] ?? '');
1012
1013 return $type === 'CustomJsonLd' ? 'Custom JSON-LD' : ($type !== '' ? $type : 'Schema');
1014 }
1015
1016 /**
1017 * Walk a dotted path through nested arrays.
1018 *
1019 * @param array $context Data tree
1020 * @param string $path e.g. "post.custom_field.price"
1021 * @return mixed|null
1022 */
1023 private function lookup(array $context, string $path) {
1024 $node = $context;
1025 foreach (explode('.', $path) as $segment) {
1026 if (!is_array($node) || !array_key_exists($segment, $node)) {
1027 return null;
1028 }
1029 $node = $node[$segment];
1030 }
1031
1032 return $node;
1033 }
1034
1035 /**
1036 * Set a dotted path in a nested array (Arr::undot()).
1037 *
1038 * @param array $node Target
1039 * @param string $path e.g. "offers.price"
1040 * @param mixed $value Value
1041 */
1042 private function set_path(array &$node, string $path, $value): void {
1043 $ref = &$node;
1044 foreach (explode('.', $path) as $segment) {
1045 if (!isset($ref[$segment]) || !is_array($ref[$segment])) {
1046 $ref[$segment] = [];
1047 }
1048 $ref = &$ref[$segment];
1049 }
1050 $ref = $value;
1051 }
1052
1053 /**
1054 * Term names for a post in one taxonomy.
1055 *
1056 * @param int $post_id Post ID
1057 * @param string $taxonomy Taxonomy
1058 * @return string[]
1059 */
1060 private function term_names(int $post_id, string $taxonomy): array {
1061 if (!function_exists('get_the_terms')) {
1062 return [];
1063 }
1064 $terms = get_the_terms($post_id, $taxonomy);
1065
1066 return is_array($terms) ? array_values(array_map(static fn($t): string => (string) $t->name, $terms)) : [];
1067 }
1068
1069 /**
1070 * A stored GMT datetime as ISO 8601, as Slim SEO Pro prints dates.
1071 *
1072 * @param string $gmt MySQL datetime (GMT)
1073 */
1074 private function iso_date(string $gmt): string {
1075 if ($gmt === '' || strpos($gmt, '0000-00-00') === 0) {
1076 return '';
1077 }
1078 $ts = strtotime($gmt . ' UTC');
1079
1080 return $ts ? gmdate('c', $ts) : '';
1081 }
1082 }
1083