Completed
Pull Request — master (#6927)
by Damian
11:37 queued 03:26
created

ShortcodeParser::removeNode()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\View\Parsers;
4
5
use DOMNodeList;
6
use SilverStripe\Core\Config\Configurable;
7
use SilverStripe\Core\Extensible;
8
use SilverStripe\Core\Injector\Injectable;
9
use SilverStripe\Core\Injector\Injector;
10
use InvalidArgumentException;
11
use DOMElement;
12
13
/**
14
 * A simple parser that allows you to map BBCode-like "shortcodes" to an arbitrary callback.
15
 * It is a simple regex based parser that allows you to replace simple bbcode-like tags
16
 * within a DBHTMLText or DBHTMLVarchar field when rendered into a template. The API is inspired by and very similar to the
17
 * [Wordpress implementation](http://codex.wordpress.org/Shortcode_API) of shortcodes.
18
 *
19
 * @see http://doc.silverstripe.org/framework/en/reference/shortcodes
20
 */
21
class ShortcodeParser
22
{
23
    use Injectable;
24
    use Configurable;
25
    use Extensible;
26
27
    public function __construct()
28
    {
29
        $this->constructExtensions();
30
    }
31
32
    public function img_shortcode($attrs)
33
    {
34
        return "<img src='".$attrs['src']."'>";
35
    }
36
37
    protected static $instances = array();
38
39
    protected static $active_instance = 'default';
40
41
    // --------------------------------------------------------------------------------------------------------------
42
43
    /**
44
     * Registered shortcodes. Items follow this structure:
45
     * [shortcode_name] => Array(
46
     *     [0] => class_containing_handler
47
     *     [1] => name_of_shortcode_handler_method
48
     * )
49
     */
50
    protected $shortcodes = array();
51
52
    // --------------------------------------------------------------------------------------------------------------
53
54
    /**
55
     * Get the {@link ShortcodeParser} instance that is attached to a particular identifier.
56
     *
57
     * @param string $identifier Defaults to "default".
58
     * @return ShortcodeParser
59
     */
60
    public static function get($identifier = 'default')
61
    {
62
        if (!array_key_exists($identifier, self::$instances)) {
63
            self::$instances[$identifier] = static::create();
64
        }
65
66
        return self::$instances[$identifier];
67
    }
68
69
    /**
70
     * Get the currently active/default {@link ShortcodeParser} instance.
71
     *
72
     * @return ShortcodeParser
73
     */
74
    public static function get_active()
75
    {
76
        return static::get(self::$active_instance);
77
    }
78
79
    /**
80
     * Set the identifier to use for the current active/default {@link ShortcodeParser} instance.
81
     *
82
     * @param string $identifier
83
     */
84
    public static function set_active($identifier)
85
    {
86
        self::$active_instance = (string) $identifier;
87
    }
88
89
    // --------------------------------------------------------------------------------------------------------------
90
91
    /**
92
     * Register a shortcode, and attach it to a PHP callback.
93
     *
94
     * The callback for a shortcode will have the following arguments passed to it:
95
     *   - Any parameters attached to the shortcode as an associative array (keys are lower-case).
96
     *   - Any content enclosed within the shortcode (if it is an enclosing shortcode). Note that any content within
97
     *     this will not have been parsed, and can optionally be fed back into the parser.
98
     *   - The {@link ShortcodeParser} instance used to parse the content.
99
     *   - The shortcode tag name that was matched within the parsed content.
100
     *   - An associative array of extra information about the shortcode being parsed.
101
     *
102
     * @param string $shortcode The shortcode tag to map to the callback - normally in lowercase_underscore format.
103
     * @param callback $callback The callback to replace the shortcode with.
104
     * @return $this
105
     */
106
    public function register($shortcode, $callback)
107
    {
108
        if (is_callable($callback)) {
109
            $this->shortcodes[$shortcode] = $callback;
110
        } else {
111
            throw new InvalidArgumentException("Callback is not callable");
112
        }
113
        return $this;
114
    }
115
116
    /**
117
     * Check if a shortcode has been registered.
118
     *
119
     * @param string $shortcode
120
     * @return bool
121
     */
122
    public function registered($shortcode)
123
    {
124
        return array_key_exists($shortcode, $this->shortcodes);
125
    }
126
127
    /**
128
     * Remove a specific registered shortcode.
129
     *
130
     * @param string $shortcode
131
     */
132
    public function unregister($shortcode)
133
    {
134
        if ($this->registered($shortcode)) {
135
            unset($this->shortcodes[$shortcode]);
136
        }
137
    }
138
139
    /**
140
     * Get an array containing information about registered shortcodes
141
     *
142
     * @return array
143
     */
144
    public function getRegisteredShortcodes()
145
    {
146
        return $this->shortcodes;
147
    }
148
149
    /**
150
     * Remove all registered shortcodes.
151
     */
152
    public function clear()
153
    {
154
        $this->shortcodes = array();
155
    }
156
157
    /**
158
     * Call a shortcode and return its replacement text
159
     * Returns false if the shortcode isn't registered
160
     *
161
     * @param string $tag
162
     * @param array $attributes
163
     * @param string $content
164
     * @param array $extra
165
     * @return mixed
166
     */
167
    public function callShortcode($tag, $attributes, $content, $extra = array())
168
    {
169
        if (!$tag || !isset($this->shortcodes[$tag])) {
170
            return false;
171
        }
172
        return call_user_func($this->shortcodes[$tag], $attributes, $content, $this, $tag, $extra);
173
    }
174
175
    /**
176
     * Return the text to insert in place of a shoprtcode.
177
     * Behaviour in the case of missing shortcodes depends on the setting of ShortcodeParser::$error_behavior.
178
     *
179
     * @param array $tag A map containing the the following keys:
180
     *  - 'open': The name of the tag
181
     *  - 'attrs': Attributes of the tag
182
     *  - 'content': Content of the tag
183
     * @param array $extra Extra-meta data
184
     * @param boolean $isHTMLAllowed A boolean indicating whether it's okay to insert HTML tags into the result
185
     *
186
     * @return bool|mixed|string
187
     */
188
    public function getShortcodeReplacementText($tag, $extra = array(), $isHTMLAllowed = true)
189
    {
190
        $content = $this->callShortcode($tag['open'], $tag['attrs'], $tag['content'], $extra);
191
192
        // Missing tag
193
        if ($content === false) {
194
            if (ShortcodeParser::$error_behavior == ShortcodeParser::ERROR) {
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

This check looks for accesses to local static members using the fully qualified name instead of self::.

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

    public function __construct()
    {
        $this->key = Certificate::TRIPLEDES_CBC;
    }
}

While this is perfectly valid, the fully qualified name of Certificate::TRIPLEDES_CBC could just as well be replaced by self::TRIPLEDES_CBC. Referencing local members with self:: assured the access will still work when the class is renamed, makes it perfectly clear that the member is in fact local and will usually be shorter.

Loading history...
195
                user_error('Unknown shortcode tag '.$tag['open'], E_USER_ERROR);
196
            } elseif (self::$error_behavior == self::WARN && $isHTMLAllowed) {
197
                $content = '<strong class="warning">'.$tag['text'].'</strong>';
198
            } elseif (ShortcodeParser::$error_behavior == ShortcodeParser::STRIP) {
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

This check looks for accesses to local static members using the fully qualified name instead of self::.

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

    public function __construct()
    {
        $this->key = Certificate::TRIPLEDES_CBC;
    }
}

While this is perfectly valid, the fully qualified name of Certificate::TRIPLEDES_CBC could just as well be replaced by self::TRIPLEDES_CBC. Referencing local members with self:: assured the access will still work when the class is renamed, makes it perfectly clear that the member is in fact local and will usually be shorter.

Loading history...
199
                return '';
200
            } else {
201
                return $tag['text'];
202
            }
203
        }
204
205
        return $content;
206
    }
