Passed
Push — feature/markdown-medias ( 09c79e )
by Arnaud
04:22
created

Parsedown   B

Complexity

Total Complexity 50

Size/Duplication

Total Lines 361
Duplicated Lines 0 %

Importance

Changes 11
Bugs 4 Features 0
Metric Value
eloc 163
c 11
b 4
f 0
dl 0
loc 361
rs 8.4
wmc 50

10 Methods

Rating   Name   Duplication   Size   Complexity  
A blockNoteContinue() 0 13 3
A blockNoteComplete() 0 6 1
A blockFencedCodeComplete() 0 22 3
B parseAttributeData() 0 30 7
A __construct() 0 24 4
A blockNote() 0 10 2
A inlineInsert() 0 13 4
C inlineImage() 0 68 13
A cleanUrl() 0 3 2
C blockImage() 0 104 11

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