Blurbette Plugin: Shortcode


The next class in our blueprint is the shortcode — the workhorse of the blurbette.

I’ll be sure to relegate all blurbette output to this shortcode, so proper control can be maintained and code isn’t duplicated elsewhere. In other words, if some other code wants to output a blurbette directly, it can call do_shortcode( '[blurbette id="1234"]' );.

First, I’ll design the shortcode. I want to enable the user to invoke the blurbette using its slug, as in

[blurbette slug="my-blurbette"].

For code convenience, I also want to enable an alternate ID reference, as in

[blurbette id="1234"].

I thought about enabling a ‘title’ attribute, but that gets messy. What if there are multiple blurbettes with the same title? How would the user enter a title that contains quote marks? And other headaches. (Maybe you can engineer a better solution, then you can extend this class!)

One more attribute — I know I’ll later restrict each blurbette according to its context, e.g. inside a text widget, inside a post, etc. Other code may need to determine whether a particular blurbette is available within some context, so I’ll add a context attribute which gets assigned by default. As a bonus, user can override a blurbette’s availability, by specifying this attribute.

[blurbette id="1234" context="post"]

The Shortcode Class

As you’ll see, there will be no reason to instantiate this class; everything is defined once and this class needn’t retain unique data. So all methods will be defined static.

Start with the bare bones:

class WPCX_Blurbette_Shortcode {
	public static function do_all_hooks() {
		add_shortcode( 'blurbette', array( __CLASS__, 'shortcode' ) );
	}

	public static function shortcode( $atts ) {
		$current_context = ( is_object( $GLOBALS['post'] ) )? $GLOBALS['post']->post_type : null;
		$parms = shortcode_atts(
			array(
				'slug'		=> null,
				'id'		=> null,
				'context'	=> $current_context
			), $atts );
	}
} // end class WPCX_Blurbette_Shortcode

As before, I like to define a do_all_hooks() method; I’ll call this from the Registry.

The shortcode method first determines the post type for the current page, if there is one, then uses that string as the default value for a context attribute. User or code can provide a context and that value will be used instead.

I’d like to point out: many sources (including old WordPress documentation) recommend using extract( shortcode_atts( ... ) ) to define variables in this method’s scope. I think that’s messy and accident-prone, so I always assign shortcode_atts()‘s output to a single array variable $parms.

The next part inside the shortcode method is straightforward: get the right post from the database, matching either the slug or the ID. Here I’ll call a separate method which does this job, and define it below. This function will return the blurbette’s content and, for reasons I’ll explain below, its ID. Returned value will be an array.

		$parms = shortcode_atts(
			array(
				'slug'		=> null,
				'id'		=> null,
				'context'	=> $current_context
			), $atts );
		$bbt_data = self::get_content_by_idslug( $parms );
	}
} // end class WPCX_Blurbette_Shortcode

The returned content will be straight from the database, unfiltered. So, next I’ll clean up the content and filter it.

Also, since I know there will be future step where I’ll need to check the blurbette’s availability in this context, I’ll invent a method within the Definition class (and add a placeholder there, below), which returns true if the blurbette is allowed in this context. This is why I’ve chosen to retrieve the blurbette’s ID in the previous line, so I can check it using this method.

		$bbt_data = self::get_content_by_idslug( $parms );
		if ( ! empty( $bbt_data['content'] ) && WPCX_Blurbette_Def::check_availability( $bbt_data['id'], $parms['context'] ) ) :
			
			// Replace line breaks with <br />
			$bbt_data['content'] = preg_replace( '|[\n\r]+|', '<br />', trim( $bbt_data['content'] ) );
			
			// The preferred way to process post_content from a queried post,
			// expands shortcodes and other user-preferred formatting
			$bbt_data['content'] = apply_filters( 'the_content', $bbt_data['content'] );
			$bbt_data['content'] = str_replace( ']]>', ']]&gt;', $bbt_data['content'] );
			
			// Remove <p> tags
			$bbt_data['content'] = preg_replace( '|(</?p[^>]*>)+|i', '', $bbt_data['content'] );
			
			return $bbt_data['content'];
		endif;
		
		return null;
	}
} // end class WPCX_Blurbette_Shortcode

