• File: markdown-endpoint.php
  • Full Path: /home/slvgwrnz/public_html/wp-content/plugins/elementor/modules/agents/components/readability/markdown-endpoint.php
  • Date Modified: 11/05/2026 9:17 AM
  • File size: 12.32 KB
  • MIME-type: text/x-php
  • Charset: utf-8
<?php

namespace Elementor\Modules\Agents\Components\Readability;

use Elementor\Modules\Agents\Agent_Ready_Settings;
use Elementor\Modules\Agents\Classes\Feature_Component;
use Elementor\Modules\Agents\Classes\Post_Noindex;
use Elementor\Modules\Agents\Markdown_Manual_Content;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Handles the `.md` path endpoint for the Agents module.
 *
 * Intercepts requests of the form `/{slug}.md`, `/{post-type}/{slug}.md`,
 * and `/index.md`, resolves the target post, and serves a markdown response
 * composed of YAML front matter followed by extracted body content.
 *
 * Also serves as a fallback for `?format=markdown` / `Accept: text/markdown`
 * requests that the markdown-render module bailed on (non-Elementor pages),
 * and adds missing response headers to pages served by markdown-render.
 */
class Markdown_Endpoint extends Feature_Component {

	/**
	 * Stable string identifier for this component.
	 */
	public function get_id(): string {
		return 'markdown_endpoint';
	}

	/**
	 * Layer this component belongs to.
	 */
	public function get_layer(): string {
		return 'readability';
	}

	public function get_required_modules(): array {
		return [
			Agent_Ready_Settings::MODULE_MARKDOWN_CONTENT,
		];
	}

	/** @var int|null Post ID resolved from a `.md` path request. */
	private ?int $md_post_id = null;

	/** @var bool Whether a `.md` path was detected on this request. */
	private bool $serving_md = false;

	private Agent_Ready_Settings $settings;

	private Markdown_Manual_Content $manual_content;

	public function __construct( Agent_Ready_Settings $settings, Markdown_Manual_Content $manual_content ) {
		$this->settings       = $settings;
		$this->manual_content = $manual_content;
	}

	// -------------------------------------------------------------------------
	// Registration
	// -------------------------------------------------------------------------

	/**
	 * Wire all hooks.
	 *
	 * Called by the component architecture once it is ready. Not invoked
	 * automatically — a follow-up PR will call this from module.php.
	 */
	public function register(): void {
		if ( $this->settings->is_module_active( Agent_Ready_Settings::MODULE_MARKDOWN_CONTENT ) ) {
			add_action( 'parse_request', [ $this, 'on_parse_request' ], 1 );
			add_action( 'template_redirect', [ $this, 'on_md_path_redirect' ], 1 );
			add_action( 'template_redirect', [ $this, 'on_markdown_request_fallback' ], 2 );
			add_action( 'elementor/markdown/headers', [ $this, 'on_markdown_headers' ] );
			add_filter( 'elementor/markdown/should_serve', [ $this, 'filter_should_serve' ], 10, 2 );
		}
	}

	// -------------------------------------------------------------------------
	// Hooks
	// -------------------------------------------------------------------------

	/**
	 * Detect a `.md` suffix in the request path, resolve the matching post,
	 * and store it for the `template_redirect` handler.
	 *
	 * Fires at `parse_request` priority 1 — before WordPress has committed to
	 * a 404 response for an unknown path.
	 *
	 * @param \WP $wp Current WordPress environment instance.
	 */
	public function on_parse_request( \WP $wp ): void {
		$path = \Elementor\Modules\Agents\Classes\Request_Path::get_relative_path();

		if ( '' === $path ) {
			return;
		}

		// Must end with `.md` and not be a known special file like `llms.txt`.
		if ( '.md' !== substr( $path, -3 ) ) {
			return;
		}

		// Strip the `.md` suffix to get the plain path.
		$plain_path = substr( $path, 0, -3 );

		$post_id = $this->resolve_post_id_from_path( $plain_path );

		if ( ! $post_id ) {
			return;
		}

		$post = get_post( $post_id );

		if ( ! ( $post instanceof \WP_Post ) ) {
			return;
		}

		$this->md_post_id = $post_id;
		$this->serving_md = true;
	}

