Let Slim SEO read the text on your Etch pages
Slim SEO skips server-rendered blocks, and every Etch block is one, so it finds no text on an Etch page to build a meta description from. This snippet passes it the text stored in your Etch blocks, without rendering the page.
Paste it into a new PHP snippet. Install steps are below the code.
<?php
/**
* Etch + Slim SEO / Slim SEO Pro
*
* Extract stored text without rendering blocks, running queries, resolving
* dynamic data, or executing shortcodes. Requires WordPress 6.5 or later.
* Component props are a text estimate; component definitions are not loaded.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_filter( 'slim_seo_post_content', 'ed_etch_slim_seo_content', 20, 2 );
function ed_etch_slim_seo_content( $content, $post ) {
if (
! is_string( $content )
|| ! ( $post instanceof WP_Post )
|| 1 !== preg_match( '/<!--\s+wp:etch\//', $content )
) {
return $content;
}
// Metadata is public, even when a visitor has unlocked a protected post.
if (
'' !== (string) $post->post_password
|| strlen( $content ) > 1048576
|| substr_count( $content, '<!--' ) > 10000
|| ! method_exists( 'WP_HTML_Tag_Processor', 'next_token' )
) {
return '';
}
$state = array(
'chunks' => array(),
'remaining' => 20000,
'exhausted' => false,
);
ed_etch_seo_walk_blocks( parse_blocks( $content ), $state );
if ( $state['exhausted'] ) {
return '';
}
/*
* Slim SEO later calls do_shortcode() and do_blocks(). Encode literal
* markup and brackets so escaped shortcodes cannot become executable.
* Never return raw Etch content when extraction produces no text.
*/
$text = htmlspecialchars( trim( implode( "\n", $state['chunks'] ) ), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' );
return strtr( $text, array( '[' => '[', ']' => ']' ) );
}
/** Bound traversal of blocks, nested props, decoded JSON, and HTML tokens. */
function ed_etch_seo_step( array &$state, $depth ) {
if ( $state['exhausted'] || $depth > 64 || --$state['remaining'] < 0 ) {
$state['exhausted'] = true;
return false;
}
return true;
}
function ed_etch_seo_walk_blocks( array $blocks, array &$state, $depth = 0 ) {
foreach ( $blocks as $block ) {
if ( ! ed_etch_seo_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':
ed_etch_seo_add_text( $attrs['content'] ?? ( $attrs['text'] ?? '' ), $state );
break;
case 'etch/raw-html':
// Read content only. The unsafe field is never a text source.
ed_etch_seo_add_text( $attrs['content'] ?? '', $state );
break;
case 'etch/component':
// Prefer attributes; reading both representations doubles the copy.
ed_etch_seo_walk_props( $attrs['attributes'] ?? ( $attrs['props'] ?? array() ), '', $state, $depth + 1 );
ed_etch_seo_walk_blocks( $inner, $state, $depth + 1 );
break;
case 'etch/condition':
// The branch may be private or invisible. Do not guess its result.
case 'etch/post-content':
// Do not fetch another document or bypass its access rules.
break;
case 'etch/loop':
// Read the stored template once; never execute its query.
case 'etch/slot-content':
case 'etch/slot-placeholder':
ed_etch_seo_walk_blocks( $inner, $state, $depth + 1 );
break;
case 'etch/passthrough':
$wrapped = $attrs['gutenbergBlock'] ?? null;
if ( is_string( $wrapped ) ) {
$wrapped = json_decode( $wrapped, true, 64 );
}
if ( is_array( $wrapped ) ) {
ed_etch_seo_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 ? ( $html_attrs['tag'] ?? 'div' ) : ( $attrs['tag'] ?? 'div' );
if (
! is_string( $tag )
|| 1 !== preg_match( '/^[a-z][a-z0-9-]*$/i', $tag )
|| ed_etch_seo_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;
}
ed_etch_seo_walk_blocks( $inner, $state, $depth + 1 );
break;
default:
// Ignore unknown wrappers: their children may depend on access rules.
if ( '' === $name || 0 === strpos( $name, 'core/' ) ) {
ed_etch_seo_walk_core_block( $block, $state, $depth + 1 );
}
break;
}
}
}
function ed_etch_seo_walk_core_block( array $block, array &$state, $depth ) {
if ( ! ed_etch_seo_step( $state, $depth ) ) {
return;
}
$name = $block['blockName'] ?? '';
$allowed = array(
'', 'core/freeform', 'core/paragraph', 'core/heading', 'core/list',
'core/list-item', 'core/quote', 'core/pullquote', 'core/table',
'core/preformatted', 'core/verse', 'core/button', 'core/buttons',
'core/group', 'core/columns', 'core/column',
);
// Check the wrapper before visiting children, including passthrough blocks.
if ( ! in_array( $name, $allowed, true ) || ! empty( $block['attrs']['metadata']['bindings'] ) ) {
return;
}
$inner = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : array();
$parts = isset( $block['innerContent'] ) && is_array( $block['innerContent'] ) ? $block['innerContent'] : null;
if ( $inner ) {
// An excluded HTML wrapper also excludes its nested Gutenberg blocks.
$html = null !== $parts ? implode( '', array_filter( $parts, 'is_string' ) ) : ( $block['innerHTML'] ?? '' );
if ( ! is_string( $html ) ) {
return;
}
$processor = new WP_HTML_Tag_Processor( $html );
while ( $processor->next_token() ) {
if ( ! ed_etch_seo_step( $state, $depth ) ) {
return;
}
$tag = $processor->get_tag();
if ( null !== $tag && ! $processor->is_tag_closer() && (
ed_etch_seo_excluded_tag( $tag )
|| null !== $processor->get_attribute( 'hidden' )
|| 'true' === strtolower( (string) $processor->get_attribute( 'aria-hidden' ) )
) ) {
return;
}
}
}
if ( $inner && null !== $parts ) {
// Keep parent text, citations, and nested list items in document order.
$index = 0;
foreach ( $parts as $part ) {
if ( ! ed_etch_seo_step( $state, $depth ) ) {
return;
}
if ( null === $part ) {
if ( isset( $inner[ $index ] ) && is_array( $inner[ $index ] ) ) {
ed_etch_seo_walk_blocks( array( $inner[ $index ] ), $state, $depth + 1 );
}
++$index;
} else {
ed_etch_seo_add_text( $part, $state );
}
}
} elseif ( $inner ) {
ed_etch_seo_walk_blocks( $inner, $state, $depth + 1 );
} else {
ed_etch_seo_add_text( $block['innerHTML'] ?? '', $state );
}
}
function ed_etch_seo_walk_props( $props, $path, array &$state, $depth ) {
if ( ! ed_etch_seo_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 ) {
// Preserve the field name for a list of strings, e.g. bulletPoints[0].
$next_path = is_int( $key ) ? $path : ( '' === $path ? (string) $key : $path . '.' . $key );
ed_etch_seo_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 ) ) {
ed_etch_seo_walk_props( $decoded, $path, $state, $depth + 1 );
return;
}
if ( JSON_ERROR_DEPTH === json_last_error() ) {
return;
}
}
if ( ed_etch_seo_prop_is_copy( $path, $value ) ) {
ed_etch_seo_add_text( $value, $state );
}
}
function ed_etch_seo_prop_is_copy( $key, $value ) {
if ( '' === $value || preg_match( '#^(?:[a-z][a-z0-9+.-]*:|//|/|\#|var\()#i', $value ) ) {
return false;
}
// Recognize text fields in camelCase, snake_case, or dotted prop paths.
$key = preg_replace( '/([a-z0-9])([A-Z])/', '$1.$2', $key );
$key = str_replace( array( '_', '-' ), '.', strtolower( $key ) );
// Unknown multiword values may be configuration or private data.
return 1 === preg_match(
'/(?:^|\.)(?:text|title|heading|headline|subheading|subtitle|eyebrow|kicker|label|description|content|paragraph|body|copy|excerpt|intro|summary|lead|quote|question|answer|caption|notice|message|address|phone|tel|telephone|email|person|author|position|jurisdiction|credential|service|practice|location|city|state|date|year|name|item|bullet|point|step)(?:s|list|items?|\d+)?$/',
$key
);
}
function ed_etch_seo_excluded_tag( $tag ) {
return in_array( strtolower( $tag ), array( 'form', 'script', 'style', 'noscript', 'iframe', 'svg', 'template', 'select', 'textarea', 'title', 'xmp', 'noembed', 'noframes' ), true );
}
/** Extract text nodes; never interpret HTML entities as new markup. */
function ed_etch_seo_add_text( $value, array &$state ) {
if ( ! is_string( $value ) || '' === $value || $state['exhausted'] ) {
return;
}
$processor = new WP_HTML_Tag_Processor( $value );
$text = '';
$skip_tag = null;
$skip_depth = 0;
while ( $processor->next_token() ) {
if ( ! ed_etch_seo_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()
&& (
ed_etch_seo_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;
}
// Keep block boundaries without splitting words around inline markup.
if ( in_array( $tag, array( 'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'BR', 'DD', 'DIV', 'DL', 'DT', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'TABLE', 'TD', 'TH', 'TR', 'UL' ), true ) ) {
$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 ) {
// Repeated visible text is meaningful to word counts and keyword analysis.
$state['chunks'][] = $text;
}
}- Requires
- Etch + Slim SEO, WordPress 6.5+
- Runs on
- Front end and wp-admin
- Version
- 1.0.1
- License
- GPL-2.0-or-later
<?php
/**
* Etch + Slim SEO / Slim SEO Pro
*
* Extract stored text without rendering blocks, running queries, resolving
* dynamic data, or executing shortcodes. Requires WordPress 6.5 or later.
* Component props are a text estimate; component definitions are not loaded.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_filter( 'slim_seo_post_content', 'ed_etch_slim_seo_content', 20, 2 );
function ed_etch_slim_seo_content( $content, $post ) {
if (
! is_string( $content )
|| ! ( $post instanceof WP_Post )
|| 1 !== preg_match( '/<!--\s+wp:etch\//', $content )
) {
return $content;
}
// Metadata is public, even when a visitor has unlocked a protected post.
if (
'' !== (string) $post->post_password
|| strlen( $content ) > 1048576
|| substr_count( $content, '<!--' ) > 10000
|| ! method_exists( 'WP_HTML_Tag_Processor', 'next_token' )
) {
return '';
}
$state = array(
'chunks' => array(),
'remaining' => 20000,
'exhausted' => false,
);
ed_etch_seo_walk_blocks( parse_blocks( $content ), $state );
if ( $state['exhausted'] ) {
return '';
}
/*
* Slim SEO later calls do_shortcode() and do_blocks(). Encode literal
* markup and brackets so escaped shortcodes cannot become executable.
* Never return raw Etch content when extraction produces no text.
*/
$text = htmlspecialchars( trim( implode( "\n", $state['chunks'] ) ), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' );
return strtr( $text, array( '[' => '[', ']' => ']' ) );
}
/** Bound traversal of blocks, nested props, decoded JSON, and HTML tokens. */
function ed_etch_seo_step( array &$state, $depth ) {
if ( $state['exhausted'] || $depth > 64 || --$state['remaining'] < 0 ) {
$state['exhausted'] = true;
return false;
}
return true;
}
function ed_etch_seo_walk_blocks( array $blocks, array &$state, $depth = 0 ) {
foreach ( $blocks as $block ) {
if ( ! ed_etch_seo_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':
ed_etch_seo_add_text( $attrs['content'] ?? ( $attrs['text'] ?? '' ), $state );
break;
case 'etch/raw-html':
// Read content only. The unsafe field is never a text source.
ed_etch_seo_add_text( $attrs['content'] ?? '', $state );
break;
case 'etch/component':
// Prefer attributes; reading both representations doubles the copy.
ed_etch_seo_walk_props( $attrs['attributes'] ?? ( $attrs['props'] ?? array() ), '', $state, $depth + 1 );
ed_etch_seo_walk_blocks( $inner, $state, $depth + 1 );
break;
case 'etch/condition':
// The branch may be private or invisible. Do not guess its result.
case 'etch/post-content':
// Do not fetch another document or bypass its access rules.
break;
case 'etch/loop':
// Read the stored template once; never execute its query.
case 'etch/slot-content':
case 'etch/slot-placeholder':
ed_etch_seo_walk_blocks( $inner, $state, $depth + 1 );
break;
case 'etch/passthrough':
$wrapped = $attrs['gutenbergBlock'] ?? null;
if ( is_string( $wrapped ) ) {
$wrapped = json_decode( $wrapped, true, 64 );
}
if ( is_array( $wrapped ) ) {
ed_etch_seo_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 ? ( $html_attrs['tag'] ?? 'div' ) : ( $attrs['tag'] ?? 'div' );
if (
! is_string( $tag )
|| 1 !== preg_match( '/^[a-z][a-z0-9-]*$/i', $tag )
|| ed_etch_seo_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;
}
ed_etch_seo_walk_blocks( $inner, $state, $depth + 1 );
break;
default:
// Ignore unknown wrappers: their children may depend on access rules.
if ( '' === $name || 0 === strpos( $name, 'core/' ) ) {
ed_etch_seo_walk_core_block( $block, $state, $depth + 1 );
}
break;
}
}
}
function ed_etch_seo_walk_core_block( array $block, array &$state, $depth ) {
if ( ! ed_etch_seo_step( $state, $depth ) ) {
return;
}
$name = $block['blockName'] ?? '';
$allowed = array(
'', 'core/freeform', 'core/paragraph', 'core/heading', 'core/list',
'core/list-item', 'core/quote', 'core/pullquote', 'core/table',
'core/preformatted', 'core/verse', 'core/button', 'core/buttons',
'core/group', 'core/columns', 'core/column',
);
// Check the wrapper before visiting children, including passthrough blocks.
if ( ! in_array( $name, $allowed, true ) || ! empty( $block['attrs']['metadata']['bindings'] ) ) {
return;
}
$inner = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : array();
$parts = isset( $block['innerContent'] ) && is_array( $block['innerContent'] ) ? $block['innerContent'] : null;
if ( $inner ) {
// An excluded HTML wrapper also excludes its nested Gutenberg blocks.
$html = null !== $parts ? implode( '', array_filter( $parts, 'is_string' ) ) : ( $block['innerHTML'] ?? '' );
if ( ! is_string( $html ) ) {
return;
}
$processor = new WP_HTML_Tag_Processor( $html );
while ( $processor->next_token() ) {
if ( ! ed_etch_seo_step( $state, $depth ) ) {
return;
}
$tag = $processor->get_tag();
if ( null !== $tag && ! $processor->is_tag_closer() && (
ed_etch_seo_excluded_tag( $tag )
|| null !== $processor->get_attribute( 'hidden' )
|| 'true' === strtolower( (string) $processor->get_attribute( 'aria-hidden' ) )
) ) {
return;
}
}
}
if ( $inner && null !== $parts ) {
// Keep parent text, citations, and nested list items in document order.
$index = 0;
foreach ( $parts as $part ) {
if ( ! ed_etch_seo_step( $state, $depth ) ) {
return;
}
if ( null === $part ) {
if ( isset( $inner[ $index ] ) && is_array( $inner[ $index ] ) ) {
ed_etch_seo_walk_blocks( array( $inner[ $index ] ), $state, $depth + 1 );
}
++$index;
} else {
ed_etch_seo_add_text( $part, $state );
}
}
} elseif ( $inner ) {
ed_etch_seo_walk_blocks( $inner, $state, $depth + 1 );
} else {
ed_etch_seo_add_text( $block['innerHTML'] ?? '', $state );
}
}
function ed_etch_seo_walk_props( $props, $path, array &$state, $depth ) {
if ( ! ed_etch_seo_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 ) {
// Preserve the field name for a list of strings, e.g. bulletPoints[0].
$next_path = is_int( $key ) ? $path : ( '' === $path ? (string) $key : $path . '.' . $key );
ed_etch_seo_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 ) ) {
ed_etch_seo_walk_props( $decoded, $path, $state, $depth + 1 );
return;
}
if ( JSON_ERROR_DEPTH === json_last_error() ) {
return;
}
}
if ( ed_etch_seo_prop_is_copy( $path, $value ) ) {
ed_etch_seo_add_text( $value, $state );
}
}
function ed_etch_seo_prop_is_copy( $key, $value ) {
if ( '' === $value || preg_match( '#^(?:[a-z][a-z0-9+.-]*:|//|/|\#|var\()#i', $value ) ) {
return false;
}
// Recognize text fields in camelCase, snake_case, or dotted prop paths.
$key = preg_replace( '/([a-z0-9])([A-Z])/', '$1.$2', $key );
$key = str_replace( array( '_', '-' ), '.', strtolower( $key ) );
// Unknown multiword values may be configuration or private data.
return 1 === preg_match(
'/(?:^|\.)(?:text|title|heading|headline|subheading|subtitle|eyebrow|kicker|label|description|content|paragraph|body|copy|excerpt|intro|summary|lead|quote|question|answer|caption|notice|message|address|phone|tel|telephone|email|person|author|position|jurisdiction|credential|service|practice|location|city|state|date|year|name|item|bullet|point|step)(?:s|list|items?|\d+)?$/',
$key
);
}
function ed_etch_seo_excluded_tag( $tag ) {
return in_array( strtolower( $tag ), array( 'form', 'script', 'style', 'noscript', 'iframe', 'svg', 'template', 'select', 'textarea', 'title', 'xmp', 'noembed', 'noframes' ), true );
}
/** Extract text nodes; never interpret HTML entities as new markup. */
function ed_etch_seo_add_text( $value, array &$state ) {
if ( ! is_string( $value ) || '' === $value || $state['exhausted'] ) {
return;
}
$processor = new WP_HTML_Tag_Processor( $value );
$text = '';
$skip_tag = null;
$skip_depth = 0;
while ( $processor->next_token() ) {
if ( ! ed_etch_seo_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()
&& (
ed_etch_seo_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;
}
// Keep block boundaries without splitting words around inline markup.
if ( in_array( $tag, array( 'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'BR', 'DD', 'DIV', 'DL', 'DT', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'TABLE', 'TD', 'TH', 'TR', 'UL' ), true ) ) {
$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 ) {
// Repeated visible text is meaningful to word counts and keyword analysis.
$state['chunks'][] = $text;
}
}What it does
Slim SEO reads the post content through its slim_seo_post_content filter to build automatic meta descriptions, editor previews and AI suggestions. It renders only static blocks there and skips dynamic ones. Etch keeps your text in block attributes and renders every block on the server, so Slim SEO gets no text from an Etch page.
The snippet hooks that filter and reads the text stored in Etch blocks, component props and common core blocks, in page order. Slim SEO gets plain text it can shorten into a description, and nothing on the page is rendered, queried or executed to produce it.
- Runs only when the post content contains Etch blocks. Other posts reach Slim SEO unchanged.
- Reads stored text only. It renders no blocks and runs no queries, dynamic data or shortcodes.
- Takes component props that look like copy, such as titles, headings, text and labels. URLs, classes, settings and credential-like fields are skipped.
- Skips condition branches, forms, scripts, templates and elements marked hidden or aria-hidden.
- Returns no text for password-protected posts, so their content never ends up in public meta tags.
- Escapes markup and square brackets in the result, so Slim SEO’s own shortcode and block pass cannot run anything from your copy.
Install it
- 01Add a new PHP snippet Use a snippet manager such as FluentSnippets, WPCodeBox or Code Snippets, or a site-specific plugin. Most managers add the opening PHP tag for you.
- 02Paste the code and run it everywhere Slim SEO reads content on the front end for meta tags and in wp-admin for previews and AI suggestions, so the snippet has to load in both. It needs WordPress 6.5 or later.
- 03Replace any earlier copy Updating from an older version of this snippet? Delete the old one first. Never run two versions side by side.
- 04Check a page Open an Etch page that has no excerpt or manual meta description and view its source. The meta description now holds text from the page.
What it leaves alone
- It does not replace an excerpt or a manual meta description. Slim SEO still prefers those, and they are the way to control the exact wording.
- Component definitions are not loaded. A text prop that the component never shows can still count, and default prop values are missing.
- Loop templates are read once without running their query, condition branches are always skipped, and Post Content blocks do not pull in other posts.
- Very large or deeply nested content returns an empty text instead of a rendered page: over 1 MiB, 10,000 HTML comments, 64 nesting levels or 20,000 steps.
Test it on a staging site with the Etch and Slim SEO versions you run in production. Compare the meta description of a public page, a page with conditions and a password-protected page. It was checked with Slim SEO 4.10.1 on WordPress 6.5 and 7.1.2; Slim SEO Pro was not part of that check.
- 1.0.1 Oct 1, 2026Reads Etch elements saved without a tag again. The block editor omits Etch's default div, so a page last updated there previously gave Slim SEO no text.
- 1.0.0 Sep 26, 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.