You are here

tableofcontents.module in Table of Contents 6.2

This is a filter module to generate a collapsible jquery enabled mediawiki style table of contents based on <h[1-6]> tags. Transforms header tags into named anchors.

It is a complete rewrite of the original non-jquery enabled tableofcontents filter module as follows: +added jquery to make ToC collapsible +preserves attributes on the header tags +checks for existing ID on headers and uses that if found (if none, creates one) +extends the minimum header level to 1 +header conversion is case insensitive +made the regex match for options on the <!--tableofcontents--> marker tolerant of spaces +added a more explanatory error message for invalid options & concatenated it into one string to prevent duplicates from being displayed +added several divs to make ToC themable via CSS +provided basic CSS

File

tableofcontents.module
View source
<?php

/**
 * @file
 * This is a filter module to generate a collapsible jquery enabled mediawiki
 * style table of contents based on <h[1-6]> tags. Transforms header tags into
 * named anchors.
 *
 * It is a complete rewrite of the original non-jquery enabled tableofcontents
 * filter module as follows:
 *   +added jquery to make ToC collapsible
 *   +preserves attributes on the header tags
 *   +checks for existing ID on headers and uses that if found (if none,
 *    creates one)
 *   +extends the minimum header level to 1
 *   +header conversion is case insensitive
 *   +made the regex match for options on the <!--tableofcontents-->
 *    marker tolerant of spaces
 *   +added a more explanatory error message for invalid options & concatenated
 *    it into one string to prevent duplicates from being displayed
 *   +added several divs to make ToC themable via CSS
 *   +provided basic CSS
 */

/**
 * Implementation of hook_init()
 * 
 * We have to load the JS here since we are now caching the filter results
 */
function tableofcontents_init() {
  drupal_add_js(drupal_get_path('module', 'tableofcontents') . '/tableofcontents.js');
  drupal_add_css(drupal_get_path('module', 'tableofcontents') . '/tableofcontents.css');
}

/**
 * Implementation of hook_help().
 */
function tableofcontents_help($section) {
  switch ($section) {
    case 'admin/modules#description':
      return t('A module to create a table of contents based on HTML header tags. Changes headers to anchors for processing so it may be incompatible with other filters that process header tags. It does use existing IDs on the header tags if already present and only operates on header levels 1 - 6.');
  }
}

/**
 * Implementation of hook_filter_tips().
 */
function tableofcontents_filter_tips($delta, $format, $long = FALSE) {
  $override = variable_get('tableofcontents_allow_override_' . $format, TRUE);
  if ($long) {
    if ($override) {
      return t('Every instance of "&lt;!--tableofcontents--&gt;" in the input text will be replaced with a collapsible mediawiki-style table of contents. Accepts options for title, list style, minimum heading level, and maximum heading level, and attachments as follows: &lt;!--tableofcontents list: ol; title: Table of Contents; minlevel: 2; maxlevel: 3; attachments: yes;--&gt;. All arguments are optional.');
    }
    else {
      return t('Every instance of "&lt;!--tableofcontents--&gt;" in the input text will be replaced with a collapsible mediawiki-style table of contents.');
    }
  }
  else {
    if ($override) {
      return t('Insert &lt;!--tableofcontents [list: ol; title: Table of Contents; minlevel: 2; maxlevel: 3; attachments: yes;]--&gt; to insert a mediawiki style collapsible table of contents. Arguments within [] are optional.');
    }
    else {
      return t('Insert &lt;!--tableofcontents--&gt; to insert a mediawiki style collapsible table of contents.');
    }
  }
}

/**
 * Implementation of hook_filter().
 */