	/**
	 * If a `.md` path was resolved, serve the markdown response and exit.
	 *
	 * Fires at `template_redirect` priority 1 — before markdown-render's own
	 * `template_redirect` at priority 1 runs for the same request (WordPress
	 * calls multiple callbacks at the same priority in registration order, but
	 * this class is registered after markdown-render so we need priority 1 here
	 * to run in the same slot; in practice the `.md` path won't satisfy
	 * markdown-render's `is_singular()` check, so there is no conflict).
	 */
	public function on_md_path_redirect(): void {
		if ( ! $this->serving_md || null === $this->md_post_id ) {
			return;
		}

		$post = get_post( $this->md_post_id );

		if ( ! ( $post instanceof \WP_Post ) ) {
			return;
		}

		$this->serve_markdown( $post );
	}

	/**
	 * Fallback handler for `?format=markdown` or `Accept: text/markdown`
	 * requests on non-Elementor pages.
	 *
	 * The markdown-render module fires at `template_redirect` priority 1 and
	 * bails (returns without exiting) when the page is not built with Elementor.
	 * This handler runs at priority 2 to catch those cases.
	 *
	 * Fires at `template_redirect` priority 2.
	 */
	public function on_markdown_request_fallback(): void {
		// Already handled by the `.md` path flow.
		if ( $this->serving_md ) {
			return;
		}

		if ( ! $this->is_markdown_request() ) {
			return;
		}

		if ( ! is_singular() ) {
			return;
		}

		$post_id = get_the_ID();
		$post    = get_post( $post_id );

		if ( ! ( $post instanceof \WP_Post ) ) {
			return;
		}

		if ( ! $this->is_included_by_settings( $post ) ) {
			return;
		}

		// Only handle pages not built with Elementor — those are already
		// handled (or intentionally skipped) by markdown-render — unless the
		// post carries a manual override, which markdown-render must not serve.
		if ( ! $this->manual_content->has_override( $post_id ) && class_exists( '\Elementor\Plugin' ) ) {
			$document = \Elementor\Plugin::$instance->documents->get( $post_id );

			if ( $document && $document->is_built_with_elementor() ) {
				return;
			}
		}

		$this->serve_markdown( $post );
	}

	/**
	 * Keep markdown-render from serving posts the Agent Ready settings exclude
	 * or that an SEO plugin marks noindex, so the request falls through to the
	 * regular HTML page.
	 *
	 * Does not repeat the publish-status or password checks: markdown-render
	 * already does those, and applying them here would block editor previews
	 * of drafts.
	 *
	 * Fires on the `elementor/markdown/should_serve` filter.
	 *
	 * @param bool     $should_serve Whether markdown-render should attempt to serve.
	 * @param \WP_Post $post         The current post.
	 */
	public function filter_should_serve( $should_serve, \WP_Post $post ): bool {
		if ( ! $this->settings->is_module_active( Agent_Ready_Settings::MODULE_MARKDOWN_CONTENT ) ) {
			return (bool) $should_serve;
		}

		if ( ! $this->is_included_by_settings( $post ) || Post_Noindex::is_noindex( $post->ID ) ) {
			return false;
		}

		if ( $this->manual_content->has_override( $post->ID ) ) {
			return false;
		}

		return (bool) $should_serve;
	}

	/**
	 * Add the three response headers that markdown-render does not emit but
	 * that agents and crawlers expect on `.md` / markdown responses.
	 *
	 * Fires on the `elementor/markdown/headers` action, which is triggered by
	 * markdown-render just before it sends its own headers.
	 *
	 * @param int $post_id The post being served.
	 */
	public function on_markdown_headers( int $post_id ): void {
		if ( headers_sent() ) {
			return;
		}

		foreach ( $this->build_markdown_hook_headers( $post_id ) as $name => $value ) {
			header( $name . ': ' . $value );
		}
	}

	// -------------------------------------------------------------------------
	// Core serving logic
	// -------------------------------------------------------------------------

	/**
	 * Build and emit a complete markdown response for the given post, then exit.
	 *
	 * Performs a basic content-scope check (published, publicly viewable, not
	 * password-protected) before generating output. A dedicated `Content_Scope`
	 * class will replace this inline check in a later PR.
	 *
	 * @param \WP_Post $post The post to serve.
	 */
	public function serve_markdown( \WP_Post $post ): void {
		if ( ! $this->is_markdown_access_allowed( $post ) ) {
			$this->deny_markdown_access();
		}

		$output = $this->build_markdown( $post );

		$this->send_headers( $post->ID );
		status_header( 200 );
		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo $output;
		exit;
	}

