PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.4.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.4.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.4.0, at includes/Core/Request.php

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