Passed
Push — page-build ( fc820f )
by Arnaud
04:24
created

Parsedown::createMediaFromLink()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 26
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 17
c 1
b 0
f 0
nc 4
nop 2
dl 0
loc 26
rs 9.7
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" as URL
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
        $embed = false;
105
        $embed = (bool) $this->builder->getConfig()->get('body.links.embed.enabled') ?? false;
106
        if (isset($link['element']['attributes']['embed'])) {
107
            $embed = true;
108
            if ($link['element']['attributes']['embed'] == 'false') {
109
                $embed = false;
110
            }
111
            unset($link['element']['attributes']['embed']);
112
        }
113
        if (!$embed) {
114
            return $link;
115
        }
116
        // video or audio?
117
        $extension = pathinfo($link['element']['attributes']['href'], PATHINFO_EXTENSION);
118
        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

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