1 <?php
  2 
  3 /**
  4  * Markdown parser.
  5  *
  6  * @author gharlan
  7  *
  8  * @package redaxo\core
  9  */
 10 class rex_markdown
 11 {
 12     use rex_factory_trait;
 13 
 14     private function __construct()
 15     {
 16     }
 17 
 18     /**
 19      * @return static
 20      */
 21     public static function factory()
 22     {
 23         $class = static::getFactoryClass();
 24         return new $class();
 25     }
 26 
 27     /**
 28      * Parses markdown code.
 29      *
 30      * @param string $code Markdown code
 31      *
 32      * @return string HTML code
 33      */
 34     public function parse($code)
 35     {
 36         $parser = new ParsedownExtra();
 37         $parser->setBreaksEnabled(true);
 38 
 39         return $parser->text($code);
 40     }
 41 
 42     /**
 43      * Parses markdown code and extracts a table-of-content.
 44      *
 45      * @param string $code        Markdown code
 46      * @param int    $topLevel    Top included headline level for TOC, e.g. `1` for `<h1>`
 47      * @param int    $bottomLevel Bottom included headline level for TOC, e.g. `6` for `<h6>`
 48      *
 49      * @return array tupel of table-of-content and content
 50      */
 51     public function parseWithToc($code, $topLevel = 2, $bottomLevel = 3)
 52     {
 53         $parser = new rex_parsedown_with_toc();
 54         $parser->setBreaksEnabled(true);
 55         $parser->topLevel = $topLevel;
 56         $parser->bottomLevel = $bottomLevel;
 57 
 58         $content = $parser->text($code);
 59         $headers = $parser->headers;
 60 
 61         $previous = $topLevel - 1;
 62         $toc = '';
 63 
 64         foreach ($headers as $header) {
 65             $level = $header['level'];
 66 
 67             if ($level > $previous) {
 68                 if ($level > $previous + 1) {
 69                     $message = 'The headline structure in the given markdown document is malformed, ';
 70                     if ($previous < $topLevel) {
 71                         $message .= "it starts with a h$level instead of a h$topLevel.";
 72                     } else {
 73                         $message .= "a h$previous is followed by a h$level, but only a h".($previous + 1).' or lower is allowed.';
 74                     }
 75 
 76                     throw new rex_exception($message);
 77                 }
 78 
 79                 $toc .= "<ul>\n";
 80                 $previous = $level;
 81             } elseif ($level < $previous) {
 82                 for (; $level < $previous; --$previous) {
 83                     $toc .= "</li>\n";
 84                     $toc .= "</ul>\n";
 85                 }
 86             } else {
 87                 $toc .= "</li>\n";
 88             }
 89 
 90             $toc .= "<li>\n";
 91             $toc .= '<a href="#'.rex_escape($header['id']).'">'.rex_escape($header['text'])."</a>\n";
 92         }
 93 
 94         for (; $previous > $topLevel - 1; --$previous) {
 95             $toc .= "</li>\n";
 96             $toc .= "</ul>\n";
 97         }
 98 
 99         return [$toc, $content];
100     }
101 }
102 
103 /**
104  * @internal
105  */
106 final class rex_parsedown_with_toc extends ParsedownExtra
107 {
108     private $ids = [];
109 
110     public $topLevel = 2;
111     public $bottomLevel = 3;
112     public $headers = [];
113 
114     protected function blockHeader($line)
115     {
116         $block = parent::blockHeader($line);
117 
118         return $this->handleHeader($block);
119     }
120 
121     protected function blockSetextHeader($line, array $block = null)
122     {
123         $block = parent::blockSetextHeader($line, $block);
124 
125         return $this->handleHeader($block);
126     }
127 
128     private function handleHeader(array $block = null)
129     {
130         if (!$block) {
131             return $block;
132         }
133 
134         list($level) = sscanf($block['element']['name'], 'h%d');
135 
136         if ($level < $this->topLevel || $level > $this->bottomLevel) {
137             return $block;
138         }
139 
140         if (!isset($block['element']['attributes']['id'])) {
141             $baseId = $id = 'header-'.rex_string::normalize($block['element']['text'], '-');
142 
143             for ($i = 2; isset($this->ids[$id]); ++$i) {
144                 $id = $baseId.'-'.$i;
145             }
146 
147             $block['element']['attributes']['id'] = $id;
148         }
149 
150         $id = $block['element']['attributes']['id'];
151         $this->ids[$id] = true;
152 
153         $this->headers[] = [
154             'level' => $level,
155             'id' => $id,
156             'text' => $block['element']['text'],
157         ];
158 
159         return $block;
160     }
161 }
162