Parsedown::__construct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 24
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 11
nc 1
nop 2
dl 0
loc 24
rs 9.9
c 0
b 0
f 0
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\Assets\Asset;
17
use Cecil\Assets\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
    public function __construct(Builder $builder, ?array $options = null)
60
    {
61
        $this->builder = $builder;
62
        $this->config = $builder->getConfig();
63
64
        // "insert" line block: ++text++ -> <ins>text</ins>
65
        $this->InlineTypes['+'][] = 'Insert';
66
        $this->inlineMarkerList = implode('', array_keys($this->InlineTypes));
67
        $this->specialCharacters[] = '+';
68
69
        // Image block (to avoid paragraph)
70
        $this->BlockTypes['!'][] = 'Image';
71
72
        // "notes" block
73
        $this->BlockTypes[':'][] = 'Note';
74
75
        // code highlight
76
        $this->highlighter = new Highlighter();
77
78
        // options
79
        $options = array_merge(['selectors' => (array) $this->config->get('pages.body.toc')], $options ?? []);
80
81
        parent::__construct();
82
        parent::setOptions($options);
83
    }
84
85
    /**
86
     * Insert inline.
87
     * e.g.: ++text++ -> <ins>text</ins>.
88
     */
89
    protected function inlineInsert($Excerpt)
90
    {
91
        if (!isset($Excerpt['text'][1])) {
92
            return;
93
        }
94
95
        if ($Excerpt['text'][1] === '+' && preg_match('/^\+\+(?=\S)(.+?)(?<=\S)\+\+/', $Excerpt['text'], $matches)) {
96
            return [
97
                'extent'  => \strlen($matches[0]),
98
                'element' => [
99
                    'name'    => 'ins',
100
                    'text'    => $matches[1],
101
                    'handler' => 'line',
102
                ],
103
            ];
104
        }
105
    }
106
107
    /**
108
     * {@inheritdoc}
109
     */
110
    protected function inlineLink($Excerpt)
