PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.1
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.1
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
betterdocs / includes / Core / Request.php

Request.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.1, at includes/Core/Request.php

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