llms.txt: "Generate llms.txt" writes /llms.txt at the site root (the folder WordPress's * home URL points to): the site name and description, then the key pages (home page first, then * pages in your menus, then the rest) and the latest posts, each with a one-line description * (llmstxt.org format). Only on a button click, by an administrator; no external calls. * * Left out: password-protected pages, anything set to noindex in Yoast, Rank Math, All in One SEO * (3 and 4) or SEOPress, shop/account/checkout/thank-you/login pages (from WooCommerce's and Easy * Digital Downloads' page settings, plus their usual slugs) - and whatever the * aeo_bob_lite_llms_exclude filter says. * * An llms.txt written by AEO Bob (Pro, or Lite) is AEO Bob's own and Generate replaces it. A file * from anything else is only replaced when the administrator ticks "Replace it". Every Generate * ends with a visible result: created / updated (with counts and size), not replaced, not * writable, or failed. llms-full.txt, engine prompt files and Notify AI Models are AEO Bob Pro. */ if (!defined('ABSPATH')) { exit; } class AEO_Bob_Lite_Llms { const OPTION = 'aeo_bob_lite_llms'; // {hash, generated_at, pages, posts, bytes} of the file Lite last wrote const PAGE = 'aeo-bob-lite-llms'; const ACTION = 'aeo_bob_lite_llms'; const AJAX = 'aeo_bob_lite_generate_llms'; // v1.5.88: Generate from the AEO BOB Score box const UPGRADE_URL = 'https://aeobob.com/?utm_source=lite&utm_medium=llms-alert#choose-your-plan'; const MAX_ITEMS = 25; // per section const MARKER = ''; return implode("\n", $lines) . "\n"; } /** Pages and posts listed by the last build(). */ public function counts() { return $this->counts; } /** "- [Title](url): description" for the listed items of a type, in order. */ private function items($type) { $args = array( 'post_type' => $type, 'post_status' => 'publish', 'has_password' => false, 'posts_per_page' => $type === 'page' ? 200 : self::MAX_ITEMS * 2, 'no_found_rows' => true, ); if ($type === 'page') { $args['orderby'] = array('menu_order' => 'ASC', 'title' => 'ASC'); } else { $args['orderby'] = 'date'; $args['order'] = 'DESC'; } $posts = array(); foreach (get_posts($args) as $post) { if (!$this->is_excluded($post)) { $posts[] = $post; } } if ($type === 'page') { $posts = $this->order_pages($posts); } $out = array(); foreach (array_slice($posts, 0, self::MAX_ITEMS) as $post) { $title = str_replace(array('[', ']'), array('(', ')'), $this->clean(get_the_title($post))); if ($title === '') { continue; } $line = '- [' . $title . '](' . esc_url_raw(get_permalink($post)) . ')'; $desc = $this->describe($post); if ($desc !== '') { $line .= ': ' . $desc; } $out[] = $line; } return $out; } /** Home page first, then pages in the site's menus (menu order), then the rest. */ private function order_pages(array $pages) { $rank = array(); $front = (int) get_option('page_on_front'); if ($front) { $rank[$front] = 0; } $i = 1; if (function_exists('wp_get_nav_menus')) { foreach ((array) wp_get_nav_menus() as $menu) { foreach ((array) wp_get_nav_menu_items($menu) as $item) { if (isset($item->object, $item->object_id) && $item->object === 'page' && !isset($rank[(int) $item->object_id])) { $rank[(int) $item->object_id] = $i++; } } } } $pos = array(); foreach ($pages as $n => $p) { $pos[$p->ID] = $n; } usort($pages, function ($a, $b) use ($rank, $pos) { $ra = isset($rank[$a->ID]) ? $rank[$a->ID] : PHP_INT_MAX; $rb = isset($rank[$b->ID]) ? $rank[$b->ID] : PHP_INT_MAX; return $ra !== $rb ? ($ra < $rb ? -1 : 1) : $pos[$a->ID] - $pos[$b->ID]; }); return $pages; } /** Is this page/post left out of llms.txt? (filter: aeo_bob_lite_llms_exclude) */ public function is_excluded($post) { $excluded = in_array((int) $post->ID, $this->system_page_ids(), true) || preg_match(self::UTILITY_SLUGS, (string) $post->post_name) || $this->is_noindex($post->ID); return (bool) apply_filters('aeo_bob_lite_llms_exclude', (bool) $excluded, $post); } /** Shop / checkout / account pages from WooCommerce's and Easy Digital Downloads' settings. */ private function system_page_ids() { $ids = array(); foreach (array('woocommerce_cart_page_id', 'woocommerce_checkout_page_id', 'woocommerce_myaccount_page_id', 'woocommerce_pay_page_id', 'woocommerce_thanks_page_id', 'woocommerce_view_order_page_id', 'woocommerce_edit_address_page_id', 'woocommerce_lost_password_page_id', 'woocommerce_change_password_page_id', 'woocommerce_logout_page_id') as $opt) { $ids[] = (int) get_option($opt, 0); } $edd = get_option('edd_settings', array()); if (is_array($edd)) { foreach (array('purchase_page', 'success_page', 'failure_page', 'purchase_history_page', 'login_page', 'login_redirect_page') as $key) { $ids[] = isset($edd[$key]) ? (int) $edd[$key] : 0; } } return array_values(array_filter(array_map('intval', (array) apply_filters('aeo_bob_lite_llms_excluded_ids', array_values(array_filter($ids)))))); } /** noindex set in Yoast, Rank Math, All in One SEO 3/4 or SEOPress. */ private function is_noindex($post_id) { if ((string) get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true) === '1' || (string) get_post_meta($post_id, '_aioseop_noindex', true) === 'on' || (string) get_post_meta($post_id, '_seopress_robots_index', true) === 'yes') { return true; } $rank_math = get_post_meta($post_id, 'rank_math_robots', true); if (is_array($rank_math) && in_array('noindex', $rank_math, true)) { return true; } $aioseo = $this->aioseo_row($post_id); return $aioseo && !empty($aioseo['robots_noindex']) && empty($aioseo['robots_default']); } /** All in One SEO 4 keeps its per-post settings in its own table. */ private function aioseo_row($post_id) { global $wpdb; static $has_table = null; if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_row')) { return null; } $table = $wpdb->prefix . 'aioseo_posts'; if ($has_table === null) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $has_table = ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table); } if (!$has_table) { return null; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $row = $wpdb->get_row($wpdb->prepare("SELECT description, robots_default, robots_noindex FROM {$table} WHERE post_id = %d", $post_id), ARRAY_A); return is_array($row) ? $row : null; } /** A one-line description: the SEO meta description (AIOSEO, Yoast, Rank Math), else a clean excerpt. */ private function describe($post) { $aioseo = $this->aioseo_row($post->ID); $candidates = array($aioseo && isset($aioseo['description']) ? (string) $aioseo['description'] : ''); foreach (array('_aioseo_description', '_yoast_wpseo_metadesc', '_rank_math_description', '_aeo_bob_meta_description') as $key) { $candidates[] = (string) get_post_meta($post->ID, $key, true); } foreach ($candidates as $value) { // unresolved SEO-plugin templates (%%title%%, #post_excerpt, %excerpt%) are not descriptions if (trim($value) !== '' && !preg_match('/%%|#[a-z]+_[a-z_]+|%[a-z_]+%/i', $value)) { return $this->shorten($this->clean($value)); } } $text = $post->post_excerpt !== '' ? $post->post_excerpt : $post->post_content; $text = preg_replace('//s', ' ', (string) $text); $text = preg_replace('/<(script|style|form|noscript)\b[^>]*>.*?<\/\1>/is', ' ', $text); // never form fields or code $text = preg_replace('/]*>.*?<\/h[1-6]>/is', ' ', $text); // body text, not the page's own headings return $this->shorten($this->clean($text)); } /** Plain text: shortcodes and tags removed, entities decoded ("–" -> "–"), one line. */ public function clean($text) { $text = preg_replace('/\[\/?[a-zA-Z_][\w-]*(?:\s[^\]]*)?\]/', ' ', (string) $text); // shortcode tags, never run $text = preg_replace('/|<\/(?:p|div|li|h[1-6])>/i', ' ', $text); $text = html_entity_decode(wp_strip_all_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'); return trim(preg_replace('/[\s\x{00A0}]+/u', ' ', $text)); } /** At most 160 characters, cut at a sentence end when one fits, else at a word. */ private function shorten($text) { if (mb_strlen($text) <= 160) { return $text; } $cut = mb_substr($text, 0, 160); if (preg_match('/^(.{60,}[.!?])\s/u', $cut . ' ', $m)) { return trim($m[1]); } $cut = mb_substr($text, 0, 157); $space = mb_strrpos($cut, ' '); return rtrim($space > 80 ? mb_substr($cut, 0, $space) : $cut, " ,;:-–—") . '...'; } // ------------------------------------------------------------------ file /** /llms.txt at the site root (filterable for unusual setups). */ public function path() { if (!function_exists('get_home_path')) { require_once ABSPATH . 'wp-admin/includes/file.php'; } return (string) apply_filters('aeo_bob_lite_llms_path', trailingslashit(get_home_path()) . 'llms.txt'); } public function url() { return home_url('/llms.txt'); } /** * Who made the existing file: '' (none), lite (Lite wrote it - recorded hash, or its marker), * pro (AEO Bob Pro's headers) or other. */ public function owner() { $path = $this->path(); if (!file_exists($path)) { return ''; } $saved = get_option(self::OPTION, array()); $content = (string) @file_get_contents($path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file if ((is_array($saved) && !empty($saved['hash']) && hash_equals((string) $saved['hash'], md5($content))) || strpos($content, self::MARKER) !== false) { return 'lite'; } $text = preg_replace('/^\xEF\xBB\xBF/', '', $content); // a byte-order mark must not hide the first line return preg_match(self::PRO_SIGNATURES, $text) || preg_match('/\bAEO[\s_-]*Bob\b/i', $text) ? 'pro' : 'other'; } /** * v1.5.88: the plugin that made another llms.txt, when it says so ("Generated by All in One SEO * v5.0.2.1" on billiardaimtrainer.com), else ''. */ public function generator() { $path = $this->path(); if (!file_exists($path)) { return ''; } $head = (string) @file_get_contents($path, false, null, 0, 2000); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file foreach (array('All in One SEO', 'Yoast SEO', 'Rank Math', 'SEOPress', 'The SEO Framework', 'Website LLMs.txt') as $name) { if (stripos($head, $name) !== false) { return $name; } } return ''; } /** v1.5.88: one-line state for the AEO BOB Score box: "Not created yet" / "Created by AEO Bob" / "Existing file found (All in One SEO)". */ public function state_label($owner = null) { $owner = null === $owner ? $this->owner() : $owner; if ('' === $owner) { return __('Not created yet', 'aeo-bob-lite'); } if ('other' !== $owner) { return __('Created by AEO Bob', 'aeo-bob-lite'); } $by = $this->generator(); /* translators: %s: plugin that made the existing llms.txt, e.g. "All in One SEO" */ return '' !== $by ? sprintf(__('Existing file found (made by %s)', 'aeo-bob-lite'), $by) : __('Existing file found', 'aeo-bob-lite'); } /** v1.5.88: "Last generated: " for AEO Bob's own file, else ''. */ public function last_generated_text($status = null) { $status = null === $status ? $this->status() : $status; if (empty($status['generated_at'])) { return ''; } $when = function_exists('wp_date') ? wp_date(get_option('date_format') . ' ' . get_option('time_format'), $status['generated_at']) : gmdate('Y-m-d H:i', $status['generated_at']); /* translators: %s: date and time */ return sprintf(__('Last generated: %s', 'aeo-bob-lite'), $when); } /** @return array {exists, owner, ours (Lite's own), writable, generated_at, generated_by} */ public function status() { $path = $this->path(); $exists = file_exists($path); $owner = $this->owner(); $saved = get_option(self::OPTION, array()); $at = 0; $by = ''; if ($owner === 'lite') { $at = is_array($saved) && !empty($saved['generated_at']) ? (int) $saved['generated_at'] : (int) @filemtime($path); $by = 'AEO Bob Lite'; } elseif ($owner === 'pro') { $content = (string) @file_get_contents($path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file $at = preg_match('/^# (?:Generated|Last updated):\s*(\S+(?: \S+)?)/mi', $content, $m) ? (int) strtotime($m[1]) : (int) @filemtime($path); $by = 'AEO Bob Pro'; } return array( 'exists' => $exists, 'owner' => $owner, 'ours' => $owner === 'lite', 'writable' => $exists ? wp_is_writable($path) : wp_is_writable(dirname($path)), 'generated_at' => $at, 'generated_by' => $by, ); } /** The WordPress filesystem, when it can write without asking for FTP credentials. */ private function filesystem() { global $wp_filesystem; if (!function_exists('WP_Filesystem')) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if (!WP_Filesystem() || !$wp_filesystem) { return null; } return $wp_filesystem; } /** * Write /llms.txt. AEO Bob's own file (Lite or Pro) is replaced; another file only with $replace. * * @return array {code: created|updated|not_replaced|not_writable|failed, pages, posts, bytes, previous} */ public function write($replace = false) { $status = $this->status(); $result = array('code' => '', 'pages' => 0, 'posts' => 0, 'bytes' => 0, 'previous' => $status['owner']); if ($status['owner'] === 'other' && !$replace) { $result['code'] = 'not_replaced'; return $result; } if (!$status['writable']) { $result['code'] = 'not_writable'; return $result; } $fs = $this->filesystem(); if (!$fs) { $result['code'] = 'not_writable'; return $result; } $content = $this->build(); if (!$fs->put_contents($this->path(), $content, defined('FS_CHMOD_FILE') ? FS_CHMOD_FILE : 0644)) { $result['code'] = 'failed'; return $result; } $counts = $this->counts(); update_option(self::OPTION, array('hash' => md5($content), 'generated_at' => time(), 'pages' => $counts['pages'], 'posts' => $counts['posts'], 'bytes' => strlen($content)), false); return array_merge($result, $counts, array('code' => $status['exists'] ? 'updated' : 'created', 'bytes' => strlen($content))); } /** Is /llms.txt exactly what Lite last wrote (not edited since)? */ public function unedited() { $saved = get_option(self::OPTION, array()); $path = $this->path(); return is_array($saved) && !empty($saved['hash']) && file_exists($path) && hash_equals((string) $saved['hash'], (string) md5_file($path)); } /** Delete /llms.txt - only the file Lite wrote, unedited (anything else is left alone). */ public function delete() { if (!$this->unedited()) { return 'not_deleted'; } $fs = $this->filesystem(); if (!$fs || !$fs->delete($this->path())) { return 'failed'; } delete_option(self::OPTION); return 'deleted'; } // ------------------------------------------------------------------ admin /** admin-post.php?action=aeo_bob_lite_llms - Generate / Regenerate / Delete. Always ends with a result. */ public function handle_post() { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to do this.', 'aeo-bob-lite'), 403); } check_admin_referer(self::ACTION); $op = isset($_POST['op']) ? sanitize_key(wp_unslash($_POST['op'])) : ''; if ($op === 'delete') { $result = array('code' => $this->delete()); } else { $result = $this->write(!empty($_POST['replace'])); } // the result travels in a short-lived per-user note (and the code in the URL as a fallback), // so the page always shows what happened set_transient(self::result_key(), $result, 5 * MINUTE_IN_SECONDS); wp_safe_redirect(add_query_arg('aeo_llms', $result['code'], admin_url('admin.php?page=' . self::PAGE))); exit; } /** * v1.5.88: admin-ajax.php?action=aeo_bob_lite_generate_llms - Generate from the AEO BOB Score box * without leaving the editor. Another plugin's llms.txt is only replaced with replace=1: without * it the answer is {confirm: true} and the box asks "Replace the existing llms.txt? Yes / Cancel". */ public function ajax_generate() { if (!check_ajax_referer(self::AJAX, 'nonce', false)) { wp_send_json_error(array('code' => 'nonce', 'message' => __('Your session has expired. Reload the page and try again.', 'aeo-bob-lite')), 403); } if (!current_user_can('manage_options')) { wp_send_json_error(array('code' => 'forbidden', 'message' => __('Only administrators can generate llms.txt.', 'aeo-bob-lite')), 403); } // v1.5.92: like Pro's Generate, also build this post's schema (Article + FAQPage), so the // AEO score's Schema markup factor counts it. Independent of the llms.txt result. $schema = ''; $post_id = isset($_POST['post_id']) ? absint(wp_unslash($_POST['post_id'])) : 0; if ($post_id && current_user_can('edit_post', $post_id) && class_exists('AEO_Bob_Lite_Schema')) { $schema = AEO_Bob_Lite_Schema::get_instance()->generate($post_id); } $result = $this->write(!empty($_POST['replace'])); $status = $this->status(); $data = array( 'code' => $result['code'], 'state' => $this->state_label($status['owner']), 'lastGenerated' => $this->last_generated_text($status), 'viewUrl' => $status['exists'] ? $this->url() : '', 'schema' => $schema, // v1.5.92 ); if (in_array($result['code'], array('created', 'updated'), true)) { $data['message'] = sprintf( /* translators: 1: number of pages, 2: number of posts */ 'created' === $result['code'] ? __('llms.txt created: %1$d pages, %2$d posts', 'aeo-bob-lite') : __('llms.txt updated: %1$d pages, %2$d posts', 'aeo-bob-lite'), (int) $result['pages'], (int) $result['posts'] ); wp_send_json_success($data); } if ('not_replaced' === $result['code']) { $by = $this->generator(); $data['confirm'] = true; $data['message'] = '' !== $by /* translators: %s: plugin that made the existing llms.txt, e.g. "All in One SEO" */ ? sprintf(__('Replace the existing llms.txt (made by %s)?', 'aeo-bob-lite'), $by) : __('Replace the existing llms.txt?', 'aeo-bob-lite'); wp_send_json_error($data); } $notice = $this->notice_for($result); $data['message'] = $notice[1]; wp_send_json_error($data); } /** * v1.5.88: "Notify AI Models" where AEO Bob Pro has it (Step 3, directly under the Generate * button). v1.5.89: always shown to administrators, full width like Generate. Locked in Lite: * the button only opens a short note with "Upgrade to Pro" - Lite never sends any notification. */ public static function render_notify_locked() { ?>
= 1024 ? number_format_i18n($bytes / 1024, 1) . ' KB' : (int) $bytes . ' B'; } /** [type, text, with_view_link] for a result. */ public function notice_for(array $result) { $result = array_merge(array('pages' => 0, 'posts' => 0, 'bytes' => 0, 'previous' => ''), $result); $counts = sprintf( /* translators: 1: number of pages, 2: number of posts, 3: file size (e.g. "3.2 KB") */ __('%1$d pages, %2$d posts, %3$s', 'aeo-bob-lite'), (int) $result['pages'], (int) $result['posts'], $this->size((int) $result['bytes']) ); switch (isset($result['code']) ? $result['code'] : '') { case 'created': /* translators: %s: "14 pages, 10 posts, 3.2 KB" */ return array('success', sprintf(__('llms.txt created: %s.', 'aeo-bob-lite'), $counts), true); case 'updated': $text = $result['previous'] === 'pro' /* translators: %s: "14 pages, 10 posts, 3.2 KB" */ ? sprintf(__('llms.txt updated (it replaced the file AEO Bob Pro wrote): %s.', 'aeo-bob-lite'), $counts) /* translators: %s: "14 pages, 10 posts, 3.2 KB" */ : sprintf(__('llms.txt updated: %s.', 'aeo-bob-lite'), $counts); return array('success', $text, true); case 'deleted': return array('success', __('llms.txt was deleted.', 'aeo-bob-lite'), false); case 'not_deleted': return array('warning', __('Not deleted: llms.txt was changed since AEO Bob Lite wrote it (or was not written by Lite), so it is left alone.', 'aeo-bob-lite'), true); case 'not_replaced': return array('warning', __('Not replaced: your site already has an llms.txt that was not created by AEO Bob. Tick "Replace it" to overwrite it.', 'aeo-bob-lite'), true); case 'not_writable': return array('error', __('llms.txt could not be written: WordPress cannot write to your site root (file permissions, or your host asks for FTP details). Copy the preview below into a file named llms.txt in the folder that holds wp-config.php, or ask your host to make that folder writable.', 'aeo-bob-lite'), false); default: return array('error', __('llms.txt could not be written or deleted (an unexpected error). Nothing was changed - please try again.', 'aeo-bob-lite'), false); } } public function render_page() { if (!current_user_can('manage_options')) { return; } // The result of the last Generate/Delete: the per-user note, else the code in the URL. $result = get_transient(self::result_key()); if ($result !== false) { delete_transient(self::result_key()); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only result code from our own redirect $code = isset($_GET['aeo_llms']) ? sanitize_key(wp_unslash($_GET['aeo_llms'])) : ''; if (!is_array($result) && $code !== '') { $saved = get_option(self::OPTION, array()); $result = array_merge(is_array($saved) ? $saved : array(), array('code' => $code)); } $notice = is_array($result) ? $this->notice_for($result) : null; $status = $this->status(); // After a Generate, show the file that was actually written; before, what Generate will write. $from_file = $status['exists'] && $status['owner'] !== 'other'; $preview = $from_file ? (string) @file_get_contents($this->path()) : $this->build(); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file $when = $status['generated_at'] ? (function_exists('wp_date') ? wp_date(get_option('date_format') . ' ' . get_option('time_format'), $status['generated_at']) : gmdate('Y-m-d H:i', $status['generated_at'])) : ''; ?>


path())) : ?>

url()); ?> -
generator()) { /* translators: %s: plugin that made the existing llms.txt, e.g. "All in One SEO" */ echo ' ' . esc_html(sprintf(__('It was made by %s - if you replace it, turn off that plugin\'s llms.txt option so it does not write its own file again.', 'aeo-bob-lite'), $this->generator())); } } else { esc_html_e('Created by AEO Bob. Generate updates it.', 'aeo-bob-lite'); } ?>

unedited()) : ?>