207
208
    // --------------------------------------------------------------------------------------------------------------
209
210
    protected function removeNode($node)
211
    {
212
        $node->parentNode->removeChild($node);
213
    }
214
215
    /**
216
     * @param DOMElement $new
217
     * @param DOMElement $after
218
     */
219
    protected function insertAfter($new, $after)
220
    {
221
        $parent = $after->parentNode;
222
        $next = $after->nextSibling;
223
224
        if ($next) {
225
            $parent->insertBefore($new, $next);
226
        } elseif ($parent) {
227
            $parent->appendChild($new);
228
        }
229
    }
230
231
    /**
232
     * @param DOMNodeList $new
233
     * @param DOMElement $after
234
     */
235
    protected function insertListAfter($new, $after)
236
    {
237
        $doc = $after->ownerDocument;
238
        $parent = $after->parentNode;
239
        $next = $after->nextSibling;
240
241
        for ($i = 0; $i < $new->length; $i++) {
242
            $imported = $doc->importNode($new->item($i), true);
243
244
            if ($next) {
245
                $parent->insertBefore($imported, $next);
246
            } else {
247
                $parent->appendChild($imported);
248
            }
249
        }
250
    }
251
252
    /**
253
     * @var string
254
     */
255
    protected static $marker_class = '--ss-shortcode-marker';
