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