@@ -37,14 +37,29 @@ public function __construct() {
3737 * @since 2.3.0
3838 */
3939 public static function register_post_type () {
40- $ slug = sanitize_title ( \wzkb_get_option ( 'kb_slug ' , 'knowledgebase ' ) );
40+ // Sanitize KB slug (remove placeholders, preserve slashes).
41+ $ slug = self ::sanitize_slug ( \wzkb_get_option ( 'kb_slug ' , 'knowledgebase ' ) );
42+ $ article_structure = \wzkb_get_option ( 'article_permalink ' , '' );
43+
44+ // If article permalink is set to just %postname%, don't use a slug prefix.
45+ // This allows KB articles to use the same permalink structure as regular posts.
46+ $ use_slug_prefix = ! ( '%postname% ' === $ article_structure );
47+
4148 $ archives = defined ( 'WZKB_DISABLE_ARCHIVE ' ) && WZKB_DISABLE_ARCHIVE ? false : $ slug ;
42- $ rewrite = defined ( 'WZKB_DISABLE_REWRITE ' ) && WZKB_DISABLE_REWRITE ? false : array (
43- 'slug ' => $ slug ,
49+
50+ $ rewrite = defined ( 'WZKB_DISABLE_REWRITE ' ) && WZKB_DISABLE_REWRITE ? false : array (
51+ 'slug ' => $ use_slug_prefix ? $ slug : 'wz_knowledgebase ' ,
4452 'with_front ' => false ,
4553 'feeds ' => \wzkb_get_option ( 'disable_kb_feed ' ) ? false : true ,
4654 );
4755
56+ // If not using slug prefix, we'll add custom rewrite rules and filter permalinks.
57+ // Only if Pro Custom Permalinks class is not active (Pro handles this itself).
58+ if ( ! $ use_slug_prefix && ! class_exists ( 'WebberZone\Knowledge_Base\Pro\Custom_Permalinks ' ) ) {
59+ add_action ( 'init ' , array ( __CLASS__ , 'add_root_level_rewrite_rules ' ), 99 );
60+ add_filter ( 'post_type_link ' , array ( __CLASS__ , 'filter_root_level_permalink ' ), 10 , 2 );
61+ }
62+
4863 $ ptlabels = array (
4964 'name ' => _x ( 'Knowledge Base ' , 'Post Type General Name ' , 'knowledgebase ' ),
5065 'singular_name ' => _x ( 'Knowledge Base ' , 'Post Type Singular Name ' , 'knowledgebase ' ),
@@ -110,6 +125,29 @@ public static function register_post_type() {
110125 register_post_type ( 'wz_knowledgebase ' , $ ptargs );
111126 }
112127
128+ /**
129+ * Sanitize slug while preserving slashes and optionally removing placeholders.
130+ *
131+ * @since 3.0.0
132+ *
133+ * @param string $slug The slug to sanitize.
134+ * @param bool $remove_placeholders Whether to remove placeholders. Default true.
135+ * @return string Sanitized slug.
136+ */
137+ private static function sanitize_slug ( string $ slug , bool $ remove_placeholders = true ): string {
138+ // Remove any placeholders (e.g., %product_name%, %section_name%) if requested.
139+ if ( $ remove_placeholders ) {
140+ $ slug = preg_replace ( '/%[^%]+%/ ' , '' , $ slug );
141+ }
142+
143+ // Split by slash, sanitize each part, then rejoin.
144+ $ parts = explode ( '/ ' , $ slug );
145+ $ parts = array_map ( 'sanitize_title ' , $ parts );
146+ $ parts = array_filter ( $ parts ); // Remove empty parts.
147+
148+ return implode ( '/ ' , $ parts );
149+ }
150+
113151 /**
114152 * Get base arguments for taxonomies.
115153 *
@@ -198,9 +236,10 @@ private static function get_taxonomy_labels( string $singular, string $plural ):
198236 */
199237 public static function register_taxonomies () {
200238 // Get taxonomy slugs from options.
201- $ catslug = sanitize_title ( \wzkb_get_option ( 'category_slug ' , 'kb/section ' ) );
202- $ tagslug = sanitize_title ( \wzkb_get_option ( 'tag_slug ' , 'kb/tags ' ) );
203- $ productslug = sanitize_title ( \wzkb_get_option ( 'product_slug ' , 'kb/products ' ) );
239+ // Use custom sanitization that preserves slashes and removes placeholders.
240+ $ catslug = self ::sanitize_slug ( \wzkb_get_option ( 'category_slug ' , 'kb/section ' ) );
241+ $ tagslug = self ::sanitize_slug ( \wzkb_get_option ( 'tag_slug ' , 'kb/tags ' ) );
242+ $ productslug = self ::sanitize_slug ( \wzkb_get_option ( 'product_slug ' , 'kb/products ' ) );
204243
205244 // Register products taxonomy first.
206245 $ product_args = self ::get_taxonomy_base_args ( $ productslug , false );
@@ -246,5 +285,143 @@ public static function register_taxonomies() {
246285 $ tag_args = apply_filters ( 'wzkb_tag_args ' , $ tag_args );
247286
248287 register_taxonomy ( 'wzkb_tag ' , array ( 'wz_knowledgebase ' ), $ tag_args );
288+
289+ // Add taxonomy rewrite rules with 'top' priority to ensure they match before post type rules.
290+ add_action ( 'init ' , array ( __CLASS__ , 'add_taxonomy_rewrite_rules ' ), 20 );
291+ }
292+
293+ /**
294+ * Filter KB article permalinks to remove the CPT slug when using %postname% structure.
295+ *
296+ * @since 3.0.0
297+ *
298+ * @param string $permalink The post's permalink.
299+ * @param \WP_Post $post The post object.
300+ * @return string Filtered permalink.
301+ */
302+ public static function filter_root_level_permalink ( $ permalink , $ post ) {
303+ // Only process KB articles.
304+ if ( 'wz_knowledgebase ' !== $ post ->post_type ) {
305+ return $ permalink ;
306+ }
307+
308+ // Remove the 'wz_knowledgebase/' prefix from the permalink.
309+ $ permalink = str_replace ( '/wz_knowledgebase/ ' , '/ ' , $ permalink );
310+
311+ return $ permalink ;
312+ }
313+
314+ /**
315+ * Maybe query KB article if regular post isn't found.
316+ *
317+ * When using %postname% structure, WordPress will try to find a regular post first.
318+ * If not found, we check if there's a KB article with that slug.
319+ *
320+ * @since 3.0.0
321+ *
322+ * @param \WP_Query $query The WP_Query instance.
323+ */
324+ public static function maybe_query_kb_article ( $ query ) {
325+ // Only on main query, not admin.
326+ if ( ! $ query ->is_main_query () || is_admin () ) {
327+ return ;
328+ }
329+
330+ // Get the post slug from query vars.
331+ $ post_name = $ query ->get ( 'postname ' );
332+ if ( empty ( $ post_name ) ) {
333+ $ post_name = $ query ->get ( 'name ' );
334+ }
335+
336+ // If no post name, nothing to check.
337+ if ( empty ( $ post_name ) ) {
338+ return ;
339+ }
340+
341+ // If it's already querying a specific post type, don't interfere.
342+ $ post_type = $ query ->get ( 'post_type ' );
343+ if ( ! empty ( $ post_type ) && 'post ' !== $ post_type ) {
344+ return ;
345+ }
346+
347+ // Check if there's a regular post with this slug first.
348+ $ regular_post = get_posts (
349+ array (
350+ 'name ' => $ post_name ,
351+ 'post_type ' => 'post ' ,
352+ 'post_status ' => 'publish ' ,
353+ 'posts_per_page ' => 1 ,
354+ )
355+ );
356+
357+ // If a regular post exists, let WordPress handle it.
358+ if ( ! empty ( $ regular_post ) ) {
359+ return ;
360+ }
361+
362+ // No regular post found, check if there's a KB article with this slug.
363+ $ kb_post = get_posts (
364+ array (
365+ 'name ' => $ post_name ,
366+ 'post_type ' => 'wz_knowledgebase ' ,
367+ 'post_status ' => 'publish ' ,
368+ 'posts_per_page ' => 1 ,
369+ )
370+ );
371+
372+ // If a KB article exists, modify the query to fetch it instead.
373+ if ( ! empty ( $ kb_post ) ) {
374+ $ query ->set ( 'post_type ' , 'wz_knowledgebase ' );
375+ $ query ->set ( 'name ' , $ post_name );
376+ }
377+ }
378+
379+ /**
380+ * Add root-level rewrite rules for KB articles when using %postname% structure.
381+ *
382+ * This allows KB articles to use the same permalink structure as regular posts.
383+ * Note: These rules are added but WordPress will try regular posts first, then fall back to KB articles.
384+ *
385+ * @since 3.0.0
386+ */
387+ public static function add_root_level_rewrite_rules () {
388+ // We don't actually need custom rewrite rules because WordPress's default post rules
389+ // will handle the URL matching. We just need to hook into the query to check for KB articles
390+ // when a regular post isn't found. Use parse_query which runs before WordPress sets p=0.
391+ add_action ( 'parse_query ' , array ( __CLASS__ , 'maybe_query_kb_article ' ), 1 );
392+ }
393+
394+ /**
395+ * Add taxonomy rewrite rules with top priority.
396+ *
397+ * This ensures taxonomy archive URLs are matched before post type attachment rules.
398+ *
399+ * @since 3.0.0
400+ */
401+ public static function add_taxonomy_rewrite_rules () {
402+ $ productslug = self ::sanitize_slug ( \wzkb_get_option ( 'product_slug ' , 'kb/products ' ) );
403+ $ catslug = self ::sanitize_slug ( \wzkb_get_option ( 'category_slug ' , 'kb/section ' ) );
404+ $ tagslug = self ::sanitize_slug ( \wzkb_get_option ( 'tag_slug ' , 'kb/tags ' ) );
405+
406+ // Add product taxonomy rules.
407+ add_rewrite_rule (
408+ '^ ' . preg_quote ( $ productslug , '/ ' ) . '/([^/]+)/?$ ' ,
409+ 'index.php?wzkb_product=$matches[1] ' ,
410+ 'top '
411+ );
412+
413+ // Add section taxonomy rules (hierarchical).
414+ add_rewrite_rule (
415+ '^ ' . preg_quote ( $ catslug , '/ ' ) . '/(.+?)/?$ ' ,
416+ 'index.php?wzkb_category=$matches[1] ' ,
417+ 'top '
418+ );
419+
420+ // Add tag taxonomy rules.
421+ add_rewrite_rule (
422+ '^ ' . preg_quote ( $ tagslug , '/ ' ) . '/([^/]+)/?$ ' ,
423+ 'index.php?wzkb_tag=$matches[1] ' ,
424+ 'top '
425+ );
249426 }
250427}
0 commit comments