256
257
    protected static $block_level_elements = array(
258
        'address', 'article', 'aside', 'audio', 'blockquote', 'canvas', 'dd', 'div', 'dl', 'fieldset', 'figcaption',
259
        'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'ol', 'output', 'p',
260
        'pre', 'section', 'table', 'ul'
261
    );
262
263
    protected static $attrrx = '
264
		([^\s\/\'"=,]+)       # Name
265
		\s* = \s*
266
		(?:
267
			(?:\'([^\']+)\') | # Value surrounded by \'
268
			(?:"([^"]+)")    | # Value surrounded by "
269
			([^\s,\]]+)          # Bare value
270
		)
271
';
272
273
    protected static function attrrx()
274
    {
275
        return '/'.self::$attrrx.'/xS';
276
    }
277
278
    protected static $tagrx = '
279
		# HTML Tag
280
		<(?<element>(?:"[^"]*"[\'"]*|\'[^\']*\'[\'"]*|[^\'">])+)>
281
282
		| # Opening tag
283
		(?<oesc>\[?)
284
		\[
285
			(?<open>\w+)
286
			[\s,]*
287
			(?<attrs> (?: %s [\s,]*)* )
288
		\/?\]
289
		(?<cesc1>\]?)
290
291
		| # Closing tag
292
		\[\/
293
			(?<close>\w+)
294
		\]
295
		(?<cesc2>\]?)
296
';
297
298
    protected static function tagrx()
299
    {
300
        return '/'.sprintf(self::$tagrx, self::$attrrx).'/xS';
301
    }
302
303
    const WARN = 'warn';
304
    const STRIP = 'strip';
305
    const LEAVE = 'leave';
306
    const ERROR = 'error';
307
308
    public static $error_behavior = self::LEAVE;
309
310
311
    /**
312
     * Look through a string that contains shortcode tags and pull out the locations and details
313
     * of those tags
314
     *
315
     * Doesn't support nested shortcode tags
316
     *
317
     * @param string $content
318
     * @return array - The list of tags found. When using an open/close pair, only one item will be in the array,
319
     * with "content" set to the text between the tags
320
     */
321
    public function extractTags($content)
