|
<?php |
|
/* |
|
* Plugin Name: F1 Table of Contents |
|
* Version: 0.1.4 |
|
* Description: |
|
* Author: Forum One, Russell Heimlich |
|
* GitHub Plugin URI: https://github.com/forumone/f1-table-of-contents |
|
*/ |
|
|
|
class F1_Table_Of_Contents { |
|
|
|
// Properties |
|
/** |
|
* Holds objects with information about each heading. |
|
* @var array |
|
*/ |
|
public $headings = array(); |
|
|
|
/** |
|
* Collection of strings that need to be found and replaced. |
|
* @var array |
|
*/ |
|
public $find = array(); |
|
|
|
/** |
|
* Collection of strings that are replacements to be found. |
|
* @var array |
|
*/ |
|
public $replace = array(); |
|
|
|
/** |
|
* Collection of integers representing $header indexes that have already been processed during build_html_tree() |
|
* @var array |
|
*/ |
|
public $html_tree_processed = array(); |
|
|
|
// Methods |
|
public function __construct() {} |
|
|
|
/** |
|
* Sets up hooks this class uses. |
|
*/ |
|
public function setup() { |
|
add_filter( 'the_content', array( $this, 'the_content' ), 100 ); // run after shortcodes are interpretted (level 10) |
|
} |
|
|
|
/** |
|
* Replaces opening heading tags to add an anchor element that can be linked to. |
|
* @param string $the_content The post content to be modified. |
|
* @return string The modified content. |
|
*/ |
|
public function the_content( $the_content ) { |
|
if( !is_singular() ) { |
|
return $the_content; |
|
} |
|
|
|
$this->get_headings( $the_content ); |
|
|
|
return $this->mb_find_replace( $the_content ); |
|
} |
|
|
|
/** |
|
* Extract heading information from a string of text. |
|
* @param string $text Text with headings in it to be extracted. |
|
* @return object Return information about the headings extracted. |
|
*/ |
|
public function get_headings( $text ) { |
|
$matches = array(); |
|
$toc_anchor_text = apply_filters( 'f1_toc_anchor_text', '' ); // If you want to add text within the anchor link then hook into this filter. |
|
|
|
if( preg_match_all( '/(<h([1-6]{1})[^>]*>).*<\/h\2>/msuU', $text, $matches, PREG_SET_ORDER ) ) { |
|
/* |
|
$matches[x][0] - Full HTML heading, <h1>Hello World!</h1> |
|
$matches[x][1] - Opening headling tag, <h1> or <h2> etc. |
|
$matches[x][2] - Numeric heading level, 1 or 2 |
|
*/ |
|
|
|
$output = array(); |
|
for( $i = 0; $i < count( $matches ); $i++ ) { |
|
// Get anchor and add to find and replace arrays |
|
$anchor = $this->url_anchor_target( $matches[ $i ][0] ); |
|
$this->find[] = $matches[ $i ][0]; |
|
$this->replace[] = str_replace( |
|
array( |
|
$matches[ $i ][1], // Start of heading |
|
'</h' . intval( $matches[ $i ][2] ) . '>', // End of heading |
|
), |
|
array( |
|
$matches[ $i ][1] . '<a id="' . esc_attr( $anchor ) . '" href="#' . esc_attr( $anchor ) .'" class="toc-anchor">' . $toc_anchor_text . '</a>', |
|
'</h' . intval( $matches[ $i ][2] ) . '>', |
|
), |
|
$matches[ $i ][0] |
|
); |
|
|
|
$output[] = (object) array( |
|
'anchor' => $anchor, |
|
'text' => strip_tags( $matches[ $i ][0] ), |
|
'html' => $matches[ $i ][0], |
|
'opening_tag' => $matches[ $i ][1], |
|
'level' => intval($matches[ $i ][2]), |
|
); |
|
} |
|
|
|
$this->headings = $output; |
|
|
|
return $output; |
|
} |
|
} |
|
|
|
/** |
|
* Convert a string to a hypenated slug. |
|
* @param string $title The string to slugify. |
|
* @return string Slugified version of the string. |
|
*/ |
|
private function url_anchor_target( $title = '' ) { |
|
$return = false; |
|
|
|
if ( $title ) { |
|
$return = trim( strip_tags( $title ) ); |
|
|
|
// Replace newlines with spaces (eg when headings are split over multiple lines) |
|
$return = str_replace( array("\r", "\n", "\n\r", "\r\n"), ' ', $return ); |
|
|
|
$return = sanitize_file_name( $return ); |
|
$return = strtolower( $return ); |
|
} |
|
|
|
return $return; |
|
} |
|
|
|
/** |
|
* Multibyte-aware find and replace. |
|
* @param string $string String to be searched |
|
* @return string Modified string |
|
*/ |
|
private function mb_find_replace( &$string = '' ) { |
|
// Check if multibyte strings are supported |
|
|
|
$find = $this->find; |
|
$replace = $this->replace; |
|
|
|
if( function_exists( 'mb_strpos' ) ) { |
|
for( $i = 0; $i < count( $find ); $i++ ) { |
|
$string = |
|
mb_substr( $string, 0, mb_strpos( $string, $find[ $i ] ) ) . // Everything before $find |
|
$replace[ $i ] . // Its replacement |
|
mb_substr( $string, mb_strpos( $string, $find[ $i ] ) + mb_strlen( $find[ $i ] ) ) // Everything after $find |
|
; |
|
} |
|
} |
|
else { |
|
for( $i = 0; $i < count( $find ); $i++ ) { |
|
$string = substr_replace( |
|
$string, |
|
$replace[ $i ], |
|
strpos( $string, $find[ $i ] ), |
|
strlen( $find[ $i ] ) |
|
); |
|
} |
|
} |
|
|
|
return $string; |
|
} |
|
|
|
public function build_html_tree( $headers, $index = 0, $depth = 0 ) { |
|
if( in_array( $index, $this->html_tree_processed ) ) { |
|
return ''; |
|
} |
|
|
|
$tree = ''; |
|
if( $depth === 0 ) { |
|
$depth++; |
|
foreach( $headers as $i => $header ) { |
|
$tree .= $this->build_html_tree( $headers, $i, $depth ); |
|
} |
|
|
|
if( $tree ) { |
|
$tree = '<ol class="f1-toc">' . $tree . '</ol>'; |
|
} |
|
return $tree; |
|
} |
|
|
|
$header = $headers[ $index ]; |
|
$next_header = false; |
|
if( isset( $headers[ $index + 1 ] ) ) { |
|
$next_header = $headers[ $index + 1 ]; |
|
} |
|
|
|
$this->html_tree_processed[] = $index; |
|
$tree .= '<li>'; |
|
$tree .= '<a href="#' . $header->anchor . '">' . $header->text . '</a>'; |
|
if( $next_header && $header->level < $next_header->level ) { |
|
$node = $this->build_html_tree( $headers, $index + 1, $depth++ ); |
|
if( $node ) { |
|
$tree .= '<ol>' . $node . '</ol>'; |
|
} |
|
} |
|
$tree .= '</li>'; |
|
|
|
if( $next_header && $header->level == $next_header->level ) { |
|
$node = $this->build_html_tree( $headers, $index + 1, $depth++ ); |
|
if( $node ) { |
|
$tree .= $node; |
|
} |
|
} |
|
|
|
return $tree; |
|
} |
|
} |
|
|
|
global $f1_table_of_contents; |
|
$f1_table_of_contents = new F1_Table_Of_Contents(); |
|
$f1_table_of_contents->setup(); |
|
|
|
|
|
|
|
/* Helper Functions */ |
|
|
|
|
|
/** |
|
* Gets header data from the content. |
|
* @return array List of objects containing header data |
|
*/ |
|
function get_f1_toc() { |
|
global $f1_table_of_contents; |
|
return $f1_table_of_contents->headings; |
|
} |
|
|
|
/** |
|
* Generate HTML markup based on the heading structure of the post content with anchor links to different headers. |
|
* @param integer $limit The deepest level of headings to show |
|
* @return string HTML output |
|
*/ |
|
function f1_toc( $limit = 6 ) { |
|
global $f1_table_of_contents; |
|
$raw_headers = get_f1_toc(); |
|
if( !$raw_headers ) { |
|
return; |
|
} |
|
|
|
$headers = []; |
|
foreach( $raw_headers as $header ) { |
|
if( $header->level <= $limit ) { |
|
$headers[] = $header; |
|
} |
|
} |
|
$headers = apply_filters( 'f1_toc_headers', $headers ); |
|
|
|
// Reset the array that keeps track of which headers have already been processed |
|
$f1_table_of_contents->html_tree_processed = array(); |
|
return $f1_table_of_contents->build_html_tree( $headers ); |
|
} |