Passed
Pull Request — master (#1676)
by Arnaud
08:52 queued 03:11
created

Parsedown::inlineLink()   C

Complexity

Conditions 16
Paths 38

Size

Total Lines 121
Code Lines 74

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 79
CRAP Score 16.09

Importance

Changes 9
Bugs 3 Features 0
Metric Value
cc 16
eloc 74
c 9
b 3
f 0
nc 38
nop 1
dl 0
loc 121
ccs 79
cts 85
cp 0.9294
crap 16.09
rs 5.5666

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
    /** @var \Cecil\Config */
29
    protected $config;
30
31
    /** {@inheritdoc} */
32
    protected $regexAttribute = '(?:[#.][-\w:\\\]+[ ]*|[-\w:\\\]+(?:=(?:["\'][^\n]*?["\']|[^\s]+)?)?[ ]*)';
33
34
    /** Regex who's looking for images */
35
    protected $regexImage = "~^!\[.*?\]\(.*?\)~";
36
37
    /** @var Highlighter */
38
    protected $highlighter;
39
40 1
    public function __construct(Builder $builder, ?array $options = null)
41
    {
42 1
        $this->builder = $builder;
43 1
        $this->config = $builder->getConfig();
44
45
        // "insert" line block: ++text++ -> <ins>text</ins>
46 1
        $this->InlineTypes['+'][] = 'Insert';
47 1
        $this->inlineMarkerList = implode('', array_keys($this->InlineTypes));
48 1
        $this->specialCharacters[] = '+';
49
50
        // Image block (to avoid paragraph)
51 1
        $this->BlockTypes['!'][] = 'Image';
52
53
        // "notes" block
54 1
        $this->BlockTypes[':'][] = 'Note';
55
56
        // code highlight
57 1
        $this->highlighter = new Highlighter();
58
59
        // options
60 1
        $options = array_merge(['selectors' => (array) $this->config->get('pages.body.toc')], $options ?? []);
61
62 1
        parent::__construct($options);
63
    }
64
65
    /**
66
     * Insert inline.
67
     * e.g.: ++text++ -> <ins>text</ins>.
68
     */
69 1
    protected function inlineInsert($Excerpt)
70
    {
71 1
        if (!isset($Excerpt['text'][1])) {
72
            return;
73
        }
74
75 1
        if ($Excerpt['text'][1] === '+' && preg_match('/^\+\+(?=\S)(.+?)(?<=\S)\+\+/', $Excerpt['text'], $matches)) {
76 1
            return [
77 1
                'extent'  => strlen($matches[0]),
78 1
                'element' => [
79 1
                    'name'    => 'ins',
80 1
                    'text'    => $matches[1],
81 1
                    'handler' => 'line',
82 1
                ],
83 1
            ];
84
        }
85
    }
86
87
    /**
88
     * {@inheritdoc}
89
     */
90 1
    protected function inlineLink($Excerpt)
91
    {
92 1
        $link = parent::inlineLink($Excerpt);
93
94 1
        if (!isset($link)) {
95
            return null;
96
        }
97
98
        // Link to a page with "page:page_id" as URL
99 1
        if (Util\Str::startsWith($link['element']['attributes']['href'], 'page:')) {
100 1
            $link['element']['attributes']['href'] = new \Cecil\Assets\Url($this->builder, substr($link['element']['attributes']['href'], 5, strlen($link['element']['attributes']['href'])));
101
102 1
            return $link;
103
        }
104
105
        /*
106
         * Embed link?
107
         */
108 1
        $embed = false;
109 1
        $embed = (bool) $this->config->get('pages.body.links.embed.enabled') ?? false;
110 1
        if (isset($link['element']['attributes']['embed'])) {
111 1
            $embed = true;
112 1
            if ($link['element']['attributes']['embed'] == 'false') {
113 1
                $embed = false;
114
            }
115 1
            unset($link['element']['attributes']['embed']);
116
        }
117
        // video or audio?
118 1
        $extension = pathinfo($link['element']['attributes']['href'], PATHINFO_EXTENSION);
119 1
        if (in_array($extension, (array) $this->config->get('pages.body.links.embed.video.ext'))) {
120 1
            if (!$embed) {
121 1
                $link['element']['attributes']['href'] = (string) new Asset($this->builder, $link['element']['attributes']['href'], ['force_slash' => false]);
122
123 1
                return $link;
124
            }
125 1
            $video = $this->createMediaFromLink($link, 'video');
126 1
            if ((bool) $this->config->get('pages.body.images.caption.enabled')) {
127 1
                return $this->createFigure($video);
128
            }
129
130
            return $video;
131
        }
132 1
        if (in_array($extension, (array) $this->config->get('pages.body.links.embed.audio.ext'))) {
133 1
            if (!$embed) {
134 1
                $link['element']['attributes']['href'] = (string) new Asset($this->builder, $link['element']['attributes']['href'], ['force_slash' => false]);
135
136 1
                return $link;
137
            }
138 1
            $audio = $this->createMediaFromLink($link, 'audio');
139 1
            if ((bool) $this->config->get('pages.body.images.caption.enabled')) {
140 1
                return $this->createFigure($audio);
141
            }
142
143
            return $audio;
144
        }
145 1
        if (!$embed) {
146 1
            return $link;
147
        }
148
        // GitHub Gist link?
149
        // https://regex101.com/r/QmCiAL/1
150 1
        $pattern = 'https:\/\/gist\.github.com\/[-a-zA-Z0-9_]+\/[-a-zA-Z0-9_]+';
151 1
        if (preg_match('/' . $pattern . '/is', (string) $link['element']['attributes']['href'], $matches)) {
152 1
            $gist = [
153 1
                'extent'  => $link['extent'],
154 1
                'element' => [
155 1
                    'name'       => 'script',
156 1
                    'text'       => $link['element']['text'],
157 1
                    'attributes' => [
158 1
                        'src'   => $matches[0] . '.js',
159 1
                        'title' => $link['element']['attributes']['title'],
160 1
                    ],
161 1
                ],
162 1
            ];
163 1
            if ((bool) $this->config->get('pages.body.images.caption.enabled')) {
164 1
                return $this->createFigure($gist);
165
            }
166
167
            return $gist;
168
        }
169
        // Youtube link?
170
        // https://regex101.com/r/gznM1j/1
171 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,})';
172 1
        if (preg_match('/' . $pattern . '/is', (string) $link['element']['attributes']['href'], $matches)) {
173 1
            $iframe = [
174 1
                'element' => [
175 1
                    'name'       => 'iframe',
176 1
                    'text'       => $link['element']['text'],
177 1
                    'attributes' => [
178 1
                        'width'           => '560',
179 1
                        'height'          => '315',
180 1
                        'title'           => $link['element']['text'],
181 1
                        'src'             => 'https://www.youtube.com/embed/' . $matches[1],
182 1
                        'frameborder'     => '0',
183 1
                        'allow'           => 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture',
184 1
                        'allowfullscreen' => '',
185 1
                        'style'           => 'position:absolute; top:0; left:0; width:100%; height:100%; border:0',
186 1
                    ],
187 1
                ],
188 1
            ];
189 1
            $youtube = [
190 1
                'extent'  => $link['extent'],
191 1
                'element' => [
192 1
                    'name'    => 'div',
193 1
                    'handler' => 'elements',
194 1
                    'text'    => [
195 1
                        $iframe['element'],
196 1
                    ],
197 1
                    'attributes' => [
198 1
                        'style' => 'position:relative; padding-bottom:56.25%; height:0; overflow:hidden',
199 1
                        'title' => $link['element']['attributes']['title'],
200 1
                    ],
201 1
                ],
202 1
            ];
203 1
            if ((bool) $this->config->get('pages.body.images.caption.enabled')) {
204 1
                return $this->createFigure($youtube);
205
            }
206
207
            return $youtube;
208
        }
