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 ) ) . '
  • '; 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 .= ''; } $out .= ''; foreach ( $rows as $row ) { $out .= ''; foreach ( $row as $cell ) { $out .= ''; } $out .= ''; } return $out . '
    ' . $this->inline( $cell ) . '
    ' . $this->inline( $cell ) . '
    '; } 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: ![alt](src). $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 '' . $m[1] . ''; }, $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 ); } }