Completed
Push — master ( c17796...052b15 )
by Damian
01:29
created

ShortcodeParser::get_active()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
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
    }
30
31
    public function img_shortcode($attrs)
32
    {
33
        return "<img src='".$attrs['src']."'>";
34
    }
35
36
    protected static $instances = array();
37
38
    protected static $active_instance = 'default';
39
40
    // --------------------------------------------------------------------------------------------------------------
41
42
    /**
43
     * Registered shortcodes. Items follow this structure:
44
     * [shortcode_name] => Array(
45
     *     [0] => class_containing_handler
46
     *     [1] => name_of_shortcode_handler_method
47
     * )
48
     */
49
    protected $shortcodes = array();
50
51
    // --------------------------------------------------------------------------------------------------------------
52
53
    /**
54
     * Get the {@link ShortcodeParser} instance that is attached to a particular identifier.
55
     *
56
     * @param string $identifier Defaults to "default".
57
     * @return ShortcodeParser
58
     */
59
    public static function get($identifier = 'default')
60
    {
61
        if (!array_key_exists($identifier, self::$instances)) {
62
            self::$instances[$identifier] = static::create();
63
        }
64
65
        return self::$instances[$identifier];
66
    }
67
68
    /**
69
     * Get the currently active/default {@link ShortcodeParser} instance.
70
     *
71
     * @return ShortcodeParser
72
     */
73
    public static function get_active()
74
    {
75
        return static::get(self::$active_instance);
76
    }
77
78
    /**
79
     * Set the identifier to use for the current active/default {@link ShortcodeParser} instance.
80
     *
81
     * @param string $identifier
82
     */
83
    public static function set_active($identifier)
84
    {
85
        self::$active_instance = (string) $identifier;
86
    }
87
88
    // --------------------------------------------------------------------------------------------------------------
89
90
    /**
91
     * Register a shortcode, and attach it to a PHP callback.
92
     *
93
     * The callback for a shortcode will have the following arguments passed to it:
94
     *   - Any parameters attached to the shortcode as an associative array (keys are lower-case).
95
     *   - Any content enclosed within the shortcode (if it is an enclosing shortcode). Note that any content within
96
     *     this will not have been parsed, and can optionally be fed back into the parser.
97
     *   - The {@link ShortcodeParser} instance used to parse the content.
98
     *   - The shortcode tag name that was matched within the parsed content.
99
     *   - An associative array of extra information about the shortcode being parsed.
100
     *
101
     * @param string $shortcode The shortcode tag to map to the callback - normally in lowercase_underscore format.
102
     * @param callback $callback The callback to replace the shortcode with.
103
     * @return $this
104
     */
105
    public function register($shortcode, $callback)
106
    {
107
        if (is_callable($callback)) {
108
            $this->shortcodes[$shortcode] = $callback;
109
        } else {
110
            throw new InvalidArgumentException("Callback is not callable");
111
        }
112
        return $this;
113
    }
114
115
    /**
116
     * Check if a shortcode has been registered.
117
     *
118
     * @param string $shortcode
119
     * @return bool
120
     */
121
    public function registered($shortcode)
122
    {
123
        return array_key_exists($shortcode, $this->shortcodes);
124
    }
125
126
    /**
127
     * Remove a specific registered shortcode.
128
     *
129
     * @param string $shortcode
130
     */
131
    public function unregister($shortcode)
132
    {
133
        if ($this->registered($shortcode)) {
134
            unset($this->shortcodes[$shortcode]);
135
        }
136
    }
137
138
    /**
139
     * Get an array containing information about registered shortcodes
140
     *
141
     * @return array
142
     */
143
    public function getRegisteredShortcodes()
144
    {
145
        return $this->shortcodes;
146
    }
147
148
    /**
149
     * Remove all registered shortcodes.
150
     */
151
    public function clear()
152
    {
153
        $this->shortcodes = array();
154
    }
155
156
    /**
157
     * Call a shortcode and return its replacement text
158
     * Returns false if the shortcode isn't registered
159
     *
160
     * @param string $tag
161
     * @param array $attributes
162
     * @param string $content
163
     * @param array $extra
164
     * @return mixed
165
     */
166
    public function callShortcode($tag, $attributes, $content, $extra = array())