209
210
        return $link;
211
    }
212
213
    /**
214
     * {@inheritdoc}
215
     */
216 1
    protected function inlineImage($Excerpt)
217
    {
218 1
        $InlineImage = parent::inlineImage($Excerpt);
219 1
        if (!isset($InlineImage)) {
220
            return null;
221
        }
222
223
        // normalize path
224 1
        $InlineImage['element']['attributes']['src'] = $this->normalizePath($InlineImage['element']['attributes']['src']);
225
226
        // should be lazy loaded?
227 1
        if ((bool) $this->config->get('pages.body.images.lazy.enabled') && !isset($InlineImage['element']['attributes']['loading'])) {
228 1
            $InlineImage['element']['attributes']['loading'] = 'lazy';
229
        }
230
        // should be decoding async?
231 1
        if ((bool) $this->config->get('pages.body.images.decoding.enabled') && !isset($InlineImage['element']['attributes']['decoding'])) {
232 1
            $InlineImage['element']['attributes']['decoding'] = 'async';
233
        }
234
        // add default class?
235 1
        if ((string) $this->config->get('pages.body.images.class')) {
236 1
            if (!array_key_exists('class', $InlineImage['element']['attributes'])) {
237 1
                $InlineImage['element']['attributes']['class'] = '';
238
            }
239 1
            $InlineImage['element']['attributes']['class'] .= ' ' . (string) $this->config->get('pages.body.images.class');
240 1
            $InlineImage['element']['attributes']['class'] = trim($InlineImage['element']['attributes']['class']);
241
        }
242
243
        // disable remote image handling?
244 1
        if (Util\Url::isUrl($InlineImage['element']['attributes']['src']) && !(bool) $this->config->get('pages.body.images.remote.enabled') ?? true) {
245
            return $InlineImage;
246
        }
247
248
        // create asset
249 1
        $assetOptions = ['force_slash' => false];
250 1
        if ((bool) $this->config->get('pages.body.images.remote.fallback.enabled')) {
251 1
            $assetOptions += ['remote_fallback' => (string) $this->config->get('pages.body.images.remote.fallback.path')];
252
        }
253 1
        $asset = new Asset($this->builder, $InlineImage['element']['attributes']['src'], $assetOptions);
254 1
        $InlineImage['element']['attributes']['src'] = $asset;
255 1
        $width = $asset['width'];
256
257
        /*
258
         * Should be resized?
259
         */
260 1
        $assetResized = null;
261
        if (
262 1
            isset($InlineImage['element']['attributes']['width'])
263 1
            && (int) $InlineImage['element']['attributes']['width'] < $width
264 1
            && (bool) $this->config->get('pages.body.images.resize.enabled')
265
        ) {
266 1
            $width = (int) $InlineImage['element']['attributes']['width'];
267
268
            try {
269 1
                $assetResized = $asset->resize($width);
270 1
                $InlineImage['element']['attributes']['src'] = $assetResized;
271
            } catch (\Exception $e) {
272
                $this->builder->getLogger()->debug($e->getMessage());
273
274
                return $InlineImage;
275
            }
276
        }
277
278
        // set width
279 1
        if (!isset($InlineImage['element']['attributes']['width'])) {
280 1
            $InlineImage['element']['attributes']['width'] = $width;
281
        }
282
        // set height
283 1
        if (!isset($InlineImage['element']['attributes']['height'])) {
284 1
            $InlineImage['element']['attributes']['height'] = $assetResized !== null ? $assetResized['height'] : $asset['height'];
285
        }
286
287
        /*
288
         * Should be responsive?
289
         */
290 1
        $sizes = '';
291 1
        if ((bool) $this->config->get('pages.body.images.responsive.enabled')) {
292
            try {
293
                if (
294 1
                    $srcset = Image::buildSrcset(
295 1
                        $assetResized ?? $asset,
296 1
                        $this->config->getAssetsImagesWidths()
297 1
                    )
298
                ) {
299 1
                    $InlineImage['element']['attributes']['srcset'] = $srcset;
300 1
                    $sizes = Image::getSizes($InlineImage['element']['attributes']['class'] ?? '', (array) $this->config->getAssetsImagesSizes());
301 1
                    $InlineImage['element']['attributes']['sizes'] = $sizes;
302
                }
303
            } catch (\Exception $e) {
304
                $this->builder->getLogger()->debug($e->getMessage());
305
            }
306
        }
307
308
        /*
309
        <!-- if title: a <figure> is required to put in it a <figcaption> -->
310
        <figure>
311
            <!-- if WebP is enabled: a <picture> is required for the WebP <source> -->
312
            <picture>
313
                <source type="image/webp"
314
                    srcset="..."
315
                    sizes="..."
316
                >
317
                <img src="..."
318
                    srcset="..."
319
                    sizes="..."
320
                >
321
            </picture>
322
            <figcaption><!-- title --></figcaption>
323
        </figure>
324
        */
325
326 1
        $image = $InlineImage;
327
328
        // converts image to WebP and put it in picture > source
329
        if (
330 1
            (bool) $this->config->get('pages.body.images.webp.enabled') ?? false
331
            && (($InlineImage['element']['attributes']['src'])['type'] == 'image'
332
            && ($InlineImage['element']['attributes']['src'])['subtype'] != 'image/webp')
333
        ) {
334
            try {
335
                // InlineImage src must be an Asset instance
336 1
                if (!$InlineImage['element']['attributes']['src'] instanceof Asset) {
337
                    throw new RuntimeException(\sprintf('Asset "%s" can\'t be converted to WebP', $InlineImage['element']['attributes']['src']));
338
                }
339
                // abord if InlineImage is an animated GIF
340 1
                if (Image::isAnimatedGif($InlineImage['element']['attributes']['src'])) {
341 1
                    throw new RuntimeException(\sprintf('Asset "%s" is an animated GIF and can\'t be converted to WebP', $InlineImage['element']['attributes']['src']));
342
                }
343 1
                $assetWebp = ($InlineImage['element']['attributes']['src'])->webp();
344
                $srcset = '';
345
                // build responsives WebP?
346
                if ((bool) $this->config->get('pages.body.images.responsive.enabled')) {
347
                    try {
348
                        $srcset = Image::buildSrcset(
349
                            $assetWebp,
350
                            $this->config->getAssetsImagesWidths()
351
                        );
352
                    } catch (\Exception $e) {
353
                        $this->builder->getLogger()->debug($e->getMessage());
354
                    }
355
                }
356
                // if not, default image as srcset
357
                if (empty($srcset)) {
358
                    $srcset = (string) $assetWebp;
359
                }
360
                $picture = [
361
                    'extent'  => $InlineImage['extent'],
362
                    'element' => [
363
                        'name'       => 'picture',
364
                        'handler'    => 'elements',
365
                        'attributes' => [
366
                            'title' => $image['element']['attributes']['title'],
367
                        ],
368
                    ],
369
                ];
370
                $source = [
371
                    'element' => [
372
                        'name'       => 'source',
373
                        'attributes' => [
374
                            'type'   => 'image/webp',
375
                            'srcset' => $srcset,
376
                            'sizes'  => $sizes,
377
                        ],
378
                    ],
379
                ];
380
                $picture['element']['text'][] = $source['element'];
381
                unset($image['element']['attributes']['title']);
382
                $picture['element']['text'][] = $image['element'];
383
                $image = $picture;
384 1
            } catch (\Exception $e) {
385 1
                $this->builder->getLogger()->debug($e->getMessage());
386
            }
387
        }
388
389
        // if title: put the <img> (or <picture>) in a <figure> and create a <figcaption>
390 1
        if ((bool) $this->config->get('pages.body.images.caption.enabled')) {
391 1
            return $this->createFigure($image);
392
        }
393
394
        return $image;
395
    }
