Ajaxify Your Drupal Site

March 18, 2015

You may have noticed that most of the menus on old.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:

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 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.

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                  with no sub-tree
 *  li.expanded              with a sub-tree
 *  li.active-trail          with active tree
 *  li.leaf.active-trail a   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 = $('')
                  .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(/(.*?)/i)[1].trim();
          document.title = $('').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(' ');
                $('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(//gi, '');
    var m = html
            .match(/]*>[\s\S]*?|/gi);

    if (m) {
      for (var i=1; i
    // (except: simplemenu and front related)
    var skip = /^(simplemenu.*|(not-)?front)$/,
        c = $('body').attr('class').split(/ +/),
        bod = html.match(/]*>/gi);
    $.each(c, function(i,v) {
      if (!v.match(skip)) $('body').removeClass(v);
    });
    // Now add classes from the loaded
    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($('').attr({type:'text/javascript',src:src}), dst);
    ext_js += '[' + src + ']';
  }

  function add_ext_css(href,dst) {
    dst = dst || 'head';
    html_add_tag($('').attr({type:'text/css'}).html('@import url("' + href + '")'), dst);
    // html_add_tag($('').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.

AJAX Javascript Drupal lesson jQuery blog