Custom Post Type Archives In WordPress Menus!

You may have read about the Custom Post Type Archives In WordPress Menus dilemma that I brushed over quickly yesterday. Today, after a bit of sleep, I’m ready to publish some concept code that adds Custom Post Type archives to the WordPress-powered menus. It’s going to get dirty, you’ve been warned.

Custom Post Type Archives in WordPress Menus

…okay, okay, for those who came straight for the solution: TLDR

In search of a hook

“We don’t need no hooks!” ~ Wu-Tang Clan – No Hooks

First of all, we need to be able to inject a meta box as closely to where the other menu meta boxes appear as possible. The search starts here. The wp_nav_menu_setup is called to show the Custom Links, Post Types and Taxonomies meta boxes. No direct and obvious hooks in sight.

Scrolling down a little lower we see filters being applied inside the foreach ( $post_types as $post_type ) and the foreach ( $taxonomies as $tax ) loops. These loops register meta boxes with the Menus screen, using the add_meta_box function. So while inside there, let’s add our own.

The Post types loop will trigger the nav_menu_meta_box_object filter first.

add_filter( 'nav_menu_meta_box_object', 'inject_cpt_archives_menu_meta_box', 10, 1);
function inject_cpt_archives_menu_meta_box( $object ) {
  /* get triggered only once */
  remove_filter( 'nav_menu_meta_box_object', 'inject_cpt_archives_menu_meta_box', 10, 1);

  add_meta_box( 'add-cpt', __( 'CPT Archives' ), 'wp_nav_menu_cpt_archives_meta_box', 'nav-menus', 'side', 'default' );

  return $object; /* pass */
}

Notice how a filter is semantically used as an action in this case, sorry, Otto, we be hackin’. Don’t forget to pass the $object out, since the loop is expecting it.

Update – 1st March

Otto has provided an invaluable tip to get rid of the nasty hack. The admin_head-nav-menus.php action works equally well.

add_action( 'admin_head-nav-menus.php', 'inject_cpt_archives_menu_meta_box' );
function inject_cpt_archives_menu_meta_box() {
  /* isn't this much better? */
  add_meta_box( 'add-cpt', __( 'CPT Archives' ), 'wp_nav_menu_cpt_archives_meta_box', 'nav-menus', 'side', 'default' );
}

The metabox

As this is purely a concept, the basic functionality has to be taken care of first and foremost, all the bells and whistles will be added later on as needed. A very stripped down version of a working meta box is something along the following lines:

/* render custom post type archives meta box */
function wp_nav_menu_cpt_archives_meta_box() {

  /* get custom post types with archive support */
  $post_types = get_post_types( array( 'show_in_nav_menus' => true, 'has_archive' => true ), 'object' );

  /* hydrate the necessary properties for identification in the walker */
  foreach ( $post_types as &$post_type ) {
    $post_type->classes = array();
    $post_type->type = $post_type->name;
    $post_type->object_id = $post_type->name;
    $post_type->title = $post_type->labels->name . ' ' . __e( 'Archive');
    $post_type->object = 'cpt-archive';
    
    $post_type->menu_item_parent = null;
    $post_type->url = null;
    $post_type->xfn = null;
    $post_type->db_id = null;
    $post_type->target = null;
    $post_type->attr_title = null;
  }

  /* the native menu checklist */
  $walker = new Walker_Nav_Menu_Checklist( array() );

  ?>
  <div id="cpt-archive" class="posttypediv">
    <div id="tabs-panel-cpt-archive" class="tabs-panel tabs-panel-active">
    <ul id="ctp-archive-checklist" class="categorychecklist form-no-clear">
    <?php
      echo walk_nav_menu_tree( array_map('wp_setup_nav_menu_item', $post_types), 0, (object) array( 'walker' => $walker) );
    ?>
    </ul>
    </div><!-- /.tabs-panel -->
    </div>
    <p class="button-controls">
      <span class="add-to-menu">
	    <img class="waiting" src="<?php echo esc_url( admin_url( 'images/wpspin_light.gif' ) ); ?>" alt="" />
        <input type="submit"<?php disabled( $nav_menu_selected_id, 0 ); ?> class="button-secondary submit-add-to-menu" value="<?php esc_attr_e('Add to Menu'); ?>" name="add-ctp-archive-menu-item" id="submit-cpt-archive" />
      </span>
    </p>
  <?php
}

WordPress Custom Post Type Archive Menu

Most of the code is taken from here, the walker is here.

The AJAX

I didn’t expect the JavaScript to work, but it did. The addition is quite generic, keeping a consistent naming scheme allows the buttons and checkboxes to relate to one another. An XMLHTTP Request is fired off towards /wp-admin/admin-ajax.php. Much of the object information is missing in the request, but the important parts for a proof-of-concept are all there.

action:add-menu-item
menu:14
menu-settings-column-nonce:4361e6f40e
menu-item[-3][menu-item-object-id]:product
menu-item[-3][menu-item-db-id]:0
menu-item[-3][menu-item-object]:cpt-archive
menu-item[-3][menu-item-parent-id]:
menu-item[-3][menu-item-type]:product
menu-item[-3][menu-item-title]:Products Archive
menu-item[-3][menu-item-url]:
menu-item[-3][menu-item-target]:
menu-item[-3][menu-item-classes]:
menu-item[-3][menu-item-xfn]:

We know the archive type, we know the fact that it’s a ‘cpt-archive’ object, ID numbers are irrelevant in the context, everything else is unnecessary for now. Let’s look inside the add-menu-item AJAX action. It uses the Walker_Nav_Menu_Edit walker to render the menu items that we move about. So far so good.

Saving…

Guess what? It just works. This is why I love WordPress.

