Passed
Push — images-optim ( b62e9b )
by Arnaud
12:46 queued 09:01
created

Parsedown   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 402
Duplicated Lines 0 %

Test Coverage

Coverage 81.48%

Importance

Changes 9
Bugs 5 Features 0
Metric Value
eloc 193
c 9
b 5
f 0
dl 0
loc 402
ccs 132
cts 162
cp 0.8148
rs 4.5599
wmc 58

10 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 22 1
A inlineInsert() 0 13 4
F blockMedia() 0 136 17
A blockNoteContinue() 0 13 3
A blockNoteComplete() 0 6 1
A blockFencedCodeComplete() 0 22 3
B parseAttributeData() 0 30 7
A blockNote() 0 18 3
C inlineImage() 0 72 17
A cleanUrl() 0 3 2

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 a media block (image, audio or video) */
32
    protected $MarkdownMediaRegex = "~^!\[.*?\]\(.*?\)~";
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
        // Media (image, audio or video) block
47 1
        $this->BlockTypes['!'][] = 'Media';
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 1
        parent::__construct($options);
59
    }
60
61
    /**
62
     * Insert inline.
63
     * e.g.: ++text++ -> <ins>text</ins>.
64
     */
65 1
    protected function inlineInsert($Excerpt)
66
    {
67 1
        if (!isset($Excerpt['text'][1])) {
68
            return;
69
        }
70
71 1
        if ($Excerpt['text'][1] === '+' && preg_match('/^\+\+(?=\S)(.+?)(?<=\S)\+\+/', $Excerpt['text'], $matches)) {
72
            return [
73 1
                'extent'  => strlen($matches[0]),
74
                'element' => [
75 1
                    'name'    => 'ins',
76 1
                    'text'    => $matches[1],
77
                    'handler' => 'line',
78
                ],
79
            ];
80
        }
81
    }
82
83
    /**
84
     * {@inheritdoc}
85
     */
86 1
    protected function inlineImage($Excerpt)
87
    {
88 1
        $image = parent::inlineImage($Excerpt);
89 1
        if (!isset($image)) {
90
            return null;
91
        }
92
93
        // clean source path / URL
94 1
        $image['element']['attributes']['src'] = $this->cleanUrl($image['element']['attributes']['src']);
95
96
        // should be lazy loaded?
97 1
        if ($this->builder->getConfig()->get('body.images.lazy.enabled')) {
98 1
            $image['element']['attributes']['loading'] = 'lazy';
99
        }
100
101
        // disable remote image handling?
102 1
        if (Util\Url::isUrl($image['element']['attributes']['src']) && !$this->builder->getConfig()->get('body.images.remote.enabled') ?? true) {
103
            return $image;
104
        }
105
106
        // create asset
107 1
        $asset = new Asset($this->builder, $image['element']['attributes']['src'], ['force_slash' => false]);
108 1
        $image['element']['attributes']['src'] = $asset;
109 1
        $width = $asset->getWidth();
110
111
        /**
112
         * Should be resized?
113
         */
114 1
        $assetResized = null;
115 1
        if (isset($image['element']['attributes']['width'])
116 1
            && (int) $image['element']['attributes']['width'] < $width
117 1
            && $this->builder->getConfig()->get('body.images.resize.enabled')
118
        ) {
119 1
            $width = (int) $image['element']['attributes']['width'];
120
121
            try {
122 1
                $assetResized = $asset->resize($width);
123 1
                $image['element']['attributes']['src'] = $assetResized;
124
            } catch (\Exception $e) {
125
                $this->builder->getLogger()->debug($e->getMessage());
126
127
                return $image;
128
            }
129
        }
130
131
        // set width
132 1
        if (!isset($image['element']['attributes']['width']) && $asset['type'] == 'image') {
133 1
            $image['element']['attributes']['width'] = $width;
134
        }
135
        // set height
136 1
        if (!isset($image['element']['attributes']['height']) && $asset['type'] == 'image') {
137 1
            $image['element']['attributes']['height'] = ($assetResized ?? $asset)->getHeight();
138
        }
139
140
        /**
141
         * Should be responsive?
142
         */
143 1
        if ($asset['type'] == 'image' && $this->builder->getConfig()->get('body.images.responsive.enabled')) {
144
            try {
145 1
                if ($srcset = Image::buildSrcset(
146 1
                    $assetResized ?? $asset,
147 1
                    $this->builder->getConfig()->get('assets.images.responsive.widths') ?? [480, 640, 768, 1024, 1366, 1600, 1920]
148
                )) {
149
                    $image['element']['attributes']['srcset'] = $srcset;
150 1
                    $image['element']['attributes']['sizes'] = $this->builder->getConfig()->get('assets.images.responsive.sizes.default');
151
                }
152
            } catch (\Exception $e) {
153
                $this->builder->getLogger()->debug($e->getMessage());
154
            }
155
        }
156
157 1
        return $image;
158
    }
