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

Parsedown   F

Complexity

Total Complexity 76

Size/Duplication

Total Lines 548
Duplicated Lines 0 %

Test Coverage

Coverage 82.8%

Importance

Changes 22
Bugs 10 Features 1
Metric Value
eloc 266
dl 0
loc 548
ccs 130
cts 157
cp 0.828
rs 2.32
c 22
b 10
f 1
wmc 76

13 Methods

Rating   Name   Duplication   Size   Complexity  
A inlineInsert() 0 13 4
A removeQueryString() 0 3 2
A blockNoteContinue() 0 13 3
A blockNoteComplete() 0 6 1
A blockFencedCodeComplete() 0 25 4
B parseAttributeData() 0 30 7
B inlineLink() 0 90 10
A __construct() 0 22 1
A blockNote() 0 18 3
F inlineImage() 0 85 20
A createMediaFromLink() 0 26 4
A normalizePath() 0 14 2
C blockImage() 0 117 15

How to fix   Complexity   

Complex Class

Complex classes like Parsedown often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Parsedown, and based on these observations, apply Extract Interface, too.

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