Parsedown::blockNoteComplete()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 6
rs 10
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
    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
149
            return $this->createFigure($video);
150
        }
151
        // audio?
152
        if (\in_array($extension, $this->config->get('pages.body.links.embed.audio') ?? [])) {
153
            if (!$embed) {
154
                $link['element']['attributes']['href'] = new Url($this->builder, $link['element']['attributes']['href']);
155
156
                return $link;
157
            }
158
            $audio = $this->createMediaFromLink($link, 'audio');
159
160
            return $this->createFigure($audio);
161
        }
162
        if (!$embed) {
163
            return $link;
164
        }
165
        // Youtube link?
166
        // https://regex101.com/r/gznM1j/1
167
        $pattern = '(?:https?:\/\/)?(?:www\.)?youtu(?:\.be\/|be.com\/\S*(?:watch|embed)(?:(?:(?=\/[-a-zA-Z0-9_]{11,}(?!\S))\/)|(?:\S*v=|v\/)))([-a-zA-Z0-9_]{11,})';
168
        if (preg_match('/' . $pattern . '/is', (string) $link['element']['attributes']['href'], $matches)) {
169
            return $this->createFigure($this->createEmbeddedVideoFromLink($link, 'https://www.youtube-nocookie.com/embed/', $matches[1]));
170
        }
171
        // Vimeo link?
172
        // https://regex101.com/r/wCEFhd/1
173
        $pattern = 'https:\/\/vimeo\.com\/([0-9]+)';
174
        if (preg_match('/' . $pattern . '/is', (string) $link['element']['attributes']['href'], $matches)) {
175
            return $this->createFigure($this->createEmbeddedVideoFromLink($link, 'https://player.vimeo.com/video/', $matches[1]));
176
        }
177
        // Dailymotion link?
178
        // https://regex101.com/r/YKnLPm/1
179
        $pattern = '(?:https?:\/\/)?(?:www\.)?dailymotion\.com\/video\/([a-z0-9]+)';
180
        if (preg_match('/' . $pattern . '/is', (string) $link['element']['attributes']['href'], $matches)) {
181
            return $this->createFigure($this->createEmbeddedVideoFromLink($link, 'https://geo.dailymotion.com/player.html?video=', $matches[1]));
182
        }
183
        // GitHub Gist link?
184
        // https://regex101.com/r/KWVMYI/1
185
        $pattern = 'https:\/\/gist\.github\.com\/[-a-zA-Z0-9_]+\/[-a-zA-Z0-9_]+';
186
        if (preg_match('/' . $pattern . '/is', (string) $link['element']['attributes']['href'], $matches)) {
187
            $gist = [
188
                'extent'  => $link['extent'],
189
                'element' => [
190
                    'name'       => 'script',
191
                    'text'       => $link['element']['text'],
192
                    'attributes' => [
193
                        'src'   => $matches[0] . '.js',
194
                        'title' => $link['element']['attributes']['title'],
195
                    ],
196
                ],
197
            ];
198
199
            return $this->createFigure($gist);
200
        }
201
202
        return $link;
203
    }
204
205
    /**
206
     * {@inheritdoc}
207
     */
208
    protected function inlineUrl($Excerpt)
209
    {
210
        $link = parent::inlineUrl($Excerpt); // @phpstan-ignore staticMethod.notFound
211
212
        if (!isset($link)) {
213
            return;
214
        }
215
216
        // External link
217
        return $this->handleExternalLink($link);
218
    }
219
220
    /**
221
     * {@inheritdoc}
222
     */
223
    protected function inlineUrlTag($Excerpt)
224
    {
225
        $link = parent::inlineUrlTag($Excerpt); // @phpstan-ignore staticMethod.notFound
226
227
        if (!isset($link)) {
228
            return;
229
        }
230
231
        // External link
232
        return $this->handleExternalLink($link);
233
    }
234
235
    /**
236
     * {@inheritdoc}
237
     */
238
    protected function inlineImage($Excerpt)
