Passed
Push — converter ( 50b3ae )
by Arnaud
03:35
created

Parsedown::blockMedia()   C

Complexity

Conditions 15
Paths 134

Size

Total Lines 118
Code Lines 60

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 35.8329

Importance

Changes 5
Bugs 3 Features 0
Metric Value
cc 15
eloc 60
c 5
b 3
f 0
nc 134
nop 1
dl 0
loc 118
ccs 23
cts 42
cp 0.5476
crap 35.8329
rs 5.6333

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
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 image */
32
    protected $regexImageBlock = "~^!\[.*?\]\(.*?\)~";
33
34
    /** @var Highlighter */
35
    protected $highlighter;
36
37 1
    public function __construct(Builder $builder, ?array $options = null)
38
    {
39 1
        $this->builder = $builder;
40
41
        // "insert" line block: ++text++ -> <ins>text</ins>
42 1
        $this->InlineTypes['+'][] = 'Insert';
43 1
        $this->inlineMarkerList = implode('', array_keys($this->InlineTypes));
44 1
        $this->specialCharacters[] = '+';
45
46
        // Image block
47 1
        $this->BlockTypes['!'][] = 'Image';
48
49
        // "notes" block
50 1
        $this->BlockTypes[':'][] = 'Note';
51
52
        // code highlight
53 1
        $this->highlighter = new Highlighter();
54
55
        // options
56 1
        $options = array_merge(['selectors' => $this->builder->getConfig()->get('body.toc')], $options ?? []);
57
58
        parent::__construct($options);
59
    }
60
61
    /**
62
     * Insert inline.
63 1
     * e.g.: ++text++ -> <ins>text</ins>.
64
     */
65 1
    protected function inlineInsert($Excerpt)
66
    {
67
        if (!isset($Excerpt['text'][1])) {
68
            return;
69 1
        }
70
71 1
        if ($Excerpt['text'][1] === '+' && preg_match('/^\+\+(?=\S)(.+?)(?<=\S)\+\+/', $Excerpt['text'], $matches)) {
72
            return [
73 1
                'extent'  => strlen($matches[0]),
74 1
                'element' => [
75
                    'name'    => 'ins',
76
                    'text'    => $matches[1],
77
                    'handler' => 'line',
78
                ],
79
            ];
80
        }
81
    }
82
83
    /**
84 1
     * {@inheritdoc}
85
     */
86 1
    protected function inlineLink($Excerpt)
87 1
    {
88
        $link = parent::inlineLink($Excerpt);
89
90
        if (!isset($link)) {
91
            return null;
92 1
        }
93
94
        // Link to a page with "page:page_id" as URL
95 1
        if (Util\Str::startsWith($link['element']['attributes']['href'], 'page:')) {
96 1
            $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 1
101
        /*
102
         * Embed link?
103
         */
104
        $embed = false;
105 1
        $embed = (bool) $this->builder->getConfig()->get('body.links.embed.enabled') ?? false;
106 1
        if (isset($link['element']['attributes']['embed'])) {
107 1
            $embed = true;
108
            if ($link['element']['attributes']['embed'] == 'false') {
109
                $embed = false;
110
            }
111
            unset($link['element']['attributes']['embed']);
112 1
        }
113 1
        if (!$embed) {
114 1
            return $link;
115 1
        }
116
        // video or audio?
117 1
        $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 1
        }
121 1
        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 1
                'element' => [
131 1
                    'name'       => 'script',
132
                    'text'       => $link['element']['text'],
133
                    'attributes' => [
134 1
                        'src' => $matches[0].'.js',
135 1
                    ],
136
                ],
137
            ];
138
        }
139
        // Youtube link?
140
        // https://regex101.com/r/gznM1j/1