	/**
	 * Build the markdown document served for a post, without sending it.
	 *
	 * @param \WP_Post $post The post to render.
	 */
	public function build_markdown( \WP_Post $post ): string {
		if ( $this->manual_content->has_override( $post->ID ) ) {
			return $this->manual_content->get_content( $post->ID );
		}

		$extractor   = new Content_Extractor();
		$extraction  = $extractor->extract_with_id( $post );
		$frontmatter = ( new Frontmatter_Builder() )->build( $post, $extraction['id'] );

		return $frontmatter . "\n\n" . $extraction['body'];
	}

	/**
	 * @param \WP_Post $post
	 * @return bool
	 */
	public function is_markdown_access_allowed( \WP_Post $post ): bool {
		if ( 'publish' !== $post->post_status ) {
			return false;
		}

		if ( post_password_required( $post ) ) {
			return false;
		}

		if ( ! is_post_publicly_viewable( $post ) ) {
			return false;
		}

		if ( Post_Noindex::is_noindex( $post->ID ) ) {
			return false;
		}

		return $this->is_included_by_settings( $post );
	}

	public function has_manual_override( \WP_Post $post ): bool {
		return $this->manual_content->has_override( $post->ID );
	}

	public function is_included_by_settings( \WP_Post $post ): bool {
		return $this->settings->is_markdown_enabled()
			&& in_array( $post->post_type, $this->settings->get_markdown_post_types(), true )
			&& ! $this->settings->is_post_excluded( Agent_Ready_Settings::MODULE_MARKDOWN_CONTENT, $post->ID );
	}

	/**
	 * Emit all required response headers for a markdown document.
	 *
	 * @param int $post_id The post ID whose canonical URL is included in the
	 *                     Link header.
	 */
	public function send_headers( int $post_id ): void {
		if ( headers_sent() ) {
			return;
		}

		foreach ( $this->build_response_headers( $post_id ) as $name => $value ) {
			header( $name . ': ' . $value );
		}
	}

	/**
	 * @return array<string, string>
	 */
	public function build_response_headers( int $post_id ): array {
		return array_merge(
			$this->build_base_markdown_headers(),
			[
				'Link' => '<' . esc_url_raw( get_permalink( $post_id ) ) . '>; rel="canonical"',
			]
		);
	}

	/**
	 * @return array<string, string>
	 */
	public function build_base_markdown_headers(): array {
		return [
			'Content-Type'           => 'text/markdown; charset=utf-8',
			'X-Content-Type-Options' => 'nosniff',
			'Vary'                   => 'Accept',
			'X-Robots-Tag'           => 'noindex',
		];
	}

	/**
	 * @return array<string, string>
	 */
	public function build_markdown_hook_headers( int $post_id ): array {
		return [
			'Vary'         => 'Accept',
			'Link'         => '<' . esc_url_raw( get_permalink( $post_id ) ) . '>; rel="canonical"',
			'X-Robots-Tag' => 'noindex',
		];
	}

	// -------------------------------------------------------------------------
	// Internal helpers
	// -------------------------------------------------------------------------

	/**
	 * Return true if the current request signals a markdown response is desired,
	 * either via the `?format=markdown` query parameter or the `Accept` header.
	 *
	 * Mirrors the same logic used in the markdown-render module so the two
	 * modules agree on what constitutes a markdown request.
	 */
	private function resolve_post_id_from_path( string $plain_path ): int {
		$path = trim( $plain_path, '/' );

		if ( '' === $path ) {
			return 0;
		}

		if ( 'index' === $path && 'page' === get_option( 'show_on_front' ) ) {
			return (int) get_option( 'page_on_front' );
		}

		$post_types = get_post_types( [ 'public' => true ] );
		$post       = get_page_by_path( $path, OBJECT, $post_types );

		if ( $post instanceof \WP_Post ) {
			return $post->ID;
		}

		return (int) url_to_postid( home_url( $plain_path ) );
	}

	private function deny_markdown_access(): void {
		global $wp_query;

		$wp_query->set_404();
		status_header( 404 );
		nocache_headers();
		exit;
	}

	public function is_markdown_request(): bool {
		if ( isset( $_GET['format'] ) && 'markdown' === $_GET['format'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			return true;
		}

		$accept = isset( $_SERVER['HTTP_ACCEPT'] )
			? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) )
			: '';

		return false !== strpos( $accept, 'text/markdown' );
	}
}