111
    {
112
        $link = parent::inlineLink($Excerpt); // @phpstan-ignore staticMethod.notFound
113
114
        if (!isset($link)) {
115
            return null;
116
        }
117
118
        // Link to a page with "page:page_id" as URL
119
        if (Util\Str::startsWith($link['element']['attributes']['href'], 'page:')) {
120
            $link['element']['attributes']['href'] = new Url($this->builder, substr($link['element']['attributes']['href'], 5, \strlen($link['element']['attributes']['href'])));
121
122
            return $link;
123
        }
124
125
        // External link
126
        $link = $this->handleExternalLink($link);
127
128
        /*
129
         * Embed link?
130
         */
131
        $embed = $this->config->isEnabled('pages.body.links.embed');
132
        if (isset($link['element']['attributes']['embed'])) {
133
            $embed = true;
134
            if ($link['element']['attributes']['embed'] == 'false') {
135
                $embed = false;
136
            }
137
            unset($link['element']['attributes']['embed']);
138
        }
139
        $extension = pathinfo($link['element']['attributes']['href'], PATHINFO_EXTENSION);
140
        // video?
141
        if (\in_array($extension, $this->config->get('pages.body.links.embed.video') ?? [])) {
142
            if (!$embed) {
143
                $link['element']['attributes']['href'] = new Url($this->builder, $link['element']['attributes']['href']);
144
145
                return $link;
146
            }
147
            $video = $this->createMediaFromLink($link, 'video');
148
            if ($this->config->isEnabled('pages.body.images.caption')) {
149
                return $this->createFigure($video);
150
            }
151
152
            return $video;
153
        }
154
        // audio?
155
        if (\in_array($extension, $this->config->get('pages.body.links.embed.audio') ?? [])) {
156
            if (!$embed) {
157
                $link['element']['attributes']['href'] = new Url($this->builder, $link['element']['attributes']['href']);
158
159
                return $link;
160
            }
161
            $audio = $this->createMediaFromLink($link, 'audio');
162
            if ($this->config->isEnabled('pages.body.images.caption')) {
163
                return $this->createFigure($audio);
164
            }
165
166
            return $audio;
167
        }
168
        if (!$embed) {
169
            return $link;
170
        }
171
        // GitHub Gist link?
172
        // https://regex101.com/r/QmCiAL/1
173
        $pattern = 'https:\/\/gist\.github.com\/[-a-zA-Z0-9_]+\/[-a-zA-Z0-9_]+';
174
        if (preg_match('/' . $pattern . '/is', (string) $link['element']['attributes']['href'], $matches)) {
175
            $gist = [
176
                'extent'  => $link['extent'],
177
                'element' => [
178
                    'name'       => 'script',
179
                    'text'       => $link['element']['text'],
180
                    'attributes' => [
181
                        'src'   => $matches[0] . '.js',
182
                        'title' => $link['element']['attributes']['title'],
183
                    ],
184
                ],
185
            ];
186
            if ($this->config->isEnabled('pages.body.images.caption')) {
187
                return $this->createFigure($gist);
188
            }
189
190
            return $gist;
191
        }
192
        // Youtube link?
193
        // https://regex101.com/r/gznM1j/1
194
        $pattern = '(?:https?:\/\/)?(?:www\.)?youtu(?:\.be\/|be.com\/\S*(?:watch|embed)(?:(?:(?=\/[-a-zA-Z0-9_]{11,}(?!\S))\/)|(?:\S*v=|v\/)))([-a-zA-Z0-9_]{11,})';
195
        if (preg_match('/' . $pattern . '/is', (string) $link['element']['attributes']['href'], $matches)) {
196
            $iframe = [
197
                'element' => [
198
                    'name'       => 'iframe',
199
                    'text'       => $link['element']['text'],
200
                    'attributes' => [
201
                        'width'           => '560',
202
                        'height'          => '315',
203
                        'title'           => $link['element']['text'],
204
                        'src'             => 'https://www.youtube.com/embed/' . $matches[1],
205
                        'frameborder'     => '0',
206
                        'allow'           => 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture',
207
                        'allowfullscreen' => '',
208
                        'style'           => 'position:absolute; top:0; left:0; width:100%; height:100%; border:0',
209
                    ],
210
                ],
211
            ];
212
            $youtube = [
213
                'extent'  => $link['extent'],
214
                'element' => [
215
                    'name'    => 'div',
216
                    'handler' => 'elements',
217
                    'text'    => [
218
                        $iframe['element'],
219
                    ],
220
                    'attributes' => [
221
                        'style' => 'position:relative; padding-bottom:56.25%; height:0; overflow:hidden',
222
                        'title' => $link['element']['attributes']['title'],
223
                    ],
224
                ],
225
            ];
226
            if ($this->config->isEnabled('pages.body.images.caption')) {
227
                return $this->createFigure($youtube);
228
            }
229
230
            return $youtube;
231
        }
232
233
        return $link;
234
    }
235
236
    /**
237
     * {@inheritdoc}
238
     */
239
    protected function inlineUrl($Excerpt)
240
    {
241
        $link = parent::inlineUrl($Excerpt); // @phpstan-ignore staticMethod.notFound
242
243
        if (!isset($link)) {
244
            return;
245
        }
246
247
        // External link
248
        return $this->handleExternalLink($link);
249
    }
250
251
    /**
252
     * {@inheritdoc}
253
     */
254
    protected function inlineUrlTag($Excerpt)
255
    {
256
        $link = parent::inlineUrlTag($Excerpt); // @phpstan-ignore staticMethod.notFound
257
258
        if (!isset($link)) {
259
            return;
260
        }
261
262
        // External link
263
        return $this->handleExternalLink($link);
264
    }
265
266
    /**
267
     * {@inheritdoc}
268
     */
269
    protected function inlineImage($Excerpt)