396
397
    /**
398
     * Image block.
399
     */
400 1
    protected function blockImage($Excerpt)
401
    {
402 1
        if (1 !== preg_match($this->regexImage, $Excerpt['text'])) {
403
            return;
404
        }
405
406 1
        $InlineImage = $this->inlineImage($Excerpt);
407 1
        if (!isset($InlineImage)) {
408
            return;
409
        }
410
411 1
        return $InlineImage;
412
    }
413
414
    /**
415
     * Note block-level markup.
416
     *
417
     * :::tip
418
     * **Tip:** This is an advice.
419
     * :::
420
     *
421
     * Code inspired by https://github.com/sixlive/parsedown-alert from TJ Miller (@sixlive).
422
     */
423 1
    protected function blockNote($block)
424
    {
425 1
        if (preg_match('/:::(.*)/', $block['text'], $matches)) {
426 1
            $block = [
427 1
                'char'    => ':',
428 1
                'element' => [
429 1
                    'name'       => 'aside',
430 1
                    'text'       => '',
431 1
                    'attributes' => [
432 1
                        'class' => 'note',
433 1
                    ],
434 1
                ],
435 1
            ];
436 1
            if (!empty($matches[1])) {
437 1
                $block['element']['attributes']['class'] .= " note-{$matches[1]}";
438
            }
439
440 1
            return $block;
441
        }
442
    }
