` 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'] ?? '' ) );
}
}