141 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,})';
142 1
        if (preg_match('/'.$pattern.'/is', (string) $link['element']['attributes']['href'], $matches)) {
143 1
            $iframe = [
144 1
                'element' => [
145
                    'name'       => 'iframe',
146
                    'text'       => $link['element']['text'],
147
                    'attributes' => [
148
                        'width'           => '560',
149
                        'height'          => '315',
150
                        'title'           => $link['element']['text'],
151 1
                        '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 1
            return [
161
                'extent'  => $link['extent'],
162 1
                'element' => [
163
                    'name'    => 'div',
164
                    'handler' => 'elements',
165
                    'text'    => [
166 1
                        $iframe['element'],
167 1
                    ],
168
                    'attributes' => [
169
                        'style' => 'position:relative; padding-bottom:56.25%; height:0; overflow:hidden',
170 1
                    ],
171
                ],
172 1
            ];
173 1
        }
174 1
175 1
        return $link;
176
    }
177
178
    /**
179 1
     * {@inheritdoc}
180 1
     */
181 1
    protected function inlineImage($Excerpt)
182 1
    {
183 1
        $image = parent::inlineImage($Excerpt);
184 1
        if (!isset($image)) {
185 1
            return null;
186 1
        }
187
188
        // remove quesry string
189
        $image['element']['attributes']['src'] = $this->removeQueryString($image['element']['attributes']['src']);
190 1
191 1
        // normalize path
192 1
        $image['element']['attributes']['src'] = $this->normalizePath($image['element']['attributes']['src']);
193 1
194
        // should be lazy loaded?
195 1
        if ($this->builder->getConfig()->get('body.images.lazy.enabled') && !isset($image['element']['attributes']['loading'])) {
196 1
            $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 1
        /**
220
         * Should be resized?
221
         */
222
        $assetResized = null;
223
        if (isset($image['element']['attributes']['width'])
224 1
            && (int) $image['element']['attributes']['width'] < $width
225
            && $this->builder->getConfig()->get('body.images.resize.enabled')
226
        ) {
227
            $width = (int) $image['element']['attributes']['width'];
228 1
229
            try {
230
                $assetResized = $asset->resize($width);
231 1
                $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 1
                $this->builder->getLogger()->debug($e->getMessage());
262 1
            }
263
        }
264
265
        return $image;
266
    }
267 1
268
    /**
269
     * Image block support:
270 1
     * 1. <picture>/<source> for WebP images
271
     * 2. <figure>/<figcaption> for element with a title.
272
     */
273 1
    protected function blockImage($Excerpt)
274
    {
275
        if (1 !== preg_match($this->regexImageBlock, $Excerpt['text'])) {
276
            return;
277
        }
278
279 1
        $InlineImage = $this->inlineImage($Excerpt);
280 1
        if (!isset($InlineImage)) {
281
            return;
282
        }
283 1
284
        /*
285 1
        <!-- if title: a <figure> is required to put it in <figcaption> -->
286
        <figure>
287
            <!-- if WebP is enabled: a <picture> is required for <source> -->
288 1
            <picture>
289
                <source type="image/webp"
290
                    srcset="..."
291
                    sizes="..."
292
                >
293
                <img src="..."
294
                    srcset="..."
295
                    sizes="..."
296
                >
297
            </picture>
298
            <figcaption><!-- title --></figcaption>
299
        </figure>
300 1
        */
301
302 1
        // clean title (and preserve raw HTML)
303
        $titleRawHtml = '';
304
        if (isset($InlineImage['element']['attributes']['title'])) {
305
            $titleRawHtml = $this->line($InlineImage['element']['attributes']['title']);
306
            $InlineImage['element']['attributes']['title'] = strip_tags($titleRawHtml);
307
        }
308
309
        $block = $InlineImage;
310
311
        // converts image to WebP and put it in picture > source
312
        if ($this->builder->getConfig()->get('body.images.webp.enabled') ?? false
313 1
            && (($InlineImage['element']['attributes']['src'])['type'] == 'image'
314 1
                && ($InlineImage['element']['attributes']['src'])['subtype'] != 'image/webp')
315
        ) {
316
            try {
317 1
                // Image src must be an Asset instance
318
                if (is_string($InlineImage['element']['attributes']['src'])) {
319
                    throw new RuntimeException(\sprintf('Asset "%s" can\'t be converted to WebP', $InlineImage['element']['attributes']['src']));
320
                }
321 1
                // Image asset is an animated GIF
322
                if (Image::isAnimatedGif($InlineImage['element']['attributes']['src'])) {
323 1
                    throw new RuntimeException(\sprintf('Asset "%s" is an animated GIF and can\'t be converted to WebP', $InlineImage['element']['attributes']['src']));
324 1
                }
325
                $assetWebp = Image::convertTopWebp($InlineImage['element']['attributes']['src'], $this->builder->getConfig()->get('assets.images.quality') ?? 75);
326 1
                $srcset = '';
327 1
                // build responsives WebP?
328
                if ($this->builder->getConfig()->get('body.images.responsive.enabled')) {
329 1
                    try {
330
                        $srcset = Image::buildSrcset(
331 1
                            $assetWebp,
332
                            $this->builder->getConfig()->get('assets.images.responsive.widths') ?? [480, 640, 768, 1024, 1366, 1600, 1920]
333 1
                        );
334
                    } catch (\Exception $e) {
335
                        $this->builder->getLogger()->debug($e->getMessage());
336 1
                    }
337
                }
338 1
                // if not, default image as srcset
339 1
                if (empty($srcset)) {
340
                    $srcset = (string) $assetWebp;
341 1
                }
342
                $PictureBlock = [
343
                    'element' => [
344
                        'name'    => 'picture',
345
                        'handler' => 'elements',
346
                    ],
347 1
                ];
348
                $source = [
349 1
                    'element' => [
350
                        'name'       => 'source',
351
                        'attributes' => [
352 1
                            'type'   => 'image/webp',
353
                            'srcset' => $srcset,
354
                            'sizes'  => $this->builder->getConfig()->get('assets.images.responsive.sizes.default'),
355
                        ],
356 1
                    ],
357 1
                ];
358 1
                $PictureBlock['element']['text'][] = $source['element'];
359 1
                $PictureBlock['element']['text'][] = $block['element'];
360 1
                $block = $PictureBlock;
361 1
            } catch (\Exception $e) {
362
                $this->builder->getLogger()->debug($e->getMessage());
363 1
            }
364
        }
365 1
366 1
        // if there is a title: put the <img> (or <picture>) in a <figure> element to use the <figcaption>
367
        if ($this->builder->getConfig()->get('body.images.caption.enabled') && !empty($titleRawHtml)) {
368 1
            $FigureBlock = [
369
                'element' => [
370
                    'name'    => 'figure',
371
                    'handler' => 'elements',
372
                    'text'    => [
373
                        $block['element'],
374 1
                    ],
375
                ],
376 1
            ];
377 1
            $InlineFigcaption = [
378 1
                'element' => [
379
                    'name'                   => 'figcaption',
380 1
                    'allowRawHtmlInSafeMode' => true,
381 1
                    'rawHtml'                => $this->line($titleRawHtml),
382 1
                ],
383 1
            ];
384 1
            $FigureBlock['element']['text'][] = $InlineFigcaption['element'];
385 1
386 1
            return $FigureBlock;
387 1
        }
388
389 1
        return $block;
390 1
    }
391
392
    /**
393
     * Note block-level markup.
394 1
     *
395 1
     * :::tip
396
     * **Tip:** This is an advice.
397 1
     * :::
398 1
     *
399 1
     * Code inspired by https://github.com/sixlive/parsedown-alert from TJ Miller (@sixlive).
400
     */
401
    protected function blockNote($block)
402
    {
403 1
        if (preg_match('/:::(.*)/', $block['text'], $matches)) {
404
            $block = [
405
                'char'    => ':',
406
                'element' => [
407
                    'name'       => 'aside',
408
                    'text'       => '',
409 1
                    'attributes' => [
410
                        'class' => 'note',
411 1
                    ],
412
                ],
413
            ];
414
            if (!empty($matches[1])) {
415
                $block['element']['attributes']['class'] .= " note-{$matches[1]}";
416
            }
417
418
            return $block;
419
        }
420
    }
421
422
    protected function blockNoteContinue($line, $block)
423
    {
424
        if (isset($block['complete'])) {
425
            return;
426
        }
427
        if (preg_match('/:::/', $line['text'])) {
428
            $block['complete'] = true;
429
430
            return $block;
431
        }
432
        $block['element']['text'] .= $line['text']."\n";
433
434
        return $block;
435
    }
436
437
    protected function blockNoteComplete($block)
438
    {
439
        $block['element']['rawHtml'] = $this->text($block['element']['text']);
440
        unset($block['element']['text']);
441
442
        return $block;
443
    }
444
445
    /**
446
     * Apply Highlight to code blocks.
447
     */
448
    protected function blockFencedCodeComplete($block)
449
    {
450
        if (!$this->builder->getConfig()->get('body.highlight.enabled')) {
451
            return $block;
452
        }
453
        if (!isset($block['element']['text']['attributes'])) {
454
            return $block;
455
        }
456
457
        try {
458
            $code = $block['element']['text']['text'];
459
            $languageClass = $block['element']['text']['attributes']['class'];
460
            $language = explode('-', $languageClass);
461
            $highlighted = $this->highlighter->highlight($language[1], $code);
462
            $block['element']['text']['attributes']['class'] = vsprintf('%s hljs %s', [
463
                $languageClass,
464
                $highlighted->language,
465
            ]);
466
            $block['element']['text']['rawHtml'] = $highlighted->value;
467
            $block['element']['text']['allowRawHtmlInSafeMode'] = true;
468
            unset($block['element']['text']['text']);
469
        } catch (\Exception $e) {
470
            $this->builder->getLogger()->debug($e->getMessage());
471
        } finally {
472
            return $block;
473
        }
474
    }
475
476
    /**
477
     * {@inheritdoc}
478
     */
479
    protected function parseAttributeData($attributeString)
480
    {
481
        $attributes = preg_split('/[ ]+/', $attributeString, -1, PREG_SPLIT_NO_EMPTY);
482
        $Data = [];
483
        $HtmlAtt = [];
484
485
        foreach ($attributes as $attribute) {
486
            switch ($attribute[0]) {
487
                case '#': // ID
488
                    $Data['id'] = substr($attribute, 1);
489
                    break;
490
                case '.': // Classes
491
                    $classes[] = substr($attribute, 1);
492
                    break;
493
                default:  // Attributes
494
                    parse_str($attribute, $parsed);
495
                    $HtmlAtt = array_merge($HtmlAtt, $parsed);
496
            }
497
        }
498
499
        if (isset($classes)) {
500
            $Data['class'] = implode(' ', $classes);
501
        }
502
        if (!empty($HtmlAtt)) {
503
            foreach ($HtmlAtt as $a => $v) {
504
                $Data[$a] = trim($v, '"');
505
            }
506
        }
507
508
        return $Data;
509
    }
510
511
    /**
512
     * Remove query string form a path/URL.
513
     */
514
    private function removeQueryString(string $path): string
515
    {
516
        return strtok(trim($path), '?') ?: trim($path);
517
    }
518
519
    /**
520
     * Turns a path relative to static or assets into a website relative path.
521
     *
522
     *   "../../assets/images/img.jpeg"
523
     *   ->
524
     *   "/images/img.jpeg"
525
     */
526
    private function normalizePath(string $path): string
527
    {
528
        // https://regex101.com/r/Rzguzh/1
529
        $pattern = \sprintf(
530
            '(\.\.\/)+(\b%s|%s\b)+(\/.*)',
531
            $this->builder->getConfig()->get('static.dir'),
532
            $this->builder->getConfig()->get('assets.dir')
533
        );
534
        $path = Util::joinPath($path);
535
        if (!preg_match('/'.$pattern.'/is', $path, $matches)) {
536
            return $path;
537
        }
538
539
        return $matches[3];
540
    }
541
542
    /**
543
     * Create a media (video or audio) element from a link.
544
     */
545
    private function createMediaFromLink(array $link, string $type = 'video'): array
546
    {
547
        $block = [
548
            'extent'  => $link['extent'],
549
            'element' => [
550
                'handler' => 'element',
551
            ],
552
        ];
553
        $block['element']['attributes'] = $link['element']['attributes'];
554
        unset($block['element']['attributes']['href']);
555
        $block['element']['attributes']['src'] = (string) new Asset($this->builder, $link['element']['attributes']['href'], ['force_slash' => false]);
556
        switch ($type) {
557
            case 'video':
558
                $block['element']['name'] = 'video';
559
                if (isset($link['element']['attributes']['poster'])) {
560
                    $block['element']['attributes']['poster'] = (string) new Asset($this->builder, $link['element']['attributes']['poster'], ['force_slash' => false]);
561
                }
562
563
                return $block;
564
            case 'audio':
565
                $block['element']['name'] = 'audio';
566
567
                return $block;
568
        }
569
570
        throw new \Exception(\sprintf('Can\'t create %s from "%s".', $type, $link['element']['attributes']['href']));
571
    }
572
}
573