PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.1-beta
16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-seo / src / class-llms-txt.php
jetpack / jetpack_vendor / automattic / jetpack-seo / src Last commit date
class-admin-page.php 4 weeks ago class-ai-crawlers.php 4 weeks ago class-author-schema-node.php 1 month ago class-breadcrumb-schema-node.php 1 month ago class-content-coverage.php 4 weeks ago class-dashboard-data.php 3 weeks ago class-initializer.php 3 weeks ago class-llms-txt.php 4 weeks ago class-local-business-schema-node.php 1 month ago class-organization-schema-node.php 1 month ago class-post-schema-node.php 1 month ago class-post-types.php 4 weeks ago class-schema-builder.php 1 month ago class-schema-graph.php 1 month ago class-schema-node-ids.php 1 month ago class-schema-settings-controller.php 1 month ago class-schema-settings.php 1 month ago class-surface-visibility.php 3 weeks ago class-website-schema-node.php 1 month ago
class-llms-txt.php
296 lines
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 $raw = get_the_excerpt( $post );
281 } elseif (
282 get_post_meta( $post->ID, '_jetpack_memberships_contains_paid_content', true )
283 || get_post_meta( $post->ID, '_jetpack_memberships_contains_paywalled_content', true )
284 || has_block( 'premium-content/container', $post )
285 || has_block( 'jetpack/paywall', $post )
286 ) {
287 return '';
288 } else {
289 $raw = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $post->post_content ) ), 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