443
444 1
    protected function blockNoteContinue($line, $block)
445
    {
446 1
        if (isset($block['complete'])) {
447 1
            return;
448
        }
449 1
        if (preg_match('/:::/', $line['text'])) {
450 1
            $block['complete'] = true;
451
452 1
            return $block;
453
        }
454 1
        $block['element']['text'] .= $line['text'] . "\n";
455
456 1
        return $block;
457
    }
458
459 1
    protected function blockNoteComplete($block)
460
    {
461 1
        $block['element']['rawHtml'] = $this->text($block['element']['text']);
462 1
        unset($block['element']['text']);
463
464 1
        return $block;
465
    }
466
467
    /**
468
     * Apply Highlight to code blocks.
469
     */
470 1
    protected function blockFencedCodeComplete($block)
471
    {
472 1
        if (!(bool) $this->config->get('pages.body.highlight.enabled')) {
473
            return $block;
474
        }
475 1
        if (!isset($block['element']['text']['attributes'])) {
476
            return $block;
477
        }
478
479
        try {
480 1
            $code = $block['element']['text']['text'];
481 1
            $languageClass = $block['element']['text']['attributes']['class'];
482 1
            $language = explode('-', $languageClass);
483 1
            $highlighted = $this->highlighter->highlight($language[1], $code);
484 1
            $block['element']['text']['attributes']['class'] = vsprintf('%s hljs %s', [
485 1
                $languageClass,
486 1
                $highlighted->language,
487 1
            ]);
488 1
            $block['element']['text']['rawHtml'] = $highlighted->value;
489 1
            $block['element']['text']['allowRawHtmlInSafeMode'] = true;
490 1
            unset($block['element']['text']['text']);
491
        } catch (\Exception $e) {
492
            $this->builder->getLogger()->debug($e->getMessage());
493
        } finally {
494 1
            return $block;
495
        }
496
    }
