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