function tableofcontents_filter($op, $delta = 0, $format = -1, $text = '') {
  if ($op == 'list') {
    return array(
      0 => t('Table of Contents'),
    );
  }
  global $toc_options;
  switch ($op) {
    case 'description':
      return t('Inserts a table of contents in the place of &lt;!--tableofcontents--&gt; tags.');
    case 'no cache':
      return FALSE;
    case 'settings':
      return _tableofcontents_settings($format);
    case 'prepare':

      // get all toc markers and options
      preg_match_all('!<\\!-- ?tableofcontents(.*)-->!', $text, $options_str, PREG_PATTERN_ORDER);

      // to optimize performance enclose preparation in conditional that tests for presence of ToC marker
      if (!empty($options_str[0]) && $options_str[0][0] != "") {

        // Set defaults.
        $toc_options = array();

        // We save a translate call and only translate this when we process the options.
        $toc_options["title"] = variable_get('tableofcontents_title_' . $format, 'Table of Contents');
        $toc_options["list"] = variable_get('tableofcontents_list_type_' . $format, 'ol');
        $toc_options["minlevel"] = variable_get('tableofcontents_minlevel_' . $format, 2);
        $toc_options["maxlevel"] = variable_get('tableofcontents_maxlevel_' . $format, 3);
        $toc_options["attachments"] = variable_get('tableofcontents_attachments_' . $format, FALSE);

        // Only process options if user arguments were specified and are allowed.
        if (variable_get('tableofcontents_allow_override_' . $format, TRUE) && count($options_str, 1) > 2) {

          // parse separate options
          preg_match_all('/([A-z]+): ?([A-z0-9 ]+);/', $options_str[1][0], $options, PREG_PATTERN_ORDER);

          // make sure arguments contain valid option identifiers
          $allowed_options = array_keys($toc_options);
          for ($i = 0; $i < sizeof($options[1]); $i++) {
            if (!in_array($options[1][$i], $allowed_options)) {
              form_set_error("Table of Contents", t("Table of Contents Error: " . $options[1][$i] . " is an invalid option."));
            }
            else {
              $toc_options[$options[1][$i]] = $options[2][$i];
            }
          }

          // validate option values, use defaults for invalid values, build & display error message
          $error_status = false;
          if ($toc_options["list"] != "ol" && $toc_options["list"] != "ul") {
            $error_message[] = $toc_options["list"] . " is an invalid list type. The only valid values for list are 'ol' or 'ul'. Using default value of ol.\n";
            $error_status = true;
            $toc_options["list"] = "ol";
          }
          if (!($toc_options["minlevel"] >= 1 && $toc_options["minlevel"] < 6)) {
            $error_message[] = $toc_options["minlevel"] . " is an invalid minimum level option. You must use a number between 1 and 4. Using default value of 1.\n";
            $error_status = true;
            $toc_options["minlevel"] = 1;
          }
          if (!($toc_options["maxlevel"] >= $toc_options["minlevel"] && $toc_options["maxlevel"] <= 6)) {
            $error_message[] = $toc_options["maxlevel"] . " is an invalid maximum depth option. You must use a number between " . $toc_options["minlevel"] . " and 5. Using default value of 3.";
            $error_status = true;
            $toc_options["maxlevel"] = 3;
          }
          if (!($toc_options["attachments"] == 1 || $toc_options["attachments"] == 0)) {
            $error_message[] = t("!attachments is an invalid attachments option. Attachments option must be set to '1' to display or '0' to hide.", array(
              '!attachments' => $toc_options["attachments"],
            ));
            $error_status = true;
          }
          if ($error_status) {

            // According to IRC, there can be issues with translations when you use dynamic variables
            // As well, t() should only be on single paragraphs with no newlines.
            // within t().
            foreach ($error_message as $message) {
              form_set_error("Table of Contents", t('%error', array(
                '%error' => $message,
              )));
            }
          }
        }

        // Translate title.
        $toc_options["title"] = t($toc_options["title"]);
      }
      return $text;
    case 'process':

      // to optimize performance enclose processing in conditional that tests for the presence of the toc marker options
      if (isset($toc_options)) {

        //build the toc

        // toc(array('level' => 1, 'heading' => $text))
        $toc = array();

        // $i = index of header level being processed
        // $matches[0][$i] -> Whole string matched
        // $matches[1][$i] -> First heading level
        // $matches[2][$i] -> Whole string of attributes
        // $matches[3][$i] -> id attibute, used for anchor
        // $matches[4][$i] -> Text of id attribute
        // $matches[5][$i] -> Text inside of h tag
        // $matches[6][$i] -> Close heading level, should be equal to open level
        $matches = array();

        //get all headers of current level, case insensitive
        $pattern = '/<h([' . $toc_options["minlevel"] . '-' . $toc_options["maxlevel"] . '])( .*?(id="([^"]+)" ?.*?))?>(.*?)<\\/h([' . $toc_options["minlevel"] . '-' . $toc_options["maxlevel"] . '])>/is';
        preg_match('!<\\!-- ?tableofcontents(.*)-->!', $text, $matches, PREG_OFFSET_CAPTURE);
        $heading_search_text = substr($text, $matches[0][1]);
        $matches = array();
        preg_match_all($pattern, $heading_search_text, $matches, PREG_PATTERN_ORDER);
        $anchors = array();
        for ($i = 0; $i < sizeof($matches[0]); $i++) {

          // Strip HTML and non alphanumerics
          $level = $matches[1][$i];
          $heading = strip_tags($matches[5][$i]);
          $anchor = $matches[4][$i];
          array_push($toc, array(
            'level' => $level,
            'heading' => $heading,
            'anchor' => $anchor,
          ));
        }

        // If attachments are enabled, prepare the $files variable
        if ($toc_options["attachments"]) {
          $nid = explode('/', $_GET['q']);
          if (isset($nid[0]) && $nid[0] == 'node' && isset($nid[1]) && is_numeric($nid[1])) {
            $nid = $nid[1];
            $node = node_load($nid);
          }
          if (!empty($node->files)) {
            $files = $node->files;
          }
        }

        // Build HTML for the Table of Contents.
        $toc_html = theme('tableofcontents_toc', $toc, $toc_options, $files);

        // replace all tableofcontents markers with generated ToC html
        return preg_replace('!<\\!-- ?tableofcontents(.*)-->!', $toc_html, $text);
      }
      else {
        return $text;
      }
  }
}

