Let Rank Math read your Etch pages
Rank Math can miss the content stored in Etch blocks. This snippet gives its SEO analysis and link counter the rendered page, and builds automatic meta descriptions from the text saved in your blocks.
Etch 1.7.0 added native Rank Math support. If your site runs Etch 1.7.0 or later, you don’t need this snippet and can remove it.
Read the Etch 1.7.0 changelogPaste it into a new PHP snippet. Install steps are below the code.
<?php
/**
* Etch + Rank Math SEO
*
* Etch keeps page text in block attributes, so Rank Math finds no content on
* an Etch page. This snippet gives Rank Math that content:
*
* - Content analysis, "Update SEO Scores" and the link counter read the
* rendered page. These results are shown only in wp-admin.
* - The %excerpt% variable, Rank Math's default meta description, gets a
* paragraph of the text stored in Etch blocks. Public metadata is never
* built from rendered output, so conditional or private content stays out.
*
* It changes nothing for posts without Etch blocks, when an Etch block stores
* its own HTML, or when Etch itself hooks the same Rank Math filter.
* Turn it off with: add_filter( 'ed_etch_rank_math_enabled', '__return_false' );
*
* Version 1.0.0. Requires WordPress 6.5 or later and PHP 7.4 or later.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// A second copy of this snippet does nothing instead of causing a fatal error.
if ( ! class_exists( 'ED_Etch_Rank_Math' ) ) {
final class ED_Etch_Rank_Math {
const VERSION = '1.0.0';
/** Shorter paragraphs are usually labels, such as an eyebrow above a heading. */
const MIN_EXCERPT_LENGTH = 50;
public static function init() {
add_action( 'rank_math/admin/editor_scripts', array( __CLASS__, 'enqueue_editor_script' ) );
add_filter( 'rank_math/recalculate_score/data', array( __CLASS__, 'filter_score_data' ), 10, 2 );
add_filter( 'rank_math/links/content', array( __CLASS__, 'filter_links_content' ), 10, 2 );
add_filter( 'rank_math/replacements', array( __CLASS__, 'filter_replacements' ), 10, 2 );
}
/*
* Detection.
*/
/**
* Whether the content is Etch markup that Rank Math cannot read: it has
* Etch blocks and none of them stores HTML of its own. If a future Etch
* version saves HTML in its blocks, Rank Math can read it and this
* snippet steps aside.
*
* @param mixed $content Serialized post content.
*/
public static function is_unreadable( $content ) {
if ( ! is_string( $content ) || false === strpos( $content, '<!-- wp:etch/' ) || strlen( $content ) > 2097152 ) {
return false;
}
static $cache = array();
$key = md5( $content );
if ( ! isset( $cache[ $key ] ) ) {
if ( count( $cache ) > 100 ) {
$cache = array();
}
$found = false;
$cache[ $key ] = self::blocks_store_no_html( parse_blocks( $content ), $found, 0 ) && $found;
}
return $cache[ $key ];
}
private static function blocks_store_no_html( array $blocks, &$found, $depth ) {
if ( $depth > 64 ) {
return false;
}
foreach ( $blocks as $block ) {
$name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : '';
if ( 0 === strpos( $name, 'etch/' ) ) {
$found = true;
$parts = isset( $block['innerContent'] ) && is_array( $block['innerContent'] ) ? $block['innerContent'] : array();
if ( '' !== trim( implode( '', array_filter( $parts, 'is_string' ) ) ) ) {
return false;
}
}
// Other blocks, such as passthrough core blocks, may keep their HTML.
if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) && ! self::blocks_store_no_html( $block['innerBlocks'], $found, $depth + 1 ) ) {
return false;
}
}
return true;
}
/**
* The feature is switched on, Etch is active, and no Etch code hooks
* the given Rank Math filter.
*/
private static function active( $feature, $hook = '' ) {
if ( ! apply_filters( 'ed_etch_rank_math_enabled', true, $feature ) || ! self::etch_is_active() ) {
return false;
}
return '' === $hook || ! self::etch_hooks( $hook );
}
private static function etch_is_active() {
static $active = false;
if ( ! $active ) {
foreach ( array_keys( WP_Block_Type_Registry::get_instance()->get_all_registered() ) as $name ) {
if ( 0 === strpos( $name, 'etch/' ) ) {
$active = true;
break;
}
}
}
return $active;
}
/**
* Whether code from the Etch plugin hooks this Rank Math filter, for
* example a future built-in integration. This snippet then steps aside.
*/
private static function etch_hooks( $hook ) {
global $wp_filter;
if ( empty( $wp_filter[ $hook ] ) || ! $wp_filter[ $hook ] instanceof WP_Hook ) {
return false;
}
$etch_dir = wp_normalize_path( trailingslashit( WP_PLUGIN_DIR ) . 'etch/' );
foreach ( $wp_filter[ $hook ]->callbacks as $callbacks ) {
foreach ( $callbacks as $callback ) {
list( $file, $class ) = self::callback_source( $callback['function'] );
if ( 0 === strpos( $file, $etch_dir ) || 0 === stripos( $class, 'Etch\\' ) ) {
return true;
}
}
}
return false;
}
private static function callback_source( $callback ) {
try {
if ( is_string( $callback ) && false !== strpos( $callback, '::' ) ) {
$callback = explode( '::', $callback, 2 );
}
if ( is_array( $callback ) && isset( $callback[0], $callback[1] ) ) {
$reflection = new ReflectionMethod( $callback[0], $callback[1] );
$class = $reflection->getDeclaringClass()->getName();
} elseif ( is_object( $callback ) && ! $callback instanceof Closure ) {
$reflection = new ReflectionMethod( $callback, '__invoke' );
$class = get_class( $callback );
} else {
$reflection = new ReflectionFunction( $callback );
$class = '';
}
return array( wp_normalize_path( (string) $reflection->getFileName() ), $class );
} catch ( Throwable $e ) {
return array( '', '' ); // Unknown callbacks are not treated as Etch.
}
}
/*
* Analysis in wp-admin: the rendered page.
*/
/**
* Renders content like the frontend does. Returns null on failure, so
* callers keep Rank Math's own value and a broken block cannot break a save.
*/
private static function render( $content, $post_id ) {
static $rendering = false;
$post = get_post( $post_id );
if ( $rendering || ! $post instanceof WP_Post ) {
return null;
}
$rendering = true;
$previous = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : null;
$level = ob_get_level();
$html = null;
ob_start();
try {
$GLOBALS['post'] = $post;
setup_postdata( $post );
$html = apply_filters( 'the_content', $content );
} catch ( Throwable $e ) {
$html = null;
} finally {
// Output echoed by a block must not reach a REST or admin response.
while ( ob_get_level() > $level ) {
ob_end_clean();
}
$GLOBALS['post'] = $previous;
if ( $previous instanceof WP_Post ) {
setup_postdata( $previous );
}
$rendering = false;
}
return is_string( $html ) ? $html : null;
}
/**
* Status & Tools → Database Tools → "Update SEO Scores".
*/
public static function filter_score_data( $values, $post_id ) {
$raw = get_post_field( 'post_content', $post_id, 'raw' );
// Act only on the value Rank Math built itself, not on one another plugin changed.
if (
! is_array( $values )
|| ! isset( $values['content'] )
|| ! self::is_unreadable( $raw )
|| wpautop( $raw ) !== $values['content']
|| ! self::active( 'score_update', 'rank_math/recalculate_score/data' )
) {
return $values;
}
$html = self::render( $raw, $post_id );
if ( null !== $html ) {
$values['content'] = $html;
}
return $values;
}
/**
* Link counter. Rank Math runs it when WordPress saves the post (save_post).
*/
public static function filter_links_content( $content, $post_id ) {
if ( ! self::is_unreadable( $content ) || ! self::active( 'links', 'rank_math/links/content' ) ) {
return $content;
}
$html = self::render( $content, $post_id );
return null === $html ? $content : $html;
}
/**
* Block editor: content analysis and the snippet preview description.
*/
public static function enqueue_editor_script() {
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
$post = get_post();
if (
! $screen
|| ! $screen->is_block_editor()
|| ! $post instanceof WP_Post
|| ! self::is_unreadable( $post->post_content )
|| ! self::active( 'analysis' )
) {
return;
}
$config = array(
'minExcerptLength' => self::MIN_EXCERPT_LENGTH,
'paragraphs' => array(),
);
// The preview shows the description only where the frontend gets it too.
if ( '' === (string) $post->post_password && self::active( 'description', 'rank_math/replacements' ) ) {
$config['paragraphs'] = self::paragraphs( $post );
}
$handle = 'ed-etch-rank-math';
wp_register_script( $handle, false, array( 'wp-hooks', 'wp-data', 'wp-block-serialization-default-parser', 'rank-math-analyzer' ), self::VERSION, true );
wp_enqueue_script( $handle );
wp_add_inline_script(
$handle,
str_replace( '__ED_ETCH_RANK_MATH_CONFIG__', (string) wp_json_encode( $config, JSON_HEX_TAG | JSON_HEX_AMP ), self::editor_script() )
);
}
private static function editor_script() {
return <<<'JS'
( function ( hooks, data, parser, config ) {
var NAMESPACE = 'ed-etch-rank-math';
var checked = { content: null, result: false };
function storesNoHtml( blocks, state, depth ) {
if ( depth > 64 ) {
return false;
}
for ( var i = 0; i < blocks.length; i++ ) {
var block = blocks[ i ];
if ( block.blockName && 0 === block.blockName.indexOf( 'etch/' ) ) {
state.found = true;
var own = ( block.innerContent || [] ).filter( function ( part ) {
return 'string' === typeof part;
} ).join( '' );
if ( own.trim() ) {
return false;
}
}
if ( block.innerBlocks && block.innerBlocks.length && ! storesNoHtml( block.innerBlocks, state, depth + 1 ) ) {
return false;
}
}
return true;
}
// Same rule as the PHP side: Etch blocks are present and none stores HTML.
function isUnreadable( content ) {
if ( 'string' !== typeof content || -1 === content.indexOf( '<!-- wp:etch/' ) ) {
return false;
}
if ( content !== checked.content ) {
var state = { found: false };
checked.content = content;
checked.result = storesNoHtml( parser.parse( content ), state, 0 ) && state.found;
}
return checked.result;
}
// A Rank Math filter from Etch itself, e.g. a future built-in integration, takes over.
function etchIntegrates() {
return [ 'rank_math_content', 'rank_math_dataCollector_data' ].some( function ( hook ) {
var registered = hooks.filters && hooks.filters[ hook ];
return !! ( registered && registered.handlers && registered.handlers.some( function ( handler ) {
return /^etch(?:[\/_-]|$)/i.test( String( handler.namespace ) );
} ) );
} );
}
// HTML the block editor already loaded with the post (last saved version).
function renderedContent() {
var editor = data.select( 'core/editor' );
var postId = editor && editor.getCurrentPostId();
if ( ! postId ) {
return null;
}
var post = data.select( 'core' ).getEntityRecord( 'postType', editor.getCurrentPostType(), postId );
return post && post.content && 'string' === typeof post.content.rendered ? post.content.rendered : null;
}
// Same choice as the PHP side, with the focus keyword currently in the editor.
function pickExcerpt() {
var paragraphs = config.paragraphs;
var keyword = window.rankMathEditor && 'function' === typeof window.rankMathEditor.getPrimaryKeyword
? String( window.rankMathEditor.getPrimaryKeyword() || '' ).trim().toLowerCase()
: '';
var i;
if ( keyword ) {
for ( i = 0; i < paragraphs.length; i++ ) {
if ( -1 !== paragraphs[ i ].toLowerCase().indexOf( keyword ) ) {
return paragraphs[ i ];
}
}
}
for ( i = 0; i < paragraphs.length; i++ ) {
if ( Array.from( paragraphs[ i ] ).length >= config.minExcerptLength ) {
return paragraphs[ i ];
}
}
return paragraphs[ 0 ] || '';
}
function usesExcerptTemplate() {
var serp = window.rankMath && window.rankMath.assessor && window.rankMath.assessor.serpData;
return !! serp && '%excerpt%' === String( serp.descriptionTemplate || '' ).trim();
}
// Priority 5: before Rank Math appends its own additions, such as ACF fields.
hooks.addFilter( 'rank_math_content', NAMESPACE, function ( content ) {
try {
if ( isUnreadable( content ) && ! etchIntegrates() ) {
var rendered = renderedContent();
if ( null !== rendered ) {
return rendered;
}
}
} catch ( e ) {}
return content;
}, 5 );
// The snippet preview builds %excerpt% from the raw content. Give it the
// paragraph the frontend uses. Returns a copy; Rank Math's data is untouched.
hooks.addFilter( 'rank_math_dataCollector_data', NAMESPACE, function ( collected ) {
try {
if (
collected
&& ! collected.excerpt
&& config.paragraphs.length
&& usesExcerptTemplate()
&& isUnreadable( collected.content )
&& ! etchIntegrates()
) {
var excerpt = pickExcerpt();
if ( excerpt ) {
return Object.assign( {}, collected, { excerpt: excerpt } );
}
}
} catch ( e ) {}
return collected;
} );
// Once Rank Math is ready, and whenever the rendered HTML changes (e.g. after
// saving), run the same updates Rank Math runs for a content change.
var synced = null;
data.subscribe( function () {
try {
if ( ! window.rankMathEditor ) {
return;
}
var rendered = renderedContent();
var store = data.select( 'rank-math' );
if ( null === rendered || rendered === synced || ! store || ! store.isLoaded() ) {
return;
}
synced = rendered;
var actions = data.dispatch( 'rank-math' );
if ( actions && 'function' === typeof actions.updateSerpDescription && 'function' === typeof store.getDescription ) {
actions.updateSerpDescription( store.getDescription() );
}
window.rankMathEditor.refresh( 'content' );
} catch ( e ) {}
} );
} )( window.wp.hooks, window.wp.data, window.wp.blockSerializationDefaultParser, __ED_ETCH_RANK_MATH_CONFIG__ );
JS;
}
/*
* Public metadata: stored Etch text only.
*/
/**
* Fills %excerpt% when Rank Math would auto-generate it from content it
* cannot read. An excerpt or a manual meta description still wins, and
* password-protected posts get nothing.
*/
public static function filter_replacements( $replacements, $args ) {
if (
! is_array( $replacements )
|| ! array_key_exists( '%excerpt%', $replacements )
|| ! is_object( $args )
|| empty( $args->ID )
|| empty( $args->post_type )
) {
return $replacements;
}
try {
$post = get_post( (int) $args->ID );
if (
! $post instanceof WP_Post
|| $post->post_type !== $args->post_type
|| '' !== (string) $post->post_password
|| '' !== trim( (string) $post->post_excerpt )
|| ! self::is_unreadable( $post->post_content )
|| ! self::active( 'description', 'rank_math/replacements' )
) {
return $replacements;
}
$excerpt = self::pick_excerpt( self::paragraphs( $post ), self::focus_keyword( $post->ID ) );
if ( '' !== $excerpt ) {
$replacements['%excerpt%'] = $excerpt;
}
} catch ( Throwable $e ) {
// Keep Rank Math's own value.
}
return $replacements;
}
private static function focus_keyword( $post_id ) {
$keywords = explode( ',', (string) get_post_meta( $post_id, 'rank_math_focus_keyword', true ) );
return trim( $keywords[0] );
}
/**
* Like Rank Math: the first paragraph with the focus keyword, otherwise
* the first paragraph. Short labels are skipped when a longer one exists.
*/
private static function pick_excerpt( array $paragraphs, $keyword ) {
$lower = function_exists( 'mb_strtolower' ) ? 'mb_strtolower' : 'strtolower';
if ( '' !== $keyword ) {
foreach ( $paragraphs as $paragraph ) {
if ( false !== strpos( $lower( $paragraph ), $lower( $keyword ) ) ) {
return $paragraph;
}
}
}
foreach ( $paragraphs as $paragraph ) {
if ( mb_strlen( $paragraph ) >= self::MIN_EXCERPT_LENGTH ) {
return $paragraph;
}
}
return isset( $paragraphs[0] ) ? $paragraphs[0] : '';
}
/**
* Text of the paragraphs stored in the post, in page order. Reads block
* attributes only: no rendering, queries, dynamic data or shortcodes.
*/
public static function paragraphs( WP_Post $post ) {
static $cache = array();
$content = (string) $post->post_content;
$key = $post->ID . ':' . md5( $content );
if ( isset( $cache[ $key ] ) ) {
return $cache[ $key ];
}
if ( count( $cache ) > 100 ) {
$cache = array();
}
$state = array(
'paragraphs' => array(),
'current' => null,
'previous' => array(),
'remaining' => 50000,
'exhausted' => false,
);
if (
strlen( $content ) <= 1048576
&& substr_count( $content, '<!--' ) <= 20000
&& method_exists( 'WP_HTML_Tag_Processor', 'next_token' )
) {
self::walk_blocks( parse_blocks( $content ), $state, 0 );
}
// Past the limits, keep the paragraphs completed before them.
if ( $state['exhausted'] && null !== $state['current'] ) {
array_splice( $state['paragraphs'], $state['current'] );
}
$paragraphs = array();
foreach ( $state['paragraphs'] as $text ) {
// Inline markup is joined with spaces; tidy the space before punctuation.
$text = trim( (string) preg_replace( '/\s+([.,;:!?])/u', '$1', $text ) );
if ( '' !== $text ) {
$paragraphs[] = $text;
}
}
$cache[ $key ] = $paragraphs;
return $paragraphs;
}
/** Bounds traversal of blocks, nested props, decoded JSON and HTML tokens. */
private static function step( array &$state, $depth ) {
if ( $state['exhausted'] || $depth > 64 || --$state['remaining'] < 0 ) {
$state['exhausted'] = true;
return false;
}
return true;
}
private static function walk_blocks( array $blocks, array &$state, $depth ) {
foreach ( $blocks as $block ) {
if ( ! self::step( $state, $depth ) ) {
return;
}
if ( ! is_array( $block ) ) {
continue;
}
$name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : '';
$attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
$inner = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : array();
switch ( $name ) {
case 'etch/text':
self::add_text( isset( $attrs['content'] ) ? $attrs['content'] : ( isset( $attrs['text'] ) ? $attrs['text'] : '' ), $state );
break;
case 'etch/raw-html':
// Read content only. The unsafe field is never a text source.
self::add_text( isset( $attrs['content'] ) ? $attrs['content'] : '', $state );
break;
case 'etch/component':
self::walk_props( isset( $attrs['attributes'] ) ? $attrs['attributes'] : ( isset( $attrs['props'] ) ? $attrs['props'] : array() ), '', $state, $depth + 1 );
self::walk_blocks( $inner, $state, $depth + 1 );
break;
case 'etch/slot-content':
case 'etch/slot-placeholder':
self::walk_blocks( $inner, $state, $depth + 1 );
break;
case 'etch/passthrough':
$wrapped = isset( $attrs['gutenbergBlock'] ) ? $attrs['gutenbergBlock'] : null;
if ( is_string( $wrapped ) ) {
$wrapped = json_decode( $wrapped, true, 64 );
}
if ( is_array( $wrapped ) ) {
self::walk_core_block( $wrapped, $state, $depth + 1 );
}
break;
case 'etch/element':
case 'etch/dynamic-element':
$html_attrs = isset( $attrs['attributes'] ) && is_array( $attrs['attributes'] ) ? $attrs['attributes'] : array();
// A missing tag is Etch's default div. The block editor omits it on save.
$tag = 'etch/dynamic-element' === $name ? ( isset( $html_attrs['tag'] ) ? $html_attrs['tag'] : 'div' ) : ( isset( $attrs['tag'] ) ? $attrs['tag'] : 'div' );
if (
! is_string( $tag )
|| 1 !== preg_match( '/^[a-z][a-z0-9-]*$/i', $tag )
|| self::excluded_tag( $tag )
|| ( array_key_exists( 'hidden', $html_attrs ) && false !== $html_attrs['hidden'] && null !== $html_attrs['hidden'] )
|| ( isset( $html_attrs['aria-hidden'] ) && 'true' === $html_attrs['aria-hidden'] )
) {
break;
}
if ( 'p' === strtolower( $tag ) ) {
self::open_paragraph( $state );
self::walk_blocks( $inner, $state, $depth + 1 );
self::close_paragraph( $state );
} else {
self::walk_blocks( $inner, $state, $depth + 1 );
}
break;
default:
// Conditions, loops, post content and unknown blocks are skipped: their
// output depends on the visitor, a query, or another post.
if ( 0 === strpos( $name, 'core/' ) ) {
self::walk_core_block( $block, $state, $depth + 1 );
}
break;
}
}
}
private static function open_paragraph( array &$state ) {
$state['paragraphs'][] = '';
$state['previous'][] = $state['current'];
$state['current'] = count( $state['paragraphs'] ) - 1;
}
private static function close_paragraph( array &$state ) {
if ( $state['exhausted'] ) {
return; // Leave the paragraph open, so it is dropped as incomplete.
}
$state['current'] = array_pop( $state['previous'] );
}
private static function walk_core_block( array $block, array &$state, $depth ) {
if ( ! self::step( $state, $depth ) ) {
return;
}
$name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : '';
$containers = array( 'core/group', 'core/columns', 'core/column', 'core/quote' );
if (
( 'core/paragraph' !== $name && ! in_array( $name, $containers, true ) )
|| ! empty( $block['attrs']['metadata']['bindings'] )
) {
return;
}
$parts = isset( $block['innerContent'] ) && is_array( $block['innerContent'] ) ? $block['innerContent'] : array();
$html = implode( '', array_filter( $parts, 'is_string' ) );
if ( 'core/paragraph' === $name ) {
self::open_paragraph( $state );
self::add_text( $html, $state );
self::close_paragraph( $state );
return;
}
// An excluded or hidden wrapper also excludes its nested blocks.
$processor = new WP_HTML_Tag_Processor( $html );
while ( $processor->next_token() ) {
if ( ! self::step( $state, $depth ) ) {
return;
}
$tag = $processor->get_tag();
if ( null !== $tag && ! $processor->is_tag_closer() && (
self::excluded_tag( $tag )
|| null !== $processor->get_attribute( 'hidden' )
|| 'true' === strtolower( (string) $processor->get_attribute( 'aria-hidden' ) )
) ) {
return;
}
}
$inner = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : array();
self::walk_blocks( $inner, $state, $depth + 1 );
}
private static function walk_props( $props, $path, array &$state, $depth ) {
if ( ! self::step( $state, $depth ) ) {
return;
}
// A text-like suffix must not turn a credential field into public copy.
if ( preg_match( '/(?:^|\.)(?:api[_-]?key|secret|password|token|nonce|private)[^.]*(?:\.|$)/i', $path ) ) {
return;
}
// Exclude whole configuration branches, not just their leaf names.
if ( preg_match( '/(?:^|\.)(?:ref|componentid|class|classes|classname|style|styles|css|tag|variant|theme|color|background|layout|align|alignment|size|gapsize|width|height|mediaid|usesrcset|maximumsize|imageid|imageurl|imagesrc|iconid|iconname|iconurl|iconsrc|url|href|src|target|rel|config|settings|options|script|code|unsafe|secret|password|token|apikey|nonce|query)(?:\.|$)/i', $path ) ) {
return;
}
if ( is_array( $props ) ) {
foreach ( $props as $key => $value ) {
$next_path = is_int( $key ) ? $path : ( '' === $path ? (string) $key : $path . '.' . $key );
self::walk_props( $value, $next_path, $state, $depth + 1 );
if ( $state['exhausted'] ) {
return;
}
}
return;
}
if ( ! is_string( $props ) ) {
return;
}
$value = trim( $props );
// Decode only possible JSON containers, rather than every text value.
if ( '' !== $value && ( '[' === $value[0] || '{' === $value[0] ) ) {
$decoded = json_decode( $value, true, 64 );
if ( is_array( $decoded ) ) {
self::walk_props( $decoded, $path, $state, $depth + 1 );
return;
}
if ( JSON_ERROR_DEPTH === json_last_error() ) {
return;
}
}
if ( self::prop_is_paragraph( $path, $value ) ) {
self::open_paragraph( $state );
self::add_text( $value, $state );
self::close_paragraph( $state );
}
}
/** Component props that read like body copy. Titles and labels are not paragraphs. */
private static function prop_is_paragraph( $key, $value ) {
if ( '' === $value || preg_match( '#^(?:[a-z][a-z0-9+.-]*:|//|/|\#|var\()#i', $value ) ) {
return false;
}
$key = preg_replace( '/([a-z0-9])([A-Z])/', '$1.$2', $key );
$key = str_replace( array( '_', '-' ), '.', strtolower( $key ) );
return 1 === preg_match( '/(?:^|\.)(?:text|description|content|paragraph|body|copy|excerpt|intro|summary|lead|lede|blurb)(?:s|\d+)?$/', $key );
}
private static function excluded_tag( $tag ) {
return in_array( strtolower( $tag ), array( 'form', 'script', 'style', 'noscript', 'iframe', 'svg', 'template', 'select', 'textarea', 'title', 'xmp', 'noembed', 'noframes', 'button', 'label', 'nav' ), true );
}
/** Appends text nodes to the open paragraph; never interprets entities as markup. */
private static function add_text( $value, array &$state ) {
if ( ! is_string( $value ) || '' === $value || $state['exhausted'] || null === $state['current'] ) {
return;
}
$processor = new WP_HTML_Tag_Processor( $value );
$text = '';
$skip_tag = null;
$skip_depth = 0;
while ( $processor->next_token() ) {
if ( ! self::step( $state, 0 ) ) {
return;
}
$tag = $processor->get_tag();
if ( null !== $skip_tag ) {
if ( $tag === $skip_tag ) {
$skip_depth += $processor->is_tag_closer() ? -1 : 1;
if ( 0 === $skip_depth ) {
$skip_tag = null;
}
}
continue;
}
if ( '#text' === $processor->get_token_type() ) {
$text .= $processor->get_modifiable_text();
continue;
}
if ( null === $tag ) {
continue; // Comments and other non-text tokens are not page copy.
}
// These tokens already include their entire raw-text body and end tag.
if ( in_array( $tag, array( 'SCRIPT', 'STYLE', 'IFRAME', 'TEXTAREA', 'TITLE', 'XMP', 'NOEMBED', 'NOFRAMES' ), true ) ) {
$text .= ' ';
continue;
}
if (
! $processor->is_tag_closer()
&& (
self::excluded_tag( $tag )
|| null !== $processor->get_attribute( 'hidden' )
|| 'true' === strtolower( (string) $processor->get_attribute( 'aria-hidden' ) )
)
) {
if ( ! in_array( $tag, array( 'AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR' ), true ) ) {
$skip_tag = $tag;
$skip_depth = 1;
}
$text .= ' ';
continue;
}
if ( 'BR' === $tag ) {
$text .= ' ';
}
}
// Strip registered shortcode syntax without invoking its callback.
$text = strip_shortcodes( $text );
$text = preg_replace( '/\{(?:[^{}]++|(?R))*\}/u', ' ', $text );
if ( null === $text || false !== strpbrk( $text, '{}' ) ) {
return; // Discard malformed or unresolved dynamic expressions.
}
$text = trim( (string) preg_replace( '/[\s\p{Z}]+/u', ' ', $text ) );
if ( '' !== $text ) {
$state['paragraphs'][ $state['current'] ] .= ' ' . $text;
}
}
}
ED_Etch_Rank_Math::init();
}- Requires
- Etch + Rank Math
- Runs on
- Admin + front end
- Version
- 1.0.0
- License
- GPL-2.0-or-later
<?php
/**
* Etch + Rank Math SEO
*
* Etch keeps page text in block attributes, so Rank Math finds no content on
* an Etch page. This snippet gives Rank Math that content:
*
* - Content analysis, "Update SEO Scores" and the link counter read the
* rendered page. These results are shown only in wp-admin.
* - The %excerpt% variable, Rank Math's default meta description, gets a
* paragraph of the text stored in Etch blocks. Public metadata is never
* built from rendered output, so conditional or private content stays out.
*
* It changes nothing for posts without Etch blocks, when an Etch block stores
* its own HTML, or when Etch itself hooks the same Rank Math filter.
* Turn it off with: add_filter( 'ed_etch_rank_math_enabled', '__return_false' );
*
* Version 1.0.0. Requires WordPress 6.5 or later and PHP 7.4 or later.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// A second copy of this snippet does nothing instead of causing a fatal error.
if ( ! class_exists( 'ED_Etch_Rank_Math' ) ) {
final class ED_Etch_Rank_Math {
const VERSION = '1.0.0';
/** Shorter paragraphs are usually labels, such as an eyebrow above a heading. */
const MIN_EXCERPT_LENGTH = 50;
public static function init() {
add_action( 'rank_math/admin/editor_scripts', array( __CLASS__, 'enqueue_editor_script' ) );
add_filter( 'rank_math/recalculate_score/data', array( __CLASS__, 'filter_score_data' ), 10, 2 );
add_filter( 'rank_math/links/content', array( __CLASS__, 'filter_links_content' ), 10, 2 );
add_filter( 'rank_math/replacements', array( __CLASS__, 'filter_replacements' ), 10, 2 );
}
/*
* Detection.
*/
/**
* Whether the content is Etch markup that Rank Math cannot read: it has
* Etch blocks and none of them stores HTML of its own. If a future Etch
* version saves HTML in its blocks, Rank Math can read it and this
* snippet steps aside.
*
* @param mixed $content Serialized post content.
*/
public static function is_unreadable( $content ) {
if ( ! is_string( $content ) || false === strpos( $content, '<!-- wp:etch/' ) || strlen( $content ) > 2097152 ) {
return false;
}
static $cache = array();
$key = md5( $content );
if ( ! isset( $cache[ $key ] ) ) {
if ( count( $cache ) > 100 ) {
$cache = array();
}
$found = false;
$cache[ $key ] = self::blocks_store_no_html( parse_blocks( $content ), $found, 0 ) && $found;
}
return $cache[ $key ];
}
private static function blocks_store_no_html( array $blocks, &$found, $depth ) {
if ( $depth > 64 ) {
return false;
}
foreach ( $blocks as $block ) {
$name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : '';
if ( 0 === strpos( $name, 'etch/' ) ) {
$found = true;
$parts = isset( $block['innerContent'] ) && is_array( $block['innerContent'] ) ? $block['innerContent'] : array();
if ( '' !== trim( implode( '', array_filter( $parts, 'is_string' ) ) ) ) {
return false;
}
}
// Other blocks, such as passthrough core blocks, may keep their HTML.
if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) && ! self::blocks_store_no_html( $block['innerBlocks'], $found, $depth + 1 ) ) {
return false;
}
}
return true;
}
/**
* The feature is switched on, Etch is active, and no Etch code hooks
* the given Rank Math filter.
*/
private static function active( $feature, $hook = '' ) {
if ( ! apply_filters( 'ed_etch_rank_math_enabled', true, $feature ) || ! self::etch_is_active() ) {
return false;
}
return '' === $hook || ! self::etch_hooks( $hook );
}
private static function etch_is_active() {
static $active = false;
if ( ! $active ) {
foreach ( array_keys( WP_Block_Type_Registry::get_instance()->get_all_registered() ) as $name ) {
if ( 0 === strpos( $name, 'etch/' ) ) {
$active = true;
break;
}
}
}
return $active;
}
/**
* Whether code from the Etch plugin hooks this Rank Math filter, for
* example a future built-in integration. This snippet then steps aside.
*/
private static function etch_hooks( $hook ) {
global $wp_filter;
if ( empty( $wp_filter[ $hook ] ) || ! $wp_filter[ $hook ] instanceof WP_Hook ) {
return false;
}
$etch_dir = wp_normalize_path( trailingslashit( WP_PLUGIN_DIR ) . 'etch/' );
foreach ( $wp_filter[ $hook ]->callbacks as $callbacks ) {
foreach ( $callbacks as $callback ) {
list( $file, $class ) = self::callback_source( $callback['function'] );
if ( 0 === strpos( $file, $etch_dir ) || 0 === stripos( $class, 'Etch\\' ) ) {
return true;
}
}
}
return false;
}
private static function callback_source( $callback ) {
try {
if ( is_string( $callback ) && false !== strpos( $callback, '::' ) ) {
$callback = explode( '::', $callback, 2 );
}
if ( is_array( $callback ) && isset( $callback[0], $callback[1] ) ) {
$reflection = new ReflectionMethod( $callback[0], $callback[1] );
$class = $reflection->getDeclaringClass()->getName();
} elseif ( is_object( $callback ) && ! $callback instanceof Closure ) {
$reflection = new ReflectionMethod( $callback, '__invoke' );
$class = get_class( $callback );
} else {
$reflection = new ReflectionFunction( $callback );
$class = '';
}
return array( wp_normalize_path( (string) $reflection->getFileName() ), $class );
} catch ( Throwable $e ) {
return array( '', '' ); // Unknown callbacks are not treated as Etch.
}
}
/*
* Analysis in wp-admin: the rendered page.
*/
/**
* Renders content like the frontend does. Returns null on failure, so
* callers keep Rank Math's own value and a broken block cannot break a save.
*/
private static function render( $content, $post_id ) {
static $rendering = false;
$post = get_post( $post_id );
if ( $rendering || ! $post instanceof WP_Post ) {
return null;
}
$rendering = true;
$previous = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : null;
$level = ob_get_level();
$html = null;
ob_start();
try {
$GLOBALS['post'] = $post;
setup_postdata( $post );
$html = apply_filters( 'the_content', $content );
} catch ( Throwable $e ) {
$html = null;
} finally {
// Output echoed by a block must not reach a REST or admin response.
while ( ob_get_level() > $level ) {
ob_end_clean();
}
$GLOBALS['post'] = $previous;
if ( $previous instanceof WP_Post ) {
setup_postdata( $previous );
}
$rendering = false;
}
return is_string( $html ) ? $html : null;
}
/**
* Status & Tools → Database Tools → "Update SEO Scores".
*/
public static function filter_score_data( $values, $post_id ) {
$raw = get_post_field( 'post_content', $post_id, 'raw' );
// Act only on the value Rank Math built itself, not on one another plugin changed.
if (
! is_array( $values )
|| ! isset( $values['content'] )
|| ! self::is_unreadable( $raw )
|| wpautop( $raw ) !== $values['content']
|| ! self::active( 'score_update', 'rank_math/recalculate_score/data' )
) {
return $values;
}
$html = self::render( $raw, $post_id );
if ( null !== $html ) {
$values['content'] = $html;
}
return $values;
}
/**
* Link counter. Rank Math runs it when WordPress saves the post (save_post).
*/
public static function filter_links_content( $content, $post_id ) {
if ( ! self::is_unreadable( $content ) || ! self::active( 'links', 'rank_math/links/content' ) ) {
return $content;
}
$html = self::render( $content, $post_id );
return null === $html ? $content : $html;
}
/**
* Block editor: content analysis and the snippet preview description.
*/
public static function enqueue_editor_script() {
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
$post = get_post();
if (
! $screen
|| ! $screen->is_block_editor()
|| ! $post instanceof WP_Post
|| ! self::is_unreadable( $post->post_content )
|| ! self::active( 'analysis' )
) {
return;
}
$config = array(
'minExcerptLength' => self::MIN_EXCERPT_LENGTH,
'paragraphs' => array(),
);
// The preview shows the description only where the frontend gets it too.
if ( '' === (string) $post->post_password && self::active( 'description', 'rank_math/replacements' ) ) {
$config['paragraphs'] = self::paragraphs( $post );
}
$handle = 'ed-etch-rank-math';
wp_register_script( $handle, false, array( 'wp-hooks', 'wp-data', 'wp-block-serialization-default-parser', 'rank-math-analyzer' ), self::VERSION, true );
wp_enqueue_script( $handle );
wp_add_inline_script(
$handle,
str_replace( '__ED_ETCH_RANK_MATH_CONFIG__', (string) wp_json_encode( $config, JSON_HEX_TAG | JSON_HEX_AMP ), self::editor_script() )
);
}
private static function editor_script() {
return <<<'JS'
( function ( hooks, data, parser, config ) {
var NAMESPACE = 'ed-etch-rank-math';
var checked = { content: null, result: false };
function storesNoHtml( blocks, state, depth ) {
if ( depth > 64 ) {
return false;
}
for ( var i = 0; i < blocks.length; i++ ) {
var block = blocks[ i ];
if ( block.blockName && 0 === block.blockName.indexOf( 'etch/' ) ) {
state.found = true;
var own = ( block.innerContent || [] ).filter( function ( part ) {
return 'string' === typeof part;
} ).join( '' );
if ( own.trim() ) {
return false;
}
}
if ( block.innerBlocks && block.innerBlocks.length && ! storesNoHtml( block.innerBlocks, state, depth + 1 ) ) {
return false;
}
}
return true;
}
// Same rule as the PHP side: Etch blocks are present and none stores HTML.
function isUnreadable( content ) {
if ( 'string' !== typeof content || -1 === content.indexOf( '<!-- wp:etch/' ) ) {
return false;
}
if ( content !== checked.content ) {
var state = { found: false };
checked.content = content;
checked.result = storesNoHtml( parser.parse( content ), state, 0 ) && state.found;
}
return checked.result;
}
// A Rank Math filter from Etch itself, e.g. a future built-in integration, takes over.
function etchIntegrates() {
return [ 'rank_math_content', 'rank_math_dataCollector_data' ].some( function ( hook ) {
var registered = hooks.filters && hooks.filters[ hook ];
return !! ( registered && registered.handlers && registered.handlers.some( function ( handler ) {
return /^etch(?:[\/_-]|$)/i.test( String( handler.namespace ) );
} ) );
} );
}
// HTML the block editor already loaded with the post (last saved version).
function renderedContent() {
var editor = data.select( 'core/editor' );
var postId = editor && editor.getCurrentPostId();
if ( ! postId ) {
return null;
}
var post = data.select( 'core' ).getEntityRecord( 'postType', editor.getCurrentPostType(), postId );
return post && post.content && 'string' === typeof post.content.rendered ? post.content.rendered : null;
}
// Same choice as the PHP side, with the focus keyword currently in the editor.
function pickExcerpt() {
var paragraphs = config.paragraphs;
var keyword = window.rankMathEditor && 'function' === typeof window.rankMathEditor.getPrimaryKeyword
? String( window.rankMathEditor.getPrimaryKeyword() || '' ).trim().toLowerCase()
: '';
var i;
if ( keyword ) {
for ( i = 0; i < paragraphs.length; i++ ) {
if ( -1 !== paragraphs[ i ].toLowerCase().indexOf( keyword ) ) {
return paragraphs[ i ];
}
}
}
for ( i = 0; i < paragraphs.length; i++ ) {
if ( Array.from( paragraphs[ i ] ).length >= config.minExcerptLength ) {
return paragraphs[ i ];
}
}
return paragraphs[ 0 ] || '';
}
function usesExcerptTemplate() {
var serp = window.rankMath && window.rankMath.assessor && window.rankMath.assessor.serpData;
return !! serp && '%excerpt%' === String( serp.descriptionTemplate || '' ).trim();
}
// Priority 5: before Rank Math appends its own additions, such as ACF fields.
hooks.addFilter( 'rank_math_content', NAMESPACE, function ( content ) {
try {
if ( isUnreadable( content ) && ! etchIntegrates() ) {
var rendered = renderedContent();
if ( null !== rendered ) {
return rendered;
}
}
} catch ( e ) {}
return content;
}, 5 );
// The snippet preview builds %excerpt% from the raw content. Give it the
// paragraph the frontend uses. Returns a copy; Rank Math's data is untouched.
hooks.addFilter( 'rank_math_dataCollector_data', NAMESPACE, function ( collected ) {
try {
if (
collected
&& ! collected.excerpt
&& config.paragraphs.length
&& usesExcerptTemplate()
&& isUnreadable( collected.content )
&& ! etchIntegrates()
) {
var excerpt = pickExcerpt();
if ( excerpt ) {
return Object.assign( {}, collected, { excerpt: excerpt } );
}
}
} catch ( e ) {}
return collected;
} );
// Once Rank Math is ready, and whenever the rendered HTML changes (e.g. after
// saving), run the same updates Rank Math runs for a content change.
var synced = null;
data.subscribe( function () {
try {
if ( ! window.rankMathEditor ) {
return;
}
var rendered = renderedContent();
var store = data.select( 'rank-math' );
if ( null === rendered || rendered === synced || ! store || ! store.isLoaded() ) {
return;
}
synced = rendered;
var actions = data.dispatch( 'rank-math' );
if ( actions && 'function' === typeof actions.updateSerpDescription && 'function' === typeof store.getDescription ) {
actions.updateSerpDescription( store.getDescription() );
}
window.rankMathEditor.refresh( 'content' );
} catch ( e ) {}
} );
} )( window.wp.hooks, window.wp.data, window.wp.blockSerializationDefaultParser, __ED_ETCH_RANK_MATH_CONFIG__ );
JS;
}
/*
* Public metadata: stored Etch text only.
*/
/**
* Fills %excerpt% when Rank Math would auto-generate it from content it
* cannot read. An excerpt or a manual meta description still wins, and
* password-protected posts get nothing.
*/
public static function filter_replacements( $replacements, $args ) {
if (
! is_array( $replacements )
|| ! array_key_exists( '%excerpt%', $replacements )
|| ! is_object( $args )
|| empty( $args->ID )
|| empty( $args->post_type )
) {
return $replacements;
}
try {
$post = get_post( (int) $args->ID );
if (
! $post instanceof WP_Post
|| $post->post_type !== $args->post_type
|| '' !== (string) $post->post_password
|| '' !== trim( (string) $post->post_excerpt )
|| ! self::is_unreadable( $post->post_content )
|| ! self::active( 'description', 'rank_math/replacements' )
) {
return $replacements;
}
$excerpt = self::pick_excerpt( self::paragraphs( $post ), self::focus_keyword( $post->ID ) );
if ( '' !== $excerpt ) {
$replacements['%excerpt%'] = $excerpt;
}
} catch ( Throwable $e ) {
// Keep Rank Math's own value.
}
return $replacements;
}
private static function focus_keyword( $post_id ) {
$keywords = explode( ',', (string) get_post_meta( $post_id, 'rank_math_focus_keyword', true ) );
return trim( $keywords[0] );
}
/**
* Like Rank Math: the first paragraph with the focus keyword, otherwise
* the first paragraph. Short labels are skipped when a longer one exists.
*/
private static function pick_excerpt( array $paragraphs, $keyword ) {
$lower = function_exists( 'mb_strtolower' ) ? 'mb_strtolower' : 'strtolower';
if ( '' !== $keyword ) {
foreach ( $paragraphs as $paragraph ) {
if ( false !== strpos( $lower( $paragraph ), $lower( $keyword ) ) ) {
return $paragraph;
}
}
}
foreach ( $paragraphs as $paragraph ) {
if ( mb_strlen( $paragraph ) >= self::MIN_EXCERPT_LENGTH ) {
return $paragraph;
}
}
return isset( $paragraphs[0] ) ? $paragraphs[0] : '';
}
/**
* Text of the paragraphs stored in the post, in page order. Reads block
* attributes only: no rendering, queries, dynamic data or shortcodes.
*/
public static function paragraphs( WP_Post $post ) {
static $cache = array();
$content = (string) $post->post_content;
$key = $post->ID . ':' . md5( $content );
if ( isset( $cache[ $key ] ) ) {
return $cache[ $key ];
}
if ( count( $cache ) > 100 ) {
$cache = array();
}
$state = array(
'paragraphs' => array(),
'current' => null,
'previous' => array(),
'remaining' => 50000,
'exhausted' => false,
);
if (
strlen( $content ) <= 1048576
&& substr_count( $content, '<!--' ) <= 20000
&& method_exists( 'WP_HTML_Tag_Processor', 'next_token' )
) {
self::walk_blocks( parse_blocks( $content ), $state, 0 );
}
// Past the limits, keep the paragraphs completed before them.
if ( $state['exhausted'] && null !== $state['current'] ) {
array_splice( $state['paragraphs'], $state['current'] );
}
$paragraphs = array();
foreach ( $state['paragraphs'] as $text ) {
// Inline markup is joined with spaces; tidy the space before punctuation.
$text = trim( (string) preg_replace( '/\s+([.,;:!?])/u', '$1', $text ) );
if ( '' !== $text ) {
$paragraphs[] = $text;
}
}
$cache[ $key ] = $paragraphs;
return $paragraphs;
}
/** Bounds traversal of blocks, nested props, decoded JSON and HTML tokens. */
private static function step( array &$state, $depth ) {
if ( $state['exhausted'] || $depth > 64 || --$state['remaining'] < 0 ) {
$state['exhausted'] = true;
return false;
}
return true;
}
private static function walk_blocks( array $blocks, array &$state, $depth ) {
foreach ( $blocks as $block ) {
if ( ! self::step( $state, $depth ) ) {
return;
}
if ( ! is_array( $block ) ) {
continue;
}
$name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : '';
$attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
$inner = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : array();
switch ( $name ) {
case 'etch/text':
self::add_text( isset( $attrs['content'] ) ? $attrs['content'] : ( isset( $attrs['text'] ) ? $attrs['text'] : '' ), $state );
break;
case 'etch/raw-html':
// Read content only. The unsafe field is never a text source.
self::add_text( isset( $attrs['content'] ) ? $attrs['content'] : '', $state );
break;
case 'etch/component':
self::walk_props( isset( $attrs['attributes'] ) ? $attrs['attributes'] : ( isset( $attrs['props'] ) ? $attrs['props'] : array() ), '', $state, $depth + 1 );
self::walk_blocks( $inner, $state, $depth + 1 );
break;
case 'etch/slot-content':
case 'etch/slot-placeholder':
self::walk_blocks( $inner, $state, $depth + 1 );
break;
case 'etch/passthrough':
$wrapped = isset( $attrs['gutenbergBlock'] ) ? $attrs['gutenbergBlock'] : null;
if ( is_string( $wrapped ) ) {
$wrapped = json_decode( $wrapped, true, 64 );
}
if ( is_array( $wrapped ) ) {
self::walk_core_block( $wrapped, $state, $depth + 1 );
}
break;
case 'etch/element':
case 'etch/dynamic-element':
$html_attrs = isset( $attrs['attributes'] ) && is_array( $attrs['attributes'] ) ? $attrs['attributes'] : array();
// A missing tag is Etch's default div. The block editor omits it on save.
$tag = 'etch/dynamic-element' === $name ? ( isset( $html_attrs['tag'] ) ? $html_attrs['tag'] : 'div' ) : ( isset( $attrs['tag'] ) ? $attrs['tag'] : 'div' );
if (
! is_string( $tag )
|| 1 !== preg_match( '/^[a-z][a-z0-9-]*$/i', $tag )
|| self::excluded_tag( $tag )
|| ( array_key_exists( 'hidden', $html_attrs ) && false !== $html_attrs['hidden'] && null !== $html_attrs['hidden'] )
|| ( isset( $html_attrs['aria-hidden'] ) && 'true' === $html_attrs['aria-hidden'] )
) {
break;
}
if ( 'p' === strtolower( $tag ) ) {
self::open_paragraph( $state );
self::walk_blocks( $inner, $state, $depth + 1 );
self::close_paragraph( $state );
} else {
self::walk_blocks( $inner, $state, $depth + 1 );
}
break;
default:
// Conditions, loops, post content and unknown blocks are skipped: their
// output depends on the visitor, a query, or another post.
if ( 0 === strpos( $name, 'core/' ) ) {
self::walk_core_block( $block, $state, $depth + 1 );
}
break;
}
}
}
private static function open_paragraph( array &$state ) {
$state['paragraphs'][] = '';
$state['previous'][] = $state['current'];
$state['current'] = count( $state['paragraphs'] ) - 1;
}
private static function close_paragraph( array &$state ) {
if ( $state['exhausted'] ) {
return; // Leave the paragraph open, so it is dropped as incomplete.
}
$state['current'] = array_pop( $state['previous'] );
}
private static function walk_core_block( array $block, array &$state, $depth ) {
if ( ! self::step( $state, $depth ) ) {
return;
}
$name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : '';
$containers = array( 'core/group', 'core/columns', 'core/column', 'core/quote' );
if (
( 'core/paragraph' !== $name && ! in_array( $name, $containers, true ) )
|| ! empty( $block['attrs']['metadata']['bindings'] )
) {
return;
}
$parts = isset( $block['innerContent'] ) && is_array( $block['innerContent'] ) ? $block['innerContent'] : array();
$html = implode( '', array_filter( $parts, 'is_string' ) );
if ( 'core/paragraph' === $name ) {
self::open_paragraph( $state );
self::add_text( $html, $state );
self::close_paragraph( $state );
return;
}
// An excluded or hidden wrapper also excludes its nested blocks.
$processor = new WP_HTML_Tag_Processor( $html );
while ( $processor->next_token() ) {
if ( ! self::step( $state, $depth ) ) {
return;
}
$tag = $processor->get_tag();
if ( null !== $tag && ! $processor->is_tag_closer() && (
self::excluded_tag( $tag )
|| null !== $processor->get_attribute( 'hidden' )
|| 'true' === strtolower( (string) $processor->get_attribute( 'aria-hidden' ) )
) ) {
return;
}
}
$inner = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : array();
self::walk_blocks( $inner, $state, $depth + 1 );
}
private static function walk_props( $props, $path, array &$state, $depth ) {
if ( ! self::step( $state, $depth ) ) {
return;
}
// A text-like suffix must not turn a credential field into public copy.
if ( preg_match( '/(?:^|\.)(?:api[_-]?key|secret|password|token|nonce|private)[^.]*(?:\.|$)/i', $path ) ) {
return;
}
// Exclude whole configuration branches, not just their leaf names.
if ( preg_match( '/(?:^|\.)(?:ref|componentid|class|classes|classname|style|styles|css|tag|variant|theme|color|background|layout|align|alignment|size|gapsize|width|height|mediaid|usesrcset|maximumsize|imageid|imageurl|imagesrc|iconid|iconname|iconurl|iconsrc|url|href|src|target|rel|config|settings|options|script|code|unsafe|secret|password|token|apikey|nonce|query)(?:\.|$)/i', $path ) ) {
return;
}
if ( is_array( $props ) ) {
foreach ( $props as $key => $value ) {
$next_path = is_int( $key ) ? $path : ( '' === $path ? (string) $key : $path . '.' . $key );
self::walk_props( $value, $next_path, $state, $depth + 1 );
if ( $state['exhausted'] ) {
return;
}
}
return;
}
if ( ! is_string( $props ) ) {
return;
}
$value = trim( $props );
// Decode only possible JSON containers, rather than every text value.
if ( '' !== $value && ( '[' === $value[0] || '{' === $value[0] ) ) {
$decoded = json_decode( $value, true, 64 );
if ( is_array( $decoded ) ) {
self::walk_props( $decoded, $path, $state, $depth + 1 );
return;
}
if ( JSON_ERROR_DEPTH === json_last_error() ) {
return;
}
}
if ( self::prop_is_paragraph( $path, $value ) ) {
self::open_paragraph( $state );
self::add_text( $value, $state );
self::close_paragraph( $state );
}
}
/** Component props that read like body copy. Titles and labels are not paragraphs. */
private static function prop_is_paragraph( $key, $value ) {
if ( '' === $value || preg_match( '#^(?:[a-z][a-z0-9+.-]*:|//|/|\#|var\()#i', $value ) ) {
return false;
}
$key = preg_replace( '/([a-z0-9])([A-Z])/', '$1.$2', $key );
$key = str_replace( array( '_', '-' ), '.', strtolower( $key ) );
return 1 === preg_match( '/(?:^|\.)(?:text|description|content|paragraph|body|copy|excerpt|intro|summary|lead|lede|blurb)(?:s|\d+)?$/', $key );
}
private static function excluded_tag( $tag ) {
return in_array( strtolower( $tag ), array( 'form', 'script', 'style', 'noscript', 'iframe', 'svg', 'template', 'select', 'textarea', 'title', 'xmp', 'noembed', 'noframes', 'button', 'label', 'nav' ), true );
}
/** Appends text nodes to the open paragraph; never interprets entities as markup. */
private static function add_text( $value, array &$state ) {
if ( ! is_string( $value ) || '' === $value || $state['exhausted'] || null === $state['current'] ) {
return;
}
$processor = new WP_HTML_Tag_Processor( $value );
$text = '';
$skip_tag = null;
$skip_depth = 0;
while ( $processor->next_token() ) {
if ( ! self::step( $state, 0 ) ) {
return;
}
$tag = $processor->get_tag();
if ( null !== $skip_tag ) {
if ( $tag === $skip_tag ) {
$skip_depth += $processor->is_tag_closer() ? -1 : 1;
if ( 0 === $skip_depth ) {
$skip_tag = null;
}
}
continue;
}
if ( '#text' === $processor->get_token_type() ) {
$text .= $processor->get_modifiable_text();
continue;
}
if ( null === $tag ) {
continue; // Comments and other non-text tokens are not page copy.
}
// These tokens already include their entire raw-text body and end tag.
if ( in_array( $tag, array( 'SCRIPT', 'STYLE', 'IFRAME', 'TEXTAREA', 'TITLE', 'XMP', 'NOEMBED', 'NOFRAMES' ), true ) ) {
$text .= ' ';
continue;
}
if (
! $processor->is_tag_closer()
&& (
self::excluded_tag( $tag )
|| null !== $processor->get_attribute( 'hidden' )
|| 'true' === strtolower( (string) $processor->get_attribute( 'aria-hidden' ) )
)
) {
if ( ! in_array( $tag, array( 'AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR' ), true ) ) {
$skip_tag = $tag;
$skip_depth = 1;
}
$text .= ' ';
continue;
}
if ( 'BR' === $tag ) {
$text .= ' ';
}
}
// Strip registered shortcode syntax without invoking its callback.
$text = strip_shortcodes( $text );
$text = preg_replace( '/\{(?:[^{}]++|(?R))*\}/u', ' ', $text );
if ( null === $text || false !== strpbrk( $text, '{}' ) ) {
return; // Discard malformed or unresolved dynamic expressions.
}
$text = trim( (string) preg_replace( '/[\s\p{Z}]+/u', ' ', $text ) );
if ( '' !== $text ) {
$state['paragraphs'][ $state['current'] ] .= ' ' . $text;
}
}
}
ED_Etch_Rank_Math::init();
}What it does
Etch stores page text in block attributes and renders it on the server. Rank Math normally reads the post content, so it can see an empty page: content checks fail, link counts stay at zero and %excerpt% has nothing to use. This snippet gives Rank Math the content it is missing.
It uses two separate sources. SEO analysis in wp-admin reads the rendered page. Public descriptions use only text stored in Etch blocks, without rendering the page or resolving dynamic data.
- Content analysis: the SEO score, readability checks and snippet preview in the WordPress block editor can read your Etch page.
- Stored scores and link counts: Rank Math’s Update SEO Scores tool and internal link counter read the rendered page too. These results stay in wp-admin.
- Automatic descriptions: %excerpt% uses the first paragraph containing the focus keyword; otherwise, the first paragraph with at least 50 characters. If none is long enough, it uses the first available paragraph. Short labels are skipped when a longer paragraph exists.
- Public metadata: conditions, loops, forms, hidden or aria-hidden elements and Post Content blocks are skipped. Dynamic expressions are removed rather than resolved. The same paragraph reaches Open Graph, schema and llms.txt wherever they use %excerpt%.
- Steps aside when it is not needed: non-Etch posts stay unchanged, as do Etch blocks that already store HTML. If Etch hooks the same Rank Math filter, that filter is left to Etch. Inactive Etch or a rendering failure keeps Rank Math’s own value; the snippet stores no data of its own.
Install it
- 01Add a new PHP snippet Use a snippet manager such as FluentSnippets, WPCodeBox or Code Snippets, or a site-specific plugin. Paste the code above. If your manager already adds the opening PHP tag, omit that first line when pasting.
- 02Run it everywhere Enable it in both wp-admin and the front end: analysis runs in the block editor, descriptions on the front end and the link counter when a post is saved. Requires WordPress 6.5+ and PHP 7.4+. Remove any older copy before activating this version.
- 03Check an Etch page Save the page in Etch, then open or reload it in the WordPress block editor. Check Rank Math’s content analysis. On a page without an excerpt or a manual meta description, view the page source and confirm the meta description contains a paragraph from the page.
- 04Update existing scores and link counts Run Rank Math → Status & Tools → Database Tools → Update SEO Scores to refresh stored scores. Link counts update when Rank Math processes a saved page, for example after updating it in the block editor. Saving in Etch alone does not update the stored SEO score.
What to know
- Your editorial choices win: an excerpt or a manual meta description takes priority. Password-protected posts get no description from this snippet. It does not choose a social image; set a featured image or Rank Math’s default social image.
- The editor analyses the last saved version. After saving in Etch, reload the block editor. The snippet preview refreshes its description after a reload or content change, not while you type a new focus keyword.
- Component definitions and default prop values are not loaded for descriptions. Body-copy props such as text or description can count as paragraphs. Rank Math’s content-analysis ability for AI agents still reads raw content and cannot use this integration.
Test on staging with the Etch and Rank Math versions you use in production. Compare editor checks and meta descriptions on a regular page, a page with conditions and a password-protected page. The supplied snippet was checked with Rank Math 1.0.279, WordPress 7.1.2 and PHP 7.4/8.5, plus Etch 1.6.7 in the block editor. Rank Math PRO was not included in those checks.
- 1.0.0 Oct 1, 2026First public version.
Tell me and I will fix the snippet
The versions above are the ones this code was tested on. If yours differ, or the code fails on your site, send me the details.