Passed
Push — fallback-intl ( 2f4c17...1f6a03 )
by Arnaud
03:32
created

Parsedown::inlineInsert()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 13
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 4.25

Importance

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