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 -1118 4.4.04.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,701 +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 - // Normalize base slug
581 - $docs_slug = $this->rewrite->get_base_slug();
582 -
583 - // If user is using a custom page as root, use that page's path
584 - if ( ! $this->settings->get( 'builtin_doc_page', true ) ) {
585 - $docs_page_id = $this->settings->get( 'docs_page', 0 );
586 - if ( $docs_page_id ) {
587 - $page_path = get_page_uri( $docs_page_id );
588 - if ( $page_path ) {
589 - $docs_slug = $page_path;
590 - }
591 - }
592 - }
593 -
594 - $docs_slug = trim( $docs_slug, '/' );
595 -
596 - if ( empty( $docs_slug ) ) {
597 - return;
598 - }
599 -
600 - // Check if this is a query we should validate
601 - $is_docs_query = is_singular( 'docs' ) || is_post_type_archive( 'docs' ) || is_tax( [ 'doc_category', 'knowledge_base', 'doc_tag' ] );
602 - $looks_like_docs_url = strpos( $request_path, $docs_slug ) !== false;
603 -
604 - // We validate if it's explicitly a docs query, OR if it looks like a docs URL but fell back to home/archive
605 - if ( ! $is_docs_query && ! ( $looks_like_docs_url && is_home() ) ) {
606 - return;
607 - }
608 -
609 - // If WordPress already correctly resolved this as a single docs post, trust that resolution.
610 - // The post was found and is valid — no need to validate the URL prefix at all.
611 - // Path validation is only meaningful for archive/tax pages whose URL leaked past the docs slug.
612 - if ( is_singular( 'docs' ) ) {
613 - return;
614 - }
615 -
616 - // Check if request path strictly starts with docs slug, category slug, or tag slug
617 - // Using # as delimiter, need to preg_quote
618 - $valid_prefixes = [
619 - preg_quote( $docs_slug, '#' ),
620 - preg_quote( trim( $this->settings->get( 'category_slug', 'docs-category' ), '/' ), '#' ),
621 - preg_quote( trim( $this->settings->get( 'tag_slug', 'docs-tag' ), '/' ), '#' )
622 - ];
623 - $valid_prefixes = array_filter( $valid_prefixes );
624 -
625 - // Allow optional language prefixes (e.g. /en/, /pt-br/) for WPML/Polylang/TranslatePress compatibility
626 - $lang_pattern = '(?:[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,4})?/)?';
627 -
628 - $prefix_pattern = '#^' . $lang_pattern . '(' . implode( '|', $valid_prefixes ) . ')(/|$)#';
629 -
630 - if ( ! preg_match( $prefix_pattern, $request_path ) ) {
631 - global $wp_query;
632 - $wp_query->set_404();
633 - status_header( 404 );
634 - nocache_headers();
635 - }
636 - }
637 -
638 - /**
639 - * Re-apply taxonomy flags on template_redirect
640 - * This ensures the flags stick even if WordPress or other plugins reset them
641 - */
642 - public function reapply_taxonomy_flags() {
188 + public function validate_single_docs_category_redirect() {
643 189 global $wp_query;
644 190
645 - // If we found invalid query vars, do not mess with the query flags.
646 - if ( $this->invalid_request_query_vars !== null ) {
647 - return;
648 - }
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'] ) ) {
649 194
650 - // Check if we have doc_category or knowledge_base in query vars
651 - if ( isset( $wp_query->query_vars['doc_category'] ) && ! empty( $wp_query->query_vars['doc_category'] ) ) {
652 - // If this is already identified as singular, don't override it
653 - if ( $wp_query->is_singular() || $wp_query->is_singular ) {
195 + $doc_category = $wp_query->query_vars['doc_category'];
196 + $post_name = $wp_query->query_vars['docs'];
654 197
655 - // Ensure it's not marked as 404
656 - $wp_query->is_404 = false;
657 - return;
658 - }
659 -
660 - // Check if we have a post ID set (p query var)
661 - if ( isset( $wp_query->query_vars['p'] ) && $wp_query->query_vars['p'] > 0 ) {
198 + // Handle hierarchical category slugs
199 + $category_parts = explode('/', trim($doc_category, '/'));
200 + $target_category_slug = end($category_parts);
662 201
663 -
664 - // Security check: if this is a private post and user can't read private docs, show 404
665 - $post = get_post( $wp_query->query_vars['p'] );
666 - if ( $post && $post->post_status === 'private' && ! current_user_can( 'read_private_docs' ) ) {
202 + global $wpdb;
667 203
668 - $wp_query->is_404 = true;
669 - $wp_query->is_single = false;
670 - $wp_query->is_singular = false;
671 - return;
672 - }
673 -
674 - // Explicitly set this as a single post, not an archive or 404
675 - $wp_query->is_single = true;
676 - $wp_query->is_singular = true;
677 - $wp_query->is_404 = false;
678 - $wp_query->is_archive = false;
679 - $wp_query->is_tax = false;
680 - return;
681 - }
682 -
683 - // Check if we have 'docs' query var (alternative to 'name')
684 - if ( isset( $wp_query->query_vars['docs'] ) && ! empty( $wp_query->query_vars['docs'] ) ) {
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 + );
685 219
686 - // Explicitly set this as a single post
687 - $wp_query->is_single = true;
688 - $wp_query->is_singular = true;
689 - $wp_query->is_404 = false;
690 - $wp_query->is_archive = false;
691 - $wp_query->is_tax = false;
692 - return;
693 - }
694 -
695 - // If 'name' is set, check if a post with that name exists
696 - // This prevents posts from being incorrectly treated as category archives
697 - // (important when post slug == category slug, e.g. docs/old/new/new)
698 - if ( isset( $wp_query->query_vars['name'] ) && ! empty( $wp_query->query_vars['name'] ) ) {
699 - $post_exists = get_page_by_path( $wp_query->query_vars['name'], OBJECT, 'docs' );
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' );
700 223
701 - if ( $post_exists ) {
702 - // A post exists - explicitly mark as single post and clear any taxonomy flags.
703 - // Without this, WP may leave is_tax=true (set during parse_request because
704 - // doc_category is also present), causing redirect_canonical to redirect
705 - // the correct single-post URL to the category archive URL.
706 - $wp_query->is_single = true;
707 - $wp_query->is_singular = true;
708 - $wp_query->is_404 = false;
709 - $wp_query->is_archive = false;
710 - $wp_query->is_tax = false;
711 - $wp_query->queried_object = $post_exists;
712 - $wp_query->queried_object_id = $post_exists->ID;
713 - return;
714 - }
715 - }
716 -
717 - // Only set taxonomy flags if none of the above conditions are met (pure category archive)
718 - if ( ( ! isset( $wp_query->query_vars['name'] ) || empty( $wp_query->query_vars['name'] ) ) &&
719 - ( ! isset( $wp_query->query_vars['p'] ) || $wp_query->query_vars['p'] <= 0 ) &&
720 - ( ! isset( $wp_query->query_vars['docs'] ) || empty( $wp_query->query_vars['docs'] ) ) ) {
224 + if ( ! empty( $post_categories ) ) {
225 + $found_valid_hierarchy = false;
721 226
722 - // Ensure the queried object is set or fetch the term
723 - $term = null;
724 - if ( isset( $wp_query->queried_object ) && $wp_query->queried_object ) {
725 - $term = $wp_query->queried_object;
726 - } else {
727 - // WordPress/Polylang may store non-Latin slugs URL-encoded; try both forms.
728 - $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['doc_category'], 'doc_category' );
729 - }
227 + foreach ( $post_categories as $post_category ) {
228 + // Build the hierarchy path for this category
229 + $hierarchy_path = [];
230 + $current_term = $post_category;
730 231
731 - // Only if the term effectively exists, we set the flags
732 - if ( $term && ! is_wp_error( $term ) ) {
733 - // Also validate knowledge_base if present
734 - if ( isset( $wp_query->query_vars['knowledge_base'] ) && ! empty( $wp_query->query_vars['knowledge_base'] ) ) {
735 - if ( ! $this->get_term_by_slug_or_encoded( $wp_query->query_vars['knowledge_base'], 'knowledge_base' ) ) {
736 - return;
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;
737 236 }
738 - }
739 237
740 - // Re-apply the taxonomy flags
741 - $wp_query->is_tax = true;
742 - $wp_query->is_archive = true;
743 - $wp_query->is_home = false;
744 - $wp_query->is_404 = false;
745 -
746 - if ( ! isset( $wp_query->queried_object ) || ! $wp_query->queried_object ) {
747 - $wp_query->queried_object = $term;
748 - $wp_query->queried_object_id = $term->term_id;
749 -
750 - // Set up tax_query using proper WP_Tax_Query class
751 - if ( ! isset( $wp_query->tax_query ) || ! is_a( $wp_query->tax_query, 'WP_Tax_Query' ) ) {
752 - $tax_query_args = [
753 - [
754 - 'taxonomy' => 'doc_category',
755 - 'field' => 'slug',
756 - 'terms' => [ $term->slug ]
757 - ]
758 - ];
759 - $wp_query->tax_query = new \WP_Tax_Query( $tax_query_args );
760 - $wp_query->tax_query->queried_terms = [
761 - 'doc_category' => [
762 - 'terms' => [ $term->slug ],
763 - 'field' => 'slug'
764 - ]
765 - ];
238 + // Check if this hierarchy matches the URL structure
239 + if ( implode('/', $hierarchy_path) === $doc_category ) {
240 + $found_valid_hierarchy = true;
241 + break;
766 242 }
767 243 }
768 - }
769 - }
770 - }
771 244
772 - }
773 -
774 - /**
775 - * Debug template redirect to see the query state
776 - */
777 - /**
778 - * Prevent 404 status for valid taxonomy archives
779 - *
780 - * @param string $status_header The HTTP status header
781 - * @param int $code The HTTP status code
782 - * @return string The modified status header
783 - */
784 - public function prevent_404_status( $status_header, $code ) {
785 - global $wp_query;
786 -
787 - // If we've explicitly marked this request as invalid (malformed KB/category slug), respect the 404!
788 - if ( $this->invalid_request_query_vars !== null ) {
789 - return $status_header;
790 - }
791 -
792 - // If a 404 is being sent but the queried object is a valid single docs post,
793 - // override with 200. This guards against false 404s on single docs pages.
794 - if ( $code == 404 &&
795 - isset( $wp_query->queried_object ) &&
796 - $wp_query->queried_object instanceof \WP_Post &&
797 - $wp_query->queried_object->post_type === 'docs' &&
798 - in_array( $wp_query->queried_object->post_status, [ 'publish', 'private' ], true )
799 - ) {
800 - // Only allow if the current user can actually read this post
801 - if ( 'publish' === $wp_query->queried_object->post_status ||
802 - current_user_can( 'read_private_posts', $wp_query->queried_object->ID ) ) {
803 - return 'HTTP/1.1 200 OK';
804 - }
805 - }
806 -
807 - // If this is a 404 but we have doc_category or doc_tag query vars, change it to 200
808 - // We check the query vars instead of is_tax because the flags get reset by WordPress
809 - if ( $code == 404 && (
810 - (isset($wp_query->query_vars['doc_category']) && ! empty($wp_query->query_vars['doc_category'])) ||
811 - (isset($wp_query->query_vars['doc_tag']) && ! empty($wp_query->query_vars['doc_tag']))
812 - ) ) {
813 - // Validate existence before forcing 200
814 - // Use encoded fallback so Bengali/Arabic/CJK slugs are found correctly.
815 - if ( isset($wp_query->query_vars['doc_category']) && ! empty($wp_query->query_vars['doc_category']) ) {
816 - $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['doc_category'], 'doc_category' );
817 - if ( ! $term || is_wp_error( $term ) ) {
818 - return $status_header;
245 + // If no valid hierarchy found, force 404
246 + if ( ! $found_valid_hierarchy ) {
247 + $post_id = 0;
248 + }
819 249 }
820 250 }
821 - if ( isset($wp_query->query_vars['doc_tag']) && ! empty($wp_query->query_vars['doc_tag']) ) {
822 - $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['doc_tag'], 'doc_tag' );
823 - if ( ! $term || is_wp_error( $term ) ) {
824 - return $status_header;
825 - }
826 - }
827 - if ( isset($wp_query->query_vars['knowledge_base']) && ! empty($wp_query->query_vars['knowledge_base']) ) {
828 - $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['knowledge_base'], 'knowledge_base' );
829 - if ( ! $term || is_wp_error( $term ) ) {
830 - return $status_header;
831 - }
832 - }
833 251
834 - return 'HTTP/1.1 200 OK';
835 - }
836 -
837 - return $status_header;
838 - }
839 -
840 - /**
841 - * Ensure tax_query is always initialized as an object
842 - * This prevents null reference errors from WPML and other plugins
843 - * Only applies to BetterDocs post type and taxonomies
844 - */
845 - public function ensure_tax_query_initialized() {
846 - global $wp_query;
847 -
848 - // Only apply to BetterDocs-related queries
849 - $is_betterdocs_query = false;
850 -
851 - // Check if this is a docs post type query
852 - if ( isset( $wp_query->query_vars['post_type'] ) && $wp_query->query_vars['post_type'] === 'docs' ) {
853 - $is_betterdocs_query = true;
854 - }
855 -
856 - // Check if this is a BetterDocs taxonomy query
857 - if ( isset( $wp_query->query_vars['doc_category'] ) && ! empty( $wp_query->query_vars['doc_category'] ) ) {
858 - $is_betterdocs_query = true;
859 - }
860 -
861 - if ( isset( $wp_query->query_vars['doc_tag'] ) && ! empty( $wp_query->query_vars['doc_tag'] ) ) {
862 - $is_betterdocs_query = true;
863 - }
864 -
865 - if ( isset( $wp_query->query_vars['knowledge_base'] ) && ! empty( $wp_query->query_vars['knowledge_base'] ) ) {
866 - $is_betterdocs_query = true;
867 - }
868 -
869 - // Check if queried object is a BetterDocs taxonomy term
870 - if ( isset( $wp_query->queried_object ) && isset( $wp_query->queried_object->taxonomy ) ) {
871 - if ( in_array( $wp_query->queried_object->taxonomy, [ 'doc_category', 'doc_tag', 'knowledge_base' ] ) ) {
872 - $is_betterdocs_query = true;
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();
873 257 }
874 258 }
875 -
876 - // Only proceed if this is a BetterDocs-related query
877 - if ( ! $is_betterdocs_query ) {
878 - return;
879 - }
880 -
881 - // Only initialize if it's not already a proper WP_Tax_Query instance
882 - if ( ! isset( $wp_query->tax_query ) || ! is_a( $wp_query->tax_query, 'WP_Tax_Query' ) ) {
883 - // Create a proper WP_Tax_Query instance with empty queries
884 - $wp_query->tax_query = new \WP_Tax_Query( [] );
885 - $wp_query->tax_query->queried_terms = [];
886 - }
887 -
888 - // For WPML compatibility: if queried_object is null, set it to an empty object
889 - // but only if we're actually on a taxonomy page (is_tax is true)
890 - if ( ! isset( $wp_query->queried_object ) && $wp_query->is_tax ) {
891 - // Create a minimal WP_Term-like object to prevent errors
892 - $wp_query->queried_object = new \stdClass();
893 - $wp_query->queried_object->term_id = 0;
894 - $wp_query->queried_object->name = '';
895 - $wp_query->queried_object->slug = '';
896 - $wp_query->queried_object->term_group = 0;
897 - $wp_query->queried_object->term_taxonomy_id = 0;
898 - $wp_query->queried_object->taxonomy = 'doc_category';
899 - $wp_query->queried_object->description = '';
900 - $wp_query->queried_object->parent = 0;
901 - $wp_query->queried_object->count = 0;
902 - $wp_query->queried_object->filter = 'raw';
903 - }
904 259 }
905 260
906 261 protected function is_docs( &$query_vars ) {
907 262 if ( ! $this->settings->get( 'builtin_doc_page', true ) ) {
@@ -929,330 +284,73 @@
929 284
930 285 global $wpdb;
931 286 $name = isset( $query_vars['docs'] ) ? $query_vars['docs'] : $query_vars['name'];
932 287
933 -
934 288 // If doc_category is specified in the URL, validate that the post belongs to that category
935 - if ( isset( $query_vars['doc_category'] ) ) {
936 - $doc_category = $query_vars['doc_category'];
289 + if ( isset( $query_vars['doc_category'] ) ) {
290 + $doc_category = $query_vars['doc_category'];
937 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
938 295
939 - // Handle hierarchical category slugs (e.g., parent/child/grandchild)
940 - $category_parts = explode('/', trim($doc_category, '/'));
941 - $target_category_slug = end($category_parts); // Get the last part as the target category
942 -
943 -
944 - // First, check if the post exists.
945 - // When MKB is active, multiple translated posts share the same slug — one per KB.
946 - // We MUST join the KB taxonomy so we select the post for the correct language/KB.
947 - // Polylang/multilingual plugins store post_name URL-encoded; try the encoded form first.
948 - $_encoded_name = rawurlencode( $name );
949 -
950 - if ( isset( $query_vars['knowledge_base'] ) && ! empty( $query_vars['knowledge_base'] ) ) {
951 - // KB-aware lookup: only select the post that is assigned to this KB.
952 - $_kb_slug = $query_vars['knowledge_base'];
953 - $_kb_slug_enc = strtolower( rawurlencode( $_kb_slug ) );
296 + // Check if post exists and belongs to the specified category (or its hierarchy)
954 297 $_post_id = (int) $wpdb->get_var(
955 298 $wpdb->prepare(
956 299 "SELECT p.ID FROM {$wpdb->posts} p
957 - INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
958 - INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'knowledge_base'
959 - INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
960 - WHERE p.post_name = %s AND p.post_type = 'docs'
961 - AND t.slug IN (%s, %s)
962 - LIMIT 1",
963 - esc_sql( $_encoded_name ),
964 - esc_sql( $_kb_slug ),
965 - esc_sql( $_kb_slug_enc )
966 - )
967 - );
968 - // Fallback: post_name stored as decoded Unicode
969 - if ( ! $_post_id && $_encoded_name !== $name ) {
970 - $_post_id = (int) $wpdb->get_var(
971 - $wpdb->prepare(
972 - "SELECT p.ID FROM {$wpdb->posts} p
973 - INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
974 - INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'knowledge_base'
975 - INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
976 - WHERE p.post_name = %s AND p.post_type = 'docs'
977 - AND t.slug IN (%s, %s)
978 - LIMIT 1",
979 - esc_sql( $name ),
980 - esc_sql( $_kb_slug ),
981 - esc_sql( $_kb_slug_enc )
982 - )
983 - );
984 - }
985 - } else {
986 - // No KB in URL — use the simple post_name lookup (single-KB sites).
987 - $_post_id = (int) $wpdb->get_var(
988 - $wpdb->prepare(
989 - "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
990 - esc_sql( $_encoded_name ),
991 - 'docs'
992 - )
993 - );
994 - if ( ! $_post_id && $_encoded_name !== $name ) {
995 - $_post_id = (int) $wpdb->get_var(
996 - $wpdb->prepare(
997 - "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
998 - esc_sql( $name ),
999 - 'docs'
1000 - )
1001 - );
1002 - }
1003 - }
1004 -
1005 - // If post exists, validate it belongs to the category in the URL
1006 - if ( $_post_id > 0 ) {
1007 -
1008 - // When hierarchical slugs are enabled, check if post belongs to any category in the path
1009 - $has_category = false;
1010 -
1011 - if ( $this->settings->get( 'enable_category_hierarchy_slugs' ) && count($category_parts) > 1 ) {
1012 -
1013 - // Check if post belongs to ANY category in the hierarchy path
1014 - // For example, if URL is "update/overview", check for both "update" and "overview"
1015 - $category_slugs_to_check = $category_parts;
1016 -
1017 - foreach ( $category_slugs_to_check as $cat_slug ) {
1018 -
1019 - // rawurlencode produces uppercase hex (%E0%...) but WP/Polylang stores lowercase (%e0%).
1020 - // Always normalise to lowercase so the slug IN (...) comparison succeeds.
1021 - $_encoded_cat = strtolower( rawurlencode( $cat_slug ) );
1022 - $cat_check = $wpdb->get_var(
1023 - $wpdb->prepare(
1024 - "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
1025 - INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1026 - INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
1027 - WHERE tr.object_id = %d AND t.slug IN (%s, %s) AND tt.taxonomy = %s",
1028 - $_post_id,
1029 - esc_sql( $_encoded_cat ),
1030 - esc_sql( $cat_slug ),
1031 - 'doc_category'
1032 - )
1033 - );
1034 -
1035 - if ( $cat_check > 0 ) {
1036 - // If knowledge_base is set, verify the category belongs to that KB
1037 - if ( isset( $query_vars['knowledge_base'] ) ) {
1038 -
1039 - // Get the term ID - use our helper that tries both decoded and encoded forms.
1040 - $term = $this->get_term_by_slug_or_encoded( $cat_slug, 'doc_category' );
1041 - if ( $term ) {
1042 - $term_kbs = get_term_meta( $term->term_id, 'doc_category_knowledge_base', true );
1043 -
1044 - // Primary check: category's stored KB meta includes the requested KB.
1045 - if ( is_array( $term_kbs ) && in_array( $query_vars['knowledge_base'], $term_kbs ) ) {
1046 - $has_category = true;
1047 - break;
1048 - }
1049 -
1050 - // Fallback: for Polylang/multilingual sites the term meta may store the
1051 - // original-language KB slug while the URL uses the translated slug.
1052 - // Verify instead that the post is actually assigned to the requested KB.
1053 - // wp_get_post_terms may return slugs URL-encoded (Polylang) or decoded (standard WP).
1054 - // Normalise everything to lowercase for comparison.
1055 - $kb_slug_url = $query_vars['knowledge_base'];
1056 - $kb_slug_enc = strtolower( rawurlencode( $kb_slug_url ) );
1057 - $post_kbs = wp_get_post_terms( $_post_id, 'knowledge_base', [ 'fields' => 'slugs' ] );
1058 - $post_kbs_lower = array_map( 'strtolower', is_array( $post_kbs ) ? $post_kbs : [] );
1059 - if ( ! is_wp_error( $post_kbs ) &&
1060 - ( in_array( $kb_slug_url, $post_kbs_lower ) || in_array( $kb_slug_enc, $post_kbs_lower ) ) ) {
1061 - $has_category = true;
1062 - break;
1063 - }
1064 - }
1065 - } else {
1066 -
1067 - // No KB in URL, so any category match is valid
1068 - $has_category = true;
1069 - break;
1070 - }
1071 - }
1072 - }
1073 - } else {
1074 - // Non-hierarchical or single category - check only the target category
1075 - // Always lowercase-encode so the slug matches WP/Polylang's stored lowercase hex.
1076 - $_encoded_target = strtolower( rawurlencode( $target_category_slug ) );
1077 - $has_category = $wpdb->get_var(
1078 - $wpdb->prepare(
1079 - "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
300 + INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
1080 301 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1081 302 INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
1082 - WHERE tr.object_id = %d AND t.slug IN (%s, %s) AND tt.taxonomy = %s",
1083 - $_post_id,
1084 - esc_sql( $_encoded_target ),
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',
1085 307 esc_sql( $target_category_slug ),
1086 308 'doc_category'
1087 309 )
1088 310 );
1089 -
1090 - // If knowledge_base is set and category was found, verify the POST belongs to that KB.
1091 - // We use the post's actual KB taxonomy terms as the source of truth,
1092 - // NOT the doc_category_knowledge_base meta (which can be stale or misconfigured).
1093 - // Only block if the post is explicitly assigned to OTHER KBs that don't include the requested one.
1094 - if ( $has_category && isset( $query_vars['knowledge_base'] ) ) {
1095 - $post_kbs = wp_get_post_terms( $_post_id, 'knowledge_base', [ 'fields' => 'slugs' ] );
1096 - if ( ! is_wp_error( $post_kbs ) && ! empty( $post_kbs ) ) {
1097 - $kb_slug = $query_vars['knowledge_base'];
1098 - // PHP's rawurlencode() produces uppercase (%E0%A6...) but WordPress/Polylang stores
1099 - // slugs with lowercase hex (%e0%a6...). Normalise both sides to lowercase.
1100 - $kb_slug_encoded = strtolower( rawurlencode( $kb_slug ) );
1101 - $post_kbs_lower = array_map( 'strtolower', $post_kbs );
1102 - // Check decoded form (standard WP) and encoded form (Polylang).
1103 - if ( ! in_array( $kb_slug, $post_kbs_lower ) && ! in_array( $kb_slug_encoded, $post_kbs_lower ) ) {
1104 - $has_category = false;
1105 - }
1106 - }
1107 - }
1108 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' );
1109 316
1110 - }
317 + if ( ! empty( $post_categories ) ) {
318 + $found_valid_hierarchy = false;
1111 319
1112 - // Special handling for uncategorized docs
1113 - if ( ! $has_category && $target_category_slug === 'uncategorized' ) {
1114 - // Check if the post has no categories assigned at all
1115 - $category_count = $wpdb->get_var(
1116 - $wpdb->prepare(
1117 - "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
1118 - INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1119 - WHERE tr.object_id = %d AND tt.taxonomy = %s",
1120 - $_post_id,
1121 - 'doc_category'
1122 - )
1123 - );
320 + foreach ( $post_categories as $post_category ) {
321 + // Build the hierarchy path for this category
322 + $hierarchy_path = [];
323 + $current_term = $post_category;
1124 324
1125 - // If post has no categories, allow it for uncategorized URL
1126 - if ( $category_count == 0 ) {
1127 - $has_category = true;
1128 - }
1129 - }
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 + }
1130 330
1131 -
1132 - // If post doesn't belong to the target category, return false (404)
1133 - if ( ! $has_category ) {
1134 - // Remember these query vars so we can block any canonical redirect for this invalid URL
1135 - $this->invalid_request_query_vars = $query_vars;
1136 - return false;
1137 - }
1138 -
1139 - // If hierarchical slugs are enabled and we found a post, validate the full hierarchy
1140 - if ( $this->settings->get( 'enable_category_hierarchy_slugs' ) && count($category_parts) > 1 ) {
1141 - // Get the post's category terms
1142 - $post_categories = wp_get_object_terms( $_post_id, 'doc_category' );
1143 -
1144 - if ( ! empty( $post_categories ) ) {
1145 - $found_valid_hierarchy = false;
1146 -
1147 - foreach ( $post_categories as $post_category ) {
1148 - // Build the hierarchy path for this category
1149 - $hierarchy_path = [];
1150 - $current_term = $post_category;
1151 -
1152 - // Build path from child to parent
1153 - while ( $current_term ) {
1154 - array_unshift( $hierarchy_path, $current_term->slug );
1155 - $current_term = $current_term->parent ? get_term( $current_term->parent, 'doc_category' ) : null;
331 + // Check if this hierarchy matches the URL structure
332 + if ( implode('/', $hierarchy_path) === $doc_category ) {
333 + $found_valid_hierarchy = true;
334 + break;
335 + }
1156 336 }
1157 337
1158 - // Check if this hierarchy matches the URL structure
1159 - $built_path = implode('/', $hierarchy_path);
1160 -
1161 - // Allow partial path matching to accommodate KB-prefixed URLs or partial hierarchies.
1162 - // Using substr for broad PHP version compatibility (equivalent to str_ends_with).
1163 - $is_suffix = strlen($built_path) > 0 && substr($doc_category, -strlen($built_path)) === $built_path;
1164 - $is_prefix = strlen($doc_category) > 0 && substr($built_path, -strlen($doc_category)) === $doc_category;
1165 -
1166 - if ( $built_path === $doc_category || $is_suffix || $is_prefix ) {
1167 - $found_valid_hierarchy = true;
1168 - break;
338 + // If no valid hierarchy found, return false (404)
339 + if ( ! $found_valid_hierarchy ) {
340 + $_post_id = 0;
1169 341 }
1170 342 }
1171 -
1172 - // If no valid hierarchy found, return false (404)
1173 - if ( ! $found_valid_hierarchy ) {
1174 - return false;
1175 - }
1176 343 }
1177 - }
1178 - }
1179 344 } else {
1180 - // First, check if the post exists.
1181 - // When MKB is active, multiple translated posts share the same slug — one per KB.
1182 - // Join the KB taxonomy when knowledge_base is in the URL to find the right post.
1183 - $_encoded_name = rawurlencode( $name );
1184 -
1185 - if ( isset( $query_vars['knowledge_base'] ) && ! empty( $query_vars['knowledge_base'] ) ) {
1186 - $_kb_slug = $query_vars['knowledge_base'];
1187 - $_kb_slug_enc = strtolower( rawurlencode( $_kb_slug ) );
1188 - $_post_id = (int) $wpdb->get_var(
1189 - $wpdb->prepare(
1190 - "SELECT p.ID FROM {$wpdb->posts} p
1191 - INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1192 - INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'knowledge_base'
1193 - INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1194 - WHERE p.post_name = %s AND p.post_type = 'docs'
1195 - AND t.slug IN (%s, %s)
1196 - LIMIT 1",
1197 - esc_sql( $_encoded_name ),
1198 - esc_sql( $_kb_slug ),
1199 - esc_sql( $_kb_slug_enc )
1200 - )
1201 - );
1202 - if ( ! $_post_id && $_encoded_name !== $name ) {
1203 - $_post_id = (int) $wpdb->get_var(
1204 - $wpdb->prepare(
1205 - "SELECT p.ID FROM {$wpdb->posts} p
1206 - INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1207 - INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'knowledge_base'
1208 - INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1209 - WHERE p.post_name = %s AND p.post_type = 'docs'
1210 - AND t.slug IN (%s, %s)
1211 - LIMIT 1",
1212 - esc_sql( $name ),
1213 - esc_sql( $_kb_slug ),
1214 - esc_sql( $_kb_slug_enc )
1215 - )
1216 - );
1217 - }
1218 - } else {
1219 - $_post_id = (int) $wpdb->get_var(
1220 - $wpdb->prepare(
1221 - "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
1222 - esc_sql( $_encoded_name ),
1223 - 'docs'
1224 - )
1225 - );
1226 - if ( ! $_post_id && $_encoded_name !== $name ) {
1227 - $_post_id = (int) $wpdb->get_var(
1228 - $wpdb->prepare(
1229 - "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
1230 - esc_sql( $name ),
1231 - 'docs'
1232 - )
1233 - );
1234 - }
1235 - }
1236 -
1237 -
1238 - // If knowledge_base is set, validate the post actually belongs to that KB.
1239 - // wp_get_post_terms may return slugs URL-encoded (Polylang) or decoded (standard WP).
1240 - // Check both forms so the match works regardless of storage format.
1241 - if ( $_post_id > 0 && isset( $query_vars['knowledge_base'] ) ) {
1242 - $post_kbs = wp_get_post_terms( $_post_id, 'knowledge_base', [ 'fields' => 'slugs' ] );
1243 - if ( ! is_wp_error( $post_kbs ) && ! empty( $post_kbs ) ) {
1244 - $kb_slug = $query_vars['knowledge_base'];
1245 - $kb_slug_encoded = strtolower( rawurlencode( $kb_slug ) );
1246 - $post_kbs_lower = array_map( 'strtolower', $post_kbs );
1247 - if ( ! in_array( $kb_slug, $post_kbs_lower ) && ! in_array( $kb_slug_encoded, $post_kbs_lower ) ) {
1248 - // Remember these query vars so we can block any canonical redirect for this invalid URL
1249 - $this->invalid_request_query_vars = $query_vars;
1250 - return false;
1251 - }
1252 - }
1253 - // If post has no KB terms → allow it (not assigned to any KB explicitly).
1254 - }
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 + );
1255 353 }
1256 354
1257 355 return $_post_id > 0;
1258 356 }
@@ -1257,10 +355,9 @@
1257 355 return $_post_id > 0;
1258 356 }
1259 357
1260 358 protected function is_docs_category( $query_vars ) {
1261 - $result = $this->term_exists( $query_vars, 'doc_category' );
1262 - return $result;
359 + return $this->term_exists( $query_vars, 'doc_category' );
1263 360 }
1264 361
1265 362 protected function is_docs_tag( $query_vars ) {
1266 363 return $this->term_exists( $query_vars, 'doc_tag' );
@@ -1270,41 +367,11 @@
1270 367 if ( ! isset( $query_vars[ $taxonomy ] ) ) {
1271 368 return false;
1272 369 }
1273 370
1274 - // WordPress/Polylang stores non-Latin slugs URL-encoded (%e0%a6...) but the query var
1275 - // arrives already decoded (e.g. বেটারডক্স). Try the decoded form first, then encoded.
1276 - if ( term_exists( $query_vars[ $taxonomy ], $taxonomy ) ) {
1277 - return true;
1278 - }
1279 - $encoded = strtolower( rawurlencode( $query_vars[ $taxonomy ] ) );
1280 - if ( $encoded !== $query_vars[ $taxonomy ] && term_exists( $encoded, $taxonomy ) ) {
1281 - return true;
1282 - }
1283 - return false;
371 + return term_exists( $query_vars[ $taxonomy ], $taxonomy );
1284 372 }
1285 373
1286 - /**
1287 - * Look up a taxonomy term by slug, trying both the raw (possibly Unicode-decoded) form
1288 - * and the lowercase URL-encoded form that WordPress/Polylang stores for non-Latin slugs.
1289 - *
1290 - * @param string $slug Slug to look up (may be decoded Unicode, e.g. বেটারডক্স).
1291 - * @param string $taxonomy Taxonomy name.
1292 - * @return \WP_Term|false
1293 - */
1294 - protected function get_term_by_slug_or_encoded( $slug, $taxonomy ) {
1295 - $term = get_term_by( 'slug', $slug, $taxonomy );
1296 - if ( $term ) {
1297 - return $term;
1298 - }
1299 - // Fallback: WordPress/Polylang stores non-Latin slugs as lowercase percent-encoded strings.
1300 - $encoded = strtolower( rawurlencode( $slug ) );
1301 - if ( $encoded !== $slug ) {
1302 - $term = get_term_by( 'slug', $encoded, $taxonomy );
1303 - }
1304 - return $term ? $term : false;
1305 - }
1306 -
1307 374 public function set_perma_structure( $structures = [] ) {
1308 375 $this->perma_structure = array_merge( $this->perma_structure, $structures );
1309 376 }
1310 377
@@ -1333,41 +400,18 @@
1333 400
1334 401 if ( ! empty( $this->perma_structure ) ) {
1335 402 $_valid = [];
1336 403
1337 - // Normalize request path: remove index.php/ and leading/trailing slashes.
1338 - // urldecode is correct here: $wp->request arrives decoded by PHP/Apache, and the structure
1339 - // regex patterns (e.g. "docs/%knowledge_base%") are plain ASCII, so matching works fine.
1340 - // DB lookups handle the encoding separately below.
1341 - $request = isset( $wp->request ) ? urldecode( $wp->request ) : '';
1342 - $request = trim( preg_replace( '#^index\.php(/|$)#', '', $request ), '/' );
1343 -
1344 - // Strip optional language prefix injected by Polylang/WPML (e.g. "en/", "bn/", "pt-br/")
1345 - // so that "bn/docs/..." matches the structure "docs/..." correctly.
1346 - $request_without_lang = preg_replace( '#^[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,8})?/#', '', $request );
1347 -
1348 404 foreach ( $this->perma_structure as $_type => $structure ) {
1349 - // First try the raw (possibly language-prefixed) request, then the lang-stripped variant.
1350 - // This ensures we still match non-multilingual sites without stripping valid slugs.
1351 - $_perma_vars = $this->is_perma_valid_for( $structure, $request );
1352 - if ( ! $_perma_vars && $request_without_lang !== $request ) {
1353 - $_perma_vars = $this->is_perma_valid_for( $structure, $request_without_lang );
1354 - }
405 + $_perma_vars = $this->is_perma_valid_for( $structure, $wp->request );
1355 406
1356 407 // $_valid = empty( $_valid ) && $_perma_vars ? [ 'type' => $_type, 'query_vars' => $_perma_vars ] : $_valid;
1357 408 if ( ( $_perma_vars && method_exists( $this, $_type ) && call_user_func_array( [$this, $_type], [ & $_perma_vars] ) ) ) {
1358 -
1359 409 // dump( $_type, $_perma_vars );
1360 410 if ( $_type === 'is_single_docs' || $_type == 'is_docs_feed' || $_type == 'is_docs_author' ) {
1361 411 $_perma_vars['post_type'] = 'docs';
1362 412 }
1363 413 $_valid = ['type' => $_type, 'query_vars' => $_perma_vars];
1364 -
1365 - // Single doc match is definitive — stop here so later category/KB archive
1366 - // structures cannot overwrite it (e.g. is_knowledge_base_category).
1367 - if ( $_type === 'is_single_docs' ) {
1368 - break;
1369 - }
1370 414 }
1371 415 }
1372 416
1373 417 $type = isset( $_valid['type'] ) ? $_valid['type'] : '';
@@ -1388,9 +432,8 @@
1388 432 );
1389 433 }
1390 434
1391 435 $wp->query_vars = is_array( $query_vars ) ? array_merge( $wp->query_vars, $query_vars ) : $wp->query_vars;
1392 -
1393 436 // Fallback
1394 437 if ( ! empty( $_valid ) ) {
1395 438 unset( $wp->query_vars['attachment'] );
1396 439 }
@@ -1422,26 +465,12 @@
1422 465 return $_is_valid;
1423 466 }
1424 467 );
1425 468
1426 - // First, preg_quote the structure to safely use it in a regex
1427 - $_perma_structure = preg_quote( $structure, '#' );
1428 -
1429 - // Since our placeholders like %name% contain characters (like %) that preg_quote escapes,
1430 - // we must also preg_quote the tags before searching for them in the escaped structure.
1431 - foreach ( $_replace_tags as $tag ) {
1432 - $tag_escaped = preg_quote( $tag, '#' );
1433 - $replacement = '([^/]+)';
1434 -
1435 - // If hierarchical slugs are enabled, allow slashes in the %doc_category% placeholder
1436 - if ( $tag === '%doc_category%' && $this->settings->get( 'enable_category_hierarchy_slugs' ) ) {
1437 - $replacement = '(.+?)';
1438 - }
1439 -
1440 - $_perma_structure = str_replace( $tag_escaped, $replacement, $_perma_structure );
1441 - }
469 + $_perma_structure = preg_quote( $structure, '/' );
470 + $_perma_structure = str_replace( $_replace_tags, '([^\/]+)', $_perma_structure );
1442 471
1443 - preg_match( "#^$_perma_structure$#", $request, $matches );
472 + preg_match( "/^$_perma_structure$/", $request, $matches );
1444 473
1445 474 if ( empty( $matches ) || ! is_array( $matches ) ) {
1446 475 return false;
1447 476 }