PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.3
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.3
4.9.3 4.9.2 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 All 201 releases
betterdocs / includes / Core / Request.php

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

1,948 lines 72.1 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 // Verify the requested doc actually exists (and is visible to the
953 // current user) before forcing a single-doc render. Category-in-path
954 // permalinks put the doc slug in the `docs` var alongside a valid
955 // doc_category; without this guard a missing slug under a real
956 // category rendered the single template against a null post — an
957 // HTTP 200 soft-404 plus "read property on null" warnings on every
958 // (often bot) hit. See betterdocs/betterdocs#169.
959 $doc_post = get_page_by_path( $wp_query->query_vars['docs'], OBJECT, 'docs' );
960 $doc_visible = $doc_post
961 && ( 'private' !== $doc_post->post_status || current_user_can( 'read_private_docs' ) );
962
963 if ( ! $doc_visible ) {
964 // No such doc (or private and not permitted) — serve a genuine
965 // 404 instead of a single render against a null post.
966 $wp_query->set_404();
967 status_header( 404 );
968 nocache_headers();
969 add_filter( 'template_include', function( $template ) {
970 $not_found = get_404_template();
971 return $not_found ? $not_found : $template;
972 }, 999 );
973 return;
974 }
975
976 // Explicitly set this as a single post
977 $wp_query->is_single = true;
978 $wp_query->is_singular = true;
979 $wp_query->is_404 = false;
980 $wp_query->is_archive = false;
981 $wp_query->is_tax = false;
982 $wp_query->queried_object = $doc_post;
983 $wp_query->queried_object_id = $doc_post->ID;
984 return;
985 }
986
987 // If 'name' is set, check if a post with that name exists
988 // This prevents posts from being incorrectly treated as category archives
989 // (important when post slug == category slug, e.g. docs/old/new/new)
990 if ( isset( $wp_query->query_vars['name'] ) && ! empty( $wp_query->query_vars['name'] ) ) {
991 $post_exists = get_page_by_path( $wp_query->query_vars['name'], OBJECT, 'docs' );
992
993 if ( $post_exists ) {
994 // A post exists - explicitly mark as single post and clear any taxonomy flags.
995 // Without this, WP may leave is_tax=true (set during parse_request because
996 // doc_category is also present), causing redirect_canonical to redirect
997 // the correct single-post URL to the category archive URL.
998 $wp_query->is_single = true;
999 $wp_query->is_singular = true;
1000 $wp_query->is_404 = false;
1001 $wp_query->is_archive = false;
1002 $wp_query->is_tax = false;
1003 $wp_query->queried_object = $post_exists;
1004 $wp_query->queried_object_id = $post_exists->ID;
1005 return;
1006 }
1007 }
1008
1009 // Only set taxonomy flags if none of the above conditions are met (pure category archive)
1010 if ( ( ! isset( $wp_query->query_vars['name'] ) || empty( $wp_query->query_vars['name'] ) ) &&
1011 ( ! isset( $wp_query->query_vars['p'] ) || $wp_query->query_vars['p'] <= 0 ) &&
1012 ( ! isset( $wp_query->query_vars['docs'] ) || empty( $wp_query->query_vars['docs'] ) ) ) {
1013
1014 // Ensure the queried object is set or fetch the term
1015 $term = null;
1016 if ( isset( $wp_query->queried_object ) && $wp_query->queried_object ) {
1017 $term = $wp_query->queried_object;
1018 } else {
1019 // WordPress/Polylang may store non-Latin slugs URL-encoded; try both forms.
1020 $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['doc_category'], 'doc_category' );
1021 }
1022
1023 // Only if the term effectively exists, we set the flags
1024 if ( $term && ! is_wp_error( $term ) ) {
1025 // Also validate knowledge_base if present
1026 if ( isset( $wp_query->query_vars['knowledge_base'] ) && ! empty( $wp_query->query_vars['knowledge_base'] ) ) {
1027 if ( ! $this->get_term_by_slug_or_encoded( $wp_query->query_vars['knowledge_base'], 'knowledge_base' ) ) {
1028 return;
1029 }
1030 }
1031
1032 // Re-apply the taxonomy flags
1033 $wp_query->is_tax = true;
1034 $wp_query->is_archive = true;
1035 $wp_query->is_home = false;
1036 $wp_query->is_404 = false;
1037
1038 if ( ! isset( $wp_query->queried_object ) || ! $wp_query->queried_object ) {
1039 $wp_query->queried_object = $term;
1040 $wp_query->queried_object_id = $term->term_id;
1041
1042 // Set up tax_query using proper WP_Tax_Query class
1043 if ( ! isset( $wp_query->tax_query ) || ! is_a( $wp_query->tax_query, 'WP_Tax_Query' ) ) {
1044 $tax_query_args = [
1045 [
1046 'taxonomy' => 'doc_category',
1047 'field' => 'slug',
1048 'terms' => [ $term->slug ]
1049 ]
1050 ];
1051 $wp_query->tax_query = new \WP_Tax_Query( $tax_query_args );
1052 $wp_query->tax_query->queried_terms = [
1053 'doc_category' => [
1054 'terms' => [ $term->slug ],
1055 'field' => 'slug'
1056 ]
1057 ];
1058 }
1059 }
1060 }
1061 }
1062 }
1063
1064 }
1065
1066 /**
1067 * Debug template redirect to see the query state
1068 */
1069 /**
1070 * Prevent 404 status for valid taxonomy archives
1071 *
1072 * @param string $status_header The HTTP status header
1073 * @param int $code The HTTP status code
1074 * @return string The modified status header
1075 */
1076 public function prevent_404_status( $status_header, $code ) {
1077 global $wp_query;
1078
1079 // If we've explicitly marked this request as invalid (malformed KB/category slug), respect the 404!
1080 if ( $this->invalid_request_query_vars !== null ) {
1081 return $status_header;
1082 }
1083
1084 // If a 404 is being sent but the queried object is a valid single docs post,
1085 // override with 200. This guards against false 404s on single docs pages.
1086 if ( $code == 404 &&
1087 isset( $wp_query->queried_object ) &&
1088 $wp_query->queried_object instanceof \WP_Post &&
1089 $wp_query->queried_object->post_type === 'docs' &&
1090 in_array( $wp_query->queried_object->post_status, [ 'publish', 'private' ], true )
1091 ) {
1092 // Only allow if the current user can actually read this post
1093 if ( 'publish' === $wp_query->queried_object->post_status ||
1094 current_user_can( 'read_private_posts', $wp_query->queried_object->ID ) ) {
1095 return 'HTTP/1.1 200 OK';
1096 }
1097 }
1098
1099 // If this is a 404 but we have doc_category or doc_tag query vars, change it to 200
1100 // We check the query vars instead of is_tax because the flags get reset by WordPress
1101 if ( $code == 404 && (
1102 (isset($wp_query->query_vars['doc_category']) && ! empty($wp_query->query_vars['doc_category'])) ||
1103 (isset($wp_query->query_vars['doc_tag']) && ! empty($wp_query->query_vars['doc_tag']))
1104 ) ) {
1105 // A single-doc request (category-in-path permalinks carry the doc slug
1106 // in the `docs`/`name` var alongside doc_category) must resolve to a real
1107 // doc. A valid doc is already served 200 by the queried-object branch
1108 // above; if we reach here with a single-doc indicator but no resolvable
1109 // post, the doc does not exist — keep the genuine 404 rather than forcing
1110 // a soft-404 200 off the category term alone. See betterdocs/betterdocs#169.
1111 $single_doc_slug = '';
1112 if ( isset( $wp_query->query_vars['docs'] ) && ! empty( $wp_query->query_vars['docs'] ) ) {
1113 $single_doc_slug = $wp_query->query_vars['docs'];
1114 } elseif ( isset( $wp_query->query_vars['name'] ) && ! empty( $wp_query->query_vars['name'] ) ) {
1115 $single_doc_slug = $wp_query->query_vars['name'];
1116 }
1117 if ( '' !== $single_doc_slug && ! get_page_by_path( $single_doc_slug, OBJECT, 'docs' ) ) {
1118 return $status_header;
1119 }
1120
1121 // Validate existence before forcing 200
1122 // Use encoded fallback so Bengali/Arabic/CJK slugs are found correctly.
1123 if ( isset($wp_query->query_vars['doc_category']) && ! empty($wp_query->query_vars['doc_category']) ) {
1124 $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['doc_category'], 'doc_category' );
1125 if ( ! $term || is_wp_error( $term ) ) {
1126 return $status_header;
1127 }
1128 }
1129 if ( isset($wp_query->query_vars['doc_tag']) && ! empty($wp_query->query_vars['doc_tag']) ) {
1130 $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['doc_tag'], 'doc_tag' );
1131 if ( ! $term || is_wp_error( $term ) ) {
1132 return $status_header;
1133 }
1134 }
1135 if ( isset($wp_query->query_vars['knowledge_base']) && ! empty($wp_query->query_vars['knowledge_base']) ) {
1136 $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['knowledge_base'], 'knowledge_base' );
1137 if ( ! $term || is_wp_error( $term ) ) {
1138 return $status_header;
1139 }
1140 }
1141
1142 return 'HTTP/1.1 200 OK';
1143 }
1144
1145 return $status_header;
1146 }
1147
1148 /**
1149 * Ensure tax_query is always initialized as an object
1150 * This prevents null reference errors from WPML and other plugins
1151 * Only applies to BetterDocs post type and taxonomies
1152 */
1153 public function ensure_tax_query_initialized() {
1154 global $wp_query;
1155
1156 // Only apply to BetterDocs-related queries
1157 $is_betterdocs_query = false;
1158
1159 // Check if this is a docs post type query
1160 if ( isset( $wp_query->query_vars['post_type'] ) && $wp_query->query_vars['post_type'] === 'docs' ) {
1161 $is_betterdocs_query = true;
1162 }
1163
1164 // Check if this is a BetterDocs taxonomy query
1165 if ( isset( $wp_query->query_vars['doc_category'] ) && ! empty( $wp_query->query_vars['doc_category'] ) ) {
1166 $is_betterdocs_query = true;
1167 }
1168
1169 if ( isset( $wp_query->query_vars['doc_tag'] ) && ! empty( $wp_query->query_vars['doc_tag'] ) ) {
1170 $is_betterdocs_query = true;
1171 }
1172
1173 if ( isset( $wp_query->query_vars['knowledge_base'] ) && ! empty( $wp_query->query_vars['knowledge_base'] ) ) {
1174 $is_betterdocs_query = true;
1175 }
1176
1177 // Check if queried object is a BetterDocs taxonomy term
1178 if ( isset( $wp_query->queried_object ) && isset( $wp_query->queried_object->taxonomy ) ) {
1179 if ( in_array( $wp_query->queried_object->taxonomy, [ 'doc_category', 'doc_tag', 'knowledge_base' ] ) ) {
1180 $is_betterdocs_query = true;
1181 }
1182 }
1183
1184 // Only proceed if this is a BetterDocs-related query
1185 if ( ! $is_betterdocs_query ) {
1186 return;
1187 }
1188
1189 // Only initialize if it's not already a proper WP_Tax_Query instance
1190 if ( ! isset( $wp_query->tax_query ) || ! is_a( $wp_query->tax_query, 'WP_Tax_Query' ) ) {
1191 // Create a proper WP_Tax_Query instance with empty queries
1192 $wp_query->tax_query = new \WP_Tax_Query( [] );
1193 $wp_query->tax_query->queried_terms = [];
1194 }
1195
1196 // For WPML compatibility: if queried_object is null, set it to an empty object
1197 // but only if we're actually on a taxonomy page (is_tax is true)
1198 if ( ! isset( $wp_query->queried_object ) && $wp_query->is_tax ) {
1199 // Create a minimal WP_Term-like object to prevent errors
1200 $wp_query->queried_object = new \stdClass();
1201 $wp_query->queried_object->term_id = 0;
1202 $wp_query->queried_object->name = '';
1203 $wp_query->queried_object->slug = '';
1204 $wp_query->queried_object->term_group = 0;
1205 $wp_query->queried_object->term_taxonomy_id = 0;
1206 $wp_query->queried_object->taxonomy = 'doc_category';
1207 $wp_query->queried_object->description = '';
1208 $wp_query->queried_object->parent = 0;
1209 $wp_query->queried_object->count = 0;
1210 $wp_query->queried_object->filter = 'raw';
1211 }
1212 }
1213
1214 protected function is_docs( &$query_vars ) {
1215 if ( ! $this->settings->get( 'builtin_doc_page', true ) ) {
1216 $query_vars['post_type'] = 'page';
1217 $query_vars['name'] = trim( $this->rewrite->get_base_slug(), '/' );
1218 }
1219
1220 return $query_vars;
1221 }
1222
1223 public function is_docs_feed( $query_vars ) {
1224 global $wp_rewrite;
1225 return isset( $query_vars['feed'] ) && in_array( $query_vars['feed'], $wp_rewrite->feeds );
1226 }
1227
1228 public function is_docs_author( $query_vars ) {
1229 return isset( $query_vars['author'] ) ? true : false;
1230 }
1231
1232 protected function is_single_docs( $query_vars ) {
1233 // Check for both 'name' and 'docs' query variables
1234 if ( ! isset( $query_vars['name'] ) && ! isset( $query_vars['docs'] ) ) {
1235 return false;
1236 }
1237
1238 // 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.
1239 global $wpdb;
1240 $name = isset( $query_vars['docs'] ) ? $query_vars['docs'] : $query_vars['name'];
1241
1242
1243 // If doc_category is specified in the URL, validate that the post belongs to that category
1244 if ( isset( $query_vars['doc_category'] ) ) {
1245 $doc_category = $query_vars['doc_category'];
1246
1247
1248 // Handle hierarchical category slugs (e.g., parent/child/grandchild)
1249 $category_parts = explode('/', trim($doc_category, '/'));
1250 $target_category_slug = end($category_parts); // Get the last part as the target category
1251
1252
1253 // First, check if the post exists.
1254 // When MKB is active, multiple translated posts share the same slug — one per KB.
1255 // We MUST join the KB taxonomy so we select the post for the correct language/KB.
1256 // Polylang/multilingual plugins store post_name URL-encoded; try the encoded form first.
1257 $_encoded_name = rawurlencode( $name );
1258
1259 if ( isset( $query_vars['knowledge_base'] ) && ! empty( $query_vars['knowledge_base'] ) ) {
1260 // KB-aware lookup: only select the post that is assigned to this KB.
1261 // Also join doc_category when present so that, on Polylang/WPML sites
1262 // where multiple translated posts share both the same post_name and
1263 // the same KB term (e.g. all language variants assigned to the
1264 // "advice" KB), we land on the translation whose doc_category matches
1265 // the URL — not the one the DB happens to return first.
1266 $_kb_slug = $query_vars['knowledge_base'];
1267 $_kb_slug_enc = strtolower( rawurlencode( $_kb_slug ) );
1268
1269 $_cat_target = $target_category_slug;
1270 $_cat_target_enc = strtolower( rawurlencode( $_cat_target ) );
1271
1272 $_post_id = (int) $wpdb->get_var(
1273 $wpdb->prepare(
1274 "SELECT p.ID FROM {$wpdb->posts} p
1275 INNER JOIN {$wpdb->term_relationships} tr_kb ON tr_kb.object_id = p.ID
1276 INNER JOIN {$wpdb->term_taxonomy} tt_kb ON tt_kb.term_taxonomy_id = tr_kb.term_taxonomy_id AND tt_kb.taxonomy = 'knowledge_base'
1277 INNER JOIN {$wpdb->terms} t_kb ON t_kb.term_id = tt_kb.term_id
1278 INNER JOIN {$wpdb->term_relationships} tr_cat ON tr_cat.object_id = p.ID
1279 INNER JOIN {$wpdb->term_taxonomy} tt_cat ON tt_cat.term_taxonomy_id = tr_cat.term_taxonomy_id AND tt_cat.taxonomy = 'doc_category'
1280 INNER JOIN {$wpdb->terms} t_cat ON t_cat.term_id = tt_cat.term_id
1281 WHERE p.post_name = %s AND p.post_type = 'docs'
1282 AND t_kb.slug IN (%s, %s)
1283 AND t_cat.slug IN (%s, %s)
1284 LIMIT 1",
1285 esc_sql( $_encoded_name ),
1286 esc_sql( $_kb_slug ),
1287 esc_sql( $_kb_slug_enc ),
1288 esc_sql( $_cat_target_enc ),
1289 esc_sql( $_cat_target )
1290 )
1291 );
1292 // Fallback: post_name stored as decoded Unicode
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_kb ON tr_kb.object_id = p.ID
1298 INNER JOIN {$wpdb->term_taxonomy} tt_kb ON tt_kb.term_taxonomy_id = tr_kb.term_taxonomy_id AND tt_kb.taxonomy = 'knowledge_base'
1299 INNER JOIN {$wpdb->terms} t_kb ON t_kb.term_id = tt_kb.term_id
1300 INNER JOIN {$wpdb->term_relationships} tr_cat ON tr_cat.object_id = p.ID
1301 INNER JOIN {$wpdb->term_taxonomy} tt_cat ON tt_cat.term_taxonomy_id = tr_cat.term_taxonomy_id AND tt_cat.taxonomy = 'doc_category'
1302 INNER JOIN {$wpdb->terms} t_cat ON t_cat.term_id = tt_cat.term_id
1303 WHERE p.post_name = %s AND p.post_type = 'docs'
1304 AND t_kb.slug IN (%s, %s)
1305 AND t_cat.slug IN (%s, %s)
1306 LIMIT 1",
1307 esc_sql( $name ),
1308 esc_sql( $_kb_slug ),
1309 esc_sql( $_kb_slug_enc ),
1310 esc_sql( $_cat_target_enc ),
1311 esc_sql( $_cat_target )
1312 )
1313 );
1314 }
1315
1316 // Fallback to the KB-only lookup (no category filter) when nothing
1317 // matched both KB + category. Keeps single-language behaviour intact
1318 // and lets the category-validation block below handle any genuine
1319 // mismatch by setting invalid_request_query_vars.
1320 if ( ! $_post_id ) {
1321 $_post_id = (int) $wpdb->get_var(
1322 $wpdb->prepare(
1323 "SELECT p.ID FROM {$wpdb->posts} p
1324 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1325 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'knowledge_base'
1326 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1327 WHERE p.post_name = %s AND p.post_type = 'docs'
1328 AND t.slug IN (%s, %s)
1329 LIMIT 1",
1330 esc_sql( $_encoded_name ),
1331 esc_sql( $_kb_slug ),
1332 esc_sql( $_kb_slug_enc )
1333 )
1334 );
1335 if ( ! $_post_id && $_encoded_name !== $name ) {
1336 $_post_id = (int) $wpdb->get_var(
1337 $wpdb->prepare(
1338 "SELECT p.ID FROM {$wpdb->posts} p
1339 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1340 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'knowledge_base'
1341 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1342 WHERE p.post_name = %s AND p.post_type = 'docs'
1343 AND t.slug IN (%s, %s)
1344 LIMIT 1",
1345 esc_sql( $name ),
1346 esc_sql( $_kb_slug ),
1347 esc_sql( $_kb_slug_enc )
1348 )
1349 );
1350 }
1351 }
1352 } else {
1353 // No KB in URL — disambiguate via doc_category. WPML/Polylang assign each
1354 // translated post the same post_name (e.g. "spacious-family-home..."),
1355 // so a plain `WHERE post_name = ... LIMIT 1` returns whichever language
1356 // the DB serves first. When that pick does not belong to the category in
1357 // the URL, the validation below falsely fires a 404 even though the
1358 // correctly-translated post exists. Join doc_category so we land on the
1359 // translation that actually owns the requested category.
1360 $_cat_target = $target_category_slug;
1361 $_cat_target_enc = strtolower( rawurlencode( $_cat_target ) );
1362 $_post_id = (int) $wpdb->get_var(
1363 $wpdb->prepare(
1364 "SELECT p.ID FROM {$wpdb->posts} p
1365 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1366 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'doc_category'
1367 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1368 WHERE p.post_name = %s AND p.post_type = 'docs'
1369 AND t.slug IN (%s, %s)
1370 LIMIT 1",
1371 esc_sql( $_encoded_name ),
1372 esc_sql( $_cat_target_enc ),
1373 esc_sql( $_cat_target )
1374 )
1375 );
1376 if ( ! $_post_id && $_encoded_name !== $name ) {
1377 $_post_id = (int) $wpdb->get_var(
1378 $wpdb->prepare(
1379 "SELECT p.ID FROM {$wpdb->posts} p
1380 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1381 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'doc_category'
1382 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1383 WHERE p.post_name = %s AND p.post_type = 'docs'
1384 AND t.slug IN (%s, %s)
1385 LIMIT 1",
1386 esc_sql( $name ),
1387 esc_sql( $_cat_target_enc ),
1388 esc_sql( $_cat_target )
1389 )
1390 );
1391 }
1392
1393 // Fallback: post exists with this name but not under the requested
1394 // category. Let the category-validation block below handle the 404 so
1395 // we keep the existing single-language behaviour intact.
1396 if ( ! $_post_id ) {
1397 $_post_id = (int) $wpdb->get_var(
1398 $wpdb->prepare(
1399 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
1400 esc_sql( $_encoded_name ),
1401 'docs'
1402 )
1403 );
1404 if ( ! $_post_id && $_encoded_name !== $name ) {
1405 $_post_id = (int) $wpdb->get_var(
1406 $wpdb->prepare(
1407 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
1408 esc_sql( $name ),
1409 'docs'
1410 )
1411 );
1412 }
1413 }
1414 }
1415
1416 // If post exists, validate it belongs to the category in the URL
1417 if ( $_post_id > 0 ) {
1418
1419 // When hierarchical slugs are enabled, check if post belongs to any category in the path
1420 $has_category = false;
1421
1422 if ( $this->settings->get( 'enable_category_hierarchy_slugs' ) && count($category_parts) > 1 ) {
1423
1424 // Check if post belongs to ANY category in the hierarchy path
1425 // For example, if URL is "update/overview", check for both "update" and "overview"
1426 $category_slugs_to_check = $category_parts;
1427
1428 foreach ( $category_slugs_to_check as $cat_slug ) {
1429
1430 // rawurlencode produces uppercase hex (%E0%...) but WP/Polylang stores lowercase (%e0%).
1431 // Always normalise to lowercase so the slug IN (...) comparison succeeds.
1432 $_encoded_cat = strtolower( rawurlencode( $cat_slug ) );
1433 $cat_check = $wpdb->get_var(
1434 $wpdb->prepare(
1435 "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
1436 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1437 INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
1438 WHERE tr.object_id = %d AND t.slug IN (%s, %s) AND tt.taxonomy = %s",
1439 $_post_id,
1440 esc_sql( $_encoded_cat ),
1441 esc_sql( $cat_slug ),
1442 'doc_category'
1443 )
1444 );
1445
1446 if ( $cat_check > 0 ) {
1447 // If knowledge_base is set, verify the category belongs to that KB
1448 if ( isset( $query_vars['knowledge_base'] ) ) {
1449
1450 // Get the term ID - use our helper that tries both decoded and encoded forms.
1451 $term = $this->get_term_by_slug_or_encoded( $cat_slug, 'doc_category' );
1452 if ( $term ) {
1453 $term_kbs = get_term_meta( $term->term_id, 'doc_category_knowledge_base', true );
1454
1455 // Primary check: category's stored KB meta includes the requested KB.
1456 if ( is_array( $term_kbs ) && in_array( $query_vars['knowledge_base'], $term_kbs ) ) {
1457 $has_category = true;
1458 break;
1459 }
1460
1461 // Fallback: for Polylang/multilingual sites the term meta may store the
1462 // original-language KB slug while the URL uses the translated slug.
1463 // Verify instead that the post is actually assigned to the requested KB.
1464 // wp_get_post_terms may return slugs URL-encoded (Polylang) or decoded (standard WP).
1465 // Normalise everything to lowercase for comparison.
1466 $kb_slug_url = $query_vars['knowledge_base'];
1467 $kb_slug_enc = strtolower( rawurlencode( $kb_slug_url ) );
1468 $post_kbs = wp_get_post_terms( $_post_id, 'knowledge_base', [ 'fields' => 'slugs' ] );
1469 $post_kbs_lower = array_map( 'strtolower', is_array( $post_kbs ) ? $post_kbs : [] );
1470 if ( ! is_wp_error( $post_kbs ) &&
1471 ( in_array( $kb_slug_url, $post_kbs_lower ) || in_array( $kb_slug_enc, $post_kbs_lower ) ) ) {
1472 $has_category = true;
1473 break;
1474 }
1475 }
1476 } else {
1477
1478 // No KB in URL, so any category match is valid
1479 $has_category = true;
1480 break;
1481 }
1482 }
1483 }
1484 } else {
1485 // Non-hierarchical or single category - check only the target category
1486 // Always lowercase-encode so the slug matches WP/Polylang's stored lowercase hex.
1487 $_encoded_target = strtolower( rawurlencode( $target_category_slug ) );
1488 $has_category = $wpdb->get_var(
1489 $wpdb->prepare(
1490 "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
1491 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1492 INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
1493 WHERE tr.object_id = %d AND t.slug IN (%s, %s) AND tt.taxonomy = %s",
1494 $_post_id,
1495 esc_sql( $_encoded_target ),
1496 esc_sql( $target_category_slug ),
1497 'doc_category'
1498 )
1499 );
1500
1501 // If knowledge_base is set and category was found, verify the POST belongs to that KB.
1502 // We use the post's actual KB taxonomy terms as the source of truth,
1503 // NOT the doc_category_knowledge_base meta (which can be stale or misconfigured).
1504 // Only block if the post is explicitly assigned to OTHER KBs that don't include the requested one.
1505 if ( $has_category && isset( $query_vars['knowledge_base'] ) ) {
1506 $post_kbs = wp_get_post_terms( $_post_id, 'knowledge_base', [ 'fields' => 'slugs' ] );
1507 if ( ! is_wp_error( $post_kbs ) && ! empty( $post_kbs ) ) {
1508 $kb_slug = $query_vars['knowledge_base'];
1509 // PHP's rawurlencode() produces uppercase (%E0%A6...) but WordPress/Polylang stores
1510 // slugs with lowercase hex (%e0%a6...). Normalise both sides to lowercase.
1511 $kb_slug_encoded = strtolower( rawurlencode( $kb_slug ) );
1512 $post_kbs_lower = array_map( 'strtolower', $post_kbs );
1513 // Check decoded form (standard WP) and encoded form (Polylang).
1514 if ( ! in_array( $kb_slug, $post_kbs_lower ) && ! in_array( $kb_slug_encoded, $post_kbs_lower ) ) {
1515 $has_category = false;
1516 }
1517 }
1518 }
1519
1520
1521 }
1522
1523 // Special handling for uncategorized docs
1524 if ( ! $has_category && $target_category_slug === 'uncategorized' ) {
1525 // Check if the post has no categories assigned at all
1526 $category_count = $wpdb->get_var(
1527 $wpdb->prepare(
1528 "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
1529 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1530 WHERE tr.object_id = %d AND tt.taxonomy = %s",
1531 $_post_id,
1532 'doc_category'
1533 )
1534 );
1535
1536 // If post has no categories, allow it for uncategorized URL
1537 if ( $category_count == 0 ) {
1538 $has_category = true;
1539 }
1540 }
1541
1542
1543 // If post doesn't belong to the target category, return false (404)
1544 if ( ! $has_category ) {
1545 // Remember these query vars so we can block any canonical redirect for this invalid URL
1546 $this->invalid_request_query_vars = $query_vars;
1547 return false;
1548 }
1549
1550 // If hierarchical slugs are enabled and we found a post, validate the full hierarchy
1551 if ( $this->settings->get( 'enable_category_hierarchy_slugs' ) && count($category_parts) > 1 ) {
1552 // Get the post's category terms
1553 $post_categories = wp_get_object_terms( $_post_id, 'doc_category' );
1554
1555 if ( ! empty( $post_categories ) ) {
1556 $found_valid_hierarchy = false;
1557
1558 foreach ( $post_categories as $post_category ) {
1559 // Build the hierarchy path for this category
1560 $hierarchy_path = [];
1561 $current_term = $post_category;
1562
1563 // Build path from child to parent
1564 while ( $current_term ) {
1565 array_unshift( $hierarchy_path, $current_term->slug );
1566 $current_term = $current_term->parent ? get_term( $current_term->parent, 'doc_category' ) : null;
1567 }
1568
1569 // Check if this hierarchy matches the URL structure.
1570 // WordPress/Polylang store non-Latin term slugs URL-encoded
1571 // (%e0%a6...) while $doc_category arrives decoded from
1572 // is_perma_valid_for. Compare in the decoded form so Bengali,
1573 // Arabic, CJK, etc. hierarchies actually match.
1574 $built_path = implode('/', $hierarchy_path);
1575 $built_path_norm = urldecode( $built_path );
1576 $doc_cat_norm = urldecode( $doc_category );
1577
1578 // Allow partial path matching to accommodate KB-prefixed URLs or partial hierarchies.
1579 // Using substr for broad PHP version compatibility (equivalent to str_ends_with).
1580 $is_suffix = strlen( $built_path_norm ) > 0 && substr( $doc_cat_norm, -strlen( $built_path_norm ) ) === $built_path_norm;
1581 $is_prefix = strlen( $doc_cat_norm ) > 0 && substr( $built_path_norm, -strlen( $doc_cat_norm ) ) === $doc_cat_norm;
1582
1583 if ( $built_path_norm === $doc_cat_norm || $is_suffix || $is_prefix ) {
1584 $found_valid_hierarchy = true;
1585 break;
1586 }
1587 }
1588
1589 // If no valid hierarchy found, return false (404)
1590 if ( ! $found_valid_hierarchy ) {
1591 return false;
1592 }
1593 }
1594 }
1595 }
1596 } else {
1597 // First, check if the post exists.
1598 // When MKB is active, multiple translated posts share the same slug — one per KB.
1599 // Join the KB taxonomy when knowledge_base is in the URL to find the right post.
1600 $_encoded_name = rawurlencode( $name );
1601
1602 if ( isset( $query_vars['knowledge_base'] ) && ! empty( $query_vars['knowledge_base'] ) ) {
1603 $_kb_slug = $query_vars['knowledge_base'];
1604 $_kb_slug_enc = strtolower( rawurlencode( $_kb_slug ) );
1605 $_post_id = (int) $wpdb->get_var(
1606 $wpdb->prepare(
1607 "SELECT p.ID FROM {$wpdb->posts} p
1608 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1609 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'knowledge_base'
1610 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1611 WHERE p.post_name = %s AND p.post_type = 'docs'
1612 AND t.slug IN (%s, %s)
1613 LIMIT 1",
1614 esc_sql( $_encoded_name ),
1615 esc_sql( $_kb_slug ),
1616 esc_sql( $_kb_slug_enc )
1617 )
1618 );
1619 if ( ! $_post_id && $_encoded_name !== $name ) {
1620 $_post_id = (int) $wpdb->get_var(
1621 $wpdb->prepare(
1622 "SELECT p.ID FROM {$wpdb->posts} p
1623 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
1624 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'knowledge_base'
1625 INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
1626 WHERE p.post_name = %s AND p.post_type = 'docs'
1627 AND t.slug IN (%s, %s)
1628 LIMIT 1",
1629 esc_sql( $name ),
1630 esc_sql( $_kb_slug ),
1631 esc_sql( $_kb_slug_enc )
1632 )
1633 );
1634 }
1635 } else {
1636 $_post_id = (int) $wpdb->get_var(
1637 $wpdb->prepare(
1638 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
1639 esc_sql( $_encoded_name ),
1640 'docs'
1641 )
1642 );
1643 if ( ! $_post_id && $_encoded_name !== $name ) {
1644 $_post_id = (int) $wpdb->get_var(
1645 $wpdb->prepare(
1646 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
1647 esc_sql( $name ),
1648 'docs'
1649 )
1650 );
1651 }
1652 }
1653
1654
1655 // If knowledge_base is set, validate the post actually belongs to that KB.
1656 // wp_get_post_terms may return slugs URL-encoded (Polylang) or decoded (standard WP).
1657 // Check both forms so the match works regardless of storage format.
1658 if ( $_post_id > 0 && isset( $query_vars['knowledge_base'] ) ) {
1659 $post_kbs = wp_get_post_terms( $_post_id, 'knowledge_base', [ 'fields' => 'slugs' ] );
1660 if ( ! is_wp_error( $post_kbs ) && ! empty( $post_kbs ) ) {
1661 $kb_slug = $query_vars['knowledge_base'];
1662 $kb_slug_encoded = strtolower( rawurlencode( $kb_slug ) );
1663 $post_kbs_lower = array_map( 'strtolower', $post_kbs );
1664 if ( ! in_array( $kb_slug, $post_kbs_lower ) && ! in_array( $kb_slug_encoded, $post_kbs_lower ) ) {
1665 // Remember these query vars so we can block any canonical redirect for this invalid URL
1666 $this->invalid_request_query_vars = $query_vars;
1667 return false;
1668 }
1669 }
1670 // If post has no KB terms → allow it (not assigned to any KB explicitly).
1671 }
1672 }
1673
1674 return $_post_id > 0;
1675 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1676 }
1677
1678 protected function is_docs_category( $query_vars ) {
1679 $result = $this->term_exists( $query_vars, 'doc_category' );
1680 return $result;
1681 }
1682
1683 protected function is_docs_tag( $query_vars ) {
1684 return $this->term_exists( $query_vars, 'doc_tag' );
1685 }
1686
1687 protected function term_exists( $query_vars, $taxonomy ) {
1688 if ( ! isset( $query_vars[ $taxonomy ] ) ) {
1689 return false;
1690 }
1691
1692 // WordPress/Polylang stores non-Latin slugs URL-encoded (%e0%a6...) but the query var
1693 // arrives already decoded (e.g. বেটারডক্স). Try the decoded form first, then encoded.
1694 if ( term_exists( $query_vars[ $taxonomy ], $taxonomy ) ) {
1695 return true;
1696 }
1697 $encoded = strtolower( rawurlencode( $query_vars[ $taxonomy ] ) );
1698 if ( $encoded !== $query_vars[ $taxonomy ] && term_exists( $encoded, $taxonomy ) ) {
1699 return true;
1700 }
1701 return false;
1702 }
1703
1704 /**
1705 * Look up a taxonomy term by slug, trying both the raw (possibly Unicode-decoded) form
1706 * and the lowercase URL-encoded form that WordPress/Polylang stores for non-Latin slugs.
1707 *
1708 * @param string $slug Slug to look up (may be decoded Unicode, e.g. বেটারডক্স).
1709 * @param string $taxonomy Taxonomy name.
1710 * @return \WP_Term|false
1711 */
1712 protected function get_term_by_slug_or_encoded( $slug, $taxonomy ) {
1713 $term = get_term_by( 'slug', $slug, $taxonomy );
1714 if ( $term ) {
1715 return $term;
1716 }
1717 // Fallback: WordPress/Polylang stores non-Latin slugs as lowercase percent-encoded strings.
1718 $encoded = strtolower( rawurlencode( $slug ) );
1719 if ( $encoded !== $slug ) {
1720 $term = get_term_by( 'slug', $encoded, $taxonomy );
1721 }
1722 return $term ? $term : false;
1723 }
1724
1725 public function set_perma_structure( $structures = [] ) {
1726 $this->perma_structure = array_merge( $this->perma_structure, $structures );
1727 }
1728
1729 public function set_query_vars( $query_vars = [] ) {
1730 $this->query_vars = array_merge( $this->query_vars, $query_vars );
1731 }
1732
1733 public function backward_compability( $wp ) {
1734 if ( static::$already_parsed ) {
1735 return;
1736 }
1737
1738 $this->permalink_magic( $wp );
1739 }
1740
1741 public function parse( $wp ) {
1742 static::$already_parsed = true;
1743
1744 // An API Reference rewrite rule already matched (/docs/api/{slug}).
1745 // The permalink magic below re-interprets the raw path against the
1746 // docs/category/KB structures and would hijack the request whenever a
1747 // doc_category or knowledge_base term shares the reference's slug —
1748 // an explicit CPT match always wins.
1749 if ( isset( $wp->query_vars['betterdocs_api_ref'] ) ) {
1750 return;
1751 }
1752
1753 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- legacy public filter name, retained for back-compat with Pro/extensions.
1754 $this->perma_structure = apply_filters('docs_rewrite_rules', $this->perma_structure);
1755
1756 $this->permalink_magic( $wp );
1757 }
1758
1759 protected function permalink_magic( $wp ) {
1760 $this->wp_query_vars = $wp->query_vars;
1761
1762 if ( ! empty( $this->perma_structure ) ) {
1763 $_valid = [];
1764
1765 // Normalize request path: remove index.php/ and leading/trailing slashes.
1766 // urldecode is correct here: $wp->request arrives decoded by PHP/Apache, and the structure
1767 // regex patterns (e.g. "docs/%knowledge_base%") are plain ASCII, so matching works fine.
1768 // DB lookups handle the encoding separately below.
1769 $request = isset( $wp->request ) ? urldecode( $wp->request ) : '';
1770 $request = trim( preg_replace( '#^index\.php(/|$)#', '', $request ), '/' );
1771
1772 // Strip pagination segment (/page/N) before matching permalink structures.
1773 // When hierarchy slugs are enabled, the (.+?) regex for %doc_category% would
1774 // otherwise capture "/page/2" as part of the category slug, breaking pagination.
1775 $paged = 0;
1776 if ( preg_match( '#/page/([0-9]+)/?$#', $request, $page_matches ) ) {
1777 $paged = intval( $page_matches[1] );
1778 $request = preg_replace( '#/page/[0-9]+/?$#', '', $request );
1779 }
1780
1781 // Strip optional language prefix injected by Polylang/WPML (e.g. "en/", "bn/", "pt-br/")
1782 // so that "bn/docs/..." matches the structure "docs/..." correctly.
1783 $request_without_lang = preg_replace( '#^[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,8})?/#', '', $request );
1784
1785 // When WPML translates a taxonomy base slug per language (e.g. doc_tag
1786 // "docs-tag" -> "docs-tag-bn"), the incoming URL uses the translated slug,
1787 // but perma_structure is built from the default-language slug. Map the
1788 // leading translated slug back to its default so the raw structures match.
1789 // No-op on non-WPML sites and when the slug isn't translated.
1790 $request_canonical = $this->canonicalize_translated_slug( $request_without_lang );
1791
1792 foreach ( $this->perma_structure as $_type => $structure ) {
1793 // First try the raw (possibly language-prefixed) request, then the lang-stripped variant.
1794 // This ensures we still match non-multilingual sites without stripping valid slugs.
1795 $_perma_vars = $this->is_perma_valid_for( $structure, $request );
1796 if ( ! $_perma_vars && $request_without_lang !== $request ) {
1797 $_perma_vars = $this->is_perma_valid_for( $structure, $request_without_lang );
1798 }
1799 if ( ! $_perma_vars && $request_canonical !== $request_without_lang ) {
1800 $_perma_vars = $this->is_perma_valid_for( $structure, $request_canonical );
1801 }
1802
1803 // $_valid = empty( $_valid ) && $_perma_vars ? [ 'type' => $_type, 'query_vars' => $_perma_vars ] : $_valid;
1804 if ( ( $_perma_vars && method_exists( $this, $_type ) && call_user_func_array( [$this, $_type], [ & $_perma_vars] ) ) ) {
1805
1806 // dump( $_type, $_perma_vars );
1807 if ( $_type === 'is_single_docs' || $_type == 'is_docs_feed' || $_type == 'is_docs_author' ) {
1808 $_perma_vars['post_type'] = 'docs';
1809 }
1810 $_valid = ['type' => $_type, 'query_vars' => $_perma_vars];
1811
1812 // Single doc match is definitive — stop here so later category/KB archive
1813 // structures cannot overwrite it (e.g. is_knowledge_base_category).
1814 if ( $_type === 'is_single_docs' ) {
1815 break;
1816 }
1817 }
1818 }
1819
1820 $type = isset( $_valid['type'] ) ? $_valid['type'] : '';
1821 $query_vars = isset( $_valid['query_vars'] ) ? $_valid['query_vars'] : [];
1822
1823 // Inject the paged query var if a /page/N segment was stripped from the request.
1824 if ( $paged > 0 && ! empty( $type ) ) {
1825 $query_vars['paged'] = $paged;
1826 }
1827
1828 if ( ! empty( $type ) ) {
1829 unset( $this->query_vars[ $type ] );
1830 array_map(
1831 function ( $_vars ) use ( &$wp ) {
1832 array_map(
1833 function ( $_var ) use ( &$wp ) {
1834 unset( $wp->query_vars[ $_var ] );
1835 },
1836 $_vars
1837 );
1838 },
1839 $this->query_vars
1840 );
1841 }
1842
1843 $wp->query_vars = is_array( $query_vars ) ? array_merge( $wp->query_vars, $query_vars ) : $wp->query_vars;
1844
1845 // Fallback
1846 if ( ! empty( $_valid ) ) {
1847 unset( $wp->query_vars['attachment'] );
1848 }
1849 }
1850 }
1851
1852 /**
1853 * Map a leading WPML-translated taxonomy base slug back to its default-language
1854 * value so the default-language perma_structure patterns can match a translated URL.
1855 *
1856 * Only the first path segment is considered (the taxonomy base). Returns the
1857 * request unchanged when WPML is inactive or the leading segment isn't a
1858 * translated BetterDocs slug.
1859 *
1860 * @param string $request Language-stripped request path (no leading/trailing slash).
1861 * @return string
1862 */
1863 private function canonicalize_translated_slug( $request ) {
1864 if ( $request === '' || strpos( $request, '/' ) === false ) {
1865 return $request;
1866 }
1867
1868 $slug_map = [
1869 'doc_tag' => trim( $this->settings->get( 'tag_slug', 'docs-tag' ), '/' ),
1870 'doc_category' => trim( $this->settings->get( 'category_slug', 'docs-category' ), '/' ),
1871 ];
1872
1873 $segments = explode( '/', $request );
1874
1875 foreach ( $slug_map as $taxonomy => $default ) {
1876 if ( $default === '' ) {
1877 continue;
1878 }
1879
1880 $translated = Helper::wpml_translated_tax_slug( $taxonomy, $default );
1881 if ( $translated !== $default && $segments[0] === $translated ) {
1882 $segments[0] = $default;
1883 return implode( '/', $segments );
1884 }
1885 }
1886
1887 return $request;
1888 }
1889
1890 /**
1891 * This method is responsible for checking a structure is valid again a request.
1892 *
1893 * @param string $structure
1894 * @param string $request
1895 * @return array|bool
1896 */
1897 private function is_perma_valid_for( $structure, $request ) {
1898 if ( empty( $structure ) ) {
1899 return false;
1900 }
1901
1902 $_tags = explode( '/', trim( $structure, '/' ) );
1903 $_replace_matched_tags = [];
1904
1905 $_replace_tags = array_filter(
1906 $_tags,
1907 function ( $item ) use ( &$_replace_matched_tags ) {
1908 $_is_valid = strpos( $item, '%' ) !== false;
1909 if ( $_is_valid ) {
1910 $_replace_matched_tags[] = trim( $item, '%' );
1911 }
1912 return $_is_valid;
1913 }
1914 );
1915
1916 // First, preg_quote the structure to safely use it in a regex
1917 $_perma_structure = preg_quote( $structure, '#' );
1918
1919 // Since our placeholders like %name% contain characters (like %) that preg_quote escapes,
1920 // we must also preg_quote the tags before searching for them in the escaped structure.
1921 foreach ( $_replace_tags as $tag ) {
1922 $tag_escaped = preg_quote( $tag, '#' );
1923 $replacement = '([^/]+)';
1924
1925 // If hierarchical slugs are enabled, allow slashes in the %doc_category% placeholder
1926 if ( $tag === '%doc_category%' && $this->settings->get( 'enable_category_hierarchy_slugs' ) ) {
1927 $replacement = '(.+?)';
1928 }
1929
1930 $_perma_structure = str_replace( $tag_escaped, $replacement, $_perma_structure );
1931 }
1932
1933 preg_match( "#^$_perma_structure$#", $request, $matches );
1934
1935 if ( empty( $matches ) || ! is_array( $matches ) ) {
1936 return false;
1937 }
1938
1939 if ( count( $matches ) === 1 ) {
1940 return [ 'post_type' => 'docs' ];
1941 }
1942
1943 unset( $matches[0] );
1944
1945 return array_combine( $_replace_matched_tags, $matches );
1946 }
1947 }
1948