1 | <?php |
||||
2 | |||||
3 | /** |
||||
4 | * This file is part of Cecil. |
||||
5 | * |
||||
6 | * (c) Arnaud Ligny <[email protected]> |
||||
7 | * |
||||
8 | * For the full copyright and license information, please view the LICENSE |
||||
9 | * file that was distributed with this source code. |
||||
10 | */ |
||||
11 | |||||
12 | declare(strict_types=1); |
||||
13 | |||||
14 | namespace Cecil\Converter; |
||||
15 | |||||
16 | use Cecil\Asset; |
||||
17 | use Cecil\Asset\Image; |
||||
18 | use Cecil\Builder; |
||||
19 | use Cecil\Exception\RuntimeException; |
||||
20 | use Cecil\Url; |
||||
21 | use Cecil\Util; |
||||
22 | use Highlight\Highlighter; |
||||
23 | |||||
24 | /** |
||||
25 | * Parsedown class. |
||||
26 | * |
||||
27 | * This class extends ParsedownExtra (and ParsedownToc) and provides methods to parse Markdown content |
||||
28 | * with additional features such as inline insertions, image handling, note blocks, |
||||
29 | * and code highlighting. |
||||
30 | * |
||||
31 | * @property array $InlineTypes |
||||
32 | * @property string $inlineMarkerList |
||||
33 | * @property array $specialCharacters |
||||
34 | * @property array $BlockTypes |
||||
35 | */ |
||||
36 | class Parsedown extends \ParsedownToc |
||||
37 | { |
||||
38 | /** @var Builder */ |
||||
39 | protected $builder; |
||||
40 | |||||
41 | /** @var \Cecil\Config */ |
||||
42 | protected $config; |
||||
43 | |||||
44 | /** |
||||
45 | * Regex for attributes. |
||||
46 | * @var string |
||||
47 | */ |
||||
48 | protected $regexAttribute = '(?:[#.][-\w:\\\]+[ ]*|[-\w:\\\]+(?:=(?:["\'][^\n]*?["\']|[^\s]+)?)?[ ]*)'; |
||||
49 | |||||
50 | /** |
||||
51 | * Regex for image block. |
||||
52 | * @var string |
||||
53 | */ |
||||
54 | protected $regexImage = "~^!\[.*?\]\(.*?\)~"; |
||||
55 | |||||
56 | /** @var Highlighter */ |
||||
57 | protected $highlighter; |
||||
58 | |||||
59 | 1 | public function __construct(Builder $builder, ?array $options = null) |
|||
60 | { |
||||
61 | 1 | $this->builder = $builder; |
|||
62 | 1 | $this->config = $builder->getConfig(); |
|||
63 | |||||
64 | // "insert" line block: ++text++ -> <ins>text</ins> |
||||
65 | 1 | $this->InlineTypes['+'][] = 'Insert'; |
|||
66 | 1 | $this->inlineMarkerList = implode('', array_keys($this->InlineTypes)); |
|||
67 | 1 | $this->specialCharacters[] = '+'; |
|||
68 | |||||
69 | // Image block (to avoid paragraph) |
||||
70 | 1 | $this->BlockTypes['!'][] = 'Image'; |
|||
71 | |||||
72 | // "notes" block |
||||
73 | 1 | $this->BlockTypes[':'][] = 'Note'; |
|||
74 | |||||
75 | // code highlight |
||||
76 | 1 | $this->highlighter = new Highlighter(); |
|||
77 | |||||
78 | // options |
||||
79 | 1 | $options = array_merge(['selectors' => (array) $this->config->get('pages.body.toc')], $options ?? []); |
|||
80 | |||||
81 | 1 | parent::__construct(); |
|||
82 | 1 | parent::setOptions($options); |
|||
83 | } |
||||
84 | |||||
85 | /** |
||||
86 | * Insert inline. |
||||
87 | * e.g.: ++text++ -> <ins>text</ins>. |
||||
88 | */ |
||||
89 | 1 | protected function inlineInsert($Excerpt) |
|||
90 | { |
||||
91 | 1 | if (!isset($Excerpt['text'][1])) { |
|||
92 | return; |
||||
93 | } |
||||
94 | |||||
95 | 1 | if ($Excerpt['text'][1] === '+' && preg_match('/^\+\+(?=\S)(.+?)(?<=\S)\+\+/', $Excerpt['text'], $matches)) { |
|||
96 | 1 | return [ |
|||
97 | 1 | 'extent' => \strlen($matches[0]), |
|||
98 | 1 | 'element' => [ |
|||
99 | 1 | 'name' => 'ins', |
|||
100 | 1 | 'text' => $matches[1], |
|||
101 | 1 | 'handler' => 'line', |
|||
102 | 1 | ], |
|||
103 | 1 | ]; |
|||
104 | } |
||||
105 | } |
||||
106 | |||||
107 | /** |
||||
108 | * {@inheritdoc} |
||||
109 | */ |
||||
110 | 1 | protected function inlineLink($Excerpt) |
|||
111 | { |
||||
112 | 1 | $link = parent::inlineLink($Excerpt); // @phpstan-ignore staticMethod.notFound |
|||
113 | |||||
114 | 1 | if (!isset($link)) { |
|||
115 | return null; |
||||
116 | } |
||||
117 | |||||
118 | // Link to a page with "page:page_id" as URL |
||||
119 | 1 | if (Util\Str::startsWith($link['element']['attributes']['href'], 'page:')) { |
|||
120 | 1 | $link['element']['attributes']['href'] = new Url($this->builder, substr($link['element']['attributes']['href'], 5, \strlen($link['element']['attributes']['href']))); |
|||
121 | |||||
122 | 1 | return $link; |
|||
123 | } |
||||
124 | |||||
125 | // External link |
||||
126 | 1 | $link = $this->handleExternalLink($link); |
|||
127 | |||||
128 | /* |
||||
129 | * Embed link? |
||||
130 | */ |
||||
131 | 1 | $embed = $this->config->isEnabled('pages.body.links.embed'); |
|||
132 | 1 | if (isset($link['element']['attributes']['embed'])) { |
|||
133 | 1 | $embed = true; |
|||
134 | 1 | if ($link['element']['attributes']['embed'] == 'false') { |
|||
135 | 1 | $embed = false; |
|||
136 | } |
||||
137 | 1 | unset($link['element']['attributes']['embed']); |
|||
138 | } |
||||
139 | 1 | $extension = pathinfo($link['element']['attributes']['href'], PATHINFO_EXTENSION); |
|||
140 | // video? |
||||
141 | 1 | if (\in_array($extension, $this->config->get('pages.body.links.embed.video') ?? [])) { |
|||
142 | 1 | if (!$embed) { |
|||
143 | 1 | $link['element']['attributes']['href'] = new Url($this->builder, $link['element']['attributes']['href']); |
|||
144 | |||||
145 | 1 | return $link; |
|||
146 | } |
||||
147 | 1 | $video = $this->createMediaFromLink($link, 'video'); |
|||
148 | 1 | if ($this->config->isEnabled('pages.body.images.caption')) { |
|||
149 | 1 | return $this->createFigure($video); |
|||
150 | } |
||||
151 | |||||
152 | return $video; |
||||
153 | } |
||||
154 | // audio? |
||||
155 | 1 | if (\in_array($extension, $this->config->get('pages.body.links.embed.audio') ?? [])) { |
|||
156 | 1 | if (!$embed) { |
|||
157 | 1 | $link['element']['attributes']['href'] = new Url($this->builder, $link['element']['attributes']['href']); |
|||
158 | |||||
159 | 1 | return $link; |
|||
160 | } |
||||
161 | 1 | $audio = $this->createMediaFromLink($link, 'audio'); |
|||
162 | 1 | if ($this->config->isEnabled('pages.body.images.caption')) { |
|||
163 | 1 | return $this->createFigure($audio); |
|||
164 | } |
||||
165 | |||||
166 | return $audio; |
||||
167 | } |
||||
168 | 1 | if (!$embed) { |
|||
169 | 1 | return $link; |
|||
170 | } |
||||
171 | // Youtube link? |
||||
172 | // https://regex101.com/r/gznM1j/1 |
||||
173 | 1 | $pattern = '(?:https?:\/\/)?(?:www\.)?youtu(?:\.be\/|be.com\/\S*(?:watch|embed)(?:(?:(?=\/[-a-zA-Z0-9_]{11,}(?!\S))\/)|(?:\S*v=|v\/)))([-a-zA-Z0-9_]{11,})'; |
|||
174 | 1 | if (preg_match('/' . $pattern . '/is', (string) $link['element']['attributes']['href'], $matches)) { |
|||
175 | 1 | return $this->createEmbeddedVideoFromLink($link, 'https://www.youtube.com/embed/', $matches[1]); |
|||
176 | } |
||||
177 | // Vimeo link? |
||||
178 | // https://regex101.com/r/wCEFhd/1 |
||||
179 | 1 | $pattern = 'https:\/\/vimeo\.com\/([0-9]+)'; |
|||
180 | 1 | if (preg_match('/' . $pattern . '/is', (string) $link['element']['attributes']['href'], $matches)) { |
|||
181 | return $this->createEmbeddedVideoFromLink($link, 'https://player.vimeo.com/video/', $matches[1]); |
||||
182 | } |
||||
183 | // GitHub Gist link? |
||||
184 | // https://regex101.com/r/KWVMYI/1 |
||||
185 | 1 | $pattern = 'https:\/\/gist\.github\.com\/[-a-zA-Z0-9_]+\/[-a-zA-Z0-9_]+'; |
|||
186 | 1 | if (preg_match('/' . $pattern . '/is', (string) $link['element']['attributes']['href'], $matches)) { |
|||
187 | 1 | $gist = [ |
|||
188 | 1 | 'extent' => $link['extent'], |
|||
189 | 1 | 'element' => [ |
|||
190 | 1 | 'name' => 'script', |
|||
191 | 1 | 'text' => $link['element']['text'], |
|||
192 | 1 | 'attributes' => [ |
|||
193 | 1 | 'src' => $matches[0] . '.js', |
|||
194 | 1 | 'title' => $link['element']['attributes']['title'], |
|||
195 | 1 | ], |
|||
196 | 1 | ], |
|||
197 | 1 | ]; |
|||
198 | 1 | if ($this->config->isEnabled('pages.body.images.caption')) { |
|||
199 | 1 | return $this->createFigure($gist); |
|||
200 | } |
||||
201 | |||||
202 | return $gist; |
||||
203 | } |
||||
204 | |||||
205 | 1 | return $link; |
|||
206 | } |
||||
207 | |||||
208 | /** |
||||
209 | * {@inheritdoc} |
||||
210 | */ |
||||
211 | 1 | protected function inlineUrl($Excerpt) |
|||
212 | { |
||||
213 | 1 | $link = parent::inlineUrl($Excerpt); // @phpstan-ignore staticMethod.notFound |
|||
214 | |||||
215 | 1 | if (!isset($link)) { |
|||
216 | 1 | return; |
|||
217 | } |
||||
218 | |||||
219 | // External link |
||||
220 | return $this->handleExternalLink($link); |
||||
221 | } |
||||
222 | |||||
223 | /** |
||||
224 | * {@inheritdoc} |
||||
225 | */ |
||||
226 | protected function inlineUrlTag($Excerpt) |
||||
227 | { |
||||
228 | $link = parent::inlineUrlTag($Excerpt); // @phpstan-ignore staticMethod.notFound |
||||
229 | |||||
230 | if (!isset($link)) { |
||||
231 | return; |
||||
232 | } |
||||
233 | |||||
234 | // External link |
||||
235 | return $this->handleExternalLink($link); |
||||
236 | } |
||||
237 | |||||
238 | /** |
||||
239 | * {@inheritdoc} |
||||
240 | */ |
||||
241 | 1 | protected function inlineImage($Excerpt) |
|||
242 | { |
||||
243 | 1 | $InlineImage = parent::inlineImage($Excerpt); // @phpstan-ignore staticMethod.notFound |
|||
244 | 1 | if (!isset($InlineImage)) { |
|||
245 | return null; |
||||
246 | } |
||||
247 | |||||
248 | // normalize path |
||||
249 | 1 | $InlineImage['element']['attributes']['src'] = $this->normalizePath($InlineImage['element']['attributes']['src']); |
|||
250 | |||||
251 | // should be lazy loaded? |
||||
252 | 1 | if ($this->config->isEnabled('pages.body.images.lazy') && !isset($InlineImage['element']['attributes']['loading'])) { |
|||
253 | 1 | $InlineImage['element']['attributes']['loading'] = 'lazy'; |
|||
254 | } |
||||
255 | // should be decoding async? |
||||
256 | 1 | if ($this->config->isEnabled('pages.body.images.decoding') && !isset($InlineImage['element']['attributes']['decoding'])) { |
|||
257 | 1 | $InlineImage['element']['attributes']['decoding'] = 'async'; |
|||
258 | } |
||||
259 | // add default class? |
||||
260 | 1 | if ((string) $this->config->get('pages.body.images.class')) { |
|||
261 | 1 | if (!\array_key_exists('class', $InlineImage['element']['attributes'])) { |
|||
262 | 1 | $InlineImage['element']['attributes']['class'] = ''; |
|||
263 | } |
||||
264 | 1 | $InlineImage['element']['attributes']['class'] .= ' ' . (string) $this->config->get('pages.body.images.class'); |
|||
265 | 1 | $InlineImage['element']['attributes']['class'] = trim($InlineImage['element']['attributes']['class']); |
|||
266 | } |
||||
267 | |||||
268 | // disable remote image handling? |
||||
269 | 1 | if (Util\File::isRemote($InlineImage['element']['attributes']['src']) && !$this->config->isEnabled('pages.body.images.remote')) { |
|||
270 | return $InlineImage; |
||||
271 | } |
||||
272 | |||||
273 | // create asset |
||||
274 | 1 | $assetOptions = ['leading_slash' => false]; |
|||
275 | 1 | if ($this->config->isEnabled('pages.body.images.remote.fallback')) { |
|||
276 | 1 | $assetOptions = ['leading_slash' => true]; |
|||
277 | 1 | $assetOptions += ['fallback' => (string) $this->config->get('pages.body.images.remote.fallback')]; |
|||
278 | } |
||||
279 | 1 | $asset = new Asset($this->builder, $InlineImage['element']['attributes']['src'], $assetOptions); |
|||
280 | 1 | $InlineImage['element']['attributes']['src'] = new Url($this->builder, $asset); |
|||
281 | 1 | $width = $asset['width']; |
|||
282 | |||||
283 | /* |
||||
284 | * Should be resized? |
||||
285 | */ |
||||
286 | 1 | $shouldResize = false; |
|||
287 | 1 | $assetResized = null; |
|||
288 | // pages.body.images.resize |
||||
289 | if ( |
||||
290 | 1 | \is_int($this->config->get('pages.body.images.resize')) |
|||
291 | 1 | && $this->config->get('pages.body.images.resize') > 0 |
|||
292 | 1 | && $width > $this->config->get('pages.body.images.resize') |
|||
293 | ) { |
||||
294 | $shouldResize = true; |
||||
295 | $width = $this->config->get('pages.body.images.resize'); |
||||
296 | } |
||||
297 | // width attribute |
||||
298 | if ( |
||||
299 | 1 | isset($InlineImage['element']['attributes']['width']) |
|||
300 | 1 | && $width > (int) $InlineImage['element']['attributes']['width'] |
|||
301 | ) { |
||||
302 | 1 | $shouldResize = true; |
|||
303 | 1 | $width = (int) $InlineImage['element']['attributes']['width']; |
|||
304 | } |
||||
305 | // responsive images |
||||
306 | if ( |
||||
307 | 1 | $this->config->isEnabled('pages.body.images.responsive') |
|||
308 | 1 | && !empty($this->config->getAssetsImagesWidths()) |
|||
309 | 1 | && $width > max($this->config->getAssetsImagesWidths()) |
|||
310 | ) { |
||||
311 | $shouldResize = true; |
||||
312 | $width = max($this->config->getAssetsImagesWidths()); |
||||
313 | } |
||||
314 | 1 | if ($shouldResize) { |
|||
315 | try { |
||||
316 | 1 | $assetResized = $asset->resize($width); |
|||
317 | } catch (\Exception $e) { |
||||
318 | $this->builder->getLogger()->debug($e->getMessage()); |
||||
319 | |||||
320 | return $InlineImage; |
||||
321 | } |
||||
322 | } |
||||
323 | |||||
324 | // set width |
||||
325 | 1 | $InlineImage['element']['attributes']['width'] = $width; |
|||
326 | // set height |
||||
327 | 1 | $InlineImage['element']['attributes']['height'] = $assetResized['height'] ?? $asset['height']; |
|||
328 | |||||
329 | // placeholder |
||||
330 | if ( |
||||
331 | 1 | (!empty($this->config->get('pages.body.images.placeholder')) || isset($InlineImage['element']['attributes']['placeholder'])) |
|||
332 | 1 | && \in_array($assetResized['subtype'] ?? $asset['subtype'], ['image/jpeg', 'image/png', 'image/gif']) |
|||
333 | ) { |
||||
334 | 1 | if (!\array_key_exists('placeholder', $InlineImage['element']['attributes'])) { |
|||
335 | 1 | $InlineImage['element']['attributes']['placeholder'] = (string) $this->config->get('pages.body.images.placeholder'); |
|||
336 | } |
||||
337 | 1 | if (!\array_key_exists('style', $InlineImage['element']['attributes'])) { |
|||
338 | 1 | $InlineImage['element']['attributes']['style'] = ''; |
|||
339 | } |
||||
340 | 1 | $InlineImage['element']['attributes']['style'] = trim($InlineImage['element']['attributes']['style'], ';'); |
|||
341 | 1 | switch ($InlineImage['element']['attributes']['placeholder']) { |
|||
342 | 1 | case 'color': |
|||
343 | 1 | $InlineImage['element']['attributes']['style'] .= \sprintf(';max-width:100%%;height:auto;background-color:%s;', Image::getDominantColor($assetResized ?? $asset)); |
|||
344 | 1 | break; |
|||
345 | 1 | case 'lqip': |
|||
346 | // aborts if animated GIF for performance reasons |
||||
347 | 1 | if (Image::isAnimatedGif($assetResized ?? $asset)) { |
|||
348 | break; |
||||
349 | } |
||||
350 | 1 | $InlineImage['element']['attributes']['style'] .= \sprintf(';max-width:100%%;height:auto;background-image:url(%s);background-repeat:no-repeat;background-position:center;background-size:cover;', Image::getLqip($asset)); |
|||
351 | 1 | break; |
|||
352 | } |
||||
353 | 1 | unset($InlineImage['element']['attributes']['placeholder']); |
|||
354 | 1 | $InlineImage['element']['attributes']['style'] = trim($InlineImage['element']['attributes']['style']); |
|||
355 | } |
||||
356 | |||||
357 | /* |
||||
358 | * Should be responsive? |
||||
359 | */ |
||||
360 | 1 | $sizes = ''; |
|||
361 | 1 | if ($this->config->isEnabled('pages.body.images.responsive')) { |
|||
362 | try { |
||||
363 | if ( |
||||
364 | 1 | $srcset = Image::buildHtmlSrcset( |
|||
365 | 1 | $assetResized ?? $asset, |
|||
366 | 1 | $this->config->getAssetsImagesWidths() |
|||
367 | 1 | ) |
|||
368 | ) { |
||||
369 | 1 | $InlineImage['element']['attributes']['srcset'] = $srcset; |
|||
370 | 1 | $sizes = Image::getHtmlSizes($InlineImage['element']['attributes']['class'] ?? '', (array) $this->config->getAssetsImagesSizes()); |
|||
371 | 1 | $InlineImage['element']['attributes']['sizes'] = $sizes; |
|||
372 | } |
||||
373 | 1 | } catch (\Exception $e) { |
|||
374 | 1 | $this->builder->getLogger()->debug($e->getMessage()); |
|||
375 | } |
||||
376 | } |
||||
377 | |||||
378 | /* |
||||
379 | <!-- if title: a <figure> is required to put in it a <figcaption> --> |
||||
380 | <figure> |
||||
381 | <!-- if formats: a <picture> is required for each <source> --> |
||||
382 | <picture> |
||||
383 | <source type="image/avif" |
||||
384 | srcset="..." |
||||
385 | sizes="..." |
||||
386 | > |
||||
387 | <source type="image/webp" |
||||
388 | srcset="..." |
||||
389 | sizes="..." |
||||
390 | > |
||||
391 | <img src="..." |
||||
392 | srcset="..." |
||||
393 | sizes="..." |
||||
394 | > |
||||
395 | </picture> |
||||
396 | <figcaption><!-- title --></figcaption> |
||||
397 | </figure> |
||||
398 | */ |
||||
399 | |||||
400 | 1 | $image = $InlineImage; |
|||
401 | |||||
402 | // converts image to formats and put them in picture > source |
||||
403 | if ( |
||||
404 | 1 | \count($formats = ((array) $this->config->get('pages.body.images.formats'))) > 0 |
|||
405 | 1 | && \in_array($assetResized['subtype'] ?? $asset['subtype'], ['image/jpeg', 'image/png', 'image/gif']) |
|||
406 | ) { |
||||
407 | try { |
||||
408 | // InlineImage src must be an Asset instance |
||||
409 | 1 | if (!($assetResized ?? $asset) instanceof Asset) { |
|||
0 ignored issues
–
show
introduced
by
![]() |
|||||
410 | throw new RuntimeException(\sprintf('Asset "%s" can\'t be converted.', $InlineImage['element']['attributes']['src'])); |
||||
411 | } |
||||
412 | // abord if InlineImage is an animated GIF |
||||
413 | 1 | if (Image::isAnimatedGif($assetResized ?? $asset)) { |
|||
414 | 1 | $filepath = Util::joinFile($this->config->getOutputPath(), $assetResized['path'] ?? $asset['path']); |
|||
0 ignored issues
–
show
It seems like
$assetResized['path'] ?? $asset['path'] can also be of type null ; however, parameter $path of Cecil\Util::joinFile() does only seem to accept string , maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||
415 | 1 | throw new RuntimeException(\sprintf('Asset "%s" is not converted (animated GIF).', $filepath)); |
|||
416 | } |
||||
417 | 1 | $sources = []; |
|||
418 | 1 | foreach ($formats as $format) { |
|||
419 | 1 | $srcset = ''; |
|||
420 | try { |
||||
421 | 1 | $assetConverted = ($assetResized ?? $asset)->convert($format); |
|||
422 | 1 | } catch (\Exception $e) { |
|||
423 | 1 | $this->builder->getLogger()->debug($e->getMessage()); |
|||
424 | 1 | continue; |
|||
425 | } |
||||
426 | // build responsive images? |
||||
427 | if ($this->config->isEnabled('pages.body.images.responsive')) { |
||||
428 | try { |
||||
429 | $srcset = Image::buildHtmlSrcset($assetConverted, $this->config->getAssetsImagesWidths()); |
||||
430 | } catch (\Exception $e) { |
||||
431 | $this->builder->getLogger()->debug($e->getMessage()); |
||||
432 | } |
||||
433 | } |
||||
434 | // if not, use default image as srcset |
||||
435 | if (empty($srcset)) { |
||||
436 | $srcset = (string) $assetConverted; |
||||
437 | } |
||||
438 | // add format to <sources> |
||||
439 | $sources[] = [ |
||||
440 | 'name' => 'source', |
||||
441 | 'attributes' => [ |
||||
442 | 'type' => "image/$format", |
||||
443 | 'srcset' => $srcset, |
||||
444 | 'sizes' => $sizes, |
||||
445 | 'width' => $InlineImage['element']['attributes']['width'], |
||||
446 | 'height' => $InlineImage['element']['attributes']['height'], |
||||
447 | ], |
||||
448 | ]; |
||||
449 | } |
||||
450 | 1 | if (\count($sources) > 0) { |
|||
451 | $picture = [ |
||||
452 | 'extent' => $InlineImage['extent'], |
||||
453 | 'element' => [ |
||||
454 | 'name' => 'picture', |
||||
455 | 'handler' => 'elements', |
||||
456 | 'attributes' => [ |
||||
457 | 'title' => $image['element']['attributes']['title'], |
||||
458 | ], |
||||
459 | ], |
||||
460 | ]; |
||||
461 | $picture['element']['text'] = $sources; |
||||
462 | unset($image['element']['attributes']['title']); // @phpstan-ignore unset.offset |
||||
463 | $picture['element']['text'][] = $image['element']; |
||||
464 | 1 | $image = $picture; |
|||
465 | } |
||||
466 | 1 | } catch (\Exception $e) { |
|||
467 | 1 | $this->builder->getLogger()->debug($e->getMessage()); |
|||
468 | } |
||||
469 | } |
||||
470 | |||||
471 | // if title: put the <img> (or <picture>) in a <figure> and create a <figcaption> |
||||
472 | 1 | if ($this->config->isEnabled('pages.body.images.caption')) { |
|||
473 | 1 | return $this->createFigure($image); |
|||
474 | } |
||||
475 | |||||
476 | return $image; |
||||
477 | } |
||||
478 | |||||
479 | /** |
||||
480 | * Image block. |
||||
481 | */ |
||||
482 | 1 | protected function blockImage($Excerpt) |
|||
483 | { |
||||
484 | 1 | if (1 !== preg_match($this->regexImage, $Excerpt['text'])) { |
|||
485 | return; |
||||
486 | } |
||||
487 | |||||
488 | 1 | $InlineImage = $this->inlineImage($Excerpt); |
|||
489 | 1 | if (!isset($InlineImage)) { |
|||
490 | return; |
||||
491 | } |
||||
492 | |||||
493 | 1 | return $InlineImage; |
|||
494 | } |
||||
495 | |||||
496 | /** |
||||
497 | * Note block-level markup. |
||||
498 | * |
||||
499 | * :::tip |
||||
500 | * **Tip:** This is an advice. |
||||
501 | * ::: |
||||
502 | * |
||||
503 | * Code inspired by https://github.com/sixlive/parsedown-alert from TJ Miller (@sixlive). |
||||
504 | */ |
||||
505 | 1 | protected function blockNote($block) |
|||
506 | { |
||||
507 | 1 | if (preg_match('/:::(.*)/', $block['text'], $matches)) { |
|||
508 | 1 | $block = [ |
|||
509 | 1 | 'char' => ':', |
|||
510 | 1 | 'element' => [ |
|||
511 | 1 | 'name' => 'aside', |
|||
512 | 1 | 'text' => '', |
|||
513 | 1 | 'attributes' => [ |
|||
514 | 1 | 'class' => 'note', |
|||
515 | 1 | ], |
|||
516 | 1 | ], |
|||
517 | 1 | ]; |
|||
518 | 1 | if (!empty($matches[1])) { |
|||
519 | 1 | $block['element']['attributes']['class'] .= " note-{$matches[1]}"; |
|||
520 | } |
||||
521 | |||||
522 | 1 | return $block; |
|||
523 | } |
||||
524 | } |
||||
525 | |||||
526 | 1 | protected function blockNoteContinue($line, $block) |
|||
527 | { |
||||
528 | 1 | if (isset($block['complete'])) { |
|||
529 | 1 | return; |
|||
530 | } |
||||
531 | 1 | if (preg_match('/:::/', $line['text'])) { |
|||
532 | 1 | $block['complete'] = true; |
|||
533 | |||||
534 | 1 | return $block; |
|||
535 | } |
||||
536 | 1 | $block['element']['text'] .= $line['text'] . "\n"; |
|||
537 | |||||
538 | 1 | return $block; |
|||
539 | } |
||||
540 | |||||
541 | 1 | protected function blockNoteComplete($block) |
|||
542 | { |
||||
543 | 1 | $block['element']['rawHtml'] = $this->text($block['element']['text']); |
|||
544 | 1 | unset($block['element']['text']); |
|||
545 | |||||
546 | 1 | return $block; |
|||
547 | } |
||||
548 | |||||
549 | /** |
||||
550 | * Apply Highlight to code blocks. |
||||
551 | */ |
||||
552 | 1 | protected function blockFencedCodeComplete($block) |
|||
553 | { |
||||
554 | 1 | if (!$this->config->isEnabled('pages.body.highlight')) { |
|||
555 | return $block; |
||||
556 | } |
||||
557 | 1 | if (!isset($block['element']['text']['attributes'])) { |
|||
558 | return $block; |
||||
559 | } |
||||
560 | |||||
561 | try { |
||||
562 | 1 | $code = $block['element']['text']['text']; |
|||
563 | 1 | $languageClass = $block['element']['text']['attributes']['class']; |
|||
564 | 1 | $language = explode('-', $languageClass); |
|||
565 | 1 | $highlighted = $this->highlighter->highlight($language[1], $code); |
|||
566 | 1 | $block['element']['text']['attributes']['class'] = vsprintf('%s hljs %s', [ |
|||
567 | 1 | $languageClass, |
|||
568 | 1 | $highlighted->language, |
|||
569 | 1 | ]); |
|||
570 | 1 | $block['element']['text']['rawHtml'] = $highlighted->value; |
|||
571 | 1 | $block['element']['text']['allowRawHtmlInSafeMode'] = true; |
|||
572 | 1 | unset($block['element']['text']['text']); |
|||
573 | } catch (\Exception $e) { |
||||
574 | $this->builder->getLogger()->debug("Highlighter: " . $e->getMessage()); |
||||
575 | } finally { |
||||
576 | 1 | return $block; |
|||
577 | } |
||||
578 | } |
||||
579 | |||||
580 | /** |
||||
581 | * {@inheritdoc} |
||||
582 | */ |
||||
583 | 1 | protected function parseAttributeData($attributeString) |
|||
584 | { |
||||
585 | 1 | $attributes = preg_split('/[ ]+/', $attributeString, -1, PREG_SPLIT_NO_EMPTY); |
|||
586 | 1 | $Data = []; |
|||
587 | 1 | $HtmlAtt = []; |
|||
588 | |||||
589 | 1 | if (is_iterable($attributes)) { |
|||
590 | 1 | foreach ($attributes as $attribute) { |
|||
591 | 1 | switch ($attribute[0]) { |
|||
592 | 1 | case '#': // ID |
|||
593 | 1 | $Data['id'] = substr($attribute, 1); |
|||
594 | 1 | break; |
|||
595 | 1 | case '.': // Classes |
|||
596 | 1 | $classes[] = substr($attribute, 1); |
|||
597 | 1 | break; |
|||
598 | default: // Attributes |
||||
599 | 1 | parse_str($attribute, $parsed); |
|||
600 | 1 | $HtmlAtt = array_merge($HtmlAtt, $parsed); |
|||
601 | } |
||||
602 | } |
||||
603 | |||||
604 | 1 | if (isset($classes)) { |
|||
605 | 1 | $Data['class'] = implode(' ', $classes); |
|||
606 | } |
||||
607 | 1 | if (!empty($HtmlAtt)) { |
|||
608 | 1 | foreach ($HtmlAtt as $a => $v) { |
|||
609 | 1 | $Data[$a] = trim($v, '"'); |
|||
610 | } |
||||
611 | } |
||||
612 | } |
||||
613 | |||||
614 | 1 | return $Data; |
|||
615 | } |
||||
616 | |||||
617 | /** |
||||
618 | * {@inheritdoc} |
||||
619 | * |
||||
620 | * Converts XHTML '<br />' tag to '<br>'. |
||||
621 | * |
||||
622 | * @return string |
||||
623 | */ |
||||
624 | 1 | protected function unmarkedText($text) |
|||
625 | { |
||||
626 | 1 | return str_replace('<br />', '<br>', parent::unmarkedText($text)); // @phpstan-ignore staticMethod.notFound |
|||
627 | } |
||||
628 | |||||
629 | /** |
||||
630 | * {@inheritdoc} |
||||
631 | * |
||||
632 | * XHTML closing tag to HTML5 closing tag. |
||||
633 | * |
||||
634 | * @return string |
||||
635 | */ |
||||
636 | 1 | protected function element(array $Element) |
|||
637 | { |
||||
638 | 1 | return str_replace(' />', '>', parent::element($Element)); // @phpstan-ignore staticMethod.notFound |
|||
639 | } |
||||
640 | |||||
641 | /** |
||||
642 | * Turns a path relative to static or assets into a website relative path. |
||||
643 | * |
||||
644 | * "../../assets/images/img.jpeg" |
||||
645 | * -> |
||||
646 | * "/images/img.jpeg" |
||||
647 | */ |
||||
648 | 1 | private function normalizePath(string $path): string |
|||
649 | { |
||||
650 | // https://regex101.com/r/Rzguzh/1 |
||||
651 | 1 | $pattern = \sprintf( |
|||
652 | 1 | '(\.\.\/)+(\b%s|%s\b)+(\/.*)', |
|||
653 | 1 | (string) $this->config->get('static.dir'), |
|||
654 | 1 | (string) $this->config->get('assets.dir') |
|||
655 | 1 | ); |
|||
656 | 1 | $path = Util::joinPath($path); |
|||
657 | 1 | if (!preg_match('/' . $pattern . '/is', $path, $matches)) { |
|||
658 | 1 | return $path; |
|||
659 | } |
||||
660 | |||||
661 | 1 | return $matches[3]; |
|||
662 | } |
||||
663 | |||||
664 | /** |
||||
665 | * Create a media (video or audio) element from a link. |
||||
666 | */ |
||||
667 | 1 | private function createMediaFromLink(array $link, string $type = 'video'): array |
|||
668 | { |
||||
669 | 1 | $block = [ |
|||
670 | 1 | 'extent' => $link['extent'], |
|||
671 | 1 | 'element' => [ |
|||
672 | 1 | 'text' => $link['element']['text'], |
|||
673 | 1 | ], |
|||
674 | 1 | ]; |
|||
675 | 1 | $block['element']['attributes'] = $link['element']['attributes']; |
|||
676 | 1 | unset($block['element']['attributes']['href']); |
|||
677 | 1 | $block['element']['attributes']['src'] = new Url($this->builder, new Asset($this->builder, $link['element']['attributes']['href'])); |
|||
678 | switch ($type) { |
||||
679 | 1 | case 'video': |
|||
680 | 1 | $block['element']['name'] = 'video'; |
|||
681 | // no controls = autoplay, loop, muted, playsinline |
||||
682 | 1 | if (!isset($block['element']['attributes']['controls'])) { |
|||
683 | 1 | $block['element']['attributes']['autoplay'] = ''; |
|||
684 | 1 | $block['element']['attributes']['loop'] = ''; |
|||
685 | 1 | $block['element']['attributes']['muted'] = ''; |
|||
686 | 1 | $block['element']['attributes']['playsinline'] = ''; |
|||
687 | } |
||||
688 | 1 | if (isset($block['element']['attributes']['poster'])) { |
|||
689 | 1 | $block['element']['attributes']['poster'] = new Url($this->builder, new Asset($this->builder, $block['element']['attributes']['poster'])); |
|||
690 | } |
||||
691 | 1 | if (!\array_key_exists('style', $block['element']['attributes'])) { |
|||
692 | 1 | $block['element']['attributes']['style'] = ''; |
|||
693 | } |
||||
694 | 1 | $block['element']['attributes']['style'] .= ';max-width:100%;height:auto;background-color:#d8d8d8;'; // background color if offline |
|||
695 | |||||
696 | 1 | return $block; |
|||
697 | 1 | case 'audio': |
|||
698 | 1 | $block['element']['name'] = 'audio'; |
|||
699 | |||||
700 | 1 | return $block; |
|||
701 | } |
||||
702 | |||||
703 | throw new \Exception(\sprintf('Can\'t create %s from "%s".', $type, $link['element']['attributes']['href'])); |
||||
704 | } |
||||
705 | |||||
706 | /** |
||||
707 | * Create an embedded video element from a link. |
||||
708 | * |
||||
709 | * $baseSrc is the base URL to embed the video |
||||
710 | * $match is the video ID or the rest of the URL to append to $baseSrc |
||||
711 | */ |
||||
712 | 1 | private function createEmbeddedVideoFromLink(array $link, string $baseSrc, string $match): array |
|||
713 | { |
||||
714 | 1 | $iframe = [ |
|||
715 | 1 | 'element' => [ |
|||
716 | 1 | 'name' => 'iframe', |
|||
717 | 1 | 'text' => $link['element']['text'], |
|||
718 | 1 | 'attributes' => [ |
|||
719 | 1 | 'width' => '640', |
|||
720 | 1 | 'height' => '360', |
|||
721 | 1 | 'title' => $link['element']['text'], |
|||
722 | 1 | 'src' => Util::joinPath($baseSrc, $match), |
|||
723 | 1 | 'frameborder' => '0', |
|||
724 | 1 | 'allow' => 'accelerometer;autoplay;encrypted-media;gyroscope;picture-in-picture;', |
|||
725 | 1 | 'allowfullscreen' => '', |
|||
726 | 1 | 'style' => 'position:absolute;top:0;left:0;width:100%;height:100%;border:0;background-color:#d8d8d8;', |
|||
727 | 1 | ], |
|||
728 | 1 | ], |
|||
729 | 1 | ]; |
|||
730 | 1 | $div = [ |
|||
731 | 1 | 'extent' => $link['extent'], |
|||
732 | 1 | 'element' => [ |
|||
733 | 1 | 'name' => 'div', |
|||
734 | 1 | 'handler' => 'elements', |
|||
735 | 1 | 'text' => [ |
|||
736 | 1 | $iframe['element'], |
|||
737 | 1 | ], |
|||
738 | 1 | 'attributes' => [ |
|||
739 | 1 | 'style' => 'position:relative;padding-bottom:56.25%;height:0;overflow:hidden;', |
|||
740 | 1 | 'title' => $link['element']['attributes']['title'], |
|||
741 | 1 | ], |
|||
742 | 1 | ], |
|||
743 | 1 | ]; |
|||
744 | 1 | if ($this->config->isEnabled('pages.body.images.caption')) { |
|||
745 | 1 | return $this->createFigure($div); |
|||
746 | } |
||||
747 | |||||
748 | return $div; |
||||
749 | } |
||||
750 | |||||
751 | /** |
||||
752 | * Create a figure / caption element. |
||||
753 | */ |
||||
754 | 1 | private function createFigure(array $inline): array |
|||
755 | { |
||||
756 | 1 | if (empty($inline['element']['attributes']['title'])) { |
|||
757 | 1 | return $inline; |
|||
758 | } |
||||
759 | |||||
760 | 1 | $titleRawHtml = $this->line($inline['element']['attributes']['title']); // @phpstan-ignore method.notFound |
|||
761 | 1 | $inline['element']['attributes']['title'] = strip_tags($titleRawHtml); |
|||
762 | |||||
763 | 1 | $figcaption = [ |
|||
764 | 1 | 'element' => [ |
|||
765 | 1 | 'name' => 'figcaption', |
|||
766 | 1 | 'allowRawHtmlInSafeMode' => true, |
|||
767 | 1 | 'rawHtml' => $titleRawHtml, |
|||
768 | 1 | ], |
|||
769 | 1 | ]; |
|||
770 | 1 | $figure = [ |
|||
771 | 1 | 'extent' => $inline['extent'], |
|||
772 | 1 | 'element' => [ |
|||
773 | 1 | 'name' => 'figure', |
|||
774 | 1 | 'handler' => 'elements', |
|||
775 | 1 | 'text' => [ |
|||
776 | 1 | $inline['element'], |
|||
777 | 1 | $figcaption['element'], |
|||
778 | 1 | ], |
|||
779 | 1 | ], |
|||
780 | 1 | ]; |
|||
781 | |||||
782 | 1 | return $figure; |
|||
783 | } |
||||
784 | |||||
785 | /** |
||||
786 | * Handle an external link. |
||||
787 | */ |
||||
788 | 1 | private function handleExternalLink(array $link): array |
|||
789 | { |
||||
790 | if ( |
||||
791 | 1 | str_starts_with($link['element']['attributes']['href'], 'http') |
|||
792 | 1 | && (!empty($this->config->get('baseurl')) && !str_starts_with($link['element']['attributes']['href'], (string) $this->config->get('baseurl'))) |
|||
793 | ) { |
||||
794 | 1 | if ($this->config->isEnabled('pages.body.links.external.blank')) { |
|||
795 | 1 | $link['element']['attributes']['target'] = '_blank'; |
|||
796 | } |
||||
797 | 1 | if (!\array_key_exists('rel', $link['element']['attributes'])) { |
|||
798 | 1 | $link['element']['attributes']['rel'] = ''; |
|||
799 | } |
||||
800 | 1 | if ($this->config->isEnabled('pages.body.links.external.noopener')) { |
|||
801 | 1 | $link['element']['attributes']['rel'] .= ' noopener'; |
|||
802 | } |
||||
803 | 1 | if ($this->config->isEnabled('pages.body.links.external.noreferrer')) { |
|||
804 | 1 | $link['element']['attributes']['rel'] .= ' noreferrer'; |
|||
805 | } |
||||
806 | 1 | if ($this->config->isEnabled('pages.body.links.external.nofollow')) { |
|||
807 | 1 | $link['element']['attributes']['rel'] .= ' nofollow'; |
|||
808 | } |
||||
809 | 1 | $link['element']['attributes']['rel'] = trim($link['element']['attributes']['rel']); |
|||
810 | } |
||||
811 | |||||
812 | 1 | return $link; |
|||
813 | } |
||||
814 | } |
||||
815 |