| 1 |
<?php |
| 2 |
|
| 3 |
declare(strict_types=1); |
| 4 |
|
| 5 |
namespace Noted; |
| 6 |
|
| 7 |
/** |
| 8 |
* Lightweight Markdown <-> HTML converter for note bodies. |
| 9 |
* |
| 10 |
* Supports headings, bold/italic, links, and (nested) ordered and unordered |
| 11 |
* lists. Not a full Markdown spec — just enough for the note UX. |
| 12 |
*/ |
| 13 |
final class Markdown |
| 14 |
{ |
| 15 |
/** |
| 16 |
* Convert a Markdown-flavoured string to safe HTML. |
| 17 |
* |
| 18 |
* The final output is run through {@see wp_kses_post()}, so even a |
| 19 |
* regex that accidentally lets a stray tag through is contained at |
| 20 |
* the boundary. |
| 21 |
*/ |
| 22 |
public static function toHtml(string $text): string |
| 23 |
{ |
| 24 |
$text = str_replace(["\r\n", "\r"], "\n", $text); |
| 25 |
$text = trim($text); |
| 26 |
|
| 27 |
$inlinePatterns = [ |
| 28 |
'/\#{6}\s?(.*)/' => '<h6>$1</h6>', |
| 29 |
'/\#{5}\s?(.*)/' => '<h5>$1</h5>', |
| 30 |
'/\#{4}\s?(.*)/' => '<h4>$1</h4>', |
| 31 |
'/\#{3}\s?(.*)/' => '<h3>$1</h3>', |
| 32 |
'/\#{2}\s?(.*)/' => '<h2>$1</h2>', |
| 33 |
'/\#{1}\s?(.*)/' => '<h1>$1</h1>', |
| 34 |
'/\*\*(.*?)\*\*|__(.*?)__/' => '<strong>$1$2</strong>', |
| 35 |
'/\*(.*?)\*|_(.*?)_/' => '<em>$1$2</em>', |
| 36 |
'/<(https?:\/\/[^\s<]+)>/' => '<a href="$1">$1</a>', |
| 37 |
'/\[(.*?)\]\((.*?)\)/' => '<a href="$2">$1</a>', |
| 38 |
'/(^|[\s>])(https?:\/\/[^\s<]+)(?=\s|$)/' => '$1<a href="$2">$2</a>', |
| 39 |
]; |
| 40 |
|
| 41 |
$html = preg_replace(array_keys($inlinePatterns), array_values($inlinePatterns), $text); |
| 42 |
$lines = explode("\n", $html); |
| 43 |
|
| 44 |
$output = ''; |
| 45 |
$listStack = []; |
| 46 |
$currentIndent = 0; |
| 47 |
|
| 48 |
foreach ($lines as $line) { |
| 49 |
preg_match('/^(\s*)/', $line, $leading); |
| 50 |
$indent = strlen($leading[0]); |
| 51 |
$line = ltrim($line); |
| 52 |
|
| 53 |
if (preg_match('/^\d+\.\s+(.*)$/', $line, $matches)) { |
| 54 |
[$output, $listStack, $currentIndent] = self::handleListItem( |
| 55 |
'ol', |
| 56 |
$matches[1], |
| 57 |
$indent, |
| 58 |
$output, |
| 59 |
$listStack, |
| 60 |
$currentIndent, |
| 61 |
); |
| 62 |
continue; |
| 63 |
} |
| 64 |
|
| 65 |
if (preg_match('/^-\s+(.*)$/', $line, $matches)) { |
| 66 |
[$output, $listStack, $currentIndent] = self::handleListItem( |
| 67 |
'ul', |
| 68 |
$matches[1], |
| 69 |
$indent, |
| 70 |
$output, |
| 71 |
$listStack, |
| 72 |
$currentIndent, |
| 73 |
); |
| 74 |
continue; |
| 75 |
} |
| 76 |
|
| 77 |
while (! empty($listStack)) { |
| 78 |
$list = array_pop($listStack); |
| 79 |
$output .= $list['type'] === 'ul' ? "</ul>\n" : "</ol>\n"; |
| 80 |
} |
| 81 |
$currentIndent = 0; |
| 82 |
$output .= $line . "\n"; |
| 83 |
} |
| 84 |
|
| 85 |
while (! empty($listStack)) { |
| 86 |
$list = array_pop($listStack); |
| 87 |
$output .= $list['type'] === 'ul' ? "</ul>\n" : "</ol>\n"; |
| 88 |
} |
| 89 |
|
| 90 |
// Collapse runs of 3+ blank lines down to a single paragraph break |
| 91 |
// so wpautop produces clean <p> separators rather than ragged gaps. |
| 92 |
$output = preg_replace('/\n{3,}/', "\n\n", $output); |
| 93 |
return wp_kses_post(wpautop(trim($output))); |
| 94 |
} |
| 95 |
|
| 96 |
/** |
| 97 |
* Convert plugin-generated HTML back to Markdown. |
| 98 |
* |
| 99 |
* Best-effort — used to round-trip note bodies during edits. |
| 100 |
*/ |
| 101 |
public static function toMarkdown(string $html): string |
| 102 |
{ |
| 103 |
$markdown = $html; |
| 104 |
|
| 105 |
// Paragraph and break tags become explicit newlines before the |
| 106 |
// generic tag-strip step runs, otherwise paragraph separators get |
| 107 |
// swallowed and the source becomes one long run-on line. |
| 108 |
$markdown = preg_replace('/<p[^>]*>/i', '', $markdown); |
| 109 |
$markdown = preg_replace('/<\/p\s*>/i', "\n\n", $markdown); |
| 110 |
$markdown = preg_replace('/<br\s*\/?>/i', "\n", $markdown); |
| 111 |
|
| 112 |
$markdown = preg_replace('/<h6[^>]*>(.*?)<\/h6>/', "\n###### $1\n", $markdown); |
| 113 |
$markdown = preg_replace('/<h5[^>]*>(.*?)<\/h5>/', "\n##### $1\n", $markdown); |
| 114 |
$markdown = preg_replace('/<h4[^>]*>(.*?)<\/h4>/', "\n#### $1\n", $markdown); |
| 115 |
$markdown = preg_replace('/<h3[^>]*>(.*?)<\/h3>/', "\n### $1\n", $markdown); |
| 116 |
$markdown = preg_replace('/<h2[^>]*>(.*?)<\/h2>/', "\n## $1\n", $markdown); |
| 117 |
$markdown = preg_replace('/<h1[^>]*>(.*?)<\/h1>/', "\n# $1\n", $markdown); |
| 118 |
|
| 119 |
$markdown = preg_replace('/<strong>(.*?)<\/strong>/', '**$1**', $markdown); |
| 120 |
$markdown = preg_replace('/<em>(.*?)<\/em>/', '_$1_', $markdown); |
| 121 |
|
| 122 |
$markdown = preg_replace('/<a href="(.*?)">\1<\/a>/', '<$1>', $markdown); |
| 123 |
$markdown = preg_replace('/<a href="(.*?)">(.*?)<\/a>/', '[$2]($1)', $markdown); |
| 124 |
|
| 125 |
$lines = explode("\n", $markdown); |
| 126 |
$inList = false; |
| 127 |
$listType = ''; |
| 128 |
$listCount = 0; |
| 129 |
$result = []; |
| 130 |
|
| 131 |
foreach ($lines as $line) { |
| 132 |
if (trim($line) === '' && ! $inList) { |
| 133 |
$result[] = ''; |
| 134 |
continue; |
| 135 |
} |
| 136 |
if (strpos($line, '<ul>') !== false) { |
| 137 |
$inList = true; |
| 138 |
$listType = 'ul'; |
| 139 |
continue; |
| 140 |
} |
| 141 |
if (strpos($line, '<ol>') !== false) { |
| 142 |
$inList = true; |
| 143 |
$listType = 'ol'; |
| 144 |
$listCount = 0; |
| 145 |
continue; |
| 146 |
} |
| 147 |
if (strpos($line, '</ul>') !== false || strpos($line, '</ol>') !== false) { |
| 148 |
$inList = false; |
| 149 |
$listType = ''; |
| 150 |
$result[] = ''; |
| 151 |
continue; |
| 152 |
} |
| 153 |
|
| 154 |
if (preg_match('/<li>(.*?)<\/li>/', $line, $matches)) { |
| 155 |
$content = $matches[1]; |
| 156 |
if ($listType === 'ul') { |
| 157 |
$result[] = '- ' . $content; |
| 158 |
} elseif ($listType === 'ol') { |
| 159 |
$listCount++; |
| 160 |
$result[] = $listCount . '. ' . $content; |
| 161 |
} |
| 162 |
continue; |
| 163 |
} |
| 164 |
|
| 165 |
if (trim($line) !== '') { |
| 166 |
$result[] = trim($line); |
| 167 |
} |
| 168 |
} |
| 169 |
|
| 170 |
$markdown = implode("\n", $result); |
| 171 |
$markdown = wp_strip_all_tags($markdown); |
| 172 |
$markdown = preg_replace('/\n{3,}/', "\n\n", $markdown); |
| 173 |
$markdown = preg_replace('/^(#{1,3}\s.*?)$/m', "\n$1\n", $markdown); |
| 174 |
|
| 175 |
return trim($markdown); |
| 176 |
} |
| 177 |
|
| 178 |
/** |
| 179 |
* Shared open/close logic for ordered and unordered list items. |
| 180 |
* |
| 181 |
* @param 'ol'|'ul' $type |
| 182 |
* @param list<array{type: string, indent: int}> $listStack |
| 183 |
* @return array{0: string, 1: list<array{type: string, indent: int}>, 2: int} |
| 184 |
*/ |
| 185 |
private static function handleListItem( |
| 186 |
string $type, |
| 187 |
string $content, |
| 188 |
int $indent, |
| 189 |
string $output, |
| 190 |
array $listStack, |
| 191 |
int $currentIndent |
| 192 |
): array { |
| 193 |
$openTag = $type === 'ol' ? "<ol>\n" : "<ul>\n"; |
| 194 |
|
| 195 |
if (empty($listStack) || $indent > $currentIndent) { |
| 196 |
$output .= $openTag; |
| 197 |
$listStack[] = ['type' => $type, 'indent' => $indent]; |
| 198 |
} elseif ($indent < $currentIndent) { |
| 199 |
while (! empty($listStack) && end($listStack)['indent'] > $indent) { |
| 200 |
$list = array_pop($listStack); |
| 201 |
$output .= $list['type'] === 'ul' ? "</ul>\n" : "</ol>\n"; |
| 202 |
} |
| 203 |
} |
| 204 |
|
| 205 |
$output .= '<li>' . $content . "</li>\n"; |
| 206 |
return [$output, $listStack, $indent]; |
| 207 |
} |
| 208 |
} |
| 209 |
|