Create New Item
Item Type
File
Folder
Item Name
Search file in folder and subfolders...
Are you sure want to rename?
File Manager
/
wp-content
/
plugins
/
elementor
/
modules
/
agents
/
components
/
readability
:
markdown-endpoint.php
Advanced Search
Upload
New Item
Settings
Back
Back Up
Advanced Editor
Save
<?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' ); } }