first commit

This commit is contained in:
User A0264400
2026-04-01 23:20:16 +03:00
commit a766acdc90
23071 changed files with 4933189 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\Llms_Txt\Application\Available_Posts;
use Yoast\WP\SEO\Llms_Txt\Domain\Available_Posts\Data_Provider\Available_Posts_Data;
use Yoast\WP\SEO\Llms_Txt\Domain\Available_Posts\Data_Provider\Available_Posts_Repository_Interface;
use Yoast\WP\SEO\Llms_Txt\Domain\Available_Posts\Data_Provider\Data_Container;
use Yoast\WP\SEO\Llms_Txt\Domain\Available_Posts\Data_Provider\Parameters;
use Yoast\WP\SEO\Llms_Txt\Infrastructure\Content\Automatic_Post_Collection;
/**
* The data provider for available posts.
*/
class Available_Posts_Repository implements Available_Posts_Repository_Interface {
/**
* The automatic post collection.
*
* @var Automatic_Post_Collection $automatic_post_collection
*/
private $automatic_post_collection;
/**
* The constructor.
*
* @param Automatic_Post_Collection $automatic_post_collection The automatic post collection.
*/
public function __construct(
Automatic_Post_Collection $automatic_post_collection
) {
$this->automatic_post_collection = $automatic_post_collection;
}
/**
* Gets the available posts' data.
*
* @param Parameters $parameters The parameters to use for getting the available posts.
*
* @return Data_Container
*/
public function get_posts( Parameters $parameters ): Data_Container {
$available_posts = $this->automatic_post_collection->get_recent_posts( $parameters->get_post_type(), 100, $parameters->get_search_filter(), true );
$available_posts_data_container = new Data_Container();
foreach ( $available_posts as $available_post ) {
$available_posts_data_container->add_data( new Available_Posts_Data( $available_post ) );
}
return $available_posts_data_container;
}
}

View File

@@ -0,0 +1,71 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Application\Configuration;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\Post_Type_Helper;
use Yoast\WP\SEO\Llms_Txt\Application\Health_Check\File_Runner;
/**
* Responsible for the llms.txt configuration.
*/
class Llms_Txt_Configuration {
/**
* Runs the health check.
*
* @var File_Runner
*/
private $runner;
/**
* The post type helper.
*
* @var Post_Type_Helper
*/
private $post_type_helper;
/**
* The options helper.
*
* @var Options_Helper
*/
private $options_helper;
/**
* The constructor.
*
* @param File_Runner $runner The File_Generation health check runner.
* @param Post_Type_Helper $post_type_helper The post type helper.
* @param Options_Helper $options_helper The options helper.
*/
public function __construct(
File_Runner $runner,
Post_Type_Helper $post_type_helper,
Options_Helper $options_helper
) {
$this->runner = $runner;
$this->post_type_helper = $post_type_helper;
$this->options_helper = $options_helper;
}
/**
* Returns a configuration
*
* @return array<string, array<string>|array<string, string|array<string, array<string, int>>>>
*/
public function get_configuration(): array {
$this->runner->run();
$configuration = [
'generationFailure' => ! $this->runner->is_successful(),
'generationFailureReason' => $this->runner->get_generation_failure_reason(),
'llmsTxtUrl' => \home_url( 'llms.txt' ),
'disabledPageIndexables' => ( $this->post_type_helper->is_of_indexable_post_type( 'page' ) === false ),
'otherIncludedPagesLimit' => $this->options_helper->get_other_included_pages_limit(),
];
return $configuration;
}
}

View File

@@ -0,0 +1,111 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Application\File\Commands;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Llms_Txt\Application\Markdown_Builders\Markdown_Builder;
use Yoast\WP\SEO\Llms_Txt\Infrastructure\File\WordPress_File_System_Adapter;
use Yoast\WP\SEO\Llms_Txt\Infrastructure\File\WordPress_Llms_Txt_Permission_Gate;
/**
* Handles the population of the llms.txt.
*/
class Populate_File_Command_Handler {
public const CONTENT_HASH_OPTION = 'wpseo_llms_txt_content_hash';
public const GENERATION_FAILURE_OPTION = 'wpseo_llms_txt_file_failure';
/**
* The permission gate.
*
* @var WordPress_Llms_Txt_Permission_Gate $permission_gate
*/
private $permission_gate;
/**
* The options helper.
*
* @var Options_Helper
*/
private $options_helper;
/**
* The file system adapter.
*
* @var WordPress_File_System_Adapter
*/
private $file_system_adapter;
/**
* The markdown builder.
*
* @var Markdown_Builder
*/
private $markdown_builder;
/**
* Constructor.
*
* @param Options_Helper $options_helper The options helper.
* @param WordPress_File_System_Adapter $file_system_adapter The file system adapter.
* @param Markdown_Builder $markdown_builder The markdown builder.
* @param WordPress_Llms_Txt_Permission_Gate $permission_gate The editing permission checker.
*/
public function __construct(
Options_Helper $options_helper,
WordPress_File_System_Adapter $file_system_adapter,
Markdown_Builder $markdown_builder,
WordPress_Llms_Txt_Permission_Gate $permission_gate
) {
$this->options_helper = $options_helper;
$this->file_system_adapter = $file_system_adapter;
$this->markdown_builder = $markdown_builder;
$this->permission_gate = $permission_gate;
}
/**
* Runs the command.
*
* @return void
*/
public function handle() {
if ( $this->permission_gate->is_managed_by_yoast_seo() ) {
$content = $this->markdown_builder->render();
$content = $this->encode_content( $content );
$file_written = $this->file_system_adapter->set_file_content( $content );
if ( $file_written ) {
// Maybe move this to a class if we need to handle this option more often.
\update_option( self::CONTENT_HASH_OPTION, \md5( $content ) );
\delete_option( self::GENERATION_FAILURE_OPTION );
return;
}
\update_option( self::GENERATION_FAILURE_OPTION, 'filesystem_permissions' );
return;
}
\update_option( self::GENERATION_FAILURE_OPTION, 'not_managed_by_yoast_seo' );
}
/**
* Encodes the content by prepending it with the Byte Order Mark (BOM) for UTF-8.
*
* @param string $content The content to encode.
*
* @return string
*/
private function encode_content( string $content ): string {
/**
* Filter: 'wpseo_llmstxt_encoding_prefix' - Allows editing the Byte Order Mark (BOM) for UTF-8 we prepend to the llmst.txt file.
*
* @param string $encoding_prefix The Byte Order Mark (BOM) for UTF-8 we prepend to the llmst.txt file.
*/
$encoding_prefix = \apply_filters( 'wpseo_llmstxt_encoding_prefix', "\xEF\xBB\xBF" );
return $encoding_prefix . $content;
}
}

View File

