imageResolver = $imageResolver;
}
/**
* First level-1 heading of the source, or an empty string.
*/
public static function extractTitle( string $markdown ): string {
if ( preg_match( '/^#\s+(.+?)\s*#*\s*$/m', $markdown, $m ) ) {
return trim( preg_replace( '/[`*_]/', '', $m[1] ) ?? $m[1] );
}
return '';
}
public function render( string $markdown ): string {
$lines = preg_split( '/\r\n|\r|\n/', $markdown );
if ( false === $lines ) {
return '';
}
/** @var string[] $html */
$html = array();
/** @var string[] $paragraph */
$paragraph = array();
$count = count( $lines );
$i = 0;
$flush = function () use ( &$paragraph, &$html ): void {
if ( $paragraph !== array() ) {
$html[] = '
' . $this->inline( implode( ' ', $paragraph ) ) . '
';
$paragraph = array();
}
};
while ( $i < $count ) {
$line = $lines[ $i ];
if ( trim( $line ) === '' ) {
$flush();
++$i;
continue;
}
// Fenced code.
if ( preg_match( '/^```/', $line ) ) {
$flush();
$code = array();
++$i;
while ( $i < $count && ! preg_match( '/^```/', $lines[ $i ] ) ) {
$code[] = $lines[ $i ];
++$i;
}
++$i;
$html[] = '' . $this->escape( implode( "\n", $code ) ) . '
';
continue;
}
// Heading.
if ( preg_match( '/^(#{1,6})\s+(.*?)\s*#*\s*$/', $line, $m ) ) {
$flush();
$level = strlen( $m[1] );
$html[] = '' . $this->inline( $m[2] ) . '';
++$i;
continue;
}
// Rule.
if ( preg_match( '/^\s*(-{3,}|\*{3,})\s*$/', $line ) ) {
$flush();
$html[] = '
';
++$i;
continue;
}
// Table: header row followed by a separator row.
if ( $i + 1 < $count && strpos( $line, '|' ) !== false && $this->isTableSeparator( $lines[ $i + 1 ] ) ) {
$flush();
$header = $this->splitRow( $line );
$rows = array();
$i += 2;
while ( $i < $count && trim( $lines[ $i ] ) !== '' && strpos( $lines[ $i ], '|' ) !== false ) {
$rows[] = $this->splitRow( $lines[ $i ] );
++$i;
}
$html[] = $this->table( $header, $rows );
continue;
}
// Block quote.
if ( preg_match( '/^>\s?/', $line ) ) {
$flush();
$quote = array();
while ( $i < $count && preg_match( '/^>\s?(.*)$/', $lines[ $i ], $m ) ) {
$quote[] = $m[1];
++$i;
}
$html[] = '' . $this->inline( implode( ' ', $quote ) ) . '
';
continue;
}
// Lists (flat; an indented continuation line extends the item).
if ( preg_match( '/^\s*([-*]|\d+\.)\s+(.*)$/', $line, $m ) ) {
$flush();
$ordered = ctype_digit( rtrim( $m[1], '.' ) );
$items = array();
while ( $i < $count ) {
if ( preg_match( '/^\s*([-*]|\d+\.)\s+(.*)$/', $lines[ $i ], $im ) ) {
$items[] = $im[2];
++$i;
} elseif ( trim( $lines[ $i ] ) !== '' && preg_match( '/^\s{2,}\S/', $lines[ $i ] ) && $items !== array() ) {
$items[ count( $items ) - 1 ] .= ' ' . trim( $lines[ $i ] );
++$i;
} else {
break;
}
}
$tag = $ordered ? 'ol' : 'ul';
$html[] = '<' . $tag . '>' . implode( '', array_map( array( $this, 'inline' ), $items ) ) . '' . $tag . '>';
continue;
}
$paragraph[] = trim( $line );
++$i;
}
$flush();
return implode( "\n", $html );
}
private function escape( string $text ): string {
return htmlspecialchars( $text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' );
}
private function isTableSeparator( string $line ): bool {
return (bool) preg_match( '/^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/', $line ) && strpos( $line, '-' ) !== false;
}
/**
* @return string[]
*/
private function splitRow( string $line ): array {
$line = trim( $line );
$line = preg_replace( '/^\||\|$/', '', $line ) ?? $line;
// A pipe inside inline code stays part of the cell.
$line = preg_replace_callback(
'/`[^`]*`/',
static function ( array $m ): string {
return str_replace( '|', "\x01", $m[0] );
},
$line
) ?? $line;
$cells = array_map(
static function ( string $cell ): string {
return trim( str_replace( "\x01", '|', $cell ) );
},
explode( '|', $line )
);
return $cells;
}
/**
* @param string[] $header
* @param string[][] $rows
*/
private function table( array $header, array $rows ): string {
$out = '';
foreach ( $header as $cell ) {
$out .= '| ' . $this->inline( $cell ) . ' | ';
}
$out .= '
';
foreach ( $rows as $row ) {
$out .= '';
foreach ( $row as $cell ) {
$out .= '| ' . $this->inline( $cell ) . ' | ';
}
$out .= '
';
}
return $out . '
';
}
private function inline( string $text ): string {
$this->codeSpans = array();
// Code spans are lifted out first so their content is not formatted.
$text = preg_replace_callback(
'/`([^`]+)`/',
function ( array $m ): string {
$this->codeSpans[] = '' . $this->escape( $m[1] ) . '';
return "\x02" . ( count( $this->codeSpans ) - 1 ) . "\x03";
},
$text
) ?? $text;
$text = $this->escape( $text );
// Images before links: .
$text = preg_replace_callback(
'/!\[([^\]]*)\]\(([^)\s]+)\)/',
function ( array $m ): string {
$src = $this->resolveImage( html_entity_decode( $m[2], ENT_QUOTES, 'UTF-8' ) );
if ( '' === $src ) {
return '';
}
return '
';
},
$text
) ?? $text;
$text = preg_replace_callback(
'/\[([^\]]+)\]\(([^)\s]+)\)/',
function ( array $m ): string {
$url = html_entity_decode( $m[2], ENT_QUOTES, 'UTF-8' );
if ( ! preg_match( '#^(https://|http://|mailto:|\#)#i', $url ) ) {
return $m[1];
}
$external = preg_match( '#^https?://#i', $url ) ? ' target="_blank" rel="noopener noreferrer"' : '';
return '' . $m[1] . '';
},
$text
) ?? $text;
$text = preg_replace( '/\*\*(.+?)\*\*/s', '$1', $text ) ?? $text;
$text = preg_replace( '/(?$1', $text ) ?? $text;
return (string) preg_replace_callback(
'/\x02(\d+)\x03/',
function ( array $m ): string {
return $this->codeSpans[ (int) $m[1] ] ?? '';
},
$text
);
}
private function resolveImage( string $src ): string {
if ( null === $this->imageResolver ) {
return '';
}
return (string) call_user_func( $this->imageResolver, $src );
}
}