array( __CLASS__, 'block' ) ) ); $properties = array(); foreach ( Tocnest_Settings::defaults() as $key => $value ) { $property = array( 'type' => is_bool( $value ) ? 'boolean' : ( is_int( $value ) ? 'integer' : ( is_array( $value ) ? 'array' : 'string' ) ) ); if ( is_array( $value ) ) { $property['items'] = array( 'type' => 'levels' === $key ? 'integer' : 'string' ); } $properties[ $key ] = $property; } $properties['enabled'] = array( 'type' => 'boolean' ); $properties['process_headings'] = array( 'type' => 'boolean' ); // Global post metadata also covers custom post types registered later on init. register_post_meta( '', '_tocnest_settings', array( 'type' => 'object', 'single' => true, 'default' => array(), 'sanitize_callback' => array( 'Tocnest_Settings', 'sanitize_meta' ), 'auth_callback' => array( __CLASS__, 'meta_auth' ), 'show_in_rest' => array( 'schema' => array( 'type' => 'object', 'properties' => $properties, 'additionalProperties' => false, ), ), ) ); } /** Register classic widget. */ public static function widgets() { register_widget( 'Tocnest_Widget' ); } /** Print styles requested after the document head, including widget renders. */ public static function late_styles() { Tocnest_Renderer::late_styles(); } /** * Shared parsed headings for any placement. * * @param WP_Post $post Post. * @param array $settings Settings. * @return array */ public static function headings( $post, $settings ) { return isset( self::$context[ $post->ID ] ) ? self::$context[ $post->ID ] : Tocnest_Parser::parse( $post->post_content, $post->ID, $settings ); } /** * Parse and add anchors before blocks and shortcodes render. * * @param string $content Post HTML. * @return string */ public static function prepare( $content ) { $post = get_post(); if ( ! Tocnest_Renderer::allowed( $post ) || ! is_singular() || ! in_the_loop() || ! is_main_query() ) { return $content; } $settings = Tocnest_Settings::for_post( $post->ID ); $parsed = Tocnest_Parser::parse( $content, $post->ID, $settings ); self::$context[ $post->ID ] = $parsed; $manual = self::manual( $content ); $anchors = in_array( $post->post_type, $settings['anchor_types'], true ); if ( isset( $settings['process_headings'] ) ) { $anchors = $settings['process_headings']; } $auto = self::automatic( $post, $settings ); $enough = count( Tocnest_Parser::select( $parsed['headings'], $settings ) ) >= $settings['min']; return $anchors || $manual || self::widget_active() || ( $auto && $enough ) ? $parsed['html'] : $content; } /** * Detect a classic or block widget that will need content anchors. * * @return bool */ private static function widget_active() { if ( is_active_widget( false, false, 'tocnest_widget', true ) ) { return true; } $widgets = get_option( 'widget_block', array() ); $sidebars = (array) get_option( 'sidebars_widgets', array() ); unset( $sidebars['array_version'] ); foreach ( $sidebars as $sidebar => $instances ) { if ( 'wp_inactive_widgets' === $sidebar ) { continue; } foreach ( (array) $instances as $instance ) { if ( preg_match( '/^block-(\d+)$/', $instance, $match ) && isset( $widgets[ $match[1] ]['content'] ) && has_block( 'tocnest/toc', $widgets[ $match[1] ]['content'] ) ) { return true; } } } return false; } /** * Detect manual placements to prevent duplicate automatic insertion. * * @param string $content Raw content. * @return bool */ private static function manual( $content ) { return has_shortcode( $content, 'tocnest' ) || has_shortcode( $content, 'lwptoc' ) || has_block( 'tocnest/toc', $content ) || has_block( 'luckywp/tableofcontents', $content ); } /** * Decide whether automatic insertion is enabled. * * @param WP_Post $post Post. * @param array $settings Settings. * @return bool */ private static function automatic( $post, $settings ) { $enabled = $settings['auto'] && in_array( $post->post_type, apply_filters( 'tocnest_post_types', $settings['post_types'] ), true ); if ( isset( $settings['enabled'] ) ) { $enabled = $settings['enabled']; } $previous = get_option( 'lwptoc_autoInsert', array() ); if ( Tocnest_Migration::old_active() && ! empty( $previous['enable'] ) && ! $settings['override_previous'] ) { $enabled = false; } return $enabled; } /** * Insert a TOC after the standard content filters finish. * * @param string $content Rendered content. * @return string */ public static function insert( $content ) { $post = get_post(); if ( ! Tocnest_Renderer::allowed( $post ) || ! is_singular() || ! in_the_loop() || ! is_main_query() ) { return $content; } $settings = Tocnest_Settings::for_post( $post->ID ); if ( self::manual( $post->post_content ) || ! self::automatic( $post, $settings ) ) { return $content; } $html = Tocnest_Renderer::render( array(), $post ); if ( '' === $html ) { return $content; } $position = $settings['position']; if ( 'bottom' === $position ) { return $content . $html; } if ( 'top' === $position ) { return $html . $content; } if ( 'afterfirstblock' === $position ) { $offset = self::first_block_end( $content ); return substr_replace( $content, $html, $offset, 0 ); } $parsed = self::headings( $post, $settings ); if ( $parsed['headings'] ) { $first = $parsed['headings'][0]; $offset = strpos( $content, $first['opening'] ); if ( false !== $offset ) { if ( 'afterfirstheading' === $position ) { $end = stripos( $content, '', $end ) + 1; } return substr_replace( $content, $html, $offset, 0 ); } } return $html . $content; } /** * Find the first complete top-level block, respecting quoted attributes. * * @param string $content Filtered HTML. * @return int Byte position for insertion. */ private static function first_block_end( $content ) { preg_match_all( '~|"\x27]|"[^"]*"|\x27[^\x27]*\x27)*>~i', $content, $tokens, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ); $stack = array(); $void = array( 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr' ); foreach ( $tokens as $token ) { if ( ! isset( $token[1] ) || '' === $token[1][0] ) { continue; } $name = strtolower( $token[1][0] ); if ( '' !== substr( $token[0][0], -2 ) ) { $stack[] = $name; } if ( ! $stack ) { return $token[0][1] + strlen( $token[0][0] ); } } return 0; } /** * Render the current post shortcode. * * @param array $attributes Shortcode settings. * @return string */ public static function shortcode( $attributes ) { return Tocnest_Renderer::render( is_array( $attributes ) ? $attributes : array() ); } /** * Render a dynamic block, including post context in widget areas. * * @param array $attributes Block settings. * @param string $content Saved fallback content. * @param WP_Block $block Block object. * @return string */ public static function block( $attributes, $content, $block ) { $post = isset( $block->context['postId'] ) ? get_post( $block->context['postId'] ) : get_post(); $preview = defined( 'REST_REQUEST' ) && REST_REQUEST && isset( $_GET['context'] ) && 'edit' === sanitize_key( wp_unslash( $_GET['context'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Core REST block renderer authenticates and checks edit_post. return Tocnest_Renderer::render( $attributes, $post, $preview ); } /** * Authorize per-post meta writes. * * @param bool $allowed Existing decision. * @param string $meta_key Meta name. * @param int $post_id Post ID. * @return bool */ public static function meta_auth( $allowed, $meta_key, $post_id ) { return current_user_can( 'edit_post', $post_id ); } }