1 | <?php |
||
2 | |||
3 | namespace cebe\markdown; |
||
4 | |||
5 | use cebe\markdown\block\TableTrait; |
||
6 | |||
7 | // work around https://github.com/facebook/hhvm/issues/1120 |
||
8 | 1 | defined('ENT_HTML401') || define('ENT_HTML401', 0); |
|
9 | |||
10 | /** |
||
11 | * Markdown parser for the [markdown extra](http://michelf.ca/projects/php-markdown/extra/) flavor. |
||
12 | * |
||
13 | * @author Carsten Brandt <[email protected]> |
||
14 | * @license https://github.com/cebe/markdown/blob/master/LICENSE |
||
15 | * @link https://github.com/cebe/markdown#readme |
||
16 | */ |
||
17 | class MarkdownExtra extends Markdown |
||
18 | { |
||
19 | // include block element parsing using traits |
||
20 | use block\TableTrait; |
||
21 | use block\FencedCodeTrait; |
||
22 | |||
23 | // include inline element parsing using traits |
||
24 | // TODO |
||
25 | |||
26 | /** |
||
27 | * @var bool whether special attributes on code blocks should be applied on the `<pre>` element. |
||
28 | * The default behavior is to put them on the `<code>` element. |
||
29 | */ |
||
30 | public $codeAttributesOnPre = false; |
||
31 | |||
32 | /** |
||
33 | * @inheritDoc |
||
34 | */ |
||
35 | protected $escapeCharacters = [ |
||
36 | // from Markdown |
||
37 | '\\', // backslash |
||
38 | '`', // backtick |
||
39 | '*', // asterisk |
||
40 | '_', // underscore |
||
41 | '{', '}', // curly braces |
||
42 | '[', ']', // square brackets |
||
43 | '(', ')', // parentheses |
||
44 | '#', // hash mark |
||
45 | '+', // plus sign |
||
46 | '-', // minus sign (hyphen) |
||
47 | '.', // dot |
||
48 | '!', // exclamation mark |
||
49 | '<', '>', |
||
50 | // added by MarkdownExtra |
||
51 | ':', // colon |
||
52 | '|', // pipe |
||
53 | ]; |
||
54 | |||
55 | private $_specialAttributesRegex = '\{(([#\.][A-z0-9-_]+\s*)+)\}'; |
||
56 | |||
57 | // TODO allow HTML intended 3 spaces |
||
58 | |||
59 | // TODO add markdown inside HTML blocks |
||
60 | |||
61 | // TODO implement definition lists |
||
62 | |||
63 | // TODO implement footnotes |
||
64 | |||
65 | // TODO implement Abbreviations |
||
66 | |||
67 | |||
68 | // block parsing |
||
69 | |||
70 | 62 | protected function identifyReference($line) |
|
71 | { |
||
72 | 62 | return ($line[0] === ' ' || $line[0] === '[') && preg_match('/^ {0,3}\[[^\[](.*?)\]:\s*([^\s]+?)(?:\s+[\'"](.+?)[\'"])?\s*('.$this->_specialAttributesRegex.')?\s*$/', $line); |
|
73 | } |
||
74 | |||
75 | /** |
||
76 | * Consume link references |
||
77 | */ |
||
78 | 10 | protected function consumeReference($lines, $current) |
|
79 | { |
||
80 | 10 | while (isset($lines[$current]) && preg_match('/^ {0,3}\[(.+?)\]:\s*(.+?)(?:\s+[\(\'"](.+?)[\)\'"])?\s*('.$this->_specialAttributesRegex.')?\s*$/', $lines[$current], $matches)) { |
|
81 | 10 | $label = strtolower($matches[1]); |
|
82 | |||
83 | 10 | $this->references[$label] = [ |
|
84 | 10 | 'url' => $this->replaceEscape($matches[2]), |
|
85 | ]; |
||
86 | 10 | View Code Duplication | if (isset($matches[3])) { |
87 | 5 | $this->references[$label]['title'] = $matches[3]; |
|
88 | } else { |
||
89 | // title may be on the next line |
||
90 | 8 | if (isset($lines[$current + 1]) && preg_match('/^\s+[\(\'"](.+?)[\)\'"]\s*$/', $lines[$current + 1], $matches)) { |
|
91 | 1 | $this->references[$label]['title'] = $matches[1]; |
|
92 | 1 | $current++; |
|
93 | } |
||
94 | } |
||
95 | 10 | if (isset($matches[5])) { |
|
96 | 1 | $this->references[$label]['attributes'] = $matches[5]; |
|
97 | } |
||
98 | 10 | $current++; |
|
99 | } |
||
100 | 10 | return [false, --$current]; |
|
101 | } |
||
102 | |||
103 | /** |
||
104 | * Consume lines for a fenced code block |
||
105 | */ |
||
106 | 3 | View Code Duplication | protected function consumeFencedCode($lines, $current) |
107 | { |
||
108 | // consume until ``` |
||
109 | $block = [ |
||
110 | 3 | 'code', |
|
111 | ]; |
||
112 | 3 | $line = trim($lines[$current]); |
|
113 | 3 | if (($pos = strrpos($line, '`')) === false) { |
|
114 | 1 | $pos = strrpos($line, '~'); |
|
115 | } |
||
116 | 3 | $fence = substr($line, 0, $pos + 1); |
|
117 | 3 | $block['attributes'] = substr($line, $pos); |
|
118 | 3 | $content = []; |
|
119 | 3 | for($i = $current + 1, $count = count($lines); $i < $count; $i++) { |
|
120 | 3 | if (($pos = strpos($line = $lines[$i], $fence)) === false || $pos > 3) { |
|
121 | 3 | $content[] = $line; |
|
122 | } else { |
||
123 | 3 | break; |
|
124 | } |
||
125 | } |
||
126 | 3 | $block['content'] = implode("\n", $content); |
|
127 | 3 | return [$block, $i]; |
|
128 | } |
||
129 | |||
130 | 19 | protected function renderCode($block) |
|
131 | { |
||
132 | 19 | $attributes = $this->renderAttributes($block); |
|
133 | 19 | return ($this->codeAttributesOnPre ? "<pre$attributes><code>" : "<pre><code$attributes>") |
|
134 | 19 | . htmlspecialchars($block['content'] . "\n", ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8') |
|
135 | 19 | . "</code></pre>\n"; |
|
136 | } |
||
137 | |||
138 | /** |
||
139 | * Renders a headline |
||
140 | */ |
||
141 | 12 | protected function renderHeadline($block) |
|
142 | { |
||
143 | 12 | foreach($block['content'] as $i => $element) { |
|
144 | 12 | if ($element[0] === 'specialAttributes') { |
|
145 | 1 | unset($block['content'][$i]); |
|
146 | 12 | $block['attributes'] = $element[1]; |
|
147 | } |
||
148 | } |
||
149 | 12 | $tag = 'h' . $block['level']; |
|
150 | 12 | $attributes = $this->renderAttributes($block); |
|
151 | 12 | return "<$tag$attributes>" . rtrim($this->renderAbsy($block['content']), "# \t") . "</$tag>\n"; |
|
152 | } |
||
153 | |||
154 | 34 | protected function renderAttributes($block) |
|
155 | { |
||
156 | 34 | $html = []; |
|
157 | 34 | if (isset($block['attributes'])) { |
|
158 | 4 | $attributes = preg_split('/\s+/', $block['attributes'], -1, PREG_SPLIT_NO_EMPTY); |
|
159 | 4 | foreach($attributes as $attribute) { |
|
160 | 4 | if ($attribute[0] === '#') { |
|
161 | 2 | $html['id'] = substr($attribute, 1); |
|
162 | } else { |
||
163 | 4 | $html['class'][] = substr($attribute, 1); |
|
164 | } |
||
165 | } |
||
166 | } |
||
167 | 34 | $result = ''; |
|
168 | 34 | foreach($html as $attr => $value) { |
|
169 | 4 | if (is_array($value)) { |
|
170 | 4 | $value = trim(implode(' ', $value)); |
|
171 | } |
||
172 | 4 | if (!empty($value)) { |
|
173 | 4 | $result .= " $attr=\"$value\""; |
|
174 | } |
||
175 | } |
||
176 | 34 | return $result; |
|
177 | } |
||
178 | |||
179 | |||
180 | // inline parsing |
||
181 | |||
182 | |||
183 | /** |
||
184 | * @marker { |
||
185 | */ |
||
186 | 1 | View Code Duplication | protected function parseSpecialAttributes($text) |
187 | { |
||
188 | 1 | if (preg_match("~$this->_specialAttributesRegex~", $text, $matches)) { |
|
189 | 1 | return [['specialAttributes', $matches[1]], strlen($matches[0])]; |
|
190 | } |
||
191 | 1 | return [['text', '{'], 1]; |
|
192 | } |
||
193 | |||
194 | 1 | protected function renderSpecialAttributes($block) |
|
195 | { |
||
196 | 1 | return '{' . $block[1] . '}'; |
|
197 | } |
||
198 | |||
199 | 62 | protected function parseInline($text) |
|
200 | { |
||
201 | 62 | $elements = parent::parseInline($text); |
|
202 | // merge special attribute elements to links and images as they are not part of the final absy later |
||
203 | 62 | $relatedElement = null; |
|
204 | 62 | foreach($elements as $i => $element) { |
|
205 | 62 | if ($element[0] === 'link' || $element[0] === 'image') { |
|
206 | 18 | $relatedElement = $i; |
|
207 | 62 | } elseif ($element[0] === 'specialAttributes') { |
|
208 | 1 | if ($relatedElement !== null) { |
|
209 | 1 | $elements[$relatedElement]['attributes'] = $element[1]; |
|
210 | 1 | unset($elements[$i]); |
|
211 | } |
||
212 | 1 | $relatedElement = null; |
|
213 | } else { |
||
214 | 62 | $relatedElement = null; |
|
215 | } |
||
216 | } |
||
217 | 62 | return $elements; |
|
218 | } |
||
219 | |||
220 | 18 | protected function renderLink($block) |
|
221 | { |
||
222 | 18 | View Code Duplication | if (isset($block['refkey'])) { |
223 | 15 | if (($ref = $this->lookupReference($block['refkey'])) !== false) { |
|
224 | 10 | $block = array_merge($block, $ref); |
|
225 | } else { |
||
226 | 8 | if (strncmp($block['orig'], '[', 1) === 0) { |
|
227 | 8 | return '[' . $this->renderAbsy($this->parseInline(substr($block['orig'], 1))); |
|
228 | } |
||
229 | return $block['orig']; |
||
230 | } |
||
231 | } |
||
232 | 17 | $attributes = $this->renderAttributes($block); |
|
233 | 17 | return '<a href="' . htmlspecialchars($block['url'], ENT_COMPAT | ENT_HTML401, 'UTF-8') . '"' |
|
234 | 17 | . (empty($block['title']) ? '' : ' title="' . htmlspecialchars($block['title'], ENT_COMPAT | ENT_HTML401 | ENT_SUBSTITUTE, 'UTF-8') . '"') |
|
235 | 17 | . $attributes . '>' . $this->renderAbsy($block['text']) . '</a>'; |
|
236 | } |
||
237 | |||
238 | 4 | protected function renderImage($block) |
|
239 | { |
||
240 | 4 | View Code Duplication | if (isset($block['refkey'])) { |
241 | 3 | if (($ref = $this->lookupReference($block['refkey'])) !== false) { |
|
242 | 1 | $block = array_merge($block, $ref); |
|
243 | } else { |
||
244 | 2 | if (strncmp($block['orig'], '![', 2) === 0) { |
|
245 | 2 | return '![' . $this->renderAbsy($this->parseInline(substr($block['orig'], 2))); |
|
246 | } |
||
247 | return $block['orig']; |
||
248 | } |
||
249 | } |
||
250 | 3 | $attributes = $this->renderAttributes($block); |
|
251 | 3 | return '<img src="' . htmlspecialchars($block['url'], ENT_COMPAT | ENT_HTML401, 'UTF-8') . '"' |
|
252 | 3 | . ' alt="' . htmlspecialchars($block['text'], ENT_COMPAT | ENT_HTML401 | ENT_SUBSTITUTE, 'UTF-8') . '"' |
|
253 | 3 | . (empty($block['title']) ? '' : ' title="' . htmlspecialchars($block['title'], ENT_COMPAT | ENT_HTML401 | ENT_SUBSTITUTE, 'UTF-8') . '"') |
|
254 | 3 | . $attributes . ($this->html5 ? '>' : ' />'); |
|
255 | } |
||
256 | } |