270
    {
271
        $InlineImage = parent::inlineImage($Excerpt); // @phpstan-ignore staticMethod.notFound
272
        if (!isset($InlineImage)) {
273
            return null;
274
        }
275
276
        // normalize path
277
        $InlineImage['element']['attributes']['src'] = $this->normalizePath($InlineImage['element']['attributes']['src']);
278
279
        // should be lazy loaded?
280
        if ($this->config->isEnabled('pages.body.images.lazy') && !isset($InlineImage['element']['attributes']['loading'])) {
281
            $InlineImage['element']['attributes']['loading'] = 'lazy';
282
        }
283
        // should be decoding async?
284
        if ($this->config->isEnabled('pages.body.images.decoding') && !isset($InlineImage['element']['attributes']['decoding'])) {
285
            $InlineImage['element']['attributes']['decoding'] = 'async';
286
        }
287
        // add default class?
288
        if ((string) $this->config->get('pages.body.images.class')) {
289
            if (!\array_key_exists('class', $InlineImage['element']['attributes'])) {
290
                $InlineImage['element']['attributes']['class'] = '';
291
            }
292
            $InlineImage['element']['attributes']['class'] .= ' ' . (string) $this->config->get('pages.body.images.class');
293
            $InlineImage['element']['attributes']['class'] = trim($InlineImage['element']['attributes']['class']);
294
        }
295
296
        // disable remote image handling?
297
        if (Util\File::isRemote($InlineImage['element']['attributes']['src']) && !$this->config->isEnabled('pages.body.images.remote')) {
298
            return $InlineImage;
299
        }
300
301
        // create asset
302
        $assetOptions = ['leading_slash' => false];
303
        if ($this->config->isEnabled('pages.body.images.remote.fallback')) {
304
            $assetOptions = ['leading_slash' => true];
305
            $assetOptions += ['fallback' => (string) $this->config->get('pages.body.images.remote.fallback')];
306
        }
307
        $asset = new Asset($this->builder, $InlineImage['element']['attributes']['src'], $assetOptions);
308
        $InlineImage['element']['attributes']['src'] = new Url($this->builder, $asset);
309
        $width = $asset['width'];
310
311
        /*
312
         * Should be resized?
313
         */
314
        $shouldResize = false;
315
        $assetResized = null;
316
        // pages.body.images.resize
317
        if (
318
            \is_int($this->config->get('pages.body.images.resize'))
319
            && $this->config->get('pages.body.images.resize') > 0
320
            && $width > $this->config->get('pages.body.images.resize')
321
        ) {
322
            $shouldResize = true;
323
            $width = $this->config->get('pages.body.images.resize');
324
        }
325
        // width attribute
326
        if (
327
            isset($InlineImage['element']['attributes']['width'])
328
            && $width > (int) $InlineImage['element']['attributes']['width']
329
        ) {
330
            $shouldResize = true;
331
            $width = (int) $InlineImage['element']['attributes']['width'];
332
        }
333
        // responsive images
334
        if (
335
            $this->config->isEnabled('pages.body.images.responsive')
336
            && !empty($this->config->getAssetsImagesWidths())
337
            && $width > max($this->config->getAssetsImagesWidths())
338
        ) {
339
            $shouldResize = true;
340
            $width = max($this->config->getAssetsImagesWidths());
341
        }
342
        if ($shouldResize) {
343
            try {
344
                $assetResized = $asset->resize($width);
345
            } catch (\Exception $e) {
346
                $this->builder->getLogger()->debug($e->getMessage());
347
348
                return $InlineImage;
349
            }
350
        }
351
352
        // set width
353
        $InlineImage['element']['attributes']['width'] = $width;
354
        // set height
355
        $InlineImage['element']['attributes']['height'] = $assetResized['height'] ?? $asset['height'];
356
357
        // placeholder
358
        if (
359
            (!empty($this->config->get('pages.body.images.placeholder')) || isset($InlineImage['element']['attributes']['placeholder']))
360
            && \in_array($assetResized['subtype'] ?? $asset['subtype'], ['image/jpeg', 'image/png', 'image/gif'])
361
        ) {
362
            if (!\array_key_exists('placeholder', $InlineImage['element']['attributes'])) {
363
                $InlineImage['element']['attributes']['placeholder'] = (string) $this->config->get('pages.body.images.placeholder');
364
            }
365
            if (!\array_key_exists('style', $InlineImage['element']['attributes'])) {
366
                $InlineImage['element']['attributes']['style'] = '';
367
            }
368
            $InlineImage['element']['attributes']['style'] = trim($InlineImage['element']['attributes']['style'], ';');
369
            switch ($InlineImage['element']['attributes']['placeholder']) {
370
                case 'color':
371
                    $InlineImage['element']['attributes']['style'] .= \sprintf(';max-width:100%%;height:auto;background-color:%s;', Image::getDominantColor($assetResized ?? $asset));
372
                    break;
373
                case 'lqip':
374
                    // aborts if animated GIF for performance reasons
375
                    if (Image::isAnimatedGif($assetResized ?? $asset)) {
376
                        break;
377
                    }
378
                    $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));
379
                    break;
380
            }