/**
 * Return the form used for the filter settings.
 * 
 * @param $format
 *   The currently selected input format.
 */
function _tableofcontents_settings($format) {
  $form['tableofcontents'] = array(
    '#type' => 'fieldset',
    '#title' => t('Table of Contents'),
    '#collapsible' => TRUE,
  );
  $form['tableofcontents']['tableofcontents_allow_override_' . $format] = array(
    '#title' => t('Allow users to override these settings within the &lt;!--tableofcontents--&gt; tag'),
    '#type' => 'checkbox',
    '#default_value' => variable_get('tableofcontents_allow_override_' . $format, TRUE),
  );
  $form['tableofcontents']['tableofcontents_remove_teaser_' . $format] = array(
    '#title' => t('Remove Table of Contents tags from teasers'),
    '#type' => 'checkbox',
    '#default_value' => variable_get("tableofcontents_remove_teaser_{$format}", TRUE),
    '#description' => t('If this setting is changed, each node may need to be re-edited to reflect the new setting. This will also cause every node with a Table of Contents to have a split teaser. If you have many nodes, the <a href="@retease">Retease module</a> can help to do this automatically.', array(
      '@retease' => url('http://drupal.org/project/retease'),
    )),
  );
  $form['tableofcontents']['tableofcontents_title_' . $format] = array(
    '#title' => t('Table of Contents Title'),
    '#type' => 'textfield',
    '#size' => 64,
    '#maxlength' => 1024,
    '#default_value' => variable_get('tableofcontents_title_' . $format, 'Table of Contents'),
    '#description' => t('Enter a default title for each Table of Contents. This will be translated for each individual page.'),
  );
  $form['tableofcontents']['tableofcontents_list_type_' . $format] = array(
    '#title' => t('List Type'),
    '#type' => 'radios',
    '#options' => array(
      'ol' => t('Ordered list'),
      'ul' => t('Unordered list'),
    ),
    '#default_value' => variable_get('tableofcontents_list_type_' . $format, 'ol'),
  );
  $form['tableofcontents']['tableofcontents_minlevel_' . $format] = array(
    '#title' => t('Minimum heading level'),
    '#type' => 'select',
    '#multiple' => FALSE,
    '#options' => array(
      1 => "1",
      2 => "2",
      3 => "3",
      4 => "4",
      5 => "5",
      6 => "6",
    ),
    '#default_value' => variable_get('tableofcontents_minlevel_' . $format, 2),
  );
  $form['tableofcontents']['tableofcontents_maxlevel_' . $format] = array(
    '#title' => t('Minimum heading level'),
    '#type' => 'select',
    '#multiple' => FALSE,
    '#options' => array(
      1 => "1",
      2 => "2",
      3 => "3",
      4 => "4",
      5 => "5",
      6 => "6",
    ),
    '#default_value' => variable_get('tableofcontents_maxlevel_' . $format, 3),
  );
  $form['tableofcontents']['tableofcontents_attachments_' . $format] = array(
    '#title' => t('Show attachments in the table of contents'),
    '#type' => 'checkbox',
    '#default_value' => variable_get('tableofcontents_attachments_' . $format, FALSE),
  );
  return $form;
}

/**
 * Implementation of hook_nodeapi
 *
 * We need to clear the cache to cover the case where file attachments have changed, but
 * the body hasn't. This might be a little aggressive, in that we clear the cache for any node
 * with attachments, but since this only occurs during editing or creating the load should be
 * pretty minimal.
 */
