PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.5.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.5.2
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 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.5.2, at includes/Core/Request.php

1,616 lines 59.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPDeveloper\BetterDocs\Core;
4
5 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 // `name` must be a non-empty string — WP populates it with '' for taxonomy archive
339 // requests (e.g. comma-separated multi-category URLs like /docs-category/a,b/),
340 // which `isset()` would treat as present and incorrectly trigger this single-doc branch.
341 if ( isset( $wp_query->query_vars['post_type'] ) && $wp_query->query_vars['post_type'] === 'docs' &&
342 isset( $wp_query->query_vars['doc_category'] ) && ! empty( $wp_query->query_vars['name'] ) ) {
343
344 $doc_category = $wp_query->query_vars['doc_category'];
345 $post_name = $wp_query->query_vars['name'];
346
347 // Get the post
348 $post = get_page_by_path( $post_name, OBJECT, 'docs' );
349
350 if ( ! $post ) {
351 $wp_query->set_404();
352 status_header( 404 );
353 nocache_headers();
354 return;
355 }
356
357 // Get post's categories
358 $post_categories = wp_get_post_terms( $post->ID, 'doc_category' );
359
360 if ( empty( $post_categories ) || is_wp_error( $post_categories ) ) {
361 // Post has no categories - only allow if URL is 'uncategorized'
362 if ( $doc_category !== 'uncategorized' ) {
363 $wp_query->set_404();
364 status_header( 404 );
365 nocache_headers();
366 return;
367 }
368 } else {
369 // Post has categories - check if it belongs to the category in URL
370 $category_slugs = wp_list_pluck( $post_categories, 'slug' );
371
372 // Handle hierarchical categories: check if any part of the path matches
373 $category_parts = explode('/', trim($doc_category, '/'));
374 $found_match = false;
375
376 foreach ( $category_parts as $cat_slug ) {
377 if ( in_array( $cat_slug, $category_slugs ) ) {
378 $found_match = true;
379 break;
380 }
381 }
382
383 if ( ! $found_match ) {
384 $wp_query->set_404();
385 status_header( 404 );
386 nocache_headers();
387 return;
388 }
389 }
390 }
391 }
392
393 /**
394 * Check if a URL matches a BetterDocs single docs permalink structure
395 * but has invalid KB/category slugs that don't match the post.
396 *
397 * @param string $url The URL to check.
398 * @return bool True if the URL is a BetterDocs docs URL with invalid slugs.
399 */
400 protected function is_invalid_docs_url( $url ) {
401 // Get the path from the URL
402 $path = trim( parse_url( $url, PHP_URL_PATH ), '/' );
403
404 // Check each permalink structure
405 foreach ( $this->perma_structure as $_type => $structure ) {
406 if ( $_type !== 'is_single_docs' ) {
407 continue;
408 }
409
410 $_perma_vars = $this->is_perma_valid_for( $structure, $path );
411 if ( ! $_perma_vars ) {
412 continue;
413 }
414
415 // URL matches the single docs structure - now validate the slugs
416 $name = isset( $_perma_vars['docs'] ) ? $_perma_vars['docs'] : ( isset( $_perma_vars['name'] ) ? $_perma_vars['name'] : '' );
417 if ( empty( $name ) ) {
418 continue;
419 }
420
421 // Check if the post exists
422 $post = get_page_by_path( $name, OBJECT, 'docs' );
423 if ( ! $post ) {
424 return false; // Post doesn't exist at all - not our concern
425 }
426
427 // Validate knowledge_base slug if present
428 if ( isset( $_perma_vars['knowledge_base'] ) && ! empty( $_perma_vars['knowledge_base'] ) ) {
429 $post_kbs = wp_get_post_terms( $post->ID, 'knowledge_base', [ 'fields' => 'slugs' ] );
430 if ( ! is_wp_error( $post_kbs ) && ! in_array( $_perma_vars['knowledge_base'], $post_kbs ) ) {
431 return true; // Invalid KB slug
432 }
433 }
434
435 // Validate doc_category slug if present
436 if ( isset( $_perma_vars['doc_category'] ) && ! empty( $_perma_vars['doc_category'] ) ) {
437 $category_parts = explode( '/', trim( $_perma_vars['doc_category'], '/' ) );
438 $post_categories = wp_get_post_terms( $post->ID, 'doc_category', [ 'fields' => 'slugs' ] );
439
440 if ( ! is_wp_error( $post_categories ) ) {
441 $found = false;
442 foreach ( $category_parts as $cat_slug ) {
443 if ( in_array( $cat_slug, $post_categories ) ) {
444 $found = true;
445 break;
446 }
447 }
448 if ( ! $found ) {
449 return true; // Invalid category slug
450 }
451 }
452 }
453 }
454
455 return false;
456 }
457
458 /**
459 * Set up taxonomy query for category archives
460 * This ensures WordPress recognizes requests with doc_category or knowledge_base as taxonomy archives
461 *
462 * @param \WP_Query $query The WordPress query object
463 */
464 public function setup_taxonomy_query( $query ) {
465 if ( is_admin() || ! $query->is_main_query() ) {
466 return;
467 }
468 if ( $this->invalid_request_query_vars !== null ) {
469 return;
470 }
471
472 // Check if this is a doc_category request
473 if ( isset( $query->query_vars['doc_category'] ) && ! empty( $query->query_vars['doc_category'] ) ) {
474 // If this is already identified as singular, don't override it
475 if ( $query->is_singular() || $query->is_singular ) {
476
477 // Ensure it's not marked as 404
478 $query->is_404 = false;
479 return;
480 }
481
482 // Check if we have a post ID set (p query var)
483 if ( isset( $query->query_vars['p'] ) && $query->query_vars['p'] > 0 ) {
484 // Security check: if this is a private post and user can't read private docs, show 404
485 $post = get_post( $query->query_vars['p'] );
486 if ( $post && $post->post_status === 'private' && ! current_user_can( 'read_private_docs' ) ) {
487
488 $query->is_404 = true;
489 $query->is_single = false;
490 $query->is_singular = false;
491 return;
492 }
493
494 // Explicitly set this as a single post, not an archive or 404
495 $query->is_single = true;
496 $query->is_singular = true;
497 $query->is_404 = false;
498 $query->is_archive = false;
499 $query->is_tax = false;
500 return;
501 }
502
503 // Check if we have 'docs' query var (alternative to 'name')
504 if ( isset( $query->query_vars['docs'] ) && ! empty( $query->query_vars['docs'] ) ) {
505 // Explicitly set this as a single post
506 $query->is_single = true;
507 $query->is_singular = true;
508 $query->is_404 = false;
509 $query->is_archive = false;
510 $query->is_tax = false;
511 return;
512 }
513
514 // If 'name' is set, check if a post with that name exists
515 // This prevents private docs from being incorrectly treated as category archives
516 if ( isset( $query->query_vars['name'] ) && ! empty( $query->query_vars['name'] ) ) {
517 $post_exists = get_page_by_path( $query->query_vars['name'], OBJECT, 'docs' );
518
519 if ( $post_exists ) {
520 // A post exists - this is a single doc request, not a category archive
521 // Don't set taxonomy flags
522 return;
523 }
524 }
525
526 // Only set taxonomy flags if none of the above conditions are met (pure category archive)
527 if ( ( ! isset( $query->query_vars['name'] ) || empty( $query->query_vars['name'] ) ) &&
528 ( ! isset( $query->query_vars['p'] ) || $query->query_vars['p'] <= 0 ) &&
529 ( ! isset( $query->query_vars['docs'] ) || empty( $query->query_vars['docs'] ) ) ) {
530
531 // Set this as a taxonomy query
532 $query->is_tax = true;
533 $query->is_archive = true;
534 $query->is_home = false;
535 $query->is_404 = false; // Important: reset 404 flag
536
537 // WordPress/Polylang may store non-Latin slugs URL-encoded (%e0%a6...) while the
538 // query var arrives decoded (বেটারডক্স). Try both forms so the term lookup succeeds.
539 $term = $this->get_term_by_slug_or_encoded( $query->query_vars['doc_category'], 'doc_category' );
540 if ( $term ) {
541 $query->queried_object = $term;
542 $query->queried_object_id = $term->term_id;
543
544 // Set up tax_query using proper WP_Tax_Query class
545 if ( ! isset( $query->tax_query ) || ! is_a( $query->tax_query, 'WP_Tax_Query' ) ) {
546 $tax_query_args = [
547 [
548 'taxonomy' => 'doc_category',
549 'field' => 'slug',
550 'terms' => [ $term->slug ]
551 ]
552 ];
553 $query->tax_query = new \WP_Tax_Query( $tax_query_args );
554 $query->tax_query->queried_terms = [
555 'doc_category' => [
556 'terms' => [ $term->slug ],
557 'field' => 'slug'
558 ]
559 ];
560 }
561 }
562 }
563 }
564
565 }
566
567 /**
568 * Validate that the requested path matches the expected documentation root.
569 * This prevents URLs with invalid prefixes (e.g., /invalid/docs/...) from showing archive templates.
570 */
571 public function validate_request_path() {
572 if ( is_admin() || ! is_main_query() ) {
573 return;
574 }
575
576 global $wp;
577 // $wp->request contains the path relative to site root, without query string
578 $request_path = isset( $wp->request ) ? urldecode( $wp->request ) : '';
579
580 // Normalize request path: remove index.php/ and leading/trailing slashes
581 $request_path = trim( preg_replace( '#^index\.php(/|$)#', '', $request_path ), '/' );
582
583 // If the request path is empty, this is a query-string-only request (e.g. /?post_type=docs).
584 // There is no URL prefix to validate in that case, so bail early.
585 if ( $request_path === '' ) {
586 return;
587 }
588
589 // Normalize base slug
590 $docs_slug = $this->rewrite->get_base_slug();
591
592 // If user is using a custom page as root, use that page's path
593 if ( ! $this->settings->get( 'builtin_doc_page', true ) ) {
594 $docs_page_id = $this->settings->get( 'docs_page', 0 );
595 if ( $docs_page_id ) {
596 $page_path = get_page_uri( $docs_page_id );
597 if ( $page_path ) {
598 $docs_slug = $page_path;
599 }
600 }
601 }
602
603 $docs_slug = trim( $docs_slug, '/' );
604
605 if ( empty( $docs_slug ) ) {
606 return;
607 }
608
609 // Check if this is a query we should validate
610 $is_docs_query = is_singular( 'docs' ) || is_post_type_archive( 'docs' ) || is_tax( [ 'doc_category', 'knowledge_base', 'doc_tag' ] );
611 $looks_like_docs_url = strpos( $request_path, $docs_slug ) !== false;
612
613 // We validate if it's explicitly a docs query, OR if it looks like a docs URL but fell back to home/archive
614 if ( ! $is_docs_query && ! ( $looks_like_docs_url && is_home() ) ) {
615 return;
616 }
617
618 // If WordPress already correctly resolved this as a single docs post, trust that resolution.
619 // The post was found and is valid — no need to validate the URL prefix at all.
620 // Path validation is only meaningful for archive/tax pages whose URL leaked past the docs slug.
621 if ( is_singular( 'docs' ) ) {
622 return;
623 }
624
625 // Check if request path strictly starts with docs slug, category slug, or tag slug
626 // Using # as delimiter, need to preg_quote
627 $valid_slugs = [
628 $docs_slug,
629 trim( $this->settings->get( 'category_slug', 'docs-category' ), '/' ),
630 trim( $this->settings->get( 'tag_slug', 'docs-tag' ), '/' )
631 ];
632
633 // WPML/Polylang translate the registered rewrite slug per active language,
634 // while $docs_slug from settings is always the default-language slug. Include
635 // the post type's / taxonomies' currently-registered rewrite slugs so that
636 // translated archive URLs (e.g. /en/support/ when settings.docs_slug is
637 // "soporte") are accepted instead of being 404'd.
638 $docs_pt = get_post_type_object( 'docs' );
639 if ( $docs_pt && ! empty( $docs_pt->rewrite['slug'] ) ) {
640 $valid_slugs[] = trim( $docs_pt->rewrite['slug'], '/' );
641 }
642 foreach ( [ 'doc_category', 'doc_tag', 'knowledge_base' ] as $tax ) {
643 $tax_obj = get_taxonomy( $tax );
644 if ( $tax_obj && ! empty( $tax_obj->rewrite['slug'] ) ) {
645 $valid_slugs[] = trim( $tax_obj->rewrite['slug'], '/' );
646 }
647 }
648
649 // Belt-and-suspenders for WPML's slug-translation feature, which may store
650 // the translated slug separately from the post type's rewrite['slug'].
651 if ( has_filter( 'wpml_get_translated_slug' ) ) {
652 $wpml_slug = apply_filters( 'wpml_get_translated_slug', $docs_slug, 'docs' );
653 if ( is_string( $wpml_slug ) && $wpml_slug !== '' ) {
654 $valid_slugs[] = trim( $wpml_slug, '/' );
655 }
656 }
657
658 $valid_prefixes = array_unique( array_filter( $valid_slugs ) );
659 $valid_prefixes = array_map( function ( $slug ) { return preg_quote( $slug, '#' ); }, $valid_prefixes );
660
661 // Allow optional language prefixes (e.g. /en/, /pt-br/) for WPML/Polylang/TranslatePress compatibility
662 $lang_pattern = '(?:[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,4})?/)?';
663
664 $prefix_pattern = '#^' . $lang_pattern . '(' . implode( '|', $valid_prefixes ) . ')(/|$)#';
665
666 if ( ! preg_match( $prefix_pattern, $request_path ) ) {
667 global $wp_query;
668 $wp_query->set_404();
669 status_header( 404 );
670 nocache_headers();
671 }
672 }
673
674 /**
675 * Re-apply taxonomy flags on template_redirect
676 * This ensures the flags stick even if WordPress or other plugins reset them
677 */
678 public function reapply_taxonomy_flags() {
679 global $wp_query;
680
681 // If we found invalid query vars, do not mess with the query flags.
682 if ( $this->invalid_request_query_vars !== null ) {
683 return;
684 }
685
686 // Check if we have doc_category or knowledge_base in query vars
687 if ( isset( $wp_query->query_vars['doc_category'] ) && ! empty( $wp_query->query_vars['doc_category'] ) ) {
688 // If this is already identified as singular, don't override it
689 if ( $wp_query->is_singular() || $wp_query->is_singular ) {
690
691 // Ensure it's not marked as 404
692 $wp_query->is_404 = false;
693 return;
694 }
695
696 // Check if we have a post ID set (p query var)
697 if ( isset( $wp_query->query_vars['p'] ) && $wp_query->query_vars['p'] > 0 ) {
698
699
700 // Security check: if this is a private post and user can't read private docs, show 404
701 $post = get_post( $wp_query->query_vars['p'] );
702 if ( $post && $post->post_status === 'private' && ! current_user_can( 'read_private_docs' ) ) {
703
704 $wp_query->is_404 = true;
705 $wp_query->is_single = false;
706 $wp_query->is_singular = false;
707 return;
708 }
709
710 // Explicitly set this as a single post, not an archive or 404
711 $wp_query->is_single = true;
712 $wp_query->is_singular = true;
713 $wp_query->is_404 = false;
714 $wp_query->is_archive = false;
715 $wp_query->is_tax = false;
716 return;
717 }
718
719 // Check if we have 'docs' query var (alternative to 'name')
720 if ( isset( $wp_query->query_vars['docs'] ) && ! empty( $wp_query->query_vars['docs'] ) ) {
721
722 // Explicitly set this as a single post
723 $wp_query->is_single = true;
724 $wp_query->is_singular = true;
725 $wp_query->is_404 = false;
726 $wp_query->is_archive = false;
727 $wp_query->is_tax = false;
728 return;
729 }
730
731 // If 'name' is set, check if a post with that name exists
732 // This prevents posts from being incorrectly treated as category archives
733 // (important when post slug == category slug, e.g. docs/old/new/new)
734 if ( isset( $wp_query->query_vars['name'] ) && ! empty( $wp_query->query_vars['name'] ) ) {
735 $post_exists = get_page_by_path( $wp_query->query_vars['name'], OBJECT, 'docs' );
736
737 if ( $post_exists ) {
738 // A post exists - explicitly mark as single post and clear any taxonomy flags.
739 // Without this, WP may leave is_tax=true (set during parse_request because
740 // doc_category is also present), causing redirect_canonical to redirect
741 // the correct single-post URL to the category archive URL.
742 $wp_query->is_single = true;
743 $wp_query->is_singular = true;
744 $wp_query->is_404 = false;
745 $wp_query->is_archive = false;
746 $wp_query->is_tax = false;
747 $wp_query->queried_object = $post_exists;
748 $wp_query->queried_object_id = $post_exists->ID;
749 return;
750 }
751 }
752
753 // Only set taxonomy flags if none of the above conditions are met (pure category archive)
754 if ( ( ! isset( $wp_query->query_vars['name'] ) || empty( $wp_query->query_vars['name'] ) ) &&
755 ( ! isset( $wp_query->query_vars['p'] ) || $wp_query->query_vars['p'] <= 0 ) &&
756 ( ! isset( $wp_query->query_vars['docs'] ) || empty( $wp_query->query_vars['docs'] ) ) ) {
757
758 // Ensure the queried object is set or fetch the term
759 $term = null;
760 if ( isset( $wp_query->queried_object ) && $wp_query->queried_object ) {
761 $term = $wp_query->queried_object;
762 } else {
763 // WordPress/Polylang may store non-Latin slugs URL-encoded; try both forms.
764 $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['doc_category'], 'doc_category' );
765 }
766
767 // Only if the term effectively exists, we set the flags
768 if ( $term && ! is_wp_error( $term ) ) {
769 // Also validate knowledge_base if present
770 if ( isset( $wp_query->query_vars['knowledge_base'] ) && ! empty( $wp_query->query_vars['knowledge_base'] ) ) {
771 if ( ! $this->get_term_by_slug_or_encoded( $wp_query->query_vars['knowledge_base'], 'knowledge_base' ) ) {
772 return;
773 }
774 }
775
776 // Re-apply the taxonomy flags
777 $wp_query->is_tax = true;
778 $wp_query->is_archive = true;
779 $wp_query->is_home = false;
780 $wp_query->is_404 = false;
781
782 if ( ! isset( $wp_query->queried_object ) || ! $wp_query->queried_object ) {
783 $wp_query->queried_object = $term;
784 $wp_query->queried_object_id = $term->term_id;
785
786 // Set up tax_query using proper WP_Tax_Query class
787 if ( ! isset( $wp_query->tax_query ) || ! is_a( $wp_query->tax_query, 'WP_Tax_Query' ) ) {
788 $tax_query_args = [
789 [
790 'taxonomy' => 'doc_category',
791 'field' => 'slug',
792 'terms' => [ $term->slug ]
793 ]
794 ];
795 $wp_query->tax_query = new \WP_Tax_Query( $tax_query_args );
796 $wp_query->tax_query->queried_terms = [
797 'doc_category' => [
798 'terms' => [ $term->slug ],
799 'field' => 'slug'
800 ]
801 ];
802 }
803 }
804 }
805 }
806 }
807
808 }
809
810 /**
811 * Debug template redirect to see the query state
812 */
813 /**
814 * Prevent 404 status for valid taxonomy archives
815 *
816 * @param string $status_header The HTTP status header
817 * @param int $code The HTTP status code
818 * @return string The modified status header
819 */
820 public function prevent_404_status( $status_header, $code ) {
821 global $wp_query;
822
823 // If we've explicitly marked this request as invalid (malformed KB/category slug), respect the 404!
824 if ( $this->invalid_request_query_vars !== null ) {
825 return $status_header;
826 }
827
828 // If a 404 is being sent but the queried object is a valid single docs post,
829 // override with 200. This guards against false 404s on single docs pages.
830 if ( $code == 404 &&
831 isset( $wp_query->queried_object ) &&
832 $wp_query->queried_object instanceof \WP_Post &&
833 $wp_query->queried_object->post_type === 'docs' &&
834 in_array( $wp_query->queried_object->post_status, [ 'publish', 'private' ], true )
835 ) {
836 // Only allow if the current user can actually read this post
837 if ( 'publish' === $wp_query->queried_object->post_status ||
838 current_user_can( 'read_private_posts', $wp_query->queried_object->ID ) ) {
839 return 'HTTP/1.1 200 OK';
840 }
841 }
842
843 // If this is a 404 but we have doc_category or doc_tag query vars, change it to 200
844 // We check the query vars instead of is_tax because the flags get reset by WordPress
845 if ( $code == 404 && (
846 (isset($wp_query->query_vars['doc_category']) && ! empty($wp_query->query_vars['doc_category'])) ||
847 (isset($wp_query->query_vars['doc_tag']) && ! empty($wp_query->query_vars['doc_tag']))
848 ) ) {
849 // Validate existence before forcing 200
850 // Use encoded fallback so Bengali/Arabic/CJK slugs are found correctly.
851 if ( isset($wp_query->query_vars['doc_category']) && ! empty($wp_query->query_vars['doc_category']) ) {
852 $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['doc_category'], 'doc_category' );
853 if ( ! $term || is_wp_error( $term ) ) {
854 return $status_header;
855 }
856 }
857 if ( isset($wp_query->query_vars['doc_tag']) && ! empty($wp_query->query_vars['doc_tag']) ) {
858 $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['doc_tag'], 'doc_tag' );
859 if ( ! $term || is_wp_error( $term ) ) {
860 return $status_header;
861 }
862 }
863 if ( isset($wp_query->query_vars['knowledge_base']) && ! empty($wp_query->query_vars['knowledge_base']) ) {
864 $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['knowledge_base'], 'knowledge_base' );
865 if ( ! $term || is_wp_error( $term ) ) {
866 return $status_header;
867 }
868 }
869
870 return 'HTTP/1.1 200 OK';
871 }
872
873 return $status_header;
874 }
875
876 /**
877 * Ensure tax_query is always initialized as an object
878 * This prevents null reference errors from WPML and other plugins
879 * Only applies to BetterDocs post type and taxonomies
880 */
881 public function ensure_tax_query_initialized() {
882 global $wp_query;
883
884 // Only apply to BetterDocs-related queries
885 $is_betterdocs_query = false;
886
887 // Check if this is a docs post type query
888 if ( isset( $wp_query->query_vars['post_type'] ) && $wp_query->query_vars['post_type'] === 'docs' ) {
889 $is_betterdocs_query = true;
890 }
891
892 // Check if this is a BetterDocs taxonomy query
893 if ( isset( $wp_query->query_vars['doc_category'] ) && ! empty( $wp_query->query_vars['doc_category'] ) ) {
894 $is_betterdocs_query = true;
895 }
896
897 if ( isset( $wp_query->query_vars['doc_tag'] ) && ! empty( $wp_query->query_vars['doc_tag'] ) ) {
898 $is_betterdocs_query = true;
899 }
900
901 if ( isset( $wp_query->query_vars['knowledge_base'] ) && ! empty( $wp_query->query_vars['knowledge_base'] ) ) {
902 $is_betterdocs_query = true;
903 }
904
905 // Check if queried object is a BetterDocs taxonomy term
906 if ( isset( $wp_query->queried_object ) && isset( $wp_query->queried_object->taxonomy ) ) {
907 if ( in_array( $wp_query->queried_object->taxonomy, [ 'doc_category', 'doc_tag', 'knowledge_base' ] ) ) {
908 $is_betterdocs_query = true;
909 }
910 }
911
912 // Only proceed if this is a BetterDocs-related query
913 if ( ! $is_betterdocs_query ) {
914 return;
915 }
916
917 // Only initialize if it's not already a proper WP_Tax_Query instance
918 if ( ! isset( $wp_query->tax_query ) || ! is_a( $wp_query->tax_query, 'WP_Tax_Query' ) ) {
919 // Create a proper WP_Tax_Query instance with empty queries
920 $wp_query->tax_query = new \WP_Tax_Query( [] );
921 $wp_query->tax_query->queried_terms = [];
922 }
923
924 // For WPML compatibility: if queried_object is null, set it to an empty object
925 // but only if we're actually on a taxonomy page (is_tax is true)
926 if ( ! isset( $wp_query->queried_object ) && $wp_query->is_tax ) {
927 // Create a minimal WP_Term-like object to prevent errors
928 $wp_query->queried_object = new \stdClass();
929 $wp_query->queried_object->term_id = 0;
930 $wp_query->queried_object->name = '';
931 $wp_query->queried_object->slug = '';
932 $wp_query->queried_object->term_group = 0;
933 $wp_query->queried_object->term_taxonomy_id = 0;
934 $wp_query->queried_object->taxonomy = 'doc_category';
935 $wp_query->queried_object->description = '';
936 $wp_query->queried_object->parent = 0;
937 $wp_query->queried_object->count = 0;
938 $wp_query->queried_object->filter = 'raw';
939 }
940 }
941
942 protected function is_docs( &$query_vars ) {
943 if ( ! $this->settings->get( 'builtin_doc_page', true ) ) {
944 $query_vars['post_type'] = 'page';
945 $query_vars['name'] = trim( $this->rewrite->get_base_slug(), '/' );
946 }
947
948 return $query_vars;
949 }
950
951 public function is_docs_feed( $query_vars ) {
952 global $wp_rewrite;
953 return isset( $query_vars['feed'] ) && in_array( $query_vars['feed'], $wp_rewrite->feeds );
954 }
955
956 public function is_docs_author( $query_vars ) {
957 return isset( $query_vars['author'] ) ? true : false;
958 }
959
960 protected function is_single_docs( $query_vars ) {
961 // Check for both 'name' and 'docs' query variables
962 if ( ! isset( $query_vars['name'] ) && ! isset( $query_vars['docs'] ) ) {
963 return false;
964 }
965
966 global $wpdb;
967 $name = isset( $query_vars['docs'] ) ? $query_vars['docs'] : $query_vars['name'];
968
969
970 // If doc_category is specified in the URL, validate that the post belongs to that category
971 if ( isset( $query_vars['doc_category'] ) ) {
972 $doc_category = $query_vars['doc_category'];
973
974
975 // Handle hierarchical category slugs (e.g., parent/child/grandchild)
976 $category_parts = explode('/', trim($doc_category, '/'));
977 $target_category_slug = end($category_parts); // Get the last part as the target category
978
979
980 // First, check if the post exists.
981 // When MKB is active, multiple translated posts share the same slug — one per KB.
982 // We MUST join the KB taxonomy so we select the post for the correct language/KB.
983 // Polylang/multilingual plugins store post_name URL-encoded; try the encoded form first.
984 $_encoded_name = rawurlencode( $name );
985
986 if ( isset( $query_vars['knowledge_base'] ) && ! empty( $query_vars['knowledge_base'] ) ) {
987 // KB-aware lookup: only select the post that is assigned to this KB.
988 // Also join doc_category when present so that, on Polylang/WPML sites
989 // where multiple translated posts share both the same post_name and
990 // the same KB term (e.g. all language variants assigned to the
991 // "advice" KB), we land on the translation whose doc_category matches
992 // the URL — not the one the DB happens to return first.
993 $_kb_slug = $query_vars['knowledge_base'];
994 $_kb_slug_enc = strtolower( rawurlencode( $_kb_slug ) );
995
996 $_cat_target = $target_category_slug;
997 $_cat_target_enc = strtolower( rawurlencode( $_cat_target ) );
998
999 $_post_id = (int) $wpdb->get_var(
1000 $wpdb->prepare(
1001 "SELECT p.ID FROM {$wpdb->posts} p
1002 INNER JOIN {$wpdb->term_relationships} tr_kb ON tr_kb.object_id = p.ID
1003 INNER JOIN {$wpdb->term_taxonomy} tt_kb ON tt_kb.term_taxonomy_id = tr_kb.term_taxonomy_id AND tt_kb.taxonomy = 'knowledge_base'
1004 INNER JOIN {$wpdb->terms} t_kb ON t_kb.term_id = tt_kb.term_id
1005 INNER JOIN {$wpdb->term_relationships} tr_cat ON tr_cat.object_id = p.ID
1006 INNER JOIN {$wpdb->term_taxonomy} tt_cat ON tt_cat.term_taxonomy_id = tr_cat.term_taxonomy_id AND tt_cat.taxonomy = 'doc_category'
1007 INNER JOIN {$wpdb->terms} t_cat ON t_cat.term_id = tt_cat.term_id
1008 WHERE p.post_name = %s AND p.post_type = 'docs'
1009 AND t_kb.slug IN (%s, %s)
1010 AND t_cat.slug IN (%s, %s)
1011 LIMIT 1",
1012 esc_sql( $_encoded_name ),
1013 esc_sql( $_kb_slug ),
1014 esc_sql( $_kb_slug_enc ),
1015 esc_sql( $_cat_target_enc ),
1016 esc_sql( $_cat_target )
1017 )
1018 );
1019 // Fallback: post_name stored as decoded Unicode
1020 if ( ! $_post_id && $_encoded_name !== $name ) {
1021 $_post_id = (int) $wpdb->get_var(
1022 $wpdb->prepare(
1023 "SELECT p.ID FROM {$wpdb->posts} p
1024 INNER JOIN {$wpdb->term_relationships} tr_kb ON tr_kb.object_id = p.ID
1025 INNER JOIN {$wpdb->term_taxonomy} tt_kb ON tt_kb.term_taxonomy_id = tr_kb.term_taxonomy_id AND tt_kb.taxonomy = 'knowledge_base'
1026 INNER JOIN {$wpdb->terms} t_kb ON t_kb.term_id = tt_kb.term_id
1027 INNER JOIN {$wpdb->term_relationships} tr_cat ON tr_cat.object_id = p.ID
1028 INNER JOIN {$wpdb->term_taxonomy} tt_cat ON tt_cat.term_taxonomy_id = tr_cat.term_taxonomy_id AND tt_cat.taxonomy = 'doc_category'
1029 INNER JOIN {$wpdb->terms} t_cat ON t_cat.term_id = tt_cat.term_id
1030 WHERE p.post_name = %s AND p.post_type = 'docs'
1031 AND t_kb.slug IN (%s, %s)
1032 AND t_cat.slug IN (%s, %s)
1033 LIMIT 1",
1034 esc_sql( $name ),
1035 esc_sql( $_kb_slug ),
1036 esc_sql( $_kb_slug_enc ),
1037 esc_sql( $_cat_target_enc ),
1038 esc_sql( $_cat_target )
1039 )
1040 );
1041 }
1042
1043 // Fallback to the KB-only lookup (no category filter) when nothing
1044 // matched both KB + category. Keeps single-language behaviour intact
1045 // and lets the category-validation block below handle any genuine
1046 // mismatch by setting invalid_request_query_vars.
1047 if ( ! $_post_id ) {
1048 $_post_id = (int) $wpdb->get_var(
1049 $wpdb->prepare(
1050 "SELECT p.ID FROM {$wpdb->posts} p
1051 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1052 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'knowledge_base'
1053 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1054 WHERE p.post_name = %s AND p.post_type = 'docs'
1055 AND t.slug IN (%s, %s)
1056 LIMIT 1",
1057 esc_sql( $_encoded_name ),
1058 esc_sql( $_kb_slug ),
1059 esc_sql( $_kb_slug_enc )
1060 )
1061 );
1062 if ( ! $_post_id && $_encoded_name !== $name ) {
1063 $_post_id = (int) $wpdb->get_var(
1064 $wpdb->prepare(
1065 "SELECT p.ID FROM {$wpdb->posts} p
1066 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1067 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'knowledge_base'
1068 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1069 WHERE p.post_name = %s AND p.post_type = 'docs'
1070 AND t.slug IN (%s, %s)
1071 LIMIT 1",
1072 esc_sql( $name ),
1073 esc_sql( $_kb_slug ),
1074 esc_sql( $_kb_slug_enc )
1075 )
1076 );
1077 }
1078 }
1079 } else {
1080 // No KB in URL — disambiguate via doc_category. WPML/Polylang assign each
1081 // translated post the same post_name (e.g. "spacious-family-home..."),
1082 // so a plain `WHERE post_name = ... LIMIT 1` returns whichever language
1083 // the DB serves first. When that pick does not belong to the category in
1084 // the URL, the validation below falsely fires a 404 even though the
1085 // correctly-translated post exists. Join doc_category so we land on the
1086 // translation that actually owns the requested category.
1087 $_cat_target = $target_category_slug;
1088 $_cat_target_enc = strtolower( rawurlencode( $_cat_target ) );
1089 $_post_id = (int) $wpdb->get_var(
1090 $wpdb->prepare(
1091 "SELECT p.ID FROM {$wpdb->posts} p
1092 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1093 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'doc_category'
1094 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1095 WHERE p.post_name = %s AND p.post_type = 'docs'
1096 AND t.slug IN (%s, %s)
1097 LIMIT 1",
1098 esc_sql( $_encoded_name ),
1099 esc_sql( $_cat_target_enc ),
1100 esc_sql( $_cat_target )
1101 )
1102 );
1103 if ( ! $_post_id && $_encoded_name !== $name ) {
1104 $_post_id = (int) $wpdb->get_var(
1105 $wpdb->prepare(
1106 "SELECT p.ID FROM {$wpdb->posts} p
1107 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1108 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'doc_category'
1109 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1110 WHERE p.post_name = %s AND p.post_type = 'docs'
1111 AND t.slug IN (%s, %s)
1112 LIMIT 1",
1113 esc_sql( $name ),
1114 esc_sql( $_cat_target_enc ),
1115 esc_sql( $_cat_target )
1116 )
1117 );
1118 }
1119
1120 // Fallback: post exists with this name but not under the requested
1121 // category. Let the category-validation block below handle the 404 so
1122 // we keep the existing single-language behaviour intact.
1123 if ( ! $_post_id ) {
1124 $_post_id = (int) $wpdb->get_var(
1125 $wpdb->prepare(
1126 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
1127 esc_sql( $_encoded_name ),
1128 'docs'
1129 )
1130 );
1131 if ( ! $_post_id && $_encoded_name !== $name ) {
1132 $_post_id = (int) $wpdb->get_var(
1133 $wpdb->prepare(
1134 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
1135 esc_sql( $name ),
1136 'docs'
1137 )
1138 );
1139 }
1140 }
1141 }
1142
1143 // If post exists, validate it belongs to the category in the URL
1144 if ( $_post_id > 0 ) {
1145
1146 // When hierarchical slugs are enabled, check if post belongs to any category in the path
1147 $has_category = false;
1148
1149 if ( $this->settings->get( 'enable_category_hierarchy_slugs' ) && count($category_parts) > 1 ) {
1150
1151 // Check if post belongs to ANY category in the hierarchy path
1152 // For example, if URL is "update/overview", check for both "update" and "overview"
1153 $category_slugs_to_check = $category_parts;
1154
1155 foreach ( $category_slugs_to_check as $cat_slug ) {
1156
1157 // rawurlencode produces uppercase hex (%E0%...) but WP/Polylang stores lowercase (%e0%).
1158 // Always normalise to lowercase so the slug IN (...) comparison succeeds.
1159 $_encoded_cat = strtolower( rawurlencode( $cat_slug ) );
1160 $cat_check = $wpdb->get_var(
1161 $wpdb->prepare(
1162 "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
1163 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1164 INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
1165 WHERE tr.object_id = %d AND t.slug IN (%s, %s) AND tt.taxonomy = %s",
1166 $_post_id,
1167 esc_sql( $_encoded_cat ),
1168 esc_sql( $cat_slug ),
1169 'doc_category'
1170 )
1171 );
1172
1173 if ( $cat_check > 0 ) {
1174 // If knowledge_base is set, verify the category belongs to that KB
1175 if ( isset( $query_vars['knowledge_base'] ) ) {
1176
1177 // Get the term ID - use our helper that tries both decoded and encoded forms.
1178 $term = $this->get_term_by_slug_or_encoded( $cat_slug, 'doc_category' );
1179 if ( $term ) {
1180 $term_kbs = get_term_meta( $term->term_id, 'doc_category_knowledge_base', true );
1181
1182 // Primary check: category's stored KB meta includes the requested KB.
1183 if ( is_array( $term_kbs ) && in_array( $query_vars['knowledge_base'], $term_kbs ) ) {
1184 $has_category = true;
1185 break;
1186 }
1187
1188 // Fallback: for Polylang/multilingual sites the term meta may store the
1189 // original-language KB slug while the URL uses the translated slug.
1190 // Verify instead that the post is actually assigned to the requested KB.
1191 // wp_get_post_terms may return slugs URL-encoded (Polylang) or decoded (standard WP).
1192 // Normalise everything to lowercase for comparison.
1193 $kb_slug_url = $query_vars['knowledge_base'];
1194 $kb_slug_enc = strtolower( rawurlencode( $kb_slug_url ) );
1195 $post_kbs = wp_get_post_terms( $_post_id, 'knowledge_base', [ 'fields' => 'slugs' ] );
1196 $post_kbs_lower = array_map( 'strtolower', is_array( $post_kbs ) ? $post_kbs : [] );
1197 if ( ! is_wp_error( $post_kbs ) &&
1198 ( in_array( $kb_slug_url, $post_kbs_lower ) || in_array( $kb_slug_enc, $post_kbs_lower ) ) ) {
1199 $has_category = true;
1200 break;
1201 }
1202 }
1203 } else {
1204
1205 // No KB in URL, so any category match is valid
1206 $has_category = true;
1207 break;
1208 }
1209 }
1210 }
1211 } else {
1212 // Non-hierarchical or single category - check only the target category
1213 // Always lowercase-encode so the slug matches WP/Polylang's stored lowercase hex.
1214 $_encoded_target = strtolower( rawurlencode( $target_category_slug ) );
1215 $has_category = $wpdb->get_var(
1216 $wpdb->prepare(
1217 "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
1218 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1219 INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
1220 WHERE tr.object_id = %d AND t.slug IN (%s, %s) AND tt.taxonomy = %s",
1221 $_post_id,
1222 esc_sql( $_encoded_target ),
1223 esc_sql( $target_category_slug ),
1224 'doc_category'
1225 )
1226 );
1227
1228 // If knowledge_base is set and category was found, verify the POST belongs to that KB.
1229 // We use the post's actual KB taxonomy terms as the source of truth,
1230 // NOT the doc_category_knowledge_base meta (which can be stale or misconfigured).
1231 // Only block if the post is explicitly assigned to OTHER KBs that don't include the requested one.
1232 if ( $has_category && isset( $query_vars['knowledge_base'] ) ) {
1233 $post_kbs = wp_get_post_terms( $_post_id, 'knowledge_base', [ 'fields' => 'slugs' ] );
1234 if ( ! is_wp_error( $post_kbs ) && ! empty( $post_kbs ) ) {
1235 $kb_slug = $query_vars['knowledge_base'];
1236 // PHP's rawurlencode() produces uppercase (%E0%A6...) but WordPress/Polylang stores
1237 // slugs with lowercase hex (%e0%a6...). Normalise both sides to lowercase.
1238 $kb_slug_encoded = strtolower( rawurlencode( $kb_slug ) );
1239 $post_kbs_lower = array_map( 'strtolower', $post_kbs );
1240 // Check decoded form (standard WP) and encoded form (Polylang).
1241 if ( ! in_array( $kb_slug, $post_kbs_lower ) && ! in_array( $kb_slug_encoded, $post_kbs_lower ) ) {
1242 $has_category = false;
1243 }
1244 }
1245 }
1246
1247
1248 }
1249
1250 // Special handling for uncategorized docs
1251 if ( ! $has_category && $target_category_slug === 'uncategorized' ) {
1252 // Check if the post has no categories assigned at all
1253 $category_count = $wpdb->get_var(
1254 $wpdb->prepare(
1255 "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
1256 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1257 WHERE tr.object_id = %d AND tt.taxonomy = %s",
1258 $_post_id,
1259 'doc_category'
1260 )
1261 );
1262
1263 // If post has no categories, allow it for uncategorized URL
1264 if ( $category_count == 0 ) {
1265 $has_category = true;
1266 }
1267 }
1268
1269
1270 // If post doesn't belong to the target category, return false (404)
1271 if ( ! $has_category ) {
1272 // Remember these query vars so we can block any canonical redirect for this invalid URL
1273 $this->invalid_request_query_vars = $query_vars;
1274 return false;
1275 }
1276
1277 // If hierarchical slugs are enabled and we found a post, validate the full hierarchy
1278 if ( $this->settings->get( 'enable_category_hierarchy_slugs' ) && count($category_parts) > 1 ) {
1279 // Get the post's category terms
1280 $post_categories = wp_get_object_terms( $_post_id, 'doc_category' );
1281
1282 if ( ! empty( $post_categories ) ) {
1283 $found_valid_hierarchy = false;
1284
1285 foreach ( $post_categories as $post_category ) {
1286 // Build the hierarchy path for this category
1287 $hierarchy_path = [];
1288 $current_term = $post_category;
1289
1290 // Build path from child to parent
1291 while ( $current_term ) {
1292 array_unshift( $hierarchy_path, $current_term->slug );
1293 $current_term = $current_term->parent ? get_term( $current_term->parent, 'doc_category' ) : null;
1294 }
1295
1296 // Check if this hierarchy matches the URL structure.
1297 // WordPress/Polylang store non-Latin term slugs URL-encoded
1298 // (%e0%a6...) while $doc_category arrives decoded from
1299 // is_perma_valid_for. Compare in the decoded form so Bengali,
1300 // Arabic, CJK, etc. hierarchies actually match.
1301 $built_path = implode('/', $hierarchy_path);
1302 $built_path_norm = urldecode( $built_path );
1303 $doc_cat_norm = urldecode( $doc_category );
1304
1305 // Allow partial path matching to accommodate KB-prefixed URLs or partial hierarchies.
1306 // Using substr for broad PHP version compatibility (equivalent to str_ends_with).
1307 $is_suffix = strlen( $built_path_norm ) > 0 && substr( $doc_cat_norm, -strlen( $built_path_norm ) ) === $built_path_norm;
1308 $is_prefix = strlen( $doc_cat_norm ) > 0 && substr( $built_path_norm, -strlen( $doc_cat_norm ) ) === $doc_cat_norm;
1309
1310 if ( $built_path_norm === $doc_cat_norm || $is_suffix || $is_prefix ) {
1311 $found_valid_hierarchy = true;
1312 break;
1313 }
1314 }
1315
1316 // If no valid hierarchy found, return false (404)
1317 if ( ! $found_valid_hierarchy ) {
1318 return false;
1319 }
1320 }
1321 }
1322 }
1323 } else {
1324 // First, check if the post exists.
1325 // When MKB is active, multiple translated posts share the same slug — one per KB.
1326 // Join the KB taxonomy when knowledge_base is in the URL to find the right post.
1327 $_encoded_name = rawurlencode( $name );
1328
1329 if ( isset( $query_vars['knowledge_base'] ) && ! empty( $query_vars['knowledge_base'] ) ) {
1330 $_kb_slug = $query_vars['knowledge_base'];
1331 $_kb_slug_enc = strtolower( rawurlencode( $_kb_slug ) );
1332 $_post_id = (int) $wpdb->get_var(
1333 $wpdb->prepare(
1334 "SELECT p.ID FROM {$wpdb->posts} p
1335 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1336 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'knowledge_base'
1337 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1338 WHERE p.post_name = %s AND p.post_type = 'docs'
1339 AND t.slug IN (%s, %s)
1340 LIMIT 1",
1341 esc_sql( $_encoded_name ),
1342 esc_sql( $_kb_slug ),
1343 esc_sql( $_kb_slug_enc )
1344 )
1345 );
1346 if ( ! $_post_id && $_encoded_name !== $name ) {
1347 $_post_id = (int) $wpdb->get_var(
1348 $wpdb->prepare(
1349 "SELECT p.ID FROM {$wpdb->posts} p
1350 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1351 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'knowledge_base'
1352 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1353 WHERE p.post_name = %s AND p.post_type = 'docs'
1354 AND t.slug IN (%s, %s)
1355 LIMIT 1",
1356 esc_sql( $name ),
1357 esc_sql( $_kb_slug ),
1358 esc_sql( $_kb_slug_enc )
1359 )
1360 );
1361 }
1362 } else {
1363 $_post_id = (int) $wpdb->get_var(
1364 $wpdb->prepare(
1365 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
1366 esc_sql( $_encoded_name ),
1367 'docs'
1368 )
1369 );
1370 if ( ! $_post_id && $_encoded_name !== $name ) {
1371 $_post_id = (int) $wpdb->get_var(
1372 $wpdb->prepare(
1373 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
1374 esc_sql( $name ),
1375 'docs'
1376 )
1377 );
1378 }
1379 }
1380
1381
1382 // If knowledge_base is set, validate the post actually belongs to that KB.
1383 // wp_get_post_terms may return slugs URL-encoded (Polylang) or decoded (standard WP).
1384 // Check both forms so the match works regardless of storage format.
1385 if ( $_post_id > 0 && isset( $query_vars['knowledge_base'] ) ) {
1386 $post_kbs = wp_get_post_terms( $_post_id, 'knowledge_base', [ 'fields' => 'slugs' ] );
1387 if ( ! is_wp_error( $post_kbs ) && ! empty( $post_kbs ) ) {
1388 $kb_slug = $query_vars['knowledge_base'];
1389 $kb_slug_encoded = strtolower( rawurlencode( $kb_slug ) );
1390 $post_kbs_lower = array_map( 'strtolower', $post_kbs );
1391 if ( ! in_array( $kb_slug, $post_kbs_lower ) && ! in_array( $kb_slug_encoded, $post_kbs_lower ) ) {
1392 // Remember these query vars so we can block any canonical redirect for this invalid URL
1393 $this->invalid_request_query_vars = $query_vars;
1394 return false;
1395 }
1396 }
1397 // If post has no KB terms → allow it (not assigned to any KB explicitly).
1398 }
1399 }
1400
1401 return $_post_id > 0;
1402 }
1403
1404 protected function is_docs_category( $query_vars ) {
1405 $result = $this->term_exists( $query_vars, 'doc_category' );
1406 return $result;
1407 }
1408
1409 protected function is_docs_tag( $query_vars ) {
1410 return $this->term_exists( $query_vars, 'doc_tag' );
1411 }
1412
1413 protected function term_exists( $query_vars, $taxonomy ) {
1414 if ( ! isset( $query_vars[ $taxonomy ] ) ) {
1415 return false;
1416 }
1417
1418 // WordPress/Polylang stores non-Latin slugs URL-encoded (%e0%a6...) but the query var
1419 // arrives already decoded (e.g. বেটারডক্স). Try the decoded form first, then encoded.
1420 if ( term_exists( $query_vars[ $taxonomy ], $taxonomy ) ) {
1421 return true;
1422 }
1423 $encoded = strtolower( rawurlencode( $query_vars[ $taxonomy ] ) );
1424 if ( $encoded !== $query_vars[ $taxonomy ] && term_exists( $encoded, $taxonomy ) ) {
1425 return true;
1426 }
1427 return false;
1428 }
1429
1430 /**
1431 * Look up a taxonomy term by slug, trying both the raw (possibly Unicode-decoded) form
1432 * and the lowercase URL-encoded form that WordPress/Polylang stores for non-Latin slugs.
1433 *
1434 * @param string $slug Slug to look up (may be decoded Unicode, e.g. বেটারডক্স).
1435 * @param string $taxonomy Taxonomy name.
1436 * @return \WP_Term|false
1437 */
1438 protected function get_term_by_slug_or_encoded( $slug, $taxonomy ) {
1439 $term = get_term_by( 'slug', $slug, $taxonomy );
1440 if ( $term ) {
1441 return $term;
1442 }
1443 // Fallback: WordPress/Polylang stores non-Latin slugs as lowercase percent-encoded strings.
1444 $encoded = strtolower( rawurlencode( $slug ) );
1445 if ( $encoded !== $slug ) {
1446 $term = get_term_by( 'slug', $encoded, $taxonomy );
1447 }
1448 return $term ? $term : false;
1449 }
1450
1451 public function set_perma_structure( $structures = [] ) {
1452 $this->perma_structure = array_merge( $this->perma_structure, $structures );
1453 }
1454
1455 public function set_query_vars( $query_vars = [] ) {
1456 $this->query_vars = array_merge( $this->query_vars, $query_vars );
1457 }
1458
1459 public function backward_compability( $wp ) {
1460 if ( static::$already_parsed ) {
1461 return;
1462 }
1463
1464 $this->permalink_magic( $wp );
1465 }
1466
1467 public function parse( $wp ) {
1468 static::$already_parsed = true;
1469
1470 $this->perma_structure = apply_filters('docs_rewrite_rules', $this->perma_structure);
1471
1472 $this->permalink_magic( $wp );
1473 }
1474
1475 protected function permalink_magic( $wp ) {
1476 $this->wp_query_vars = $wp->query_vars;
1477
1478 if ( ! empty( $this->perma_structure ) ) {
1479 $_valid = [];
1480
1481 // Normalize request path: remove index.php/ and leading/trailing slashes.
1482 // urldecode is correct here: $wp->request arrives decoded by PHP/Apache, and the structure
1483 // regex patterns (e.g. "docs/%knowledge_base%") are plain ASCII, so matching works fine.
1484 // DB lookups handle the encoding separately below.
1485 $request = isset( $wp->request ) ? urldecode( $wp->request ) : '';
1486 $request = trim( preg_replace( '#^index\.php(/|$)#', '', $request ), '/' );
1487
1488 // Strip pagination segment (/page/N) before matching permalink structures.
1489 // When hierarchy slugs are enabled, the (.+?) regex for %doc_category% would
1490 // otherwise capture "/page/2" as part of the category slug, breaking pagination.
1491 $paged = 0;
1492 if ( preg_match( '#/page/([0-9]+)/?$#', $request, $page_matches ) ) {
1493 $paged = intval( $page_matches[1] );
1494 $request = preg_replace( '#/page/[0-9]+/?$#', '', $request );
1495 }
1496
1497 // Strip optional language prefix injected by Polylang/WPML (e.g. "en/", "bn/", "pt-br/")
1498 // so that "bn/docs/..." matches the structure "docs/..." correctly.
1499 $request_without_lang = preg_replace( '#^[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,8})?/#', '', $request );
1500
1501 foreach ( $this->perma_structure as $_type => $structure ) {
1502 // First try the raw (possibly language-prefixed) request, then the lang-stripped variant.
1503 // This ensures we still match non-multilingual sites without stripping valid slugs.
1504 $_perma_vars = $this->is_perma_valid_for( $structure, $request );
1505 if ( ! $_perma_vars && $request_without_lang !== $request ) {
1506 $_perma_vars = $this->is_perma_valid_for( $structure, $request_without_lang );
1507 }
1508
1509 // $_valid = empty( $_valid ) && $_perma_vars ? [ 'type' => $_type, 'query_vars' => $_perma_vars ] : $_valid;
1510 if ( ( $_perma_vars && method_exists( $this, $_type ) && call_user_func_array( [$this, $_type], [ & $_perma_vars] ) ) ) {
1511
1512 // dump( $_type, $_perma_vars );
1513 if ( $_type === 'is_single_docs' || $_type == 'is_docs_feed' || $_type == 'is_docs_author' ) {
1514 $_perma_vars['post_type'] = 'docs';
1515 }
1516 $_valid = ['type' => $_type, 'query_vars' => $_perma_vars];
1517
1518 // Single doc match is definitive — stop here so later category/KB archive
1519 // structures cannot overwrite it (e.g. is_knowledge_base_category).
1520 if ( $_type === 'is_single_docs' ) {
1521 break;
1522 }
1523 }
1524 }
1525
1526 $type = isset( $_valid['type'] ) ? $_valid['type'] : '';
1527 $query_vars = isset( $_valid['query_vars'] ) ? $_valid['query_vars'] : [];
1528
1529 // Inject the paged query var if a /page/N segment was stripped from the request.
1530 if ( $paged > 0 && ! empty( $type ) ) {
1531 $query_vars['paged'] = $paged;
1532 }
1533
1534 if ( ! empty( $type ) ) {
1535 unset( $this->query_vars[ $type ] );
1536 array_map(
1537 function ( $_vars ) use ( &$wp ) {
1538 array_map(
1539 function ( $_var ) use ( &$wp ) {
1540 unset( $wp->query_vars[ $_var ] );
1541 },
1542 $_vars
1543 );
1544 },
1545 $this->query_vars
1546 );
1547 }
1548
1549 $wp->query_vars = is_array( $query_vars ) ? array_merge( $wp->query_vars, $query_vars ) : $wp->query_vars;
1550
1551 // Fallback
1552 if ( ! empty( $_valid ) ) {
1553 unset( $wp->query_vars['attachment'] );
1554 }
1555 }
1556 }
1557
1558 /**
1559 * This method is responsible for checking a structure is valid again a request.
1560 *
1561 * @param string $structure
1562 * @param string $request
1563 * @return array|bool
1564 */
1565 private function is_perma_valid_for( $structure, $request ) {
1566 if ( empty( $structure ) ) {
1567 return false;
1568 }
1569
1570 $_tags = explode( '/', trim( $structure, '/' ) );
1571 $_replace_matched_tags = [];
1572
1573 $_replace_tags = array_filter(
1574 $_tags,
1575 function ( $item ) use ( &$_replace_matched_tags ) {
1576 $_is_valid = strpos( $item, '%' ) !== false;
1577 if ( $_is_valid ) {
1578 $_replace_matched_tags[] = trim( $item, '%' );
1579 }
1580 return $_is_valid;
1581 }
1582 );
1583
1584 // First, preg_quote the structure to safely use it in a regex
1585 $_perma_structure = preg_quote( $structure, '#' );
1586
1587 // Since our placeholders like %name% contain characters (like %) that preg_quote escapes,
1588 // we must also preg_quote the tags before searching for them in the escaped structure.
1589 foreach ( $_replace_tags as $tag ) {
1590 $tag_escaped = preg_quote( $tag, '#' );
1591 $replacement = '([^/]+)';
1592
1593 // If hierarchical slugs are enabled, allow slashes in the %doc_category% placeholder
1594 if ( $tag === '%doc_category%' && $this->settings->get( 'enable_category_hierarchy_slugs' ) ) {
1595 $replacement = '(.+?)';
1596 }
1597
1598 $_perma_structure = str_replace( $tag_escaped, $replacement, $_perma_structure );
1599 }
1600
1601 preg_match( "#^$_perma_structure$#", $request, $matches );
1602
1603 if ( empty( $matches ) || ! is_array( $matches ) ) {
1604 return false;
1605 }
1606
1607 if ( count( $matches ) === 1 ) {
1608 return [ 'post_type' => 'docs' ];
1609 }
1610
1611 unset( $matches[0] );
1612
1613 return array_combine( $_replace_matched_tags, $matches );
1614 }
1615 }
1616