Passed
Push — master ( a89361...39f65b )
by
unknown
05:05
created

Parsedown::inlineLink()   C

Complexity

Conditions 16
Paths 38

Size

Total Lines 96
Code Lines 52

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 51
CRAP Score 16.1823

Importance

Changes 5
Bugs 3 Features 1
Metric Value
cc 16
eloc 52
c 5
b 3
f 1
nc 38
nop 1
dl 0
loc 96
ccs 51
cts 56
cp 0.9107
crap 16.1823
rs 5.5666

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

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