322
    {
323
        $tags = array();
324
325
        // Step 1: perform basic regex scan of individual tags
326
        if (preg_match_all(static::tagrx(), $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
327
            foreach ($matches as $match) {
328
                // Ignore any elements
329
                if (empty($match['open'][0]) && empty($match['close'][0])) {
330
                    continue;
331
                }
332
333
                // Pull the attributes out into a key/value hash
334
                $attrs = array();
335
336
                if (!empty($match['attrs'][0])) {
337
                    preg_match_all(static::attrrx(), $match['attrs'][0], $attrmatches, PREG_SET_ORDER);
338
339
                    foreach ($attrmatches as $attr) {
0 ignored issues
show
Bug introduced by
The expression $attrmatches of type null|array<integer,array<integer,string>> is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
340
                        $name = '';
341
                        $value = '';
342
                        $parts = array_values(array_filter($attr));
343
                        //the first element in the array is the complete delcaration (`id=1`) - we don't need this
344
                        array_shift($parts);
345
346
                        //the next two parts are what we care about (id and 1 from `id=1`)
347
                        $name = array_shift($parts) ?: $name;
348
                        $value = array_shift($parts) ?: $value;
349
350
                        $attrs[$name] = $value;
351
                    }
352
                }
353
354
                // And store the indexes, tag details, etc
355
                $tags[] = array(
356
                    'text' => $match[0][0],
357
                    's' => $match[0][1],
358
                    'e' => $match[0][1] + strlen($match[0][0]),
359
                    'open' =>  isset($match['open'][0]) ? $match['open'][0] : null,
360
                    'close' => isset($match['close'][0]) ? $match['close'][0] : null,
361
                    'attrs' => $attrs,
362
                    'content' => '',
363
                    'escaped' => !empty($match['oesc'][0]) || !empty($match['cesc1'][0]) || !empty($match['cesc2'][0])
364
                );
365
            }
366
        }
367
368
        // Step 2: cluster open/close tag pairs into single entries
369
        $i = count($tags);
370
        while ($i--) {
371
            if (!empty($tags[$i]['close'])) {
372
                // If the tag just before this one isn't the related opening tag, throw an error
373
                $err = null;
374
375
                if ($i == 0) {
376
                    $err = 'Close tag "'.$tags[$i]['close'].'" is the first found tag, so has no related open tag';
377
                } elseif (!$tags[$i-1]['open']) {
378
                    $err = 'Close tag "'.$tags[$i]['close'].'" preceded by another close tag "'.
379
                            $tags[$i-1]['close'].'"';
380
                } elseif ($tags[$i]['close'] != $tags[$i-1]['open']) {
381
                    $err = 'Close tag "'.$tags[$i]['close'].'" doesn\'t match preceding open tag "'.
382
                            $tags[$i-1]['open'].'"';
383
                }
384
385
                if ($err) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $err of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
386
                    if (self::$error_behavior == self::ERROR) {
387
                        user_error($err, E_USER_ERROR);
388
                    }
389
                } else {
390
                    if ($tags[$i]['escaped']) {
391
                        if (!$tags[$i-1]['escaped']) {
392
                            $tags[$i]['e'] -= 1;
393
                            $tags[$i]['escaped'] = false;
394
                        }
395
                    } else {
396
                        if ($tags[$i-1]['escaped']) {
397
                            $tags[$i-1]['s'] += 1;
398
                            $tags[$i-1]['escaped'] = false;
399
                        }
400
                    }
401
402
                    // Otherwise, grab content between tags, save in opening tag & delete the closing one
403
                    $tags[$i-1]['text'] = substr($content, $tags[$i-1]['s'], $tags[$i]['e'] - $tags[$i-1]['s']);
404
                    $tags[$i-1]['content'] = substr($content, $tags[$i-1]['e'], $tags[$i]['s'] - $tags[$i-1]['e']);
405
                    $tags[$i-1]['e'] = $tags[$i]['e'];
406
407
                    unset($tags[$i]);
408
                }
409
            }
410
        }
411
412
        // Step 3: remove any tags that don't have handlers registered
413
        // Only do this if self::$error_behavior == self::LEAVE
0 ignored issues
show
Unused Code Comprehensibility introduced by
39% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
414
        // This is optional but speeds things up.
415
        if (self::$error_behavior == self::LEAVE) {
416
            foreach ($tags as $i => $tag) {
417
                if (empty($this->shortcodes[$tag['open']])) {
418
                    unset($tags[$i]);
419
                }
420
            }
421
        }
422
423
        return array_values($tags);
424
    }
