Quintic
WordPress Plugin Development Notes

Introduction

Hi – So this blog is intended as a running commentry of my development of a WordPress Plugin.

If you look at any of the Pages on this site (Pages rather than Posts) you will see that they have a TOC (Table Of Contents) in the left hand sidebar. This is functionality that I have implemented. In my initial implementation I imbedded this functionality in the Q5 Theme. As a personal implementation that was fine. I hadn’t edited core code, and if I switched out my Theme everything worked as expected, just no TOC. However , if I ever wished to publish either the Q5 Theme or the TOC functionality, then the TOC code needed to be extracted out into its own Plugin.

I also had no mechanism within the PoC for customising the TOC (I had hard-code parameters such as the number of levels to list, HTML Elements to use as for the TOC entries, etc). So it was time to pull it out of the Theme and create a fully fledge Plugin, which I could then publish to the WordPress universe.

Once I have the Plugin operational I will publish a technical Page on the process.

Table Of Contents Plugin

WordPress Plugins are the mechanism by which you add or modify the way a WordPres site functions. They are intended to allow you to add or modify existing functionality.

Where Themes are intended to define the visualisation, Plugins define the functionality.

The functionality that I wish to add is a Table of Contents :

Note: You should NEVER change core WordPress code to enhance or modify how a site works. Changes that are made to the core code will be lost on the next upgrade. They also risk destabalizing the site, with the potential to cause unrecoverable loss of information. If you want or need to modify the way a WordPress site works, do it as a Plugin. It is what they were designed for.

What constitutes a Plugin

As with Themes, WordPress Plugins can be very simple, almost nothing bits of code. (See the Hello Dolly example). To assist in developing Plugins, WordPress provides a detailed guide – The Plugin Handbook. I have no desire to simply re-interate what is in that handbook, this blog is intended to be what I actually did to convert the TOC code from a piece of imbedded php in the Q5 Theme to a stand-alone Plugin.

Steps:

  1. Create a TOC Plugin entity
  2. Decide on State needed to be retained in database (Options table)
  3. Construct initial TOC code with hooks for Activation / Deactivation / Uninstall
  4. Decide what Configuration menus are required.
  5. TOC specific: Design how code will integrate with whatever Theme is selected
  6. Extract code from Theme and encapsulate so Plugin is agnostic to Theme.

Create a TOC Plugin entity

This is really simple. It consists of defining a unique name, that is descriptive, but which is, as best a scan be guaranteed to be unique. Inline with the Q5 naming theme I decided on Q5 TOC.

You then create a folder within wp-content/plugins with said name, and within that folder add a php file, which contains a comment line

Plugin Name: Q5 TOC.

In its minamilistic sense that is it. Fire up your site and the Plugin will be recognised. If, however, you are wanting to publish your Plugin then you will need more documentation than that line. The following is what I have for Q5 TOC

/**
 * Table of Contents Plugin
 * ========================
 *
 * Plugin Name:		Q5 TOC
 * Plugin URI:  	https://quintic.co.uk/plugins/q5-page-content/
 * Description: 	Inserts a Table of Contents (normally into a side bar).  
 *              	Additionally, links to peer pages and associated topics can be included after the TOC.
 *              	Both TOC and Topic links remain fixed when page scrolls.
 * Version:     	1.0
 * Author:      	Quintic
 * Author URI:  	https://www.quintic.co.uk/
 * Requires at least:5.2
 * Requires PHP: 	7.2
 * License:     	GPLv2 or later
 * License URI:	 	http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
 * Text Domain: 	page-content
 * Domain Path: 	./languages
 *
 * Q5 TOC is free software: you can redistribute it and/or modify it under the terms of the GNU
 * General Public License as published by the Free Software Foundation, either version 2 of the 
 * License, or any later version.
 *
 * Q5 TOC is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
 * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU General Public License for more details.
 */

The above comments are pretty self explanatory, and I basically took a copy from another plugin I am using. All I would add is that the text at the end regarding licensing is legal blurb and it is worth ensuring that you have the latest recommended wording.

Decide on State needed to be retained in database

State, in relation to Plugin, refers to any information that needs to be retained. For the TOC I needed at least to retain the depth to which the User requires that TOC to trace and the HTML elements that will be used to identify the TOC elements at each level. I could have hard coded these items, but that would have been too restrictive for what I hope will be a general purpose Plugin.

State (or options) such as these are generally retained in the wp_options table. They do not have to be held in wp_options, but WordPress provides a number of handles to make it straightforward to retain state in this table, so why go against the standard and make life harder for yourself.

I have placed a limit of 6 on the depth of the TOC. So I will need to define an entry for Max Depth, and for each depth/level (up to a max of 6) an HTML element, which, pretty obviously, must be unique for each level.

That is it for this step. it is a desgin step, so no code.

Initial TOC Activation / Deactivation / Uninstall code

I like to use Classes where possible (OOO background). Hence I defined a class, with three static methods for:

  • Activation
  • Deactivation
  • Uninstall

Why static methods? Because, if you are using classes, the register_uninstall_hook can only accept a static method. I could have used non-static methods for the other registration actions, but consistency is nice.

The state for the Plugin will be retained in wp_options table. Which, implies, creating the entries in the wp_options table on Activation, and deleting them on Deactivation. To assist in this process WordPress provides a couple of helpful wrapper functions – add_option & delete_option, as simple as that. So I included those in the Acticvation and Deactivation methods.

The add_option method takes a second param which is the options value. These (for the moment) were going to be retrieved from a static class q5_toc_defaults, though this will ultimately change.

I also created public properties for the option ids, simply to prevent copying strings around. (See code below)

<?php
/*
 * q5_toc_registration
 * ===================
 * Defines the plugin Activation / Deactivation and Uninstall functions
 * The functions are declared 'STATIC' because it is a requirement that
 * uninstall class functions are always static. For consistency all three
 * functions have been defined as static.
 *
 * Version: 1.0.0
 *
 * Since:   5.2
 */
 if ( ! defined( 'ABSPATH' ) ) {
	die( 'Invalid request.' );
}
 
 class q5_toc_registration {
	public static $depth_field_id = 'q5_toc_depth';
	public static $toc_elements_field_id = 'q5_toc_elements_field';
	
	// Plugin Acivation / Deactivation functions.
	public static function q5_toc_activation ()
	{
		add_option(q5_toc_registration::$depth_field_id, q5_toc_defaults::$default_depth);
		add_option(q5_toc_registration::$toc_elements_field_id, q5_toc_defaults::$default_headers);
	}

	public static function q5_toc_deactivation ()
	{
	}

	public static function q5_toc_uninstall ()
	{
		delete_option(q5_toc_registration::$depth_field_id);
		delete_option(q5_toc_registration::$toc_elements_field_id);	
	}
}
?>

Placed the code in its own file called q5_toc_registration.php

Next step was to add the activation hook calls to the initial q5_toc.php file and to define the default values class q5_toc_defaults

if ( ! defined( 'ABSPATH' ) ) {
	die( 'Invalid request.' );
}

require_once('q5_toc_registration.php');

register_activation_hook   ( __FILE__ , 'q5_toc_registration::q5_toc_activation');
register_deactivation_hook ( __FILE__ , 'q5_toc_registration::q5_toc_deactivation');
register_uninstall_hook    ( __FILE__ , 'q5_toc_registration::q5_toc_uninstall');
	
class q5_toc_defaults
{
	public static $default_depth = 3;
	
	public static $default_headers = array(0 => 'h1',
					1 => 'h2',
					2 => 'h3',
					3 => 'h4',
					4 => 'h5',
					5 => 'h6');
}

You will notice the the first couple of lines of code in both files is: if ( ! defined( ‘ABSPATH’ ) ) This is a general purpose security check to ensure that the php has been accessed via the WordPress index.php and not via some other means (i.e.by directly tagging on the files path name to the sites url). It isn’t foolproof, but is good practice.

Notice that the first argument to the registration function calls points to the main plugin file (in this case itself). This is vitally important as this is how the registration function knows which plugin is being registered. If the callback functions are located in a separate file (as in my case they are) then you will need a ‘require_once’ call to load them prior to referencing them.

Fired up the site and there was Q5 TOC listed in the plugins.

Q5 TOC
Q5 TOC

and once activated the options appeared in the wp_options table.

wp_options table
wp_options table

Did run through a check that Uninstall did delete all code files, the folder, and the wp_options entries. Note: Before you do this, do retain a copy of your code as it will be deleted!!

Configuration Menus

Plugin Configuration menus in WordPress are termed Administration menus, because the menus are added, or at least accessed via the Adminstration screen.

You can add menus for your Plugin, either as an additional top level entry or as a sub-menu under an already existing entry such as Settings or Tools. The recommendation from WordPress is that if you only have a single page for configuration then add it as a sub-menu.

As Q5 TOC will only require a couple of configuration menus I opted to add them as a sub-menu. I chose the Settings menu page because WordPress provides a Settings API which provides a number of wrappers to assist in adding menus and retrieving the data back from the menu update page. No point in re-inventing the wheel.

Note: The Settings.API will automagically create the entries in the wp_options table for the fields that you define if they are not already there. Which removes the need to create the fields during the registration process, though not the need to delete the fields on Uninstall. Given that you still need to delete the fields on Uninstall, it seems only sensible to both execute the create and delete functions in the registration / deregistration process and it does no harm.

Sticking to my OOO approach I created a q5_toc_admin_menu class and included a method to add the options to the Settings menu and a very simple / minimal page to display a Settings button. No fields at this time, just a menu page.

<?php
/*
 * q5_toc_admin_menu
 * =================
 * Defines the admin menus for the q5_toc plugin
 * The menus are displayed as a sub-entry to the settings menu.
 *
 * Version: 1.0.0
 *
 * Since:   5.2
 */
if ( ! defined( 'ABSPATH' ) ) {
	die( 'Invalid request.' );
}

class q5_toc_admin_menu
{
	public function q5_toc_options_page()
	{
		add_options_page(
			'Q5 TOC Options',
			'Q5 TOC',
			'manage_options',
			'q5_toc',
			array ($this, 'q5_toc_options_page_html'));
	}
	
	public function q5_toc_options_page_html()
	{
    // check user capabilities
    if (!current_user_can('manage_options')) {
        return;
    }
    ?>
    <div class="wrap">
        <h1><?= esc_html(get_admin_page_title()); ?></h1>
        <form action="options.php" method="post">
            <?php
            // output security fields for the registered setting "wporg_options"
            settings_fields('q5_toc');
            // output setting sections and their fields
            // (sections are registered for "wporg", each field is registered to a specific section)
            do_settings_sections('q5_toc');
            // output save settings button
            submit_button('Save Settings');
            ?>
        </form>
    </div>
    <?php
	}
}
?>

The result:

Settings Menu
Settings Menu
Minimal TOC menu page
Minimal TOC menu page

Note: In the function q5_toc_options_page_html, which will display the fields, the html is enclosed in a <div></div> element. This is as recommended by WordPress to isolate the html from the rest of the dashboard.

A couple of points about the code. The documented method for adding sub_menus in WordPress is add_submenu_page. This method takes seven parameters, with the seventh being optional:

  1. string $parent_slug or filename,
  2. string $page_title,
  3. string $menu_title,
  4. string $capability,
  5. string $menu_slug,
  6. callable $function = ”,
  7. int $position = null

The first problem with the above set of parameters is parameter no. 1. “$parent_slug or filename” – which is the id of the menu to which you want to attach your sub-menu. Ignoring the fact that, IMHO, using a filename as an identiifer is a particularly bad idea, the Plugin Development documentation does not list either the filenames or the slugs (menu ids) for the top level admin menus. This information you have to go looking for. (A quick trawl through the code for ‘add_sub_menu’ indicated that most instances use filenames rather than slugs. – not what I wanted)

However, helpfully there are specific functions available for adding menus to the dashboard menus. These are all detailed in the sub-menu page of the handbook. For example:

  • add_options_page – adds a sub-menu to the Settings menu
  • add_management_page – adds a sub-menu to the Tools menu.
  • add_theme_page – adds a sub-menu to the Appearance menu.

The functions are all defined in wp-admin/includes/plugin.php, and if you delve into these wrappers, then you can at least find the filenames. Although once you have discovered the wrappers, you don’t need either the slugs or the filenames

As an FYI though, here are the filenames:

  • Settings – options-general.php
  • Themes – themes.php
  • Plugins – plugins.php
  • Users – users.php
  • Dashboard – index.php
  • Media – upload.php
  • Tools – ‘tools.php’

Implementing the actual menus.

Next step is to implement the actual menus. What I need are:

  • Max depth option (Positive Integer <= 6)
  • Array of unique HTML elements, one for each depth.

The steps involved in defining menus via the Setting API are as follows:

  • Create and Register a new Settings menu
  • Create one or more Sections on the Settings menu page
  • Add the menu fields to the Section(s)

This process, for me as a virgin Plugin developer, proved rather problematic. Some notes to help you through.

Scope of Settings API

It is not immediately clear if the Settings API is only applicable to the Settings menu, or whether it will work for all Administration menus. (I believe it is only applicable for the Settings menu)

Setting API Automagically creating wp_option entries.

The Settings API will, automagically, create the required entries in the wp_options table. Apart from not being aware of this functionality until I had already coded up creating the options during the registration event, there is another issue I encounterred when, for a short period during the development I did switch to using this function. The issue is that you can define a function to sanitize/verify any result returned from the menus – sensible. However this sanitize function will also be called when the Settings API creates the entry in the wp_options table. This I have to say confused me as it is an undocumented feature. Needed to search the bug reports to find out about it.

Creating the HTML to display the menu fields

The add_settings_field function has a callback function, which the documentation describes as “a function that fills the field with the desired form inputs. The function should echo its output. “. In fact this callback is for the HTML that displays the field. Not exactly obvious from its description

Sanitize functions being called with NULL

Any data or coding issues with your HTML results in a NULL $data parameter being sent to the Sanitize function. That in itself is fine, once you realise that the reason you are getting a NULL value is (probably) a problem with your HTML .

A helpful hint: If you comment out the Sanitize function this will often result in a working system, which at least tells you that the data is being returned correctly.

Each field is presented as a separate row

I had decided to use a PHP array for the set of HTML elements used for the TOC, as opposed to having a field for each depth. I also wanted the fields displayed in a single row eg:

TOC Elements in a single row

As opposed to one per row, which would have been the case if I had defined each TOC Element as a separate field.

TOC Elements defined as separte fields

To get round that I created a TOC Elements field that comprised of a string of semi-colon seprated Element names, and then coded up some PHP and Javascript to encode/decode. Result:

<?php
/*
 * q5_toc_admin_menu
 * =================
 * Defines the admin menus for the q5_toc plugin
 * The menus are displayed as a sub-entry to the settings menu.
 *
 * Version: 1.0.0
 *
 * Since:   5.2
 */
if ( ! defined( 'ABSPATH' ) ) {
	die( 'Invalid request.' );
}

class q5_toc_admin_menu
{
	
	private $option_group = 'q5_toc';
	
	// Register Settings
	// =================
	public function q5_toc_register_setting()
	{
		// Register two settings/fields:
		//	Depth
		//	Array of HTML Elements
		
		register_setting (
			$this->option_group, 
			q5_toc_registration::$depth_field_id, 
			array ('type' => 'integer', 
			'description' => 'Q5 TOC Depth of Table of Contents. Maximum value is 6')); 

				
		register_setting ($this->option_group,
			q5_toc_registration::$toc_elements_field_id,
			array('type'        => 'string',
			'description'       => 'Q5 TOC Elements',
			'sanitize_callback' =>array($this, 'q5_toc_sanitize_html_elements')));
			
	}
		
	public function q5_toc_sanitize_html_elements($input)
	{	/*
		* q5_toc_sanitize_html_elements
		* =============================
		* HTML elements are returned as a comma separated string.
		* We need to:
		*  1. Convert to an Indexed array
		*  2. Check each entry is constructed as a valid HTML element
		* 				(Starts with a letter and only contains alphanumerics)
		*  3. Each element must be unique.
		*/
		$elements = explode(',', $input);
		$ct = 0;
		$actual_elements = array();
		foreach ($elements as $item)
		{
			if (!$this->validHTMLelementName ($item))
			{
				// Invalid HTML Element name
				$input = q5_toc::get_headers();
				return $input;
			}
			if ($ct > 0)
			{
				for ($i = 0; $i<$ct; $i++)
				{
					if ($item == $actual_elements[$i])
					{
						// Element name not unique;
						$input = q5_toc::get_headers();
						return $input;
					}
				}
			}
			$actual_elements[$ct++] = $item;
		}

		$input = $actual_elements;
		return $input;
	}
	
	private function validHTMLelementName ($name)
	{
		$trim = trim($name);
		return isset($trim) && 
		     (1 == preg_match("/^[a-z | A-Z][a-z | A-Z | 0-9]*$/", $trim));
	}
	
	// Define and populate the Fields:
	// ===============================
	public function q5_toc_add_menu_fields()
	{
		$depth = get_option(q5_toc_registration::$depth_field_id);
		
		//Depth field
		add_settings_field(
				q5_toc_registration::$depth_field_id,// id.
				'TOC Depth',                         // title
				array($this, 'q5_toc_depth_html'),   // callback to display HTML
				$this->option_group,                 // Page / Sub-menu
				'q5_toc_section',                    // Section
				array (
					'name'        => q5_toc_registration::$depth_field_id,
					'value'       => $depth,
					'option_name' => q5_toc_registration::$depth_field_id));
					
		//HTML Element fields
		$elements = get_option(q5_toc_registration::$toc_elements_field_id);
		if (!is_array($elements))
		{
			$elements = q5_toc::headers;
		}
		
		add_settings_field( 
				q5_toc_registration::$toc_elements_field_id, // id.
				'TOC Elements',                              // title
				array($this, 'q5_toc_elements_html'),        // callback to display HTML
				$this->option_group,                         // Page / Sub-menu
				'q5_toc_section',                            // Section
				array (
					'name'             => q5_toc_registration::$toc_elements_field_id,
					'value'            => $elements,
					'option_name'      => q5_toc_registration::$toc_elements_field_id,
					'depth_field_name' => q5_toc_registration::$depth_field_id));
	}
			
	public function q5_toc_depth_html ($data)
	{
		echo '<input name="' . $data['name'] . '" id="' . $data['name'] . '"';
		echo ' type="number" value="' . $data['value'] . '" min="1" max="6" size="5pt"';
        echo ' onChange="q5_toc_allVisibilityCheck()"/>';
	}

	public function q5_toc_elements_html ($data)
	{
		// Create a single hidden (actually display:none) field to hold the (max) 6 element names. 
		echo '<input name="' . $data['name'] . '" id="' . $data['name'] . '" type="text" style="display:none" />';

		$i = 1;		
		foreach ($data['value'] as $v)
		{

			echo '<input name="H' . $i .  
				'" id="H' . $i . '" type="text" value="' . $v . '" size="5pt"/>';
			echo '<script type="text/javascript">q5_toc_visibilityCheck("H' . $i . '", ' . $i++ . ') </script>';
		}		
	}

	// Define Sections
	// ===============
	public function q5_toc_add_menu_section($page)
	{
		add_settings_section ('q5_toc_section',
			'Q5 TOC Configuration',
			array($this, 'q5_toc_menu_section_callback'),
			$this->option_group);
	}
	
	public function q5_toc_menu_section_callback ()
	{
		echo _e('<p> Select the Depth to which you would like the table of Contents displayed');
		echo _e('(Max depth is 6) and the HTML Elements that identifies each heading</p>');
		echo _e('<p>The HTML elements must be unique </p>');
	}
	
	// Add TOC Options Page
	// ====================
	public function q5_toc_options_page()
	{
		add_options_page(
			'Q5 TOC Options',
			'Q5 Toc',
			'manage_options',
			$this->option_group,
			array ($this, 'q5_toc_options_page_html'));
	}
	