381
            unset($InlineImage['element']['attributes']['placeholder']);
382
            $InlineImage['element']['attributes']['style'] = trim($InlineImage['element']['attributes']['style']);
383
        }
384
385
        /*
386
         * Should be responsive?
387
         */
388
        $sizes = '';
389
        if ($this->config->isEnabled('pages.body.images.responsive')) {
390
            try {
391
                if (
392
                    $srcset = Image::buildSrcset(
393
                        $assetResized ?? $asset,
394
                        $this->config->getAssetsImagesWidths()
395
                    )
396
                ) {
397
                    $InlineImage['element']['attributes']['srcset'] = $srcset;
398
                    $sizes = Image::getSizes($InlineImage['element']['attributes']['class'] ?? '', (array) $this->config->getAssetsImagesSizes());
399
                    $InlineImage['element']['attributes']['sizes'] = $sizes;
400
                }
401
            } catch (\Exception $e) {
402
                $this->builder->getLogger()->debug($e->getMessage());
403
            }
404
        }
405
406
        /*
407
        <!-- if title: a <figure> is required to put in it a <figcaption> -->
408
        <figure>
409
            <!-- if formats: a <picture> is required for each <source> -->
410
            <picture>
411
                <source type="image/avif"
412
                    srcset="..."
413
                    sizes="..."
414
                >
415
                <source type="image/webp"
416
                    srcset="..."
417
                    sizes="..."
418
                >
419
                <img src="..."
420
                    srcset="..."
421
                    sizes="..."
422
                >
423
            </picture>
424
            <figcaption><!-- title --></figcaption>
425
        </figure>
426
        */
427
428
        $image = $InlineImage;
429
430
        // converts image to formats and put them in picture > source