497
498
    /**
499
     * {@inheritdoc}
500
     */
501 1
    protected function parseAttributeData($attributeString)
502
    {
503 1
        $attributes = preg_split('/[ ]+/', $attributeString, -1, PREG_SPLIT_NO_EMPTY);
504 1
        $Data = [];
505 1
        $HtmlAtt = [];
506
507 1
        foreach ($attributes as $attribute) {
508 1
            switch ($attribute[0]) {
509 1
                case '#': // ID
510 1
                    $Data['id'] = substr($attribute, 1);
511 1
                    break;
512 1
                case '.': // Classes
513 1
                    $classes[] = substr($attribute, 1);
514 1
                    break;
515
                default:  // Attributes
516 1
                    parse_str($attribute, $parsed);
517 1
                    $HtmlAtt = array_merge($HtmlAtt, $parsed);
518
            }
519
        }
520
521 1
        if (isset($classes)) {
522 1
            $Data['class'] = implode(' ', $classes);
523
        }
524 1
        if (!empty($HtmlAtt)) {
525 1
            foreach ($HtmlAtt as $a => $v) {
526 1
                $Data[$a] = trim($v, '"');
527
            }
528
        }
529
530 1
        return $Data;
531
    }
532
533
    /**
534
     * Turns a path relative to static or assets into a website relative path.
535
     *
536
     *   "../../assets/images/img.jpeg"
537
     *   ->
538
     *   "/images/img.jpeg"
539
     */
