Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.19% covered (success)
93.19%
178 / 191
81.48% covered (warning)
81.48%
22 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Llms_Txt
93.19% covered (success)
93.19%
178 / 191
81.48% covered (warning)
81.48%
22 / 27
80.97
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_if_hidden
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 register_invalidation
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 clear_cache_for_option
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 clear_cache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 purge_edge_cache
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 clear_cache_for_post
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 clear_cache_for_meta
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 shapes_llms_txt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 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
18.18% covered (danger)
18.18%
2 / 11
0.00% covered (danger)
0.00%
0 / 1
25.72
 get_stored
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 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
 plain_text
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
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. A rewrite rule is tracked separately in
15 * 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     * Transient holding the last built llms.txt until {@see clear_cache()} runs.
56     */
57    const CACHE_KEY = 'jetpack_seo_llms_txt';
58
59    /**
60     * How long a stored llms.txt is kept at most, for changes no hook reports.
61     */
62    const CACHE_TTL = 12 * HOUR_IN_SECONDS;
63
64    /**
65     * How long CDNs and page caches may keep a served llms.txt.
66     */
67    const HTTP_MAX_AGE = 15 * MINUTE_IN_SECONDS;
68
69    /**
70     * Options whose changes alter llms.txt, besides its own on/off setting.
71     */
72    const CACHE_OPTIONS = array( 'blogname', 'blogdescription', 'blog_public', 'show_on_front', 'page_on_front', 'permalink_structure', 'home', 'siteurl', 'WPLANG' );
73
74    /**
75     * Post meta that changes whether or how a post appears in llms.txt, including the
76     * always-include switch and page template the menu ranking reads.
77     */
78    const CACHE_META = array(
79        Content_Coverage::META_NOINDEX,
80        Content_Gate::META_CONTAINS_PAID_CONTENT,
81        Content_Gate::META_CONTAINS_PAYWALLED_CONTENT,
82        Content_Gate::META_NEWSLETTER_ACCESS,
83        self::META_ALWAYS_INCLUDE,
84        '_wp_page_template',
85    );
86
87    /**
88     * Non-public post types that still shape llms.txt: menus and the theme parts that hold them.
89     */
90    const CACHE_POST_TYPES = array( 'nav_menu_item', 'wp_navigation', 'wp_template', 'wp_template_part', 'wp_block' );
91
92    /**
93     * Wire the front-end request handler.
94     *
95     * @return void
96     */
97    public static function init() {
98        add_action( 'template_redirect', array( __CLASS__, 'maybe_serve' ) );
99    }
100
101    /**
102     * Register the always-include page meta, so the Content tab can read and save it
103     * through core REST like the page's other SEO fields, and keep it off on hidden pages.
104     *
105     * @return void
106     */
107    public static function register_always_include() {
108        register_post_meta(
109            'page',
110            self::META_ALWAYS_INCLUDE,
111            array(
112                'type'         => 'boolean',
113                // Not translated: this runs before `init`, too early to load the text domain.
114                'description'  => 'Whether to always list the page in llms.txt.',
115                'single'       => true,
116                'default'      => false,
117                'show_in_rest' => true,
118            )
119        );
120        foreach ( array( 'added_post_meta', 'updated_post_meta' ) as $hook ) {
121            add_action( $hook, array( __CLASS__, 'clear_always_include_if_hidden' ), 10, 4 );
122        }
123    }
124
125    /**
126     * Keep "Always include" off while a page is hidden from search engines, from any editor,
127     * so un-hiding the page doesn't bring it back.
128     *
129     * @param int    $meta_id    Meta ID.
130     * @param int    $post_id    Post ID.
131     * @param string $meta_key   Meta key.
132     * @param mixed  $meta_value Meta value.
133     * @return void
134     */
135    public static function clear_always_include_if_hidden( $meta_id, $post_id, $meta_key, $meta_value ) {
136        if (
137            $meta_value
138            && in_array( $meta_key, array( Content_Coverage::META_NOINDEX, self::META_ALWAYS_INCLUDE ), true )
139            && 'page' === get_post_type( $post_id )
140            && get_post_meta( $post_id, Content_Coverage::META_NOINDEX, true )
141        ) {
142            delete_post_meta( $post_id, self::META_ALWAYS_INCLUDE );
143        }
144    }
145
146    /**
147     * Clear the stored copy when something it lists changes. Registered even while llms.txt
148     * can't be served, so a copy stored earlier is not served stale once it can again.
149     *
150     * @return void
151     */
152    public static function register_invalidation() {
153        foreach ( array( 'wp_update_nav_menu', 'switch_theme', 'customize_save_after', 'activated_plugin', 'deactivated_plugin' ) as $hook ) {
154            add_action( $hook, array( __CLASS__, 'clear_cache' ) );
155        }
156        add_action( 'clean_post_cache', array( __CLASS__, 'clear_cache_for_post' ), 10, 2 );
157        // Any taxonomy: besides menus, a category's slug or parent can be part of post URLs.
158        add_action( 'clean_term_cache', array( __CLASS__, 'clear_cache' ) );
159        foreach ( array( 'added_post_meta', 'updated_post_meta', 'deleted_post_meta' ) as $hook ) {
160            add_action( $hook, array( __CLASS__, 'clear_cache_for_meta' ), 10, 3 );
161        }
162        foreach ( array( 'added_option', 'updated_option', 'deleted_option' ) as $hook ) {
163            add_action( $hook, array( __CLASS__, 'clear_cache_for_option' ) );
164        }
165    }
166
167    /**
168     * Drop the stored llms.txt after a write to an option it reads. Matched when the option is
169     * saved, since WordPress.com API requests register these hooks before switching to the site.
170     *
171     * @param string $option Option name.
172     * @return void
173     */
174    public static function clear_cache_for_option( $option ) {
175        if ( self::OPTION === $option ) {
176            // Turning llms.txt off or on is the owner's reset, so purge even when nothing is stored:
177            // the edge copy can outlive an expired stored one.
178            delete_transient( self::CACHE_KEY );
179            self::purge_edge_cache();
180        } elseif (
181            in_array( $option, self::CACHE_OPTIONS, true )
182            || ( 0 === strpos( $option, 'theme_mods_' ) && 'theme_mods_' . get_stylesheet() === $option )
183        ) {
184            self::clear_cache();
185        }
186    }
187
188    /**
189     * Drop the stored llms.txt so the next request rebuilds it.
190     *
191     * @return void
192     */
193    public static function clear_cache() {
194        // The lookup is cached when nothing is stored, so bulk edits and imports don't query on every write.
195        if ( false !== get_transient( self::CACHE_KEY ) && delete_transient( self::CACHE_KEY ) ) {
196            self::purge_edge_cache();
197        }
198    }
199
200    /**
201     * Ask WordPress.com to drop its edge copy of llms.txt on every domain of the site, as
202     * it does for robots.txt; a no-op elsewhere.
203     *
204     * @return void
205     */
206    private static function purge_edge_cache() {
207        if ( ! function_exists( 'wpcom_get_all_blog_domains_with_path' ) || ! function_exists( 'queue_async_job' ) ) {
208            return;
209        }
210        // @phan-suppress-next-line PhanUndeclaredFunction -- WordPress.com-only and guarded above; remove once wpcom stubs declare it.
211        $urls = wpcom_get_all_blog_domains_with_path( 0, '/llms.txt', true );
212        if ( ! empty( $urls ) ) {
213            // @phan-suppress-next-line PhanUndeclaredFunction -- WordPress.com-only and guarded above; remove once wpcom stubs declare it.
214            queue_async_job( array( 'output_cache' => array( 'url' => $urls ) ), 'wpcom_invalidate_output_cache_job', -16, 0 );
215        }
216    }
217
218    /**
219     * Drop the stored llms.txt after a change to a post llms.txt can list or a menu it reads.
220     *
221     * @param int           $post_id Post ID.
222     * @param \WP_Post|null $post    Post object; still set after a permanent delete, when the ID no longer resolves.
223     * @return void
224     */
225    public static function clear_cache_for_post( $post_id, $post = null ) {
226        if ( self::shapes_llms_txt( $post instanceof \WP_Post ? $post->post_type : get_post_type( $post_id ) ) ) {
227            self::clear_cache();
228        }
229    }
230
231    /**
232     * Drop the stored llms.txt after a write to post meta that llms.txt reads.
233     *
234     * @param int|int[] $meta_id  Meta ID or IDs.
235     * @param int       $post_id  Post ID.
236     * @param string    $meta_key Meta key.
237     * @return void
238     */
239    public static function clear_cache_for_meta( $meta_id, $post_id, $meta_key ) {
240        if ( in_array( $meta_key, self::CACHE_META, true ) ) {
241            self::clear_cache();
242        }
243    }
244
245    /**
246     * Whether posts of a type can appear in llms.txt or hold a menu, so a sitemap, oEmbed
247     * cache or revision write leaves the stored copy alone.
248     *
249     * @param string|false $type Post type slug.
250     * @return bool
251     */
252    private static function shapes_llms_txt( $type ) {
253        return $type && ( is_post_type_viewable( $type ) || in_array( $type, self::CACHE_POST_TYPES, true ) );
254    }
255
256    /**
257     * Whether llms.txt generation is currently switched on.
258     *
259     * @return bool
260     */
261    public static function is_enabled() {
262        return (bool) get_option( self::OPTION, false );
263    }
264
265    /**
266     * Whether Jetpack can serve llms.txt without a plugin, static-file or hosting conflict.
267     *
268     * Hosts can signal path restrictions through the jetpack_seo_llms_txt_can_serve filter.
269     *
270     * @return bool
271     */
272    public static function can_serve() {
273        if ( Seo_Tools::is_disabled() ) {
274            return false;
275        }
276
277        $has_static_file = defined( 'ABSPATH' ) && Static_Files::exists( 'llms.txt' );
278
279        /**
280         * Filters whether WordPress can serve the dynamic `/llms.txt`. Hosts that
281         * intercept root-level paths before WordPress runs can return false to
282         * surface the honest "can't take effect" state in the SEO dashboard.
283         *
284         * @since 0.7.0
285         *
286         * @param bool $can_serve Whether WordPress can serve `/llms.txt`.
287         */
288        return (bool) apply_filters( 'jetpack_seo_llms_txt_can_serve', ! $has_static_file );
289    }
290
291    /**
292     * The site-root-relative request path, normalized without surrounding
293     * slashes (e.g. `llms.txt`).
294     *
295     * @return string
296     */
297    private static function request_path() {
298        if ( empty( $_SERVER['REQUEST_URI'] ) ) {
299            return '';
300        }
301        $uri  = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
302        $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
303        return trim( $path, '/' );
304    }
305
306    /**
307     * Serve llms.txt when enabled and the request is for it; otherwise no-op.
308     *
309     * @return void
310     */
311    public static function maybe_serve() {
312        if ( ! self::is_enabled() || 'llms.txt' !== self::request_path() || ! self::can_serve() ) {
313            return;
314        }
315
316        // Respect the site's "discourage search engines" setting. When indexing is
317        // discouraged, WordPress emits `Disallow: /` in robots.txt — serving an AI
318        // content map would contradict that signal, and would expose a content map
319        // on staging/private sites, so we don't serve it.
320        if ( ! get_option( 'blog_public' ) ) {
321            return;
322        }
323
324        // A private site (`blog_public` -1) serves members only, so shared caches must not keep it.
325        if ( 1 === (int) get_option( 'blog_public' ) ) {
326            header( 'Cache-Control: public, max-age=' . self::HTTP_MAX_AGE );
327        } else {
328            nocache_headers();
329        }
330        status_header( 200 );
331        header( 'Content-Type: text/plain; charset=utf-8' );
332
333        // Content is plain-text Markdown; not HTML, so it's emitted as-is.
334        echo self::get_stored(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
335        exit;
336    }
337
338    /**
339     * The stored llms.txt, built and stored first when there is none.
340     *
341     * @return string
342     */
343    public static function get_stored() {
344        $body = get_transient( self::CACHE_KEY );
345        if ( is_string( $body ) && '' !== $body ) {
346            return $body;
347        }
348
349        // Every visitor gets the stored copy, so build it as a logged-out visitor sees it.
350        $user_id = get_current_user_id();
351        wp_set_current_user( 0 );
352        try {
353            $body = self::generate();
354        } finally {
355            wp_set_current_user( $user_id );
356        }
357
358        set_transient( self::CACHE_KEY, $body, self::CACHE_TTL );
359        return $body;
360    }
361
362    /**
363     * Build the llms.txt Markdown document from site identity and published
364     * supported content types.
365     *
366     * @return string
367     */
368    public static function generate() {
369        $name        = self::plain_text( get_bloginfo( 'name' ) );
370        $description = self::plain_text( get_bloginfo( 'description' ) );
371
372        $blocks = array();
373
374        $blocks[] = '# ' . ( '' !== $name ? $name : home_url() );
375        if ( '' !== $description ) {
376            $blocks[] = '> ' . $description;
377        }
378
379        foreach ( self::get_llms_post_type_objects() as $post_type ) {
380            $list = self::link_list( self::get_posts_for_type( $post_type->name ) );
381            if ( '' !== $list ) {
382                $blocks[] = '## ' . self::section_title( $post_type ) . "\n\n" . $list;
383            }
384        }
385
386        return implode( "\n\n", $blocks ) . "\n";
387    }
388
389    /**
390     * Return supported post types in llms.txt reading order.
391     *
392     * @return \WP_Post_Type[]
393     */
394    private static function get_llms_post_type_objects() {
395        $post_types = Post_Types::get_supported_content_type_objects();
396        $ordered    = array();
397
398        foreach ( array( 'page', 'post' ) as $post_type ) {
399            if ( isset( $post_types[ $post_type ] ) ) {
400                $ordered[ $post_type ] = $post_types[ $post_type ];
401                unset( $post_types[ $post_type ] );
402            }
403        }
404
405        return array_merge( $ordered, $post_types );
406    }
407
408    /**
409     * Fetch published content for a supported post type.
410     *
411     * @param string $post_type Post type slug.
412     * @return \WP_Post[]
413     */
414    private static function get_posts_for_type( $post_type ) {
415        if ( 'page' === $post_type ) {
416            return self::get_pages();
417        }
418
419        $args                   = self::query_args( $post_type );
420        $args['posts_per_page'] = self::MAX_POSTS_PER_TYPE;
421        $args['orderby']        = 'date';
422        $args['order']          = 'DESC';
423
424        return get_posts( $args );
425    }
426
427    /**
428     * Pages ranked: the ones the owner always includes, then the main menu, then other
429     * displayed menus, then the rest by {@see get_pages_by_order()}.
430     *
431     * @return \WP_Post[]
432     */
433    private static function get_pages() {
434        $always_included = self::get_always_included_pages();
435        $skip            = wp_list_pluck( $always_included, 'ID' );
436        $menus           = Nav_Menu_Pages::get();
437        $main            = self::get_pages_in_order( array_values( array_diff( $menus['main'], $skip ) ), self::MAX_MAIN_MENU_PAGES );
438        $room            = max( 0, self::MAX_POSTS_PER_TYPE - count( $always_included ) - count( $main ) );
439        $other           = self::get_pages_in_order( array_values( array_diff( $menus['other'], $skip ) ), $room );
440        $room           -= count( $other );
441        if ( $room <= 0 ) {
442            return array_merge( $always_included, $main, $other );
443        }
444
445        $args                   = self::query_args( 'page' );
446        $args['posts_per_page'] = $room;
447        $args['post__not_in']   = array_merge( $skip, $menus['main'], $menus['other'] );
448
449        return array_merge( $always_included, $main, $other, self::get_pages_by_order( $args ) );
450    }
451
452    /**
453     * Listable pages the owner switched on to always include, sorted by {@see get_pages_by_order()}.
454     *
455     * @return \WP_Post[]
456     */
457    private static function get_always_included_pages() {
458        $args                   = self::query_args( 'page' );
459        $args['meta_query']     = array(
460            // @phan-suppress-next-line PhanPluginMixedKeyNoKey
461            'relation' => 'AND',
462            $args['meta_query'],
463            array(
464                'key'   => self::META_ALWAYS_INCLUDE,
465                'value' => '1',
466            ),
467        );
468        $args['posts_per_page'] = -1;
469
470        return self::get_pages_by_order( $args );
471    }
472
473    /**
474     * Run a page query sorted by the page Order, lowest first, with pages whose Order was
475     * set ahead of untouched ones (Order 0), then by title.
476     *
477     * @param array $args Query arguments.
478     * @return \WP_Post[]
479     */
480    private static function get_pages_by_order( array $args ) {
481        $flag          = 'jetpack_seo_llms_txt_order';
482        $args[ $flag ] = true;
483        $orderby       = static function ( $sql, $query ) use ( $flag ) {
484            global $wpdb;
485            if ( ! $query->get( $flag ) ) {
486                return $sql;
487            }
488            // `menu_order = 0` is 0 for pages with an Order set and 1 for untouched ones, so ASC lists
489            // pages with an Order first; each group then sorts by Order, then title.
490            return "({$wpdb->posts}.menu_order = 0) ASC, {$wpdb->posts}.menu_order ASC, {$wpdb->posts}.post_title ASC";
491        };
492
493        add_filter( 'posts_orderby', $orderby, 10, 2 );
494        $posts = get_posts( $args );
495        remove_filter( 'posts_orderby', $orderby, 10 );
496
497        return $posts;
498    }
499
500    /**
501     * Fetch listable pages by ID, keeping the given order.
502     *
503     * @param int[] $ids   Page IDs.
504     * @param int   $limit Max pages to return.
505     * @return \WP_Post[]
506     */
507    private static function get_pages_in_order( array $ids, $limit ) {
508        if ( empty( $ids ) || $limit <= 0 ) {
509            return array();
510        }
511
512        $args                   = self::query_args( 'page' );
513        $args['post__in']       = $ids;
514        $args['orderby']        = 'post__in';
515        $args['posts_per_page'] = $limit;
516
517        return get_posts( $args );
518    }
519
520    /**
521     * Query arguments for published content that isn't password protected or hidden from
522     * search engines.
523     *
524     * @param string $post_type Post type slug.
525     * @return array
526     */
527    private static function query_args( $post_type ) {
528        return array(
529            'post_type'        => $post_type,
530            'post_status'      => 'publish',
531            'has_password'     => false,
532            'suppress_filters' => false,
533            'meta_query'       => array(
534                // WP_Query's meta_query takes its `relation` as a keyed entry alongside
535                // the positional clauses below; the mixed shape is required, not a slip.
536                // @phan-suppress-next-line PhanPluginMixedKeyNoKey
537                'relation' => 'OR',
538                array(
539                    'key'     => Content_Coverage::META_NOINDEX,
540                    'compare' => 'NOT EXISTS',
541                ),
542                array(
543                    'key'     => Content_Coverage::META_NOINDEX,
544                    'value'   => '1',
545                    'compare' => '!=',
546                ),
547            ),
548        );
549    }
550
551    /**
552     * Section title for a supported post type.
553     *
554     * @param \WP_Post_Type $post_type Post type object.
555     * @return string
556     */
557    private static function section_title( $post_type ) {
558        if ( 'page' === $post_type->name ) {
559            return __( 'Pages', 'jetpack-seo' );
560        }
561        if ( 'post' === $post_type->name ) {
562            return __( 'Posts', 'jetpack-seo' );
563        }
564        return self::plain_text( $post_type->label );
565    }
566
567    /**
568     * Render a list of posts as llms.txt link lines:
569     * `- [Title](url): summary`.
570     *
571     * @param \WP_Post[] $posts Posts to render.
572     * @return string Newline-joined link lines, or '' when there are none.
573     */
574    private static function link_list( $posts ) {
575        $lines = array();
576        foreach ( $posts as $post ) {
577            // Password-protected posts are `publish` status but their content is
578            // intentionally gated; keep them out of the public llms.txt entirely.
579            if ( ! empty( $post->post_password ) ) {
580                continue;
581            }
582
583            $title = self::plain_text( get_the_title( $post ) );
584            if ( '' === $title ) {
585                $title = __( '(untitled)', 'jetpack-seo' );
586            }
587            // Escape brackets so a title can't break the `[title](url)` link syntax.
588            $title = str_replace( array( '[', ']' ), array( '\[', '\]' ), $title );
589            $url   = esc_url_raw( (string) get_permalink( $post ) );
590            $line  = '- [' . $title . '](' . $url . ')';
591
592            $summary = self::summary( $post );
593            if ( '' !== $summary ) {
594                $line .= ': ' . $summary;
595            }
596            $lines[] = $line;
597        }
598        return implode( "\n", $lines );
599    }
600
601    /**
602     * A short, single-line summary for a post: its excerpt when set, otherwise a
603     * trimmed slice of the content.
604     *
605     * @param \WP_Post $post Post to summarize.
606     * @return string
607     */
608    private static function summary( $post ) {
609        if ( has_excerpt( $post ) ) {
610            // A manual excerpt is a deliberate public summary; a body gate does not withhold it.
611            $raw = get_the_excerpt( $post );
612        } elseif ( Content_Gate::is_gated( $post ) ) {
613            $teaser = Content_Gate::public_teaser( $post );
614            if ( '' === $teaser ) {
615                return '';
616            }
617            $raw = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $teaser ) ), 30, '' );
618        } else {
619            $raw = wp_trim_words( wp_strip_all_tags( strip_shortcodes( get_the_excerpt( $post ) ) ), 30, '' );
620        }
621
622        // Collapse whitespace so a link line stays on one row.
623        return self::plain_text( $raw );
624    }
625
626    /**
627     * Reduce a WordPress display string to one line of plain text.
628     *
629     * @param string $text Text that may hold tags and HTML entities.
630     * @return string
631     */
632    private static function plain_text( $text ) {
633        // Do not reorder: decoding can reveal whitespace (`&#10;`), and text like `&lt;b&gt;` isn't a tag.
634        $text = \WP_HTML_Decoder::decode_text_node( wp_strip_all_tags( $text ) );
635        return trim( preg_replace( '/\s+/', ' ', $text ) );
636    }
637}