159
160
    /**
161
     * Media block support:
162
     * 1. <picture>/<source> for WebP images
163
     * 2. <audio> and <video> elements
164
     * 3. <figure>/<figcaption> for element with a title.
165
     */
166 1
    protected function blockMedia($Excerpt)
167
    {
168 1
        if (1 !== preg_match($this->MarkdownMediaRegex, $Excerpt['text'])) {
169
            return;
170
        }
171
172 1
        $InlineImage = $this->inlineImage($Excerpt);
173 1
        if (!isset($InlineImage)) {
174
            return;
175
        }
176 1
        $block = $InlineImage;
177
178 1
        switch ($block['element']['attributes']['alt']) {
179 1
            case 'audio':
180 1
                $audio = [];
181 1
                $audio['element'] = [
182
                    'name'    => 'audio',
183
                    'handler' => 'element',
184
                ];
185 1
                $audio['element']['attributes'] = $block['element']['attributes'];
186 1
                unset($audio['element']['attributes']['loading']);
187 1
                $block = $audio;
188 1
                unset($block['element']['attributes']['alt']);
189 1
                break;
190 1
            case 'video':
191 1
                $video = [];
192 1
                $video['element'] = [
193
                    'name'       => 'video',
194
                    'handler'    => 'element',
195
                ];
196 1
                $video['element']['attributes'] = $block['element']['attributes'];
197 1
                unset($video['element']['attributes']['loading']);
198 1
                if (isset($block['element']['attributes']['poster'])) {
199 1
                    $video['element']['attributes']['poster'] = new Asset($this->builder, $block['element']['attributes']['poster'], ['force_slash' => false]);
200
                }
201 1
                $block = $video;
202 1
                unset($block['element']['attributes']['alt']);
203
        }
204
205
        /*
206
        <!-- if image has a title: a <figure> is required for <figcaption> -->
207
        <figure>
208
            <!-- if WebP: a <picture> is required for <source> -->
209
            <picture>
210
                <source type="image/webp"
211
                    srcset="..."
212
                    sizes="..."
213
                >
214
                <img src="..."
215
                    srcset="..."
216
                    sizes="..."
217
                >
218
            </picture>
219
            <!-- title -->
220
            <figcaption>...</figcaption>
221
        </figure>
222
        */
223
224
        // creates a <picture> used to add WebP <source> in addition to the image <img> element
225 1
        if ($this->builder->getConfig()->get('body.images.webp.enabled') ?? false
226
            && (($InlineImage['element']['attributes']['src'])['type'] == 'image'
227
                && ($InlineImage['element']['attributes']['src'])['subtype'] != 'image/webp')
228
        ) {
229
            try {
230
                // Image src must be an Asset instance
231 1
                if (is_string($InlineImage['element']['attributes']['src'])) {
232
                    throw new RuntimeException(\sprintf('Asset "%s" can\'t be converted to WebP', $InlineImage['element']['attributes']['src']));
233
                }
234
                // Image asset is an animated GIF
235 1
                if (Image::isAnimatedGif($InlineImage['element']['attributes']['src'])) {
236
                    throw new RuntimeException(\sprintf('Asset "%s" is an animated GIF and can\'t be converted to WebP', $InlineImage['element']['attributes']['src']));
237
                }
238 1
                $assetWebp = Image::convertTopWebp($InlineImage['element']['attributes']['src'], $this->builder->getConfig()->get('assets.images.quality') ?? 75);
239
                $srcset = '';
240
                // build responsives WebP?
241
                if ($this->builder->getConfig()->get('body.images.responsive.enabled')) {
242
                    try {
243
                        $srcset = Image::buildSrcset(
244
                            $assetWebp,
245
                            $this->builder->getConfig()->get('assets.images.responsive.widths') ?? [480, 640, 768, 1024, 1366, 1600, 1920]
246
                        );
247
                    } catch (\Exception $e) {
248
                        $this->builder->getLogger()->debug($e->getMessage());
249
                    }
250
                }
251
                // if not, default image as srcset
252
                if (empty($srcset)) {
253
                    $srcset = (string) $assetWebp;
254
                }
255
                $PictureBlock = [
256
                    'element' => [
257
                        'name'    => 'picture',
258
                        'handler' => 'elements',
259
                    ],
260
                ];
261
                $source = [
262
                    'element' => [
263
                        'name'       => 'source',
264
                        'attributes' => [
265
                            'type'   => 'image/webp',
266
                            'srcset' => $srcset,
267
                            'sizes'  => $this->builder->getConfig()->get('assets.images.responsive.sizes.default'),
268
                        ],
269
                    ],
270
                ];
271
                $PictureBlock['element']['text'][] = $source['element'];
272
                $PictureBlock['element']['text'][] = $InlineImage['element'];
273
                $block = $PictureBlock;
274 1
            } catch (\Exception $e) {
275 1
                $this->builder->getLogger()->debug($e->getMessage());
276
            }
277
        }
278
279
        // if there is a title: put the <img> (or <picture>) in a <figure> element to use the <figcaption>
280 1
        if ($this->builder->getConfig()->get('body.images.caption.enabled') && !empty($InlineImage['element']['attributes']['title'])) {
281
            $FigureBlock = [
282
                'element' => [
283 1
                    'name'    => 'figure',
284
                    'handler' => 'elements',
285
                    'text'    => [
286 1
                        $block['element'],
287
                    ],
288
                ],
289
            ];
290
            $InlineFigcaption = [
291
                'element' => [
292 1
                    'name' => 'figcaption',
293 1
                    'text' => $InlineImage['element']['attributes']['title'],
294
                ],
295
            ];
296 1
            $FigureBlock['element']['text'][] = $InlineFigcaption['element'];
297
298 1
            return $FigureBlock;
299
        }
300
301 1
        return $block;
302
    }
