Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.61% covered (warning)
73.61%
53 / 72
25.00% covered (danger)
25.00%
2 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Llms_Txt
73.61% covered (warning)
73.61%
53 / 72
25.00% covered (danger)
25.00%
2 / 8
30.89
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%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 request_path
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_serve
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 generate
94.29% covered (success)
94.29%
33 / 35
0.00% covered (danger)
0.00%
0 / 1
5.00
 link_list
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
5.01
 summary
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
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 the
8 * existing `/jetpack/v4/settings` endpoint by the AI 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 pages and posts listed, so a large site doesn't dump its whole tree.
38     */
39    const MAX_PAGES = 50;
40    const MAX_POSTS = 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 WordPress can actually serve the dynamic `/llms.txt` on this site.
62     *
63     * Two ways it can't, and the toggle then silently does nothing:
64     *  - A physical `llms.txt` sits at the site root, so the web server delivers
65     *    that file directly and WordPress never runs for the request. Detected
66     *    here.
67     *  - The host fronts WordPress for root-level paths (some managed hosts /
68     *    static-file setups). PHP can't see that, so hosts can signal it via the
69     *    `jetpack_seo_llms_txt_can_serve` filter.
70     *
71     * When this returns false the GEO tab shows an honest "can't take effect"
72     * notice instead of letting the admin believe the file is live.
73     *
74     * @return bool
75     */
76    public static function can_serve() {
77        $has_static_file = defined( 'ABSPATH' ) && file_exists( ABSPATH . '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.1.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() ) {
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, published pages,
135     * and recent posts.
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        $pages = self::link_list(
151            get_posts(
152                array(
153                    'post_type'      => 'page',
154                    'post_status'    => 'publish',
155                    'has_password'   => false,
156                    'posts_per_page' => self::MAX_PAGES,
157                    'orderby'        => 'menu_order title',
158                    'order'          => 'ASC',
159                )
160            )
161        );
162        if ( '' !== $pages ) {
163            $blocks[] = "## Pages\n\n" . $pages;
164        }
165
166        $posts = self::link_list(
167            get_posts(
168                array(
169                    'post_type'      => 'post',
170                    'post_status'    => 'publish',
171                    'has_password'   => false,
172                    'posts_per_page' => self::MAX_POSTS,
173                    'orderby'        => 'date',
174                    'order'          => 'DESC',
175                )
176            )
177        );
178        if ( '' !== $posts ) {
179            $blocks[] = "## Posts\n\n" . $posts;
180        }
181
182        return implode( "\n\n", $blocks ) . "\n";
183    }
184
185    /**
186     * Render a list of posts as llms.txt link lines:
187     * `- [Title](url): summary`.
188     *
189     * @param \WP_Post[] $posts Posts to render.
190     * @return string Newline-joined link lines, or '' when there are none.
191     */
192    private static function link_list( $posts ) {
193        $lines = array();
194        foreach ( $posts as $post ) {
195            // Password-protected posts are `publish` status but their content is
196            // intentionally gated; keep them out of the public llms.txt entirely.
197            if ( ! empty( $post->post_password ) ) {
198                continue;
199            }
200
201            // Collapse whitespace (incl. newlines) so each entry stays on one line.
202            $title = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( get_the_title( $post ) ) ) );
203            if ( '' === $title ) {
204                $title = __( '(untitled)', 'jetpack-seo' );
205            }
206            // Escape brackets so a title can't break the `[title](url)` link syntax.
207            $title = str_replace( array( '[', ']' ), array( '\[', '\]' ), $title );
208            $url   = esc_url_raw( (string) get_permalink( $post ) );
209            $line  = '- [' . $title . '](' . $url . ')';
210
211            $summary = self::summary( $post );
212            if ( '' !== $summary ) {
213                $line .= ': ' . $summary;
214            }
215            $lines[] = $line;
216        }
217        return implode( "\n", $lines );
218    }
219
220    /**
221     * A short, single-line summary for a post: its excerpt when set, otherwise a
222     * trimmed slice of the content.
223     *
224     * @param \WP_Post $post Post to summarize.
225     * @return string
226     */
227    private static function summary( $post ) {
228        $raw = has_excerpt( $post )
229            ? get_the_excerpt( $post )
230            : wp_trim_words( wp_strip_all_tags( strip_shortcodes( $post->post_content ) ), 30, '' );
231
232        // Collapse whitespace so a link line stays on one row.
233        return trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $raw ) ) );
234    }
235}