Ajaxify Your Drupal Site

You may have noticed that most of the menus on thinkyhead.com load the content without reloading the whole page, which improves the performance of the site, and hence your browsing experience, a lot. I won't build any Drupal sites in the future without applying this same technique. I find that it makes a site feel more like an app than an old-school website and it encourages further AJAX exploration.

Applying AJAX to a Drupal site isn't hard, but it takes a few non-obvious steps to achieve:

  • Add code to settings.php to identify when AJAX is requested
  • Add an AJAX page template to the site theme – to wrap content
  • Add code to template.php to use the AJAX page template for AJAX
  • Make a module to add a Drupal behavior to the page
  • Make a Drupal "behavior" – a Javascript that AJAX-ifies the page

All of this extra work ensures that the loaded content will appear as it was designed to, that any loaded widgets will be initialized and social sharing buttons activated. Another important step is to set the proper classes (active, active-trail) for any visible navigation elements on the page. All of this is intended to make a general solution so that any block or page region can theoretically be loaded without issue, and it will be properly initialized if it has Javascript to go along with it. For those instances where a simple AJAX load will suffice, all these extra steps can be skipped, and for cases where the main page content isn't changed, much of it can be skipped.

AJAX Global Define

The first step is to add some code to settings.php to create a global define namedIS_AJAX. This will be used by the theme and any other PHP code in the site that might need to know that AJAX content is being requested.

define('IS_AJAX', stristr(@$_SERVER['HTTP_X_REQUESTED_WITH'], 'xmlhttprequest')
 || @$_GET['ajax'] || strstr($_GET['q'], 'ahah_helper'));

AJAX Page Template

Every Drupal site should have at least one custom page template for loading AJAX content. The AJAX page will (or should) never be loaded by itself as a full page, except for testing. It is used only to wrap AJAX content. I added an AJAX-specific page template to the site theme.

The AJAX template for thinkyhead.com is nearly a full page template. It includes a <title> tag, all the head Javascripts (including inline scripts), CSS, Drupal.settings, etc. The head elements are needed because they can all change based on the page url, and modules and themes can depend on them. For content the AJAX template includes only the page $content variable, which will also include any blocks in the content region.

Activate the AJAX Template

To actually activate the AJAX template we add some code to the theme_preprocess_page function in our theme's template.php file. When IS_AJAX is true, this code simply selects the AJAX page template, and the theming system does the rest.

function thinkyhead_preprocess_page(&$vars) {
  if (IS_AJAX) {
    $vars['template_file'] = 'page-ajax';
  }
}

Now that we have a way to get AJAX content in a useful form, we'll need a Javascript that loads with the site to apply AJAX loading behavior to the menus. This Javascript needs to do a few extra tricks –essential in Drupal– to produce consistent results for the loaded content.

  • Set the <title> tag based on the loaded content, and put the new path into the browser history.
  • Load any Javascript into the DOM that hasn't been loaded yet (and keep track of this).
  • Load any CSS that hasn't been loaded yet (also keeping track).
  • Remove all but a few classes from the <body> tag, leaving behind those that should not be changed (such as simplemenu-processed, etc.) and replace them with classes from the loaded html's <body> tag (except some that must be skipped).
  • Do an eval() on any inline Javascript that was loaded (including initializing Drupal.settings).
  • Run Drupal.attachBehaviors() – passing the DOM selector for the newly-loaded region.

A module to add the Script

function thinky_init() {
  $settings = array(
    'content_selector' => '#content-inner',
    'loader_container' => '#content',
    'theme' => base_path() . drupal_get_path('theme', 'thinkyhead'),
    'isFront' => drupal_is_front_page()
  );
  drupal_add_js(array('thinky' => $settings), 'setting');
  drupal_add_js(drupal_get_path('module', 'thinky') .'/thinky.js');
}

As you can see, it's very simple to add a script and to pass parameters at the same time. When the Javascript executes it will find those settings in the global object Drupal.settings.thinky.

A Drupal Behavior

Here's the full Javascript that we use. You will soon see why it's tricky to make this into a general module for the masses. But it's easy to patch up this script for any site.

/**
 *  li.leaf                 <li> with no sub-tree
 *  li.expanded             <li> with a sub-tree
 *  li.active-trail         <li> with active tree
 *  li.leaf.active-trail a  <li> parent of active link
 *  a.active                ul.menu style active links
 *  li.active a             ul.links style active links
 */
