| 1 |
<?php |
| 2 |
/** |
| 3 |
* Minimal Markdown to HTML renderer for the bundled help articles. |
| 4 |
* |
| 5 |
* Supports exactly what the articles use: headings, paragraphs, bullet and |
| 6 |
* numbered lists, tables, fenced code, block quotes, rules, images, links, |
| 7 |
* bold, italic and inline code. Everything else is shown as plain text. |
| 8 |
* |
| 9 |
* The source is HTML-escaped first and formatted afterwards, so raw HTML in |
| 10 |
* an article can never reach the page. Image sources go through a resolver |
| 11 |
* that decides which paths are allowed. |
| 12 |
* |
| 13 |
* @package Forge12\DoubleOptIn\Help |
| 14 |
* @since 5.11.0 |
| 15 |
*/ |
| 16 |
|
| 17 |
declare( strict_types=1 ); |
| 18 |
|
| 19 |
namespace Forge12\DoubleOptIn\Help; |
| 20 |
|
| 21 |
if ( ! defined( 'ABSPATH' ) ) { |
| 22 |
exit; |
| 23 |
} |
| 24 |
|
| 25 |
class MarkdownRenderer { |
| 26 |
|
| 27 |
/** @var callable|null fn( string $src ): string — empty string drops the image */ |
| 28 |
private $imageResolver; |
| 29 |
|
| 30 |
/** @var string[] */ |
| 31 |
private $codeSpans = array(); |
| 32 |
|
| 33 |
public function __construct( ?callable $imageResolver = null ) { |
| 34 |
$this->imageResolver = $imageResolver; |
| 35 |
} |
| 36 |
|
| 37 |
/** |
| 38 |
* First level-1 heading of the source, or an empty string. |
| 39 |
*/ |
| 40 |
public static function extractTitle( string $markdown ): string { |
| 41 |
if ( preg_match( '/^#\s+(.+?)\s*#*\s*$/m', $markdown, $m ) ) { |
| 42 |
return trim( preg_replace( '/[`*_]/', '', $m[1] ) ?? $m[1] ); |
| 43 |
} |
| 44 |
return ''; |
| 45 |
} |
| 46 |
|
| 47 |
public function render( string $markdown ): string { |
| 48 |
$lines = preg_split( '/\r\n|\r|\n/', $markdown ); |
| 49 |
if ( false === $lines ) { |
| 50 |
return ''; |
| 51 |
} |
| 52 |
|
| 53 |
/** @var string[] $html */ |
| 54 |
$html = array(); |
| 55 |
/** @var string[] $paragraph */ |
| 56 |
$paragraph = array(); |
| 57 |
$count = count( $lines ); |
| 58 |
$i = 0; |
| 59 |
|
| 60 |
$flush = function () use ( &$paragraph, &$html ): void { |
| 61 |
if ( $paragraph !== array() ) { |
| 62 |
$html[] = '<p>' . $this->inline( implode( ' ', $paragraph ) ) . '</p>'; |
| 63 |
$paragraph = array(); |
| 64 |
} |
| 65 |
}; |
| 66 |
|
| 67 |
while ( $i < $count ) { |
| 68 |
$line = $lines[ $i ]; |
| 69 |
|
| 70 |
if ( trim( $line ) === '' ) { |
| 71 |
$flush(); |
| 72 |
++$i; |
| 73 |
continue; |
| 74 |
} |
| 75 |
|
| 76 |
// Fenced code. |
| 77 |
if ( preg_match( '/^```/', $line ) ) { |
| 78 |
$flush(); |
| 79 |
$code = array(); |
| 80 |
++$i; |
| 81 |
while ( $i < $count && ! preg_match( '/^```/', $lines[ $i ] ) ) { |
| 82 |
$code[] = $lines[ $i ]; |
| 83 |
++$i; |
| 84 |
} |
| 85 |
++$i; |
| 86 |
$html[] = '<pre><code>' . $this->escape( implode( "\n", $code ) ) . '</code></pre>'; |
| 87 |
continue; |
| 88 |
} |
| 89 |
|
| 90 |
// Heading. |
| 91 |
if ( preg_match( '/^(#{1,6})\s+(.*?)\s*#*\s*$/', $line, $m ) ) { |
| 92 |
$flush(); |
| 93 |
$level = strlen( $m[1] ); |
| 94 |
$html[] = '<h' . $level . '>' . $this->inline( $m[2] ) . '</h' . $level . '>'; |
| 95 |
++$i; |
| 96 |
continue; |
| 97 |
} |
| 98 |
|
| 99 |
// Rule. |
| 100 |
if ( preg_match( '/^\s*(-{3,}|\*{3,})\s*$/', $line ) ) { |
| 101 |
$flush(); |
| 102 |
$html[] = '<hr />'; |
| 103 |
++$i; |
| 104 |
continue; |
| 105 |
} |
| 106 |
|
| 107 |
// Table: header row followed by a separator row. |
| 108 |
if ( $i + 1 < $count && strpos( $line, '|' ) !== false && $this->isTableSeparator( $lines[ $i + 1 ] ) ) { |
| 109 |
$flush(); |
| 110 |
$header = $this->splitRow( $line ); |
| 111 |
$rows = array(); |
| 112 |
$i += 2; |
| 113 |
while ( $i < $count && trim( $lines[ $i ] ) !== '' && strpos( $lines[ $i ], '|' ) !== false ) { |
| 114 |
$rows[] = $this->splitRow( $lines[ $i ] ); |
| 115 |
++$i; |
| 116 |
} |
| 117 |
$html[] = $this->table( $header, $rows ); |
| 118 |
continue; |
| 119 |
} |
| 120 |
|
| 121 |
// Block quote. |
| 122 |
if ( preg_match( '/^>\s?/', $line ) ) { |
| 123 |
$flush(); |
| 124 |
$quote = array(); |
| 125 |
while ( $i < $count && preg_match( '/^>\s?(.*)$/', $lines[ $i ], $m ) ) { |
| 126 |
$quote[] = $m[1]; |
| 127 |
++$i; |
| 128 |
} |
| 129 |
$html[] = '<blockquote><p>' . $this->inline( implode( ' ', $quote ) ) . '</p></blockquote>'; |
| 130 |
continue; |
| 131 |
} |
| 132 |
|
| 133 |
// Lists (flat; an indented continuation line extends the item). |
| 134 |
if ( preg_match( '/^\s*([-*]|\d+\.)\s+(.*)$/', $line, $m ) ) { |
| 135 |
$flush(); |
| 136 |
$ordered = ctype_digit( rtrim( $m[1], '.' ) ); |
| 137 |
$items = array(); |
| 138 |
while ( $i < $count ) { |
| 139 |
if ( preg_match( '/^\s*([-*]|\d+\.)\s+(.*)$/', $lines[ $i ], $im ) ) { |
| 140 |
$items[] = $im[2]; |
| 141 |
++$i; |
| 142 |
} elseif ( trim( $lines[ $i ] ) !== '' && preg_match( '/^\s{2,}\S/', $lines[ $i ] ) && $items !== array() ) { |
| 143 |
$items[ count( $items ) - 1 ] .= ' ' . trim( $lines[ $i ] ); |
| 144 |
++$i; |
| 145 |
} else { |
| 146 |
break; |
| 147 |
} |
| 148 |
} |
| 149 |
$tag = $ordered ? 'ol' : 'ul'; |
| 150 |
$html[] = '<' . $tag . '><li>' . implode( '</li><li>', array_map( array( $this, 'inline' ), $items ) ) . '</li></' . $tag . '>'; |
| 151 |
continue; |
| 152 |
} |
| 153 |
|
| 154 |
$paragraph[] = trim( $line ); |
| 155 |
++$i; |
| 156 |
} |
| 157 |
|
| 158 |
$flush(); |
| 159 |
|
| 160 |
return implode( "\n", $html ); |
| 161 |
} |
| 162 |
|
| 163 |
private function escape( string $text ): string { |
| 164 |
return htmlspecialchars( $text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' ); |
| 165 |
} |
| 166 |
|
| 167 |
private function isTableSeparator( string $line ): bool { |
| 168 |
return (bool) preg_match( '/^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/', $line ) && strpos( $line, '-' ) !== false; |
| 169 |
} |
| 170 |
|
| 171 |
/** |
| 172 |
* @return string[] |
| 173 |
*/ |
| 174 |
private function splitRow( string $line ): array { |
| 175 |
$line = trim( $line ); |
| 176 |
$line = preg_replace( '/^\||\|$/', '', $line ) ?? $line; |
| 177 |
// A pipe inside inline code stays part of the cell. |
| 178 |
$line = preg_replace_callback( |
| 179 |
'/`[^`]*`/', |
| 180 |
static function ( array $m ): string { |
| 181 |
return str_replace( '|', "\x01", $m[0] ); |
| 182 |
}, |
| 183 |
$line |
| 184 |
) ?? $line; |
| 185 |
$cells = array_map( |
| 186 |
static function ( string $cell ): string { |
| 187 |
return trim( str_replace( "\x01", '|', $cell ) ); |
| 188 |
}, |
| 189 |
explode( '|', $line ) |
| 190 |
); |
| 191 |
return $cells; |
| 192 |
} |
| 193 |
|
| 194 |
/** |
| 195 |
* @param string[] $header |
| 196 |
* @param string[][] $rows |
| 197 |
*/ |
| 198 |
private function table( array $header, array $rows ): string { |
| 199 |
$out = '<table><thead><tr>'; |
| 200 |
foreach ( $header as $cell ) { |
| 201 |
$out .= '<th>' . $this->inline( $cell ) . '</th>'; |
| 202 |
} |
| 203 |
$out .= '</tr></thead><tbody>'; |
| 204 |
foreach ( $rows as $row ) { |
| 205 |
$out .= '<tr>'; |
| 206 |
foreach ( $row as $cell ) { |
| 207 |
$out .= '<td>' . $this->inline( $cell ) . '</td>'; |
| 208 |
} |
| 209 |
$out .= '</tr>'; |
| 210 |
} |
| 211 |
return $out . '</tbody></table>'; |
| 212 |
} |
| 213 |
|
| 214 |
private function inline( string $text ): string { |
| 215 |
$this->codeSpans = array(); |
| 216 |
|
| 217 |
// Code spans are lifted out first so their content is not formatted. |
| 218 |
$text = preg_replace_callback( |
| 219 |
'/`([^`]+)`/', |
| 220 |
function ( array $m ): string { |
| 221 |
$this->codeSpans[] = '<code>' . $this->escape( $m[1] ) . '</code>'; |
| 222 |
return "\x02" . ( count( $this->codeSpans ) - 1 ) . "\x03"; |
| 223 |
}, |
| 224 |
$text |
| 225 |
) ?? $text; |
| 226 |
|
| 227 |
$text = $this->escape( $text ); |
| 228 |
|
| 229 |
// Images before links: . |
| 230 |
$text = preg_replace_callback( |
| 231 |
'/!\[([^\]]*)\]\(([^)\s]+)\)/', |
| 232 |
function ( array $m ): string { |
| 233 |
$src = $this->resolveImage( html_entity_decode( $m[2], ENT_QUOTES, 'UTF-8' ) ); |
| 234 |
if ( '' === $src ) { |
| 235 |
return ''; |
| 236 |
} |
| 237 |
return '<img src="' . $this->escape( $src ) . '" alt="' . $m[1] . '" loading="lazy" />'; |
| 238 |
}, |
| 239 |
$text |
| 240 |
) ?? $text; |
| 241 |
|
| 242 |
$text = preg_replace_callback( |
| 243 |
'/\[([^\]]+)\]\(([^)\s]+)\)/', |
| 244 |
function ( array $m ): string { |
| 245 |
$url = html_entity_decode( $m[2], ENT_QUOTES, 'UTF-8' ); |
| 246 |
if ( ! preg_match( '#^(https://|http://|mailto:|\#)#i', $url ) ) { |
| 247 |
return $m[1]; |
| 248 |
} |
| 249 |
$external = preg_match( '#^https?://#i', $url ) ? ' target="_blank" rel="noopener noreferrer"' : ''; |
| 250 |
return '<a href="' . $this->escape( $url ) . '"' . $external . '>' . $m[1] . '</a>'; |
| 251 |
}, |
| 252 |
$text |
| 253 |
) ?? $text; |
| 254 |
|
| 255 |
$text = preg_replace( '/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $text ) ?? $text; |
| 256 |
$text = preg_replace( '/(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])/s', '<em>$1</em>', $text ) ?? $text; |
| 257 |
|
| 258 |
return (string) preg_replace_callback( |
| 259 |
'/\x02(\d+)\x03/', |
| 260 |
function ( array $m ): string { |
| 261 |
return $this->codeSpans[ (int) $m[1] ] ?? ''; |
| 262 |
}, |
| 263 |
$text |
| 264 |
); |
| 265 |
} |
| 266 |
|
| 267 |
private function resolveImage( string $src ): string { |
| 268 |
if ( null === $this->imageResolver ) { |
| 269 |
return ''; |
| 270 |
} |
| 271 |
return (string) call_user_func( $this->imageResolver, $src ); |
| 272 |
} |
| 273 |
} |
| 274 |
|