get_endpoint_slug(); add_rewrite_rule( '^' . preg_quote( $slug, '/' ) . '$', 'index.php?gaie_virtual_file=ai-context', 'top' ); if ( get_option( 'gaie_flush_rewrite_rules' ) ) { flush_rewrite_rules(); delete_option( 'gaie_flush_rewrite_rules' ); } } /** * Register the custom query variable. * * @since 1.0.0 * * @param array $vars Existing query variables. * @return array Modified query variables. */ public function register_query_vars( array $vars ): array { $vars[] = 'gaie_virtual_file'; return $vars; } /** * Intercept the request and serve the JSON response. * * @since 1.0.0 */ public function handle_virtual_endpoint(): void { $virtual_file = get_query_var( 'gaie_virtual_file' ); if ( 'ai-context' !== $virtual_file ) { return; } if ( ! get_option( 'gaie_virtual_endpoint', '1' ) ) { wp_die( esc_html__( 'AI context endpoint is disabled.', 'adriano-geo-context-crawl-monitor' ), esc_html__( 'Endpoint Disabled', 'adriano-geo-context-crawl-monitor' ), array( 'response' => 403 ) ); } $json = $this->get_context_json(); if ( ! headers_sent() ) { header( 'Content-Type: application/json; charset=' . get_bloginfo( 'charset' ) ); header( 'X-Robots-Tag: noindex' ); header( 'Cache-Control: public, max-age=3600' ); header( 'X-Content-Type-Options: nosniff' ); } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON sanitized at build time. echo $json; exit; } /** * Build (or retrieve from cache) the AI context JSON payload. * * @since 1.0.0 * @return string JSON-encoded context data. */ private function get_context_json(): string { $cached = wp_cache_get( self::CACHE_KEY, self::CACHE_GROUP ); if ( false !== $cached && is_string( $cached ) ) { return $cached; } $data = $this->build_context_data(); $json = wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); if ( false === $json ) { $json = '{}'; } wp_cache_set( self::CACHE_KEY, $json, self::CACHE_GROUP, self::CACHE_TTL ); return $json; } /** * Assemble the full context data array. * * @since 1.0.0 * @return array */ private function build_context_data(): array { $post_types = $this->get_registered_post_types(); return array( '@context' => 'https://schema.org', '@type' => 'WebSite', 'generator' => 'Adriano GEO Context and Crawl Monitor ' . GAIE_VERSION, 'generatedAt' => gmdate( 'c' ), 'site' => array( 'name' => sanitize_text_field( get_bloginfo( 'name' ) ), 'description' => sanitize_text_field( get_bloginfo( 'description' ) ), 'url' => esc_url_raw( get_home_url() ), 'language' => sanitize_text_field( get_bloginfo( 'language' ) ), 'charset' => sanitize_text_field( get_bloginfo( 'charset' ) ), ), 'contentTypes' => $post_types, 'taxonomies' => $this->get_taxonomies_data(), 'featuredContent' => $this->get_featured_content(), 'recentContent' => $this->get_recent_content(), ); } // ------------------------------------------------------------------------- // Content Extraction — Featured (manual or auto) // ------------------------------------------------------------------------- private function get_featured_content(): array { return $this->get_auto_featured_content(); } /** * Automatically select the top N most recently modified published posts/pages. * * @since 1.1.0 * @return array> */ private function get_auto_featured_content(): array { $count = absint( get_option( 'gaie_auto_content_count', 15 ) ); $count = max( 1, $count ); $posts = get_posts( array( 'post_type' => array( 'post', 'page' ), 'post_status' => 'publish', 'numberposts' => $count, 'orderby' => 'modified', 'order' => 'DESC', 'suppress_filters' => false, ) ); return $this->build_content_array( $posts ); } /** * Build a recent-content list (most recent 5 posts, always auto). * * @since 1.1.0 * @return array> */ private function get_recent_content(): array { $posts = get_posts( array( 'post_type' => 'post', 'post_status' => 'publish', 'numberposts' => 5, 'orderby' => 'date', 'order' => 'DESC', 'suppress_filters' => false, ) ); return $this->build_content_array( $posts, false ); } /** * Convert an array of WP_Post objects into the enriched content structure. * * @since 1.1.0 * * @param WP_Post[] $posts Posts to convert. * @param bool $include_body Whether to include the full extracted body text. * @return array> */ private function build_content_array( array $posts, bool $include_body = true ): array { $result = array(); $max_length = absint( get_option( 'gaie_content_max_length', 1500 ) ); $max_length = max( 200, min( 10000, $max_length ) ); foreach ( $posts as $post ) { if ( ! $post instanceof WP_Post ) { continue; } $item = array( 'id' => absint( $post->ID ), 'type' => sanitize_key( $post->post_type ), 'title' => sanitize_text_field( $post->post_title ), 'url' => esc_url_raw( get_permalink( $post ) ), 'publishedAt' => get_the_date( 'c', $post ), 'modifiedAt' => get_the_modified_date( 'c', $post ), 'author' => sanitize_text_field( get_the_author_meta( 'display_name', (int) $post->post_author ) ), 'excerpt' => sanitize_text_field( wp_strip_all_tags( get_the_excerpt( $post ) ) ), ); // Featured image. if ( has_post_thumbnail( $post->ID ) ) { $img = wp_get_attachment_image_src( (int) get_post_thumbnail_id( $post->ID ), 'large' ); if ( $img ) { $item['featuredImage'] = esc_url_raw( $img[0] ); } } // Taxonomies. $item['categories'] = $this->get_post_terms( $post->ID, 'category' ); $item['tags'] = $this->get_post_terms( $post->ID, 'post_tag' ); // Full body text — the most valuable signal for AI. if ( $include_body ) { $item['bodyText'] = $this->extract_body_text( $post, $max_length ); } // Headings outline for structural understanding. if ( $include_body ) { $item['headings'] = $this->extract_headings( $post->post_content ); } $result[] = $item; } return $result; } // ------------------------------------------------------------------------- // Text Extraction Helpers // ------------------------------------------------------------------------- /** * Extract clean, readable body text from a post. * * Applies shortcodes and content filters so custom blocks and shortcodes * are rendered before stripping HTML. This ensures dynamic content is * included, not left as raw markup. * * @since 1.1.0 * * @param WP_Post $post The post object. * @param int $max_length Maximum character count. * @return string Plain-text excerpt of the post body. */ private function extract_body_text( WP_Post $post, int $max_length ): string { // Set up the post global so filters work correctly. $content = $post->post_content; // Apply shortcodes. $content = do_shortcode( $content ); // Apply wpautop and other standard content filters. $content = apply_filters( 'the_content', $content ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound // Strip all HTML tags. $content = wp_strip_all_tags( $content, true ); // Collapse excess whitespace. $content = (string) preg_replace( '/\s+/', ' ', $content ); $content = trim( $content ); // Truncate to the configured limit with word boundary. if ( mb_strlen( $content ) > $max_length ) { $content = mb_substr( $content, 0, $max_length ); // Trim to last complete word. $last_space = mb_strrpos( $content, ' ' ); if ( false !== $last_space && $last_space > ( $max_length * 0.8 ) ) { $content = mb_substr( $content, 0, $last_space ); } $content .= '…'; } return sanitize_text_field( $content ); } /** * Extract an ordered list of headings (H1–H4) from raw post content. * * Gives AI crawlers an immediate structural overview without having to * parse the full body text. * * @since 1.1.0 * * @param string $content Raw post content (post_content field). * @return array> Array of heading items with 'level' and 'text'. */ private function extract_headings( string $content ): array { $headings = array(); // Strip shortcodes first to avoid capturing them in headings. $content = strip_shortcodes( $content ); if ( preg_match_all( '/]*>(.*?)<\/h\1>/is', $content, $matches, PREG_SET_ORDER ) ) { foreach ( $matches as $match ) { $headings[] = array( 'level' => 'h' . absint( $match[1] ), 'text' => sanitize_text_field( wp_strip_all_tags( $match[2] ) ), ); } } return $headings; } /** * Get term names for a post and taxonomy. * * @since 1.1.0 * * @param int $post_id Post ID. * @param string $taxonomy Taxonomy slug. * @return string[] Array of term names. */ private function get_post_terms( int $post_id, string $taxonomy ): array { $terms = wp_get_post_terms( $post_id, $taxonomy, array( 'fields' => 'names' ) ); if ( is_wp_error( $terms ) || ! is_array( $terms ) ) { return array(); } return array_map( 'sanitize_text_field', $terms ); } // ------------------------------------------------------------------------- // Site Structure Helpers // ------------------------------------------------------------------------- /** * Get a list of public post types registered on the site. * * @since 1.0.0 * @return array> */ private function get_registered_post_types(): array { $post_types = get_post_types( array( 'public' => true ), 'objects' ); $result = array(); foreach ( $post_types as $post_type ) { $result[] = array( 'name' => sanitize_key( $post_type->name ), 'label' => sanitize_text_field( $post_type->label ), ); } return $result; } /** * Get public taxonomies with term counts. * * @since 1.0.0 * @return array> */ private function get_taxonomies_data(): array { $taxonomies = get_taxonomies( array( 'public' => true ), 'objects' ); $result = array(); foreach ( $taxonomies as $taxonomy ) { $term_count = wp_count_terms( array( 'taxonomy' => $taxonomy->name ) ); $result[] = array( 'name' => sanitize_key( $taxonomy->name ), 'label' => sanitize_text_field( $taxonomy->label ), 'termCount' => is_int( $term_count ) ? $term_count : 0, ); } return $result; } /** * Retrieve the configured endpoint slug (sanitized). * * @since 1.0.0 * @return string */ private function get_endpoint_slug(): string { $slug = get_option( 'gaie_endpoint_slug', 'ai-context.json' ); return sanitize_text_field( $slug ); } }