431
        if (
432
            \count($formats = ((array) $this->config->get('pages.body.images.formats'))) > 0
433
            && \in_array($assetResized['subtype'] ?? $asset['subtype'], ['image/jpeg', 'image/png', 'image/gif'])
434
        ) {
435
            try {
436
                // InlineImage src must be an Asset instance
437
                if (!($assetResized ?? $asset) instanceof Asset) {
0 ignored issues
show
introduced by
$assetResized ?? $asset is always a sub-type of Cecil\Assets\Asset.
Loading history...
438
                    throw new RuntimeException(\sprintf('Asset "%s" can\'t be converted.', $InlineImage['element']['attributes']['src']));
439
                }
440
                // abord if InlineImage is an animated GIF
441
                if (Image::isAnimatedGif($assetResized ?? $asset)) {
442
                    $filepath = Util::joinFile($this->config->getOutputPath(), $assetResized['path'] ?? $asset['path']);
0 ignored issues
show
Bug introduced by
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 ignore-type  annotation

442
                    $filepath = Util::joinFile($this->config->getOutputPath(), /** @scrutinizer ignore-type */ $assetResized['path'] ?? $asset['path']);
Loading history...
443
                    throw new RuntimeException(\sprintf('Asset "%s" is not converted (animated GIF).', $filepath));
444
                }
445
                $sources = [];
446
                foreach ($formats as $format) {
447
                    $srcset = '';
448
                    try {
449
                        $assetConverted = ($assetResized ?? $asset)->convert($format);
450
                    } catch (\Exception $e) {
451
                        $this->builder->getLogger()->debug($e->getMessage());
452
                        continue;
453
                    }
454
                    // build responsive images?
455
                    if ($this->config->isEnabled('pages.body.images.responsive')) {
456
                        try {
457
                            $srcset = Image::buildSrcset($assetConverted, $this->config->getAssetsImagesWidths());
458
                        } catch (\Exception $e) {
459
                            $this->builder->getLogger()->debug($e->getMessage());
460
                        }
461
                    }
462
                    // if not, use default image as srcset
463
                    if (empty($srcset)) {
464
                        $srcset = (string) $assetConverted;
465
                    }
466
                    // add format to <sources>
467
                    $sources[] = [
468
                        'name'       => 'source',
469
                        'attributes' => [
470
                            'type'   => "image/$format",
471
                            'srcset' => $srcset,
472
                            'sizes'  => $sizes,
473
                            'width'  => $InlineImage['element']['attributes']['width'],
474
                            'height' => $InlineImage['element']['attributes']['height'],
475
                        ],
476
                    ];
477
                }
478
                if (\count($sources) > 0) {
479
                    $picture = [
480
                        'extent'  => $InlineImage['extent'],
481
                        'element' => [
482
                            'name'       => 'picture',
483
                            'handler'    => 'elements',
484
                            'attributes' => [
485
                                'title' => $image['element']['attributes']['title'],
486
                            ],
487
                        ],
488
                    ];
489
                    $picture['element']['text'] = $sources;
490
                    unset($image['element']['attributes']['title']); // @phpstan-ignore unset.offset
491
                    $picture['element']['text'][] = $image['element'];
492
                    $image = $picture;
493
                }
494
            } catch (\Exception $e) {
495
                $this->builder->getLogger()->debug($e->getMessage());
496
            }
497
        }
498
499
        // if title: put the <img> (or <picture>) in a <figure> and create a <figcaption>
500
        if ($this->config->isEnabled('pages.body.images.caption')) {
501
            return $this->createFigure($image);
502
        }
503
504
        return $image;
505
    }
506
507
    /**
508
     * Image block.
509
     */
510
    protected function blockImage($Excerpt)
511
    {
512
        if (1 !== preg_match($this->regexImage, $Excerpt['text'])) {
513
            return;
514
        }
515
516
        $InlineImage = $this->inlineImage($Excerpt);
517
        if (!isset($InlineImage)) {
518
            return;
519
        }
520
521
        return $InlineImage;
522
    }
523
524
    /**
525
     * Note block-level markup.
526
     *
527
     * :::tip
528
     * **Tip:** This is an advice.
529
     * :::
530
     *
531
     * Code inspired by https://github.com/sixlive/parsedown-alert from TJ Miller (@sixlive).
532
     */
533
    protected function blockNote($block)
534
    {
535
        if (preg_match('/:::(.*)/', $block['text'], $matches)) {
536
            $block = [
537
                'char'    => ':',
538
                'element' => [
539
                    'name'       => 'aside',
540
                    'text'       => '',
541
                    'attributes' => [
542
                        'class' => 'note',
543
                    ],
544
                ],
545
            ];
546
            if (!empty($matches[1])) {
547
                $block['element']['attributes']['class'] .= " note-{$matches[1]}";
548
            }
549
550
            return $block;
551
        }
552
    }
553
554
    protected function blockNoteContinue($line, $block)
555
    {
556
        if (isset($block['complete'])) {
557
            return;
558
        }
559
        if (preg_match('/:::/', $line['text'])) {
560
            $block['complete'] = true;
561
562
            return $block;
563
        }
564
        $block['element']['text'] .= $line['text'] . "\n";
565
566
        return $block;
567
    }
568
569
    protected function blockNoteComplete($block)
570
    {
571
        $block['element']['rawHtml'] = $this->text($block['element']['text']);
572
        unset($block['element']['text']);
573
574
        return $block;
575
    }
