PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.0
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 3.5.0 3.5.1 3.5.2 All 199 releases
betterdocs / includes / Core / Request.php

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

1,682 lines 62.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace WPDeveloper\BetterDocs\Core;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8
9 use WPDeveloper\BetterDocs\Utils\Base;
10 use WPDeveloper\BetterDocs\Utils\Helper;
11
12 class Request extends Base {
13 /**
14 * Flag for already parsed or not
15 *
16 * Specially needed for those who don't update pro yet.
17 * @var boolean
18 */
19 protected static $already_parsed = false;
20
21 /**
22 * List of BetterDocs Perma Structure
23 * @var array
24 */
25 private $perma_structure = [];
26
27 /**
28 * List of BetterDocs Query Vars Agains Page Structure.
29 * @var array
30 */
31 private $query_vars = [];
32
33 /**
34 * List of Query Variables from $wp->query_vars.
35 * @var array
36 */
37 private $wp_query_vars = [];
38
39 /**
40 * Stores query vars from a request that was rejected as invalid (wrong KB/category slug).
41 * Used to block canonical redirects for those invalid URLs.
42 * @var array|null
43 */
44 private $invalid_request_query_vars = null;
45
46 /**
47 * Rewrite Class Reference of BetterDocs
48 * @var Rewrite
49 */
50 protected $rewrite;
51
52 /**
53 * Settings Class Reference of BetterDocs
54 * @var Settings
55 */
56 protected $settings;
57
58 public function __construct( Rewrite $rewrite, Settings $settings ) {
59 $this->rewrite = $rewrite;
60 $this->settings = $settings;
61 }
62
63 public function init() {
64 if ( is_admin() ) {
65 return;
66 }
67
68 add_action( 'template_redirect', [ $this, 'validate_request_path' ], 1 );
69
70 $this->perma_structure = [
71 'is_docs' => trim( $this->rewrite->get_base_slug(), '/' ),
72 'is_docs_feed' => trim( $this->rewrite->get_base_slug(), '/' ) . '/%feed%',
73 'is_docs_category' => trim( $this->settings->get( 'category_slug', 'docs-category' ), '/' ) . '/%doc_category%',
74 'is_docs_tag' => trim( $this->settings->get( 'tag_slug', 'docs-tag' ), '/' ) . '/%doc_tag%',
75 'is_single_docs' => trim( $this->settings->get( 'permalink_structure', 'docs' ), '/' ) . '/%name%',
76 'is_docs_author' => trim( $this->rewrite->get_base_slug(), '/' ) . '/authors/%author%'
77 ];
78
79 $this->query_vars = [
80 'is_docs' => ['post_type'],
81 'is_docs_feed' => ['doc_category'],
82 'is_docs_category' => ['doc_category'],
83 'is_docs_tag' => ['doc_tag'],
84 'is_single_docs' => ['name', 'docs', 'post_type'],
85 'is_docs_author' => ['post_type', 'author']
86 ];
87
88 add_action( 'parse_request', [ $this, 'parse' ] );
89
90 /**
91 * Hook into pre_get_posts to set up taxonomy queries for category archives
92 */
93 add_action( 'pre_get_posts', [ $this, 'setup_taxonomy_query' ], 1 );
94
95 /**
96 * Hook into pre_get_posts at priority 20 to enforce 404 for invalid KB/category slugs.
97 * This runs before WordPress resolves templates but after parse_request sets query vars.
98 */
99 add_action( 'pre_get_posts', [ $this, 'enforce_404_for_invalid_docs' ], 20 );
100
101 /**
102 * Hook into template_redirect to re-apply taxonomy query flags
103 * This runs after pre_get_posts to ensure the flags stick
104 */
105 add_action( 'template_redirect', [ $this, 'reapply_taxonomy_flags' ], 1 );
106
107 /**
108 * Hook into status_header to prevent 404 for valid taxonomy archives
109 */
110 add_filter( 'status_header', [ $this, 'prevent_404_status' ], 10, 2 );
111
112 /**
113 * Hook into wp to ensure tax_query is always initialized
114 * This prevents null reference errors from WPML and other plugins
115 */
116 add_action( 'wp', [ $this, 'ensure_tax_query_initialized' ], 1 );
117
118 /**
119 * This is for Backward compatibility if pro not updated.
120 */
121 add_action( 'parse_request', [ $this, 'backward_compability' ], 11 );
122
123 /**
124 * Make Compatible With Permalink Manager Plugin
125 */
126 add_filter( 'permalink_manager_detected_element_id', [ $this, 'provide_compatibility' ], 10, 3 );
127
128 /**
129 * Hook into redirect_canonical to prevent redirects for invalid category-post combinations
130 */
131 add_filter( 'redirect_canonical', [ $this, 'prevent_canonical_redirect_for_invalid_docs' ], 10, 2 );
132
133 /**
134 * Hook into redirect_guess_404_permalink to prevent WordPress from guessing a redirect
135 * when an invalid KB/category slug results in a 404.
136 */
137 add_filter( 'redirect_guess_404_permalink', [ $this, 'prevent_guess_404_redirect_for_invalid_docs' ], 10 );
138
139 /**
140 * Hook into WPML's redirect filter to prevent WPML from redirecting invalid docs URLs
141 * to the canonical URL. This is the actual source of the redirect when WPML is active.
142 */
143 add_filter( 'wpml_is_redirected', [ $this, 'prevent_wpml_redirect_for_invalid_docs' ], 10, 3 );
144
145 /**
146 * Final catch-all: hook into wp_redirect to block any redirect for invalid docs URLs.
147 * This fires for ALL WordPress redirects regardless of source.
148 */
149 add_filter( 'wp_redirect', [ $this, 'prevent_any_redirect_for_invalid_docs' ], 10, 2 );
150
151 /**
152 * Hook into template_redirect to validate category-post relationships
153 * Priority 0 to run before WordPress canonical redirect (priority 10)
154 */
155 add_action( 'template_redirect', [ $this, 'validate_single_docs_category_redirect' ], 0 );
156 }
157
158 public function provide_compatibility( $element_id, $uri_parts, $request_url ) {
159 if ( $request_url == $this->settings->get( 'docs_slug' ) ) {
160 $element_id = '';
161 }
162 return $element_id;
163 }
164
165 /**
166 * Enforce 404 for invalid docs KB/category URLs via pre_get_posts.
167 *
168 * This fires before WordPress determines the template, allowing us to mark
169 * the main query as a 404 when an invalid KB or category slug was detected
170 * during parse_request.
171 *
172 * @param WP_Query $query
173 */
174 public function enforce_404_for_invalid_docs( $query ) {
175 if ( ! $query->is_main_query() || is_admin() ) {
176 return;
177 }
178 if ( $this->invalid_request_query_vars !== null ) {
179 $query->set_404();
180 status_header( 404 );
181 nocache_headers();
182
183 // Kill ALL query vars that could cause WordPress to route to a doc/taxonomy template.
184 // Without clearing these, WordPress still tries to build a tax_query from
185 // doc_category/knowledge_base, selects the wrong template, and loads it
186 // with a null post — causing PHP warnings in post-template functions.
187 $query->set( 'name', '' );
188 $query->set( 'pagename', '' );
189 $query->set( 'p', -1 );
190 $query->set( 'docs', '' );
191 $query->set( 'doc_category', '' );
192 $query->set( 'doc_tag', '' );
193 $query->set( 'knowledge_base', '' );
194 $query->set( 'post_type', '' );
195
196 // Reset all routing flags — only is_404 should remain true.
197 $query->is_single = false;
198 $query->is_singular = false;
199 $query->is_archive = false;
200 $query->is_tax = false;
201 $query->is_home = false;
202 $query->is_404 = true;
203 }
204 }
205
206 /**
207 * Prevent canonical redirect for invalid docs category-post combinations
208 *
209 * @param string $redirect_url The redirect URL.
210 * @param string $requested_url The requested URL.
211 * @return string|false The redirect URL or false to prevent redirect.
212 */
213 public function prevent_canonical_redirect_for_invalid_docs( $redirect_url, $requested_url ) {
214 global $wp_query;
215
216 // IMPORTANT: By the time redirect_canonical fires, both $requested_url and $_SERVER['REQUEST_URI']
217 // have already had the invalid KB slug stripped (resulting in double slashes like /docs//base/post/).
218 // The only place we captured the original invalid slugs was during is_single_docs() at parse_request time.
219 // So we use the stored invalid_request_query_vars to detect and block invalid redirects.
220 if ( $this->invalid_request_query_vars !== null ) {
221 return false; // Block the redirect, show 404 instead
222 }
223
224 $actual_url = home_url( isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '' );
225
226 // Legacy check: if post_type=docs is already set in query vars, validate category
227 if ( isset( $wp_query->query_vars['post_type'] ) && $wp_query->query_vars['post_type'] === 'docs' &&
228 isset( $wp_query->query_vars['doc_category'] ) && isset( $wp_query->query_vars['name'] ) ) {
229
230 $doc_category = $wp_query->query_vars['doc_category'];
231 $post_name = $wp_query->query_vars['name'];
232
233 // Get the post
234 $post = get_page_by_path( $post_name, OBJECT, 'docs' );
235
236 if ( ! $post ) {
237 return false; // Post doesn't exist, show 404
238 }
239
240 // Get post's categories
241 $post_categories = wp_get_post_terms( $post->ID, 'doc_category' );
242
243 if ( empty( $post_categories ) || is_wp_error( $post_categories ) ) {
244 // Post has no categories - only allow if URL is 'uncategorized'
245 if ( $doc_category !== 'uncategorized' ) {
246 return false;
247 }
248 } else {
249 // Post has categories - check if it belongs to the category in URL
250 $category_slugs = wp_list_pluck( $post_categories, 'slug' );
251
252 // Handle hierarchical categories: check if any part of the path matches
253 $category_parts = explode('/', trim($doc_category, '/'));
254 $found_match = false;
255
256 foreach ( $category_parts as $cat_slug ) {
257 if ( in_array( $cat_slug, $category_slugs ) ) {
258 $found_match = true;
259 break;
260 }
261 }
262
263 if ( ! $found_match ) {
264 return false; // Post doesn't belong to this category, show 404
265 }
266 }
267 }
268
269 return $redirect_url;
270 }
271
272 /**
273 * Prevent redirect_guess_404_permalink for invalid docs KB/category URLs
274 *
275 * @param string|false $redirect_url The guessed redirect URL, or false.
276 * @return string|false
277 */
278 public function prevent_guess_404_redirect_for_invalid_docs( $redirect_url ) {
279 if ( $this->invalid_request_query_vars !== null ) {
280 return false; // Don't guess a redirect for invalid docs URLs
281 }
282 return $redirect_url;
283 }
284
285 /**
286 * Prevent WPML from redirecting invalid docs KB/category URLs to canonical URLs.
287 *
288 * WPML detects that the request URL doesn't match the post's canonical permalink
289 * and issues a 301 redirect. We must block this when the URL has an invalid KB slug.
290 *
291 * @param string|false $redirect The redirect URL or false.
292 * @param int $post_id The post ID.
293 * @param WP_Query $q The query object.
294 * @return string|false
295 */
296 public function prevent_wpml_redirect_for_invalid_docs( $redirect, $post_id, $q ) {
297 // If we already detected an invalid KB/category slug during parse_request, block the redirect
298 if ( $this->invalid_request_query_vars !== null ) {
299 return false;
300 }
301 return $redirect;
302 }
303
304 /**
305 * Catch-all to prevent ANY WordPress wp_redirect() call for invalid docs URLs.
306 *
307 * This fires for all wp_redirect() calls regardless of source (canonical, WPML,
308 * redirect_guess_404_permalink, etc.). Returning empty string cancels the redirect.
309 *
310 * @param string $location The redirect URL.
311 * @param int $status The HTTP status code.
312 * @return string The redirect URL or empty string to cancel.
313 */
314 public function prevent_any_redirect_for_invalid_docs( $location, $status ) {
315 if ( $this->invalid_request_query_vars !== null ) {
316 return ''; // Returning empty string cancels the redirect in wp_redirect()
317 }
318 return $location;
319 }
320
321 /**
322 * Validate single docs category relationship on template_redirect and force 404 if invalid
323 */
324 public function validate_single_docs_category_redirect() {
325 global $wp_query, $wp;
326
327 // Use stored invalid query vars from parse time (most reliable approach)
328 if ( $this->invalid_request_query_vars !== null ) {
329 $wp_query->set_404();
330 status_header( 404 );
331 nocache_headers();
332
333 // We must actually serve the 404 template — set_404() alone doesn't stop the current template.
334 // Hook into template_include to return the 404 template instead.
335 add_filter( 'template_include', function( $template ) {
336 $not_found = get_404_template();
337 return $not_found ? $not_found : $template;
338 }, 999 );
339 return;
340 }
341
342 // Legacy check: if post_type=docs is already set in query vars, validate category.
343 // `name` must be a non-empty string — WP populates it with '' for taxonomy archive
344 // requests (e.g. comma-separated multi-category URLs like /docs-category/a,b/),
345 // which `isset()` would treat as present and incorrectly trigger this single-doc branch.
346 if ( isset( $wp_query->query_vars['post_type'] ) && $wp_query->query_vars['post_type'] === 'docs' &&
347 isset( $wp_query->query_vars['doc_category'] ) && ! empty( $wp_query->query_vars['name'] ) ) {
348
349 $doc_category = $wp_query->query_vars['doc_category'];
350 $post_name = $wp_query->query_vars['name'];
351
352 // Get the post
353 $post = get_page_by_path( $post_name, OBJECT, 'docs' );
354
355 if ( ! $post ) {
356 $wp_query->set_404();
357 status_header( 404 );
358 nocache_headers();
359 return;
360 }
361
362 // Get post's categories
363 $post_categories = wp_get_post_terms( $post->ID, 'doc_category' );
364
365 if ( empty( $post_categories ) || is_wp_error( $post_categories ) ) {
366 // Post has no categories - only allow if URL is 'uncategorized'
367 if ( $doc_category !== 'uncategorized' ) {
368 $wp_query->set_404();
369 status_header( 404 );
370 nocache_headers();
371 return;
372 }
373 } else {
374 // Post has categories - check if it belongs to the category in URL
375 $category_slugs = wp_list_pluck( $post_categories, 'slug' );
376
377 // Handle hierarchical categories: check if any part of the path matches
378 $category_parts = explode('/', trim($doc_category, '/'));
379 $found_match = false;
380
381 foreach ( $category_parts as $cat_slug ) {
382 if ( in_array( $cat_slug, $category_slugs ) ) {
383 $found_match = true;
384 break;
385 }
386 }
387
388 if ( ! $found_match ) {
389 $wp_query->set_404();
390 status_header( 404 );
391 nocache_headers();
392 return;
393 }
394 }
395 }
396 }
397
398 /**
399 * Check if a URL matches a BetterDocs single docs permalink structure
400 * but has invalid KB/category slugs that don't match the post.
401 *
402 * @param string $url The URL to check.
403 * @return bool True if the URL is a BetterDocs docs URL with invalid slugs.
404 */
405 protected function is_invalid_docs_url( $url ) {
406 // Get the path from the URL
407 $path = trim( (string) wp_parse_url( $url, PHP_URL_PATH ), '/' );
408
409 // Check each permalink structure
410 foreach ( $this->perma_structure as $_type => $structure ) {
411 if ( $_type !== 'is_single_docs' ) {
412 continue;
413 }
414
415 $_perma_vars = $this->is_perma_valid_for( $structure, $path );
416 if ( ! $_perma_vars ) {
417 continue;
418 }
419
420 // URL matches the single docs structure - now validate the slugs
421 $name = isset( $_perma_vars['docs'] ) ? $_perma_vars['docs'] : ( isset( $_perma_vars['name'] ) ? $_perma_vars['name'] : '' );
422 if ( empty( $name ) ) {
423 continue;
424 }
425
426 // Check if the post exists
427 $post = get_page_by_path( $name, OBJECT, 'docs' );
428 if ( ! $post ) {
429 return false; // Post doesn't exist at all - not our concern
430 }
431
432 // Validate knowledge_base slug if present
433 if ( isset( $_perma_vars['knowledge_base'] ) && ! empty( $_perma_vars['knowledge_base'] ) ) {
434 $post_kbs = wp_get_post_terms( $post->ID, 'knowledge_base', [ 'fields' => 'slugs' ] );
435 if ( ! is_wp_error( $post_kbs ) && ! in_array( $_perma_vars['knowledge_base'], $post_kbs ) ) {
436 return true; // Invalid KB slug
437 }
438 }
439
440 // Validate doc_category slug if present
441 if ( isset( $_perma_vars['doc_category'] ) && ! empty( $_perma_vars['doc_category'] ) ) {
442 $category_parts = explode( '/', trim( $_perma_vars['doc_category'], '/' ) );
443 $post_categories = wp_get_post_terms( $post->ID, 'doc_category', [ 'fields' => 'slugs' ] );
444
445 if ( ! is_wp_error( $post_categories ) ) {
446 $found = false;
447 foreach ( $category_parts as $cat_slug ) {
448 if ( in_array( $cat_slug, $post_categories ) ) {
449 $found = true;
450 break;
451 }
452 }
453 if ( ! $found ) {
454 return true; // Invalid category slug
455 }
456 }
457 }
458 }
459
460 return false;
461 }
462
463 /**
464 * Set up taxonomy query for category archives
465 * This ensures WordPress recognizes requests with doc_category or knowledge_base as taxonomy archives
466 *
467 * @param \WP_Query $query The WordPress query object
468 */
469 public function setup_taxonomy_query( $query ) {
470 if ( is_admin() || ! $query->is_main_query() ) {
471 return;
472 }
473 if ( $this->invalid_request_query_vars !== null ) {
474 return;
475 }
476
477 // Check if this is a doc_category request
478 if ( isset( $query->query_vars['doc_category'] ) && ! empty( $query->query_vars['doc_category'] ) ) {
479 // If this is already identified as singular, don't override it
480 if ( $query->is_singular() || $query->is_singular ) {
481
482 // Ensure it's not marked as 404
483 $query->is_404 = false;
484 return;
485 }
486
487 // Check if we have a post ID set (p query var)
488 if ( isset( $query->query_vars['p'] ) && $query->query_vars['p'] > 0 ) {
489 // Security check: if this is a private post and user can't read private docs, show 404
490 $post = get_post( $query->query_vars['p'] );
491 if ( $post && $post->post_status === 'private' && ! current_user_can( 'read_private_docs' ) ) {
492
493 $query->is_404 = true;
494 $query->is_single = false;
495 $query->is_singular = false;
496 return;
497 }
498
499 // Explicitly set this as a single post, not an archive or 404
500 $query->is_single = true;
501 $query->is_singular = true;
502 $query->is_404 = false;
503 $query->is_archive = false;
504 $query->is_tax = false;
505 return;
506 }
507
508 // Check if we have 'docs' query var (alternative to 'name')
509 if ( isset( $query->query_vars['docs'] ) && ! empty( $query->query_vars['docs'] ) ) {
510 // Explicitly set this as a single post
511 $query->is_single = true;
512 $query->is_singular = true;
513 $query->is_404 = false;
514 $query->is_archive = false;
515 $query->is_tax = false;
516 return;
517 }
518
519 // If 'name' is set, check if a post with that name exists
520 // This prevents private docs from being incorrectly treated as category archives
521 if ( isset( $query->query_vars['name'] ) && ! empty( $query->query_vars['name'] ) ) {
522 $post_exists = get_page_by_path( $query->query_vars['name'], OBJECT, 'docs' );
523
524 if ( $post_exists ) {
525 // A post exists - this is a single doc request, not a category archive
526 // Don't set taxonomy flags
527 return;
528 }
529 }
530
531 // Only set taxonomy flags if none of the above conditions are met (pure category archive)
532 if ( ( ! isset( $query->query_vars['name'] ) || empty( $query->query_vars['name'] ) ) &&
533 ( ! isset( $query->query_vars['p'] ) || $query->query_vars['p'] <= 0 ) &&
534 ( ! isset( $query->query_vars['docs'] ) || empty( $query->query_vars['docs'] ) ) ) {
535
536 // Set this as a taxonomy query
537 $query->is_tax = true;
538 $query->is_archive = true;
539 $query->is_home = false;
540 $query->is_404 = false; // Important: reset 404 flag
541
542 // WordPress/Polylang may store non-Latin slugs URL-encoded (%e0%a6...) while the
543 // query var arrives decoded (বেটারডক্স). Try both forms so the term lookup succeeds.
544 $term = $this->get_term_by_slug_or_encoded( $query->query_vars['doc_category'], 'doc_category' );
545 if ( $term ) {
546 $query->queried_object = $term;
547 $query->queried_object_id = $term->term_id;
548
549 // Set up tax_query using proper WP_Tax_Query class
550 if ( ! isset( $query->tax_query ) || ! is_a( $query->tax_query, 'WP_Tax_Query' ) ) {
551 $tax_query_args = [
552 [
553 'taxonomy' => 'doc_category',
554 'field' => 'slug',
555 'terms' => [ $term->slug ]
556 ]
557 ];
558 $query->tax_query = new \WP_Tax_Query( $tax_query_args );
559 $query->tax_query->queried_terms = [
560 'doc_category' => [
561 'terms' => [ $term->slug ],
562 'field' => 'slug'
563 ]
564 ];
565 }
566 }
567 }
568 }
569
570 }
571
572 /**
573 * Validate that the requested path matches the expected documentation root.
574 * This prevents URLs with invalid prefixes (e.g., /invalid/docs/...) from showing archive templates.
575 */
576 public function validate_request_path() {
577 if ( is_admin() || ! is_main_query() ) {
578 return;
579 }
580
581 global $wp;
582 // $wp->request contains the path relative to site root, without query string
583 $request_path = isset( $wp->request ) ? urldecode( $wp->request ) : '';
584
585 // Normalize request path: remove index.php/ and leading/trailing slashes
586 $request_path = trim( preg_replace( '#^index\.php(/|$)#', '', $request_path ), '/' );
587
588 // If the request path is empty, this is a query-string-only request (e.g. /?post_type=docs).
589 // There is no URL prefix to validate in that case, so bail early.
590 if ( $request_path === '' ) {
591 return;
592 }
593
594 // Normalize base slug
595 $docs_slug = $this->rewrite->get_base_slug();
596
597 // If user is using a custom page as root, use that page's path
598 if ( ! $this->settings->get( 'builtin_doc_page', true ) ) {
599 $docs_page_id = $this->settings->get( 'docs_page', 0 );
600 if ( $docs_page_id ) {
601 $page_path = get_page_uri( $docs_page_id );
602 if ( $page_path ) {
603 $docs_slug = $page_path;
604 }
605 }
606 }
607
608 $docs_slug = trim( $docs_slug, '/' );
609
610 if ( empty( $docs_slug ) ) {
611 return;
612 }
613
614 // Check if this is a query we should validate
615 $is_docs_query = is_singular( 'docs' ) || is_post_type_archive( 'docs' ) || is_tax( [ 'doc_category', 'knowledge_base', 'doc_tag' ] );
616 $looks_like_docs_url = strpos( $request_path, $docs_slug ) !== false;
617
618 // We validate if it's explicitly a docs query, OR if it looks like a docs URL but fell back to home/archive
619 if ( ! $is_docs_query && ! ( $looks_like_docs_url && is_home() ) ) {
620 return;
621 }
622
623 // If WordPress already correctly resolved this as a single docs post, trust that resolution.
624 // The post was found and is valid — no need to validate the URL prefix at all.
625 // Path validation is only meaningful for archive/tax pages whose URL leaked past the docs slug.
626 if ( is_singular( 'docs' ) ) {
627 return;
628 }
629
630 // Check if request path strictly starts with docs slug, category slug, or tag slug
631 // Using # as delimiter, need to preg_quote
632 $valid_slugs = [
633 $docs_slug,
634 trim( $this->settings->get( 'category_slug', 'docs-category' ), '/' ),
635 trim( $this->settings->get( 'tag_slug', 'docs-tag' ), '/' )
636 ];
637
638 // WPML/Polylang translate the registered rewrite slug per active language,
639 // while $docs_slug from settings is always the default-language slug. Include
640 // the post type's / taxonomies' currently-registered rewrite slugs so that
641 // translated archive URLs (e.g. /en/support/ when settings.docs_slug is
642 // "soporte") are accepted instead of being 404'd.
643 $docs_pt = get_post_type_object( 'docs' );
644 if ( $docs_pt && ! empty( $docs_pt->rewrite['slug'] ) ) {
645 $valid_slugs[] = trim( $docs_pt->rewrite['slug'], '/' );
646 }
647 foreach ( [ 'doc_category', 'doc_tag', 'knowledge_base' ] as $tax ) {
648 $tax_obj = get_taxonomy( $tax );
649 if ( $tax_obj && ! empty( $tax_obj->rewrite['slug'] ) ) {
650 $valid_slugs[] = trim( $tax_obj->rewrite['slug'], '/' );
651 }
652 }
653
654 // Belt-and-suspenders for WPML's slug-translation feature, which may store
655 // the translated slug separately from the post type's rewrite['slug'].
656 if ( has_filter( 'wpml_get_translated_slug' ) ) {
657 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML-owned filter; name must be used verbatim.
658 $wpml_slug = apply_filters( 'wpml_get_translated_slug', $docs_slug, 'docs' );
659 if ( is_string( $wpml_slug ) && $wpml_slug !== '' ) {
660 $valid_slugs[] = trim( $wpml_slug, '/' );
661 }
662 }
663
664 $valid_prefixes = array_unique( array_filter( $valid_slugs ) );
665 $valid_prefixes = array_map( function ( $slug ) { return preg_quote( $slug, '#' ); }, $valid_prefixes );
666
667 // Allow optional language prefixes (e.g. /en/, /pt-br/) for WPML/Polylang/TranslatePress compatibility
668 $lang_pattern = '(?:[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,4})?/)?';
669
670 $prefix_pattern = '#^' . $lang_pattern . '(' . implode( '|', $valid_prefixes ) . ')(/|$)#';
671
672 if ( ! preg_match( $prefix_pattern, $request_path ) ) {
673 global $wp_query;
674 $wp_query->set_404();
675 status_header( 404 );
676 nocache_headers();
677 }
678 }
679
680 /**
681 * Re-apply taxonomy flags on template_redirect
682 * This ensures the flags stick even if WordPress or other plugins reset them
683 */
684 public function reapply_taxonomy_flags() {
685 global $wp_query;
686
687 // If we found invalid query vars, do not mess with the query flags.
688 if ( $this->invalid_request_query_vars !== null ) {
689 return;
690 }
691
692 // Check if we have doc_category or knowledge_base in query vars
693 if ( isset( $wp_query->query_vars['doc_category'] ) && ! empty( $wp_query->query_vars['doc_category'] ) ) {
694 // If this is already identified as singular, don't override it
695 if ( $wp_query->is_singular() || $wp_query->is_singular ) {
696
697 // Ensure it's not marked as 404
698 $wp_query->is_404 = false;
699 return;
700 }
701
702 // Check if we have a post ID set (p query var)
703 if ( isset( $wp_query->query_vars['p'] ) && $wp_query->query_vars['p'] > 0 ) {
704
705
706 // Security check: if this is a private post and user can't read private docs, show 404
707 $post = get_post( $wp_query->query_vars['p'] );
708 if ( $post && $post->post_status === 'private' && ! current_user_can( 'read_private_docs' ) ) {
709
710 $wp_query->is_404 = true;
711 $wp_query->is_single = false;
712 $wp_query->is_singular = false;
713 return;
714 }
715
716 // Explicitly set this as a single post, not an archive or 404
717 $wp_query->is_single = true;
718 $wp_query->is_singular = true;
719 $wp_query->is_404 = false;
720 $wp_query->is_archive = false;
721 $wp_query->is_tax = false;
722 return;
723 }
724
725 // Check if we have 'docs' query var (alternative to 'name')
726 if ( isset( $wp_query->query_vars['docs'] ) && ! empty( $wp_query->query_vars['docs'] ) ) {
727
728 // Explicitly set this as a single post
729 $wp_query->is_single = true;
730 $wp_query->is_singular = true;
731 $wp_query->is_404 = false;
732 $wp_query->is_archive = false;
733 $wp_query->is_tax = false;
734 return;
735 }
736
737 // If 'name' is set, check if a post with that name exists
738 // This prevents posts from being incorrectly treated as category archives
739 // (important when post slug == category slug, e.g. docs/old/new/new)
740 if ( isset( $wp_query->query_vars['name'] ) && ! empty( $wp_query->query_vars['name'] ) ) {
741 $post_exists = get_page_by_path( $wp_query->query_vars['name'], OBJECT, 'docs' );
742
743 if ( $post_exists ) {
744 // A post exists - explicitly mark as single post and clear any taxonomy flags.
745 // Without this, WP may leave is_tax=true (set during parse_request because
746 // doc_category is also present), causing redirect_canonical to redirect
747 // the correct single-post URL to the category archive URL.
748 $wp_query->is_single = true;
749 $wp_query->is_singular = true;
750 $wp_query->is_404 = false;
751 $wp_query->is_archive = false;
752 $wp_query->is_tax = false;
753 $wp_query->queried_object = $post_exists;
754 $wp_query->queried_object_id = $post_exists->ID;
755 return;
756 }
757 }
758
759 // Only set taxonomy flags if none of the above conditions are met (pure category archive)
760 if ( ( ! isset( $wp_query->query_vars['name'] ) || empty( $wp_query->query_vars['name'] ) ) &&
761 ( ! isset( $wp_query->query_vars['p'] ) || $wp_query->query_vars['p'] <= 0 ) &&
762 ( ! isset( $wp_query->query_vars['docs'] ) || empty( $wp_query->query_vars['docs'] ) ) ) {
763
764 // Ensure the queried object is set or fetch the term
765 $term = null;
766 if ( isset( $wp_query->queried_object ) && $wp_query->queried_object ) {
767 $term = $wp_query->queried_object;
768 } else {
769 // WordPress/Polylang may store non-Latin slugs URL-encoded; try both forms.
770 $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['doc_category'], 'doc_category' );
771 }
772
773 // Only if the term effectively exists, we set the flags
774 if ( $term && ! is_wp_error( $term ) ) {
775 // Also validate knowledge_base if present
776 if ( isset( $wp_query->query_vars['knowledge_base'] ) && ! empty( $wp_query->query_vars['knowledge_base'] ) ) {
777 if ( ! $this->get_term_by_slug_or_encoded( $wp_query->query_vars['knowledge_base'], 'knowledge_base' ) ) {
778 return;
779 }
780 }
781
782 // Re-apply the taxonomy flags
783 $wp_query->is_tax = true;
784 $wp_query->is_archive = true;
785 $wp_query->is_home = false;
786 $wp_query->is_404 = false;
787
788 if ( ! isset( $wp_query->queried_object ) || ! $wp_query->queried_object ) {
789 $wp_query->queried_object = $term;
790 $wp_query->queried_object_id = $term->term_id;
791
792 // Set up tax_query using proper WP_Tax_Query class
793 if ( ! isset( $wp_query->tax_query ) || ! is_a( $wp_query->tax_query, 'WP_Tax_Query' ) ) {
794 $tax_query_args = [
795 [
796 'taxonomy' => 'doc_category',
797 'field' => 'slug',
798 'terms' => [ $term->slug ]
799 ]
800 ];
801 $wp_query->tax_query = new \WP_Tax_Query( $tax_query_args );
802 $wp_query->tax_query->queried_terms = [
803 'doc_category' => [
804 'terms' => [ $term->slug ],
805 'field' => 'slug'
806 ]
807 ];
808 }
809 }
810 }
811 }
812 }
813
814 }
815
816 /**
817 * Debug template redirect to see the query state
818 */
819 /**
820 * Prevent 404 status for valid taxonomy archives
821 *
822 * @param string $status_header The HTTP status header
823 * @param int $code The HTTP status code
824 * @return string The modified status header
825 */
826 public function prevent_404_status( $status_header, $code ) {
827 global $wp_query;
828
829 // If we've explicitly marked this request as invalid (malformed KB/category slug), respect the 404!
830 if ( $this->invalid_request_query_vars !== null ) {
831 return $status_header;
832 }
833
834 // If a 404 is being sent but the queried object is a valid single docs post,
835 // override with 200. This guards against false 404s on single docs pages.
836 if ( $code == 404 &&
837 isset( $wp_query->queried_object ) &&
838 $wp_query->queried_object instanceof \WP_Post &&
839 $wp_query->queried_object->post_type === 'docs' &&
840 in_array( $wp_query->queried_object->post_status, [ 'publish', 'private' ], true )
841 ) {
842 // Only allow if the current user can actually read this post
843 if ( 'publish' === $wp_query->queried_object->post_status ||
844 current_user_can( 'read_private_posts', $wp_query->queried_object->ID ) ) {
845 return 'HTTP/1.1 200 OK';
846 }
847 }
848
849 // If this is a 404 but we have doc_category or doc_tag query vars, change it to 200
850 // We check the query vars instead of is_tax because the flags get reset by WordPress
851 if ( $code == 404 && (
852 (isset($wp_query->query_vars['doc_category']) && ! empty($wp_query->query_vars['doc_category'])) ||
853 (isset($wp_query->query_vars['doc_tag']) && ! empty($wp_query->query_vars['doc_tag']))
854 ) ) {
855 // Validate existence before forcing 200
856 // Use encoded fallback so Bengali/Arabic/CJK slugs are found correctly.
857 if ( isset($wp_query->query_vars['doc_category']) && ! empty($wp_query->query_vars['doc_category']) ) {
858 $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['doc_category'], 'doc_category' );
859 if ( ! $term || is_wp_error( $term ) ) {
860 return $status_header;
861 }
862 }
863 if ( isset($wp_query->query_vars['doc_tag']) && ! empty($wp_query->query_vars['doc_tag']) ) {
864 $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['doc_tag'], 'doc_tag' );
865 if ( ! $term || is_wp_error( $term ) ) {
866 return $status_header;
867 }
868 }
869 if ( isset($wp_query->query_vars['knowledge_base']) && ! empty($wp_query->query_vars['knowledge_base']) ) {
870 $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['knowledge_base'], 'knowledge_base' );
871 if ( ! $term || is_wp_error( $term ) ) {
872 return $status_header;
873 }
874 }
875
876 return 'HTTP/1.1 200 OK';
877 }
878
879 return $status_header;
880 }
881
882 /**
883 * Ensure tax_query is always initialized as an object
884 * This prevents null reference errors from WPML and other plugins
885 * Only applies to BetterDocs post type and taxonomies
886 */
887 public function ensure_tax_query_initialized() {
888 global $wp_query;
889
890 // Only apply to BetterDocs-related queries
891 $is_betterdocs_query = false;
892
893 // Check if this is a docs post type query
894 if ( isset( $wp_query->query_vars['post_type'] ) && $wp_query->query_vars['post_type'] === 'docs' ) {
895 $is_betterdocs_query = true;
896 }
897
898 // Check if this is a BetterDocs taxonomy query
899 if ( isset( $wp_query->query_vars['doc_category'] ) && ! empty( $wp_query->query_vars['doc_category'] ) ) {
900 $is_betterdocs_query = true;
901 }
902
903 if ( isset( $wp_query->query_vars['doc_tag'] ) && ! empty( $wp_query->query_vars['doc_tag'] ) ) {
904 $is_betterdocs_query = true;
905 }
906
907 if ( isset( $wp_query->query_vars['knowledge_base'] ) && ! empty( $wp_query->query_vars['knowledge_base'] ) ) {
908 $is_betterdocs_query = true;
909 }
910
911 // Check if queried object is a BetterDocs taxonomy term
912 if ( isset( $wp_query->queried_object ) && isset( $wp_query->queried_object->taxonomy ) ) {
913 if ( in_array( $wp_query->queried_object->taxonomy, [ 'doc_category', 'doc_tag', 'knowledge_base' ] ) ) {
914 $is_betterdocs_query = true;
915 }
916 }
917
918 // Only proceed if this is a BetterDocs-related query
919 if ( ! $is_betterdocs_query ) {
920 return;
921 }
922
923 // Only initialize if it's not already a proper WP_Tax_Query instance
924 if ( ! isset( $wp_query->tax_query ) || ! is_a( $wp_query->tax_query, 'WP_Tax_Query' ) ) {
925 // Create a proper WP_Tax_Query instance with empty queries
926 $wp_query->tax_query = new \WP_Tax_Query( [] );
927 $wp_query->tax_query->queried_terms = [];
928 }
929
930 // For WPML compatibility: if queried_object is null, set it to an empty object
931 // but only if we're actually on a taxonomy page (is_tax is true)
932 if ( ! isset( $wp_query->queried_object ) && $wp_query->is_tax ) {
933 // Create a minimal WP_Term-like object to prevent errors
934 $wp_query->queried_object = new \stdClass();
935 $wp_query->queried_object->term_id = 0;
936 $wp_query->queried_object->name = '';
937 $wp_query->queried_object->slug = '';
938 $wp_query->queried_object->term_group = 0;
939 $wp_query->queried_object->term_taxonomy_id = 0;
940 $wp_query->queried_object->taxonomy = 'doc_category';
941 $wp_query->queried_object->description = '';
942 $wp_query->queried_object->parent = 0;
943 $wp_query->queried_object->count = 0;
944 $wp_query->queried_object->filter = 'raw';
945 }
946 }
947
948 protected function is_docs( &$query_vars ) {
949 if ( ! $this->settings->get( 'builtin_doc_page', true ) ) {
950 $query_vars['post_type'] = 'page';
951 $query_vars['name'] = trim( $this->rewrite->get_base_slug(), '/' );
952 }
953
954 return $query_vars;
955 }
956
957 public function is_docs_feed( $query_vars ) {
958 global $wp_rewrite;
959 return isset( $query_vars['feed'] ) && in_array( $query_vars['feed'], $wp_rewrite->feeds );
960 }
961
962 public function is_docs_author( $query_vars ) {
963 return isset( $query_vars['author'] ) ? true : false;
964 }
965
966 protected function is_single_docs( $query_vars ) {
967 // Check for both 'name' and 'docs' query variables
968 if ( ! isset( $query_vars['name'] ) && ! isset( $query_vars['docs'] ) ) {
969 return false;
970 }
971
972 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- frontend single-doc URL resolution; queries vary per request and run on `parse_request`, before object cache is reliable.
973 global $wpdb;
974 $name = isset( $query_vars['docs'] ) ? $query_vars['docs'] : $query_vars['name'];
975
976
977 // If doc_category is specified in the URL, validate that the post belongs to that category
978 if ( isset( $query_vars['doc_category'] ) ) {
979 $doc_category = $query_vars['doc_category'];
980
981
982 // Handle hierarchical category slugs (e.g., parent/child/grandchild)
983 $category_parts = explode('/', trim($doc_category, '/'));
984 $target_category_slug = end($category_parts); // Get the last part as the target category
985
986
987 // First, check if the post exists.
988 // When MKB is active, multiple translated posts share the same slug — one per KB.
989 // We MUST join the KB taxonomy so we select the post for the correct language/KB.
990 // Polylang/multilingual plugins store post_name URL-encoded; try the encoded form first.
991 $_encoded_name = rawurlencode( $name );
992
993 if ( isset( $query_vars['knowledge_base'] ) && ! empty( $query_vars['knowledge_base'] ) ) {
994 // KB-aware lookup: only select the post that is assigned to this KB.
995 // Also join doc_category when present so that, on Polylang/WPML sites
996 // where multiple translated posts share both the same post_name and
997 // the same KB term (e.g. all language variants assigned to the
998 // "advice" KB), we land on the translation whose doc_category matches
999 // the URL — not the one the DB happens to return first.
1000 $_kb_slug = $query_vars['knowledge_base'];
1001 $_kb_slug_enc = strtolower( rawurlencode( $_kb_slug ) );
1002
1003 $_cat_target = $target_category_slug;
1004 $_cat_target_enc = strtolower( rawurlencode( $_cat_target ) );
1005
1006 $_post_id = (int) $wpdb->get_var(
1007 $wpdb->prepare(
1008 "SELECT p.ID FROM {$wpdb->posts} p
1009 INNER JOIN {$wpdb->term_relationships} tr_kb ON tr_kb.object_id = p.ID
1010 INNER JOIN {$wpdb->term_taxonomy} tt_kb ON tt_kb.term_taxonomy_id = tr_kb.term_taxonomy_id AND tt_kb.taxonomy = 'knowledge_base'
1011 INNER JOIN {$wpdb->terms} t_kb ON t_kb.term_id = tt_kb.term_id
1012 INNER JOIN {$wpdb->term_relationships} tr_cat ON tr_cat.object_id = p.ID
1013 INNER JOIN {$wpdb->term_taxonomy} tt_cat ON tt_cat.term_taxonomy_id = tr_cat.term_taxonomy_id AND tt_cat.taxonomy = 'doc_category'
1014 INNER JOIN {$wpdb->terms} t_cat ON t_cat.term_id = tt_cat.term_id
1015 WHERE p.post_name = %s AND p.post_type = 'docs'
1016 AND t_kb.slug IN (%s, %s)
1017 AND t_cat.slug IN (%s, %s)
1018 LIMIT 1",
1019 esc_sql( $_encoded_name ),
1020 esc_sql( $_kb_slug ),
1021 esc_sql( $_kb_slug_enc ),
1022 esc_sql( $_cat_target_enc ),
1023 esc_sql( $_cat_target )
1024 )
1025 );
1026 // Fallback: post_name stored as decoded Unicode
1027 if ( ! $_post_id && $_encoded_name !== $name ) {
1028 $_post_id = (int) $wpdb->get_var(
1029 $wpdb->prepare(
1030 "SELECT p.ID FROM {$wpdb->posts} p
1031 INNER JOIN {$wpdb->term_relationships} tr_kb ON tr_kb.object_id = p.ID
1032 INNER JOIN {$wpdb->term_taxonomy} tt_kb ON tt_kb.term_taxonomy_id = tr_kb.term_taxonomy_id AND tt_kb.taxonomy = 'knowledge_base'
1033 INNER JOIN {$wpdb->terms} t_kb ON t_kb.term_id = tt_kb.term_id
1034 INNER JOIN {$wpdb->term_relationships} tr_cat ON tr_cat.object_id = p.ID
1035 INNER JOIN {$wpdb->term_taxonomy} tt_cat ON tt_cat.term_taxonomy_id = tr_cat.term_taxonomy_id AND tt_cat.taxonomy = 'doc_category'
1036 INNER JOIN {$wpdb->terms} t_cat ON t_cat.term_id = tt_cat.term_id
1037 WHERE p.post_name = %s AND p.post_type = 'docs'
1038 AND t_kb.slug IN (%s, %s)
1039 AND t_cat.slug IN (%s, %s)
1040 LIMIT 1",
1041 esc_sql( $name ),
1042 esc_sql( $_kb_slug ),
1043 esc_sql( $_kb_slug_enc ),
1044 esc_sql( $_cat_target_enc ),
1045 esc_sql( $_cat_target )
1046 )
1047 );
1048 }
1049
1050 // Fallback to the KB-only lookup (no category filter) when nothing
1051 // matched both KB + category. Keeps single-language behaviour intact
1052 // and lets the category-validation block below handle any genuine
1053 // mismatch by setting invalid_request_query_vars.
1054 if ( ! $_post_id ) {
1055 $_post_id = (int) $wpdb->get_var(
1056 $wpdb->prepare(
1057 "SELECT p.ID FROM {$wpdb->posts} p
1058 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1059 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'knowledge_base'
1060 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1061 WHERE p.post_name = %s AND p.post_type = 'docs'
1062 AND t.slug IN (%s, %s)
1063 LIMIT 1",
1064 esc_sql( $_encoded_name ),
1065 esc_sql( $_kb_slug ),
1066 esc_sql( $_kb_slug_enc )
1067 )
1068 );
1069 if ( ! $_post_id && $_encoded_name !== $name ) {
1070 $_post_id = (int) $wpdb->get_var(
1071 $wpdb->prepare(
1072 "SELECT p.ID FROM {$wpdb->posts} p
1073 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1074 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'knowledge_base'
1075 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1076 WHERE p.post_name = %s AND p.post_type = 'docs'
1077 AND t.slug IN (%s, %s)
1078 LIMIT 1",
1079 esc_sql( $name ),
1080 esc_sql( $_kb_slug ),
1081 esc_sql( $_kb_slug_enc )
1082 )
1083 );
1084 }
1085 }
1086 } else {
1087 // No KB in URL — disambiguate via doc_category. WPML/Polylang assign each
1088 // translated post the same post_name (e.g. "spacious-family-home..."),
1089 // so a plain `WHERE post_name = ... LIMIT 1` returns whichever language
1090 // the DB serves first. When that pick does not belong to the category in
1091 // the URL, the validation below falsely fires a 404 even though the
1092 // correctly-translated post exists. Join doc_category so we land on the
1093 // translation that actually owns the requested category.
1094 $_cat_target = $target_category_slug;
1095 $_cat_target_enc = strtolower( rawurlencode( $_cat_target ) );
1096 $_post_id = (int) $wpdb->get_var(
1097 $wpdb->prepare(
1098 "SELECT p.ID FROM {$wpdb->posts} p
1099 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1100 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'doc_category'
1101 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1102 WHERE p.post_name = %s AND p.post_type = 'docs'
1103 AND t.slug IN (%s, %s)
1104 LIMIT 1",
1105 esc_sql( $_encoded_name ),
1106 esc_sql( $_cat_target_enc ),
1107 esc_sql( $_cat_target )
1108 )
1109 );
1110 if ( ! $_post_id && $_encoded_name !== $name ) {
1111 $_post_id = (int) $wpdb->get_var(
1112 $wpdb->prepare(
1113 "SELECT p.ID FROM {$wpdb->posts} p
1114 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1115 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'doc_category'
1116 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1117 WHERE p.post_name = %s AND p.post_type = 'docs'
1118 AND t.slug IN (%s, %s)
1119 LIMIT 1",
1120 esc_sql( $name ),
1121 esc_sql( $_cat_target_enc ),
1122 esc_sql( $_cat_target )
1123 )
1124 );
1125 }
1126
1127 // Fallback: post exists with this name but not under the requested
1128 // category. Let the category-validation block below handle the 404 so
1129 // we keep the existing single-language behaviour intact.
1130 if ( ! $_post_id ) {
1131 $_post_id = (int) $wpdb->get_var(
1132 $wpdb->prepare(
1133 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
1134 esc_sql( $_encoded_name ),
1135 'docs'
1136 )
1137 );
1138 if ( ! $_post_id && $_encoded_name !== $name ) {
1139 $_post_id = (int) $wpdb->get_var(
1140 $wpdb->prepare(
1141 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
1142 esc_sql( $name ),
1143 'docs'
1144 )
1145 );
1146 }
1147 }
1148 }
1149
1150 // If post exists, validate it belongs to the category in the URL
1151 if ( $_post_id > 0 ) {
1152
1153 // When hierarchical slugs are enabled, check if post belongs to any category in the path
1154 $has_category = false;
1155
1156 if ( $this->settings->get( 'enable_category_hierarchy_slugs' ) && count($category_parts) > 1 ) {
1157
1158 // Check if post belongs to ANY category in the hierarchy path
1159 // For example, if URL is "update/overview", check for both "update" and "overview"
1160 $category_slugs_to_check = $category_parts;
1161
1162 foreach ( $category_slugs_to_check as $cat_slug ) {
1163
1164 // rawurlencode produces uppercase hex (%E0%...) but WP/Polylang stores lowercase (%e0%).
1165 // Always normalise to lowercase so the slug IN (...) comparison succeeds.
1166 $_encoded_cat = strtolower( rawurlencode( $cat_slug ) );
1167 $cat_check = $wpdb->get_var(
1168 $wpdb->prepare(
1169 "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
1170 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1171 INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
1172 WHERE tr.object_id = %d AND t.slug IN (%s, %s) AND tt.taxonomy = %s",
1173 $_post_id,
1174 esc_sql( $_encoded_cat ),
1175 esc_sql( $cat_slug ),
1176 'doc_category'
1177 )
1178 );
1179
1180 if ( $cat_check > 0 ) {
1181 // If knowledge_base is set, verify the category belongs to that KB
1182 if ( isset( $query_vars['knowledge_base'] ) ) {
1183
1184 // Get the term ID - use our helper that tries both decoded and encoded forms.
1185 $term = $this->get_term_by_slug_or_encoded( $cat_slug, 'doc_category' );
1186 if ( $term ) {
1187 $term_kbs = get_term_meta( $term->term_id, 'doc_category_knowledge_base', true );
1188
1189 // Primary check: category's stored KB meta includes the requested KB.
1190 if ( is_array( $term_kbs ) && in_array( $query_vars['knowledge_base'], $term_kbs ) ) {
1191 $has_category = true;
1192 break;
1193 }
1194
1195 // Fallback: for Polylang/multilingual sites the term meta may store the
1196 // original-language KB slug while the URL uses the translated slug.
1197 // Verify instead that the post is actually assigned to the requested KB.
1198 // wp_get_post_terms may return slugs URL-encoded (Polylang) or decoded (standard WP).
1199 // Normalise everything to lowercase for comparison.
1200 $kb_slug_url = $query_vars['knowledge_base'];
1201 $kb_slug_enc = strtolower( rawurlencode( $kb_slug_url ) );
1202 $post_kbs = wp_get_post_terms( $_post_id, 'knowledge_base', [ 'fields' => 'slugs' ] );
1203 $post_kbs_lower = array_map( 'strtolower', is_array( $post_kbs ) ? $post_kbs : [] );
1204 if ( ! is_wp_error( $post_kbs ) &&
1205 ( in_array( $kb_slug_url, $post_kbs_lower ) || in_array( $kb_slug_enc, $post_kbs_lower ) ) ) {
1206 $has_category = true;
1207 break;
1208 }
1209 }
1210 } else {
1211
1212 // No KB in URL, so any category match is valid
1213 $has_category = true;
1214 break;
1215 }
1216 }
1217 }
1218 } else {
1219 // Non-hierarchical or single category - check only the target category
1220 // Always lowercase-encode so the slug matches WP/Polylang's stored lowercase hex.
1221 $_encoded_target = strtolower( rawurlencode( $target_category_slug ) );
1222 $has_category = $wpdb->get_var(
1223 $wpdb->prepare(
1224 "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
1225 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1226 INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
1227 WHERE tr.object_id = %d AND t.slug IN (%s, %s) AND tt.taxonomy = %s",
1228 $_post_id,
1229 esc_sql( $_encoded_target ),
1230 esc_sql( $target_category_slug ),
1231 'doc_category'
1232 )
1233 );
1234
1235 // If knowledge_base is set and category was found, verify the POST belongs to that KB.
1236 // We use the post's actual KB taxonomy terms as the source of truth,
1237 // NOT the doc_category_knowledge_base meta (which can be stale or misconfigured).
1238 // Only block if the post is explicitly assigned to OTHER KBs that don't include the requested one.
1239 if ( $has_category && isset( $query_vars['knowledge_base'] ) ) {
1240 $post_kbs = wp_get_post_terms( $_post_id, 'knowledge_base', [ 'fields' => 'slugs' ] );
1241 if ( ! is_wp_error( $post_kbs ) && ! empty( $post_kbs ) ) {
1242 $kb_slug = $query_vars['knowledge_base'];
1243 // PHP's rawurlencode() produces uppercase (%E0%A6...) but WordPress/Polylang stores
1244 // slugs with lowercase hex (%e0%a6...). Normalise both sides to lowercase.
1245 $kb_slug_encoded = strtolower( rawurlencode( $kb_slug ) );
1246 $post_kbs_lower = array_map( 'strtolower', $post_kbs );
1247 // Check decoded form (standard WP) and encoded form (Polylang).
1248 if ( ! in_array( $kb_slug, $post_kbs_lower ) && ! in_array( $kb_slug_encoded, $post_kbs_lower ) ) {
1249 $has_category = false;
1250 }
1251 }
1252 }
1253
1254
1255 }
1256
1257 // Special handling for uncategorized docs
1258 if ( ! $has_category && $target_category_slug === 'uncategorized' ) {
1259 // Check if the post has no categories assigned at all
1260 $category_count = $wpdb->get_var(
1261 $wpdb->prepare(
1262 "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
1263 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1264 WHERE tr.object_id = %d AND tt.taxonomy = %s",
1265 $_post_id,
1266 'doc_category'
1267 )
1268 );
1269
1270 // If post has no categories, allow it for uncategorized URL
1271 if ( $category_count == 0 ) {
1272 $has_category = true;
1273 }
1274 }
1275
1276
1277 // If post doesn't belong to the target category, return false (404)
1278 if ( ! $has_category ) {
1279 // Remember these query vars so we can block any canonical redirect for this invalid URL
1280 $this->invalid_request_query_vars = $query_vars;
1281 return false;
1282 }
1283
1284 // If hierarchical slugs are enabled and we found a post, validate the full hierarchy
1285 if ( $this->settings->get( 'enable_category_hierarchy_slugs' ) && count($category_parts) > 1 ) {
1286 // Get the post's category terms
1287 $post_categories = wp_get_object_terms( $_post_id, 'doc_category' );
1288
1289 if ( ! empty( $post_categories ) ) {
1290 $found_valid_hierarchy = false;
1291
1292 foreach ( $post_categories as $post_category ) {
1293 // Build the hierarchy path for this category
1294 $hierarchy_path = [];
1295 $current_term = $post_category;
1296
1297 // Build path from child to parent
1298 while ( $current_term ) {
1299 array_unshift( $hierarchy_path, $current_term->slug );
1300 $current_term = $current_term->parent ? get_term( $current_term->parent, 'doc_category' ) : null;
1301 }
1302
1303 // Check if this hierarchy matches the URL structure.
1304 // WordPress/Polylang store non-Latin term slugs URL-encoded
1305 // (%e0%a6...) while $doc_category arrives decoded from
1306 // is_perma_valid_for. Compare in the decoded form so Bengali,
1307 // Arabic, CJK, etc. hierarchies actually match.
1308 $built_path = implode('/', $hierarchy_path);
1309 $built_path_norm = urldecode( $built_path );
1310 $doc_cat_norm = urldecode( $doc_category );
1311
1312 // Allow partial path matching to accommodate KB-prefixed URLs or partial hierarchies.
1313 // Using substr for broad PHP version compatibility (equivalent to str_ends_with).
1314 $is_suffix = strlen( $built_path_norm ) > 0 && substr( $doc_cat_norm, -strlen( $built_path_norm ) ) === $built_path_norm;
1315 $is_prefix = strlen( $doc_cat_norm ) > 0 && substr( $built_path_norm, -strlen( $doc_cat_norm ) ) === $doc_cat_norm;
1316
1317 if ( $built_path_norm === $doc_cat_norm || $is_suffix || $is_prefix ) {
1318 $found_valid_hierarchy = true;
1319 break;
1320 }
1321 }
1322
1323 // If no valid hierarchy found, return false (404)
1324 if ( ! $found_valid_hierarchy ) {
1325 return false;
1326 }
1327 }
1328 }
1329 }
1330 } else {
1331 // First, check if the post exists.
1332 // When MKB is active, multiple translated posts share the same slug — one per KB.
1333 // Join the KB taxonomy when knowledge_base is in the URL to find the right post.
1334 $_encoded_name = rawurlencode( $name );
1335
1336 if ( isset( $query_vars['knowledge_base'] ) && ! empty( $query_vars['knowledge_base'] ) ) {
1337 $_kb_slug = $query_vars['knowledge_base'];
1338 $_kb_slug_enc = strtolower( rawurlencode( $_kb_slug ) );
1339 $_post_id = (int) $wpdb->get_var(
1340 $wpdb->prepare(
1341 "SELECT p.ID FROM {$wpdb->posts} p
1342 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1343 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'knowledge_base'
1344 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1345 WHERE p.post_name = %s AND p.post_type = 'docs'
1346 AND t.slug IN (%s, %s)
1347 LIMIT 1",
1348 esc_sql( $_encoded_name ),
1349 esc_sql( $_kb_slug ),
1350 esc_sql( $_kb_slug_enc )
1351 )
1352 );
1353 if ( ! $_post_id && $_encoded_name !== $name ) {
1354 $_post_id = (int) $wpdb->get_var(
1355 $wpdb->prepare(
1356 "SELECT p.ID FROM {$wpdb->posts} p
1357 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1358 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'knowledge_base'
1359 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1360 WHERE p.post_name = %s AND p.post_type = 'docs'
1361 AND t.slug IN (%s, %s)
1362 LIMIT 1",
1363 esc_sql( $name ),
1364 esc_sql( $_kb_slug ),
1365 esc_sql( $_kb_slug_enc )
1366 )
1367 );
1368 }
1369 } else {
1370 $_post_id = (int) $wpdb->get_var(
1371 $wpdb->prepare(
1372 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
1373 esc_sql( $_encoded_name ),
1374 'docs'
1375 )
1376 );
1377 if ( ! $_post_id && $_encoded_name !== $name ) {
1378 $_post_id = (int) $wpdb->get_var(
1379 $wpdb->prepare(
1380 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
1381 esc_sql( $name ),
1382 'docs'
1383 )
1384 );
1385 }
1386 }
1387
1388
1389 // If knowledge_base is set, validate the post actually belongs to that KB.
1390 // wp_get_post_terms may return slugs URL-encoded (Polylang) or decoded (standard WP).
1391 // Check both forms so the match works regardless of storage format.
1392 if ( $_post_id > 0 && isset( $query_vars['knowledge_base'] ) ) {
1393 $post_kbs = wp_get_post_terms( $_post_id, 'knowledge_base', [ 'fields' => 'slugs' ] );
1394 if ( ! is_wp_error( $post_kbs ) && ! empty( $post_kbs ) ) {
1395 $kb_slug = $query_vars['knowledge_base'];
1396 $kb_slug_encoded = strtolower( rawurlencode( $kb_slug ) );
1397 $post_kbs_lower = array_map( 'strtolower', $post_kbs );
1398 if ( ! in_array( $kb_slug, $post_kbs_lower ) && ! in_array( $kb_slug_encoded, $post_kbs_lower ) ) {
1399 // Remember these query vars so we can block any canonical redirect for this invalid URL
1400 $this->invalid_request_query_vars = $query_vars;
1401 return false;
1402 }
1403 }
1404 // If post has no KB terms → allow it (not assigned to any KB explicitly).
1405 }
1406 }
1407
1408 return $_post_id > 0;
1409 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1410 }
1411
1412 protected function is_docs_category( $query_vars ) {
1413 $result = $this->term_exists( $query_vars, 'doc_category' );
1414 return $result;
1415 }
1416
1417 protected function is_docs_tag( $query_vars ) {
1418 return $this->term_exists( $query_vars, 'doc_tag' );
1419 }
1420
1421 protected function term_exists( $query_vars, $taxonomy ) {
1422 if ( ! isset( $query_vars[ $taxonomy ] ) ) {
1423 return false;
1424 }
1425
1426 // WordPress/Polylang stores non-Latin slugs URL-encoded (%e0%a6...) but the query var
1427 // arrives already decoded (e.g. বেটারডক্স). Try the decoded form first, then encoded.
1428 if ( term_exists( $query_vars[ $taxonomy ], $taxonomy ) ) {
1429 return true;
1430 }
1431 $encoded = strtolower( rawurlencode( $query_vars[ $taxonomy ] ) );
1432 if ( $encoded !== $query_vars[ $taxonomy ] && term_exists( $encoded, $taxonomy ) ) {
1433 return true;
1434 }
1435 return false;
1436 }
1437
1438 /**
1439 * Look up a taxonomy term by slug, trying both the raw (possibly Unicode-decoded) form
1440 * and the lowercase URL-encoded form that WordPress/Polylang stores for non-Latin slugs.
1441 *
1442 * @param string $slug Slug to look up (may be decoded Unicode, e.g. বেটারডক্স).
1443 * @param string $taxonomy Taxonomy name.
1444 * @return \WP_Term|false
1445 */
1446 protected function get_term_by_slug_or_encoded( $slug, $taxonomy ) {
1447 $term = get_term_by( 'slug', $slug, $taxonomy );
1448 if ( $term ) {
1449 return $term;
1450 }
1451 // Fallback: WordPress/Polylang stores non-Latin slugs as lowercase percent-encoded strings.
1452 $encoded = strtolower( rawurlencode( $slug ) );
1453 if ( $encoded !== $slug ) {
1454 $term = get_term_by( 'slug', $encoded, $taxonomy );
1455 }
1456 return $term ? $term : false;
1457 }
1458
1459 public function set_perma_structure( $structures = [] ) {
1460 $this->perma_structure = array_merge( $this->perma_structure, $structures );
1461 }
1462
1463 public function set_query_vars( $query_vars = [] ) {
1464 $this->query_vars = array_merge( $this->query_vars, $query_vars );
1465 }
1466
1467 public function backward_compability( $wp ) {
1468 if ( static::$already_parsed ) {
1469 return;
1470 }
1471
1472 $this->permalink_magic( $wp );
1473 }
1474
1475 public function parse( $wp ) {
1476 static::$already_parsed = true;
1477
1478 // An API Reference rewrite rule already matched (/docs/api/{slug}).
1479 // The permalink magic below re-interprets the raw path against the
1480 // docs/category/KB structures and would hijack the request whenever a
1481 // doc_category or knowledge_base term shares the reference's slug —
1482 // an explicit CPT match always wins.
1483 if ( isset( $wp->query_vars['betterdocs_api_ref'] ) ) {
1484 return;
1485 }
1486
1487 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- legacy public filter name, retained for back-compat with Pro/extensions.
1488 $this->perma_structure = apply_filters('docs_rewrite_rules', $this->perma_structure);
1489
1490 $this->permalink_magic( $wp );
1491 }
1492
1493 protected function permalink_magic( $wp ) {
1494 $this->wp_query_vars = $wp->query_vars;
1495
1496 if ( ! empty( $this->perma_structure ) ) {
1497 $_valid = [];
1498
1499 // Normalize request path: remove index.php/ and leading/trailing slashes.
1500 // urldecode is correct here: $wp->request arrives decoded by PHP/Apache, and the structure
1501 // regex patterns (e.g. "docs/%knowledge_base%") are plain ASCII, so matching works fine.
1502 // DB lookups handle the encoding separately below.
1503 $request = isset( $wp->request ) ? urldecode( $wp->request ) : '';
1504 $request = trim( preg_replace( '#^index\.php(/|$)#', '', $request ), '/' );
1505
1506 // Strip pagination segment (/page/N) before matching permalink structures.
1507 // When hierarchy slugs are enabled, the (.+?) regex for %doc_category% would
1508 // otherwise capture "/page/2" as part of the category slug, breaking pagination.
1509 $paged = 0;
1510 if ( preg_match( '#/page/([0-9]+)/?$#', $request, $page_matches ) ) {
1511 $paged = intval( $page_matches[1] );
1512 $request = preg_replace( '#/page/[0-9]+/?$#', '', $request );
1513 }
1514
1515 // Strip optional language prefix injected by Polylang/WPML (e.g. "en/", "bn/", "pt-br/")
1516 // so that "bn/docs/..." matches the structure "docs/..." correctly.
1517 $request_without_lang = preg_replace( '#^[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,8})?/#', '', $request );
1518
1519 // When WPML translates a taxonomy base slug per language (e.g. doc_tag
1520 // "docs-tag" -> "docs-tag-bn"), the incoming URL uses the translated slug,
1521 // but perma_structure is built from the default-language slug. Map the
1522 // leading translated slug back to its default so the raw structures match.
1523 // No-op on non-WPML sites and when the slug isn't translated.
1524 $request_canonical = $this->canonicalize_translated_slug( $request_without_lang );
1525
1526 foreach ( $this->perma_structure as $_type => $structure ) {
1527 // First try the raw (possibly language-prefixed) request, then the lang-stripped variant.
1528 // This ensures we still match non-multilingual sites without stripping valid slugs.
1529 $_perma_vars = $this->is_perma_valid_for( $structure, $request );
1530 if ( ! $_perma_vars && $request_without_lang !== $request ) {
1531 $_perma_vars = $this->is_perma_valid_for( $structure, $request_without_lang );
1532 }
1533 if ( ! $_perma_vars && $request_canonical !== $request_without_lang ) {
1534 $_perma_vars = $this->is_perma_valid_for( $structure, $request_canonical );
1535 }
1536
1537 // $_valid = empty( $_valid ) && $_perma_vars ? [ 'type' => $_type, 'query_vars' => $_perma_vars ] : $_valid;
1538 if ( ( $_perma_vars && method_exists( $this, $_type ) && call_user_func_array( [$this, $_type], [ & $_perma_vars] ) ) ) {
1539
1540 // dump( $_type, $_perma_vars );
1541 if ( $_type === 'is_single_docs' || $_type == 'is_docs_feed' || $_type == 'is_docs_author' ) {
1542 $_perma_vars['post_type'] = 'docs';
1543 }
1544 $_valid = ['type' => $_type, 'query_vars' => $_perma_vars];
1545
1546 // Single doc match is definitive — stop here so later category/KB archive
1547 // structures cannot overwrite it (e.g. is_knowledge_base_category).
1548 if ( $_type === 'is_single_docs' ) {
1549 break;
1550 }
1551 }
1552 }
1553
1554 $type = isset( $_valid['type'] ) ? $_valid['type'] : '';
1555 $query_vars = isset( $_valid['query_vars'] ) ? $_valid['query_vars'] : [];
1556
1557 // Inject the paged query var if a /page/N segment was stripped from the request.
1558 if ( $paged > 0 && ! empty( $type ) ) {
1559 $query_vars['paged'] = $paged;
1560 }
1561
1562 if ( ! empty( $type ) ) {
1563 unset( $this->query_vars[ $type ] );
1564 array_map(
1565 function ( $_vars ) use ( &$wp ) {
1566 array_map(
1567 function ( $_var ) use ( &$wp ) {
1568 unset( $wp->query_vars[ $_var ] );
1569 },
1570 $_vars
1571 );
1572 },
1573 $this->query_vars
1574 );
1575 }
1576
1577 $wp->query_vars = is_array( $query_vars ) ? array_merge( $wp->query_vars, $query_vars ) : $wp->query_vars;
1578
1579 // Fallback
1580 if ( ! empty( $_valid ) ) {
1581 unset( $wp->query_vars['attachment'] );
1582 }
1583 }
1584 }
1585
1586 /**
1587 * Map a leading WPML-translated taxonomy base slug back to its default-language
1588 * value so the default-language perma_structure patterns can match a translated URL.
1589 *
1590 * Only the first path segment is considered (the taxonomy base). Returns the
1591 * request unchanged when WPML is inactive or the leading segment isn't a
1592 * translated BetterDocs slug.
1593 *
1594 * @param string $request Language-stripped request path (no leading/trailing slash).
1595 * @return string
1596 */
1597 private function canonicalize_translated_slug( $request ) {
1598 if ( $request === '' || strpos( $request, '/' ) === false ) {
1599 return $request;
1600 }
1601
1602 $slug_map = [
1603 'doc_tag' => trim( $this->settings->get( 'tag_slug', 'docs-tag' ), '/' ),
1604 'doc_category' => trim( $this->settings->get( 'category_slug', 'docs-category' ), '/' ),
1605 ];
1606
1607 $segments = explode( '/', $request );
1608
1609 foreach ( $slug_map as $taxonomy => $default ) {
1610 if ( $default === '' ) {
1611 continue;
1612 }
1613
1614 $translated = Helper::wpml_translated_tax_slug( $taxonomy, $default );
1615 if ( $translated !== $default && $segments[0] === $translated ) {
1616 $segments[0] = $default;
1617 return implode( '/', $segments );
1618 }
1619 }
1620
1621 return $request;
1622 }
1623
1624 /**
1625 * This method is responsible for checking a structure is valid again a request.
1626 *
1627 * @param string $structure
1628 * @param string $request
1629 * @return array|bool
1630 */
1631 private function is_perma_valid_for( $structure, $request ) {
1632 if ( empty( $structure ) ) {
1633 return false;
1634 }
1635
1636 $_tags = explode( '/', trim( $structure, '/' ) );
1637 $_replace_matched_tags = [];
1638
1639 $_replace_tags = array_filter(
1640 $_tags,
1641 function ( $item ) use ( &$_replace_matched_tags ) {
1642 $_is_valid = strpos( $item, '%' ) !== false;
1643 if ( $_is_valid ) {
1644 $_replace_matched_tags[] = trim( $item, '%' );
1645 }
1646 return $_is_valid;
1647 }
1648 );
1649
1650 // First, preg_quote the structure to safely use it in a regex
1651 $_perma_structure = preg_quote( $structure, '#' );
1652
1653 // Since our placeholders like %name% contain characters (like %) that preg_quote escapes,
1654 // we must also preg_quote the tags before searching for them in the escaped structure.
1655 foreach ( $_replace_tags as $tag ) {
1656 $tag_escaped = preg_quote( $tag, '#' );
1657 $replacement = '([^/]+)';
1658
1659 // If hierarchical slugs are enabled, allow slashes in the %doc_category% placeholder
1660 if ( $tag === '%doc_category%' && $this->settings->get( 'enable_category_hierarchy_slugs' ) ) {
1661 $replacement = '(.+?)';
1662 }
1663
1664 $_perma_structure = str_replace( $tag_escaped, $replacement, $_perma_structure );
1665 }
1666
1667 preg_match( "#^$_perma_structure$#", $request, $matches );
1668
1669 if ( empty( $matches ) || ! is_array( $matches ) ) {
1670 return false;
1671 }
1672
1673 if ( count( $matches ) === 1 ) {
1674 return [ 'post_type' => 'docs' ];
1675 }
1676
1677 unset( $matches[0] );
1678
1679 return array_combine( $_replace_matched_tags, $matches );
1680 }
1681 }
1682