*/ public const FILES = [ 'llms', 'llms_full', 'robots', 'humans', 'ai_txt', 'ai_context', 'security', ]; /** * Largest draft payload accepted, in bytes. * * The settings are a few kilobytes of JSON; a draft an order of magnitude * larger than that is not a settings screen. * * @var int */ public const DRAFT_MAX_BYTES = 204800; /** * The robots.txt WordPress core builds before any filter runs. * * @var string */ private const ROBOTS_CORE = "User-agent: *\nDisallow: /wp-admin/\nAllow: /wp-admin/admin-ajax.php\n"; /** * How each previewable file is rendered: module class, method, MIME type. * * `robots` is absent on purpose — it is not a file a module renders but the * output of a core filter every plugin on the site can add to. * * @var array> */ private const RENDERERS = [ 'llms' => [ Modules\LlmsTxt::class, 'render', 'text/plain' ], 'llms_full' => [ Modules\LlmsTxt::class, 'render_full', 'text/plain' ], 'humans' => [ Modules\HumansTxt::class, 'render', 'text/plain' ], 'ai_txt' => [ Modules\AiTxt::class, 'render', 'text/plain' ], 'ai_context' => [ Modules\AiContext::class, 'render', 'application/json' ], 'security' => [ Modules\SecurityTxt::class, 'render', 'text/plain' ], ]; /** * Priority the crawler policy filters `robots_txt` at. * * The same one `Modules\RobotsPolicy::register()` uses, so a preview places * the block exactly where the served file would. * * @var int */ private const POLICY_PRIORITY = 20; /** * Argument schema of `GET /preview/{file}`. * * @return array> Argument schemas. */ public static function show_args(): array { return [ 'file' => [ 'type' => 'string', 'required' => true, 'enum' => self::FILES, 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ], 'draft' => [ 'type' => 'string', 'required' => false, 'default' => '', 'maxLength' => self::DRAFT_MAX_BYTES, 'sanitize_callback' => [ self::class, 'sanitize_draft' ], 'validate_callback' => 'rest_validate_request_arg', ], ]; } /** * Sanitizes the `draft` argument. * * JSON cannot be run through a text sanitizer without being mangled, so the * only thing enforced here is that it is a string: `draft[]=x` would * otherwise hand an array to `strlen()` and `json_decode()`. What the JSON * decodes to is sanitized against the schema by `Options::with_overlay()`, * which is where a settings value is allowed to be judged. * * @param mixed $value Raw argument value. * @return string The draft, or an empty string. */ public static function sanitize_draft( $value ): string { return is_string( $value ) ? $value : ''; } /** * Renders one file, optionally through unsaved settings. * * @param WP_REST_Request $request The request. * @return array|WP_Error Keys `file`, `content_type`, `body` and `url`. */ public function show( WP_REST_Request $request ) { $file = (string) $request->get_param( 'file' ); $draft = (string) $request->get_param( 'draft' ); if ( '' === $draft ) { $body = self::preview_body( $file ); } else { $overlay = self::decode_draft( $draft ); if ( is_wp_error( $overlay ) ) { return $overlay; } $body = Options::with_overlay( $overlay, static fn() => self::preview_body( $file ) ); } if ( is_wp_error( $body ) ) { return $body; } return [ 'file' => $file, 'content_type' => self::content_type( $file ), 'body' => (string) $body, 'url' => self::url( $file ), ]; } /** * The body one previewable file would be served with right now. * * @param string $file File key, one of `FILES`. * @return string|WP_Error The body, or a 404 when the file has no renderer. */ public static function preview_body( string $file ) { if ( 'robots' === $file ) { return self::robots_body(); } if ( ! isset( self::RENDERERS[ $file ] ) ) { return self::unknown_file( $file ); } [ $class_name, $method ] = self::RENDERERS[ $file ]; if ( ! class_exists( $class_name ) || ! method_exists( $class_name, $method ) ) { return self::unknown_file( $file ); } $module = new $class_name(); return (string) $module->$method(); } /** * The robots.txt the site would serve. * * `do_robots()` prints and cannot be captured without an output buffer, so * the preview runs the same filter on the same text core hands it. Every * other plugin's `robots_txt` filter is part of the answer, which is the * point: the preview shows the file, not this plugin's contribution to it. * * @return string The robots.txt body. */ private static function robots_body(): string { $policy = self::policy_to_attach(); if ( null !== $policy ) { add_filter( 'robots_txt', [ $policy, 'filter' ], self::POLICY_PRIORITY, 2 ); } try { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's own robots.txt filter; a preview that ran a private hook would not be a preview. return (string) apply_filters( 'robots_txt', self::ROBOTS_CORE, true ); } finally { if ( null !== $policy ) { remove_filter( 'robots_txt', [ $policy, 'filter' ], self::POLICY_PRIORITY ); } } } /** * The crawler policy this preview has to hook in itself, if any. * * A switched off policy module is instantiated by `Plugin` but never * registered, so its filter is missing exactly when a preview is most * useful. The filter is therefore checked rather than the module's * presence, and whatever is hooked in here is unhooked again as soon as the * render is over: a `GET` must not leave the site's hook table changed, and * a second preview in the same request must not append the block twice. * * Whether the block appears at all is the module's own decision — * `RobotsPolicy::filter()` returns the input untouched when * `policy.enabled` is off, including when a draft is what switched it off. * * @return Modules\RobotsPolicy|null The instance to attach, or null when one is already attached. */ private static function policy_to_attach(): ?Modules\RobotsPolicy { $booted = Plugin::instance()->module( 'policy' ); if ( $booted instanceof Modules\RobotsPolicy && has_filter( 'robots_txt', [ $booted, 'filter' ] ) ) { return null; } return new Modules\RobotsPolicy(); } /** * Turns the `draft` query argument into a settings overlay. * * @param string $draft JSON object of partial settings. * @return array|WP_Error The overlay, or a 400. */ private static function decode_draft( string $draft ) { if ( strlen( $draft ) > self::DRAFT_MAX_BYTES ) { return new WP_Error( 'acid_aeo_draft_too_large', __( 'The draft settings are too large to preview.', 'acid-aeo' ), [ 'status' => 400 ] ); } $decoded = json_decode( $draft, true ); if ( ! is_array( $decoded ) ) { return new WP_Error( 'acid_aeo_invalid_draft', __( 'The draft settings are not a valid JSON object.', 'acid-aeo' ), [ 'status' => 400 ] ); } return $decoded; } /** * The MIME type a previewed file is served with. * * @param string $file File key. * @return string MIME type. */ private static function content_type( string $file ): string { return isset( self::RENDERERS[ $file ] ) ? self::RENDERERS[ $file ][2] : 'text/plain'; } /** * The public URL of a previewed file. * * `SiteData::file_urls()` rather than `Endpoints\Router::url()` directly: a * file whose module is switched off has no registration and so no router * URL, and a preview of a file that is not published yet still has to say * where it would live once it is. * * @param string $file File key. * @return string Absolute URL, or an empty string for a file with no address. */ private static function url( string $file ): string { if ( 'robots' === $file ) { return (string) home_url( '/robots.txt' ); } $urls = SiteData::file_urls(); return (string) ( $urls[ $file ] ?? Router::url( $file ) ); } /** * The answer for a file this build cannot render. * * @param string $file File key. * @return WP_Error A 404. */ private static function unknown_file( string $file ): WP_Error { return new WP_Error( 'acid_aeo_unknown_file', sprintf( /* translators: %s: file key, e.g. llms_full. */ __( 'There is nothing to preview for "%s".', 'acid-aeo' ), sanitize_key( $file ) ), [ 'status' => 404 ] ); } }