$settings Plugin settings (excludes, etc.). * @return array{content: string, ga4_count: int, wp_count: int} */ public static function merge( $ai_body, array $settings ) { $home = home_url( '/' ); $patterns = self::parse_pattern_lines( isset( $settings['exclude_path_patterns'] ) ? (string) $settings['exclude_path_patterns'] : '' ); $exclude_types = array(); if ( ! empty( $settings['exclude_post_types'] ) && is_array( $settings['exclude_post_types'] ) ) { foreach ( $settings['exclude_post_types'] as $t ) { $t = sanitize_key( (string) $t ); if ( '' !== $t ) { $exclude_types[] = $t; } } } $ai_body = (string) $ai_body; $ga4_raw = self::extract_site_urls( $ai_body, $home ); $ga4_set = self::normalize_url_set( self::filter_urls_by_path_patterns( $ga4_raw, $patterns ) ); $wp_urls = self::collect_wordpress_urls( $exclude_types, $patterns, $home ); $wp_set = self::normalize_url_set( $wp_urls ); $only_wp = array_values( array_diff( $wp_set, $ga4_set ) ); sort( $only_wp ); $merged = $ai_body; $wp_cnt = count( $only_wp ); if ( $wp_cnt > 0 ) { $merged .= "\n\n## " . __( 'Additional pages (WordPress)', 'ai-view-sync' ) . "\n"; if ( count( $ga4_set ) > 0 ) { $merged .= '# ' . __( 'Published URLs on this site not listed in the GA4-ranked section above.', 'ai-view-sync' ) . "\n\n"; } else { $merged .= '# ' . __( 'Published URLs discovered from this WordPress site.', 'ai-view-sync' ) . "\n\n"; } foreach ( $only_wp as $u ) { $merged .= '- ' . $u . "\n"; } } return array( 'content' => $merged, 'ga4_count' => count( $ga4_set ), 'wp_count' => $wp_cnt, ); } /** * Starter body when building from WordPress only and manual content is empty. * * @return string */ public static function default_wordpress_only_base() { $stub = "# LLMS.txt\n# " . __( 'Base file for WordPress URL merge.', 'ai-view-sync' ) . "\n\nUser-agent: *\nAllow: /\n"; /** * Override the default LLMS.txt stub used when merging WordPress URLs with empty manual content. * * @param string $stub Plain text. */ return (string) apply_filters( 'aivisy_wordpress_only_base', $stub ); } /** * @param string $textarea One pattern per line (matched against URL path). * @return array */ public static function parse_pattern_lines( $textarea ) { $lines = preg_split( '/\r\n|\r|\n/', (string) $textarea, -1, PREG_SPLIT_NO_EMPTY ); $out = array(); foreach ( $lines as $line ) { $line = trim( $line ); if ( '' !== $line ) { $out[] = $line; } } return $out; } /** * Extract http(s) URLs whose host matches this site. * * @param string $text Full llms.txt body. * @param string $home Home URL. * @return array */ public static function extract_site_urls( $text, $home ) { $host = wp_parse_url( $home, PHP_URL_HOST ); if ( ! is_string( $host ) || '' === $host ) { return array(); } $host = strtolower( $host ); $host = preg_replace( '/^www\./', '', $host ); if ( ! preg_match_all( '#https?://[^\s\)\]\"\'<>]+#iu', $text, $m ) ) { return array(); } $urls = array(); foreach ( $m[0] as $raw ) { $raw = rtrim( $raw, '.,;:!?*' ); $h2 = wp_parse_url( $raw, PHP_URL_HOST ); if ( ! is_string( $h2 ) || '' === $h2 ) { continue; } $h2 = strtolower( preg_replace( '/^www\./', '', $h2 ) ); if ( $h2 === $host ) { $urls[] = $raw; } } return array_unique( $urls ); } /** * @param array $urls Full URLs. * @param array $patterns Substrings matched against path (case-insensitive). * @return array */ public static function filter_urls_by_path_patterns( array $urls, array $patterns ) { if ( empty( $patterns ) ) { return $urls; } $out = array(); foreach ( $urls as $url ) { $path = wp_parse_url( $url, PHP_URL_PATH ); if ( ! is_string( $path ) ) { $path = '/'; } $reject = false; foreach ( $patterns as $p ) { if ( '' === $p ) { continue; } if ( stripos( $path, $p ) !== false ) { $reject = true; break; } } if ( ! $reject ) { $out[] = $url; } } return $out; } /** * @param array $urls Raw URLs. * @return array Unique normalized keys for set ops. */ public static function normalize_url_set( array $urls ) { $norm = array(); foreach ( $urls as $u ) { $n = self::normalize_url( $u ); if ( '' !== $n ) { $norm[ $n ] = true; } } return array_keys( $norm ); } /** * Canonical comparison string for a URL. * * @param string $url Full URL. * @return string */ public static function normalize_url( $url ) { $url = trim( (string) $url ); $p = wp_parse_url( $url ); if ( ! is_array( $p ) || empty( $p['host'] ) ) { return ''; } $scheme = isset( $p['scheme'] ) && in_array( strtolower( $p['scheme'] ), array( 'http', 'https' ), true ) ? strtolower( $p['scheme'] ) : 'https'; $host = strtolower( (string) $p['host'] ); $host = preg_replace( '/^www\./', '', $host ); $path = isset( $p['path'] ) ? $p['path'] : '/'; if ( '/' !== $path && '/' === substr( $path, -1 ) ) { $path = untrailingslashit( $path ); } $query = isset( $p['query'] ) && $p['query'] !== '' ? '?' . $p['query'] : ''; return $scheme . '://' . $host . $path . $query; } /** * Published posts/pages (public post types) minus exclusions. * * @param array $exclude_post_types Post type slugs to skip. * @param array $path_patterns Path substring exclusions. * @param string $home Site home URL. * @return array */ public static function collect_wordpress_urls( array $exclude_post_types, array $path_patterns, $home ) { $public = get_post_types( array( 'public' => true, ), 'names' ); unset( $public['attachment'] ); foreach ( $exclude_post_types as $ex ) { unset( $public[ $ex ] ); } $public = array_values( array_filter( array_keys( $public ) ) ); if ( empty( $public ) ) { return array(); } $max = (int) apply_filters( 'aivisy_max_wp_urls', 500 ); if ( $max < 1 ) { $max = 500; } $q = new \WP_Query( array( 'post_type' => $public, 'post_status' => 'publish', 'posts_per_page' => $max, 'orderby' => 'modified', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); $urls = array(); foreach ( $q->posts as $post_id ) { $link = get_permalink( (int) $post_id ); if ( ! is_string( $link ) || '' === $link ) { continue; } $urls[] = $link; } $urls = self::filter_urls_by_path_patterns( $urls, $path_patterns ); $home_canon = self::normalize_url( $home ); if ( '' !== $home_canon ) { $urls[] = $home; } return array_unique( $urls ); } /** * Optionally persist llms.txt inside the uploads directory (wp-content/uploads/ai-view-sync/). * * The canonical public URL is served virtually via the rewrite rule registered in Output, so * writing a physical file is not required for /llms.txt to work. This method stores a copy in * the uploads folder as an accessible backup. Writing to ABSPATH (site root) is intentionally * avoided per WordPress plugin guidelines; use the aivisy_write_physical_file filter to opt out. * * @param string $content Full merged body. */ public static function maybe_write_physical_file( $content ) { // Independent of the uploads backup below: opt-in root copy for hosts that // serve .txt URLs statically (see maybe_write_root_file()). self::maybe_write_root_file( $content ); /** * Set to false to skip writing a physical copy of llms.txt to the uploads directory. * * @param bool $do Default true. */ $do = (bool) apply_filters( 'aivisy_write_physical_file', true ); if ( ! $do ) { return; } $upload = wp_upload_dir(); if ( ! empty( $upload['error'] ) ) { return; } $dir = trailingslashit( $upload['basedir'] ) . 'ai-view-sync'; if ( ! wp_mkdir_p( $dir ) ) { return; } if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } WP_Filesystem(); global $wp_filesystem; if ( ! $wp_filesystem || ! is_object( $wp_filesystem ) ) { return; } $path = trailingslashit( $dir ) . 'llms.txt'; if ( $wp_filesystem->exists( $path ) && ! $wp_filesystem->is_writable( $path ) ) { return; } if ( ! $wp_filesystem->exists( $path ) && ! $wp_filesystem->is_writable( $dir ) ) { return; } $wp_filesystem->put_contents( $path, $content, FS_CHMOD_FILE ); } /** * Initialize and return the WP filesystem, or null on failure. * * @return object|null */ protected static function get_filesystem() { if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } WP_Filesystem(); global $wp_filesystem; return ( $wp_filesystem && is_object( $wp_filesystem ) ) ? $wp_filesystem : null; } /** * Opt-in root copy for hosts whose web server answers /llms.txt statically (404s before * WordPress runs — e.g. WP Engine). Controlled by the write_root_file setting; refreshed on * every sync/save via maybe_write_physical_file(). * * Never touches a root llms.txt this plugin did not create: writes only when the file is * absent or root_file_managed is already set. * * @param string $content Full merged body. * @return bool Whether the root file was written. */ public static function maybe_write_root_file( $content ) { $s = Settings::get_all(); if ( empty( $s['write_root_file'] ) ) { return false; } $wp_filesystem = self::get_filesystem(); if ( ! $wp_filesystem ) { return false; } $path = trailingslashit( ABSPATH ) . 'llms.txt'; if ( $wp_filesystem->exists( $path ) && empty( $s['root_file_managed'] ) ) { // Pre-existing file we did not create — leave it alone. return false; } if ( $wp_filesystem->exists( $path ) && ! $wp_filesystem->is_writable( $path ) ) { return false; } if ( ! $wp_filesystem->exists( $path ) && ! $wp_filesystem->is_writable( ABSPATH ) ) { return false; } if ( ! $wp_filesystem->put_contents( $path, $content, FS_CHMOD_FILE ) ) { return false; } if ( empty( $s['root_file_managed'] ) ) { Settings::update( array( 'root_file_managed' => true ) ); } return true; } /** * Remove the root llms.txt — only if this plugin created it (root_file_managed). * Used when the setting is switched off and on uninstall. * * @return void */ public static function delete_root_file() { $s = Settings::get_all(); if ( empty( $s['root_file_managed'] ) ) { return; } $wp_filesystem = self::get_filesystem(); if ( ! $wp_filesystem ) { return; } $path = trailingslashit( ABSPATH ) . 'llms.txt'; if ( $wp_filesystem->exists( $path ) ) { $wp_filesystem->delete( $path ); } Settings::update( array( 'root_file_managed' => false ) ); } }