576
577
    /**
578
     * Apply Highlight to code blocks.
579
     */
580
    protected function blockFencedCodeComplete($block)
581
    {
582
        if (!$this->config->isEnabled('pages.body.highlight')) {
583
            return $block;
584
        }
585
        if (!isset($block['element']['text']['attributes'])) {
586
            return $block;
587
        }
588
589
        try {
590
            $code = $block['element']['text']['text'];
591
            $languageClass = $block['element']['text']['attributes']['class'];
592
            $language = explode('-', $languageClass);
593
            $highlighted = $this->highlighter->highlight($language[1], $code);
594
            $block['element']['text']['attributes']['class'] = vsprintf('%s hljs %s', [
595
                $languageClass,
596
                $highlighted->language,
597
            ]);
598
            $block['element']['text']['rawHtml'] = $highlighted->value;
599
            $block['element']['text']['allowRawHtmlInSafeMode'] = true;
600
            unset($block['element']['text']['text']);
601
        } catch (\Exception $e) {
602
            $this->builder->getLogger()->debug($e->getMessage());
603
        } finally {
604
            return $block;
605
        }
606
    }
607
608
    /**
609
     * {@inheritdoc}
610
     */
611
    protected function parseAttributeData($attributeString)
612
    {
613
        $attributes = preg_split('/[ ]+/', $attributeString, -1, PREG_SPLIT_NO_EMPTY);
614
        $Data = [];
615
        $HtmlAtt = [];
616
617
        if (is_iterable($attributes)) {
618
            foreach ($attributes as $attribute) {
619
                switch ($attribute[0]) {
620
                    case '#': // ID
621
                        $Data['id'] = substr($attribute, 1);
622
                        break;
623
                    case '.': // Classes
624
                        $classes[] = substr($attribute, 1);
625
                        break;
626
                    default:  // Attributes
627
                        parse_str($attribute, $parsed);
628
                        $HtmlAtt = array_merge($HtmlAtt, $parsed);
629
                }
630
            }
631
632
            if (isset($classes)) {
633
                $Data['class'] = implode(' ', $classes);
634
            }
635
            if (!empty($HtmlAtt)) {
636
                foreach ($HtmlAtt as $a => $v) {
637
                    $Data[$a] = trim($v, '"');
638
                }
639
            }
640
        }
641
642
        return $Data;
643
    }
644
645
    /**
646
     * {@inheritdoc}
647
     *
648
     * Converts XHTML '<br />' tag to '<br>'.
649
     *
650
     * @return string
651
     */
652
    protected function unmarkedText($text)
653
    {
654
        return str_replace('<br />', '<br>', parent::unmarkedText($text)); // @phpstan-ignore staticMethod.notFound
655
    }
656
657
    /**
658
     * {@inheritdoc}
659
     *
660
     * XHTML closing tag to HTML5 closing tag.
661
     *
662
     * @return string
663
     */
664
    protected function element(array $Element)
665
    {
666
        return str_replace(' />', '>', parent::element($Element)); // @phpstan-ignore staticMethod.notFound
667
    }
668
669
    /**
670
     * Turns a path relative to static or assets into a website relative path.
671
     *
672
     *   "../../assets/images/img.jpeg"
673
     *   ->
674
     *   "/images/img.jpeg"
675
     */
676
    private function normalizePath(string $path): string
677
    {
678
        // https://regex101.com/r/Rzguzh/1
679
        $pattern = \sprintf(
680
            '(\.\.\/)+(\b%s|%s\b)+(\/.*)',
681
            (string) $this->config->get('static.dir'),
682
            (string) $this->config->get('assets.dir')
683
        );
684
        $path = Util::joinPath($path);
685
        if (!preg_match('/' . $pattern . '/is', $path, $matches)) {
686
            return $path;
687
        }
688
689
        return $matches[3];
690
    }
691
692
    /**
693
     * Create a media (video or audio) element from a link.
694
     */
695
    private function createMediaFromLink(array $link, string $type = 'video'): array
