Passed
Pull Request — master (#1676)
by Arnaud
10:58 queued 04:17
created

Parsedown::blockNoteComplete()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

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