Parsedown::handleExternalLink()   B
last analyzed

Complexity

Conditions 9
Paths 33

Size

Total Lines 25
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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