303
304
    /**
305
     * Note block-level markup.
306
     *
307
     * :::tip
308
     * **Tip:** This is an advice.
309
     * :::
310
     *
311
     * Code inspired by https://github.com/sixlive/parsedown-alert from TJ Miller (@sixlive).
312
     */
313 1
    protected function blockNote($block)
314
    {
315 1
        if (preg_match('/:::(.*)/', $block['text'], $matches)) {
316
            $block = [
317
                'char'    => ':',
318
                'element' => [
319
                    'name'       => 'aside',
320
                    'text'       => '',
321
                    'attributes' => [
322
                        'class' => 'note',
323
                    ],
324
                ],
325
            ];
326 1
            if (!empty($matches[1])) {
327 1
                $block['element']['attributes']['class'] .= " note-{$matches[1]}";
328
            }
329
330 1
            return $block;
331
        }
332
    }
333
334 1
    protected function blockNoteContinue($line, $block)
335
    {
336 1
        if (isset($block['complete'])) {
337 1
            return;
338
        }
339 1
        if (preg_match('/:::/', $line['text'])) {
340 1
            $block['complete'] = true;
341
342 1
            return $block;
343
        }
344 1
        $block['element']['text'] .= $line['text']."\n";
345
346 1
        return $block;
347
    }
348
349 1
    protected function blockNoteComplete($block)
350
    {
351 1
        $block['element']['rawHtml'] = $this->text($block['element']['text']);
352 1
        unset($block['element']['text']);
353
354 1
        return $block;
355
    }
356
357
    /**
358
     * Apply Highlight to code blocks.
359
     */
360 1
    protected function blockFencedCodeComplete($block)
361
    {
362 1
        if (!$this->builder->getConfig()->get('body.highlight.enabled')) {
363
            return $block;
364
        }
365 1
        if (!isset($block['element']['text']['attributes'])) {
366
            return $block;
367
        }
368
369 1
        $code = $block['element']['text']['text'];
370 1
        unset($block['element']['text']['text']);
371 1
        $languageClass = $block['element']['text']['attributes']['class'];
372 1
        $language = explode('-', $languageClass);
373 1
        $highlighted = $this->highlighter->highlight($language[1], $code);
374 1
        $block['element']['text']['attributes']['class'] = vsprintf('%s hljs %s', [
375
            $languageClass,
376 1
            $highlighted->language,
377
        ]);
378 1
        $block['element']['text']['rawHtml'] = $highlighted->value;
379 1
        $block['element']['text']['allowRawHtmlInSafeMode'] = true;
380
381 1
        return $block;
382
    }
383
384
    /**
385
     * {@inheritdoc}
386
     */
387 1
    protected function parseAttributeData($attributeString)
388
    {
389 1
        $attributes = preg_split('/[ ]+/', $attributeString, -1, PREG_SPLIT_NO_EMPTY);
390 1
        $Data = [];
391 1
        $HtmlAtt = [];
392
393 1
        foreach ($attributes as $attribute) {
394 1
            switch ($attribute[0]) {
395 1
                case '#': // ID
396 1
                    $Data['id'] = substr($attribute, 1);
397 1
                    break;
398 1
                case '.': // Classes
399 1
                    $classes[] = substr($attribute, 1);
400 1
                    break;
401
                default:  // Attributes
402 1
                    parse_str($attribute, $parsed);
403 1
                    $HtmlAtt = array_merge($HtmlAtt, $parsed);
404
            }
405
        }
406
407 1
        if (isset($classes)) {
408 1
            $Data['class'] = implode(' ', $classes);
409
        }
410 1
        if (!empty($HtmlAtt)) {
411 1
            foreach ($HtmlAtt as $a => $v) {
412 1
                $Data[$a] = trim($v, '"');
413
            }
414
        }
415
416 1
        return $Data;
417
    }
418
419
    /**
420
     * Returns URL without query string.
421
     */
422 1
    private function cleanUrl(string $path): string
423
    {
424 1
        return strtok(trim($path), '?') ?: trim($path);
425
    }
426
}
427