'php', 'javascript' => 'javascript', 'python' => 'python', 'java' => 'java', 'css' => 'css', 'html' => 'html', 'json' => 'json', 'c' => 'c', 'cpp' => 'cpp', 'c++' => 'cpp', 'go' => 'go', 'golang' => 'go', 'ruby' => 'ruby', 'sql' => 'sql', 'bash' => 'bash', 'shell' => 'bash', 'sh' => 'bash', 'markdown' => 'markdown', ]; return $names[$token] ?? 'plain'; } /** Build an tag from an inline/reference destination, escaping attributes. */ function md_link(string $text, string $url, string $title = ''): string { $href = e(trim($url)); $attr = $title !== '' ? ' title="' . e($title) . '"' : ''; return '' . $text . ''; } /** * Render inline Markdown in a single logical block of text. Code spans are * pulled out and escaped first so their contents are never mistaken for * emphasis or link syntax. $refs holds reference-link definitions. */ function md_inline(string $text, array $refs = []): string { // 1. Protect code spans (`code`, ``a`b``) before anything else touches them. $spans = []; $text = preg_replace_callback('/(`+)(.+?)\1/s', function ($m) use (&$spans) { $code = preg_replace('/\s+/', ' ', trim($m[2])); $key = "\x02SPAN" . count($spans) . "\x02"; $spans[$key] = '' . e($code) . ''; return $key; }, $text); // 2. Everything remaining is literal text — escape it. $text = e($text); // 3. Images: ![alt](src "title") $text = preg_replace_callback('/!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+"([^&]*)")?\s*\)/', function ($m) { $title = isset($m[3]) && $m[3] !== '' ? ' title="' . $m[3] . '"' : ''; return '' . $m[1] . ''; }, $text); // 4. Inline links: [text](url "title") $text = preg_replace_callback('/\[([^\]]+)\]\(\s*([^)\s]+)(?:\s+"([^&]*)")?\s*\)/', function ($m) { return md_link($m[1], $m[2], $m[3] ?? ''); }, $text); // 5. Reference links: [text][id], [text][] and shortcut [id]. $text = preg_replace_callback('/\[([^\]]+)\](?:\[([^\]]*)\])?/', function ($m) use ($refs) { $label = strtolower(trim(($m[2] ?? '') !== '' ? $m[2] : $m[1])); if (isset($refs[$label])) { return md_link($m[1], $refs[$label]['url'], $refs[$label]['title']); } return $m[0]; // Not a known reference — leave untouched. }, $text); // 6. Angle-bracket autolinks: $text = preg_replace_callback('/<(https?:\/\/[^\s&]+)>/', function ($m) { return md_link($m[1], $m[1]); }, $text); // 7. Bare URL autolinks (avoid matching inside an existing attribute). $text = preg_replace_callback('/(^|[\s(])(https?:\/\/[^\s<)]+)/', function ($m) { $url = rtrim($m[2], '.,;:!?'); $tail = substr($m[2], strlen($url)); return $m[1] . md_link($url, $url) . $tail; }, $text); // 8. Emphasis and strikethrough (bold+italic before bold before italic). $text = preg_replace('/\*\*\*(.+?)\*\*\*/s', '$1', $text); $text = preg_replace('/\*\*(.+?)\*\*/s', '$1', $text); $text = preg_replace('/\*(.+?)\*/s', '$1', $text); $text = preg_replace('/___(.+?)___/s', '$1', $text); $text = preg_replace('/__(.+?)__/s', '$1', $text); // Underscore emphasis only at word boundaries (avoids my_var_name). $text = preg_replace('/(?$1', $text); $text = preg_replace('/~~(.+?)~~/s', '$1', $text); // 9. Hard line breaks: two trailing spaces or a trailing backslash. $text = preg_replace('/(?: {2,}|\\\\)\n/', "
\n", $text); // Remaining newlines are soft breaks — render as spaces. $text = str_replace("\n", ' ', $text); // 10. Restore protected code spans. return strtr($text, $spans); } /** Split one table row into trimmed cells, honouring escaped pipes (\|). */ function md_table_cells(string $row): array { $row = trim($row); $row = preg_replace('/^\||\|$/', '', $row); // Drop optional outer pipes. $cells = preg_split('/(?' . md_inline(implode("\n", $para), $refs) . "

\n"; $para = []; } }; $n = count($lines); for ($i = 0; $i < $n; $i++) { $line = $lines[$i]; $trimmed = trim($line); // Restored fenced code block placeholder (occupies its own line). if (isset($blocks[$trimmed])) { $flush(); $out .= $blocks[$trimmed] . "\n"; continue; } // Blank line ends the current paragraph. if ($trimmed === '') { $flush(); continue; } // Setext heading: === / --- directly under paragraph text. if ($para && preg_match('/^\s*(=+|-+)\s*$/', $line, $m)) { $level = $m[1][0] === '=' ? 1 : 2; $text = md_inline(implode("\n", $para), $refs); $para = []; $id = md_heading_id($text, $used); $out .= "$text\n"; continue; } // Indented code block (4 spaces / tab), only when not continuing a paragraph. if (!$para && preg_match('/^(?: {4}|\t)/', $line)) { $flush(); $code = []; while ($i < $n && (trim($lines[$i]) === '' || preg_match('/^(?: {4}|\t)/', $lines[$i]))) { $code[] = preg_replace('/^(?: {4}|\t)/', '', $lines[$i]); $i++; } $i--; while ($code && trim(end($code)) === '') { array_pop($code); } $out .= '
' . e(implode("\n", $code)) . "
\n"; continue; } // ATX heading. if (preg_match('/^(#{1,6})\s+(.*?)\s*#*\s*$/', $line, $m)) { $flush(); $level = strlen($m[1]); $html = md_inline(trim($m[2]), $refs); $id = md_heading_id($html, $used); $out .= "$html\n"; continue; } // Horizontal rule. if (preg_match('/^\s*(-{3,}|\*{3,}|_{3,})\s*$/', $line)) { $flush(); $out .= "
\n"; continue; } // GFM table: current row followed by a delimiter row. if (strpos($line, '|') !== false && $i + 1 < $n && md_is_table_delim($lines[$i + 1])) { $flush(); $headers = md_table_cells($line); $aligns = array_map(function ($c) { $l = $c[0] === ':'; $r = substr($c, -1) === ':'; return $r ? ($l ? 'center' : 'right') : ($l ? 'left' : ''); }, md_table_cells($lines[$i + 1])); $i += 2; $align_attr = function (int $col) use ($aligns) { return !empty($aligns[$col]) ? ' style="text-align:' . $aligns[$col] . '"' : ''; }; $table = "\n\n"; foreach ($headers as $col => $cell) { $table .= '' . md_inline($cell, $refs) . ''; } $table .= "\n\n\n"; while ($i < $n && trim($lines[$i]) !== '' && strpos($lines[$i], '|') !== false) { $cells = md_table_cells($lines[$i]); $table .= ''; foreach ($headers as $col => $_) { $cell = $cells[$col] ?? ''; $table .= '' . md_inline($cell, $refs) . ''; } $table .= "\n"; $i++; } $i--; $out .= $table . "\n
\n"; continue; } // Blockquote: gather consecutive '>' lines (with lazy continuation) and recurse. if (preg_match('/^\s*>/', $line)) { $flush(); $inner = []; while ($i < $n && trim($lines[$i]) !== '') { if (preg_match('/^\s*>\s?(.*)$/', $lines[$i], $mm)) { $inner[] = $mm[1]; } else { $inner[] = $lines[$i]; // Lazy continuation line. } $i++; } $i--; $out .= "
\n" . md_blocks($inner, $blocks, $refs, $used) . "
\n"; continue; } // Lists (ordered/unordered, nested, task items). if (preg_match('/^(\s*)([-*+]|\d+[.)])(\s+)(.*)$/', $line, $m)) { $flush(); $out .= md_list($lines, $i, $blocks, $refs, strlen($m[1]), $used); continue; } // Otherwise: paragraph text (indentation trimmed). $para[] = $trimmed; } $flush(); return $out; } /** * Consume a list starting at $lines[$i] whose marker sits at $baseIndent, and * return its HTML. Advances $i (by reference) to the last consumed line. Each * item's body is parsed recursively so nested lists and multi-line items work. */ function md_list(array $lines, int &$i, array &$blocks, array $refs, int $baseIndent, array &$used): string { $n = count($lines); $marker = '/^(\s*)([-*+]|\d+[.)])(\s+)(.*)$/'; $ordered = null; $items = []; // Each item: array of its (de-indented) content lines. $loose = false; $pendingBlank = false; while ($i < $n) { $line = $lines[$i]; if (trim($line) === '') { $pendingBlank = true; $i++; continue; } $indent = strlen($line) - strlen(ltrim($line, ' ')); if (preg_match($marker, $line, $m) && strlen($m[1]) <= $baseIndent + 1) { // A new item at this list's level. if ($indent > $baseIndent + 1) { break; // Belongs to a nested list handled by recursion below. } if ($ordered === null) { $ordered = ctype_digit($m[2][0]); } if ($pendingBlank && $items) { $loose = true; } $pendingBlank = false; $content = strlen($m[1]) + strlen($m[2]) + strlen($m[3]); $items[] = [substr($line, $content)]; $i++; continue; } if ($indent > $baseIndent && $items) { // Continuation / nested content for the current item. if ($pendingBlank) { $loose = true; $items[count($items) - 1][] = ''; } $pendingBlank = false; $strip = min($indent, $baseIndent + 2); $items[count($items) - 1][] = substr($line, $strip); $i++; continue; } break; // Dedented non-list line ends the list. } $i--; // Step back to the last line that belonged to the list. $tag = $ordered ? 'ol' : 'ul'; $html = "<$tag>\n"; foreach ($items as $item) { // Task-list checkbox at the very start of the item. $task = ''; if (preg_match('/^\[([ xX])\]\s+(.*)$/', $item[0], $tm)) { $checked = strtolower($tm[1]) === 'x' ? ' checked' : ''; $task = ' '; $item[0] = $tm[2]; } $inner = md_blocks($item, $blocks, $refs, $used); if (!$loose) { // Tight list: drop the

wrapping the item's leading text, even // when a nested list or other block follows it. $inner = preg_replace('#^

(.*?)

(\n|$)#s', '$1$2', $inner); } $html .= '
  • ' . $task . rtrim($inner, "\n") . "
  • \n"; } return $html . "\n"; } function markdown_to_html(string $md): string { $md = str_replace(["\r\n", "\r"], "\n", $md); // 1. Pull out fenced code blocks (``` or ~~~) and replace with placeholders. $blocks = []; $md = preg_replace_callback('/^[ \t]*(`{3,}|~{3,})([^\n]*)\n(.*?)^[ \t]*\1[ \t]*$/ms', function ($m) use (&$blocks) { $lang = md_fence_lang(preg_replace('/[^A-Za-z0-9_+#-].*$/', '', trim($m[2]))); $code = rtrim($m[3], "\n"); $html = highlight_code($code, $lang); // Delimit with \x02 (not \x00): the placeholder sits on its own line and // is matched later via trim($line), and trim() strips null bytes (\0) by // default — which would corrupt the key and leak "BLOCKn" into the page. $key = "\x02BLOCK" . count($blocks) . "\x02"; $blocks[$key] = '
    ' . $html . '
    '; return "\n" . $key . "\n"; }, $md); // 2. Collect and strip reference-link definitions: [id]: url "title". $refs = []; $md = preg_replace_callback('/^[ ]{0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+["\'(]([^"\')]*)["\')])?\s*$/m', function ($m) use (&$refs) { $refs[strtolower(trim($m[1]))] = ['url' => $m[2], 'title' => $m[3] ?? '']; return ''; }, $md); // 3. Block-level pass. $used dedupes heading anchor ids across the document. $used = []; return md_blocks(explode("\n", $md), $blocks, $refs, $used); }