Display Posts Shortcode – Full Content

The Display Posts Shortcode plugin lets you display a list of posts based on any criteria. By default it displays titles only. The example below makes it display the post’s title and full content.

  • Eliminate spam with a custom honeypot

    A custom honeypot is a simple and effective way to eliminate spam. If a hidden field in your form is filled in, you can be fairly confident the submission is spam.

    WPForms does include a built-in honeypot, but now that the plugin is used on millions of sites, most spam bots have been updated to identify and skip the WPForms field with a name of hp.

    Your custom honeypot is different. It’s unique to your form and looks like any other field to a bot. I have pretty much eliminated spam on my contact form with a custom honeypot.


    First, come up with a unique CSS class name you’ll use to identify your honeypot field. Make it something unique to your site (ie: not honeypot). If your class is my-fancy-field, add this to your theme’s stylesheet to hide that field.

    .wpforms-container .my-fancy-field {
    	display: none;
    }

    Create a field in your form and add your custom class to it.

    Add the following code to your theme’s functions.php file or a Core Functionality plugin. If this field is ever filled in, the submission will be marked as spam and have a honeypot message of “[Custom honeypot]”.

    Make sure you update the $honeypot_class variable at the top to use your custom class name.

    /**
    * WPForms Custom Honeypot
    *
    * @author Bill Erickson
    * @link http://www.billerickson.net/eliminate-spam-with-a-custom-honeypot/
    *
    * @param string $honeypot, empty if not spam, honeypot text is used in WPForms Log
    * @param array $fields
    * @param array $entry
    * @param array $form_data
    */
    function be_wpforms_custom_honeypot( $honeypot, $fields, $entry, $form_data ) {
    	$honeypot_class = 'my-fancy-field';
    
    	$honey_field = false;
    	foreach( $form_data['fields'] as $form_field ) {
    		if( false !== strpos( $form_field['css'], $honeypost_class ) ) {
    			$honey_field = absint( $form_field['id'] );
    		}
    	}
    
    	if( !empty( $honey_field ) ) {
    		$honeypot = 'Custom honeypot';
    	}
    
    	return $honeypot;
    }
    add_filter( 'wpforms_process_honeypot', 'be_wpforms_custom_honeypot', 10, 4 );

    Logging

    You can also enable logging so you can see if the honeypot is working. Every time a spam entry is submitted, this will create a post in the wpforms_log post type with the honeypot message and the full submission.

    I recommend only logging this data temporarily because you don’t want to fill up your database with a bunch of unimportant spam messages.

    First, update the wpforms_logging option to log spam:

    /**
     * Enable logging of spam
     *
     */
    add_action( 'init', function() {
    	$debug = get_option( 'wpforms_logging' );
    	if( empty( $debug ) && ! in_array( 'spam', $debug ) )
    		update_option( 'wpforms_logging', [ 'spam' ] );
    });

    Use this code to make the WPForms Log post type visible. You can then access it in WPForms > Logs.

    /**
     * Make log visible
     *
     */
    add_filter( 'wpforms_log_cpt', function( $args ) {
    	$args['show_ui'] = true;
    	unset( $args['capability_type'] );
    	return $args;
    });
  • Using SpinupWP as a development server

    SpinupWP is a modern cloud-based server control panel. It gives you the features of a managed WordPress host on your own low-cost and scalable Digital Ocean servers.

    It’s a great choice for production sites, and I host many of my personal sites there. It also makes an excellent development environment.

    My development server requirements are:

    1. Blazing fast. Most of my clients hire me to make their site faster, so my dev server should be optimized for speed.
    2. Scalable. I can easily scale up and down the size of the server based on my current needs.
    3. Reasonably priced. I want to keep my hosting costs minimal.

    I’m currently using a $15/month droplet with 3GB of memory and 60GB of storage. When you add in the $9/month for SpinupWP, my total hosting bill of $24/month is less than most managed WordPress hosts for a single site.

    $50 credit for SpinupWP

    If you sign up for SpinupWP using the link below, you’ll get a $50 credit added to your account after 30 days.

    Sign up now

    My preferred development approach

    I use git to version control the custom themes I develop. When I’m ready to push my local changes to the server, I type git push staging master. I also use WP Migrate DB Pro to push/pull the database between environments.

    For my clients hosted with WPEngine or BigScoots, I use their built-in staging environments and git-based deployment. For more information, see my articles on git for WPEngine and git for BigScoots.

    If a client is hosted elsewhere, I use SpinupWP to replicate the same git push development workflow and features.

    Setting up a development environment

    Once you have your SpinupWP server setup, it’s easy to add new sites. I have a domain I use for development sites, with each site being setup as a subdomain.

    Log into the SpinupWP Dashboard and click the “New Site” button. Create a fresh WordPress install (here’s a guide).

    Install WP Migrate DB Pro along with the Media and Theme & Plugin addons on the production site and the new development site. Pull a copy of the database, media, themes, and plugins to your dev server. Alternatively, you can copy the site files over via SSH.

    Set up git on the server

    1. When you ssh into the server, you should already be in the site directory. Typing ls should list /files and /logs
    2. Create a bare repository. git init --bare project.git
    3. Create the post-receive hook. touch project.git/hooks/post-receive
    4. Add execute permissions to the post-receive hook. chmod +x project.git/hooks/post-receive
    5. Edit the post-receive hook (vi project.git/hooks/post-receive) and add the following to it. Make sure you update the two paths at the top to match your environment
    #!/bin/bash
    TARGET="/sites/clientname.cultivatewp.com/files"
    GIT_DIR="/sites/clientname.cultivatewp.com/project.git"
    BRANCH="master"
    
    while read oldrev newrev ref
    do
            # only checking out the master (or whatever branch you would like to deploy)
            if [ "$ref" = "refs/heads/$BRANCH" ];
            then
                    echo "Ref $ref received. Deploying ${BRANCH} branch to production..."
                    git --work-tree=$TARGET --git-dir=$GIT_DIR checkout -f $BRANCH
            else
                    echo "Ref $ref received. Doing nothing: only the ${BRANCH} branch may be deployed on this server."
            fi
    done

    In your local environment, add the remote:

    git remote add staging ssh://[email protected]/~/project.git

    Local environment, no media

    A lot of the sites I build have massive uploads directories. To save local hard drive space and simplify database syncing, I don’t pull any of the media down locally.

    I’ll use Migrate DB Pro to pull down a local copy of the database, theme and plugins. I’ll then install BE Media from Production and use it to source media from the development server. You can install and set it up with wp cli:

    wp plugin install be-media-from-production --activate
    wp config set BE_MEDIA_FROM_PRODUCTION_URL https://clientname.cultivatewp.com --type=constant

    One of the first things I do when building a new theme is determine all the required image sizes. After adding the relevant add_image_size()‘s to the theme, I’ll push the theme and database to staging and regenerate thumbnails. I can then pull a fresh copy of the database locally and have access to all the newly generated thumbnails.

    Likewise, if I need to upload new images, I push the database to staging, upload the image on staging, then pull a fresh copy of the database locally.

  • Gutenberg Theme Development – Tips for Success

    I’m giving a talk on this tomorrow, April 2nd, as part of the free WPBlockTalk virtual event. I hope to see you all there!

    The new NexRep website is built completely with the Gutenberg block editor.

    We’ve built dozens of WordPress sites just like NexRep that rely on the block editor for rich, engaging landing pages that are easily maintained by non-technical editors with a consistent style guide.

    These are my tips for success with Gutenberg theme development.

    Start with Atomic Design

    If you are working with a designer, make sure they are familiar with the block editor and use an atomic design approach.

    We start with the atoms of our style guide – headings, paragraph, link, and button. These are then built into molecules, like a Post Summary (post title, category, excerpt). These are then built into organisms, like a Post Listing (section header, 3 column grid of posts).

    The launch of Gutenberg was the final push I needed to no longer work with client-provided designs. Every website I build is now designed by my two design partners, Duane Smith and Andrew Pautler, who have a deep understanding of the Gutenberg block editor.

    An atomic design approach empowers content creators with the full power of the Gutenberg block editor, rather than fighting against it and trying to maintain an old template-based design approach.

    Editor Styles

    To ensure the best content editing experience, the backend editor should match the frontend as closely as possible. Here’s what the block editor looks like for the page shown in the first screenshot.

    Use an editor stylesheet to load a separate CSS file in the editor with your unique block styles. Create an editor-style.css file in your theme, and add the following code to your theme’s functions.php file to load it:

    // Editor Styles
    add_theme_support( 'editor-styles' );
    add_editor_style( 'editor-style.css' );

    You can use the editor font sizes feature to define different font size options for paragraph text. The defaults are Small, Normal, and Large, but you can load any number you’d like.

    SASS

    SASS comes in handy here. Rather than trying to maintain two separate stylesheets, you can break your main stylesheet into SASS partials and build both stylesheets with the relevant partials.

    When you update a style that applies to the block editor, it automatically updates both style.css and editor-style.css.

    Take a look at my starter themes for examples of how I structure my stylesheets.

    Color Palette

    You should also update the Gutenberg color palette to use your theme colors. We use the color options for changing button colors and for adding background color to full width sections.

    We’ll typically have two versions for each color: the normal (darker) version which will have white text, and a lighter version that will use the standard text color.

    Additional editor scripts and styles

    If you use the add_editor_style() feature described above, WordPress will automatically prefix your styles so they only affect the content area. h2 {} becomes .editor-styles-wrapper h2 { }.

    In some instances, you may want to load scripts or styles that are not modified by WordPress. You can use the enqueue_block_editor_assetshook.

    On the NexRep site, we’re loading a stylesheet to change the content area width based on the selected page layout (ex: Full Width Content vs Content Sidebar).

    Block Styles

    You can add style variations to core blocks using block styles. On the NexRep site, we have two button styles (Default and Feature), and have removed unnecessary block styles from the separator and quote blocks.

    wp.domReady( () => {
    
    	wp.blocks.unregisterBlockStyle(
    		'core/button',
    		[ 'default', 'outline', 'squared', 'fill' ]
    	);
    
    	wp.blocks.registerBlockStyle(
    		'core/button',
    		[
    			{
    				name: 'default',
    				label: 'Default',
    				isDefault: true,
    			},
    			{
    				name: 'feature',
    				label: 'Feature',
    			}
    		]
    	);
    
    	wp.blocks.unregisterBlockStyle(
    		'core/separator',
    		[ 'default', 'wide', 'dots' ],
    	);
    
    	wp.blocks.unregisterBlockStyle(
    		'core/quote',
    		[ 'default', 'large' ]
    	);
    
    } );

    Remove core blocks

    You can also remove core blocks using wp.blocks.unregisterBlockType. We typically remove core blocks when we’ve built a custom block that’s very similar, to minimize confusion for the client.

    On NexRep, we have custom blocks that are similar to the core Media & Text, Cover, and Latest Posts blocks.

    wp.domReady( () => {
    
    	wp.blocks.unregisterBlockType( 'core/verse' );
    	wp.blocks.unregisterBlockType( 'core/cover' );
    	wp.blocks.unregisterBlockType( 'core/pullquote' );
    	wp.blocks.unregisterBlockType( 'core/media-text' );
    	wp.blocks.unregisterBlockType( 'core/latest-posts' );
    
    } );
    

    Custom blocks

    I’m a huge fan of Advanced Custom Fields. We use it on every website for custom blocks, site options, term meta, and more.

    One of the most common questions I’m asked by WordPress developers exploring Gutenberg is whether they need to build custom blocks natively with React or use a plugin like ACF.

    I personally think of it along the same lines as custom metaboxes. For single use blocks built for a specific website, it makes sense to use a tool like ACF for rapid development. If you plan to distribute your block publicly in a plugin, you’ll want to build it with React so there isn’t a dependency on another plugin.

    Most of the websites we build include 10-20 custom blocks. The NexRep site included these custom blocks:

    • Hero Header
    • Call to Action
    • Icon Bullets
    • Content & Image
    • Content & Video
    • Content & Icon
    • Content Image Overlay
    • Percent Bullets
    • Featured Logos
    • Nexrep Medallion
    • Quick Links
    • Post Listing
    • Staff

    Try to leverage core blocks as much as possible, and keep your custom blocks simple. In the quick video below, you’ll see how we use the Group block for the full width section with Heading and Icon Links block inside.

    Client Training

    The final step to a successful project is training the client so they are comfortable with the new theme. This is especially important when it comes to the block editor as this is likely the first time the client has interacted with it.

    We provide WP101 video tutorials to all of our clients. On websites like NexRep that include a fairly complex design with custom blocks, we record custom videos to include alongside the standard WordPress videos.

  • Category specific email optin with WPForms

    Almost all of our food blogger and publisher clients use email optins to encourage readers to join their email newsletter.

    We often customize the title and description of the form based on the current post’s category. For instance, the newsletter signup form in the Beauty category might look like:

    While we could build a separate form for every category, a simpler approach is to create a single form and use category metadata to override the default title and description for certain categories.

    Create default form

    Go to WPForms > Add New and create your newsletter signup form. Include the standard title and description that’s applicable site-wide.

    If the category does not have a specific title or description set, it will use whatever you have set here.

    Term Metabox for category override

    Using Advanced Custom Fields, create a metabox that appears on the Edit Category screen allowing editors to change the Optin Title and Optin Description. I’m using be_optin_title and be_optin_description as the respective meta keys.

    Use term meta to customize form

    Finally, we can add the following code to change the form’s title and description. This code can go in your theme’s functions.php file or a core functionality plugin.

    Make sure you change 123 at the top to the form ID you’d like to target. I’m using the ea_first_term() helper function to select the primary category if the post appears in more than one category.

    /**
     * Customize optin title/description by category
     * @link https://www.billerickson.net/category-specific-email-optin-with-wpforms/‎
     *
     * @param array $form_data
     * @return array
     */
    function be_wpforms_category_optin( $form_data ) {
    
    	if( 123 != $form_data['id'] )
    		return $form_data;
    
    	$term_id = false;
    
    	if( is_category() )
    		$term_id = get_queried_object_id();
    	elseif( is_single() )
    		$term_id = ea_first_term( [ 'field' => 'term_id' ] );
    
    	if( ! $term_id )
    		return $form_data;
    
    	$title = get_term_meta( $term_id, 'be_optin_title', true );
    	if( !empty( $title ) )
    		$form_data['settings']['form_title'] = $title;
    
    	$description = get_term_meta( $term_id, 'be_optin_description', true );
    	if( !empty( $description ) )
    		$form_data['settings']['form_desc'] = $description;
    
    	return $form_data;
    }
    add_filter( 'wpforms_frontend_form_data', 'be_wpforms_category_optin' );
    
  • Git based code deployment on Big Scoots

    Many of our food blogger clients are hosted with Big Scoots, a popular managed WordPress host.

    Their portal includes one-click push between your production and staging environments, which is great for testing code changes before deploying on your production site.

    With a bit of work you can create a git-based deployment workflow similar to WPEngine. I’ve also written a similar article for using git with SpinupWP.

    If everything below looks too technical, I highly recommend using the WP Pusher plugin. It’s incredibly easy to use, free for public repos, and reasonably priced for private repos. We use WP Pusher for any clients not hosted with Big Scoots or WPEngine.

    Setting up git for production

    Open a support ticket requesting git to be setup on the server and include your public key. They will respond back confirming it has been installed and provide you with SSH access to the server.

    Open Terminal and use ssh to connect to your server (ssh -p2222 [email protected]). We’ll follow this guide to setting up git on the server.

    We’re going to set up a bare git repository in the site directory. We want to create it in clientname.com , not clientname.com/public.

    By keeping it outside the public directory we won’t have any issues with the “Push Live to Staging” or “Push Staging to Live” features – they move the contents of the public directory.

    1. Navigate to the site directory. If the website is clientname.com, type cd domains/clientname.com
    2. Create a bare repository. git init --bare project.git
    3. Create the post-receive hook. touch project.git/hooks/post-receive
    4. Add execute permissions to the post-receive hook. chmod +x project.git/hooks/post-receive
    5. Edit the post-receive hook (vi project.git/hooks/post-receive) and add the following to it. Make sure you update the two paths at the top to match your environment
    #!/bin/bash
    TARGET="/home/nginx/domains/clientname.com/public"
    GIT_DIR="/home/nginx/domains/clientname.com/project.git"
    BRANCH="master"
    
    while read oldrev newrev ref
    do
            # only checking out the master (or whatever branch you would like to deploy)
            if [ "$ref" = "refs/heads/$BRANCH" ];
            then
                    echo "Ref $ref received. Deploying ${BRANCH} branch to production..."
                    git --work-tree=$TARGET --git-dir=$GIT_DIR checkout -f $BRANCH
            else
                    echo "Ref $ref received. Doing nothing: only the ${BRANCH} branch may be deployed on this server."
            fi
    done
    1. Add the remote repository to your local environment
    cd ~/path/to/working-copy
    git remote add production ssh://[email protected]:2222/~/domains/clientname.com/project.git
    1. Push code to the production server git push production master

    Setting up git for staging

    You can use the exact same approach outlined above to setup git on staging.

    If you haven’t created a staging environment yet, log into the BigScoots Portal, go to your site, and click “Create Staging”.

    Once the staging site has been created, ssh into the server and navigate to the staging site’s directory. Using the clientname.com example above, the staging environment would be named clientnamecom.bigscoots-staging.com

    Once you have everything setup on the server, go back to your local environment and add the staging remote

    git remote add staging ssh://[email protected]:2222/~/domains/clientnamecom.bigscoots-staging.com/project.git
  • SASS Color Function

    The custom WordPress themes we build use brand colors for certain design elements and the Gutenberg color palette.

    We usually have three versions of each color: the main color, and lighter/darker versions for hover effects. We’ll often auto-generate the lighter/darker versions, but sometimes we need to specify a custom color.

    In the past I’ve added all of the colors as their own independent variables:

    // Brand Colors
    // -- normal / darker / lighter
    $blue_1: #59BACC;
    $blue_2: darken( $blue_1, 10% );
    $blue_3: lighten( $blue_1, 10% );
    $green_1: #58AD69;
    $green_2: #458D53;
    $green_3: #7ABE88;

    I’ve recently shifted to using a custom SASS function, brand-color(), that makes it easier to select the color.

    a {
    	color: brand-color( 'blue' );
    
    	&:hover {
    		color: brand-color( 'blue', 'darken' );
    }

    Inside my theme I define a map of $brand_colors. If you only specify the main color, it will auto-generate the lighten and darken variants. But you can also manually specify your own colors for those.

    The list of 6 variables above has been reduce to:

    $brand_colors: (
    	'blue'		: #59BACC,
    	'green'		: #58AD69,
    	'green_darken'	: #458D53,
    	'green_lighten'	: #7ABE88,
    );

    And here’s my custom brand-color() function. Take a look at my base theme to see how it’s included.

    If you’d like to build your own SASS functions, take a look at the @function documentation.

    /**
     * Brand Color
     *
     */
    @function brand-color( $key, $variant: null ) {
    	@if map-has-key( $brand_colors, $key ) {
    		$color: map-get( $brand_colors, $key );
    		@if ( 'lighten' == $variant ) {
    			$lighten_key: $key + '_lighten';
    			@if map-has-key( $brand_colors, $lighten_key ) {
    				$color: #{map-get( $brand_colors, $lighten_key )};
    			} @else {
    				$color: lighten( $color, 10% );
    			}
    		}
    		@else if( 'darken' == $variant ) {
    			$darken_key: $key + '_darken';
    			@if map-has-key( $brand_colors, $darken_key ) {
    				$color: #{map-get( $brand_colors, $darken_key )};
    			} @else {
    				$color: darken( $color, 10% );
    			}
    		}
    		@return $color;
    	} @else {
    		@error "The #{$key} color could not be found in $brand_colors";
    	}
    }
    
  • Reusable Blocks accessible in WordPress admin area

    Reusable Blocks are one of my favorite features in the new Gutenberg block editor. You can make content “reusable” and insert it into multiple pages.

    We use this on GBC Law Group to list their services throughout the site. If they add a new service, it will automatically update everywhere at once.

    What are reusable blocks?

    Reusable blocks let you write content once and use it on multiple pages of your site. If you update the reusable block on any of those pages, it will automatically update everywhere it was used, keeping them in sync.

    Reusable blocks are actually posts in a custom post type. When you create a reusable block, it adds those blocks to a new post in the wp_block post type.

    To create a reusable block, start by adding any block to the page, then click “Add to Reusable Blocks”. Give your reusable block a title, then click “Save”. You can click “Edit” to add additional content.

    When you insert a reusable block in a page, it references the reusable block’s post ID so it can always pull in the latest content. That’s how you can edit the reusable block once and it’s updated everywhere on your site.

    On this website I have made a “Practice Areas” reusable block that contains multiple “Service” blocks – a custom block I built using Advanced Custom Fields.

    When I’m editing another page, I can search the block list for my “Practice Areas” reusable block to insert it.

    If you click the “Manage All Reusable Blocks” link, you can manage them like any other custom post type. But it would be even simpler if we included this link in the admin menu.

    Add reusable blocks to admin menu

    The following code will add “Reusable Blocks” to the WordPress admin menu. Add this to your theme’s functions.php file or a core functionality plugin:

    /**
     * Reusable Blocks accessible in backend
     * @link https://www.billerickson.net/reusable-blocks-accessible-in-wordpress-admin-area
     *
     */
    function be_reusable_blocks_admin_menu() {
        add_menu_page( 'Reusable Blocks', 'Reusable Blocks', 'edit_posts', 'edit.php?post_type=wp_block', '', 'dashicons-editor-table', 22 );
    }
    add_action( 'admin_menu', 'be_reusable_blocks_admin_menu' );

    You can now click “Reusable Blocks” to see the full listing and edit them.

    When you click on a reusable block, you get the familiar block editor for managing all the content within that reusable block.

    WordPress Plugin

    I’ve built a plugin you can install to add this functionality without editing any code.

    Download and install Reusable Blocks UI.

  • Include custom fields in your WP Recipe Maker template

    The recipe editor in WP Recipe Maker includes fields like cook time, ingredients, and other common recipe items. You can use custom fields to add additional information to your recipe cards.

    SkinnyMs. includes Weight Watchers’ SmartPoints on all of their recipes, but there isn’t a field for this in WP Recipe Maker. They also use WP Recipe Maker for workouts and needed custom fields for items like Level of Difficulty and Targeted Muscle Group.

    Adding custom fields

    In the backend of your WordPress website, go to WP Recipe Maker > Manage, then click “Your Custom Fields” at the top, and “Custom Fields” in the secondary navigation at the top.

    Click the “Create Custom Field” button in the top right to create a new custom field. In the popup window you will select a type of field (text, image…), a key, and a name.

    The key should be lowercase and have no spaces; you can use hyphens or underscores to separate the words.

    Using custom fields when creating a recipe

    Now whenever you create or edit a recipe, you can scroll down to the Custom Fields section to fill out your custom fields.

    Displaying custom fields in your recipe card

    Finally, you will need to edit your recipe card template to display your custom field. For more information, see my article on Custom Recipe Templates for WP Recipe Maker.

    Go to WP Recipe Maker > Settings and open the Template Editor. Select your template and click “Edit Template”.

    Click “Add Blocks” and select “Recipe Custom Field Container”, then select after which block it should appear. You’ll then see the “Edit Blocks” tab open. From the dropdown select the custom field you’d like displayed and any additional display options (style, text style…). You should also specify the Label at the bottom.

    Save your recipe template, exit out of the Template Editor, then go view a recipe that includes a custom field. The custom field should now appear in your recipe card.

  • SearchWP Related metabox on specific post types

    SearchWP has a related posts extension for generating a list of related posts based on their relevancy engine.

    The metabox appears on all post types by default, and you can use the searchwp_related_excluded_post_types filter to exclude certain post types.

    This often includes post types that have no need for related posts, like ACF field groups:

    Rather than listing the excluded post types, I prefer specifying the few post types to include.

    In the code below, I have an $allowed array listing the post types on which I’d like the metabox to appear. It then generate a list of all other post types and excludes SearchWP Related on those.

    /**
     * SearchWP Related on specific post types
     * @link https://www.billerickson.net/searchwp-related-metabox-on-specific-post-types/
     */
    function ea_searchwp_related_exclude( $exclude = array() ) {
    	$allowed = array( 'post', 'resource' );
    	$all = array_keys( get_post_types() );
    	return array_diff( $all, $allowed );
    }
    add_filter( 'searchwp_related_excluded_post_types', 'ea_searchwp_related_exclude' );
  • Custom importer for WP Recipe Maker

    I frequently work with food bloggers to improve the design, speed, and functionality of their websites. This often includes migrating to WP Recipe Maker, the industry-leading recipe card plugin.

    WP Recipe Maker already includes importers for many popular recipe plugins. You can skip to the end to see how to import your recipes if you are using BigOven, Cookbook, Cooked, Create, EasyRecipe, FoodiePress, Meal Planner Pro, Purr Design, Recipe Card, Simmer, Simple Recipe Pro, Tasty, WordPress.com, WP Ultimate Recipe, Yummly, or ZipList.

    Sometimes during our discovery phase we’ll find the previous developer built their own custom recipe card directly in the theme. In the process of redesigning the site we’ll need build a custom importer and migrate all the recipes to WP Recipe Maker.

    Switching to WP Recipe Maker lets us take advantage of all the modern features it provides (schema for SEO, advanced templating, user ratings…), and stay up-to-date with future changes required for SEO.

    Review the recipe data structure

    The first step is to figure out how the current plugin is storing the recipe data. Most custom built recipe cards will store it as post meta, but it could be stored in a separate post type or custom database tables.

    A quick tip for inspecting post meta is to drop this code in functions.php in my local development environment:

    /**
     * Display all post meta
     *
     */
    add_action( 'wp_footer', function() {
    	if( is_single() )
    		ea_pp( get_post_meta( get_the_ID() ) );
    });
    
    /**
     * Pretty Printing
     *
     */
    function ea_pp( $obj, $label = '' ) {
    	$data = json_encode( print_r( $obj,true ) );
    	?>
    	<style type="text/css">
    		#bsdLogger {
    		position: absolute;
    		top: 30px;
    		right: 0px;
    		border-left: 4px solid #bbb;
    		padding: 6px;
    		background: white;
    		color: #444;
    		z-index: 999;
    		font-size: 1.25em;
    		width: 400px;
    		height: 800px;
    		overflow: scroll;
    		}
    	</style>
    	<script type="text/javascript">
    		var doStuff = function(){
    			var obj = <?php echo $data; ?>;
    			var logger = document.getElementById('bsdLogger');
    			if (!logger) {
    				logger = document.createElement('div');
    				logger.id = 'bsdLogger';
    				document.body.appendChild(logger);
    			}
    			////console.log(obj);
    			var pre = document.createElement('pre');
    			var h2 = document.createElement('h2');
    			pre.innerHTML = obj;
    			h2.innerHTML = '<?php echo addslashes($label); ?>';
    			logger.appendChild(h2);
    			logger.appendChild(pre);
    		};
    		window.addEventListener ("DOMContentLoaded", doStuff, false);
    	</script>
    	<?php
    }
    

    When viewing a single post, there will be a column added to the right side of the screen that lists all of its metadata:

    It’s also a good idea to dig into the theme/plugin to see how it’s accessing and displaying the recipe data. Simple fields like Prep Time and Cook Time are usually straightforward, but more complicated fields like Ingredients and Directions may require more work to assemble.

    The site I’m working on right now is storing ingredients and instructions as serialized arrays.

    $ingredients_data = maybe_unserialize( get_post_meta( $id, '_recipe_ingredients', true ) );

    Write down all the data you’ll need to access and how it is stored. I’ll usually create a simple spreadsheet with Field Type (“ingredients”), Field Meta Key (_recipe_incredients), and Notes (“stored as serialized array”).

    Where to store the custom importer

    First you need to decide how you would like to store your custom importer. You could keep it in:

    • Your theme, in which case you’d create the importer file inside your theme and use the wprm_importer_directories filter in your functions.php file.
    • A plugin, in which case you’d create the importer file inside the plugin and use the wprm_importer_directories filter in the main plugin file. You could either build this as a standalone plugin or as part of a core functionality plugin (bundled with other functionailty)

    I typically build it as a standalone plugin. We usually build these as part of our redesign process, and we want to get the importer functionality running on the live website before the redesign is deployed. Here’s an example: CCK Recipe Importer plugin.

    Build the custom importer

    You can find all of WP Recipe Maker’s current importers and their example importer in /plugins/wp-recipe-maker/includes/admin/import/class-wprm-import-example.php.

    To create your own custom importer, create a file in your theme or core functionality plugin named class-wprm-import-{something}.php, where {something} is your unique importer name. I’ll usually use the client’s name for this, so in the custom importer I’m building for The Mom 100 it’s called class-wprm-import-themom100.php.

    Then use the wprm_importer_directories filter to tell WP Recipe Maker the directory in which your importer can be found. It will search that directory for files using the above naming structure, so you can include a directory that has other files in it without issues.

    I placed my custom importer in the /inc/ directory of my core functionality plugin, then added the following code:

    // Plugin directory
    define( 'EA_DIR' , plugin_dir_path( __FILE__ ) );
    
    /**
     * Include custom WPRM importer
     * @link https://www.billerickson.net/custom-importer-for-wp-recipe-maker/
     */
    function be_custom_wprm_importer( $directories ) {
    	$directories[] = EA_DIR . '/inc/';
    	return $directories;
    }
    add_filter( 'wprm_importer_directories', 'be_custom_wprm_importer' );

    Copy the contents of the class-wprm-import-example.php file into your new file, then rename the class from WPRM_Import_Example to WPRM_Import_{Something}, changing {Something} to match what you used in the file name.

    Update the get_uid() method to use your slug (ex: themom100), and the get_name() method to use your name (ex: The Mom 100).

    Get Recipes

    The next step is to update the get_recipe_count() and get_recipes() methods used to find the recipes on the site.

    On this project, recipes are every post in the recipe post type. If your recipes are mixed in with posts, you would write a query that searches for posts containing a meta key only used by recipes.

    It’s also important to eliminate recipes you have already imported. After I finish importing a recipe, I’m going to run update_post_meta( $post_id, '_recipe_imported', 1 ); to indicate this one has been imported. In my query below, I’m looking for recipes that do not have that key because we only want to list recipes that haven’t been imported yet.

    For get_recipe_count() you want to find the total recipe count, so set posts_per_page arbitrarily high and only query for IDs.

    	/**
    	 * Get the total number of recipes to import.
    	 *
    	 * @since    1.20.0
    	 */
    	public function get_recipe_count() {
    
    		$loop = new WP_Query( array(
    			'fields' => 'ids',
    			'post_type' => 'recipe',
    			'posts_per_page' => 999,
    			'meta_query' => array(
    				array(
    					'key' => '_recipe_imported',
    					'value' => 1,
    					'compare' => 'NOT EXISTS',
    				)
    			)
    		) );
    
    		return $loop->found_posts;
    
    	}
    

    For the get_recipes() method, we’re getting a paginated list of recipes to import. You want to keep the list to a reasonable size (ex: 20) so it doesn’t time out if someone tries importing all the recipes in the list at once. You’ll also want to use the $page parameter so that clicking the pagination links actually changes the posts shown.

    Use this loop to build the $recipes array that includes the post ID, post title, and edit post link.

    	/**
    	 * Get a list of recipes that are available to import.
    	 *
    	 * @since    1.20.0
    	 * @param	 int $page Page of recipes to get.
    	 */
    	public function get_recipes( $page = 0 ) {
    		// Return an array of recipes to be imported with name and edit URL.
    		// If not the same number of recipes as in "get_recipe_count" are returned pagination will be used.
    
    		$loop = new WP_Query( array(
    			'post_type' => 'recipe',
    			'posts_per_page' => 20,
    			'paged' => $page,
    			'meta_query' => array(
    				array(
    					'key' => '_recipe_imported',
    					'value' => 1,
    					'compare' => 'NOT EXISTS',
    				)
    			)
    		) );
    
    		$recipes = array();
    		foreach( $loop->posts as $post ) {
    			$recipes[ $post->ID ] = array(
    				'name' => $post->post_title,
    				'url'  => get_edit_post_link( $post->ID )
    			);
    		}
    		return $recipes;
    	}

    Get Recipe

    The get_recipe() method is where all the hard work happens. We’re going to take the recipe data we mapped out earlier to build the $recipe array used by WP Recipe Maker to generate its recipe.

    The first parameter is $id which is the Post ID. Use this to access any necessary post data. Don’t assume the post data is already set up. Instead of using get_the_title(), use get_the_title( $id ).

    The example file will already contain all the necessary fields for the recipe. Go through and replace them with how your current recipe data is stored. Here’s how the simple fields looked in my importer:

    $recipe['name'] = get_the_title( $id );
    $recipe['summary'] = get_post_meta( $id, '_recipe_subtitle', true );
    $recipe['author_name'] = get_the_author( $id );
    $recipe['image_id'] = get_post_thumbnail_id( $id );
    $recipe['servings'] = get_post_meta( $id, '_r_yield', true );
    $recipe['servings_unit'] = 'Servings';
    $recipe['prep_time'] = get_post_meta( $id, '_r_prep_time', true );
    $recipe['cook_time'] = get_post_meta( $id, '_r_grill_time', true );
    $recipe['total_time'] = $recipe['prep_time'] + $recipe['cook_time'];

    Ingredients and Instructions use arrays of groups, each group containing an optional name (ex: “For the Sauce”) and an array of ingredients/instructions. For each ingredient you can specify array( 'raw' => $ingredient ); and WP Recipe Maker will handle processing that raw data (ex: “1 tsp salt”) into its components (“1” count, “tsp” unit, and “salt” ingredient).

    Here’s what my ingredients and instructions looked like:

    $ingredients_data = maybe_unserialize( get_post_meta( $id, '_recipe_ingredients', true ) );
    if( !empty( $ingredients_data ) ) {
    	foreach( $ingredients_data as $data_group ) {
    		$group = array();
    		if( !empty( $data_group['title'] ) )
    			$group['name'] = wp_strip_all_tags( $data_group['title'] );
    		for( $i = 0; $i < count( $data_group['nums'] ); $i++ ) {
    			$ingredient = '';
    			if( !empty( $data_group['nums'][ $i ] ) )
    				$ingredient .= $data_group['nums'][ $i ];
    			if( !empty( $data_group['types'][ $i ] ) && 'no type' !== $data_group['types'][ $i ] )
    				$ingredient .= ' ' . $data_group['types'][ $i ];
    			if( !empty( $data_group['names'][ $i ] ) )
    				$ingredient .= ' ' . $data_group['names'][ $i ];
    			if( !empty( $ingredient ) )
    				$group['ingredients'][] = array( 'raw' => $ingredient );
    		}
    		if( !empty( $group ) )
    			$recipe['ingredients'][] = $group;
    	}
    }
    
    $instructions_data = maybe_unserialize( get_post_meta( $id, '_recipe_steps_data', true ) );
    
    $instructions = array();
    foreach( $instructions_data as $item ) {
    	$instructions[] = array(
    		'text' => $item['content'],
    	);
    }
    
    // Instructions have to follow this array structure consisting of groups first.
    $recipe['instructions'] = array(
    	array(
    		'name' => '', // Group names can be empty.
    		'instructions' => $instructions
    	)
    );

    Replace Recipe

    The replace_recipe() method lets you run additional code after the recipe has been imported. If your previous recipe plugin used a shortcode, use this to find/replace it with the WP Recipe Maker shortcode.

    In my case the previous theme hardcoded the recipe below the post content, so I’m appending the WP Recipe Maker shortcode to the end of the post content.

    I also use this method to mark the post as imported, using my custom field _recipe_imported, and to set the parent post for the recipe, using the custom field wprm_parent_post_id.

    	/**
    	 * Replace the original recipe with the newly imported WPRM one.
    	 *
    	 * @since    1.20.0
    	 * @param	 mixed $id ID of the recipe we want replace.
    	 * @param	 mixed $wprm_id ID of the WPRM recipe to replace with.
    	 * @param	 array $post_data POST data passed along when submitting the form.
    	 */
    	public function replace_recipe( $id, $wprm_id, $post_data ) {
    		// The recipe with ID $id has been imported and we now have a WPRM recipe with ID $wprm_id (can be the same ID).
    		// $post_data will contain any input fields set in the "get_settings_html" function.
    		// Use this function to do anything after the import, like replacing shortcodes.
    
    		// Mark as migrated so it isn't re-imported
    		update_post_meta( $id, '_recipe_imported', 1 );
    
    		// Set parent post that contains recipe
    		update_post_meta( $wprm_id, 'wprm_parent_post_id', $id );
    
    		// Add the WPRM shortcode
    		$post = get_post( $id );
    		$content = $post->post_content . ' [wprm-recipe id="' . $wprm_id . '"]';
    		wp_update_post( array( 'ID' => $id, 'post_content' => $content ) );
    
    	}
    

    That’s it! The importer is built and ready for testing. You’ll want to import a few recipes to ensure everything is working as expected. If you run into any issues, I recommend looking through the existing importers for guidance / ideas.

    If your current recipe plugin stores the entire ingredient list as an HTML bulleted list, check out the parse_recipe_component_list() method in class-wprm-import-purr.php for an example of parsing the HTML into individual ingredients.

    Importing recipes

    Once you have a WP Recipe Maker importer for your current recipe plugin, the process of importing recipes is very straightforward.

    In the WordPress backend go to WP Recipe Maker > Import Recipes. You should see a section with your recipe plugin’s name and the number of recipes it found. Click “Explore Import Options”

    On the next screen you’ll see a list of recipes you can import. Select all the ones you’d like to import, then click “Import Selected Recipes”.

    I recommend you import one recipe at a time, then view the post to make sure all the recipe data made it over.

Bill Erickson

Bill Erickson is a freelance WordPress developer and a contributing developer to the Genesis framework. For the past 14 years he has worked with attorneys, publishers, corporations, and non-profits, building custom websites tailored to their needs and goals.

Ready to upgrade your website?

I build custom WordPress websites that look great and are easy to manage.

Let's Talk