Passed
Push — chore/markdown ( b8c143...0e9471 )
by Arnaud
09:27 queued 04:57
created

Parsedown::cleanUrl()   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
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 3
rs 10
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
    /** Regex used to valid block image */
32
    protected $MarkdownImageRegex = "~^!\[.*?\]\(.*?\)~";
33
34
    /** @var Highlighter */
35
    protected $highlighter;
36
37
    public function __construct(Builder $builder)
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
        // add caption or WebP source to image block
47
        if ($this->builder->getConfig()->get('body.images.caption.enabled') || $this->builder->getConfig()->get('body.images.webp.enabled')) {
48
            $this->BlockTypes['!'][] = 'Image';
49
        }
50
51
        // "notes" block
52
        if ($this->builder->getConfig()->get('body.notes.enabled')) {
53
            $this->BlockTypes[':'][] = 'Note';
54
        }
55
56
        // code highlight
57
        $this->highlighter = new Highlighter();
58
59
        // ToC
60
        parent::__construct(['selectors' => $this->builder->getConfig()->get('body.toc')]);
61
    }
62
63
    /**
64
     * Insert inline.
65
     * e.g.: ++text++ -> <ins>text</ins>.
66
     */
67
    protected function inlineInsert($Excerpt)
68
    {
69
        if (!isset($Excerpt['text'][1])) {
70
            return;
71
        }
72
73
        if ($Excerpt['text'][1] === '+' && preg_match('/^\+\+(?=\S)(.+?)(?<=\S)\+\+/', $Excerpt['text'], $matches)) {
74
            return [
75
                'extent'  => strlen($matches[0]),
76
                'element' => [
77
                    'name'    => 'ins',
78
                    'text'    => $matches[1],
79
                    'handler' => 'line',
80
                ],
81
            ];
82
        }
83
    }
84
85
    /**
86
     * {@inheritdoc}
87
     */
88
    protected function inlineImage($excerpt)
89
    {
90
        $image = parent::inlineImage($excerpt);
91
        if (!isset($image)) {
92
            return null;
93
        }
94
95
        // clean source path / URL
96
        $image['element']['attributes']['src'] = $this->cleanUrl($image['element']['attributes']['src']);
97
98
        // should be lazy loaded?
99
        if ($this->builder->getConfig()->get('body.images.lazy.enabled')) {
100
            $image['element']['attributes']['loading'] = 'lazy';
101
        }
102
103
        // disable remote image handling?
104
        if (Util\Url::isUrl($image['element']['attributes']['src']) && !$this->builder->getConfig()->get('body.images.remote.enabled') ?? true) {
105
            return $image;
106
        }
107
108
        // create asset
109
        $asset = new Asset($this->builder, $image['element']['attributes']['src'], ['force_slash' => false]);
110
        $image['element']['attributes']['src'] = $asset;
111
        $width = $asset->getWidth();
112
113
        /**
114
         * Should be resized?
115
         */
116
        $assetResized = null;
117
        if (isset($image['element']['attributes']['width'])
118
            && (int) $image['element']['attributes']['width'] < $width
119
            && $this->builder->getConfig()->get('body.images.resize.enabled')
120
        ) {
121
            $width = (int) $image['element']['attributes']['width'];
122
123
            try {
124
                $assetResized = $asset->resize($width);
125
            } catch (\Exception $e) {
126
                $this->builder->getLogger()->debug($e->getMessage());
127
128
                return $image;
129
            }
130
            $image['element']['attributes']['src'] = $assetResized;
131
        }
132
133
        // set width
134
        if (!isset($image['element']['attributes']['width'])) {
135
            $image['element']['attributes']['width'] = $width;
136
        }
137
        // set height
138
        if (!isset($image['element']['attributes']['height'])) {
139
            $image['element']['attributes']['height'] = ($assetResized ?? $asset)->getHeight();
140
        }
141
142
        /**
143
         * Should be responsive?
144
         */
145
        if ($this->builder->getConfig()->get('body.images.responsive.enabled')) {
146
            if ($srcset = Image::buildSrcset(
147
                $assetResized ?? $asset,
148
                $this->builder->getConfig()->get('assets.images.responsive.widths') ?? [480, 640, 768, 1024, 1366, 1600, 1920]
149
            )) {
150
                $image['element']['attributes']['srcset'] = $srcset;
151
                $image['element']['attributes']['sizes'] = $this->builder->getConfig()->get('assets.images.responsive.sizes.default');
152
            }
153
        }
154
155
        return $image;
156
    }
