Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.78% covered (success)
93.78%
181 / 193
84.62% covered (warning)
84.62%
22 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
Llms_Txt
93.78% covered (success)
93.78%
181 / 193
84.62% covered (warning)
84.62%
22 / 26
81.54
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
 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%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 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%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 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 fire even when nothing is stored:
177            // an edge or CDN copy can outlive an expired stored one.
178            delete_transient( self::CACHE_KEY );
179            /** This action is documented in projects/packages/seo/src/class-llms-txt.php */
180            do_action( 'jetpack_seo_llms_txt_cleared' );
181        } elseif (
182            in_array( $option, self::CACHE_OPTIONS, true )
183            || ( 0 === strpos( $option, 'theme_mods_' ) && 'theme_mods_' . get_stylesheet() === $option )
184        ) {
185            self::clear_cache();
186        }
187    }
188
189    /**
190     * Drop the stored llms.txt so the next request rebuilds it.
191     *
192     * @return void
193     */
194    public static function clear_cache() {
195        // The lookup is cached when nothing is stored, so bulk edits and imports don't query on every write.
196        if ( false !== get_transient( self::CACHE_KEY ) && delete_transient( self::CACHE_KEY ) ) {
197            /**
198             * Fires after the stored llms.txt is dropped, so hosts can purge cached copies of /llms.txt.
199             *
200             * @since $$next-version$$
201             */
202            do_action( 'jetpack_seo_llms_txt_cleared' );
203        }
204    }
205
206    /**
207     * Drop the stored llms.txt after a change to a post llms.txt can list or a menu it reads.
208     *
209     * @param int           $post_id Post ID.
210     * @param \WP_Post|null $post    Post object; still set after a permanent delete, when the ID no longer resolves.
211     * @return void
212     */
213    public static function clear_cache_for_post( $post_id, $post = null ) {
214        if ( self::shapes_llms_txt( $post instanceof \WP_Post ? $post->post_type : get_post_type( $post_id ) ) ) {
215            self::clear_cache();
216        }
217    }
218
219    /**
220     * Drop the stored llms.txt after a write to post meta that llms.txt reads.
221     *
222     * @param int|int[] $meta_id  Meta ID or IDs.
223     * @param int       $post_id  Post ID.
224     * @param string    $meta_key Meta key.
225     * @return void
226     */
227    public static function clear_cache_for_meta( $meta_id, $post_id, $meta_key ) {
228        if ( in_array( $meta_key, self::CACHE_META, true ) ) {
229            self::clear_cache();
230        }
231    }
232
233    /**
234     * Whether posts of a type can appear in llms.txt or hold a menu, so a sitemap, oEmbed
235     * cache or revision write leaves the stored copy alone.
236     *
237     * @param string|false $type Post type slug.
238     * @return bool
239     */
240    private static function shapes_llms_txt( $type ) {
241        return $type && ( is_post_type_viewable( $type ) || in_array( $type, self::CACHE_POST_TYPES, true ) );
242    }
243
244    /**
245     * Whether llms.txt generation is currently switched on.
246     *
247     * @return bool
248     */
249    public static function is_enabled() {
250        return (bool) get_option( self::OPTION, false );
251    }
252
253    /**
254     * Whether Jetpack can serve llms.txt without a plugin, static-file or hosting conflict.
255     *
256     * Hosts can signal path restrictions through the jetpack_seo_llms_txt_can_serve filter.
257     *
258     * @return bool
259     */
260    public static function can_serve() {
261        if ( Seo_Tools::is_disabled() ) {
262            return false;
263        }
264
265        $has_static_file = defined( 'ABSPATH' ) && Static_Files::exists( 'llms.txt' );
266
267        /**
268         * Filters whether WordPress can serve the dynamic `/llms.txt`. Hosts that
269         * intercept root-level paths before WordPress runs can return false to
270         * surface the honest "can't take effect" state in the SEO dashboard.
271         *
272         * @since 0.7.0
273         *
274         * @param bool $can_serve Whether WordPress can serve `/llms.txt`.
275         */
276        return (bool) apply_filters( 'jetpack_seo_llms_txt_can_serve', ! $has_static_file );
277    }
278
279    /**
280     * The site-root-relative request path, normalized without surrounding
281     * slashes (e.g. `llms.txt`).
282     *
283     * @return string
284     */
285    private static function request_path() {
286        if ( empty( $_SERVER['REQUEST_URI'] ) ) {
287            return '';
288        }
289        $uri  = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
290        $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
291        return trim( $path, '/' );
292    }
293
294    /**
295     * Serve llms.txt when enabled and the request is for it; otherwise no-op.
296     *
297     * @return void
298     */
299    public static function maybe_serve() {
300        if ( ! self::is_enabled() || 'llms.txt' !== self::request_path() || ! self::can_serve() ) {
301            return;
302        }
303
304        // Respect the site's "discourage search engines" setting. When indexing is
305        // discouraged, WordPress emits `Disallow: /` in robots.txt — serving an AI
306        // content map would contradict that signal, and would expose a content map
307        // on staging/private sites, so we don't serve it.
308        if ( ! get_option( 'blog_public' ) ) {
309            return;
310        }
311
312        // A private site (`blog_public` -1) serves members only, so shared caches must not keep it.
313        if ( 1 === (int) get_option( 'blog_public' ) ) {
314            header( 'Cache-Control: public, max-age=' . self::HTTP_MAX_AGE );
315        } else {
316            nocache_headers();
317        }
318        status_header( 200 );
319        header( 'Content-Type: text/plain; charset=utf-8' );
320
321        // Content is plain-text Markdown; not HTML, so it's emitted as-is.
322        echo self::get_stored(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
323        exit;
324    }
325
326    /**
327     * The stored llms.txt, built and stored first when there is none.
328     *
329     * @return string
330     */
331    public static function get_stored() {
332        $body = get_transient( self::CACHE_KEY );
333        if ( is_string( $body ) && '' !== $body ) {
334            return $body;
335        }
336
337        // Every visitor gets the stored copy, so build it as a logged-out visitor sees it.
338        $user_id = get_current_user_id();
339        wp_set_current_user( 0 );
340        try {
341            $body = self::generate();
342        } finally {
343            wp_set_current_user( $user_id );
344        }
345
346        set_transient( self::CACHE_KEY, $body, self::CACHE_TTL );
347        return $body;
348    }
349
350    /**
351     * Build the llms.txt Markdown document from site identity and published
352     * supported content types.
353     *
354     * @return string
355     */
356    public static function generate() {
357        $name        = self::plain_text( get_bloginfo( 'name' ) );
358        $description = self::plain_text( get_bloginfo( 'description' ) );
359
360        $blocks = array();
361
362        $blocks[] = '# ' . ( '' !== $name ? $name : home_url() );
363        if ( '' !== $description ) {
364            $blocks[] = '> ' . $description;
365        }
366
367        foreach ( self::get_llms_post_type_objects() as $post_type ) {
368            $list = self::link_list( self::get_posts_for_type( $post_type->name ) );
369            if ( '' !== $list ) {
370                $blocks[] = '## ' . self::section_title( $post_type ) . "\n\n" . $list;
371            }
372        }
373
374        $text = implode( "\n\n", $blocks ) . "\n";
375
376        /**
377         * Filters the llms.txt Markdown, e.g. to add a section.
378         *
379         * The result is stored for up to 12 hours; call Llms_Txt::clear_cache() when your section's data
380         * changes. Anything but a non-empty string keeps the unfiltered text.
381         *
382         * @since $$next-version$$
383         *
384         * @param string $text The llms.txt Markdown.
385         */
386        $filtered = apply_filters( 'jetpack_seo_llms_txt', $text );
387
388        return is_string( $filtered ) && '' !== $filtered ? $filtered : $text;
389    }
390
391    /**
392     * Return supported post types in llms.txt reading order.
393     *
394     * @return \WP_Post_Type[]
395     */
396    private static function get_llms_post_type_objects() {
397        $post_types = Post_Types::get_supported_content_type_objects();
398        $ordered    = array();
399
400        foreach ( array( 'page', 'post' ) as $post_type ) {
401            if ( isset( $post_types[ $post_type ] ) ) {
402                $ordered[ $post_type ] = $post_types[ $post_type ];
403                unset( $post_types[ $post_type ] );
404            }
405        }
406
407        return array_merge( $ordered, $post_types );
408    }
409
410    /**
411     * Fetch published content for a supported post type.
412     *
413     * @param string $post_type Post type slug.
414     * @return \WP_Post[]
415     */
416    private static function get_posts_for_type( $post_type ) {
417        if ( 'page' === $post_type ) {
418            return self::get_pages();
419        }
420
421        $args                   = self::query_args( $post_type );
422        $args['posts_per_page'] = self::MAX_POSTS_PER_TYPE;
423        $args['orderby']        = 'date';
424        $args['order']          = 'DESC';
425
426        return get_posts( $args );
427    }
428
429    /**
430     * Pages ranked: the ones the owner always includes, then the main menu, then other
431     * displayed menus, then the rest by {@see get_pages_by_order()}.
432     *
433     * @return \WP_Post[]
434     */
435    private static function get_pages() {
436        $always_included = self::get_always_included_pages();
437        $skip            = wp_list_pluck( $always_included, 'ID' );
438        $menus           = Nav_Menu_Pages::get();
439        $main            = self::get_pages_in_order( array_values( array_diff( $menus['main'], $skip ) ), self::MAX_MAIN_MENU_PAGES );
440        $room            = max( 0, self::MAX_POSTS_PER_TYPE - count( $always_included ) - count( $main ) );
441        $other           = self::get_pages_in_order( array_values( array_diff( $menus['other'], $skip ) ), $room );
442        $room           -= count( $other );
443        if ( $room <= 0 ) {
444            return array_merge( $always_included, $main, $other );
445        }
446
447        $args                   = self::query_args( 'page' );
448        $args['posts_per_page'] = $room;
449        $args['post__not_in']   = array_merge( $skip, $menus['main'], $menus['other'] );
450
451        return array_merge( $always_included, $main, $other, self::get_pages_by_order( $args ) );
452    }
453
454    /**
455     * Listable pages the owner switched on to always include, sorted by {@see get_pages_by_order()}.
456     *
457     * @return \WP_Post[]
458     */
459    private static function get_always_included_pages() {
460        $args                   = self::query_args( 'page' );
461        $args['meta_query']     = array(
462            // @phan-suppress-next-line PhanPluginMixedKeyNoKey
463            'relation' => 'AND',
464            $args['meta_query'],
465            array(
466                'key'   => self::META_ALWAYS_INCLUDE,
467                'value' => '1',
468            ),
469        );
470        $args['posts_per_page'] = -1;
471
472        return self::get_pages_by_order( $args );
473    }
474
475    /**
476     * Run a page query sorted by the page Order, lowest first, with pages whose Order was
477     * set ahead of untouched ones (Order 0), then by title.
478     *
479     * @param array $args Query arguments.
480     * @return \WP_Post[]
481     */
482    private static function get_pages_by_order( array $args ) {
483        $flag          = 'jetpack_seo_llms_txt_order';
484        $args[ $flag ] = true;
485        $orderby       = static function ( $sql, $query ) use ( $flag ) {
486            global $wpdb;
487            if ( ! $query->get( $flag ) ) {
488                return $sql;
489            }
490            // `menu_order = 0` is 0 for pages with an Order set and 1 for untouched ones, so ASC lists
491            // pages with an Order first; each group then sorts by Order, then title.
492            return "({$wpdb->posts}.menu_order = 0) ASC, {$wpdb->posts}.menu_order ASC, {$wpdb->posts}.post_title ASC";
493        };
494
495        add_filter( 'posts_orderby', $orderby, 10, 2 );
496        $posts = get_posts( $args );
497        remove_filter( 'posts_orderby', $orderby, 10 );
498
499        return $posts;
500    }
501
502    /**
503     * Fetch listable pages by ID, keeping the given order.
504     *
505     * @param int[] $ids   Page IDs.
506     * @param int   $limit Max pages to return.
507     * @return \WP_Post[]
508     */
509    private static function get_pages_in_order( array $ids, $limit ) {
510        if ( empty( $ids ) || $limit <= 0 ) {
511            return array();
512        }
513
514        $args                   = self::query_args( 'page' );
515        $args['post__in']       = $ids;
516        $args['orderby']        = 'post__in';
517        $args['posts_per_page'] = $limit;
518
519        return get_posts( $args );
520    }
521
522    /**
523     * Query arguments for published content that isn't password protected or hidden from
524     * search engines.
525     *
526     * @param string $post_type Post type slug.
527     * @return array
528     */
529    private static function query_args( $post_type ) {
530        return array(
531            'post_type'        => $post_type,
532            'post_status'      => 'publish',
533            'has_password'     => false,
534            'suppress_filters' => false,
535            'meta_query'       => array(
536                // WP_Query's meta_query takes its `relation` as a keyed entry alongside
537                // the positional clauses below; the mixed shape is required, not a slip.
538                // @phan-suppress-next-line PhanPluginMixedKeyNoKey
539                'relation' => 'OR',
540                array(
541                    'key'     => Content_Coverage::META_NOINDEX,
542                    'compare' => 'NOT EXISTS',
543                ),
544                array(
545                    'key'     => Content_Coverage::META_NOINDEX,
546                    'value'   => '1',
547                    'compare' => '!=',
548                ),
549            ),
550        );
551    }
552
553    /**
554     * Section title for a supported post type.
555     *
556     * @param \WP_Post_Type $post_type Post type object.
557     * @return string
558     */
559    private static function section_title( $post_type ) {
560        if ( 'page' === $post_type->name ) {
561            return __( 'Pages', 'jetpack-seo' );
562        }
563        if ( 'post' === $post_type->name ) {
564            return __( 'Posts', 'jetpack-seo' );
565        }
566        return self::plain_text( $post_type->label );
567    }
568
569    /**
570     * Render a list of posts as llms.txt link lines:
571     * `- [Title](url): summary`.
572     *
573     * @param \WP_Post[] $posts Posts to render.
574     * @return string Newline-joined link lines, or '' when there are none.
575     */
576    private static function link_list( $posts ) {
577        $lines = array();
578        foreach ( $posts as $post ) {
579            // Password-protected posts are `publish` status but their content is
580            // intentionally gated; keep them out of the public llms.txt entirely.
581            if ( ! empty( $post->post_password ) ) {
582                continue;
583            }
584
585            $title = self::plain_text( get_the_title( $post ) );
586            if ( '' === $title ) {
587                $title = __( '(untitled)', 'jetpack-seo' );
588            }
589            // Escape brackets so a title can't break the `[title](url)` link syntax.
590            $title = str_replace( array( '[', ']' ), array( '\[', '\]' ), $title );
591            $url   = esc_url_raw( (string) get_permalink( $post ) );
592            $line  = '- [' . $title . '](' . $url . ')';
593
594            $summary = self::summary( $post );
595            if ( '' !== $summary ) {
596                $line .= ': ' . $summary;
597            }
598            $lines[] = $line;
599        }
600        return implode( "\n", $lines );
601    }
602
603    /**
604     * A short, single-line summary for a post: its SEO description when set, then
605     * its excerpt, otherwise a trimmed slice of the content.
606     *
607     * @param \WP_Post $post Post to summarize.
608     * @return string
609     */
610    private static function summary( $post ) {
611        $seo_description = '';
612        if ( class_exists( 'Jetpack_SEO_Utils' ) && class_exists( 'Jetpack_SEO_Posts' ) ) {
613            // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Posts lives in plugins/jetpack; guarded by the class_exists checks above.
614            $seo_description = wp_trim_words( strip_shortcodes( wp_strip_all_tags( \Jetpack_SEO_Posts::get_post_custom_description( $post ) ) ), Post_Schema_Node::DESCRIPTION_MAX_WORDS, '' );
615        }
616
617        // The SEO description and manual excerpt are deliberate public summaries; a body gate does not withhold them.
618        if ( '' !== $seo_description ) {
619            $raw = $seo_description;
620        } elseif ( has_excerpt( $post ) ) {
621            $raw = get_the_excerpt( $post );
622        } elseif ( Content_Gate::is_gated( $post ) ) {
623            $teaser = Content_Gate::public_teaser( $post );
624            if ( '' === $teaser ) {
625                return '';
626            }
627            $raw = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $teaser ) ), 30, '' );
628        } else {
629            $raw = wp_trim_words( wp_strip_all_tags( strip_shortcodes( get_the_excerpt( $post ) ) ), 30, '' );
630        }
631
632        // Collapse whitespace so a link line stays on one row.
633        return self::plain_text( $raw );
634    }
635
636    /**
637     * Reduce a WordPress display string to one line of plain text.
638     *
639     * @param string $text Text that may hold tags and HTML entities.
640     * @return string
641     */
642    private static function plain_text( $text ) {
643        // Do not reorder: decoding can reveal whitespace (`&#10;`), and text like `&lt;b&gt;` isn't a tag.
644        $text = \WP_HTML_Decoder::decode_text_node( wp_strip_all_tags( $text ) );
645        return trim( preg_replace( '/\s+/', ' ', $text ) );
646    }
647}