167
    {
168
        if (!$tag || !isset($this->shortcodes[$tag])) {
169
            return false;
170
        }
171
        return call_user_func($this->shortcodes[$tag], $attributes, $content, $this, $tag, $extra);
172
    }
173
174
    /**
175
     * Return the text to insert in place of a shoprtcode.
176
     * Behaviour in the case of missing shortcodes depends on the setting of ShortcodeParser::$error_behavior.
177
     *
178
     * @param array $tag A map containing the the following keys:
179
     *  - 'open': The name of the tag
180
     *  - 'attrs': Attributes of the tag
181
     *  - 'content': Content of the tag
182
     * @param array $extra Extra-meta data
183
     * @param boolean $isHTMLAllowed A boolean indicating whether it's okay to insert HTML tags into the result
184
     *
185
     * @return bool|mixed|string
186
     */
187
    public function getShortcodeReplacementText($tag, $extra = array(), $isHTMLAllowed = true)
188
    {
189
        $content = $this->callShortcode($tag['open'], $tag['attrs'], $tag['content'], $extra);
190
191
        // Missing tag
192
        if ($content === false) {
193
            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...
194
                user_error('Unknown shortcode tag '.$tag['open'], E_USER_ERROR);
195
            } elseif (self::$error_behavior == self::WARN && $isHTMLAllowed) {
196
                $content = '<strong class="warning">'.$tag['text'].'</strong>';
197
            } 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...
198
                return '';
199
            } else {
200
                return $tag['text'];
201
            }
202
        }
203
204
        return $content;
205
    }
206
207
    // --------------------------------------------------------------------------------------------------------------
208
209
    protected function removeNode($node)
210
    {
211
        $node->parentNode->removeChild($node);
212
    }
213
214
    /**
215
     * @param DOMElement $new
216
     * @param DOMElement $after
217
     */
218
    protected function insertAfter($new, $after)
219
    {
220
        $parent = $after->parentNode;
221
        $next = $after->nextSibling;
222
223
        if ($next) {
224
            $parent->insertBefore($new, $next);
225
        } elseif ($parent) {
226
            $parent->appendChild($new);
227
        }
228
    }
229
230
    /**
231
     * @param DOMNodeList $new
232
     * @param DOMElement $after
233
     */
234
    protected function insertListAfter($new, $after)
235
    {
236
        $doc = $after->ownerDocument;
237
        $parent = $after->parentNode;
238
        $next = $after->nextSibling;
239
240
        for ($i = 0; $i < $new->length; $i++) {
241
            $imported = $doc->importNode($new->item($i), true);
242
243
            if ($next) {
244
                $parent->insertBefore($imported, $next);
245
            } else {
246
                $parent->appendChild($imported);
247
            }
248
        }
249
    }
250
251
    /**
252
     * @var string
253
     */
254
    protected static $marker_class = '--ss-shortcode-marker';
255
256
    protected static $block_level_elements = array(
257
        'address', 'article', 'aside', 'audio', 'blockquote', 'canvas', 'dd', 'div', 'dl', 'fieldset', 'figcaption',
258
        'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'ol', 'output', 'p',
259
        'pre', 'section', 'table', 'ul'
260
    );
261
262
    protected static $attrrx = '
263
		([^\s\/\'"=,]+)       # Name
264
		\s* = \s*
265
		(?:
266
			(?:\'([^\']+)\') | # Value surrounded by \'
267
			(?:"([^"]+)")    | # Value surrounded by "
268
			([^\s,\]]+)          # Bare value
269
		)
270
';
271
272
    protected static function attrrx()
273
    {
274
        return '/'.self::$attrrx.'/xS';
275
    }
276
277
    protected static $tagrx = '
278
		# HTML Tag
279
		<(?<element>(?:"[^"]*"[\'"]*|\'[^\']*\'[\'"]*|[^\'">])+)>
280
281
		| # Opening tag
282
		(?<oesc>\[?)
283
		\[
284
			(?<open>\w+)
285
			[\s,]*
286
			(?<attrs> (?: %s [\s,]*)* )
287
		\/?\]
288
		(?<cesc1>\]?)
289
290
		| # Closing tag
291
		\[\/
292
			(?<close>\w+)
293
		\]
294
		(?<cesc2>\]?)
295
';
296
297
    protected static function tagrx()
298
    {
299
        return '/'.sprintf(self::$tagrx, self::$attrrx).'/xS';
300
    }
