WordPress Plugin: Store Directory

The other day, I was browsing gordini.com, a really cool WordPress site built by my former employer, Union Street Media. I was intrigued by the store locator and wondered about how I would solve that problem if I were tasked with it. As programming teasers tend to go with me, I got carried away with it. One thing led to another, and now I’d like to introduce you to Store Directory, a new WordPress plugin for keeping a database of stores/locations, which includes the ability to search for locations by radius.

Store Directory is both dead simple and extremely flexible. Find it in the WordPress.org plugin repository, along with thorough documentation.

Removing the Slug from Custom Post Types

I came across an interesting question on WordPress Answers today and it tickled my brain.

In WordPress, when you register a custom post type, having a slug is mandatory out-of-the-box. There are a few of reasons for this, but the most important one is performance. If you have conflicting rewrite rules, you can make them all work by hooking into the process and running some queries to see which rule is the intended target of the current path. These queries aren’t free, and in the world of performance, every bit counts. With that in mind, removing slugs is ill-advised. Sometimes there are restrictions beyond your control, and you have to go against your better judgement to do so anyway, so for those cases, here’s how you can remove the slug from custom post type permalinks.

Step 1: Remove the default rewrite rules

When you register a post type, if the ‘rewrite’ argument is not set to false, WordPress will add rewrite rules for you. Normally this is a good thing, but in this case, it’s not going to do so according to our master plan, so we need to disable this. When registering your post type, set the ‘rewrite’ argument to false.

Step 2: Manually add the rewrite rules

When register_post_type is called, WordPress does two main things to add rewrite rules for that post type: it adds a rewrite tag, and adds a permastruct. Since we’re asking WordPress not to do this, we’ll have to do so manually. The rewrite tag will depend on if your post type is hierarchical or not, so your code will look something like this (this should happen on init):

if ( $hierarchical )
	add_rewrite_tag( "%{$post_type}%", '(.+?)', "{$query_var}=" );
else
	add_rewrite_tag( "%{$post_type}%", '([^/]+)', "{$query_var}=" );
add_permastruct( $post_type, "%{$post_type}%", array( 'ep_mask' => EP_PERMALINK ) );

Step 3: Rearrange the generated rewrite rules

Now your rewrite rules are added, but they’re added relatively high in the rewrite array. This is going to cause unnecessary conflicts with other rewrite rules, so we need to move them down the chain a bit. Unfortunately, WordPress doesn’t provide a clean way to do this, so we have to do something a little “hacky” to get the rules in the order we want. Specifically, we need to pull our rules out and inject them into the rules array at a later point in the chain. Since this is an associative array in a specific order, this is easier said than done. We’ll first hook into the {$post_type}_rewrite_rules filter and pull out the rules for this post type, storing them for later use:

global $my_post_type_rules;
$my_post_type_rules = $rules;
# We no longer need the attachment rules, so strip them out
foreach ( $rules as $regex => $value ) {
	if ( strpos( $regex, 'attachment' ) )
	unset( $my_post_type_rules[ $regex ] );
}
return array();

Next, we’ll inject them using the rewrite_rules_array filter:

# This is the first 'page' rule
$offset = array_search( '(.?.+?)/trackback/?$', array_keys( $rules ) );
$page_rules = array_slice( $rules, $offset, null, true );
$other_rules = array_slice( $rules, 0, $offset, true );
return array_merge( $other_rules, $GLOBALS['my_post_type_rules'], $page_rules );

Step 4: Resolve conflicting rewrite rules on-the-fly

Currently, our rewrite rules are working, but pages are not. That’s because WordPress thinks that every page is an entry in our custom post type due to the conflicting rules. To resolve this, we need to hook into the request action and potentially manipulate the generated query vars.

if ( isset( $qv[ $post_type_query_var ] ) ) {
	if ( get_page_by_path( $qv[ $post_type_query_var ] ) ) {
		$qv = array( 'pagename' => $qv[ $post_type_query_var ] );
	}
}
return $qv;

Final notes

That’s the gist of the work we needed to do. It doesn’t cover everything, but should illustrate the important points of what needs to be done. Below I’ve included a class that combines all this and also resolves a few edge cases not addressed or discussed above.

The only other point worth noting is that you can’t do this to more than one post type, at least not without some significant hacking. You see, WordPress stores rewrite rules as an associative array of regex => redirect. Because the regular expression is the array key, you cannot have two identical expressions (having two would be pointless anyway). Are you out of luck? No, but I’ll save that for a future post (dun dun dun… cliffhanger!).

Combined class

<?php
/**
 * Strip the slug out of a custom post type
 */
if ( !class_exists( 'Slugless_Rewrites' ) ) :

class Slugless_Rewrites {

	public $rules;

	public $post_type;

	public $query_var;

	public $hierarchical;

	public $has_archive;

	public $archive_feeds;

	public function __construct( $post_type, $args = array() ) {
		$args = wp_parse_args( $args, array(
			'hierarchical'  => false,
			'query_var'     => false,
			'has_archive'   => false,
			'archive_feeds' => true
		) );

		$this->post_type = $post_type;

		$this->hierarchical = $args['hierarchical'];

		if ( $args['query_var'] )
			$this->query_var = $args['query_var'];
		else
			$this->query_var = $this->post_type;

		$this->has_archive = $args['has_archive'];

		$this->archive_feeds = $args['archive_feeds'];

		add_action( 'init',                             array( $this, 'add_rewrites' ),            20 );
		add_filter( 'request',                          array( $this, 'check_rewrite_conflicts' )     );
		add_filter( "{$this->post_type}_rewrite_rules", array( $this, 'strip_rules' )                 );
		add_filter( 'rewrite_rules_array',              array( $this, 'inject_rules' )                );
	}

