$raw_blocks array of decoded JSON-LD blocks * @return array> */ public static function resolve_local_business_entities(array $raw_blocks): array { $by_id = self::build_entity_index($raw_blocks); $sibling_places = self::find_sibling_places($raw_blocks); $results = []; foreach ($raw_blocks as $block) { if (!is_array($block)) { $block = is_string($block) ? json_decode($block, true) : null; if (!is_array($block)) { continue; } } foreach (self::flatten_graph($block) as $node) { if (!self::is_local_business_type($node)) { continue; } $resolved = self::resolve_entity($node, $by_id); // v2.1.2 — fold sibling Place fields onto the entity. Real-world // Rank Math output places `Place` as a peer @graph entity with // its own `geo` and `address`, but does NOT add a `location` // back-reference on Organization/ProfessionalService. The // resolver's location-based fold misses this case. Heuristic: // if the entity has no `geo` (or no `address`) but a Place node // at the same origin exists in the graph, fold its fields. $resolved = self::fold_sibling_place_fields($resolved, $sibling_places); $results[] = $resolved; } } return $results; } /** * v2.1.2 — collect Place nodes per @id origin so we can fold their fields * onto sibling LocalBusiness entities that lack a back-reference. * * @param array $raw_blocks * @return array>> origin → [Place nodes] */ private static function find_sibling_places(array $raw_blocks): array { $by_origin = []; foreach ($raw_blocks as $block) { if (!is_array($block)) { $block = is_string($block) ? json_decode($block, true) : null; if (!is_array($block)) { continue; } } foreach (self::flatten_graph($block) as $node) { $types = self::types_of($node); if (!in_array('Place', $types, true)) { continue; } // Skip Place nodes that are obviously NOT the business location // (e.g. inline mention of a tourist spot). Heuristic: must have // either geo OR address to count. if (empty($node['geo']) && empty($node['address'])) { continue; } $origin = self::origin_of_id((string) ($node['@id'] ?? '')); if ($origin === '') { $origin = '*'; // catch-all — site doesn't use @id origins. } if (!isset($by_origin[$origin])) { $by_origin[$origin] = []; } $by_origin[$origin][] = $node; } } return $by_origin; } /** * v2.1.2 — extract origin (scheme + host) from an @id URL. Used to match * sibling Place nodes to entities that share the same site origin. */ private static function origin_of_id(string $id): string { if ($id === '') { return ''; } $parts = wp_parse_url($id); if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { return ''; } return $parts['scheme'] . '://' . $parts['host']; } /** * v2.1.2 — fold address/geo/openingHours from a sibling Place onto the * entity if the entity is missing them. Only fires when the entity's @id * shares an origin with the Place's @id (or when the graph has no @id * origins at all). * * @param array $entity * @param array>> $sibling_places * @return array */ private static function fold_sibling_place_fields(array $entity, array $sibling_places): array { if (empty($sibling_places)) { return $entity; } // Already complete? Skip. $needs = []; foreach (['address', 'geo', 'hasMap', 'openingHours', 'openingHoursSpecification'] as $f) { if (empty($entity[$f])) { $needs[] = $f; } } if (empty($needs)) { return $entity; } $entity_origin = self::origin_of_id((string) ($entity['@id'] ?? '')); $candidates = []; if ($entity_origin !== '' && isset($sibling_places[$entity_origin])) { $candidates = $sibling_places[$entity_origin]; } elseif (isset($sibling_places['*'])) { $candidates = $sibling_places['*']; } else { // Fall back to any Place node — there's typically only one per page. foreach ($sibling_places as $list) { foreach ($list as $place) { $candidates[] = $place; } } } if (empty($candidates)) { return $entity; } // Use the first Place that has the most needed fields populated. $best = null; $best_score = -1; foreach ($candidates as $place) { $score = 0; foreach ($needs as $f) { if (!empty($place[$f])) { $score++; } } if ($score > $best_score) { $best_score = $score; $best = $place; } } if (!is_array($best)) { return $entity; } foreach ($needs as $f) { if (empty($entity[$f]) && !empty($best[$f])) { $entity[$f] = $best[$f]; } } return $entity; } /** * Returns true if the given JSON-LD `@type` is (or includes) any * LocalBusiness subtype. Handles both string and array forms. * * @param array|mixed $node_or_type */ public static function is_local_business_type($node_or_type): bool { if (is_array($node_or_type) && isset($node_or_type['@type'])) { $types = $node_or_type['@type']; } else { $types = $node_or_type; } if (is_string($types)) { $types = [$types]; } if (!is_array($types)) { return false; } foreach ($types as $t) { if (!is_string($t)) { continue; } if (in_array($t, self::LOCAL_BUSINESS_TYPES, true)) { return true; } } return false; } /** * Build an `@id → node` index by walking @graph trees recursively. * * @param array $raw_blocks * @return array> */ public static function build_entity_index(array $raw_blocks): array { $index = []; foreach ($raw_blocks as $block) { if (!is_array($block)) { $block = is_string($block) ? json_decode($block, true) : null; if (!is_array($block)) { continue; } } self::walk_index($block, $index); } return $index; } /** * Resolve a node's referenced fields (location, address, geo, publisher, * author, mainEntity, …) by following any nested `{@id: …}` references * against the provided index. Only one hop deep — enough for typical * Rank Math / Yoast graphs which keep their entity links flat. * * @param array $node * @param array> $by_id * @return array */ public static function resolve_entity(array $node, array $by_id): array { $out = $node; $reference_fields = ['location', 'address', 'geo', 'publisher', 'author', 'mainEntity', 'mainEntityOfPage', 'image', 'logo', 'contactPoint', 'department', 'parentOrganization', 'subOrganization', 'memberOf', 'brand', 'hasMap']; foreach ($reference_fields as $field) { if (!isset($out[$field])) { continue; } $val = $out[$field]; if (is_array($val) && isset($val['@id']) && !isset($val['@type']) && count($val) === 1 && is_string($val['@id']) && isset($by_id[$val['@id']]) ) { // Pure reference — replace with the referenced entity. $out[$field] = $by_id[$val['@id']]; } elseif (is_array($val) && self::is_list($val)) { // List of references — replace each pure-ref entry. $resolved = []; foreach ($val as $item) { if (is_array($item) && isset($item['@id']) && !isset($item['@type']) && count($item) === 1 && is_string($item['@id']) && isset($by_id[$item['@id']]) ) { $resolved[] = $by_id[$item['@id']]; } else { $resolved[] = $item; } } $out[$field] = $resolved; } } // Fold parent-Place fields onto the entity for convenience: an // Organization with location -> Place gets address/geo from the Place // copied to the top level if it doesn't have its own. if (isset($out['location']) && is_array($out['location'])) { foreach (['address', 'geo', 'hasMap', 'openingHours', 'openingHoursSpecification'] as $f) { if (empty($out[$f]) && !empty($out['location'][$f])) { $out[$f] = $out['location'][$f]; } } } return $out; } /** * Read the explicit `@type` array from a node, or empty. * * @param array $node * @return string[] */ public static function types_of(array $node): array { $t = $node['@type'] ?? null; if (is_string($t)) { return [$t]; } if (is_array($t)) { $out = []; foreach ($t as $s) { if (is_string($s)) { $out[] = $s; } } return $out; } return []; } /** * Yield every node found inside a JSON-LD root (handles `@graph` arrays * and nested objects). Returns an array of nodes (each an associative * array). Cap depth to avoid pathological recursion. * * @param array $root * @return array> */ public static function flatten_graph(array $root, int $max_depth = 6): array { $out = []; $stack = [[$root, 0]]; while (!empty($stack)) { [$node, $depth] = array_pop($stack); if (!is_array($node) || $depth > $max_depth) { continue; } if (isset($node['@type']) || isset($node['@id'])) { $out[] = $node; } if (isset($node['@graph']) && is_array($node['@graph'])) { foreach ($node['@graph'] as $g) { if (is_array($g)) { $stack[] = [$g, $depth + 1]; } } } foreach ($node as $k => $v) { if ($k === '@graph') { continue; } if (is_array($v)) { if (self::is_list($v)) { foreach ($v as $item) { if (is_array($item)) { $stack[] = [$item, $depth + 1]; } } } else { $stack[] = [$v, $depth + 1]; } } } } return $out; } /** * @param array $node * @param array> $index */ private static function walk_index(array $node, array &$index, int $depth = 0): void { if ($depth > 6) { return; } if (isset($node['@id']) && is_string($node['@id']) && $node['@id'] !== '') { // Only index nodes that look like real entities (have @type as // well as @id), not pure references like {"@id": "…"} which // appear inline as link placeholders. if (isset($node['@type'])) { $index[$node['@id']] = $node; } } if (isset($node['@graph']) && is_array($node['@graph'])) { foreach ($node['@graph'] as $g) { if (is_array($g)) { self::walk_index($g, $index, $depth + 1); } } } foreach ($node as $k => $v) { if ($k === '@graph') { continue; } if (is_array($v)) { if (self::is_list($v)) { foreach ($v as $item) { if (is_array($item)) { self::walk_index($item, $index, $depth + 1); } } } else { self::walk_index($v, $index, $depth + 1); } } } } /** * @param array $arr */ private static function is_list(array $arr): bool { // array_is_list() equivalent without the WP 6.5 / PHP 8.1 dependency: // a list is an array whose keys are exactly 0..n-1, which holds iff the // array equals its own re-indexed values. Correct for empty arrays too. return $arr === array_values($arr); } }