llmsTxtRecurrentAction, [ $this, 'generateLlmsTxt' ] ); add_action( $this->llmsTxtSingleAction, [ $this, 'generateLlmsTxt' ] ); // On Lite, heal a stale llms-full.txt a prior Pro install may have left behind. add_action( 'admin_init', [ $this, 'maybeCleanupStaleLlmsFullTxt' ] ); } /** * Schedules the LLMS file generation. * * @since 4.8.8 * * @return void */ public function scheduleRecurrentGenerationForLlmsTxt() { if ( ! aioseo()->options->sitemap->llms->enable || aioseo()->actionScheduler->isScheduled( $this->llmsTxtRecurrentAction ) ) { return; } aioseo()->actionScheduler->scheduleRecurrent( $this->llmsTxtRecurrentAction, 10, DAY_IN_SECONDS ); } /** * Schedules a single LLMS file generation. * * @since 4.8.8 * * @return void */ public function scheduleSingleGenerationForLlmsTxt() { if ( ! aioseo()->options->sitemap->llms->enable || aioseo()->actionScheduler->isScheduled( $this->llmsTxtSingleAction ) ) { return; } aioseo()->actionScheduler->scheduleSingle( $this->llmsTxtSingleAction, 10 ); } /** * Schedules a single regeneration of every static LLMs file. * * @since 5.0.3 * * @return void */ public function scheduleRegeneration() { $this->scheduleSingleGenerationForLlmsTxt(); } /** * Sets the site info. * * @since 4.8.4 * * @return void */ protected function setSiteInfo() { $isMultisite = is_multisite(); // Check for LLMS custom title setting $llmsTitle = aioseo()->options->sitemap->llms->advancedSettings->title; if ( ! empty( $llmsTitle ) ) { // Use LLMS title with hashtag tag replacement $this->title = aioseo()->tags->replaceTags( $llmsTitle ); } else { // Fallback to default site title $this->title = $isMultisite ? get_blog_option( get_current_blog_id(), 'blogname' ) : get_bloginfo( 'name' ); $this->title = $this->title ?: aioseo()->meta->title->getHomePageTitle(); } // Check for LLMS custom description setting $llmsDescription = aioseo()->options->sitemap->llms->advancedSettings->description; if ( ! empty( $llmsDescription ) ) { // Use LLMS description with hashtag tag replacement $this->description = aioseo()->tags->replaceTags( $llmsDescription ); } else { // Fallback to default site description $this->description = $isMultisite ? get_blog_option( get_current_blog_id(), 'blogdescription' ) : get_bloginfo( 'description' ); $this->description = $this->description ?: aioseo()->meta->description->getHomePageDescription(); } $this->link = $isMultisite ? get_blog_option( get_current_blog_id(), 'siteurl' ) : home_url(); $this->version = aioseo()->helpers->getAioseoVersion(); } /** * Generates the LLMS.txt file. * * @since 4.8.4 * * @return void */ public function generateLlmsTxt() { // On Lite, remove any stale llms-full.txt (and its leftover actions) a prior Pro install // left behind, so the generation cron heals it without needing a settings change. $this->maybeCleanupStaleLlmsFullTxt(); // Disabled: remove the static file and stop. Read via all() (empty() on the magic option // property misreports - the options __isset() returns value-truthiness, so the original // isset() guard was dead code) and null-check $sitemap, which reads null mid-reset. $sitemap = aioseo()->options->sitemap; $llmsOpts = $sitemap ? $sitemap->llms->all() : []; if ( empty( $llmsOpts['enable'] ) ) { aioseo()->actionScheduler->unschedule( $this->llmsTxtSingleAction ); aioseo()->actionScheduler->unschedule( $this->llmsTxtRecurrentAction ); $this->deleteLlmsFile(); return; } $fs = aioseo()->core->fs; $file = $this->getFilePath(); // Generate the full content $this->setSiteInfo(); $content = $this->getHeader(); $content .= $this->getSitemapUrl(); $content .= $this->getContent(); // Add UTF-8 BOM to help browsers recognize the encoding $content = "\xEF\xBB\xBF" . $content; $fs->putContents( $file, $content ); } /** * Gets the header section of the llms.txt file. * * @since 4.8.4 * @version 5.0.2 Emit the H1 first and move the attribution into a blockquote below it (llms.txt spec). * @version 5.0.2 Decode the title instead of HTML-escaping it; this is a plain-text file. * @version 5.0.3 Include the site description between the H1 and the attribution. * * @return string */ protected function getHeader( $llmsFull = false ) { $fileName = $llmsFull ? 'llms-full.txt' : 'llms.txt'; $generatedBy = sprintf( /* Translators: 1 - The plugin name ("All in One SEO"), 2 - The version number, 3 - The file name. */ esc_html__( 'Generated by %1$s v%2$s, this is an %3$s file, used by LLMs to index the site.', 'all-in-one-seo-pack' ), esc_html( AIOSEO_PLUGIN_NAME ), esc_html( aioseo()->version ), esc_html( $fileName ) ); // The spec requires the file to begin with the H1, and the summary blockquote directly below it. $header = ''; if ( $this->title ) { $header .= '# ' . aioseo()->helpers->decodeHtmlEntities( $this->title ) . "\n\n"; } $header .= $this->getSiteDescription(); $header .= "> {$generatedBy}\n\n"; return $header; } /** * Gets the site description section of the llms.txt file. * * @since 4.8.4 * @version 5.0.2 Decode HTML entities in the description; this is a plain-text file. * @version 5.0.3 Strip only a `>` followed by whitespace as the user's blockquote marker. * * @return string */ protected function getSiteDescription() { // The spec puts the summary in a blockquote; a marker the user typed themselves must not nest a second one. // Only a `>` followed by whitespace reads as that marker; one glued to the text (`>50%`) is content. $description = preg_replace( '/^>\s+/', '', trim( aioseo()->helpers->decodeHtmlEntities( $this->description ) ) ); if ( ! $description ) { return ''; } return "> {$description}\n\n"; } /** * Gets the sitemap link section of the llms.txt file. * * @since 4.8.4 * * @return string */ protected function getSitemapUrl() { if ( ! aioseo()->options->sitemap->general->enable ) { return ''; } $sitemapUrl = aioseo()->sitemap->helpers->getUrl( 'general' ); return "## Sitemaps\n\n- [XML Sitemap]({$sitemapUrl}): Contains all public & indexable URLs for this website.\n\n"; } /** * Gets the recent content section of the llms.txt file. * * @since 4.8.4 * @version 5.0.2 Apply the robots tiers via isPostTypeIncludable()/isTaxonomyIncludable(). * * @param bool $llmsFull Whether to include the llms-full.txt file. * @return string The content of the llms.txt file. */ protected function getContent( $llmsFull = false ) { // Get LLMS post types settings $includeAllPostTypes = aioseo()->options->sitemap->llms->advancedSettings->postTypes->all; $includedPostTypes = aioseo()->options->sitemap->llms->advancedSettings->postTypes->included; $includeAllTaxonomies = aioseo()->options->sitemap->llms->advancedSettings->taxonomies->all; $includedTaxonomies = aioseo()->options->sitemap->llms->advancedSettings->taxonomies->included; // Determine which post types to include if ( $includeAllPostTypes ) { // Include all public post types except attachments $postTypes = array_filter( aioseo()->helpers->getPublicPostTypes( true ), function( $type ) { return 'attachment' !== $type; } ); } else { // Only include the specifically selected post types, but still exclude attachments $postTypes = array_filter( $includedPostTypes, function( $type ) { return 'attachment' !== $type; } ); } if ( $includeAllTaxonomies ) { $taxonomies = aioseo()->helpers->getPublicTaxonomies( true ); } else { $taxonomies = $includedTaxonomies; } // includedPostTypes()/includedTaxonomies() read their lists from sitemap->{type}, which this // surface keeps under advancedSettings, so the tiers are applied here instead. $postTypes = array_filter( $postTypes, function( $postType ) { return aioseo()->sitemap->helpers->isPostTypeIncludable( $postType ); } ); $taxonomies = array_filter( $taxonomies, function( $taxonomy ) { return aioseo()->sitemap->helpers->isTaxonomyIncludable( $taxonomy ); } ); $originalSitemapType = aioseo()->sitemap->type; $originalLinksPerIndex = aioseo()->sitemap->linksPerIndex; $originalIndexes = aioseo()->sitemap->indexes; aioseo()->sitemap->type = 'llms'; aioseo()->sitemap->indexes = true; aioseo()->sitemap->linksPerIndex = aioseo()->options->sitemap->llms->advancedSettings->linksPerPostTax ? aioseo()->options->sitemap->llms->advancedSettings->linksPerPostTax : 20; $content = ''; foreach ( $postTypes as $postType ) { $postTypeObject = get_post_type_object( $postType ); if ( ! $postTypeObject ) { continue; } $posts = aioseo()->sitemap->query->posts( $postType ); if ( ! empty( $posts ) ) { $content .= '## ' . $postTypeObject->labels->name . "\n\n"; foreach ( $posts as $postObject ) { $post = get_post( $postObject->ID ); if ( ! is_a( $post, 'WP_Post' ) ) { continue; } aioseo()->helpers->setWpQueryPost( $post ); $content .= $this->getPostContent( $post, $llmsFull ); aioseo()->helpers->restoreWpQuery(); } $content .= "\n"; } } // Initialize sitemap settings again for terms aioseo()->sitemap->type = 'llms'; aioseo()->sitemap->indexes = true; aioseo()->sitemap->linksPerIndex = aioseo()->options->sitemap->llms->advancedSettings->linksPerPostTax ? aioseo()->options->sitemap->llms->advancedSettings->linksPerPostTax : 20; // Get recent terms for each taxonomy using sitemap query foreach ( $taxonomies as $taxonomy ) { $taxonomyObject = get_taxonomy( $taxonomy ); if ( ! $taxonomyObject ) { continue; } $terms = aioseo()->sitemap->query->terms( $taxonomy ); if ( ! empty( $terms ) ) { $content .= '## ' . $taxonomyObject->labels->name . "\n\n"; foreach ( $terms as $termObject ) { if ( is_object( $termObject ) && ! empty( $termObject->term_id ) ) { $term = get_term( $termObject->term_id, $taxonomy ); if ( is_wp_error( $term ) ) { continue; } aioseo()->helpers->setWpQueryTerm( $term, $taxonomy ); $content .= $this->getTermContent( $term, $taxonomy, $llmsFull ); aioseo()->helpers->restoreWpQuery(); } } $content .= "\n"; } } // Restore original sitemap settings aioseo()->sitemap->type = $originalSitemapType; aioseo()->sitemap->linksPerIndex = $originalLinksPerIndex; aioseo()->sitemap->indexes = $originalIndexes; return $content; } /** * Gets the post content section of the llms.txt file. * * @since 4.8.8 * * @param \WP_Post $post The post object. * @param bool $llmsFull Whether to include the llms-full.txt file. * @return string The content of the llms.txt file. */ protected function getPostContent( $post, $llmsFull = false ) { // phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable $title = apply_filters( 'aioseo_llms_post_title', $post->post_title, $post ); $content = '- [' . aioseo()->helpers->decodeHtmlEntities( $title ) . '](' . aioseo()->helpers->decodeUrl( get_permalink( $post ) ) . ')'; $description = aioseo()->meta->description->getPostDescription( $post ); $description = apply_filters( 'aioseo_llms_post_description', $description, $post ); if ( ! empty( $description ) ) { $content .= ': ' . aioseo()->helpers->decodeHtmlEntities( $description ); } $content .= "\n"; return $content; } /** * Gets the term content section of the llms.txt file. * * @since 4.9.3 * * @param \WP_Term $term The term object. * @param string $taxonomy The taxonomy name. * @param bool $llmsFull Whether to include the llms-full.txt file. * @return string The content of the llms.txt file. */ protected function getTermContent( $term, $taxonomy, $llmsFull = false ) { // phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable $title = apply_filters( 'aioseo_llms_term_title', $term->name, $term ); $content = '- [' . aioseo()->helpers->decodeHtmlEntities( $title ) . '](' . aioseo()->helpers->decodeUrl( get_term_link( $term, $taxonomy ) ) . ')'; $description = aioseo()->meta->description->getTermDescription( $term ); $description = apply_filters( 'aioseo_llms_term_description', $description, $term ); if ( ! empty( $description ) ) { $content .= ': ' . aioseo()->helpers->decodeHtmlEntities( $description ); } $content .= "\n"; return $content; } /** * Deletes the LLMS.txt file. * * @since 4.8.8 * * @return void */ public function deleteLlmsFile() { $fs = aioseo()->core->fs; $file = $this->getFilePath(); if ( $fs->isWpfsValid() ) { $fs->fs->delete( $file, false, 'f' ); } } /** * Deletes the LLMS-full.txt file. * * @since 4.8.8 * @version 5.0.3 Moved from {@see \AIOSEO\Plugin\Pro\Llms\Llms}. * * @return void */ public function deleteLlmsFullTxt() { $fs = aioseo()->core->fs; $file = $this->getFilePath( true ); if ( $fs->isWpfsValid() ) { $fs->fs->delete( $file, false, 'f' ); } } /** * Unschedules the llms-full.txt generation actions and deletes the static file. * * @since 5.0.3 * * @return void */ public function cleanupLlmsFullTxt() { aioseo()->actionScheduler->unschedule( $this->llmsFullTxtSingleAction ); aioseo()->actionScheduler->unschedule( $this->llmsFullTxtRecurrentAction ); $this->deleteLlmsFullTxt(); } /** * Removes a stale llms-full.txt on Lite (e.g. left by a prior Pro install after an in-place * downgrade). Heals the steady states the disable/reset/uninstall transitions don't reach. * * @since 5.0.3 * * @return void */ public function maybeCleanupStaleLlmsFullTxt() { // Lite never generates llms-full.txt, so any file present is stale. Gate on existence so // normal Lite sites pay only a single stat and downgrade-affected sites clean up once. if ( aioseo()->pro || ! file_exists( $this->getFilePath( true ) ) ) { return; } $this->cleanupLlmsFullTxt(); } /** * Gets the file path for the llms.txt or llms-full.txt file. * * Uses `dirname( WP_CONTENT_DIR )` instead of `ABSPATH` to support non-standard * WordPress installations where `ABSPATH` doesn't point to the web root. * * @since 4.9.5 * * @param bool $full Whether to get the full version path. * @return string The file path. */ public function getFilePath( $full = false ) { // Use the hardcoded filename to avoid conflicts with other plugins that filter `sanitize_file_name`; // since it's hardcoded, we don't need to sanitize it. $filename = $full ? 'llms-full.txt' : 'llms.txt'; return trailingslashit( dirname( WP_CONTENT_DIR ) ) . $filename; } }