register_route(); } add_filter( 'query_vars', array( $this, 'register_query_var' ) ); add_action( 'template_redirect', array( $this, 'maybe_serve' ), self::SERVE_PRIORITY ); add_action( 'admin_init', array( $this, 'reconcile_route' ) ); /* * Content: any change to a potentially includable record. Each of these * hooks passes arguments `flush_all()` does not declare, which PHP * ignores. */ foreach ( array( 'save_post', 'edit_post', 'before_delete_post', 'deleted_post', 'trashed_post', 'untrashed_post' ) as $hook ) { add_action( $hook, array( LlmsCache::class, 'flush_all' ) ); } add_action( 'transition_post_status', array( $this, 'flush_on_transition' ), 10, 3 ); /* * Site, settings, visibility, presentation and ownership inputs. The * last two entries are the supported plugins' own option names, which is * the hook evidence available for an ownership change. */ $site_wide = array( 'add_option_' . Helpers::OPTION_KEY, 'update_option_' . Helpers::OPTION_KEY, 'delete_option_' . Helpers::OPTION_KEY, 'add_option_blog_public', 'update_option_blog_public', 'update_option_blogname', 'update_option_blogdescription', 'update_option_home', 'update_option_siteurl', 'update_option_show_on_front', 'update_option_page_on_front', 'update_option_page_for_posts', 'update_option_permalink_structure', 'update_option_active_plugins', 'update_site_option_active_sitewide_plugins', 'activated_plugin', 'deactivated_plugin', 'update_option_wpseo', 'add_option_wpseo', 'update_option_rank_math_modules', 'add_option_rank_math_modules', ); foreach ( $site_wide as $hook ) { add_action( $hook, array( LlmsCache::class, 'flush_all' ) ); } } /** * Registers the one rewrite rule this module owns. * * Added at the top of the rule set so that a permalink structure which would * otherwise interpret `llms.txt` as a page name cannot shadow it. * * @since 1.4.0 * * @return void */ public function register_route() { add_rewrite_rule( self::REWRITE_REGEX, 'index.php?' . self::QUERY_VAR . '=1', 'top' ); } /** * Adds the module's query var to the public query vars. * * WordPress carries a rewritten query var into `WP::$query_vars` only when * the var is public, so this registration is what makes the rewrite rule * mean anything. `is_route_request()` is what keeps the var from being * usable as a way to request the document at another URL. * * @since 1.4.0 * * @param array|mixed $vars Registered public query vars. * @return array Query vars including this module's. */ public function register_query_var( $vars ) { $vars = is_array( $vars ) ? $vars : array(); if ( ! in_array( self::QUERY_VAR, $vars, true ) ) { $vars[] = self::QUERY_VAR; } return $vars; } /** * Repairs the stored rewrite rules once per build, from an admin request. * * Activation flushes the rules, but an upgrade that overwrites plugin files * fires no activation hook, so a site updated that way would carry a rule * set that predates this module and the route would quietly 404. The repair * is bounded to one soft flush per build: the recorded build is written * whether or not a flush was needed, so a site whose rules are filtered by * something else cannot be made to flush on every admin request. * * A site using the Plain permalink structure has no rewrite rules at all. * There is nothing to repair there and nothing is attempted: the route needs * a permalink structure, which the settings screen states. * * @since 1.4.0 * * @return void */ public function reconcile_route() { $build = defined( 'AIVE_VERSION' ) ? (string) AIVE_VERSION : ''; $recorded = get_option( self::ROUTE_BUILD_OPTION, '' ); if ( is_string( $recorded ) && $recorded === $build ) { return; } $rules = get_option( 'rewrite_rules' ); if ( is_array( $rules ) && array() !== $rules && ! array_key_exists( self::REWRITE_REGEX, $rules ) ) { // Soft flush: rewrites the stored rules and never touches .htaccess. flush_rewrite_rules( false ); } update_option( self::ROUTE_BUILD_OPTION, $build, false ); } /** * Serves the document, or yields, when this request is the route. * * Ends the request on success. Nothing may print after a text/plain body, * and `template_redirect` is the last hook before WordPress chooses a * template, so exiting here is what guarantees the response is the document * and nothing else. * * @since 1.4.0 * * @return void */ public function maybe_serve() { if ( ! self::is_route_request() ) { return; } $response = self::response(); if ( null === $response ) { self::yield_to_404(); return; } status_header( 200 ); foreach ( $response['headers'] as $name => $value ) { header( $name . ': ' . $value ); } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- A text/plain response body, not markup. Every interpolated value has passed LlmsText::normalize(), LlmsText::escape() or LlmsText::url(); an HTML escaper here would corrupt the document it is meant to protect. echo $response['body']; exit; } /** * Returns the response AIVE would send for the route, or null to yield. * * Separated from `maybe_serve()` so that the decision and the document are * observable without ending a process. * * @since 1.4.0 * * @return array{status: int, headers: array, body: string}|null Response, or null when AIVE must not serve. */ public static function response() { if ( ! Helpers::is_module_enabled( self::SLUG ) ) { return null; } $ownership = LlmsOwnership::detect(); if ( ! LlmsOwnership::allows_serving( $ownership ) ) { return null; } return array( 'status' => 200, 'headers' => array( 'Content-Type' => 'text/plain; charset=UTF-8', 'X-Content-Type-Options' => 'nosniff', ), 'body' => self::document( $ownership ), ); } /** * Returns the generated document, from cache when the state is unchanged. * * @since 1.4.0 * * @param array|null $ownership Decision from LlmsOwnership::detect(), or null to detect it. * @return string The document, LF-terminated. */ public static function document( $ownership = null ) { $ownership = is_array( $ownership ) ? $ownership : LlmsOwnership::detect(); $site = LlmsDocument::site(); $selection = LlmsSelection::selection(); $key = LlmsCache::key( $site, $selection, $ownership ); $cached = LlmsCache::get( $key ); if ( null !== $cached ) { return $cached; } return LlmsCache::set( $key, LlmsDocument::render( $site, $selection ) ); } /** * Reports whether the current request is this module's exact route. * * Both conditions are required, and each closes a different hole. The parsed * request path has to be exactly `llms.txt`, which is what stops a public * query parameter from claiming another URL. The query var has to be set, * which is what stops this module from answering for a path some other * plugin's rewrite rule routed to itself. * * @since 1.4.0 * * @return bool True when this request is the route. */ public static function is_route_request() { if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) { return false; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return false; } if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) { return false; } $wp = isset( $GLOBALS['wp'] ) ? $GLOBALS['wp'] : null; if ( ! $wp instanceof WP ) { return false; } $path = isset( $wp->request ) && is_string( $wp->request ) ? trim( $wp->request, '/' ) : ''; if ( self::ROUTE !== $path ) { return false; } $requested = isset( $wp->query_vars[ self::QUERY_VAR ] ) ? $wp->query_vars[ self::QUERY_VAR ] : null; if ( null === $requested || ! is_scalar( $requested ) ) { return false; } $requested = (string) $requested; return '' !== $requested && '0' !== $requested; } /** * Returns the state the settings screen reports. * * One call, so the screen and the public response cannot disagree about who * owns the route or why. * * @since 1.4.0 * * @return array{enabled: bool, serving: bool, ownership: array, mode: string} Current state. */ public static function state() { $enabled = Helpers::is_module_enabled( self::SLUG ); $ownership = LlmsOwnership::detect(); return array( 'enabled' => $enabled, 'serving' => $enabled && LlmsOwnership::allows_serving( $ownership ), 'ownership' => $ownership, 'mode' => array() === LlmsSelection::stored_ids() ? 'automatic' : 'selected', ); } /** * Hands the request back to WordPress as an ordinary 404. * * Not a redirect, not an empty 200 and not a public explanation of who owns * the route: the response a client gets when AIVE does not own the document * is the response it would get if this module were not installed. Returning * rather than exiting is what lets the theme's own 404 template render. * * @since 1.4.0 * * @return void */ private static function yield_to_404() { $query = isset( $GLOBALS['wp_query'] ) ? $GLOBALS['wp_query'] : null; if ( $query instanceof WP_Query ) { $query->set_404(); } status_header( 404 ); nocache_headers(); } /** * Clears the cached document when a record's status changes. * * `transition_post_status` covers the one case the save hooks can miss: a * status change made by code that does not go through `wp_update_post()`. * * @since 1.4.0 * * @param string $new_status New post status. * @param string $old_status Previous post status. * @param WP_Post|null $post Post that changed. * @return void */ public function flush_on_transition( $new_status, $old_status, $post = null ) { if ( $post instanceof WP_Post ) { LlmsCache::flush_all(); } } }