PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.4
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.4
4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 All 202 releases
betterdocs / includes / Core / MarkdownEndpoint.php

MarkdownEndpoint.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.4, at includes/Core/MarkdownEndpoint.php

431 lines 13.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPDeveloper\BetterDocs\Core;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit; // Exit if accessed directly
7 }
8
9 /**
10 * Serve docs as Markdown.
11 *
12 * AI crawlers and assistants consume plain Markdown far more reliably than
13 * themed HTML, and the AI Actions menu needs a stable public address it can hand
14 * to ChatGPT or Claude. Every doc is exposed through three permalink-agnostic
15 * mechanisms — no rewrite rules, so nothing to flush:
16 *
17 * 1. A `.md` suffix on any doc URL — /docs/my-doc.md
18 * 2. `Accept: text/markdown` negotiation — same URL, header only
19 * 3. An explicit `?format=md` query — /docs/my-doc/?format=md
20 *
21 * The `.md` suffix is stripped from REQUEST_URI on `init` (before WordPress
22 * routes the request) so the doc resolves normally; the Markdown is then emitted
23 * on `template_redirect` at priority 100 — after AiTrafficCollector (priority 1,
24 * so bot fetches are still counted) and after BetterDocs Pro's Content
25 * Restriction redirects (priorities 10 and 99).
26 *
27 * @since 4.8.0
28 */
29 class MarkdownEndpoint {
30 /** Set when the incoming request URL carried a `.md` suffix. */
31 protected $md_suffix = false;
32
33 /** Set once Markdown has actually been emitted for this request. */
34 protected $served = false;
35
36 /**
37 * @var Settings
38 */
39 protected $settings;
40
41 /**
42 * @var MarkdownRenderer
43 */
44 protected $renderer;
45
46 public function __construct( Settings $settings, MarkdownRenderer $renderer ) {
47 $this->settings = $settings;
48 $this->renderer = $renderer;
49
50 // Strip the `.md` suffix NOW rather than on another `init` callback: this
51 // class is constructed from inside Plugin::initialize(), which is itself the
52 // `init` priority-0 callback, so a hook added here would never fire — that
53 // bucket is already being iterated. Running synchronously still puts us ahead
54 // of WP::parse_request(), which is all the strip needs.
55 $this->maybe_strip_md_suffix();
56
57 add_action( 'template_redirect', [ $this, 'maybe_serve_markdown' ], 100 );
58
59 // A `.md` URL that we decline to serve must not fall through to a themed HTML
60 // page — that would be a 200 duplicate of the canonical doc.
61 add_action( 'template_redirect', [ $this, 'maybe_reject_md_suffix' ], 101 );
62
63 // Content negotiation without Vary lets a CDN cache the Markdown under the
64 // HTML URL's key (and the reverse). DONOTCACHEPAGE is a WordPress-side
65 // constant a CDN never sees, so the header has to be on the HTML response too.
66 add_filter( 'wp_headers', [ $this, 'vary_accept' ] );
67
68 // BetterDocs Pro shipped this endpoint before it moved into Free. Free wins by
69 // registration order, but an older Pro's enabled() is hardcoded true, so make
70 // the new setting govern it as well.
71 add_filter( 'betterdocs_md_endpoint_enabled', [ $this, 'filter_enabled' ], 5 );
72
73 // Stop occupying postmeta once the feature is switched off.
74 add_action( 'update_option_betterdocs_settings', [ $this, 'maybe_purge' ], 10, 2 );
75 }
76
77 /**
78 * Whether Markdown output is switched on for this site.
79 *
80 * @return bool
81 */
82 public function enabled() {
83 $enabled = (bool) $this->settings->get( 'enable_markdown_endpoint' );
84
85 /**
86 * Toggle the Markdown endpoint.
87 *
88 * Kept from BetterDocs Pro, where this endpoint originally shipped.
89 *
90 * @since 4.8.0
91 *
92 * @param bool $enabled
93 */
94 return (bool) apply_filters( 'betterdocs_md_endpoint_enabled', $enabled );
95 }
96
97 /**
98 * Make the setting govern an older BetterDocs Pro that still ships its own copy.
99 *
100 * @param bool $enabled
101 * @return bool
102 */
103 public function filter_enabled( $enabled ) {
104 return $enabled && (bool) $this->settings->get( 'enable_markdown_endpoint' );
105 }
106
107 /**
108 * The public Markdown address for a doc.
109 *
110 * Pretty permalinks get the Mintlify-style `.md` suffix. Plain permalinks
111 * (`?docs=slug`) and non-published posts — where wp_force_plain_post_permalink()
112 * makes get_permalink() return a query string — fall back to `?format=md`.
113 *
114 * @param \WP_Post|int|null $post
115 * @param string $context `endpoint` (YAML front matter, the public
116 * address) | `copy` (clipboard profile)
117 * @return string Empty string when the doc has no Markdown address.
118 */
119 public function url( $post = null, $context = 'endpoint' ) {
120 $post = get_post( $post );
121
122 if ( ! $post instanceof \WP_Post || 'docs' !== $post->post_type || ! $this->enabled() ) {
123 return '';
124 }
125
126 $permalink = get_permalink( $post );
127 if ( ! $permalink ) {
128 return '';
129 }
130
131 if ( get_option( 'permalink_structure' ) && 'publish' === $post->post_status ) {
132 // untrailingslashit() first: WordPress' default structure ends in "/", so
133 // naive concatenation would produce "/docs/my-doc/.md". The suffix goes on
134 // the path, ahead of any query string a permalink carries (WPML's
135 // language parameter: /docs/my-doc/?lang=fr → /docs/my-doc.md?lang=fr).
136 $parts = explode( '?', $permalink, 2 );
137 $url = untrailingslashit( $parts[0] ) . '.md' . ( isset( $parts[1] ) ? '?' . $parts[1] : '' );
138 } else {
139 $url = add_query_arg( 'format', 'md', $permalink );
140 }
141
142 if ( 'copy' === $context ) {
143 $url = add_query_arg( 'context', 'copy', $url );
144 }
145
146 /**
147 * Filter a doc's Markdown URL.
148 *
149 * @since 4.8.0
150 *
151 * @param string $url
152 * @param \WP_Post $post
153 * @param string $context
154 */
155 return apply_filters( 'betterdocs_markdown_url', $url, $post, $context );
156 }
157
158 /**
159 * Which output profile this request is asking for.
160 *
161 * @return string `endpoint` | `copy`
162 */
163 protected function requested_context() {
164 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public read-only GET.
165 $context = isset( $_GET['context'] ) ? sanitize_key( wp_unslash( $_GET['context'] ) ) : '';
166
167 return 'copy' === $context ? 'copy' : 'endpoint';
168 }
169
170 /**
171 * If the request path ends in `.md`, strip it before WordPress routes the
172 * request and remember that Markdown was asked for.
173 *
174 * Runs regardless of the setting so that a `.md` URL still resolves to
175 * something sensible (a redirect) when the feature is switched off, instead of
176 * hard 404ing links that used to work.
177 */
178 public function maybe_strip_md_suffix() {
179 if ( empty( $_SERVER['REQUEST_URI'] ) ) {
180 return;
181 }
182
183 // Front-end reads only — never touch admin or AJAX routing.
184 if ( is_admin() || wp_doing_ajax() ) {
185 return;
186 }
187 // HEAD as well as GET: crawlers and link checkers probe with HEAD, and a HEAD
188 // that 404s where the GET returns 200 makes the URL look broken to them.
189 if ( isset( $_SERVER['REQUEST_METHOD'] ) ) {
190 $method = strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) );
191 if ( 'GET' !== $method && 'HEAD' !== $method ) {
192 return;
193 }
194 }
195
196 /**
197 * Disable `.md` suffix handling entirely, leaving REQUEST_URI untouched.
198 *
199 * @since 4.8.0
200 *
201 * @param bool $strip
202 */
203 if ( ! apply_filters( 'betterdocs_markdown_strip_suffix', true ) ) {
204 return;
205 }
206
207 $uri = wp_unslash( $_SERVER['REQUEST_URI'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- rewritten below, never echoed.
208 $parts = explode( '?', $uri, 2 );
209 $path = $parts[0];
210 $query = isset( $parts[1] ) ? '?' . $parts[1] : '';
211
212 $trimmed = rtrim( $path, '/' );
213 if ( '.md' !== substr( $trimmed, -3 ) ) {
214 return;
215 }
216
217 // REST routes are not pages: leave `/wp-json/…/foo.md` to the REST server
218 // (and to any plugin routing its own `.md` endpoints there).
219 if ( false !== strpos( $trimmed, '/' . rest_get_url_prefix() . '/' ) ) {
220 return;
221 }
222
223 $this->md_suffix = true;
224
225 $new_path = substr( $trimmed, 0, -3 );
226 $new_path = ( '' === $new_path ? '/' : trailingslashit( $new_path ) );
227
228 $_SERVER['REQUEST_URI'] = $new_path . $query;
229 }
230
231 /**
232 * Does this request want Markdown?
233 *
234 * @return bool
235 */
236 protected function wants_markdown() {
237 if ( $this->md_suffix ) {
238 return true;
239 }
240 if ( isset( $_GET['format'] ) && 'md' === strtolower( sanitize_key( wp_unslash( $_GET['format'] ) ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public read-only GET.
241 return true;
242 }
243 if ( isset( $_SERVER['HTTP_ACCEPT'] ) ) {
244 $accept = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) );
245 if ( false !== strpos( $accept, 'text/markdown' ) || false !== strpos( $accept, 'text/x-markdown' ) ) {
246 return true;
247 }
248 }
249
250 return false;
251 }
252
253 /**
254 * Emit the current doc as Markdown and stop, when it was asked for.
255 */
256 public function maybe_serve_markdown() {
257 if ( ! $this->enabled() || ! $this->wants_markdown() ) {
258 return;
259 }
260 if ( ! is_singular( 'docs' ) || is_preview() ) {
261 return;
262 }
263
264 $post = get_queried_object();
265 if ( ! $post instanceof \WP_Post || 'docs' !== $post->post_type ) {
266 return;
267 }
268
269 // Password-protected, unpublished and Pro-restricted docs get an explicit
270 // refusal. Falling through to the themed HTML page (what BetterDocs Pro did)
271 // would put a full HTML document on the reader's clipboard.
272 if ( ! $this->renderer->can_read( $post ) ) {
273 $this->served = true;
274 $this->refuse();
275 }
276
277 $markdown = $this->renderer->render( $post, $this->requested_context() );
278 if ( '' === $markdown ) {
279 $this->served = true;
280 $this->refuse();
281 }
282
283 $this->served = true;
284
285 // Other plugins' output buffers would prepend their HTML to a text/markdown
286 // body, so drop them before we send anything.
287 while ( ob_get_level() > 0 ) {
288 ob_end_clean();
289 }
290
291 if ( ! defined( 'DONOTCACHEPAGE' ) ) {
292 define( 'DONOTCACHEPAGE', true );
293 }
294
295 if ( ! headers_sent() ) {
296 status_header( 200 );
297
298 /**
299 * Filter the Content-Type sent with Markdown output.
300 *
301 * @since 4.8.0
302 *
303 * @param string $content_type
304 */
305 header( 'Content-Type: ' . apply_filters( 'betterdocs_markdown_content_type', 'text/markdown; charset=utf-8' ) );
306
307 // Without this Chrome downloads the response instead of rendering it, so
308 // "View as Markdown" would silently become "Download as Markdown".
309 header( 'Content-Disposition: inline' );
310 header( 'X-Robots-Tag: noindex, nofollow', true );
311 header( 'X-Content-Type-Options: nosniff' );
312 header( 'X-Frame-Options: DENY' );
313 header( "Content-Security-Policy: default-src 'none'" );
314 header( 'Link: <' . esc_url_raw( get_permalink( $post ) ) . '>; rel="canonical"', false );
315 header( 'Last-Modified: ' . gmdate( 'D, d M Y H:i:s', (int) get_post_modified_time( 'U', true, $post ) ) . ' GMT' );
316 // A logged-in reader's copy (or a non-public doc) is theirs alone; only
317 // what a visitor would get may sit in a shared cache.
318 if ( is_user_logged_in() || 'publish' !== $post->post_status ) {
319 header( 'Cache-Control: private, no-store, max-age=0' );
320 } else {
321 header( 'Cache-Control: public, max-age=0, must-revalidate' );
322 }
323 header( 'Vary: Accept' );
324 // Browser-side LLM fetchers read this cross-origin.
325 header( 'Access-Control-Allow-Origin: *' );
326 }
327
328 echo $markdown; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- plain-text Markdown body.
329 exit;
330 }
331
332 /**
333 * A `.md` URL we did not serve must not render the themed HTML page: the suffix
334 * was already stripped, so the theme would happily return 200 for a duplicate of
335 * the canonical URL.
336 */
337 public function maybe_reject_md_suffix() {
338 if ( ! $this->md_suffix || $this->served ) {
339 return;
340 }
341
342 global $wp_query;
343
344 // Something earlier (e.g. Pro's content restriction) already answered 404:
345 // keep it, rather than redirecting and revealing that the doc exists.
346 if ( isset( $wp_query ) && $wp_query->is_404() ) {
347 return;
348 }
349
350 $queried = get_queried_object();
351
352 if ( $queried instanceof \WP_Post ) {
353 $permalink = get_permalink( $queried );
354 if ( $permalink ) {
355 // 302, not 301: it is answered this way only while Markdown is off
356 // (or for a page that has none), and browsers keep a 301 forever.
357 wp_safe_redirect( $permalink, 302 );
358 exit;
359 }
360 }
361
362 global $wp_query;
363 if ( isset( $wp_query ) ) {
364 $wp_query->set_404();
365 }
366 status_header( 404 );
367 nocache_headers();
368 }
369
370 /**
371 * Refuse to emit Markdown for a doc the requester may not read.
372 */
373 protected function refuse() {
374 while ( ob_get_level() > 0 ) {
375 ob_end_clean();
376 }
377
378 if ( ! headers_sent() ) {
379 status_header( 403 );
380 nocache_headers();
381 header( 'Content-Type: text/plain; charset=utf-8' );
382 header( 'X-Robots-Tag: noindex, nofollow', true );
383 }
384
385 echo esc_html__( 'This document is not available.', 'betterdocs' );
386 exit;
387 }
388
389 /**
390 * Tell caches that a doc's HTML response varies by Accept, since the same URL
391 * can return Markdown.
392 *
393 * @param array $headers
394 * @return array
395 */
396 public function vary_accept( $headers ) {
397 if ( ! $this->enabled() || ! is_array( $headers ) ) {
398 return $headers;
399 }
400
401 // Only docs negotiate, so only docs should vary. Making every response on the
402 // site vary by Accept would cut CDN hit rates for no benefit.
403 if ( ! is_singular( 'docs' ) ) {
404 return $headers;
405 }
406
407 if ( ! isset( $headers['Vary'] ) ) {
408 $headers['Vary'] = 'Accept';
409 } elseif ( false === stripos( $headers['Vary'], 'accept' ) ) {
410 $headers['Vary'] .= ', Accept';
411 }
412
413 return $headers;
414 }
415
416 /**
417 * Drop every cached Markdown blob when the endpoint is switched off.
418 *
419 * @param mixed $old_value
420 * @param mixed $value
421 */
422 public function maybe_purge( $old_value, $value ) {
423 $was_on = ! empty( $old_value['enable_markdown_endpoint'] );
424 $is_on = ! empty( $value['enable_markdown_endpoint'] );
425
426 if ( $was_on && ! $is_on ) {
427 $this->renderer->purge();
428 }
429 }
430 }
431