Skip to content

Instantly share code, notes, and snippets.

@jdevalk
Last active August 27, 2025 06:45
Show Gist options
  • Save jdevalk/6f883bff78372d64a23b3c0534189501 to your computer and use it in GitHub Desktop.
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.
<?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