| 1 |
<?php |
| 2 |
/** |
| 3 |
* Dependency-free GitHub-Flavored-Markdown to HTML renderer. Covers the |
| 4 |
* constructs a project README actually uses: ATX/setext headings, emphasis, |
| 5 |
* strikethrough, inline/fenced/indented code, links (inline + reference), |
| 6 |
* images, autolinks, tables, nested lists, task lists, blockquotes, rules, |
| 7 |
* hard line breaks and paragraphs. |
| 8 |
* |
| 9 |
* All literal text is HTML-escaped; fenced code is run through the site's |
| 10 |
* existing syntax highlighter. It is not a full CommonMark implementation — |
| 11 |
* it trades a handful of spec edge cases for a small, readable code base. |
| 12 |
*/ |
| 13 |
|
| 14 |
require_once __DIR__ . '/helpers.php'; |
| 15 |
require_once __DIR__ . '/../api/highlight.php'; |
| 16 |
|
| 17 |
/** Map a fenced-code language token (```js) to a highlighter language. */ |
| 18 |
function md_fence_lang(string $token): string |
| 19 |
{ |
| 20 |
$token = strtolower(trim($token)); |
| 21 |
if ($token === '') { |
| 22 |
return 'plain'; |
| 23 |
} |
| 24 |
// Reuse the extension map by treating the token as an extension. |
| 25 |
$lang = language_from_filename('x.' . $token); |
| 26 |
if ($lang !== 'plain') { |
| 27 |
return $lang; |
| 28 |
} |
| 29 |
$names = [ |
| 30 |
'php' => 'php', 'javascript' => 'javascript', 'python' => 'python', |
| 31 |
'java' => 'java', 'css' => 'css', 'html' => 'html', 'json' => 'json', |
| 32 |
'c' => 'c', 'cpp' => 'cpp', 'c++' => 'cpp', 'go' => 'go', 'golang' => 'go', |
| 33 |
'ruby' => 'ruby', 'sql' => 'sql', 'bash' => 'bash', 'shell' => 'bash', |
| 34 |
'sh' => 'bash', 'markdown' => 'markdown', |
| 35 |
]; |
| 36 |
return $names[$token] ?? 'plain'; |
| 37 |
} |
| 38 |
|
| 39 |
/** Build an <a> tag from an inline/reference destination, escaping attributes. */ |
| 40 |
function md_link(string $text, string $url, string $title = ''): string |
| 41 |
{ |
| 42 |
$href = e(trim($url)); |
| 43 |
$attr = $title !== '' ? ' title="' . e($title) . '"' : ''; |
| 44 |
return '<a href="' . $href . '"' . $attr . '>' . $text . '</a>'; |
| 45 |
} |
| 46 |
|
| 47 |
/** |
| 48 |
* Render inline Markdown in a single logical block of text. Code spans are |
| 49 |
* pulled out and escaped first so their contents are never mistaken for |
| 50 |
* emphasis or link syntax. $refs holds reference-link definitions. |
| 51 |
*/ |
| 52 |
function md_inline(string $text, array $refs = []): string |
| 53 |
{ |
| 54 |
// 1. Protect code spans (`code`, ``a`b``) before anything else touches them. |
| 55 |
$spans = []; |
| 56 |
$text = preg_replace_callback('/(`+)(.+?)\1/s', function ($m) use (&$spans) { |
| 57 |
$code = preg_replace('/\s+/', ' ', trim($m[2])); |
| 58 |
$key = "\x02SPAN" . count($spans) . "\x02"; |
| 59 |
$spans[$key] = '<code>' . e($code) . '</code>'; |
| 60 |
return $key; |
| 61 |
}, $text); |
| 62 |
|
| 63 |
// 2. Everything remaining is literal text — escape it. |
| 64 |
$text = e($text); |
| 65 |
|
| 66 |
// 3. Images:  |
| 67 |
$text = preg_replace_callback('/!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+"([^&]*)")?\s*\)/', function ($m) { |
| 68 |
$title = isset($m[3]) && $m[3] !== '' ? ' title="' . $m[3] . '"' : ''; |
| 69 |
return '<img alt="' . $m[1] . '" src="' . $m[2] . '"' . $title . '>'; |
| 70 |
}, $text); |
| 71 |
|
| 72 |
// 4. Inline links: [text](url "title") |
| 73 |
$text = preg_replace_callback('/\[([^\]]+)\]\(\s*([^)\s]+)(?:\s+"([^&]*)")?\s*\)/', function ($m) { |
| 74 |
return md_link($m[1], $m[2], $m[3] ?? ''); |
| 75 |
}, $text); |
| 76 |
|
| 77 |
// 5. Reference links: [text][id], [text][] and shortcut [id]. |
| 78 |
$text = preg_replace_callback('/\[([^\]]+)\](?:\[([^\]]*)\])?/', function ($m) use ($refs) { |
| 79 |
$label = strtolower(trim(($m[2] ?? '') !== '' ? $m[2] : $m[1])); |
| 80 |
if (isset($refs[$label])) { |
| 81 |
return md_link($m[1], $refs[$label]['url'], $refs[$label]['title']); |
| 82 |
} |
| 83 |
return $m[0]; // Not a known reference — leave untouched. |
| 84 |
}, $text); |
| 85 |
|
| 86 |
// 6. Angle-bracket autolinks: <https://…> |
| 87 |
$text = preg_replace_callback('/<(https?:\/\/[^\s&]+)>/', function ($m) { |
| 88 |
return md_link($m[1], $m[1]); |
| 89 |
}, $text); |
| 90 |
|
| 91 |
// 7. Bare URL autolinks (avoid matching inside an existing attribute). |
| 92 |
$text = preg_replace_callback('/(^|[\s(])(https?:\/\/[^\s<)]+)/', function ($m) { |
| 93 |
$url = rtrim($m[2], '.,;:!?'); |
| 94 |
$tail = substr($m[2], strlen($url)); |
| 95 |
return $m[1] . md_link($url, $url) . $tail; |
| 96 |
}, $text); |
| 97 |
|
| 98 |
// 8. Emphasis and strikethrough (bold+italic before bold before italic). |
| 99 |
$text = preg_replace('/\*\*\*(.+?)\*\*\*/s', '<strong><em>$1</em></strong>', $text); |
| 100 |
$text = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $text); |
| 101 |
$text = preg_replace('/\*(.+?)\*/s', '<em>$1</em>', $text); |
| 102 |
$text = preg_replace('/___(.+?)___/s', '<strong><em>$1</em></strong>', $text); |
| 103 |
$text = preg_replace('/__(.+?)__/s', '<strong>$1</strong>', $text); |
| 104 |
// Underscore emphasis only at word boundaries (avoids my_var_name). |
| 105 |
$text = preg_replace('/(?<![A-Za-z0-9])_([^_]+)_(?![A-Za-z0-9])/', '<em>$1</em>', $text); |
| 106 |
$text = preg_replace('/~~(.+?)~~/s', '<del>$1</del>', $text); |
| 107 |
|
| 108 |
// 9. Hard line breaks: two trailing spaces or a trailing backslash. |
| 109 |
$text = preg_replace('/(?: {2,}|\\\\)\n/', "<br>\n", $text); |
| 110 |
// Remaining newlines are soft breaks — render as spaces. |
| 111 |
$text = str_replace("\n", ' ', $text); |
| 112 |
|
| 113 |
// 10. Restore protected code spans. |
| 114 |
return strtr($text, $spans); |
| 115 |
} |
| 116 |
|
| 117 |
/** Split one table row into trimmed cells, honouring escaped pipes (\|). */ |
| 118 |
function md_table_cells(string $row): array |
| 119 |
{ |
| 120 |
$row = trim($row); |
| 121 |
$row = preg_replace('/^\||\|$/', '', $row); // Drop optional outer pipes. |
| 122 |
$cells = preg_split('/(?<!\\\\)\|/', $row); |
| 123 |
return array_map(function ($c) { |
| 124 |
return str_replace('\\|', '|', trim($c)); |
| 125 |
}, $cells); |
| 126 |
} |
| 127 |
|
| 128 |
/** True if a line is a GFM table delimiter row (| --- | :--: |). */ |
| 129 |
function md_is_table_delim(string $line): bool |
| 130 |
{ |
| 131 |
return (bool) preg_match('/^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/', $line) |
| 132 |
&& strpos($line, '-') !== false; |
| 133 |
} |
| 134 |
|
| 135 |
/** |
| 136 |
* Build a GitHub-style anchor id from a heading's rendered inline HTML so that |
| 137 |
* `#some-heading` fragment links (e.g. a table of contents) jump to it. Matches |
| 138 |
* GitHub's slug algorithm: take the text content, lowercase it, drop everything |
| 139 |
* that isn't a letter, number, space, hyphen or underscore, then turn each run |
| 140 |
* of whitespace into a hyphen. $used tracks ids already emitted in this document |
| 141 |
* so repeated headings get a numeric suffix, exactly like GitHub. |
| 142 |
*/ |
| 143 |
function md_heading_id(string $html, array &$used): string |
| 144 |
{ |
| 145 |
$text = html_entity_decode(strip_tags($html), ENT_QUOTES, 'UTF-8'); |
| 146 |
$text = strtolower(trim($text)); |
| 147 |
$text = preg_replace('/[^\p{L}\p{N}\s_-]+/u', '', $text); |
| 148 |
$slug = preg_replace('/\s/u', '-', $text); |
| 149 |
if ($slug === '' || $slug === null) { |
| 150 |
$slug = 'section'; |
| 151 |
} |
| 152 |
|
| 153 |
$base = $slug; |
| 154 |
for ($k = 1; isset($used[$slug]); $k++) { |
| 155 |
$slug = $base . '-' . $k; |
| 156 |
} |
| 157 |
$used[$slug] = true; |
| 158 |
|
| 159 |
return $slug; |
| 160 |
} |
| 161 |
|
| 162 |
/** |
| 163 |
* Parse an array of lines into HTML. Recurses for blockquote and list-item |
| 164 |
* content, which is how nested structures are produced. $blocks maps fenced |
| 165 |
* code placeholders to their pre-rendered HTML. |
| 166 |
*/ |
| 167 |
function md_blocks(array $lines, array &$blocks, array $refs, array &$used): string |
| 168 |
{ |
| 169 |
$out = ''; |
| 170 |
$para = []; |
| 171 |
|
| 172 |
$flush = function () use (&$para, &$out, $refs) { |
| 173 |
if ($para) { |
| 174 |
$out .= '<p>' . md_inline(implode("\n", $para), $refs) . "</p>\n"; |
| 175 |
$para = []; |
| 176 |
} |
| 177 |
}; |
| 178 |
|
| 179 |
$n = count($lines); |
| 180 |
for ($i = 0; $i < $n; $i++) { |
| 181 |
$line = $lines[$i]; |
| 182 |
$trimmed = trim($line); |
| 183 |
|
| 184 |
// Restored fenced code block placeholder (occupies its own line). |
| 185 |
if (isset($blocks[$trimmed])) { |
| 186 |
$flush(); |
| 187 |
$out .= $blocks[$trimmed] . "\n"; |
| 188 |
continue; |
| 189 |
} |
| 190 |
|
| 191 |
// Blank line ends the current paragraph. |
| 192 |
if ($trimmed === '') { |
| 193 |
$flush(); |
| 194 |
continue; |
| 195 |
} |
| 196 |
|
| 197 |
// Setext heading: === / --- directly under paragraph text. |
| 198 |
if ($para && preg_match('/^\s*(=+|-+)\s*$/', $line, $m)) { |
| 199 |
$level = $m[1][0] === '=' ? 1 : 2; |
| 200 |
$text = md_inline(implode("\n", $para), $refs); |
| 201 |
$para = []; |
| 202 |
$id = md_heading_id($text, $used); |
| 203 |
$out .= "<h$level id=\"$id\">$text</h$level>\n"; |
| 204 |
continue; |
| 205 |
} |
| 206 |
|
| 207 |
// Indented code block (4 spaces / tab), only when not continuing a paragraph. |
| 208 |
if (!$para && preg_match('/^(?: {4}|\t)/', $line)) { |
| 209 |
$flush(); |
| 210 |
$code = []; |
| 211 |
while ($i < $n && (trim($lines[$i]) === '' || preg_match('/^(?: {4}|\t)/', $lines[$i]))) { |
| 212 |
$code[] = preg_replace('/^(?: {4}|\t)/', '', $lines[$i]); |
| 213 |
$i++; |
| 214 |
} |
| 215 |
$i--; |
| 216 |
while ($code && trim(end($code)) === '') { |
| 217 |
array_pop($code); |
| 218 |
} |
| 219 |
$out .= '<pre class="md-code"><code>' . e(implode("\n", $code)) . "</code></pre>\n"; |
| 220 |
continue; |
| 221 |
} |
| 222 |
|
| 223 |
// ATX heading. |
| 224 |
if (preg_match('/^(#{1,6})\s+(.*?)\s*#*\s*$/', $line, $m)) { |
| 225 |
$flush(); |
| 226 |
$level = strlen($m[1]); |
| 227 |
$html = md_inline(trim($m[2]), $refs); |
| 228 |
$id = md_heading_id($html, $used); |
| 229 |
$out .= "<h$level id=\"$id\">$html</h$level>\n"; |
| 230 |
continue; |
| 231 |
} |
| 232 |
|
| 233 |
// Horizontal rule. |
| 234 |
if (preg_match('/^\s*(-{3,}|\*{3,}|_{3,})\s*$/', $line)) { |
| 235 |
$flush(); |
| 236 |
$out .= "<hr>\n"; |
| 237 |
continue; |
| 238 |
} |
| 239 |
|
| 240 |
// GFM table: current row followed by a delimiter row. |
| 241 |
if (strpos($line, '|') !== false && $i + 1 < $n && md_is_table_delim($lines[$i + 1])) { |
| 242 |
$flush(); |
| 243 |
$headers = md_table_cells($line); |
| 244 |
$aligns = array_map(function ($c) { |
| 245 |
$l = $c[0] === ':'; |
| 246 |
$r = substr($c, -1) === ':'; |
| 247 |
return $r ? ($l ? 'center' : 'right') : ($l ? 'left' : ''); |
| 248 |
}, md_table_cells($lines[$i + 1])); |
| 249 |
$i += 2; |
| 250 |
|
| 251 |
$align_attr = function (int $col) use ($aligns) { |
| 252 |
return !empty($aligns[$col]) ? ' style="text-align:' . $aligns[$col] . '"' : ''; |
| 253 |
}; |
| 254 |
|
| 255 |
$table = "<table class=\"md-table\">\n<thead>\n<tr>"; |
| 256 |
foreach ($headers as $col => $cell) { |
| 257 |
$table .= '<th' . $align_attr($col) . '>' . md_inline($cell, $refs) . '</th>'; |
| 258 |
} |
| 259 |
$table .= "</tr>\n</thead>\n<tbody>\n"; |
| 260 |
while ($i < $n && trim($lines[$i]) !== '' && strpos($lines[$i], '|') !== false) { |
| 261 |
$cells = md_table_cells($lines[$i]); |
| 262 |
$table .= '<tr>'; |
| 263 |
foreach ($headers as $col => $_) { |
| 264 |
$cell = $cells[$col] ?? ''; |
| 265 |
$table .= '<td' . $align_attr($col) . '>' . md_inline($cell, $refs) . '</td>'; |
| 266 |
} |
| 267 |
$table .= "</tr>\n"; |
| 268 |
$i++; |
| 269 |
} |
| 270 |
$i--; |
| 271 |
$out .= $table . "</tbody>\n</table>\n"; |
| 272 |
continue; |
| 273 |
} |
| 274 |
|
| 275 |
// Blockquote: gather consecutive '>' lines (with lazy continuation) and recurse. |
| 276 |
if (preg_match('/^\s*>/', $line)) { |
| 277 |
$flush(); |
| 278 |
$inner = []; |
| 279 |
while ($i < $n && trim($lines[$i]) !== '') { |
| 280 |
if (preg_match('/^\s*>\s?(.*)$/', $lines[$i], $mm)) { |
| 281 |
$inner[] = $mm[1]; |
| 282 |
} else { |
| 283 |
$inner[] = $lines[$i]; // Lazy continuation line. |
| 284 |
} |
| 285 |
$i++; |
| 286 |
} |
| 287 |
$i--; |
| 288 |
$out .= "<blockquote>\n" . md_blocks($inner, $blocks, $refs, $used) . "</blockquote>\n"; |
| 289 |
continue; |
| 290 |
} |
| 291 |
|
| 292 |
// Lists (ordered/unordered, nested, task items). |
| 293 |
if (preg_match('/^(\s*)([-*+]|\d+[.)])(\s+)(.*)$/', $line, $m)) { |
| 294 |
$flush(); |
| 295 |
$out .= md_list($lines, $i, $blocks, $refs, strlen($m[1]), $used); |
| 296 |
continue; |
| 297 |
} |
| 298 |
|
| 299 |
// Otherwise: paragraph text (indentation trimmed). |
| 300 |
$para[] = $trimmed; |
| 301 |
} |
| 302 |
$flush(); |
| 303 |
|
| 304 |
return $out; |
| 305 |
} |
| 306 |
|
| 307 |
/** |
| 308 |
* Consume a list starting at $lines[$i] whose marker sits at $baseIndent, and |
| 309 |
* return its HTML. Advances $i (by reference) to the last consumed line. Each |
| 310 |
* item's body is parsed recursively so nested lists and multi-line items work. |
| 311 |
*/ |
| 312 |
function md_list(array $lines, int &$i, array &$blocks, array $refs, int $baseIndent, array &$used): string |
| 313 |
{ |
| 314 |
$n = count($lines); |
| 315 |
$marker = '/^(\s*)([-*+]|\d+[.)])(\s+)(.*)$/'; |
| 316 |
$ordered = null; |
| 317 |
$items = []; // Each item: array of its (de-indented) content lines. |
| 318 |
$loose = false; |
| 319 |
$pendingBlank = false; |
| 320 |
|
| 321 |
while ($i < $n) { |
| 322 |
$line = $lines[$i]; |
| 323 |
|
| 324 |
if (trim($line) === '') { |
| 325 |
$pendingBlank = true; |
| 326 |
$i++; |
| 327 |
continue; |
| 328 |
} |
| 329 |
|
| 330 |
$indent = strlen($line) - strlen(ltrim($line, ' ')); |
| 331 |
|
| 332 |
if (preg_match($marker, $line, $m) && strlen($m[1]) <= $baseIndent + 1) { |
| 333 |
// A new item at this list's level. |
| 334 |
if ($indent > $baseIndent + 1) { |
| 335 |
break; // Belongs to a nested list handled by recursion below. |
| 336 |
} |
| 337 |
if ($ordered === null) { |
| 338 |
$ordered = ctype_digit($m[2][0]); |
| 339 |
} |
| 340 |
if ($pendingBlank && $items) { |
| 341 |
$loose = true; |
| 342 |
} |
| 343 |
$pendingBlank = false; |
| 344 |
$content = strlen($m[1]) + strlen($m[2]) + strlen($m[3]); |
| 345 |
$items[] = [substr($line, $content)]; |
| 346 |
$i++; |
| 347 |
continue; |
| 348 |
} |
| 349 |
|
| 350 |
if ($indent > $baseIndent && $items) { |
| 351 |
// Continuation / nested content for the current item. |
| 352 |
if ($pendingBlank) { |
| 353 |
$loose = true; |
| 354 |
$items[count($items) - 1][] = ''; |
| 355 |
} |
| 356 |
$pendingBlank = false; |
| 357 |
$strip = min($indent, $baseIndent + 2); |
| 358 |
$items[count($items) - 1][] = substr($line, $strip); |
| 359 |
$i++; |
| 360 |
continue; |
| 361 |
} |
| 362 |
|
| 363 |
break; // Dedented non-list line ends the list. |
| 364 |
} |
| 365 |
$i--; // Step back to the last line that belonged to the list. |
| 366 |
|
| 367 |
$tag = $ordered ? 'ol' : 'ul'; |
| 368 |
$html = "<$tag>\n"; |
| 369 |
foreach ($items as $item) { |
| 370 |
// Task-list checkbox at the very start of the item. |
| 371 |
$task = ''; |
| 372 |
if (preg_match('/^\[([ xX])\]\s+(.*)$/', $item[0], $tm)) { |
| 373 |
$checked = strtolower($tm[1]) === 'x' ? ' checked' : ''; |
| 374 |
$task = '<input type="checkbox" disabled' . $checked . '> '; |
| 375 |
$item[0] = $tm[2]; |
| 376 |
} |
| 377 |
|
| 378 |
$inner = md_blocks($item, $blocks, $refs, $used); |
| 379 |
if (!$loose) { |
| 380 |
// Tight list: drop the <p> wrapping the item's leading text, even |
| 381 |
// when a nested list or other block follows it. |
| 382 |
$inner = preg_replace('#^<p>(.*?)</p>(\n|$)#s', '$1$2', $inner); |
| 383 |
} |
| 384 |
$html .= '<li>' . $task . rtrim($inner, "\n") . "</li>\n"; |
| 385 |
} |
| 386 |
return $html . "</$tag>\n"; |
| 387 |
} |
| 388 |
|
| 389 |
function markdown_to_html(string $md): string |
| 390 |
{ |
| 391 |
$md = str_replace(["\r\n", "\r"], "\n", $md); |
| 392 |
|
| 393 |
// 1. Pull out fenced code blocks (``` or ~~~) and replace with placeholders. |
| 394 |
$blocks = []; |
| 395 |
$md = preg_replace_callback('/^[ \t]*(`{3,}|~{3,})([^\n]*)\n(.*?)^[ \t]*\1[ \t]*$/ms', function ($m) use (&$blocks) { |
| 396 |
$lang = md_fence_lang(preg_replace('/[^A-Za-z0-9_+#-].*$/', '', trim($m[2]))); |
| 397 |
$code = rtrim($m[3], "\n"); |
| 398 |
$html = highlight_code($code, $lang); |
| 399 |
// Delimit with \x02 (not \x00): the placeholder sits on its own line and |
| 400 |
// is matched later via trim($line), and trim() strips null bytes (\0) by |
| 401 |
// default — which would corrupt the key and leak "BLOCKn" into the page. |
| 402 |
$key = "\x02BLOCK" . count($blocks) . "\x02"; |
| 403 |
$blocks[$key] = '<pre class="md-code"><code>' . $html . '</code></pre>'; |
| 404 |
return "\n" . $key . "\n"; |
| 405 |
}, $md); |
| 406 |
|
| 407 |
// 2. Collect and strip reference-link definitions: [id]: url "title". |
| 408 |
$refs = []; |
| 409 |
$md = preg_replace_callback('/^[ ]{0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+["\'(]([^"\')]*)["\')])?\s*$/m', function ($m) use (&$refs) { |
| 410 |
$refs[strtolower(trim($m[1]))] = ['url' => $m[2], 'title' => $m[3] ?? '']; |
| 411 |
return ''; |
| 412 |
}, $md); |
| 413 |
|
| 414 |
// 3. Block-level pass. $used dedupes heading anchor ids across the document. |
| 415 |
$used = []; |
| 416 |
return md_blocks(explode("\n", $md), $blocks, $refs, $used); |
| 417 |
} |
| 418 |
|