Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.29% covered (success)
93.29%
139 / 149
77.78% covered (warning)
77.78%
14 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Llms_Txt
93.29% covered (success)
93.29%
139 / 149
77.78% covered (warning)
77.78%
14 / 18
49.73
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
 register_always_include
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 clear_always_include_on_hide
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
4
 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%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 get_always_included_pages
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 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     * Page meta an owner sets to always list a page in llms.txt.
49     *
50     * @var string
51     */
52    const META_ALWAYS_INCLUDE = 'jetpack_seo_llms_txt_always_include';
53
54    /**
55     * Wire the front-end request handler.
56     *
57     * @return void
58     */
59    public static function init() {
60        add_action( 'template_redirect', array( __CLASS__, 'maybe_serve' ) );
61    }
62
63    /**
64     * Register the always-include page meta, so the Content tab can read and save it
65     * through core REST like the page's other SEO fields, and keep it off on hidden pages.
66     *
67     * @return void
68     */
69    public static function register_always_include() {
70        register_post_meta(
71            'page',
72            self::META_ALWAYS_INCLUDE,
73            array(
74                'type'         => 'boolean',
75                // Not translated: this runs before `init`, too early to load the text domain.
76                'description'  => 'Whether to always list the page in llms.txt.',
77                'single'       => true,
78                'default'      => false,
79                'show_in_rest' => true,
80            )
81        );
82        foreach ( array( 'added_post_meta', 'updated_post_meta' ) as $hook ) {
83            add_action( $hook, array( __CLASS__, 'clear_always_include_on_hide' ), 10, 4 );
84        }
85    }
86
87    /**
88     * Switch "Always include" off when a page is hidden from search engines, from any editor,
89     * so un-hiding the page doesn't bring it back.
90     *
91     * @param int    $meta_id    Meta ID.
92     * @param int    $post_id    Post ID.
93     * @param string $meta_key   Meta key.
94     * @param mixed  $meta_value Meta value.
95     * @return void
96     */
97    public static function clear_always_include_on_hide( $meta_id, $post_id, $meta_key, $meta_value ) {
98        if ( Content_Coverage::META_NOINDEX === $meta_key && $meta_value && 'page' === get_post_type( $post_id ) ) {
99            delete_post_meta( $post_id, self::META_ALWAYS_INCLUDE );
100        }
101    }
102
103    /**
104     * Whether llms.txt generation is currently switched on.
105     *
106     * @return bool
107     */
108    public static function is_enabled() {
109        return (bool) get_option( self::OPTION, false );
110    }
111
112    /**
113     * Whether Jetpack can serve llms.txt without a plugin, static-file or hosting conflict.
114     *
115     * Hosts can signal path restrictions through the jetpack_seo_llms_txt_can_serve filter.
116     *
117     * @return bool
118     */
119    public static function can_serve() {
120        if ( Seo_Tools::is_disabled() ) {
121            return false;
122        }
123
124        $has_static_file = defined( 'ABSPATH' ) && Static_Files::exists( 'llms.txt' );
125
126        /**
127         * Filters whether WordPress can serve the dynamic `/llms.txt`. Hosts that
128         * intercept root-level paths before WordPress runs can return false to
129         * surface the honest "can't take effect" state in the SEO dashboard.
130         *
131         * @since 0.7.0
132         *
133         * @param bool $can_serve Whether WordPress can serve `/llms.txt`.
134         */
135        return (bool) apply_filters( 'jetpack_seo_llms_txt_can_serve', ! $has_static_file );
136    }
137
138    /**
139     * The site-root-relative request path, normalized without surrounding
140     * slashes (e.g. `llms.txt`).
141     *
142     * @return string
143     */
144    private static function request_path() {
145        if ( empty( $_SERVER['REQUEST_URI'] ) ) {
146            return '';
147        }
148        $uri  = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
149        $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
150        return trim( $path, '/' );
151    }
152
153    /**
154     * Serve llms.txt when enabled and the request is for it; otherwise no-op.
155     *
156     * @return void
157     */
158    public static function maybe_serve() {
159        if ( ! self::is_enabled() || 'llms.txt' !== self::request_path() || ! self::can_serve() ) {
160            return;
161        }
162
163        // Respect the site's "discourage search engines" setting. When indexing is
164        // discouraged, WordPress emits `Disallow: /` in robots.txt — serving an AI
165        // content map would contradict that signal, and would expose a content map
166        // on staging/private sites, so we don't serve it.
167        if ( ! get_option( 'blog_public' ) ) {
168            return;
169        }
170
171        nocache_headers();
172        status_header( 200 );
173        header( 'Content-Type: text/plain; charset=utf-8' );
174
175        // Content is plain-text Markdown; not HTML, so it's emitted as-is.
176        echo self::generate(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
177        exit;
178    }
179
180    /**
181     * Build the llms.txt Markdown document from site identity and published
182     * supported content types.
183     *
184     * @return string
185     */
186    public static function generate() {
187        $name        = wp_strip_all_tags( get_bloginfo( 'name' ) );
188        $description = wp_strip_all_tags( get_bloginfo( 'description' ) );
189
190        $blocks = array();
191
192        $blocks[] = '# ' . ( '' !== $name ? $name : home_url() );
193        if ( '' !== $description ) {
194            $blocks[] = '> ' . $description;
195        }
196
197        foreach ( self::get_llms_post_type_objects() as $post_type ) {
198            $list = self::link_list( self::get_posts_for_type( $post_type->name ) );
199            if ( '' !== $list ) {
200                $blocks[] = '## ' . self::section_title( $post_type ) . "\n\n" . $list;
201            }
202        }
203
204        return implode( "\n\n", $blocks ) . "\n";
205    }
206
207    /**
208     * Return supported post types in llms.txt reading order.
209     *
210     * @return \WP_Post_Type[]
211     */
212    private static function get_llms_post_type_objects() {
213        $post_types = Post_Types::get_supported_content_type_objects();
214        $ordered    = array();
215
216        foreach ( array( 'page', 'post' ) as $post_type ) {
217            if ( isset( $post_types[ $post_type ] ) ) {
218                $ordered[ $post_type ] = $post_types[ $post_type ];
219                unset( $post_types[ $post_type ] );
220            }
221        }
222
223        return array_merge( $ordered, $post_types );
224    }
225
226    /**
227     * Fetch published content for a supported post type.
228     *
229     * @param string $post_type Post type slug.
230     * @return \WP_Post[]
231     */
232    private static function get_posts_for_type( $post_type ) {
233        if ( 'page' === $post_type ) {
234            return self::get_pages();
235        }
236
237        $args                   = self::query_args( $post_type );
238        $args['posts_per_page'] = self::MAX_POSTS_PER_TYPE;
239        $args['orderby']        = 'date';
240        $args['order']          = 'DESC';
241
242        return get_posts( $args );
243    }
244
245    /**
246     * Pages ranked: the ones the owner always includes, then the main menu, then other
247     * displayed menus, then the rest by {@see get_pages_by_order()}.
248     *
249     * @return \WP_Post[]
250     */
251    private static function get_pages() {
252        $always_included = self::get_always_included_pages();
253        $skip            = wp_list_pluck( $always_included, 'ID' );
254        $menus           = Nav_Menu_Pages::get();
255        $main            = self::get_pages_in_order( array_values( array_diff( $menus['main'], $skip ) ), self::MAX_MAIN_MENU_PAGES );
256        $room            = max( 0, self::MAX_POSTS_PER_TYPE - count( $always_included ) - count( $main ) );
257        $other           = self::get_pages_in_order( array_values( array_diff( $menus['other'], $skip ) ), $room );
258        $room           -= count( $other );
259        if ( $room <= 0 ) {
260            return array_merge( $always_included, $main, $other );
261        }
262
263        $args                   = self::query_args( 'page' );
264        $args['posts_per_page'] = $room;
265        $args['post__not_in']   = array_merge( $skip, $menus['main'], $menus['other'] );
266
267        return array_merge( $always_included, $main, $other, self::get_pages_by_order( $args ) );
268    }
269
270    /**
271     * Listable pages the owner switched on to always include, sorted by {@see get_pages_by_order()}.
272     *
273     * @return \WP_Post[]
274     */
275    private static function get_always_included_pages() {
276        $args                   = self::query_args( 'page' );
277        $args['meta_query']     = array(
278            // @phan-suppress-next-line PhanPluginMixedKeyNoKey
279            'relation' => 'AND',
280            $args['meta_query'],
281            array(
282                'key'   => self::META_ALWAYS_INCLUDE,
283                'value' => '1',
284            ),
285        );
286        $args['posts_per_page'] = -1;
287
288        return self::get_pages_by_order( $args );
289    }
290
291    /**
292     * Run a page query sorted by the page Order, lowest first, with pages whose Order was
293     * set ahead of untouched ones (Order 0), then by title.
294     *
295     * @param array $args Query arguments.
296     * @return \WP_Post[]
297     */
298    private static function get_pages_by_order( array $args ) {
299        $flag          = 'jetpack_seo_llms_txt_order';
300        $args[ $flag ] = true;
301        $orderby       = static function ( $sql, $query ) use ( $flag ) {
302            global $wpdb;
303            if ( ! $query->get( $flag ) ) {
304                return $sql;
305            }
306            // `menu_order = 0` is 0 for pages with an Order set and 1 for untouched ones, so ASC lists
307            // pages with an Order first; each group then sorts by Order, then title.
308            return "({$wpdb->posts}.menu_order = 0) ASC, {$wpdb->posts}.menu_order ASC, {$wpdb->posts}.post_title ASC";
309        };
310
311        add_filter( 'posts_orderby', $orderby, 10, 2 );
312        $posts = get_posts( $args );
313        remove_filter( 'posts_orderby', $orderby, 10 );
314
315        return $posts;
316    }
317
318    /**
319     * Fetch listable pages by ID, keeping the given order.
320     *
321     * @param int[] $ids   Page IDs.
322     * @param int   $limit Max pages to return.
323     * @return \WP_Post[]
324     */
325    private static function get_pages_in_order( array $ids, $limit ) {
326        if ( empty( $ids ) || $limit <= 0 ) {
327            return array();
328        }
329
330        $args                   = self::query_args( 'page' );
331        $args['post__in']       = $ids;
332        $args['orderby']        = 'post__in';
333        $args['posts_per_page'] = $limit;
334
335        return get_posts( $args );
336    }
337
338    /**
339     * Query arguments for published content that isn't password protected or hidden from
340     * search engines.
341     *
342     * @param string $post_type Post type slug.
343     * @return array
344     */
345    private static function query_args( $post_type ) {
346        return array(
347            'post_type'        => $post_type,
348            'post_status'      => 'publish',
349            'has_password'     => false,
350            'suppress_filters' => false,
351            'meta_query'       => array(
352                // WP_Query's meta_query takes its `relation` as a keyed entry alongside
353                // the positional clauses below; the mixed shape is required, not a slip.
354                // @phan-suppress-next-line PhanPluginMixedKeyNoKey
355                'relation' => 'OR',
356                array(
357                    'key'     => Content_Coverage::META_NOINDEX,
358                    'compare' => 'NOT EXISTS',
359                ),
360                array(
361                    'key'     => Content_Coverage::META_NOINDEX,
362                    'value'   => '1',
363                    'compare' => '!=',
364                ),
365            ),
366        );
367    }
368
369    /**
370     * Section title for a supported post type.
371     *
372     * @param \WP_Post_Type $post_type Post type object.
373     * @return string
374     */
375    private static function section_title( $post_type ) {
376        if ( 'page' === $post_type->name ) {
377            return __( 'Pages', 'jetpack-seo' );
378        }
379        if ( 'post' === $post_type->name ) {
380            return __( 'Posts', 'jetpack-seo' );
381        }
382        return wp_strip_all_tags( $post_type->label );
383    }
384
385    /**
386     * Render a list of posts as llms.txt link lines:
387     * `- [Title](url): summary`.
388     *
389     * @param \WP_Post[] $posts Posts to render.
390     * @return string Newline-joined link lines, or '' when there are none.
391     */
392    private static function link_list( $posts ) {
393        $lines = array();
394        foreach ( $posts as $post ) {
395            // Password-protected posts are `publish` status but their content is
396            // intentionally gated; keep them out of the public llms.txt entirely.
397            if ( ! empty( $post->post_password ) ) {
398                continue;
399            }
400
401            // Collapse whitespace (incl. newlines) so each entry stays on one line.
402            $title = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( get_the_title( $post ) ) ) );
403            if ( '' === $title ) {
404                $title = __( '(untitled)', 'jetpack-seo' );
405            }
406            // Escape brackets so a title can't break the `[title](url)` link syntax.
407            $title = str_replace( array( '[', ']' ), array( '\[', '\]' ), $title );
408            $url   = esc_url_raw( (string) get_permalink( $post ) );
409            $line  = '- [' . $title . '](' . $url . ')';
410
411            $summary = self::summary( $post );
412            if ( '' !== $summary ) {
413                $line .= ': ' . $summary;
414            }
415            $lines[] = $line;
416        }
417        return implode( "\n", $lines );
418    }
419
420    /**
421     * A short, single-line summary for a post: its excerpt when set, otherwise a
422     * trimmed slice of the content.
423     *
424     * @param \WP_Post $post Post to summarize.
425     * @return string
426     */
427    private static function summary( $post ) {
428        if ( has_excerpt( $post ) ) {
429            // A manual excerpt is a deliberate public summary; a body gate does not withhold it.
430            $raw = get_the_excerpt( $post );
431        } elseif ( Content_Gate::is_gated( $post ) ) {
432            $teaser = Content_Gate::public_teaser( $post );
433            if ( '' === $teaser ) {
434                return '';
435            }
436            $raw = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $teaser ) ), 30, '' );
437        } else {
438            $raw = wp_trim_words( wp_strip_all_tags( strip_shortcodes( get_the_excerpt( $post ) ) ), 30, '' );
439        }
440
441        // Collapse whitespace so a link line stays on one row.
442        return trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $raw ) ) );
443    }
444}