Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.74% covered (success)
91.74%
111 / 121
73.33% covered (warning)
73.33%
11 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Llms_Txt
91.74% covered (success)
91.74%
111 / 121
73.33% covered (warning)
73.33%
11 / 15
43.00
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 can_serve
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 request_path
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 maybe_serve
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
16.76
 generate
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 get_llms_post_type_objects
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 get_posts_for_type
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 get_pages
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 get_pages_by_order
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 get_pages_in_order
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 query_args
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 section_title
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 link_list
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 summary
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Generation and serving of the site's llms.txt.
4 *
5 * Serves a root-level `/llms.txt` — the emerging convention (llmstxt.org) for
6 * giving AI assistants a curated, Markdown map of a site's key content. Gated on
7 * the durable option `jetpack_seo_llms_txt_enabled` (round-tripped through core's
8 * `/wp/v2/settings` endpoint by the GEO tab).
9 *
10 * Serving approach: this hooks `template_redirect` and inspects the raw request
11 * path rather than registering a rewrite rule, so it needs no rewrite flush and
12 * works under any permalink structure. The tradeoff — it matches a root-level
13 * request only, and like robots.txt won't apply on a multisite subdirectory or
14 * a site fronted by a static file. Hardening the serving path (a rewrite rule,
15 * caching) is tracked separately in JETPACK-1830.
16 *
17 * @package automattic/jetpack-seo-package
18 */
19
20namespace Automattic\Jetpack\SEO;
21
22/**
23 * Builds and serves the site's llms.txt.
24 */
25class Llms_Txt {
26
27    /**
28     * Option recording whether llms.txt generation is enabled. Mirrored as a
29     * literal in the plugin's settings endpoint whitelist (`jp_group =>
30     * 'seo-tools'`).
31     *
32     * @var string
33     */
34    const OPTION = 'jetpack_seo_llms_txt_enabled';
35
36    /**
37     * Max items listed per supported content type, so a large site doesn't dump
38     * its whole tree. Pages in the main menu are listed even past it.
39     */
40    const MAX_POSTS_PER_TYPE = 50;
41
42    /**
43     * Max main-menu pages listed, so a mega menu can't list a whole site.
44     */
45    const MAX_MAIN_MENU_PAGES = 100;
46
47    /**
48     * Wire the front-end request handler.
49     *
50     * @return void
51     */
52    public static function init() {
53        add_action( 'template_redirect', array( __CLASS__, 'maybe_serve' ) );
54    }
55
56    /**
57     * Whether llms.txt generation is currently switched on.
58     *
59     * @return bool
60     */
61    public static function is_enabled() {
62        return (bool) get_option( self::OPTION, false );
63    }
64
65    /**
66     * Whether Jetpack can serve llms.txt without a plugin, static-file or hosting conflict.
67     *
68     * Hosts can signal path restrictions through the jetpack_seo_llms_txt_can_serve filter.
69     *
70     * @return bool
71     */
72    public static function can_serve() {
73        if ( Seo_Tools::is_disabled() ) {
74            return false;
75        }
76
77        $has_static_file = defined( 'ABSPATH' ) && Static_Files::exists( 'llms.txt' );
78
79        /**
80         * Filters whether WordPress can serve the dynamic `/llms.txt`. Hosts that
81         * intercept root-level paths before WordPress runs can return false to
82         * surface the honest "can't take effect" state in the SEO dashboard.
83         *
84         * @since 0.7.0
85         *
86         * @param bool $can_serve Whether WordPress can serve `/llms.txt`.
87         */
88        return (bool) apply_filters( 'jetpack_seo_llms_txt_can_serve', ! $has_static_file );
89    }
90
91    /**
92     * The site-root-relative request path, normalized without surrounding
93     * slashes (e.g. `llms.txt`).
94     *
95     * @return string
96     */
97    private static function request_path() {
98        if ( empty( $_SERVER['REQUEST_URI'] ) ) {
99            return '';
100        }
101        $uri  = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
102        $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
103        return trim( $path, '/' );
104    }
105
106    /**
107     * Serve llms.txt when enabled and the request is for it; otherwise no-op.
108     *
109     * @return void
110     */
111    public static function maybe_serve() {
112        if ( ! self::is_enabled() || 'llms.txt' !== self::request_path() || ! self::can_serve() ) {
113            return;
114        }
115
116        // Respect the site's "discourage search engines" setting. When indexing is
117        // discouraged, WordPress emits `Disallow: /` in robots.txt — serving an AI
118        // content map would contradict that signal, and would expose a content map
119        // on staging/private sites, so we don't serve it.
120        if ( ! get_option( 'blog_public' ) ) {
121            return;
122        }
123
124        nocache_headers();
125        status_header( 200 );
126        header( 'Content-Type: text/plain; charset=utf-8' );
127
128        // Content is plain-text Markdown; not HTML, so it's emitted as-is.
129        echo self::generate(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
130        exit;
131    }
132
133    /**
134     * Build the llms.txt Markdown document from site identity and published
135     * supported content types.
136     *
137     * @return string
138     */
139    public static function generate() {
140        $name        = wp_strip_all_tags( get_bloginfo( 'name' ) );
141        $description = wp_strip_all_tags( get_bloginfo( 'description' ) );
142
143        $blocks = array();
144
145        $blocks[] = '# ' . ( '' !== $name ? $name : home_url() );
146        if ( '' !== $description ) {
147            $blocks[] = '> ' . $description;
148        }
149
150        foreach ( self::get_llms_post_type_objects() as $post_type ) {
151            $list = self::link_list( self::get_posts_for_type( $post_type->name ) );
152            if ( '' !== $list ) {
153                $blocks[] = '## ' . self::section_title( $post_type ) . "\n\n" . $list;
154            }
155        }
156
157        return implode( "\n\n", $blocks ) . "\n";
158    }
159
160    /**
161     * Return supported post types in llms.txt reading order.
162     *
163     * @return \WP_Post_Type[]
164     */
165    private static function get_llms_post_type_objects() {
166        $post_types = Post_Types::get_supported_content_type_objects();
167        $ordered    = array();
168
169        foreach ( array( 'page', 'post' ) as $post_type ) {
170            if ( isset( $post_types[ $post_type ] ) ) {
171                $ordered[ $post_type ] = $post_types[ $post_type ];
172                unset( $post_types[ $post_type ] );
173            }
174        }
175
176        return array_merge( $ordered, $post_types );
177    }
178
179    /**
180     * Fetch published content for a supported post type.
181     *
182     * @param string $post_type Post type slug.
183     * @return \WP_Post[]
184     */
185    private static function get_posts_for_type( $post_type ) {
186        if ( 'page' === $post_type ) {
187            return self::get_pages();
188        }
189
190        $args                   = self::query_args( $post_type );
191        $args['posts_per_page'] = self::MAX_POSTS_PER_TYPE;
192        $args['orderby']        = 'date';
193        $args['order']          = 'DESC';
194
195        return get_posts( $args );
196    }
197
198    /**
199     * Pages ranked main menu first, then other displayed menus, then the rest by
200     * {@see get_pages_by_order()}.
201     *
202     * @return \WP_Post[]
203     */
204    private static function get_pages() {
205        $menus = Nav_Menu_Pages::get();
206        $main  = self::get_pages_in_order( $menus['main'], self::MAX_MAIN_MENU_PAGES );
207        $room  = max( 0, self::MAX_POSTS_PER_TYPE - count( $main ) );
208        $other = self::get_pages_in_order( $menus['other'], $room );
209        $room -= count( $other );
210        if ( $room <= 0 ) {
211            return array_merge( $main, $other );
212        }
213
214        $args                   = self::query_args( 'page' );
215        $args['posts_per_page'] = $room;
216        $args['post__not_in']   = array_merge( $menus['main'], $menus['other'] );
217
218        return array_merge( $main, $other, self::get_pages_by_order( $args ) );
219    }
220
221    /**
222     * Run a page query sorted by the page Order, lowest first, with pages whose Order was
223     * set ahead of untouched ones (Order 0), then by title.
224     *
225     * @param array $args Query arguments.
226     * @return \WP_Post[]
227     */
228    private static function get_pages_by_order( array $args ) {
229        $flag          = 'jetpack_seo_llms_txt_order';
230        $args[ $flag ] = true;
231        $orderby       = static function ( $sql, $query ) use ( $flag ) {
232            global $wpdb;
233            if ( ! $query->get( $flag ) ) {
234                return $sql;
235            }
236            // `menu_order = 0` is 0 for pages with an Order set and 1 for untouched ones, so ASC lists
237            // pages with an Order first; each group then sorts by Order, then title.
238            return "({$wpdb->posts}.menu_order = 0) ASC, {$wpdb->posts}.menu_order ASC, {$wpdb->posts}.post_title ASC";
239        };
240
241        add_filter( 'posts_orderby', $orderby, 10, 2 );
242        $posts = get_posts( $args );
243        remove_filter( 'posts_orderby', $orderby, 10 );
244
245        return $posts;
246    }
247
248    /**
249     * Fetch listable pages by ID, keeping the given order.
250     *
251     * @param int[] $ids   Page IDs.
252     * @param int   $limit Max pages to return.
253     * @return \WP_Post[]
254     */
255    private static function get_pages_in_order( array $ids, $limit ) {
256        if ( empty( $ids ) || $limit <= 0 ) {
257            return array();
258        }
259
260        $args                   = self::query_args( 'page' );
261        $args['post__in']       = $ids;
262        $args['orderby']        = 'post__in';
263        $args['posts_per_page'] = $limit;
264
265        return get_posts( $args );
266    }
267
268    /**
269     * Query arguments for published content that isn't password protected or hidden from
270     * search engines.
271     *
272     * @param string $post_type Post type slug.
273     * @return array
274     */
275    private static function query_args( $post_type ) {
276        return array(
277            'post_type'        => $post_type,
278            'post_status'      => 'publish',
279            'has_password'     => false,
280            'suppress_filters' => false,
281            'meta_query'       => array(
282                // WP_Query's meta_query takes its `relation` as a keyed entry alongside
283                // the positional clauses below; the mixed shape is required, not a slip.
284                // @phan-suppress-next-line PhanPluginMixedKeyNoKey
285                'relation' => 'OR',
286                array(
287                    'key'     => Content_Coverage::META_NOINDEX,
288                    'compare' => 'NOT EXISTS',
289                ),
290                array(
291                    'key'     => Content_Coverage::META_NOINDEX,
292                    'value'   => '1',
293                    'compare' => '!=',
294                ),
295            ),
296        );
297    }
298
299    /**
300     * Section title for a supported post type.
301     *
302     * @param \WP_Post_Type $post_type Post type object.
303     * @return string
304     */
305    private static function section_title( $post_type ) {
306        if ( 'page' === $post_type->name ) {
307            return __( 'Pages', 'jetpack-seo' );
308        }
309        if ( 'post' === $post_type->name ) {
310            return __( 'Posts', 'jetpack-seo' );
311        }
312        return wp_strip_all_tags( $post_type->label );
313    }
314
315    /**
316     * Render a list of posts as llms.txt link lines:
317     * `- [Title](url): summary`.
318     *
319     * @param \WP_Post[] $posts Posts to render.
320     * @return string Newline-joined link lines, or '' when there are none.
321     */
322    private static function link_list( $posts ) {
323        $lines = array();
324        foreach ( $posts as $post ) {
325            // Password-protected posts are `publish` status but their content is
326            // intentionally gated; keep them out of the public llms.txt entirely.
327            if ( ! empty( $post->post_password ) ) {
328                continue;
329            }
330
331            // Collapse whitespace (incl. newlines) so each entry stays on one line.
332            $title = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( get_the_title( $post ) ) ) );
333            if ( '' === $title ) {
334                $title = __( '(untitled)', 'jetpack-seo' );
335            }
336            // Escape brackets so a title can't break the `[title](url)` link syntax.
337            $title = str_replace( array( '[', ']' ), array( '\[', '\]' ), $title );
338            $url   = esc_url_raw( (string) get_permalink( $post ) );
339            $line  = '- [' . $title . '](' . $url . ')';
340
341            $summary = self::summary( $post );
342            if ( '' !== $summary ) {
343                $line .= ': ' . $summary;
344            }
345            $lines[] = $line;
346        }
347        return implode( "\n", $lines );
348    }
349
350    /**
351     * A short, single-line summary for a post: its excerpt when set, otherwise a
352     * trimmed slice of the content.
353     *
354     * @param \WP_Post $post Post to summarize.
355     * @return string
356     */
357    private static function summary( $post ) {
358        if ( has_excerpt( $post ) ) {
359            // A manual excerpt is a deliberate public summary; a body gate does not withhold it.
360            $raw = get_the_excerpt( $post );
361        } elseif ( Content_Gate::is_gated( $post ) ) {
362            $teaser = Content_Gate::public_teaser( $post );
363            if ( '' === $teaser ) {
364                return '';
365            }
366            $raw = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $teaser ) ), 30, '' );
367        } else {
368            $raw = wp_trim_words( wp_strip_all_tags( strip_shortcodes( get_the_excerpt( $post ) ) ), 30, '' );
369        }
370
371        // Collapse whitespace so a link line stays on one row.
372        return trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $raw ) ) );
373    }
374}