Passed
Push — analysis-N4mJ0v ( eaf47b )
by Arnaud
04:15 queued 12s
created

Parsedown::createFigure()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 29
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 18
nc 2
nop 1
dl 0
loc 29
rs 9.6666
c 1
b 0
f 0
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
    protected $regexImage = "~^!\[.*?\]\(.*?\)~";
32
33
    protected $regexLink = "~^\[.*?\]\(.*?\)~";
34
35
    /** @var Highlighter */
36
    protected $highlighter;
37
38
    public function __construct(Builder $builder, ?array $options = null)
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
        // Image block (to avoid paragraph)
48
        $this->BlockTypes['!'][] = 'Image';
49
50
        // Link block (to avoid paragraph)
51
        $this->BlockTypes['['][] = 'Link';
52
53
        // "notes" block
54
        $this->BlockTypes[':'][] = 'Note';
55
56
        // code highlight
57
        $this->highlighter = new Highlighter();
58
59
        // options
60
        $options = array_merge(['selectors' => $this->builder->getConfig()->get('body.toc')], $options ?? []);
61
62
        parent::__construct($options);
63
    }
64
65
    /**
66
     * Insert inline.
67
     * e.g.: ++text++ -> <ins>text</ins>.
68
     */
69
    protected function inlineInsert($Excerpt)
70
    {
71
        if (!isset($Excerpt['text'][1])) {
72
            return;
73
        }
74
75
        if ($Excerpt['text'][1] === '+' && preg_match('/^\+\+(?=\S)(.+?)(?<=\S)\+\+/', $Excerpt['text'], $matches)) {
76
            return [
77
                'extent'  => strlen($matches[0]),
78
                'element' => [
79
                    'name'    => 'ins',
80
                    'text'    => $matches[1],
81
                    'handler' => 'line',
82
                ],
83
            ];
84
        }
85
    }
86
87
    /**
88
     * {@inheritdoc}
89
     */
90
    protected function inlineLink($Excerpt)
91
    {
92
        $link = parent::inlineLink($Excerpt);
93
94
        if (!isset($link)) {
95
            return null;
96
        }
97
98
        // Link to a page with "page:page_id" as URL
99
        if (Util\Str::startsWith($link['element']['attributes']['href'], 'page:')) {
100
            $link['element']['attributes']['href'] = new \Cecil\Assets\Url($this->builder, substr($link['element']['attributes']['href'], 5, strlen($link['element']['attributes']['href'])));
101
102
            return $link;
103
        }
104
105
        /*
106
         * Embed link?
107
         */
108
        $embed = false;
109
        $embed = (bool) $this->builder->getConfig()->get('body.links.embed.enabled') ?? false;
110
        if (isset($link['element']['attributes']['embed'])) {
111
            $embed = true;
112
            if ($link['element']['attributes']['embed'] == 'false') {
113
                $embed = false;
114
            }
115
            unset($link['element']['attributes']['embed']);
116
        }
117
        if (!$embed) {
118
            return $link;
119
        }
120
        // video or audio?
121
        $extension = pathinfo($link['element']['attributes']['href'], PATHINFO_EXTENSION);
122
        if (in_array($extension, $this->builder->getConfig()->get('body.links.embed.video.ext'))) {
0 ignored issues
show
Bug introduced by
It seems like $this->builder->getConfi...links.embed.video.ext') can also be of type null; however, parameter $haystack of in_array() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

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