__( 'Markdown Feeds', 'ai' ),
'description' => __( 'Serves your content as Markdown for AI agents and other machine readers: adds a Markdown feed at /feed/markdown/ and Markdown versions of individual posts and pages via ?output_format=markdown, with optional Accept-header negotiation.', 'ai' ),
'category' => Experiment_Category::ADMIN,
'capability' => 'none',
'usage' => array(
'trigger' => 'none',
'runs' => __( 'On front-end requests', 'ai' ),
'sends' => __( 'Nothing', 'ai' ),
'details' => __( 'Renders Markdown versions of public posts, pages, and comments', 'ai' ),
),
);
}
/**
* {@inheritDoc}
*/
public function register(): void {
add_feed( self::FEED_NAME, array( $this, 'do_feed_markdown' ) );
add_filter( 'feed_content_type', array( $this, 'filter_feed_content_type' ), 10, 2 );
add_action( 'template_redirect', array( $this, 'handle_template_redirect' ) );
add_action( 'wp_head', array( $this, 'add_discovery_links' ) );
}
/**
* {@inheritDoc}
*/
public function get_settings_fields(): array {
return array(
array(
'id' => 'accept_header',
'label' => __( 'Serve Markdown when a request prefers it via the Accept header (may conflict with page caches that ignore the Vary header)', 'ai' ),
'type' => 'boolean',
'default' => false,
),
);
}
/**
* {@inheritDoc}
*
* @since 1.5.0
*/
public function get_preloaded_options(): array {
return array( self::FLUSH_FLAG_OPTION );
}
/**
* {@inheritDoc}
*
* Registers the `accept_header` setting and the option-change listeners
* that schedule a rewrite-rules flush. This runs for ALL registered features
* regardless of enablement which is required so the flush also happens on
* the disable transition.
*
* @since 1.5.0 Registers the `accept_header` setting.
*/
public function register_settings(): void {
parent::register_settings();
register_setting(
Settings_Registration::OPTION_GROUP,
static::get_field_option_name( 'accept_header' ),
array(
'type' => 'boolean',
'default' => false,
'sanitize_callback' => 'rest_sanitize_boolean',
'show_in_rest' => array(
'schema' => array(
'type' => 'boolean',
),
),
)
);
$enabled_option = sprintf( 'wpai_feature_%s_enabled', static::get_id() );
add_action( "add_option_{$enabled_option}", array( $this, 'schedule_rewrite_flush' ) );
add_action( "update_option_{$enabled_option}", array( $this, 'schedule_rewrite_flush' ) );
add_action( 'wp_loaded', array( $this, 'maybe_flush_rewrite_rules' ) );
}
/**
* Sends an HTTP header when headers have not already been sent.
*
* @since 1.4.0
*
* @param string $header Header line to send.
* @param bool $replace Whether to replace a previously sent header of the same name.
*/
protected function send_header( string $header, bool $replace = true ): void {
if ( headers_sent() ) {
return;
}
header( $header, $replace );
}
/**
* Removes a previously queued HTTP header when headers have not already been sent.
*
* @since 1.5.0
*
* @param string $name Name of the header to remove.
*/
protected function remove_header( string $name ): void {
if ( headers_sent() ) {
return;
}
header_remove( $name );
}
/**
* Renders the markdown feed for the current feed query.
*
* @since 1.4.0
*/
public function do_feed_markdown(): void {
$this->send_header( 'Content-Type: text/markdown; charset=' . get_option( 'blog_charset' ) );
// Comment feed contexts list comments; singular feed requests never reach this point, they are answered with a 404 earlier.
$markdown = is_comment_feed() && ! is_singular()
? ( new Markdown_Comment_Renderer() )->render_feed()
: ( new Markdown_Feed_Renderer() )->render();
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Plain-text Markdown response, not HTML.
echo $markdown;
}
/**
* Filters the content type reported for the markdown feed.
*
* @since 1.4.0
*
* @param string $content_type Content type being sent for the feed.
* @param string $type Type of feed being requested.
* @return string Content type, mapped to text/markdown for the markdown feed.
*/
public function filter_feed_content_type( string $content_type, string $type ): string {
if ( self::FEED_NAME === $type ) {
return 'text/markdown';
}
return $content_type;
}
/**
* Serves singular content as Markdown when requested.
*
* @since 1.4.0
*/
public function handle_template_redirect(): void {
if ( $this->is_singular_feed_request() ) {
$this->send_not_found();
return;
}
if ( is_singular() && ! is_feed() && $this->is_accept_negotiation_enabled() ) {
$this->send_header( 'Vary: Accept', false );
}
$markdown = $this->get_singular_markdown();
if ( null === $markdown ) {
return;
}
$this->send_header( 'Content-Type: text/markdown; charset=' . get_option( 'blog_charset' ) );
$this->send_header( 'X-Robots-Tag: noindex' );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Plain-text Markdown response, not HTML.
echo $markdown;
exit;
}
/**
* Checks whether the request is for the Markdown feed of a single post.
*
* @since 1.5.0
*
* @return bool Whether the request is a singular Markdown feed request.
*/
private function is_singular_feed_request(): bool {
return is_feed() && is_singular() && self::FEED_NAME === get_query_var( 'feed' );
}
/**
* Turns the current request into a 404.
*
* @since 1.5.0
*/
protected function send_not_found(): void {
global $wp_query;
$wp_query->set_404();
$wp_query->is_feed = false;
status_header( 404 );
nocache_headers();
$this->remove_header( 'ETag' );
$this->send_header( 'Content-Type: ' . get_option( 'html_type' ) . '; charset=' . get_option( 'blog_charset' ) );
}
/**
* Returns the Markdown document for the current singular request, or null
* when Markdown was not requested or must not be served.
*
* @since 1.4.0
*
* @return string|null Markdown document, or null to serve the normal template.
*/
public function get_singular_markdown(): ?string {
// A post's comment feed is singular too, and feeds are never negotiated.
if ( ! is_singular() || is_feed() ) {
return null;
}
$post = get_queried_object();
if ( ! $post instanceof WP_Post ) {
return null;
}
if ( ! $this->is_markdown_requested() ) {
return null;
}
if ( ! is_post_publicly_viewable( $post ) || post_password_required( $post ) ) {
return null;
}
$renderer = new Markdown_Singular_Renderer();
return $renderer->render( $post );
}
/**
* Prints Markdown autodiscovery link tags.
*
* @since 1.4.0
*/
public function add_discovery_links(): void {
printf(
'' . "\n",
esc_attr(
sprintf(
/* translators: %s: site name. */
__( '%s Markdown Feed', 'ai' ),
get_bloginfo( 'name' )
)
),
esc_url( get_feed_link( self::FEED_NAME ) )
);
if ( ! is_singular() ) {
return;
}
$post = get_queried_object();
if ( ! $post instanceof WP_Post || ! is_post_publicly_viewable( $post ) || post_password_required( $post ) ) {
return;
}
$permalink = get_permalink( $post );
if ( ! $permalink ) {
return;
}
printf(
'' . "\n",
esc_attr(
sprintf(
/* translators: %s: post title. */
__( '%s (Markdown)', 'ai' ),
the_title_attribute(
array(
'echo' => false,
'post' => $post,
)
)
)
),
esc_url( add_query_arg( 'output_format', 'markdown', $permalink ) )
);
}
/**
* Flags that rewrite rules must be flushed on the next request.
*
* @since 1.4.0
*/
public function schedule_rewrite_flush(): void {
update_option( self::FLUSH_FLAG_OPTION, '1', false );
}
/**
* Flushes rewrite rules as needed.
*
* @since 1.4.0
*/
public function maybe_flush_rewrite_rules(): void {
if ( get_option( self::FLUSH_FLAG_OPTION ) ) {
delete_option( self::FLUSH_FLAG_OPTION );
} elseif ( ! $this->is_feed_missing_from_rewrite_rules() || get_transient( self::REWRITE_REPAIR_TRANSIENT ) ) {
return;
}
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.flush_rewrite_rules_flush_rewrite_rules -- Deferred to a single wp_loaded request, only when the enabled toggle changed or the stored rules do not list the registered feed.
flush_rewrite_rules( false );
// Nothing is missing, so end any wait left by an earlier flush that did not help.
if ( ! $this->is_feed_missing_from_rewrite_rules() ) {
delete_transient( self::REWRITE_REPAIR_TRANSIENT );
return;
}
// The flush did not add the feed, so something else keeps it out. Wait before trying again.
set_transient( self::REWRITE_REPAIR_TRANSIENT, 1, HOUR_IN_SECONDS );
}
/**
* Checks whether the feed is registered but missing from the stored rewrite rules.
*
* @since 1.4.0
*
* @return bool Whether the stored rewrite rules must be rebuilt to serve the feed.
*/
private function is_feed_missing_from_rewrite_rules(): bool {
global $wp_rewrite;
// Nothing to repair while the feed is not registered.
if ( ! in_array( self::FEED_NAME, $wp_rewrite->feeds, true ) ) {
return false;
}
$rules = get_option( 'rewrite_rules' );
// Plain permalinks store no rules.
if ( ! is_array( $rules ) ) {
return false;
}
/*
* The root feed rules point to this query and list every registered feed
* in their key, e.g. `feed/(feed|rdf|rss|rss2|atom|markdown)/?$`.
*/
$feed_rules = array_keys( $rules, 'index.php?&feed=$matches[1]', true );
return array() !== $feed_rules && array() === preg_grep( '/[(|]' . preg_quote( self::FEED_NAME, '/' ) . '[|)]/', $feed_rules );
}
/**
* Checks whether the current request asked for Markdown.
*
* @since 1.4.0
*
* @return bool Whether Markdown output was requested.
*/
private function is_markdown_requested(): bool {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public, read-only format negotiation.
if ( isset( $_GET['output_format'] ) && 'markdown' === sanitize_key( wp_unslash( (string) $_GET['output_format'] ) ) ) {
return true;
}
if ( ! $this->is_accept_negotiation_enabled() ) {
return false;
}
$accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_ACCEPT'] ) ) : '';
return $this->prefers_markdown( $accept );
}
/**
* Checks whether an Accept header prefers Markdown over HTML.
*
* Markdown wins with a higher effective quality than HTML, or on a tie
* when its match is more specific or listed first. Refusals and
* wildcard-only headers keep HTML.
*
* @since 1.5.0
*
* @param string $accept Accept header value.
* @return bool Whether Markdown is preferred.
*/
private function prefers_markdown( string $accept ): bool {
// Best match so far per media type: specificity, quality, position in the header.
$best = array(
'text/html' => array( 0, 0.0, PHP_INT_MAX ),
'text/markdown' => array( 0, 0.0, PHP_INT_MAX ),
);
foreach ( explode( ',', strtolower( $accept ) ) as $position => $range ) {
$params = array_map( 'trim', explode( ';', $range ) );
$type = array_shift( $params );
$quality = 1.0;
foreach ( $params as $param ) {
if ( 1 !== preg_match( '/^q\s*=\s*(0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/', $param, $matches ) ) {
continue;
}
$quality = (float) $matches[1];
}
if ( 'text/x-markdown' === $type ) {
$type = 'text/markdown';
}
foreach ( $best as $media_type => $current ) {
if ( $type === $media_type ) {
$specificity = 3;
} elseif ( 'text/*' === $type ) {
$specificity = 2;
} elseif ( '*/*' === $type ) {
$specificity = 1;
} else {
continue;
}
if ( $specificity < $current[0] || ( $specificity === $current[0] && $quality <= $current[1] ) ) {
continue;
}
$best[ $media_type ] = array( $specificity, $quality, $position );
}
}
[ $markdown_specificity, $markdown_quality, $markdown_position ] = $best['text/markdown'];
[ $html_specificity, $html_quality, $html_position ] = $best['text/html'];
if ( $markdown_quality <= 0 ) {
return false;
}
if ( $markdown_quality !== $html_quality ) {
return $markdown_quality > $html_quality;
}
// Same quality: the more specific match wins, then the one listed first. One range matching both keeps HTML.
if ( $markdown_specificity !== $html_specificity ) {
return $markdown_specificity > $html_specificity;
}
return $markdown_position < $html_position;
}
/**
* Checks whether Accept-header negotiation is enabled via the sub-toggle.
*
* @since 1.4.0
*
* @return bool Whether Accept-header negotiation is enabled.
*/
private function is_accept_negotiation_enabled(): bool {
return (bool) get_option( static::get_field_option_name( 'accept_header' ), false );
}
}