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

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