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