157
158
    /**
159
     * {@inheritdoc}
160
     */
161
    protected function parseAttributeData($attributeString)
162
    {
163
        $attributes = preg_split('/[ ]+/', $attributeString, -1, PREG_SPLIT_NO_EMPTY);
164
        $Data = [];
165
        $HtmlAtt = [];
166
167
        foreach ($attributes as $attribute) {
168
            switch ($attribute[0]) {
169
                case '#': // ID
170
                    $Data['id'] = substr($attribute, 1);
171
                    break;
172
                case '.': // Classes
173
                    $classes[] = substr($attribute, 1);
174
                    break;
175
                default:  // Attributes
176
                    parse_str($attribute, $parsed);
177
                    $HtmlAtt = array_merge($HtmlAtt, $parsed);
178
            }
179
        }
180
181
        if (isset($classes)) {
182
            $Data['class'] = implode(' ', $classes);
183
        }
184
        if (!empty($HtmlAtt)) {
185
            foreach ($HtmlAtt as $a => $v) {
186
                $Data[$a] = trim($v, '"');
187
            }
188
        }
189
190
        return $Data;
191
    }
192
193
    /**
194
     * Enhances image block with <picture>/<source> and/or <figure>/<figcaption>.
195
     */
196
    protected function blockImage($Line)
197
    {
198
        if (1 !== preg_match($this->MarkdownImageRegex, $Line['text'])) {
199
            return;
200
        }
201
202
        $InlineImage = $this->inlineImage($Line);
203
        if (!isset($InlineImage)) {
204
            return;
205
        }
206
207
        $block = $InlineImage;
208
209
        /*
210
        <!-- if image has a title: a <figure> is required for <figcaption> -->
211
        <figure>
212
            <!-- if WebP: a <picture> is required for <source> -->
213
            <picture>
214
                <source type="image/webp"
215
                    srcset="..."
216
                    sizes="..."
217
                >
218
                <img src="..."
219
                    srcset="..."
220
                    sizes="..."
221
                >
222
            </picture>
223
            <!-- title -->
224
            <figcaption>...</figcaption>
225
        </figure>
226
        */
227
228
        // creates a <picture> used to add WebP <source> in addition to the image <img> element
229
        if ($this->builder->getConfig()->get('body.images.webp.enabled') ?? false && !Image::isAnimatedGif($InlineImage['element']['attributes']['src'])) {
230
            try {
231
                if (is_string($InlineImage['element']['attributes']['src'])) {
232
                    throw new RuntimeException(\sprintf('Asset "%s" can\'t be converted to WebP', $InlineImage['element']['attributes']['src']));
233
                }
234
                $assetWebp = Image::convertTopWebp($InlineImage['element']['attributes']['src'], $this->builder->getConfig()->get('assets.images.quality') ?? 75);
235
                $srcset = '';
236
                if ($this->builder->getConfig()->get('body.images.responsive.enabled')) {
237
                    $srcset = Image::buildSrcset(
238
                        $assetWebp,
239
                        $this->builder->getConfig()->get('assets.images.responsive.widths') ?? [480, 640, 768, 1024, 1366, 1600, 1920]
240
                    );
241
                }
242
                if (empty($srcset)) {
243
                    $srcset = (string) $assetWebp;
244
                }
245
                $PictureBlock = [
246
                    'element' => [
247
                        'name'    => 'picture',
248
                        'handler' => 'elements',
249
                    ],
250
                ];
251
                $source = [
252
                    'element' => [
253
                        'name'       => 'source',
254
                        'attributes' => [
255
                            'type'   => 'image/webp',
256
                            'srcset' => $srcset,
257
                            'sizes'  => $this->builder->getConfig()->get('assets.images.responsive.sizes.default'),
258
                        ],
259
                    ],
260
                ];
261
                $PictureBlock['element']['text'][] = $source['element'];
262
                $PictureBlock['element']['text'][] = $InlineImage['element'];
263
                $block = $PictureBlock;
264
            } catch (\Exception $e) {
265
                $this->builder->getLogger()->debug($e->getMessage());
266
            }
267
        }
268
269
        // if there is a title: put the <img> (or <picture>) in a <figure> element to use the <figcaption>
270
        if (!empty($InlineImage['element']['attributes']['title'])) {
271
            $FigureBlock = [
272
                'element' => [
273
                    'name'    => 'figure',
274
                    'handler' => 'elements',
275
                    'text'    => [
276
                        $block['element'],
277
                    ],
278
                ],
279
            ];
280
            $InlineFigcaption = [
281
                'element' => [
282
                    'name' => 'figcaption',
283
                    'text' => $InlineImage['element']['attributes']['title'],
284
                ],
285
            ];
286
            $FigureBlock['element']['text'][] = $InlineFigcaption['element'];
287
288
            return $FigureBlock;
289
        }
290
291
        return $block;
292
    }