425
426
    /**
427
     * Replaces the shortcode tags extracted by extractTags with HTML element "markers", so that
428
     * we can parse the resulting string as HTML and easily mutate the shortcodes in the DOM
429
     *
430
     * @param string $content The HTML string with [tag] style shortcodes embedded
431
     * @param array $tags The tags extracted by extractTags
432
     * @param callable $generator
433
     * @return string The HTML string with [tag] style shortcodes replaced by markers
434
     */
435
    protected function replaceTagsWithText($content, $tags, $generator)
436
    {
437
        // The string with tags replaced with markers
438
        $str = '';
439
        // The start index of the next tag, remembered as we step backwards through the list
440
        $li = null;
441
442
        $i = count($tags);
443
        while ($i--) {
444
            if ($li === null) {
445
                $tail = substr($content, $tags[$i]['e']);
446
            } else {
447
                $tail = substr($content, $tags[$i]['e'], $li - $tags[$i]['e']);
448
            }
449
450
            if ($tags[$i]['escaped']) {
451
                $str = substr($content, $tags[$i]['s']+1, $tags[$i]['e'] - $tags[$i]['s'] - 2) . $tail . $str;
452
            } else {
453
                $str = $generator($i, $tags[$i]) . $tail . $str;
454
            }
455
456
            $li = $tags[$i]['s'];
457
        }
458
459
        return substr($content, 0, $tags[0]['s']) . $str;
460
    }
461
462
    /**
463
     * Replace the shortcodes in attribute values with the calculated content
464
     *
465
     * We don't use markers with attributes because there's no point, it's easier to do all the matching
466
     * in-DOM after the XML parse
467
     *
468
     * @param HTMLValue $htmlvalue
469
     */
470
    protected function replaceAttributeTagsWithContent($htmlvalue)
471
    {
472
        $attributes = $htmlvalue->query('//@*[contains(.,"[")][contains(.,"]")]');
473
        $parser = $this;
474
475
        for ($i = 0; $i < $attributes->length; $i++) {
476
            $node = $attributes->item($i);
477
            $tags = $this->extractTags($node->nodeValue);
478
            $extra = array('node' => $node, 'element' => $node->ownerElement);
0 ignored issues
show
Bug introduced by
The property ownerElement does not seem to exist in DOMNode.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
479
480
            if ($tags) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $tags of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
481
                $node->nodeValue = $this->replaceTagsWithText(
482
                    $node->nodeValue,
483
                    $tags,
484
                    function ($idx, $tag) use ($parser, $extra) {
485
                        return $parser->getShortcodeReplacementText($tag, $extra, false);
486
                    }
487
                );
488
            }
489
        }
490
    }
491
492
    /**
493
     * Replace the element-scoped tags with markers
494
     *
495
     * @param string $content
496
     * @return array
497
     */
498
    protected function replaceElementTagsWithMarkers($content)
499
    {
500
        $tags = $this->extractTags($content);
501
502
        if ($tags) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $tags of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
503
            $markerClass = self::$marker_class;
504
505
            $content = $this->replaceTagsWithText($content, $tags, function ($idx, $tag) use ($markerClass) {
0 ignored issues
show
Unused Code introduced by
The parameter $tag is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
506
                return '<img class="'.$markerClass.'" data-tagid="'.$idx.'" />';
507
            });
508
        }
509
510
        return array($content, $tags);
511
    }
512
513
    /**
514
     * @param DOMNodeList $nodes
515
     * @return array
516
     */
517
    protected function findParentsForMarkers($nodes)
518
    {
519
        $parents = array();
520
521
        /** @var DOMElement $node */
522
        foreach ($nodes as $node) {
523
            $parent = $node;
524
525
            do {
526
                $parent = $parent->parentNode;
527
            } while ($parent instanceof DOMElement &&
528
                !in_array(strtolower($parent->tagName), self::$block_level_elements)
529
            );
530
531
            $node->setAttribute('data-parentid', count($parents));
532
            $parents[] = $parent;
533
        }
534
535
        return $parents;
536
    }
537
538
    const BEFORE = 'before';
539
    const AFTER = 'after';
540
    const SPLIT = 'split';
541
    const INLINE = 'inline';
