Passed
Push — gist ( dc7f4a...f16181 )
by Arnaud
19:56 queued 16:16
created

Parsedown::inlineLink()   B

Complexity

Conditions 6
Paths 6

Size

Total Lines 73
Code Lines 42

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 6
eloc 42
c 3
b 0
f 0
nc 6
nop 1
dl 0
loc 73
rs 8.6257

How to fix   Long Method   

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