Improving Versioning of WordPress Static Files

I was recently asked to improve the PageSpeed and YSlow scores of a WordPress-based website that I work on. One of the items that PageSpeed checks is “Don’t include a query string in the URL for static resources,” (under the “Leverage Proxy Caching” heading). I suspect this is most important when you are using a CDN, or otherwise run through a proxy server. WordPress puts a version in the query string of any JavaScript or CSS that’s enqueued using wp_enqueue_script() or wp_enqueue_style().

Interestingly, PageSpeed doesn’t seem to complain about the WordPress-included static resources that have a query string on the URL, only about static resources on 3rd-party URLs. I’m not sure why this is, but let’s remove the query strings from the WordPress-included static resources anyway, while still maintaining a version somewhere in the URL so we can set far-future Expires headers.

The format of a WordPress version tag is ver={version number}, like this: example.com/wp-content/themes/theme-name/style.css?ver=2.0.5.4, but example.com/ver2.0.5.4/wp-content/themes/theme-name/style.css should be more cacheable. There are two steps to making this happen:

1) Use Apache’s mod_rewrite to Serve the Correct File to the Updated URL

In step 2 we’ll be moving the version tag to the beginning of the URL, we only need one RewriteRule to make it work:

# This goes in your .htaccess file, before the WordPress section
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /

## Cache-Busting ##
RewriteRule ^ver([\d\.\-a-zA-Z]+)/(.*) $2?ver=$1 [L] #match something with the version tag right at the beginning of the URL
## Done cache-busting ##

</IfModule>

The rule above matches any URL that starts with “ver” followed by any number of letters, numbers, periods, dashes, and underscores, captures that collection of characters up to the first forward slash, then it captures everything after the first forward slash. The URL is rewritten as $2?ver=$1 where $2 is everything after the version tag, (the original URL, with no version tag), and $1 is the version number that’s added, internally, to the query string, but never appears in the browser.

Note: It is probably possible to leave off the ?ver=$1, but I haven’t tried it yet.

Now example.com/wp-content/themes/theme-name/style.css?ver=2.0.5.4 and example.com/ver2.0.5.4/wp-content/themes/theme-name/style.css should both serve up the same style.css file.

2) Filter the URLs for Enqueued Scripts & Styles to Move the Version Tag from the Querystring into the Path

The URL for every enqueued Javascript is filtered by the script_loader_src filter, and the URL for every enqueued stylesheet is filtered by the style_loader_src filter, so we’ll use these filters to put the new, improved URL in the HTML. This is an absolute URL, so we can take it apart, move the parts around so that it looks how we like. Here’s the function I’m using to change the URLs, plus two lines to hook on to the filters:

/**
 * Changes WordPress's default versioned URLs from putting the verion in the
 * querystring to putting the version as part of the file page, (requires 
 * .htaccess modification to work). If there's no version in the querystring
 * one can be added by setting the $defaultversion.
 * @param  string  $url            The URL to change the location of the version.
 * @param  mixed   $defaultversion A string default verion. Defaults to false, (
 *                                 no default version).
 * @return string                  The new URL.
 */
function jb_better_versioned_url( $url, $defaultversion = false ) {

    // don't bother for the admin
    if ( ! is_admin() ) {

        // parse the URL
        $parts = parse_url( $url );

        // and the querystring
        $query = wp_parse_args( $parts['query'], array() );

        // check if there's a version in the querystring. If so, do more.
        if ( isset( $query['ver'] ) || false !== $defaultversion ) {

            if ( isset( $query['ver'] ) ) {
                // prepend "/ver" + {version value} to the *path*
                $parts['path'] = '/ver' . $query['ver'] . $parts['path'];

                // unset the version in the querystring, since it's in the path now.
                unset( $query['ver'] );
            } else {
                $parts['path'] = '/ver' . $defaultversion . $parts['path'];
            }

            // if this is a PHP file just let it be.
            if( ! preg_match( '~\.php$~', $parts['path'] ) ) {
            

                // start rebuilding the URL string
                $url = $parts['scheme'] . '://' . $parts['host'] . $parts['path'];

                // see if there's still anything in the query
                if ( count( $query ) > 0 ) {

                    // rebuild query with whatever remains
                    $parts['query'] = build_query( $query );

                    // append it to the URL string
                    $url .= '?' . $parts['query'];
                }
            }
        }
    }

    return $url;

}

// hook onto the filters
add_filter( 'style_loader_src', 'jb_better_versioned_url' );
add_filter( 'script_loader_src', 'jb_better_versioned_url' );

