Turning Drupal 7 into a Static Site Generator

January 16th, 2012 Permalink

Ex Ratione runs on Movable Type, which for those unfamiliar with the platform is essentially a web-based tool for producing and managing a static (or largely static) web site. To put it another way, Movable Type is a web application written in Perl for the purpose of producing a web application written in PHP - because there can never be too many layers of abstraction. While it is true that a database is involved in this process, the primary end product of creating a web page through the Movable Type administration interface is a nothing more than a minimal PHP script file: largely HTML, and with a little scripting around the edges. Many Movable Type sites dispense with the scripting entirely and just turn out flat HTML files. Either way, the result is what amounts to a static site: all the content is sitting in HTML or HTML-plus-a-little-bit-of-scripting files, and the content management system is just there to organize those files - with the help of a database that contains another copy of that information, sufficient to reconstruct and reorder the site if necessary.

As an approach to a content management system, this is quite different from that of a framework like Drupal, in which content is stored in the database and in-memory caches, and loaded up and assembled on the fly each time a page is accessed. The presented page never actually exists as a flat file - or at least anywhere outside of whatever front-end page cache you might be using. But the Drupal development community is active and sprawling, and any such community will build modules, plug-ins, and add-ons that allow their platform of choice to mimic the operation of almost any other platform of choice - this is something of a law of software development, though I'm not sure I've seen it formally named.

Thus it is possible to set up a Drupal installation as a static site generator that, at the highest level, operates in a conceptually very similar way to Movable Type. By this I mean that you create pages in the administration interface, and all the data within those pages is saved to files containing mixed PHP and HTML. Those files become the primary mode of access to the pages, via the webserver - and while that data is also in the database, the database is relegated to a secondary status. It is now only a part of the web application by which an administrator manages site content, rather than being integral to actually serving up web pages.

It has been possible to set up Drupal as a static site generation for a while now, but I'll focus on Drupal 7 for the purposes of this post - a lot of what I discuss below exists in Drupal 6, but don't assume that to be the case for any particular item without checking. The two modules you need in order to start down this path are these:

Ctools provides an API for (a) defining arbitrary data entities and (b) importing and exporting those entities between the Drupal database and flat PHP files. These entities can be complex, such as nodes or Panels pages, or simple, such as rows in a given table - but all of these can be boiled down to PHP objects, arrays, and strings when exported into code.

Features builds upon the ctools entity API by allowing collections of entities and dependencies between entities to be bundled into a Drupal module. The Features administrative interface lets an administrator define the contents of such a module, and then download the archived module code, fully generated and ready to be dropped into the site webroot. When enabled, that module imports the entities defined within into the Drupal database, and keeps track of whether that data already exists or is later overridden. The Features administrative interface allows review of modules where some or all of the data defined in code is overridden in the database, and allows the administrator to either (a) revert back to the data defined in the module, (b) update the module code to reflect the new overridden data, or (c) leave it as-is, overridden.

So you can probably see where this is going - in the direction of a workflow that looks something like this:

  • Create a node/page through the standard Drupal administrative interface.
  • Visit the Features administrative interface and export the new page definition by adding it to your my_site module.
  • Overwrite the existing my_site module code in your webroot with the newly downloaded module.

This is unforgivably clunky, but at the conceptual level it is essentially the same as Movable Type: your visitor-facing site definition is now contained primarily in static files in code, and the administrative interface and database is being used to manage that static code. I'm sure you can see half a hundred ways in which this is very sub-optimal in comparison to the Movable Type user experience, but I'll look at addressing only a couple of those.

Missing Entity Types

If you want to completely move your visitor-facing site configuration into code, you will have to export more types of entity than are provided by ctools and Features out of the box. The most important of these is probably the contents of the "variable" table, as this is used for module configuration parameters, as well as a range of site information such as site name and so forth.

If you go hunting, you'll find that the Strongarm module defines a ctools entity for variable table rows. Unfortunately, it also comes with an agenda for managing variables that slows down most sites to an unacceptable degree. So you may want to create your own module to hold just the variable entity definition from the Strongarm codebase while omitting the rest of the Strongarm functionality. Or, alternately, copy the approach taken in the Features module for taxonomies or menu links. That second course would look something like the code below, if you were calling your module "variable_export":

/**
 * Implements hook_features_api().
 *
 * We have to tell features that we are defining a new entity for export.
 */
function variable_export_features_api() {
  return array(
    'variable' => array(
      'name' => t('Variables'),
      'feature_source' => TRUE,
      'default_hook' => 'variable_default_variables',
      'default_file' => FEATURES_DEFAULTS_INCLUDED,
    ),
  );
}

// Note that other than the hook above, all of the hook functions here
// begin "variable_" rather than "variable_export_". This is necessary.

/**
 * Implements hook_features_export_options().
 *
 * This returns an options array for the checkboxes shown in the form
 * for creating or recreating a features module. Here that just means
 * all of the variables from the database.
 */
function variable_features_export_options() {
  $vars = array();
  $result = db_query("select * from {variable} order by name");
  foreach ($result as $var) {
    $vars[$var->name] = $var->name;
  }
  return $vars;
}

/**
 * Implements hook_features_export_render().
 *
 * Produce the code that will be saved out into the features module.
 *
 * @param array $data an array of variable names
 */
