global_settings = $global_settings; $this->resolver = $resolver; } /** * @param string $title Custom H1 override (M61) — falls back to the * Organization name, then the site title. * @param string $description Custom description override (M61) — falls * back to the site tagline (Settings → General). * @param string $footer Free since M69 Etap 1 (Pro-gated in M57) — the * WP.org review treats license-gated local code as * trialware, and a closing text block was never a * saleable feature. Empty string = no footer. */ public function build( string $intro, string $footer = '', string $title = '', string $description = '' ): string { if ( '' === trim( $title ) ) { $organization_name = trim( (string) $this->global_settings->get()['organization_name'] ); $title = '' !== $organization_name ? $organization_name : get_bloginfo( 'name' ); } if ( '' === trim( $description ) ) { $description = trim( (string) get_bloginfo( 'description' ) ); } $lines = array(); $lines[] = '# ' . trim( $title ); $lines[] = ''; if ( '' !== trim( $description ) ) { $lines[] = '> ' . trim( $description ); $lines[] = ''; } if ( '' !== trim( $intro ) ) { $lines[] = trim( $intro ); $lines[] = ''; } $pages = $this->link_list( $this->top_pages(), false ); if ( array() !== $pages ) { $lines[] = '## ' . __( 'Main pages', 'acez-seo' ); $lines[] = ''; $lines = array_merge( $lines, $pages ); $lines[] = ''; } // Publish dates only in THIS section (M57 scopes them to "Najnowsze // wpisy", where recency is the point; "Główne strony" are evergreen). // Free since M69 Etap 1 — was license-gated, which WP.org reads as // trialware; a date next to a link is not something anyone buys. $posts = $this->link_list( $this->recent_posts(), true ); if ( array() !== $posts ) { $lines[] = '## ' . __( 'Recent posts', 'acez-seo' ); $lines[] = ''; $lines = array_merge( $lines, $posts ); $lines[] = ''; } $content = implode( "\n", $lines ); $footer = trim( $footer ); if ( '' !== $footer ) { $content .= "\n\n" . $footer; } /** * Filters the generated llms.txt content. * * @param string $content */ return (string) apply_filters( 'acez_seo_llms_txt', $content ); } /** * @param array $posts * * @return string[] */ private function link_list( array $posts, bool $with_date ): array { $lines = array(); foreach ( $posts as $post ) { if ( $this->is_noindex( $post->ID ) || (bool) get_post_meta( $post->ID, MetaFields::LLMS_EXCLUDE, true ) ) { continue; } $title = get_the_title( $post ); if ( '' === $title ) { continue; } $line = sprintf( '- [%1$s](%2$s)', $title, get_permalink( $post ) ); // Optional link description (M57), per the llmstxt.org spec's // `- [title](url): description` form — the SAME resolved // description as the real tag, so the // two never drift (custom override → template → excerpt fallback). $description = trim( wp_strip_all_tags( $this->resolver->description( $post ) ) ); if ( '' !== $description ) { $line .= ': ' . $description; } if ( $with_date ) { $line .= ' (' . get_the_date( '', $post ) . ')'; } $lines[] = $line; } return $lines; } /** * A post explicitly set to noindex must not be fed to LLMs either — via * this plugin's OWN meta, or (M57) via whichever OTHER SEO plugin * `SeoPluginDetector` finds active: this plugin's own metatags already * defer to a conflicting SEO plugin (`ConflictNotice`), llms.txt must too. * The own-meta default is "index" when the row does not exist — only a * stored false is a deliberate opt-out. */ private function is_noindex( int $post_id ): bool { if ( metadata_exists( 'post', $post_id, MetaFields::ROBOTS_INDEX ) && ! (bool) get_post_meta( $post_id, MetaFields::ROBOTS_INDEX, true ) ) { return true; } return ForeignSeoRobots::is_noindex( $post_id ); } /** * Hierarchical supported post types (M57 — was hardcoded to core 'page'; * now covers any hierarchical CPT the plugin manages, via the SAME * `PostTypes::supported()` used throughout the rest of the plugin). * * @return array */ private function top_pages(): array { $types = array_filter( PostTypes::supported(), 'is_post_type_hierarchical' ); $pages = array(); // One get_pages() call per type, not a single call with 'post_type' // as an array — WP core supports the array form, but the PHPStan // stub's signature for get_pages() only accepts a single string, // so this avoids fighting the stub over a behavior core doesn't // even document as a hard requirement here. foreach ( $types as $type ) { $found = get_pages( array( 'post_type' => $type, 'parent' => 0, 'sort_column' => 'menu_order,post_title', 'number' => self::MAX_PAGES, ) ); if ( is_array( $found ) ) { $pages = array_merge( $pages, $found ); } } return array_slice( $pages, 0, self::MAX_PAGES ); } /** * Non-hierarchical supported post types (M57 — was hardcoded to core * 'post'; now covers CPTs like WooCommerce 'product' too). * * @return array */ private function recent_posts(): array { $types = array_values( array_filter( PostTypes::supported(), static fn( string $type ): bool => ! is_post_type_hierarchical( $type ) ) ); if ( array() === $types ) { return array(); } return get_posts( array( 'post_type' => $types, 'numberposts' => self::MAX_POSTS, 'post_status' => 'publish', ) ); } }