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.
…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 }
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_dump
s 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.
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?
[…] promised, a follow-up can be found here: Custom Post Type Archives in WordPress Menus – because we can!. Tweet !function(d,s,id){var […]
Why must you be all hacking and such? 😉
Try hooking to the “admin_head-nav-menus.php” action to add your meta box.
Ah, how nice. That actually did the trick. Thanks for your input, Otto 🙂
There’s also a plugin for that in the repository. You might want to give that a bash, it does things slightly differently, and doesn’t create a seperate Meta Box for them! Oh and it’s less line of code too! 😉
@Mark, thanks for the tip, don’t know how I missed that plugin. It’s very small indeed and I like the concept. One thing that is a turnoff though is its hardcoded
site_url( $archive_slug )
as URL. When moving a site, these will have to be changed; however, most of the links will have to be changed either way, as they are all absolute, so there isn’t much value in my code having a URL that is always valid, being built on the fly (L53. $item->url = get_post_type_archive_link( $item->type );
, yet I’m really enjoying my solution in production on a couple of sites now and finding it quite useful. As for the number of lines, the HTML for the metabox takes up a whole lot of space 🙂Thanks again for you feedback.
[…] List All Posts In WordPress Navigation MenuYou may remember that last week, I’ve been dabbling in the world or WordPress navigation manus hacking around and looking into how to get dynamic Custom Post Type Archives in WordPress Menus. […]
[…] Source […]
This works great.
I like the addition of the wp_die line, well done.
Thanks for stopping by Jon, glad it worked for you. 🙂
you should wrap this into a plugin for the official repo. though you might need to remove the die() for that. 😉
here, i went ahead and wrapped it up:
https://gist.github.com/2596447
thanks! this saved me a lot of struggle.
Kathy, I don’t think this deserves a plugin of its own, plus there’s already the https://wordpress.org/extend/plugins/cpt-archives-in-nav-menus/ plugin which is very very similar. I would rather see some sort of functionality for post type archive links for menus in core. But I do appreciate your wrapping it up and gisting it, I am adding a link to it at the end of my post, although if you’re going to make any forks of it and altering the original code (besides removing the
die()
) I kindly ask you to remove my authorship from the header; don’t want any code floating around that I’ve not fully written, checked and am ready to back, I’m sure you can understand 😉 feel free to publish and maintain it as a plugin in your own name and give me a little credit for inspiration, though 🙂Have a fantastic weekend ahead.
i don’t know…. i’ve wanted this functionality for a long time. and even though i just came across the other plugins, i think the difference though was that you didn’t use a hard-coded custom link…. i liked that. i agree this shouldn’t be a plugin, it should totally be core..i’m incredulous that this isn’t already! but until it becomes core it is perfectly within plugin territory. hell, maybe if the plugin got popular enough it would be a feature that the core team might consider adding. thanks for the code!
Hey,
first, thanks for this great post. I wanted a menu-item that automatically adds all posts from a specific cpt as submenu-items. So I developed a plugin that works very similar to this. The only difference is that not an archive is displayed but all posts as submenu items. I used your code for the meta-box in the admin nav-menus and wanted to ask if it is ok for you if I publish the plugin?
If you want to take a look at it, mail me and I send it to you. It’s my first plugin, so I hope I made everything the right way.
Andreas, glad my post and code inspired you; you are very welcome to use the code in any way you like, no attribution required (mailed you as well). Good luck!
It’s a great piece of code!
I’ve used in a project but found a problem: it doesn’t atribute the ancestor’s current item class… so I had problem with displaying my sub menus.
maybe you find how to fix this bug.
Thanks for stopping by, Hugo. I’m not sure what you mean, what is the expected behavior? What happens? If you are more specific I’ll be glad to check things out.
let me try to explain:
imagine that you have a dummy first level menu item and your custom post type menu items within; something like:
[href=#] my stuff
[href=my-books] my books
[href=my-discs] my discs
[href=my-cars] my cars
so, your custom post types are books, discs and cars.
when in the archives templates of “books” for instance, you get the “current-menu-item” in the “my books” menu entry, but don’t get the “current-menu-ancestor” class in the “my stuff” menu item.
long story short: you get the “current” class only in one level; need to have the same on the level up.
ps.: sorry if my english sucks 😉
Hugo, if you look at lines 55-56 of the code where
/* set current */ is done you'll see that the 'current-menu-item' class is being applied only
if ( get_query_var( 'post_type' ) == $item->type )
; you can apply the class to the parent dummy item easily, all the items are there. Hope this helps.Thanks for this, it should really be in core.
Thanks for this code. I’m not sure if you’ve tested it with WP_DEBUG on though as I get a lot of PHP notices. I’m running WordPress version 3.5-beta2-22241 so I guess it could be that as well.
I haven’t worked out yet what’s causing it but thought I’d let you know in case you want to update the code.
Here are the notices:
https://hastebin.com/xasunulipu.vbs
Johannes, thanks for your report. Send in any updates you find, I’ll take a look into it a bit later if I have the chance. Does it still work though? And have you got the ability to print out full stack traces?
Yes the functionality is still there and works as expected
Sorry I don’t have ability to do stack traces on this server. I’ll report back anything I find when trying to solve this though.
Thanks for spotting this, I forgot to add some crucial data fields when converting from a `post_type` to a `menu_item`, fixed code above by adding the missing fields.
just try :
…
<!– /.tabs-panel –>
…
<!– /.posttypediv –>
it run perfectly =)
[…] Source […]
Great plugin,
I found a couple of issues in the WordPress 3.8 update, I forked Kathy’s gist to fix it:
https://gist.github.com/davidmh/8050982
Thanks, looks good 🙂
For my first site that i am developping for a client using the “great” WordPress, I found this peace of code genious and wonder why they didn’t integrate it in the core of WordPress
(sorry for my bad english 🙂
thank you it is working fine for me 🙂
[…] code is completely based on this one here, with a couple of minor changes in the interpretation part of the item […]
Thanks a lot, your code still works flawlessly in WP 4.1 🙂 Yes, this should be in the core.
[…] example : https://codeseekah.com/2012/03/01/custom-post-type-archives-in-wordpress-menus-2/ this solution doesn’t seem to work because it call add_meta_box with a "nav-menus" post type, […]
Nice solution, Thx,
But I found one problem.
When do you use:
$post_type->type = $post_type->name;
then function wp_ajax_add_menu_item in ajax-actions.php
[https://github.com/markjaquith/WordPress/blob/master/wp-admin/includes/ajax-actions.php#L1106] trying use variable $_object
[https://github.com/markjaquith/WordPress/blob/master/wp-admin/includes/ajax-actions.php#L1134] and this varible don’t exist , generate NOTICE.
better is set:
$postType->type = NULL;
$postType->object = ‘cpt-archive-‘ . $postType->name;
and in cpt_archive_menu_filter:
function cpt_archive_menu_filter($items, $menu, $args)
{
/* alter the URL for cpt-archive objects */
foreach ($items as &$item) {
if (preg_match(‘~^cpt-archive-(?P.*)$~’, $item->object, $matches)) {
if (empty($matches[‘type’])) continue;
$item->url = get_post_type_archive_link($matches[‘type’]);
/* set current */
if (get_query_var(‘post_type’) == $matches[‘type’]) {
$item->classes [] = ‘current-menu-item’;
$item->current = TRUE;
}
}
}
return $items;
}
Thanks, perhaps changes from WordPress version to the next broke this, or maybe it was a miss on my part. Either way, I appreciate your fix.