function tableofcontents_nodeapi(&$node, $op, $a3 = NULL, $a4 = NULL) {
  switch ($op) {
    case 'prepare':
      if (isset($node->files)) {

        // Remove the cached version if there attachments on this node
        $cid = $node->format . ':' . md5($node->body);
        cache_clear_all($cid, 'cache_filter');
      }
      break;
    case 'presave':
      if (variable_get("tableofcontents_remove_teaser_{$node->format}", TRUE)) {
        $filters = filter_list_format($node->format);
        if (isset($filters['tableofcontents/0'])) {
          if ($node->teaser && $node->teaser != ($new_teaser = preg_replace('!<\\!-- ?tableofcontents(.*)-->!', '', $node->teaser))) {
            if (strpos($node->body, '<!--break-->') > 0) {

              // We've specfied the split, but the summary is shown in the full
              // view. So, we now have to convert it so that the summary is
              // hidden.
              $node->body = preg_replace('/(.*)<!--break-->/s', '<!--break-->$1', $node->body);
              drupal_set_message(t("Your summary was split from the body as site settings don't allow Table of Contents in summaries."));
            }
            else {

              // This is the case where no specific break or summary was
              // specified, so now it becomes a split teaser.
              $node->body = '<!--break-->' . $node->body;
              drupal_set_message(t("A split summary was automatically created as site settings don't allow Table of Contents in summaries."));
            }

            // Remove toc from teasers.
            $node->teaser = $new_teaser;
          }
        }
      }
      break;
  }
}

/**
 * Implementation of hook_theme
 * @return
 *   Array of theme hooks this module implements.
 */
function tableofcontents_theme() {
  return array(
    'tableofcontents_toc' => array(
      'arguments' => array(
        'toc' => NULL,
        'options' => NULL,
        'files' => array(),
      ),
    ),
  );
}

/**
 * Theme the output of a table of contents.
 * 
 * @param $toc
 *   Array containing the table of contents.
 * @param $options
 *   The array of options for the table of contents.
 * @param $files
 *   Optional array of files to render in the table of contents.
 * @return
 *   Rendered HTML to be displayed.
 */
function theme_tableofcontents_toc($toc, $options, $files = array()) {
  $output = "<div class=\"toc\">\n<div class=\"toc-title\">" . $options["title"] . " [<a href=\"#\" class=\"toc-toggle\">" . t('hide') . "</a>]</div>\n<div class=\"toc-list\">\n<" . $options["list"] . ">\n";
  $depth = $options["minlevel"];
  foreach ($toc as $index => $title) {

    // Process nested lists.
    $curdepth = $title['level'];
    if ($curdepth <= $options["maxlevel"]) {

      // Close list items but not before no items have been added.
      if ($curdepth == $depth && $index != 0) {
        $output .= "</li>\n";
      }

      // Be sure to deal with skipping between non-adjacent h levels.
      while ($curdepth != $depth) {
        if ($curdepth > $depth) {
          $output .= "\n<" . $options["list"] . ">\n";
          $depth++;
        }
        else {
          if ($curdepth < $depth) {
            $output .= "</li>\n</" . $options["list"] . ">\n";
            $depth--;
            if ($curdepth == $depth) {
              $output .= "</li>\n";
            }
          }
        }
      }

      // insert the li element
      $output .= "\t<li><a href=\"#" . $title['anchor'] . "\">" . $title['heading'] . "</a>";
    }
  }

  // Did we recurse back out? If not, close open lists.
  while ($depth > $options["minlevel"]) {
    $output .= "</li>\n</" . $options["list"] . ">\n";
    $depth = $depth - 1;
  }
  $output .= "</li>\n";

  // If we've been passed an array of files, add them to the table of contents.
  if (!empty($files)) {
    $output .= '<li><a href="#attachments">' . t("Attachments") . "</a>";
    $output .= "<" . $options["list"] . ">";
    foreach ($files as $file) {
      if ($file->list && !$file->remove) {
        $output .= '<li>';

        // If there is no function providing phptemplate_file_icon in
        // template.php or similar then this will fail gracefully.
        $output .= theme('file_icon', $file);
        $file_href = $href = file_create_url(strpos($file->fid, 'upload') === FALSE ? $file->filepath : file_create_filename($file->filename, file_create_path()));
        $file_text = $file->description ? $file->description : $file->filename;
        $output .= l($file_text, $file_href) . "</li>\n";
      }
    }
    $output .= "</" . $options['list'] . "></li>\n";
  }
  $output .= "</" . $options["list"] . ">\n</div>\n</div>";
  return $output;
}

Functions

Namesort descending Description
tableofcontents_filter Implementation of hook_filter().
tableofcontents_filter_tips Implementation of hook_filter_tips().
tableofcontents_help Implementation of hook_help().
tableofcontents_init Implementation of hook_init()
tableofcontents_nodeapi Implementation of hook_nodeapi
tableofcontents_theme Implementation of hook_theme
theme_tableofcontents_toc Theme the output of a table of contents.
_tableofcontents_settings Return the form used for the filter settings.