' . 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 ''; } return '' . implode( '
', $inner ) . '
' . implode( '
', array_map( array( $this, 'inline' ), $lines ) ) . '
' . 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 '';
}
}