settings = $settings; } /** * Register frontend hooks. * * @return void */ public function init() { add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_frontend_helper' ), 99 ); } /** * Enqueue optional inline CSS helper for common ad slots. * * @return void */ public function enqueue_frontend_helper() { $options = $this->settings->get_options(); if ( empty( $options['layout_helper_css_enabled'] ) || empty( $options['layout_reserve_ad_slots'] ) ) { return; } $css = $this->get_helper_css( $options ); if ( '' === trim( $css ) ) { return; } wp_register_style( 'agizoai-agentic-layout-helper', false, array(), AGIZOAI_AGENTIC_VERSION ); wp_enqueue_style( 'agizoai-agentic-layout-helper' ); wp_add_inline_style( 'agizoai-agentic-layout-helper', $css ); } /** * Return generated CSS helper preview. * * @param array|null $options Optional settings. * @return string */ public function get_helper_css( $options = null ) { if ( null === $options || ! is_array( $options ) ) { $options = $this->settings->get_options(); } $top = max( 0, absint( isset( $options['layout_top_ad_min_height'] ) ? $options['layout_top_ad_min_height'] : 90 ) ); $inline = max( 0, absint( isset( $options['layout_inline_ad_min_height'] ) ? $options['layout_inline_ad_min_height'] : 250 ) ); $sidebar = max( 0, absint( isset( $options['layout_sidebar_ad_min_height'] ) ? $options['layout_sidebar_ad_min_height'] : 280 ) ); $mobile = max( 0, absint( isset( $options['layout_mobile_ad_min_height'] ) ? $options['layout_mobile_ad_min_height'] : 250 ) ); $header = max( 0, absint( isset( $options['layout_sticky_header_offset'] ) ? $options['layout_sticky_header_offset'] : 0 ) ); $css = array(); $css[] = '/* AgizoAI Agentic Browsing Optimizer: layout stability helper. */'; $css[] = ':root{--agizoai-agentic-top-ad-min-height:' . $top . 'px;--agizoai-agentic-inline-ad-min-height:' . $inline . 'px;--agizoai-agentic-sidebar-ad-min-height:' . $sidebar . 'px;--agizoai-agentic-mobile-ad-min-height:' . $mobile . 'px;--agizoai-agentic-sticky-header-offset:' . $header . 'px;}'; $css[] = '.agizoai-agentic-ad-slot,.agizoai-agentic-ad-top,.agizoai-agentic-ad-inline,.agizoai-agentic-ad-sidebar,.ad-slot,.ads-slot,.adslot,.advertisement-slot,.advert-slot,ins.adsbygoogle{display:block;box-sizing:border-box;}'; $css[] = '.agizoai-agentic-ad-top,.ad-slot-top,.ads-slot-top,.adslot-top,.advertisement-top{min-height:var(--agizoai-agentic-top-ad-min-height);}'; $css[] = '.agizoai-agentic-ad-inline,.ad-slot-inline,.ads-slot-inline,.adslot-inline,.advertisement-inline,.entry-content ins.adsbygoogle{min-height:var(--agizoai-agentic-inline-ad-min-height);}'; $css[] = '.agizoai-agentic-ad-sidebar,.ad-slot-sidebar,.ads-slot-sidebar,.adslot-sidebar,.widget ins.adsbygoogle{min-height:var(--agizoai-agentic-sidebar-ad-min-height);}'; $css[] = 'html{scroll-padding-top:var(--agizoai-agentic-sticky-header-offset);}'; $css[] = '@media (max-width: 767px){.agizoai-agentic-ad-slot,.agizoai-agentic-ad-top,.agizoai-agentic-ad-inline,.agizoai-agentic-ad-sidebar,.ad-slot,.ads-slot,.adslot,.advertisement-slot,.advert-slot,ins.adsbygoogle{min-height:var(--agizoai-agentic-mobile-ad-min-height);}}'; return implode( "\n", $css ) . "\n"; } /** * Run layout stability scan over public content. * * @param int $limit Number of public items to scan. * @return array */ public function scan( $limit = 25 ) { $options = $this->settings->get_options(); $limit = max( 1, min( 50, absint( $limit ) ) ); $post_types = isset( $options['include_post_types'] ) && is_array( $options['include_post_types'] ) ? $options['include_post_types'] : array( 'page', 'post' ); $post_types = array_values( array_filter( array_map( 'sanitize_key', $post_types ) ) ); if ( empty( $post_types ) ) { $post_types = array( 'page', 'post' ); } $metrics = $this->empty_metrics(); $issues = array(); $pages = array(); $query = new WP_Query( array( 'post_type' => $post_types, 'post_status' => 'publish', 'posts_per_page' => $limit, 'orderby' => 'date', 'order' => 'DESC', 'ignore_sticky_posts' => true, 'no_found_rows' => true, ) ); if ( $query->have_posts() ) { foreach ( $query->posts as $post ) { $html = apply_filters( 'the_content', $post->post_content ); $page = array( 'id' => absint( $post->ID ), 'title' => get_the_title( $post ), 'url' => get_permalink( $post ), 'type' => get_post_type( $post ), ); $page_result = $this->analyse_html( $html, $page ); $pages[] = array_merge( $page, array( 'issues' => count( $page_result['issues'] ), ) ); foreach ( $page_result['metrics'] as $key => $value ) { if ( isset( $metrics[ $key ] ) ) { $metrics[ $key ] += absint( $value ); } } $issues = array_merge( $issues, $page_result['issues'] ); } wp_reset_postdata(); } $metrics['pages_scanned'] = count( $pages ); $critical_count = count( array_filter( $issues, function ( $issue ) { return isset( $issue['severity'] ) && 'critical' === $issue['severity']; } ) ); $warning_count = count( array_filter( $issues, function ( $issue ) { return isset( $issue['severity'] ) && 'warning' === $issue['severity']; } ) ); return array( 'enabled' => ! empty( $options['layout_scanner_enabled'] ), 'helper_enabled' => ! empty( $options['layout_helper_css_enabled'] ) && ! empty( $options['layout_reserve_ad_slots'] ), 'pages' => $pages, 'pages_scanned' => absint( $metrics['pages_scanned'] ), 'metrics' => $metrics, 'issues' => $issues, 'issue_count' => count( $issues ), 'critical_count' => $critical_count, 'warning_count' => $warning_count, 'score' => $this->score_from_issues( $issues ), ); } /** * Empty metrics array. * * @return array */ private function empty_metrics() { return array( 'pages_scanned' => 0, 'images_total' => 0, 'images_missing_dimensions' => 0, 'iframes_total' => 0, 'iframes_missing_dimensions' => 0, 'ad_slots_total' => 0, 'ad_slots_without_reserved_space'=> 0, 'popups_detected' => 0, 'sticky_or_fixed_elements' => 0, ); } /** * Analyse HTML with DOM or regex fallback. * * @param string $html Rendered HTML. * @param array $page Page context. * @return array */ private function analyse_html( $html, array $page ) { if ( class_exists( 'DOMDocument' ) ) { return $this->analyse_with_dom( $html, $page ); } return $this->analyse_with_regex( $html, $page ); } /** * DOM-based analysis. * * @param string $html Rendered HTML. * @param array $page Page context. * @return array */ private function analyse_with_dom( $html, array $page ) { $metrics = $this->empty_metrics(); $issues = array(); $dom = new DOMDocument(); $previous = libxml_use_internal_errors( true ); $encoded = '' . (string) $html; $dom->loadHTML( $encoded, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); $images = $dom->getElementsByTagName( 'img' ); $metrics['images_total'] = $images->length; foreach ( $images as $img ) { if ( ! $this->node_has_box_size( $img ) ) { $metrics['images_missing_dimensions']++; $issues[] = $this->issue( 'image_dimensions_missing', __( 'Image may cause layout shift', 'agizoai-agentic-browsing-optimizer' ), __( 'An image in public content has no explicit width and height or reserved size.', 'agizoai-agentic-browsing-optimizer' ), __( 'Agents may identify an element position before the image loads, then fail when the page shifts.', 'agizoai-agentic-browsing-optimizer' ), __( 'Add width and height attributes, use aspect-ratio CSS, or reserve the image container space.', 'agizoai-agentic-browsing-optimizer' ), 'warning', $page ); } } $iframes = $dom->getElementsByTagName( 'iframe' ); $metrics['iframes_total'] = $iframes->length; foreach ( $iframes as $iframe ) { if ( ! $this->node_has_box_size( $iframe ) ) { $metrics['iframes_missing_dimensions']++; $issues[] = $this->issue( 'iframe_dimensions_missing', __( 'Iframe or embed may shift layout', 'agizoai-agentic-browsing-optimizer' ), __( 'An iframe/embed has no explicit width and height or reserved size.', 'agizoai-agentic-browsing-optimizer' ), __( 'Embeds can load late and move nearby buttons, forms or links after an agent has located them.', 'agizoai-agentic-browsing-optimizer' ), __( 'Set width and height attributes or wrap the embed in a fixed aspect-ratio container.', 'agizoai-agentic-browsing-optimizer' ), 'warning', $page ); } } $all_nodes = $dom->getElementsByTagName( '*' ); foreach ( $all_nodes as $node ) { if ( $this->looks_like_ad_slot( $node ) ) { $metrics['ad_slots_total']++; if ( ! $this->node_has_reserved_space( $node ) ) { $metrics['ad_slots_without_reserved_space']++; $issues[] = $this->issue( 'ad_slot_no_reserved_space', __( 'Ad slot has no reserved space', 'agizoai-agentic-browsing-optimizer' ), __( 'A likely ad container does not declare a fixed, minimum or aspect-ratio size.', 'agizoai-agentic-browsing-optimizer' ), __( 'Ad scripts that inject late can push content down and make agent clicks unreliable.', 'agizoai-agentic-browsing-optimizer' ), __( 'Reserve ad-slot space with min-height, width/height or the v0.5.0 CSS helper.', 'agizoai-agentic-browsing-optimizer' ), 'critical', $page ); } } if ( $this->looks_like_popup_or_modal( $node ) ) { $metrics['popups_detected']++; } if ( $this->looks_like_sticky_or_fixed( $node ) ) { $metrics['sticky_or_fixed_elements']++; } } if ( $metrics['popups_detected'] > 0 ) { $issues[] = $this->issue( 'popup_detected', __( 'Popup or modal pattern detected', 'agizoai-agentic-browsing-optimizer' ), __( 'Public content appears to include popup, modal or overlay markup.', 'agizoai-agentic-browsing-optimizer' ), __( 'Overlays can cover forms or buttons and confuse screenshot-based agents.', 'agizoai-agentic-browsing-optimizer' ), __( 'Ensure popups do not shift content, have clear close buttons and do not block key tasks.', 'agizoai-agentic-browsing-optimizer' ), 'warning', $page ); } if ( $metrics['sticky_or_fixed_elements'] > 0 ) { $issues[] = $this->issue( 'sticky_fixed_detected', __( 'Sticky or fixed element pattern detected', 'agizoai-agentic-browsing-optimizer' ), __( 'Public content appears to include sticky/fixed element markup or inline positioning.', 'agizoai-agentic-browsing-optimizer' ), __( 'Sticky headers or bars can hide anchors, forms or action buttons after scrolling.', 'agizoai-agentic-browsing-optimizer' ), __( 'Use scroll-padding-top or the sticky header offset helper where needed.', 'agizoai-agentic-browsing-optimizer' ), 'warning', $page ); } return array( 'metrics' => $metrics, 'issues' => $issues, ); } /** * Regex fallback analysis. * * @param string $html Rendered HTML. * @param array $page Page context. * @return array */ private function analyse_with_regex( $html, array $page ) { $metrics = $this->empty_metrics(); $issues = array(); $html = (string) $html; if ( preg_match_all( '/]*>/i', $html, $matches ) ) { $metrics['images_total'] = count( $matches[0] ); foreach ( $matches[0] as $tag ) { if ( ! preg_match( '/\bwidth\s*=\s*["\']?\d+/i', $tag ) || ! preg_match( '/\bheight\s*=\s*["\']?\d+/i', $tag ) ) { $metrics['images_missing_dimensions']++; } } } if ( preg_match_all( '/]*>/i', $html, $matches ) ) { $metrics['iframes_total'] = count( $matches[0] ); foreach ( $matches[0] as $tag ) { if ( ! preg_match( '/\bwidth\s*=\s*["\']?\d+/i', $tag ) || ! preg_match( '/\bheight\s*=\s*["\']?\d+/i', $tag ) ) { $metrics['iframes_missing_dimensions']++; } } } if ( preg_match_all( '/<(div|aside|section|ins)\b[^>]*(adslot|ad-slot|adsbygoogle|advert|advertisement)[^>]*>/i', $html, $matches ) ) { $metrics['ad_slots_total'] = count( $matches[0] ); foreach ( $matches[0] as $tag ) { if ( ! preg_match( '/(min-height|height|aspect-ratio|width)\s*:/i', $tag ) && ! preg_match( '/\bheight\s*=\s*["\']?\d+/i', $tag ) ) { $metrics['ad_slots_without_reserved_space']++; } } } if ( $metrics['images_missing_dimensions'] > 0 ) { $issues[] = $this->issue( 'image_dimensions_missing', __( 'Images may cause layout shift', 'agizoai-agentic-browsing-optimizer' ), __( 'One or more images lack explicit dimensions.', 'agizoai-agentic-browsing-optimizer' ), __( 'Late-loading images can move interaction targets after agents locate them.', 'agizoai-agentic-browsing-optimizer' ), __( 'Add width/height attributes or aspect-ratio CSS.', 'agizoai-agentic-browsing-optimizer' ), 'warning', $page ); } if ( $metrics['iframes_missing_dimensions'] > 0 ) { $issues[] = $this->issue( 'iframe_dimensions_missing', __( 'Embeds may cause layout shift', 'agizoai-agentic-browsing-optimizer' ), __( 'One or more iframes lack explicit dimensions.', 'agizoai-agentic-browsing-optimizer' ), __( 'Late-loading embeds can move nearby targets.', 'agizoai-agentic-browsing-optimizer' ), __( 'Reserve iframe/embed space.', 'agizoai-agentic-browsing-optimizer' ), 'warning', $page ); } if ( $metrics['ad_slots_without_reserved_space'] > 0 ) { $issues[] = $this->issue( 'ad_slot_no_reserved_space', __( 'Ad slots need reserved space', 'agizoai-agentic-browsing-optimizer' ), __( 'One or more likely ad slots lack reserved space.', 'agizoai-agentic-browsing-optimizer' ), __( 'Late ad injection can push content and break agent interaction.', 'agizoai-agentic-browsing-optimizer' ), __( 'Use fixed/min-height ad containers or the CSS helper.', 'agizoai-agentic-browsing-optimizer' ), 'critical', $page ); } return array( 'metrics' => $metrics, 'issues' => $issues, ); } /** * Check whether a DOM node has explicit box dimensions. * * @param DOMElement $node Node. * @return bool */ private function node_has_box_size( DOMElement $node ) { $width = $node->hasAttribute( 'width' ) ? trim( $node->getAttribute( 'width' ) ) : ''; $height = $node->hasAttribute( 'height' ) ? trim( $node->getAttribute( 'height' ) ) : ''; $style = $node->hasAttribute( 'style' ) ? strtolower( $node->getAttribute( 'style' ) ) : ''; if ( '' !== $width && '' !== $height ) { return true; } return false !== strpos( $style, 'aspect-ratio' ) || ( false !== strpos( $style, 'width:' ) && ( false !== strpos( $style, 'height:' ) || false !== strpos( $style, 'min-height:' ) ) ); } /** * Check reserved space on possible container. * * @param DOMElement $node Node. * @return bool */ private function node_has_reserved_space( DOMElement $node ) { if ( $this->node_has_box_size( $node ) ) { return true; } $style = $node->hasAttribute( 'style' ) ? strtolower( $node->getAttribute( 'style' ) ) : ''; return false !== strpos( $style, 'min-height:' ) || false !== strpos( $style, 'min-width:' ) || false !== strpos( $style, 'aspect-ratio' ); } /** * Return whether a node looks like an ad container. * * @param DOMElement $node Node. * @return bool */ private function looks_like_ad_slot( DOMElement $node ) { $name = strtolower( $node->nodeName ); $id = $node->hasAttribute( 'id' ) ? strtolower( $node->getAttribute( 'id' ) ) : ''; $cls = $node->hasAttribute( 'class' ) ? strtolower( $node->getAttribute( 'class' ) ) : ''; $text = $id . ' ' . $cls; if ( 'ins' === $name && false !== strpos( $cls, 'adsbygoogle' ) ) { return true; } if ( false !== strpos( $text, 'adslot' ) || false !== strpos( $text, 'ad-slot' ) || false !== strpos( $text, 'ads-slot' ) || false !== strpos( $text, 'advertisement' ) || false !== strpos( $text, 'advert-slot' ) ) { return true; } return ( false !== strpos( $text, 'google-auto-placed' ) || false !== strpos( $text, 'adsbygoogle' ) ); } /** * Return whether a node looks like popup/modal markup. * * @param DOMElement $node Node. * @return bool */ private function looks_like_popup_or_modal( DOMElement $node ) { $id = $node->hasAttribute( 'id' ) ? strtolower( $node->getAttribute( 'id' ) ) : ''; $cls = $node->hasAttribute( 'class' ) ? strtolower( $node->getAttribute( 'class' ) ) : ''; $role = $node->hasAttribute( 'role' ) ? strtolower( $node->getAttribute( 'role' ) ) : ''; $text = $id . ' ' . $cls . ' ' . $role; return false !== strpos( $text, 'modal' ) || false !== strpos( $text, 'popup' ) || false !== strpos( $text, 'overlay' ) || false !== strpos( $text, 'dialog' ); } /** * Return whether a node looks fixed/sticky. * * @param DOMElement $node Node. * @return bool */ private function looks_like_sticky_or_fixed( DOMElement $node ) { $id = $node->hasAttribute( 'id' ) ? strtolower( $node->getAttribute( 'id' ) ) : ''; $cls = $node->hasAttribute( 'class' ) ? strtolower( $node->getAttribute( 'class' ) ) : ''; $style = $node->hasAttribute( 'style' ) ? strtolower( $node->getAttribute( 'style' ) ) : ''; $text = $id . ' ' . $cls . ' ' . $style; return false !== strpos( $text, 'position:fixed' ) || false !== strpos( $text, 'position: sticky' ) || false !== strpos( $text, 'position:sticky' ) || false !== strpos( $text, 'sticky' ) || false !== strpos( $text, 'fixed-bar' ); } /** * Build issue array. * * @param string $id ID. * @param string $title Title. * @param string $problem Problem. * @param string $why Why. * @param string $fix Fix. * @param string $severity Severity. * @param array $page Page context. * @return array */ private function issue( $id, $title, $problem, $why, $fix, $severity, array $page ) { return array( 'id' => sanitize_key( $id ), 'title' => $title, 'problem' => $problem, 'why' => $why, 'fix' => $fix, 'severity' => sanitize_key( $severity ), 'page_id' => isset( $page['id'] ) ? absint( $page['id'] ) : 0, 'page_title' => isset( $page['title'] ) ? wp_strip_all_tags( (string) $page['title'] ) : '', 'page_url' => isset( $page['url'] ) ? esc_url_raw( (string) $page['url'] ) : '', 'page_type' => isset( $page['type'] ) ? sanitize_key( (string) $page['type'] ) : '', ); } /** * Convert issues into score. * * @param array> $issues Issues. * @return int */ private function score_from_issues( array $issues ) { $score = 100; foreach ( $issues as $issue ) { $score -= isset( $issue['severity'] ) && 'critical' === $issue['severity'] ? 12 : 6; } return max( 0, min( 100, absint( $score ) ) ); } }