239
    {
240
        $InlineImage = parent::inlineImage($Excerpt); // @phpstan-ignore staticMethod.notFound
241
        if (!isset($InlineImage)) {
242
            return null;
243
        }
244
245
        // remove link attributes
246
        unset($InlineImage['element']['attributes']['target'], $InlineImage['element']['attributes']['rel']);
247
248
        // normalize path
249
        $InlineImage['element']['attributes']['src'] = $this->normalizePath($InlineImage['element']['attributes']['src']);
250
251
        // should be lazy loaded?
252
        if ($this->config->isEnabled('pages.body.images.lazy') && !isset($InlineImage['element']['attributes']['loading'])) {
253
            $InlineImage['element']['attributes']['loading'] = 'lazy';
254
        }
255
        // should be decoding async?
256
        if ($this->config->isEnabled('pages.body.images.decoding') && !isset($InlineImage['element']['attributes']['decoding'])) {
257
            $InlineImage['element']['attributes']['decoding'] = 'async';
258
        }
259
        // add default class?
260
        if ((string) $this->config->get('pages.body.images.class')) {
261
            if (!\array_key_exists('class', $InlineImage['element']['attributes'])) {
262
                $InlineImage['element']['attributes']['class'] = '';
263
            }
264
            $InlineImage['element']['attributes']['class'] .= ' ' . (string) $this->config->get('pages.body.images.class');
265
            $InlineImage['element']['attributes']['class'] = trim($InlineImage['element']['attributes']['class']);
266
        }
267
268
        // disable remote image handling?
269
        if (Util\File::isRemote($InlineImage['element']['attributes']['src']) && !$this->config->isEnabled('pages.body.images.remote')) {
270
            return $this->createFigure($InlineImage);
271
        }
272
273
        // create asset
274
        $assetOptions = ['leading_slash' => false];
275
        if ($this->config->isEnabled('pages.body.images.remote.fallback')) {
276
            $assetOptions = ['leading_slash' => true];
277
            $assetOptions += ['fallback' => (string) $this->config->get('pages.body.images.remote.fallback')];
278
        }
279
        $asset = new Asset($this->builder, $InlineImage['element']['attributes']['src'], $assetOptions);
280
        $InlineImage['element']['attributes']['src'] = new Url($this->builder, $asset);
281
        $width = $asset['width'];
282
283
        /*
284
         * Should be resized?
285
         */
286
        $shouldResize = false;
287
        $assetResized = null;
288
        // pages.body.images.resize
289
        if (
290
            \is_int($this->config->get('pages.body.images.resize'))
291
            && $this->config->get('pages.body.images.resize') > 0
292
            && $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
            isset($InlineImage['element']['attributes']['width'])
300
            && $width > (int) $InlineImage['element']['attributes']['width']
301
        ) {
302
            $shouldResize = true;
303
            $width = (int) $InlineImage['element']['attributes']['width'];
304
        }
305
        // responsive images
306
        if (
307
            $this->config->isEnabled('pages.body.images.responsive')
308
            && !empty($this->config->getAssetsImagesWidths())
309
            && $width > max($this->config->getAssetsImagesWidths())
310
        ) {
311
            $shouldResize = true;
312
            $width = max($this->config->getAssetsImagesWidths());
313
        }
314
        if ($shouldResize) {
315
            try {
316
                $assetResized = $asset->resize($width);
317
            } catch (\Exception $e) {
318
                $this->builder->getLogger()->debug($e->getMessage());
319
320
                return $this->createFigure($InlineImage);
321
            }
322
        }
323
324
        // set width
325
        $InlineImage['element']['attributes']['width'] = $width;
326
        // set height
327
        $InlineImage['element']['attributes']['height'] = $assetResized['height'] ?? $asset['height'];
328
329
        // placeholder
330
        if (
331
            (!empty($this->config->get('pages.body.images.placeholder')) || isset($InlineImage['element']['attributes']['placeholder']))
332
            && \in_array($assetResized['subtype'] ?? $asset['subtype'], ['image/jpeg', 'image/png', 'image/gif'])
333
        ) {
334
            if (!\array_key_exists('placeholder', $InlineImage['element']['attributes'])) {
335
                $InlineImage['element']['attributes']['placeholder'] = (string) $this->config->get('pages.body.images.placeholder');
336
            }
337
            if (!\array_key_exists('style', $InlineImage['element']['attributes'])) {
338
                $InlineImage['element']['attributes']['style'] = '';
339
            }
340
            $InlineImage['element']['attributes']['style'] = trim($InlineImage['element']['attributes']['style'], ';');
341
            switch ($InlineImage['element']['attributes']['placeholder']) {
342
                case 'color':
343
                    $InlineImage['element']['attributes']['style'] .= \sprintf(';max-width:100%%;height:auto;background-color:%s;', Image::getDominantColor($assetResized ?? $asset));
344
                    break;
345
                case 'lqip':
346
                    // aborts if animated GIF for performance reasons
347
                    if (Image::isAnimatedGif($assetResized ?? $asset)) {
348
                        break;
349
                    }
350
                    $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
                    break;
352
            }
353
            unset($InlineImage['element']['attributes']['placeholder']);
354
            $InlineImage['element']['attributes']['style'] = trim($InlineImage['element']['attributes']['style']);
355
        }
356
357
        /*
358
         * Should be responsive?
359
         */
360
        $sizes = '';
361
        if ($this->config->isEnabled('pages.body.images.responsive')) {
362
            try {
363
                if (
364
                    $srcset = Image::buildHtmlSrcsetW(
365
                        $assetResized ?? $asset,
366
                        $this->config->getAssetsImagesWidths()
367
                    )
368
                ) {
369
                    $InlineImage['element']['attributes']['srcset'] = $srcset;
370
                    $sizes = Image::getHtmlSizes($InlineImage['element']['attributes']['class'] ?? '', (array) $this->config->getAssetsImagesSizes());
371
                    $InlineImage['element']['attributes']['sizes'] = $sizes;
372
                }
373
            } catch (\Exception $e) {
374
                $this->builder->getLogger()->warning($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
        $image = $InlineImage;
401
402
        // converts image to formats and put them in picture > source
403
        if (
404
            \count($formats = ((array) $this->config->get('pages.body.images.formats'))) > 0
405
            && \in_array($assetResized['subtype'] ?? $asset['subtype'], ['image/jpeg', 'image/png', 'image/gif'])
406
        ) {
407
            try {
408
                // abord if InlineImage is an animated GIF
409
                if (Image::isAnimatedGif($assetResized ?? $asset)) {
410
                    $filepath = Util::joinFile($this->config->getOutputPath(), $assetResized['path'] ?? $asset['path'] ?? '');
411
                    throw new RuntimeException(\sprintf('Asset "%s" is not converted (animated GIF).', $filepath));
412
                }
413
                $sources = [];
414
                foreach ($formats as $format) {
415
                    $srcset = '';
416
                    try {
417
                        $assetConverted = ($assetResized ?? $asset)->convert($format);
418
                        // build responsive images?
419
                        if ($this->config->isEnabled('pages.body.images.responsive')) {
420
                            $srcset = Image::buildHtmlSrcset($assetConverted, $this->config->getAssetsImagesWidths());
421
                        }
422
                        // if not, use default image as srcset
423
                        if (empty($srcset)) {
424
                            $srcset = (string) $assetConverted;
425
                        }
426
                        // add format to <sources>
427
                        $sources[] = [
428
                            'name'       => 'source',
429
                            'attributes' => [
430
                                'type'   => "image/$format",
431
                                'srcset' => $srcset,
432
                                'sizes'  => $sizes,
433
                                'width'  => $InlineImage['element']['attributes']['width'],
434
                                'height' => $InlineImage['element']['attributes']['height'],
435
                            ],
436
                        ];
437
                    } catch (\Exception $e) {
438
                        $this->builder->getLogger()->warning($e->getMessage());
439
                        continue;
440
                    }
441
                }
442
                if (\count($sources) > 0) {
443
                    $picture = [
444
                        'extent'  => $InlineImage['extent'],
445
                        'element' => [
446
                            'name'       => 'picture',
447
                            'handler'    => 'elements',
448
                            'attributes' => [
449
                                'title' => $image['element']['attributes']['title'],
450
                            ],
451
                        ],
452
                    ];
453
                    $picture['element']['text'] = $sources;
454
                    unset($image['element']['attributes']['title']); // phpstan-ignore unset.offset
455
                    $picture['element']['text'][] = $image['element'];
456
                    $image = $picture;
457
                }
458
            } catch (\Exception $e) {
459
                $this->builder->getLogger()->debug($e->getMessage());
460
            }
461
        }
462
463
        return $this->createFigure($image);
464
    }
465
466
    /**
467
     * Image block.
468
     */
469
    protected function blockImage($Excerpt)
470
    {
471
        if (1 !== preg_match($this->regexImage, $Excerpt['text'])) {
472
            return;
473
        }
474
475
        $InlineImage = $this->inlineImage($Excerpt);
476
        if (!isset($InlineImage)) {
477
            return;
478
        }
479
480
        return $InlineImage;
481
    }
482
483
    /**
484
     * Note block-level markup.
485
     *
486
     * :::tip
487
     * **Tip:** This is an advice.
488
     * :::
489
     *
490
     * Code inspired by https://github.com/sixlive/parsedown-alert from TJ Miller (@sixlive).
491
     */
492
    protected function blockNote($block)
493
    {
494
        if (preg_match('/:::(.*)/', $block['text'], $matches)) {
495
            $block = [
496
                'char'    => ':',
497
                'element' => [
498
                    'name'       => 'aside',
499
                    'text'       => '',
500
                    'attributes' => [
501
                        'class' => 'note',
502
                    ],
503
                ],
504
            ];
505
            if (!empty($matches[1])) {
506
                $block['element']['attributes']['class'] .= " note-{$matches[1]}";
507
            }
508
509
            return $block;
510
        }
511
    }
512
513
    protected function blockNoteContinue($line, $block)
514
    {
515
        if (isset($block['complete'])) {
516
            return;
517
        }
518
        if (preg_match('/:::/', $line['text'])) {
519
            $block['complete'] = true;
520
521
            return $block;
522
        }
523
        $block['element']['text'] .= $line['text'] . "\n";
524
525
        return $block;
526
    }
527
528
    protected function blockNoteComplete($block)
529
    {
530
        $block['element']['rawHtml'] = $this->text($block['element']['text']);
531
        unset($block['element']['text']);
532
533
        return $block;
534
    }
535
536
    /**
537
     * Apply Highlight to code blocks.
538
     */
539
    protected function blockFencedCodeComplete($block)
540
    {
541
        if (!$this->config->isEnabled('pages.body.highlight')) {
542
            return $block;
543
        }
544
        if (!isset($block['element']['text']['attributes'])) {
545
            return $block;
546
        }
547
548
        try {
549
            $code = $block['element']['text']['text'];
550
            $languageClass = $block['element']['text']['attributes']['class'];
551
            $language = explode('-', $languageClass);
552
            $highlighted = $this->highlighter->highlight($language[1], $code);
553
            $block['element']['text']['attributes']['class'] = vsprintf('%s hljs %s', [
554
                $languageClass,
555
                $highlighted->language,
556
            ]);
557
            $block['element']['text']['rawHtml'] = $highlighted->value;
558
            $block['element']['text']['allowRawHtmlInSafeMode'] = true;
559
            unset($block['element']['text']['text']);
560
        } catch (\Exception $e) {
561
            $this->builder->getLogger()->debug("Highlighter: " . $e->getMessage());
562
        } finally {
563
            return $block;
564
        }
565
    }
566
567
    /**
568
     * {@inheritdoc}
569
     */
570
    protected function parseAttributeData($attributeString)
571
    {
572
        $attributes = preg_split('/[ ]+/', $attributeString, -1, PREG_SPLIT_NO_EMPTY);
573
        $Data = [];
574
        $HtmlAtt = [];
575
576
        if (is_iterable($attributes)) {
577
            foreach ($attributes as $attribute) {
578
                switch ($attribute[0]) {
579
                    case '#': // ID
580
                        $Data['id'] = substr($attribute, 1);
581
                        break;
582
                    case '.': // Classes
583
                        $classes[] = substr($attribute, 1);
584
                        break;
585
                    default:  // Attributes
586
                        parse_str($attribute, $parsed);
587
                        $HtmlAtt = array_merge($HtmlAtt, $parsed);
588
                }
589
            }
590
591
            if (isset($classes)) {
592
                $Data['class'] = implode(' ', $classes);
593
            }
594
            if (!empty($HtmlAtt)) {
595
                foreach ($HtmlAtt as $a => $v) {
596
                    $Data[$a] = trim($v, '"');
597
                }
598
            }
599
        }
600
601
        return $Data;
602
    }
603
604
    /**
605
     * {@inheritdoc}
606
     *
607
     * Converts XHTML '<br />' tag to '<br>'.
608
     *
609
     * @return string
610
     */
611
    protected function unmarkedText($text)
612
    {
613
        return str_replace('<br />', '<br>', parent::unmarkedText($text)); // @phpstan-ignore staticMethod.notFound
614
    }
615
616
    /**
617
     * {@inheritdoc}
618
     *
619
     * XHTML closing tag to HTML5 closing tag.
620
     *
621
     * @return string
622
     */
623
    protected function element(array $Element)
624
    {
625
        return str_replace(' />', '>', parent::element($Element)); // @phpstan-ignore staticMethod.notFound
626
    }
627
628
    /**
629
     * Turns a path relative to static or assets into a website relative path.
630
     *
631
     *   "../../assets/images/img.jpeg"
632
     *   ->
633
     *   "/images/img.jpeg"
634
     */
635
    private function normalizePath(string $path): string
636
    {
637
        // https://regex101.com/r/Rzguzh/1
638
        $pattern = \sprintf(
639
            '(\.\.\/)+(\b%s|%s\b)+(\/.*)',
640
            (string) $this->config->get('static.dir'),
641
            (string) $this->config->get('assets.dir')
642
        );
643
        $path = Util::joinPath($path);
644
        if (!preg_match('/' . $pattern . '/is', $path, $matches)) {
645
            return $path;
646
        }
647
648
        return $matches[3];
649
    }
650
651
    /**
652
     * Create a media (video or audio) element from a link.
653
     */
654
    private function createMediaFromLink(array $link, string $type = 'video'): array
655
    {
656
        $block = [
657
            'extent'  => $link['extent'],
658
            'element' => [
659
                'text' => $link['element']['text'],
660
            ],
661
        ];
662
        $block['element']['attributes'] = $link['element']['attributes'];
663
        unset($block['element']['attributes']['href']);
664
        $block['element']['attributes']['src'] = new Url($this->builder, new Asset($this->builder, $link['element']['attributes']['href']));
665
        switch ($type) {
666
            case 'video':
667
                $block['element']['name'] = 'video';
668
                // no controls = autoplay, loop, muted, playsinline
669
                if (!isset($block['element']['attributes']['controls'])) {
670
                    $block['element']['attributes']['autoplay'] = '';
671
                    $block['element']['attributes']['loop'] = '';
672
                    $block['element']['attributes']['muted'] = '';
673
                    $block['element']['attributes']['playsinline'] = '';
674
                }
675
                if (isset($block['element']['attributes']['poster'])) {
676
                    $block['element']['attributes']['poster'] = new Url($this->builder, new Asset($this->builder, $block['element']['attributes']['poster']));
677
                }
678
                if (!\array_key_exists('style', $block['element']['attributes'])) {
679
                    $block['element']['attributes']['style'] = '';
680
                }
681
                $block['element']['attributes']['style'] .= ';max-width:100%;height:auto;background-color:#d8d8d8;'; // background color if offline
682
683
                return $block;
684
            case 'audio':
685
                $block['element']['name'] = 'audio';
686
687
                return $block;
688
        }
689
690
        throw new \Exception(\sprintf('Unable to create %s from "%s".', $type, $link['element']['attributes']['href']));
691
    }
692
693
    /**
694
     * Create an embedded video element from a link.
695
     *
696
     * $baseSrc is the base URL to embed the video
697
     * $match is the video ID or the rest of the URL to append to $baseSrc
698
     */
699
    private function createEmbeddedVideoFromLink(array $link, string $baseSrc, string $match): array
700
    {
701
        $iframe = [
702
            'element' => [
703
                'name'       => 'iframe',
704
                'text'       => $link['element']['text'],
705
                'attributes' => [
706
                    'width'           => '640',
707
                    'height'          => '360',
708
                    'title'           => $link['element']['text'],
709
                    'src'             => $baseSrc . $match,
710
                    'frameborder'     => '0',
711
                    'allow'           => 'accelerometer;autoplay;encrypted-media;gyroscope;picture-in-picture;fullscreen;web-share;',
712
                    'allowfullscreen' => '',
713
                    'style'           => 'position:absolute;top:0;left:0;width:100%;height:100%;border:0;background-color:#d8d8d8;',
714
                ],
715
            ],
716
        ];
717
718
        return [
719
            'extent'  => $link['extent'],
720
            'element' => [
721
                'name'    => 'div',
722
                'handler' => 'elements',
723
                'text'    => [
724
                    $iframe['element'],
725
                ],
726
                'attributes' => [
727
                    'style' => 'position:relative;padding-bottom:56.25%;height:0;overflow:hidden;',
728
                    'title' => $link['element']['attributes']['title'],
729
                ],
730
            ],
731
        ];
732
    }
733
734
    /**
735
     * Create a figure > figcaption element.
736
     */
737
    private function createFigure(array $inline): array
738
    {
739
        if (!$this->config->isEnabled('pages.body.images.caption')) {
740
            return $inline;
741
        }
742
743
        if (empty($inline['element']['attributes']['title'])) {
744
            return $inline;
745
        }
746
747
        $titleRawHtml = $this->line($inline['element']['attributes']['title']); // @phpstan-ignore method.notFound
748
        $inline['element']['attributes']['title'] = strip_tags($titleRawHtml);
749
750
        $figcaption = [
751
            'element' => [
752
                'name'                   => 'figcaption',
753
                'allowRawHtmlInSafeMode' => true,
754
                'rawHtml'                => $titleRawHtml,
755
            ],
756
        ];
757
        $figure = [
758
            'extent'  => $inline['extent'],
759
            'element' => [
760
                'name'    => 'figure',
761
                'handler' => 'elements',
762
                'text'    => [
763
                    $inline['element'],
764
                    $figcaption['element'],
765
                ],
766
            ],
767
        ];
768
769
        return $figure;
770
    }
771
772
    /**
773
     * Handle an external link.
774
     */
775
    private function handleExternalLink(array $link): array
776
    {
777
        if (
778
            str_starts_with($link['element']['attributes']['href'], 'http')
779
            && (!empty($this->config->get('baseurl')) && !str_starts_with($link['element']['attributes']['href'], (string) $this->config->get('baseurl')))
780
        ) {
781
            if ($this->config->isEnabled('pages.body.links.external.blank')) {
782
                $link['element']['attributes']['target'] = '_blank';
783
            }
784
            if (!\array_key_exists('rel', $link['element']['attributes'])) {
785
                $link['element']['attributes']['rel'] = '';
786
            }
787
            if ($this->config->isEnabled('pages.body.links.external.noopener')) {
788
                $link['element']['attributes']['rel'] .= ' noopener';
789
            }
790
            if ($this->config->isEnabled('pages.body.links.external.noreferrer')) {
791
                $link['element']['attributes']['rel'] .= ' noreferrer';
792
            }
793
            if ($this->config->isEnabled('pages.body.links.external.nofollow')) {
794
                $link['element']['attributes']['rel'] .= ' nofollow';
795
            }
796
            $link['element']['attributes']['rel'] = trim($link['element']['attributes']['rel']);
797
        }
798
799
        return $link;
800
    }
801
}
802