Now it’s time to write the get_content_by_idslug method. Straightforward; it returns an associative array with two elements: ‘id’ and ‘content’.

	protected static function get_content_by_idslug( $atts ) {
		$return = array( 'id'=>null, 'content'=>null );
		if ( isset( $atts['slug'] ) && ! empty( $atts['slug'] ) ) :
			$search_key = 'name';
			$search_value = $atts['slug'];
		elseif ( isset( $atts['id'] ) && ! empty( $atts['id'] ) ) :
			$search_key = 'p';
			$search_value = $atts['id'];
		endif;
		if ( isset( $search_key ) ) :
			$query_args = array(
				'post_type'		=> WPCX_Blurbette_Def::POST_TYPE,
				$search_key		=> $search_value
			);
			$results = get_posts( $query_args );
			if ( is_array( $results ) && count( $results ) ) :
				$return['id'] = $results[0]->ID;
				$return['content'] = $results[0]->post_content;
			endif;
		endif;
		return $return;
	}

Enabling Widget and Caption Shortcodes

By default, WordPress disallows shortcodes in its built-in Text Widget, and in photo captions.

It’d be very simple to enable all shortcodes in both contexts; here’s a quick snippet that would do the trick (in non-OOP syntax):

add_filter( 'widget_text', 'do_shortcode' );
add_filter( 'shortcode_atts_caption', 'enableShortcodesInCaptions' ) );
function enableShortcodesInCaptions( $atts ) {
	$atts['caption'] = do_shortcode( $atts['caption'] );
	return $atts;
}

However, this plugin should “play nice,” and it’s not nice to make a global change affecting other plugins’ functionality — there may be a good reason a user doesn’t want certain shortcodes enabled in those places.

So this plugin should only enable the [blurbette] shortcode in both those contexts. This is easier than you might think; we simply add filter callbacks and do a regex search for a [blurbette ...] string. First, the widget callback:

	public static function widget_text_shortcode( $widget_text ) {
		$widget_text = preg_replace_callback( '/\[blurbette.+?\]/i', array( __CLASS__, 'shortcode_on_preg_widget' ), $widget_text );
		return $widget_text;
	}
	public static function shortcode_on_preg_widget( $matches ) {
		if ( ! preg_match( '/context\=/i', $matches[0] ) ) :
			$matches[0] = str_replace( ']', ' context="widget"]', $matches[0] );
		endif;
		return do_shortcode( strtolower( $matches[0] ) );
	}

Notice I’ve added a check for the context attribute in the shortcode — then add a ‘widget’ context only if it’s not already defined.

Note: If you’re sure this plugin will run under PHP 5.3 or later, you can replace the additional 'shortcode_on_preg_widget' callback with an anonymous function.

The caption callback is practically identical, except it acts on an array with element named ‘caption’:

	public static function caption_shortcode($output) {
		$output['caption'] = preg_replace_callback( '/\[blurbette.+?\]/i', array( __CLASS__, 'shortcode_on_preg_caption' ), $output['caption'] );
		return $output;
	}
	public static function shortcode_on_preg_caption( $matches ) {
		if ( ! preg_match( '/context\=/i', $matches[0] ) ) :
			$matches[0] = str_replace( ']', ' context="attachment"]', $matches[0] );
		endif;
		return do_shortcode( strtolower( $matches[0] ) );
	}

Brief explanation about captions: when WordPress inserts an image, it employs a [caption] shortcode. Internally, this caption function does something funny: it strips the caption text and stores it as a separate shortcode attribute. Then, it applies the img_caption_shortcode filter, and if the filter returns something, it simply outputs that and stops there. Therefore, if some plugin has hooked into img_caption_shortcode, it’s possible this shortcode may not output at all.

If the img_caption_shortcode filter returns nothing, WordPress (as of version 3.6) then goes through a sequence which includes applying the shortcode_atts_caption filter, on the array of attributes.

Now I need to activate these filters, so I’ll return to the do_all_hooks method and insert them:

	public static function do_all_hooks() {
		add_shortcode( 'blurbette', array( __CLASS__, 'shortcode' ) );
		add_filter( 'widget_text', array( __CLASS__, 'widget_text_shortcode' ) );
		add_filter( 'shortcode_atts_caption', array( __CLASS__, 'caption_shortcode' ) );
	}

Uh-oh.

There is a potentially serious problem with this shortcode; I wonder if you’ve already guessed what it is? Hint: I’ve opened up the possibility of an infinite recursion.

The problem: a blurbette might contain a [blurbette] shortcode that outputs itself. Or a trickier example: one blurbette can output a second blurbette which outputs the first.

