Passed
Push — feat/markdown-highlighter ( 1efac5...98434c )
by Arnaud
13:07 queued 08:45
created

Parsedown::inlineInsert()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 13
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 4.0312

Importance

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