diff --git a/includes/class-content-toc.php b/includes/class-content-toc.php
index e04389a..4b3018d 100644
--- a/includes/class-content-toc.php
+++ b/includes/class-content-toc.php
@@ -28,6 +28,12 @@ class TOC {
// Placeholder HTML that shortcode is substituted for
protected $placeholder = '
';
+ // Available TOC structure types
+ protected $types;
+
+ // HTML structure type of the TOC list
+ protected $type;
+
/**
* Create Content_TOC:
* 1) Setup default header elements
@@ -57,6 +63,13 @@ protected function __construct() {
$this->settings = apply_filters( 'hm_content_toc_settings', $this->settings );
+ // Set up available TOC structure types
+ $this->types = array( 'flat', 'nested' );
+
+ // Set up default TOC structure type
+ // TODO: do I need to check the value after the filter against the available types?
+ $this->type = apply_filters( 'hm_content_toc_default_type', 'flat', $this->types );
+
// Register shortcode
add_shortcode( 'hm_content_toc', array( $this, 'shortcode' ) );
@@ -108,7 +121,8 @@ public function shortcode( $shortcode_atts, $shortcode_content = null ) {
$shortcode_atts = shortcode_atts( array(
'headers' => $this->headers,
- 'title' => $this->settings['title']
+ 'title' => $this->settings['title'],
+ 'type' => $this->type
), $shortcode_atts, 'hm_content_toc' );
// Stop - if subsequent TOC is being processed (not 1st one). Only process the first TOC shortcode
@@ -210,7 +224,7 @@ public function filter_content( $post_content, $shortcode_atts ) {
$title_html = $this->get_toc_title_html( $shortcode_atts );
// TOC items HTML
- $items_html = $this->get_toc_items_html( $toc_items_matches );
+ $items_html = $this->get_toc_items_html( $toc_items_matches, $shortcode_atts );
// TOC list HTML
$list_html = $items_html;
@@ -247,7 +261,7 @@ public function filter_content( $post_content, $shortcode_atts ) {
* Find and return an array of HTML headers for a given set of accepted header elements
* and a given string of HTML content
*
- * @param array $headers Comma separated list of header elements
+ * @param string $headers Comma separated list of header elements
* @param string $post_content A HTML content string
*
* @return array Regex matches of specified header elements
@@ -261,7 +275,7 @@ public function get_content_toc_headers( $headers, $post_content ) {
}
// Prepare headers for regex & get them in array
- $headers = $this->prepare_headers( $headers );
+ $headers = $this->prepare_headers( $headers, true );
// Stop - if no header elements are specified to be matched
if ( empty( $headers ) ) {
@@ -291,11 +305,12 @@ public function get_content_toc_headers( $headers, $post_content ) {
* 4) Keep unique values only
* 5) Escape regex special chars in headers with preg_quote
*
- * @param $headers Comma separated list of header elements to match for TOC generation
+ * @param string $headers Comma separated list of header elements to match for TOC generation
+ * @param bool $regex_escape Flag to either regex escape headers or not
*
- * @return array Header elements to be matched in content to generate TOC
+ * @return array Header elements to be matched in content to generate TOC
*/
- public function prepare_headers( $headers ) {
+ public function prepare_headers( $headers, $regex_escape = false ) {
// 1) Split string by commas
$headers_arr = explode( ',', $headers );
@@ -313,7 +328,9 @@ public function prepare_headers( $headers ) {
$headers_arr = array_unique( $headers_arr );
// 5) Escape regex special chars
- $headers_arr = array_map( 'preg_quote', $headers_arr );
+ if ( $regex_escape ) {
+ $headers_arr = array_map( 'preg_quote', $headers_arr );
+ }
return $headers_arr;
}
@@ -341,11 +358,30 @@ protected function get_toc_title_html( $shortcode_atts ) {
/**
* Gets the HTML for the content TOC items
*
- * @param array $toc_items_matches Array of specified headers that were matched in the content
+ * @param array $toc_items_matches Array of TOC matched headers
+ * (headers that have been matched in the content)
+ * @param array $shortcode_atts Array of shorcode attributes
*
* @return string Output HTML for content TOC items
*/
- protected function get_toc_items_html( $toc_items_matches ) {
+ protected function get_toc_items_html( $toc_items_matches, $shortcode_atts ) {
+
+ // Decide what TOC structure to display, default to flat
+ // TODO: Do I need to store a default type rather than have it specified here?
+ $type = in_array( $shortcode_atts['type'], $this->types ) ? $shortcode_atts['type'] : 'flat';
+
+ // Return TOC items HTML markup depending on its type
+ return call_user_func( array( $this, "toc_markup_type_{$type}" ), $toc_items_matches, $shortcode_atts['headers'] );
+ }
+
+ /**
+ * Return flat HTML structure for the TOC items
+ *
+ * @param array $toc_items_matches Matched TOC items
+ *
+ * @return string Flat list HTML markup for the TOC items
+ */
+ protected function toc_markup_type_flat( $toc_items_matches ) {
$items_html = '';
@@ -376,6 +412,195 @@ protected function get_toc_items_html( $toc_items_matches ) {
return $items_html;
}
+ /**
+ * Return nested HTML structure for the TOC items, where
+ * each header in the shortcode denotes a new level.
+ *
+ * @param array $toc_items_matches Array of TOC items matches, each match is of the
+ * following structure, example:
+ * Array (
+ * [0] => Header 2
i.e. full match of the header from content
+ * [1] => Header 2 i.e. header text only
+ * [2] => h2 i.e. header element only
+ * )
+ * @param string $headers Headers list dictates the nesting levels, so that
+ * 1st header is the top level, 2nd is second level and so on
+ *
+ * @return string Nested list HTML markup for the TOC items
+ */
+ protected function toc_markup_type_nested( $toc_items_matches, $headers ) {
+
+ // Convert headers string into array, clean them up
+ $headers = $this->prepare_headers( $headers );
+
+ $nested_array = $this->get_toc_nested_array( $toc_items_matches, $headers );
+
+ $html = $this->toc_walk_nested_array( $nested_array );
+
+ return $html;
+ }
+
+ /**
+ * @param $nested_array
+ *
+ * @return string
+ */
+ protected function toc_walk_nested_array( $nested_array ) {
+
+ ob_start(); ?>
+
+
+
+
+ -
+ <type; ?>>
+ title ); ?>
+ type; ?>>
+
+ children ) {
+ echo $this->toc_walk_nested_array( $item->children );
+ } ?>
+
+
+
+
+
+ parse_toc_items_for_level( $toc_items_matches, $headers );
+
+ foreach ( $items_sanitized as $key => $toc_item_match ) {
+
+ // Get the required depth of the current match
+ $depth = $toc_item_match['level'];
+ $last_depth = isset( $items_sanitized[ $key - 1 ]['level'] ) ? $items_sanitized[ $key - 1 ]['level'] : null;
+
+ // Get the current keys to be used to drill down the nested array, we shorten the array depending on depth of the item
+ $current_keys = array_slice( $current_keys, 0, ( ( $depth > 0 ) ? ( $depth + 1 ) : 0 ) );
+
+ // Fill current_keys with 0 values if we are missing any depth pointers
+ while ( ( count( $current_keys ) - 1 ) < $depth && $depth > 0 ) {
+ $current_keys[] = 0;
+ }
+
+ // An internal reference of the nested array to be used

+ // when drilling through to the required entry point
+ $internal_ref = &$nested;
+
+ foreach ( $current_keys as $index => $current_key ) {
+
+ // If we have found an array
+ if ( is_array( $internal_ref ) ) {
+ $internal_ref = &$internal_ref[ $current_key ];
+
+ // If we have found an object but we need to dig into that object's children
+ } else if ( is_object( $internal_ref ) && ( count( $current_keys ) - 1 ) > $index ) {
+ $internal_ref = &$internal_ref->children[ $current_key ];
+
+ // If we have found an object but we don't need to drill into that object's children
+ } else if ( is_object( $internal_ref ) ) {
+ $internal_ref = &$internal_ref->children;
+ }
+ }
+
+ $item = (object) array(
+ 'title' => $toc_item_match[1],
+ 'type' => $toc_item_match[2],
+ 'children' => array(),
+ );
+
+ $internal_ref[] = $item;
+
+ // Only increment our counter if we are at equal or lower depth than the last item
+ if ( $depth <= $last_depth && $last_depth !== null ) {
+ $current_keys[ count( $current_keys ) - 1 ]++;
+ }
+
+ }
+
+ return $nested;
+ }
+
+ /**
+ * Adds 'level' key/value pair to each TOC match.
+ * Flattens any missed levels in TOC matches. So for headers h1, h2, h3
+ * the TOC matches h1, h3 will have the following levels h1 -> 0, h3 -> 1
+ *
+ * @param array $toc_items_matches Array of TOC items matches, each match is of the
+ * following structure, example:
+ * Array (
+ * [0] => Header 2
i.e. full match of the header from content
+ * [1] => Header 2 i.e. header text only
+ * [2] => h2 i.e. header element only
+ * )
+ * @param array $headers Headers array dictates the nesting levels, so that
+ * 1st header is the top level, 2nd is second level and so on
+ *
+ * @return array Original $toc_items_matches array with added 'level' key/value pair
+ */
+ protected function parse_toc_items_for_level( $toc_items_matches, $headers ) {
+
+ foreach ( $toc_items_matches as $key => &$toc_item_match ) {
+
+ $current_level = null;
+ $key_prev = $key - 1;
+
+ // Go through all previous TOC matches to determine current TOC match level
+ // Stop when current TOC match is on the same or lower level than previous one
+ while ( isset( $toc_items_matches[ $key_prev ] ) && $current_level === null ) {
+
+ // Level of current item (look up current TOC item header element in array of header elements)
+ $current_item_level = array_search( $toc_item_match[2], $headers );
+
+ // Level of previous item (look up previous TOC item header element in array of header elements)
+ $prev_item_level = array_search( $toc_items_matches[ $key_prev ][2], $headers );
+
+ // Items on the same level
+ // Use previous item's level as it's been correctly determined before
+ if ( $current_item_level === $prev_item_level ) {
+ $current_level = $toc_items_matches[ $key_prev ]['level'];
+ }
+
+ // Current item is at a lower level than previous item
+ // Use previous item's level and put current item only 1 level below,
+ // This is done to avoid empty missed levels
+ if ( $current_item_level > $prev_item_level ) {
+ $current_level = $toc_items_matches[ $key_prev ]['level'] + 1;
+ }
+
+ // Next previous item key
+ $key_prev -= 1;
+ }
+
+ // If current TOC match's level has not been determined by looking at previous matches
+ // It means we have a first top level match
+ // TODO: is there a case where $current_level < 0 ??
+ if ( $current_level === null || $current_level < 0 ) {
+ $current_level = 0;
+ }
+
+ $toc_item_match['level'] = $current_level;
+ }
+
+ return $toc_items_matches;
+ }
+
/**
* Inserts anchors into the supplied post content, just before each of
* header that was matched and supplied as array of TOC matches