Parsedown::blockFencedCodeComplete()   A
last analyzed

Complexity

Conditions 4
Paths 27

Size

Total Lines 25
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

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