Passed
Push — toc-url ( 47c560...bce3ef )
by Arnaud
10:27 queued 05:58
created

Parsedown::createMediaFromLink()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 30
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 5.0634

Importance

Changes 3
Bugs 1 Features 0
Metric Value
cc 5
eloc 20
nc 6
nop 2
dl 0
loc 30
ccs 19
cts 22
cp 0.8636
crap 5.0634
rs 9.2888
c 3
b 1
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
    /** Regex who's looking for images */
32
    protected $regexImage = "~^!\[.*?\]\(.*?\)~";
33
34
    /** @var Highlighter */
35
    protected $highlighter;
36
37 1
    public function __construct(Builder $builder, ?array $options = null)
38
    {
39 1
        $this->builder = $builder;
40
41
        // "insert" line block: ++text++ -> <ins>text</ins>
42 1
        $this->InlineTypes['+'][] = 'Insert';
43 1
        $this->inlineMarkerList = implode('', array_keys($this->InlineTypes));
44 1
        $this->specialCharacters[] = '+';
45
46
        // Image block (to avoid paragraph)
47 1
        $this->BlockTypes['!'][] = 'Image';
48
49
        // "notes" block
50 1
        $this->BlockTypes[':'][] = 'Note';
51
52
        // code highlight
53 1
        $this->highlighter = new Highlighter();
54
55
        // options
56 1
        $options = array_merge(['selectors' => $this->builder->getConfig()->get('body.toc')], $options ?? []);
57
58 1
        parent::__construct($options);
59
    }
60
61
    /**
62
     * Insert inline.
63
     * e.g.: ++text++ -> <ins>text</ins>.
64
     */
65 1
    protected function inlineInsert($Excerpt)
66
    {
67 1
        if (!isset($Excerpt['text'][1])) {
68
            return;
69
        }
70
71 1
        if ($Excerpt['text'][1] === '+' && preg_match('/^\+\+(?=\S)(.+?)(?<=\S)\+\+/', $Excerpt['text'], $matches)) {
72 1
            return [
73 1
                'extent'  => strlen($matches[0]),
74 1
                'element' => [
75 1
                    'name'    => 'ins',
76 1
                    'text'    => $matches[1],
77 1
                    'handler' => 'line',
78 1
                ],
79 1
            ];
80
        }
81
    }
82
83
    /**
84
     * {@inheritdoc}
85
     */
86 1
    protected function inlineLink($Excerpt)
87
    {
88 1
        $link = parent::inlineLink($Excerpt);
89
90 1
        if (!isset($link)) {
91
            return null;
92
        }
93
94
        // Link to a page with "page:page_id" as URL
95 1
        if (Util\Str::startsWith($link['element']['attributes']['href'], 'page:')) {
96 1
            $link['element']['attributes']['href'] = new \Cecil\Assets\Url($this->builder, substr($link['element']['attributes']['href'], 5, strlen($link['element']['attributes']['href'])));
97
98 1
            return $link;
99
        }
100
101
        /*
102
         * Embed link?
103
         */
104 1
        $embed = false;
105 1
        $embed = (bool) $this->builder->getConfig()->get('body.links.embed.enabled') ?? false;
106 1
        if (isset($link['element']['attributes']['embed'])) {
107 1
            $embed = true;
108 1
            if ($link['element']['attributes']['embed'] == 'false') {
109
                $embed = false;
110
            }
111 1
            unset($link['element']['attributes']['embed']);
112
        }
113
        // video or audio?
114 1
        $extension = pathinfo($link['element']['attributes']['href'], PATHINFO_EXTENSION);
115 1
        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

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