From 4bf5ffbab13fdd5be13d6520c0697d28cc8023ef Mon Sep 17 00:00:00 2001
From: James LePage <36246732+Jameswlepage@users.noreply.github.com>
Date: Fri, 19 Dec 2025 17:49:40 -0500
Subject: [PATCH 1/9] Add Markdown feeds experiment
---
docs/experiments/markdown-feeds.md | 42 ++
includes/Experiment_Loader.php | 1 +
.../HTML_To_Markdown_Converter.php | 376 ++++++++++++++
.../Markdown_Feeds/Markdown_Feed_Renderer.php | 183 +++++++
.../Markdown_Feeds/Markdown_Feeds.php | 468 ++++++++++++++++++
.../Markdown_Singular_Renderer.php | 153 ++++++
.../Includes/Experiment_LoaderTest.php | 7 +
.../HTML_To_Markdown_ConverterTest.php | 45 ++
.../Markdown_Feeds/Markdown_FeedsTest.php | 72 +++
9 files changed, 1347 insertions(+)
create mode 100644 docs/experiments/markdown-feeds.md
create mode 100644 includes/Experiments/Markdown_Feeds/HTML_To_Markdown_Converter.php
create mode 100644 includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php
create mode 100644 includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
create mode 100644 includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php
create mode 100644 tests/Integration/Includes/Experiments/Markdown_Feeds/HTML_To_Markdown_ConverterTest.php
create mode 100644 tests/Integration/Includes/Experiments/Markdown_Feeds/Markdown_FeedsTest.php
diff --git a/docs/experiments/markdown-feeds.md b/docs/experiments/markdown-feeds.md
new file mode 100644
index 00000000..60e75dab
--- /dev/null
+++ b/docs/experiments/markdown-feeds.md
@@ -0,0 +1,42 @@
+# Markdown Feeds Experiment
+
+## Summary
+
+Adds Markdown representations of WordPress content:
+
+- Feed: `/?feed=markdown` (and `/feed/markdown/` once rewrite rules are flushed).
+- Singular: `https://example.com/my-post.md` (optional).
+- Singular content negotiation: `Accept: text/markdown` (optional).
+
+The output is intended to be a lightweight, text-first format that is easier for automated clients (including AI tooling) to ingest than full HTML.
+
+## Key Hooks & Entry Points
+
+- `init` -> registers a custom feed using `add_feed( 'markdown', ... )` (optional).
+- `do_parse_request` -> strips a trailing `.md` from front-end requests so WordPress can resolve the underlying canonical URL (optional).
+- `template_redirect` -> renders `text/markdown` for singular content when requested via `.md` or `Accept: text/markdown` (optional).
+- Renderer -> converts rendered `the_content` HTML into Markdown using WordPress core’s HTML API (`WP_HTML_Processor`, with fallback to `WP_HTML_Tag_Processor`).
+- Filters:
+ - `ai_experiments_markdown_feed_html` -> adjust HTML before conversion.
+ - `ai_experiments_markdown_feed_markdown` -> adjust Markdown after conversion.
+ - `ai_experiments_markdown_singular_html` -> adjust HTML before conversion (singular).
+ - `ai_experiments_markdown_singular_markdown` -> adjust Markdown after conversion (singular).
+
+## Assets & Data Flow
+
+- No JS/CSS assets.
+- Uses the main feed query loop (`have_posts()` / `the_post()`) and outputs Markdown for each item (title, URL, publish date, content).
+
+## Testing
+
+1. Enable **AI Experiments** globally and enable **Markdown**.
+2. Visit `/?feed=markdown` and confirm a `text/markdown` response containing one or more posts.
+3. Visit a single post or page with `.md` appended (e.g. `/hello-world.md`) and confirm a `text/markdown` response.
+4. Make a request to a post or page with `Accept: text/markdown` and confirm a `text/markdown` response.
+5. If pretty permalinks are enabled, flush rewrite rules (e.g., visit Settings → Permalinks) and confirm `/feed/markdown/` works.
+6. Verify common content renders reasonably (headings, paragraphs, lists, links, images, code blocks).
+
+## Notes
+
+- The HTML-to-Markdown conversion is intentionally conservative and based on WordPress core’s HTML API (`WP_HTML_Processor`) rather than a bundled third-party parser.
+- This experiment currently targets singular post content (title + metadata + content). It does not attempt to convert full theme templates or archive views.
diff --git a/includes/Experiment_Loader.php b/includes/Experiment_Loader.php
index ae74cf75..b86a82f6 100644
--- a/includes/Experiment_Loader.php
+++ b/includes/Experiment_Loader.php
@@ -106,6 +106,7 @@ private function get_default_experiments(): array {
$experiment_classes = array(
\WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class,
\WordPress\AI\Experiments\Image_Generation\Image_Generation::class,
+ \WordPress\AI\Experiments\Markdown_Feeds\Markdown_Feeds::class,
\WordPress\AI\Experiments\Summarization\Summarization::class,
\WordPress\AI\Experiments\Title_Generation\Title_Generation::class,
);
diff --git a/includes/Experiments/Markdown_Feeds/HTML_To_Markdown_Converter.php b/includes/Experiments/Markdown_Feeds/HTML_To_Markdown_Converter.php
new file mode 100644
index 00000000..39dee991
--- /dev/null
+++ b/includes/Experiments/Markdown_Feeds/HTML_To_Markdown_Converter.php
@@ -0,0 +1,376 @@
+create_processor( $html );
+ if ( ! $processor ) {
+ return trim( wp_strip_all_tags( $html ) );
+ }
+ $markdown = $this->convert_with_processor( $processor );
+
+ if (
+ $processor instanceof \WP_HTML_Processor
+ && method_exists( $processor, 'get_last_error' )
+ && \WP_HTML_Processor::ERROR_UNSUPPORTED === $processor->get_last_error()
+ && class_exists( \WP_HTML_Tag_Processor::class )
+ ) {
+ $markdown = $this->convert_with_processor( new \WP_HTML_Tag_Processor( $html ) );
+ }
+
+ return trim( $this->cleanup( $markdown ) );
+ }
+
+ /**
+ * Creates the best available HTML processor for conversion.
+ *
+ * Uses the HTML Processor in fragment mode when available, and falls back to
+ * the Tag Processor for broader tag tolerance.
+ *
+ * @since x.x.x
+ *
+ * @param string $html HTML string.
+ * @return \WP_HTML_Tag_Processor|\WP_HTML_Processor|null Processor instance.
+ */
+ private function create_processor( string $html ) {
+ $processor = null;
+
+ if ( class_exists( \WP_HTML_Processor::class ) ) {
+ if ( method_exists( \WP_HTML_Processor::class, 'create_fragment' ) ) {
+ $processor = \WP_HTML_Processor::create_fragment( $html );
+ }
+
+ if ( ! $processor ) {
+ $processor = new \WP_HTML_Processor( $html );
+ }
+ } elseif ( class_exists( \WP_HTML_Tag_Processor::class ) ) {
+ $processor = new \WP_HTML_Tag_Processor( $html );
+ }
+
+ if (
+ ! $processor
+ || ! method_exists( $processor, 'next_token' )
+ || ! method_exists( $processor, 'get_token_name' )
+ || ! method_exists( $processor, 'get_modifiable_text' )
+ || ! method_exists( $processor, 'is_tag_closer' )
+ || ! method_exists( $processor, 'get_attribute' )
+ ) {
+ return null;
+ }
+
+ return $processor;
+ }
+
+ /**
+ * Converts HTML into Markdown using a provided HTML API processor.
+ *
+ * @since x.x.x
+ *
+ * @param object $processor Processor instance.
+ * @return string Markdown output.
+ */
+ private function convert_with_processor( $processor ): string {
+ $markdown = '';
+
+ $at_line_start = true;
+ $blockquote_depth = 0;
+ $in_pre = false;
+
+ $link_stack = array();
+ $list_stack = array();
+
+ while ( $processor->next_token() ) {
+ $token_name = $processor->get_token_name();
+
+ if ( '#text' === $token_name ) {
+ $text = (string) $processor->get_modifiable_text();
+ $this->append_text( $markdown, $text, $at_line_start, $blockquote_depth, $in_pre );
+ continue;
+ }
+
+ // Skip script/style entirely.
+ if ( 'SCRIPT' === $token_name || 'STYLE' === $token_name ) {
+ continue;
+ }
+
+ $is_closer = $processor->is_tag_closer();
+
+ if ( 'BR' === $token_name ) {
+ $this->append_newline( $markdown, $at_line_start );
+ continue;
+ }
+
+ if ( 'HR' === $token_name && ! $is_closer ) {
+ $this->ensure_blank_line( $markdown, $at_line_start );
+ $this->append_line( $markdown, '---', $at_line_start, $blockquote_depth );
+ $this->ensure_blank_line( $markdown, $at_line_start );
+ continue;
+ }
+
+ if ( ( 'P' === $token_name || 'DIV' === $token_name ) && $is_closer ) {
+ if ( ! $in_pre ) {
+ $this->ensure_blank_line( $markdown, $at_line_start );
+ }
+ continue;
+ }
+
+ if ( 'BLOCKQUOTE' === $token_name ) {
+ if ( $is_closer ) {
+ $blockquote_depth = max( 0, $blockquote_depth - 1 );
+ $this->ensure_blank_line( $markdown, $at_line_start );
+ } else {
+ ++$blockquote_depth;
+ $this->ensure_blank_line( $markdown, $at_line_start );
+ }
+ continue;
+ }
+
+ if ( 'PRE' === $token_name ) {
+ if ( $is_closer ) {
+ if ( ! $at_line_start ) {
+ $this->append_newline( $markdown, $at_line_start );
+ }
+ $this->append_line( $markdown, '```', $at_line_start, $blockquote_depth );
+ $this->ensure_blank_line( $markdown, $at_line_start );
+ $in_pre = false;
+ } else {
+ $this->ensure_blank_line( $markdown, $at_line_start );
+ $this->append_line( $markdown, '```', $at_line_start, $blockquote_depth );
+ $in_pre = true;
+ }
+ continue;
+ }
+
+ if ( 'CODE' === $token_name && ! $in_pre ) {
+ $markdown .= '`';
+ $at_line_start = false;
+ continue;
+ }
+
+ if ( 'STRONG' === $token_name || 'B' === $token_name ) {
+ $markdown .= '**';
+ $at_line_start = false;
+ continue;
+ }
+
+ if ( 'EM' === $token_name || 'I' === $token_name ) {
+ $markdown .= '*';
+ $at_line_start = false;
+ continue;
+ }
+
+ if ( 'A' === $token_name ) {
+ if ( $is_closer ) {
+ $href = array_pop( $link_stack );
+ if ( $href ) {
+ $markdown .= '](' . $href . ')';
+ } else {
+ $markdown .= ']';
+ }
+ } else {
+ $link_stack[] = (string) $processor->get_attribute( 'href' );
+ $markdown .= '[';
+ }
+ $at_line_start = false;
+ continue;
+ }
+
+ if ( 'IMG' === $token_name && ! $is_closer ) {
+ $src = (string) $processor->get_attribute( 'src' );
+ if ( '' === $src ) {
+ continue;
+ }
+
+ $alt = (string) $processor->get_attribute( 'alt' );
+ $this->append_text( $markdown, '', $at_line_start, $blockquote_depth, true );
+ continue;
+ }
+
+ if ( 'UL' === $token_name || 'OL' === $token_name ) {
+ if ( $is_closer ) {
+ array_pop( $list_stack );
+ $this->ensure_blank_line( $markdown, $at_line_start );
+ } else {
+ $list_stack[] = array(
+ 'type' => $token_name,
+ 'index' => 0,
+ );
+ $this->ensure_blank_line( $markdown, $at_line_start );
+ }
+ continue;
+ }
+
+ if ( 'LI' === $token_name && ! $is_closer ) {
+ $this->ensure_newline( $markdown, $at_line_start );
+
+ $depth = count( $list_stack );
+ $indent = str_repeat( ' ', max( 0, $depth - 1 ) );
+
+ $marker = '-';
+ if ( $depth > 0 && 'OL' === $list_stack[ $depth - 1 ]['type'] ) {
+ ++$list_stack[ $depth - 1 ]['index'];
+ $marker = (string) $list_stack[ $depth - 1 ]['index'] . '.';
+ }
+
+ $this->append_text(
+ $markdown,
+ $indent . $marker . ' ',
+ $at_line_start,
+ $blockquote_depth,
+ true
+ );
+
+ continue;
+ }
+
+ if ( ! preg_match( '/^H([1-6])$/', $token_name, $matches ) ) {
+ continue;
+ }
+
+ if ( $is_closer ) {
+ $this->ensure_blank_line( $markdown, $at_line_start );
+ } else {
+ $level = (int) $matches[1];
+ $this->ensure_blank_line( $markdown, $at_line_start );
+ $this->append_text(
+ $markdown,
+ str_repeat( '#', $level ) . ' ',
+ $at_line_start,
+ $blockquote_depth,
+ true
+ );
+ }
+ continue;
+ }
+
+ return $markdown;
+ }
+
+ /**
+ * Appends plain text to the Markdown output.
+ *
+ * @since x.x.x
+ *
+ * @param string $markdown Markdown buffer.
+ * @param string $text Text to append.
+ * @param bool $at_line_start Whether output is at the start of a line.
+ * @param int $blockquote_depth Current blockquote depth.
+ * @param bool $preserve_whitespace Whether to preserve whitespace.
+ */
+ private function append_text( string &$markdown, string $text, bool &$at_line_start, int $blockquote_depth, bool $preserve_whitespace = false ): void {
+ if ( '' === $text ) {
+ return;
+ }
+
+ $text = str_replace( array( "\r\n", "\r" ), "\n", $text );
+
+ if ( ! $preserve_whitespace ) {
+ $text = preg_replace( '/\\s+/u', ' ', $text );
+ }
+
+ if ( $at_line_start && 0 < $blockquote_depth ) {
+ $markdown .= str_repeat( '> ', $blockquote_depth );
+ }
+
+ $markdown .= $text;
+ $at_line_start = false;
+ }
+
+ /**
+ * Appends a newline.
+ *
+ * @since x.x.x
+ *
+ * @param string $markdown Markdown buffer.
+ * @param bool $at_line_start Whether output is at the start of a line.
+ */
+ private function append_newline( string &$markdown, bool &$at_line_start ): void {
+ $markdown .= "\n";
+ $at_line_start = true;
+ }
+
+ /**
+ * Appends a full line and ensures the buffer ends at a new line.
+ *
+ * @since x.x.x
+ *
+ * @param string $markdown Markdown buffer.
+ * @param string $line Line content.
+ * @param bool $at_line_start Whether output is at the start of a line.
+ * @param int $blockquote_depth Current blockquote depth.
+ */
+ private function append_line( string &$markdown, string $line, bool &$at_line_start, int $blockquote_depth ): void {
+ $this->ensure_newline( $markdown, $at_line_start );
+ $this->append_text( $markdown, $line, $at_line_start, $blockquote_depth, true );
+ $this->append_newline( $markdown, $at_line_start );
+ }
+
+ /**
+ * Ensures output starts on a new line.
+ *
+ * @since x.x.x
+ *
+ * @param string $markdown Markdown buffer.
+ * @param bool $at_line_start Whether output is at the start of a line.
+ */
+ private function ensure_newline( string &$markdown, bool &$at_line_start ): void {
+ if ( $at_line_start ) {
+ return;
+ }
+
+ $this->append_newline( $markdown, $at_line_start );
+ }
+
+ /**
+ * Ensures output ends with a blank line.
+ *
+ * @since x.x.x
+ *
+ * @param string $markdown Markdown buffer.
+ * @param bool $at_line_start Whether output is at the start of a line.
+ */
+ private function ensure_blank_line( string &$markdown, bool &$at_line_start ): void {
+ $markdown = rtrim( $markdown, "\n" );
+ $markdown .= "\n\n";
+ $at_line_start = true;
+ }
+
+ /**
+ * Cleans up excessive whitespace and newlines.
+ *
+ * @since x.x.x
+ *
+ * @param string $markdown Markdown buffer.
+ * @return string Cleaned buffer.
+ */
+ private function cleanup( string $markdown ): string {
+ $markdown = preg_replace( "/[ \\t]+\\n/", "\n", $markdown );
+ $markdown = preg_replace( "/\\n{3,}/", "\n\n", $markdown );
+ return (string) $markdown;
+ }
+}
diff --git a/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php b/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php
new file mode 100644
index 00000000..2567b49b
--- /dev/null
+++ b/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php
@@ -0,0 +1,183 @@
+send_headers();
+ if ( $this->is_head_request() ) {
+ return;
+ }
+ $this->send_feed_header();
+ $this->send_posts();
+ }
+
+ /**
+ * Sends HTTP headers for the Markdown feed response.
+ *
+ * @since x.x.x
+ */
+ private function send_headers(): void {
+ status_header( 200 );
+ nocache_headers();
+ header( 'Content-Type: text/markdown; charset=' . get_option( 'blog_charset' ), true );
+ header( 'X-Content-Type-Options: nosniff', true );
+ }
+
+ /**
+ * Outputs the feed header in Markdown.
+ *
+ * @since x.x.x
+ */
+ private function send_feed_header(): void {
+ $site_name = wp_strip_all_tags( (string) get_bloginfo( 'name' ) );
+ $site_desc = wp_strip_all_tags( (string) get_bloginfo( 'description' ) );
+ $feed_url = esc_url_raw( get_self_link() );
+
+ echo '# ' . esc_html( $site_name ) . ' — ' . esc_html__( 'Markdown Feed', 'ai' ) . "\n\n";
+
+ if ( '' !== $site_desc ) {
+ echo esc_html( $site_desc ) . "\n\n";
+ }
+
+ echo esc_html__( 'Feed URL:', 'ai' ) . ' <' . esc_url( $feed_url ) . ">\n\n";
+ }
+
+ /**
+ * Outputs all posts in the current feed query.
+ *
+ * @since x.x.x
+ */
+ private function send_posts(): void {
+ global $more;
+
+ // Ensure full content is used.
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Used intentionally to ensure full content in feed.
+ $more = 1;
+
+ if ( ! have_posts() ) {
+ echo esc_html__( 'No posts found.', 'ai' ) . "\n";
+ return;
+ }
+
+ while ( have_posts() ) {
+ the_post();
+
+ $post = get_post();
+ if ( ! $post instanceof WP_Post ) {
+ continue;
+ }
+
+ $this->send_post( $post );
+ }
+ }
+
+ /**
+ * Outputs a single post block in Markdown.
+ *
+ * @since x.x.x
+ *
+ * @param \WP_Post $post Post object.
+ */
+ private function send_post( WP_Post $post ): void {
+ $title = wp_strip_all_tags( (string) get_the_title( $post ) );
+ $permalink = esc_url_raw( get_permalink( $post ) );
+ $date_r = (string) get_post_time( 'r', true, $post );
+
+ echo '## ' . esc_html( $title ) . "\n\n";
+ echo esc_html__( 'URL:', 'ai' ) . ' <' . esc_url( $permalink ) . ">\n";
+ echo esc_html__( 'Published:', 'ai' ) . ' ' . esc_html( $date_r ) . "\n\n";
+
+ $content = (string) get_post_field( 'post_content', $post );
+ $html = (string) apply_filters( 'the_content', $content );
+
+ $markdown = $this->convert_html_to_markdown( $html );
+ $markdown = trim( $markdown );
+ if ( '' !== $markdown ) {
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Markdown response.
+ echo $markdown . "\n\n";
+ }
+
+ echo "---\n\n";
+ }
+
+ /**
+ * Converts HTML to Markdown.
+ *
+ * @since x.x.x
+ *
+ * @param string $html HTML string.
+ * @return string Markdown string.
+ */
+ private function convert_html_to_markdown( string $html ): string {
+ /**
+ * Filters the HTML input before conversion to Markdown.
+ *
+ * @since x.x.x
+ *
+ * @param string $html HTML to convert.
+ */
+ $html = (string) apply_filters( 'ai_experiments_markdown_feed_html', $html );
+
+ $converter = new HTML_To_Markdown_Converter();
+ $markdown = $converter->convert( $html );
+
+ /**
+ * Filters the Markdown output after conversion.
+ *
+ * @since x.x.x
+ *
+ * @param string $markdown Markdown output.
+ * @param string $html Original HTML input.
+ */
+ return (string) apply_filters( 'ai_experiments_markdown_feed_markdown', $markdown, $html );
+ }
+
+ /**
+ * Checks whether the current HTTP request is a HEAD request.
+ *
+ * @since x.x.x
+ *
+ * @return bool
+ */
+ private function is_head_request(): bool {
+ $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_key( (string) $_SERVER['REQUEST_METHOD'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ return 'head' === $method;
+ }
+}
diff --git a/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php b/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
new file mode 100644
index 00000000..b5eeb422
--- /dev/null
+++ b/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
@@ -0,0 +1,468 @@
+ 'markdown-feeds',
+ 'label' => esc_html__( 'Markdown', 'ai' ),
+ 'description' => esc_html__( 'Adds Markdown representations of posts and pages via feeds, .md URLs, and Accept header negotiation.', 'ai' ),
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function register(): void {
+ add_action( 'init', array( $this, 'register_feed' ) );
+ add_filter( 'do_parse_request', array( $this, 'maybe_strip_markdown_extension' ), 1 );
+ add_filter( 'redirect_canonical', array( $this, 'filter_redirect_canonical' ), 10, 2 );
+ add_filter( 'wp_headers', array( $this, 'filter_wp_headers' ) );
+ add_action( 'template_redirect', array( $this, 'maybe_render_singular_markdown' ), 0 );
+ }
+
+ /**
+ * Registers the Markdown feed.
+ *
+ * @since x.x.x
+ */
+ public function register_feed(): void {
+ $settings = $this->get_settings();
+ if ( ! $settings['enable_feed'] ) {
+ return;
+ }
+
+ add_feed( self::FEED_NAME, array( $this, 'render_feed' ) );
+ }
+
+ /**
+ * Registers experiment-specific settings.
+ *
+ * @since x.x.x
+ */
+ public function register_settings(): void {
+ register_setting(
+ Settings_Registration::OPTION_GROUP,
+ self::OPTION_ENABLE_FEED,
+ array(
+ 'type' => 'boolean',
+ 'default' => self::DEFAULT_ENABLE_FEED,
+ 'sanitize_callback' => 'rest_sanitize_boolean',
+ )
+ );
+
+ register_setting(
+ Settings_Registration::OPTION_GROUP,
+ self::OPTION_ENABLE_MD_EXTENSION,
+ array(
+ 'type' => 'boolean',
+ 'default' => self::DEFAULT_ENABLE_MD_EXTENSION,
+ 'sanitize_callback' => 'rest_sanitize_boolean',
+ )
+ );
+
+ register_setting(
+ Settings_Registration::OPTION_GROUP,
+ self::OPTION_ENABLE_ACCEPT_HEADERS,
+ array(
+ 'type' => 'boolean',
+ 'default' => self::DEFAULT_ENABLE_ACCEPT_HEADERS,
+ 'sanitize_callback' => 'rest_sanitize_boolean',
+ )
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function has_settings(): bool {
+ return true;
+ }
+
+ /**
+ * Renders settings controls on the Experiments screen.
+ *
+ * @since x.x.x
+ */
+ public function render_settings_fields(): void {
+ $settings = $this->get_settings();
+ ?>
+
+
+
+
+
+
+
+ render();
+ }
+
+ /**
+ * Removes the `.md` suffix from the request path so WordPress can resolve the
+ * underlying resource via existing rewrite rules.
+ *
+ * @since x.x.x
+ *
+ * @param bool $do_parse Whether to parse the request.
+ * @return bool
+ */
+ public function maybe_strip_markdown_extension( bool $do_parse ): bool {
+ $settings = $this->get_settings();
+ if ( ! $settings['enable_md_extension'] ) {
+ return $do_parse;
+ }
+
+ if ( is_admin() ) {
+ return $do_parse;
+ }
+
+ $method = $this->get_request_method();
+ if ( 'get' !== $method && 'head' !== $method ) {
+ return $do_parse;
+ }
+
+ $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ if ( '' === $request_uri ) {
+ return $do_parse;
+ }
+
+ $parts = wp_parse_url( $request_uri );
+ if ( ! is_array( $parts ) ) {
+ return $do_parse;
+ }
+ $path = isset( $parts['path'] ) ? (string) $parts['path'] : '';
+ if ( '' === $path ) {
+ return $do_parse;
+ }
+
+ if ( 0 === strpos( $path, '/wp-json/' ) ) {
+ return $do_parse;
+ }
+
+ $has_trailing_slash = '/' === substr( $path, -1 );
+ $path_trimmed = $has_trailing_slash ? rtrim( $path, '/' ) : $path;
+
+ if ( '.md' !== substr( $path_trimmed, -3 ) ) {
+ return $do_parse;
+ }
+
+ $base_path = substr( $path_trimmed, 0, -3 );
+ if ( '' === $base_path ) {
+ $base_path = '/';
+ }
+
+ $new_path = $base_path;
+ if ( $has_trailing_slash ) {
+ $new_path .= '/';
+ }
+
+ $new_request_uri = $new_path;
+ if ( ! empty( $parts['query'] ) ) {
+ $new_request_uri .= '?' . $parts['query'];
+ }
+
+ $this->markdown_extension_request = true;
+
+ $_SERVER['REQUEST_URI'] = $new_request_uri;
+ if ( isset( $_SERVER['PATH_INFO'] ) ) {
+ $_SERVER['PATH_INFO'] = $new_path;
+ }
+
+ return $do_parse;
+ }
+
+ /**
+ * Prevents canonical redirects for `.md` requests.
+ *
+ * @since x.x.x
+ *
+ * @param string|false $redirect_url The redirect URL.
+ * @param string $requested_url The requested URL.
+ * @return string|false
+ */
+ public function filter_redirect_canonical( $redirect_url, string $requested_url ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
+ if ( $this->markdown_extension_request ) {
+ return false;
+ }
+
+ return $redirect_url;
+ }
+
+ /**
+ * Adds `Vary: Accept` when Accept header negotiation is enabled.
+ *
+ * @since x.x.x
+ *
+ * @param array $headers Array of headers to send.
+ * @return array
+ */
+ public function filter_wp_headers( array $headers ): array {
+ $settings = $this->get_settings();
+ if ( ! $settings['enable_accept_headers'] ) {
+ return $headers;
+ }
+
+ if ( $this->markdown_extension_request ) {
+ return $headers;
+ }
+
+ if ( ! is_singular() ) {
+ return $headers;
+ }
+
+ $headers['Vary'] = $this->merge_vary_header( $headers['Vary'] ?? '', 'Accept' );
+ return $headers;
+ }
+
+ /**
+ * Renders Markdown for singular content when requested via `.md` or `Accept: text/markdown`.
+ *
+ * @since x.x.x
+ */
+ public function maybe_render_singular_markdown(): void {
+ $settings = $this->get_settings();
+
+ $method = $this->get_request_method();
+ if ( 'get' !== $method && 'head' !== $method ) {
+ return;
+ }
+
+ $wants_markdown = false;
+ if ( $settings['enable_md_extension'] && $this->markdown_extension_request ) {
+ $wants_markdown = true;
+ } elseif ( $settings['enable_accept_headers'] && $this->client_accepts_markdown() ) {
+ $wants_markdown = true;
+ }
+
+ if ( ! $wants_markdown ) {
+ return;
+ }
+
+ if ( ! is_singular() ) {
+ if ( $this->markdown_extension_request ) {
+ $renderer = new Markdown_Singular_Renderer();
+ $renderer->render_not_found();
+ exit;
+ }
+
+ return;
+ }
+
+ $post = get_queried_object();
+ if ( ! $post instanceof \WP_Post ) {
+ $renderer = new Markdown_Singular_Renderer();
+ $renderer->render_not_found();
+ exit;
+ }
+
+ $renderer = new Markdown_Singular_Renderer();
+ $renderer->render( $post );
+ exit;
+ }
+
+ /**
+ * Checks whether the current request's `Accept` header includes Markdown.
+ *
+ * @since x.x.x
+ *
+ * @return bool
+ */
+ private function client_accepts_markdown(): bool {
+ $accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? strtolower( (string) wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ if ( '' === $accept ) {
+ return false;
+ }
+
+ $types = array(
+ 'text/markdown',
+ 'text/x-markdown',
+ 'application/markdown',
+ );
+
+ foreach ( $types as $type ) {
+ if ( false !== strpos( $accept, $type ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Merges a token into a `Vary` header value.
+ *
+ * @since x.x.x
+ *
+ * @param string $current Existing Vary header.
+ * @param string $token Token to add.
+ * @return string
+ */
+ private function merge_vary_header( string $current, string $token ): string {
+ $current = trim( $current );
+
+ if ( '' === $current ) {
+ return $token;
+ }
+
+ $parts = array_map( 'trim', explode( ',', $current ) );
+ foreach ( $parts as $part ) {
+ if ( strtolower( $part ) === strtolower( $token ) ) {
+ return $current;
+ }
+ }
+
+ $parts[] = $token;
+ return implode( ', ', $parts );
+ }
+
+ /**
+ * Reads experiment settings with defaults.
+ *
+ * @since x.x.x
+ *
+ * @return array{enable_feed: bool, enable_md_extension: bool, enable_accept_headers: bool}
+ */
+ private function get_settings(): array {
+ return array(
+ 'enable_feed' => (bool) get_option( self::OPTION_ENABLE_FEED, self::DEFAULT_ENABLE_FEED ),
+ 'enable_md_extension' => (bool) get_option( self::OPTION_ENABLE_MD_EXTENSION, self::DEFAULT_ENABLE_MD_EXTENSION ),
+ 'enable_accept_headers' => (bool) get_option( self::OPTION_ENABLE_ACCEPT_HEADERS, self::DEFAULT_ENABLE_ACCEPT_HEADERS ),
+ );
+ }
+
+ /**
+ * Reads the current HTTP request method (lowercase).
+ *
+ * @since x.x.x
+ *
+ * @return string
+ */
+ private function get_request_method(): string {
+ if ( ! isset( $_SERVER['REQUEST_METHOD'] ) ) {
+ return 'get';
+ }
+
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Request method used for routing only.
+ return sanitize_key( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) );
+ }
+}
diff --git a/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php b/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php
new file mode 100644
index 00000000..1bd3d58c
--- /dev/null
+++ b/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php
@@ -0,0 +1,153 @@
+send_headers( 200 );
+
+ if ( $this->is_head_request() ) {
+ return;
+ }
+
+ $this->send_post( $post );
+ }
+
+ /**
+ * Writes a Markdown "not found" response.
+ *
+ * @since x.x.x
+ */
+ public function render_not_found(): void {
+ $this->send_headers( 404 );
+
+ if ( $this->is_head_request() ) {
+ return;
+ }
+
+ echo '# ' . esc_html__( 'Not Found', 'ai' ) . "\n\n";
+ echo esc_html__( 'No Markdown representation is available for this URL.', 'ai' ) . "\n";
+ }
+
+ /**
+ * Sends HTTP headers for the Markdown response.
+ *
+ * @since x.x.x
+ *
+ * @param int $status_code HTTP status code.
+ */
+ private function send_headers( int $status_code ): void {
+ status_header( $status_code );
+ nocache_headers();
+ header( 'Content-Type: text/markdown; charset=' . get_option( 'blog_charset' ), true );
+ header( 'X-Content-Type-Options: nosniff', true );
+ }
+
+ /**
+ * Outputs a single post block in Markdown.
+ *
+ * @since x.x.x
+ *
+ * @param \WP_Post $post Post object.
+ */
+ private function send_post( WP_Post $post ): void {
+ $title = wp_strip_all_tags( (string) get_the_title( $post ) );
+ $permalink = (string) get_permalink( $post );
+
+ $published = (string) get_post_time( 'r', true, $post );
+ $modified = (string) get_post_modified_time( 'r', true, $post );
+
+ echo '# ' . esc_html( $title ) . "\n\n";
+ echo esc_html__( 'URL:', 'ai' ) . ' <' . esc_url( $permalink ) . ">\n";
+ echo esc_html__( 'Published:', 'ai' ) . ' ' . esc_html( $published ) . "\n";
+
+ if ( $modified !== $published ) {
+ echo esc_html__( 'Updated:', 'ai' ) . ' ' . esc_html( $modified ) . "\n";
+ }
+
+ echo "\n";
+
+ $content = (string) get_post_field( 'post_content', $post );
+ $html = (string) apply_filters( 'the_content', $content );
+
+ /**
+ * Filters the HTML input before conversion to Markdown.
+ *
+ * @since x.x.x
+ *
+ * @param string $html HTML to convert.
+ * @param \WP_Post $post Post object.
+ */
+ $html = (string) apply_filters( 'ai_experiments_markdown_singular_html', $html, $post );
+
+ $converter = new HTML_To_Markdown_Converter();
+ $markdown = $converter->convert( $html );
+
+ /**
+ * Filters the Markdown output after conversion.
+ *
+ * @since x.x.x
+ *
+ * @param string $markdown Markdown output.
+ * @param string $html Original HTML input.
+ * @param \WP_Post $post Post object.
+ */
+ $markdown = (string) apply_filters( 'ai_experiments_markdown_singular_markdown', $markdown, $html, $post );
+ $markdown = trim( $markdown );
+
+ if ( '' === $markdown ) {
+ return;
+ }
+
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Markdown response.
+ echo $markdown . "\n";
+ }
+
+ /**
+ * Checks whether the current HTTP request is a HEAD request.
+ *
+ * @since x.x.x
+ *
+ * @return bool
+ */
+ private function is_head_request(): bool {
+ $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_key( (string) $_SERVER['REQUEST_METHOD'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ return 'head' === $method;
+ }
+}
diff --git a/tests/Integration/Includes/Experiment_LoaderTest.php b/tests/Integration/Includes/Experiment_LoaderTest.php
index 77ea4e09..a216a42c 100644
--- a/tests/Integration/Includes/Experiment_LoaderTest.php
+++ b/tests/Integration/Includes/Experiment_LoaderTest.php
@@ -123,6 +123,10 @@ public function test_register_default_experiments() {
$this->registry->has_experiment( 'title-generation' ),
'Title generation experiment should be registered'
);
+ $this->assertTrue(
+ $this->registry->has_experiment( 'markdown-feeds' ),
+ 'Markdown feeds experiment should be registered'
+ );
$excerpt_experiment = $this->registry->get_experiment( 'excerpt-generation' );
$this->assertNotNull( $excerpt_experiment, 'Excerpt generation experiment should exist' );
@@ -139,6 +143,9 @@ public function test_register_default_experiments() {
$title_experiment = $this->registry->get_experiment( 'title-generation' );
$this->assertNotNull( $title_experiment, 'Title generation experiment should exist' );
$this->assertEquals( 'title-generation', $title_experiment->get_id() );
+ $markdown_experiment = $this->registry->get_experiment( 'markdown-feeds' );
+ $this->assertNotNull( $markdown_experiment, 'Markdown feeds experiment should exist' );
+ $this->assertEquals( 'markdown-feeds', $markdown_experiment->get_id() );
}
/**
diff --git a/tests/Integration/Includes/Experiments/Markdown_Feeds/HTML_To_Markdown_ConverterTest.php b/tests/Integration/Includes/Experiments/Markdown_Feeds/HTML_To_Markdown_ConverterTest.php
new file mode 100644
index 00000000..13cd7c5e
--- /dev/null
+++ b/tests/Integration/Includes/Experiments/Markdown_Feeds/HTML_To_Markdown_ConverterTest.php
@@ -0,0 +1,45 @@
+echo \"hi\";\n";
+
+ $html = 'Heading
'
+ . 'Hello world link.
'
+ . ''
+ . '
'
+ . $code;
+
+ $markdown = $converter->convert( $html );
+
+ $this->assertStringContainsString( '## Heading', $markdown );
+ $this->assertStringContainsString( 'Hello **world** [link](https://example.com).', $markdown );
+ $this->assertStringContainsString( '- One', $markdown );
+ $this->assertStringContainsString( '- Two', $markdown );
+ $this->assertStringContainsString( '', $markdown );
+ $this->assertStringContainsString( '```', $markdown );
+ $this->assertStringContainsString( 'echo "hi";', $markdown );
+ }
+}
diff --git a/tests/Integration/Includes/Experiments/Markdown_Feeds/Markdown_FeedsTest.php b/tests/Integration/Includes/Experiments/Markdown_Feeds/Markdown_FeedsTest.php
new file mode 100644
index 00000000..3eef1bb5
--- /dev/null
+++ b/tests/Integration/Includes/Experiments/Markdown_Feeds/Markdown_FeedsTest.php
@@ -0,0 +1,72 @@
+ 'test-api-key' ) );
+
+ // Mock has_valid_ai_credentials to return true for tests.
+ add_filter( 'ai_experiments_pre_has_valid_credentials_check', '__return_true' );
+
+ // Enable experiments globally and individually.
+ update_option( 'ai_experiments_enabled', true );
+ update_option( 'ai_experiment_markdown-feeds_enabled', true );
+
+ $registry = new Experiment_Registry();
+ $loader = new Experiment_Loader( $registry );
+ $loader->register_default_experiments();
+
+ $experiment = $registry->get_experiment( 'markdown-feeds' );
+ $this->assertInstanceOf( Markdown_Feeds::class, $experiment, 'Markdown feeds experiment should be registered in the registry.' );
+ }
+
+ /**
+ * Tear down test case.
+ *
+ * @since x.x.x
+ */
+ public function tearDown(): void {
+ delete_option( 'ai_experiments_enabled' );
+ delete_option( 'ai_experiment_markdown-feeds_enabled' );
+ delete_option( 'wp_ai_client_provider_credentials' );
+ remove_filter( 'ai_experiments_pre_has_valid_credentials_check', '__return_true' );
+ parent::tearDown();
+ }
+
+ /**
+ * Test that the experiment is registered correctly.
+ *
+ * @since x.x.x
+ */
+ public function test_experiment_registration() {
+ $experiment = new Markdown_Feeds();
+
+ $this->assertEquals( 'markdown-feeds', $experiment->get_id() );
+ $this->assertEquals( 'Markdown', $experiment->get_label() );
+ $this->assertTrue( $experiment->is_enabled() );
+ }
+}
From 793d5a48c71bfde5e28cbbb04c8974cc9e997d8f Mon Sep 17 00:00:00 2001
From: James LePage <36246732+Jameswlepage@users.noreply.github.com>
Date: Fri, 19 Dec 2025 20:52:48 -0500
Subject: [PATCH 2/9] Enhance Markdown feeds for Core readiness
- Add feed autodiscovery links in HTML head
- Add Link rel=canonical header for .md URLs
- Add post visibility checks (private, password-protected)
- Add HTTP caching (Last-Modified, ETag, 304 responses)
- Add table, figure/figcaption, and cite tag support
- Refactor .md URL handling to use request filter
- Fix leading whitespace in output
- Add comprehensive test coverage
---
.../HTML_To_Markdown_Converter.php | 98 ++++++
.../Markdown_Feeds/Markdown_Feed_Renderer.php | 92 ++++-
.../Markdown_Feeds/Markdown_Feeds.php | 242 +++++++++----
.../Markdown_Singular_Renderer.php | 75 ++++-
.../HTML_To_Markdown_ConverterTest.php | 317 +++++++++++++++++-
.../Markdown_Feeds/Markdown_FeedsTest.php | 136 +++++++-
6 files changed, 888 insertions(+), 72 deletions(-)
diff --git a/includes/Experiments/Markdown_Feeds/HTML_To_Markdown_Converter.php b/includes/Experiments/Markdown_Feeds/HTML_To_Markdown_Converter.php
index 39dee991..de11c0ce 100644
--- a/includes/Experiments/Markdown_Feeds/HTML_To_Markdown_Converter.php
+++ b/includes/Experiments/Markdown_Feeds/HTML_To_Markdown_Converter.php
@@ -104,11 +104,25 @@ private function convert_with_processor( $processor ): string {
$link_stack = array();
$list_stack = array();
+ // Table state.
+ $in_table = false;
+ $current_row = array();
+ $is_header_row = false;
+ $header_row_done = false;
+
while ( $processor->next_token() ) {
$token_name = $processor->get_token_name();
if ( '#text' === $token_name ) {
$text = (string) $processor->get_modifiable_text();
+
+ // If we're in a table cell, collect text for the cell.
+ if ( $in_table && ! empty( $current_row ) ) {
+ $cell_index = count( $current_row ) - 1;
+ $current_row[ $cell_index ] .= trim( preg_replace( '/\s+/', ' ', $text ) );
+ continue;
+ }
+
$this->append_text( $markdown, $text, $at_line_start, $blockquote_depth, $in_pre );
continue;
}
@@ -211,6 +225,33 @@ private function convert_with_processor( $processor ): string {
continue;
}
+ // Figure element (contains image + optional caption).
+ if ( 'FIGURE' === $token_name ) {
+ $this->ensure_blank_line( $markdown, $at_line_start );
+ continue;
+ }
+
+ // Figure caption - render as italic text on new line.
+ if ( 'FIGCAPTION' === $token_name ) {
+ if ( ! $is_closer ) {
+ $this->ensure_newline( $markdown, $at_line_start );
+ $markdown .= '*';
+ $at_line_start = false;
+ } else {
+ $markdown .= '*';
+ }
+ continue;
+ }
+
+ // Citation in blockquotes - prefix with em dash.
+ if ( 'CITE' === $token_name ) {
+ if ( ! $is_closer ) {
+ $markdown .= '— ';
+ $at_line_start = false;
+ }
+ continue;
+ }
+
if ( 'UL' === $token_name || 'OL' === $token_name ) {
if ( $is_closer ) {
array_pop( $list_stack );
@@ -248,6 +289,59 @@ private function convert_with_processor( $processor ): string {
continue;
}
+ // Table handling.
+ if ( 'TABLE' === $token_name ) {
+ if ( $is_closer ) {
+ $in_table = false;
+ $header_row_done = false;
+ $this->ensure_blank_line( $markdown, $at_line_start );
+ } else {
+ $in_table = true;
+ $this->ensure_blank_line( $markdown, $at_line_start );
+ }
+ continue;
+ }
+
+ if ( 'THEAD' === $token_name || 'TBODY' === $token_name || 'TFOOT' === $token_name ) {
+ // Skip these structural elements; we detect headers via TH tags.
+ continue;
+ }
+
+ if ( 'TR' === $token_name ) {
+ if ( $is_closer ) {
+ // Output the row.
+ if ( ! empty( $current_row ) ) {
+ $row_line = '| ' . implode( ' | ', $current_row ) . ' |';
+ $this->append_line( $markdown, $row_line, $at_line_start, $blockquote_depth );
+
+ // Add separator after header row.
+ if ( $is_header_row && ! $header_row_done ) {
+ $separator = '|' . str_repeat( ' --- |', count( $current_row ) );
+ $this->append_line( $markdown, $separator, $at_line_start, $blockquote_depth );
+ $header_row_done = true;
+ }
+ }
+ $current_row = array();
+ $is_header_row = false;
+ }
+ continue;
+ }
+
+ if ( 'TH' === $token_name ) {
+ if ( ! $is_closer ) {
+ $current_row[] = '';
+ $is_header_row = true;
+ }
+ continue;
+ }
+
+ if ( 'TD' === $token_name ) {
+ if ( ! $is_closer ) {
+ $current_row[] = '';
+ }
+ continue;
+ }
+
if ( ! preg_match( '/^H([1-6])$/', $token_name, $matches ) ) {
continue;
}
@@ -369,7 +463,11 @@ private function ensure_blank_line( string &$markdown, bool &$at_line_start ): v
* @return string Cleaned buffer.
*/
private function cleanup( string $markdown ): string {
+ // Remove trailing whitespace from lines.
$markdown = preg_replace( "/[ \\t]+\\n/", "\n", $markdown );
+ // Remove leading whitespace from lines (except in code blocks).
+ $markdown = preg_replace( "/\\n[ \\t]+(?!\\s*```)/", "\n", $markdown );
+ // Collapse multiple blank lines.
$markdown = preg_replace( "/\\n{3,}/", "\n\n", $markdown );
return (string) $markdown;
}
diff --git a/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php b/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php
index 2567b49b..8107238b 100644
--- a/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php
+++ b/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php
@@ -10,6 +10,7 @@
namespace WordPress\AI\Experiments\Markdown_Feeds;
use WP_Post;
+use WP_Query;
use function apply_filters;
use function esc_html;
use function esc_html__;
@@ -24,11 +25,11 @@
use function get_self_link;
use function get_the_title;
use function have_posts;
-use function nocache_headers;
use function sanitize_key;
use function status_header;
use function the_post;
use function wp_strip_all_tags;
+use function wp_unslash;
/**
* Outputs a Markdown representation of the current feed query.
@@ -40,10 +41,19 @@ final class Markdown_Feed_Renderer {
* @since x.x.x
*/
public function render(): void {
- $this->send_headers();
+ $last_modified = $this->get_feed_last_modified();
+
+ // Check for conditional GET (304 Not Modified).
+ if ( $this->handle_conditional_get( $last_modified ) ) {
+ return;
+ }
+
+ $this->send_headers( $last_modified );
+
if ( $this->is_head_request() ) {
return;
}
+
$this->send_feed_header();
$this->send_posts();
}
@@ -52,12 +62,86 @@ public function render(): void {
* Sends HTTP headers for the Markdown feed response.
*
* @since x.x.x
+ *
+ * @param int $last_modified Unix timestamp of last modification.
*/
- private function send_headers(): void {
+ private function send_headers( int $last_modified ): void {
status_header( 200 );
- nocache_headers();
header( 'Content-Type: text/markdown; charset=' . get_option( 'blog_charset' ), true );
header( 'X-Content-Type-Options: nosniff', true );
+
+ // Caching headers similar to core RSS feeds.
+ if ( $last_modified > 0 ) {
+ header( 'Last-Modified: ' . gmdate( 'D, d M Y H:i:s', $last_modified ) . ' GMT', true );
+
+ // Generate ETag from last modified time and feed URL.
+ $etag = md5( $last_modified . get_self_link() );
+ header( 'ETag: "' . $etag . '"', true );
+ }
+
+ // Allow caching for a short period (similar to core feeds behavior).
+ header( 'Cache-Control: max-age=300, must-revalidate', true );
+ }
+
+ /**
+ * Handles conditional GET requests (If-Modified-Since, If-None-Match).
+ *
+ * @since x.x.x
+ *
+ * @param int $last_modified Unix timestamp of last modification.
+ * @return bool True if 304 response was sent, false otherwise.
+ */
+ private function handle_conditional_get( int $last_modified ): bool {
+ if ( $last_modified <= 0 ) {
+ return false;
+ }
+
+ $client_etag = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( (string) wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ $client_last_modified = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? (string) wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+
+ $etag = '"' . md5( $last_modified . get_self_link() ) . '"';
+
+ $etag_match = '' !== $client_etag && $client_etag === $etag;
+ $last_modified_match = '' !== $client_last_modified && strtotime( $client_last_modified ) >= $last_modified;
+
+ if ( $etag_match || $last_modified_match ) {
+ status_header( 304 );
+ header( 'Content-Type: text/markdown; charset=' . get_option( 'blog_charset' ), true );
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Gets the last modified timestamp for the feed.
+ *
+ * @since x.x.x
+ *
+ * @return int Unix timestamp, or 0 if unknown.
+ */
+ private function get_feed_last_modified(): int {
+ global $wp_query;
+
+ if ( ! $wp_query instanceof WP_Query || empty( $wp_query->posts ) ) {
+ return 0;
+ }
+
+ $latest = 0;
+ foreach ( $wp_query->posts as $post ) {
+ if ( ! $post instanceof WP_Post ) {
+ continue;
+ }
+
+ $modified = strtotime( $post->post_modified_gmt );
+ if ( $modified <= $latest ) {
+ continue;
+ }
+
+ $latest = $modified;
+ }
+
+ return $latest;
}
/**
diff --git a/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php b/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
index b5eeb422..1542eb70 100644
--- a/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
+++ b/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
@@ -13,14 +13,27 @@
use WordPress\AI\Settings\Settings_Registration;
use function __;
+use function add_query_arg;
+use function current_user_can;
use function esc_attr;
use function esc_html;
use function esc_html__;
-use function esc_html_e;
+use function esc_url;
+use function get_bloginfo;
+use function get_feed_link;
use function get_option;
+use function get_permalink;
+use function get_post_status;
+use function get_post_type_object;
+use function get_queried_object;
+use function get_the_title;
use function is_admin;
+use function is_feed;
+use function is_singular;
+use function post_password_required;
use function register_setting;
use function sanitize_key;
+use function trailingslashit;
use function wp_kses;
/**
@@ -52,7 +65,7 @@ class Markdown_Feeds extends Abstract_Experiment {
protected function load_experiment_metadata(): array {
return array(
'id' => 'markdown-feeds',
- 'label' => esc_html__( 'Markdown', 'ai' ),
+ 'label' => esc_html__( 'Markdown Feeds', 'ai' ),
'description' => esc_html__( 'Adds Markdown representations of posts and pages via feeds, .md URLs, and Accept header negotiation.', 'ai' ),
);
}
@@ -62,10 +75,13 @@ protected function load_experiment_metadata(): array {
*/
public function register(): void {
add_action( 'init', array( $this, 'register_feed' ) );
- add_filter( 'do_parse_request', array( $this, 'maybe_strip_markdown_extension' ), 1 );
+ add_filter( 'request', array( $this, 'filter_request_for_markdown_extension' ), 1 );
add_filter( 'redirect_canonical', array( $this, 'filter_redirect_canonical' ), 10, 2 );
add_filter( 'wp_headers', array( $this, 'filter_wp_headers' ) );
add_action( 'template_redirect', array( $this, 'maybe_render_singular_markdown' ), 0 );
+
+ // Feed autodiscovery.
+ add_action( 'wp_head', array( $this, 'add_feed_autodiscovery_links' ) );
}
/**
@@ -82,6 +98,96 @@ public function register_feed(): void {
add_feed( self::FEED_NAME, array( $this, 'render_feed' ) );
}
+ /**
+ * Outputs feed autodiscovery links in the HTML head.
+ *
+ * Mirrors the behavior of `feed_links()` and `feed_links_extra()` for RSS/Atom.
+ *
+ * @since x.x.x
+ */
+ public function add_feed_autodiscovery_links(): void {
+ $settings = $this->get_settings();
+ if ( ! $settings['enable_feed'] ) {
+ return;
+ }
+
+ // Don't add discovery links on feeds themselves.
+ if ( is_feed() ) {
+ return;
+ }
+
+ $site_name = get_bloginfo( 'name' );
+
+ // Main site feed.
+ $feed_url = $this->get_markdown_feed_link();
+ /* translators: %s: Site name. */
+ $title = sprintf( __( '%s Markdown Feed', 'ai' ), $site_name );
+
+ printf(
+ '' . "\n",
+ esc_attr( $title ),
+ esc_url( $feed_url )
+ );
+
+ // Singular post/page: add link to .md version.
+ if ( ! is_singular() || ! $settings['enable_md_extension'] ) {
+ return;
+ }
+
+ $post = get_queried_object();
+ if ( ! ( $post instanceof \WP_Post ) || ! $this->is_post_accessible( $post ) ) {
+ return;
+ }
+
+ $md_url = $this->get_markdown_permalink( $post );
+ /* translators: %s: Post title. */
+ $md_title = sprintf( __( '%s (Markdown)', 'ai' ), get_the_title( $post ) );
+
+ printf(
+ '' . "\n",
+ esc_attr( $md_title ),
+ esc_url( $md_url )
+ );
+ }
+
+ /**
+ * Gets the Markdown feed URL.
+ *
+ * @since x.x.x
+ *
+ * @param string $context Optional. Feed context (empty for main feed, or 'category', 'tag', etc.).
+ * @return string Feed URL.
+ */
+ public function get_markdown_feed_link( string $context = '' ): string {
+ if ( '' === $context ) {
+ return get_feed_link( self::FEED_NAME );
+ }
+
+ // For archive feeds, use the base feed link with feed query parameter.
+ return add_query_arg( 'feed', self::FEED_NAME, $context );
+ }
+
+ /**
+ * Gets the Markdown permalink for a post.
+ *
+ * @since x.x.x
+ *
+ * @param \WP_Post $post Post object.
+ * @return string Markdown permalink.
+ */
+ public function get_markdown_permalink( \WP_Post $post ): string {
+ $permalink = get_permalink( $post );
+ if ( ! $permalink ) {
+ return '';
+ }
+
+ // Remove trailing slash, add .md extension.
+ $permalink = trailingslashit( $permalink );
+ $permalink = rtrim( $permalink, '/' );
+
+ return $permalink . '.md';
+ }
+
/**
* Registers experiment-specific settings.
*
@@ -218,77 +324,51 @@ public function render_feed( $for_comments, $feed ): void { // phpcs:ignore Vari
}
/**
- * Removes the `.md` suffix from the request path so WordPress can resolve the
- * underlying resource via existing rewrite rules.
+ * Filters parsed query vars to strip `.md` suffix from slug-based vars.
+ *
+ * This allows URLs like `/post-name.md` to resolve to the post with slug `post-name`.
+ * Works with the `request` filter which fires after WordPress parses the URL.
*
* @since x.x.x
*
- * @param bool $do_parse Whether to parse the request.
- * @return bool
+ * @param array $query_vars Parsed query vars.
+ * @return array
*/
- public function maybe_strip_markdown_extension( bool $do_parse ): bool {
+ public function filter_request_for_markdown_extension( array $query_vars ): array {
$settings = $this->get_settings();
if ( ! $settings['enable_md_extension'] ) {
- return $do_parse;
+ return $query_vars;
}
if ( is_admin() ) {
- return $do_parse;
+ return $query_vars;
}
$method = $this->get_request_method();
if ( 'get' !== $method && 'head' !== $method ) {
- return $do_parse;
- }
-
- $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
- if ( '' === $request_uri ) {
- return $do_parse;
+ return $query_vars;
}
- $parts = wp_parse_url( $request_uri );
- if ( ! is_array( $parts ) ) {
- return $do_parse;
- }
- $path = isset( $parts['path'] ) ? (string) $parts['path'] : '';
- if ( '' === $path ) {
- return $do_parse;
- }
-
- if ( 0 === strpos( $path, '/wp-json/' ) ) {
- return $do_parse;
- }
-
- $has_trailing_slash = '/' === substr( $path, -1 );
- $path_trimmed = $has_trailing_slash ? rtrim( $path, '/' ) : $path;
+ // Query vars that contain post/page slugs which may have .md suffix.
+ $slug_vars = array( 'name', 'pagename', 'attachment' );
- if ( '.md' !== substr( $path_trimmed, -3 ) ) {
- return $do_parse;
- }
-
- $base_path = substr( $path_trimmed, 0, -3 );
- if ( '' === $base_path ) {
- $base_path = '/';
- }
-
- $new_path = $base_path;
- if ( $has_trailing_slash ) {
- $new_path .= '/';
- }
-
- $new_request_uri = $new_path;
- if ( ! empty( $parts['query'] ) ) {
- $new_request_uri .= '?' . $parts['query'];
- }
+ foreach ( $slug_vars as $var ) {
+ if ( empty( $query_vars[ $var ] ) ) {
+ continue;
+ }
- $this->markdown_extension_request = true;
+ $value = (string) $query_vars[ $var ];
+ if ( '.md' !== substr( $value, -3 ) ) {
+ continue;
+ }
- $_SERVER['REQUEST_URI'] = $new_request_uri;
- if ( isset( $_SERVER['PATH_INFO'] ) ) {
- $_SERVER['PATH_INFO'] = $new_path;
+ // Strip the .md suffix and mark this as a markdown request.
+ $query_vars[ $var ] = substr( $value, 0, -3 );
+ $this->markdown_extension_request = true;
+ break;
}
- return $do_parse;
+ return $query_vars;
}
/**
@@ -358,9 +438,10 @@ public function maybe_render_singular_markdown(): void {
return;
}
+ $renderer = new Markdown_Singular_Renderer();
+
if ( ! is_singular() ) {
if ( $this->markdown_extension_request ) {
- $renderer = new Markdown_Singular_Renderer();
$renderer->render_not_found();
exit;
}
@@ -370,16 +451,63 @@ public function maybe_render_singular_markdown(): void {
$post = get_queried_object();
if ( ! $post instanceof \WP_Post ) {
- $renderer = new Markdown_Singular_Renderer();
$renderer->render_not_found();
exit;
}
- $renderer = new Markdown_Singular_Renderer();
+ // Check post accessibility (status, password protection, etc.).
+ if ( ! $this->is_post_accessible( $post ) ) {
+ if ( post_password_required( $post ) ) {
+ $renderer->render_password_required();
+ } else {
+ $renderer->render_not_found();
+ }
+ exit;
+ }
+
+ // Send Link header pointing to canonical HTML version.
+ $canonical_url = get_permalink( $post );
+ if ( $canonical_url ) {
+ header( 'Link: <' . esc_url( $canonical_url ) . '>; rel="canonical"', false );
+ }
+
$renderer->render( $post );
exit;
}
+ /**
+ * Checks whether a post is accessible for Markdown rendering.
+ *
+ * Validates post status and password protection similar to core feed behavior.
+ *
+ * @since x.x.x
+ *
+ * @param \WP_Post $post Post object.
+ * @return bool True if accessible, false otherwise.
+ */
+ private function is_post_accessible( \WP_Post $post ): bool {
+ $status = get_post_status( $post );
+
+ // Published posts are accessible unless password-protected.
+ if ( 'publish' === $status ) {
+ return ! post_password_required( $post );
+ }
+
+ // Private posts require the read_private_posts capability.
+ if ( 'private' === $status ) {
+ $post_type_obj = get_post_type_object( $post->post_type );
+ if ( ! $post_type_obj ) {
+ return false;
+ }
+
+ $cap = $post_type_obj->cap->read_private_posts ?? 'read_private_posts';
+ return current_user_can( $cap );
+ }
+
+ // Draft, pending, future, trash, etc. are not accessible.
+ return false;
+ }
+
/**
* Checks whether the current request's `Accept` header includes Markdown.
*
diff --git a/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php b/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php
index 1bd3d58c..25bd0202 100644
--- a/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php
+++ b/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php
@@ -21,10 +21,10 @@
use function get_post_modified_time;
use function get_post_time;
use function get_the_title;
-use function nocache_headers;
use function sanitize_key;
use function status_header;
use function wp_strip_all_tags;
+use function wp_unslash;
/**
* Outputs a Markdown representation of a singular post.
@@ -40,7 +40,14 @@ final class Markdown_Singular_Renderer {
* @param \WP_Post $post Post object.
*/
public function render( WP_Post $post ): void {
- $this->send_headers( 200 );
+ $last_modified = strtotime( $post->post_modified_gmt );
+
+ // Check for conditional GET (304 Not Modified).
+ if ( $last_modified && $this->handle_conditional_get( $last_modified, $post ) ) {
+ return;
+ }
+
+ $this->send_headers( 200, $last_modified, $post );
if ( $this->is_head_request() ) {
return;
@@ -65,18 +72,76 @@ public function render_not_found(): void {
echo esc_html__( 'No Markdown representation is available for this URL.', 'ai' ) . "\n";
}
+ /**
+ * Writes a Markdown "password required" response.
+ *
+ * @since x.x.x
+ */
+ public function render_password_required(): void {
+ $this->send_headers( 401 );
+
+ if ( $this->is_head_request() ) {
+ return;
+ }
+
+ echo '# ' . esc_html__( 'Password Required', 'ai' ) . "\n\n";
+ echo esc_html__( 'This content is password protected. Please provide the password to view the Markdown representation.', 'ai' ) . "\n";
+ }
+
/**
* Sends HTTP headers for the Markdown response.
*
* @since x.x.x
*
- * @param int $status_code HTTP status code.
+ * @param int $status_code HTTP status code.
+ * @param int|false $last_modified Unix timestamp of last modification, or false.
+ * @param \WP_Post|null $post Post object for ETag generation.
*/
- private function send_headers( int $status_code ): void {
+ private function send_headers( int $status_code, $last_modified = false, ?WP_Post $post = null ): void {
status_header( $status_code );
- nocache_headers();
header( 'Content-Type: text/markdown; charset=' . get_option( 'blog_charset' ), true );
header( 'X-Content-Type-Options: nosniff', true );
+
+ // Only add caching headers for successful responses with valid data.
+ if ( 200 !== $status_code || ! $last_modified || ! $post ) {
+ return;
+ }
+
+ header( 'Last-Modified: ' . gmdate( 'D, d M Y H:i:s', $last_modified ) . ' GMT', true );
+
+ // Generate ETag from last modified time and post ID.
+ $etag = md5( $last_modified . '-' . $post->ID );
+ header( 'ETag: "' . $etag . '"', true );
+
+ // Allow caching for a short period.
+ header( 'Cache-Control: max-age=300, must-revalidate', true );
+ }
+
+ /**
+ * Handles conditional GET requests (If-Modified-Since, If-None-Match).
+ *
+ * @since x.x.x
+ *
+ * @param int $last_modified Unix timestamp of last modification.
+ * @param \WP_Post $post Post object.
+ * @return bool True if 304 response was sent, false otherwise.
+ */
+ private function handle_conditional_get( int $last_modified, WP_Post $post ): bool {
+ $client_etag = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( (string) wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ $client_last_modified = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? (string) wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+
+ $etag = '"' . md5( $last_modified . '-' . $post->ID ) . '"';
+
+ $etag_match = '' !== $client_etag && $client_etag === $etag;
+ $last_modified_match = '' !== $client_last_modified && strtotime( $client_last_modified ) >= $last_modified;
+
+ if ( $etag_match || $last_modified_match ) {
+ status_header( 304 );
+ header( 'Content-Type: text/markdown; charset=' . get_option( 'blog_charset' ), true );
+ return true;
+ }
+
+ return false;
}
/**
diff --git a/tests/Integration/Includes/Experiments/Markdown_Feeds/HTML_To_Markdown_ConverterTest.php b/tests/Integration/Includes/Experiments/Markdown_Feeds/HTML_To_Markdown_ConverterTest.php
index 13cd7c5e..97bc5fe1 100644
--- a/tests/Integration/Includes/Experiments/Markdown_Feeds/HTML_To_Markdown_ConverterTest.php
+++ b/tests/Integration/Includes/Experiments/Markdown_Feeds/HTML_To_Markdown_ConverterTest.php
@@ -16,14 +16,30 @@
* @since x.x.x
*/
class HTML_To_Markdown_ConverterTest extends WP_UnitTestCase {
+
/**
- * Test that basic HTML is converted into Markdown.
+ * The converter instance.
+ *
+ * @var \WordPress\AI\Experiments\Markdown_Feeds\HTML_To_Markdown_Converter
+ */
+ private HTML_To_Markdown_Converter $converter;
+
+ /**
+ * Set up test case.
*
* @since x.x.x
*/
- public function test_basic_conversion() {
- $converter = new HTML_To_Markdown_Converter();
+ public function setUp(): void {
+ parent::setUp();
+ $this->converter = new HTML_To_Markdown_Converter();
+ }
+ /**
+ * Test that basic HTML is converted into Markdown.
+ *
+ * @since x.x.x
+ */
+ public function test_basic_conversion(): void {
$code = "echo \"hi\";\n
";
$html = 'Heading
'
@@ -32,7 +48,7 @@ public function test_basic_conversion() {
. '
'
. $code;
- $markdown = $converter->convert( $html );
+ $markdown = $this->converter->convert( $html );
$this->assertStringContainsString( '## Heading', $markdown );
$this->assertStringContainsString( 'Hello **world** [link](https://example.com).', $markdown );
@@ -42,4 +58,297 @@ public function test_basic_conversion() {
$this->assertStringContainsString( '```', $markdown );
$this->assertStringContainsString( 'echo "hi";', $markdown );
}
+
+ /**
+ * Test conversion of all heading levels.
+ *
+ * @since x.x.x
+ */
+ public function test_converts_all_heading_levels(): void {
+ $html = 'H1
H2
H3
H4
H5
H6
';
+
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( '# H1', $markdown );
+ $this->assertStringContainsString( '## H2', $markdown );
+ $this->assertStringContainsString( '### H3', $markdown );
+ $this->assertStringContainsString( '#### H4', $markdown );
+ $this->assertStringContainsString( '##### H5', $markdown );
+ $this->assertStringContainsString( '###### H6', $markdown );
+ }
+
+ /**
+ * Test conversion of bold text using both strong and b tags.
+ *
+ * @since x.x.x
+ */
+ public function test_converts_bold_text(): void {
+ $html = 'This is bold and also bold.
';
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( '**bold**', $markdown );
+ $this->assertStringContainsString( '**also bold**', $markdown );
+ }
+
+ /**
+ * Test conversion of italic text using both em and i tags.
+ *
+ * @since x.x.x
+ */
+ public function test_converts_italic_text(): void {
+ $html = 'This is italic and also italic.
';
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( '*italic*', $markdown );
+ $this->assertStringContainsString( '*also italic*', $markdown );
+ }
+
+ /**
+ * Test conversion of links with href attribute.
+ *
+ * @since x.x.x
+ */
+ public function test_converts_links(): void {
+ $html = 'Visit Example Site for more info.
';
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( '[Example Site](https://example.com)', $markdown );
+ }
+
+ /**
+ * Test conversion of images with alt text.
+ *
+ * @since x.x.x
+ */
+ public function test_converts_images_with_alt(): void {
+ $html = '
';
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( '', $markdown );
+ }
+
+ /**
+ * Test conversion of unordered lists.
+ *
+ * @since x.x.x
+ */
+ public function test_converts_unordered_lists(): void {
+ $html = '';
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( '- Apple', $markdown );
+ $this->assertStringContainsString( '- Banana', $markdown );
+ $this->assertStringContainsString( '- Cherry', $markdown );
+ }
+
+ /**
+ * Test conversion of ordered lists.
+ *
+ * @since x.x.x
+ */
+ public function test_converts_ordered_lists(): void {
+ $html = '- First
- Second
- Third
';
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( '1. First', $markdown );
+ $this->assertStringContainsString( '2. Second', $markdown );
+ $this->assertStringContainsString( '3. Third', $markdown );
+ }
+
+ /**
+ * Test conversion of blockquotes.
+ *
+ * @since x.x.x
+ */
+ public function test_converts_blockquotes(): void {
+ $html = 'This is a quoted text.
';
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( '> This is a quoted text.', $markdown );
+ }
+
+ /**
+ * Test conversion of inline code.
+ *
+ * @since x.x.x
+ */
+ public function test_converts_inline_code(): void {
+ $html = 'Use the console.log() function for debugging.
';
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( '`console.log()`', $markdown );
+ }
+
+ /**
+ * Test conversion of code blocks.
+ *
+ * @since x.x.x
+ */
+ public function test_converts_code_blocks(): void {
+ $html = 'const x = 42;
';
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( '```', $markdown );
+ $this->assertStringContainsString( 'const x = 42;', $markdown );
+ }
+
+ /**
+ * Test conversion of horizontal rules.
+ *
+ * @since x.x.x
+ */
+ public function test_converts_horizontal_rules(): void {
+ $html = 'Above the line.
Below the line.
';
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( '---', $markdown );
+ }
+
+ /**
+ * Test conversion of line breaks.
+ *
+ * @since x.x.x
+ */
+ public function test_converts_line_breaks(): void {
+ $html = 'Line one
Line two
';
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( "Line one\nLine two", $markdown );
+ }
+
+ /**
+ * Test conversion of simple tables with thead and tbody.
+ *
+ * @since x.x.x
+ */
+ public function test_converts_tables_with_thead(): void {
+ $html = '
+
+ | Name | Age |
+
+
+ | Alice | 30 |
+ | Bob | 25 |
+
+
';
+
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( '| Name | Age |', $markdown );
+ $this->assertStringContainsString( '| --- | --- |', $markdown );
+ $this->assertStringContainsString( '| Alice | 30 |', $markdown );
+ $this->assertStringContainsString( '| Bob | 25 |', $markdown );
+ }
+
+ /**
+ * Test conversion of tables using th tags without explicit thead.
+ *
+ * @since x.x.x
+ */
+ public function test_converts_tables_with_th_no_thead(): void {
+ $html = '
+ | Product | Price |
+ | Widget | $10 |
+
';
+
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( '| Product | Price |', $markdown );
+ $this->assertStringContainsString( '| --- | --- |', $markdown );
+ $this->assertStringContainsString( '| Widget | $10 |', $markdown );
+ }
+
+ /**
+ * Test that script tags are completely removed.
+ *
+ * @since x.x.x
+ */
+ public function test_removes_script_tags(): void {
+ $html = 'Safe content
More safe content
';
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( 'Safe content', $markdown );
+ $this->assertStringContainsString( 'More safe content', $markdown );
+ $this->assertStringNotContainsString( 'malicious', $markdown );
+ $this->assertStringNotContainsString( 'script', $markdown );
+ }
+
+ /**
+ * Test that style tags are completely removed.
+ *
+ * @since x.x.x
+ */
+ public function test_removes_style_tags(): void {
+ $html = 'Content
';
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( 'Content', $markdown );
+ $this->assertStringNotContainsString( 'color', $markdown );
+ $this->assertStringNotContainsString( 'style', $markdown );
+ }
+
+ /**
+ * Test that empty input returns empty output.
+ *
+ * @since x.x.x
+ */
+ public function test_empty_input(): void {
+ $markdown = $this->converter->convert( '' );
+
+ $this->assertEmpty( $markdown );
+ }
+
+ /**
+ * Test that whitespace-only input is handled gracefully.
+ *
+ * @since x.x.x
+ */
+ public function test_whitespace_only_input(): void {
+ $markdown = $this->converter->convert( ' ' );
+
+ $this->assertEmpty( trim( $markdown ) );
+ }
+
+ /**
+ * Test nested formatting (bold within italic, etc.).
+ *
+ * @since x.x.x
+ */
+ public function test_nested_formatting(): void {
+ $html = 'This is bold italic text.
';
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( '***bold italic***', $markdown );
+ }
+
+ /**
+ * Test complex nested structure with blockquote containing formatted text.
+ *
+ * @since x.x.x
+ */
+ public function test_complex_blockquote(): void {
+ $html = '
+ A quote with bold and italic text.
+
';
+
+ $markdown = $this->converter->convert( $html );
+
+ $this->assertStringContainsString( '>', $markdown );
+ $this->assertStringContainsString( '**bold**', $markdown );
+ $this->assertStringContainsString( '*italic*', $markdown );
+ }
+
+ /**
+ * Test that excessive whitespace is normalized.
+ *
+ * @since x.x.x
+ */
+ public function test_normalizes_whitespace(): void {
+ $html = 'Multiple spaces here
';
+ $markdown = $this->converter->convert( $html );
+
+ // Should not contain multiple consecutive spaces.
+ $this->assertStringNotContainsString( ' ', $markdown );
+ $this->assertStringContainsString( 'Multiple spaces here', $markdown );
+ }
}
diff --git a/tests/Integration/Includes/Experiments/Markdown_Feeds/Markdown_FeedsTest.php b/tests/Integration/Includes/Experiments/Markdown_Feeds/Markdown_FeedsTest.php
index 3eef1bb5..4bcf3f47 100644
--- a/tests/Integration/Includes/Experiments/Markdown_Feeds/Markdown_FeedsTest.php
+++ b/tests/Integration/Includes/Experiments/Markdown_Feeds/Markdown_FeedsTest.php
@@ -18,6 +18,14 @@
* @since x.x.x
*/
class Markdown_FeedsTest extends WP_UnitTestCase {
+
+ /**
+ * The experiment instance.
+ *
+ * @var \WordPress\AI\Experiments\Markdown_Feeds\Markdown_Feeds
+ */
+ private Markdown_Feeds $experiment;
+
/**
* Set up test case.
*
@@ -42,6 +50,9 @@ public function setUp(): void {
$experiment = $registry->get_experiment( 'markdown-feeds' );
$this->assertInstanceOf( Markdown_Feeds::class, $experiment, 'Markdown feeds experiment should be registered in the registry.' );
+
+ $this->experiment = new Markdown_Feeds();
+ $this->experiment->register();
}
/**
@@ -62,11 +73,132 @@ public function tearDown(): void {
*
* @since x.x.x
*/
- public function test_experiment_registration() {
+ public function test_experiment_registration(): void {
$experiment = new Markdown_Feeds();
$this->assertEquals( 'markdown-feeds', $experiment->get_id() );
- $this->assertEquals( 'Markdown', $experiment->get_label() );
+ $this->assertEquals( 'Markdown Feeds', $experiment->get_label() );
$this->assertTrue( $experiment->is_enabled() );
}
+
+ /**
+ * Test that the Markdown feed is registered.
+ *
+ * @since x.x.x
+ */
+ public function test_markdown_feed_is_registered(): void {
+ global $wp_rewrite;
+
+ $this->experiment->register_feed();
+
+ // The feed should be added to the registered feeds.
+ $this->assertContains( 'markdown', $wp_rewrite->feeds );
+ }
+
+ /**
+ * Test that get_markdown_feed_link returns the correct URL.
+ *
+ * @since x.x.x
+ */
+ public function test_get_markdown_feed_link(): void {
+ $feed_link = $this->experiment->get_markdown_feed_link();
+
+ $this->assertStringContainsString( 'feed', $feed_link );
+ $this->assertStringContainsString( 'markdown', $feed_link );
+ }
+
+ /**
+ * Test that get_markdown_permalink returns the correct URL for a post.
+ *
+ * @since x.x.x
+ */
+ public function test_get_markdown_permalink(): void {
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_title' => 'Test Post',
+ 'post_name' => 'test-post',
+ 'post_status' => 'publish',
+ )
+ );
+
+ $post = get_post( $post_id );
+ $md_permalink = $this->experiment->get_markdown_permalink( $post );
+
+ $this->assertStringEndsWith( '.md', $md_permalink );
+ $this->assertStringContainsString( 'test-post', $md_permalink );
+ }
+
+ /**
+ * Test that request filter strips .md from post name.
+ *
+ * @since x.x.x
+ */
+ public function test_filter_request_strips_md_from_name(): void {
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+
+ $query_vars = array( 'name' => 'test-post.md' );
+ $result = $this->experiment->filter_request_for_markdown_extension( $query_vars );
+
+ $this->assertEquals( 'test-post', $result['name'] );
+ }
+
+ /**
+ * Test that request filter strips .md from pagename.
+ *
+ * @since x.x.x
+ */
+ public function test_filter_request_strips_md_from_pagename(): void {
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+
+ $query_vars = array( 'pagename' => 'sample-page.md' );
+ $result = $this->experiment->filter_request_for_markdown_extension( $query_vars );
+
+ $this->assertEquals( 'sample-page', $result['pagename'] );
+ }
+
+ /**
+ * Test that request filter ignores non-.md extensions.
+ *
+ * @since x.x.x
+ */
+ public function test_filter_request_ignores_non_md_extensions(): void {
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+
+ $query_vars = array( 'name' => 'test-post.html' );
+ $result = $this->experiment->filter_request_for_markdown_extension( $query_vars );
+
+ $this->assertEquals( 'test-post.html', $result['name'] );
+ }
+
+ /**
+ * Test that request filter ignores POST requests.
+ *
+ * @since x.x.x
+ */
+ public function test_filter_request_ignores_post_requests(): void {
+ $_SERVER['REQUEST_METHOD'] = 'POST';
+
+ $query_vars = array( 'name' => 'test-post.md' );
+ $result = $this->experiment->filter_request_for_markdown_extension( $query_vars );
+
+ $this->assertEquals( 'test-post.md', $result['name'] );
+ }
+
+ /**
+ * Test canonical redirect is prevented for .md requests.
+ *
+ * @since x.x.x
+ */
+ public function test_filter_redirect_canonical_prevents_redirect_for_md(): void {
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+
+ // First, trigger the markdown extension detection.
+ $query_vars = array( 'name' => 'test-post.md' );
+ $this->experiment->filter_request_for_markdown_extension( $query_vars );
+
+ // Now test that canonical redirect is prevented.
+ $result = $this->experiment->filter_redirect_canonical( 'http://example.com/test-post/', 'http://example.com/test-post.md' );
+
+ $this->assertFalse( $result );
+ }
}
From c79a617eda15d712bf458a956bfa3f79a9a2b2a0 Mon Sep 17 00:00:00 2001
From: James LePage <36246732+Jameswlepage@users.noreply.github.com>
Date: Mon, 19 Jan 2026 14:03:25 -0500
Subject: [PATCH 3/9] fix: handle markdown permalinks without pretty URLs
---
docs/experiments/markdown-feeds.md | 1 +
.../Markdown_Feeds/Markdown_Feeds.php | 10 ++-
.../Markdown_Feeds/Markdown_FeedsTest.php | 62 +++++++++++++++----
3 files changed, 59 insertions(+), 14 deletions(-)
diff --git a/docs/experiments/markdown-feeds.md b/docs/experiments/markdown-feeds.md
index 60e75dab..18293dfa 100644
--- a/docs/experiments/markdown-feeds.md
+++ b/docs/experiments/markdown-feeds.md
@@ -40,3 +40,4 @@ The output is intended to be a lightweight, text-first format that is easier for
- The HTML-to-Markdown conversion is intentionally conservative and based on WordPress core’s HTML API (`WP_HTML_Processor`) rather than a bundled third-party parser.
- This experiment currently targets singular post content (title + metadata + content). It does not attempt to convert full theme templates or archive views.
+- `.md` permalinks require pretty permalinks (a non-empty permalink structure).
diff --git a/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php b/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
index 1542eb70..c7c032d0 100644
--- a/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
+++ b/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
@@ -140,6 +140,9 @@ public function add_feed_autodiscovery_links(): void {
}
$md_url = $this->get_markdown_permalink( $post );
+ if ( '' === $md_url ) {
+ return;
+ }
/* translators: %s: Post title. */
$md_title = sprintf( __( '%s (Markdown)', 'ai' ), get_the_title( $post ) );
@@ -176,8 +179,13 @@ public function get_markdown_feed_link( string $context = '' ): string {
* @return string Markdown permalink.
*/
public function get_markdown_permalink( \WP_Post $post ): string {
+ $permalink_structure = (string) get_option( 'permalink_structure' );
+ if ( '' === $permalink_structure ) {
+ return '';
+ }
+
$permalink = get_permalink( $post );
- if ( ! $permalink ) {
+ if ( ! $permalink || false !== strpos( $permalink, '?' ) ) {
return '';
}
diff --git a/tests/Integration/Includes/Experiments/Markdown_Feeds/Markdown_FeedsTest.php b/tests/Integration/Includes/Experiments/Markdown_Feeds/Markdown_FeedsTest.php
index 4bcf3f47..6a4d21ee 100644
--- a/tests/Integration/Includes/Experiments/Markdown_Feeds/Markdown_FeedsTest.php
+++ b/tests/Integration/Includes/Experiments/Markdown_Feeds/Markdown_FeedsTest.php
@@ -113,19 +113,55 @@ public function test_get_markdown_feed_link(): void {
* @since x.x.x
*/
public function test_get_markdown_permalink(): void {
- $post_id = self::factory()->post->create(
- array(
- 'post_title' => 'Test Post',
- 'post_name' => 'test-post',
- 'post_status' => 'publish',
- )
- );
-
- $post = get_post( $post_id );
- $md_permalink = $this->experiment->get_markdown_permalink( $post );
-
- $this->assertStringEndsWith( '.md', $md_permalink );
- $this->assertStringContainsString( 'test-post', $md_permalink );
+ global $wp_rewrite;
+
+ $original_structure = (string) get_option( 'permalink_structure' );
+ $wp_rewrite->set_permalink_structure( '/%postname%/' );
+
+ try {
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_title' => 'Test Post',
+ 'post_name' => 'test-post',
+ 'post_status' => 'publish',
+ )
+ );
+
+ $post = get_post( $post_id );
+ $md_permalink = $this->experiment->get_markdown_permalink( $post );
+
+ $this->assertStringEndsWith( '.md', $md_permalink );
+ $this->assertStringContainsString( 'test-post', $md_permalink );
+ } finally {
+ $wp_rewrite->set_permalink_structure( $original_structure );
+ }
+ }
+
+ /**
+ * Test that get_markdown_permalink returns empty without pretty permalinks.
+ *
+ * @since x.x.x
+ */
+ public function test_get_markdown_permalink_returns_empty_with_plain_permalinks(): void {
+ global $wp_rewrite;
+
+ $original_structure = (string) get_option( 'permalink_structure' );
+ $wp_rewrite->set_permalink_structure( '' );
+
+ try {
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_title' => 'Plain Post',
+ 'post_name' => 'plain-post',
+ 'post_status' => 'publish',
+ )
+ );
+
+ $post = get_post( $post_id );
+ $this->assertSame( '', $this->experiment->get_markdown_permalink( $post ) );
+ } finally {
+ $wp_rewrite->set_permalink_structure( $original_structure );
+ }
}
/**
From 461b4297c76cf5b03de5283504d4294911b8d16e Mon Sep 17 00:00:00 2001
From: James LePage <36246732+Jameswlepage@users.noreply.github.com>
Date: Mon, 19 Jan 2026 14:13:51 -0500
Subject: [PATCH 4/9] feat: add filters for markdown entry sections
---
docs/experiments/markdown-feeds.md | 2 +
.../Markdown_Feeds/Markdown_Feed_Renderer.php | 40 ++++++++++++++---
.../Markdown_Singular_Renderer.php | 45 ++++++++++++++-----
3 files changed, 68 insertions(+), 19 deletions(-)
diff --git a/docs/experiments/markdown-feeds.md b/docs/experiments/markdown-feeds.md
index 18293dfa..510fb054 100644
--- a/docs/experiments/markdown-feeds.md
+++ b/docs/experiments/markdown-feeds.md
@@ -19,8 +19,10 @@ The output is intended to be a lightweight, text-first format that is easier for
- Filters:
- `ai_experiments_markdown_feed_html` -> adjust HTML before conversion.
- `ai_experiments_markdown_feed_markdown` -> adjust Markdown after conversion.
+ - `ai_experiments_markdown_feed_post_sections` -> reorder or inject sections in each feed entry.
- `ai_experiments_markdown_singular_html` -> adjust HTML before conversion (singular).
- `ai_experiments_markdown_singular_markdown` -> adjust Markdown after conversion (singular).
+ - `ai_experiments_markdown_singular_post_sections` -> reorder or inject sections in each singular response.
## Assets & Data Flow
diff --git a/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php b/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php
index 8107238b..098c1807 100644
--- a/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php
+++ b/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php
@@ -204,21 +204,47 @@ private function send_post( WP_Post $post ): void {
$permalink = esc_url_raw( get_permalink( $post ) );
$date_r = (string) get_post_time( 'r', true, $post );
- echo '## ' . esc_html( $title ) . "\n\n";
- echo esc_html__( 'URL:', 'ai' ) . ' <' . esc_url( $permalink ) . ">\n";
- echo esc_html__( 'Published:', 'ai' ) . ' ' . esc_html( $date_r ) . "\n\n";
-
$content = (string) get_post_field( 'post_content', $post );
$html = (string) apply_filters( 'the_content', $content );
$markdown = $this->convert_html_to_markdown( $html );
$markdown = trim( $markdown );
+
+ $meta_lines = array(
+ esc_html__( 'URL:', 'ai' ) . ' <' . esc_url( $permalink ) . '>',
+ esc_html__( 'Published:', 'ai' ) . ' ' . esc_html( $date_r ),
+ );
+
+ $sections = array(
+ 'header' => '## ' . esc_html( $title ),
+ 'meta' => implode( "\n", $meta_lines ),
+ );
+
if ( '' !== $markdown ) {
- // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Markdown response.
- echo $markdown . "\n\n";
+ $sections['content'] = $markdown;
+ }
+
+ $sections['footer'] = '---';
+
+ /**
+ * Filters the Markdown feed entry sections.
+ *
+ * Allows reordering or inserting custom sections before output is emitted.
+ *
+ * @since x.x.x
+ *
+ * @param array $sections Markdown sections keyed by role.
+ * @param \WP_Post $post Post object.
+ */
+ $sections = (array) apply_filters( 'ai_experiments_markdown_feed_post_sections', $sections, $post );
+
+ $entry = implode( "\n\n", $sections );
+ if ( '' === $entry ) {
+ return;
}
- echo "---\n\n";
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Markdown response.
+ echo $entry . "\n\n";
}
/**
diff --git a/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php b/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php
index 25bd0202..5e3f0560 100644
--- a/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php
+++ b/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php
@@ -158,16 +158,6 @@ private function send_post( WP_Post $post ): void {
$published = (string) get_post_time( 'r', true, $post );
$modified = (string) get_post_modified_time( 'r', true, $post );
- echo '# ' . esc_html( $title ) . "\n\n";
- echo esc_html__( 'URL:', 'ai' ) . ' <' . esc_url( $permalink ) . ">\n";
- echo esc_html__( 'Published:', 'ai' ) . ' ' . esc_html( $published ) . "\n";
-
- if ( $modified !== $published ) {
- echo esc_html__( 'Updated:', 'ai' ) . ' ' . esc_html( $modified ) . "\n";
- }
-
- echo "\n";
-
$content = (string) get_post_field( 'post_content', $post );
$html = (string) apply_filters( 'the_content', $content );
@@ -196,12 +186,43 @@ private function send_post( WP_Post $post ): void {
$markdown = (string) apply_filters( 'ai_experiments_markdown_singular_markdown', $markdown, $html, $post );
$markdown = trim( $markdown );
- if ( '' === $markdown ) {
+ $meta_lines = array(
+ esc_html__( 'URL:', 'ai' ) . ' <' . esc_url( $permalink ) . '>',
+ esc_html__( 'Published:', 'ai' ) . ' ' . esc_html( $published ),
+ );
+
+ if ( $modified !== $published ) {
+ $meta_lines[] = esc_html__( 'Updated:', 'ai' ) . ' ' . esc_html( $modified );
+ }
+
+ $sections = array(
+ 'header' => '# ' . esc_html( $title ),
+ 'meta' => implode( "\n", $meta_lines ),
+ );
+
+ if ( '' !== $markdown ) {
+ $sections['content'] = $markdown;
+ }
+
+ /**
+ * Filters the Markdown singular entry sections.
+ *
+ * Allows reordering or inserting custom sections before output is emitted.
+ *
+ * @since x.x.x
+ *
+ * @param array $sections Markdown sections keyed by role.
+ * @param \WP_Post $post Post object.
+ */
+ $sections = (array) apply_filters( 'ai_experiments_markdown_singular_post_sections', $sections, $post );
+
+ $entry = implode( "\n\n", $sections );
+ if ( '' === $entry ) {
return;
}
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Markdown response.
- echo $markdown . "\n";
+ echo $entry . "\n";
}
/**
From f27644f0e90928985e646d0447da09b157960cdf Mon Sep 17 00:00:00 2001
From: Darin Kotter
Date: Mon, 2 Feb 2026 13:33:16 -0700
Subject: [PATCH 5/9] Minor code cleanup
---
.../HTML_To_Markdown_Converter.php | 1 +
.../Markdown_Feeds/Markdown_Feed_Renderer.php | 19 ---------
.../Markdown_Feeds/Markdown_Feeds.php | 40 ++++---------------
.../Markdown_Singular_Renderer.php | 15 -------
4 files changed, 8 insertions(+), 67 deletions(-)
diff --git a/includes/Experiments/Markdown_Feeds/HTML_To_Markdown_Converter.php b/includes/Experiments/Markdown_Feeds/HTML_To_Markdown_Converter.php
index de11c0ce..ac013cd2 100644
--- a/includes/Experiments/Markdown_Feeds/HTML_To_Markdown_Converter.php
+++ b/includes/Experiments/Markdown_Feeds/HTML_To_Markdown_Converter.php
@@ -32,6 +32,7 @@ public function convert( string $html ): string {
if ( ! $processor ) {
return trim( wp_strip_all_tags( $html ) );
}
+
$markdown = $this->convert_with_processor( $processor );
if (
diff --git a/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php b/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php
index 098c1807..71d0967d 100644
--- a/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php
+++ b/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php
@@ -11,25 +11,6 @@
use WP_Post;
use WP_Query;
-use function apply_filters;
-use function esc_html;
-use function esc_html__;
-use function esc_url;
-use function esc_url_raw;
-use function get_bloginfo;
-use function get_option;
-use function get_permalink;
-use function get_post;
-use function get_post_field;
-use function get_post_time;
-use function get_self_link;
-use function get_the_title;
-use function have_posts;
-use function sanitize_key;
-use function status_header;
-use function the_post;
-use function wp_strip_all_tags;
-use function wp_unslash;
/**
* Outputs a Markdown representation of the current feed query.
diff --git a/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php b/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
index c7c032d0..65b6ce66 100644
--- a/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
+++ b/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
@@ -12,30 +12,6 @@
use WordPress\AI\Abstracts\Abstract_Experiment;
use WordPress\AI\Settings\Settings_Registration;
-use function __;
-use function add_query_arg;
-use function current_user_can;
-use function esc_attr;
-use function esc_html;
-use function esc_html__;
-use function esc_url;
-use function get_bloginfo;
-use function get_feed_link;
-use function get_option;
-use function get_permalink;
-use function get_post_status;
-use function get_post_type_object;
-use function get_queried_object;
-use function get_the_title;
-use function is_admin;
-use function is_feed;
-use function is_singular;
-use function post_password_required;
-use function register_setting;
-use function sanitize_key;
-use function trailingslashit;
-use function wp_kses;
-
/**
* Registers Markdown representations for feeds and singular content.
*/
@@ -120,6 +96,7 @@ public function add_feed_autodiscovery_links(): void {
// Main site feed.
$feed_url = $this->get_markdown_feed_link();
+
/* translators: %s: Site name. */
$title = sprintf( __( '%s Markdown Feed', 'ai' ), $site_name );
@@ -143,6 +120,7 @@ public function add_feed_autodiscovery_links(): void {
if ( '' === $md_url ) {
return;
}
+
/* translators: %s: Post title. */
$md_title = sprintf( __( '%s (Markdown)', 'ai' ), get_the_title( $post ) );
@@ -190,10 +168,7 @@ public function get_markdown_permalink( \WP_Post $post ): string {
}
// Remove trailing slash, add .md extension.
- $permalink = trailingslashit( $permalink );
- $permalink = rtrim( $permalink, '/' );
-
- return $permalink . '.md';
+ return rtrim( trailingslashit( $permalink ), '/' ) . '.md';
}
/**
@@ -263,7 +238,7 @@ public function render_settings_fields(): void {
sprintf(
/* translators: %s: Markdown feed URL query string. */
__( 'Enable %s', 'ai' ),
- esc_html( '/?feed=markdown' )
+ '/?feed=markdown'
),
array( 'code' => array() )
);
@@ -285,7 +260,7 @@ public function render_settings_fields(): void {
sprintf(
/* translators: %s: File extension used for Markdown permalinks. */
__( 'Enable %s permalinks for singular content', 'ai' ),
- esc_html( '.md' )
+ '.md'
),
array( 'code' => array() )
);
@@ -307,7 +282,7 @@ public function render_settings_fields(): void {
sprintf(
/* translators: %s: HTTP Accept header value used for Markdown negotiation. */
__( 'Enable %s negotiation for singular content', 'ai' ),
- esc_html( 'Accept: text/markdown' )
+ 'Accept: text/markdown'
),
array( 'code' => array() )
);
@@ -508,8 +483,7 @@ private function is_post_accessible( \WP_Post $post ): bool {
return false;
}
- $cap = $post_type_obj->cap->read_private_posts ?? 'read_private_posts';
- return current_user_can( $cap );
+ return current_user_can( $post_type_obj->cap->read_private_posts ?? 'read_private_posts' );
}
// Draft, pending, future, trash, etc. are not accessible.
diff --git a/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php b/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php
index 5e3f0560..a0be5a3c 100644
--- a/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php
+++ b/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php
@@ -11,21 +11,6 @@
use WP_Post;
-use function apply_filters;
-use function esc_html;
-use function esc_html__;
-use function esc_url;
-use function get_option;
-use function get_permalink;
-use function get_post_field;
-use function get_post_modified_time;
-use function get_post_time;
-use function get_the_title;
-use function sanitize_key;
-use function status_header;
-use function wp_strip_all_tags;
-use function wp_unslash;
-
/**
* Outputs a Markdown representation of a singular post.
*
From 498a3f9dadb00fdd0316b409a9abdd18e3849224 Mon Sep 17 00:00:00 2001
From: Darin Kotter
Date: Mon, 2 Feb 2026 13:45:50 -0700
Subject: [PATCH 6/9] Fix PHPStan and lint errors
---
.../HTML_To_Markdown_Converter.php | 45 +++++++------------
.../Markdown_Feeds/Markdown_Feed_Renderer.php | 2 +-
.../Markdown_Feeds/Markdown_Feeds.php | 2 +-
.../Markdown_Singular_Renderer.php | 2 +-
4 files changed, 20 insertions(+), 31 deletions(-)
diff --git a/includes/Experiments/Markdown_Feeds/HTML_To_Markdown_Converter.php b/includes/Experiments/Markdown_Feeds/HTML_To_Markdown_Converter.php
index ac013cd2..e0a5a6ea 100644
--- a/includes/Experiments/Markdown_Feeds/HTML_To_Markdown_Converter.php
+++ b/includes/Experiments/Markdown_Feeds/HTML_To_Markdown_Converter.php
@@ -13,6 +13,9 @@
namespace WordPress\AI\Experiments\Markdown_Feeds;
+use WP_HTML_Processor;
+use WP_HTML_Tag_Processor;
+
/**
* Converts HTML fragments into Markdown.
*
@@ -36,12 +39,11 @@ public function convert( string $html ): string {
$markdown = $this->convert_with_processor( $processor );
if (
- $processor instanceof \WP_HTML_Processor
- && method_exists( $processor, 'get_last_error' )
- && \WP_HTML_Processor::ERROR_UNSUPPORTED === $processor->get_last_error()
- && class_exists( \WP_HTML_Tag_Processor::class )
+ $processor instanceof WP_HTML_Processor
+ && WP_HTML_Processor::ERROR_UNSUPPORTED === $processor->get_last_error()
+ && class_exists( WP_HTML_Tag_Processor::class )
) {
- $markdown = $this->convert_with_processor( new \WP_HTML_Tag_Processor( $html ) );
+ $markdown = $this->convert_with_processor( new WP_HTML_Tag_Processor( $html ) );
}
return trim( $this->cleanup( $markdown ) );
@@ -61,27 +63,14 @@ public function convert( string $html ): string {
private function create_processor( string $html ) {
$processor = null;
- if ( class_exists( \WP_HTML_Processor::class ) ) {
- if ( method_exists( \WP_HTML_Processor::class, 'create_fragment' ) ) {
- $processor = \WP_HTML_Processor::create_fragment( $html );
- }
+ if ( class_exists( WP_HTML_Processor::class ) ) {
+ $processor = WP_HTML_Processor::create_fragment( $html );
if ( ! $processor ) {
- $processor = new \WP_HTML_Processor( $html );
+ $processor = new WP_HTML_Processor( $html );
}
- } elseif ( class_exists( \WP_HTML_Tag_Processor::class ) ) {
- $processor = new \WP_HTML_Tag_Processor( $html );
- }
-
- if (
- ! $processor
- || ! method_exists( $processor, 'next_token' )
- || ! method_exists( $processor, 'get_token_name' )
- || ! method_exists( $processor, 'get_modifiable_text' )
- || ! method_exists( $processor, 'is_tag_closer' )
- || ! method_exists( $processor, 'get_attribute' )
- ) {
- return null;
+ } elseif ( class_exists( WP_HTML_Tag_Processor::class ) ) {
+ $processor = new WP_HTML_Tag_Processor( $html );
}
return $processor;
@@ -92,7 +81,7 @@ private function create_processor( string $html ) {
*
* @since x.x.x
*
- * @param object $processor Processor instance.
+ * @param \WP_HTML_Tag_Processor|\WP_HTML_Processor $processor Processor instance.
* @return string Markdown output.
*/
private function convert_with_processor( $processor ): string {
@@ -120,7 +109,7 @@ private function convert_with_processor( $processor ): string {
// If we're in a table cell, collect text for the cell.
if ( $in_table && ! empty( $current_row ) ) {
$cell_index = count( $current_row ) - 1;
- $current_row[ $cell_index ] .= trim( preg_replace( '/\s+/', ' ', $text ) );
+ $current_row[ $cell_index ] .= trim( (string) preg_replace( '/\s+/', ' ', $text ) );
continue;
}
@@ -343,7 +332,7 @@ private function convert_with_processor( $processor ): string {
continue;
}
- if ( ! preg_match( '/^H([1-6])$/', $token_name, $matches ) ) {
+ if ( ! $token_name || ! preg_match( '/^H([1-6])$/', $token_name, $matches ) ) {
continue;
}
@@ -467,9 +456,9 @@ private function cleanup( string $markdown ): string {
// Remove trailing whitespace from lines.
$markdown = preg_replace( "/[ \\t]+\\n/", "\n", $markdown );
// Remove leading whitespace from lines (except in code blocks).
- $markdown = preg_replace( "/\\n[ \\t]+(?!\\s*```)/", "\n", $markdown );
+ $markdown = preg_replace( "/\\n[ \\t]+(?!\\s*```)/", "\n", (string) $markdown );
// Collapse multiple blank lines.
- $markdown = preg_replace( "/\\n{3,}/", "\n\n", $markdown );
+ $markdown = preg_replace( "/\\n{3,}/", "\n\n", (string) $markdown );
return (string) $markdown;
}
}
diff --git a/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php b/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php
index 71d0967d..443a77c8 100644
--- a/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php
+++ b/includes/Experiments/Markdown_Feeds/Markdown_Feed_Renderer.php
@@ -185,7 +185,7 @@ private function send_post( WP_Post $post ): void {
$permalink = esc_url_raw( get_permalink( $post ) );
$date_r = (string) get_post_time( 'r', true, $post );
- $content = (string) get_post_field( 'post_content', $post );
+ $content = get_post_field( 'post_content', $post );
$html = (string) apply_filters( 'the_content', $content );
$markdown = $this->convert_html_to_markdown( $html );
diff --git a/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php b/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
index 65b6ce66..74d931f8 100644
--- a/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
+++ b/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
@@ -483,7 +483,7 @@ private function is_post_accessible( \WP_Post $post ): bool {
return false;
}
- return current_user_can( $post_type_obj->cap->read_private_posts ?? 'read_private_posts' );
+ return current_user_can( $post_type_obj->cap->read_private_posts ?? 'read_private_posts' ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined
}
// Draft, pending, future, trash, etc. are not accessible.
diff --git a/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php b/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php
index a0be5a3c..06b421de 100644
--- a/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php
+++ b/includes/Experiments/Markdown_Feeds/Markdown_Singular_Renderer.php
@@ -143,7 +143,7 @@ private function send_post( WP_Post $post ): void {
$published = (string) get_post_time( 'r', true, $post );
$modified = (string) get_post_modified_time( 'r', true, $post );
- $content = (string) get_post_field( 'post_content', $post );
+ $content = get_post_field( 'post_content', $post );
$html = (string) apply_filters( 'the_content', $content );
/**
From 98c7c30c0120d36837a76defc3620e32ae5d526e Mon Sep 17 00:00:00 2001
From: Darin Kotter
Date: Mon, 2 Feb 2026 14:01:25 -0700
Subject: [PATCH 7/9] Add better styling to our nested settings
---
.../Experiments/Markdown_Feeds/Markdown_Feeds.php | 15 ++-------------
src/admin/settings/index.scss | 8 ++++++++
2 files changed, 10 insertions(+), 13 deletions(-)
diff --git a/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php b/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
index 74d931f8..60ec6f53 100644
--- a/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
+++ b/includes/Experiments/Markdown_Feeds/Markdown_Feeds.php
@@ -172,9 +172,7 @@ public function get_markdown_permalink( \WP_Post $post ): string {
}
/**
- * Registers experiment-specific settings.
- *
- * @since x.x.x
+ * {@inheritDoc}
*/
public function register_settings(): void {
register_setting(
@@ -211,19 +209,10 @@ public function register_settings(): void {
/**
* {@inheritDoc}
*/
- public function has_settings(): bool {
- return true;
- }
-
- /**
- * Renders settings controls on the Experiments screen.
- *
- * @since x.x.x
- */
public function render_settings_fields(): void {
$settings = $this->get_settings();
?>
-
+