Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.13% covered (warning)
89.13%
82 / 92
63.64% covered (warning)
63.64%
7 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Llms_Txt
89.13% covered (warning)
89.13%
82 / 92
63.64% covered (warning)
63.64%
7 / 11
35.48
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%
25 / 25
100.00% covered (success)
100.00%
1 / 1
2
 section_title
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 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.
39     */
40    const MAX_POSTS_PER_TYPE = 50;
41
42    /**
43     * Wire the front-end request handler.
44     *
45     * @return void
46     */
47    public static function init() {
48        add_action( 'template_redirect', array( __CLASS__, 'maybe_serve' ) );
49    }
50
51    /**
52     * Whether llms.txt generation is currently switched on.
53     *
54     * @return bool
55     */
56    public static function is_enabled() {
57        return (bool) get_option( self::OPTION, false );
58    }
59
60    /**
61     * Whether Jetpack can serve llms.txt without a plugin, static-file or hosting conflict.
62     *
63     * Hosts can signal path restrictions through the jetpack_seo_llms_txt_can_serve filter.
64     *
65     * @return bool
66     */
67    public static function can_serve() {
68        if ( apply_filters( 'jetpack_disable_seo_tools', false ) ) {
69            return false;
70        }
71
72        $has_static_file = defined( 'ABSPATH' ) && Static_Files::exists( 'llms.txt' );
73
74        /**
75         * Filters whether WordPress can serve the dynamic `/llms.txt`. Hosts that
76         * intercept root-level paths before WordPress runs can return false to
77         * surface the honest "can't take effect" state in the SEO dashboard.
78         *
79         * @since 0.7.0
80         *
81         * @param bool $can_serve Whether WordPress can serve `/llms.txt`.
82         */
83        return (bool) apply_filters( 'jetpack_seo_llms_txt_can_serve', ! $has_static_file );
84    }
85
86    /**
87     * The site-root-relative request path, normalized without surrounding
88     * slashes (e.g. `llms.txt`).
89     *
90     * @return string
91     */
92    private static function request_path() {
93        if ( empty( $_SERVER['REQUEST_URI'] ) ) {
94            return '';
95        }
96        $uri  = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
97        $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
98        return trim( $path, '/' );
99    }
100
101    /**
102     * Serve llms.txt when enabled and the request is for it; otherwise no-op.
103     *
104     * @return void
105     */
106    public static function maybe_serve() {
107        if ( ! self::is_enabled() || 'llms.txt' !== self::request_path() || ! self::can_serve() ) {
108            return;
109        }
110
111        // Respect the site's "discourage search engines" setting. When indexing is
112        // discouraged, WordPress emits `Disallow: /` in robots.txt — serving an AI
113        // content map would contradict that signal, and would expose a content map
114        // on staging/private sites, so we don't serve it.
115        if ( ! get_option( 'blog_public' ) ) {
116            return;
117        }
118
119        nocache_headers();
120        status_header( 200 );
121        header( 'Content-Type: text/plain; charset=utf-8' );
122
123        // Content is plain-text Markdown; not HTML, so it's emitted as-is.
124        echo self::generate(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
125        exit;
126    }
127
128    /**
129     * Build the llms.txt Markdown document from site identity and published
130     * supported content types.
131     *
132     * @return string
133     */
134    public static function generate() {
135        $name        = wp_strip_all_tags( get_bloginfo( 'name' ) );
136        $description = wp_strip_all_tags( get_bloginfo( 'description' ) );
137
138        $blocks = array();
139
140        $blocks[] = '# ' . ( '' !== $name ? $name : home_url() );
141        if ( '' !== $description ) {
142            $blocks[] = '> ' . $description;
143        }
144
145        foreach ( self::get_llms_post_type_objects() as $post_type ) {
146            $list = self::link_list( self::get_posts_for_type( $post_type->name ) );
147            if ( '' !== $list ) {
148                $blocks[] = '## ' . self::section_title( $post_type ) . "\n\n" . $list;
149            }
150        }
151
152        return implode( "\n\n", $blocks ) . "\n";
153    }
154
155    /**
156     * Return supported post types in llms.txt reading order.
157     *
158     * @return \WP_Post_Type[]
159     */
160    private static function get_llms_post_type_objects() {
161        $post_types = Post_Types::get_supported_content_type_objects();
162        $ordered    = array();
163
164        foreach ( array( 'page', 'post' ) as $post_type ) {
165            if ( isset( $post_types[ $post_type ] ) ) {
166                $ordered[ $post_type ] = $post_types[ $post_type ];
167                unset( $post_types[ $post_type ] );
168            }
169        }
170
171        return array_merge( $ordered, $post_types );
172    }
173
174    /**
175     * Fetch published content for a supported post type.
176     *
177     * @param string $post_type Post type slug.
178     * @return \WP_Post[]
179     */
180    private static function get_posts_for_type( $post_type ) {
181        $args = array(
182            'post_type'        => $post_type,
183            'post_status'      => 'publish',
184            'has_password'     => false,
185            'posts_per_page'   => self::MAX_POSTS_PER_TYPE,
186            'suppress_filters' => false,
187            'meta_query'       => array(
188                // WP_Query's meta_query takes its `relation` as a keyed entry alongside
189                // the positional clauses below; the mixed shape is required, not a slip.
190                // @phan-suppress-next-line PhanPluginMixedKeyNoKey
191                'relation' => 'OR',
192                array(
193                    'key'     => Content_Coverage::META_NOINDEX,
194                    'compare' => 'NOT EXISTS',
195                ),
196                array(
197                    'key'     => Content_Coverage::META_NOINDEX,
198                    'value'   => '1',
199                    'compare' => '!=',
200                ),
201            ),
202        );
203
204        if ( 'page' === $post_type ) {
205            $args['orderby'] = 'menu_order title';
206            $args['order']   = 'ASC';
207        } else {
208            $args['orderby'] = 'date';
209            $args['order']   = 'DESC';
210        }
211
212        return get_posts( $args );
213    }
214
215    /**
216     * Section title for a supported post type.
217     *
218     * @param \WP_Post_Type $post_type Post type object.
219     * @return string
220     */
221    private static function section_title( $post_type ) {
222        if ( 'page' === $post_type->name ) {
223            return __( 'Pages', 'jetpack-seo' );
224        }
225        if ( 'post' === $post_type->name ) {
226            return __( 'Posts', 'jetpack-seo' );
227        }
228        return wp_strip_all_tags( $post_type->label );
229    }
230
231    /**
232     * Render a list of posts as llms.txt link lines:
233     * `- [Title](url): summary`.
234     *
235     * @param \WP_Post[] $posts Posts to render.
236     * @return string Newline-joined link lines, or '' when there are none.
237     */
238    private static function link_list( $posts ) {
239        $lines = array();
240        foreach ( $posts as $post ) {
241            // Password-protected posts are `publish` status but their content is
242            // intentionally gated; keep them out of the public llms.txt entirely.
243            if ( ! empty( $post->post_password ) ) {
244                continue;
245            }
246
247            // Collapse whitespace (incl. newlines) so each entry stays on one line.
248            $title = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( get_the_title( $post ) ) ) );
249            if ( '' === $title ) {
250                $title = __( '(untitled)', 'jetpack-seo' );
251            }
252            // Escape brackets so a title can't break the `[title](url)` link syntax.
253            $title = str_replace( array( '[', ']' ), array( '\[', '\]' ), $title );
254            $url   = esc_url_raw( (string) get_permalink( $post ) );
255            $line  = '- [' . $title . '](' . $url . ')';
256
257            $summary = self::summary( $post );
258            if ( '' !== $summary ) {
259                $line .= ': ' . $summary;
260            }
261            $lines[] = $line;
262        }
263        return implode( "\n", $lines );
264    }
265
266    /**
267     * A short, single-line summary for a post: its excerpt when set, otherwise a
268     * trimmed slice of the content.
269     *
270     * @param \WP_Post $post Post to summarize.
271     * @return string
272     */
273    private static function summary( $post ) {
274        if ( has_excerpt( $post ) ) {
275            // A manual excerpt is a deliberate public summary; a body gate does not withhold it.
276            $raw = get_the_excerpt( $post );
277        } elseif ( Content_Gate::is_gated( $post ) ) {
278            $teaser = Content_Gate::public_teaser( $post );
279            if ( '' === $teaser ) {
280                return '';
281            }
282            $raw = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $teaser ) ), 30, '' );
283        } else {
284            $raw = wp_trim_words( wp_strip_all_tags( strip_shortcodes( get_the_excerpt( $post ) ) ), 30, '' );
285        }
286
287        // Collapse whitespace so a link line stays on one row.
288        return trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $raw ) ) );
289    }
290}