PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.1 2.2.0 2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 All 44 releases
fluent-boards / app / Services / DescriptionMarkdownConverter.php

DescriptionMarkdownConverter.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration 2.2.0, at app/Services/DescriptionMarkdownConverter.php

254 lines 8.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBoards\App\Services;
4
5 /**
6 * Dependency-free HTML → Markdown converter for the one-time description
7 * migration (see docs/plan/description-markdown-migration.md).
8 *
9 * Legacy task/board descriptions were authored with the WP editor and stored as
10 * HTML; the Milkdown editor stores markdown. This converts the common subset of
11 * tags the old editor emitted (headings, emphasis, links, images, lists, quotes,
12 * code, rules). It is intentionally self-contained rather than pulling a composer
13 * dependency into a distributed WP plugin. Anything it converts imperfectly is
14 * recoverable because the migration keeps the original HTML backup in meta.
15 */
16 class DescriptionMarkdownConverter
17 {
18 /**
19 * Cheap heuristic mirroring the frontend `looksLikeHtml` helper: does the
20 * string contain markup the legacy editor would have produced outside code?
21 */
22 public static function looksLikeHtml($str): bool
23 {
24 if (!is_string($str) || $str === '') {
25 return false;
26 }
27
28 $fence = null;
29 $prose = '';
30 foreach (explode("\n", $str) as $line) {
31 if ($fence !== null) {
32 if (preg_match('/^ {0,3}(`+|~+)[ \t]*\r?$/', $line, $closing)
33 && $closing[1][0] === $fence[0]
34 && strlen($closing[1]) >= strlen($fence)) {
35 $fence = null;
36 }
37 continue;
38 }
39 if (preg_match('/^ {0,3}(`{3,}|~{3,})([^\r\n]*)/', $line, $opening)
40 && !($opening[1][0] === '`' && strpos($opening[2], '`') !== false)) {
41 $fence = $opening[1];
42 continue;
43 }
44 $prose .= $line . "\n";
45 }
46
47 return (bool) preg_match(
48 '/<(\/?)(p|div|br|span|ul|ol|li|h[1-6]|img|a|table|pre|blockquote|strong|em|code)\b[^>]*>/i',
49 $prose
50 );
51 }
52
53 /**
54 * Convert an HTML description to markdown. Plain text / already-markdown
55 * input is returned trimmed and unchanged.
56 */
57 public static function convert($html): string
58 {
59 if (!is_string($html) || trim($html) === '') {
60 return '';
61 }
62
63 if (!self::looksLikeHtml($html)) {
64 return trim($html);
65 }
66
67 $dom = new \DOMDocument();
68 $previous = libxml_use_internal_errors(true);
69
70 // Force UTF-8 and wrap in a known root so we can walk a single subtree.
71 $loaded = $dom->loadHTML(
72 '<?xml encoding="UTF-8"?><div id="__fbs_root__">' . $html . '</div>',
73 LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
74 );
75
76 libxml_clear_errors();
77 libxml_use_internal_errors($previous);
78
79 if (!$loaded) {
80 // Fall back to a tag strip rather than losing the content entirely.
81 return trim(wp_strip_all_tags($html));
82 }
83
84 $root = $dom->getElementById('__fbs_root__');
85 $markdown = $root ? self::renderChildren($root) : '';
86
87 // Normalise trailing whitespace without removing Markdown hard breaks.
88 $markdown = preg_replace_callback('/[ \t]+\n/', function ($matches) {
89 return substr($matches[0], -3) === " \n" ? " \n" : "\n";
90 }, $markdown);
91 $markdown = preg_replace("/\n{3,}/", "\n\n", $markdown);
92
93 return trim($markdown);
94 }
95
96 /**
97 * Normalize mixed legacy HTML / markdown descriptions to markdown without
98 * letting converter failures break API or MCP responses.
99 */
100 public static function normalize($description): string
101 {
102 if (!is_string($description) || trim($description) === '') {
103 return '';
104 }
105
106 if (!self::looksLikeHtml($description)) {
107 // Milkdown already serializes new descriptions as Markdown. Rewriting
108 // its line breaks here changes code blocks and other pasted content
109 // between the live editor and the next reload.
110 return $description;
111 }
112
113 try {
114 return self::convert($description);
115 } catch (\Throwable $e) {
116 return self::fallbackToPlainText($description);
117 }
118 }
119
120 private static function fallbackToPlainText($html): string
121 {
122 $html = preg_replace('/<(br|\/p|\/div|\/li|\/h[1-6]|\/blockquote|\/tr)\b[^>]*>/i', "\n", $html);
123 $html = preg_replace('/<(p|div|li|h[1-6]|blockquote|tr)\b[^>]*>/i', "\n", $html);
124 $text = function_exists('wp_strip_all_tags')
125 ? wp_strip_all_tags($html)
126 : strip_tags($html);
127
128 $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
129 $text = preg_replace("/[ \t]+\n/", "\n", $text);
130 $text = preg_replace("/\n{3,}/", "\n\n", $text);
131
132 return trim($text);
133 }
134
135 private static function renderChildren(\DOMNode $node): string
136 {
137 $out = '';
138 foreach ($node->childNodes as $child) {
139 $out .= self::renderNode($child);
140 }
141 return $out;
142 }
143
144 private static function renderNode(\DOMNode $node): string
145 {
146 if ($node->nodeType === XML_TEXT_NODE) {
147 // Collapse runs of whitespace (HTML is whitespace-insensitive).
148 return preg_replace('/\s+/', ' ', $node->nodeValue);
149 }
150
151 if ($node->nodeType !== XML_ELEMENT_NODE) {
152 return '';
153 }
154
155 $tag = strtolower($node->nodeName);
156
157 switch ($tag) {
158 case 'h1': return "\n\n# " . trim(self::renderChildren($node)) . "\n\n";
159 case 'h2': return "\n\n## " . trim(self::renderChildren($node)) . "\n\n";
160 case 'h3': return "\n\n### " . trim(self::renderChildren($node)) . "\n\n";
161 case 'h4': return "\n\n#### " . trim(self::renderChildren($node)) . "\n\n";
162 case 'h5': return "\n\n##### " . trim(self::renderChildren($node)) . "\n\n";
163 case 'h6': return "\n\n###### ". trim(self::renderChildren($node)) . "\n\n";
164
165 case 'p':
166 case 'div':
167 $inner = trim(self::renderChildren($node));
168 return $inner === '' ? '' : "\n\n" . $inner . "\n\n";
169
170 case 'br':
171 return " \n";
172
173 case 'strong':
174 case 'b':
175 return '**' . self::renderChildren($node) . '**';
176
177 case 'em':
178 case 'i':
179 return '*' . self::renderChildren($node) . '*';
180
181 case 'del':
182 case 's':
183 case 'strike':
184 return '~~' . self::renderChildren($node) . '~~';
185
186 case 'code':
187 // Inline code (code inside <pre> is handled by the 'pre' branch).
188 if ($node->parentNode && strtolower($node->parentNode->nodeName) === 'pre') {
189 return self::renderChildren($node);
190 }
191 return '`' . self::textContent($node) . '`';
192
193 case 'pre':
194 return "\n\n```\n" . rtrim(self::textContent($node)) . "\n```\n\n";
195
196 case 'blockquote':
197 $inner = trim(self::renderChildren($node));
198 $quoted = preg_replace('/^/m', '> ', $inner);
199 return "\n\n" . $quoted . "\n\n";
200
201 case 'hr':
202 return "\n\n---\n\n";
203
204 case 'a':
205 $href = $node->getAttribute('href');
206 $text = self::renderChildren($node);
207 if ($href === '') {
208 return $text;
209 }
210 return '[' . $text . '](' . $href . ')';
211
212 case 'img':
213 $src = $node->getAttribute('src');
214 $alt = $node->getAttribute('alt');
215 return $src === '' ? '' : '![' . $alt . '](' . $src . ')';
216
217 case 'ul':
218 case 'ol':
219 return "\n\n" . self::renderList($node, $tag === 'ol') . "\n";
220
221 case 'li':
222 // Handled by renderList; render inline if reached directly.
223 return self::renderChildren($node);
224
225 default:
226 return self::renderChildren($node);
227 }
228 }
229
230 private static function renderList(\DOMNode $node, bool $ordered): string
231 {
232 $out = '';
233 $index = 1;
234 foreach ($node->childNodes as $child) {
235 if ($child->nodeType !== XML_ELEMENT_NODE || strtolower($child->nodeName) !== 'li') {
236 continue;
237 }
238
239 $marker = $ordered ? ($index . '. ') : '- ';
240 $content = trim(self::renderChildren($child));
241 // Indent wrapped/nested lines under the marker.
242 $content = preg_replace("/\n/", "\n" . str_repeat(' ', strlen($marker)), $content);
243 $out .= $marker . $content . "\n";
244 $index++;
245 }
246 return $out;
247 }
248
249 private static function textContent(\DOMNode $node): string
250 {
251 return $node->textContent;
252 }
253 }
254