diff --git a/CHANGELOG.md b/CHANGELOG.md index 522d3a4..b439d92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## 5.2.0 (2026-10-11) + +### Changed + +- **`shortcode_close_parent` only closes elements that cannot hold block content** (#9). A block + shortcode now closes `

`, `

`-`

`, `
` and ``, whose content model is phrasing
+  content, plus any inline elements (``, ``) between it and that element. Containers that
+  can already hold block content (`
`, `
  • `, ``, ``, `
    `, `
    `, `
    `, + `
    `, `
    `, `
    `, `
    ` and the like) are left whole, so a shortcode in a list item or table cell no longer + breaks the list or table. Inline elements inside such a container are still closed and reopened + around the shortcode. + **Behaviour change:** 5.1.0 closed a container when it was on the same line as the shortcode, so + `
    [sc]
    ` came out as just the shortcode output, without the `
    `. It now stays + `
    [sc output]
    `; the same goes for `
  • [sc]
  • ` and `[sc]`. Content that + relied on the wrapping `
    ` being removed needs the shortcode moved out of it. + +### Fixed + +- **`shortcode_close_parent` closes the shortcode's own wrapping element** (#8). It closed the first + element it could find earlier on the same line instead, so `

    Address

    [sc]

    ` came out + as `

  • [sc]

    `, which browsers repair into a stray empty `

    ` with the `

    ` left open. + Also wrong before: an outer `

    ` was closed instead of the `

    ` inside it, a `


    ` or a tag + inside an HTML comment could be taken as the parent, a second block shortcode in the same + paragraph was left inside it, a shortcode outside any element got a stray empty `

    `, and + `[sc_block_other]` was handled as `[sc_block]`. The content is now walked tag by tag. When the + shortcode sits inside inline elements (``, ``), those are closed too, up to the block + element holding them, and reopened after it with their attributes. + **Multi-line content:** 5.1.0 only looked within one line, so a paragraph or heading on a line of + its own (`

    \n[sc]\n

    `) was left alone; it is now closed as well. Containers are not, on any + number of lines (see Changed above). + The walk also covers what the 5.1.0 regex got right or did not need: comma-separated shortcode + attributes (`[sc,id=1]`), custom elements with a hyphen in their name (``, read whole and + treated as inline), reopened inline elements without their `id` (or a link's `name`), so an + anchor is not repeated, and a `>` inside a quoted attribute value. + **Limit:** a block shortcode inside more than 10 nested inline elements, or inside inline + elements whose opening tags add up to more than 8 KB, is left where it is, untouched. Closing + and reopening every level for every shortcode made the output grow with nesting depth (or tag + length) times the number of shortcodes. With the limits, the markup reopened per shortcode is + bounded (10 elements, 8 KB), so the time and the output size grow linearly with the content. + ## 5.1.0 (2026-09-25) **One line for Silverstripe 5 and 6.** `main` now declares `silverstripe/framework ^5 || ^6` on PHP diff --git a/README.md b/README.md index 784dced..e9498dd 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,18 @@ In this module, `$shortcodable_is_block` and `$disable_wrapper` have been replac If you set `$shortcode_close_parent` to true, the parent node will be closed before your shortcode output and reopened after.
    Eg `

    Some [shortcode] content

    ` would become `

    Some

    [shortcode]

    content

    `; +If the shortcode sits inside inline elements (eg `

    Some [shortcode]

    `), those are closed +as well, up to the block element holding them, and reopened after the shortcode. A bare element (no attributes) +that would be left empty on either side is dropped instead. + +Only elements that cannot hold block content are closed: `

    `, `

    `-`

    `, `
    ` and `` (plus
    +inline elements). Containers that can already hold block content, such as `
    `, `
  • `, ``, `
    `, `
    `, +`
    `, `
    ` and `
    `, are left whole: `
  • [shortcode]
  • ` stays a list item with the shortcode +output inside it. Inline elements inside such a container are still closed and reopened around the shortcode. +A shortcode nested inside more than 10 inline elements, or inside inline elements whose opening tags add up to more +than 8 KB, is left untouched (a guard against pathological markup). +Up to 5.1.0 a container on the same line was closed too, so `
    [shortcode]
    ` lost its `
    `; since 5.2.0 +it is kept. ## Shortcode placeholder images diff --git a/src/Extensions/ShortcodeParserExtension.php b/src/Extensions/ShortcodeParserExtension.php index 81e77b0..3476600 100755 --- a/src/Extensions/ShortcodeParserExtension.php +++ b/src/Extensions/ShortcodeParserExtension.php @@ -28,34 +28,393 @@ public function onBeforeParse(&$content) return; } - // Assemble regex string: https://regex101.com/r/FriwNk/1 - // ~> appended .{0,10} to also select a part of the string directly following our schortcode tag - $regex = '<([a-z0-9]+)[^>]*?>.*?(\[(' . implode('|', $sc_block_wrapped_map) . ')[^]]*?\]([^[]*?\[/\3\])*).{0,10}'; - $content = preg_replace_callback( - '%'.$regex.'%i', - function ($matches) use ($sc_block_wrapped_map) { - $full_matched_string = $matches[0]; - $parent_el = $matches[1]; // [1]=>"p" <-- current wrapping element (to close & reopen) - $full_sc = $matches[2]; // [2]=>"[currentyear]" <-- full shortcode string (incl [sc attr="xy"]wrapped [/sc] verstions - $sc_tag = $matches[3]; // [3]=> "currentyear" <-- shortcode 'tag' - - // eg

    [currentyear]

    - $replacement_html = "{$full_sc}<{$parent_el}>"; - // replace (eg [currentyear] ->

    [currentyear]

    ) - $return_str = str_replace($full_sc, $replacement_html, $full_matched_string); - - $replace_items = [ - //

    [currentyear]

    ->

    [currentyear]

    (removes
    before) - "
    $replacement_html" => $replacement_html, - //

    [currentyear] -> [currentyear] - "<{$parent_el}>$full_sc" => $full_sc, - // [currentyear]

    -> [currentyear] - "$full_sc<{$parent_el}>" => $full_sc, - ]; - - return str_replace(array_keys($replace_items), array_values($replace_items), $return_str); + # Issue #8: the regex below took the FIRST opening tag that its lazy `.*?` could reach on the + # line as the parent, not the shortcode's own wrapping element - so an earlier sibling (an

    + # before the

    ), an outer

    , a void
    or a tag inside a comment got closed instead. + # It is kept (disabled) for reference; closeParentElements() walks the tags in order and + # closes the element that actually wraps the shortcode. + // // Assemble regex string: https://regex101.com/r/FriwNk/1 + // // ~> appended .{0,10} to also select a part of the string directly following our schortcode tag + # (the next disabled line is split at '?' . '>' only because a literal question mark + '>' ends PHP + # mode even inside a // comment; the string is otherwise unchanged) + // $regex = '<([a-z0-9]+)[^>]*?' . '>.*?(\[(' . implode('|', $sc_block_wrapped_map) . ')[^]]*?\]([^[]*?\[/\3\])*).{0,10}'; + // $content = preg_replace_callback( + // '%'.$regex.'%i', + // function ($matches) use ($sc_block_wrapped_map) { + // $full_matched_string = $matches[0]; + // $parent_el = $matches[1]; // [1]=>"p" <-- current wrapping element (to close & reopen) + // $full_sc = $matches[2]; // [2]=>"[currentyear]" <-- full shortcode string (incl [sc attr="xy"]wrapped [/sc] verstions + // $sc_tag = $matches[3]; // [3]=> "currentyear" <-- shortcode 'tag' + // + // // eg

    [currentyear]

    + // $replacement_html = "{$full_sc}<{$parent_el}>"; + // // replace (eg [currentyear] ->

    [currentyear]

    ) + // $return_str = str_replace($full_sc, $replacement_html, $full_matched_string); + // + // $replace_items = [ + // //

    [currentyear]

    ->

    [currentyear]

    (removes
    before) + // "
    $replacement_html" => $replacement_html, + // //

    [currentyear] -> [currentyear] + // "<{$parent_el}>$full_sc" => $full_sc, + // // [currentyear]

    -> [currentyear] + // "$full_sc<{$parent_el}>" => $full_sc, + // ]; + // + // return str_replace(array_keys($replace_items), array_values($replace_items), $return_str); + // }, + // (string) $content + // ); + $content = $this->closeParentElements((string) $content, $sc_block_wrapped_map); + } + + /** + * Elements that never hold content, so they are never a shortcode's parent. + */ + private const VOID_ELEMENTS = [ + 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', + 'track', 'wbr', + ]; + + /** + * Inline (phrasing) elements: block-level output inside one of these is still inside the block + * element around it, so the closing continues outwards to that block element. + */ + private const INLINE_ELEMENTS = [ + 'a', 'abbr', 'b', 'bdi', 'bdo', 'cite', 'code', 'data', 'del', 'dfn', 'em', 'font', 'i', 'ins', + 'kbd', 'label', 'mark', 'q', 's', 'samp', 'small', 'span', 'strike', 'strong', 'sub', 'sup', + 'time', 'tt', 'u', 'var', + ]; + + /** + * Block elements a block shortcode closes: those whose content model is phrasing content only, + * so block output cannot stay inside them (issue #9, option b). Every other block element - div, + * li, td, th, dd, blockquote, section, article, figure and the like - can already hold block + * content, so a shortcode directly inside one is left in place and the element is not split. + * address and dt are not listed: in the WHATWG HTML standard both take flow content (with some + * descendants excluded), so they can hold a
    ; closing a
    would also leave the block + * output directly inside the
    . summary is phrasing content (optionally with headings). + */ + private const CLOSABLE_ELEMENTS = [ + 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'summary', + ]; + + /** + * The most inline ancestors (, , , ...) closed and reopened around one block + * shortcode. Past this depth the shortcode is left where it is, untouched, as for a container: + * real content never nests this deep, and closing and reopening every level for every shortcode + * made the output grow with depth x shortcodes (1600 nested spans and 1600 shortcodes gave + * 59 MB from 43 KB). + */ + private const MAX_INLINE_DEPTH = 10; + + /** + * The most bytes of inline opening tags reopened after one block shortcode. Each reopened inline + * element repeats its whole opening tag, so a long tag (a 20 KB title) around many shortcodes + * made the output grow with tag length x shortcodes (1000 shortcodes: 31 KB in, 20 MB out). + * Past this length the shortcode is left in place, untouched, as for MAX_INLINE_DEPTH. 8 KB, not + * less: real links can be long (an Outlook safelinks href is about 1.1 KB) and must still be + * closed and reopened. + */ + private const MAX_REOPEN_LENGTH = 8192; + + /** + * Close the element(s) wrapping each block shortcode before it and reopen them after it, eg + * `

    Some [sc] content

    ` becomes `

    Some

    [sc]

    content

    `. + * + * The content is walked tag by tag with a stack of open elements, so the element closed is the + * shortcode's own parent. If that parent is inline (,
    ), the inline elements are closed + * up to and including the block element holding them. Only block elements that cannot hold block + * content are closed (CLOSABLE_ELEMENTS:

    , headings,

    , ...); a container such as 
    , + *
  • or is left whole, and only inline elements inside it are closed. An element without attributes that would be + * left empty on either side is dropped rather than closed/reopened, as is a
    directly before + * the shortcode. + * + * @param string $content + * @param string[] $tags shortcode tags configured with shortcode_close_parent + * @return string + */ + private function closeParentElements(string $content, array $tags): string + { + $tagAlternation = implode('|', array_map(function ($tag) { + return preg_quote($tag, '%'); + }, $tags)); + # Most content has no block shortcode at all. One cheap search for any of the tags skips the + # tokenising below, which on a 3 MB page without one took ~45 MB of memory (5.1.0's single + # regex needed ~5 MB). The lookahead is the same tag ending as in the full pattern. + if (!preg_match('%\[(?:' . $tagAlternation . ')(?=[\s\],/])%i', $content)) { + return $content; + } + # One pass over comments, HTML tags and block shortcodes, in document order. A shortcode inside + # a tag's attribute is part of the tag match, so it is (rightly) never treated as an element. + # The tag name must end at whitespace, ']', '/' or ',' (ShortcodeParser's own tag regex accepts + # `[tag,attr=x]`), so [sc_block_other] is not [sc_block]. + # Enclosed content (`[sc]...[/sc]`) is matched as in the original regex: no '[' inside it. + # Element names may contain hyphens (custom elements, eg ), so the name runs on to + # whitespace, '/' or '>' rather than to a word boundary, which would cut it at the hyphen. + # Two scans are bounded so the pass stays linear: an unterminated comment runs to the end of + # the content (as it does for an HTML parser), instead of every '|$))' + . '|(?[a-z][a-z0-9-]*)(?=[\s/>])(?:[^<>"\']++|"[^"<]*+"|\'[^\'<]*+\')*+>)' + . '|(?\[(?' . $tagAlternation . ')(?=[\s\],/])[^\]\[]*\](?:[^\[]*?\[/\k\])*)%is'; + # Tokenise: text between the matches, plus each match with its kind. One match at a time + # (preg_match from an offset, not preg_match_all) and kept as three flat lists rather than an + # array per token: on large content the full match set and per-token arrays cost several + # hundred MB. + $kinds = $strings = $names = []; + $cursor = 0; + $length = strlen($content); + while ($cursor < $length && preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE, $cursor)) { + [$string, $offset] = $match[0]; + if ($offset > $cursor) { + $kinds[] = 'text'; + $strings[] = substr($content, $cursor, $offset - $cursor); + $names[] = null; + } + if (isset($match['sc']) && $match['sc'][1] !== -1) { + $kinds[] = 'sc'; + $names[] = null; + } elseif (isset($match['tag']) && $match['tag'][1] !== -1) { + $kinds[] = 'tag'; + $names[] = strtolower($match['name'][0]); + } else { + $kinds[] = 'text'; + $names[] = null; + } + $strings[] = $string; + # An empty match cannot happen (every alternative starts with a literal), but never loop on one + $cursor = $offset + max(1, strlen($string)); + } + if (!in_array('sc', $kinds, true)) { + # No block shortcode outside a tag or comment: nothing to do + return $content; + } + if ($cursor < $length) { + $kinds[] = 'text'; + $strings[] = substr($content, $cursor); + $names[] = null; + } + + # The output is collected as a list of parts, one per token or inserted tag, and joined once at + # the end. The clean-ups below only look at, or drop, the last few parts: working on the whole + # output string instead (a regex or rtrim() over it, per shortcode) made the walk quadratic. + $out = []; + # Open elements, outermost first: [name, original opening tag] + $stack = []; + # How many elements of each name are open, so a stray closing tag (none of that name open) + # is ignored without scanning the whole stack: that scan made many stray closers inside + # deep nesting quadratic + $openCounts = []; + $count = count($kinds); + for ($i = 0; $i < $count; $i++) { + $kind = $kinds[$i]; + $string = $strings[$i]; + $name = $names[$i]; + + if ($kind === 'tag') { + $out[] = $string; + $this->trackTag($stack, $openCounts, $string, $name); + continue; + } + if ($kind !== 'sc' || !$stack) { + # Text, comments, and a block shortcode with no wrapping element: nothing to close + $out[] = $string; + continue; + } + + # The elements to close, innermost first, as [name, opening tag, stack index]: inline + # ancestors up to the first block one, and + # that block one only if it cannot hold block content (CLOSABLE_ELEMENTS). A container + # (
    ,
  • , ,
    , ...) stops the walk without being closed: splitting it + # would break the list or table around it (issue #9). + # More than MAX_INLINE_DEPTH inline ancestors, or more than MAX_REOPEN_LENGTH bytes of inline + # opening tags to reopen: leave the shortcode in place (see there). + $chain = []; + $reopenLength = 0; + for ($s = count($stack) - 1; $s >= 0; $s--) { + if ($this->isInline($stack[$s][0])) { + if (count($chain) >= self::MAX_INLINE_DEPTH) { + $chain = []; + break; + } + # The reopened copy is about as long as the opening tag (minus an id/name) + $reopenLength += strlen($stack[$s][1]); + if ($reopenLength > self::MAX_REOPEN_LENGTH) { + $chain = []; + break; + } + $chain[] = [$stack[$s][0], $stack[$s][1], $s]; + continue; + } + if (in_array($stack[$s][0], self::CLOSABLE_ELEMENTS)) { + $chain[] = [$stack[$s][0], $stack[$s][1], $s]; + } + break; + } + if (!$chain) { + # Directly inside a container: nothing to close, so nothing to clean up either (a
    + # before the shortcode stays, as it does at the top level) + $out[] = $string; + continue; + } + + #

    [sc] ->

    [sc] (a line break right before block output only adds a blank line) + $last = $this->lastNonBlankPart($out); + if ($last !== null && preg_match('%^$%i', $out[$last])) { + $this->dropPartsFrom($out, $last); + } + foreach ($chain as [$elName, $openTag]) { + #

    [sc] -> [sc]: the element would be left empty, so drop its opening tag instead. + # Only a bare opening tag, as before: one with attributes (an id used as an anchor, a + # class) is closed and kept, even if empty. The whitespace after it goes too. + $last = $this->lastNonBlankPart($out); + if ($last !== null && preg_match('%^<[a-z0-9-]+\s*>$%i', $openTag) + && (strcasecmp($out[$last], $openTag) === 0 || $out[$last] === "<{$elName}>") + ) { + $this->dropPartsFrom($out, $last); + } else { + $out[] = ""; + } + } + $out[] = $string; + + # [sc]

    -> [sc]: closing tags directly after the shortcode (whitespace aside) are + # consumed instead of reopening an element that would be left empty + while ($chain) { + $next = $i + 1; + while ($next < $count && $kinds[$next] === 'text' && trim($strings[$next]) === '') { + $next++; + } + if ($next < $count && $kinds[$next] === 'tag' && $names[$next] === $chain[0][0] + && strpos($strings[$next], 'trackTag($stack, $openCounts, $strings[$next], $names[$next]); + array_shift($chain); + $i = $next; + continue; + } + break; + } + + # Reopen what is still open, outermost first. The block element is reopened bare, as + # before (an id must not be duplicated); inline ones keep their attributes, so a link or + # a styled span carries on after the shortcode - minus their id (and a link's name + # anchor), which must stay unique. + # The stack entry takes the reopened tag, as that is what now stands open in the output: + # a second shortcode right after then sees a bare reopened

    and drops it instead of + # leaving an empty

    between the two (when the original

    had attributes). + foreach (array_reverse($chain) as [$elName, $openTag, $stackIndex]) { + $reopened = $this->isInline($elName) ? $this->withoutUniqueAttributes($openTag, $elName) : "<{$elName}>"; + $stack[$stackIndex][1] = $reopened; + $out[] = $reopened; + } + } + + return implode('', $out); + } + + /** + * Drop the output parts from this index to the end. array_pop() rather than array_splice(), + * which rebuilds the whole array and so would make every clean-up cost the full output size. + * + * @param string[] $out + * @param int $from + */ + private function dropPartsFrom(array &$out, int $from): void + { + while (count($out) > $from) { + array_pop($out); + } + } + + /** + * Index of the last output part that is not whitespace-only text, or null if there is none. + * Parts are whole tokens, so a tag is always a part of its own. + * + * @param string[] $out + * @return int|null + */ + private function lastNonBlankPart(array $out): ?int + { + for ($p = count($out) - 1; $p >= 0; $p--) { + if (trim($out[$p]) !== '') { + return $p; + } + } + return null; + } + + /** + * An opening tag without its id attribute, and without name on a link (an anchor target), so + * a reopened copy of the element does not repeat them. + */ + private function withoutUniqueAttributes(string $openTag, string $name): string + { + $attributes = $name === 'a' ? 'id|name' : 'id'; + # Walk the tag with the same quote-aware alternation as the tag match: a quoted value is + # consumed whole by the first alternative, so `id=` written inside another attribute's value + # (title=" id=1") is never seen as an attribute. Only a real id/name attribute matches the + # second alternative (whitespace before the name, so data-id or aria-name are left alone; + # quoted, bare or no value) and is dropped; everything else is put back unchanged. + // return preg_replace('%\s+(?:' . $attributes . ')\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>"\']+)%i', '', $openTag); + return preg_replace_callback( + '%"[^"]*+"|\'[^\']*+\'|(?\s+(?:' . $attributes . ')(?=[\s=/>])(?:\s*=\s*(?:"[^"]*+"|\'[^\']*+\'|[^\s>"\']++))?)%i', + function ($match) { + return isset($match['drop']) && $match['drop'] !== '' ? '' : $match[0]; }, - (string) $content + $openTag ); } + + /** + * Whether an element is inline (phrasing) content. Custom elements (a hyphen in the name) are + * phrasing content in HTML, so they count as inline too. + */ + private function isInline(string $name): bool + { + return in_array($name, self::INLINE_ELEMENTS) || strpos($name, '-') !== false; + } + + /** + * Update the open-element stack for one tag. + * + * @param array $stack open elements, outermost first: [name, original opening tag] + * @param int[] $openCounts number of open elements per name, kept in step with $stack + * @param string $tag the tag as written + * @param string $name its lowercased element name + */ + private function trackTag(array &$stack, array &$openCounts, string $tag, string $name): void + { + if (strpos($tag, ' '), -1) === '/') { + # Void or self-closed: never open + return; + } + $stack[] = [$name, $tag]; + $openCounts[$name] = ($openCounts[$name] ?? 0) + 1; + } } diff --git a/tests/ShortcodeParserExtensionTest.php b/tests/ShortcodeParserExtensionTest.php index e2b32e2..26fdd3c 100644 --- a/tests/ShortcodeParserExtensionTest.php +++ b/tests/ShortcodeParserExtensionTest.php @@ -7,6 +7,7 @@ use Shortcodable\Tests\Stub\SimpleShortcode; use SilverStripe\Core\Config\Config; use SilverStripe\Dev\SapphireTest; +use SilverStripe\View\Parsers\HTMLValue; use SilverStripe\View\Parsers\ShortcodeParser; /** @@ -61,6 +62,497 @@ public function testInlineShortcodeStaysInsideItsParentElement() ); } + /** + * Issue #8: the element closed and reopened must be the shortcode's own wrapping element, whatever + * precedes or surrounds it. Each case is input => expected output ('B' stands for the block stub's + * rendered output). All cases run and every mismatch is reported together, so a failure shows the + * whole table rather than the first broken row (no @dataProvider: see the compatibility note). + * + * The comparison is structural: ShortcodeParser::parse() itself loads the content into a DOM + * (HTMLValue, an HTML5 parser that repairs misnested markup the way a browser does) and returns + * that DOM serialised, so the expected markup is put through the same parser. A wrong element + * closed by the extension shows up as a different tree (a stray

    , a block left inside + * a

    ), not as a formatting difference. + */ + public function testCloseParentClosesTheShortcodesOwnWrappingElement() + { + $b = '

    B
    '; + $cases = [ + // The case from the issue: an earlier sibling block must not be taken as the parent + 'heading and paragraph before' => [ + '

    Address

    Street 1

    [sc_block]

    ', + "

    Address

    Street 1

    $b", + ], + 'heading before, text around' => [ + '

    Title

    Some [sc_block] content

    ', + "

    Title

    Some

    $b

    content

    ", + ], + 'sibling after' => [ + '

    Some [sc_block] content

    Next

    ', + "

    Some

    $b

    content

    Next

    ", + ], + 'siblings on separate lines' => [ + "

    A

    \n

    [sc_block]

    \n

    after

    ", + "

    A

    \n$b\n

    after

    ", + ], + // Only the direct block parent is closed, not an outer container + 'nested in a div' => [ + '

    Some [sc_block] content

    ', + "

    Some

    $b

    content

    ", + ], + 'heading as parent' => [ + '

    Title [sc_block]

    ', + "

    Title

    $b", + ], + // Inline parents are closed up to and including the block element that holds them, and + // reopened with their own attributes so a link or class carries on after the shortcode + 'inside strong inside p' => [ + '

    Some bold [sc_block] text end

    ', + "

    Some bold

    $b

    text end

    ", + ], + 'inside a link inside p' => [ + '

    see [sc_block] here

    ', + "

    see

    $b

    here

    ", + ], + 'only child of strong inside p' => [ + '

    [sc_block]

    ', + $b, + ], + 'first child of a paragraph with attributes' => [ + '

    [sc_block] after

    ', + "

    $b

    after

    ", + ], + // Several block shortcodes in one parent: each one closes the (reopened) parent + 'two in one paragraph' => [ + '

    A [sc_block] B [sc_block] C

    ', + "

    A

    $b

    B

    $b

    C

    ", + ], + 'two adjacent' => [ + '

    [sc_block][sc_block]

    ', + "$b$b", + ], + // The reopened parent is bare, so it is dropped between two shortcodes even when the + // original had attributes; an inline one keeps its attributes, so it stays + 'two adjacent in a paragraph with attributes' => [ + '

    A [sc_block][sc_block] B

    ', + "

    A

    $b$b

    B

    ", + ], + 'two with a space in a paragraph with attributes' => [ + '

    A [sc_block] [sc_block] B

    ', + "

    A

    $b$b

    B

    ", + ], + 'inline shortcode before a block one' => [ + '

    [sc_simple] and [sc_block]

    ', + '

    SIMPLE[] and

    ' . $b, + ], + // First/last child: no empty parent element may be left on that side + 'first child' => [ + '

    [sc_block] content

    ', + "$b

    content

    ", + ], + 'last child' => [ + '

    Some [sc_block]

    ', + "

    Some

    $b", + ], + 'only child with whitespace around' => [ + "

    \n[sc_block]\n

    ", + $b, + ], + // Void elements are never taken as the parent; a trailing
    is dropped as before + 'br directly before' => [ + '

    Line
    [sc_block]

    ', + "

    Line

    $b", + ], + 'self-closed br earlier in the paragraph' => [ + '

    Line
    text [sc_block] end

    ', + "

    Line
    text

    $b

    end

    ", + ], + 'img before in the same paragraph' => [ + '

    [sc_block] end

    ', + "

    \"\"

    $b

    end

    ", + ], + 'hr sibling before' => [ + '

    [sc_block]

    ', + "
    $b", + ], + 'comment containing a tag before' => [ + '

    [sc_block]

    ', + "$b", + ], + 'uppercase tags' => [ + '

    Some [sc_block] x

    ', + "

    Some

    $b

    x

    ", + ], + 'enclosing shortcode with content' => [ + '

    Before [sc_block]inner[/sc_block] after

    ', + "

    Before

    $b

    after

    ", + ], + // A shortcode with no wrapping element has nothing to close + 'top level, between paragraphs' => [ + '

    Intro

    [sc_block]

    Outro

    ', + "

    Intro

    $b

    Outro

    ", + ], + // ShortcodeParser also accepts a comma between the tag and its attributes + 'comma-separated attributes' => [ + '

    Some [sc_block,id=1] x

    ', + "

    Some

    $b

    x

    ", + ], + // A custom element's name includes hyphens; custom elements are phrasing content, so + // they are closed like the inline elements, up to the block element holding them + 'custom element inside p' => [ + '

    x [sc_block] y

    ', + "

    x

    $b

    y

    ", + ], + // A reopened inline element must not repeat an id (or a link's name anchor) + 'link with id and name inside p' => [ + '

    see [sc_block] here

    ', + "

    see

    $b

    here

    ", + ], + 'span with id inside p' => [ + "

    a [sc_block] b

    ", + "

    a

    $b

    b

    ", + ], + // Only real id/name attributes are dropped, not `id=` text inside another attribute's value + 'id= inside a quoted title' => [ + '

    x [sc_block] y

    ', + "

    x

    $b

    y

    ", + ], + 'id= at the end of a quoted title before href' => [ + '

    x [sc_block] y

    ', + "

    x

    $b

    y

    ", + ], + // Tags inside a comment are not elements + 'comment containing tags inside the parent' => [ + '

    a [sc_block] b

    ', + "

    a

    $b

    b

    ", + ], + // Only closing tags are consumed after the shortcode, never an opening tag + 'opening tag directly after' => [ + '

    [sc_block]x

    ', + "$b

    x

    ", + ], + // Element names are compared case-insensitively + 'uppercase inline and block tags' => [ + '

    x b [sc_block] c y

    ', + "

    x b

    $b

    c y

    ", + ], + // A self-closed non-void element (XHTML style) does not open, so it is not the parent + 'self-closed span before' => [ + '

    a [sc_block] b

    ', + "

    a

    $b

    b

    ", + ], + // Without a wrapping element nothing is cleaned up either: the
    stays + 'top level after a br' => [ + 'Line
    [sc_block]', + "Line
    $b", + ], + // A stray closing tag (nothing open by that name) leaves the open elements alone + 'stray closing tag inside the parent' => [ + '

    a [sc_block] b

    ', + "

    a

    $b

    b

    ", + ], + 'opening tag of the same element directly after' => [ + '

    a [sc_block]x b

    ', + "

    a

    $b

    x b

    ", + ], + // A '>' inside a quoted attribute value does not end the tag, double or single quoted + 'link with > in a double-quoted title' => [ + '

    x [sc_block] y

    Next paragraph

    ', + "

    b\" href=\"/x\">x

    $b

    b\" href=\"/x\"> y

    Next paragraph

    ", + ], + 'link with > in a single-quoted title' => [ + "

    x [sc_block] y

    Next

    ", + "

    b\" href=\"/x\">x

    $b

    b\" href=\"/x\"> y

    Next

    ", + ], + // A tag that merely starts with the block tag's name is another shortcode + 'other shortcode sharing the prefix' => [ + '

    Some [sc_block_other] x

    ', + '

    Some [sc_block_other] x

    ', + ], + ]; + + $failures = []; + foreach ($cases as $label => [$input, $expected]) { + $actual = ShortcodeParser::get()->parse($input); + $expected = HTMLValue::create($expected)->getContent(); + if ($actual !== $expected) { + $failures[] = sprintf("%s\n input: %s\n expected: %s\n actual: %s", $label, json_encode($input), json_encode($expected), json_encode($actual)); + } + } + $this->assertSame([], $failures, count($failures) . ' of ' . count($cases) . " cases failed:\n" . implode("\n", $failures)); + } + + /** + * Issue #9, option (b): a block shortcode closes only block elements that cannot hold block + * content (p, h1-h6, pre, summary) plus inline ancestors. A container that can + * already hold block content (div, li, td, blockquote, section, ...) is left whole, on one line + * or several: splitting it would delete or break the list item, table cell or quote around it. + * Input => expected output, compared structurally as in the #8 table above. + */ + public function testContainerParentsAreLeftWhole() + { + $b = '
    B
    '; + $cases = [ + // Containers, multi-line: 5.1.0 left these alone (it only looked within one line) + 'table cell, multi-line' => [ + "\n\n
    \n[sc_block]\n
    ", + "\n\n
    \n$b\n
    ", + ], + 'list item, multi-line' => [ + "
      \n
    • \n[sc_block]\n
    • \n
    ", + "
      \n
    • \n$b\n
    • \n
    ", + ], + 'blockquote, multi-line' => [ + "
    \n[sc_block]\n
    ", + "
    \n$b\n
    ", + ], + 'section, multi-line' => [ + "
    \n[sc_block]\n
    ", + "
    \n$b\n
    ", + ], + // Containers, one line: 5.1.0 closed these (and dropped the emptied element); now left whole + 'table cell, one line' => [ + '
    [sc_block]
    ', + "
    $b
    ", + ], + 'list item, one line' => [ + '
    • One
    • [sc_block]
    ', + "
    • One
    • $b
    ", + ], + 'div, one line' => [ + '
    [sc_block]
    ', + "
    $b
    ", + ], + 'list item with text around' => [ + '
    • One [sc_block] two
    ', + "
    • One $b two
    ", + ], + 'definition description' => [ + '
    T
    x [sc_block] y
    ', + "
    T
    x $b y
    ", + ], + // address and dt take flow content (WHATWG), so they are containers too + 'address' => [ + '
    Street [sc_block] 1
    ', + "
    Street $b 1
    ", + ], + 'definition term' => [ + '
    Term [sc_block]
    x
    ', + "
    Term $b
    x
    ", + ], + // A
    before the shortcode stays when nothing is closed, as at the top level + 'br before, inside a div' => [ + '
    Line
    [sc_block]
    ', + "
    Line
    $b
    ", + ], + // Inline ancestors inside a container are still closed and reopened, the container is not + 'strong inside a list item' => [ + '
    • a [sc_block] b
    ', + "
    • a $b b
    ", + ], + 'only child of a bare em inside a table cell' => [ + '
    [sc_block]
    ', + "
    $b
    ", + ], + // An inline element with attributes is closed and kept even if left empty, as in a

    + 'only child of a link inside a table cell' => [ + '
    [sc_block]
    ', + "
    $b
    ", + ], + // A closable element inside a container: only that element is closed + 'paragraph inside a list item' => [ + '

    • x [sc_block] y

    ', + "
    • x

      $b

      y

    ", + ], + // The closable elements besides p and h1-h6 (those are in the #8 table): pre and summary + 'pre, multi-line' => [ + "
    \nline\n[sc_block]\n
    ", + "
    \nline\n
    $b", + ], + 'h4' => [ + '

    Title [sc_block]

    ', + "

    Title

    $b", + ], + 'summary' => [ + '
    S [sc_block]body
    ', + "
    S {$b}body
    ", + ], + ]; + + $failures = []; + foreach ($cases as $label => [$input, $expected]) { + $actual = ShortcodeParser::get()->parse($input); + $expected = HTMLValue::create($expected)->getContent(); + if ($actual !== $expected) { + $failures[] = sprintf("%s\n input: %s\n expected: %s\n actual: %s", $label, json_encode($input), json_encode($expected), json_encode($actual)); + } + } + $this->assertSame([], $failures, count($failures) . ' of ' . count($cases) . " cases failed:\n" . implode("\n", $failures)); + } + + /** + * The tag walk must stay linear in the content size. Each input below took tens of seconds to + * minutes when every shortcode re-scanned or copied the output built so far (or the rest of the + * content), against milliseconds for the 5.1.0 regex. Only the extension's onBeforeParse hook is + * run, not the whole parse, so the DOM pass in ShortcodeParser does not dominate the timing. The + * limit is generous on purpose: it separates linear from quadratic, it does not benchmark. Each + * row also checks the exact output. + */ + public function testCloseParentStaysLinearOnLargeContent() + { + # Each row: [content, expected output]. The output is checked exactly, so a row does not + # rest on wall-clock time alone: a cap mutant (MAX_INLINE_DEPTH) changes the output of the + # nested-spans row. A purely quadratic regression gives the same output, so for those the + # time limit is the guard; it is generous (5 s against tens of milliseconds) so a slow CI + # runner does not flake it. + $n = 30000; + $cases = [ + '60000 block shortcodes' => [ + str_repeat('

    a [sc_block] b

    ', 60000), + str_repeat('

    a

    [sc_block]

    b

    ', 60000), + ], + // The
    before each shortcode is dropped and the emptied

    after it consumed + '60000 after a line break' => [ + str_repeat("

    a
    \n [sc_block]\n

    ", 60000), + str_repeat('

    a

    [sc_block]', 60000), + ], + // An unterminated comment runs to the end, so the shortcode is inside it: unchanged + '20000 unterminated comments' => [ + str_repeat('