[rewrite regex, physical path]. * security.txt lives under .well-known (RFC 9116). * * @var array */ const VIRTUAL_FILES = array( 'ai_txt' => array( '^ai\.txt$', 'ai.txt' ), 'humans_txt' => array( '^humans\.txt$', 'humans.txt' ), 'security_txt' => array( '^\.well-known/security\.txt$', '.well-known/security.txt' ), ); /** * Bump when rewrite rules change so updates flush them once. */ const REWRITE_VERSION = '2'; /** * Schema @types skipped when an SEO plugin (Yoast / Rank Math) is active, * to avoid duplicate Organization / WebSite graphs. * * @var string[] */ const SEO_PLUGIN_TYPES = array( 'Organization', 'WebSite' ); /** * Hook everything up. */ public function __construct() { add_action( 'init', array( __CLASS__, 'register_llms_rewrite' ) ); add_action( 'init', array( __CLASS__, 'register_file_rewrites' ) ); add_action( 'init', array( __CLASS__, 'maybe_flush_rewrites' ), 99 ); add_filter( 'query_vars', array( $this, 'register_query_var' ) ); add_action( 'template_redirect', array( $this, 'maybe_serve_llms_txt' ), 0 ); add_action( 'template_redirect', array( $this, 'maybe_serve_virtual_file' ), 0 ); add_action( 'wp_head', array( $this, 'inject_jsonld' ), 5 ); add_action( 'wp_head', array( $this, 'inject_meta' ), 6 ); add_filter( 'wp_robots', array( $this, 'add_snippet_directives' ), 20 ); add_filter( 'robots_txt', array( $this, 'append_robots_directives' ), 99, 2 ); } /** * Whether a module is active (module toggle AND global kill-switch). * * @param string $module Module slug: jsonld | robots | llms. * @return bool */ private function is_module_active( $module ) { if ( ! get_option( 'aeo_connect_enabled', true ) ) { return false; } $modules = AEO_Connect_Cache::get_modules(); return ! empty( $modules[ $module ] ); } /* --------------------------------------------------------------------- * JSON-LD * ------------------------------------------------------------------ */ /** * Print the bundle's JSON-LD scripts matching the current URL. * * Runs on wp_head at priority 5. Other SEO plugins' ld+json output is not * in the DOM yet at this point, so the anti-duplicate strategy is: * if Yoast (WPSEO_VERSION) or Rank Math (RANK_MATH_VERSION) is active and * the schema @type is Organization or WebSite, skip it — those plugins * always emit their own. * * @return void */ public function inject_jsonld() { if ( ! $this->is_module_active( 'jsonld' ) ) { return; } $bundle = AEO_Connect_Cache::get_bundle(); if ( null === $bundle || empty( $bundle['modules']['jsonld'] ) || ! is_array( $bundle['modules']['jsonld'] ) ) { return; } $current_path = $this->current_path(); $seo_plugin_active = defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ); $output = ''; foreach ( $bundle['modules']['jsonld'] as $item ) { if ( ! is_array( $item ) || empty( $item['page'] ) || empty( $item['schema'] ) || ! is_array( $item['schema'] ) ) { continue; } if ( ! $this->path_matches( (string) $item['page'], $current_path ) ) { continue; } $type = isset( $item['schema']['@type'] ) ? $item['schema']['@type'] : ''; if ( $seo_plugin_active && is_string( $type ) && in_array( $type, self::SEO_PLUGIN_TYPES, true ) ) { continue; // Avoid duplicating Yoast / Rank Math graphs. } $json = wp_json_encode( $item['schema'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ( false === $json ) { continue; } // Hardening: neutralize a potential breakout inside string // values. `<\/` decodes to the exact same JSON data as `' . $json . '' . "\n"; } if ( '' !== $output ) { echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON safely encoded via wp_json_encode with normalize_path( $path ); } /** * Whether a bundle page path matches the current request. * * '/' matches the front page (is_front_page()); any other path requires * an exact (trailing-slash-insensitive) match. * * @param string $page Page path from the bundle item. * @param string $current_path Normalized current path. * @return bool */ private function path_matches( $page, $current_path ) { $page = trim( (string) $page ); if ( '' === $page ) { return false; } if ( '/' === $page ) { return is_front_page() || '/' === $current_path; } return $this->normalize_path( $page ) === $current_path; } /** * Normalize a path: leading slash, no trailing slash, '/' for the root. * * @param string $path Raw path. * @return string */ private function normalize_path( $path ) { $path = (string) $path; if ( '' === $path ) { return '/'; } if ( '/' !== $path[0] ) { $path = '/' . $path; } $path = untrailingslashit( $path ); return '' === $path ? '/' : $path; } /* --------------------------------------------------------------------- * meta / Open Graph (only-if-absent) * ------------------------------------------------------------------ */ /** * Map of bundle `meta` field → ['attr', 'name'] for the tag. * * @var array */ const META_TAG_MAP = array( 'description' => array( 'name', 'description' ), 'og_title' => array( 'property', 'og:title' ), 'og_description' => array( 'property', 'og:description' ), 'og_url' => array( 'property', 'og:url' ), 'og_type' => array( 'property', 'og:type' ), 'twitter_card' => array( 'name', 'twitter:card' ), 'twitter_title' => array( 'name', 'twitter:title' ), 'twitter_description' => array( 'name', 'twitter:description' ), ); /** * Print the bundle's meta / Open Graph tags on wp_head (priority 6). * * Anti-conflict: if Yoast (WPSEO_VERSION) or Rank Math (RANK_MATH_VERSION) * is active, skip entirely — those plugins already emit description and * Open Graph. The plugin NEVER emits a (WordPress owns it). This * module targets sites WITHOUT a dedicated SEO plugin (the common case for * the under-optimized sites we connect). * * @return void */ public function inject_meta() { if ( ! $this->is_module_active( 'meta' ) ) { return; } if ( defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ) ) { return; // Let the SEO plugin own description / Open Graph. } $bundle = AEO_Connect_Cache::get_bundle(); if ( null === $bundle || empty( $bundle['modules']['meta'] ) || ! is_array( $bundle['modules']['meta'] ) ) { return; } $meta = $bundle['modules']['meta']; $output = ''; foreach ( self::META_TAG_MAP as $field => $spec ) { if ( empty( $meta[ $field ] ) || ! is_string( $meta[ $field ] ) ) { continue; } $attr = $spec[0]; $name = $spec[1]; $value = $meta[ $field ]; $output .= '<meta ' . $attr . '="' . esc_attr( $name ) . '" content="' . esc_attr( $value ) . '" class="aeo-tool-connect" />' . "\n"; } // canonical (1.3.0, only-if-absent by construction): WP core emits // rel=canonical ONLY on singular views — on archives / front page // without an SEO plugin there is none. We fill exactly that gap. if ( ! empty( $meta['canonical_enabled'] ) && ! is_singular() ) { $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : '/'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- path extracted below, escaped via esc_url on output. $path = wp_parse_url( $request_uri, PHP_URL_PATH ); $canonical = home_url( is_string( $path ) ? $path : '/' ); $output .= '<link rel="canonical" href="' . esc_url( $canonical ) . '" class="aeo-tool-connect" />' . "\n"; } if ( '' !== $output ) { echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- each attribute escaped via esc_attr above. } } /** * Add snippet directives (max-snippet:-1, max-video-preview:-1) through the * wp_robots filter — APPEND-ONLY: existing keys are never overridden. WP * core already adds max-image-preview:large since 5.7. * * @param array $robots Directives array. * @return array */ public function add_snippet_directives( $robots ) { if ( ! $this->is_module_active( 'meta' ) ) { return $robots; } $bundle = AEO_Connect_Cache::get_bundle(); if ( null === $bundle || empty( $bundle['modules']['meta']['snippet_directives'] ) ) { return $robots; } if ( ! isset( $robots['max-snippet'] ) ) { $robots['max-snippet'] = '-1'; } if ( ! isset( $robots['max-video-preview'] ) ) { $robots['max-video-preview'] = '-1'; } if ( ! isset( $robots['max-image-preview'] ) ) { $robots['max-image-preview'] = 'large'; } return $robots; } /* --------------------------------------------------------------------- * robots.txt (append-only) * ------------------------------------------------------------------ */ /** * Append the bundle's AI-bot directives AFTER the existing robots.txt * output. Never replaces anything. * * @param string $output Current robots.txt output. * @param bool $is_public Whether the site is public. * @return string */ public function append_robots_directives( $output, $is_public ) { unset( $is_public ); $bundle = AEO_Connect_Cache::get_bundle(); // Not paired yet: serve the locally generated AI-bot directives so a fresh // install produces something verifiable without an account. The bundle // always wins once it exists. if ( null === $bundle ) { if ( ! AEO_Connect_Standalone::is_active() ) { return $output; } $content = AEO_Connect_Standalone::robots_directives(); } else { if ( ! $this->is_module_active( 'robots' ) ) { return $output; } if ( ! isset( $bundle['modules']['robots_txt']['content'] ) ) { return $output; } $content = $bundle['modules']['robots_txt']['content']; } if ( ! is_string( $content ) || '' === trim( $content ) ) { return $output; } $output = rtrim( (string) $output ); $output .= "\n\n# --- Everlange Connect (AI bots) ---\n" . trim( $content ) . "\n"; return $output; } /* --------------------------------------------------------------------- * Virtual /llms.txt * ------------------------------------------------------------------ */ /** * Register the rewrite rule for the virtual /llms.txt — only when no * physical llms.txt file exists at the WordPress root. * * Static so the activation hook can call it before flushing rules. * * @return void */ public static function register_llms_rewrite() { if ( file_exists( ABSPATH . 'llms.txt' ) ) { return; } add_rewrite_rule( '^llms\.txt$', 'index.php?' . self::LLMS_QUERY_VAR . '=1', 'top' ); } /** * Register the llms.txt query var. * * @param string[] $vars Public query vars. * @return string[] */ public function register_query_var( $vars ) { $vars[] = self::LLMS_QUERY_VAR; $vars[] = self::FILE_QUERY_VAR; $vars[] = self::INKEY_QUERY_VAR; return $vars; } /** * Register rewrite rules for the 1.3.0 virtual files (ai.txt, humans.txt, * .well-known/security.txt) and the IndexNow key file /{64-hex}.txt. * Physical files are never shadowed. * * @return void */ public static function register_file_rewrites() { foreach ( self::VIRTUAL_FILES as $slug => $spec ) { if ( file_exists( ABSPATH . $spec[1] ) ) { continue; } add_rewrite_rule( $spec[0], 'index.php?' . self::FILE_QUERY_VAR . '=' . $slug, 'top' ); } // IndexNow key file: /{key}.txt (64 hex). The requested key is compared // to the bundle's key at serve time — anything else 404s. add_rewrite_rule( '^([a-f0-9]{64})\.txt$', 'index.php?' . self::FILE_QUERY_VAR . '=indexnow&' . self::INKEY_QUERY_VAR . '=$matches[1]', 'top' ); } /** * Flush rewrite rules ONCE after an update that changes them (1.3.0). * * @return void */ public static function maybe_flush_rewrites() { if ( get_option( 'aeo_connect_rewrite_ver' ) === self::REWRITE_VERSION ) { return; } flush_rewrite_rules( false ); update_option( 'aeo_connect_rewrite_ver', self::REWRITE_VERSION, false ); } /** * Serve a 1.3.0 virtual file (ai.txt / humans.txt / security.txt / * IndexNow key) as text/plain from the bundle. * * @return void */ public function maybe_serve_virtual_file() { $slug = (string) get_query_var( self::FILE_QUERY_VAR ); if ( '' === $slug ) { return; } $content = ''; if ( 'indexnow' === $slug ) { if ( $this->is_module_active( 'indexnow' ) ) { $bundle = AEO_Connect_Cache::get_bundle(); $key = ( null !== $bundle && isset( $bundle['modules']['indexnow']['key'] ) ) ? (string) $bundle['modules']['indexnow']['key'] : ''; $requested = strtolower( (string) get_query_var( self::INKEY_QUERY_VAR ) ); if ( '' !== $key && hash_equals( $key, $requested ) ) { $content = $key; } } } elseif ( isset( self::VIRTUAL_FILES[ $slug ] ) ) { if ( file_exists( ABSPATH . self::VIRTUAL_FILES[ $slug ][1] ) ) { return; // A physical file appeared: never shadow it. } if ( $this->is_module_active( $slug ) ) { $bundle = AEO_Connect_Cache::get_bundle(); if ( null !== $bundle && isset( $bundle['modules'][ $slug ]['content'] ) && is_string( $bundle['modules'][ $slug ]['content'] ) ) { $content = $bundle['modules'][ $slug ]['content']; } } } else { return; } if ( '' === trim( $content ) ) { global $wp_query; if ( $wp_query instanceof WP_Query ) { $wp_query->set_404(); } status_header( 404 ); return; } status_header( 200 ); header( 'Content-Type: text/plain; charset=utf-8' ); header( 'X-Content-Type-Options: nosniff' ); header( 'Cache-Control: max-age=3600' ); echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- plain-text body served with an explicit text/plain content type; content sanitized on intake. exit; } /** * Serve the virtual llms.txt as text/plain. * * @return void */ public function maybe_serve_llms_txt() { if ( '1' !== (string) get_query_var( self::LLMS_QUERY_VAR ) ) { return; } if ( file_exists( ABSPATH . 'llms.txt' ) ) { return; // A physical file appeared since activation: never shadow it. } $content = ''; $bundle = AEO_Connect_Cache::get_bundle(); if ( null === $bundle ) { // Not paired: build a minimal llms.txt from the site's own data rather // than returning a 404, which is what made a fresh install look broken. if ( AEO_Connect_Standalone::is_active() ) { $content = AEO_Connect_Standalone::llms_txt(); } } elseif ( $this->is_module_active( 'llms' ) ) { if ( isset( $bundle['modules']['llms_txt']['content'] ) && is_string( $bundle['modules']['llms_txt']['content'] ) ) { $content = $bundle['modules']['llms_txt']['content']; } } if ( '' === trim( $content ) ) { global $wp_query; if ( $wp_query instanceof WP_Query ) { $wp_query->set_404(); } status_header( 404 ); return; } status_header( 200 ); header( 'Content-Type: text/plain; charset=utf-8' ); header( 'X-Content-Type-Options: nosniff' ); header( 'Cache-Control: max-age=3600' ); echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- plain-text body served with an explicit text/plain content type; content sanitized on intake. exit; } }