function variable_features_export_render($module, $data) {

  // this code is basically copied from hook_features_export() implementations
  // found in the /include/*.inc files in the features module. Look through those
  // files for further examples of how to do this.

  $code = array();
  $code[] = '$vars = array();';
  $result = db_select('variable')
    ->condition('name', array_values($data), 'in')
    ->execute();
  foreach ($result as $var) {
    $value = var_export(unserialize($var->value), true);
    $code[] = "$vars['{$var->name}'] = " . $value . ';';
  }
  $code[] = 'return $vars;';
  $code = implode("n", $code);
  return array('variable_default_variables' => $code);
}

/**
 * Implements hook_features_rebuild().
 *
 * Fairly straightforward: load all the variable defaults from features and set them.
 */
function variable_features_rebuild($module) {
  if ($defaults = features_get_default('variable', $module)) {
    foreach ($defaults as $name => $variable) {
      variable_set($name, $variable);
    }
  }
}

/**
 * Implements hook_features_revert().
 *
 * Reverting is the same as rebuilding in this case.
 */
function variable_features_revert($module) {
  return variable_features_rebuild($module);
}

/**
 * Implements hook_features_export().
 *
 * Used to add dependencies in this case, though other forms of features
 * export do more complex things.
 *
 * @param array $data an array of variable names in this case
 * @param array $export the export definition
 */
function variable_features_export($data, &$export, $module_name = '') {

  // this code is basically copied from hook_features_export() implementations
  // found in the /include/*.inc files in the features module. Look through those
  // files for further examples of how to do this.

  $export['dependencies']['features'] = 'features';

  $map = features_get_default_map('variable', '_variable_export_identifier');
  foreach ($data as $varname) {
    // If this variable is provided by a different module, add it as a dependency.
    if (isset($map[$varname]) && $map[$varname] != $module_name) {
      $export['dependencies'][$map[$varname]] = $map[$varname];
    }
    else {
      $export['features']['variable'][$varname] = $varname;
    }
  }

  // add the necessary dependency on this module
  $export['dependencies']['variable_export'] = 'variable_export';

  $pipe = array();
  return $pipe;
}

/**
 * A trivial callback function that is passed to features_get_default_map.
 */
function _variable_export_identifier($variable_name) {
  return $variable_name;
}

This will add an option to select variable table row entities for export to code in the Features page for creating modules. Note that not all variable rows are usefully stored as code! Some are transient, system defined, or otherwise subject to change or derived from other values and thus not necessary to store in code.

Making Features Write Modules to the Filesystem

As things stand, creating a module through the Features administrative interface gives you an archive file download. Extracting the contents and plugging them into the webroot is then up to you - which is fair enough, as you could be storing your data entity defining modules in any old subdirectory. But why not alter this process to have Features drop your module into the filesystem directly, and thereby save you the trouble?

A starting point for this effort might be an implementation of hook_form_FORM_ID_alter() in a new module called features_addons that changes the behavior of the form for creating Features. Note that this was written with consideration of Features 7.x-1.0-* versions, and the form is structured very differently in some of the other branches:

/**
 * Implements hook_form_FORM_ID_alter().
 */
function features_addons_features_export_form_alter(&$form, &$form_state) {
  // Add a couple of fields to allow the user to choose whether to download
  // the created module, or move it directly to the filesystem.
  $form['module_management'] = array(
    '#type' => 'fieldset',
    '#title' => 'Module Management',
  );
  $form['module_management']['delivery'] = array(
    '#type' => 'select',
    '#title' => 'Module Delivery',
    '#options' => array(
      'write' => 'Write to Modules Directory and Enable'
      'download' => 'Download as Archive',
    ),
    '#default_value' => variable_get('features_addons_default_delivery', 'write'),
  );
  $form['module_management']['path'] = array(
    '#type' => 'textfield',
    '#title' => 'Module Path',
    '#description' => 'If writing to the modules directory, specify the base path.',
    '#default_value' =>
          variable_get('features_addons_default_path', 'sites/all/modules'),
  );

  // Hijack the form submission and route it through our submit function
  $form['buttons']['submit']['#submit'] = array('features_addons_export_form_submit');
}

/**
 * A wrapper to manage submission of the features export form with the
 * features_addons additional options.
 */
function features_addons_export_form_submit(&$form, &$form_state) {
  $values = $form_state['values'];

  // first update the defaults
  variable_set('features_addons_default_path', $values['path']);
  variable_set('features_addons_default_delivery', $values['delivery']);

  // doing this the old way?
  if ($values['delivery'] == 'download') {
    // business as usual, nothing to see here - route to the normal destination
    features_export_build_form_submit($form, $form_state);
    return;
  }
  elseif ($values['delivery'] == 'write') {

    // otherwise, we're writing to the filesystem
    // now build the module code and save it to where it needs to go

    // this would be accomplished by cloning much of the
    // features_export_build_form_submit() function code, but instead of
    // creating an archive and printing it to output, the individual files would
    // be written to a suitable folder in the filesystem, depending on module
    // name and the declared path

    // this code is left as an exercise for the reader - it isn't hard to work out
    // if you just look at features_export_build_form_submit()

  }
}

But Why?

At the end of the day, why would anyone even want to go through all of this trouble to turn Drupal - possibly the most database-centric of all content management systems - into a static site generator? Well, one possibility is that version control becomes a great deal easier when all of your content and settings are exported into code. The pain point for most professional sites built on Drupal is the fact that the world of databases on the one side and the world of file-based deployment processes and version control systems on the other side don't really mix all that well in the middle - and in Drupal pretty much everything of any consequence involves changing the data in the database. Unless, of course, you find a way to turn all of that data into code...