" quotes, * automatic links, @mentions and #hashtags. * * All text is escaped first; only tags produced here survive, and the * result passes through wp_kses() with a fixed allow-list. */ final class Renderer { /** * Most members linked and notified per post or comment. * * @var int */ const MAX_MENTIONS = 20; /** * Longest text accepted before rendering, in characters. * * @var int */ const MAX_INPUT = 40000; /** * Mentioned users looked up while rendering this text, by slug; false when not found. * * @var array */ private $users = array(); /** * Placeholder delimiter. Control characters are stripped from input, * so members cannot type it. * * @var string */ const MARK = "\x1A"; /** * Saved fragments that must not be processed again. * * @var string[] */ private $fragments = array(); /** * User IDs mentioned in the text. * * @var int[] */ private $mentions = array(); /** * Hashtags found in the text, lower case. * * @var string[] */ private $hashtags = array(); /** * Renders text. * * @param string $text Raw text from a member. * @return array{html: string, mentions: int[], hashtags: string[], text: string} */ public static function render( $text ) { $renderer = new self(); $text = (string) $text; // Callers reject text over their own limit; cutting very long input // first keeps rendering cheap while still failing that check. if ( strlen( $text ) > self::MAX_INPUT * 4 || mb_strlen( $text ) > self::MAX_INPUT ) { $text = mb_substr( $text, 0, self::MAX_INPUT ); } $clean = self::clean( $text ); $html = $renderer->blocks( $clean ); return array( 'html' => wp_kses( $html, self::allowed_html() ), 'mentions' => array_values( array_unique( $renderer->mentions ) ), 'hashtags' => array_values( array_unique( $renderer->hashtags ) ), 'text' => $clean, ); } /** * Normalizes raw input: valid UTF-8, Unix line endings, no control characters. * * @param string $text Raw text. * @return string */ public static function clean( $text ) { $text = wp_check_invalid_utf8( (string) $text, true ); $text = str_replace( array( "\r\n", "\r" ), "\n", $text ); $text = preg_replace( '/[\x00-\x08\x0B-\x1F\x7F]/u', '', $text ); return trim( (string) $text ); } /** * Tags and attributes allowed in rendered content. * * @return array */ public static function allowed_html() { return array( 'p' => array(), 'br' => array(), 'strong' => array(), 'em' => array(), 'code' => array(), 'pre' => array(), 'ul' => array(), 'ol' => array(), 'li' => array(), 'blockquote' => array(), 'a' => array( 'href' => true, 'class' => true, 'target' => true, 'rel' => true, 'data-sbay-user' => true, 'data-sbay-tag' => true, 'data-sbay-route' => true, ), ); } /** * Splits text into blocks and renders each one. * * @param string $text Clean text. * @return string */ private function blocks( $text ) { // Fenced code blocks first, so blank lines inside them are kept. $text = preg_replace_callback( '/^```[^\n]*\n(.*?)\n?```[ \t]*$/ms', function ( $parts ) { return "\n\n" . $this->keep( '
' . esc_html( $parts[1] ) . '
' ) . "\n\n"; }, $text ); $html = array(); foreach ( preg_split( "/\n{2,}/", $text ) as $block ) { $block = trim( $block, "\n" ); if ( '' === trim( $block ) ) { continue; } $html[] = $this->block( $block ); } return $this->restore( implode( "\n", $html ) ); } /** * Renders one block. * * @param string $block Block text. * @return string */ private function block( $block ) { if ( preg_match( '/^' . preg_quote( self::MARK, '/' ) . '\d+' . preg_quote( self::MARK, '/' ) . '$/', trim( $block ) ) ) { return trim( $block ); } $lines = explode( "\n", $block ); if ( $this->all_match( $lines, '/^\s*[-*]\s+/' ) ) { return ''; } if ( $this->all_match( $lines, '/^\s*\d{1,3}[.)]\s+/' ) ) { return '
    ' . $this->list_items( $lines, '/^\s*\d{1,3}[.)]\s+/' ) . '
'; } if ( $this->all_match( $lines, '/^\s*>\s?/' ) ) { $inner = array_map( function ( $line ) { return $this->inline( preg_replace( '/^\s*>\s?/', '', $line ) ); }, $lines ); return '

' . implode( '
', $inner ) . '

'; } return '

' . implode( '
', array_map( array( $this, 'inline' ), $lines ) ) . '