301
302
    const WARN = 'warn';
303
    const STRIP = 'strip';
304
    const LEAVE = 'leave';
305
    const ERROR = 'error';
306
307
    public static $error_behavior = self::LEAVE;
308
309
310
    /**
311
     * Look through a string that contains shortcode tags and pull out the locations and details
312
     * of those tags
313
     *
314
     * Doesn't support nested shortcode tags
315
     *
316
     * @param string $content
317
     * @return array - The list of tags found. When using an open/close pair, only one item will be in the array,
318
     * with "content" set to the text between the tags
319
     */
320
    public function extractTags($content)
321
    {
322
        $tags = array();
323
324
        // Step 1: perform basic regex scan of individual tags
325
        if (preg_match_all(static::tagrx(), $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
326
            foreach ($matches as $match) {
327
                // Ignore any elements
328
                if (empty($match['open'][0]) && empty($match['close'][0])) {
329
                    continue;
330
                }
331
332
                // Pull the attributes out into a key/value hash
333
                $attrs = array();
334
335
                if (!empty($match['attrs'][0])) {
336
                    preg_match_all(static::attrrx(), $match['attrs'][0], $attrmatches, PREG_SET_ORDER);
337
338
                    foreach ($attrmatches as $attr) {
339
                        $name = '';
340
                        $value = '';
341
                        $parts = array_values(array_filter($attr));
342
                        //the first element in the array is the complete delcaration (`id=1`) - we don't need this
343
                        array_shift($parts);
344
345
                        //the next two parts are what we care about (id and 1 from `id=1`)
346
                        $name = array_shift($parts) ?: $name;
347
                        $value = array_shift($parts) ?: $value;
348
349
                        $attrs[$name] = $value;
350
                    }
351
                }
352
353
                // And store the indexes, tag details, etc
354
                $tags[] = array(
355
                    'text' => $match[0][0],
356
                    's' => $match[0][1],
357
                    'e' => $match[0][1] + strlen($match[0][0]),
358
                    'open' =>  isset($match['open'][0]) ? $match['open'][0] : null,
359
                    'close' => isset($match['close'][0]) ? $match['close'][0] : null,
360
                    'attrs' => $attrs,
361
                    'content' => '',
362
                    'escaped' => !empty($match['oesc'][0]) || !empty($match['cesc1'][0]) || !empty($match['cesc2'][0])
363
                );
364
            }
365
        }
366
367
        // Step 2: cluster open/close tag pairs into single entries
368
        $i = count($tags);
369
        while ($i--) {
370
            if (!empty($tags[$i]['close'])) {
371
                // If the tag just before this one isn't the related opening tag, throw an error
372
                $err = null;
373
374
                if ($i == 0) {
375
                    $err = 'Close tag "'.$tags[$i]['close'].'" is the first found tag, so has no related open tag';
376
                } elseif (!$tags[$i-1]['open']) {
377
                    $err = 'Close tag "'.$tags[$i]['close'].'" preceded by another close tag "'.
378
                            $tags[$i-1]['close'].'"';
379
                } elseif ($tags[$i]['close'] != $tags[$i-1]['open']) {
380
                    $err = 'Close tag "'.$tags[$i]['close'].'" doesn\'t match preceding open tag "'.
381
                            $tags[$i-1]['open'].'"';
382
                }
383
384
                if ($err) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $err of type null|string 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...
385
                    if (self::$error_behavior == self::ERROR) {
386
                        user_error($err, E_USER_ERROR);
387
                    }
388
                } else {
389
                    if ($tags[$i]['escaped']) {
390
                        if (!$tags[$i-1]['escaped']) {
391
                            $tags[$i]['e'] -= 1;
392
                            $tags[$i]['escaped'] = false;
393
                        }
394
                    } else {
395
                        if ($tags[$i-1]['escaped']) {
396
                            $tags[$i-1]['s'] += 1;
397
                            $tags[$i-1]['escaped'] = false;
398
                        }
399
                    }
400
401
                    // Otherwise, grab content between tags, save in opening tag & delete the closing one
402
                    $tags[$i-1]['text'] = substr($content, $tags[$i-1]['s'], $tags[$i]['e'] - $tags[$i-1]['s']);
403
                    $tags[$i-1]['content'] = substr($content, $tags[$i-1]['e'], $tags[$i]['s'] - $tags[$i-1]['e']);
404
                    $tags[$i-1]['e'] = $tags[$i]['e'];
405
406
                    unset($tags[$i]);
407
                }
408
            }
409
        }
410
411
        // Step 3: remove any tags that don't have handlers registered
412
        // 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...
413
        // This is optional but speeds things up.
414
        if (self::$error_behavior == self::LEAVE) {
415
            foreach ($tags as $i => $tag) {
416
                if (empty($this->shortcodes[$tag['open']])) {
417
                    unset($tags[$i]);
418
                }
419
            }
420
        }
421
422
        return array_values($tags);
423
    }
