'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: 
$text = preg_replace_callback('/!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+"([^&]*)")?\s*\)/', function ($m) {
$title = isset($m[3]) && $m[3] !== '' ? ' title="' . $m[3] . '"' : '';
return '';
}, $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:
$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) . "
' . 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 .= "| ' . md_inline($cell, $refs) . ' | '; } $table .= "
|---|
| ' . md_inline($cell, $refs) . ' | '; } $table .= "
\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 .= '' . $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);
}