'; } /** * Whether every line matches a pattern. * * @param string[] $lines Lines. * @param string $pattern Regular expression. * @return bool */ private function all_match( array $lines, $pattern ) { foreach ( $lines as $line ) { if ( ! preg_match( $pattern, $line ) ) { return false; } } return true; } /** * Renders list items. * * @param string[] $lines Lines. * @param string $pattern Marker pattern to remove. * @return string */ private function list_items( array $lines, $pattern ) { $items = ''; foreach ( $lines as $line ) { $items .= '
  • ' . $this->inline( preg_replace( $pattern, '', $line ) ) . '
  • '; } return $items; } /** * Renders inline formatting in one line. * * @param string $line Raw line. * @return string HTML with placeholders. */ private function inline( $line ) { // Inline code. $line = preg_replace_callback( '/`([^`\n]+)`/', function ( $parts ) { return $this->keep( '' . esc_html( $parts[1] ) . '' ); }, $line ); // Links. $line = preg_replace_callback( '#\bhttps?://[^\s<>"\'`]+#i', function ( $parts ) { $url = $parts[0]; $trail = ''; while ( '' !== $url && false !== strpos( '.,;:!?)]}', substr( $url, -1 ) ) ) { $trail = substr( $url, -1 ) . $trail; $url = substr( $url, 0, -1 ); } $safe = esc_url( $url ); if ( '' === $safe ) { return $parts[0]; } $label = mb_strlen( $url ) > 60 ? mb_substr( $url, 0, 57 ) . '…' : $url; return $this->keep( '' . esc_html( $label ) . '' ) . $trail; }, $line ); $line = esc_html( $line ); // Mentions: @nicename. $line = preg_replace_callback( '/(^|[^\w@\/&])@([a-z0-9][a-z0-9_.-]{1,49})/i', function ( $parts ) { $slug = strtolower( rtrim( $parts[2], '.-' ) ); // Only the first few members mentioned are linked and notified. if ( count( array_unique( $this->mentions ) ) >= self::MAX_MENTIONS ) { return $parts[0]; } if ( ! array_key_exists( $slug, $this->users ) ) { $found = get_user_by( 'slug', $slug ); $this->users[ $slug ] = $found && ( ! is_multisite() || is_user_member_of_blog( $found->ID ) ) ? $found : false; } $user = $this->users[ $slug ]; if ( ! $user ) { return $parts[0]; } $this->mentions[] = (int) $user->ID; $rest = substr( $parts[2], strlen( $slug ) ); $link = '@' . esc_html( $user->display_name ) . ''; return $parts[1] . $this->keep( $link ) . $rest; }, $line ); // Hashtags: #word, with at least one letter. $line = preg_replace_callback( '/(^|[^\w&#\/])#([\p{L}\p{N}_]{1,50})/u', function ( $parts ) { if ( ! preg_match( '/\p{L}/u', $parts[2] ) ) { return $parts[0]; } $tag = mb_strtolower( $parts[2] ); $this->hashtags[] = $tag; $route = 'tags/' . rawurlencode( $tag ); $link = '#' . esc_html( $parts[2] ) . ''; return $parts[1] . $this->keep( $link ); }, $line ); // Bold, then italic. $line = preg_replace( '/\*\*(?=\S)(.+?)(?<=\S)\*\*/', '$1', $line ); $line = preg_replace( '/(?$1', $line ); $line = preg_replace( '/(?$1', $line ); return $line; } /** * Stores a finished fragment and returns its placeholder. * * @param string $html Finished HTML. * @return string */ private function keep( $html ) { $this->fragments[] = $html; return self::MARK . ( count( $this->fragments ) - 1 ) . self::MARK; } /** * Replaces placeholders with their fragments. * * @param string $html HTML with placeholders. * @return string */ private function restore( $html ) { $pattern = '/' . preg_quote( self::MARK, '/' ) . '(\d+)' . preg_quote( self::MARK, '/' ) . '/'; // Fragments can contain other placeholders, so repeat until none are left. for ( $pass = 0; $pass < 3; $pass++ ) { if ( ! preg_match( $pattern, $html ) ) { break; } $html = preg_replace_callback( $pattern, function ( $parts ) { return $this->fragments[ (int) $parts[1] ] ?? ''; }, $html ); } return $html; } /** * Returns the first web address in a text, outside code. * * @param string $text Clean text. * @return string Empty string when there is none. */ public static function first_url( $text ) { $text = preg_replace( '/```.*?```/s', '', (string) $text ); $text = preg_replace( '/`[^`\n]+`/', '', $text ); if ( preg_match( '#\bhttps?://[^\s<>"\'`]+#i', $text, $parts ) ) { return esc_url_raw( rtrim( $parts[0], '.,;:!?)]}' ) ); } return ''; } }