next_tag() ) { $value = $processor->get_attribute( $name ); return null === $value ? null : ( true === $value ? '' : (string) $value ); } return null; } preg_match_all( '/([^\s=<>`"\x27\/]+)(?:\s*=\s*(?:"([^"]*)"|\x27([^\x27]*)\x27|([^\s>]+)))?/', $tag, $attributes, PREG_SET_ORDER ); foreach ( $attributes as $attribute ) { if ( strtolower( $attribute[1] ) === strtolower( $name ) ) { $value = isset( $attribute[4] ) ? $attribute[4] : ( isset( $attribute[3] ) ? $attribute[3] : ( isset( $attribute[2] ) ? $attribute[2] : '' ) ); return html_entity_decode( $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } } return null; } /** * Set an ID without duplicating an empty or valueless authored attribute. * * @param string $opening Heading start tag. * @param string $id Generated ID. * @return string */ private static function with_id( $opening, $id ) { if ( class_exists( 'WP_HTML_Tag_Processor' ) ) { $processor = new WP_HTML_Tag_Processor( $opening ); $processor->next_tag(); $processor->set_attribute( 'id', $id ); return $processor->get_updated_html(); } preg_match_all( '/([^\s=<>`"\x27\/]+)(?:\s*=\s*(?:"[^"]*"|\x27[^\x27]*\x27|[^\s>]+))?/', $opening, $attributes, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ); foreach ( $attributes as $attribute ) { if ( 'id' === strtolower( $attribute[1][0] ) ) { return substr_replace( $opening, 'id="' . esc_attr( $id ) . '"', $attribute[0][1], strlen( $attribute[0][0] ) ); } } return substr_replace( $opening, ' id="' . esc_attr( $id ) . '"', -1, 0 ); } /** * Build an anchor from heading text. * * @param string $text Plain text. * @param int $index Heading counter. * @param array $settings Settings. * @return string */ public static function anchor( $text, $index, $settings ) { if ( 'counter' === $settings['anchor_format'] ) { return 'toc-' . $index; } $text = 'text_raw' === $settings['anchor_format'] ? $text : remove_accents( $text ); $text = preg_replace( '/[^\p{L}\p{N}_\-\s]+/u', '', $text ); $text = preg_replace( '/\s+/u', '-', trim( $text ) ); if ( $settings['dashes'] ) { $text = str_replace( '_', '-', $text ); } if ( $settings['lowercase'] ) { $text = function_exists( 'mb_strtolower' ) ? mb_strtolower( $text, 'UTF-8' ) : strtolower( $text ); } return '' === $text ? 'toc-' . $index : $text; } /** * Parse once for each post, content and anchor configuration. * * The tokenizer skips comments and raw/code elements before it * matches headings. Only missing heading ID attributes are inserted. * Existing IDs anywhere in the fragment are reserved before generating IDs. * * @param string $content Original HTML. * @param int $post_id Post ID. * @param array $settings Anchor options. * @return array HTML and heading records. */ public static function parse( $content, $post_id, $settings ) { $key = $post_id . ':' . md5( $content . wp_json_encode( array_intersect_key( $settings, array_flip( array( 'anchor_format', 'lowercase', 'dashes' ) ) ) ) ); if ( isset( self::$cache[ $key ] ) ) { return self::$cache[ $key ]; } $tag = '~|\z)|]*>|"\x27]|"[^"]*"|\x27[^\x27]*\x27)*)?\s*/?>~'; preg_match_all( $tag, $content, $tokens, PREG_OFFSET_CAPTURE ); $used = self::$claimed; foreach ( $tokens[0] as $token ) { $id = self::attribute( $token[0], 'id' ); if ( null !== $id ) { $used[ $id ] = true; self::$claimed[ $id ] = true; } } $matches = array(); $current = null; $blocked = ''; $blocked_depth = 0; foreach ( $tokens[0] as $token ) { if ( ! preg_match( '~^<(/?)([a-zA-Z][a-zA-Z0-9:-]*)\b~', $token[0], $name ) ) { continue; } $closing = '/' === $name[1]; $name = strtolower( $name[2] ); if ( $blocked ) { if ( $name === $blocked ) { $blocked_depth += $closing ? -1 : ( in_array( $blocked, array( 'script', 'style', 'textarea' ), true ) ? 0 : 1 ); if ( 0 === $blocked_depth ) { $blocked = ''; } } continue; } if ( ! $closing && null === $current && in_array( $name, array( 'pre', 'code', 'script', 'style', 'textarea', 'template', 'nav' ), true ) ) { $blocked = $name; $blocked_depth = 1; continue; } if ( ! preg_match( '/^h([1-6])$/', $name, $level ) ) { continue; } if ( ! $closing ) { $current = array( 'opening' => $token, 'level' => (int) $level[1], 'body' => $token[1] + strlen( $token[0] ), ); } elseif ( null !== $current && $current['level'] === (int) $level[1] ) { $current['text'] = substr( $content, $current['body'], $token[1] - $current['body'] ); $matches[] = $current; $current = null; } } $headings = array(); $edits = array(); foreach ( $matches as $match ) { $opening = $match['opening'][0]; $text = trim( preg_replace( '/\s+/u', ' ', html_entity_decode( wp_strip_all_tags( $match['text'] ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ) ); $id = self::attribute( $opening, 'id' ); if ( null === $id || '' === $id ) { $id = self::anchor( $text, count( $headings ) + 1, $settings ); $id = (string) apply_filters( 'tocnest_anchor', $id, $text, array( 'level' => $match['level'], 'text' => $text, ) ); $id = '' === $id ? 'toc-' . ( count( $headings ) + 1 ) : $id; $base = $id; $suffix = 2; while ( isset( $used[ $id ] ) ) { $id = $base . '-' . $suffix; ++$suffix; } $updated = self::with_id( $opening, $id ); $edits[] = array( $match['opening'][1], strlen( $opening ), $updated ); $opening = $updated; } $used[ $id ] = true; self::$claimed[ $id ] = true; $headings[] = array( 'id' => $id, 'text' => $text, 'level' => $match['level'], 'class' => (string) self::attribute( $opening, 'class' ), 'opening' => $opening, ); } foreach ( array_reverse( $edits ) as $edit ) { $content = substr_replace( $content, $edit[2], $edit[0], $edit[1] ); } self::$cache[ $key ] = array( 'html' => $content, 'headings' => $headings, ); return self::$cache[ $key ]; } /** * Select headings and calculate structural nesting depth. * * @param array $headings Parsed records. * @param array $settings Settings. * @return array */ public static function select( $headings, $settings ) { $result = array(); $stack = array(); $skip_levels = preg_split( '/[^1-6]+/', $settings['skip_levels'], -1, PREG_SPLIT_NO_EMPTY ); $skip_classes = preg_split( '/\s+/', trim( 'tocnest-skip ' . $settings['skip_class'] ) ); $patterns = preg_split( '/\r\n|\r|\n/', $settings['skip_text'], -1, PREG_SPLIT_NO_EMPTY ); foreach ( $headings as $heading ) { if ( '' === $heading['text'] || ! in_array( $heading['level'], $settings['levels'], true ) || in_array( (string) $heading['level'], $skip_levels, true ) || array_intersect( $skip_classes, preg_split( '/\s+/', $heading['class'] ) ) ) { continue; } foreach ( $patterns as $pattern ) { if ( preg_match( '/^' . str_replace( '\*', '.*', preg_quote( trim( $pattern ), '/' ) ) . '$/iu', $heading['text'] ) ) { continue 2; } } while ( $stack && end( $stack ) >= $heading['level'] ) { array_pop( $stack ); } $stack[] = $heading['level']; if ( count( $stack ) > $settings['depth'] ) { continue; } $heading['depth'] = $settings['hierarchical'] ? count( $stack ) : 1; $result[] = $heading; } return $result; } /** * Format an integer in Roman notation. * * @param int $number Positive integer. * @return string */ public static function roman( $number ) { $output = ''; foreach ( array( 1000 => 'M', 900 => 'CM', 500 => 'D', 400 => 'CD', 100 => 'C', 90 => 'XC', 50 => 'L', 40 => 'XL', 10 => 'X', 9 => 'IX', 5 => 'V', 4 => 'IV', 1 => 'I', ) as $value => $symbol ) { while ( $number >= $value ) { $output .= $symbol; $number -= $value; } } return $output; } /** * Number one row from its hierarchy counters. * * @param array $counters Hierarchy counters. * @param int $index Flat index. * @param string $style Numbering style. * @param string $suffix Suffix enum. * @return string */ public static function number( $counters, $index, $style, $suffix ) { if ( 'none' === $style ) { return ''; } $parts = false !== strpos( $style, 'nested' ) ? $counters : array( $index ); if ( 0 === strpos( $style, 'roman' ) ) { $parts = array_map( array( __CLASS__, 'roman' ), $parts ); } return implode( '.', $parts ) . ( 'dot' === $suffix ? '.' : ( 'roundbracket' === $suffix ? ')' : '' ) ); } }