424
425
    /**
426
     * Replaces the shortcode tags extracted by extractTags with HTML element "markers", so that
427
     * we can parse the resulting string as HTML and easily mutate the shortcodes in the DOM
428
     *
429
     * @param string $content The HTML string with [tag] style shortcodes embedded
430
     * @param array $tags The tags extracted by extractTags
431
     * @param callable $generator
432
     * @return string The HTML string with [tag] style shortcodes replaced by markers
433
     */
434
    protected function replaceTagsWithText($content, $tags, $generator)
435
    {
436
        // The string with tags replaced with markers
437
        $str = '';
438
        // The start index of the next tag, remembered as we step backwards through the list
439
        $li = null;
440
441
        $i = count($tags);
442
        while ($i--) {
443
            if ($li === null) {
444
                $tail = substr($content, $tags[$i]['e']);
445
            } else {
446
                $tail = substr($content, $tags[$i]['e'], $li - $tags[$i]['e']);
447
            }
448
449
            if ($tags[$i]['escaped']) {
450
                $str = substr($content, $tags[$i]['s']+1, $tags[$i]['e'] - $tags[$i]['s'] - 2) . $tail . $str;
451
            } else {
452
                $str = $generator($i, $tags[$i]) . $tail . $str;
453
            }
454
455
            $li = $tags[$i]['s'];
456
        }
457
458
        return substr($content, 0, $tags[0]['s']) . $str;
459
    }
460
461
    /**
462
     * Replace the shortcodes in attribute values with the calculated content
463
     *
464
     * We don't use markers with attributes because there's no point, it's easier to do all the matching
465
     * in-DOM after the XML parse
466
     *
467
     * @param HTMLValue $htmlvalue
468
     */
469
    protected function replaceAttributeTagsWithContent($htmlvalue)
470
    {
471
        $attributes = $htmlvalue->query('//@*[contains(.,"[")][contains(.,"]")]');
472
        $parser = $this;
473
474
        for ($i = 0; $i < $attributes->length; $i++) {
475
            $node = $attributes->item($i);
476
            $tags = $this->extractTags($node->nodeValue);
477
            $extra = array('node' => $node, 'element' => $node->ownerElement);
0 ignored issues
show
Bug introduced by
The property ownerElement does not seem to exist on DOMElement.
Loading history...
478
479
            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...
480
                $node->nodeValue = $this->replaceTagsWithText(
481
                    $node->nodeValue,
482
                    $tags,
483
                    function ($idx, $tag) use ($parser, $extra) {
484
                        return $parser->getShortcodeReplacementText($tag, $extra, false);
485
                    }
486
                );
487
            }
488
        }
489
    }
490
491
    /**
492
     * Replace the element-scoped tags with markers
493
     *
494
     * @param string $content
495
     * @return array
496
     */
497
    protected function replaceElementTagsWithMarkers($content)
498
    {
499
        $tags = $this->extractTags($content);
500
501
        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...
502
            $markerClass = self::$marker_class;
503
504
            $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. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

504
            $content = $this->replaceTagsWithText($content, $tags, function ($idx, /** @scrutinizer ignore-unused */ $tag) use ($markerClass) {

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

Loading history...
505
                return '<img class="'.$markerClass.'" data-tagid="'.$idx.'" />';
506
            });
507
        }
508
509
        return array($content, $tags);
510
    }
511
512
    /**
513
     * @param DOMNodeList $nodes
514
     * @return array
515
     */
516
    protected function findParentsForMarkers($nodes)
