*/ public static function all() { return array( 'security-headers' => array( 'title' => __( 'Add security headers', 'getreport' ), 'why' => __( 'Tells browsers to use HTTPS only, not to guess file types, to send less of your address to other sites, and to keep camera, microphone and location off.', 'getreport' ), 'module' => 'security', ), 'xmlrpc-off' => array( 'title' => __( 'Turn off XML-RPC', 'getreport' ), 'why' => __( 'An old remote-publishing door that bots use to guess passwords. The block editor and the WordPress apps do not need it. Not offered while Jetpack, which uses it, is active.', 'getreport' ), 'module' => 'security', 'available' => array( __CLASS__, 'no_jetpack' ), ), 'rest-users-hidden' => array( 'title' => __( 'Hide the user list from visitors', 'getreport' ), 'why' => __( 'Anyone can read your usernames at /wp-json/wp/v2/users, which is half of a login. Logged-in users still see it.', 'getreport' ), 'module' => 'security', ), 'attachment-noindex' => array( 'title' => __( 'Keep attachment pages out of Google', 'getreport' ), 'why' => __( 'Each uploaded image gets its own thin page. Asking search engines not to index them keeps your real pages in front.', 'getreport' ), 'module' => 'seo', ), 'search-visible' => array( 'title' => __( 'Let search engines see this site', 'getreport' ), 'why' => __( '"Discourage search engines from indexing this site" is ticked in Settings → Reading, so Google is asked not to list you.', 'getreport' ), 'module' => 'seo', 'available' => array( __CLASS__, 'search_hidden' ), ), 'sample-content' => array( 'title' => __( 'Remove "Hello world!" and "Sample Page"', 'getreport' ), 'why' => __( 'The demo post and page WordPress installs are public and look unfinished. They move to the Trash, so Undo brings them back.', 'getreport' ), 'module' => 'practices', 'available' => array( __CLASS__, 'sample_content_ids' ), 'confirm' => __( 'Yes, move them to the Trash', 'getreport' ), ), 'organization-schema' => array( 'title' => __( 'Add Organization and WebSite schema', 'getreport' ), 'why' => __( 'Structured data on your home page tells Google and AI assistants your name, logo and, from Settings → Organisation facts, your founding year, address, phone and official profiles. Only offered when no SEO plugin already adds it.', 'getreport' ), 'module' => 'schema', 'checks' => array( 'schema-missing', 'missing-sameas' ), 'available' => array( __CLASS__, 'no_seo_plugin' ), ), 'woo-cart-fragments' => array( 'title' => __( 'Stop WooCommerce cart fragments outside the cart', 'getreport' ), 'why' => __( 'A request on every page refreshes the cart count, even for visitors who never shop. It stays on the cart and checkout.', 'getreport' ), 'module' => 'speed', 'available' => array( __CLASS__, 'has_woocommerce' ), ), // ---- AI visibility (docs/05 §3.5) ---- 'robots-ai-rules' => array( 'title' => __( 'Choose which AI bots may crawl (robots.txt)', 'getreport' ), 'why' => __( 'Lets you choose, per AI bot, whether your robots.txt lets it in. Keep the bots that fetch pages to answer questions (ChatGPT search, Perplexity, Claude search) allowed; blocking the bots that collect training data is your call. Choose per bot on the AI crawlers page.', 'getreport' ), 'module' => 'ai', 'checks' => array( 'ai-bot-robots-blocked', 'ai-training-bots-policy' ), 'evidence' => 'strong', 'available' => array( __CLASS__, 'no_robots_file' ), ), 'show-modified-date' => array( 'title' => __( 'Show "Updated" dates on posts', 'getreport' ), 'why' => __( 'Adds "Updated {date}" under the title of your posts (the post types in Settings), and the date in the page\'s markup when no SEO plugin does. AI assistants prefer content they can see is current.', 'getreport' ), 'module' => 'ai', 'checks' => array( 'no-visible-date' ), 'evidence' => 'strong', ), 'author-box' => array( 'title' => __( 'Add an author box to posts', 'getreport' ), 'why' => __( 'A short box after each post with the author\'s name, bio (from their WordPress profile) and a link to their posts, plus Person markup when no SEO plugin adds it. It tells readers and AI who stands behind the claims.', 'getreport' ), 'module' => 'ai', 'checks' => array( 'no-author' ), 'evidence' => 'mixed', ), 'indexnow' => array( 'title' => __( 'Tell Bing about new and updated posts (IndexNow)', 'getreport' ), 'why' => __( 'Each time you publish or update, the address is sent to IndexNow, which Bing (behind ChatGPT search and Copilot), Yandex and others share. This sends the page address to api.indexnow.org, nothing else.', 'getreport' ), 'module' => 'ai', 'checks' => array( 'bing-indexable' ), 'evidence' => 'mixed', ), 'llms-txt' => array( 'title' => __( 'Publish an llms.txt', 'getreport' ), 'why' => __( 'A short list of your main pages at /llms.txt. Weak evidence: no major AI assistant is known to read it; coding agents sometimes do. Do the other fixes first.', 'getreport' ), 'module' => 'ai', 'checks' => array( 'llms-txt-missing' ), 'evidence' => 'weak', 'available' => array( __CLASS__, 'no_llms_file' ), ), ); } /** State of every fix: id → { on, previous, at, retest, before }. */ public static function state() { $s = get_option( self::OPTION, array() ); return is_array( $s ) ? $s : array(); } /** * Whether a fix is on. * * @param string $id Fix id. */ public static function is_on( $id ) { $s = self::state(); return ! empty( $s[ $id ]['on'] ); } /** * Whether a fix can be offered on this site (it is on, or its condition holds). * * @param string $id Fix id. */ public static function available( $id ) { $all = self::all(); if ( ! isset( $all[ $id ] ) ) { return false; } if ( self::is_on( $id ) || empty( $all[ $id ]['available'] ) ) { return true; } return (bool) call_user_func( $all[ $id ]['available'] ); } /** * A fix's own settings (robots rules per bot), with defaults. * * @param string $id Fix id. * @return array */ public static function config( $id ) { $s = self::state(); $c = $s[ $id ]['config'] ?? array(); if ( 'robots-ai-rules' === $id ) { return array_merge( self::default_robots_rules(), is_array( $c ) ? $c : array() ); } if ( 'indexnow' === $id && empty( $c['key'] ) ) { $c['key'] = ''; } return is_array( $c ) ? $c : array(); } /** * Saves a fix's settings. * * @param string $id Fix id. * @param array $config Settings. */ public static function set_config( $id, $config ) { self::save( $id, array( 'config' => $config ) ); } /** * Default robots rules: allow the search and user-fetch bots, allow the training bots too * (blocking them is the owner's choice, made on the AI crawlers page). * * @return array token → allow | block. */ public static function default_robots_rules() { $out = array(); foreach ( GetReport_Registry::bots() as $b ) { if ( 'ai' === $b['family'] ) { $out[ $b['token'] ] = 'allow'; } } return $out; } /** * Applies a fix and remembers what it changed. * * @param string $id Fix id. * @return true|WP_Error */ public static function apply( $id ) { if ( ! self::available( $id ) ) { return new WP_Error( 'getreport_fix', __( 'This fix does not apply to this site.', 'getreport' ) ); } if ( self::is_on( $id ) ) { return true; } $previous = null; if ( 'indexnow' === $id && ! self::config( 'indexnow' )['key'] ) { self::set_config( 'indexnow', array( 'key' => strtolower( wp_generate_password( 32, false, false ) ) ) ); } if ( 'search-visible' === $id ) { $previous = get_option( 'blog_public' ); update_option( 'blog_public', '1' ); } elseif ( 'sample-content' === $id ) { $previous = array(); foreach ( self::sample_content_ids() as $post_id ) { $previous[ $post_id ] = get_post_status( $post_id ); wp_trash_post( $post_id ); } } self::save( $id, array( 'on' => true, 'previous' => $previous, 'at' => time(), ) ); self::log_apply( $id, $previous ); return true; } /** * Logs an applied fix in the gr_fixes table (the Fixes tab's history). * * @param string $id Fix id. * @param mixed $before What the fix replaced. */ private static function log_apply( $id, $before ) { global $wpdb; if ( ! isset( $wpdb ) || ! class_exists( 'GetReport_Db' ) ) { return; } $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery GetReport_Db::table( 'fixes' ), array( 'fix_key' => $id, 'target' => '', 'before_value' => wp_json_encode( $before ), 'after_value' => wp_json_encode( self::config( $id ) ), 'applied_by' => get_current_user_id(), 'applied_at' => current_time( 'mysql', true ), ) ); } /** * Marks a fix's latest log row as reverted. * * @param string $id Fix id. */ private static function log_undo( $id ) { global $wpdb; if ( ! isset( $wpdb ) || ! class_exists( 'GetReport_Db' ) ) { return; } $t = GetReport_Db::table( 'fixes' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the plugin's own table $row = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(id) FROM %i WHERE fix_key = %s AND reverted_at IS NULL', $t, $id ) ); if ( $row ) { $wpdb->update( $t, array( 'reverted_at' => current_time( 'mysql', true ) ), array( 'id' => $row ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } } /** * The history of applied fixes, newest first. * * @param int $limit Rows. * @return array */ public static function history( $limit = 50 ) { global $wpdb; $t = GetReport_Db::table( 'fixes' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the plugin's own table return (array) $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY id DESC LIMIT %d', $t, $limit ), ARRAY_A ); } /** * Puts back what a fix changed. * * @param string $id Fix id. * @return true|WP_Error */ public static function undo( $id ) { if ( ! self::is_on( $id ) ) { return new WP_Error( 'getreport_fix', __( 'This fix is not on.', 'getreport' ) ); } $entry = self::state()[ $id ]; if ( 'search-visible' === $id ) { update_option( 'blog_public', (string) $entry['previous'] ); } elseif ( 'sample-content' === $id && is_array( $entry['previous'] ) ) { foreach ( $entry['previous'] as $post_id => $status ) { wp_untrash_post( (int) $post_id ); // Since WordPress 5.6 an untrashed post comes back as a draft, so restore its status. wp_update_post( array( 'ID' => (int) $post_id, 'post_status' => $status, ) ); } } self::save( $id, array( 'on' => false, 'previous' => null, 'at' => time(), ) ); self::log_undo( $id ); return true; } /** * Remembers a re-test started after a change, with the module score before it. * * @param string $id Fix id. * @param string $report_id Report id. * @param int|null $before Score before. */ public static function remember_retest( $id, $report_id, $before ) { self::save( $id, array( 'retest' => $report_id, 'before' => $before, ) ); } /** * Merges fields into a fix's state. * * @param string $id Fix id. * @param array $fields Fields. */ private static function save( $id, $fields ) { $s = self::state(); $s[ $id ] = array_merge( $s[ $id ] ?? array(), $fields ); update_option( self::OPTION, $s, false ); } // ---- Conditions ---- /** "Discourage search engines" is ticked. */ public static function search_hidden() { return '0' === (string) get_option( 'blog_public', '1' ); } /** * The published default "Hello world!" post and "Sample Page", when still there. * * @return int[] */ public static function sample_content_ids() { $ids = array(); foreach ( array( array( 'hello-world', 'post' ), array( 'sample-page', 'page' ) ) as $pair ) { $post = get_page_by_path( $pair[0], OBJECT, $pair[1] ); if ( $post && 'publish' === $post->post_status ) { $ids[] = (int) $post->ID; } } return $ids; } /** No SEO plugin that prints its own schema is active. */ public static function no_seo_plugin() { foreach ( self::SEO_PLUGINS as $const ) { if ( defined( $const ) ) { return false; } } return true; } /** Jetpack, which talks to WordPress.com over XML-RPC, is not active. */ public static function no_jetpack() { return ! defined( 'JETPACK__VERSION' ); } /** WooCommerce is active. */ public static function has_woocommerce() { return class_exists( 'WooCommerce' ); } /** Whether robots.txt is WordPress's virtual one (a real file on disk would ignore our rules). */ public static function no_robots_file() { return ! file_exists( ABSPATH . 'robots.txt' ); } /** There is no real llms.txt file and no SEO plugin publishes one. */ public static function no_llms_file() { $yoast = get_option( 'wpseo' ); return ! file_exists( ABSPATH . 'llms.txt' ) && ! ( is_array( $yoast ) && ! empty( $yoast['enable_llms_txt'] ) ); } // ---- Runtime: the filters of the fixes that are on ---- /** Hooks what the fixes that are on need, on every request. */ public static function register_runtime() { if ( self::is_on( 'security-headers' ) ) { add_action( 'send_headers', array( __CLASS__, 'send_security_headers' ) ); } if ( self::is_on( 'xmlrpc-off' ) ) { add_filter( 'xmlrpc_enabled', '__return_false' ); add_filter( 'xmlrpc_methods', '__return_empty_array' ); add_filter( 'wp_headers', array( __CLASS__, 'drop_pingback_header' ) ); // The filters above leave xmlrpc.php answering (and pingbacks listed); refuse the request. if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) { add_action( 'init', array( __CLASS__, 'refuse_xmlrpc' ), 0 ); } } if ( self::is_on( 'rest-users-hidden' ) ) { add_filter( 'rest_endpoints', array( __CLASS__, 'hide_user_endpoints' ) ); } if ( self::is_on( 'attachment-noindex' ) ) { add_filter( 'wp_robots', array( __CLASS__, 'noindex_attachments' ) ); } if ( self::is_on( 'organization-schema' ) && self::no_seo_plugin() ) { add_action( 'wp_head', array( __CLASS__, 'print_schema' ), 20 ); } if ( self::is_on( 'woo-cart-fragments' ) ) { add_action( 'wp_enqueue_scripts', array( __CLASS__, 'dequeue_cart_fragments' ), 99 ); } if ( self::is_on( 'robots-ai-rules' ) ) { add_filter( 'robots_txt', array( __CLASS__, 'robots_rules' ), 20, 2 ); } if ( self::is_on( 'show-modified-date' ) ) { add_filter( 'the_content', array( __CLASS__, 'prepend_updated' ), 5 ); } if ( self::is_on( 'author-box' ) ) { add_filter( 'the_content', array( __CLASS__, 'append_author_box' ), 20 ); } if ( ( self::is_on( 'show-modified-date' ) || self::is_on( 'author-box' ) ) && self::no_seo_plugin() ) { add_action( 'wp_head', array( __CLASS__, 'print_article_schema' ), 21 ); } if ( self::is_on( 'indexnow' ) ) { add_action( 'transition_post_status', array( __CLASS__, 'indexnow_on_publish' ), 10, 3 ); add_action( 'init', array( __CLASS__, 'serve_indexnow_key' ), 1 ); } if ( self::is_on( 'llms-txt' ) ) { add_action( 'init', array( __CLASS__, 'serve_llms_txt' ), 1 ); } } // ---- AI fixes ---- /** * Adds a "Disallow: /" group to the virtual robots.txt for each AI bot the owner blocks. * Allowed bots get no group of their own: they follow the "*" group like every other crawler * (a named group would replace it, and let them into /wp-admin/). A site that asks search * engines not to index it keeps its full block. * * @param string $output robots.txt so far. * @param string $is_public blog_public. */ public static function robots_rules( $output, $is_public ) { if ( '0' === (string) $is_public ) { return $output; } $lines = array(); foreach ( self::config( 'robots-ai-rules' ) as $token => $rule ) { if ( 'block' === $rule && preg_match( '/^[A-Za-z0-9._-]+$/', (string) $token ) ) { $lines[] = 'User-agent: ' . $token; $lines[] = 'Disallow: /'; $lines[] = ''; } } if ( ! $lines ) { return $output; } array_unshift( $lines, '', '# AI crawlers you blocked (getReport → AI crawlers)' ); return rtrim( $output ) . "\n" . implode( "\n", $lines ); } /** * Whether the_content is being printed for the page's own post, on a single post of one of the * chosen types, in the page body. Block themes render the body before wp_head, classic themes * after it; SEO plugins that call the_content inside wp_head (for descriptions) are skipped. * * @param string $setting date_types | author_types. */ private static function on_chosen_single( $setting ) { return ! is_admin() && is_singular( (array) GetReport_Plugin::setting( $setting ) ) && ! doing_action( 'wp_head' ) && (int) get_the_ID() === (int) get_queried_object_id() && ! doing_filter( 'get_the_excerpt' ); } /** * "Updated {date}" above the post content. * * @param string $content Content. */ public static function prepend_updated( $content ) { if ( ! self::on_chosen_single( 'date_types' ) ) { return $content; } $post = get_post(); $iso = get_the_modified_date( 'c', $post ); /* translators: %s: date. */ $label = sprintf( __( 'Updated %s', 'getreport' ), get_the_modified_date( '', $post ) ); return '

' . $content; } /** * The author box after the post content. * * @param string $content Content. */ public static function append_author_box( $content ) { if ( ! self::on_chosen_single( 'author_types' ) ) { return $content; } $post = get_post(); $author = (int) $post->post_author; $name = get_the_author_meta( 'display_name', $author ); if ( '' === (string) $name ) { return $content; } $bio = get_the_author_meta( 'description', $author ); $url = get_author_posts_url( $author ); $avatar = get_avatar( $author, 64, '', '', array( 'class' => 'getreport-author-avatar' ) ); $box = ''; return $content . $box; } /** Article markup with dateModified and the author, when no SEO plugin prints it (wp_head). */ public static function print_article_schema() { if ( ! is_singular( array_merge( (array) GetReport_Plugin::setting( 'date_types' ), (array) GetReport_Plugin::setting( 'author_types' ) ) ) ) { return; } $post = get_post(); $data = array( '@context' => 'https://schema.org', '@type' => 'Article', 'headline' => wp_strip_all_tags( get_the_title( $post ) ), 'mainEntityOfPage' => get_permalink( $post ), 'datePublished' => get_the_date( 'c', $post ), 'dateModified' => get_the_modified_date( 'c', $post ), ); if ( self::is_on( 'author-box' ) && '' !== (string) get_the_author_meta( 'display_name', (int) $post->post_author ) ) { $data['author'] = array( '@type' => 'Person', 'name' => get_the_author_meta( 'display_name', (int) $post->post_author ), 'url' => get_author_posts_url( (int) $post->post_author ), ); } // wp_json_encode() escapes "/", so the JSON cannot close the script tag. wp_print_inline_script_tag( wp_json_encode( $data ), array( 'type' => 'application/ld+json' ) ); } /** * Pings IndexNow when a post is published or a published post is updated. * * @param string $new_status New status. * @param string $old_status Old status. * @param WP_Post $post Post. */ public static function indexnow_on_publish( $new_status, $old_status, $post ) { if ( 'publish' !== $new_status || wp_is_post_revision( $post ) || ! is_post_type_viewable( $post->post_type ) ) { return; } // One ping per post per 10 minutes: the block editor saves more than once. $lock = 'getreport_indexnow_' . $post->ID; if ( get_transient( $lock ) ) { return; } set_transient( $lock, 1, 10 * MINUTE_IN_SECONDS ); $key = self::config( 'indexnow' )['key']; wp_remote_post( 'https://api.indexnow.org/indexnow', array( 'timeout' => 5, 'blocking' => false, 'headers' => array( 'content-type' => 'application/json; charset=utf-8' ), 'body' => wp_json_encode( array( 'host' => wp_parse_url( home_url( '/' ), PHP_URL_HOST ), 'key' => $key, 'keyLocation' => home_url( '/' . $key . '.txt' ), 'urlList' => array( get_permalink( $post ) ), ) ), ) ); } /** Answers /{key}.txt with the IndexNow key (no rewrite rules needed). */ public static function serve_indexnow_key() { $key = self::config( 'indexnow' )['key']; if ( $key && self::request_path() === '/' . $key . '.txt' ) { self::plain_text( $key ); } } /** Answers /llms.txt. */ public static function serve_llms_txt() { if ( '/llms.txt' === self::request_path() && self::no_llms_file() ) { self::plain_text( self::llms_txt(), 'text/markdown' ); } } /** The request path relative to the site's home (so a site in a subfolder works). */ private static function request_path() { $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) strtok( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), '?' ) : ''; $base = rtrim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' ); return ( '' !== $base && 0 === strpos( $uri, $base ) ) ? substr( $uri, strlen( $base ) ) : $uri; } /** * Sends a plain-text answer and stops. * * @param string $body Body. * @param string $type Content type. */ private static function plain_text( $body, $type = 'text/plain' ) { status_header( 200 ); header( 'Content-Type: ' . $type . '; charset=utf-8' ); header( 'X-Robots-Tag: noindex' ); // Plain text: no tags at all. Entities such as & read the same in Markdown. echo wp_kses( $body, array() ); exit; } /** The llms.txt body: the site, its main pages and recent posts, as Markdown links. */ public static function llms_txt() { $out = array( '# ' . wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), '' ); $desc = wp_specialchars_decode( get_bloginfo( 'description' ), ENT_QUOTES ); $out[] = $desc ? '> ' . $desc : ''; $out[] = ''; $pages = get_posts( array( 'post_type' => 'page', 'post_status' => 'publish', 'posts_per_page' => 20, 'orderby' => 'menu_order title', 'order' => 'ASC', ) ); if ( $pages ) { $out[] = '## Pages'; foreach ( $pages as $p ) { $out[] = '- [' . wp_strip_all_tags( get_the_title( $p ) ) . '](' . get_permalink( $p ) . ')'; } $out[] = ''; } $posts = get_posts( array( 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 30, ) ); if ( $posts ) { $out[] = '## Recent posts'; foreach ( $posts as $p ) { $ex = wp_strip_all_tags( get_the_excerpt( $p ) ); $out[] = '- [' . wp_strip_all_tags( get_the_title( $p ) ) . '](' . get_permalink( $p ) . ')' . ( $ex ? ': ' . wp_html_excerpt( $ex, 140, '…' ) : '' ); } } return implode( "\n", $out ) . "\n"; } /** * The headers a fix adds, leaving any the server or another plugin already sends. * * @param string[] $already Header lines already set. * @param bool $https The site is served over HTTPS. * @return array */ public static function security_headers( $already, $https ) { $want = array( 'X-Content-Type-Options' => 'nosniff', 'Referrer-Policy' => 'strict-origin-when-cross-origin', 'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()', ); // HSTS only on HTTPS, and without includeSubDomains or preload: those can lock out other hosts. if ( $https ) { $want['Strict-Transport-Security'] = 'max-age=31536000'; } $have = array(); foreach ( $already as $line ) { $have[ strtolower( trim( strtok( $line, ':' ) ) ) ] = true; } return array_filter( $want, function ( $name ) use ( $have ) { return empty( $have[ strtolower( $name ) ] ); }, ARRAY_FILTER_USE_KEY ); } /** Sends the security headers (send_headers). */ public static function send_security_headers() { if ( headers_sent() ) { return; } $https = 'https' === wp_parse_url( home_url( '/' ), PHP_URL_SCHEME ); foreach ( self::security_headers( headers_list(), $https ) as $name => $value ) { header( $name . ': ' . $value ); } } /** Answers an XML-RPC request with 403 and stops. */ public static function refuse_xmlrpc() { status_header( 403 ); header( 'Content-Type: text/plain; charset=utf-8' ); echo esc_html__( 'XML-RPC is turned off on this site.', 'getreport' ); exit; } /** * Drops X-Pingback (it advertises XML-RPC). * * @param array $headers Headers. */ public static function drop_pingback_header( $headers ) { unset( $headers['X-Pingback'] ); return $headers; } /** * Removes the users endpoints for logged-out visitors. * * @param array $endpoints REST endpoints. */ public static function hide_user_endpoints( $endpoints ) { if ( is_user_logged_in() ) { return $endpoints; } foreach ( array_keys( $endpoints ) as $route ) { if ( 0 === strpos( $route, '/wp/v2/users' ) ) { unset( $endpoints[ $route ] ); } } return $endpoints; } /** * Adds noindex to attachment pages (wp_robots, WordPress 5.7+). * * @param array $robots Robots directives. */ public static function noindex_attachments( $robots ) { if ( is_attachment() ) { $robots['noindex'] = true; $robots['follow'] = true; } return $robots; } /** * The JSON-LD for the home page. * * @param string $name Site name. * @param string $url Home URL. * @param string|null $logo Logo URL. * @param array $facts Organisation facts from Settings (name, founded, address, phone, email, same_as). */ public static function schema_json( $name, $url, $logo, $facts = array() ) { $org_name = trim( (string) ( $facts['name'] ?? '' ) ); $org = array( '@type' => 'Organization', '@id' => $url . '#organization', 'name' => '' !== $org_name ? $org_name : $name, 'url' => $url, ); if ( $logo ) { $org['logo'] = $logo; } if ( preg_match( '/^\d{4}$/', trim( (string) ( $facts['founded'] ?? '' ) ) ) ) { $org['foundingDate'] = trim( $facts['founded'] ); } foreach ( array( 'address' => 'address', 'phone' => 'telephone', 'email' => 'email', ) as $k => $prop ) { if ( '' !== trim( (string) ( $facts[ $k ] ?? '' ) ) ) { $org[ $prop ] = trim( $facts[ $k ] ); } } $same = array_values( array_filter( preg_split( '/\s+/', trim( (string) ( $facts['same_as'] ?? '' ) ) ), function ( $u ) { return (bool) preg_match( '#^https?://#i', $u ) && false !== filter_var( $u, FILTER_VALIDATE_URL ); } ) ); if ( $same ) { $org['sameAs'] = $same; } $graph = array( '@context' => 'https://schema.org', '@graph' => array( $org, array( '@type' => 'WebSite', '@id' => $url . '#website', 'name' => $name, 'url' => $url, 'publisher' => array( '@id' => $url . '#organization' ), ), ), ); return wp_json_encode( $graph ); } /** Prints the schema on the front page only (wp_head). */ public static function print_schema() { if ( ! is_front_page() ) { return; } $logo_id = (int) get_theme_mod( 'custom_logo' ); $logo = $logo_id ? wp_get_attachment_image_url( $logo_id, 'full' ) : null; // wp_json_encode() escapes "/", so the JSON cannot close the script tag. wp_print_inline_script_tag( self::schema_json( get_bloginfo( 'name' ), home_url( '/' ), $logo, (array) GetReport_Plugin::setting( 'facts' ) ), array( 'type' => 'application/ld+json' ) ); } /** Dequeues cart fragments outside the cart and checkout (wp_enqueue_scripts). */ public static function dequeue_cart_fragments() { if ( function_exists( 'is_cart' ) && ( is_cart() || is_checkout() ) ) { return; } wp_dequeue_script( 'wc-cart-fragments' ); } }