696
    {
697
        $block = [
698
            'extent'  => $link['extent'],
699
            'element' => [
700
                'text' => $link['element']['text'],
701
            ],
702
        ];
703
        $block['element']['attributes'] = $link['element']['attributes'];
704
        unset($block['element']['attributes']['href']);
705
        $block['element']['attributes']['src'] = new Url($this->builder, new Asset($this->builder, $link['element']['attributes']['href']));
706
        switch ($type) {
707
            case 'video':
708
                $block['element']['name'] = 'video';
709
                // no controls = autoplay, loop, muted, playsinline
710
                if (!isset($block['element']['attributes']['controls'])) {
711
                    $block['element']['attributes']['autoplay'] = '';
712
                    $block['element']['attributes']['loop'] = '';
713
                    $block['element']['attributes']['muted'] = '';
714
                    $block['element']['attributes']['playsinline'] = '';
715
                }
716
                if (isset($block['element']['attributes']['poster'])) {
717
                    $block['element']['attributes']['poster'] = new Url($this->builder, new Asset($this->builder, $block['element']['attributes']['poster']));
718
                }
719
                if (!\array_key_exists('style', $block['element']['attributes'])) {
720
                    $block['element']['attributes']['style'] = '';
721
                }
722
                $block['element']['attributes']['style'] .= ';max-width:100%;height:auto;background-color:#d8d8d8;'; // background color if offline
723
724
                return $block;
725
            case 'audio':
726
                $block['element']['name'] = 'audio';
727
728
                return $block;
729
        }
730
731
        throw new \Exception(\sprintf('Can\'t create %s from "%s".', $type, $link['element']['attributes']['href']));
732
    }
733
734
    /**
735
     * Create a figure / caption element.
736
     */
737
    private function createFigure(array $inline): array
738
    {
739
        if (empty($inline['element']['attributes']['title'])) {
740
            return $inline;
741
        }
742
743
        $titleRawHtml = $this->line($inline['element']['attributes']['title']); // @phpstan-ignore method.notFound
744
        $inline['element']['attributes']['title'] = strip_tags($titleRawHtml);
745
746
        $figcaption = [
747
            'element' => [
748
                'name'                   => 'figcaption',
749
                'allowRawHtmlInSafeMode' => true,
750
                'rawHtml'                => $titleRawHtml,
751
            ],
752
        ];
753
        $figure = [
754
            'extent'  => $inline['extent'],
755
            'element' => [
756
                'name'    => 'figure',
757
                'handler' => 'elements',
758
                'text'    => [
759
                    $inline['element'],
760
                    $figcaption['element'],
761
                ],
762
            ],
763
        ];
764
765
        return $figure;
766
    }
767
768
    /**
769
     * Handle an external link.
770
     */
771
    private function handleExternalLink(array $link): array
772
    {
773
        if (
774
            str_starts_with($link['element']['attributes']['href'], 'http')
775
            && (!empty($this->config->get('baseurl')) && !str_starts_with($link['element']['attributes']['href'], (string) $this->config->get('baseurl')))
776
        ) {
777
            if ($this->config->isEnabled('pages.body.links.external.blank')) {
778
                $link['element']['attributes']['target'] = '_blank';
779
            }
780
            if (!\array_key_exists('rel', $link['element']['attributes'])) {
781
                $link['element']['attributes']['rel'] = '';
782
            }
783
            if ($this->config->isEnabled('pages.body.links.external.noopener')) {
784
                $link['element']['attributes']['rel'] .= ' noopener';
785
            }
786
            if ($this->config->isEnabled('pages.body.links.external.noreferrer')) {
787
                $link['element']['attributes']['rel'] .= ' noreferrer';
788
            }
789
            if ($this->config->isEnabled('pages.body.links.external.nofollow')) {
790
                $link['element']['attributes']['rel'] .= ' nofollow';
791
            }
792
            $link['element']['attributes']['rel'] = trim($link['element']['attributes']['rel']);
793
        }
794
795
        return $link;
796
    }
797
}
798