*/ public static function empty_graph(): array { return array(); } // ------------------------------------------------------------------ // Merge mode. // ------------------------------------------------------------------ /** * Every @type emitted at the top level of third-party JSON-LD in a page. * * @param string $html Full page HTML. * @return array */ public static function detect_third_party_output( string $html ): array { return self::types_of( self::extract_third_party_nodes( $html ) ); } /** * Unique @type values across a list of nodes. * * @param array> $nodes Nodes. * @return array */ public static function types_of( array $nodes ): array { $types = array(); foreach ( $nodes as $node ) { $types = array_merge( $types, self::node_types( $node ) ); } return array_values( array_unique( $types ) ); } /** * Top-level nodes from every JSON-LD script in a page except our own. * Malformed scripts are skipped. * * @param string $html Full page HTML. * @return array> */ public static function extract_third_party_nodes( string $html ): array { $pattern = '#(]*\btype\s*=\s*["\']application/ld\+json["\'][^>]*>)(.*?)#is'; if ( ! preg_match_all( $pattern, $html, $matches, PREG_SET_ORDER ) ) { return array(); } $nodes = array(); foreach ( $matches as $match ) { // Never count our own script tag. if ( preg_match( '/\bid\s*=\s*["\']schema-monster-jsonld["\']/i', $match[1] ) ) { continue; } $data = json_decode( trim( $match[2] ), true ); if ( ! is_array( $data ) ) { continue; } // A script may hold a list of documents, each with its own @graph. $documents = self::is_list( $data ) ? $data : array( $data ); foreach ( $documents as $document ) { if ( is_array( $document ) ) { $nodes = array_merge( $nodes, self::extract_nodes( $document ) ); } } } return $nodes; } /** * Drop every node from our graph whose @type a third party already * emits, then repoint references to dropped nodes at the matching * third-party node where one can be identified. * * @param array $graph Our graph (with @context/@graph). * @param array $third_party_types Types from detect_third_party_output(). * @param array> $third_party_nodes Nodes from extract_third_party_nodes(), used to repoint references. * @return array Filtered graph, or an empty array when nothing remains. */ public static function filter_graph_merge( array $graph, array $third_party_types, array $third_party_nodes = array() ): array { if ( empty( $graph['@graph'] ) || ! is_array( $graph['@graph'] ) || empty( $third_party_types ) ) { return $graph; } $kept = array(); $dropped = array(); foreach ( $graph['@graph'] as $node ) { if ( is_array( $node ) && ! empty( array_intersect( self::node_types( $node ), $third_party_types ) ) ) { $dropped[] = $node; continue; } $kept[] = $node; } if ( empty( $kept ) ) { return array(); } $replacements = self::reference_replacements( $dropped, $third_party_nodes ); if ( ! empty( $replacements ) ) { foreach ( $kept as $i => $node ) { if ( is_array( $node ) ) { $kept[ $i ] = self::rewrite_references( $node, $replacements, true ); } } } $graph['@graph'] = $kept; return $graph; } /** * Map each dropped node's @id to the @id of a third-party node of the * same @type, when the third-party @id ends in a known fragment for that * type. Non-standard fragments are left unmapped (our reference stays as * it was). * * @param array> $dropped Our dropped nodes. * @param array> $third_party_nodes Third-party nodes. * @return array Old @id => new @id. */ private static function reference_replacements( array $dropped, array $third_party_nodes ): array { /** * Filter: @id fragments that identify a node's type, used to repoint * references when Merge mode drops one of our nodes in favour of a * third-party node. Keys are fragments including '#'; values are * schema.org types. * * @param array $fragments Fragment => @type. */ $fragments = (array) apply_filters( 'smfree_known_fragments', array( '#webpage' => 'WebPage', '#website' => 'WebSite', '#organization' => 'Organization', '#breadcrumb' => 'BreadcrumbList', '#person' => 'Person', ) ); $fragments = array_change_key_case( $fragments, CASE_LOWER ); // Type => first third-party @id whose fragment is known for that type. $targets = array(); foreach ( $third_party_nodes as $node ) { $id = isset( $node['@id'] ) && is_string( $node['@id'] ) ? trim( $node['@id'] ) : ''; if ( '' === $id ) { continue; } $fragment = wp_parse_url( $id, PHP_URL_FRAGMENT ); if ( ! is_string( $fragment ) || '' === $fragment ) { continue; } $fragment_type = $fragments[ '#' . strtolower( $fragment ) ] ?? ''; if ( '' !== $fragment_type && in_array( $fragment_type, self::node_types( $node ), true ) && ! isset( $targets[ $fragment_type ] ) ) { $targets[ $fragment_type ] = $id; } } $replacements = array(); foreach ( $dropped as $node ) { $old = isset( $node['@id'] ) && is_string( $node['@id'] ) ? $node['@id'] : ''; if ( '' === $old ) { continue; } foreach ( self::node_types( $node ) as $type ) { if ( isset( $targets[ $type ] ) ) { $replacements[ $old ] = $targets[ $type ]; break; } } } return $replacements; } /** * Recursively replace nested @id values. The top-level node's own @id is * left alone. * * @param array $value Node or nested value. * @param array $replacements Old @id => new @id. * @param bool $is_root True for the top-level node. * @return array */ private static function rewrite_references( array $value, array $replacements, bool $is_root = false ): array { foreach ( $value as $key => $item ) { if ( is_array( $item ) ) { $value[ $key ] = self::rewrite_references( $item, $replacements ); } elseif ( ! $is_root && '@id' === $key && is_string( $item ) && isset( $replacements[ $item ] ) ) { $value[ $key ] = $replacements[ $item ]; } } return $value; } // ------------------------------------------------------------------ // Internals. // ------------------------------------------------------------------ /** * Normalize a graph document into a flat list of nodes. * * @param array $data Decoded JSON-LD. * @return array> */ private static function extract_nodes( array $data ): array { if ( isset( $data['@graph'] ) && is_array( $data['@graph'] ) ) { return array_values( array_filter( $data['@graph'], 'is_array' ) ); } return isset( $data['@type'] ) || isset( $data['@id'] ) ? array( $data ) : array(); } /** * Normalize a node's @type into a flat string array. * * @param array $node A JSON-LD node. * @return array */ private static function node_types( array $node ): array { $types = $node['@type'] ?? array(); return array_values( array_filter( is_array( $types ) ? $types : array( $types ), 'is_string' ) ); } /** * True for a sequential 0-indexed array. * * @param array $value Array to test. */ private static function is_list( array $value ): bool { return array_keys( $value ) === range( 0, count( $value ) - 1 ); } }