418 lines · 15.8 KB
Raw Download
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: ![alt](src "title")
67
    $text = preg_replace_callback('/!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+&quot;([^&]*)&quot;)?\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+&quot;([^&]*)&quot;)?\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('/&lt;(https?:\/\/[^\s&]+)&gt;/', 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