From bfac838c004c7cf337da5a506f1aa55a6f1f46c9 Mon Sep 17 00:00:00 2001 From: Ben Word Date: Sat, 18 Apr 2026 10:59:30 -0500 Subject: [PATCH] Respect Accept q-values and emit Vary: Accept --- README.md | 10 +++++ post-content-to-markdown.php | 85 ++++++++++++++++++++++++++++++++---- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7122c80..bcff200 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,16 @@ add_filter('post_content_to_markdown/post_allowed', function ($allowed, $post) { **Default:** `true` +#### `post_content_to_markdown/emit_vary` + +Controls whether the plugin appends `Vary: Accept` to every front-end response. Enabled by default so that downstream caches (browsers, proxies, CDNs) key on the Accept header and don't cross-serve an HTML response to a Markdown request (or vice versa). Disable if you don't want the plugin touching HTML response headers — Markdown responses will still include `Vary: Accept` regardless, so content negotiation stays correct for direct Markdown hits. + +```php +add_filter('post_content_to_markdown/emit_vary', '__return_false'); +``` + +**Default:** `true` + ### Feed filters #### `post_content_to_markdown/feed_post_types` diff --git a/post-content-to-markdown.php b/post-content-to-markdown.php index e7578a6..4452114 100644 --- a/post-content-to-markdown.php +++ b/post-content-to-markdown.php @@ -3,7 +3,7 @@ /** * Plugin Name: Post Content to Markdown * Description: Serve post content as Markdown via Accept headers or query parameters. - * Version: 1.4.0 + * Version: 1.5.0 * Author: roots.io * Requires PHP: 8.1 */ @@ -26,21 +26,69 @@ } /** - * Check if Markdown format is requested via Accept header or query parameter + * Return the q-value for a media type in an Accept header, picking the q of + * the most specific matching entry (exact > type/* > *\/*) per RFC 9110. */ -function isMarkdownRequested() +function acceptQuality($accept, $type, $subtype) { - // Check Accept header - if (isset($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'text/markdown')) { - return true; + $best = 0.0; + $bestSpecificity = -1; + + foreach (explode(',', $accept) as $entry) { + $entry = trim($entry); + if ($entry === '') { + continue; + } + + $parts = explode(';', $entry); + [$t, $s] = array_pad(explode('/', strtolower(trim(array_shift($parts))), 2), 2, ''); + + $specificity = match (true) { + $t === $type && $s === $subtype => 2, + $t === $type && $s === '*' => 1, + $t === '*' && $s === '*' => 0, + default => -1, + }; + if ($specificity < 0) { + continue; + } + + $q = 1.0; + foreach ($parts as $param) { + $param = trim($param); + if (stripos($param, 'q=') === 0) { + $q = (float) substr($param, 2); + } + } + + if ($specificity > $bestSpecificity || ($specificity === $bestSpecificity && $q > $best)) { + $best = $q; + $bestSpecificity = $specificity; + } } - // Check query parameter + return $best; +} + +/** + * Check if Markdown is preferred over HTML via Accept header q-values, + * or requested explicitly via ?format=markdown. + */ +function isMarkdownRequested() +{ if (isset($_GET['format']) && $_GET['format'] === 'markdown') { return true; } - return false; + $accept = $_SERVER['HTTP_ACCEPT'] ?? ''; + if ($accept === '') { + return false; + } + + $markdown = acceptQuality($accept, 'text', 'markdown'); + $html = acceptQuality($accept, 'text', 'html'); + + return $markdown > 0.0 && $markdown > $html; } /** @@ -53,6 +101,23 @@ function isMarkdownRequested() } }); +/** + * Advertise that responses vary by Accept header so caches key on it. Applied to + * every front-end response (not just Markdown) so an HTML response cached first + * can't be served to a later Markdown request for the same URL. + */ +add_action('send_headers', function () { + if (is_admin()) { + return; + } + + if (! apply_filters('post_content_to_markdown/emit_vary', true)) { + return; + } + + header('Vary: Accept', false); +}); + /** * Serve content as Markdown based on Accept header or query parameter */ @@ -90,6 +155,7 @@ function isMarkdownRequested() } header('Content-Type: text/markdown; charset='.get_option('blog_charset')); + header('Vary: Accept', false); echo '# '.strip_tags($post->post_title)."\n\n".contentToMarkdown($post->post_content); exit; } @@ -112,6 +178,7 @@ function outputMarkdownFeed() $cached_feed = get_transient($cache_key); if ($cached_feed !== false) { header('Content-Type: text/markdown; charset='.get_option('blog_charset')); + header('Vary: Accept', false); echo $cached_feed; exit; } @@ -204,6 +271,7 @@ function outputMarkdownFeed() set_transient($cache_key, $feed_content, apply_filters('post_content_to_markdown/feed_cache_duration', HOUR_IN_SECONDS)); header('Content-Type: text/markdown; charset='.get_option('blog_charset')); + header('Vary: Accept', false); echo $feed_content; exit; } @@ -214,6 +282,7 @@ function outputMarkdownFeed() function outputSinglePostCommentFeed($post) { header('Content-Type: text/markdown; charset='.get_option('blog_charset')); + header('Vary: Accept', false); echo '# '.strip_tags($post->post_title)."\n\n";