What’s needed is a “recursion buster” function. It will cycle through all a blurbette’s content, looking for nested [blurbette] shortcodes, and drill down into the content of each:

	protected static function recursion_buster( $content, &$nested_ids ) {
		static $shortcode_regex;
		if ( empty( $shortcode_regex ) ) :
			$shortcode_regex = get_shortcode_regex();
		endif;
		preg_match_all( "/$shortcode_regex/", $content, $matches );
		if ( in_array( 'blurbette', $matches[2] ) ) :
			for ( $ix = 0; $ix < count( $matches[2] ); $ix++ ) :
				if ( 'blurbette' != $matches[2][$ix] ) continue;
				$atts = shortcode_parse_atts( $matches[3][$ix] );
				$nested_data = self::get_content_by_idslug( $atts );
				if ( ! empty( $nested_data['id'] ) ) :
					if ( in_array( $nested_data['id'], $nested_ids ) ) return true;
					$nested_ids[] = $nested_data['id'];
					if ( self::recursion_buster( $nested_data['content'], $nested_ids ) ) return true;
				endif;
			endfor;
		endif;
		return false;
	}

Lots to point out here:

The static variable declaration. PHP (confusingly) enables you to declare a variable within any function (even a global-scope function outside of any class) as ‘static,’ which has nothing to do with static elements of a class. Inside a function (or method), a ‘static’ variable exists only within the function’s scope but retains its value across multiple calls. Think of it as kind of the opposite of ‘global’.

WordPress provides a function get_shortcode_regex() that returns the regex string we need to match all defined shortcodes. I don’t want to waste resources by calling this function many times, so I use a static variable to store this regex string.

Notice this is a recursive function (meaning it calls itself). It keeps track of a growing array of IDs ($nested_ids), and scans through $content looking for [blurbette ...] shortcodes. Each time it encounters one, it first checks if the referenced ID is in the $nested_ids array, returning true if there’s a match. If no match, it adds the encountered ID to $nested_ids and calls itself on the encountered content, drilling down one ‘level’; if the result is true then it returns true from the current ‘level.’

For clarity, here’s what $matches looks like when preg_match_all acts upon the shortcode regex. Suppose a post’s content looks like this:

[blurbette id="1234"]
[caption id="attachment_358" align="alignnone"]Some caption here[/caption]
[blurbette slug="another-blurbette"]

…. then $matches looks like this:

[0] => Array
        (
            [0] => [blurbette id="1234"]
            [1] => [caption id="attachment_358" align="alignnone"]Some caption here[/caption]
            [2] => [blurbette slug="another-blurbette"]
        )
    [1] => Array
        (
            [0] => 
            [1] => 
            [2] => 
        )
    [2] => Array
        (
            [0] => blurbette
            [1] => caption
            [2] => blurbette
        )
    [3] => Array
        (
            [0] =>  id="1234"
            [1] =>  id="attachment_358" align="alignnone"
            [2] =>  slug="another-blurbette"
        )
    [4] => Array
        (
            [0] => 
            [1] => 
            [2] => 
        )
    [5] => Array
        (
            [0] => 
            [1] => Some caption here
            [2] => 
        )
    [6] => Array
        (
            [0] => 
            [1] => 
            [2] => 
        )
)

Now all that’s left is to return to my shortcode method, and insert a call to this recursion_buster method. If it returns true, then ditch the whole blurbette (return null).

		$bbt_data = self::get_content_by_idslug( $parms );
		if ( ! empty( $bbt_data['content'] ) && WPCX_Blurbette_Def::check_availability( $bbt_data['id'], $parms['context'] ) ) :
			
			// Must prevent recursion...
			$nested_ids = array( $bbt_data['id'] );
			if ( self::recursion_buster( $bbt_data['content'], $nested_ids ) ) :
				return null;
			endif;

Updating Registry and Definition

Returning to the Registry/Definition script file, I’ll update some required code.

First, add the check_availability() method to WPCX_Blurbette_Def, as a placeholder that merely returns true for now:

public static function check_availability( $id, $context ) {
	return true;
}

} // end of class WPCX_Blurbette_Def

Next, invoke the do_all_hooks() method of the new (static) class, from within the Registry:

    function __construct() {
        spl_autoload_register( array( $this, 'class_autoloader' ) );
        WPCX_Blurbette_Def::do_all_hooks();
        WPCX_Blurbette_Shortcode::do_all_hooks();
    }

Recapping the Code

Here is the class in its entirety:

<?php
class WPCX_Blurbette_Shortcode {
	public static function do_all_hooks() {
		add_shortcode( 'blurbette', array( __CLASS__, 'shortcode' ) );
		add_filter( 'widget_text', array( __CLASS__, 'widget_text_shortcode' ) );
		add_filter( 'shortcode_atts_caption', array( __CLASS__, 'caption_shortcode' ) );
	}

