Category Archives: Web

Remove post type slug in custom post type URL and move subpages to website root in WordPress

I recently had a need to rewrite the URLs of all parent and child pages in a custom post type so they appeared to live at the website root, but in reality continued to live in a custom post type with their hierarchy.

Preface

The situation:

  1. I have a page called Services that lives at domain.com/services.
  2. I have a custom post type called Services.
  3. I have Services post called Programming that lives at domain.com/services/programming.
  4. I have Services post called Web Development, that is a child of Programming, that lives at domain.com/services/programming/web-development.

The goal:

  1. The Services page should remain where it is i.e. domain.com/services.
  2. The Programming post should appear to live at the website root i.e. domain.com/programming.
  3. The Web Development service should also live at the website root i.e. domain.com/web-development.

The reason:

  1. Shorter URLs.
  2. Keep all website pages together.
  3. Keep all services together.
  4. Maintain hierarchy of services.

Setup

I have a custom post type setup like so:

1
2
3
4
5
6
7
8
9
10
11
function register_services() {
  $arguments = array(
    'label' => 'Services',
    'public' => true,
    'hierarchical' => true,
    'supports' => array('title', 'editor', 'page-attributes'),
    'has_archive' => false,
    'rewrite' => true
  );
  register_post_type('services', $arguments);
}
function register_services() {
	$arguments = array(
		'label' => 'Services',
		'public' => true,
		'hierarchical' => true,
		'supports' => array('title', 'editor', 'page-attributes'),
		'has_archive' => false,
		'rewrite' => true
	);
	register_post_type('services', $arguments);
}

Solution

1. Remove “services” from the custom post type URL

We’re going to use the post_type_link filter to do this.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
add_filter(
  'post_type_link',
  'custom_post_type_link',
  10,
  3
);
 
function custom_post_type_link($permalink, $post, $leavename) {
 
  $url_components = parse_url($permalink);
  $post_path = $url_components['path'];
  $post_name = end(explode('/', trim($post_path, '/')));
 
  if(!empty($post_name)) {
 
    switch($post->post_type) {
 
      case 'services':
        $permalink = str_replace($post_path, '/' . $post_name . '/', $permalink);
        break;
 
    }
 
  }
 
  return $permalink;
}
add_filter(
	'post_type_link',
	'custom_post_type_link',
	10,
	3
);

function custom_post_type_link($permalink, $post, $leavename) {

	$url_components = parse_url($permalink);
	$post_path = $url_components['path'];
	$post_name = end(explode('/', trim($post_path, '/')));

	if(!empty($post_name)) {

		switch($post->post_type) {

			case 'services':
				$permalink = str_replace($post_path, '/' . $post_name . '/', $permalink);
				break;

		}

	}

	return $permalink;
}
  • Line 10: Let’s split the permalink into scheme (http), host (domain.com) and path (/programming/web-development/), of which we’re interested in the path.
  • Line 11: So we’re going to grab the path.
  • Line 12: We’ll get rid of the beginning and trialing slash, split the path into pieces based on any slashes in the middle, and then grab the last piece of the path, which is now going to be our page name.
  • Line 18-19: If the post type is called services, then we’ll remove the entire path and replace it with just the page name.

All of this will essentially give us URLs like this:

  • domain.com/programming/
  • domain.com/web-development/

But neither of those will now work – you’ll get a 404 error – because WordPress can no longer identify those posts as Services posts and therefore attempts to find pages called Programming and Web Development, which don’t exist, hence the 404 error.

If you take a look at WordPress’ rewrite rules:

1
echo '<pre>' . print_r(get_option('rewrite_rules'), true) . '</pre>';
echo '<pre>' . print_r(get_option('rewrite_rules'), true) . '</pre>';

You’ll see something similar to this:

1
2
3
4
5
Array
(
  [services/(.+?)(/[0-9]+)?/?$] => index.php?services=$matches[1]&page=$matches[2]
  [(.?.+?)(/[0-9]+)?/?$] => index.php?pagename=$matches[1]&page=$matches[2]
)
Array
(
	[services/(.+?)(/[0-9]+)?/?$] => index.php?services=$matches[1]&page=$matches[2]
	[(.?.+?)(/[0-9]+)?/?$] => index.php?pagename=$matches[1]&page=$matches[2]
)

As you can see, using the services slug, WordPress knows to take whatever comes after it ((.+?)) and pass it in as the pagename value ($matches[1]), which translates to something like:

domain.com/index.php?services=programming

Since the first rewrite rule no longer applies (slug removed), it now passes programming in as the pagename without specifying the post type, and since this page doesn’t exist, WordPress can’t return the page.

2. Add post type back to the post query

Now that WordPress doesn’t know that it’s dealing with a custom post type, we need to provide it with this information. For that we’re going to use the pre_get_posts filter.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
add_filter(
  'pre_get_posts',
  'custom_pre_get_posts'
);
 
function custom_pre_get_posts($query) {
    global $wpdb;
 
    if(!$query->is_main_query()) {
      return;
    }
 
    $post_name = $query->get('pagename');
 
    $post_type = $wpdb->get_var(
      $wpdb->prepare(
        'SELECT post_type FROM ' . $wpdb->posts . ' WHERE post_name = %s LIMIT 1',
        $post_name
      )
    );
 
    switch($post_type) {
      case 'services':
        $query->set('services', $post_name);
        $query->set('post_type', $post_type);
        $query->is_single = true;
        $query->is_page = false;
        break;
    }
 
    return $query;
}
add_filter(
	'pre_get_posts',
	'custom_pre_get_posts'
);