@@ -0,0 +1,69 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Application\File\Commands;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Llms_Txt\Infrastructure\File\WordPress_File_System_Adapter;
use Yoast\WP\SEO\Llms_Txt\Infrastructure\File\WordPress_Llms_Txt_Permission_Gate;
/**
* Handles the removal of the llms.txt
*/
class Remove_File_Command_Handler {
/**
* The options helper.
*
* @var Options_Helper
*/
private $options_helper;
/**
* The file system adapter.
*
* @var WordPress_File_System_Adapter
*/
private $file_system_adapter;
/**
* The permission gate.
*
* @var WordPress_Llms_Txt_Permission_Gate $permission_gate
*/
private $permission_gate;
/**
* Constructor.
*
* @param Options_Helper $options_helper The options helper.
* @param WordPress_File_System_Adapter $file_system_adapter The file system adapter.
* @param WordPress_Llms_Txt_Permission_Gate $permission_gate The permission gate.
*/
public function __construct(
Options_Helper $options_helper,
WordPress_File_System_Adapter $file_system_adapter,
WordPress_Llms_Txt_Permission_Gate $permission_gate
) {
$this->options_helper = $options_helper;
$this->file_system_adapter = $file_system_adapter;
$this->permission_gate = $permission_gate;
}
/**
* Runs the command.
*
* @return void
*/
public function handle() {
if ( $this->permission_gate->is_managed_by_yoast_seo() ) {
$file_removed = $this->file_system_adapter->remove_file();
if ( $file_removed ) {
// Maybe move this to a class if we need to handle this option more often.
\update_option( Populate_File_Command_Handler::CONTENT_HASH_OPTION, '' );
}
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Application\File;
use WPSEO_Shortlinker;
use Yoast\WP\SEO\Llms_Txt\Application\File\Commands\Populate_File_Command_Handler;
use Yoast\WP\SEO\Presenters\Abstract_Presenter;
/**
* Class File_Failure_Notification_Presenter.
*/
class File_Failure_Notification_Presenter extends Abstract_Presenter {
/**
* Returns the notification as an HTML string.
*
* @return string The notification in an HTML string representation.
*/
public function present() {
$notification_text = '<p>';
$notification_text .= $this->get_message();
$notification_text .= '</p>';
return $notification_text;
}
/**
* Returns the message to show.
*
* @return string The message.
*/
protected function get_message() {
$reason = \get_option( Populate_File_Command_Handler::GENERATION_FAILURE_OPTION, false );
switch ( $reason ) {
case 'not_managed_by_yoast_seo':
$message = \sprintf(
/* translators: 1: Link start tag to the WordPress Reading Settings page, 2: Link closing tag. */
\esc_html__( 'An existing llms.txt file wasn\'t created by Yoast or has been edited manually. Yoast won\'t overwrite it. %1$sDelete it manually%2$s or turn off this feature.', 'wordpress-seo' ),
'<a href="' . \esc_url( WPSEO_Shortlinker::get( 'https://yoa.st/llms-txt-file-deletion' ) ) . '" target="_blank" rel="noopener noreferrer">',
'</a>',
);
break;
case 'filesystem_permissions':
$message =
\__( 'You have activated the Yoast llms.txt feature, but we couldn\'t generate an llms.txt file. It looks like there aren\'t sufficient permissions on the web server\'s filesystem.', 'wordpress-seo' );
break;
default:
$message = \__( 'You have activated the Yoast llms.txt feature, but we couldn\'t generate an llms.txt file, for unknown reasons.', 'wordpress-seo' );
break;
}
return \sprintf(
'<strong>%1$s</strong> %2$s',
\esc_html__( 'Your llms.txt file couldn\'t be auto-generated', 'wordpress-seo' ),
$message,
);
}
}

View File

@@ -0,0 +1,79 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Application\File;
use Yoast\WP\SEO\Helpers\Options_Helper;
/**
* Responsible for scheduling and unscheduling the cron.
*/
class Llms_Txt_Cron_Scheduler {
/**
* The name of the cron job.
*/
public const LLMS_TXT_POPULATION = 'wpseo_llms_txt_population';
/**
* The options helper.
*
* @var Options_Helper
*/
private $options_helper;
/**
* Constructor.
*
* @param Options_Helper $options_helper The options helper.
*/
public function __construct(
Options_Helper $options_helper
) {
$this->options_helper = $options_helper;
}
/**
* Schedules the llms txt population cron a week from now.
*
* @return void
*/
public function schedule_weekly_llms_txt_population(): void {
if ( $this->options_helper->get( 'enable_llms_txt', false ) !== true ) {
return;
}
if ( ! \wp_next_scheduled( self::LLMS_TXT_POPULATION ) ) {
\wp_schedule_event( ( \time() + \WEEK_IN_SECONDS ), 'weekly', self::LLMS_TXT_POPULATION );
}
}
/**
* Schedules the llms txt population cron 5 minutes from now.
*
* @return void
*/
public function schedule_quick_llms_txt_population(): void {
if ( $this->options_helper->get( 'enable_llms_txt', false ) !== true ) {
return;
}
if ( \wp_next_scheduled( self::LLMS_TXT_POPULATION ) ) {
$this->unschedule_llms_txt_population();
}
\wp_schedule_event( ( \time() + ( \MINUTE_IN_SECONDS * 5 ) ), 'weekly', self::LLMS_TXT_POPULATION );
}
/**
* Unschedules the llms txt population cron.
*
* @return void
*/
public function unschedule_llms_txt_population() {
$scheduled = \wp_next_scheduled( self::LLMS_TXT_POPULATION );
if ( $scheduled ) {
\wp_unschedule_event( $scheduled, self::LLMS_TXT_POPULATION );
}
}
}

View File

@@ -0,0 +1,77 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Application\Health_Check;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Llms_Txt\User_Interface\Health_Check\File_Reports;
use Yoast\WP\SEO\Services\Health_Check\Health_Check;
/**
* Fails when the llms.txt file fails to be generated.
*/
class File_Check extends Health_Check {
/**
* Runs the health check.
*
* @var File_Runner
*/
private $runner;
/**
* Generates WordPress-friendly health check results.
*
* @var File_Reports
*/
private $reports;
/**
* The Options_Helper instance.
*
* @var Options_Helper
*/
private $options_helper;
/**
* Constructor.
*
* @param File_Runner $runner The object that implements the actual health check.
* @param File_Reports $reports The object that generates WordPress-friendly results.
* @param Options_Helper $options_helper The options helper.
*/
public function __construct(
File_Runner $runner,
File_Reports $reports,
Options_Helper $options_helper
) {
$this->runner = $runner;
$this->reports = $reports;
$this->options_helper = $options_helper;
$this->reports->set_test_identifier( $this->get_test_identifier() );
$this->set_runner( $this->runner );
}
/**
* Returns the WordPress-friendly health check result.
*
* @return string[] The WordPress-friendly health check result.
*/
protected function get_result() {
if ( $this->runner->is_successful() ) {
return $this->reports->get_success_result();
}
return $this->reports->get_generation_failure_result( $this->runner->get_generation_failure_reason() );
}
/**
* Returns true when the llms.txt feature is disabled.
*
* @return bool Whether the health check should be excluded from the results.
*/
public function is_excluded() {
return $this->options_helper->get( 'enable_llms_txt', false ) !== true;
}
}

View File

@@ -0,0 +1,47 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Application\Health_Check;
use Yoast\WP\SEO\Llms_Txt\Application\File\Commands\Populate_File_Command_Handler;
use Yoast\WP\SEO\Services\Health_Check\Runner_Interface;
/**
* Runs the File_Generation health check.
*/
class File_Runner implements Runner_Interface {
/**
* Is set to non-empty string when the llms.txt file failed to (re-)generate.
*
* @var bool
*/
private $generation_failure_reason = '';
/**
* Runs the health check.
*
* @return void
*/
public function run() {
$this->generation_failure_reason = \get_option( Populate_File_Command_Handler::GENERATION_FAILURE_OPTION, '' );
}
/**
* Returns true if there is no generation failure reason.
*
* @return bool The boolean indicating if the health check was succesful.
*/
public function is_successful() {
return $this->generation_failure_reason === '';
}
/**
* Returns the generation failure reason.
*
* @return string The boolean indicating if the health check was succesful.
*/
public function get_generation_failure_reason(): string {
return $this->generation_failure_reason;
}
}

View File

@@ -0,0 +1,40 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Application\Markdown_Builders;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Sections\Description;
use Yoast\WP\SEO\Llms_Txt\Infrastructure\Markdown_Services\Description_Adapter;
/**
* The builder of the description section.
*/
class Description_Builder {
/**
* The description adapter.
*
* @var Description_Adapter
*/
protected $description_adapter;
/**
* Class constructor.
*
* @param Description_Adapter $description_adapter The description adapter.
*/
public function __construct(
Description_Adapter $description_adapter
) {
$this->description_adapter = $description_adapter;
}
/**
* Builds the description section.
*
* @return Description The description section.
*/
public function build_description(): Description {
return $this->description_adapter->get_description();
}
}

View File

@@ -0,0 +1,35 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Application\Markdown_Builders;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Sections\Intro;
/**
* The builder of the intro section.
*/
class Intro_Builder {
/**
* Gets the plugin version that generated the llms.txt file.
*
* @return string The plugin version that generated the llms.txt file.
*/
protected function get_generator_version(): string {
return 'Yoast SEO v' . \WPSEO_VERSION;
}
/**
* Builds the intro section.
*
* @return Intro The intro section.
*/
public function build_intro(): Intro {
$intro_content = \sprintf(
'Generated by %s, this is an llms.txt file, meant for consumption by LLMs.',
$this->get_generator_version(),
);
return new Intro( $intro_content, [] );
}
}

View File

@@ -0,0 +1,54 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Application\Markdown_Builders;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Sections\Link_List;
use Yoast\WP\SEO\Llms_Txt\Infrastructure\Markdown_Services\Content_Types_Collector;
use Yoast\WP\SEO\Llms_Txt\Infrastructure\Markdown_Services\Terms_Collector;
/**
* The builder of the link list sections.
*/
class Link_Lists_Builder {
/**
* The content types collector.
*
* @var Content_Types_Collector
*/
private $content_types_collector;
/**
* The terms collector.
*
* @var Terms_Collector
*/
private $terms_collector;
/**
* Constructs the class.
*
* @param Content_Types_Collector $content_types_collector The content types collector.
* @param Terms_Collector $terms_collector The terms collector.
*/
public function __construct(
Content_Types_Collector $content_types_collector,
Terms_Collector $terms_collector
) {
$this->content_types_collector = $content_types_collector;
$this->terms_collector = $terms_collector;
}
/**
* Builds the link list sections.
*
* @return Link_List[] The link list sections.
*/
public function build_link_lists(): array {
return \array_merge(
$this->content_types_collector->get_content_types_lists(),
$this->terms_collector->get_terms_lists(),
);
}
}

View File

@@ -0,0 +1,114 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Application\Markdown_Builders;
use Yoast\WP\SEO\Llms_Txt\Application\Markdown_Escaper;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Llms_Txt_Renderer;
/**
* The builder of the markdown file.
*/
class Markdown_Builder {
/**
* The renderer of the LLMs.txt file.
*
* @var Llms_Txt_Renderer
*/
protected $llms_txt_renderer;
/**
* The intro builder.
*
* @var Intro_Builder
*/
protected $intro_builder;
/**
* The title builder.
*
* @var Title_Builder
*/
protected $title_builder;
/**
* The description builder.
*
* @var Description_Builder
*/
protected $description_builder;
/**
* The link lists builder.
*
* @var Link_Lists_Builder
*/
protected $link_lists_builder;
/**
* The markdown escaper.
*
* @var Markdown_Escaper
*/
protected $markdown_escaper;
/**
* The optional link list builder.
*
* @var Optional_Link_List_Builder
*/
protected $optional_link_list_builder;
/**
* The constructor.
*
* @param Llms_Txt_Renderer $llms_txt_renderer The renderer of the LLMs.txt file.
* @param Intro_Builder $intro_builder The intro builder.
* @param Title_Builder $title_builder The title builder.
* @param Description_Builder $description_builder The description builder.
* @param Link_Lists_Builder $link_lists_builder The link lists builder.
* @param Markdown_Escaper $markdown_escaper The markdown escaper.
* @param Optional_Link_List_Builder $optional_link_list_builder The optional link list builder.
*/
public function __construct(
Llms_Txt_Renderer $llms_txt_renderer,
Intro_Builder $intro_builder,
Title_Builder $title_builder,
Description_Builder $description_builder,
Link_Lists_Builder $link_lists_builder,
Markdown_Escaper $markdown_escaper,
Optional_Link_List_Builder $optional_link_list_builder
) {
$this->llms_txt_renderer = $llms_txt_renderer;
$this->intro_builder = $intro_builder;
$this->title_builder = $title_builder;
$this->description_builder = $description_builder;
$this->link_lists_builder = $link_lists_builder;
$this->markdown_escaper = $markdown_escaper;
$this->optional_link_list_builder = $optional_link_list_builder;
}
/**
* Renders the markdown.
*
* @return string The rendered markdown.
*/
public function render(): string {
$this->llms_txt_renderer->add_section( $this->title_builder->build_title() );
$this->llms_txt_renderer->add_section( $this->description_builder->build_description() );
$this->llms_txt_renderer->add_section( $this->intro_builder->build_intro() );
foreach ( $this->link_lists_builder->build_link_lists() as $link_list ) {
$this->llms_txt_renderer->add_section( $link_list );
}
$this->llms_txt_renderer->add_section( $this->optional_link_list_builder->build_optional_link_list() );
foreach ( $this->llms_txt_renderer->get_sections() as $section ) {
$section->escape_markdown( $this->markdown_escaper );
}
return $this->llms_txt_renderer->render();
}
}

View File

@@ -0,0 +1,45 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Application\Markdown_Builders;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Sections\Link_List;
use Yoast\WP\SEO\Llms_Txt\Infrastructure\Markdown_Services\Sitemap_Link_Collector;
/**
* The builder of the intro section.
*/
class Optional_Link_List_Builder {
/**
* The sitemap link collector.
*
* @var Sitemap_Link_Collector
*/
private $sitemap_link_collector;
/**
* The constructor.
*
* @param Sitemap_Link_Collector $sitemap_link_collector The sitemap link collector.
*/
public function __construct(
Sitemap_Link_Collector $sitemap_link_collector
) {
$this->sitemap_link_collector = $sitemap_link_collector;
}
/**
* Builds the optional link list.
*
* @return Link_List The optional link list.
*/
public function build_optional_link_list(): Link_List {
$sitemap_link = $this->sitemap_link_collector->get_link();
if ( $sitemap_link === null ) {
return new Link_List( 'Optional', [] );
}
return new Link_List( 'Optional', [ $sitemap_link ] );
}
}

View File

@@ -0,0 +1,40 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Application\Markdown_Builders;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Sections\Title;
use Yoast\WP\SEO\Llms_Txt\Infrastructure\Markdown_Services\Title_Adapter;
/**
* The builder of the title section.
*/
class Title_Builder {
/**
* The title adapter.
*
* @var Title_Adapter
*/
protected $title_adapter;
/**
* The constructor.
*
* @param Title_Adapter $title_adapter The title adapter.
*/
public function __construct(
Title_Adapter $title_adapter
) {
$this->title_adapter = $title_adapter;
}
/**
* Builds the title section.
*
* @return Title The title section.
*/
public function build_title(): Title {
return $this->title_adapter->get_title();
}
}

View File

@@ -0,0 +1,44 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Application;
/**
* The escaper of markdown.
*/
class Markdown_Escaper {
/**
* Escapes markdown text.
*
* @param string $text The markdown text to escape.
*
* @return string The escaped markdown text.
*/
public function escape_markdown_content( $text ) {
// We have to decode the text first mostly because ampersands will be escaped below.
$text = \html_entity_decode( $text, \ENT_QUOTES, 'UTF-8' );
// Define a regex pattern for all the special characters in markdown that we want to escape.
$pattern = '/[-#*+`._[\]()!&<>_{}|]/';
$replacement = static function ( $matches ) {
return '\\' . $matches[0];
};
return \preg_replace_callback( $pattern, $replacement, $text );
}
/**
* Escapes URLs in markdown.
*
* @param string $url The markdown URL to escape.
*
* @return string The escaped markdown URL.
*/
public function escape_markdown_url( $url ) {
$escaped_url = \str_replace( [ ' ', '(', ')', '\\' ], [ '%20', '%28', '%29', '%5C' ], $url );
return $escaped_url;
}
}

View File

@@ -0,0 +1,42 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded -- Needed in the folder structure.
namespace Yoast\WP\SEO\Llms_Txt\Domain\Available_Posts\Data_Provider;
use Yoast\WP\SEO\Llms_Txt\Domain\Content_Types\Content_Type_Entry;
/**
* Domain object that represents a Available Posts data record.
*/
class Available_Posts_Data implements Data_Interface {
/**
* The content type entry.
*
* @var Content_Type_Entry
*/
private $content_type_entry;
/**
* The constructor.
*
* @param Content_Type_Entry $content_type_entry The content type entry.
*/
public function __construct( Content_Type_Entry $content_type_entry ) {
$this->content_type_entry = $content_type_entry;
}
/**
* The array representation of this domain object.
*
* @return array<string|float|int|string[]>
*/
public function to_array(): array {
return [
'id' => $this->content_type_entry->get_id(),
'title' => $this->content_type_entry->get_title(),
'slug' => $this->content_type_entry->get_slug(),
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded -- Needed in the folder structure.
namespace Yoast\WP\SEO\Llms_Txt\Domain\Available_Posts\Data_Provider;
/**
* Interface describing the way to get data for a specific data provider.
*/
interface Available_Posts_Repository_Interface {
/**
* Method to get available posts from a provider.
*
* @param Parameters $parameters The parameter to get the available posts for.
*
* @return Data_Container
*/
public function get_posts( Parameters $parameters ): Data_Container;
}

View File

@@ -0,0 +1,59 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded -- Needed in the folder structure.
namespace Yoast\WP\SEO\Llms_Txt\Domain\Available_Posts\Data_Provider;
/**
* The data container.
*/
class Data_Container {
/**
* All the data points.
*
* @var array<Data_Interface>
*/
private $data_container;
/**
* The constructor
*/
public function __construct() {
$this->data_container = [];
}
/**
* Method to add data.
*
* @param Data_Interface $data The data.
*
* @return void
*/
public function add_data( Data_Interface $data ) {
$this->data_container[] = $data;
}
/**
* Method to get all the data points.
*
* @return Data_Interface[] All the data points.
*/
public function get_data(): array {
return $this->data_container;
}
/**
* Converts the data points into an array.
*
* @return array<string, string> The array of the data points.
*/
public function to_array(): array {
$result = [];
foreach ( $this->data_container as $data ) {
$result[] = $data->to_array();
}
return $result;
}
}

View File

@@ -0,0 +1,18 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded -- Needed in the folder structure.
namespace Yoast\WP\SEO\Llms_Txt\Domain\Available_Posts\Data_Provider;
/**
* The interface to describe the data domain.
*/
interface Data_Interface {
/**
* A to array method.
*
* @return array<string>
*/
public function to_array(): array;
}

View File

@@ -0,0 +1,54 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded -- Needed in the folder structure.
namespace Yoast\WP\SEO\Llms_Txt\Domain\Available_Posts\Data_Provider;
/**
* Object representation of the request parameters.
*/
class Parameters {
/**
* The post type.
*
* @var string
*/
private $post_type;
/**
* The search filter.
*
* @var string
*/
private $search_filter;
/**
* Class constructor.
*
* @param string $post_type The post type.
* @param string $search_filter The search filter.
*/
public function __construct( string $post_type, string $search_filter ) {
$this->post_type = $post_type;
$this->search_filter = $search_filter;
}
/**
* Getter for the post type.
*
* @return string
*/
public function get_post_type(): string {
return $this->post_type;
}
/**
* Getter for the search filter.
*
* @return string
*/
public function get_search_filter(): string {
return $this->search_filter;
}
}

View File

@@ -0,0 +1,19 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\Llms_Txt\Domain\Available_Posts;
use Exception;
/**
* Exception for when the post type asked is invalid.
*/
class Invalid_Post_Type_Exception extends Exception {
/**
* Constructor of the exception.
*/
public function __construct() {
parent::__construct( 'The post type asked is not valid', 400 );
}
}

View File

@@ -0,0 +1,151 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\Llms_Txt\Domain\Content_Types;
use WP_Post;
use Yoast\WP\SEO\Surfaces\Values\Meta;
/**
* This class describes a Content Type Entry.
*/
class Content_Type_Entry {
/**
* The ID of the content type entry.
*
* @var int
*/
private $id;
/**
* The title of the content type entry.
*
* @var string
*/
private $title;
/**
* The URL of the content type entry.
*
* @var string
*/
private $url;
/**
* The description of the content type entry.
*
* @var string
*/
private $description;
/**
* The slug of the content type entry.
*
* @var string
*/
private $slug;
/**
* The constructor.
*
* @param int $id The ID of the content type entry.
* @param string $title The title of the content type entry.
* @param string $url The URL of the content type entry.
* @param string $description The description of the content type entry.
* @param string $slug The slug of the content type entry.
*/
public function __construct(
int $id,
?string $title = null,
?string $url = null,
?string $description = null,
?string $slug = null
) {
$this->id = $id;
$this->title = $title;
$this->url = $url;
$this->description = $description;
$this->slug = $slug;
}
/**
* Gets the ID of the content type entry.
*
* @return int The ID of the content type entry.
*/
public function get_id(): int {
return $this->id;
}
/**
* Gets the title of the content type entry.
*
* @return string The title of the content type entry.
*/
public function get_title(): string {
return $this->title;
}
/**
* Gets the URL of the content type entry.
*
* @return string The URL of the content type entry.
*/
public function get_url(): string {
return $this->url;
}
/**
* Gets the description of the content type entry.
*
* @return string The description of the content type entry.
*/
public function get_description(): string {
return $this->description;
}
/**
* Gets the slug of the content type entry.
*
* @return string The slug of the content type entry.
*/
public function get_slug(): string {
return $this->slug;
}
/**
* Creates a new instance of the class from the provided Meta object.
*
* @param Meta $meta The Meta object containing the necessary data to construct the instance.
*
* @return self A new instance of the class.
*/
public static function from_meta( Meta $meta ): self {
return new self(
$meta->post->ID,
$meta->post->post_title,
$meta->canonical,
$meta->post->post_excerpt,
$meta->post->post_name,
);
}
/**
* Creates an instance of the class from a WordPress post object.
*
* @param WP_Post $post The WordPress post object.
* @param string $permalink The permalink of the post.
*
* @return self An instance of the class.
*/
public static function from_post( WP_Post $post, string $permalink ): self {
return new self(
$post->ID,
$post->post_title,
$permalink,
$post->post_excerpt,
$post->post_name,
);
}
}

View File

@@ -0,0 +1,9 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\Llms_Txt\Domain\Content;
/**
* Describes the post collection interface.
*/
interface Post_Collection_Interface {}

View File

@@ -0,0 +1,40 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Domain\File;
/**
* Interface to describe handeling the llms.txt file.
*/
interface Llms_File_System_Interface {
/**
* Method to set the llms.txt file content.
*
* @param string $content The content for the file.
*
* @return bool True on success, false on failure.
*/
public function set_file_content( string $content ): bool;
/**
* Method to remove the llms.txt file from the file system.
*
* @return bool True on success, false on failure.
*/
public function remove_file(): bool;
/**
* Gets the contents of the current llms.txt file.
*
* @return string
*/
public function get_file_contents(): string;
/**
* Checks if the llms.txt file exists.
*
* @return bool Whether the llms.txt file exists.
*/
public function file_exists(): bool;
}

View File

@@ -0,0 +1,17 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Domain\File;
/**
* This interface is responsible for defining ways to make sure we can edit/regenerate the llms.txt file.
*/
interface Llms_Txt_Permission_Gate_Interface {
/**
* Checks if Yoast SEO manages the llms.txt.
*
* @return bool Checks if Yoast SEO manages the llms.txt.
*/
public function is_managed_by_yoast_seo(): bool;
}

View File

@@ -0,0 +1,28 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded
namespace Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Items;
use Yoast\WP\SEO\Llms_Txt\Application\Markdown_Escaper;
/**
* Represents a markdown item.
*/
interface Item_Interface {
/**
* Renders the item.
*
* @return string
*/
public function render(): string;
/**
* Escapes the markdown content.
*
* @param Markdown_Escaper $markdown_escaper The markdown escaper.
*
* @return void
*/
public function escape_markdown( Markdown_Escaper $markdown_escaper ): void;
}

View File

@@ -0,0 +1,69 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded
namespace Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Items;
use Yoast\WP\SEO\Llms_Txt\Application\Markdown_Escaper;
/**
* Represents a link markdown item.
*/
class Link implements Item_Interface {
/**
* The description that is part of this link.
*
* @var string
*/
private $description;
/**
* The link text.
*
* @var string
*/
private $text;
/**
* The anchor text.
*
* @var string
*/
private $anchor;
/**
* Class constructor.
*
* @param string $text The link text.
* @param string $anchor The anchor text.
* @param string $description The description.
*/
public function __construct( string $text, string $anchor, string $description = '' ) {
$this->text = $text;
$this->anchor = $anchor;
$this->description = $description;
}
/**
* Renders the link item.
*
* @return string
*/
public function render(): string {
$description = ( $this->description !== '' ) ? ": $this->description" : '';
return "[$this->text]($this->anchor)$description";
}
/**
* Escapes the markdown content.
*
* @param param Markdown_Escaper $markdown_escaper The markdown escaper.
*
* @return void
*/
public function escape_markdown( Markdown_Escaper $markdown_escaper ): void {
$this->text = $markdown_escaper->escape_markdown_content( $this->text );
$this->description = $markdown_escaper->escape_markdown_content( $this->description );
$this->anchor = $markdown_escaper->escape_markdown_url( $this->anchor );
}
}

View File

@@ -0,0 +1,62 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Domain\Markdown;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Sections\Section_Interface;
/**
* The renderer of the LLMs.txt file.
*/
class Llms_Txt_Renderer {
/**
* The sections.
*
* @var Section_Interface[]
*/
private $sections;
/**
* Adds a section.
*
* @param Section_Interface $section The section to add.
*
* @return void
*/
public function add_section( Section_Interface $section ): void {
$this->sections[] = $section;
}
/**
* Returns the sections.
*
* @return Section_Interface[]
*/
public function get_sections(): array {
return $this->sections;
}
/**
* Renders the items of the bucket.
*
* @return string
*/
public function render(): string {
if ( empty( $this->sections ) ) {
return '';
}
$rendered_sections = [];
foreach ( $this->sections as $section ) {
$section_content = $section->render();
if ( $section_content === '' ) {
continue;
}
$rendered_sections[] = $section->get_prefix() . $section_content . \PHP_EOL;
}
return \implode( \PHP_EOL, $rendered_sections );
}
}

View File

@@ -0,0 +1,57 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded
namespace Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Sections;
use Yoast\WP\SEO\Llms_Txt\Application\Markdown_Escaper;
/**
* Represents the description section.
*/
class Description implements Section_Interface {
/**
* The description.
*
* @var string
*/
private $description;
/**
* Class constructor.
*
* @param string $description The description.
*/
public function __construct( string $description ) {
$this->description = $description;
}
/**
* Returns the prefix of the description section.
*
* @return string
*/
public function get_prefix(): string {
return '> ';
}
/**
* Renders the description section.
*
* @return string
*/
public function render(): string {
return $this->description;
}
/**
* Escapes the markdown content.
*
* @param Markdown_Escaper $markdown_escaper The markdown escaper.
*
* @return void
*/
public function escape_markdown( Markdown_Escaper $markdown_escaper ): void {
$this->description = $markdown_escaper->escape_markdown_content( $this->description );
}
}

View File

@@ -0,0 +1,98 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded
namespace Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Sections;
use Yoast\WP\SEO\Llms_Txt\Application\Markdown_Escaper;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Items\Link;
/**
* Represents the intro section.
*/
class Intro implements Section_Interface {
/**
* The intro content.
*
* @var string
*/
private $intro_content;
/**
* The intro links.
*
* @var Link[]
*/
private $intro_links = [];
/**
* Class constructor.
*
* @param string $intro_content The intro content.
* @param Link[] $intro_links The intro links.
*/
public function __construct( string $intro_content, array $intro_links ) {
$this->intro_content = $intro_content;
foreach ( $intro_links as $link ) {
$this->add_link( $link );
}
}
/**
* Returns the prefix of the intro section.
*
* @return string
*/
public function get_prefix(): string {
return '';
}
/**
* Adds a link to the intro section.
*
* @param Link $link The link to add.
*
* @return void
*/
public function add_link( Link $link ): void {
$this->intro_links[] = $link;
}
/**
* Returns the content of the intro section.
*
* @return string
*/
public function render(): string {
if ( \count( $this->intro_links ) === 0 ) {
return $this->intro_content;
}
$rendered_links = \array_map(
static function ( $link ) {
return $link->render();
},
$this->intro_links,
);
$this->intro_content = \sprintf(
$this->intro_content,
...$rendered_links,
);
return $this->intro_content;
}
/**
* Escapes the markdown content.
*
* @param Markdown_Escaper $markdown_escaper The markdown escaper.
*
* @return void
*/
public function escape_markdown( Markdown_Escaper $markdown_escaper ): void {
foreach ( $this->intro_links as $link ) {
$link->escape_markdown( $markdown_escaper );
}
}
}

View File

@@ -0,0 +1,94 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded
namespace Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Sections;
use Yoast\WP\SEO\Llms_Txt\Application\Markdown_Escaper;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Items\Link;
/**
* Represents a link list markdown section.
*/
class Link_List implements Section_Interface {
/**
* The type of the links.
*
* @var string
*/
private $type;
/**
* The links.
*
* @var Link[]
*/
private $links = [];
/**
* Class constructor.
*
* @param string $type The type of the links.
* @param Link[] $links The links.
*/
public function __construct( string $type, array $links ) {
$this->type = $type;
foreach ( $links as $link ) {
$this->add_link( $link );
}
}
/**
* Adds a link to the list.
*
* @param Link $link The link to add.
*
* @return void
*/
public function add_link( Link $link ): void {
$this->links[] = $link;
}
/**
* Returns the prefix of the link list section.
*
* @return string
*/
public function get_prefix(): string {
return '## ';
}
/**
* Renders the link item.
*
* @return string
*/
public function render(): string {
if ( empty( $this->links ) ) {
return '';
}
$rendered_links = [];
foreach ( $this->links as $link ) {
$rendered_links[] = '- ' . $link->render();
}
return $this->type . \PHP_EOL . \implode( \PHP_EOL, $rendered_links );
}
/**
* Escapes the markdown content.
*
* @param Markdown_Escaper $markdown_escaper The markdown escaper.
*
* @return void
*/
public function escape_markdown( Markdown_Escaper $markdown_escaper ): void {
$this->type = $markdown_escaper->escape_markdown_content( $this->type );
foreach ( $this->links as $link ) {
$link->escape_markdown( $markdown_escaper );
}
}
}

View File

@@ -0,0 +1,19 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded
namespace Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Sections;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Items\Item_Interface;
/**
* Represents a section.
*/
interface Section_Interface extends Item_Interface {
/**
* Returns the prefix of the section.
*
* @return string
*/
public function get_prefix(): string;
}

View File

@@ -0,0 +1,78 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded
namespace Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Sections;
use Yoast\WP\SEO\Llms_Txt\Application\Markdown_Escaper;
/**
* Represents the title section.
*/
class Title implements Section_Interface {
/**
* The site title.
*
* @var string
*/
private $site_title;
/**
* The site tagline.
*
* @var string
*/
private $site_tagline;
/**
* Class constructor.
*
* @param string $site_title The site title.
* @param string $site_tagline The site tagline.
*/
public function __construct(
string $site_title,
string $site_tagline
) {
$this->site_title = $site_title;
$this->site_tagline = $site_tagline;
}
/**
* Returns the prefix of the section.
*
* @return string
*/
public function get_prefix(): string {
return '# ';
}
/**
* Renders the title section.
*
* @return string
*/
public function render(): string {
if ( $this->site_tagline === '' ) {
return $this->site_title;
}
if ( $this->site_title === '' ) {
return $this->site_tagline;
}
return "$this->site_title: $this->site_tagline";
}
/**
* Escapes the markdown content.
*
* @param Markdown_Escaper $markdown_escaper The markdown escaper.
*
* @return void
*/
public function escape_markdown( Markdown_Escaper $markdown_escaper ): void {
$this->site_title = $markdown_escaper->escape_markdown_content( $this->site_title );
$this->site_tagline = $markdown_escaper->escape_markdown_content( $this->site_tagline );
}
}

View File

@@ -0,0 +1,215 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\Llms_Txt\Infrastructure\Content;
use WP_Post;
use Yoast\WP\SEO\Helpers\Indexable_Helper;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Llms_Txt\Domain\Content\Post_Collection_Interface;
use Yoast\WP\SEO\Llms_Txt\Domain\Content_Types\Content_Type_Entry;
use Yoast\WP\SEO\Repositories\Indexable_Repository;
use Yoast\WP\SEO\Surfaces\Meta_Surface;
/**
* The class that handles the automatic post collection. Based on either indexables or WP_Query.
*
* @makePublic
*/
class Automatic_Post_Collection implements Post_Collection_Interface {
/**
* The options helper.
*
* @var Options_Helper
*/
private $options_helper;
/**
* The indexable repository.
*
* @var Indexable_Repository
*/
private $indexable_repository;
/**
* The meta surface.
*
* @var Meta_Surface
*/
private $meta;
/**
* The indexable helper.
*
* @var Indexable_Helper
*/
private $indexable_helper;
/**
* Constructs the class.
*
* @param Options_Helper $options_helper The options helper.
* @param Indexable_Repository $indexable_repository The indexable repository.
* @param Meta_Surface $meta The meta surface.
* @param Indexable_Helper $indexable_helper The indexable helper.
*/
public function __construct(
Options_Helper $options_helper,
Indexable_Repository $indexable_repository,
Meta_Surface $meta,
Indexable_Helper $indexable_helper
) {
$this->options_helper = $options_helper;
$this->indexable_repository = $indexable_repository;
$this->meta = $meta;
$this->indexable_helper = $indexable_helper;
}
/**
* Gets the posts that are relevant for the LLMs.txt.
*
* @param string $post_type The post type.
* @param int $limit The maximum number of posts to return.
*
* @return array<int, array<Content_Type_Entry>> The posts that are relevant for the LLMs.txt.
*/
public function get_posts( string $post_type, int $limit ): array {
$posts = $this->get_recent_cornerstone_content( $post_type, $limit );
if ( \count( $posts ) >= $limit ) {
return $posts;
}
$recent_posts = $this->get_recent_posts( $post_type, $limit );
foreach ( $recent_posts as $recent_post ) {
// If the post is already in the list because it's cornerstone, don't add it again.
if ( isset( $posts[ $recent_post->get_id() ] ) ) {
continue;
}
$posts[ $recent_post->get_id() ] = $recent_post;
if ( \count( $posts ) >= $limit ) {
break;
}
}
return $posts;
}
/**
* Gets the most recently modified cornerstone content.
*
* @param string $post_type The post type.
* @param int $limit The maximum number of posts to return.
*
* @return array<int, array<Content_Type_Entry>> The most recently modified cornerstone content.
*/
private function get_recent_cornerstone_content( string $post_type, int $limit ): array {
if ( ! $this->options_helper->get( 'enable_cornerstone_content' ) ) {
return [];
}
$cornerstone_limit = ( \is_post_type_hierarchical( $post_type ) ) ? null : $limit;
$cornerstones = $this->indexable_repository->get_recent_cornerstone_for_post_type( $post_type, $cornerstone_limit );
$recent_cornerstone_posts = [];
foreach ( $cornerstones as $cornerstone ) {
$cornerstone_meta = $this->meta->for_indexable( $cornerstone );
if ( $cornerstone_meta->post instanceof WP_Post ) {
$recent_cornerstone_posts[ $cornerstone_meta->post->ID ] = Content_Type_Entry::from_meta( $cornerstone_meta );
}
}
return $recent_cornerstone_posts;
}
/**
* Gets the most recently modified posts.
*
* @param string $post_type The post type.
* @param int $limit The maximum number of posts to return.
* @param string $search_filter Optional. The search filter to apply to the query.
* @param bool $disable_excluding_old_posts Optional. Whether to disable excluding posts older than one year.
*
* @return array<Content_Type_Entry> The most recently modified posts.
*/
public function get_recent_posts( string $post_type, int $limit, string $search_filter = '', bool $disable_excluding_old_posts = false ): array {
$exclude_older_than_one_year = false;
if ( $post_type === 'post' && ! $disable_excluding_old_posts ) {
$exclude_older_than_one_year = true;
}
if ( $this->indexable_helper->should_index_indexables() ) {
return $this->get_recently_modified_posts_indexables( $post_type, $limit, $exclude_older_than_one_year, $search_filter );
}
return $this->get_recently_modified_posts_wp_query( $post_type, $limit, $exclude_older_than_one_year, $search_filter );
}
/**
* Returns most recently modified posts of a post type, using indexables.
*
* @param string $post_type The post type.
* @param int $limit The maximum number of posts to return.
* @param bool $exclude_older_than_one_year Whether to exclude posts older than one year.
* @param string $search_filter Optional. The search filter to apply to the query.
*
* @return array<Content_Type_Entry> The most recently modified posts.
*/
private function get_recently_modified_posts_indexables( string $post_type, int $limit, bool $exclude_older_than_one_year, string $search_filter = '' ): array {
$posts = [];
$recently_modified_indexables = $this->indexable_repository->get_recently_modified_posts( $post_type, $limit, $exclude_older_than_one_year, $search_filter );
foreach ( $recently_modified_indexables as $indexable ) {
$indexable_meta = $this->meta->for_indexable( $indexable );
if ( $indexable_meta->post instanceof WP_Post ) {
$posts[] = Content_Type_Entry::from_meta( $indexable_meta );
}
}
return $posts;
}
/**
* Returns most recently modified posts of a post type, using WP_Query.
*
* @param string $post_type The post type.
* @param int $limit The maximum number of posts to return.
* @param bool $exclude_older_than_one_year Whether to exclude posts older than one year.
* @param string $search_filter Optional. The search filter to apply to the query.
*
* @return array<WP_Post> The most recently modified posts.
*/
private function get_recently_modified_posts_wp_query( string $post_type, int $limit, bool $exclude_older_than_one_year, string $search_filter = '' ): array {
$args = [
'post_type' => $post_type,
'posts_per_page' => $limit,
'post_status' => 'publish',
'orderby' => 'modified',
'order' => 'DESC',
'has_password' => false,
];
if ( $exclude_older_than_one_year === true ) {
$args['date_query'] = [
[
'after' => '12 months ago',
],
];
}
if ( $search_filter !== '' ) {
$args['s'] = $search_filter;
}
$posts = [];
foreach ( \get_posts( $args ) as $post ) {
$posts[] = Content_Type_Entry::from_post( $post, \get_permalink( $post->ID ) );
}
return $posts;
}
}

View File

@@ -0,0 +1,169 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\Llms_Txt\Infrastructure\Content;
use WP_Post;
use Yoast\WP\SEO\Helpers\Indexable_Helper;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Llms_Txt\Domain\Content\Post_Collection_Interface;
use Yoast\WP\SEO\Llms_Txt\Domain\Content_Types\Content_Type_Entry;
use Yoast\WP\SEO\Repositories\Indexable_Repository;
use Yoast\WP\SEO\Surfaces\Meta_Surface;
/**
* The class that handles the manual post collection. Based on either indexables or WP_Query.
*/
class Manual_Post_Collection implements Post_Collection_Interface {
/**
* The options helper.
*
* @var Options_Helper
*/
private $options_helper;
/**
* The indexable helper.
*
* @var Indexable_Helper
*/
private $indexable_helper;
/**
* The indexable repository.
*
* @var Indexable_Repository
*/
private $indexable_repository;
/**
* The meta surface.
*
* @var Meta_Surface
*/
private $meta;
/**
* Constructor.
*
* @param Options_Helper $options_helper The options helper.
* @param Indexable_Helper $indexable_helper The indexable helper.
* @param Indexable_Repository $indexable_repository The indexable repository.
* @param Meta_Surface $meta The meta surface.
*/
public function __construct(
Options_Helper $options_helper,
Indexable_Helper $indexable_helper,
Indexable_Repository $indexable_repository,
Meta_Surface $meta
) {
$this->options_helper = $options_helper;
$this->indexable_helper = $indexable_helper;
$this->indexable_repository = $indexable_repository;
$this->meta = $meta;
}
/**
* The post method to get all relevant content type entries
*
* @return array<int, array<Content_Type_Entry>> The posts that are relevant for the LLMs.txt.
*/
public function get_posts(): array {
$posts = [];
$pages = [
'about_us_page',
'contact_page',
'terms_page',
'privacy_policy_page',
'shop_page',
];
foreach ( $pages as $page ) {
$page_id = $this->options_helper->get( $page );
if ( ! empty( $page_id ) ) {
$post = $this->get_content_type_entry( $page_id );
if ( $post !== null ) {
$posts[] = $post;
}
else {
$this->options_helper->set( $page, 0 );
}
}
}
$other_pages = $this->options_helper->get( 'other_included_pages' );
$filtered_pages = [];
if ( ! empty( $other_pages ) ) {
foreach ( $other_pages as $page_id ) {
$post = $this->get_content_type_entry( $page_id );
if ( $post !== null ) {
$posts[] = $post;
$filtered_pages[] = $page_id;
}
}
if ( \count( $filtered_pages ) !== \count( $other_pages ) ) {
$this->options_helper->set( 'other_included_pages', $filtered_pages );
}
}
return $posts;
}
/**
* Gets the content entries.
*
* @param int $page_id The id of the page.
*
* @return Content_Type_Entry The content type entry.
*/
public function get_content_type_entry( int $page_id ): ?Content_Type_Entry {
if ( $this->indexable_helper->should_index_indexables() ) {
$post = $this->get_content_type_entry_for_indexable( $page_id );
}
else {
$post = $this->get_content_type_entry_wp_query( $page_id );
}
return $post;
}
/**
* Gets the content entries with WP query.
*
* @param int $page_id The id of the page.
*
* @return Content_Type_Entry The content type entry.
*/
public function get_content_type_entry_wp_query( int $page_id ): ?Content_Type_Entry {
$page = \get_post( $page_id );
if ( $page !== null && $page->post_password === '' && $page->post_status === 'publish' ) {
return Content_Type_Entry::from_post( $page, \get_permalink( $page->ID ) );
}
return null;
}
/**
* Gets the content entries with indexables.
*
* @param int $page_id The id of the page.
*
* @return Content_Type_Entry The content type entry.
*/
public function get_content_type_entry_for_indexable( int $page_id ): ?Content_Type_Entry {
$indexable = $this->indexable_repository->find_by_id_and_type( $page_id, 'post' );
if ( $indexable && ( $indexable->is_public === null || $indexable->is_public ) ) {
$indexable_meta = $this->meta->for_indexable( $indexable );
if ( $indexable_meta->post instanceof WP_Post ) {
return Content_Type_Entry::from_meta( $indexable_meta );
}
}
return null;
}
}

View File

@@ -0,0 +1,57 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\Llms_Txt\Infrastructure\Content;
use Exception;
use Yoast\WP\SEO\Llms_Txt\Domain\content\Post_Collection_Interface;
/**
* The factory to determine which post collection class to use.
*/
class Post_Collection_Factory {
/**
* The manual post collection.
*
* @var Manual_Post_Collection
*/
private $manual_post_collection;
/**
* The automatic post collection.
*
* @var Automatic_Post_Collection
*/
private $automatic_post_collection;
/**
* Constructor.
*
* @param Manual_Post_Collection $manual_post_collection The manual post collection.
* @param Automatic_Post_Collection $automatic_post_collection The automatic post collection.
*/
public function __construct( Manual_Post_Collection $manual_post_collection, Automatic_Post_Collection $automatic_post_collection ) {
$this->manual_post_collection = $manual_post_collection;
$this->automatic_post_collection = $automatic_post_collection;
}
/**
* Determines which collection class is needed.
*
* @param string $collection_type The type of collection.
*
* @throws Exception Throws when an invalid type is given.
* @return Post_Collection_Interface
*/
public function get_post_collection( string $collection_type ): Post_Collection_Interface {
switch ( $collection_type ) {
case 'manual':
return $this->manual_post_collection;
case 'auto':
return $this->automatic_post_collection;
default:
throw new Exception( 'Invalid collection type provided' );
}
}
}

View File

@@ -0,0 +1,118 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Infrastructure\File;
use Yoast\WP\SEO\Llms_Txt\Domain\File\Llms_File_System_Interface;
/**
* Adapter class for handling file system operations in a WordPress environment.
*/
class WordPress_File_System_Adapter implements Llms_File_System_Interface {
/**
* Creates a file and writes the specified content to it.
*
* @param string $content The content to write into the file.
*
* @return bool True on success, false on failure.
*/
public function set_file_content( string $content ): bool {
if ( $this->is_file_system_available() ) {
global $wp_filesystem;
$result = $wp_filesystem->put_contents(
$this->get_llms_file_path(),
$content,
\FS_CHMOD_FILE,
);
return $result;
}
return false;
}
/**
* Removes the llms.txt from the filesystem.
*
* @return bool True on success, false on failure.
*/
public function remove_file(): bool {
if ( $this->is_file_system_available() ) {
global $wp_filesystem;
$result = $wp_filesystem->delete( $this->get_llms_file_path() );
return $result;
}
return false;
}
/**
* Gets the contents of the current llms.txt file.
*
* @return string The content of the file.
*/
public function get_file_contents(): string {
if ( $this->is_file_system_available() ) {
global $wp_filesystem;
return $wp_filesystem->get_contents( $this->get_llms_file_path() );
}
return '';
}
/**
* Checks if the llms.txt file exists.
*
* @return bool Whether the llms.txt file exists.
*/
public function file_exists(): bool {
if ( $this->is_file_system_available() ) {
global $wp_filesystem;
return $wp_filesystem->exists( $this->get_llms_file_path() );
}
return false;
}
/**
* Checks if the file system is available.
*
* @return bool If the file system is available.
*/
private function is_file_system_available(): ?bool {
if ( ! \function_exists( 'WP_Filesystem' ) ) {
require_once \ABSPATH . 'wp-admin/includes/file.php';
}
return \WP_Filesystem();
}
/**
* Creates the path to the llms.txt file.
*
* @return string
*/
private function get_llms_file_path(): string {
$llms_filesystem_path = \get_home_path();
// phpcs:disable WordPress.Security.ValidatedSanitizedInput -- Reason: This is how we used this for the robots.txt file as well.
if ( ! \is_writable( $llms_filesystem_path ) && ! empty( $_SERVER['DOCUMENT_ROOT'] ) ) {
$llms_filesystem_path = $_SERVER['DOCUMENT_ROOT'];
}
// phpcs:enable WordPress.Security.ValidatedSanitizedInput
/**
* Filter: 'wpseo_llmstxt_filesystem_path' - Allows editing the filesystem path of the llmst.txt file to account for server restrictions to the filesystem.
*
* @param string $llms_filesystem_path The filesystem path of the llmst.txt file that defaults to get_home_path() or the $_SERVER['DOCUMENT_ROOT'] if the home path is not writeable.
*/
$llms_filesystem_path = \apply_filters( 'wpseo_llmstxt_filesystem_path', $llms_filesystem_path );
return \trailingslashit( $llms_filesystem_path ) . 'llms.txt';
}
}

View File

@@ -0,0 +1,66 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Infrastructure\File;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Llms_Txt\Application\File\Commands\Populate_File_Command_Handler;
use Yoast\WP\SEO\Llms_Txt\Domain\File\Llms_Txt_Permission_Gate_Interface;
/**
* Handles checks to see if we manage the llms.txt file.
*/
class WordPress_Llms_Txt_Permission_Gate implements Llms_Txt_Permission_Gate_Interface {
/**
* The file system adapter.
*
* @var WordPress_File_System_Adapter
*/
private $file_system_adapter;
/**
* The options helper.
*
* @var Options_Helper
*/
private $options_helper;
/**
* Constructor.
*
* @param WordPress_File_System_Adapter $file_system_adapter The file system adapter.
* @param Options_Helper $options_helper The options helper.
*/
public function __construct(
WordPress_File_System_Adapter $file_system_adapter,
Options_Helper $options_helper
) {
$this->file_system_adapter = $file_system_adapter;
$this->options_helper = $options_helper;
}
/**
* Checks if Yoast SEO manages the llms.txt.
*
* @return bool Checks if Yoast SEO manages the llms.txt.
*/
public function is_managed_by_yoast_seo(): bool {
$stored_hash = \get_option( Populate_File_Command_Handler::CONTENT_HASH_OPTION, '' );
// If the file does not exist yet, we always regenerate/create it.
if ( ! $this->file_system_adapter->file_exists() ) {
return true;
}
// This means the file is already there (maybe hand made or another plugin created it). And since we don't have a hash it's not ours.
if ( $stored_hash === '' ) {
return false;
}
$current_content = $this->file_system_adapter->get_file_contents();
// If you have a hash, we want to make sure it's the same. This check makes sure the file is not edited by the user.
return \md5( $current_content ) === $stored_hash;
}
}

View File

@@ -0,0 +1,118 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Infrastructure\Markdown_Services;
use WP_Post_Type;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\Post_Type_Helper;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Items\Link;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Sections\Link_List;
use Yoast\WP\SEO\Llms_Txt\Infrastructure\Content\Post_Collection_Factory;
/**
* The collector of content types.
*
* @TODO: This class could maybe be unified with
* Yoast\WP\SEO\Dashboard\Infrastructure\Content_Types\Content_Types_Collector.
*/
class Content_Types_Collector {
/**
* The post type helper.
*
* @var Post_Type_Helper
*/
private $post_type_helper;
/**
* The collection factory.
*
* @var Post_Collection_Factory
*/
private $collection_factory;
/**
* The options helper.
*
* @var Options_Helper
*/
private $options_helper;
/**
* The constructor.
*
* @param Post_Type_Helper $post_type_helper The post type helper.
* @param Post_Collection_Factory $collection_factory The collection factory.
* @param Options_Helper $options_helper The options helper.
*/
public function __construct(
Post_Type_Helper $post_type_helper,
Post_Collection_Factory $collection_factory,
Options_Helper $options_helper
) {
$this->post_type_helper = $post_type_helper;
$this->collection_factory = $collection_factory;
$this->options_helper = $options_helper;
}
/**
* Returns the content types in a link list.
*
* @return Link_List[] The content types in a link list.
*/
public function get_content_types_lists(): array {
$post_types = $this->post_type_helper->get_indexable_post_type_objects();
$post_types = $this->make_sure_pages_are_first( $post_types );
$link_list = [];
foreach ( $post_types as $post_type_object ) {
if ( $this->post_type_helper->is_indexable( $post_type_object->name ) === false ) {
continue;
}
$option = 'auto';
if ( $post_type_object->name === 'page' ) {
$option = $this->options_helper->get( 'llms_txt_selection_mode' );
}
$collection_strategy = $this->collection_factory->get_post_collection( $option );
$posts = $collection_strategy->get_posts( $post_type_object->name, 5 );
$post_links = new Link_List( $post_type_object->label, [] );
foreach ( $posts as $post ) {
/**
* Filter 'wpseo_llmstxt_link_description' - Allow filtering the description of links in the llms.txt post lists.
*
* @since 26.3
*
* @param string $link_description The description of the link.
* @param string $post_id The ID of the post that is being added as a link.
* @param string $post_type The post type of the post that is being added as a link.
*/
$link_description = \apply_filters( 'wpseo_llmstxt_link_description', $post->get_description(), $post->get_id(), $post_type_object->name );
$post_link = new Link( $post->get_title(), $post->get_url(), $link_description );
$post_links->add_link( $post_link );
}
$link_list[] = $post_links;
}
return $link_list;
}
/**
* Returns an array of indexable post types with pages and posts as the first two.
*
* @param array<WP_Post_Type> $post_types List of indexable post type objects.
*
* @return array<WP_Post_Type> List of indexable post type objects.
*/
private function make_sure_pages_are_first( array $post_types ): array {
$types_to_go_first = [];
if ( isset( $post_types['page'] ) ) {
$types_to_go_first['page'] = $post_types['page'];
unset( $post_types['page'] );
}
return \array_merge( $types_to_go_first, $post_types );
}
}

View File

@@ -0,0 +1,48 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Infrastructure\Markdown_Services;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Sections\Description;
use Yoast\WP\SEO\Surfaces\Meta_Surface;
/**
* The adapter of the description.
*/
class Description_Adapter {
/**
* Holds the meta helper surface.
*
* @var Meta_Surface
*/
private $meta;
/**
* Class constructor.
*
* @param Meta_Surface $meta The meta surface.
*/
public function __construct(
Meta_Surface $meta
) {
$this->meta = $meta;
}
/**
* Gets the description.
*
* @return Description The description.
*/
public function get_description(): Description {
$meta_description = $this->meta->for_home_page()->meta_description;
// In a lot of cases, the homepage's meta description falls back to the site's tagline.
// But that is already used for the title section, so let's try to not have duplicate content.
if ( $meta_description === \get_bloginfo( 'description' ) ) {
return new Description( '' );
}
return new Description( $meta_description );
}
}

View File

@@ -0,0 +1,34 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Infrastructure\Markdown_Services;
use WPSEO_Options;
use WPSEO_Sitemaps_Router;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Items\Link;
/**
* The sitemap link collector.
*/
class Sitemap_Link_Collector {
/**
* Gets the link for the sitemap.
*
* @return Link The link for the sitemap.
*/
public function get_link(): ?Link {
if ( WPSEO_Options::get( 'enable_xml_sitemap' ) ) {
$sitemap_url = WPSEO_Sitemaps_Router::get_base_url( 'sitemap_index.xml' );
return new Link( 'Sitemap index', $sitemap_url );
}
$sitemap_url = \get_sitemap_url( 'index' );
if ( $sitemap_url !== false ) {
return new Link( 'Sitemap index', $sitemap_url );
}
return null;
}
}

View File

@@ -0,0 +1,65 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Infrastructure\Markdown_Services;
use Yoast\WP\SEO\Helpers\Taxonomy_Helper;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Items\Link;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Sections\Link_List;
/**
* The collector of terms.
*/
class Terms_Collector {
/**
* The taxonomy helper.
*
* @var Taxonomy_Helper
*/
private $taxonomy_helper;
/**
* The constructor.
*
* @param Taxonomy_Helper $taxonomy_helper The taxonomy helper.
*/
public function __construct( Taxonomy_Helper $taxonomy_helper ) {
$this->taxonomy_helper = $taxonomy_helper;
}
/**
* Returns the content types in a link list.
*
* @return Link_List[] The content types in a link list.
*/
public function get_terms_lists(): array {
$taxonomies = $this->taxonomy_helper->get_indexable_taxonomy_objects();
$link_list = [];
foreach ( $taxonomies as $taxonomy ) {
if ( $this->taxonomy_helper->is_indexable( $taxonomy->name ) === false ) {
continue;
}
$terms = \get_categories(
[
'taxonomy' => $taxonomy->name,
'number' => 5,
'orderby' => 'count',
'order' => 'DESC',
],
);
$term_links = new Link_List( $taxonomy->label, [] );
foreach ( $terms as $term ) {
$term_link = new Link( $term->name, \get_term_link( $term, $taxonomy->name ) );
$term_links->add_link( $term_link );
}
$link_list[] = $term_links;
}
return $link_list;
}
}

View File

@@ -0,0 +1,43 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\Infrastructure\Markdown_Services;
use Yoast\WP\SEO\Llms_Txt\Domain\Markdown\Sections\Title;
use Yoast\WP\SEO\Services\Health_Check\Default_Tagline_Runner;
/**
* The adapter of the title.
*/
class Title_Adapter {
/**
* The default tagline runner.
*
* @var Default_Tagline_Runner
*/
private $default_tagline_runner;
/**
* Class constructor.
*
* @param Default_Tagline_Runner $default_tagline_runner The default tagline runner.
*/
public function __construct(
Default_Tagline_Runner $default_tagline_runner
) {
$this->default_tagline_runner = $default_tagline_runner;
}
/**
* Gets the title.
*
* @return Title The title.
*/
public function get_title(): Title {
$this->default_tagline_runner->run();
$tagline = ( $this->default_tagline_runner->is_successful() ? \get_bloginfo( 'description' ) : '' );
return new Title( \get_bloginfo( 'name' ), $tagline );
}
}

View File

@@ -0,0 +1,149 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
namespace Yoast\WP\SEO\Llms_Txt\User_Interface;
use Exception;
use WP_Post_Type;
use WP_REST_Request;
use WP_REST_Response;
use Yoast\WP\SEO\Conditionals\No_Conditionals;
use Yoast\WP\SEO\Helpers\Capability_Helper;
use Yoast\WP\SEO\Llms_Txt\Application\Available_Posts\Available_Posts_Repository;
use Yoast\WP\SEO\Llms_Txt\Domain\Available_Posts\Data_Provider\Parameters;
use Yoast\WP\SEO\Llms_Txt\Domain\Available_Posts\Invalid_Post_Type_Exception;
use Yoast\WP\SEO\Main;
use Yoast\WP\SEO\Routes\Route_Interface;
/**
* Available posts route.
*/
class Available_Posts_Route implements Route_Interface {
use No_Conditionals;
/**
* The namespace of the route.
*
* @var string
*/
public const ROUTE_NAMESPACE = Main::API_V1_NAMESPACE;
/**
* The prefix of the route.
*
* @var string
*/
public const ROUTE_NAME = '/available_posts';
/**
* Holds the available posts repository.
*
* @var Available_Posts_Repository
*/
private $available_posts_repository;
/**
* Holds the capability helper instance.
*
* @var Capability_Helper
*/
private $capability_helper;
/**
* The constructor.
*
* @param Available_Posts_Repository $available_posts_repository The data provider for the available posts.
* @param Capability_Helper $capability_helper The capability helper.
*/
public function __construct(
Available_Posts_Repository $available_posts_repository,
Capability_Helper $capability_helper
) {
$this->available_posts_repository = $available_posts_repository;
$this->capability_helper = $capability_helper;
}
/**
* Registers routes for scores.
*
* @return void
*/
public function register_routes() {
\register_rest_route(
self::ROUTE_NAMESPACE,
self::ROUTE_NAME,
[
[
'methods' => 'GET',
'callback' => [ $this, 'get_available_posts' ],
'permission_callback' => [ $this, 'permission_manage_options' ],
'args' => [
'search' => [
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
],
'postType' => [
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => 'page',
],
],
],
],
);
}
/**
* Gets the available posts.
*
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response The success or failure response.
*/
public function get_available_posts( WP_REST_Request $request ): WP_REST_Response {
try {
$request_parameters = new Parameters( $request->get_param( 'postType' ), $request->get_param( 'search' ) );
$this->validate_request_parameters( $request_parameters );
$available_posts_container = $this->available_posts_repository->get_posts( $request_parameters );
} catch ( Exception $exception ) {
return new WP_REST_Response(
[
'error' => $exception->getMessage(),
],
$exception->getCode(),
);
}
return new WP_REST_Response(
$available_posts_container->to_array(),
200,
);
}
/**
* Validates the request's parameters.
*
* @param Parameters $request_parameters The request parameters.
*
* @return void.
*
* @throws Invalid_Post_Type_Exception When the given post type is invalid.
*/
public function validate_request_parameters( Parameters $request_parameters ): void {
if ( ! \is_a( \get_post_type_object( $request_parameters->get_post_type() ), WP_Post_Type::class ) ) {
throw new Invalid_Post_Type_Exception();
}
}
/**
* Permission callback.
*
* @return bool True when user has the 'wpseo_manage_options' capability.
*/
public function permission_manage_options() {
return $this->capability_helper->current_user_can( 'wpseo_manage_options' );
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Yoast\WP\SEO\Llms_Txt\User_Interface;
use Yoast\WP\SEO\Conditionals\No_Conditionals;
use Yoast\WP\SEO\Integrations\Integration_Interface;
use Yoast\WP\SEO\Llms_Txt\Application\File\Commands\Remove_File_Command_Handler;
use Yoast\WP\SEO\Llms_Txt\Application\File\Llms_Txt_Cron_Scheduler;
/**
* Trys to clean up the llms.txt file when the plugin is deactivated.
*/
class Cleanup_Llms_Txt_On_Deactivation implements Integration_Interface {
use No_Conditionals;
/**
* The command handler.
*
* @var Remove_File_Command_Handler
*/
private $command_handler;
/**
* The cron scheduler.
*
* @var Llms_Txt_Cron_Scheduler
*/
private $cron_scheduler;
/**
* Constructor.
*
* @param Remove_File_Command_Handler $command_handler The command handler.
* @param Llms_Txt_Cron_Scheduler $cron_scheduler The scheduler.
*/
public function __construct(
Remove_File_Command_Handler $command_handler,
Llms_Txt_Cron_Scheduler $cron_scheduler
) {
$this->command_handler = $command_handler;
$this->cron_scheduler = $cron_scheduler;
}
/**
* Registers the unscheduling of the cron to the deactivation action.
*
* @return void
*/
public function register_hooks() {
\add_action( 'wpseo_deactivate', [ $this, 'maybe_remove_llms_file' ] );
}
/**
* Call the command handler to remove the file.
*
* @return void
*/
public function maybe_remove_llms_file(): void {
$this->command_handler->handle();
$this->cron_scheduler->unschedule_llms_txt_population();
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace Yoast\WP\SEO\Llms_Txt\User_Interface;
use Yoast\WP\SEO\Conditionals\No_Conditionals;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Integrations\Integration_Interface;
use Yoast\WP\SEO\Llms_Txt\Application\File\Commands\Populate_File_Command_Handler;
use Yoast\WP\SEO\Llms_Txt\Application\File\Commands\Remove_File_Command_Handler;
use Yoast\WP\SEO\Llms_Txt\Application\File\Llms_Txt_Cron_Scheduler;
/**
* Watches and handles changes to the LLMS.txt enabled option.
*/
class Enable_Llms_Txt_Option_Watcher implements Integration_Interface {
use No_Conditionals;
/**
* The option names that should trigger a population of the llms.txt file.
*
* @var string[]
*/
private $option_names = [
'llms_txt_selection_mode',
'about_us_page',
'contact_page',
'terms_page',
'privacy_policy_page',
'shop_page',
'other_included_pages',
];
/**
* The scheduler.
*
* @var Llms_Txt_Cron_Scheduler
*/
private $scheduler;
/**
* The remove file command handler.
*
* @var Remove_File_Command_Handler
*/
private $remove_file_command_handler;
/**
* The populate file command handler.
*
* @var Populate_File_Command_Handler
*/
private $populate_file_command_handler;
/**
* The options helper.
*
* @var Options_Helper
*/
private $options_helper;
/**
* Constructor.
*
* @param Llms_Txt_Cron_Scheduler $scheduler The cron scheduler.
* @param Remove_File_Command_Handler $remove_file_command_handler The remove file command handler.
* @param Populate_File_Command_Handler $populate_file_command_handler The populate file command handler.
* @param Options_Helper $options_helper The options helper.
*/
public function __construct(
Llms_Txt_Cron_Scheduler $scheduler,
Remove_File_Command_Handler $remove_file_command_handler,
Populate_File_Command_Handler $populate_file_command_handler,
Options_Helper $options_helper
) {
$this->scheduler = $scheduler;
$this->remove_file_command_handler = $remove_file_command_handler;
$this->populate_file_command_handler = $populate_file_command_handler;
$this->options_helper = $options_helper;
}
/**
* Initializes the integration.
*
* This is the place to register hooks and filters.
*
* @return void
*/
public function register_hooks() {
\add_action( 'update_option_wpseo', [ $this, 'check_toggle_llms_txt' ], 10, 2 );
\add_action( 'update_option_wpseo_llmstxt', [ $this, 'check_llms_txt_selection' ], 10, 2 );
}
/**
* Checks if the LLMS.txt feature is toggled.
*
* @param array<string|int|bool|array<string|int|bool>> $old_value The old value of the option.
* @param array<string|int|bool|array<string|int|bool>> $new_value The new value of the option.
*
* @return void
*/
public function check_toggle_llms_txt( $old_value, $new_value ): void {
$option_name = 'enable_llms_txt';
if ( \array_key_exists( $option_name, $old_value ) && \array_key_exists( $option_name, $new_value ) && $old_value[ $option_name ] !== $new_value[ $option_name ] ) {
if ( $new_value[ $option_name ] === true ) {
$this->scheduler->schedule_weekly_llms_txt_population();
$this->populate_file_command_handler->handle();
}
else {
$this->scheduler->unschedule_llms_txt_population();
$this->remove_file_command_handler->handle();
}
}
}
/**
* Checks if any of the llms.txt settings were changed.
*
* @param array<string, string|int|array<int>> $old_value The old value of the option.
* @param array<string, string|int|array<int>> $new_value The new value of the option.
*
* @return void
*/
public function check_llms_txt_selection( $old_value, $new_value ): void {
if ( $this->options_helper->get( 'enable_llms_txt', false ) !== true ) {
return;
}
foreach ( $this->option_names as $option_name ) {
if ( ! \array_key_exists( $option_name, $old_value ) || ! \array_key_exists( $option_name, $new_value ) ) {
continue;
}
if ( $old_value[ $option_name ] !== $new_value[ $option_name ] ) {
$this->populate_file_command_handler->handle();
return;
}
}
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace Yoast\WP\SEO\Llms_Txt\User_Interface;
use Yoast\WP\SEO\Conditionals\No_Conditionals;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Integrations\Integration_Interface;
use Yoast\WP\SEO\Llms_Txt\Application\File\Commands\Populate_File_Command_Handler;
use Yoast\WP\SEO\Llms_Txt\Application\File\File_Failure_Notification_Presenter;
use Yoast_Notification;
use Yoast_Notification_Center;
/**
* Watches and handles changes to the LLMS.txt file failure option.
*
* @phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded
*/
class File_Failure_Llms_Txt_Notification_Integration implements Integration_Interface {
use No_Conditionals;
/**
* The notification ID.
*/
public const NOTIFICATION_ID = 'wpseo-llms-txt-generation-failure';
/**
* The options helper.
*
* @var Options_Helper
*/
private $options_helper;
/**
* The notification center.
*
* @var Yoast_Notification_Center
*/
private $notification_center;
/**
* The notification presenter.
*
* @var File_Failure_Notification_Presenter
*/
private $presenter;
/**
* Constructor.
*
* @param Options_Helper $options_helper The options helper.
* @param Yoast_Notification_Center $notification_center The notification center.
* @param File_Failure_Notification_Presenter $presenter The notification presenter.
*/
public function __construct(
Options_Helper $options_helper,
Yoast_Notification_Center $notification_center,
File_Failure_Notification_Presenter $presenter
) {
$this->options_helper = $options_helper;
$this->notification_center = $notification_center;
$this->presenter = $presenter;
}
/**
* Initializes the integration.
*
* This is the place to register hooks and filters.
*
* @return void
*/
public function register_hooks() {
\add_action( 'admin_init', [ $this, 'maybe_show_notification' ], 10, 2 );
}
/**
* Manage the search engines discouraged notification.
*
* Shows the notification if needed and deletes it if needed.
*
* @return void
*/
public function maybe_show_notification() {
if ( ! $this->should_show_file_failure_notification() ) {
$this->remove_file_failure_notification_if_exists();
}
else {
$this->maybe_add_file_failure_notification();
}
}
/**
* Whether the file failure notification should be shown.
*
* @return bool
*/
private function should_show_file_failure_notification(): bool {
return $this->options_helper->get( 'enable_llms_txt', false ) && \get_option( Populate_File_Command_Handler::GENERATION_FAILURE_OPTION, false ) !== false;
}
/**
* Remove the search engines discouraged notification if it exists.
*
* @return void
*/
private function remove_file_failure_notification_if_exists() {
$this->notification_center->remove_notification_by_id( self::NOTIFICATION_ID );
}
/**
* Add the search engines discouraged notification if it does not exist yet.
*
* @return void
*/
private function maybe_add_file_failure_notification() {
if ( ! $this->notification_center->get_notification_by_id( self::NOTIFICATION_ID ) ) {
$notification = new Yoast_Notification(
$this->presenter->present(),
[
'type' => Yoast_Notification::ERROR,
'id' => self::NOTIFICATION_ID,
'capabilities' => 'wpseo_manage_options',
'priority' => 1,
],
);
$this->notification_center->restore_notification( $notification );
$this->notification_center->add_notification( $notification );
}
}
}

View File

@@ -0,0 +1,97 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
namespace Yoast\WP\SEO\Llms_Txt\User_Interface\Health_Check;
use Yoast\WP\SEO\Services\Health_Check\Report_Builder_Factory;
use Yoast\WP\SEO\Services\Health_Check\Reports_Trait;
/**
* Presents a set of different messages for the File_Generation health check.
*/
class File_Reports {
use Reports_Trait;
/**
* Constructor
*
* @param Report_Builder_Factory $report_builder_factory The factory for result builder objects.
* This class uses the report builder to generate WordPress-friendly
* health check results.
*/
public function __construct( Report_Builder_Factory $report_builder_factory ) {
$this->report_builder_factory = $report_builder_factory;
}
/**
* Returns the message for a successful health check.
*
* @return string[] The message as a WordPress site status report.
*/
public function get_success_result() {
$label = \sprintf(
/* translators: %s: Yoast SEO. */
\__( 'Your llms.txt file is auto-generated by %s', 'wordpress-seo' ),
'Yoast SEO',
);
$description = \sprintf(
/* translators: %s: Yoast SEO. */
\__( '%s keeps your llms.txt file up-to-date. This helps LLMs access and provide your site\'s information more easily.', 'wordpress-seo' ),
'Yoast SEO',
);
return $this->get_report_builder()
->set_label( $label )
->set_status_good()
->set_description( $description )
->build();
}
/**
* Returns the message for a failed health check. In this case, when the llms.txt file couldn't be auto-generated.
*
* @param string $reason The reason why the llms.txt file couldn't be auto-generated.
*
* @return string[] The message as a WordPress site status report.
*/
public function get_generation_failure_result( $reason ) {
switch ( $reason ) {
case 'not_managed_by_yoast_seo':
$title = \__( 'Your llms.txt file couldn\'t be auto-generated', 'wordpress-seo' );
$message = \sprintf(
/* translators: 1,3,5: expand to opening paragraph tag, 2,4,6: expand to opening paragraph tag. */
\__( '%1$sYou have activated the Yoast llms.txt feature, but we couldn\'t generate an llms.txt file.%2$s%3$sIt looks like there is an llms.txt file already that wasn\'t created by Yoast, or the llms.txt file created by Yoast has been edited manually.%4$s%5$sWe don\'t want to overwrite this file\'s content, so if you want to let Yoast keep auto-generating the llms.txt file, you can manually delete the existing one. Otherwise, consider disabling the Yoast feature.%6$s', 'wordpress-seo' ),
'<p>',
'</p>',
'<p>',
'</p>',
'<p>',
'</p>',
);
break;
case 'filesystem_permissions':
$title = \__( 'Your llms.txt file couldn\'t be auto-generated', 'wordpress-seo' );
$message = \sprintf(
/* translators: 1,3: expand to opening paragraph tag, 2,4: expand to opening paragraph tag. */
\__( '%1$sYou have activated the Yoast llms.txt feature, but we couldn\'t generate an llms.txt file.%2$s%3$sIt looks like there aren\'t sufficient permissions on the web server\'s filesystem.%4$s', 'wordpress-seo' ),
'<p>',
'</p>',
'<p>',
'</p>',
);
break;
default:
$title = \__( 'Your llms.txt file couldn\'t be auto-generated', 'wordpress-seo' );
$message = \__( 'You have activated the Yoast llms.txt feature, but we couldn\'t generate an llms.txt file, for unknown reasons.', 'wordpress-seo' );
break;
}
return $this->get_report_builder()
->set_label( $title )
->set_status_recommended()
->set_description( $message )
->build();
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Yoast\WP\SEO\Llms_Txt\User_Interface;
use Yoast\WP\SEO\Conditionals\No_Conditionals;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Integrations\Integration_Interface;
use Yoast\WP\SEO\Llms_Txt\Application\File\Commands\Populate_File_Command_Handler;
use Yoast\WP\SEO\Llms_Txt\Application\File\Commands\Remove_File_Command_Handler;
use Yoast\WP\SEO\Llms_Txt\Application\File\Llms_Txt_Cron_Scheduler;
/**
* Cron Callback integration. This handles the actual process of populating the llms.txt on a cron trigger.
*/
class Llms_Txt_Cron_Callback_Integration implements Integration_Interface {
use No_Conditionals;
/**
* The remove file command handler.
*
* @var Remove_File_Command_Handler
*/
private $remove_file_command_handler;
/**
* The Create Populate Command Handler.
*
* @var Populate_File_Command_Handler
*/
private $populate_file_command_handler;
/**
* The options helper.
*
* @var Options_Helper
*/
private $options_helper;
/**
* The scheduler.
*
* @var Llms_Txt_Cron_Scheduler
*/
private $scheduler;
/**
* Constructor.
*
* @param Options_Helper $options_helper The options helper.
* @param Llms_Txt_Cron_Scheduler $scheduler The scheduler.
* @param Populate_File_Command_Handler $populate_file_command_handler The populate file command handler.
* @param Remove_File_Command_Handler $remove_file_command_handler The remove file command handler.
*/
public function __construct(
Options_Helper $options_helper,
Llms_Txt_Cron_Scheduler $scheduler,
Populate_File_Command_Handler $populate_file_command_handler,
Remove_File_Command_Handler $remove_file_command_handler
) {
$this->options_helper = $options_helper;
$this->scheduler = $scheduler;
$this->populate_file_command_handler = $populate_file_command_handler;
$this->remove_file_command_handler = $remove_file_command_handler;
}
/**
* Registers the hooks with WordPress.
*
* @return void
*/
public function register_hooks() {
\add_action(
Llms_Txt_Cron_Scheduler::LLMS_TXT_POPULATION,
[
$this,
'populate_file',
],
);
}
/**
* Populates and creates the file.
*
* @return void
*/
public function populate_file(): void {
if ( ! \wp_doing_cron() ) {
return;
}
if ( $this->options_helper->get( 'enable_llms_txt', false ) !== true ) {
$this->scheduler->unschedule_llms_txt_population();
$this->remove_file_command_handler->handle();
return;
}
$this->populate_file_command_handler->handle();
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Yoast\WP\SEO\Llms_Txt\User_Interface;
use Yoast\WP\SEO\Conditionals\No_Conditionals;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Integrations\Integration_Interface;
use Yoast\WP\SEO\Llms_Txt\Application\File\Llms_Txt_Cron_Scheduler;
/**
* Handles the cron when the plugin is activated.
*/
class Schedule_Population_On_Activation_Integration implements Integration_Interface {
use No_Conditionals;
/**
* The options helper.
*
* @var Options_Helper $options_helper
*/
private $options_helper;
/**
* The scheduler.
*
* @var Llms_Txt_Cron_Scheduler $scheduler
*/
private $scheduler;
/**
* The constructor.
*
* @param Llms_Txt_Cron_Scheduler $scheduler The cron scheduler.
* @param Options_Helper $options_helper The options helper.
*/
public function __construct(
Llms_Txt_Cron_Scheduler $scheduler,
Options_Helper $options_helper
) {
$this->scheduler = $scheduler;
$this->options_helper = $options_helper;
}
/**
* Registers the scheduling of the cron to the activation action.
*
* @return void
*/
public function register_hooks() {
\add_action( 'wpseo_activate', [ $this, 'schedule_llms_txt_population' ] );
}
/**
* Schedules the cron if the option is turned on.
*
* @return void
*/
public function schedule_llms_txt_population() {
if ( $this->options_helper->get( 'enable_llms_txt', false ) === true ) {
$this->scheduler->schedule_quick_llms_txt_population();
}
}
}