Displaying

If you take a look at the menu from the frontend, it will not have a href attribute for the new cpt-archive menu items.

wp_nav_menu_items is generally used to retrieve menu items for a menu. This is used by wp_nav_menu as well. A couple of var_dumps later, we find out that the url property (this is parsed into the href attribute) gets set here. And you’ll clearly see inside here, that Custom Links (which is what our CPT Archive links are, unfortunately) get their url set by acquiring the _menu_item_url meta value. This value is hardcoded when saving a Custom Link, but we didn’t pass any URL when adding our menu item object, the value is blank in our case.

No problemo! wp_get_nav_menu_items filter to the rescue.

add_filter( 'wp_get_nav_menu_items', 'cpt_archive_menu_filter', 10, 3 );
function cpt_archive_menu_filter( $items, $menu, $args ) {
  
  /* alter the URL for cpt-archive objects */
  foreach ( $items as &$item ) {
    if ( $item->object != 'cpt-archive' ) continue;
    
    /* we stored the post type in the type property of the menu item */
    $item->url = get_post_type_archive_link( $item->type );
  }

  return $items;
}

Nice and easy. Remember how we stored the name of the Custom Post Type in the type property of the item? And thus has it resurfaced. We use get_post_type_archive_link to get a URL that is always valid (given that the CPT doesn’t cease to exist in between calls).

Update – 2nd March

The currently selected menu item has to have it’s attributes set. Unfortunately, this does not happen out of the box. So the menu hook has check whether the queried for archive type is the one being currently displayed.

add_filter( 'wp_get_nav_menu_items', 'cpt_archive_menu_filter', 10, 3 );
function cpt_archive_menu_filter( $items, $menu, $args ) {
  
  /* alter the URL for cpt-archive objects */
  foreach ( $items as &$item ) {
    if ( $item->object != 'cpt-archive' ) continue;
    
    /* we stored the post type in the type property of the menu item */
    $item->url = get_post_type_archive_link( $item->type );

    if ( get_query_var( 'post_type' ) == $item->type ) {
      $item->classes []= 'current-menu-item';
      $item->current = true;
    }
  }

  return $items;
}

The concept code

Note: for lack of a better separator, and my awful design skills, I’m using GIMP peppers in the collage below.
WordPress Custom Post Type Archives Menu Hack

Here’s the full concept code. “Concept” as in “do not use in production”, “no error checking”, etc. Everyone is free to use the code, but please keep me updated on how it behaves, on what improvements you have made, etc.

<?php
  /* revision 20120302.1 */

  /* inject cpt archives meta box */
  add_action( 'admin_head-nav-menus.php', 'inject_cpt_archives_menu_meta_box' );
  function inject_cpt_archives_menu_meta_box() {
    add_meta_box( 'add-cpt', __( 'CPT Archives', 'default' ), 'wp_nav_menu_cpt_archives_meta_box', 'nav-menus',    'side', 'default' );
  }

  /* render custom post type archives meta box */
  function wp_nav_menu_cpt_archives_meta_box() {
    /* get custom post types with archive support */
    $post_types = get_post_types( array( 'show_in_nav_menus' => true, 'has_archive' => true ), 'object' );    
    
    /* hydrate the necessary object properties for the walker */
    foreach ( $post_types as &$post_type ) {
        $post_type->classes = array();
        $post_type->type = $post_type->name;
        $post_type->object_id = $post_type->name;
        $post_type->title = $post_type->labels->name . ' ' . __( 'Archive', 'default' );
        $post_type->object = 'cpt-archive';
    }


    $walker = new Walker_Nav_Menu_Checklist( array() );

    ?>
    <div id="cpt-archive" class="posttypediv">
      <div id="tabs-panel-cpt-archive" class="tabs-panel tabs-panel-active">
        <ul id="ctp-archive-checklist" class="categorychecklist form-no-clear">
          <?php
            echo walk_nav_menu_tree( array_map('wp_setup_nav_menu_item', $post_types), 0, (object) array( 'walker' => $walker) );
          ?>
        </ul>
      </div><!-- /.tabs-panel -->
    </div>
    <p class="button-controls">
      <span class="add-to-menu">
        <img class="waiting" src="<?php echo esc_url( admin_url( 'images/wpspin_light.gif' ) ); ?>" alt="" />
        <input type="submit"<?php disabled( $nav_menu_selected_id, 0 ); ?> class="button-secondary submit-add-to-menu" value="<?php esc_attr_e('Add to Menu'); ?>" name="add-ctp-archive-menu-item" id="submit-cpt-archive" />
      </span>
    </p>
    <?php
  }

  wp_die('You pasted the code without reading it...');

  /* take care of the urls */
  add_filter( 'wp_get_nav_menu_items', 'cpt_archive_menu_filter', 10, 3 );
  function cpt_archive_menu_filter( $items, $menu, $args ) {
    /* alter the URL for cpt-archive objects */
    foreach ( $items as &$item ) {
      if ( $item->object != 'cpt-archive' ) continue;
      $item->url = get_post_type_archive_link( $item->type );

      /* set current */
      if ( get_query_var( 'post_type' ) == $item->type ) {
        $item->classes []= 'current-menu-item';
        $item->current = true;
      }
    }

    return $items;
  }
?>

WordPress Custom Post Type Archives in Menus [Source Code]

Update 6th May 2012: Kathy Darling has been kind enough to wrap it up into a ready-to-use plugin right here.

Update 20th Dec 2013: A new fork that fixes issues in WordPress 3.8 can be found here.

Conclusion

So! Thoughts, comments, ideas, fixes, improvements, questions, disapproval? Where do we take this from here? Plugin territory or core?