PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.0
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.13.0, at includes/admin/importers/class-slim-seo-schema-converter.php

1,085 lines 40.3 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 is_string($value)
795 && (bool) filter_var($value, FILTER_VALIDATE_URL)
796 && in_array(strtolower((string) wp_parse_url($value, PHP_URL_SCHEME)), ['http', 'https'], true);
797 }
798
799 // -------------------------------------------------------------------------
800 // Data trees
801 // -------------------------------------------------------------------------
802
803 /**
804 * Data for a schema that renders the same everywhere.
805 */
806 private function site_context(): array {
807 return [
808 'site' => $this->site_data(),
809 'current' => ['url' => home_url('/')],
810 'schemas' => $this->references([], home_url('/'), true),
811 ];
812 }
813
814 /**
815 * Data for one post, mirroring SlimSEOPro\Schema\Renderer\Data::collect()
816 * as that post's own page would see it. `user.*` is the visitor and
817 * resolves to nothing, as it does for one who is not logged in.
818 *
819 * @param int $post_id Post ID
820 * @param array $post_schemas The post's own schemas, for references
821 * @param array $slim_seo The post's rendered Slim SEO fields
822 */
823 private function post_context(int $post_id, array $post_schemas, array $slim_seo): array {
824 $post = get_post($post_id);
825 if (!$post) {
826 return $this->site_context();
827 }
828
829 $url = (string) get_permalink($post);
830 $content = trim((string) preg_replace('/\s+/', ' ', wp_strip_all_tags(strip_shortcodes((string) $post->post_content))));
831
832 $tax = [];
833 if (function_exists('get_object_taxonomies')) {
834 foreach ((array) get_object_taxonomies((string) $post->post_type, 'names') as $taxonomy) {
835 $tax[str_replace('-', '_', (string) $taxonomy)] = $this->term_names($post_id, (string) $taxonomy);
836 }
837 }
838
839 $custom_field = [];
840 foreach ((array) get_post_meta($post_id) as $meta_key => $values) {
841 // Any plugin's meta can sit on the post: never instantiate objects
842 // out of it. Arrays are kept so `custom_field.a.b` paths resolve.
843 $raw = is_array($values) ? reset($values) : '';
844 $custom_field[(string) $meta_key] = is_string($raw) && is_serialized($raw)
845 ? Safe_Unserializer::unserialize($raw, '')
846 : $raw;
847 }
848
849 $current_url = trailingslashit(strtok(strtok($url, '#'), '?') ?: $url);
850
851 return [
852 'post' => [
853 'ID' => $post_id,
854 'title' => (string) $post->post_title,
855 'excerpt' => (string) $post->post_excerpt,
856 'content' => $content,
857 'url' => $url,
858 'slug' => (string) ($post->post_name ?? ''),
859 'date' => $this->iso_date((string) ($post->post_date_gmt ?? '')),
860 'modified_date' => $this->iso_date((string) ($post->post_modified_gmt ?? '')),
861 'thumbnail' => function_exists('get_the_post_thumbnail_url') ? (string) get_the_post_thumbnail_url($post_id, 'full') : '',
862 'comment_count' => (int) ($post->comment_count ?? 0),
863 'tags' => $this->term_names($post_id, 'post_tag'),
864 'categories' => $this->term_names($post_id, 'category'),
865 'custom_field' => $custom_field,
866 'tax' => $tax,
867 'word_count' => str_word_count($content),
868 ],
869 'author' => $this->author_data((int) ($post->post_author ?? 0)),
870 'user' => [],
871 'term' => [],
872 'site' => $this->site_data(),
873 'current' => ['url' => $current_url, 'title' => (string) ($slim_seo['title'] ?? $post->post_title)],
874 'slim_seo' => $slim_seo,
875 'schemas' => $this->references($post_schemas, $current_url, false, $url),
876 ];
877 }
878
879 /**
880 * `{{ schemas.<id> }}` targets: a reference carrying the node's `@type`
881 * (ThinkRank Pro requires one on every nested object) and the `@id`
882 * ThinkRank's own graph uses for the nodes it emits, or Slim SEO's
883 * `{current url}#{id}` for the rest.
884 *
885 * @param array $post_schemas The post's own schemas
886 * @param string $current_url The page the reference renders on
887 * @param bool $site_level Whether the entry renders site-wide
888 * @param string $permalink The post's permalink exactly as WordPress
889 * builds it: Schema_Graph keys its page-level
890 * ids off that, trailing slash or not
891 * @return array<string,array{@id:string,@type:string}>
892 */
893 private function references(array $post_schemas, string $current_url, bool $site_level, string $permalink = ''): array {
894 $ids = $this->global_ids;
895 foreach ($this->active($post_schemas) as $schema) {
896 $ids[$this->reference_id($schema)] = (string) ($schema['type'] ?? '');
897 }
898
899 $page = $permalink !== '' ? $permalink : $current_url;
900 $native = [
901 'WebSite' => home_url('/#website'),
902 'Organization' => home_url('/#organization'),
903 'Person' => home_url('/#person'),
904 'WebPage' => $page . '#webpage',
905 'BreadcrumbList' => $page . '#breadcrumb',
906 ];
907
908 $references = [];
909 foreach ($ids as $id => $type) {
910 if ($type === '') {
911 continue;
912 }
913 $references[$id] = [
914 '@type' => $type,
915 '@id' => $native[$type] ?? (($site_level ? home_url('/') : $current_url) . '#' . $id),
916 ];
917 }
918
919 return $references;
920 }
921
922 /**
923 * `site.*` (Renderer\Data::get_site_data()).
924 */
925 private function site_data(): array {
926 return [
927 'title' => (string) get_bloginfo('name'),
928 'description' => (string) get_bloginfo('description'),
929 'url' => home_url('/'),
930 'language' => str_replace('_', '-', (string) get_locale()),
931 'icon' => function_exists('get_site_icon_url') ? (string) get_site_icon_url() : '',
932 ];
933 }
934
935 /**
936 * `author.*` (Renderer\Data::get_user()).
937 *
938 * @param int $user_id User ID
939 */
940 private function author_data(int $user_id): array {
941 $user = $user_id > 0 ? get_userdata($user_id) : false;
942 if (!$user) {
943 return [];
944 }
945
946 return [
947 'ID' => (int) $user->ID,
948 'first_name' => (string) ($user->first_name ?? ''),
949 'last_name' => (string) ($user->last_name ?? ''),
950 'display_name' => (string) ($user->display_name ?? ''),
951 'nickname' => (string) ($user->nickname ?? ''),
952 'url' => (string) ($user->user_url ?? ''),
953 'nicename' => (string) ($user->user_nicename ?? ''),
954 'description' => (string) ($user->description ?? ''),
955 'posts_url' => function_exists('get_author_posts_url') ? (string) get_author_posts_url((int) $user->ID) : '',
956 'avatar' => function_exists('get_avatar_url') ? (string) get_avatar_url((int) $user->ID) : '',
957 ];
958 }
959
960 // -------------------------------------------------------------------------
961 // Helpers
962 // -------------------------------------------------------------------------
963
964 /**
965 * Active schemas only (a missing `active` flag means active), keyed.
966 *
967 * @param array $schemas Schemas
968 * @return array<string,array>
969 */
970 private function active(array $schemas): array {
971 $active = [];
972 foreach ($schemas as $key => $schema) {
973 if (!is_array($schema)) {
974 continue;
975 }
976 $flag = $schema['active'] ?? true;
977 if ($flag === true || $flag === 'true' || $flag === 1 || $flag === '1') {
978 $active[(string) $key] = $schema;
979 }
980 }
981
982 return $active;
983 }
984
985 /**
986 * The id other schemas reference this one by: its label, or its type,
987 * sanitised the way PropParser::sanitize_id() does it.
988 *
989 * @param array $schema Schema
990 */
991 private function reference_id(array $schema): string {
992 $label = (string) ($schema['fields']['_label'] ?? ($schema['type'] ?? ''));
993 $id = sanitize_title($label);
994 $id = (string) preg_replace('/[^a-z0-9_]/', '_', $id);
995 $id = (string) preg_replace('/[ _]{2,}/', '_', $id);
996 $id = trim($id, '_');
997 $id = (string) preg_replace('/^\d+/', '', $id);
998
999 return trim($id, '_');
1000 }
1001
1002 /**
1003 * A schema's display name: its label, else its type.
1004 *
1005 * @param array $schema Schema
1006 */
1007 private function label(array $schema): string {
1008 $label = trim((string) ($schema['fields']['_label'] ?? ''));
1009 if ($label !== '') {
1010 return $label;
1011 }
1012
1013 $type = (string) ($schema['type'] ?? '');
1014
1015 return $type === 'CustomJsonLd' ? 'Custom JSON-LD' : ($type !== '' ? $type : 'Schema');
1016 }
1017
1018 /**
1019 * Walk a dotted path through nested arrays.
1020 *
1021 * @param array $context Data tree
1022 * @param string $path e.g. "post.custom_field.price"
1023 * @return mixed|null
1024 */
1025 private function lookup(array $context, string $path) {
1026 $node = $context;
1027 foreach (explode('.', $path) as $segment) {
1028 if (!is_array($node) || !array_key_exists($segment, $node)) {
1029 return null;
1030 }
1031 $node = $node[$segment];
1032 }
1033
1034 return $node;
1035 }
1036
1037 /**
1038 * Set a dotted path in a nested array (Arr::undot()).
1039 *
1040 * @param array $node Target
1041 * @param string $path e.g. "offers.price"
1042 * @param mixed $value Value
1043 */
1044 private function set_path(array &$node, string $path, $value): void {
1045 $ref = &$node;
1046 foreach (explode('.', $path) as $segment) {
1047 if (!isset($ref[$segment]) || !is_array($ref[$segment])) {
1048 $ref[$segment] = [];
1049 }
1050 $ref = &$ref[$segment];
1051 }
1052 $ref = $value;
1053 }
1054
1055 /**
1056 * Term names for a post in one taxonomy.
1057 *
1058 * @param int $post_id Post ID
1059 * @param string $taxonomy Taxonomy
1060 * @return string[]
1061 */
1062 private function term_names(int $post_id, string $taxonomy): array {
1063 if (!function_exists('get_the_terms')) {
1064 return [];
1065 }
1066 $terms = get_the_terms($post_id, $taxonomy);
1067
1068 return is_array($terms) ? array_values(array_map(static fn($t): string => (string) $t->name, $terms)) : [];
1069 }
1070
1071 /**
1072 * A stored GMT datetime as ISO 8601, as Slim SEO Pro prints dates.
1073 *
1074 * @param string $gmt MySQL datetime (GMT)
1075 */
1076 private function iso_date(string $gmt): string {
1077 if ($gmt === '' || strpos($gmt, '0000-00-00') === 0) {
1078 return '';
1079 }
1080 $ts = strtotime($gmt . ' UTC');
1081
1082 return $ts ? gmdate('c', $ts) : '';
1083 }
1084 }
1085