should_send( $wp ) ) { return; } if ( (bool) Options::get( 'headers.link_header', true ) && null !== Router::file( 'llms' ) ) { $url = esc_url_raw( Router::url( 'llms' ) ); if ( '' !== $url ) { header( 'Link: <' . $url . '>; rel="alternate"; type="text/plain"', false ); } } if ( $this->content_signal_enabled() ) { $signal = SiteData::content_signal_string(); if ( '' !== $signal ) { header( 'Content-Signal: ' . $signal ); } } } /** * Whether the `Content-Signal` header belongs on responses at all. * * Two switches, because the header repeats a policy rather than owning one: * its own setting says whether to send it, and `policy.enabled` says whether * there is a policy to send. With the policy module off, robots.txt carries * no AI rules and `ai.txt` states nothing — a header still announcing terms * would be the one place contradicting them. * * @return bool True when the header should be sent. */ private function content_signal_enabled(): bool { return (bool) Options::get( 'headers.content_signal_header', true ) && (bool) Options::get( 'policy.enabled', true ); } /** * Why the `Content-Signal` check is skipped, in the words that fit the reason. * * Two switches silence this header, and telling somebody the header is off * when what they actually switched off is the policy sends them to the * wrong settings tab. * * @return string Translated reason, or an empty string to use the default. */ private function content_signal_skip_reason(): string { if ( (bool) Options::get( 'headers.content_signal_header', true ) && ! (bool) Options::get( 'policy.enabled', true ) ) { return __( 'The AI policy module is switched off in the settings.', 'acid-aeo' ); } return ''; } /** * Audit checks contributed by this module. * * A page cache or a reverse proxy is free to strip headers WordPress sent, * which is exactly why these are read back over HTTP instead of trusted. * * @return array Checks. */ public function checks(): array { return [ $this->header_check( 'headers_link', __( 'Link header points at llms.txt', 'acid-aeo' ), 'link', 'llms', (bool) Options::get( 'headers.link_header', true ), __( 'Responses carry a Link header pointing at llms.txt, so a client that never parses HTML still finds it.', 'acid-aeo' ), __( 'No Link header naming llms.txt came back. A page cache or a reverse proxy in front of WordPress is most likely dropping it.', 'acid-aeo' ) ), $this->header_check( 'headers_content_signal', __( 'Content-Signal header', 'acid-aeo' ), 'content-signal', '', $this->content_signal_enabled(), __( 'Responses state what your content may be used for, in the header as well as in robots.txt.', 'acid-aeo' ), __( 'No Content-Signal header came back. A page cache or a reverse proxy in front of WordPress is most likely dropping it.', 'acid-aeo' ), $this->content_signal_skip_reason() ), ]; } /** * Whether one header survives the trip to a client. * * @param string $id Check id. * @param string $label Translated check name. * @param string $header Header name, lowercase. * @param string $needle Text the header value must carry, or an empty string for any value. * @param bool $enabled Whether the setting that sends the header is on. * @param string $ok Message when the header is there. * @param string $bad Message when it is not. * @param string $skip Message when it is switched off; defaults to the header's own setting. * @return Check The finding. */ private function header_check( string $id, string $label, string $header, string $needle, bool $enabled, string $ok, string $bad, string $skip = '' ): Check { $weight = Check::WEIGHT_NICE; $slug = $this->slug(); if ( ! $enabled ) { $skip = '' !== $skip ? $skip : __( 'This header is switched off in the settings.', 'acid-aeo' ); return Check::skip( $id, $slug, $label, $skip, $weight ); } $url = home_url( '/' ); $problem = Checks::unreadable( $id, $slug, $label, $url, $weight ); if ( null !== $problem ) { return $problem; } $result = Checks::fetch( $url ); $headers = is_array( $result['headers'] ) ? $result['headers'] : []; $value = (string) ( $headers[ $header ] ?? '' ); $found = '' !== $value && ( '' === $needle || false !== stripos( $value, $needle ) ); return $found ? Check::pass( $id, $slug, $label, $ok, $weight ) : Check::warn( $id, $slug, $label, $bad, $weight ); } /** * Whether this request is one a crawler reads the site through. * * @param mixed $wp The `WP` object, as `send_headers` passes it. * @return bool True when the headers belong on this response. */ private function should_send( $wp ): bool { if ( headers_sent() || is_admin() || wp_doing_ajax() || wp_doing_cron() ) { return false; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return false; } $vars = is_object( $wp ) && isset( $wp->query_vars ) && is_array( $wp->query_vars ) ? $wp->query_vars : []; return empty( $vars['feed'] ) && empty( $vars[ Router::QUERY_VAR ] ); } }