Passed
Push — asset-querystring ( 58dbbb )
by Arnaud
04:13
created

Parsedown::removeQueryString()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 1
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 2
rs 10
c 0
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
    /** @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
    public function __construct(Builder $builder, ?array $options = null)
41
    {
42
        $this->builder = $builder;
43
        $this->config = $builder->getConfig();
44
45
        // "insert" line block: ++text++ -> <ins>text</ins>
46
        $this->InlineTypes['+'][] = 'Insert';
47
        $this->inlineMarkerList = implode('', array_keys($this->InlineTypes));
48
        $this->specialCharacters[] = '+';
49
50
        // Image block (to avoid paragraph)
51
        $this->BlockTypes['!'][] = 'Image';
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' => (array) $this->config->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->config->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
        // video or audio?
118
        $extension = pathinfo($link['element']['attributes']['href'], PATHINFO_EXTENSION);
119
        if (in_array($extension, (array) $this->config->get('body.links.embed.video.ext'))) {
120
            if (!$embed) {
121
                $link['element']['attributes']['href'] = (string) new Asset($this->builder, $link['element']['attributes']['href'], ['force_slash' => false]);
122
123
                return $link;
124
            }
125
            $video = $this->createMediaFromLink($link, 'video');
126
            if ((bool) $this->config->get('body.images.caption.enabled')) {
127
                return $this->createFigure($video);
128
            }
129
130
            return $video;
131
        }
132
        if (in_array($extension, (array) $this->config->get('body.links.embed.audio.ext'))) {
133
            if (!$embed) {
134
                $link['element']['attributes']['href'] = (string) new Asset($this->builder, $link['element']['attributes']['href'], ['force_slash' => false]);
135
136
                return $link;
137
            }
138
            $audio = $this->createMediaFromLink($link, 'audio');
139
            if ((bool) $this->config->get('body.images.caption.enabled')) {
140
                return $this->createFigure($audio);
141
            }
142
143
            return $audio;
144
        }
145
        if (!$embed) {
146
            return $link;
147
        }
148
        // GitHub Gist link?
149
        // https://regex101.com/r/QmCiAL/1
150
        $pattern = 'https:\/\/gist\.github.com\/[-a-zA-Z0-9_]+\/[-a-zA-Z0-9_]+';
151
        if (preg_match('/' . $pattern . '/is', (string) $link['element']['attributes']['href'], $matches)) {
152
            $gist = [
153
                'extent'  => $link['extent'],
154
                'element' => [
155
                    'name'       => 'script',
156
                    'text'       => $link['element']['text'],
157
                    'attributes' => [
158
                        'src'   => $matches[0] . '.js',
159
                        'title' => $link['element']['attributes']['title'],
160
                    ],
161
                ],
162
            ];
163
            if ((bool) $this->config->get('body.images.caption.enabled')) {
164
                return $this->createFigure($gist);
165
            }
166
167
            return $gist;
168
        }
169
        // Youtube link?
170
        // https://regex101.com/r/gznM1j/1
171
        $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
        if (preg_match('/' . $pattern . '/is', (string) $link['element']['attributes']['href'], $matches)) {
173
            $iframe = [
174
                'element' => [
175
                    'name'       => 'iframe',
176
                    'text'       => $link['element']['text'],
177
                    'attributes' => [
178
                        'width'           => '560',
179
                        'height'          => '315',
180
                        'title'           => $link['element']['text'],
181
                        'src'             => 'https://www.youtube.com/embed/' . $matches[1],
182
                        'frameborder'     => '0',
183
                        'allow'           => 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture',
184
                        'allowfullscreen' => '',
185
                        'style'           => 'position:absolute; top:0; left:0; width:100%; height:100%; border:0',
186
                    ],
187
                ],
188
            ];
189
            $youtube = [
190
                'extent'  => $link['extent'],
191
                'element' => [
192
                    'name'    => 'div',
193
                    'handler' => 'elements',
194
                    'text'    => [
195
                        $iframe['element'],
196
                    ],
197
                    'attributes' => [
198
                        'style' => 'position:relative; padding-bottom:56.25%; height:0; overflow:hidden',
199
                        'title' => $link['element']['attributes']['title'],
200
                    ],
201
                ],
202
            ];
203
            if ((bool) $this->config->get('body.images.caption.enabled')) {
204
                return $this->createFigure($youtube);
205
            }
206
207
            return $youtube;
208
        }
209
210
        return $link;
211
    }
212
213
    /**
214
     * {@inheritdoc}
215
     */
216
    protected function inlineImage($Excerpt)
