PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / jetpack_vendor / automattic / jetpack-seo / src / class-llms-txt.php

class-llms-txt.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at jetpack_vendor/automattic/jetpack-seo/src/class-llms-txt.php

296 lines 8.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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
20 namespace Automattic\Jetpack\SEO;
21
22 /**
23 * Builds and serves the site's llms.txt.
24 */
25 class 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 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.7.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 and published
135 * supported content types.
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 foreach ( self::get_llms_post_type_objects() as $post_type ) {
151 $list = self::link_list( self::get_posts_for_type( $post_type->name ) );
152 if ( '' !== $list ) {
153 $blocks[] = '## ' . self::section_title( $post_type ) . "\n\n" . $list;
154 }
155 }
156
157 return implode( "\n\n", $blocks ) . "\n";
158 }
159
160 /**
161 * Return supported post types in llms.txt reading order.
162 *
163 * @return \WP_Post_Type[]
164 */
165 private static function get_llms_post_type_objects() {
166 $post_types = Post_Types::get_supported_content_type_objects();
167 $ordered = array();
168
169 foreach ( array( 'page', 'post' ) as $post_type ) {
170 if ( isset( $post_types[ $post_type ] ) ) {
171 $ordered[ $post_type ] = $post_types[ $post_type ];
172 unset( $post_types[ $post_type ] );
173 }
174 }
175
176 return array_merge( $ordered, $post_types );
177 }
178
179 /**
180 * Fetch published content for a supported post type.
181 *
182 * @param string $post_type Post type slug.
183 * @return \WP_Post[]
184 */
185 private static function get_posts_for_type( $post_type ) {
186 $args = array(
187 'post_type' => $post_type,
188 'post_status' => 'publish',
189 'has_password' => false,
190 'posts_per_page' => self::MAX_POSTS_PER_TYPE,
191 'suppress_filters' => false,
192 'meta_query' => array(
193 // WP_Query's meta_query takes its `relation` as a keyed entry alongside
194 // the positional clauses below; the mixed shape is required, not a slip.
195 // @phan-suppress-next-line PhanPluginMixedKeyNoKey
196 'relation' => 'OR',
197 array(
198 'key' => Content_Coverage::META_NOINDEX,
199 'compare' => 'NOT EXISTS',
200 ),
201 array(
202 'key' => Content_Coverage::META_NOINDEX,
203 'value' => '1',
204 'compare' => '!=',
205 ),
206 ),
207 );
208
209 if ( 'page' === $post_type ) {
210 $args['orderby'] = 'menu_order title';
211 $args['order'] = 'ASC';
212 } else {
213 $args['orderby'] = 'date';
214 $args['order'] = 'DESC';
215 }
216
217 return get_posts( $args );
218 }
219
220 /**
221 * Section title for a supported post type.
222 *
223 * @param \WP_Post_Type $post_type Post type object.
224 * @return string
225 */
226 private static function section_title( $post_type ) {
227 if ( 'page' === $post_type->name ) {
228 return __( 'Pages', 'jetpack-seo' );
229 }
230 if ( 'post' === $post_type->name ) {
231 return __( 'Posts', 'jetpack-seo' );
232 }
233 return wp_strip_all_tags( $post_type->label );
234 }
235
236 /**
237 * Render a list of posts as llms.txt link lines:
238 * `- [Title](url): summary`.
239 *
240 * @param \WP_Post[] $posts Posts to render.
241 * @return string Newline-joined link lines, or '' when there are none.
242 */
243 private static function link_list( $posts ) {
244 $lines = array();
245 foreach ( $posts as $post ) {
246 // Password-protected posts are `publish` status but their content is
247 // intentionally gated; keep them out of the public llms.txt entirely.
248 if ( ! empty( $post->post_password ) ) {
249 continue;
250 }
251
252 // Collapse whitespace (incl. newlines) so each entry stays on one line.
253 $title = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( get_the_title( $post ) ) ) );
254 if ( '' === $title ) {
255 $title = __( '(untitled)', 'jetpack-seo' );
256 }
257 // Escape brackets so a title can't break the `[title](url)` link syntax.
258 $title = str_replace( array( '[', ']' ), array( '\[', '\]' ), $title );
259 $url = esc_url_raw( (string) get_permalink( $post ) );
260 $line = '- [' . $title . '](' . $url . ')';
261
262 $summary = self::summary( $post );
263 if ( '' !== $summary ) {
264 $line .= ': ' . $summary;
265 }
266 $lines[] = $line;
267 }
268 return implode( "\n", $lines );
269 }
270
271 /**
272 * A short, single-line summary for a post: its excerpt when set, otherwise a
273 * trimmed slice of the content.
274 *
275 * @param \WP_Post $post Post to summarize.
276 * @return string
277 */
278 private static function summary( $post ) {
279 if ( has_excerpt( $post ) ) {
280 // A manual excerpt is a deliberate public summary; a body gate does not withhold it.
281 $raw = get_the_excerpt( $post );
282 } elseif ( Content_Gate::is_gated( $post ) ) {
283 $teaser = Content_Gate::public_teaser( $post );
284 if ( '' === $teaser ) {
285 return '';
286 }
287 $raw = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $teaser ) ), 30, '' );
288 } else {
289 $raw = wp_trim_words( wp_strip_all_tags( strip_shortcodes( get_the_excerpt( $post ) ) ), 30, '' );
290 }
291
292 // Collapse whitespace so a link line stays on one row.
293 return trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $raw ) ) );
294 }
295 }
296