PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 3.5.3
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v3.5.3
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.5.2 All 199 releases
← All changes | includes/Core/Request.php +159 -1821 4.9.13.5.3 View file →
@@ -1,80 +1,65 @@
1 1 <?php
2 +
2 3 namespace WPDeveloper\BetterDocs\Core;
3 4
4 -if ( ! defined( 'ABSPATH' ) ) {
5 - exit;
6 -}
7 -
8 -
9 5 use WPDeveloper\BetterDocs\Utils\Base;
10 -use WPDeveloper\BetterDocs\Utils\Helper;
11 6
12 7 class Request extends Base {
13 - /**
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;
8 + /**
9 + * Flag for already parsed or not
10 + *
11 + * Specially needed for those who don't update pro yet.
12 + * @var boolean
13 + */
14 + protected static $already_parsed = false;
20 15
21 - /**
22 - * List of BetterDocs Perma Structure
23 - * @var array
24 - */
25 - private $perma_structure = [];
16 + /**
17 + * List of BetterDocs Perma Structure
18 + * @var array
19 + */
20 + private $perma_structure = [];
26 21
27 - /**
28 - * List of BetterDocs Query Vars Agains Page Structure.
29 - * @var array
30 - */
31 - private $query_vars = [];
22 + /**
23 + * List of BetterDocs Query Vars Agains Page Structure.
24 + * @var array
25 + */
26 + private $query_vars = [];
32 27
33 - /**
34 - * List of Query Variables from $wp->query_vars.
35 - * @var array
36 - */
37 - private $wp_query_vars = [];
28 + /**
29 + * List of Query Variables from $wp->query_vars.
30 + * @var array
31 + */
32 + private $wp_query_vars = [];
38 33
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;
34 + /**
35 + * Rewrite Class Reference of BetterDocs
36 + * @var Rewrite
37 + */
38 + protected $rewrite;
45 39
46 - /**
47 - * Rewrite Class Reference of BetterDocs
48 - * @var Rewrite
49 - */
50 - protected $rewrite;
40 + /**
41 + * Settings Class Reference of BetterDocs
42 + * @var Settings
43 + */
44 + protected $settings;
51 45
52 - /**
53 - * Settings Class Reference of BetterDocs
54 - * @var Settings
55 - */
56 - protected $settings;
46 + public function __construct( Rewrite $rewrite, Settings $settings ) {
47 + $this->rewrite = $rewrite;
48 + $this->settings = $settings;
49 + }
57 50
58 - public function __construct( Rewrite $rewrite, Settings $settings ) {
59 - $this->rewrite = $rewrite;
60 - $this->settings = $settings;
61 - }
51 + public function init() {
52 + if ( is_admin() ) {
53 + return;
54 + }
62 55
63 - public function init() {
64 - if ( is_admin() ) {
65 - return;
66 - }
67 -
68 - add_action( 'template_redirect', [ $this, 'validate_request_path' ], 1 );
69 -
70 56 $this->perma_structure = [
71 57 'is_docs' => trim( $this->rewrite->get_base_slug(), '/' ),
72 58 'is_docs_feed' => trim( $this->rewrite->get_base_slug(), '/' ) . '/%feed%',
73 59 'is_docs_category' => trim( $this->settings->get( 'category_slug', 'docs-category' ), '/' ) . '/%doc_category%',
74 60 '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%'
61 + 'is_single_docs' => trim( $this->settings->get( 'permalink_structure', 'docs' ), '/' ) . '/%name%'
77 62 ];
78 63
79 64 $this->query_vars = [
80 65 'is_docs' => ['post_type'],
@@ -80,1826 +65,179 @@
80 65 'is_docs' => ['post_type'],
81 66 'is_docs_feed' => ['doc_category'],
82 67 'is_docs_category' => ['doc_category'],
83 68 'is_docs_tag' => ['doc_tag'],
84 - 'is_single_docs' => ['name', 'docs', 'post_type'],
85 - 'is_docs_author' => ['post_type', 'author']
69 + 'is_single_docs' => ['name', 'docs', 'post_type']
86 70 ];
87 71
88 - add_action( 'parse_request', [ $this, 'parse' ] );
72 + add_action( 'parse_request', [$this, 'parse'] );
89 73
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 );
74 + /**
75 + * This is for Backward compatibility if pro not updated.
76 + */
77 + add_action( 'parse_request', [$this, 'backward_compability'], 11 );
94 78
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 );
79 + /**
80 + * Make Compatible With Permalink Manager Plugin
81 + */
82 + add_filter( 'permalink_manager_detected_element_id', [$this, 'provide_compatibility'], 10, 3 );
83 + }
100 84
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 );
85 + public function provide_compatibility( $element_id, $uri_parts, $request_url ) {
86 + if ( $request_url == $this->settings->get( 'docs_slug' ) ) {
87 + $element_id = '';
88 + }
89 + return $element_id;
90 + }
106 91
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 );
92 + protected function is_docs( &$query_vars ) {
93 + if ( ! $this->settings->get( 'builtin_doc_page', true ) ) {
94 + $query_vars['post_type'] = 'page';
95 + $query_vars['name'] = trim( $this->rewrite->get_base_slug(), '/' );
96 + }
111 97
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 );
98 + return $query_vars;
99 + }
117 100
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;
101 + public function is_docs_feed( $query_vars ) {
102 + global $wp_rewrite;
103 + return isset( $query_vars['feed'] ) && in_array( $query_vars['feed'], $wp_rewrite->feeds );
1188 104 }
1189 105
1190 106 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'] ) ) {
107 + if ( ! isset( $query_vars['name'] ) ) {
1193 108 return false;
1194 109 }
1195 110
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'];
111 + global $wpdb;
112 + $name = $query_vars['name'];
1199 113
114 + $_post_id = (int) $wpdb->get_var(
115 + $wpdb->prepare(
116 + "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s LIMIT 1",
117 + esc_sql( $name ),
118 + 'docs'
119 + )
120 + );
1200 121
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'];
122 + return $_post_id > 0;
123 + }
1204 124
125 + protected function is_docs_category( $query_vars ) {
126 + return $this->term_exists( $query_vars, 'doc_category' );
127 + }
1205 128
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
129 + protected function is_docs_tag( $query_vars ) {
130 + return $this->term_exists( $query_vars, 'doc_tag' );
131 + }
1209 132
133 + protected function term_exists( $query_vars, $taxonomy ) {
134 + if ( ! isset( $query_vars[$taxonomy] ) ) {
135 + return false;
136 + }
1210 137
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 );
138 + return term_exists( $query_vars[$taxonomy], $taxonomy );
139 + }
1216 140
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 ) );
141 + public function set_perma_structure( $structures = [] ) {
142 + $this->perma_structure = array_merge( $this->perma_structure, $structures );
143 + }
1226 144
1227 - $_cat_target = $target_category_slug;
1228 - $_cat_target_enc = strtolower( rawurlencode( $_cat_target ) );
145 + public function set_query_vars( $query_vars = [] ) {
146 + $this->query_vars = array_merge( $this->query_vars, $query_vars );
147 + }
1229 148
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 - }
149 + public function backward_compability( $wp ) {
150 + if ( static::$already_parsed ) {
151 + return;
152 + }
1273 153
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 - }
154 + $this->permalink_magic( $wp );
155 + }
1350 156
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 - }
157 + public function parse( $wp ) {
158 + static::$already_parsed = true;
1373 159
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 160 $this->permalink_magic( $wp );
1715 161 }
1716 162
1717 - protected function permalink_magic( $wp ) {
1718 - $this->wp_query_vars = $wp->query_vars;
163 + protected function permalink_magic( $wp ) {
164 + $this->wp_query_vars = $wp->query_vars;
1719 165
1720 - if ( ! empty( $this->perma_structure ) ) {
1721 - $_valid = [];
166 + if ( ! empty( $this->perma_structure ) ) {
167 + $_valid = [];
1722 168
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 ), '/' );
169 + foreach ( $this->perma_structure as $_type => $structure ) {
170 + $_perma_vars = $this->is_perma_valid_for( $structure, $wp->request );
1729 171
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 172 // $_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 -
173 + if ( ( $_perma_vars && method_exists( $this, $_type ) && call_user_func_array( [$this, $_type], [ & $_perma_vars] ) ) ) {
1764 174 // dump( $_type, $_perma_vars );
1765 - if ( $_type === 'is_single_docs' || $_type == 'is_docs_feed' || $_type == 'is_docs_author' ) {
175 + if ( $_type === 'is_single_docs' || $_type == 'is_docs_feed' ) {
1766 176 $_perma_vars['post_type'] = 'docs';
1767 177 }
1768 178 $_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 179 }
1776 180 }
1777 181
1778 - $type = isset( $_valid['type'] ) ? $_valid['type'] : '';
1779 - $query_vars = isset( $_valid['query_vars'] ) ? $_valid['query_vars'] : [];
182 + $type = isset( $_valid['type'] ) ? $_valid['type'] : '';
183 + $query_vars = isset( $_valid['query_vars'] ) ? $_valid['query_vars'] : [];
1780 184
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 - }
185 + if ( ! empty( $type ) ) {
186 + unset( $this->query_vars[$type] );
187 + array_map( function ( $_vars ) use ( &$wp ) {
188 + array_map( function ( $_var ) use ( &$wp ) {
189 + unset( $wp->query_vars[$_var] );
190 + }, $_vars );
191 + }, $this->query_vars );
192 + }
1785 193
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 194 $wp->query_vars = is_array( $query_vars ) ? array_merge( $wp->query_vars, $query_vars ) : $wp->query_vars;
1802 -
195 + //var_dump($_valid);
1803 196 // Fallback
1804 - if ( ! empty( $_valid ) ) {
197 + if( ! empty( $_valid ) ) {
1805 198 unset( $wp->query_vars['attachment'] );
1806 199 }
1807 200 }
1808 201 }
1809 202
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 - }
203 + /**
204 + * This method is responsible for checking a structure is valid again a request.
205 + *
206 + * @param string $structure
207 + * @param string $request
208 + * @return array|bool
209 + */
210 + private function is_perma_valid_for( $structure, $request ) {
211 + if ( empty( $structure ) ) {
212 + return false;
213 + }
1825 214
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 - ];
215 + $_tags = explode( '/', trim( $structure, '/' ) );
216 + $_replace_matched_tags = [];
1830 217
1831 - $segments = explode( '/', $request );
218 + $_replace_tags = array_filter( $_tags, function ( $item ) use ( &$_replace_matched_tags ) {
219 + $_is_valid = strpos( $item, '%' ) !== false;
220 + if ( $_is_valid ) {
221 + $_replace_matched_tags[] = trim( $item, '%' );
222 + }
223 + return $_is_valid;
224 + } );
1832 225
1833 - foreach ( $slug_map as $taxonomy => $default ) {
1834 - if ( $default === '' ) {
1835 - continue;
1836 - }
226 + $_perma_structure = preg_quote( $structure, '/' );
227 + $_perma_structure = str_replace( $_replace_tags, '([^\/]+)', $_perma_structure );
1837 228
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 - }
229 + preg_match( "/^$_perma_structure$/", $request, $matches );
1844 230
1845 - return $request;
1846 - }
231 + if ( empty( $matches ) || ! is_array( $matches ) ) {
232 + return false;
233 + }
1847 234
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 - }
235 + if ( count( $matches ) === 1 ) {
236 + return ['post_type' => 'docs'];
237 + }
1859 238
1860 - $_tags = explode( '/', trim( $structure, '/' ) );
1861 - $_replace_matched_tags = [];
239 + unset( $matches[0] );
1862 240
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 - }
241 + return array_combine( $_replace_matched_tags, $matches );
242 + }
1905 243 }