217
    {
218
        $InlineImage = parent::inlineImage($Excerpt);
219
        if (!isset($InlineImage)) {
220
            return null;
221
        }
222
223
        // normalize path
224
        $InlineImage['element']['attributes']['src'] = $this->normalizePath($InlineImage['element']['attributes']['src']);
225
226
        // should be lazy loaded?
227
        if ((bool) $this->config->get('body.images.lazy.enabled') && !isset($InlineImage['element']['attributes']['loading'])) {
228
            $InlineImage['element']['attributes']['loading'] = 'lazy';
229
        }
230
        // should be decoding async?
231
        if ((bool) $this->config->get('body.images.decoding.enabled') && !isset($InlineImage['element']['attributes']['decoding'])) {
232
            $InlineImage['element']['attributes']['decoding'] = 'async';
233
        }
234
        // add default class?
235
        if ((string) $this->config->get('body.images.class')) {
236
            if (!array_key_exists('class', $InlineImage['element']['attributes'])) {
237
                $InlineImage['element']['attributes']['class'] = '';
238
            }
239
            $InlineImage['element']['attributes']['class'] .= ' ' . (string) $this->config->get('body.images.class');
240
            $InlineImage['element']['attributes']['class'] = trim($InlineImage['element']['attributes']['class']);
241
        }
242
243
        // disable remote image handling?
244
        if (Util\Url::isUrl($InlineImage['element']['attributes']['src']) && !(bool) $this->config->get('body.images.remote.enabled') ?? true) {
245
            return $InlineImage;
246
        }
247
248
        // create asset
249
        $assetOptions = ['force_slash' => false];
250
        if ((bool) $this->config->get('body.images.remote.fallback.enabled')) {
251
            $assetOptions += ['remote_fallback' => (string) $this->config->get('body.images.remote.fallback.path')];
252
        }
253
        $asset = new Asset($this->builder, $InlineImage['element']['attributes']['src'], $assetOptions);
254
        $InlineImage['element']['attributes']['src'] = $asset;
255
        $width = $asset['width'];
256
257
        /*
258
         * Should be resized?
259
         */
260
        $assetResized = null;
261
        if (
262
            isset($InlineImage['element']['attributes']['width'])
263
            && (int) $InlineImage['element']['attributes']['width'] < $width
264
            && (bool) $this->config->get('body.images.resize.enabled')
265
        ) {
266
            $width = (int) $InlineImage['element']['attributes']['width'];
267
268
            try {
269
                $assetResized = $asset->resize($width);
270
                $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
        if (!isset($InlineImage['element']['attributes']['width'])) {
280
            $InlineImage['element']['attributes']['width'] = $width;
281
        }
282
        // set height
283
        if (!isset($InlineImage['element']['attributes']['height'])) {
284
            $InlineImage['element']['attributes']['height'] = $assetResized !== null ? $assetResized['height'] : $asset['height'];
285
        }
286
287
        /*
288
         * Should be responsive?
289
         */
290
        $sizes = '';
291
        if ((bool) $this->config->get('body.images.responsive.enabled')) {
292
            try {
293
                if (
294
                    $srcset = Image::buildSrcset(
295
                        $assetResized ?? $asset,
296
                        $this->config->getAssetsImagesWidths()
297
                    )
298
                ) {
299
                    $InlineImage['element']['attributes']['srcset'] = $srcset;
300
                    $sizes = Image::getSizes($InlineImage['element']['attributes']['class'] ?? '', (array) $this->config->getAssetsImagesSizes());
301
                    $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
        $image = $InlineImage;
327
328
        // converts image to WebP and put it in picture > source
329
        if (
330
            (bool) $this->config->get('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
                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
                if (Image::isAnimatedGif($InlineImage['element']['attributes']['src'])) {
341
                    throw new RuntimeException(\sprintf('Asset "%s" is an animated GIF and can\'t be converted to WebP', $InlineImage['element']['attributes']['src']));
342
                }
343
                $assetWebp = ($InlineImage['element']['attributes']['src'])->webp();
344
                $srcset = '';
345
                // build responsives WebP?
346
                if ((bool) $this->config->get('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
            } catch (\Exception $e) {
385
                $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
        if ((bool) $this->config->get('body.images.caption.enabled')) {
391
            return $this->createFigure($image);
392
        }
393
394
        return $image;
395
    }
396
397
    /**
398
     * Image block.
399
     */
400
    protected function blockImage($Excerpt)
401
    {
402
        if (1 !== preg_match($this->regexImage, $Excerpt['text'])) {
403
            return;
404
        }
405
406
        $InlineImage = $this->inlineImage($Excerpt);
407
        if (!isset($InlineImage)) {
408
            return;
409
        }
410
411
        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
    protected function blockNote($block)
424
    {
425
        if (preg_match('/:::(.*)/', $block['text'], $matches)) {
426
            $block = [
427
                'char'    => ':',
428
                'element' => [
429
                    'name'       => 'aside',
430
                    'text'       => '',
431
                    'attributes' => [
432
                        'class' => 'note',
433
                    ],
434
                ],
435
            ];
436
            if (!empty($matches[1])) {
437
                $block['element']['attributes']['class'] .= " note-{$matches[1]}";
438
            }
439
440
            return $block;
441
        }
442
    }
443
444
    protected function blockNoteContinue($line, $block)
445
    {
446
        if (isset($block['complete'])) {
447
            return;
448
        }
449
        if (preg_match('/:::/', $line['text'])) {
450
            $block['complete'] = true;
451
452
            return $block;
453
        }
454
        $block['element']['text'] .= $line['text'] . "\n";
455
456
        return $block;
457
    }
458
459
    protected function blockNoteComplete($block)
460
    {
461
        $block['element']['rawHtml'] = $this->text($block['element']['text']);
462
        unset($block['element']['text']);
463
464
        return $block;
465
    }
466
467
    /**
468
     * Apply Highlight to code blocks.
469
     */
470
    protected function blockFencedCodeComplete($block)
471
    {
472
        if (!(bool) $this->config->get('body.highlight.enabled')) {
473
            return $block;
474
        }
475
        if (!isset($block['element']['text']['attributes'])) {
476
            return $block;
477
        }
478
479
        try {
480
            $code = $block['element']['text']['text'];
481
            $languageClass = $block['element']['text']['attributes']['class'];
482
            $language = explode('-', $languageClass);
483
            $highlighted = $this->highlighter->highlight($language[1], $code);
484
            $block['element']['text']['attributes']['class'] = vsprintf('%s hljs %s', [
485
                $languageClass,
486
                $highlighted->language,
487
            ]);
488
            $block['element']['text']['rawHtml'] = $highlighted->value;
489
            $block['element']['text']['allowRawHtmlInSafeMode'] = true;
490
            unset($block['element']['text']['text']);
491
        } catch (\Exception $e) {
492
            $this->builder->getLogger()->debug($e->getMessage());
493
        } finally {
494
            return $block;
495
        }
496
    }
497
498
    /**
499
     * {@inheritdoc}
500
     */
501
    protected function parseAttributeData($attributeString)
502
    {
503
        $attributes = preg_split('/[ ]+/', $attributeString, -1, PREG_SPLIT_NO_EMPTY);
504
        $Data = [];
505
        $HtmlAtt = [];
506
507
        foreach ($attributes as $attribute) {
508
            switch ($attribute[0]) {
509
                case '#': // ID
510
                    $Data['id'] = substr($attribute, 1);
511
                    break;
512
                case '.': // Classes
513
                    $classes[] = substr($attribute, 1);
514
                    break;
515
                default:  // Attributes
516
                    parse_str($attribute, $parsed);
517
                    $HtmlAtt = array_merge($HtmlAtt, $parsed);
518
            }
519
        }
520
521
        if (isset($classes)) {
522
            $Data['class'] = implode(' ', $classes);
523
        }
524
        if (!empty($HtmlAtt)) {
525
            foreach ($HtmlAtt as $a => $v) {
526
                $Data[$a] = trim($v, '"');
527
            }
528
        }
529
530
        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
    private function normalizePath(string $path): string
541
    {
542
        // https://regex101.com/r/Rzguzh/1
543
        $pattern = \sprintf(
544
            '(\.\.\/)+(\b%s|%s\b)+(\/.*)',
545
            (string) $this->config->get('static.dir'),
546
            (string) $this->config->get('assets.dir')
547
        );
548
        $path = Util::joinPath($path);
549
        if (!preg_match('/' . $pattern . '/is', $path, $matches)) {
550
            return $path;
551
        }
552
553
        return $matches[3];
554
    }
555
556
    /**
557
     * Create a media (video or audio) element from a link.
558
     */
559
    private function createMediaFromLink(array $link, string $type = 'video'): array
560
    {
561
        $block = [
562
            'extent'  => $link['extent'],
563
            'element' => [
564
                'text' => $link['element']['text'],
565
            ],
566
        ];
567
        $block['element']['attributes'] = $link['element']['attributes'];
568
        unset($block['element']['attributes']['href']);
569
        $block['element']['attributes']['src'] = (string) new Asset($this->builder, $link['element']['attributes']['href'], ['force_slash' => false]);
570
        switch ($type) {
571
            case 'video':
572
                $block['element']['name'] = 'video';
573
                if (!isset($block['element']['attributes']['controls'])) {
574
                    $block['element']['attributes']['autoplay'] = '';
575
                    $block['element']['attributes']['loop'] = '';
576
                }
577
                if (isset($block['element']['attributes']['poster'])) {
578
                    $block['element']['attributes']['poster'] = (string) new Asset($this->builder, $block['element']['attributes']['poster'], ['force_slash' => false]);
579
                }
580
581
                return $block;
582
            case 'audio':
583
                $block['element']['name'] = 'audio';
584
585
                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
    private function createFigure(array $inline): array
595
    {
596
        if (empty($inline['element']['attributes']['title'])) {
597
            return $inline;
598
        }
599
600
        $titleRawHtml = $this->line($inline['element']['attributes']['title']);
601
        $inline['element']['attributes']['title'] = strip_tags($titleRawHtml);
602
603
        $figcaption = [
604
            'element' => [
605
                'name'                   => 'figcaption',
606
                'allowRawHtmlInSafeMode' => true,
607
                'rawHtml'                => $titleRawHtml,
608
            ],
609
        ];
610
        $figure = [
611
            'extent'  => $inline['extent'],
612
            'element' => [
613
                'name'    => 'figure',
614
                'handler' => 'elements',
615
                'text'    => [
616
                    $inline['element'],
617
                    $figcaption['element'],
618
                ],
619
            ],
620
        ];
621
622
        return $figure;
623
    }
624
}
625