post_content, $post ); $content = do_blocks( $content ); $content = do_shortcode( $content ); return $content; } /** * Post content prepared for an AI provider. * * Deliberately not `rendered()`. That runs do_shortcode(), which executes * arbitrary plugin code before the redactor ever sees the output — a * [customer_list], a form plugin's prefill, a members-only block would * each render their data straight into the prompt, and stripping addresses * afterwards does not put that back. Here shortcodes are removed rather * than run, and the private marker is honoured before anything else so a * section marked private cannot be reached even by a filter. * * Blocks are still rendered: static blocks are the page's own markup, and * without them a block-built page is empty text. Dynamic blocks can be * excluded per site through the filter. */ public static function for_ai( \WP_Post $post ): string { $content = (string) $post->post_content; // Private sections first, before any filter or renderer can move them. $content = self::without_private( $content ); /** * Filters the raw content sent to an AI provider. * * Separate from `airanksuite/content/raw` on purpose: a page builder may want * its markup analysed locally without also shipping it to a vendor. * * @param string $content Raw post_content, private sections removed. * @param \WP_Post $post The post. */ $content = (string) apply_filters( 'airanksuite/ai/content/raw', $content, $post ); // Remove, never execute. $content = strip_shortcodes( $content ); /* * Dynamic blocks are rendered by their plugin's PHP, which can print * anything the page's viewer would see — a members-only notice, a * personalised list, a private field. Static blocks are just the * author's own saved markup and are safe to render. * * So dynamic blocks are dropped by default rather than run. A site that * knows a particular dynamic block is safe (and wants its text analysed) * can allow it by name through the filter. */ $blocks = parse_blocks( $content ); $safe = array(); foreach ( $blocks as $block ) { $name = (string) ( $block['blockName'] ?? '' ); if ( '' === $name ) { $safe[] = $block; // Freeform / classic content. continue; } $type = \WP_Block_Type_Registry::get_instance()->get_registered( $name ); $dynamic = $type && is_callable( $type->render_callback ); /** * Filters whether a dynamic block may be rendered for an AI prompt. * * @param bool $allow Default false for dynamic blocks. * @param string $name Block name, e.g. "core/latest-posts". */ if ( $dynamic && ! (bool) apply_filters( 'airanksuite/ai/allow_dynamic_block', false, $name ) ) { continue; } $safe[] = $block; } $content = implode( '', array_map( 'render_block', $safe ) ); // render_block can re-introduce shortcodes from a block's saved content. $content = strip_shortcodes( $content ); return $content; } /** * Remove every [airanksuite-private]…[/airanksuite-private] (or the older [airs-private]) section. * * The marker is not a registered shortcode, so nothing in WordPress takes * it out on its own — every path that publishes or transmits content has * to ask. The regex lived in two places and now lives here, because a * third copy is how one of them ends up a version behind. * * @param string $content Raw or rendered content. */ public static function without_private( string $content ): string { return (string) preg_replace( '#\[(airanksuite|airs)-private\].*?\[/\1-private\]#is', '', $content ); } /** * Plain text with entities decoded and whitespace normalized. */ public static function text( string $html ): string { $text = wp_strip_all_tags( $html, false ); $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); return trim( (string) preg_replace( '/[ \t]+/', ' ', $text ) ); } public static function word_count( string $html ): int { $text = self::text( $html ); return '' === $text ? 0 : count( preg_split( '/\s+/u', $text ) ?: array() ); } /** * All headings: [ [level, text], … ]. * * @return array */ public static function headings( string $html ): array { $out = array(); if ( preg_match_all( '/]*>(.*?)<\/h\1>/is', $html, $m, PREG_SET_ORDER ) ) { foreach ( $m as $hit ) { $out[] = array( (int) $hit[1], self::text( $hit[2] ) ); } } return $out; } /** * Paragraph texts (non-empty). * * @return array */ public static function paragraphs( string $html ): array { $out = array(); if ( preg_match_all( '/]*>(.*?)<\/p>/is', $html, $m ) ) { foreach ( $m[1] as $p ) { $text = self::text( $p ); if ( '' !== $text ) { $out[] = $text; } } } if ( empty( $out ) ) { // Fallback for content without

tags (some builders): split on double newlines. $out = array_values( array_filter( array_map( 'trim', preg_split( '/\n{2,}/', self::text( $html ) ) ?: array() ) ) ); } return $out; } /** * Images with/without alt: [total, missing_alt]. * * @return array{0:int, 1:int} */ public static function images_alt( string $html ): array { $total = 0; $missing = 0; if ( preg_match_all( '/]*>/i', $html, $m ) ) { foreach ( $m[0] as $img ) { ++$total; if ( ! preg_match( '/\balt\s*=\s*("([^"]+)"|\'([^\']+)\')/i', $img ) ) { ++$missing; } } } return array( $total, $missing ); } /** * Links split into [internal, external] counts relative to home host. * * @return array{0:int, 1:int} */ public static function links( string $html ): array { $internal = 0; $external = 0; $home = (string) wp_parse_url( home_url(), PHP_URL_HOST ); if ( preg_match_all( '/]*href\s*=\s*("([^"]+)"|\'([^\']+)\')/i', $html, $m ) ) { foreach ( $m[2] as $i => $href ) { $href = '' !== $href ? $href : $m[3][ $i ]; if ( str_starts_with( $href, '#' ) || str_starts_with( $href, 'mailto:' ) || str_starts_with( $href, 'tel:' ) ) { continue; } $host = (string) wp_parse_url( $href, PHP_URL_HOST ); if ( '' === $host || $host === $home ) { ++$internal; } else { ++$external; } } } return array( $internal, $external ); } /** * Lightweight HTML → markdown for llms-full.txt (headings, lists, paragraphs). */ public static function to_markdown( string $html ): string { $html = preg_replace( '/<(script|style)\b.*?<\/\1>/is', '', $html ) ?? ''; $html = preg_replace_callback( '/]*>(.*?)<\/h\1>/is', static fn( $m ) => "\n\n" . str_repeat( '#', min( 6, (int) $m[1] + 1 ) ) . ' ' . self::text( $m[2] ) . "\n\n", $html ) ?? ''; $html = preg_replace_callback( '/]*>(.*?)<\/li>/is', static fn( $m ) => "\n- " . self::text( $m[1] ), $html ) ?? ''; $html = preg_replace( '/<\/(p|div|ul|ol|table|blockquote)>/i', "\n\n", $html ) ?? ''; $html = preg_replace( '//i', "\n", $html ) ?? ''; $text = self::text( $html ); // Re-apply markdown newlines lost by text(): text() keeps \n, only collapses spaces. return trim( (string) preg_replace( "/\n{3,}/", "\n\n", $text ) ); } }