Last active
August 27, 2025 06:45
-
-
Save jdevalk/6f883bff78372d64a23b3c0534189501 to your computer and use it in GitHub Desktop.
A plugin that takes the schema from Yoast SEO and renders it as a hidden microdata div on a page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Plugin Name: Yoast Schema to Microdata | |
| * Description: Mirrors Yoast SEO's Schema graph (via Surfaces API) as Microdata inside a hidden <div>, preserving @id as itemid. | |
| * Version: 0.4.0 | |
| * Author: Your Name | |
| * License: GPL-2.0-or-later | |
| */ | |
| if ( ! defined( 'ABSPATH' ) ) { | |
| exit; | |
| } | |
| if ( ! class_exists( 'JDV_Microdata_Plugin' ) ) { | |
| final class JDV_Microdata_Plugin { | |
| /** @var JDV_Microdata_Plugin|null */ | |
| private static $instance = null; | |
| /** ID used on the wrapper div */ | |
| private $container_id = 'jdv-microdata'; | |
| /** | |
| * Singleton. | |
| */ | |
| public static function instance() : self { | |
| if ( null === self::$instance ) { | |
| self::$instance = new self(); | |
| } | |
| return self::$instance; | |
| } | |
| /** | |
| * Wire up hooks. | |
| */ | |
| private function __construct() { | |
| $hook = apply_filters( 'jdv_microdata_output_hook', 'wp_footer' ); // e.g. 'wp_footer' | |
| $this->container_id = apply_filters( 'jdv_microdata_container_id', $this->container_id ); | |
| add_action( $hook, [ $this, 'render_hidden_div' ], 99 ); | |
| } | |
| /** | |
| * Output microdata mirror inside a hidden div. | |
| */ | |
| public function render_hidden_div() : void { | |
| if ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { | |
| return; | |
| } | |
| if ( ! function_exists( 'YoastSEO' ) ) { | |
| return; | |
| } | |
| $meta = YoastSEO()->meta->for_current_page(); // Surfaces API. | |
| // Expect: $meta->schema as array with @graph and $meta->main_schema_id. | |
| if ( empty( $meta ) || empty( $meta->schema ) || ! is_array( $meta->schema ) ) { | |
| return; | |
| } | |
| $schema = $meta->schema; | |
| $graph = isset( $schema['@graph'] ) ? $schema['@graph'] : ( $schema['graph'] ?? [] ); | |
| if ( empty( $graph ) || ! is_array( $graph ) ) { | |
| return; | |
| } | |
| $index = $this->index_graph( $graph ); | |
| $main_id = ! empty( $meta->main_schema_id ) ? $meta->main_schema_id : ''; | |
| $root_node = $main_id && isset( $index[ $main_id ] ) ? $index[ $main_id ] : $this->guess_root( $index ); | |
| if ( empty( $root_node ) ) { | |
| return; | |
| } | |
| $visited = []; | |
| $html = $this->render_node( $root_node, $index, $visited ); | |
| if ( $html ) { | |
| $container_attr = sprintf( | |
| 'id="%s" style="display:none" aria-hidden="true" hidden', | |
| esc_attr( $this->container_id ) | |
| ); | |
| echo "\n<!-- Yoast Schema → Microdata mirror -->\n"; | |
| echo '<div ' . $container_attr . '>' . $html . "</div>\n"; | |
| } | |
| } | |
| /** | |
| * Build @id → node index. | |
| * | |
| * @param array $graph | |
| * @return array | |
| */ | |
| private function index_graph( array $graph ) : array { | |
| $index = []; | |
| foreach ( $graph as $node ) { | |
| if ( is_array( $node ) && isset( $node['@id'] ) ) { | |
| $index[ $node['@id'] ] = $node; | |
| } | |
| } | |
| return $index; | |
| } | |
| /** | |
| * Fallback for a sensible root when main_schema_id is missing. | |
| * | |
| * @param array $index | |
| * @return array|null | |
| */ | |
| private function guess_root( array $index ) { | |
| foreach ( $index as $node ) { | |
| $type = $node['@type'] ?? null; | |
| $types = is_array( $type ) ? $type : ( $type ? [ $type ] : [] ); | |
| if ( in_array( 'WebPage', $types, true ) || in_array( 'Article', $types, true ) ) { | |
| return $node; | |
| } | |
| } | |
| return $index ? reset( $index ) : null; | |
| } | |
| /** | |
| * Render a node as a nested itemscope. Avoid loops via $visited. | |
| * | |
| * @param array|string $node_or_id | |
| * @param array $index | |
| * @param array $visited | |
| * @param string|null $prop_name | |
| * @return string | |
| */ | |
| private function render_node( $node_or_id, array $index, array &$visited, $prop_name = null ) : string { | |
| // Resolve references. | |
| if ( is_string( $node_or_id ) && isset( $index[ $node_or_id ] ) ) { | |
| $node = $index[ $node_or_id ]; | |
| } elseif ( is_array( $node_or_id ) ) { | |
| $node = $node_or_id; | |
| } else { | |
| return ''; | |
| } | |
| // Prevent cycles. | |
| $node_id = $node['@id'] ?? null; | |
| if ( $node_id ) { | |
| if ( isset( $visited[ $node_id ] ) ) { | |
| return ''; | |
| } | |
| $visited[ $node_id ] = true; | |
| } | |
| // Types. | |
| $types = []; | |
| if ( isset( $node['@type'] ) ) { | |
| $types = is_array( $node['@type'] ) ? $node['@type'] : [ $node['@type'] ]; | |
| $types = array_filter( array_map( 'sanitize_text_field', $types ) ); | |
| } | |
| // Attributes for the wrapper. | |
| $attrs = [ 'itemscope' => 'itemscope' ]; | |
| if ( ! empty( $types ) ) { | |
| $attrs['itemtype'] = esc_attr( implode( ' ', array_map( | |
| function( $t ) { return 'https://schema.org/' . $t; }, | |
| $types | |
| ) ) ); | |
| } | |
| if ( $prop_name ) { | |
| $attrs['itemprop'] = esc_attr( $prop_name ); | |
| } | |
| // New: preserve Yoast @id as itemid, behind a filter. | |
| $use_itemid = apply_filters( 'jdv_microdata_use_itemid', true, $node, $prop_name ); | |
| if ( $use_itemid && $node_id ) { | |
| // itemid expects a valid URL or unique string. We pass through Yoast's @id. | |
| $attrs['itemid'] = esc_url( $node_id ); | |
| } | |
| $html = '<div ' . $this->attrs( $attrs ) . '>'; | |
| // Properties, skipping JSON-LD control keys. | |
| foreach ( $node as $prop => $value ) { | |
| if ( 0 === strpos( (string) $prop, '@' ) ) { | |
| continue; | |
| } | |
| // Preserve Schema.org camelCase: no lowercasing. Strip only unsafe chars. | |
| $prop_out = $this->normalize_prop( (string) $prop ); | |
| if ( $prop_out === '' ) { | |
| continue; | |
| } | |
| $html .= $this->render_property( $prop_out, $value, $index, $visited ); | |
| } | |
| $html .= '</div>'; | |
| return $html; | |
| } | |
| /** | |
| * Render a single property value. | |
| * | |
| * @param string $prop | |
| * @param mixed $value | |
| * @param array $index | |
| * @param array $visited | |
| * @return string | |
| */ | |
| private function render_property( string $prop, $value, array $index, array &$visited ) : string { | |
| // Scalar. | |
| if ( is_scalar( $value ) ) { | |
| $content = is_bool( $value ) ? ( $value ? 'true' : 'false' ) : (string) $value; | |
| // If scalar matches a known @id, expand as nested itemscope. | |
| if ( is_string( $value ) && isset( $index[ $value ] ) ) { | |
| return $this->render_node( $index[ $value ], $index, $visited, $prop ); | |
| } | |
| return '<meta itemprop="' . esc_attr( $prop ) . '" content="' . esc_attr( $content ) . '" />'; | |
| } | |
| // Lists. | |
| if ( is_array( $value ) && array_values( $value ) === $value ) { | |
| $html = ''; | |
| foreach ( $value as $entry ) { | |
| $html .= $this->render_property( $prop, $entry, $index, $visited ); | |
| } | |
| return $html; | |
| } | |
| // Objects. | |
| if ( is_array( $value ) ) { | |
| // Pure reference object { "@id": "..." }. | |
| if ( isset( $value['@id'] ) && count( $value ) === 1 && isset( $index[ $value['@id'] ] ) ) { | |
| return $this->render_node( $index[ $value['@id'] ], $index, $visited, $prop ); | |
| } | |
| // Inline nested object. | |
| return $this->render_node( $value, $index, $visited, $prop ); | |
| } | |
| return ''; | |
| } | |
| /** | |
| * Format attributes, handling itemscope. | |
| * | |
| * @param array $attrs | |
| * @return string | |
| */ | |
| private function attrs( array $attrs ) : string { | |
| $parts = []; | |
| foreach ( $attrs as $k => $v ) { | |
| if ( $v === 'itemscope' ) { | |
| $parts[] = 'itemscope'; | |
| } else { | |
| $parts[] = $k . '="' . $v . '"'; | |
| } | |
| } | |
| return implode( ' ', $parts ); | |
| } | |
| /** | |
| * Preserve camelCase while removing unsafe characters for an HTML attribute value. | |
| * Allows letters, digits, underscore, dash, colon, and dot. | |
| */ | |
| private function normalize_prop( string $prop ) : string { | |
| return preg_replace( '/[^A-Za-z0-9_:\.\-]/', '', $prop ); | |
| } | |
| } | |
| // Bootstrap. | |
| add_action( 'plugins_loaded', [ 'JDV_Microdata_Plugin', 'instance' ] ); | |
| } | |
| /** | |
| * Developer notes: | |
| * - Filters: | |
| * - 'jdv_microdata_output_hook' to move output, default 'wp_footer'. | |
| * - 'jdv_microdata_container_id' to change the hidden div ID, default 'jdv-microdata'. | |
| * - 'jdv_microdata_use_itemid' to enable or disable itemid, default true. | |
| * - We preserve Schema.org camelCase for itemprop names, no forced lowercasing. | |
| * - Each itemscope with an @id now includes itemid="<same value>" which keeps entity identity aligned with Yoast’s graph. | |
| */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment