Add Basic Functionality

When I say "add basic functionality," I am not referring to how to work the WordPress interface. You can get that from any of hundreds of tutorials on the Internet. I am referring to the basic items you will need in WordPress code that are not present out of the box. This article discusses the add_action() calls from the previous article.

Title Tag. This one is probably optional, but I wasn’t happy with how the title tags were output with add_theme_support(‘title-tag’). Most users would just install an SEO plugin for this, but most SEO can be done without yet another plugin. By default WordPress outputs "page title | site name | tagline," and only outputs tagline on the main page. I want my title tags specific to the page: "page title | extended keyword description | site name." Since site name is always the same, it should really be last in the title (or taken out completely, haven’t decided as of this article.)

First open the screen options at the upper right and ensure Custom Fields is checked. At the bottom, create a custom field in the page or post and name it titletag. The intent is not to replace the title tag, but customize how we output the tagline portion of the title tag – I would use tagline instead, but that might get confused with the actual site tagline.

Custom field to enhance title tags

The action that will make use of this custom field is the hook from the previous article to my custom function:

  
   
    add_filter('document_title_parts', 'filter_document_title_parts');

  

Because of the way WordPress works, it sets the parts of the title tag in a hard coded order. In my function, I have to swap out site_title with tagline to get the title tag to output in the right order (without excessive coding.) You can see the result by viewing the source of any page on this site.

Note that in both this code and the meta description/keyword below, we are using the third param true in get_post_meta(). Otherwise it returns an array. Variable $parts is now mutable, but it is what it is.

<?php
/**
 * Here we tweak the title to include a custom field 'titletag'
 * containing keywords (if it exists.) Also I didn't like the
 * format title|site|tagline and moved the redundant 'site'
 * to the end of the string.
 *
 * @param array $parts
 * @return array
 */
function filter_document_title_parts($parts)
{
    global $post;
    $post_id = (is_home())? get_option('page_for_posts') : $post->ID;

    $additional = get_post_meta($post_id, 'titletag', true);

    // Note tagline = site, site = tagline
    if (! empty($additional)) {
        $parts['tagline'] = ($parts['site'])?? '';
        $parts['site'] = $additional;
    }

    return $parts;
}

What is happening (and in the custom functions below) is that the blog "index" page doesn’t have it’s own post, so if it’s the index page, get the meta data from that page, not $post.

Meta Description and Meta Keywords. Meta description and meta keywords elements are critical to SEO, and you don’t need a plugin to create them. In the previous article you saw this line that calls a custom function to output these meta tags:

  
   
    add_action('wp_head', 'my_theme_meta_headers', 1);

  

Like the above, on each page or post, create custom fields for your posts and name one description and the other keywords.

screen shot of custom fields

Save the post or page, and after adding the callback function below to your functions.php file, the meta tags will appear in the page/post. See source code of any page of this site for the result.

<?php
/**
 * Creates the meta description and meta keywords tags in the head. Requires
 * that you set custom fields for each post in admin. Note that the blog
 * home will have the first post in $post. When creating tags for blog
 * home, use the ID of the blog page.
 *
 * @return void
 */
function my_theme_meta_headers()
{
    global $post;
    $post_id = (is_home())? get_option('page_for_posts') : $post->ID;
    echo my_theme_meta_tag($post_id, 'description')
         . my_theme_meta_tag($post_id, 'keywords');
}

/**
 * Creates the meta description or keywords header tag, or not
 * @param int $post_id
 * @param string $key description|keywords
 * @return string
 */
function my_theme_meta_tag($post_id = 0, $key = 'description')
{
    $meta = get_post_meta($post_id, $key, true);
    if (empty($meta)) {
        return '';
    }

    return "
            <meta name=\"$key\" content=\"$meta\">";
}

You might ask, why not put it all in one function? As previously discussed, one of the the most important rules of SOLID is the "S," the Single Responsibility Principle, and this is a demonstration as to why it’s important. Uncoupling the helper function reduces the code, ensures each function follows SRP, and breaks the logic into short, single function code blocks.*

Preload Headers. (Optional) If you are loading font files, the preload code in the head helps tell the browser to load the fonts first without rendering (which FireFox doesn’t respect very well.) This is not an out-of-the-box functionality. When it adds that action, it runs this function to output the preloads (see the results by viewing source and look in the head.) It’s important to load with a priority number higher than the CSS load (in this case 8) to ensure the preload occurs after the CSS is loaded. Otherwise browsers throw you a warning in the console. The caller and custom function:

  
   
    add_action('wp_head', 'add_font_preload_links', 8);

  
<?php
/**
 * Creates the font preload meta tags in the head.
 *
 * @return void
 */
if (! function_exists('add_font_preload_links')) {
    function add_font_preload_links()
    {
        $html = '';
        $fonts = [
            'EBGaramond'            => 'EBGaramond-Regular.woff',
            'ArchitectsDaughter'    => 'ArchitectsDaughter-Regular.woff',
            'WalterTurncoat'        => 'WalterTurncoat.woff',
        ];

        foreach ($fonts as $dir => $font) {
            $html .=
                '<link rel="prefetch" href="'

                // Strips off full URL
                .  parse_url(get_template_directory_uri(), PHP_URL_PATH)
                . "/css/fonts/$dir/$font"
                . '" as="font" type="font/woff2" crossorigin>';
        }
        echo $html;
    }
}

I am also not using get_template_directory_uri() here, another rewrite is being used. I’m not real happy about the echo (not supposed to echo in a function,) maybe I’ll look at this more later. Important: Note I am using PHP7+ syntax in this code. If your implementation runs anything less, use PHP 5 syntax, for example, Array() instead of [].

All of the hard coded values you see above – the $fonts array for example – is version 1 of the code for clarity. These have all been moved into a customized admin options page. The functions themselves have been moved into their own file as described in the previous article with styles-scripts.php. The admin options page and fonts preload code now looks like this.

screen shot of font config fields

<?php
/**
 * Creates the font preload meta tags in the head.
 *
 * @return void
 */
if (! function_exists('add_font_preload_links')) {

    function add_font_preload_links()
    {

        $html = '';
        $options = json_decode(get_option('my_theme_fonts_array'), 1);

        foreach ($options as $arr) {
            $html .= '
            <link rel="prefetch" href="'
            // Strips off full URL if uncommented - .htaccess redirects all css to theme dir
            //.  parse_url(get_template_directory_uri(), PHP_URL_PATH)
            . "/css/fonts/{$arr['dir']}/{$arr['font']}"
            . '" as="font" type="font/woff2" crossorigin>
            ';
        }

        echo $html;
    }
}

Domain Relative URL’s. If you are developing a site in your local Apache or WAMP server, any links in your localhost database will be expressed as full URL’s, e.g. http://www.example.com/my-resource/. When you go to push it live and import your localhost database into the live database, you will have to find and swap out all the instances of example.com for the live web site, and I found I always always missed something. Wouldn’t it be nice if you didn’t have to do that?

That is why I hook out the full URL’s and convert them to domain-relative URL’s. http://www.example.com/my-resource/ now becomes /my-resource/ and it’s one less thing to worry about in deployment. There are other reasons too, which I discuss in HTML/CSS.

  
   
    add_action('template_redirect', 'rw_relative_urls');

  

It’s a bit messy (fixed below) as we have to step through the page types. I just couldn’t stand it, or the hard coded references.

<?php
/**
 * Disable the domainname/url's to domain-relative url's /. Note that
 * page link and post link kill canonical tag. See function
 * my_theme_canonical_tag() for details.
 *
 * @return void
 */
function rw_relative_urls()
{
    if (is_feed() or get_query_var('sitemap')) {
        return;
    }
    $filters = [
        'post_link',
        'post_type_link',
        'page_link',
        'attachment_link',
        'get_shortlink',
        'post_type_archive_link',
        'get_pagenum_link',
        'get_comments_pagenum_link',
        'term_link',
        'search_link',
        'day_link',
        'month_link',
        'year_link',
        'wp_get_attachment_url',
    ];
    foreach ($filters as $filter) {
        add_filter($filter, 'wp_make_link_relative');
    }
}

In the final version, there are checkboxes in the theme options page to select which to make relative and the code looks like this.

<?php
/**
 * Disable the domainname/url's to domain-relative url's /. Note that
 * page link and post link kill canonical tag. See function
 * my_theme_canonical_tag() for details.
 *
 * @return void
 */