	public static function shortcode( $atts ) {
		$current_context = ( is_object( $GLOBALS['post'] ) )? $GLOBALS['post']->post_type : null;
		$parms = shortcode_atts(
			array(
				'slug'		=> null,
				'id'		=> null,
				'context'	=> $current_context
			), $atts );

		$bbt_data = self::get_content_by_idslug( $parms );
		if ( ! empty( $bbt_data['content'] ) && WPCX_Blurbette_Def::check_availability( $bbt_data['id'], $parms['context'] ) ) :
			
			// Must prevent recursion...
			$nested_ids = array( $bbt_data['id'] );
			if ( self::recursion_buster( $bbt_data['content'], $nested_ids ) ) :
				return null;
			endif;
			
			// Replace line breaks with <br />
			$bbt_data['content'] = preg_replace( '|[\n\r]+|', '<br />', trim( $bbt_data['content'] ) );
			
			// The preferred way to process post_content from a queried post,
			// expands shortcodes and other user-preferred formatting
			$bbt_data['content'] = apply_filters( 'the_content', $bbt_data['content'] );
			$bbt_data['content'] = str_replace( ']]>', ']]&gt;', $bbt_data['content'] );
			
			// Remove <p> tags
			$bbt_data['content'] = preg_replace( '|(</?p[^>]*>)+|i', '', $bbt_data['content'] );
			
			return $bbt_data['content'];
		endif;
		
		return null;
	}
	
	protected static function get_content_by_idslug( $atts ) {
		$return = array( 'id'=>null, 'content'=>null );
		if ( isset( $atts['slug'] ) && ! empty( $atts['slug'] ) ) :
			$search_key = 'name';
			$search_value = $atts['slug'];
		elseif ( isset( $atts['id'] ) && ! empty( $atts['id'] ) ) :
			$search_key = 'p';
			$search_value = $atts['id'];
		endif;
		if ( isset( $search_key ) ) :
			$query_args = array(
				'post_type'		=> WPCX_Blurbette_Def::POST_TYPE,
				$search_key		=> $search_value
			);
			$results = get_posts( $query_args );
			if ( is_array( $results ) && count( $results ) ) :
				$return['id'] = $results[0]->ID;
				$return['content'] = $results[0]->post_content;
			endif;
		endif;
		return $return;
	}
	
	public static function widget_text_shortcode( $widget_text ) {
		$widget_text = preg_replace_callback( '/\[blurbette.+?\]/i', array( __CLASS__, 'shortcode_on_preg_widget' ), $widget_text );
		return $widget_text;
	}
	public static function shortcode_on_preg_widget( $matches ) {
		if ( ! preg_match( '/context\=/i', $matches[0] ) ) :
			$matches[0] = str_replace( ']', ' context="widget"]', $matches[0] );
		endif;
		return do_shortcode( strtolower( $matches[0] ) );
	}
	public static function caption_shortcode($output) {
		$output['caption'] = preg_replace_callback( '/\[blurbette.+?\]/i', array( __CLASS__, 'shortcode_on_preg_caption' ), $output['caption'] );
		return $output;
	}
	public static function shortcode_on_preg_caption( $matches ) {
		if ( ! preg_match( '/context\=/i', $matches[0] ) ) :
			$matches[0] = str_replace( ']', ' context="attachment"]', $matches[0] );
		endif;
		return do_shortcode( strtolower( $matches[0] ) );
	}

	protected static function recursion_buster( $content, &$nested_ids ) {
		static $shortcode_regex;
		if ( empty( $shortcode_regex ) ) :
			$shortcode_regex = get_shortcode_regex();
		endif;
		preg_match_all( "/$shortcode_regex/", $content, $matches );
		if ( in_array( 'blurbette', $matches[2] ) ) :
			for ( $ix = 0; $ix < count( $matches[2] ); $ix++ ) :
				if ( 'blurbette' != $matches[2][$ix] ) continue;
				$atts = shortcode_parse_atts( $matches[3][$ix] );
				$nested_data = self::get_content_by_idslug( $atts );
				if ( ! empty( $nested_data['id'] ) ) :
					if ( in_array( $nested_data['id'], $nested_ids ) ) return true;
					$nested_ids[] = $nested_data['id'];
					if ( self::recursion_buster( $nested_data['content'], $nested_ids ) ) return true;
				endif;
			endfor;
		endif;
		return false;
	}

} // end class WPCX_Blurbette_Shortcode