Drupal.behaviors.thinky = function (d) {

  "use strict";

  var hidLogo = false, popt = false, s = Drupal.settings.thinky, isFront = s.isFront,
      ext_js = '', evald_js = [], ext_css = '', evald_css = [],
      $loader = $('<div id="ajax-loader"><img src="'+s.theme+'/images/default-photobar.gif" /></div>')
                  .appendTo(s.loader_container);

  cleanup_loaded_page();

  function ajaxify_menu(menu) {

    var $menu = $(menu);
    if ($menu.length) {
      var $trail = $menu.find('a').not('.noajax,[rel|="noajax"],[target="_blank"],[href^="http:"],[href^="https:"],[href*="/edit"],[href*="/node/add/"]'),

      set_trail = function($t) {
        if (!$t.hasClass('active')) {
          var $tpar = $t.parents('li');
          $tpar.addClass('active-trail');
          $($tpar[0]).addClass('active');
          $t.addClass('active');
          return true;
        }
      },

      ajax_load = function(a,do_trail) {
        var dst = a.match(/(https?:\/\/[^\/]+)?([^#]*)$/)[2]; // path after domain, before hash, may be ''
        if (do_trail) {
          $menu.find('.active').removeClass('active');
          $menu.find('li.active-trail').removeClass('active-trail active');
          $menu.find('a[href="'+dst+'"]').each(function(){
            set_trail($(this));
          });
        }
        $loader.show();
        $(s.content_selector).html('').load(dst + ' ' + s.content_selector + '>*', function(html){
          $loader.hide();

          // Get the title and set it on the document
          var t = html.match(/<title>(.*?)<\/title>/i)[1].trim();
          document.title = $('<div />').html(t).text();

          // Apply new script, link, and style content
          attach_addons(html);

          // Always apply Drupal behaviors
          // Behaviors must be able to handle being called
          // multiple times per page with one or more contexts.
          Drupal.attachBehaviors($(s.content_selector)[0]);

          // Scroll to the top
          var scroll_pos=(0);
          $('html, body').animate({scrollTop:(scroll_pos)}, '2000');
        });
      };

      $trail.unbind('click');
      $trail.click(function(e){
        if (e.shiftKey || e.metaKey || e.altKey || e.ctrlKey) return true;
        e.preventDefault();
        var $t = $(this);
        if (!$t.hasClass('active')) {
          var url = $t.attr('href');
          window.history.pushState({},'',url);
          ajax_load(url, true);
          var $h = $('#header');
          isFront = (url == '/');
          if ($h.length && hidLogo == isFront) {
            hidLogo = !isFront;
            setTimeout(function(){
              if (isFront) {
                $h.show();
                $('#inner-logo').remove();
                $('body').removeClass('ajax');
              }
              else {
                $h.hide('slow');
                $('#navbar').prepend('<a id="inner-logo" href="/"> </a>');
                $('body').addClass('ajax');
              }
            }, 2000);
          }
        }
        return false;
      });

      if (!Drupal.thinkyheadHistoryReady) {
        Drupal.thinkyheadHistoryReady = true;
        window.addEventListener('popstate', function(e) {
          // console.log(e);
          // pop ignores the first time but always loads after
          // if (!popt) { popt = true; } else {
            // console.log("Load from history: " + location.href);
            ajax_load(location.href, true);
          // }
        });
      }

    } // if menu
  } // ajaxify_menu

  function cleanup_loaded_page() {
    $('.node-inner .unpublished').each(function(){
      $(this).parent('.node-inner').addClass('unpublished');
      $(this).remove();
    });

    var m1 = '#sidebar-left-inner ul.menu, #primary ul.links, #simplemenu',
        m2 = '#breadcrumb, #block-menu_block-1 ul.menu, #content-header .tabs.primary, #content-area-admin .admin-panel, #content-area .node-inner .taxonomy ul.links';

    if (!Drupal.thinkyheadIsAjax) {
      snapshot_addons();
      ajaxify_menu(m1+', '+m2);
      Drupal.thinkyheadIsAjax = true;
    }
    else {

      ajaxify_menu(m2);

      if (typeof fbAsyncInit === 'function') fbAsyncInit();

      // Tell Google Analytics the page changed.
      // Does the Drupal module add a behavior to do this?
      if (typeof _gaq !== 'undefined') {
        _gaq.push(['_trackPageview', location.pathname]);
      }
      else if (typeof ga !== 'undefined') {
        ga('send', 'pageview', {'page':location.pathname,'title':document.title});
      }
    }
  }

  function snapshot_addons() {
    $('script, link[href], style').each(function(){
      switch(this.tagName) {
        case 'SCRIPT':
          var src = $(this).attr('src');
          if (src) {
            ext_js += '[' + src + ']';
          }
          else {
            evald_js.push($(this).html());
          }
          break;
        case 'LINK':
          ext_css += '[' + $(this).attr('href') + ']';
          break;
        case 'STYLE':
          evald_css.push($(this).text());
          break;
      }
    });
  } // >snapshot_addons

  function attach_addons(html) {
    html.replace(/<!--\[if [\s\S]*<!\[endif\]-->/gi, '');
    var m = html
            .match(/<(script|style)[^>]*>[\s\S]*?<\/\2>|<link .+?\/>/gi);

    if (m) {
      for (var i=1; i<m.length; i++) {
        var $tag = $(m[i]);
        switch($tag[0].tagName) {

          case 'LINK':
            if (!/text\/css/i.test($tag.attr('type')) && !/stylesheet/i.test($tag.attr('rel')))
              break;

          case 'STYLE':
            var href = $tag.attr('href');
            if (href) {
              if (ext_css.indexOf('['+href+']') == -1) {
                // console.log("Got new css: " + href);
                add_ext_css(href);
              }
            }
            else {
              // see if the code has already been run
              var css = $(m[i]).text(), do_css = true;
              $.each(evald_css, function(i,v){
                if (css.indexOf(v) != -1) {
                  return do_css = false;
                }
              });
              // run any new code we got
              if (do_css) {
                // console.log("Got new css: \n" + css);
                html_add_tag($tag);
                evald_css.push(css);
              }
            }
            break;

          case 'SCRIPT':
            var src = $tag.attr('src');
            if (src) {
              if (ext_js.indexOf('['+src+']') == -1) {
                // console.log("Got new js: " + src);
                add_ext_script(src);
              }
            }
            else {
              // see if the code has already been run
              var code = $(m[i]).text(), do_exec = true;
              $.each(evald_js, function(i,v){
                if (code.indexOf(v) != -1) {
                  return (do_exec = false);
                }
              });
              // run any new code we got
              if (do_exec) {
                // console.log("Got new code: \n" + code);
                eval(code);
                evald_js.push(code);
              }
            }
            break;

        } // switch
      } // for
    } // if s

    // Remove all classes from <body>
    // (except: simplemenu and front related)
    var skip = /^(simplemenu.*|(not-)?front)$/,
        c = $('body').attr('class').split(/ +/),
        bod = html.match(/<body [^>]*>/gi);
    $.each(c, function(i,v) {
      if (!v.match(skip)) $('body').removeClass(v);
    });
    // Now add classes from the loaded <body>
    if (bod) {
      c = bod[0].replace(/.*class="([^"]+)".*/gi, function(a,b){return b;}).split(/ +/);
      $.each(c, function(i,v) {
        if (!v.match(skip)) $('body').addClass(v);
      });
    }

  } // >attach_addons

  function add_ext_script(src,dst) {
    dst = dst || 'head';
    html_add_tag($('<script>').attr({type:'text/javascript',src:src}), dst);
    ext_js += '[' + src + ']';
  }

  function add_ext_css(href,dst) {
    dst = dst || 'head';
    html_add_tag($('<style>').attr({type:'text/css'}).html('@import url("' + href + '")'), dst);
    // html_add_tag($('<link>').attr({type:'text/css',rel:'stylesheet',href:href}), dst);
    ext_css += '[' + href + ']';
    // console.log("CSS: " + ext_css);
  }

  function html_add_tag($tag,dst) {
    dst = dst || 'head';
    $(dst).append($tag);
    // $('head ' + $tag[0].tagName.toLowerCase() + ':last').after($tag);
  }

}

Let's look at some of the more interesting parts of this script.

Line 9: Create the behavior

In Drupal, custom Javascript is wrapped in behaviors. A behavior takes one argument, a DOM element that needs to be updated. When a page first loads, this will be the window.document. Drupal behaviors are executed when a page first finishes loading, and also any time an AJAX element loads on the page.

Line 9: Create the behavior
In Drupal, custom Javascript is wrapped in behaviors. A behavior takes one argument, a DOM element that needs to be updated. When a page first loads, this will be the window.document. Drupal behaviors are executed when a page first finishes loading, and also any time an AJAX element loads on the page.
Line 15: Loader image
It's nice to provide feedback in the form of an animated GIF image.
Line 18: Cleanup
Call a function to set up the AJAX links and do other cleanup.
Line 20: ajaxify_menu
This function applies the AJAX loader to a menu, passed as a DOM element or any jQuery-compatible selector. It also ensures that the browser history buttons will behave properly. The function is pretty self-explanatory, but I will point out some highlights below.
Line 24: $trail
The trail will include any links inside the menu, except for links that are marked as non-ajax, or which should never load as AJAX pages.
Line 26: set_trail
This little function makes sure that the menu trail is highlighted (and if the menu is set up with CSS the right way, that the sub-items of the active item are visible and other sub-items are hidden).
Line 36: ajax_load
This function is the click action handler for any AJAX links on the page. It shows the loading animation, loads the AJAX content and inserts it into the page, and updates the menu trail.
Line 54: Call attach_addons
This function applies new classes to the <body> tag, loads and runs any new Javascript, updates Drupal.settings, and loads any new CSS that the content requires. The function begins at line 165.
Line 59: Drupal.attachBehaviors
The loader last calls Drupal.attachBehaviors, passing the DOM item corresponding to the settings.content_selector value we set in the module code. This will call back into this behavior, by the way, so we have to be careful that all this code is re-entrant.
Line 67: Ajaxify the Page
This is the click action for all the AJAX links. Note how it does some filtering on the keyboard modifiers before it will load the content as AJAX. Browsers use these modifiers to download or open content in a new window.
Line 74: This page is history
We need to add the page's URL to the browser history, otherwise the back and forward buttons won't be able to navigate through pages loaded this way.
Line 78: Custom stuff
For the thinkyhead site I found that it helped to transition the page header from the front style to the inner style so that page reloads looked good. Code to do custom actions like that should go in this spot.