PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.1.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.1.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 1.40 1.41 All 42 releases
fluent-boards / app / Services / DescriptionMarkdownConverter.php

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

235 lines 8.0 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?
21 */
22 public static function looksLikeHtml($str): bool
23 {
24 if (!is_string($str) || $str === '') {
25 return false;
26 }
27
28 return (bool) preg_match(
29 '/<(\/?)(p|div|br|span|ul|ol|li|h[1-6]|img|a|table|pre|blockquote|strong|em|code)\b[^>]*>/i',
30 $str
31 );
32 }
33
34 /**
35 * Convert an HTML description to markdown. Plain text / already-markdown
36 * input is returned trimmed and unchanged.
37 */
38 public static function convert($html): string
39 {
40 if (!is_string($html) || trim($html) === '') {
41 return '';
42 }
43
44 if (!self::looksLikeHtml($html)) {
45 return trim($html);
46 }
47
48 $dom = new \DOMDocument();
49 $previous = libxml_use_internal_errors(true);
50
51 // Force UTF-8 and wrap in a known root so we can walk a single subtree.
52 $loaded = $dom->loadHTML(
53 '<?xml encoding="UTF-8"?><div id="__fbs_root__">' . $html . '</div>',
54 LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
55 );
56
57 libxml_clear_errors();
58 libxml_use_internal_errors($previous);
59
60 if (!$loaded) {
61 // Fall back to a tag strip rather than losing the content entirely.
62 return trim(wp_strip_all_tags($html));
63 }
64
65 $root = $dom->getElementById('__fbs_root__');
66 $markdown = $root ? self::renderChildren($root) : '';
67
68 // Normalise trailing whitespace without removing Markdown hard breaks.
69 $markdown = preg_replace_callback('/[ \t]+\n/', function ($matches) {
70 return substr($matches[0], -3) === " \n" ? " \n" : "\n";
71 }, $markdown);
72 $markdown = preg_replace("/\n{3,}/", "\n\n", $markdown);
73
74 return trim($markdown);
75 }
76
77 /**
78 * Normalize mixed legacy HTML / markdown descriptions to markdown without
79 * letting converter failures break API or MCP responses.
80 */
81 public static function normalize($description): string
82 {
83 if (!is_string($description) || trim($description) === '') {
84 return '';
85 }
86
87 if (!self::looksLikeHtml($description)) {
88 // Milkdown already serializes new descriptions as Markdown. Rewriting
89 // its line breaks here changes code blocks and other pasted content
90 // between the live editor and the next reload.
91 return $description;
92 }
93
94 try {
95 return self::convert($description);
96 } catch (\Throwable $e) {
97 return self::fallbackToPlainText($description);
98 }
99 }
100
101 private static function fallbackToPlainText($html): string
102 {
103 $html = preg_replace('/<(br|\/p|\/div|\/li|\/h[1-6]|\/blockquote|\/tr)\b[^>]*>/i', "\n", $html);
104 $html = preg_replace('/<(p|div|li|h[1-6]|blockquote|tr)\b[^>]*>/i', "\n", $html);
105 $text = function_exists('wp_strip_all_tags')
106 ? wp_strip_all_tags($html)
107 : strip_tags($html);
108
109 $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
110 $text = preg_replace("/[ \t]+\n/", "\n", $text);
111 $text = preg_replace("/\n{3,}/", "\n\n", $text);
112
113 return trim($text);
114 }
115
116 private static function renderChildren(\DOMNode $node): string
117 {
118 $out = '';
119 foreach ($node->childNodes as $child) {
120 $out .= self::renderNode($child);
121 }
122 return $out;
123 }
124
125 private static function renderNode(\DOMNode $node): string
126 {
127 if ($node->nodeType === XML_TEXT_NODE) {
128 // Collapse runs of whitespace (HTML is whitespace-insensitive).
129 return preg_replace('/\s+/', ' ', $node->nodeValue);
130 }
131
132 if ($node->nodeType !== XML_ELEMENT_NODE) {
133 return '';
134 }
135
136 $tag = strtolower($node->nodeName);
137
138 switch ($tag) {
139 case 'h1': return "\n\n# " . trim(self::renderChildren($node)) . "\n\n";
140 case 'h2': return "\n\n## " . trim(self::renderChildren($node)) . "\n\n";
141 case 'h3': return "\n\n### " . trim(self::renderChildren($node)) . "\n\n";
142 case 'h4': return "\n\n#### " . trim(self::renderChildren($node)) . "\n\n";
143 case 'h5': return "\n\n##### " . trim(self::renderChildren($node)) . "\n\n";
144 case 'h6': return "\n\n###### ". trim(self::renderChildren($node)) . "\n\n";
145
146 case 'p':
147 case 'div':
148 $inner = trim(self::renderChildren($node));
149 return $inner === '' ? '' : "\n\n" . $inner . "\n\n";
150
151 case 'br':
152 return " \n";
153
154 case 'strong':
155 case 'b':
156 return '**' . self::renderChildren($node) . '**';
157
158 case 'em':
159 case 'i':
160 return '*' . self::renderChildren($node) . '*';
161
162 case 'del':
163 case 's':
164 case 'strike':
165 return '~~' . self::renderChildren($node) . '~~';
166
167 case 'code':
168 // Inline code (code inside <pre> is handled by the 'pre' branch).
169 if ($node->parentNode && strtolower($node->parentNode->nodeName) === 'pre') {
170 return self::renderChildren($node);
171 }
172 return '`' . self::textContent($node) . '`';
173
174 case 'pre':
175 return "\n\n```\n" . rtrim(self::textContent($node)) . "\n```\n\n";
176
177 case 'blockquote':
178 $inner = trim(self::renderChildren($node));
179 $quoted = preg_replace('/^/m', '> ', $inner);
180 return "\n\n" . $quoted . "\n\n";
181
182 case 'hr':
183 return "\n\n---\n\n";
184
185 case 'a':
186 $href = $node->getAttribute('href');
187 $text = self::renderChildren($node);
188 if ($href === '') {
189 return $text;
190 }
191 return '[' . $text . '](' . $href . ')';
192
193 case 'img':
194 $src = $node->getAttribute('src');
195 $alt = $node->getAttribute('alt');
196 return $src === '' ? '' : '![' . $alt . '](' . $src . ')';
197
198 case 'ul':
199 case 'ol':
200 return "\n\n" . self::renderList($node, $tag === 'ol') . "\n";
201
202 case 'li':
203 // Handled by renderList; render inline if reached directly.
204 return self::renderChildren($node);
205
206 default:
207 return self::renderChildren($node);
208 }
209 }
210
211 private static function renderList(\DOMNode $node, bool $ordered): string
212 {
213 $out = '';
214 $index = 1;
215 foreach ($node->childNodes as $child) {
216 if ($child->nodeType !== XML_ELEMENT_NODE || strtolower($child->nodeName) !== 'li') {
217 continue;
218 }
219
220 $marker = $ordered ? ($index . '. ') : '- ';
221 $content = trim(self::renderChildren($child));
222 // Indent wrapped/nested lines under the marker.
223 $content = preg_replace("/\n/", "\n" . str_repeat(' ', strlen($marker)), $content);
224 $out .= $marker . $content . "\n";
225 $index++;
226 }
227 return $out;
228 }
229
230 private static function textContent(\DOMNode $node): string
231 {
232 return $node->textContent;
233 }
234 }
235