Passed
Push — analysis-Bo9Bmg ( b17016 )
by Arnaud
47:58
created

Parsedown::createMediaFromLink()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 24
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 1 Features 0
Metric Value
cc 4
eloc 17
c 2
b 1
f 0
nc 4
nop 2
dl 0
loc 24
rs 9.7
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Cecil.
7
 *
8
 * Copyright (c) Arnaud Ligny <[email protected]>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
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\Util;
21
use Highlight\Highlighter;
22
23
class Parsedown extends \ParsedownToC
24
{
25
    /** @var Builder */
26
    protected $builder;
27
28
    /** {@inheritdoc} */
29
    protected $regexAttribute = '(?:[#.][-\w:\\\]+[ ]*|[-\w:\\\]+(?:=(?:["\'][^\n]*?["\']|[^\s]+)?)?[ ]*)';
30
31
    /** Valid a media block (image, audio or video) */
32
    protected $MarkdownMediaRegex = "~^!\[.*?\]\(.*?\)~";
33
34
    /** @var Highlighter */
35
    protected $highlighter;
36
37
    public function __construct(Builder $builder, ?array $options = null)
38
    {
39
        $this->builder = $builder;
40
41
        // "insert" line block: ++text++ -> <ins>text</ins>
42
        $this->InlineTypes['+'][] = 'Insert';
43
        $this->inlineMarkerList = implode('', array_keys($this->InlineTypes));
44
        $this->specialCharacters[] = '+';
45
46
        // Media (image, audio or video) block
47
        $this->BlockTypes['!'][] = 'Media';
48
49
        // "notes" block
50
        $this->BlockTypes[':'][] = 'Note';
51
52
        // code highlight
53
        $this->highlighter = new Highlighter();
54
55
        // options
56
        $options = array_merge(['selectors' => $this->builder->getConfig()->get('body.toc')], $options ?? []);
57
58
        parent::__construct($options);
59
    }
60
61
    /**
62
     * Insert inline.
63
     * e.g.: ++text++ -> <ins>text</ins>.
64
     */
65
    protected function inlineInsert($Excerpt)
66
    {
67
        if (!isset($Excerpt['text'][1])) {
68
            return;
69
        }
70
71
        if ($Excerpt['text'][1] === '+' && preg_match('/^\+\+(?=\S)(.+?)(?<=\S)\+\+/', $Excerpt['text'], $matches)) {
72
            return [
73
                'extent'  => strlen($matches[0]),
74
                'element' => [
75
                    'name'    => 'ins',
76
                    'text'    => $matches[1],
77
                    'handler' => 'line',
78
                ],
79
            ];
80
        }
81
    }
82
83
    /**
84
     * {@inheritdoc}
85
     */
86
    protected function inlineLink($Excerpt)
87
    {
88
        $link = parent::inlineLink($Excerpt);
89
90
        if (!isset($link)) {
91
            return null;
92
        }
93
94
        // Link to a page with "page:page_id" as URL
95
        if (Util\Str::startsWith($link['element']['attributes']['href'], 'page:')) {
96
            $link['element']['attributes']['href'] = new \Cecil\Assets\Url($this->builder, substr($link['element']['attributes']['href'], 5, strlen($link['element']['attributes']['href'])));
97
98
            return $link;
99
        }
100
101
        /*
102
         * Embed link?
103
         */
104
        $embed = false;
105
        $embed = (bool) $this->builder->getConfig()->get('body.links.embed.enabled') ?? false;
106
        if (isset($link['element']['attributes']['embed'])) {
107
            $embed = true;
108
            if ($link['element']['attributes']['embed'] == 'false') {
109
                $embed = false;
110
            }
111
            unset($link['element']['attributes']['embed']);
112
        }
113
        if (!$embed) {
114
            return $link;
115
        }
116
        // video or audio?
117
        $extension = pathinfo($link['element']['attributes']['href'], PATHINFO_EXTENSION);
118
        if (in_array($extension, $this->builder->getConfig()->get('body.links.embed.video.ext'))) {
0 ignored issues
show
Bug introduced by
It seems like $this->builder->getConfi...links.embed.video.ext') can also be of type null; however, parameter $haystack of in_array() does only seem to accept array, 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

118
        if (in_array($extension, /** @scrutinizer ignore-type */ $this->builder->getConfig()->get('body.links.embed.video.ext'))) {
Loading history...
119
            return $this->createMediaFromLink($link, 'video');
120
        }
121
        if (in_array($extension, $this->builder->getConfig()->get('body.links.embed.audio.ext'))) {
122
            return $this->createMediaFromLink($link, 'audio');
123
        }
124
        // GitHub Gist link?
125
        // https://regex101.com/r/QmCiAL/1
126
        $pattern = 'https:\/\/gist\.github.com\/[-a-zA-Z0-9_]+\/[-a-zA-Z0-9_]+';
127
        if (preg_match('/'.$pattern.'/is', (string) $link['element']['attributes']['href'], $matches)) {
128
            return [
129
                'extent'  => $link['extent'],
130
                'element' => [
131
                    'name'       => 'script',
132
                    'text'       => $link['element']['text'],
133
                    'attributes' => [
134
                        'src' => $matches[0].'.js',
135
                    ],
136
                ],
137
            ];
138
        }
139
        // Youtube link?
140
        // https://regex101.com/r/gznM1j/1
141
        $pattern = '(?:https?:\/\/)?(?:www\.)?youtu(?:\.be\/|be.com\/\S*(?:watch|embed)(?:(?:(?=\/[-a-zA-Z0-9_]{11,}(?!\S))\/)|(?:\S*v=|v\/)))([-a-zA-Z0-9_]{11,})';
142
        if (preg_match('/'.$pattern.'/is', (string) $link['element']['attributes']['href'], $matches)) {
143
            $iframe = [
144
                'element' => [
145
                    'name'       => 'iframe',
146
                    'text'       => $link['element']['text'],
147
                    'attributes' => [
148
                        'width'           => '560',
149
                        'height'          => '315',
150
                        'title'           => $link['element']['text'],
151
                        'src'             => 'https://www.youtube.com/embed/'.$matches[1],
152
                        'frameborder'     => '0',
153
                        'allow'           => 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture',
154
                        'allowfullscreen' => '',
155
                        'style'           => 'position:absolute; top:0; left:0; width:100%; height:100%; border:0',
156
                    ],
157
                ],
158
            ];
159
160
            return [
161
                'extent'  => $link['extent'],
162
                'element' => [
163
                    'name'    => 'div',
164
                    'handler' => 'elements',
165
                    'text'    => [
166
                        $iframe['element'],
167
                    ],
168
                    'attributes' => [
169
                        'style' => 'position:relative; padding-bottom:56.25%; height:0; overflow:hidden',
170
                    ],
171
                ],
172
            ];
173
        }
174
175
        return $link;
176
    }
177
178
    /**
179
     * {@inheritdoc}
180
     */
181
    protected function inlineImage($Excerpt)
182
    {
183
        $image = parent::inlineImage($Excerpt);
184
        if (!isset($image)) {
185
            return null;
186
        }
187
188
        // remove quesry string
189
        $image['element']['attributes']['src'] = $this->removeQueryString($image['element']['attributes']['src']);
190
191
        // normalize path
192
        $image['element']['attributes']['src'] = $this->normalizePath($image['element']['attributes']['src']);
193
194
        // should be lazy loaded?
195
        if ($this->builder->getConfig()->get('body.images.lazy.enabled') && !isset($image['element']['attributes']['loading'])) {
196
            $image['element']['attributes']['loading'] = 'lazy';
197
        }
198
199
        // add default class?
200
        if ($this->builder->getConfig()->get('body.images.class')) {
201
            $image['element']['attributes']['class'] .= ' '.$this->builder->getConfig()->get('body.images.class');
202
            $image['element']['attributes']['class'] = trim($image['element']['attributes']['class']);
203
        }
204
205
        // disable remote image handling?
206
        if (Util\Url::isUrl($image['element']['attributes']['src']) && !$this->builder->getConfig()->get('body.images.remote.enabled') ?? true) {
207
            return $image;
208
        }
209
210
        // create asset
211
        $assetOptions = ['force_slash' => false];
212
        if ($this->builder->getConfig()->get('body.images.remote.fallback.enabled')) {
213
            $assetOptions += ['remote_fallback' => $this->builder->getConfig()->get('body.images.remote.fallback.path')];
214
        }
215
        $asset = new Asset($this->builder, $image['element']['attributes']['src'], $assetOptions);
216
        $image['element']['attributes']['src'] = $asset;
217
        $width = $asset->getWidth();
218
219
        /**
220
         * Should be resized?
221
         */
222
        $assetResized = null;
223
        if (isset($image['element']['attributes']['width'])
224
            && (int) $image['element']['attributes']['width'] < $width
225
            && $this->builder->getConfig()->get('body.images.resize.enabled')
226
        ) {
227
            $width = (int) $image['element']['attributes']['width'];
228
229
            try {
230
                $assetResized = $asset->resize($width);
231
                $image['element']['attributes']['src'] = $assetResized;
232
            } catch (\Exception $e) {
233
                $this->builder->getLogger()->debug($e->getMessage());
234
235
                return $image;
236
            }
237
        }
238
239
        // set width
240
        if (!isset($image['element']['attributes']['width']) && $asset['type'] == 'image') {
241
            $image['element']['attributes']['width'] = $width;
242
        }
243
        // set height
244
        if (!isset($image['element']['attributes']['height']) && $asset['type'] == 'image') {
245
            $image['element']['attributes']['height'] = ($assetResized ?? $asset)->getHeight();
246
        }
247
248
        /**
249
         * Should be responsive?
250
         */
251
        if ($asset['type'] == 'image' && $this->builder->getConfig()->get('body.images.responsive.enabled')) {
252
            try {
253
                if ($srcset = Image::buildSrcset(
254
                    $assetResized ?? $asset,
255
                    $this->builder->getConfig()->get('assets.images.responsive.widths') ?? [480, 640, 768, 1024, 1366, 1600, 1920]
256
                )) {
257
                    $image['element']['attributes']['srcset'] = $srcset;
258
                    $image['element']['attributes']['sizes'] = $this->builder->getConfig()->get('assets.images.responsive.sizes.default');
259
                }
260
            } catch (\Exception $e) {
261
                $this->builder->getLogger()->debug($e->getMessage());
262
            }
263
        }
264
265
        return $image;
266
    }
267
268
    /**
269
     * Media block support:
270
     * 1. <picture>/<source> for WebP images
271
     * 2. <audio> and <video> elements
272
     * 3. <figure>/<figcaption> for element with a title.
273
     */
274
    protected function blockMedia($Excerpt)
275
    {
276
        if (1 !== preg_match($this->MarkdownMediaRegex, $Excerpt['text'])) {
277
            return;
278
        }
279
280
        $InlineImage = $this->inlineImage($Excerpt);
281
        if (!isset($InlineImage)) {
282
            return;
283
        }
284
285
        // clean title (and preserve raw HTML)
286
        $titleRawHtml = '';
287
        if (isset($InlineImage['element']['attributes']['title'])) {
288
            $titleRawHtml = $this->line($InlineImage['element']['attributes']['title']);
289
            $InlineImage['element']['attributes']['title'] = strip_tags($titleRawHtml);
290
        }
291
292
        $block = $InlineImage;
293
294
        /*
295
        <!-- if image has a title: a <figure> is required for <figcaption> -->
296
        <figure>
297
            <!-- if WebP: a <picture> is required for <source> -->
298
            <picture>
299
                <source type="image/webp"
300
                    srcset="..."
301
                    sizes="..."
302
                >
303
                <img src="..."
304
                    srcset="..."
305
                    sizes="..."
306
                >
307
            </picture>
308
            <!-- title -->
309
            <figcaption>...</figcaption>
310
        </figure>
311
        */
312
313
        // creates a <picture> used to add WebP <source> in addition to the image <img> element
314
        if ($this->builder->getConfig()->get('body.images.webp.enabled') ?? false
315
            && (($InlineImage['element']['attributes']['src'])['type'] == 'image'
316
                && ($InlineImage['element']['attributes']['src'])['subtype'] != 'image/webp')
317
        ) {
318
            try {
319
                // Image src must be an Asset instance
320
                if (is_string($InlineImage['element']['attributes']['src'])) {
321
                    throw new RuntimeException(\sprintf('Asset "%s" can\'t be converted to WebP', $InlineImage['element']['attributes']['src']));
322
                }
323
                // Image asset is an animated GIF
324
                if (Image::isAnimatedGif($InlineImage['element']['attributes']['src'])) {
325
                    throw new RuntimeException(\sprintf('Asset "%s" is an animated GIF and can\'t be converted to WebP', $InlineImage['element']['attributes']['src']));
326
                }
327
                $assetWebp = Image::convertTopWebp($InlineImage['element']['attributes']['src'], $this->builder->getConfig()->get('assets.images.quality') ?? 75);
328
                $srcset = '';
329
                // build responsives WebP?
330
                if ($this->builder->getConfig()->get('body.images.responsive.enabled')) {
331
                    try {
332
                        $srcset = Image::buildSrcset(
333
                            $assetWebp,
334
                            $this->builder->getConfig()->get('assets.images.responsive.widths') ?? [480, 640, 768, 1024, 1366, 1600, 1920]
335
                        );
336
                    } catch (\Exception $e) {
337
                        $this->builder->getLogger()->debug($e->getMessage());
338
                    }
339
                }
340
                // if not, default image as srcset
341
                if (empty($srcset)) {
342
                    $srcset = (string) $assetWebp;
343
                }
344
                $PictureBlock = [
345
                    'element' => [
346
                        'name'    => 'picture',
347
                        'handler' => 'elements',
348
                    ],
349
                ];
350
                $source = [
351
                    'element' => [
352
                        'name'       => 'source',
353
                        'attributes' => [
354
                            'type'   => 'image/webp',
355
                            'srcset' => $srcset,
356
                            'sizes'  => $this->builder->getConfig()->get('assets.images.responsive.sizes.default'),
357
                        ],
358
                    ],
359
                ];
360
                $PictureBlock['element']['text'][] = $source['element'];
361
                $PictureBlock['element']['text'][] = $block['element'];
362
                $block = $PictureBlock;
363
            } catch (\Exception $e) {
364
                $this->builder->getLogger()->debug($e->getMessage());
365
            }
366
        }
367
368
        // if there is a title: put the <img> (or <picture>) in a <figure> element to use the <figcaption>
369
        if ($this->builder->getConfig()->get('body.images.caption.enabled') && !empty($titleRawHtml)) {
370
            $FigureBlock = [
371
                'element' => [
372
                    'name'    => 'figure',
373
                    'handler' => 'elements',
374
                    'text'    => [
375
                        $block['element'],
376
                    ],
377
                ],
378
            ];
379
            $InlineFigcaption = [
380
                'element' => [
381
                    'name'                   => 'figcaption',
382
                    'allowRawHtmlInSafeMode' => true,
383
                    'rawHtml'                => $this->line($titleRawHtml),
384
                ],
385
            ];
386
            $FigureBlock['element']['text'][] = $InlineFigcaption['element'];
387
388
            return $FigureBlock;
389
        }
390
391
        return $block;
392
    }
393
394
    /**
395
     * Note block-level markup.
396
     *
397
     * :::tip
398
     * **Tip:** This is an advice.
399
     * :::
400
     *
401
     * Code inspired by https://github.com/sixlive/parsedown-alert from TJ Miller (@sixlive).
402
     */
403
    protected function blockNote($block)
404
    {
405
        if (preg_match('/:::(.*)/', $block['text'], $matches)) {
406
            $block = [
407
                'char'    => ':',
408
                'element' => [
409
                    'name'       => 'aside',
410
                    'text'       => '',
411
                    'attributes' => [
412
                        'class' => 'note',
413
                    ],
414
                ],
415
            ];
416
            if (!empty($matches[1])) {
417
                $block['element']['attributes']['class'] .= " note-{$matches[1]}";
418
            }
419
420
            return $block;
421
        }
422
    }
423
424
    protected function blockNoteContinue($line, $block)
425
    {
426
        if (isset($block['complete'])) {
427
            return;
428
        }
429
        if (preg_match('/:::/', $line['text'])) {
430
            $block['complete'] = true;
431
432
            return $block;
433
        }
434
        $block['element']['text'] .= $line['text']."\n";
435
436
        return $block;
437
    }
438
439
    protected function blockNoteComplete($block)
440
    {
441
        $block['element']['rawHtml'] = $this->text($block['element']['text']);
442
        unset($block['element']['text']);
443
444
        return $block;
445
    }
446
447
    /**
448
     * Apply Highlight to code blocks.
449
     */
450
    protected function blockFencedCodeComplete($block)
451
    {
452
        if (!$this->builder->getConfig()->get('body.highlight.enabled')) {
453
            return $block;
454
        }
455
        if (!isset($block['element']['text']['attributes'])) {
456
            return $block;
457
        }
458
459
        try {
460
            $code = $block['element']['text']['text'];
461
            $languageClass = $block['element']['text']['attributes']['class'];
462
            $language = explode('-', $languageClass);
463
            $highlighted = $this->highlighter->highlight($language[1], $code);
464
            $block['element']['text']['attributes']['class'] = vsprintf('%s hljs %s', [
465
                $languageClass,
466
                $highlighted->language,
467
            ]);
468
            $block['element']['text']['rawHtml'] = $highlighted->value;
469
            $block['element']['text']['allowRawHtmlInSafeMode'] = true;
470
            unset($block['element']['text']['text']);
471
        } catch (\Exception $e) {
472
            $this->builder->getLogger()->debug($e->getMessage());
473
        } finally {
474
            return $block;
475
        }
476
    }
477
478
    /**
479
     * {@inheritdoc}
480
     */
481
    protected function parseAttributeData($attributeString)
482
    {
483
        $attributes = preg_split('/[ ]+/', $attributeString, -1, PREG_SPLIT_NO_EMPTY);
484
        $Data = [];
485
        $HtmlAtt = [];
486
487
        foreach ($attributes as $attribute) {
488
            switch ($attribute[0]) {
489
                case '#': // ID
490
                    $Data['id'] = substr($attribute, 1);
491
                    break;
492
                case '.': // Classes
493
                    $classes[] = substr($attribute, 1);
494
                    break;
495
                default:  // Attributes
496
                    parse_str($attribute, $parsed);
497
                    $HtmlAtt = array_merge($HtmlAtt, $parsed);
498
            }
499
        }
500
501
        if (isset($classes)) {
502
            $Data['class'] = implode(' ', $classes);
503
        }
504
        if (!empty($HtmlAtt)) {
505
            foreach ($HtmlAtt as $a => $v) {
506
                $Data[$a] = trim($v, '"');
507
            }
508
        }
509
510
        return $Data;
511
    }
512
513
    /**
514
     * Remove query string form a path/URL.
515
     */
516
    private function removeQueryString(string $path): string
517
    {
518
        return strtok(trim($path), '?') ?: trim($path);
519
    }
520
521
    /**
522
     * Turns a path relative to static or assets into a website relative path.
523
     *
524
     *   "../../assets/images/img.jpeg"
525
     *   ->
526
     *   "/images/img.jpeg"
527
     */
528
    private function normalizePath(string $path): string
529
    {
530
        // https://regex101.com/r/Rzguzh/1
531
        $pattern = \sprintf(
532
            '(\.\.\/)+(\b%s|%s\b)+(\/.*)',
533
            $this->builder->getConfig()->get('static.dir'),
534
            $this->builder->getConfig()->get('assets.dir')
535
        );
536
        $path = Util::joinPath($path);
537
        if (!preg_match('/'.$pattern.'/is', $path, $matches)) {
538
            return $path;
539
        }
540
541
        return $matches[3];
542
    }
543
544
    /**
545
     * Create a media (video or audio) element from a link.
546
     */
547
    private function createMediaFromLink(array $link, string $type = 'video'): array
548
    {
549
        $block = [
550
            'extent'  => $link['extent'],
551
            'element' => [
552
                'handler' => 'element',
553
            ],
554
        ];
555
        $block['element']['attributes'] = $link['element']['attributes'];
556
        unset($block['element']['attributes']['href']);
557
        $block['element']['attributes']['src'] = (string) new Asset($this->builder, $link['element']['attributes']['href'], ['force_slash' => false]);
558
        switch ($type) {
559
            case 'video':
560
                $block['element']['name'] = 'video';
561
                if (isset($link['element']['attributes']['poster'])) {
562
                    $block['element']['attributes']['poster'] = (string) new Asset($this->builder, $link['element']['attributes']['poster'], ['force_slash' => false]);
563
                }
564
565
                return $block;
566
            case 'audio':
567
                $block['element']['name'] = 'audio';
568
                break;
569
570
            throw new \Exception(\sprintf('Can\'t create %s from "%s".', $type, $link['element']['attributes']['href']));
0 ignored issues
show
Unused Code introduced by
ThrowNode is not reachable.

This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed.

Unreachable code is most often the result of return, die or exit statements that have been added for debug purposes.

function fx() {
    try {
        doSomething();
        return true;
    }
    catch (\Exception $e) {
        return false;
    }

    return false;
}

In the above example, the last return false will never be executed, because a return statement has already been met in every possible execution path.

Loading history...
571
        }
572
    }
573
}
574