> */ private static array $files = []; /** * Registers a virtual file. * * @param string $slug File slug, e.g. `llms`. Also the cache key suffix. * @param string $regex Rewrite regex without anchors, e.g. `llms\.txt`. * @param string $content_type MIME type sent with the response, e.g. `text/plain`. * @param callable $renderer Returns the body as a string, or null to send a 404. * @param array $args Optional: `path` (public path used by `url()`, defaults to * the slug), `cache` (bool, default true), `noindex` (bool, * adds `X-Robots-Tag: noindex`) and `extra_query` (string * appended to the rewrite target, e.g. * `&acid_aeo_key=$matches[1]`). * * A file with an `extra_query` is never cached, whatever the * caller asked for: its rewrite regex captures part of the * request, so one cached body would be replayed for every * captured value and a renderer that verifies that value (the * IndexNow key file) could be bypassed. * @return void */ public static function add_file( string $slug, string $regex, string $content_type, callable $renderer, array $args = [] ): void { $slug = sanitize_key( $slug ); if ( '' === $slug || '' === $regex ) { return; } $extra_query = (string) ( $args['extra_query'] ?? '' ); self::$files[ $slug ] = [ 'slug' => $slug, 'regex' => $regex, 'content_type' => '' !== $content_type ? $content_type : 'text/plain', 'renderer' => $renderer, 'path' => ltrim( (string) ( $args['path'] ?? $slug ), '/' ), 'cache' => '' === $extra_query && ( ! array_key_exists( 'cache', $args ) || (bool) $args['cache'] ), 'noindex' => ! empty( $args['noindex'] ), 'extra_query' => $extra_query, ]; } /** * Every registered file, keyed by slug. * * Describes the files rather than handing out the registrations: the * renderer is a callable, which neither the REST API nor the CLI can * serialize, and nothing outside the router should invoke it directly. * * @return array> Slug => `slug`, `path`, `regex`, `content_type`, * `cache` and `noindex`. */ public static function files(): array { $files = []; foreach ( self::$files as $slug => $file ) { $files[ $slug ] = self::describe( $file ); } return $files; } /** * One registered file. * * @param string $slug File slug. * @return array|null Description, or null when the slug is unknown. */ public static function file( string $slug ): ?array { $file = self::registration( $slug ); return null !== $file ? self::describe( $file ) : null; } /** * Absolute URL of a registered file. * * Falls back to the query var form when the site runs on plain permalinks, * where no rewrite rule can match. * * @param string $slug File slug. * @return string Absolute URL, or an empty string when the slug is not registered. */ public static function url( string $slug ): string { $file = self::registration( $slug ); if ( null === $file ) { return ''; } if ( '' === (string) get_option( 'permalink_structure', '' ) ) { return home_url( '/?' . self::QUERY_VAR . '=' . $file['slug'] ); } return home_url( '/' . $file['path'] ); } /** * Drops every registration. For tests. * * @return void */ public static function reset(): void { self::$files = []; } /** * Hooks the router into WordPress. * * @return void */ public function register(): void { add_action( 'init', [ $this, 'add_rewrite_rules' ] ); add_filter( 'query_vars', [ $this, 'query_vars' ] ); add_action( 'template_redirect', [ $this, 'maybe_serve' ], 5 ); } /** * Adds one rewrite rule per registered file. * * Files without an `extra_query` (literal paths) go first, then the files * with one (capturing rules such as the IndexNow key), each group in * registration order. WordPress takes the first matching rule and a * capturing rule can shadow a literal one (the key pattern also matches * `llms-full.txt` and `security.txt`), so literal files must win. * * @return void */ public function add_rewrite_rules(): void { $literal = []; $capturing = []; foreach ( self::$files as $slug => $file ) { if ( '' === $file['extra_query'] ) { $literal[ $slug ] = $file; } else { $capturing[ $slug ] = $file; } } foreach ( $literal + $capturing as $slug => $file ) { add_rewrite_rule( '^' . $file['regex'] . '$', 'index.php?' . self::QUERY_VAR . '=' . $slug . $file['extra_query'], 'top' ); } } /** * Takes every rule this router added back out of the rewrite table. * * `add_rewrite_rule( …, 'top' )` puts a rule in `$wp_rewrite->extra_rules_top`, * which is where `flush_rewrite_rules()` reads it from when it regenerates * the stored table. Deactivation runs long after `init`, so by then the * plugin's own rules are in there — and a flush that leaves them in writes * them straight back, which is how a deactivated plugin goes on answering * `/llms.txt` with the home page. Removing them first makes the flush that * follows produce the table WordPress would have without this plugin. * * Keys are rebuilt exactly as `add_rewrite_rules()` spells them, so a rule * some other plugin registered for the same path is never touched. * * @return void */ public static function remove_rewrite_rules(): void { global $wp_rewrite; if ( ! is_object( $wp_rewrite ) || ! isset( $wp_rewrite->extra_rules_top ) || ! is_array( $wp_rewrite->extra_rules_top ) ) { return; } foreach ( self::$files as $slug => $file ) { $key = '^' . $file['regex'] . '$'; $target = 'index.php?' . self::QUERY_VAR . '=' . $slug . $file['extra_query']; if ( ( $wp_rewrite->extra_rules_top[ $key ] ?? null ) === $target ) { unset( $wp_rewrite->extra_rules_top[ $key ] ); } } } /** * Declares the query vars the rewrite rules set. * * @param mixed $vars Public query vars. * @return array Query vars with the plugin's added. */ public function query_vars( $vars ): array { $vars = is_array( $vars ) ? $vars : []; $vars[] = self::QUERY_VAR; $vars[] = self::KEY_VAR; return $vars; } /** * Serves the requested file and ends the request. * * Does nothing when the request is not for one of the registered files, so * WordPress keeps rendering the page it resolved. * * A literal file that renders nothing answers 404 and ends the request. A * file registered with an `extra_query` captures part of the URL, so a null * body only means the captured name is not this file's: the request is * marked 404 (query flag and status header, otherwise WordPress would * canonical-redirect and render the home page) but not ended, so later * `template_redirect` callbacks (another plugin's virtual `.txt`) keep * running. * * @return void */ public function maybe_serve(): void { $file = self::registration( sanitize_key( (string) get_query_var( self::QUERY_VAR ) ) ); if ( null === $file ) { return; } $body = self::body( $file ); if ( null === $body ) { if ( '' !== (string) $file['extra_query'] ) { // The captured name is not the configured one, so this request is // not ours: mark it 404 the way WordPress would have without our // rule, but do not end it, because another plugin's // `template_redirect` handler may still serve the same path. global $wp_query; if ( $wp_query instanceof \WP_Query ) { $wp_query->set_404(); } status_header( 404 ); nocache_headers(); return; } status_header( 404 ); nocache_headers(); exit; } self::send( $file, $body ); } /** * One registration, renderer included. Internal use only. * * @param string $slug File slug. * @return array|null Registration, or null when the slug is unknown. */ private static function registration( string $slug ): ?array { return self::$files[ sanitize_key( $slug ) ] ?? null; } /** * A registration without its renderer, safe to hand out and to serialize. * * @param array $file Registration. * @return array Description. */ private static function describe( array $file ): array { return [ 'slug' => $file['slug'], 'path' => $file['path'], 'regex' => $file['regex'], 'content_type' => $file['content_type'], 'cache' => $file['cache'], 'noindex' => $file['noindex'], ]; } /** * Renders a file, through the cache when the registration allows it. * * @param array $file Registration. * @return string|null Body, or null when the renderer declined to answer. */ private static function body( array $file ): ?string { $cache_key = 'file_' . $file['slug']; if ( $file['cache'] ) { $cached = Cache::get( $cache_key ); if ( is_string( $cached ) ) { return $cached; } } $body = call_user_func( $file['renderer'] ); if ( null === $body ) { return null; } $body = (string) $body; if ( $file['cache'] ) { Cache::set( $cache_key, $body, HOUR_IN_SECONDS ); } return $body; } /** * Sends the body with the file's headers and ends the request. * * @param array $file Registration. * @param string $body Response body. * @return void */ private static function send( array $file, string $body ): void { status_header( 200 ); header( 'Content-Type: ' . $file['content_type'] . '; charset=utf-8' ); header( 'X-Content-Type-Options: nosniff' ); header( 'Cache-Control: public, max-age=3600' ); if ( $file['noindex'] ) { header( 'X-Robots-Tag: noindex' ); } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Generated file body (plain text or JSON) sent with a fixed Content-Type; escaping would corrupt it. echo $body; exit; } }