PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / trunk
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification vtrunk
5.11.0 5.10.0 5.9.0 5.8.0 5.8.1 5.7.0 5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 All 44 releases
double-opt-in / src / Help / MarkdownRenderer.php

MarkdownRenderer.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification trunk, at src/Help/MarkdownRenderer.php

274 lines 7.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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: ![alt](src).
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