Files
heretek-control-core/includes/class-page-snapshot.php
T
Mian Shahzad RazaandClaude Opus 4.8 f5d7847502 fix(snapshot): read SEO meta from all postmeta plugins + add override seam
seo_lite hard-coded Yoast/Rank Math keys, so AIOSEO/SEOPress sites reported
empty meta. Adds the SEOPress postmeta keys and an emcp_tools_page_snapshot_seo_lite
filter so table-based plugins (AIOSEO) can supply accurate values.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-09 23:35:01 +05:00

750 lines
24 KiB
PHP

<?php
/**
* Page Snapshot builder — one normalized page digest for MCP agents.
*
* Assembles a compact, normalized view of a page (structure tree + counts,
* global tokens actually in use, per-device responsive overrides, content
* outline, SEO-lite) so an AI agent can reason about a page from a single
* call instead of chaining get-page-structure / get-global-settings /
* list-global-classes and reassembling. Heavy audit summaries are opt-in and
* resolved through the `emcp_tools_page_snapshot_sections` filter seam.
*
* @package EMCP_Tools
* @since 3.3.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Builds normalized page snapshots.
*
* @since 3.3.0
*/
class EMCP_Tools_Page_Snapshot {
/**
* The data access layer.
*
* @var EMCP_Tools_Data
*/
private $data;
/**
* Constructor.
*
* @param EMCP_Tools_Data $data The data access layer.
*/
public function __construct( EMCP_Tools_Data $data ) {
$this->data = $data;
}
/**
* Core sections, always available and computed in-process.
*/
const CORE_SECTIONS = array( 'post', 'structure', 'tokens', 'responsive', 'content', 'seo_lite', 'warnings' );
/**
* Heavy, opt-in sections (loopback fetch / WCAG math). performance is free;
* a11y + seo are Pro (resolved via the seam).
*/
const HEAVY_SECTIONS = array( 'performance', 'a11y', 'seo' );
/**
* Build a page snapshot.
*
* @param int $post_id Target post.
* @param array $args { builder?, post?, sections?, include?, fresh?, url? }.
* @return array
*/
public function build( int $post_id, array $args = array() ): array {
$sections = ( ! empty( $args['sections'] ) && is_array( $args['sections'] ) )
? array_values( array_intersect( self::CORE_SECTIONS, $args['sections'] ) )
: self::CORE_SECTIONS;
$include = ( ! empty( $args['include'] ) && is_array( $args['include'] ) )
? array_values( array_intersect( self::HEAVY_SECTIONS, $args['include'] ) )
: array();
$builder = isset( $args['builder'] ) ? (string) $args['builder'] : 'elementor';
$elements = ( 'elementor' === $builder ) ? (array) $this->data->get_page_data( $post_id ) : array();
$norm = self::normalize_tree( $elements );
$content = self::content_stats( $elements );
$out = array();
if ( in_array( 'post', $sections, true ) ) {
$out['post'] = ( isset( $args['post'] ) && is_array( $args['post'] ) )
? $args['post']
: array(
'id' => $post_id,
'builder' => $builder,
);
}
if ( in_array( 'structure', $sections, true ) ) {
$out['structure'] = $norm;
}
if ( in_array( 'tokens', $sections, true ) ) {
$out['tokens'] = self::extract_tokens( $elements );
}
if ( in_array( 'responsive', $sections, true ) ) {
$out['responsive'] = self::detect_responsive( $elements );
}
if ( in_array( 'content', $sections, true ) ) {
$out['content'] = $content;
}
if ( in_array( 'seo_lite', $sections, true ) ) {
$out['seo_lite'] = self::seo_lite( $post_id, $content );
}
if ( in_array( 'warnings', $sections, true ) ) {
$out['warnings'] = self::warnings( $elements, $norm['counts'], $content );
}
// Heavy, opt-in sections resolved via the seam (see heavy_sections()).
if ( $include ) {
foreach ( $this->heavy_sections( $post_id, $include, $args ) as $key => $section ) {
$out[ $key ] = $section;
}
}
return $out;
}
/**
* Free SEO-lite read: h1 count from content + meta title/description/canonical/og
* read from the common postmeta-based SEO plugins (Yoast / Rank Math / SEOPress).
* Plugins that store SEO data outside postmeta (e.g. All in One SEO's own table)
* supply their values through the `emcp_tools_page_snapshot_seo_lite` filter.
*
* @param int $post_id Post ID.
* @param array $content From content_stats().
* @return array{h1_count:int,meta_title:string,meta_description:string,canonical:string,og_image:string}
*/
public static function seo_lite( int $post_id, array $content ): array {
$h1 = 0;
foreach ( ( $content['headings'] ?? array() ) as $h ) {
if ( isset( $h['tag'] ) && 'h1' === strtolower( (string) $h['tag'] ) ) {
++$h1;
}
}
$read = static function ( array $keys ) use ( $post_id ): string {
if ( ! function_exists( 'get_post_meta' ) || $post_id <= 0 ) {
return '';
}
foreach ( $keys as $key ) {
$v = get_post_meta( $post_id, $key, true );
if ( is_string( $v ) && '' !== $v ) {
return $v;
}
}
return '';
};
$result = array(
'h1_count' => $h1,
'meta_title' => $read( array( '_yoast_wpseo_title', 'rank_math_title', '_seopress_titles_title' ) ),
'meta_description' => $read( array( '_yoast_wpseo_metadesc', 'rank_math_description', '_seopress_titles_desc' ) ),
'canonical' => $read( array( '_yoast_wpseo_canonical', 'rank_math_canonical_url', '_seopress_robots_canonical' ) ),
'og_image' => $read( array( '_yoast_wpseo_opengraph-image', 'rank_math_facebook_image', '_seopress_social_fb_img' ) ),
);
/**
* Filter the free SEO-lite snapshot. Lets an active SEO integration (or a
* Pro overlay) supply accurate values for plugins that don't store SEO
* meta in postmeta — e.g. All in One SEO, which keeps it in its own table.
*
* @param array $result { h1_count, meta_title, meta_description, canonical, og_image }.
* @param int $post_id Post ID.
*/
return (array) apply_filters( 'emcp_tools_page_snapshot_seo_lite', $result, $post_id );
}
/**
* Resolve opt-in heavy sections. Free-core supplies `performance`; the Pro overlay
* hooks `emcp_tools_page_snapshot_sections` for `a11y` + deep `seo`. Anything still
* unresolved degrades to a pro-gated/unavailable stub. Heavy sections are
* transient-cached (15 min) unless $args['fresh'].
*
* @param int $post_id Post ID.
* @param array $include Requested heavy sections.
* @param array $args Build args.
* @return array<string,array>
*/
protected function heavy_sections( int $post_id, array $include, array $args ): array {
$fresh = ! empty( $args['fresh'] );
$sections = array();
// Free: performance (page-level; needs manage_options).
if ( in_array( 'performance', $include, true ) ) {
$sections['performance'] = $this->cached_section(
$post_id,
'performance',
$fresh,
function () use ( $post_id, $args ) {
if ( ! ( function_exists( 'current_user_can' ) && current_user_can( 'manage_options' ) ) ) {
return array(
'available' => false,
'reason' => 'permission',
);
}
if ( ! class_exists( 'EMCP_Tools_Performance_Analyzer' ) ) {
return array(
'available' => false,
'reason' => 'unavailable',
);
}
$analyzer = new EMCP_Tools_Performance_Analyzer();
$input = array( 'post_id' => $post_id );
if ( ! empty( $args['url'] ) ) {
$input['url'] = (string) $args['url'];
}
$report = $analyzer->analyze( $input );
if ( function_exists( 'is_wp_error' ) && is_wp_error( $report ) ) {
return array(
'available' => false,
'reason' => 'error',
);
}
$report = (array) $report;
return array(
'available' => true,
'score' => $report['score'] ?? null,
'grade' => $report['grade'] ?? null,
'recommendations' => array_slice( (array) ( $report['top_recommendations'] ?? array() ), 0, 5 ),
);
}
);
}
// Pro sections via the seam.
$seam = apply_filters( 'emcp_tools_page_snapshot_sections', array(), $post_id, $include, $args );
foreach ( array( 'a11y', 'seo' ) as $key ) {
if ( ! in_array( $key, $include, true ) ) {
continue;
}
if ( isset( $seam[ $key ] ) && is_array( $seam[ $key ] ) ) {
$sections[ $key ] = $seam[ $key ];
} else {
$sections[ $key ] = array(
'available' => false,
'pro_gated' => true,
);
}
}
return $sections;
}
/**
* Transient wrapper for a heavy section.
*
* @param int $post_id Post ID.
* @param string $section Section key.
* @param bool $fresh Bypass the cache.
* @param callable $compute Producer returning the section array.
* @return array
*/
private function cached_section( int $post_id, string $section, bool $fresh, callable $compute ): array {
$key = 'emcp_snap_' . $post_id . '_' . $section;
if ( ! $fresh && function_exists( 'get_transient' ) ) {
$hit = get_transient( $key );
if ( is_array( $hit ) ) {
$hit['cached'] = true;
return $hit;
}
}
$val = $compute();
if ( function_exists( 'set_transient' ) && ! empty( $val['available'] ) ) {
set_transient( $key, $val, 15 * ( defined( 'MINUTE_IN_SECONDS' ) ? MINUTE_IN_SECONDS : 60 ) );
}
$val['cached'] = false;
return $val;
}
/**
* Recursively normalize an Elementor elements array into a compact tree + counts.
*
* @param array $elements Elementor elements array.
* @param int $depth Current depth (0 at top).
* @return array{tree:array,counts:array}
*/
public static function normalize_tree( array $elements, int $depth = 0 ): array {
$tree = array();
$counts = array(
'containers' => 0,
'widgets' => 0,
'by_widget_type' => array(),
'max_depth' => $depth,
'total_elements' => 0,
);
foreach ( $elements as $el ) {
if ( ! is_array( $el ) ) {
continue;
}
$el_type = isset( $el['elType'] ) ? (string) $el['elType'] : '';
$is_widget = ( 'widget' === $el_type );
$node = array(
'id' => isset( $el['id'] ) ? (string) $el['id'] : '',
'kind' => $is_widget ? 'widget' : 'container',
'depth' => $depth,
);
if ( $is_widget ) {
$wt = isset( $el['widgetType'] ) ? (string) $el['widgetType'] : '';
$node['widget_type'] = $wt;
++$counts['widgets'];
if ( '' !== $wt ) {
$counts['by_widget_type'][ $wt ] = ( $counts['by_widget_type'][ $wt ] ?? 0 ) + 1;
}
} else {
++$counts['containers'];
}
++$counts['total_elements'];
$label = self::element_label( $el );
if ( '' !== $label ) {
$node['label'] = $label;
}
$children = ( isset( $el['elements'] ) && is_array( $el['elements'] ) ) ? $el['elements'] : array();
if ( $children ) {
$sub = self::normalize_tree( $children, $depth + 1 );
$node['children'] = $sub['tree'];
$counts['containers'] += $sub['counts']['containers'];
$counts['widgets'] += $sub['counts']['widgets'];
$counts['total_elements'] += $sub['counts']['total_elements'];
$counts['max_depth'] = max( $counts['max_depth'], $sub['counts']['max_depth'] );
foreach ( $sub['counts']['by_widget_type'] as $k => $v ) {
$counts['by_widget_type'][ $k ] = ( $counts['by_widget_type'][ $k ] ?? 0 ) + $v;
}
} else {
$node['children'] = array();
}
$tree[] = $node;
}
return array(
'tree' => $tree,
'counts' => $counts,
);
}
/**
* Derive a short human label for an element from its settings.
*
* @param array $el Element array.
* @return string Short label, or '' when none derivable.
*/
public static function element_label( array $el ): string {
$s = ( isset( $el['settings'] ) && is_array( $el['settings'] ) ) ? $el['settings'] : array();
foreach ( array( '_title', 'title', 'text', 'editor', 'heading_title' ) as $k ) {
if ( ! empty( $s[ $k ] ) && is_string( $s[ $k ] ) ) {
$plain = trim( (string) preg_replace( '/<[^>]*>/', '', $s[ $k ] ) );
if ( '' !== $plain ) {
return self::snippet( $plain, 60 );
}
}
}
return '';
}
/**
* Walk elements collecting which global colors/typography, g- classes, and raw
* fonts are actually referenced, with usage counts.
*
* @param array $elements Elementor elements array.
* @return array{global_colors:array<string,int>,global_typography:array<string,int>,global_classes:array<string,int>,fonts_in_use:string[],colors_in_use:string[]}
*/
public static function extract_tokens( array $elements ): array {
$acc = array(
'global_colors' => array(),
'global_typography' => array(),
'global_classes' => array(),
'fonts_in_use' => array(),
'colors_in_use' => array(),
);
self::walk_tokens( $elements, $acc );
$acc['fonts_in_use'] = array_values( array_unique( $acc['fonts_in_use'] ) );
$acc['colors_in_use'] = array_values( array_unique( $acc['colors_in_use'] ) );
return $acc;
}
/**
* Recursive token collector.
*
* @param array $elements Elements.
* @param array $acc Accumulator (by reference).
*/
private static function walk_tokens( array $elements, array &$acc ): void {
foreach ( $elements as $el ) {
if ( ! is_array( $el ) ) {
continue;
}
$s = ( isset( $el['settings'] ) && is_array( $el['settings'] ) ) ? $el['settings'] : array();
// Global color/typography refs live in __globals__: "globals/colors?id=primary".
if ( ! empty( $s['__globals__'] ) && is_array( $s['__globals__'] ) ) {
foreach ( $s['__globals__'] as $ref ) {
if ( ! is_string( $ref ) ) {
continue;
}
if ( preg_match( '#globals/colors\?id=([\w-]+)#', $ref, $m ) ) {
$acc['global_colors'][ $m[1] ] = ( $acc['global_colors'][ $m[1] ] ?? 0 ) + 1;
} elseif ( preg_match( '#globals/typography\?id=([\w-]+)#', $ref, $m ) ) {
$acc['global_typography'][ $m[1] ] = ( $acc['global_typography'][ $m[1] ] ?? 0 ) + 1;
}
}
}
// g- global classes appear in _css_classes / classes (string), or atomic classes.value (array).
foreach ( array( '_css_classes', 'classes' ) as $ck ) {
if ( ! empty( $s[ $ck ] ) && is_string( $s[ $ck ] ) ) {
foreach ( preg_split( '/\s+/', $s[ $ck ] ) as $cls ) {
if ( '' !== $cls && 0 === strpos( $cls, 'g-' ) ) {
$acc['global_classes'][ $cls ] = ( $acc['global_classes'][ $cls ] ?? 0 ) + 1;
}
}
}
}
if ( isset( $s['classes']['value'] ) && is_array( $s['classes']['value'] ) ) {
foreach ( $s['classes']['value'] as $cls ) {
if ( is_string( $cls ) && 0 === strpos( $cls, 'g-' ) ) {
$acc['global_classes'][ $cls ] = ( $acc['global_classes'][ $cls ] ?? 0 ) + 1;
}
}
}
// Raw fonts / hex colors in use.
foreach ( $s as $key => $val ) {
if ( is_string( $val ) && '' !== $val ) {
if ( false !== strpos( (string) $key, 'font_family' ) ) {
$acc['fonts_in_use'][] = $val;
} elseif ( ( false !== strpos( (string) $key, 'color' ) ) && preg_match( '/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $val ) ) {
$acc['colors_in_use'][] = strtolower( $val );
}
}
}
if ( ! empty( $el['elements'] ) && is_array( $el['elements'] ) ) {
self::walk_tokens( $el['elements'], $acc );
}
}
}
/**
* Detect which elements carry per-device (_tablet / _mobile / _laptop / _widescreen)
* setting overrides.
*
* @param array $elements Elementor elements array.
* @return array{overrides:array<int,array{element_id:string,widget_type:string,settings:string[]}>,counts:array{desktop_only:int,has_tablet:int,has_mobile:int}}
*/
public static function detect_responsive( array $elements ): array {
$overrides = array();
$counts = array(
'desktop_only' => 0,
'has_tablet' => 0,
'has_mobile' => 0,
);
self::walk_responsive( $elements, $overrides, $counts );
return array(
'overrides' => $overrides,
'counts' => $counts,
);
}
/**
* Recursive responsive-override collector.
*
* @param array $elements Elements.
* @param array $overrides Accumulator (by reference).
* @param array $counts Accumulator (by reference).
*/
private static function walk_responsive( array $elements, array &$overrides, array &$counts ): void {
foreach ( $elements as $el ) {
if ( ! is_array( $el ) ) {
continue;
}
$s = ( isset( $el['settings'] ) && is_array( $el['settings'] ) ) ? $el['settings'] : array();
$keys = array();
$has_tablet = false;
$has_mobile = false;
foreach ( array_keys( $s ) as $k ) {
if ( preg_match( '/_(tablet|mobile|laptop|widescreen|mobile_extra|tablet_extra)$/', (string) $k, $m ) ) {
$keys[] = $k;
if ( false !== strpos( $m[1], 'tablet' ) ) {
$has_tablet = true;
}
if ( false !== strpos( $m[1], 'mobile' ) ) {
$has_mobile = true;
}
}
}
if ( $keys ) {
$overrides[] = array(
'element_id' => isset( $el['id'] ) ? (string) $el['id'] : '',
'widget_type' => isset( $el['widgetType'] ) ? (string) $el['widgetType'] : '',
'settings' => $keys,
);
if ( $has_tablet ) {
++$counts['has_tablet'];
}
if ( $has_mobile ) {
++$counts['has_mobile'];
}
} else {
++$counts['desktop_only'];
}
if ( ! empty( $el['elements'] ) && is_array( $el['elements'] ) ) {
self::walk_responsive( $el['elements'], $overrides, $counts );
}
}
}
/**
* Content-level stats: heading outline, word/image/link/button counts, missing alts.
*
* @param array $elements Elementor elements array.
* @return array{headings:array,word_count:int,image_count:int,images_missing_alt:int,link_count:int,button_count:int}
*/
public static function content_stats( array $elements ): array {
$acc = array(
'headings' => array(),
'word_count' => 0,
'image_count' => 0,
'images_missing_alt' => 0,
'link_count' => 0,
'button_count' => 0,
);
self::walk_content( $elements, $acc );
return $acc;
}
/**
* Recursive content collector.
*
* @param array $elements Elements.
* @param array $acc Accumulator (by reference).
*/
private static function walk_content( array $elements, array &$acc ): void {
foreach ( $elements as $el ) {
if ( ! is_array( $el ) ) {
continue;
}
$wt = isset( $el['widgetType'] ) ? (string) $el['widgetType'] : '';
$s = ( isset( $el['settings'] ) && is_array( $el['settings'] ) ) ? $el['settings'] : array();
if ( 'heading' === $wt && ! empty( $s['title'] ) && is_string( $s['title'] ) ) {
$tag = ( ! empty( $s['header_size'] ) && is_string( $s['header_size'] ) ) ? $s['header_size'] : 'h2';
$acc['headings'][] = array(
'tag' => $tag,
'text' => self::snippet( trim( (string) preg_replace( '/<[^>]*>/', '', $s['title'] ) ), 120 ),
'element_id' => isset( $el['id'] ) ? (string) $el['id'] : '',
);
}
// Word count from common text fields.
foreach ( array( 'title', 'editor', 'text', 'description_text', 'testimonial_content' ) as $tk ) {
if ( ! empty( $s[ $tk ] ) && is_string( $s[ $tk ] ) ) {
$acc['word_count'] += self::count_words( $s[ $tk ] );
}
}
if ( 'image' === $wt || 'theme-site-logo' === $wt ) {
if ( isset( $s['image'] ) && is_array( $s['image'] ) ) {
++$acc['image_count'];
if ( empty( $s['image']['alt'] ) ) {
++$acc['images_missing_alt'];
}
}
}
if ( 'button' === $wt ) {
++$acc['button_count'];
}
if ( isset( $s['link']['url'] ) && '' !== $s['link']['url'] ) {
++$acc['link_count'];
}
// Atomic (Elementor 4.0) widgets store their content as $$type-wrapped
// props under different keys than classic widgets, so the classic
// branches above miss them entirely (issue #91). Handle them here.
switch ( $wt ) {
case 'e-heading':
$atext = self::atomic_scalar( $s['title'] ?? null );
if ( '' !== $atext ) {
$atag = self::atomic_scalar( $s['tag'] ?? null );
$acc['headings'][] = array(
'tag' => ( '' !== $atag ) ? $atag : 'h2',
'text' => self::snippet( trim( (string) preg_replace( '/<[^>]*>/', '', $atext ) ), 120 ),
'element_id' => isset( $el['id'] ) ? (string) $el['id'] : '',
);
$acc['word_count'] += self::count_words( $atext );
}
break;
case 'e-paragraph':
$acc['word_count'] += self::count_words( self::atomic_scalar( $s['paragraph'] ?? null ) );
break;
case 'e-button':
++$acc['button_count'];
$acc['word_count'] += self::count_words( self::atomic_scalar( $s['text'] ?? null ) );
break;
case 'e-image':
if ( null !== self::atomic_image( $s['image'] ?? null ) ) {
++$acc['image_count'];
if ( '' === self::atomic_scalar( $s['alt'] ?? null ) ) {
++$acc['images_missing_alt'];
}
}
break;
}
// Atomic link props are $$type-wrapped, so the classic check above
// (which expects settings.link.url) never fires for them.
if ( isset( $s['link']['$$type'] ) && '' !== self::atomic_scalar( $s['link'] ) ) {
++$acc['link_count'];
}
if ( ! empty( $el['elements'] ) && is_array( $el['elements'] ) ) {
self::walk_content( $el['elements'], $acc );
}
}
}
/**
* Counts words in a text/HTML fragment (tags stripped, whitespace collapsed).
*
* @param string $text Text or HTML.
* @return int Word count.
*/
private static function count_words( string $text ): int {
$plain = trim( (string) preg_replace( '/\s+/', ' ', (string) preg_replace( '/<[^>]*>/', ' ', $text ) ) );
if ( '' === $plain ) {
return 0;
}
return count( preg_split( '/\s+/', $plain ) );
}
/**
* Unwraps an atomic ($$type-wrapped) prop to a scalar string. Returns a plain
* string as-is; returns '' for anything that doesn't resolve to a string.
*
* @param mixed $prop Atomic prop or plain value.
* @return string
*/
private static function atomic_scalar( $prop ): string {
if ( is_string( $prop ) ) {
return $prop;
}
if ( is_array( $prop ) && isset( $prop['$$type'] ) && class_exists( 'EMCP_Tools_Atomic_Props' ) ) {
$value = EMCP_Tools_Atomic_Props::unwrap( $prop );
return is_string( $value ) ? $value : '';
}
return '';
}
/**
* Unwraps an atomic image prop to { id, url }, or null when it carries no
* image reference.
*
* @param mixed $prop Atomic image prop.
* @return array{id:mixed,url:mixed}|null
*/
private static function atomic_image( $prop ): ?array {
if ( ! is_array( $prop ) || ! isset( $prop['$$type'] ) || ! class_exists( 'EMCP_Tools_Atomic_Props' ) ) {
return null;
}
$image = EMCP_Tools_Atomic_Props::unwrap( $prop );
if ( is_array( $image ) && ( ! empty( $image['id'] ) || ! empty( $image['url'] ) ) ) {
return $image;
}
return null;
}
/**
* Structural smell warnings.
*
* @param array $elements Raw elements (for empty-container detection).
* @param array $counts From normalize_tree()['counts'].
* @param array $content From content_stats().
* @return array<int,array{code:string,message:string}>
*/
public static function warnings( array $elements, array $counts, array $content ): array {
$w = array();
$h1 = 0;
foreach ( ( $content['headings'] ?? array() ) as $h ) {
if ( isset( $h['tag'] ) && 'h1' === strtolower( (string) $h['tag'] ) ) {
++$h1;
}
}
if ( 0 === $h1 ) {
$w[] = array(
'code' => 'no_h1',
'message' => 'Page has no H1 heading.',
);
}
if ( $h1 > 1 ) {
$w[] = array(
'code' => 'multiple_h1',
'message' => 'Page has more than one H1 heading.',
);
}
if ( isset( $counts['max_depth'] ) && $counts['max_depth'] >= 6 ) {
$w[] = array(
'code' => 'deep_nesting',
'message' => 'Container nesting is 6+ levels deep.',
);
}
if ( self::has_empty_container( $elements ) ) {
$w[] = array(
'code' => 'empty_container',
'message' => 'One or more containers have no children.',
);
}
return $w;
}
/**
* Whether any non-widget element has no children.
*
* @param array $elements Elements.
* @return bool
*/
private static function has_empty_container( array $elements ): bool {
foreach ( $elements as $el ) {
if ( ! is_array( $el ) ) {
continue;
}
$is_widget = ( isset( $el['elType'] ) && 'widget' === $el['elType'] );
$children = ( isset( $el['elements'] ) && is_array( $el['elements'] ) ) ? $el['elements'] : array();
if ( ! $is_widget && ! $children ) {
return true;
}
if ( $children && self::has_empty_container( $children ) ) {
return true;
}
}
return false;
}
/**
* Truncate to a max length with an ellipsis.
*
* @param string $text Text.
* @param int $max Max length.
* @return string
*/
public static function snippet( string $text, int $max ): string {
$text = trim( (string) preg_replace( '/\s+/', ' ', $text ) );
$len = function_exists( 'mb_strlen' ) ? mb_strlen( $text ) : strlen( $text );
if ( $len <= $max ) {
return $text;
}
$cut = function_exists( 'mb_substr' ) ? mb_substr( $text, 0, $max - 1 ) : substr( $text, 0, $max - 1 );
return $cut . '…';
}
}