markdown.php 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. <?php
  2. /**
  3. * Ultra-small Markdown renderer for the legal pages (Impressum / Datenschutz).
  4. *
  5. * Deliberately supports only a safe, basic subset. The input is always
  6. * HTML-escaped first, so no raw HTML from the editor is ever emitted; only the
  7. * handful of constructs below are turned into tags:
  8. *
  9. * # / ## / ### headings
  10. * - item unordered lists (also "* item")
  11. * **bold** *italic*
  12. * [text](url) links (http/https/mailto schemes only)
  13. * blank line new paragraph; a single newline becomes <br>
  14. */
  15. declare(strict_types=1);
  16. /** Render a basic-Markdown string to safe HTML. */
  17. function markdown_basic(string $text): string
  18. {
  19. $lines = explode("\n", str_replace("\r\n", "\n", $text));
  20. $html = '';
  21. $inList = false;
  22. $para = [];
  23. $flushPara = static function () use (&$para, &$html): void {
  24. if ($para) {
  25. $html .= '<p>' . implode('<br>', $para) . "</p>\n";
  26. $para = [];
  27. }
  28. };
  29. $closeList = static function () use (&$inList, &$html): void {
  30. if ($inList) {
  31. $html .= "</ul>\n";
  32. $inList = false;
  33. }
  34. };
  35. foreach ($lines as $line) {
  36. $trimmed = trim($line);
  37. if ($trimmed === '') {
  38. $flushPara();
  39. $closeList();
  40. continue;
  41. }
  42. if (preg_match('/^(#{1,3})\s+(.*)$/', $trimmed, $m)) {
  43. $flushPara();
  44. $closeList();
  45. $level = strlen($m[1]);
  46. $html .= "<h$level>" . markdown_inline($m[2]) . "</h$level>\n";
  47. continue;
  48. }
  49. if (preg_match('/^[-*]\s+(.*)$/', $trimmed, $m)) {
  50. $flushPara();
  51. if (!$inList) {
  52. $html .= "<ul>\n";
  53. $inList = true;
  54. }
  55. $html .= '<li>' . markdown_inline($m[1]) . "</li>\n";
  56. continue;
  57. }
  58. // Ordinary text: collect into the current paragraph; consecutive
  59. // non-blank lines are joined with <br>.
  60. $closeList();
  61. $para[] = markdown_inline($trimmed);
  62. }
  63. $flushPara();
  64. $closeList();
  65. return $html;
  66. }
  67. /** Inline formatting for one line. Escapes first, then applies the subset. */
  68. function markdown_inline(string $text): string
  69. {
  70. // Escape everything up front so no raw HTML survives from the input.
  71. $text = e($text);
  72. // Links [text](url) — only http/https/mailto schemes are turned into <a>.
  73. $text = preg_replace_callback(
  74. '/\[([^\]]+)\]\(([^)\s]+)\)/',
  75. static function (array $m): string {
  76. [$whole, $label, $url] = $m;
  77. if (!preg_match('#^(https?:|mailto:)#i', $url)) {
  78. return $whole; // leave untouched if the scheme is not allowed
  79. }
  80. return '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $label . '</a>';
  81. },
  82. $text
  83. );
  84. // Bold first (**...**), then remaining single-asterisk italics (*...*).
  85. $text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text);
  86. $text = preg_replace('/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/', '<em>$1</em>', $text);
  87. return $text;
  88. }