| 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 |
|