ShortcodeParser::clear()   A
last analyzed

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
17
 * similar to the [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 callable $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) {
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) {
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) {
0 ignored issues
show
introduced by
$next is of type DOMElement, thus it always evaluated to true.
Loading history...
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) {
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
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);
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
                    htmlspecialchars($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
        } elseif ($location == self::SPLIT) {
579
            // Split parent at node
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
        } elseif ($location == self::INLINE) {
599
            // Do nothing
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);
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
        $this->extend('onBeforeParse', $content);
645
646
        $continue = true;
647
648
        // If no shortcodes defined, don't try and parse any
649
        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...
650
            $continue = false;
651
        } elseif (!trim($content)) {
652
            // If no content, don't try and parse it
653
            $continue = false;
654
        } elseif (strpos($content, '[') === false) {
655
            // If no shortcode tag, don't try and parse it
656
            $continue = false;
657
        }
658
659
        if ($continue) {
660
            // First we operate in text mode, replacing any shortcodes with marker elements so that later we can
661
            // use a proper DOM
662
            list($content, $tags) = $this->replaceElementTagsWithMarkers($content);
663
664
        /** @var HTMLValue $htmlvalue */
665
            $htmlvalue = Injector::inst()->create('HTMLValue', $content);
666
667
            // Now parse the result into a DOM
668
            if (!$htmlvalue->isValid()) {
669
                if (self::$error_behavior == self::ERROR) {
670
                    user_error('Couldn\'t decode HTML when processing short codes', E_USER_ERROR);
671
                } else {
672
                    $continue = false;
673
                }
674
            }
675
        }
676
677
        if ($continue) {
678
            // First, replace any shortcodes that are in attributes
679
            $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...
680
681
            // Find all the element scoped shortcode markers
682
            $shortcodes = $htmlvalue->query('//img[@class="' . self::$marker_class . '"]');
683
684
            // Find the parents. Do this before DOM modification, since SPLIT might cause parents to move otherwise
685
            $parents = $this->findParentsForMarkers($shortcodes);
686
687
        /** @var DOMElement $shortcode */
688
            foreach ($shortcodes as $shortcode) {
689
                $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...
690
                $parent = $parents[$shortcode->getAttribute('data-parentid')];
691
692
                $class = null;
693
                if (!empty($tag['attrs']['location'])) {
694
                    $class = $tag['attrs']['location'];
695
                } elseif (!empty($tag['attrs']['class'])) {
696
                    $class = $tag['attrs']['class'];
697
                }
698
699
                $location = self::INLINE;
700
                if ($class == 'left' || $class == 'right') {
701
                    $location = self::BEFORE;
702
                }
703
                /**
704
                 * Below code disabled due to https://github.com/silverstripe/silverstripe-framework/issues/5987
705
                if ($class == 'center' || $class == 'leftAlone') {
706
                    $location = self::SPLIT;
707
                }
708
                 */
709
710
                if (!$parent) {
711
                    if ($location !== self::INLINE) {
712
                        user_error(
713
                            "Parent block for shortcode couldn't be found, but location wasn't INLINE",
714
                            E_USER_ERROR
715
                        );
716
                    }
717
                } else {
718
                    $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

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