add_filter( 'robots_txt', $this, 'filter_robots_txt', -1, 1 ); } /** * The base content WordPress core feeds into the `robots_txt` filter * before anything (this plugin or others) touches it — same two lines * do_robots() builds (wp-includes/functions.php). Reimplemented rather * than invoking do_robots() itself, since that function unconditionally * sends a Content-Type header and echoes — wrong for building a preview * value inside an admin/REST response. * * @return string */ public static function core_base_output(): string { $output = "User-agent: *\n"; $output .= 'Disallow: ' . wp_parse_url( admin_url(), PHP_URL_PATH ) . "\n"; $output .= 'Allow: ' . wp_parse_url( admin_url( 'admin-ajax.php' ), PHP_URL_PATH ) . "\n"; return $output; } /** * Whether a custom value has been saved. * * @return bool */ public function has_custom_content(): bool { return '' !== trim( (string) get_option( self::OPTION_NAME, '' ) ); } /** * The editor's field content: the saved custom value, or — if nothing * has ever been saved — WordPress's own current default, so the field * never starts blank. * * @return string */ public function get_content(): string { $stored = (string) get_option( self::OPTION_NAME, '' ); return '' !== trim( $stored ) ? $stored : self::core_base_output(); } /** * Sanitizes raw textarea input for storage: strips tags, normalizes * line endings to \n, caps length. Deliberately not * sanitize_textarea_field() — it collapses newlines, which would * destroy the one thing that makes this content meaningful. * * @param string $raw Raw submitted content. * @return string */ public static function sanitize( string $raw ): string { $stripped = wp_strip_all_tags( $raw ); $normalized = preg_replace( '/\r\n|\r/', "\n", $stripped ); $normalized = null !== $normalized ? $normalized : ''; return substr( trim( $normalized ), 0, self::MAX_LENGTH ); } /** * Sanitizes and stores custom content. * * @param string $raw Raw submitted content. * @return string The sanitized value that was stored. */ public function save( string $raw ): string { $sanitized = self::sanitize( $raw ); update_option( self::OPTION_NAME, $sanitized, false ); return $sanitized; } /** * Clears the custom option, reverting output to WordPress's own virtual * default (plus this plugin's Sitemap: line, unaffected either way). */ public function restore_default(): void { delete_option( self::OPTION_NAME ); } /** * The path a physical robots.txt file would occupy. * * @return string */ public static function physical_file_path(): string { return ABSPATH . 'robots.txt'; } /** * Whether a real robots.txt file sits in the site root. When it does, * WordPress serves that file directly and never fires the `robots_txt` * filter at all — this editor cannot work while it's there, and must * say so honestly instead of pretending saving would take effect. * * @return bool */ public static function has_physical_file(): bool { return file_exists( self::physical_file_path() ); } /** * The `robots_txt` filter callback: replaces the output with the saved * custom content when one is set, otherwise passes the incoming value * through untouched (WordPress's own default, or whatever an earlier * filter already produced). No HTML escaping on the way out — this is * a plain text file, not markup — but the stored value already passed * through save()'s sanitizer, so this never inserts raw, untyped * request input. * * @param string $output Incoming robots.txt content. * @return string */ public function filter_robots_txt( $output ) { if ( ! $this->has_custom_content() ) { return $output; } return (string) get_option( self::OPTION_NAME, '' ); } /** * Non-blocking hints about likely mistakes in a robots.txt draft: an * unrecognized directive, a `User-agent` block with no rules under it, * or a blank line separating a `User-agent` line from its rules (some * crawlers treat a blank line as ending the block). Pure line parsing, * no WordPress calls — the same split as * ABDS_SEO_Robots_Txt_Checker::critical_disallow_all(), which this * class deliberately does NOT duplicate — the save-time "Disallow: /" * warning reuses that existing checker instead. * * @param string $content Draft robots.txt content. * @return string[] Hint messages, most specific to least. */ public static function find_hints( string $content ): array { $hints = array(); $lines = preg_split( '/\r\n|\r|\n/', $content ); if ( false === $lines ) { return $hints; } $current_agent = null; $agent_has_rule = true; $blank_after_agent = false; foreach ( $lines as $line ) { $trimmed = trim( $line ); if ( '' === $trimmed ) { if ( null !== $current_agent && ! $agent_has_rule ) { $blank_after_agent = true; } continue; } if ( 0 === strpos( $trimmed, '#' ) ) { continue; } if ( ! preg_match( '/^([a-zA-Z-]+)\s*:\s*(.*)$/', $trimmed, $matches ) ) { /* translators: %s: the offending line. */ $hints[] = sprintf( __( 'This line doesn\'t look like a directive: "%s".', 'abds-seo' ), $trimmed ); continue; } $directive = strtolower( $matches[1] ); if ( 'user-agent' === $directive ) { if ( null !== $current_agent && ! $agent_has_rule ) { /* translators: %s: the user-agent value. */ $hints[] = sprintf( __( '"User-agent: %s" has no rules under it.', 'abds-seo' ), $current_agent ); } $current_agent = trim( $matches[2] ); $agent_has_rule = false; $blank_after_agent = false; continue; } if ( ! in_array( $directive, self::KNOWN_DIRECTIVES, true ) ) { /* translators: %s: the unrecognized directive name. */ $hints[] = sprintf( __( 'Unknown directive: "%s".', 'abds-seo' ), $matches[1] ); } if ( null !== $current_agent ) { if ( $blank_after_agent && ! $agent_has_rule ) { /* translators: %s: the user-agent value. */ $hints[] = sprintf( __( 'Blank line between "User-agent: %s" and its rules.', 'abds-seo' ), $current_agent ); } $agent_has_rule = true; } } if ( null !== $current_agent && ! $agent_has_rule ) { /* translators: %s: the user-agent value. */ $hints[] = sprintf( __( '"User-agent: %s" has no rules under it.', 'abds-seo' ), $current_agent ); } return array_values( array_unique( $hints ) ); } }