*/ private static array $summaries = []; /** * FAQs already resolved this request, keyed by post ID. * * @var array>> */ private static array $faqs = []; /** * The pages a reader (or an agent) should start from. * * The chosen pages come from the settings, in the stored order. When none * is chosen, or none of them is published any more, it falls back to the top * level entries of the site's main menu, and then to the most recent pages, * so the file is never empty on a site whose owner skipped the wizard. * * A page opted out with the `_acid_aeo_exclude` meta is dropped, whichever of * those three sources named it: the opt-out is the author's answer about the * page, not about the way it was picked. * * @return array> Rows of `id`, `title`, `url` and `summary`. */ public static function key_pages(): array { $chosen = Options::get( 'llms.key_pages', [] ); $rows = self::rows( is_array( $chosen ) ? $chosen : [] ); if ( [] !== $rows ) { return $rows; } $rows = self::rows( self::menu_post_ids() ); if ( [] !== $rows ) { return $rows; } return self::rows( self::recent_page_ids() ); } /** * The most recently updated published posts. * * Posts opted out with the `_acid_aeo_exclude` meta are left out. * * @param array $post_types Post types to include. * @param int $limit Maximum number of rows. * @return array> Rows of `id`, `title`, `url`, `summary`, `date`, * `modified`, `author_name` and `author_url`. */ public static function recent_posts( array $post_types, int $limit ): array { $post_types = array_values( array_filter( array_map( 'sanitize_key', $post_types ) ) ); if ( [] === $post_types || $limit <= 0 ) { return []; } $query = new WP_Query( [ 'post_type' => $post_types, 'post_status' => 'publish', // A `noindex` set by the site's SEO plugin is decided per post in // `row()`, in that plugin's own vocabulary, so a few of these are // expected to drop out. Asking for a margin keeps a file that // promises twenty entries from quietly publishing fifteen. 'posts_per_page' => $limit + self::NOINDEX_MARGIN, 'orderby' => 'modified', 'order' => 'DESC', 'ignore_sticky_posts' => true, 'no_found_rows' => true, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Opt-out flag; the alternative is over-fetching and filtering in PHP. 'meta_query' => [ 'relation' => 'OR', [ 'key' => self::EXCLUDE_META, 'compare' => 'NOT EXISTS', ], [ 'key' => self::EXCLUDE_META, 'value' => '1', 'compare' => '!=', ], ], ] ); $rows = []; foreach ( $query->posts as $post ) { if ( ! $post instanceof WP_Post ) { continue; } $row = self::row( (int) $post->ID ); if ( null === $row ) { continue; } $author = (int) $post->post_author; $rows[] = array_merge( $row, [ 'date' => (string) get_post_time( 'Y-m-d', true, $post ), 'modified' => (string) get_post_modified_time( 'Y-m-d', true, $post ), 'author_name' => (string) get_the_author_meta( 'display_name', $author ), 'author_url' => $author > 0 ? (string) get_author_posts_url( $author ) : '', ] ); if ( count( $rows ) >= $limit ) { break; } } return $rows; } /** * The most recently published post. * * By publication date, where `recent_posts()` orders by modification date: * the audit inspects one post as a sample of how posts are published here, * and an old post edited yesterday is not that sample. * * @param array $post_types Post types to consider. * @return int Post ID, or 0 when nothing is published. */ public static function latest_post_id( array $post_types ): int { $post_types = array_values( array_filter( array_map( 'sanitize_key', $post_types ) ) ); if ( [] === $post_types ) { return 0; } $query = new WP_Query( [ 'post_type' => $post_types, 'post_status' => 'publish', 'posts_per_page' => 1, 'orderby' => 'date', 'order' => 'DESC', 'ignore_sticky_posts' => true, 'no_found_rows' => true, 'fields' => 'ids', ] ); $ids = is_array( $query->posts ) ? $query->posts : []; return isset( $ids[0] ) ? (int) $ids[0] : 0; } /** * The short answer a post leads with. * * In order: the TL;DR block, the summary meta, the excerpt, the opening of * the content. * * @param int $post_id Post ID. * @return string Plain text summary, empty when the post does not exist. */ public static function summary_for( int $post_id ): string { if ( ! isset( self::$summaries[ $post_id ] ) ) { self::$summaries[ $post_id ] = self::build_summary( $post_id ); } return self::$summaries[ $post_id ]; } /** * The question and answer pairs of one post. * * Collects every FAQ block in the content, then the JSON meta the classic * editor writes. * * @param int $post_id Post ID. * @return array> Rows of `q` and `a`. */ public static function faq_for( int $post_id ): array { if ( isset( self::$faqs[ $post_id ] ) ) { return self::$faqs[ $post_id ]; } $post = get_post( $post_id ); $items = []; if ( $post instanceof WP_Post ) { foreach ( self::find_blocks( parse_blocks( (string) $post->post_content ), self::FAQ_BLOCK ) as $block ) { $rows = $block['attrs']['items'] ?? null; $items = array_merge( $items, self::faq_items( is_array( $rows ) ? $rows : [] ) ); } $meta = self::meta_string( $post_id, self::FAQ_META ); if ( '' !== $meta ) { $rows = json_decode( $meta, true ); $items = array_merge( $items, self::faq_items( is_array( $rows ) ? $rows : [] ) ); } } self::$faqs[ $post_id ] = $items; return $items; } /** * The site wide FAQ from the settings. * * Normalized like a post's FAQ, so callers get one shape: a row whose * question or answer is empty is dropped, because an unanswered question is * of no use in `llms.txt` or in an `FAQPage` node. * * @return array> Rows of `q` and `a`. */ public static function global_faq(): array { $rows = Options::get( 'faq', [] ); return self::faq_items( is_array( $rows ) ? $rows : [] ); } /** * Shortcode-shaped tokens, whether or not anything registered them. * * `strip_shortcodes()` only removes tags that are in `$shortcode_tags`, and * a page builder registers its tags for the screens it draws, not for a * plain text file being rendered in the same request. Salient, Divi and * Elementor content then reaches the reader as `[vc_row type="…"]` soup, * with the attribute quotes already curled by `wptexturize`. * * The token is removed and the text between tokens is kept, which is the * whole point: a builder stores the prose inside its wrappers. A tag has to * start with a letter, so `[1]` in a citation survives. * * @var string */ private const SHORTCODE_TOKEN = '/\[\/?[a-zA-Z][a-zA-Z0-9_-]*(?:[^\]\[]*)?\]/'; /** * Removes shortcode tokens left behind by whatever did not render them. * * Pure: no WordPress, so the exact behaviour is unit tested. * * @param string $text Text that may still carry shortcodes. * @return string Text without shortcode tokens. */ public static function without_shortcodes( string $text ): string { $clean = preg_replace( self::SHORTCODE_TOKEN, ' ', $text ); if ( ! is_string( $clean ) ) { return $text; } return trim( (string) preg_replace( '/[ \t]{2,}/', ' ', $clean ) ); } /** * A post's rendered content as Markdown. * * @param int $post_id Post ID. * @return string Markdown, or stripped text while the converter is not available. */ public static function markdown_for( int $post_id ): string { $post = get_post( $post_id ); if ( ! $post instanceof WP_Post ) { return ''; } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core content filter, not introducing a hook. $html = (string) apply_filters( 'the_content', (string) $post->post_content ); // A builder that did not run leaves its own tags in the text `the_content` // hands back; they are noise to every reader this file is written for. $html = self::without_shortcodes( strip_shortcodes( $html ) ); if ( class_exists( MarkdownConverter::class ) ) { return (string) ( new MarkdownConverter() )->convert( $html ); } return trim( (string) wp_strip_all_tags( $html ) ); } /** * The most used terms of a taxonomy. * * @param string $tax Taxonomy name. * @param int $limit Maximum number of terms. * @return array> Rows of `name`, `url` and `count`. */ public static function taxonomy_terms( string $tax, int $limit = 50 ): array { $tax = sanitize_key( $tax ); if ( '' === $tax || $limit <= 0 || ! taxonomy_exists( $tax ) ) { return []; } $terms = get_terms( [ 'taxonomy' => $tax, 'hide_empty' => true, 'number' => $limit, 'orderby' => 'count', 'order' => 'DESC', ] ); if ( ! is_array( $terms ) ) { return []; } $rows = []; foreach ( $terms as $term ) { if ( ! $term instanceof WP_Term ) { continue; } $url = get_term_link( $term ); if ( is_wp_error( $url ) ) { continue; } $rows[] = [ 'name' => (string) $term->name, 'url' => (string) $url, 'count' => (int) $term->count, ]; } return $rows; } /** * The day the site last changed, in UTC. * * @return string Date as `Y-m-d`. */ public static function last_modified_gmt(): string { $last = get_lastpostmodified( 'gmt' ); $last = is_string( $last ) ? $last : ''; return '' !== $last ? substr( $last, 0, 10 ) : gmdate( 'Y-m-d' ); } /** * Drops the memoized summaries and FAQs. * * For tests, and for long running processes (WP-CLI, cron) that edit posts * and then read them back in the same PHP process. * * @return void */ public static function reset(): void { self::$summaries = []; self::$faqs = []; } /** * Turns post IDs into rows, dropping the ones that are not published. * * @param array $ids Post IDs. * @return array> Rows. */ private static function rows( array $ids ): array { $rows = []; foreach ( $ids as $id ) { $row = self::row( (int) $id ); if ( null !== $row ) { $rows[] = $row; } } return $rows; } /** * One published, opted in post as a row. * * @param int $post_id Post ID. * @return array|null Row, or null when the post is missing, not published * or opted out. */ private static function row( int $post_id ): ?array { $post = get_post( $post_id ); if ( ! $post instanceof WP_Post || 'publish' !== $post->post_status || self::is_excluded( $post_id ) ) { return null; } return [ 'id' => (int) $post->ID, 'title' => self::plain_text( get_the_title( $post ) ), 'url' => (string) get_permalink( $post ), 'summary' => self::summary_for( (int) $post->ID ), ]; } /** * Whether a post is kept out of the generated files. * * Two answers, both given elsewhere. The `_acid_aeo_exclude` meta is this * plugin's own per-post opt-out; `Detect\NoIndex` reads the one the site's * SEO plugin already stores, so a page kept out of search is not then * advertised in files written for machines to read. * * The query in `recent_posts()` can only express the first, which is why * both are checked here: `row()` is the one gate every listing goes * through, whether the post came from a query, from the settings or from a * menu. * * @param int $post_id Post ID. * @return bool True when the post is opted out or marked `noindex`. */ private static function is_excluded( int $post_id ): bool { if ( '1' === self::meta_string( $post_id, self::EXCLUDE_META ) ) { return true; } return NoIndex::is( $post_id ); } /** * Post IDs linked from the top level of the site's main menu. * * @return array Post IDs, in menu order. */ private static function menu_post_ids(): array { $menu_id = self::main_menu_id(); if ( 0 === $menu_id ) { return []; } $items = wp_get_nav_menu_items( $menu_id ); if ( ! is_array( $items ) ) { return []; } $ids = []; foreach ( $items as $item ) { if ( 0 !== (int) $item->menu_item_parent || 'post_type' !== $item->type ) { continue; } $ids[] = (int) $item->object_id; } return $ids; } /** * The menu assigned to the `primary` location, else any assigned menu, else the first one. * * @return int Menu term ID, or 0 when the site has no menu. */ private static function main_menu_id(): int { $locations = get_nav_menu_locations(); $locations = is_array( $locations ) ? $locations : []; if ( ! empty( $locations['primary'] ) ) { return (int) $locations['primary']; } foreach ( $locations as $menu_id ) { if ( (int) $menu_id > 0 ) { return (int) $menu_id; } } $menus = wp_get_nav_menus(); return is_array( $menus ) && isset( $menus[0]->term_id ) ? (int) $menus[0]->term_id : 0; } /** * IDs of the most recently published pages. * * @return array Post IDs. */ private static function recent_page_ids(): array { // "Pages" means whatever this site's pages are: a hierarchical public // post type is what a theme and a menu treat as a page, and a site can // register more than the one core ships. $types = get_post_types( [ 'public' => true, 'hierarchical' => true, ] ); $types = is_array( $types ) && [] !== $types ? array_map( 'strval', array_keys( $types ) ) : [ 'page' ]; $ids = get_posts( [ 'post_type' => $types, 'post_status' => 'publish', 'numberposts' => self::FALLBACK_PAGES, 'orderby' => 'date', 'order' => 'DESC', 'fields' => 'ids', 'suppress_filters' => false, ] ); return is_array( $ids ) ? array_map( 'intval', $ids ) : []; } /** * Resolves a post's summary without the memoization. * * @param int $post_id Post ID. * @return string Plain text summary. */ private static function build_summary( int $post_id ): string { $post = get_post( $post_id ); if ( ! $post instanceof WP_Post ) { return ''; } $content = (string) $post->post_content; foreach ( self::find_blocks( parse_blocks( $content ), self::SUMMARY_BLOCK ) as $block ) { $text = self::plain_text( $block['attrs']['text'] ?? '' ); if ( '' !== $text ) { return $text; } } $meta = self::meta_string( $post_id, self::SUMMARY_META ); if ( '' !== $meta ) { return $meta; } // Cleaned like the content below it: an excerpt WordPress generated from // a builder page carries the builder's tags, and this branch wins over // the one that strips them. $excerpt = self::without_shortcodes( self::plain_text( $post->post_excerpt ) ); if ( '' !== $excerpt ) { return $excerpt; } $plain = trim( (string) wp_html_excerpt( self::without_shortcodes( strip_shortcodes( $content ) ), self::SUMMARY_LENGTH ) ); if ( '' !== $plain ) { return $plain; } return self::rendered_summary( $post ); } /** * The summary of a page whose content is not in `post_content`. * * Elementor, Bricks, Oxygen and Divi's builder keep the page in post meta * and leave `post_content` empty or nearly so. Everything above this has * nothing to read, and the page would be published as a bare link with no * description — which is the one thing `llms.txt` exists to avoid. * * The only reader of that meta is the builder itself, and it offers it on * `the_content`, for the post WordPress considers current. So the post is * made current for the length of one filter call and put back afterwards. * A builder that renders from a template instead answers with nothing, and * the page keeps the empty summary it would have had anyway. * * This is the expensive branch — it is also the last one, reached only for * pages the cheap reads could not describe, and the files it feeds are * cached and written to disk rather than built per request. * * @param WP_Post $post Post being summarized. * @return string Plain text summary, empty when nothing renders. */ private static function rendered_summary( WP_Post $post ): string { $previous = $GLOBALS['post'] ?? null; $GLOBALS['post'] = $post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Making the post current for one filter call; restored below. setup_postdata( $post ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying the core content filter, not introducing a hook. $html = (string) apply_filters( 'the_content', (string) $post->post_content ); wp_reset_postdata(); if ( $previous instanceof WP_Post ) { $GLOBALS['post'] = $previous; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the post that was current. } else { unset( $GLOBALS['post'] ); } $text = self::without_shortcodes( strip_shortcodes( (string) wp_strip_all_tags( $html ) ) ); return trim( (string) wp_html_excerpt( $text, self::SUMMARY_LENGTH ) ); } /** * Every block of one type in a block tree, inner blocks included. * * Public because it is pure: it takes the output of `parse_blocks()` and * needs no WordPress, which is what the unit test exercises. * * @param array $blocks Parsed blocks. * @param string $name Block name, e.g. `acid-aeo/faq`. * @return array> Matching blocks, in document order. */ public static function find_blocks( array $blocks, string $name ): array { $found = []; foreach ( $blocks as $block ) { if ( ! is_array( $block ) ) { continue; } $block_name = isset( $block['blockName'] ) ? (string) $block['blockName'] : ''; if ( $name === $block_name ) { $found[] = $block; } if ( is_array( $block['innerBlocks'] ?? null ) ) { $found = array_merge( $found, self::find_blocks( $block['innerBlocks'], $name ) ); } } return $found; } /** * Normalizes a list of question and answer pairs. * * A row is kept only when both halves survive: a question without an answer * cannot be used in `llms.txt` or in an `FAQPage` node. Markup is stripped, * since every consumer of these rows is plain text or JSON. * * Public because it is pure, and unit tested as such. * * @param array $rows Rows from a block attribute, a JSON meta or the settings. * @return array> Rows of `q` and `a`, incomplete ones dropped. */ public static function faq_items( array $rows ): array { $items = []; foreach ( $rows as $item ) { if ( ! is_array( $item ) ) { continue; } $question = self::plain_text( $item['q'] ?? '' ); $answer = self::plain_text( $item['a'] ?? '' ); if ( '' === $question || '' === $answer ) { continue; } $items[] = [ 'q' => $question, 'a' => $answer, ]; } return $items; } /** * A single meta value as a trimmed string. * * @param int $post_id Post ID. * @param string $key Meta key. * @return string Meta value, empty when unset or not scalar. */ private static function meta_string( int $post_id, string $key ): string { $value = get_post_meta( $post_id, $key, true ); return is_scalar( $value ) ? trim( (string) $value ) : ''; } /** * A value as trimmed plain text. * * Public because every consumer of this class needs the same normalization: * a `` attribute, a JSON-LD string and a line of `llms.txt` all want * text with the markup taken out, and each writing its own version is how * three subtly different answers get published for one title. * * @param mixed $value Raw value. * @return string Plain text. */ public static function plain_text( $value ): string { return is_scalar( $value ) ? trim( (string) wp_strip_all_tags( (string) $value ) ) : ''; } /** * Keeps the opening of a text. * * Counts characters and not bytes wherever `mbstring` is available, so a * description cut at 160 does not end on half of a `ș`. * * @param string $value Raw text. * @param int $limit Maximum number of characters. * @return string The text, shortened when it was longer than the limit. */ public static function shorten( string $value, int $limit ): string { if ( function_exists( 'mb_substr' ) ) { return mb_strlen( $value ) > $limit ? rtrim( mb_substr( $value, 0, $limit ) ) : $value; } return strlen( $value ) > $limit ? rtrim( substr( $value, 0, $limit ) ) : $value; } }