Passed
Push — remote-asset-fallback ( 17302c...76ea07 )
by Arnaud
07:39 queued 03:08
created

Parsedown::inlineImage()   F

Complexity

Conditions 19
Paths 203

Size

Total Lines 79
Code Lines 40

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 1 Features 0
Metric Value
cc 19
eloc 40
c 4
b 1
f 0
nc 203
nop 1
dl 0
loc 79
rs 3.6458

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