Passed
Push — parsedown-asset-path ( 9f4fc6...e6847e )
by Arnaud
16:15 queued 11:54
created

Parsedown::removeQueryString()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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