'GET', 'callback' => array( __CLASS__, 'state' ), 'permission_callback' => $auth ) ); register_rest_route( $ns, '/alt', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'alt' ), 'permission_callback' => $auth ) ); register_rest_route( $ns, '/alt/undo', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'alt_undo' ), 'permission_callback' => $auth ) ); register_rest_route( $ns, '/local-schema', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'local_schema' ), 'permission_callback' => $auth ) ); register_rest_route( $ns, '/llms-txt', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'llms_txt' ), 'permission_callback' => $auth ) ); register_rest_route( $ns, '/robots', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'robots' ), 'permission_callback' => $auth ) ); } /** Folder that holds the site's public root files (robots.txt, llms.txt), which may differ from ABSPATH. */ private static function root_dir() { if ( ! function_exists( 'get_home_path' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } return trailingslashit( get_home_path() ); } private static function root_file_exists( $name ) { return file_exists( self::root_dir() . $name ) || file_exists( ABSPATH . $name ); } /** Which SEO plugin already prints LocalBusiness markup: yoast-local, rankmath, or null. */ public static function other_local_schema() { if ( defined( 'WPSEO_LOCAL_VERSION' ) || class_exists( 'WPSEO_Local_Core' ) ) { return 'yoast-local'; } if ( class_exists( '\\RankMath\\Helper' ) && \RankMath\Helper::is_module_active( 'local-seo' ) ) { $titles = (array) get_option( 'rank-math-options-titles', array() ); if ( isset( $titles['knowledgegraph_type'] ) && 'company' === $titles['knowledgegraph_type'] ) { return 'rankmath'; } } return null; } /** What is live now, and what can't be changed (files on the server win over WordPress). */ public static function state() { return array( 'ok' => true, 'robotsFile' => self::root_file_exists( 'robots.txt' ), 'llmsFile' => self::root_file_exists( 'llms.txt' ), 'robotsAllow' => self::robots_allow(), 'llmsTxt' => '' !== (string) get_option( self::LLMS_OPTION, '' ), 'localSchema' => (bool) get_option( self::LOCAL_SCHEMA_OPTION, '' ), 'otherLocalSchema' => self::other_local_schema(), ); } // ---------- Alt text ---------- /** Address without scheme, host, WordPress size suffix or "-scaled", for comparing image URLs. */ private static function image_key( $src ) { $path = (string) wp_parse_url( html_entity_decode( (string) $src ), PHP_URL_PATH ); $path = preg_replace( '/-\d+x\d+(\.[a-z0-9]+)$/i', '$1', $path ); $path = preg_replace( '/-scaled(\.[a-z0-9]+)$/i', '$1', $path ); return strtolower( rawurldecode( $path ) ); } /** The Media Library item behind an image address (any size), or 0. */ private static function attachment_for( $src ) { $src = html_entity_decode( (string) strtok( $src, '?' ) ); $id = attachment_url_to_postid( $src ); if ( ! $id ) { $full = preg_replace( '/-\d+x\d+(\.[a-z0-9]+)$/i', '$1', $src ); $id = attachment_url_to_postid( $full ); if ( ! $id ) { $id = attachment_url_to_postid( preg_replace( '/(\.[a-z0-9]+)$/i', '-scaled$1', $full ) ); } } return (int) $id; } /** * Set alt text on images in a post's content. $changes: key => array( from => string|null (null = any empty alt), to => string|null (null = remove) ). * Only images whose alt still matches "from" are touched. Returns array( content, changed: key => count, before: key => old alt|null ). */ private static function rewrite_alts( $content, $changes, $ids = array() ) { $changed = array(); $before = array(); if ( ! class_exists( 'WP_HTML_Tag_Processor' ) ) { return array( $content, $changed, $before ); } $p = new WP_HTML_Tag_Processor( $content ); while ( $p->next_tag( 'img' ) ) { $key = ''; foreach ( array( 'src', 'data-src', 'data-lazy-src' ) as $attr ) { $v = $p->get_attribute( $attr ); if ( is_string( $v ) && isset( $changes[ self::image_key( $v ) ] ) ) { $key = self::image_key( $v ); break; } } if ( ! $key ) { // Block images carry their Media Library id in the class, so a resized copy still matches. $class = (string) $p->get_attribute( 'class' ); if ( preg_match( '/\bwp-image-(\d+)\b/', $class, $m ) && isset( $ids[ (int) $m[1] ] ) ) { $key = $ids[ (int) $m[1] ]; } } if ( ! $key ) { continue; } $current = $p->get_attribute( 'alt' ); $current = is_string( $current ) ? $current : null; $from = $changes[ $key ]['from']; $matches = null === $from ? ( null === $current || '' === trim( $current ) ) : $current === $from; if ( ! $matches ) { continue; } if ( ! array_key_exists( $key, $before ) ) { $before[ $key ] = $current; } if ( null === $changes[ $key ]['to'] ) { $p->remove_attribute( 'alt' ); } else { $p->set_attribute( 'alt', $changes[ $key ]['to'] ); } $changed[ $key ] = ( $changed[ $key ] ?? 0 ) + 1; } return array( $p->get_updated_html(), $changed, $before ); } private static function save_content( $post_id, $content ) { if ( function_exists( 'wp_save_post_revision' ) ) { wp_save_post_revision( $post_id ); } // Raw content: kses would strip markup the editor allows for this user. global $wpdb; $wpdb->update( $wpdb->posts, array( 'post_content' => $content ), array( 'ID' => $post_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- clean_post_cache() below clears the cache. clean_post_cache( $post_id ); } /** * Add alt text to images that have none: in the Media Library (used wherever the theme prints the image) * and inside the page's own content. Body: { url, images: [{ src, alt }] }. * Returns { changed, missing: src[], previous: { images: [{ src, alt, attachmentId, attachmentAlt, inlineAlt, inline }] } }. */ public static function alt( WP_REST_Request $request ) { $p = $request->get_json_params(); $url = isset( $p['url'] ) ? esc_url_raw( $p['url'] ) : ''; $images = isset( $p['images'] ) && is_array( $p['images'] ) ? array_slice( $p['images'], 0, 60 ) : array(); if ( ! $url || ! $images ) { return new WP_Error( 'gralus_invalid', 'A page address and at least one image are required.', array( 'status' => 400 ) ); } $post_id = Gralus_Rest::post_id_for_url( $url ); $post = $post_id ? get_post( $post_id ) : null; $plan = array(); $ids = array(); foreach ( $images as $im ) { $src = isset( $im['src'] ) ? (string) $im['src'] : ''; $alt = isset( $im['alt'] ) ? sanitize_text_field( (string) $im['alt'] ) : ''; if ( '' === $src || '' === $alt ) { continue; } $key = self::image_key( $src ); $aid = self::attachment_for( $src ); $plan[ $key ] = array( 'src' => $src, 'alt' => $alt, 'attachmentId' => $aid, 'attachmentAlt' => '', 'inlineAlt' => null, 'inline' => 0 ); if ( $aid ) { $ids[ $aid ] = $key; } } if ( $post ) { $changes = array(); foreach ( $plan as $key => $row ) { $changes[ $key ] = array( 'from' => null, 'to' => $row['alt'] ); } list( $content, $changed, $before ) = self::rewrite_alts( $post->post_content, $changes, $ids ); if ( $changed ) { self::save_content( $post->ID, $content ); foreach ( $changed as $key => $n ) { $plan[ $key ]['inline'] = $n; $plan[ $key ]['inlineAlt'] = $before[ $key ]; } } } $done = 0; $missing = array(); foreach ( $plan as $key => $row ) { $set_media = false; if ( $row['attachmentId'] ) { $old = (string) get_post_meta( $row['attachmentId'], '_wp_attachment_image_alt', true ); // Never overwrite alt text someone wrote in the Media Library. if ( '' === trim( $old ) ) { update_post_meta( $row['attachmentId'], '_wp_attachment_image_alt', $row['alt'] ); $plan[ $key ]['attachmentAlt'] = $old; $set_media = true; } else { $plan[ $key ]['attachmentId'] = 0; } } if ( $set_media || $row['inline'] ) { ++$done; } else { $missing[] = $row['src']; } } $previous = array(); foreach ( $plan as $row ) { if ( $row['attachmentId'] || $row['inline'] ) { $previous[] = $row; } } return array( 'ok' => true, 'id' => $post ? (int) $post->ID : 0, 'url' => $post ? get_permalink( $post ) : $url, 'changed' => $done, 'missing' => $missing, 'previous' => array( 'images' => $previous ), ); } /** Put back what /alt replaced, only where the alt text is still the one Gralus wrote. Body: { url, images: previous.images }. */ public static function alt_undo( WP_REST_Request $request ) { $p = $request->get_json_params(); $url = isset( $p['url'] ) ? esc_url_raw( $p['url'] ) : ''; $images = isset( $p['images'] ) && is_array( $p['images'] ) ? array_slice( $p['images'], 0, 60 ) : array(); $undone = 0; $changes = array(); $ids = array(); foreach ( $images as $im ) { $alt = isset( $im['alt'] ) ? sanitize_text_field( (string) $im['alt'] ) : ''; $src = isset( $im['src'] ) ? (string) $im['src'] : ''; if ( '' === $alt || '' === $src ) { continue; } $key = self::image_key( $src ); $aid = isset( $im['attachmentId'] ) ? (int) $im['attachmentId'] : 0; if ( $aid && (string) get_post_meta( $aid, '_wp_attachment_image_alt', true ) === $alt ) { $old = isset( $im['attachmentAlt'] ) ? (string) $im['attachmentAlt'] : ''; if ( '' === $old ) { delete_post_meta( $aid, '_wp_attachment_image_alt' ); } else { update_post_meta( $aid, '_wp_attachment_image_alt', $old ); } ++$undone; } if ( ! empty( $im['inline'] ) ) { $changes[ $key ] = array( 'from' => $alt, 'to' => array_key_exists( 'inlineAlt', $im ) && null !== $im['inlineAlt'] ? (string) $im['inlineAlt'] : null ); if ( $aid ) { $ids[ $aid ] = $key; } } } $post_id = $url ? Gralus_Rest::post_id_for_url( $url ) : 0; $post = $post_id ? get_post( $post_id ) : null; if ( $post && $changes ) { list( $content, $changed ) = self::rewrite_alts( $post->post_content, $changes, $ids ); if ( $changed ) { self::save_content( $post->ID, $content ); $undone += array_sum( $changed ); } } return array( 'ok' => true, 'undone' => $undone ); } // ---------- LocalBusiness markup ---------- /** Body: { schema: object|null }. null removes it. Returns the previous markup. */ public static function local_schema( WP_REST_Request $request ) { $p = $request->get_json_params(); $before = self::get_local_schema(); $schema = isset( $p['schema'] ) && is_array( $p['schema'] ) ? $p['schema'] : null; if ( $schema ) { $json = wp_json_encode( $schema ); if ( ! $json || strlen( $json ) > 20000 ) { return new WP_Error( 'gralus_invalid', 'The markup is not valid JSON or is too long.', array( 'status' => 400 ) ); } update_option( self::LOCAL_SCHEMA_OPTION, $json ); } else { delete_option( self::LOCAL_SCHEMA_OPTION ); } return array( 'ok' => true, 'url' => home_url( '/' ), 'otherLocalSchema' => self::other_local_schema(), 'previous' => array( 'schema' => $before ), ); } public static function get_local_schema() { $raw = get_option( self::LOCAL_SCHEMA_OPTION, '' ); $v = is_string( $raw ) && '' !== $raw ? json_decode( $raw, true ) : $raw; return is_array( $v ) ? $v : null; } /** Printed on every page, unless an SEO plugin already prints LocalBusiness markup. */ public static function print_local_schema() { $schema = self::get_local_schema(); if ( ! $schema || null !== self::other_local_schema() ) { return; } wp_print_inline_script_tag( wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ), array( 'type' => 'application/ld+json' ) ); } // ---------- llms.txt ---------- /** Body: { text: string|null }. null or '' removes it. Returns the previous text. */ public static function llms_txt( WP_REST_Request $request ) { if ( self::root_file_exists( 'llms.txt' ) ) { return new WP_Error( 'gralus_llms_file', 'There is an llms.txt file on the server, and it is served instead of the one from WordPress. Delete or edit that file.', array( 'status' => 409 ) ); } $p = $request->get_json_params(); $before = (string) get_option( self::LLMS_OPTION, '' ); $text = isset( $p['text'] ) ? wp_strip_all_tags( str_replace( "\r\n", "\n", wp_check_invalid_utf8( (string) $p['text'] ) ) ) : ''; if ( strlen( $text ) > 100000 ) { return new WP_Error( 'gralus_invalid', 'llms.txt is too long.', array( 'status' => 400 ) ); } if ( '' === trim( $text ) ) { delete_option( self::LLMS_OPTION ); } else { update_option( self::LLMS_OPTION, $text, false ); } return array( 'ok' => true, 'url' => home_url( '/llms.txt' ), 'previous' => array( 'text' => '' === $before ? null : $before ), ); } /** Answer /llms.txt (at the site root, or under the subfolder WordPress lives in) before WordPress looks for a page. */ public static function serve_llms_txt() { $text = (string) get_option( self::LLMS_OPTION, '' ); if ( '' === $text || empty( $_SERVER['REQUEST_URI'] ) ) { return; } $path = (string) wp_parse_url( esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH ); $want = untrailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ) . '/llms.txt'; if ( $path !== $want ) { return; } status_header( 200 ); header( 'Content-Type: text/plain; charset=utf-8' ); header( 'X-Robots-Tag: noindex' ); header( 'X-Content-Type-Options: nosniff' ); // Plain text, stripped of tags when saved; HTML escaping would change the file's characters. echo $text; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped exit; } // ---------- robots.txt ---------- public static function robots_allow() { $v = get_option( self::ROBOTS_OPTION, array() ); return is_array( $v ) ? array_values( array_filter( array_map( 'strval', $v ) ) ) : array(); } /** Body: { allow: string[] } (robots.txt user-agent names). [] removes the rules. Returns the previous list. */ public static function robots( WP_REST_Request $request ) { if ( self::root_file_exists( 'robots.txt' ) ) { return new WP_Error( 'gralus_robots_file', 'This site has a robots.txt file on the server, so WordPress does not write robots.txt and the plugin cannot change it. Edit that file by hand (or ask your host) and allow the AI crawlers there.', array( 'status' => 409 ) ); } $p = $request->get_json_params(); $before = self::robots_allow(); $allow = array(); foreach ( ( isset( $p['allow'] ) && is_array( $p['allow'] ) ? $p['allow'] : array() ) as $bot ) { $bot = preg_replace( '/[^A-Za-z0-9._-]/', '', (string) $bot ); if ( '' !== $bot && ! in_array( $bot, $allow, true ) ) { $allow[] = $bot; } } $allow = array_slice( $allow, 0, 30 ); if ( $allow ) { update_option( self::ROBOTS_OPTION, $allow ); } else { delete_option( self::ROBOTS_OPTION ); } return array( 'ok' => true, 'url' => home_url( '/robots.txt' ), 'previous' => array( 'bots' => $before ), ); } /** * Rewrite WordPress's robots.txt so the listed crawlers may read the site: their own blocking groups are * removed (or they are taken out of shared groups), then one group allows them everything. */ public static function filter_robots( $output, $public = '1' ) { $bots = self::robots_allow(); if ( ! $bots ) { return $output; } return self::rewrite_robots( (string) $output, $bots ); } public static function rewrite_robots( $output, $bots ) { $lower = array_map( 'strtolower', $bots ); $lines = preg_split( '/\r\n|\n|\r/', $output ); $out = array(); $agents = array(); $skip = false; $flush = function () use ( &$agents, &$out, &$skip, $lower ) { if ( ! $agents ) { return; } $keep = array(); foreach ( $agents as $line ) { $name = strtolower( trim( substr( $line, strpos( $line, ':' ) + 1 ) ) ); if ( ! in_array( $name, $lower, true ) ) { $keep[] = $line; } } $skip = ! $keep; foreach ( $keep as $line ) { $out[] = $line; } $agents = array(); }; foreach ( $lines as $line ) { $bare = trim( preg_replace( '/#.*/', '', $line ) ); if ( preg_match( '/^user-agent\s*:/i', $bare ) ) { $agents[] = $bare; continue; } if ( $agents ) { $flush(); } if ( preg_match( '/^sitemap\s*:/i', $bare ) ) { $out[] = $line; continue; } if ( '' === $bare ) { $out[] = $line; continue; } if ( ! $skip ) { $out[] = $line; } } $flush(); $out = rtrim( implode( "\n", $out ) ); $add = "\n\n# Added by Gralus: let AI assistants read the site\n"; foreach ( $bots as $bot ) { $add .= 'User-agent: ' . $bot . "\n"; } $add .= "Allow: /\n"; return ltrim( $out . $add ); } }