Passed
Push — analysis-EPpnGA ( dd42a4 )
by Arnaud
03:51 queued 16s
created

Parsedown::inlineLink()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 44
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Importance

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