	public function add_rewrites() {
		if ( $this->hierarchical )
			add_rewrite_tag( "%{$this->post_type}%", '(.+?)', "{$this->query_var}=" );
		else
			add_rewrite_tag( "%{$this->post_type}%", '([^/]+)', "{$this->query_var}=" );

		if ( ! empty( $this->has_archive ) )
			$this->add_archive_rules();

		add_permastruct( $this->post_type, "%{$this->post_type}%", array( 'ep_mask' => EP_PERMALINK ) );
	}

	public function add_archive_rules() {
		global $wp_rewrite;

		$archive_slug = $this->has_archive === true ? $this->post_type : $this->has_archive;

		add_rewrite_rule( "{$archive_slug}/?$", "index.php?post_type={$this->post_type}", 'top' );
		if ( $this->archive_feeds && $wp_rewrite->feeds ) {
			$feeds = '(' . trim( implode( '|', $wp_rewrite->feeds ) ) . ')';
			add_rewrite_rule( "{$archive_slug}/feed/$feeds/?$", "index.php?post_type={$this->post_type}" . '&feed=$matches[1]', 'top' );
			add_rewrite_rule( "{$archive_slug}/$feeds/?$", "index.php?post_type={$this->post_type}" . '&feed=$matches[1]', 'top' );
		}
		add_rewrite_rule( "{$archive_slug}/{$wp_rewrite->pagination_base}/([0-9]{1,})/?$", "index.php?post_type={$this->post_type}" . '&paged=$matches[1]', 'top' );
	}

	public function check_rewrite_conflicts( $qv ) {
		if ( isset( $qv[ $this->query_var ] ) ) {
			if ( get_page_by_path( $qv[ $this->query_var ] ) ) {
				$qv['pagename'] = $qv[ $this->query_var ];
				unset( $qv[ $this->query_var ], $qv['post_type'], $qv['name'] );
			}
		}
		return $qv;
	}

	public function strip_rules( $rules ) {
		$this->rules = $rules;
		# We no longer need the attachment rules, so strip them out
		foreach ( $this->rules as $regex => $value ) {
			if ( strpos( $value, 'attachment' ) )
				unset( $this->rules[ $regex ] );
		}
		return array();
	}

	public function inject_rules( $rules ) {
		# This is the first 'page' rule
		$offset = array_search( '(.?.+?)/trackback/?$', array_keys( $rules ) );
		$page_rules = array_slice( $rules, $offset, null, true );
		$other_rules = array_slice( $rules, 0, $offset, true );
		return array_merge( $other_rules, $this->rules, $page_rules );
	}
}

endif;

WordCamp Portland

Update Here’s the video from wordcamp.tv:

Thanks to everyone at WordCamp Portland for having me!

Boomer’s 6th Birthday

My Australian Shepherd Boomer turned 6 yesterday. In a fit of nostalgia, I went through some puppy pictures, and came across one of my favorites of him in a dish bucket. Especially since he still has the same pink stuffed elephant, I decided it would be fun to partially re-create the photo:

Boomer - 2 months Boomer - Age 6

New WordPress Plugin: Post Type Icons

WordPress’ announcement of the dashboard redesign inspired me to overhaul the way icons are done in SuperCPT (note: this addition will come in version 0.2, but you can use it now by cloning it from GitHub). After doing so, I realized I could easily parse the icon piece out as its own plugin! Enter Post Type Icons.

Post Type Icons is a radically simple plugin that gives you 361 wonderful icons courtesy of Font Awesome. The icons are font-based, so they’re vector in nature and look amazing no matter your screen’s DPI. Now your custom post types can have custom icons that look just like the core icons in every state (previously, it was difficult to get the hover/active state working quite right). This will be in the WordPress Plugin Repository as soon as it’s approved!

Continue reading

Arrested Development Easter Eggs on Netflix

Netflix introduced some brilliant easter eggs for Arrested Development fans in preparation of Season 4. Here’s what I’ve found:

  • Homeless Dad
  • Love, Indubitably
  • Blue handprints on searches that involve the word “blue”
  • Bananas instead of stars on Arrested Development rating in instant queue (props to Scott Nellé for pointing this one out)
  • Les Cousins Dangereux
  • Girls with Low Self Esteem
  • Girls with Low Self Esteem: Newport Beach
  • Families with Low Self Esteem
  • Scandalmakers
  • Franklin Comes Alive
  • Boyfights
  • World’s Worst Drivers
  • Caged Wisdom
  • El Amor Prohibido
  • Mock Trial with J. Reinhold
  • Ready, Aim, … Marry Me!
  • Wrench

This is an impressive list, nice work Netflix! I probably enjoyed seeing El Amor Prohibido the most, they reached deep for that one. I know I watch too much AD, and as a result, here is a list of titles I was greedily disappointed were absent:

  • New Warden
  • The Ocean Walker
  • Mr. Bananagrabber
  • Use Your Allusion
  • A Thoroughly Polite Dustup
  • Boys Will Be Boys: Boyfights 2
  • A Boyfights Cookout
  • Backseat Boyfights: The Trip To Uncle Jack’s 70th
  • Los Mas Buscados de Mexico