293
294
    /**
295
     * Note block-level markup.
296
     *
297
     * :::tip
298
     * **Tip:** This is an advice.
299
     * :::
300
     *
301
     * Code inspired by https://github.com/sixlive/parsedown-alert from TJ Miller (@sixlive).
302
     */
303
    protected function blockNote($block)
304
    {
305
        if (preg_match('/:::(.*)/', $block['text'], $matches)) {
306
            return [
307
                'char'    => ':',
308
                'element' => [
309
                    'name'       => 'aside',
310
                    'text'       => '',
311
                    'attributes' => [
312
                        'class' => "note note-{$matches[1]}",
313
                    ],
314
                ],
315
            ];
316
        }
317
    }
318
319
    protected function blockNoteContinue($line, $block)
320
    {
321
        if (isset($block['complete'])) {
322
            return;
323
        }
324
        if (preg_match('/:::/', $line['text'])) {
325
            $block['complete'] = true;
326
327
            return $block;
328
        }
329
        $block['element']['text'] .= $line['text']."\n";
330
331
        return $block;
332
    }
333
334
    protected function blockNoteComplete($block)
335
    {
336
        $block['element']['rawHtml'] = $this->text($block['element']['text']);
337
        unset($block['element']['text']);
338
339
        return $block;
340
    }
341
342
    /**
343
     * Apply Highlight to code blocks.
344
     */
345
    protected function blockFencedCodeComplete($block)
346
    {
347
        if (!$this->builder->getConfig()->get('body.highlight.enabled')) {
348
            return $block;
349
        }
350
        if (!isset($block['element']['text']['attributes'])) {
351
            return $block;
352
        }
353
354
        $code = $block['element']['text']['text'];
355
        unset($block['element']['text']['text']);
356
        $languageClass = $block['element']['text']['attributes']['class'];
357
        $language = explode('-', $languageClass);
358
        $highlighted = $this->highlighter->highlight($language[1], $code);
359
        $block['element']['text']['attributes']['class'] = vsprintf('%s hljs %s', [
360
            $languageClass,
361
            $highlighted->language,
362
        ]);
363
        $block['element']['text']['rawHtml'] = $highlighted->value;
364
        $block['element']['text']['allowRawHtmlInSafeMode'] = true;
365
366
        return $block;
367
    }
368
369
    /**
370
     * Returns URL without query string.
371
     */
372
    private function cleanUrl(string $path): string
373
    {
374
        return strtok(trim($path), '?') ?: trim($path);
375
    }
376
}
377