542
543
    /**
544
     * Given a node with represents a shortcode marker and a location string, mutates the DOM to put the
545
     * marker in the compliant location
546
     *
547
     * For shortcodes inserted BEFORE, that location is just before the block container that
548
     * the marker is in
549
     *
550
     * For shortcodes inserted AFTER, that location is just after the block container that
551
     * the marker is in
552
     *
553
     * For shortcodes inserted SPLIT, that location is where the marker is, but the DOM
554
     * is split around it up to the block container the marker is in - for instance,
555
     *
556
     *   <p>A<span>B<marker />C</span>D</p>
557
     *
558
     * becomes
559
     *
560
     *   <p>A<span>B</span></p><marker /><p><span>C</span>D</p>
561
     *
562
     * For shortcodes inserted INLINE, no modification is needed (but in that case the shortcode handler needs to
563
     * generate only inline blocks)
564
     *
565
     * @param DOMElement $node
566
     * @param DOMElement $parent
567
     * @param int $location ShortcodeParser::BEFORE, ShortcodeParser::SPLIT or ShortcodeParser::INLINE
568
     */
569
    protected function moveMarkerToCompliantHome($node, $parent, $location)
570
    {
571
        // Move before block parent
572
        if ($location == self::BEFORE) {
573
            if (isset($parent->parentNode)) {
574
                $parent->parentNode->insertBefore($node, $parent);
575
            }
576
        } elseif ($location == self::AFTER) {
577
            // Move after block parent
578
            $this->insertAfter($node, $parent);
579
        } // Split parent at node
580
        elseif ($location == self::SPLIT) {
581
            $at = $node;
582
            $splitee = $node->parentNode;
583
584
            while ($splitee !== $parent->parentNode) {
585
                /** @var DOMElement $spliter */
586
                $spliter = $splitee->cloneNode(false);
587
588
                $this->insertAfter($spliter, $splitee);
0 ignored issues
show
Compatibility introduced by
$splitee of type object<DOMNode> is not a sub-type of object<DOMElement>. It seems like you assume a child class of the class DOMNode to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
589
590
                while ($at->nextSibling) {
591
                    $spliter->appendChild($at->nextSibling);
592
                }
593
594
                $at = $splitee;
595
                $splitee = $splitee->parentNode;
596
            }
597
598
            $this->insertAfter($node, $parent);
599
        } // Do nothing
600
        elseif ($location == self::INLINE) {
601
            if (in_array(strtolower($node->tagName), self::$block_level_elements)) {
602
                user_error(
603
                    'Requested to insert block tag '.$node->tagName.
604
                    ' inline - probably this will break HTML compliance',
605
                    E_USER_WARNING
606
                );
607
            }
608
            // NOP
609
        } else {
610
            user_error('Unknown value for $location argument '.$location, E_USER_ERROR);
611
        }
612
    }
613
614
    /**
615
     * Given a node with represents a shortcode marker and some information about the shortcode, call the
616
     * shortcode handler & replace the marker with the actual content
617
     *
618
     * @param DOMElement $node
619
     * @param array $tag
620
     */
621
    protected function replaceMarkerWithContent($node, $tag)
622
    {
623
        $content = $this->getShortcodeReplacementText($tag);
624
625
        if ($content) {
626
            /** @var HTMLValue $parsed */
627
            $parsed = Injector::inst()->create('HTMLValue', $content);
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Psr\Container\ContainerInterface as the method create() does only exist in the following implementations of said interface: SilverStripe\Core\Injector\Injector.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
628
            $body = $parsed->getBody();
629
            if ($body) {
630
                $this->insertListAfter($body->childNodes, $node);
631
            }
632
        }
633
634
        $this->removeNode($node);
635
    }
636
637
    /**
638
     * Parse a string, and replace any registered shortcodes within it with the result of the mapped callback.
639
     *
640
     * @param string $content
641
     * @return string
642
     */
643
    public function parse($content)
644
    {
645
646
        $this->extend('onBeforeParse', $content);
647
648
        $continue = true;
649
650
        // If no shortcodes defined, don't try and parse any
651
        if (!$this->shortcodes) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->shortcodes of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
652
            $continue = false;
653
        } // If no content, don't try and parse it
