` tags that tell crawlers what they may do and where to read. * * Three jobs, each switchable on its own: robots directives that let an answer * engine quote the page at full length, `` tags pointing at the machine * readable files this plugin publishes, and a basic Open Graph and Twitter card * for sites with no SEO plugin to write one. * * A link is printed only for a file that is actually registered, so switching a * module off never leaves a dead link in the head. */ final class HeadTags extends AbstractModule { /** * Post meta asking that a post be kept out of AI training sets. * * @var string */ public const NOAI_META = '_acid_aeo_noai'; /** * Robots directives added to every indexable page. * * `max-image-preview: large` is already core's default for a public site; * it is named here so that the value is right on sites where something else * dropped it, and skipped when it is already set. * * @var array */ private const DIRECTIVES = [ 'max-snippet' => '-1', 'max-image-preview' => 'large', 'max-video-preview' => '-1', ]; /** * How many characters of the summary the social description keeps. * * @var int */ private const DESCRIPTION_LENGTH = 200; /** * Machine name, matching the settings section the module reads. * * @return string Module slug. */ public function slug(): string { return 'head'; } /** * Human readable, translated module name. * * @return string Module title. */ public function title(): string { return __( 'Head tags', 'acid-aeo' ); } /** * Hooks the module into WordPress. * * @return void */ public function register(): void { if ( (bool) Options::get( 'head.robots_directives', true ) ) { add_filter( 'wp_robots', [ $this, 'robots' ] ); } add_action( 'wp_head', [ $this, 'render' ], 2 ); add_action( 'acid_aeo_settings_updated', [ $this, 'on_settings_updated' ] ); } /** * Lets answer engines quote the page in full. * * A page somebody has already marked `noindex` is left alone: raising the * snippet limit on a page that must not be indexed says nothing, and * touching another plugin's decision is not this module's business. * * @param mixed $robots Directives collected so far. * @return array Directives. */ public function robots( $robots ): array { $robots = is_array( $robots ) ? $robots : []; if ( ! empty( $robots['noindex'] ) ) { return $robots; } foreach ( self::DIRECTIVES as $directive => $value ) { if ( ! isset( $robots[ $directive ] ) ) { $robots[ $directive ] = $value; } } return $robots; } /** * Prints the tags for the current request. * * @return void */ public function render(): void { if ( is_404() || is_feed() || is_embed() ) { return; } $this->render_links(); $this->render_author(); $this->render_noai(); if ( $this->renders_open_graph() ) { $this->render_open_graph(); } } /** * Audit checks contributed by this module. * * Both look at the home page, which is one request: `Audit\Loopback` caches * the response for the length of the audit. * * @return array Checks. */ public function checks(): array { $slug = $this->slug(); $url = home_url( '/' ); $directives = (bool) Options::get( 'head.robots_directives', true ) ? Checks::observed( 'head_robots_directives', $slug, __( 'Snippet limits are lifted', 'acid-aeo' ), $url, static fn( string $body ): bool => false !== strpos( $body, 'max-snippet:-1' ), [ 'ok' => __( 'Pages tell search and answer engines they may quote the whole passage.', 'acid-aeo' ), 'bad' => __( 'The home page carries no max-snippet:-1 directive, so engines fall back to a short snippet and quote less of your writing. Another plugin is most likely filtering wp_robots.', 'acid-aeo' ), 'status' => Check::WARN, 'weight' => Check::WEIGHT_IMPORTANT, ] ) : Check::skip( 'head_robots_directives', $slug, __( 'Snippet limits are lifted', 'acid-aeo' ), __( 'Robots directives are switched off in the head tag settings.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT ); $links = (bool) Options::get( 'head.link_tags', true ) ? Checks::observed( 'head_link_tags', $slug, __( 'Pages point at llms.txt', 'acid-aeo' ), $url, static fn( string $body ): bool => false !== strpos( $body, 'rel="alternate"' ) && false !== strpos( $body, 'llms.txt' ), [ 'ok' => __( 'The head links to llms.txt, so a client that reads the HTML finds the context file without guessing.', 'acid-aeo' ), 'bad' => __( 'The home page carries no alternate link to llms.txt. Check that the llms.txt module is on and that the theme calls wp_head().', 'acid-aeo' ), 'status' => Check::WARN, 'weight' => Check::WEIGHT_IMPORTANT, ] ) : Check::skip( 'head_link_tags', $slug, __( 'Pages point at llms.txt', 'acid-aeo' ), __( 'Link tags are switched off in the head tag settings.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT ); return [ $directives, $links ]; } /** * Whether this module writes the Open Graph and Twitter tags. * * On `auto` the answer is "only when nobody else does": an SEO plugin * writes its own, and two `og:title` tags is worse than none. The detection * goes through `Environment::seo_plugin()`, which reads the constants those * plugins define, rather than the full `detect()` the audit uses. * * @return bool True when the tags are printed. */ public function renders_open_graph(): bool { $mode = (string) Options::get( 'head.og_fallback', 'auto' ); if ( 'on' === $mode ) { return true; } return 'auto' === $mode && null === Environment::seo_plugin(); } /** * Points at the machine readable files the plugin publishes. * * @return void */ private function render_links(): void { if ( ! (bool) Options::get( 'head.link_tags', true ) ) { return; } $links = [ 'llms' => [ 'rel' => 'alternate', 'type' => 'text/plain', 'title' => __( 'LLM context', 'acid-aeo' ), ], 'humans' => [ 'rel' => 'author', 'type' => '', 'title' => '', ], 'ai_context' => [ 'rel' => 'help', 'type' => 'application/json', 'title' => '', ], ]; foreach ( $links as $file => $link ) { $url = Router::url( $file ); if ( null === Router::file( $file ) || '' === $url ) { continue; } printf( "\n", esc_attr( $link['rel'] ), '' !== $link['type'] ? ' type="' . esc_attr( $link['type'] ) . '"' : '', esc_url( $url ), '' !== $link['title'] ? ' title="' . esc_attr( $link['title'] ) . '"' : '' ); } } /** * Names the author of a singular page. * * @return void */ private function render_author(): void { if ( ! is_singular() || ! (bool) Options::get( 'head.meta_author', true ) ) { return; } $post = get_post(); if ( ! $post instanceof WP_Post ) { return; } $author = (string) get_the_author_meta( 'display_name', (int) $post->post_author ); if ( '' === $author ) { return; } printf( "\n", esc_attr( $author ) ); } /** * Asks that this page be kept out of AI training sets. * * `noai` and `noimageai` are an informal convention rather than a standard, * which is why they are opt-in per post and off by default. * * @return void */ private function render_noai(): void { if ( ! is_singular() || ! (bool) Options::get( 'policy.noai_meta', false ) ) { return; } $post = get_post(); if ( ! $post instanceof WP_Post || ! $this->is_flagged( (int) $post->ID, self::NOAI_META ) ) { return; } echo "\n"; } /** * Whether a checkbox style post meta is switched on. * * A checkbox reaches the database as `1`, but a meta box, an import or the * REST API can write `on`, `true` or `yes` just as easily; anything that is * neither empty nor a literal zero is read as "yes", so an opt-out is never * silently ignored because the value did not spell itself `1`. * * @param int $post_id Post ID. * @param string $key Meta key. * @return bool True when the flag is set. */ private function is_flagged( int $post_id, string $key ): bool { $value = get_post_meta( $post_id, $key, true ); $value = is_scalar( $value ) ? trim( (string) $value ) : ''; return '' !== $value && '0' !== $value; } /** * Prints a basic Open Graph and Twitter card. * * @return void */ private function render_open_graph(): void { $page = $this->page(); $image = is_array( $page['image'] ) ? $page['image'] : []; $tags = [ 'og:type' => $page['type'], 'og:title' => $page['title'], 'og:description' => $page['description'], 'og:url' => $page['url'], 'og:site_name' => $page['site_name'], 'og:locale' => (string) get_locale(), 'og:image' => (string) ( $image['url'] ?? '' ), ]; $width = (int) ( $image['width'] ?? 0 ); $height = (int) ( $image['height'] ?? 0 ); if ( '' !== $tags['og:image'] && $width > 0 && $height > 0 ) { $tags['og:image:width'] = (string) $width; $tags['og:image:height'] = (string) $height; } foreach ( $tags as $property => $content ) { if ( '' === $content ) { continue; } printf( "\n", esc_attr( $property ), esc_attr( $content ) ); } $twitter = [ 'twitter:card' => 'summary_large_image', 'twitter:title' => $page['title'], 'twitter:description' => $page['description'], 'twitter:image' => (string) ( $image['url'] ?? '' ), ]; foreach ( $twitter as $name => $content ) { if ( '' === $content ) { continue; } printf( "\n", esc_attr( $name ), esc_attr( $content ) ); } } /** * What the social tags say about the current page. * * @return array Keys `type`, `title`, `description`, `url`, `site_name` * and `image`. */ private function page(): array { $identity = SiteData::identity(); $site_name = ContentData::plain_text( $identity['name'] ?? '' ); $post = is_singular() ? get_post() : null; if ( ! $post instanceof WP_Post ) { return [ 'type' => 'website', 'title' => ContentData::plain_text( wp_get_document_title() ), 'description' => ContentData::shorten( ContentData::plain_text( $identity['description'] ?? '' ), self::DESCRIPTION_LENGTH ), 'url' => SiteData::current_url(), 'site_name' => $site_name, 'image' => [ 'url' => $this->site_image( $identity ) ], ]; } return [ 'type' => is_front_page() ? 'website' : 'article', 'title' => ContentData::plain_text( get_the_title( $post ) ), 'description' => ContentData::shorten( ContentData::summary_for( (int) $post->ID ), self::DESCRIPTION_LENGTH ), 'url' => (string) get_permalink( $post ), 'site_name' => $site_name, 'image' => $this->image( (int) $post->ID, $identity ), ]; } /** * The image the social card shows. * * @param int $post_id Post ID. * @param array $identity Identity from `Data\SiteData`. * @return array Keys `url`, `width` and `height`. */ private function image( int $post_id, array $identity ): array { $source = wp_get_attachment_image_src( (int) get_post_thumbnail_id( $post_id ), 'full' ); if ( is_array( $source ) && ! empty( $source[0] ) ) { return [ 'url' => (string) $source[0], 'width' => (int) ( $source[1] ?? 0 ), 'height' => (int) ( $source[2] ?? 0 ), ]; } return [ 'url' => $this->site_image( $identity ) ]; } /** * The image that stands for the site when a page has none of its own. * * @param array $identity Identity from `Data\SiteData`. * @return string Absolute URL, empty when neither is set. */ private function site_image( array $identity ): string { $image = trim( (string) ( $identity['image_url'] ?? '' ) ); return '' !== $image ? $image : trim( (string) ( $identity['logo_url'] ?? '' ) ); } }