540 1
    private function normalizePath(string $path): string
541
    {
542
        // https://regex101.com/r/Rzguzh/1
543 1
        $pattern = \sprintf(
544 1
            '(\.\.\/)+(\b%s|%s\b)+(\/.*)',
545 1
            (string) $this->config->get('static.dir'),
546 1
            (string) $this->config->get('assets.dir')
547 1
        );
548 1
        $path = Util::joinPath($path);
549 1
        if (!preg_match('/' . $pattern . '/is', $path, $matches)) {
550 1
            return $path;
551
        }
552
553 1
        return $matches[3];
554
    }
555
556
    /**
557
     * Create a media (video or audio) element from a link.
558
     */
559 1
    private function createMediaFromLink(array $link, string $type = 'video'): array
560
    {
561 1
        $block = [
562 1
            'extent'  => $link['extent'],
563 1
            'element' => [
564 1
                'text' => $link['element']['text'],
565 1
            ],
566 1
        ];
567 1
        $block['element']['attributes'] = $link['element']['attributes'];
568 1
        unset($block['element']['attributes']['href']);
569 1
        $block['element']['attributes']['src'] = (string) new Asset($this->builder, $link['element']['attributes']['href'], ['force_slash' => false]);
570
        switch ($type) {
571 1
            case 'video':
572 1
                $block['element']['name'] = 'video';
573 1
                if (!isset($block['element']['attributes']['controls'])) {
574 1
                    $block['element']['attributes']['autoplay'] = '';
575 1
                    $block['element']['attributes']['loop'] = '';
576
                }
577 1
                if (isset($block['element']['attributes']['poster'])) {
578 1
                    $block['element']['attributes']['poster'] = (string) new Asset($this->builder, $block['element']['attributes']['poster'], ['force_slash' => false]);
579
                }
580
581 1
                return $block;
582 1
            case 'audio':
583 1
                $block['element']['name'] = 'audio';
584
585 1
                return $block;
586
        }
587
588
        throw new \Exception(\sprintf('Can\'t create %s from "%s".', $type, $link['element']['attributes']['href']));
589
    }
590
591
    /**
592
     * Create a figure / caption element.
593
     */
594 1
    private function createFigure(array $inline): array
595
    {
596 1
        if (empty($inline['element']['attributes']['title'])) {
597 1
            return $inline;
598
        }
599
600 1
        $titleRawHtml = $this->line($inline['element']['attributes']['title']);
601 1
        $inline['element']['attributes']['title'] = strip_tags($titleRawHtml);
602
603 1
        $figcaption = [
604 1
            'element' => [
605 1
                'name'                   => 'figcaption',
606 1
                'allowRawHtmlInSafeMode' => true,
607 1
                'rawHtml'                => $titleRawHtml,
608 1
            ],
609 1
        ];
610 1
        $figure = [
611 1
            'extent'  => $inline['extent'],
612 1
            'element' => [
613 1
                'name'    => 'figure',
614 1
                'handler' => 'elements',
615 1
                'text'    => [
616 1
                    $inline['element'],
617 1
                    $figcaption['element'],
618 1
                ],
619 1
            ],
620 1
        ];
621
622 1
        return $figure;
623
    }
624
}
625