PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.2.6
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.2.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
← All changes | includes/Core/Request.php +147 -1138 4.4.14.2.6 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,10 +52,8 @@
59 52 if ( is_admin() ) {
60 53 return;
61 54 }
62 55
63 - add_action( 'template_redirect', [ $this, 'validate_request_path' ], 1 );
64 -
65 56 $this->perma_structure = [
66 57 'is_docs' => trim( $this->rewrite->get_base_slug(), '/' ),
67 58 'is_docs_feed' => trim( $this->rewrite->get_base_slug(), '/' ) . '/%feed%',
68 59 'is_docs_category' => trim( $this->settings->get( 'category_slug', 'docs-category' ), '/' ) . '/%doc_category%',
@@ -82,36 +73,8 @@
82 73
83 74 add_action( 'parse_request', [ $this, 'parse' ] );
84 75
85 76 /**
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 77 * This is for Backward compatibility if pro not updated.
115 78 */
116 79 add_action( 'parse_request', [ $this, 'backward_compability' ], 11 );
117 80
@@ -125,26 +88,8 @@
125 88 */
126 89 add_filter( 'redirect_canonical', [ $this, 'prevent_canonical_redirect_for_invalid_docs' ], 10, 2 );
127 90
128 91 /**
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 92 * Hook into template_redirect to validate category-post relationships
148 93 * Priority 0 to run before WordPress canonical redirect (priority 10)
149 94 */
150 95 add_action( 'template_redirect', [ $this, 'validate_single_docs_category_redirect' ], 0 );
@@ -157,49 +102,8 @@
157 102 return $element_id;
158 103 }
159 104
160 105 /**
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 106 * Prevent canonical redirect for invalid docs category-post combinations
203 107 *
204 108 * @param string $redirect_url The redirect URL.
205 109 * @param string $requested_url The requested URL.
@@ -207,707 +111,152 @@
207 111 */
208 112 public function prevent_canonical_redirect_for_invalid_docs( $redirect_url, $requested_url ) {
209 113 global $wp_query;
210 114
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
115 + // Check if this is a single docs query with category
222 116 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'] ) ) {
117 + isset( $wp_query->query_vars['doc_category'] ) && isset( $wp_query->query_vars['docs'] ) ) {
224 118
225 119 $doc_category = $wp_query->query_vars['doc_category'];
226 - $post_name = $wp_query->query_vars['name'];
120 + $post_name = $wp_query->query_vars['docs'];
227 121
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 - }
122 + // Handle hierarchical category slugs
123 + $category_parts = explode('/', trim($doc_category, '/'));
124 + $target_category_slug = end($category_parts);
234 125
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 - }
126 + global $wpdb;
263 127
264 - return $redirect_url;
265 - }
128 + // Check if post exists and belongs to the specified category
129 + $post_id = (int) $wpdb->get_var(
130 + $wpdb->prepare(
131 + "SELECT p.ID FROM {$wpdb->posts} p
132 + INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
133 + INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
134 + INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
135 + WHERE p.post_name = %s AND p.post_type = %s AND t.slug = %s AND tt.taxonomy = %s
136 + LIMIT 1",
137 + esc_sql( $post_name ),
138 + 'docs',
139 + esc_sql( $target_category_slug ),
140 + 'doc_category'
141 + )
142 + );
266 143
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 - }
144 + // If hierarchical slugs are enabled and we found a post, validate the full hierarchy
145 + if ( $post_id > 0 && $this->settings->get( 'enable_category_hierarchy_slugs' ) && count($category_parts) > 1 ) {
146 + $post_categories = wp_get_object_terms( $post_id, 'doc_category' );
279 147
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 - }
148 + if ( ! empty( $post_categories ) ) {
149 + $found_valid_hierarchy = false;
298 150
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 - }
151 + foreach ( $post_categories as $post_category ) {
152 + // Build the hierarchy path for this category
153 + $hierarchy_path = [];
154 + $current_term = $post_category;
315 155
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;
156 + // Build path from child to parent
157 + while ( $current_term ) {
158 + array_unshift( $hierarchy_path, $current_term->slug );
159 + $current_term = $current_term->parent ? get_term( $current_term->parent, 'doc_category' ) : null;
160 + }
321 161
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;
162 + // Check if this hierarchy matches the URL structure
163 + if ( implode('/', $hierarchy_path) === $doc_category ) {
164 + $found_valid_hierarchy = true;
442 165 break;
443 166 }
444 167 }
445 - if ( ! $found ) {
446 - return true; // Invalid category slug
168 +
169 + // If no valid hierarchy found, force 404
170 + if ( ! $found_valid_hierarchy ) {
171 + $post_id = 0;
447 172 }
448 173 }
449 174 }
450 - }
451 -
452 - return false;
453 - }
454 175
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;
176 + // If no valid post found, prevent redirect (we'll show 404 instead)
177 + if ( $post_id === 0 ) {
178 + return false;
477 179 }
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 180 }
561 181
182 + return $redirect_url;
562 183 }
563 184
564 185 /**
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.
186 + * Validate single docs category relationship on template_redirect and force 404 if invalid
567 187 */
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() {
188 + public function validate_single_docs_category_redirect() {
649 189 global $wp_query;
650 190
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 - }
191 + // Check if this is a single docs query with category
192 + if ( isset( $wp_query->query_vars['post_type'] ) && $wp_query->query_vars['post_type'] === 'docs' &&
193 + isset( $wp_query->query_vars['doc_category'] ) && isset( $wp_query->query_vars['docs'] ) ) {
655 194
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 ) {
195 + $doc_category = $wp_query->query_vars['doc_category'];
196 + $post_name = $wp_query->query_vars['docs'];
660 197
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 ) {
198 + // Handle hierarchical category slugs
199 + $category_parts = explode('/', trim($doc_category, '/'));
200 + $target_category_slug = end($category_parts);
668 201
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' ) ) {
202 + global $wpdb;
673 203
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'] ) ) {
204 + // Check if post exists and belongs to the specified category
205 + $post_id = (int) $wpdb->get_var(
206 + $wpdb->prepare(
207 + "SELECT p.ID FROM {$wpdb->posts} p
208 + INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
209 + INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
210 + INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
211 + WHERE p.post_name = %s AND p.post_type = %s AND t.slug = %s AND tt.taxonomy = %s
212 + LIMIT 1",
213 + esc_sql( $post_name ),
214 + 'docs',
215 + esc_sql( $target_category_slug ),
216 + 'doc_category'
217 + )
218 + );
691 219
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' );
220 + // If hierarchical slugs are enabled and we found a post, validate the full hierarchy
221 + if ( $post_id > 0 && $this->settings->get( 'enable_category_hierarchy_slugs' ) && count($category_parts) > 1 ) {
222 + $post_categories = wp_get_object_terms( $post_id, 'doc_category' );
706 223
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'] ) ) ) {
224 + if ( ! empty( $post_categories ) ) {
225 + $found_valid_hierarchy = false;
727 226
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 - }
227 + foreach ( $post_categories as $post_category ) {
228 + // Build the hierarchy path for this category
229 + $hierarchy_path = [];
230 + $current_term = $post_category;
736 231
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;
232 + // Build path from child to parent
233 + while ( $current_term ) {
234 + array_unshift( $hierarchy_path, $current_term->slug );
235 + $current_term = $current_term->parent ? get_term( $current_term->parent, 'doc_category' ) : null;
743 236 }
744 - }
745 237
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 - ];
238 + // Check if this hierarchy matches the URL structure
239 + if ( implode('/', $hierarchy_path) === $doc_category ) {
240 + $found_valid_hierarchy = true;
241 + break;
772 242 }
773 243 }
774 - }
775 - }
776 - }
777 244
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;
245 + // If no valid hierarchy found, force 404
246 + if ( ! $found_valid_hierarchy ) {
247 + $post_id = 0;
248 + }
825 249 }
826 250 }
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 251
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;
252 + // If no valid post found, force 404
253 + if ( $post_id === 0 ) {
254 + $wp_query->set_404();
255 + status_header( 404 );
256 + nocache_headers();
879 257 }
880 258 }
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 259 }
911 260
912 261 protected function is_docs( &$query_vars ) {
913 262 if ( ! $this->settings->get( 'builtin_doc_page', true ) ) {
@@ -935,330 +284,73 @@
935 284
936 285 global $wpdb;
937 286 $name = isset( $query_vars['docs'] ) ? $query_vars['docs'] : $query_vars['name'];
938 287
939 -
940 288 // 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'];
289 + if ( isset( $query_vars['doc_category'] ) ) {
290 + $doc_category = $query_vars['doc_category'];
943 291
292 + // Handle hierarchical category slugs (e.g., parent/child/grandchild)
293 + $category_parts = explode('/', trim($doc_category, '/'));
294 + $target_category_slug = end($category_parts); // Get the last part as the target category
944 295
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 ) );
296 + // Check if post exists and belongs to the specified category (or its hierarchy)
960 297 $_post_id = (int) $wpdb->get_var(
961 298 $wpdb->prepare(
962 299 "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
300 + INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
1086 301 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1087 302 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 ),
303 + WHERE p.post_name = %s AND p.post_type = %s AND t.slug = %s AND tt.taxonomy = %s
304 + LIMIT 1",
305 + esc_sql( $name ),
306 + 'docs',
1091 307 esc_sql( $target_category_slug ),
1092 308 'doc_category'
1093 309 )
1094 310 );
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 311
312 + // If hierarchical slugs are enabled and we found a post, validate the full hierarchy
313 + if ( $_post_id > 0 && $this->settings->get( 'enable_category_hierarchy_slugs' ) && count($category_parts) > 1 ) {
314 + // Get the post's category terms
315 + $post_categories = wp_get_object_terms( $_post_id, 'doc_category' );
1115 316
1116 - }
317 + if ( ! empty( $post_categories ) ) {
318 + $found_valid_hierarchy = false;
1117 319
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 - );
320 + foreach ( $post_categories as $post_category ) {
321 + // Build the hierarchy path for this category
322 + $hierarchy_path = [];
323 + $current_term = $post_category;
1130 324
1131 - // If post has no categories, allow it for uncategorized URL
1132 - if ( $category_count == 0 ) {
1133 - $has_category = true;
1134 - }
1135 - }
325 + // Build path from child to parent
326 + while ( $current_term ) {
327 + array_unshift( $hierarchy_path, $current_term->slug );
328 + $current_term = $current_term->parent ? get_term( $current_term->parent, 'doc_category' ) : null;
329 + }
1136 330
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;
1142 - return false;
1143 - }
1144 -
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' );
1149 -
1150 - if ( ! empty( $post_categories ) ) {
1151 - $found_valid_hierarchy = false;
1152 -
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;
331 + // Check if this hierarchy matches the URL structure
332 + if ( implode('/', $hierarchy_path) === $doc_category ) {
333 + $found_valid_hierarchy = true;
334 + break;
335 + }
1162 336 }
1163 337
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;
338 + // If no valid hierarchy found, return false (404)
339 + if ( ! $found_valid_hierarchy ) {
340 + $_post_id = 0;
1175 341 }
1176 342 }
1177 -
1178 - // If no valid hierarchy found, return false (404)
1179 - if ( ! $found_valid_hierarchy ) {
1180 - return false;
1181 - }
1182 343 }
1183 - }
1184 - }
1185 344 } 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 - }
345 + // Fallback to original behavior if no category is specified
346 + $_post_id = (int) $wpdb->get_var(
347 + $wpdb->prepare(
348 + "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
349 + esc_sql( $name ),
350 + 'docs'
351 + )
352 + );
1261 353 }
1262 354
1263 355 return $_post_id > 0;
1264 356 }
@@ -1263,10 +355,9 @@
1263 355 return $_post_id > 0;
1264 356 }
1265 357
1266 358 protected function is_docs_category( $query_vars ) {
1267 - $result = $this->term_exists( $query_vars, 'doc_category' );
1268 - return $result;
359 + return $this->term_exists( $query_vars, 'doc_category' );
1269 360 }
1270 361
1271 362 protected function is_docs_tag( $query_vars ) {
1272 363 return $this->term_exists( $query_vars, 'doc_tag' );
@@ -1276,41 +367,11 @@
1276 367 if ( ! isset( $query_vars[ $taxonomy ] ) ) {
1277 368 return false;
1278 369 }
1279 370
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;
371 + return term_exists( $query_vars[ $taxonomy ], $taxonomy );
1290 372 }
1291 373
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 374 public function set_perma_structure( $structures = [] ) {
1314 375 $this->perma_structure = array_merge( $this->perma_structure, $structures );
1315 376 }
1316 377
@@ -1339,50 +400,18 @@
1339 400
1340 401 if ( ! empty( $this->perma_structure ) ) {
1341 402 $_valid = [];
1342 403
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 404 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 - }
405 + $_perma_vars = $this->is_perma_valid_for( $structure, $wp->request );
1370 406
1371 407 // $_valid = empty( $_valid ) && $_perma_vars ? [ 'type' => $_type, 'query_vars' => $_perma_vars ] : $_valid;
1372 408 if ( ( $_perma_vars && method_exists( $this, $_type ) && call_user_func_array( [$this, $_type], [ & $_perma_vars] ) ) ) {
1373 -
1374 409 // dump( $_type, $_perma_vars );
1375 410 if ( $_type === 'is_single_docs' || $_type == 'is_docs_feed' || $_type == 'is_docs_author' ) {
1376 411 $_perma_vars['post_type'] = 'docs';
1377 412 }
1378 413 $_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;
1384 - }
1385 414 }
1386 415 }
1387 416
1388 417 $type = isset( $_valid['type'] ) ? $_valid['type'] : '';
@@ -1387,13 +416,8 @@
1387 416
1388 417 $type = isset( $_valid['type'] ) ? $_valid['type'] : '';
1389 418 $query_vars = isset( $_valid['query_vars'] ) ? $_valid['query_vars'] : [];
1390 419
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 420 if ( ! empty( $type ) ) {
1397 421 unset( $this->query_vars[ $type ] );
1398 422 array_map(
1399 423 function ( $_vars ) use ( &$wp ) {
@@ -1408,9 +432,8 @@
1408 432 );
1409 433 }
1410 434
1411 435 $wp->query_vars = is_array( $query_vars ) ? array_merge( $wp->query_vars, $query_vars ) : $wp->query_vars;
1412 -
1413 436 // Fallback
1414 437 if ( ! empty( $_valid ) ) {
1415 438 unset( $wp->query_vars['attachment'] );
1416 439 }
@@ -1442,26 +465,12 @@
1442 465 return $_is_valid;
1443 466 }
1444 467 );
1445 468
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 - }
469 + $_perma_structure = preg_quote( $structure, '/' );
470 + $_perma_structure = str_replace( $_replace_tags, '([^\/]+)', $_perma_structure );
1462 471
1463 - preg_match( "#^$_perma_structure$#", $request, $matches );
472 + preg_match( "/^$_perma_structure$/", $request, $matches );
1464 473
1465 474 if ( empty( $matches ) || ! is_array( $matches ) ) {
1466 475 return false;
1467 476 }