PluginProbe
Noted! / trunk
Noted! vtrunk
2.0.2 2.0.1 trunk 1.0 2.0
noted / includes / Markdown.php

Markdown.php in Noted! trunk, at includes/Markdown.php

209 lines 7.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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