517
    {
518
        $parents = array();
519
520
        /** @var DOMElement $node */
521
        foreach ($nodes as $node) {
522
            $parent = $node;
523
524
            do {
525
                $parent = $parent->parentNode;
526
            } while ($parent instanceof DOMElement &&
527
                !in_array(strtolower($parent->tagName), self::$block_level_elements)
528
            );
529
530
            $node->setAttribute('data-parentid', count($parents));
531
            $parents[] = $parent;
532
        }
533
534
        return $parents;
535
    }
536
537
    const BEFORE = 'before';
538
    const AFTER = 'after';
539
    const SPLIT = 'split';
540
    const INLINE = 'inline';
541
542
    /**
543
     * Given a node with represents a shortcode marker and a location string, mutates the DOM to put the
544
     * marker in the compliant location
545
     *
546
     * For shortcodes inserted BEFORE, that location is just before the block container that
547
     * the marker is in
548
     *
549
     * For shortcodes inserted AFTER, that location is just after the block container that
550
     * the marker is in
551
     *
552
     * For shortcodes inserted SPLIT, that location is where the marker is, but the DOM
553
     * is split around it up to the block container the marker is in - for instance,
554
     *
555
     *   <p>A<span>B<marker />C</span>D</p>
556
     *
557
     * becomes
558
     *
559
     *   <p>A<span>B</span></p><marker /><p><span>C</span>D</p>
560
     *
561
     * For shortcodes inserted INLINE, no modification is needed (but in that case the shortcode handler needs to
562
     * generate only inline blocks)
563
     *
564
     * @param DOMElement $node
565
     * @param DOMElement $parent
566
     * @param int $location ShortcodeParser::BEFORE, ShortcodeParser::SPLIT or ShortcodeParser::INLINE
567
     */
568
    protected function moveMarkerToCompliantHome($node, $parent, $location)
569
    {
570
        // Move before block parent
571
        if ($location == self::BEFORE) {
572
            if (isset($parent->parentNode)) {
573
                $parent->parentNode->insertBefore($node, $parent);
574
            }
575
        } elseif ($location == self::AFTER) {
576
            // Move after block parent
577
            $this->insertAfter($node, $parent);
578
        } // Split parent at node
579
        elseif ($location == self::SPLIT) {
580
            $at = $node;
581
            $splitee = $node->parentNode;
582
583
            while ($splitee !== $parent->parentNode) {
584
                /** @var DOMElement $spliter */
585
                $spliter = $splitee->cloneNode(false);
586
587
                $this->insertAfter($spliter, $splitee);
588
589
                while ($at->nextSibling) {
590
                    $spliter->appendChild($at->nextSibling);
591
                }
592
593
                $at = $splitee;
594
                $splitee = $splitee->parentNode;
595
            }
596
597
            $this->insertAfter($node, $parent);
598
        } // Do nothing
599
        elseif ($location == self::INLINE) {
600
            if (in_array(strtolower($node->tagName), self::$block_level_elements)) {
601
                user_error(
602
                    'Requested to insert block tag '.$node->tagName.
603
                    ' inline - probably this will break HTML compliance',
604
                    E_USER_WARNING
605
                );
606
            }
607
            // NOP
608
        } else {
609
            user_error('Unknown value for $location argument '.$location, E_USER_ERROR);
610
        }
611
    }
612
613
    /**
614
     * Given a node with represents a shortcode marker and some information about the shortcode, call the
615
     * shortcode handler & replace the marker with the actual content
616
     *
617
     * @param DOMElement $node
618
     * @param array $tag
619
     */
620
    protected function replaceMarkerWithContent($node, $tag)
621
    {
622
        $content = $this->getShortcodeReplacementText($tag);
623
624
        if ($content) {
625
            /** @var HTMLValue $parsed */
626
            $parsed = HTMLValue::create($content);
0 ignored issues
show
Bug introduced by
It seems like $content can also be of type string; however, parameter $args of SilverStripe\View\ViewableData::create() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

626
            $parsed = HTMLValue::create(/** @scrutinizer ignore-type */ $content);
Loading history...
627
            $body = $parsed->getBody();
628
            if ($body) {
629
                $this->insertListAfter($body->childNodes, $node);
630
            }
631
        }
632
633
        $this->removeNode($node);
634
    }
635
636
    /**
637
     * Parse a string, and replace any registered shortcodes within it with the result of the mapped callback.
638
     *
639
     * @param string $content
640
     * @return string
641
     */
642
    public function parse($content)