654
        elseif (!trim($content)) {
655
            $continue = false;
656
        } // If no shortcode tag, don't try and parse it
657
        elseif (strpos($content, '[') === false) {
658
            $continue = false;
659
        }
660
661
        if ($continue) {
662
            // First we operate in text mode, replacing any shortcodes with marker elements so that later we can
663
            // use a proper DOM
664
            list($content, $tags) = $this->replaceElementTagsWithMarkers($content);
665
666
        /** @var HTMLValue $htmlvalue */
667
            $htmlvalue = Injector::inst()->create('HTMLValue', $content);
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Psr\Container\ContainerInterface as the method create() does only exist in the following implementations of said interface: SilverStripe\Core\Injector\Injector.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
668
669
            // Now parse the result into a DOM
670
            if (!$htmlvalue->isValid()) {
671
                if (self::$error_behavior == self::ERROR) {
672
                    user_error('Couldn\'t decode HTML when processing short codes', E_USER_ERRROR);
673
                } else {
674
                    $continue = false;
675
                }
676
            }
677
        }
678
679
        if ($continue) {
680
            // First, replace any shortcodes that are in attributes
681
            $this->replaceAttributeTagsWithContent($htmlvalue);
0 ignored issues
show
Bug introduced by
The variable $htmlvalue does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
682
683
            // Find all the element scoped shortcode markers
684
            $shortcodes = $htmlvalue->query('//img[@class="'.self::$marker_class.'"]');
685
686
            // Find the parents. Do this before DOM modification, since SPLIT might cause parents to move otherwise
687
            $parents = $this->findParentsForMarkers($shortcodes);
688
689
        /** @var DOMElement $shortcode */
690
            foreach ($shortcodes as $shortcode) {
691
                $tag = $tags[$shortcode->getAttribute('data-tagid')];
0 ignored issues
show
Bug introduced by
The variable $tags does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
692
                $parent = $parents[$shortcode->getAttribute('data-parentid')];
693
694
                $class = null;
695
                if (!empty($tag['attrs']['location'])) {
696
                    $class = $tag['attrs']['location'];
697
                } elseif (!empty($tag['attrs']['class'])) {
698
                    $class = $tag['attrs']['class'];
699
                }
700
701
                $location = self::INLINE;
702
                if ($class == 'left' || $class == 'right') {
703
                    $location = self::BEFORE;
704
                }
705
                /**
706
                 * Below code disabled due to https://github.com/silverstripe/silverstripe-framework/issues/5987
707
                if ($class == 'center' || $class == 'leftAlone') {
708
                    $location = self::SPLIT;
709
                }
710
                 */
711
712
                if (!$parent) {
713
                    if ($location !== self::INLINE) {
714
                        user_error(
715
                            "Parent block for shortcode couldn't be found, but location wasn't INLINE",
716
                            E_USER_ERROR
717
                        );
718
                    }
719
                } else {
720
                    $this->moveMarkerToCompliantHome($shortcode, $parent, $location);
721
                }
722
723
                $this->replaceMarkerWithContent($shortcode, $tag);
724
            }
725
726
            $content = $htmlvalue->getContent();
727
728
            // Clean up any marker classes left over, for example, those injected into <script> tags
729
            $parser = $this;
730
            $content = preg_replace_callback(
731
                // Not a general-case parser; assumes that the HTML generated in replaceElementTagsWithMarkers()
732
                // hasn't been heavily modified
733
                '/<img[^>]+class="'.preg_quote(self::$marker_class).'"[^>]+data-tagid="([^"]+)"[^>]+>/i',
734
                function ($matches) use ($tags, $parser) {
0 ignored issues
show
Bug introduced by
The variable $tags does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
735
                    $tag = $tags[$matches[1]];
736
                    return $parser->getShortcodeReplacementText($tag);
737
                },
738
                $content
739
            );
740
        }
741
742
        $this->extend('onAfterParse', $content);
743
744
        return $content;
745
    }
746
}
747