function custom_pre_get_posts($query) {
    global $wpdb;

    if(!$query->is_main_query()) {
	    return;
    }

    $post_name = $query->get('pagename');

    $post_type = $wpdb->get_var(
    	$wpdb->prepare(
    		'SELECT post_type FROM ' . $wpdb->posts . ' WHERE post_name = %s LIMIT 1',
    		$post_name
    	)
    );

    switch($post_type) {
	    case 'services':
	    	$query->set('services', $post_name);
	    	$query->set('post_type', $post_type);
	    	$query->is_single = true;
	    	$query->is_page = false;
	    	break;
    }

    return $query;
}
  • Line 7: We’ll need the WordPress database object to get additional information about the post.
  • Line 9-11: We’re going to make sure that the current query is the main query. Since a page can have multiple queries, we’re limiting our filter to only the query that gets information about the page itself.
  • Line 13: We’re going to grab the pagename from the query, which might be programming or web-development.
  • Line 15-20: Now we’re going to use the pagename to look in the database for that particular post and extract its post type.
  • Line 22-23: If this post has a post type that we know we need to manipulate, we’ll need to perform additional actions.
  • Line 24: We need to a create a query var for the post type and set it to the pagename, so WordPress knows where to actually find the data for the post.
  • Line 25: We also need to set the post_type back to services.
  • Line 26: In order for WordPress to load the right template i.e. single-services.php, we need to set is_single to true.
  • Line 27: Last, but not least, we set is_page to false for good measure, since it’s a custom post type.

Result

You’ve met all your goals. In addition, if you try to create a page with the same name as one of your services, you’ll notice that the slug (i.e. post_name, pagename, etc.) will now increment, avoiding a possible collision.

Here are a couple of things to keep in mind:

  1. I didn’t test the performance of this yet, but if you use some kind of caching system on your website, you should be fine.
  2. All my functions live within a PHP namespace, so I only prefixed them with custom_ for simplicity of this post.
  3. I’ve looked at many different solutions online, and they all had their fair share of problems, so this may too, I just don’t know it yet :)

If you have questions or suggestions, leave them in the comments below.

-RS

Prevent checked categories in meta box from appearing at top in WordPress

I previously shared a jQuery snippet that enforces parent checkboxes to be checked when a child is checked and children to be unchecked if the parent is unchecked.

This works really great in theory, except that WordPress, by default, moves all checked checkboxes to the top of the category list, which effectively breaks the JavaScript snippet because it depends on the hierarchy.

If enforcing the checkboxes is more important to you than how they are visually displayed, here’s a WordPress filter that lets you prevent checked checkboxes from moving to the top.

1
2
3
4
5
6
7
8
9
add_filter(
  'wp_terms_checklist_args',
  NS . 'wp_terms_checklist_args'
);
 
function wp_terms_checklist_args($args) {
  $args['checked_ontop'] = false;
  return $args;
}
add_filter(
	'wp_terms_checklist_args',
	NS . 'wp_terms_checklist_args'
);

function wp_terms_checklist_args($args) {
	$args['checked_ontop'] = false;
	return $args;
}

The constant NS is what I use to maintain namespaces in the plugins I write. This ensures compatibility without having to write crazy long function names or create a class just for the sake of namespacing.

-RS

Automatically check parent checkbox if child is selected in WordPress

To keep all posts in WordPress organized in terms of categorization, it helps to make sure that when a user selects a category that’s three levels down i.e. has two parents, that all the corresponding parents get checked.

What follows is a short and sweet JavaScript solution to help the user do this.

Here is how it works:

  1. Find all the checkboxes within in the #categorychecklist container
  2. Bind a change event to each of the checkboxes
  3. If a checkbox is checked, find all the parents and check their immediate child
  4. If a checkbox is unchecked, find all the children and uncheck them
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
jQuery(document).ready(function($){
  synchronize_child_and_parent_category($);
});
 
function synchronize_child_and_parent_category($) {
  $('#categorychecklist').find('input').each(function(index, input) {
    $(input).bind('change', function() {
      var checkbox = $(this);
      var is_checked = $(checkbox).is(':checked');
      if(is_checked) {
        $(checkbox).parents('li').children('label').children('input').attr('checked', 'checked');
      } else {
        $(checkbox).parentsUntil('ul').find('input').removeAttr('checked');
      }
    });
  });
}
jQuery(document).ready(function($){
  synchronize_child_and_parent_category($);
});

function synchronize_child_and_parent_category($) {
  $('#categorychecklist').find('input').each(function(index, input) {
    $(input).bind('change', function() {
      var checkbox = $(this);
      var is_checked = $(checkbox).is(':checked');
      if(is_checked) {
        $(checkbox).parents('li').children('label').children('input').attr('checked', 'checked');
      } else {
        $(checkbox).parentsUntil('ul').find('input').removeAttr('checked');
      }
    });
  });
}

I have a generic admin.js file in WordPress where I keep little back-end snippets like this.

I then register the script:

1
2
3
4
5
wp_register_script(
  PS . '-admin',
  plugins_url('_scripts/admin.js', dirname(dirname(__FILE__))),
  array('jquery')
);
wp_register_script(
  PS . '-admin',
  plugins_url('_scripts/admin.js', dirname(dirname(__FILE__))),
  array('jquery')
);

And enqueue it where needed:

1
2
3
wp_enqueue_style(
  PS . '-admin'
);
wp_enqueue_style(
  PS . '-admin'
);

If you’re curious, the PS (plugin slug) is a constant I define and use infront of many things where I can’t use a PHP namespace, like script and field names, to prevent them from potentially interfering with other plugins.

Lastly, if you’re thinking about using the code above, keep in mind that you’ll also have to prevent checked checkboxes from bubbling to the top, because when a user edits a post with previously checked checkboxes, there’s a good chance the hierarchy will be broken.

-RS