With these two modifications all of your JavaScript & CSS will have the version in the path instead of the querystring. In addition, any images, fonts, or anything else referenced with relative URLs from your stylesheets will have the version in the beginning of their URL, which will be rewritten and the file served properly. This way if you ever have caching problems you can bump a version number and browsers will pull new versions of all your static resources.

A Word of Warning

This involves considerable coding acrobatics, and adds complexity to your WordPress-based site, for speed improvements that may not be very large, and may not exist at all, so use this method at your own risk.

I have this code in production, but would love to hear the opinions of others on it, that’s what the comments are for, or Twitter, or elsewhere – just let me know where the technique is discussed.

Announcing DeadTrees

Today I’m releasing DeadTrees, a WordPress plugin to share the books you read. Get it from wordpress.org or search for DeadTrees in the Plugins > Add section of your WordPress admin.

Features

DeadTrees lets you post the books you read, with or without writing about them, (really, does the internet need to know what you thought of the last mystery you read?). It generates Amazon affiliate links to those books so you, (or I), can make a little money if your readers buy the books, and it auto-fetches the books’ cover art from Amazon so things look cool.

Why?

I have been posting about books that I read for a while now, but ground to a halt when I got lazy & didn’t want to write a whole post about each book, and realized often it doesn’t matter what I think about a book. However, I did want to keep posting at least the te title & author of each book I read, (and so my sister can check to see what I’ve read before giving me a book).

Why write a plugin when there are other plugins to share the books I read? Because the other plugins didn’t do it how I wanted them to. I couldn’t find another plugin that uses WordPress’s Custom Post Types to store books I’ve read, and books are such a perfect use of CPTs that they’re even used as the example in the WordPress documentation!

Support & All That

I’ve put DeadTrees up at GitHub, if you have issues try to submit them there. My contact page is also always available to reach me.

See It Live

DeadTrees is up & running here. Take a look at the books I’ve read.

MyMap Explorer 1.2

Last night I updated MyMap Explorer for Google Maps to make it more future-proof and improve KML support. The most recent version is available on the project website.

The most important changes that were made are:

  • MyMap Explorer is now locked to Google Maps API version 2.150, so version 1.2 will not break on July 1 when API versions prior to 2.140 are depreciated
  • MyMap Explorer now supports area overlays on the maps, not simply points as before
  • There is some improved error-checking

This update was initiated by a fellow named Joel asking questions and reporting bugs. Thanks Joel!

Announcing MyMap Explorer for Google Maps

Today I am announcing the release of MyMap Explorer for Google Maps. This small javascript allows you to embed a map created using Gooogle Maps’ My Maps feature into any web page with more information and flexibility than Google’s iFrame embed code.

Back in November, Heri asked for a relatively simple way to integrate his Technology Map of Montreal into Montréal Tech Watch, and MyMap Explorer is the result, (see it live on the Technology Map of Montréal), It takes the KML description of a map from Google Maps and adds it to a map created using the Google Maps API. It also provides an alphabetized, clickable list of the points on the map so that your users don’t have to click on each marker to find the location that they are looking for.

The KML is loaded live from Google Maps so if you make a change to your map on Google Maps it will be shown in all embedded versions of your map as well. This script has no dependencies, other than Javascript and a Google Maps API key. Just insert it into a web page where you want to see your map and it appears!

I have some features that I still plan on adding, but want to get the basic script out there and into use. I’ve released the code on Google Code under an MIT license so you are all free to use MyMap Explorer, and contribute if you feel up to it.

The demo is here.

Downloading Firefox 3

Today is Download Day and there has been a lot of griping about the servers going down.  Well, it looks like the servers are back up again.  I hadn’t tried earlier, (well, I tried at 10 AM EDT, since I’d read that FF3 was going to be available at 10 AM, but apparently the article I read forgot to mention the timezone.  10 AM = 1 PM).

I have now downloaded Firefox 3 with no problems at all and am writing this post using it.  I had played around with some of the release candidates but hadn’t been using them full-time because of plugin compatibility, (I’m looking at you Firebug).

Speaking of Firebug, it didn’t upgrade automatically when I installed FF3 as it should have, and when I went to getfirebug.com I got no response.  After a quick Google search I discovered that getfirebug.com has been down for a bit, (ownership is being transferred to Mozilla so this never happens again).  However, you can download Firebug 1.2 from Mozilla, (this is the FF3-compatible version).

So I am now running FF3, (and keeping a copy of FF2 around for testing). If you want FF3, get it here.