<?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' );
}
}