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