643
    {
644
645
        $this->extend('onBeforeParse', $content);
646
647
        $continue = true;
648
649
        // If no shortcodes defined, don't try and parse any
650
        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...
651
            $continue = false;
652
        } // If no content, don't try and parse it
653
        elseif (!trim($content)) {
654
            $continue = false;
655
        } // If no shortcode tag, don't try and parse it
656
        elseif (strpos($content, '[') === false) {
657
            $continue = false;
658
        }
659
660
        if ($continue) {
661
            // First we operate in text mode, replacing any shortcodes with marker elements so that later we can
662
            // use a proper DOM
663
            list($content, $tags) = $this->replaceElementTagsWithMarkers($content);
664
665
        /** @var HTMLValue $htmlvalue */
666
            $htmlvalue = Injector::inst()->create('HTMLValue', $content);
667
668
            // Now parse the result into a DOM
669
            if (!$htmlvalue->isValid()) {
670
                if (self::$error_behavior == self::ERROR) {
671
                    user_error('Couldn\'t decode HTML when processing short codes', E_USER_ERROR);
672
                } else {
673
                    $continue = false;
674
                }
675
            }
676
        }
677
678
        if ($continue) {
679
            // First, replace any shortcodes that are in attributes
680
            $this->replaceAttributeTagsWithContent($htmlvalue);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $htmlvalue does not seem to be defined for all execution paths leading up to this point.
Loading history...
681
682
            // Find all the element scoped shortcode markers
683
            $shortcodes = $htmlvalue->query('//img[@class="'.self::$marker_class.'"]');
684
685
            // Find the parents. Do this before DOM modification, since SPLIT might cause parents to move otherwise
686
            $parents = $this->findParentsForMarkers($shortcodes);
687
688
        /** @var DOMElement $shortcode */
689
            foreach ($shortcodes as $shortcode) {
690
                $tag = $tags[$shortcode->getAttribute('data-tagid')];
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $tags does not seem to be defined for all execution paths leading up to this point.
Loading history...
691
                $parent = $parents[$shortcode->getAttribute('data-parentid')];
692
693
                $class = null;
694
                if (!empty($tag['attrs']['location'])) {
695
                    $class = $tag['attrs']['location'];
696
                } elseif (!empty($tag['attrs']['class'])) {
697
                    $class = $tag['attrs']['class'];
698
                }
699
700
                $location = self::INLINE;
701
                if ($class == 'left' || $class == 'right') {
702
                    $location = self::BEFORE;
703
                }
704
                /**
705
                 * Below code disabled due to https://github.com/silverstripe/silverstripe-framework/issues/5987
706
                if ($class == 'center' || $class == 'leftAlone') {
707
                    $location = self::SPLIT;
708
                }
709
                 */
710
711
                if (!$parent) {
712
                    if ($location !== self::INLINE) {
713
                        user_error(
714
                            "Parent block for shortcode couldn't be found, but location wasn't INLINE",
715
                            E_USER_ERROR
716
                        );
717
                    }
718
                } else {
719
                    $this->moveMarkerToCompliantHome($shortcode, $parent, $location);
0 ignored issues
show
Bug introduced by
$location of type string is incompatible with the type integer expected by parameter $location of SilverStripe\View\Parser...MarkerToCompliantHome(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

719
                    $this->moveMarkerToCompliantHome($shortcode, $parent, /** @scrutinizer ignore-type */ $location);
Loading history...
720
                }
721
722
                $this->replaceMarkerWithContent($shortcode, $tag);
723
            }
724
725
            $content = $htmlvalue->getContent();
726
727
            // Clean up any marker classes left over, for example, those injected into <script> tags
728
            $parser = $this;
729
            $content = preg_replace_callback(
730
                // Not a general-case parser; assumes that the HTML generated in replaceElementTagsWithMarkers()
731
                // hasn't been heavily modified
732
                '/<img[^>]+class="'.preg_quote(self::$marker_class).'"[^>]+data-tagid="([^"]+)"[^>]+>/i',
733
                function ($matches) use ($tags, $parser) {
734
                    $tag = $tags[$matches[1]];
735
                    return $parser->getShortcodeReplacementText($tag);
736
                },
737
                $content
738
            );
739
        }
740
741
        $this->extend('onAfterParse', $content);
742
743
        return $content;
744
    }
745
}
746