	public function q5_toc_options_page_html()
	{
    // check user capabilities
    if (!current_user_can('manage_options')) {
        return;
    }
	
    ?>
    <div class="wrap">
		<script type="text/javascript">
		function q5_toc_visibilityCheck (elementId, level)
		{
			
			if (document.getElementById("q5_toc_depth").value >= level)
			{
				document.getElementById(elementId).style.visibility="visible";
			}
			else
			{
				document.getElementById(elementId).style.visibility="hidden";
			}	
		}
		function q5_toc_allVisibilityCheck()
		{
			q5_toc_visibilityCheck ("H2", 2);
			q5_toc_visibilityCheck ("H3", 3);
			q5_toc_visibilityCheck ("H4", 4);
			q5_toc_visibilityCheck ("H5", 5);
			q5_toc_visibilityCheck ("H6", 6);
		}
		function q5_toc_validateForm()
		{
			var h1 = document.getElementById("H1").value;
			var h2 = document.getElementById("H2").value;
			var h3 = document.getElementById("H3").value;
			var h4 = document.getElementById("H4").value;
			var h5 = document.getElementById("H5").value;
			var h6 = document.getElementById("H6").value;
			document.getElementById("q5_toc_elements_field").value = 
			          h1 + "," + h2 + "," + h3 + "," + h4 + "," + h5 + "," + h6;
			return true;
		}
	</script>
        <h1><?= esc_html(get_admin_page_title()); ?></h1>
        <form action="options.php" onsubmit="return q5_toc_validateForm()" method="post">
            <?php
            // output security fields for the registered setting
            settings_fields($this->option_group);
			
            // output setting sections and their fields
            do_settings_sections($this->option_group);
			
            // output save settings button
            submit_button (_e('Save Settings'));
            ?>
        </form>
    </div>
    <?php
	}
}
?>

At this point we have a set of working Plugin menus, saving the data to the database. Next step.

Design Integration of Plugin with Theme

For the TOC to work as a Plugin, it has to be able to work with any Theme. Now I have designed it to reside in a Sidebar (Doesn’t have to be in a Sidebar, but that’s how I expect it to be used most often). WordPress have a special object for adding functionality to Sidebars – call Widgets, and Plugins and Widgets often go hand in hand.

As it happens, I had already implementecd the TOC as a Widget (which, by the way is remarkably easy), and so, what I thought was going to be a significant piece of work, turned out to be remarkably simple.

  • Extracted q5_toc code from Theme and placed in Plugin
  • Replaced hardcoded Depth and HTML elements with references to the values stored in wp_options table

And that was it. Then just test and validate.

CSS Styles

OK – I missed something in extracting the Q5_TOC code from the theme – The associated CSS Styles. I needed to extract the Q5_TOC styles into a style.css file and then load that on Plugin load. Actually very easy. Added the following code to the q5_toc.php file


//Register css styles.
add_action('wp_enqueue_scripts', 'q5_toc_registration::register_q5_toc_scripts');

and then another static function to the q5_toc_registration class

	
	public static function register_q5_toc_scripts()
	{
		 wp_enqueue_style( 'q5_toc_style', plugins_url( 'css/style.css' , __FILE__ ) );
	}

Testing

So theory goes that the Q5_TOC Plugin should now work with any Theme. Time to test, as up uintil now I had only tested with the Q5 theme from whence I started. Initially I tried one of the standard WordPress themes: TwentyFiveteen. And as you would expect, it didn’t work. No table of Contents, nothing. The same lack of result with TwentySixteen and TwentyNineteen. I eventually tried a non WordPress Theme and this time the page just looped whilst loading. tracked the problem down to Q5_TOC calling a library function from the Q5 theme, which, of course was no longer available. Added that function into Q5_TOC (wrapped in a function_exists check) and all was fine.

State and PHP Installations

Before I end this blog (and then re-do it as a technical page) I have just one last piece of insight to add. Whilst replacing the hardcoded options with the values from the wp_options table I tried to optimise the number of times I accessed the D/B.

My initial thought was to utilise Static Properties to read the values once on ‘Start up’ and only resetting them when the the values where changed. (Using the updated_options event/hook).

The idea was sound. The execution less so. The problem is in the understanding of ‘Start up’ as it relates to a PHP WAMP installation. Each Server – Client – Server round trip triggers a new ‘Start up’. There is no PHP State that persist over a round trip.

I was still able to optimise the number of calls to the database, but not as effectively as I had hoped.