function rw_relative_urls()
{
    if (is_feed() or get_query_var('sitemap')) {
        return;
    }

    // @todo rename it, relative_links might get stepped on
    $options = json_decode(get_option('relative_links'), 1);

    foreach ($options as $name => $value) {
        if ($value == 1) {
            add_filter($name, 'wp_make_link_relative');
        }
    }
}

Canonical Tags. (Optional if you don’t use rw_relative_urls().) The upside of the previous is that get_permalink() also now returns a domain-less url, but the nasty downside is it breaks the one place we need a full URL, the canonical tag in the header. The fix for that:

  
   
    add_action('template_redirect', 'my_theme_canonical_tag');

  
<?php
/**
 * Function rw_relative_urls() strips the full URL off all URI's, which
 * we want. Unfortunately it also borks the canonical tag. This is the
 * only place besides the sitemap we want or need a full URL.
 *
 * @return void
 */
function my_theme_canonical_tag()
{
    global $post;
    $post_id = (is_home())? get_option('page_for_posts') : $post->ID;

    echo '
    <link rel="canonical" href="'
    . get_site_url()
    . get_the_permalink($post_id) . '">
    ';
}

"Read More" Links. Out of the box you get an ellipsis when you run the_excerpt() and in many cases want a read more link for other snippets. The standard filter for this works fine with moderate CSS skills. As mentioned above, with rw_relative_urls() in place, get_permalink() will output a domain-relative URL just as planned.

  
   
    add_filter('excerpt_more', 'my_theme_read_more_link');

  
<?php
/**
 * Modify the_excerpt() read more or call separately for "more" links.
 *
 * @return string
 */
function my_theme_read_more_link()
{
    if (!is_admin()) {
        return ' <a href="' . esc_url(get_permalink())
        . '" class="more-link">read more &gt;&gt;</a>';
    }
}

Remove role attribute from post navigation. The reasons I need this one are described in Ensure Web Pages are Accessible and Why Validation Matters. It’s not a deal-breaker, but I am obsessive about my pages validating.

  
   
    add_filter('navigation_markup_template', 'my_theme_navigation_template');

  
<?php
/**
 * Hook out the role attribute from the posts navigation.
 * Throws a warning in the validator.
 *
 * @return string
 */
function my_theme_navigation_template()
{
    return '
    <nav class="navigation %1$s">
        <h2 class="screen-reader-text">%2$s</h2>
        <div class="nav-links">%3$s</div>
    </nav>';
}

This basically overrides the core function navigation_markup_template() and returns a copy of it without the role attribute.

Standardize the Script Tags.This last one I attribute only to my own obsessiveness for consistency mentioned in PSRs and Legibility. It is strictly optional. Over the entire site, all attributes are coded with double quotes. The script tags are the only place in the framework that outputs SINGLE QUOTES. In truth single quotes, double quotes, mixed, it doesn’t matter, but I prefer one style and stick to it. This functionality converts the singles to doubles, and also removes the domain from the URL. If there are XHMTL tag ends, since we are not EXTENDING XML it strips off ending XHTML tags. I did say obsessive. Its not the most graceful solution but it works, at some point the code smell may bug me enough to change it.

  
  
  add_filter('script_loader_tag', 'modify_css_js_tags');
  add_filter('style_loader_tag', 'modify_css_js_tags');
   
  
<?php
/**
 * The new(er) add theme support for html5 removes type="text/javascript,"
 * but it leaves the attributes single quoted. Nit-picky obsession for
 * consistency.Also removes site URL's and makes the link domain
 * relative.
 *
 * @param string $tag
 * @return string
 */
if (! function_exists('modify_css_js_tags')) {
    function modify_css_js_tags($tag)
    {
        if(is_admin()) {
            return $tag;
        }
        $tag = preg_replace('|' . site_url() . '|', '', $tag);
        $tag = "\t" . preg_replace("/'/", '"', $tag);
        $tag = preg_replace('/\s+\/?>$/', '>', $tag);
        return $tag;
    }
}

We now have clean HTML being output on all our pages with no further maintenance and the basic tools for SEO without the use of plugins. Let’s move on to the next helpful functionality we’ll need that will save us tons of time in the long run, working with shortcodes.

* One could argue all we need to do is surround the two results in if/else logic constructs. I discuss this at length in PSR’s and Legibility. The bottom line is that this line of thinking – one that took me years to un-learn – is how spaghetti code gets started.